Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1f7e6c7
feat(web): add wallet, token and per-token balance detail to the home…
tobySolutions Jul 30, 2026
5dc4b96
merge: main
tobySolutions Jul 30, 2026
c3106f8
fix(web): make the token balance card an allocation, not a row of rules
tobySolutions Jul 30, 2026
7902c8b
feat(web): lead the home page with one number instead of four tiles
tobySolutions Jul 30, 2026
7eafd0d
chore(web): drop the suppression and hint the hero made dead
tobySolutions Jul 30, 2026
1701e49
feat(web): give the balance card real token marks and a linked hover
tobySolutions Jul 30, 2026
4cb50b1
fix(web): keep the balance row highlight off mouse handlers
tobySolutions Jul 30, 2026
9a03666
feat(web): cap the token list, drop the Home title, match the skeleton
tobySolutions Jul 30, 2026
405bc65
fix(web): unsqueeze the activity table and retire the page titles
tobySolutions Jul 30, 2026
4648a7e
fix(web): name org tokens in the activity table instead of a clipped …
tobySolutions Jul 30, 2026
e1d2905
Merge branch 'main' into feat/home-metrics
tobySolutions Jul 30, 2026
621af28
fix(web): keep page titles outside home, and name tokens the org no l…
tobySolutions Jul 30, 2026
8cfa021
Merge branch 'main' into feat/home-metrics
tobySolutions Jul 30, 2026
a387f76
fix(web): count held tokens the way the wallet page already does
tobySolutions Jul 30, 2026
8142291
Merge branch 'main' into feat/home-metrics
GuiBibeau Jul 31, 2026
2db6c96
fix(web): keep Token and Wallet in English on the French home page
tobySolutions Jul 31, 2026
fbea977
Merge remote-tracking branch 'origin/main' into feat/home-metrics
tobySolutions Jul 31, 2026
a3c31f6
fix(web): leave French home copy to the translation flow
tobySolutions Jul 31, 2026
5232e8e
fix(web): render the token overflow counts instead of raw ICU syntax
tobySolutions Jul 31, 2026
4798766
Merge branch 'main' into feat/home-metrics
tobySolutions Jul 31, 2026
d0de187
Merge branch 'main' into feat/home-metrics
tobySolutions Aug 3, 2026
be4e480
Merge branch 'main' into feat/home-metrics
GuiBibeau Aug 4, 2026
053e911
Merge branch 'main' into feat/home-metrics
GuiBibeau Aug 4, 2026
884a5c2
Merge branch 'main' into feat/home-metrics
GuiBibeau Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions apps/sdp-web/messages/en/shared.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
140 changes: 140 additions & 0 deletions apps/sdp-web/src/app/dashboard/home-balance-breakdown.ts
Original file line number Diff line number Diff line change
@@ -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;
}
178 changes: 178 additions & 0 deletions apps/sdp-web/src/app/dashboard/home-balance-breakdown.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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);
});
});
9 changes: 9 additions & 0 deletions apps/sdp-web/src/app/dashboard/home-page.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand Down
Loading
Loading