diff --git a/apps/sdp-web/messages/en/shared.json b/apps/sdp-web/messages/en/shared.json index 9521802f9..cb34a2b13 100644 --- a/apps/sdp-web/messages/en/shared.json +++ b/apps/sdp-web/messages/en/shared.json @@ -230,15 +230,22 @@ "createFirstWalletBalances": "Create your first wallet to start tracking balances.", "noTrackedBalances": "No tracked balances found yet.", "activityUnavailable": "Activity is unavailable right now.", - "paymentActivityAfterWallet": "Payment activity will appear after you create a wallet.", - "noPaymentVolume": "No payment volume recorded yet.", - "loadingPaymentActivity": "Loading payment activity...", "createFirstWalletActivity": "Create your first wallet to start tracking balances and activity.", "noRecentActivity": "No recent activity found yet.", "loadingRecentActivity": "Loading recent activity...", "createWallet": "Create Wallet", "totalBalance": "Total Balance", "todaysVolume": "Today's Volume", + "walletsTracked": "Wallets", + "tokensHeld": "Tokens held", + "balanceByToken": "Balance by token", + "firstRunTitle": "Create a wallet to get started", + "firstRunBody": "Balances, payment activity and token holdings appear here once this organization has its first wallet.", + "notPriced": "No price feed", + "singleMoreToken": "1 more token", + "moreTokensCount": "{count} more tokens", + "singleOtherToken": "1 other token", + "otherTokensCount": "{count} other tokens", "recentTransactions": "Recent transactions", "activityDescription": "Latest wallet and issuance activity across the organization.", "seeAllPayments": "See all payments", diff --git a/apps/sdp-web/src/app/dashboard/home-balance-breakdown.ts b/apps/sdp-web/src/app/dashboard/home-balance-breakdown.ts new file mode 100644 index 000000000..836a11249 --- /dev/null +++ b/apps/sdp-web/src/app/dashboard/home-balance-breakdown.ts @@ -0,0 +1,140 @@ +import type { CustodyWalletTokenBalance } from "@sdp/types"; + +export interface HomeBalanceSlice { + /** Mint address — the stable identity, and the React key. */ + mint: string; + /** Raw token field from the aggregate; the renderer resolves the display symbol. */ + token: string; + uiAmount: string; + /** Present only on priced holdings. */ + usdValue: number | null; + /** Share of the total priced value, 0-100. Zero for unpriced holdings. */ + sharePercent: number; +} + +export interface HomeBalanceBreakdown { + /** Holdings with a USD value, largest first — these compose the allocation bar. */ + priced: HomeBalanceSlice[]; + /** Holdings with no price feed, largest amount first. Never charted. */ + unpriced: HomeBalanceSlice[]; + /** Sum of every priced holding, or null when nothing is priced. */ + totalUsd: number | null; + /** Priced holdings dropped from `priced` after the cap, folded into one bucket. */ + otherPricedCount: number; + otherPricedUsd: number; + otherPricedSharePercent: number; + /** Unpriced holdings beyond the cap. Counted, not listed. */ + otherUnpricedCount: number; +} + +/** + * Whether a balance counts as a holding. + * + * Deliberately the same rule the wallet page already applies to this same data in + * `payments/ramps/components/wallet-asset-breakdown.tsx` — a spent token account keeps + * its aggregate row at zero, and counting those inflated the "Tokens held" tile. An + * amount that will not parse cannot be counted as a holding either, since nothing + * downstream can rank or sum it. + */ +function isHeldAmount(balance: CustodyWalletTokenBalance): boolean { + const amount = Number(balance.uiAmount); + return Number.isFinite(amount) && amount > 0; +} + +/** + * Whether a balance belongs in the breakdown *list*, which is a looser question than + * whether it is held. + * + * A definite zero is dropped: it contributed a `$0.00` row and an empty segment to an + * allocation it makes up none of. An unparseable amount is kept, because the list's job + * is to show what the aggregate returned — hiding a row nobody can explain is worse + * than showing it as unpriced, which is what the non-finite case here already + * guarantees. + */ +function isListable(balance: CustodyWalletTokenBalance): boolean { + const amount = Number(balance.uiAmount); + return !Number.isFinite(amount) || amount > 0; +} + +function usdValueOf(balance: CustodyWalletTokenBalance): number | null { + if (typeof balance.usdValue === "number" && Number.isFinite(balance.usdValue)) { + return balance.usdValue; + } + if (typeof balance.usdPrice === "number" && Number.isFinite(balance.usdPrice)) { + const amount = Number(balance.uiAmount); + if (Number.isFinite(amount)) { + return amount * balance.usdPrice; + } + } + return null; +} + +/** + * Splits holdings into what can be compared and what cannot. + * + * An organization's own issued tokens have no price feed, so their balance is an + * amount and nothing more. Ranking `132.5 nwSOL` against `$149.11` on one scale is a + * category error — the earlier version drew a share bar for every row and the + * unpriced ones came out as empty full-width rules that read as dividers. Only + * priced holdings get a share; unpriced ones are returned separately so the caller + * can list them without pretending they are part of an allocation. + * + * Shares are of the **priced total**, so the segments of a stacked bar sum to 100. + * + * @param balances - Aggregate token balances, already summed across wallets. + * @param limit - How many priced holdings to name before folding the rest into "Other". + * @param unpricedLimit - How many unpriced holdings to list before counting the rest. + * Uncapped, an organization issuing twenty tokens turned the card into a ledger. + */ +export function buildHomeBalanceBreakdown( + balances: CustodyWalletTokenBalance[], + limit = 4, + unpricedLimit = 4 +): HomeBalanceBreakdown { + const priced: HomeBalanceSlice[] = []; + const unpriced: HomeBalanceSlice[] = []; + + for (const balance of balances.filter(isListable)) { + const usdValue = usdValueOf(balance); + const slice: HomeBalanceSlice = { + mint: balance.mint, + token: balance.token, + uiAmount: balance.uiAmount, + usdValue, + sharePercent: 0, + }; + if (usdValue === null) { + unpriced.push(slice); + } else { + priced.push(slice); + } + } + + priced.sort((a, b) => (b.usdValue ?? 0) - (a.usdValue ?? 0)); + unpriced.sort((a, b) => Number(b.uiAmount) - Number(a.uiAmount)); + + const totalUsd = priced.reduce((sum, slice) => sum + (slice.usdValue ?? 0), 0); + const share = (value: number) => (totalUsd > 0 ? (value / totalUsd) * 100 : 0); + + const named = priced.slice(0, limit).map((slice) => ({ + ...slice, + sharePercent: share(slice.usdValue ?? 0), + })); + const rest = priced.slice(limit); + const otherPricedUsd = rest.reduce((sum, slice) => sum + (slice.usdValue ?? 0), 0); + + return { + priced: named, + unpriced: unpriced.slice(0, unpricedLimit), + otherUnpricedCount: Math.max(unpriced.length - unpricedLimit, 0), + totalUsd: priced.length > 0 ? totalUsd : null, + otherPricedCount: rest.length, + otherPricedUsd, + otherPricedSharePercent: share(otherPricedUsd), + }; +} + +/** Distinct tokens held, used for the "Tokens held" tile. */ +export function countHeldTokens(balances: CustodyWalletTokenBalance[]): number { + return new Set(balances.filter(isHeldAmount).map((balance) => balance.mint)).size; +} diff --git a/apps/sdp-web/src/app/dashboard/home-balance-breakdown.unit.test.ts b/apps/sdp-web/src/app/dashboard/home-balance-breakdown.unit.test.ts new file mode 100644 index 000000000..72110dea2 --- /dev/null +++ b/apps/sdp-web/src/app/dashboard/home-balance-breakdown.unit.test.ts @@ -0,0 +1,178 @@ +import type { CustodyWalletTokenBalance } from "@sdp/types"; +import { describe, expect, it } from "vitest"; +import { buildHomeBalanceBreakdown, countHeldTokens } from "./home-balance-breakdown"; + +function balance(overrides: Partial): CustodyWalletTokenBalance { + return { + token: "USDC", + mint: "mint-usdc", + amount: "1000000", + uiAmount: "1", + decimals: 6, + ...overrides, + }; +} + +describe("buildHomeBalanceBreakdown", () => { + it("keeps unpriced holdings out of the allocation entirely", () => { + // The real case behind the redesign: one priced token, two org-issued ones with + // no feed. Charting them together compares dollars against raw token counts. + const result = buildHomeBalanceBreakdown([ + balance({ mint: "sol", token: "SOL", usdValue: 149.11 }), + balance({ mint: "nwsol", token: "nwSOL", uiAmount: "132.5" }), + balance({ mint: "atd", token: "ATD", uiAmount: "25000" }), + ]); + + expect(result.priced.map((s) => s.token)).toEqual(["SOL"]); + expect(result.unpriced.map((s) => s.token)).toEqual(["ATD", "nwSOL"]); + expect(result.unpriced.every((s) => s.sharePercent === 0)).toBe(true); + expect(result.totalUsd).toBeCloseTo(149.11); + }); + + it("makes priced shares sum to 100 so a stacked bar is whole", () => { + const result = buildHomeBalanceBreakdown([ + balance({ mint: "a", usdValue: 75 }), + balance({ mint: "b", usdValue: 25 }), + ]); + + expect(result.priced.map((s) => s.sharePercent)).toEqual([75, 25]); + expect(result.priced.reduce((sum, s) => sum + s.sharePercent, 0)).toBeCloseTo(100); + }); + + it("orders priced holdings largest first", () => { + const result = buildHomeBalanceBreakdown([ + balance({ mint: "small", usdValue: 5 }), + balance({ mint: "big", usdValue: 50 }), + balance({ mint: "mid", usdValue: 20 }), + ]); + + expect(result.priced.map((s) => s.mint)).toEqual(["big", "mid", "small"]); + }); + + it("orders unpriced holdings by amount, largest first", () => { + const result = buildHomeBalanceBreakdown([ + balance({ mint: "few", uiAmount: "10" }), + balance({ mint: "many", uiAmount: "9000" }), + ]); + + expect(result.unpriced.map((s) => s.mint)).toEqual(["many", "few"]); + }); + + it("folds priced holdings past the cap into one Other bucket", () => { + const many = Array.from({ length: 7 }, (_, i) => balance({ mint: `m${i}`, usdValue: 10 })); + const result = buildHomeBalanceBreakdown(many, 4); + + expect(result.priced).toHaveLength(4); + expect(result.otherPricedCount).toBe(3); + expect(result.otherPricedUsd).toBe(30); + // Named shares plus Other still account for the whole bar. + const total = + result.priced.reduce((sum, s) => sum + s.sharePercent, 0) + result.otherPricedSharePercent; + expect(total).toBeCloseTo(100); + }); + + it("derives value from price when usdValue is absent", () => { + const result = buildHomeBalanceBreakdown([ + balance({ mint: "priced", uiAmount: "3", usdPrice: 7 }), + ]); + + expect(result.priced[0].usdValue).toBe(21); + }); + + it("treats a non-finite amount as unpriced rather than producing NaN", () => { + const result = buildHomeBalanceBreakdown([ + balance({ mint: "bad", uiAmount: "not-a-number", usdPrice: 2 }), + ]); + + expect(result.priced).toHaveLength(0); + expect(result.unpriced.map((s) => s.mint)).toEqual(["bad"]); + expect(result.totalUsd).toBeNull(); + }); + + it("reports no total when nothing is priced", () => { + const result = buildHomeBalanceBreakdown([balance({ mint: "a" }), balance({ mint: "b" })]); + + expect(result.totalUsd).toBeNull(); + expect(result.priced).toHaveLength(0); + expect(result.unpriced).toHaveLength(2); + }); + + it("returns empty for no balances", () => { + const result = buildHomeBalanceBreakdown([]); + expect(result.priced).toEqual([]); + expect(result.unpriced).toEqual([]); + expect(result.totalUsd).toBeNull(); + }); +}); + +describe("countHeldTokens", () => { + it("counts distinct mints, not rows", () => { + expect( + countHeldTokens([balance({ mint: "a" }), balance({ mint: "a" }), balance({ mint: "b" })]) + ).toBe(2); + }); + + it("does not count a mint whose balance is spent", () => { + // A spent token account keeps its aggregate row, so counting rows claimed + // holdings the organization no longer has. + expect( + countHeldTokens([ + balance({ mint: "a", uiAmount: "1" }), + balance({ mint: "b", uiAmount: "0" }), + balance({ mint: "c", uiAmount: "0.0" }), + ]) + ).toBe(1); + }); + + it("does not count an amount it cannot parse", () => { + // Matches the rule wallet-asset-breakdown.tsx already applies to this data. The + // list still shows the row (as unpriced) — showing something unexplained beats + // hiding it — but nothing that cannot be ranked or summed is claimed as a holding. + const balances = [ + balance({ mint: "ok", uiAmount: "3" }), + balance({ mint: "bad", uiAmount: "not-a-number" }), + ]; + + expect(countHeldTokens(balances)).toBe(1); + expect(buildHomeBalanceBreakdown(balances).unpriced.map((s) => s.mint)).toContain("bad"); + }); + + it("agrees with what the breakdown lists", () => { + const balances = [ + balance({ mint: "sol", token: "SOL", uiAmount: "2", usdValue: 149.11 }), + balance({ mint: "spent", token: "SPENT", uiAmount: "0", usdValue: 0 }), + balance({ mint: "nwsol", token: "nwSOL", uiAmount: "132.5" }), + ]; + const breakdown = buildHomeBalanceBreakdown(balances); + const listed = [...breakdown.priced, ...breakdown.unpriced].length; + + expect(countHeldTokens(balances)).toBe(2); + expect(listed).toBe(2); + expect([...breakdown.priced, ...breakdown.unpriced].map((s) => s.token)).not.toContain("SPENT"); + }); + + it("is zero for no balances", () => { + expect(countHeldTokens([])).toBe(0); + }); +}); + +describe("unpriced cap", () => { + it("lists only the first few unpriced holdings and counts the rest", () => { + // An organization issuing a dozen of its own tokens turned the card into a + // ledger; only the largest few are listed and the remainder is a count. + const many = Array.from({ length: 11 }, (_, i) => + balance({ mint: `u${i}`, token: `TKN${i}`, uiAmount: String(100 - i) }) + ); + const result = buildHomeBalanceBreakdown(many); + + expect(result.unpriced).toHaveLength(4); + expect(result.otherUnpricedCount).toBe(7); + expect(result.unpriced[0].mint).toBe("u0"); + }); + + it("counts nothing extra when the unpriced list fits", () => { + const result = buildHomeBalanceBreakdown([balance({ mint: "a" }), balance({ mint: "b" })]); + expect(result.unpriced).toHaveLength(2); + expect(result.otherUnpricedCount).toBe(0); + }); +}); diff --git a/apps/sdp-web/src/app/dashboard/home-page.data.ts b/apps/sdp-web/src/app/dashboard/home-page.data.ts index 8afbae6d6..999965685 100644 --- a/apps/sdp-web/src/app/dashboard/home-page.data.ts +++ b/apps/sdp-web/src/app/dashboard/home-page.data.ts @@ -21,6 +21,13 @@ export interface HomeActivityRow { createdAt: string; type: string; token: string; + /** + * Raw mint behind `token`, when there is one. `token` is already resolved here, + * but this builder only sees issued-token symbols — a holding the organization + * did not issue degrades to a shortened mint. Carrying the mint lets the client, + * which has the symbols that came back with the balances, name it properly. + */ + tokenMint: string | null; amount: string; address: string; explorer: HomeActivityExplorerRef | null; @@ -151,6 +158,7 @@ export function buildHomeActivityRows( // transfer.token is a mint address; without this the row renders the raw // base58 while the Transactions table shows the symbol for the same row. token: resolveTransferTokenLabel(transfer.token, issuedTokenSymbolsByMint) ?? "—", + tokenMint: transfer.token?.trim() || null, amount: transfer.amount ?? "—", address: resolvePaymentsAddress(transfer), explorer: resolvePaymentsExplorer(transfer), @@ -170,6 +178,7 @@ export function buildHomeActivityRows( createdAt: transaction.createdAt, type: toTitleCase(transaction.type), token: token.symbol || token.name || "—", + tokenMint: token.mintAddress?.trim() || null, amount: resolveIssuanceAmount(transaction), address: resolveIssuanceAddress(transaction), explorer: resolveIssuanceExplorer(transaction), diff --git a/apps/sdp-web/src/app/dashboard/home-page.data.unit.test.ts b/apps/sdp-web/src/app/dashboard/home-page.data.unit.test.ts index b93020372..3fac6cd47 100644 --- a/apps/sdp-web/src/app/dashboard/home-page.data.unit.test.ts +++ b/apps/sdp-web/src/app/dashboard/home-page.data.unit.test.ts @@ -108,3 +108,26 @@ describe("home issuance activity", () => { expect(row?.token).toBe("9xQeWv…nLpQ"); }); }); + +describe("token mint passthrough", () => { + it("keeps the raw mint so the client can name a token this builder cannot", () => { + // The builder only knows issued-token symbols. A holding the organization did + // not issue used to degrade to a shortened mint with no way back to a symbol. + const mint = "9xQeWvG816bUx9EPfuxEzHh9VY5kvJkFqRk3nJvHnLpQ"; + const [row] = buildHomeActivityRows( + [ + { + id: "t1", + createdAt: "2026-07-30T10:00:00.000Z", + token: mint, + amount: "12", + direction: "inbound", + } as never, + ], + [], + ((key: string) => key) as never + ); + + expect(row.tokenMint).toBe(mint); + }); +}); diff --git a/apps/sdp-web/src/app/dashboard/home-token-symbols.ts b/apps/sdp-web/src/app/dashboard/home-token-symbols.ts new file mode 100644 index 000000000..56fd08af0 --- /dev/null +++ b/apps/sdp-web/src/app/dashboard/home-token-symbols.ts @@ -0,0 +1,37 @@ +import type { CustodyWalletTokenBalance } from "@sdp/types"; + +interface TokenNamedRow { + token: string; + tokenMint: string | null; +} + +/** + * The mint→symbol map the home activity table resolves names against. + * + * Balances are the better source when they have the mint: they carry a live symbol for + * every token the organization currently holds, including ones the shared catalogue has + * never heard of. + * + * Rows are folded in first, and they matter for the mints balances *cannot* cover. An + * issuance row carries the authoritative `token.symbol`, and resolving its mint against + * balances alone fell through to a shortened mint — which is a truthy string, so it beat + * the good symbol the row already had rather than letting the caller fall back to it. + * That turned the old "raw base58 in the Token column" bug into a subtler one: a real + * symbol replaced by `4zMMC9srt5…` for any token the organization no longer holds. + * + * A row contributes nothing when it has no mint, when its token is the `—` placeholder, + * or when the "symbol" is just the mint again — none of those name anything. + */ +export function buildTokenSymbolsByMint( + rows: readonly TokenNamedRow[], + balances: readonly CustodyWalletTokenBalance[] +): Record { + return Object.fromEntries([ + ...rows.flatMap((row) => + row.tokenMint && row.token !== "—" && row.token !== row.tokenMint + ? [[row.tokenMint, row.token] as const] + : [] + ), + ...balances.map((balance) => [balance.mint, balance.token] as const), + ]); +} diff --git a/apps/sdp-web/src/app/dashboard/home-token-symbols.unit.test.ts b/apps/sdp-web/src/app/dashboard/home-token-symbols.unit.test.ts new file mode 100644 index 000000000..91a5b358b --- /dev/null +++ b/apps/sdp-web/src/app/dashboard/home-token-symbols.unit.test.ts @@ -0,0 +1,49 @@ +import type { CustodyWalletTokenBalance } from "@sdp/types"; +import { describe, expect, it } from "vitest"; +import { buildTokenSymbolsByMint } from "./home-token-symbols"; +import { resolveTransferTokenLabel } from "./payments/payments-overview.utils"; + +const ISSUED_MINT = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; + +function balance(overrides: Partial): CustodyWalletTokenBalance { + return { + token: "USDC", + mint: "mint-usdc", + amount: "1000000", + uiAmount: "1", + decimals: 6, + ...overrides, + }; +} + +describe("buildTokenSymbolsByMint", () => { + it("keeps an issued symbol for a mint the organization no longer holds", () => { + // The regression: with balances as the only source the mint fell through to + // shortenAddress, which is truthy, so it won over the symbol the row already had. + const rows = [{ token: "ATD", tokenMint: ISSUED_MINT }]; + const symbols = buildTokenSymbolsByMint(rows, []); + + expect(resolveTransferTokenLabel(ISSUED_MINT, symbols)).toBe("ATD"); + expect(resolveTransferTokenLabel(ISSUED_MINT, {})).not.toBe("ATD"); + }); + + it("prefers the balance symbol when both know the mint", () => { + const rows = [{ token: "STALE", tokenMint: "mint-usdc" }]; + const symbols = buildTokenSymbolsByMint(rows, [balance({ token: "USDC" })]); + + expect(symbols["mint-usdc"]).toBe("USDC"); + }); + + it("ignores rows that name nothing", () => { + const symbols = buildTokenSymbolsByMint( + [ + { token: "—", tokenMint: ISSUED_MINT }, + { token: "SOL", tokenMint: null }, + { token: ISSUED_MINT, tokenMint: ISSUED_MINT }, + ], + [] + ); + + expect(symbols).toEqual({}); + }); +}); diff --git a/apps/sdp-web/src/app/dashboard/home-workspace.tsx b/apps/sdp-web/src/app/dashboard/home-workspace.tsx index 649d62e00..ae4cb1990 100644 --- a/apps/sdp-web/src/app/dashboard/home-workspace.tsx +++ b/apps/sdp-web/src/app/dashboard/home-workspace.tsx @@ -1,10 +1,12 @@ "use client"; -import type { PaymentsDashboardWallet, SolanaCluster } from "@sdp/types"; +import type { CustodyWalletTokenBalance, PaymentsDashboardWallet, SolanaCluster } from "@sdp/types"; import { ExternalLink } from "lucide-react"; +import { useState } from "react"; import { CreateApiKeyModal } from "@/app/dashboard/api-keys/create-api-key-modal"; import { SectionEntry } from "@/app/dashboard/wallets/section-entry"; import { DashboardNavigationLink as Link } from "@/components/dashboard-navigation-link"; +import { TokenMark } from "@/components/token-mark"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { @@ -21,15 +23,24 @@ import { useLocale, useTranslations } from "@/i18n/provider"; import { usePersistedDashboardSWR } from "@/lib/dashboard-swr"; import { explorerAddressUrl, explorerTxUrl } from "@/lib/explorer"; import { useSolanaCluster } from "@/lib/use-solana-cluster"; +import { cn } from "@/lib/utils"; import { formatRelativeTime } from "./activity-format-utils"; +import { buildHomeBalanceBreakdown, countHeldTokens } from "./home-balance-breakdown"; import type { HomeActivityExplorerRef, HomeActivityRow } from "./home-page.data"; +import { buildTokenSymbolsByMint } from "./home-token-symbols"; import { fetchHomeActivity } from "./home-workspace.data"; -import { formatCurrencyAmount, formatDisplayAmount } from "./payments/payments-overview.utils"; +import { + formatCurrencyAmount, + formatDisplayAmount, + resolveTransferTokenLabel, +} from "./payments/payments-overview.utils"; interface HomeWorkspaceProps { totalBalance: number | null; totalBalanceError: string | null; wallets: PaymentsDashboardWallet[]; + balances: CustodyWalletTokenBalance[]; + walletCount: number; } const HOME_ACTIVITY_KEY = "dashboard-home-activity"; @@ -80,35 +91,341 @@ function ActivityAddress({ ); } -function MetricCard({ - label, - value, - error, - hint, +/** + * Descending emphasis for descending share. The design system ships no categorical + * chart palette, and a stacked allocation bar is a magnitude encoding rather than an + * identity one, so a single neutral ramp is the right job — and it re-steps itself + * per theme instead of needing a hand-picked dark variant. + */ +const ALLOCATION_FILLS = [ + "bg-primary", + "bg-secondary", + "bg-tertiary", + "bg-muted", + "bg-fill-strong", +] as const; + +function allocationFill(index: number): string { + return ALLOCATION_FILLS[Math.min(index, ALLOCATION_FILLS.length - 1)]; +} + +/** + * Holdings by token. The aggregate already returns the per-token rows the total is + * summed from, so this needs no extra request. + * + * Priced holdings compose one stacked allocation bar; unpriced ones are listed below + * it without a bar. An organization's own issued tokens have no price feed, and the + * first version drew a share bar for every row — the unpriced ones rendered as empty + * full-width rules that read as dividers, and comparing a raw token count against a + * dollar amount was meaningless anyway. + */ +function BalanceAllocation({ + balances, + locale, }: { - label: string; - value: number | null; - error: string | null; - hint?: string | null; + balances: CustodyWalletTokenBalance[]; + locale: string; +}) { + const t = useTranslations(); + const [hovered, setHovered] = useState(null); + const breakdown = buildHomeBalanceBreakdown(balances); + if (breakdown.priced.length === 0 && breakdown.unpriced.length === 0) { + return null; + } + + // Balances carry their own symbols, so tokens the catalogue has never heard of + // still get named rather than falling back to a shortened mint. + const symbolsByMint = Object.fromEntries( + balances.map((balance) => [balance.mint, balance.token]) + ); + const symbolFor = (slice: { mint: string; token: string }) => + resolveTransferTokenLabel(slice.mint, symbolsByMint) ?? slice.token; + + const segments = [ + ...breakdown.priced.map((slice, index) => ({ + key: slice.mint, + mint: slice.mint, + label: symbolFor(slice), + percent: slice.sharePercent, + value: slice.usdValue ?? 0, + fill: allocationFill(index), + })), + ...(breakdown.otherPricedCount > 0 + ? [ + { + key: "__other__", + mint: null, + label: + breakdown.otherPricedCount === 1 + ? t("Shared.homeWorkspace.singleOtherToken") + : t("Shared.homeWorkspace.otherTokensCount", { + count: breakdown.otherPricedCount, + }), + percent: breakdown.otherPricedSharePercent, + value: breakdown.otherPricedUsd, + fill: allocationFill(breakdown.priced.length), + }, + ] + : []), + ]; + + return ( +
+
+

{t("Shared.homeWorkspace.balanceByToken")}

+ {breakdown.totalUsd !== null ? ( +

+ {formatCurrencyAmount(breakdown.totalUsd, locale)} +

+ ) : null} +
+ + {segments.length > 0 ? ( + <> + {/* Hovering either the bar or its row lifts the same segment, so the two + read as one object. gap-0.5 is the 2px spacer that stops adjacent + fills merging into a single block. */} +
+ {segments.map((segment) => ( +
+ +
    + {segments.map((segment) => ( +
  • + {/* Row highlight is pure CSS. Driving it from mouse handlers on a + plain element is a keyboard trap — the segment button above owns + the interaction, and this only mirrors it. */} +
    + {segment.mint ? ( + + ) : ( +
    +
  • + ))} +
+ + ) : null} + + {breakdown.unpriced.length > 0 ? ( +
+

{t("Shared.homeWorkspace.notPriced")}

+ {breakdown.unpriced.map((slice) => { + const symbol = symbolFor(slice); + return ( +
+ + + {symbol} + + + {formatDisplayAmount(slice.uiAmount, symbol)} + +
+ ); + })} + {breakdown.otherUnpricedCount > 0 ? ( +

+ {breakdown.otherUnpricedCount === 1 + ? t("Shared.homeWorkspace.singleMoreToken") + : t("Shared.homeWorkspace.moreTokensCount", { + count: breakdown.otherUnpricedCount, + })} +

+ ) : null} +
+ ) : null} +
+ ); +} + +/** A secondary figure beside the hero — deliberately far smaller than the balance. */ +function HeroStat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +/** + * One number, at the top, at maximum weight. + * + * Four equal tiles gave the balance, the day's volume, a wallet count and a token + * count identical visual weight, so the page answered no question first — the reader + * had to pick. Total balance is what someone opening a custody dashboard came for, + * so it leads; everything else is context beside it, and the primary action sits in + * the same block rather than floating above the page. + */ +function BalanceHero({ + totalBalance, + totalBalanceError, + totalBalanceHint, + todaysVolume, + todaysVolumeError, + walletCount, + heldTokenCount, + balances, + locale, + canManageApiKeys, + canManageCustody, +}: { + totalBalance: number | null; + totalBalanceError: string | null; + totalBalanceHint: string | null; + todaysVolume: number | null; + todaysVolumeError: string | null; + walletCount: number; + heldTokenCount: number; + balances: CustodyWalletTokenBalance[]; + locale: string; + canManageApiKeys: boolean; + canManageCustody: boolean; }) { const t = useTranslations(); - const locale = useLocale(); return ( - - -

{label}

-

- {error ? t("Shared.homeWorkspace.unavailable") : formatCurrencyAmount(value, locale)} -

- {error ?

{error}

: null} - {!error && hint ?

{hint}

: null} + + +
+
+

{t("Shared.homeWorkspace.totalBalance")}

+

+ {totalBalanceError + ? t("Shared.homeWorkspace.unavailable") + : formatCurrencyAmount(totalBalance, locale)} +

+ {totalBalanceError ? ( +

{totalBalanceError}

+ ) : totalBalanceHint ? ( +

{totalBalanceHint}

+ ) : null} +
+ +
+ {canManageApiKeys ? ( + + ) : null} + {canManageCustody ? ( + + ) : null} +
+
+ +
+ + + +
+ + {balances.length > 0 ? ( +
+ +
+ ) : null}
); } -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Dashboard orchestration keeps related loading, empty, and populated states together. -export function HomeWorkspace({ totalBalance, totalBalanceError, wallets }: HomeWorkspaceProps) { +/** + * What to do when there is nothing yet. + * + * The populated layout rendered four zeroes and a hint under each, which reads as a + * broken dashboard rather than a new one. A first run gets one instruction and the + * action next to it instead. + */ +function FirstRunPanel({ canCreateWallet }: { canCreateWallet: boolean }) { + const t = useTranslations(); + return ( + + +
+

+ {t("Shared.homeWorkspace.firstRunTitle")} +

+

{t("Shared.homeWorkspace.firstRunBody")}

+
+ {canCreateWallet ? ( + + ) : null} +
+
+ ); +} + +export function HomeWorkspace({ + totalBalance, + totalBalanceError, + wallets, + balances, + walletCount, +}: HomeWorkspaceProps) { const t = useTranslations(); const locale = useLocale(); const cluster = useSolanaCluster(); @@ -126,6 +443,7 @@ export function HomeWorkspace({ totalBalance, totalBalanceError, wallets }: Home } ); const isWalletEmptyState = wallets.length === 0; + const heldTokenCount = countHeldTokens(balances); const totalBalanceHint = isWalletEmptyState ? t("Shared.homeWorkspace.createFirstWalletBalances") : totalBalance === null @@ -138,19 +456,13 @@ export function HomeWorkspace({ totalBalance, totalBalanceError, wallets }: Home : t("Shared.homeWorkspace.activityUnavailable") : (activitySnapshot?.activityError ?? null); const activityRows = activitySnapshot?.activityRows ?? []; + const symbolsByMint = buildTokenSymbolsByMint(activityRows, balances); const activityError = activityRequestError ? activityRequestError instanceof Error ? activityRequestError.message || t("Shared.homeWorkspace.activityUnavailable") : t("Shared.homeWorkspace.activityUnavailable") : (activitySnapshot?.activityError ?? null); const activityNotice = activitySnapshot?.activityNotice ?? null; - const todaysVolumeHint = isWalletEmptyState - ? t("Shared.homeWorkspace.paymentActivityAfterWallet") - : todaysVolume === null - ? activitySnapshot - ? t("Shared.homeWorkspace.noPaymentVolume") - : t("Shared.homeWorkspace.loadingPaymentActivity") - : null; const emptyActivityMessage = isWalletEmptyState ? t("Shared.homeWorkspace.createFirstWalletActivity") : activitySnapshot @@ -160,39 +472,23 @@ export function HomeWorkspace({ totalBalance, totalBalanceError, wallets }: Home return (
-
- {dashboardAccess.capabilities.canManageApiKeys ? ( - - ) : null} - {dashboardAccess.capabilities.canManageCustody ? ( - - ) : null} -
-
- - -
- - + ) : ( + -
+ )}
@@ -221,19 +517,19 @@ export function HomeWorkspace({ totalBalance, totalBalanceError, wallets }: Home - + {t("Shared.homeWorkspace.time")} - + {t("Shared.homeWorkspace.activity")} - + {t("Shared.homeWorkspace.type")} {t("Shared.homeWorkspace.token")} - + {t("Shared.homeWorkspace.amount")} @@ -244,10 +540,19 @@ export function HomeWorkspace({ totalBalance, totalBalanceError, wallets }: Home {activityRows.map((row) => { const timeLabel = formatRelativeTime(row.createdAt, locale); + // `row.token` is already resolved, but only against issued + // tokens — anything else arrives as a shortened mint. Re-resolve + // from the mint using the balance symbols before falling back. + const tokenSymbol = + resolveTransferTokenLabel(row.tokenMint, symbolsByMint) ?? row.token; const amountLabel = row.amount === "—" ? "—" - : formatDisplayAmount(row.amount, row.token, locale); + : formatDisplayAmount(row.amount, "", locale).trim(); + const mobileAmountLabel = + row.amount === "—" + ? "—" + : formatDisplayAmount(row.amount, tokenSymbol, locale); return ( @@ -256,7 +561,7 @@ export function HomeWorkspace({ totalBalance, totalBalanceError, wallets }: Home
{row.type}
- {amountLabel} + {mobileAmountLabel}
- + + + + - + diff --git a/apps/sdp-web/src/app/dashboard/loading.tsx b/apps/sdp-web/src/app/dashboard/loading.tsx index e6780b79c..0cd10e05e 100644 --- a/apps/sdp-web/src/app/dashboard/loading.tsx +++ b/apps/sdp-web/src/app/dashboard/loading.tsx @@ -9,7 +9,9 @@ import { TableRow, } from "@/components/ui/table"; -const METRIC_SKELETON_IDS = ["home-metric-skeleton-1", "home-metric-skeleton-2"]; +// Mirrors the hero: three context figures under the balance, then the allocation. +const HERO_STAT_IDS = ["home-hero-stat-1", "home-hero-stat-2", "home-hero-stat-3"]; +const ALLOCATION_ROW_IDS = ["home-alloc-1", "home-alloc-2", "home-alloc-3"]; const ACTIVITY_ROW_IDS = [ "home-table-skeleton-1", "home-table-skeleton-2", @@ -22,21 +24,48 @@ const ACTIVITY_ROW_IDS = [ export default function DashboardLoading() { return (
-
- - -
- -
- {METRIC_SKELETON_IDS.map((id) => ( -
+
+
+
- + +
+
+ + +
+
+ +
+ {HERO_STAT_IDS.map((id) => ( +
+ + +
+ ))} +
+ +
+
+ + +
+ +
+ {ALLOCATION_ROW_IDS.map((id) => ( +
+ + +
+ + +
+ ))}
- ))} +
diff --git a/apps/sdp-web/src/app/dashboard/page.tsx b/apps/sdp-web/src/app/dashboard/page.tsx index a73593792..a581ef4ef 100644 --- a/apps/sdp-web/src/app/dashboard/page.tsx +++ b/apps/sdp-web/src/app/dashboard/page.tsx @@ -32,7 +32,8 @@ export default async function DashboardPage() { const wallets = walletsResult.data ?? []; const isWalletEmptyState = walletsResult.ok && wallets.length === 0; - const totalBalance = resolveTotalBalance(aggregateResult.data?.balances ?? []); + const balances = aggregateResult.data?.balances ?? []; + const totalBalance = resolveTotalBalance(balances); const aggregateError = aggregateResult.ok || isWalletEmptyState @@ -50,6 +51,10 @@ export default async function DashboardPage() { totalBalance={totalBalance} totalBalanceError={aggregateError} wallets={wallets} + // Already on the wire for the total; the home page used to discard the + // per-token rows and the wallet count that came back with it. + balances={balances} + walletCount={aggregateResult.data?.walletCount ?? wallets.length} /> ); } catch (error) { diff --git a/apps/sdp-web/src/components/dashboard-header.tsx b/apps/sdp-web/src/components/dashboard-header.tsx index 0ac6888c5..223348c2c 100644 --- a/apps/sdp-web/src/components/dashboard-header.tsx +++ b/apps/sdp-web/src/components/dashboard-header.tsx @@ -135,7 +135,12 @@ export function StandardDashboardTopBar({ data-dashboard-standard-topbar >
{leadingContent}
- {hideTitle ? null : ( + {/* Hiding the title is a visual decision, not a structural one — every page + still needs exactly one h1 for assistive tech and for tests that look one + up by name. */} + {hideTitle ? ( +

{title}

+ ) : (

{ }); }); + it("keeps catalogs free of ICU syntax translate cannot render", () => { + // translate only substitutes {name}. An ICU construct such as + // {count, plural, one {#} other {#}} matches nothing, throws nothing, and + // reaches the user verbatim, so no catalog may contain one. + for (const locale of supportedLocales) { + const messages = getMessages(locale) as unknown; + const offenders = flattenKeys(messages).filter((key) => { + const value = key.split(".").reduce((carry, segment) => { + return carry && typeof carry === "object" + ? (carry as Record)[segment] + : undefined; + }, messages); + return ( + typeof value === "string" && /\{\s*\w+\s*,\s*(plural|select|selectordinal)\b/.test(value) + ); + }); + + expect(offenders).toEqual([]); + } + }); + it("rejects missing interpolation values", () => { expect(() => translate(getMessages("en"), "DashboardCustody.rotateKey")).toThrow( "Missing interpolation value hours for DashboardCustody.rotateKey"