Skip to content

Commit 3a4bca1

Browse files
sktbrdclaude
andauthored
feat(treasury): accented KPI header with synced badge, subnet earnings (#275)
* docs(spec): treasury KPI header redesign Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(plan): treasury KPI header implementation plan Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(treasury): snapshot metadata, auction count, subnet earnings data Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(treasury): accented KPI header with synced badge and subnet earnings Four cards per the design mock — accent-mixed borders, radial corner tints, faded asset marks (noggles/ETH/USDC) — replacing the three plain KPI cards. New Subnet Earnings card (all-time Morpheus USDC + claim count, dash when unknowable). Synced badge binds to the ISR snapshot timestamp, relative time computed client-side against the viewer's clock. TreasuryBalance/TreasuryBalanceClient deleted (superseded). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(treasury): honest subnet and auction figures for the KPI cards Runtime verification caught both: splits pay through the warehouse, so the split-address filter summed zero — subnet earnings now walk warehouse→treasury USDC transfers and keep the ones the final split emitted in, the ledger's own attribution rule. And the settled-auction count saturated at one subgraph page (Gnars is past 2000 auctions) — now cursor-paged by id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e2ec8e2 commit 3a4bca1

15 files changed

Lines changed: 962 additions & 125 deletions

docs/INDEX.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ This is the canonical entry point for project documentation. Everything below sh
4444
- `docs/superpowers/specs/2026-03-16-vote-card-delegation-breakdown-design.md` — vote card own vs delegated breakdown
4545
- `docs/superpowers/specs/2026-04-24-proposal-template-fields-design.md` — structured input fields per proposal template (replaces markdown skeleton)
4646
- `docs/superpowers/specs/2026-05-11-i18n-en-ptbr-design.md` — i18n EN + PT-BR design
47+
- `docs/superpowers/specs/2026-08-18-treasury-kpi-header-design.md` — treasury KPI header: 4 accented cards + synced badge + subnet earnings
4748

4849
## Research
4950

docs/superpowers/plans/2026-08-18-treasury-kpi-header.md

Lines changed: 499 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Treasury KPI header redesign
2+
3+
Apply the evolved top section from the Claude Design mock
4+
(`Treasury.dc.html`, project `8b9a918a-e570-4c4f-ac96-32db36a3ebf6`) to
5+
`/treasury`. Scope is the header row and the KPI cards only — everything below
6+
(Allocation, Inflows, Sponsorship, charts, Token Holdings, NFT grid) stays as
7+
shipped in #273/#274. User approved this scope and the design below.
8+
9+
## Header
10+
11+
Title + description unchanged on the left. On the right, a "Synced Xm ago"
12+
pill: green dot, muted border/background, bound to the timestamp the treasury
13+
snapshot was generated (the page ISR-revalidates every 300s, so the badge honestly
14+
reads minutes, not hours). Server-rendered relative time; no client ticking.
15+
i18n: EN "Synced {time} ago" / PT "Sincronizado há {time}" via next-intl's
16+
relative formatting, both locales in the same change.
17+
18+
## KPI row: one component, four cards
19+
20+
Replace the three inline `<Card>` blocks in `page.tsx` with a single server
21+
component `TreasuryKpiRow` under one `Suspense`. It loads
22+
`loadTreasurySnapshot` once (today's three cards already share that load via
23+
React `cache`) plus the new `loadSubnetEarnings`, and renders four cards:
24+
25+
| Card | Value | Note | Accent | Corner mark |
26+
| --- | --- | --- | --- | --- |
27+
| Total Treasury Value | usdTotal (fiat — BRL on PT, existing rule + fallback note) | "across {n} assets" (priced snapshot assets incl. ETH) | red | `public/red_noggles.png`, opacity .2 |
28+
| ETH Balance | ethBalance ETH | ETH in USD (fiat — converts on PT) | blue | ETH diamond SVG from mock |
29+
| Total Auction Sales | totalAuctionSales ETH | "{n} auctions settled" (real settled count from subgraph, not the mock's 12) | yellow | ETH diamond SVG |
30+
| Subnet Earnings | total USDC (token quantity — never converts to BRL, same rule as "6.8003 ETH") | "Morpheus · {n} claims" | green | USDC circle SVG from mock, value text green |
31+
32+
Card chrome, per the mock: border `color-mix(in oklab, <accent> 26%, <default border>)`,
33+
radial tint `color-mix(in oklab, <accent> 16%, transparent)` from the bottom-right
34+
corner, mono tabular values (CountUp animation kept), 12px muted unit and note.
35+
Accents come from theme tokens/`color-mix`, not hardcoded dark-only colors, so
36+
light mode degrades gracefully. Grid: `grid-cols-1 sm:grid-cols-2 lg:grid-cols-4`.
37+
38+
## New data: subnet earnings
39+
40+
The inflows feed is windowed (Alchemy pages of 50), so it cannot honestly sum
41+
all-time earnings. New service function `loadSubnetEarnings()` in
42+
`src/services/treasury-inflows.ts`: Alchemy `getAssetTransfers` filtered
43+
`fromAddress = SUBNET_FINAL_SPLIT`, `toAddress = treasury`, category erc20
44+
(USDC), paginated to exhaustion (the claim history is small). Returns
45+
`{ totalUsdc, claimCount } | null`; `null` (fetch failure or no ALCHEMY_KEY)
46+
renders the card with an em dash value and no claim note — never $0. React
47+
`cache` for per-request dedup; page ISR (300s) is the refresh cadence.
48+
49+
Auctions settled count: total count of settled auctions from the existing
50+
subgraph query path (aggregate count, not the mock's placeholder 12).
51+
52+
## Error handling
53+
54+
- Snapshot load failure: em-dash values in the affected cards (the page's
55+
"unavailable, not zero" convention), notes omitted.
56+
- Subnet earnings `null`: dash, no fabricated zero.
57+
- BRL rate outage: existing `FiatFallbackNote` appears in the Total card (the
58+
only KPI card showing converted fiat as its main value).
59+
60+
## Testing
61+
62+
- Unit: none new beyond existing fiat tests (presentation component + a fetch
63+
aggregator; the aggregation math is a two-line reduce).
64+
- Runtime: dev captures EN + PT, desktop + mobile, light + dark chrome checked
65+
in desktop capture; gates (tsc/eslint/prettier) clean; i18n parity for the
66+
new strings in both locales.

messages/en/treasury.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22
"page": {
33
"title": "Treasury",
44
"description": "Overview of the Gnars DAO treasury holdings and financial position",
5+
"synced": "Synced {time}",
56
"kpis": {
67
"totalValue": "Total Treasury Value",
78
"ethBalance": "ETH Balance",
8-
"totalAuctions": "Total Auction Sales"
9+
"totalAuctions": "Total Auction Sales",
10+
"subnetEarnings": "Subnet Earnings",
11+
"acrossAssets": "across {count} assets",
12+
"auctionsSettled": "{count} auctions settled",
13+
"morpheusClaims": "Morpheus · {count} claims"
914
},
1015
"nftSection": {
1116
"title": "NFT Holdings",

messages/pt-br/treasury.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22
"page": {
33
"title": "Tesouro",
44
"description": "Visão geral dos holdings do tesouro da Gnars DAO e da posição financeira",
5+
"synced": "Sincronizado {time}",
56
"kpis": {
67
"totalValue": "Valor Total do Tesouro",
78
"ethBalance": "Saldo de ETH",
8-
"totalAuctions": "Total de Vendas em Leilão"
9+
"totalAuctions": "Total de Vendas em Leilão",
10+
"subnetEarnings": "Ganhos da Subnet",
11+
"acrossAssets": "em {count} ativos",
12+
"auctionsSettled": "{count} leilões concluídos",
13+
"morpheusClaims": "Morpheus · {count} resgates"
914
},
1015
"nftSection": {
1116
"title": "Holdings de NFT",

src/app/[locale]/treasury/page.tsx

Lines changed: 25 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ import {
99
} from "@/components/skeletons/treasury-skeletons";
1010
import { NftHoldings } from "@/components/treasury/NftHoldings";
1111
import { SponsorshipYield } from "@/components/treasury/SponsorshipYield";
12+
import { SyncedBadge } from "@/components/treasury/SyncedBadge";
1213
import { TokenHoldings } from "@/components/treasury/TokenHoldings";
1314
import { TreasuryAllocation } from "@/components/treasury/TreasuryAllocation";
14-
import { TreasuryBalance } from "@/components/treasury/TreasuryBalance";
1515
import { TreasuryInflows } from "@/components/treasury/TreasuryInflows";
16-
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
16+
import { TreasuryKpiRow } from "@/components/treasury/TreasuryKpiRow";
1717
import { DAO_ADDRESSES } from "@/lib/config";
1818
import { getBrlRateForRequest } from "@/services/exchange-rate";
1919

@@ -79,47 +79,31 @@ export default async function TreasuryPage({ params }: { params: Promise<{ local
7979
return (
8080
<div className="py-8">
8181
<div className="space-y-8">
82-
{/* Page Header */}
83-
<div className="space-y-2">
84-
<h1 className="text-3xl font-bold tracking-tight">{t("page.title")}</h1>
85-
<p className="text-muted-foreground">{t("page.description")}</p>
82+
{/* Page Header — synced badge binds to the ISR snapshot's timestamp */}
83+
<div className="flex flex-wrap items-end justify-between gap-4">
84+
<div className="space-y-2">
85+
<h1 className="text-3xl font-bold tracking-tight">{t("page.title")}</h1>
86+
<p className="text-muted-foreground">{t("page.description")}</p>
87+
</div>
88+
<Suspense fallback={null}>
89+
<SyncedBadge />
90+
</Suspense>
8691
</div>
8792

88-
{/* KPIs */}
89-
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
90-
<Card className="gap-2">
91-
<CardHeader>
92-
<CardTitle className="text-sm font-medium">{t("page.kpis.totalValue")}</CardTitle>
93-
</CardHeader>
94-
<CardContent>
95-
<Suspense fallback={<MetricSkeleton />}>
96-
<TreasuryBalance treasuryAddress={DAO_ADDRESSES.treasury} />
97-
</Suspense>
98-
</CardContent>
99-
</Card>
100-
101-
<Card className="gap-2">
102-
<CardHeader>
103-
<CardTitle className="text-sm font-medium">{t("page.kpis.ethBalance")}</CardTitle>
104-
</CardHeader>
105-
<CardContent>
106-
<Suspense fallback={<MetricSkeleton />}>
107-
<TreasuryBalance treasuryAddress={DAO_ADDRESSES.treasury} metric="eth" />
108-
</Suspense>
109-
</CardContent>
110-
</Card>
111-
112-
<Card className="gap-2">
113-
<CardHeader>
114-
<CardTitle className="text-sm font-medium">{t("page.kpis.totalAuctions")}</CardTitle>
115-
</CardHeader>
116-
<CardContent>
117-
<Suspense fallback={<MetricSkeleton />}>
118-
<TreasuryBalance treasuryAddress={DAO_ADDRESSES.treasury} metric="auctions" />
119-
</Suspense>
120-
</CardContent>
121-
</Card>
122-
</div>
93+
{/* KPIs — one component, one snapshot load, four accented cards */}
94+
<Suspense
95+
fallback={
96+
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
97+
{Array.from({ length: 4 }).map((_, i) => (
98+
<div key={i} className="rounded-xl border bg-card p-6">
99+
<MetricSkeleton />
100+
</div>
101+
))}
102+
</div>
103+
}
104+
>
105+
<TreasuryKpiRow />
106+
</Suspense>
123107

124108
{/* What the treasury is MADE OF, before the income breakdown below.
125109
Every figure is already on this page — the point is the share, which
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"use client";
2+
3+
import { useLocale } from "next-intl";
4+
import { CountUp } from "@/components/ui/count-up";
5+
import { localizeFiat } from "@/lib/i18n/fiat";
6+
7+
interface KpiValueProps {
8+
/** `null` = value unavailable — renders an em dash, never 0. */
9+
value: number | null;
10+
decimals: number;
11+
/** Token unit ("ETH", "USDC") rendered after the number. Omit for fiat. */
12+
unit?: string;
13+
/** Convert to BRL on pt-br (Total Treasury Value only). */
14+
fiat?: boolean;
15+
brlRate?: number | null;
16+
className?: string;
17+
}
18+
19+
export function KpiValue({
20+
value,
21+
decimals,
22+
unit,
23+
fiat,
24+
brlRate = null,
25+
className,
26+
}: KpiValueProps) {
27+
const locale = useLocale();
28+
if (value == null) {
29+
return <span className="font-mono text-2xl font-bold text-muted-foreground"></span>;
30+
}
31+
const { value: displayValue, currency } = fiat
32+
? localizeFiat(value, locale, brlRate)
33+
: { value, currency: null };
34+
const prefix =
35+
currency === "BRL" ? "R$ " : currency === "USD" ? (locale === "pt-br" ? "US$ " : "$") : "";
36+
return (
37+
<span className="flex items-baseline gap-1.5 whitespace-nowrap">
38+
<span
39+
className={`font-mono text-2xl font-bold tabular-nums tracking-tight ${className ?? ""}`}
40+
>
41+
{prefix}
42+
<CountUp value={displayValue} decimals={decimals} className="tabular-nums" />
43+
</span>
44+
{unit && <span className="text-xs font-medium text-muted-foreground">{unit}</span>}
45+
</span>
46+
);
47+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { DAO_ADDRESSES } from "@/lib/config";
2+
import { loadTreasurySnapshot } from "@/services/treasury";
3+
import { SyncedBadgeLabel } from "./SyncedBadgeLabel";
4+
5+
/** "Synced Xm ago" pill — bound to when the ISR treasury snapshot was computed. */
6+
export async function SyncedBadge() {
7+
let generatedAt: number;
8+
try {
9+
generatedAt = (await loadTreasurySnapshot(DAO_ADDRESSES.treasury)).generatedAt;
10+
} catch {
11+
return null;
12+
}
13+
return (
14+
<div className="flex items-center gap-2 rounded-lg border bg-card px-3 py-1.5 text-xs font-medium text-muted-foreground">
15+
<span aria-hidden className="size-1.5 rounded-full bg-[var(--chart-2)]" />
16+
<SyncedBadgeLabel generatedAt={generatedAt} />
17+
</div>
18+
);
19+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { useFormatter, useTranslations } from "next-intl";
5+
6+
/**
7+
* Relative time must be computed against the viewer's clock — an ISR page's
8+
* server render can itself be minutes old, so the server HTML carries no time
9+
* and the label fills in on mount.
10+
*/
11+
export function SyncedBadgeLabel({ generatedAt }: { generatedAt: number }) {
12+
const t = useTranslations("treasury.page");
13+
const format = useFormatter();
14+
const [time, setTime] = useState<string | null>(null);
15+
useEffect(() => {
16+
setTime(format.relativeTime(generatedAt, Date.now()));
17+
}, [format, generatedAt]);
18+
if (time == null) return null;
19+
return <span>{t("synced", { time })}</span>;
20+
}

src/components/treasury/TreasuryBalance.tsx

Lines changed: 0 additions & 30 deletions
This file was deleted.

0 commit comments

Comments
 (0)