Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 34 additions & 0 deletions apps/desktop-tauri/src/components/MenuCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ describe("MenuCard", () => {
WayfinderOffline: "Gateway offline",
WayfinderDryRun: "Dry run",
WayfinderMissingKeys: "Missing keys",
UsageSpendTokens: "tokens",
DeepSeekPricingTitle: "DeepSeek pricing",
DeepSeekPricingStandard: "Standard / pre-schedule",
DeepSeekPricingPeak: "Peak hours",
Expand All @@ -160,6 +161,7 @@ describe("MenuCard", () => {
tauriMocks.getProviderChartData.mockResolvedValue({
providerId: "claude",
costHistory: [{ date: "2026-05-24", value: 1.23 }],
tokensHistory: [{ date: "2026-05-24", tokens: 14_200 }],
creditsHistory: [],
usageBreakdown: [],
localUsage: {
Expand Down Expand Up @@ -601,6 +603,38 @@ describe("MenuCard", () => {
expect(details.open).toBe(true);
});

it("includes the matching daily token count in the local cost tooltip", async () => {
const snapshot = provider(null);
snapshot.providerId = "codex";
snapshot.displayName = "Codex";
tauriMocks.getProviderChartData.mockResolvedValue({
providerId: "codex",
costHistory: [{ date: "2026-05-24", value: 1.23 }],
creditsHistory: [],
usageBreakdown: [],
localUsage: {
todayCost: null,
thirtyDayCost: 1.23,
thirtyDayTokens: 14_200,
latestTokens: null,
topModel: "gpt-5",
estimateNote: "Estimated from local logs",
tokenCostUpdatedAtMs: 1234,
},
tokensHistory: [{ date: "2026-05-24", tokens: 14_200 }],
tokensIncomplete: false,
});

const { container } = renderCard(snapshot);
const bar = await waitFor(() => {
const element = container.querySelector(".menu-card__local-chart span");
if (!element) throw new Error("local chart bar not rendered yet");
return element;
});

expect(bar).toHaveAttribute("title", "2026-05-24: $1.23 · 14K tokens");
});

it("places Claude accounts above metrics and the collapsed usage details", async () => {
tauriMocks.claudeAccountsList.mockResolvedValue([
{ id: "a", email: "a@example.com", organization: "Personal", isActive: true, isSaved: true },
Expand Down
25 changes: 23 additions & 2 deletions apps/desktop-tauri/src/components/MenuCardDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
CostSummaryDisplayStyle,
DailyCostPoint,
ProviderDisplayDetail,
DailyTokenPoint,
PaceSnapshot,
ProviderInventoryItem,
ProviderChartData,
Expand Down Expand Up @@ -90,12 +91,23 @@ function formatCurrency(amount: number, code: string): string {
}

function formatCompactCount(value: number | null): string {
if (value == null || value <= 0) return "—";
if (value == null || !Number.isFinite(value) || value < 0) return "—";
return (value >= 1_000_000 ? compactCountFormat1 : compactCountFormat0).format(
value,
);
}

function formatLocalUsagePointTitle(
point: DailyCostPoint,
tokenCount: number | undefined,
tokenLabel: string,
): string {
if (point.value == null && tokenCount == null) return point.date;
const cost = point.value == null ? "—" : formatCurrency(point.value, "USD");
const tokens = tokenCount == null ? "—" : `${formatCompactCount(tokenCount)} ${tokenLabel}`;
return `${point.date}: ${cost} · ${tokens}`;
}

function formatBudget(value: number): string {
return value < 10
? value.toFixed(1).replace(/\.0$/, "")
Expand All @@ -106,10 +118,12 @@ function LocalUsageBlock({
providerId,
summary,
costHistory,
tokensHistory,
}: {
providerId: string;
summary: ProviderLocalUsageSummary;
costHistory: DailyCostPoint[];
tokensHistory: DailyTokenPoint[];
}) {
const { t } = useLocale();
const isCodex = providerId === "codex";
Expand All @@ -119,6 +133,7 @@ function LocalUsageBlock({
...visibleHistory.flatMap((point) => (point.value == null ? [] : [point.value])),
0,
);
const tokensByDate = new Map(tokensHistory.map((point) => [point.date, point.tokens]));

return (
<section className="menu-card__group menu-card__local-usage">
Expand Down Expand Up @@ -166,7 +181,11 @@ function LocalUsageBlock({
height: `${point.value == null || maxCost <= 0 ? 1 : Math.max(4, Math.round((point.value / maxCost) * 64))}px`,
opacity: point.value == null ? 0 : undefined,
}}
title={point.value == null ? point.date : `${point.date}: ${formatCurrency(point.value, "USD")}`}
title={formatLocalUsagePointTitle(
point,
tokensByDate.get(point.date),
t("UsageSpendTokens"),
)}
/>
))}
</div>
Expand Down Expand Up @@ -540,6 +559,7 @@ export default function MenuCardDetails({
display.resetTimeRelative,
);
const localCostHistory = chartData?.costHistory ?? [];
const localTokenHistory = chartData?.tokensHistory ?? [];
const costStyle = display.costSummaryDisplayStyle ?? "detailed";

const {
Expand Down Expand Up @@ -698,6 +718,7 @@ export default function MenuCardDetails({
providerId={provider.providerId}
summary={localUsage}
costHistory={localCostHistory}
tokensHistory={localTokenHistory}
/>
)}

Expand Down
114 changes: 114 additions & 0 deletions apps/desktop-tauri/src/components/UsageSpendDailyLedger.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

vi.mock("../hooks/useLocale", () => ({
useLocale: () => ({
t: (key: string) =>
({
UsageSpendDailyLedger: "Daily ledger",
UsageSpendDailyLedgerHelper: "Recorded daily cost and token totals",
UsageSpendNoDailyData: "No daily history yet.",
UsageSpendColDate: "Date",
UsageSpendColCost: "Cost",
UsageSpendColTokens: "Tokens",
UsageSpendUnknown: "Unknown",
UsageSpendTokens: "tokens",
UsageSpendShowAll: "Show all",
UsageSpendShowLess: "Show less",
})[key] ?? key,
}),
}));

import {
DAILY_LEDGER_COLLAPSED_ROWS,
UsageSpendDailyLedger,
dailyLedgerVisibleRows,
} from "./UsageSpendDailyLedger";

/** `days` consecutive UTC days in oldest-first order, like the contract's daily history. */
function dailyHistory(days: number) {
return Array.from({ length: days }, (_, index) => ({
day: new Date(Date.UTC(2026, 0, 1) + index * 86_400_000).toISOString().slice(0, 10),
costUsd: index,
totalTokens: index,
}));
}

describe("dailyLedgerVisibleRows", () => {
// Translated from upstream v0.67.0 SpendDashboardDailyLedgerTests
// `ledger expansion preserves every day in newest first order` (#3998).
it.each([0, 7, 30, 31, 365])(
"ledger expansion preserves every day in newest first order (%i days)",
(days) => {
const daily = dailyHistory(days);
const collapsed = dailyLedgerVisibleRows(daily, false, 30);
expect(collapsed).toHaveLength(Math.min(days, 30));
expect(collapsed).toEqual(daily.slice(-30).reverse());
const expanded = dailyLedgerVisibleRows(daily, true, 30);
expect(expanded).toEqual([...daily].reverse());
},
);

it("collapses to the upstream default of 30 rows", () => {
expect(DAILY_LEDGER_COLLAPSED_ROWS).toBe(30);
expect(dailyLedgerVisibleRows(dailyHistory(45), false)).toHaveLength(30);
});
});

describe("UsageSpendDailyLedger", () => {
it("renders newest daily rows and keeps zero distinct from unknown", () => {
render(
<UsageSpendDailyLedger
daily={[
{ day: "2026-09-11", costUsd: null, totalTokens: 8_100 },
{ day: "2026-09-13", costUsd: 0, totalTokens: 0 },
{ day: "2026-09-12", costUsd: 0.12, totalTokens: null },
]}
/>,
);

const table = screen.getByRole("table", { name: "Daily ledger" });
const rows = within(table).getAllByRole("row");
expect(rows[1]).toHaveTextContent("2026-09-13");
expect(rows[1]).toHaveTextContent("$0.00");
expect(rows[1]).toHaveTextContent("0 tokens");
expect(rows[2]).toHaveTextContent("2026-09-12");
expect(rows[2]).toHaveTextContent("$0.12");
expect(rows[2]).toHaveTextContent("Unknown");
expect(rows[3]).toHaveTextContent("2026-09-11");
expect(rows[3]).toHaveTextContent("Unknown");
expect(rows[3]).toHaveTextContent("8,100 tokens");
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});

it("starts long ledgers with the newest 30 rows and a Show all control", () => {
render(<UsageSpendDailyLedger daily={dailyHistory(31)} />);

const table = screen.getByRole("table", { name: "Daily ledger" });
let bodyRows = within(table).getAllByRole("row").slice(1);
expect(bodyRows).toHaveLength(30);
expect(bodyRows[0]).toHaveTextContent("2026-01-31");
expect(bodyRows[29]).toHaveTextContent("2026-01-02");
expect(table).not.toHaveTextContent("2026-01-01");

const toggle = screen.getByRole("button", { name: "Show all (31)" });
expect(toggle).toHaveAttribute("aria-expanded", "false");
fireEvent.click(toggle);

bodyRows = within(table).getAllByRole("row").slice(1);
expect(bodyRows).toHaveLength(31);
expect(bodyRows[30]).toHaveTextContent("2026-01-01");
const collapse = screen.getByRole("button", { name: "Show less" });
expect(collapse).toHaveAttribute("aria-expanded", "true");
fireEvent.click(collapse);
expect(within(table).getAllByRole("row").slice(1)).toHaveLength(30);
});

it("shows an explicit empty state when no daily points are available", () => {
render(<UsageSpendDailyLedger daily={[]} />);

expect(screen.getByText("No daily history yet.")).toBeInTheDocument();
expect(screen.queryByRole("table")).not.toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
76 changes: 76 additions & 0 deletions apps/desktop-tauri/src/components/UsageSpendDailyLedger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useState } from "react";
import { useLocale } from "../hooks/useLocale";
import type { SpendContract } from "../types/bridge";
import {
formatSpendTokens,
formatUsd,
} from "../lib/usageSpendSharing";

type DailyPoints = SpendContract["daily"];

/** Upstream `SpendDailyLedger.collapsedRowCount`: long ranges start with the newest 30 days. */
export const DAILY_LEDGER_COLLAPSED_ROWS = 30;

/**
* Newest-first ledger rows. Collapsed shows the newest `collapsedRowCount` days; expanded shows the
* complete history (upstream `spendDailyLedgerVisibleSummaries`).
*/
export function dailyLedgerVisibleRows(
daily: DailyPoints,
showsAllRows: boolean,
collapsedRowCount: number = DAILY_LEDGER_COLLAPSED_ROWS,
): DailyPoints {
const newestFirst = [...daily].sort((a, b) => b.day.localeCompare(a.day));
return showsAllRows ? newestFirst : newestFirst.slice(0, collapsedRowCount);
}

export function UsageSpendDailyLedger({
daily,
}: {
daily: DailyPoints;
}) {
const { t } = useLocale();
const [showsAllRows, setShowsAllRows] = useState(false);
const rows = dailyLedgerVisibleRows(daily, showsAllRows);

return (
<section className="settings-section__group usage-spend-daily" aria-labelledby="usage-spend-daily-title">
<h4 id="usage-spend-daily-title">{t("UsageSpendDailyLedger")}</h4>
<p className="settings-section__caption">{t("UsageSpendDailyLedgerHelper")}</p>
{rows.length === 0 ? (
<p className="settings-section__caption">{t("UsageSpendNoDailyData")}</p>
) : (
<div className="usage-spend-daily__table">
<table className="usage-spend-table" aria-label={t("UsageSpendDailyLedger")}>
<thead>
<tr>
<th>{t("UsageSpendColDate")}</th>
<th>{t("UsageSpendColCost")}</th>
<th>{t("UsageSpendColTokens")}</th>
</tr>
</thead>
<tbody>
{rows.map((point) => (
<tr key={point.day}>
<td>{point.day}</td>
<td>{point.costUsd == null ? t("UsageSpendUnknown") : formatUsd(point.costUsd, "USD")}</td>
<td>{formatSpendTokens(point.totalTokens, t("UsageSpendTokens"), t("UsageSpendUnknown"))}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{daily.length > DAILY_LEDGER_COLLAPSED_ROWS && (
<button
type="button"
className="credential-btn credential-btn--secondary usage-spend-daily__toggle"
aria-expanded={showsAllRows}
onClick={() => setShowsAllRows((value) => !value)}
>
{showsAllRows ? t("UsageSpendShowLess") : `${t("UsageSpendShowAll")} (${daily.length})`}
</button>
)}
</section>
);
}
6 changes: 6 additions & 0 deletions apps/desktop-tauri/src/i18n/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,9 @@ export const ALL_LOCALE_KEYS = [
"UsageSpendTokens",
"UsageSpendAllTimeHistory",
"UsageSpendCustomPricing",
"UsageSpendDailyLedger",
"UsageSpendDailyLedgerHelper",
"UsageSpendNoDailyData",
"OverviewSpendTitle",
"OverviewSpendProviderCoverage",
"OverviewSpendEstimate",
Expand All @@ -434,6 +437,9 @@ export const ALL_LOCALE_KEYS = [
"UsageSpendCol30d",
"UsageSpendColCurrency",
"UsageSpendColSource",
"UsageSpendColDate",
"UsageSpendColCost",
"UsageSpendColTokens",
"UsageSpendShareFailed",
"UsageSpendShareEmpty",
"UsageSpendShare",
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop-tauri/src/lib/usageSpendSharing.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";

import {
formatSpendTokens,
formatUsageSpendReportingDay,
filterUsageSpendSummaryForOverview,
renderUsageSpendSharePng,
Expand All @@ -25,6 +26,15 @@ describe("usage spend sharing", () => {
expect(formatUsageSpendReportingDay("2026-10-01", "Pacific/Norfolk")).toBe("Oct 1, 2026");
});

it("keeps a zero token count distinct from an unknown one", () => {
expect(formatSpendTokens(0, "tokens", "Unknown")).toBe("0 tokens");
expect(formatSpendTokens(8100, "tokens", "Unknown")).toBe("8,100 tokens");
expect(formatSpendTokens(null, "tokens", "Unknown")).toBe("Unknown");
expect(formatSpendTokens(undefined, "tokens", "Unknown")).toBe("Unknown");
expect(formatSpendTokens(-1, "tokens", "Unknown")).toBe("Unknown");
expect(formatSpendTokens(Number.NaN, "tokens", "Unknown")).toBe("Unknown");
});

it("falls back for an invalid dashboard timezone or date", () => {
expect(formatUsageSpendReportingDay("2026-10-01", "Not/AZone")).toBe("2026-10-01");
expect(formatUsageSpendReportingDay("2026-02-31", "UTC")).toBe("2026-02-31");
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop-tauri/src/lib/usageSpendSharing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,16 @@ export function formatSpendMetric(
return parts.length > 0 ? parts.join(" · ") : "—";
}

/** Canonical token cell for spend ledgers; a missing or negative count is unknown, zero is known. */
export function formatSpendTokens(
tokens: number | null | undefined,
tokenLabel: string,
unknownLabel: string,
): string {
if (tokens == null || !Number.isFinite(tokens) || tokens < 0) return unknownLabel;
return `${tokens.toLocaleString()} ${tokenLabel}`;
}

/**
* Render the sanitized share-card PNG.
*
Expand Down
Loading