Skip to content
Merged
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
32 changes: 20 additions & 12 deletions apps/desktop-tauri/src/components/MenuCard.chartRefresh.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,27 @@ import { buildBundle } from "../test/localeHarness";
import type { ProviderUsageSnapshot } from "../types/bridge";
import MenuCard from "./MenuCard";

const window0 = makeRateWindow(10, { reserveWillLastToReset: false, reserveEtaSeconds: null });
function rateWindow(used: number) {
return makeRateWindow(used, { reserveWillLastToReset: false, reserveEtaSeconds: null });
}

function snapshot(updatedAt: string): ProviderUsageSnapshot {
function snapshot(updatedAt: string, used: number): ProviderUsageSnapshot {
return makeUsageSnapshot("claude", {
displayName: "Claude",
primary: window0,
selectedMetric: window0,
primary: rateWindow(used),
selectedMetric: rateWindow(used),
sourceLabel: "oauth",
updatedAt,
errorState: "unknown",
fetchDurationMs: null,
});
}

function card(updatedAt: string) {
function card(updatedAt: string, used = 10) {
return (
<LocaleProvider>
<MenuCard
provider={snapshot(updatedAt)}
provider={snapshot(updatedAt, used)}
display={{ hideEmail: false, resetTimeRelative: true }}
/>
</LocaleProvider>
Expand All @@ -63,23 +65,29 @@ describe("MenuCard chart refresh", () => {
});
});

it("re-reads chart data only when the local day changes across an update", async () => {
it("re-reads chart data after same-day usage updates and across midnight", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
try {
vi.setSystemTime(new Date(2026, 9, 7, 23, 0, 0));
const { rerender } = render(card("2026-10-07T23:00:00Z"));
await waitFor(() => expect(tauriMocks.getProviderChartData).toHaveBeenCalledTimes(1));

// Plain rerender and a same-day provider update do not refetch.
// A plain rerender must not start a second scan.
rerender(card("2026-10-07T23:00:00Z"));
vi.setSystemTime(new Date(2026, 9, 7, 23, 30, 0));
rerender(card("2026-10-07T23:30:00Z"));
expect(tauriMocks.getProviderChartData).toHaveBeenCalledTimes(1);
// A refresh that only moves the timestamp must not rescan either.
vi.setSystemTime(new Date(2026, 9, 7, 23, 15, 0));
rerender(card("2026-10-07T23:15:00Z"));
expect(tauriMocks.getProviderChartData).toHaveBeenCalledTimes(1);
// Published usage changes must reach the chart on the same day too.
vi.setSystemTime(new Date(2026, 9, 7, 23, 30, 0));
rerender(card("2026-10-07T23:30:00Z", 12));
await waitFor(() => expect(tauriMocks.getProviderChartData).toHaveBeenCalledTimes(2));

// The next update lands after local midnight; Today must be re-read.
vi.setSystemTime(new Date(2026, 9, 8, 0, 5, 0));
rerender(card("2026-10-08T00:05:00Z"));
await waitFor(() => expect(tauriMocks.getProviderChartData).toHaveBeenCalledTimes(2));
rerender(card("2026-10-08T00:05:00Z", 12));
await waitFor(() => expect(tauriMocks.getProviderChartData).toHaveBeenCalledTimes(3));
} finally {
vi.useRealTimers();
}
Expand Down
13 changes: 9 additions & 4 deletions apps/desktop-tauri/src/components/MenuCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,15 @@ export default function MenuCard({
setChartData(null);
}, [provider.providerId, provider.accountEmail]);

// Re-read the local-usage chart when the local calendar day changes (seen on
// the next render, e.g. a provider update), so "Today" does not freeze at the
// first mount without a full transcript walk on every refresh.
// Re-read the local-usage chart after a provider publishes changed usage and
// when a render crosses the local calendar day, so Today keeps advancing.
// `updatedAt` changes on every refresh, and each read can walk the local
// transcripts, so key on the usage values instead.
const localDay = new Date().toLocaleDateString("sv");
const usageRevision = [provider.primary, provider.secondary, provider.tertiary]
.map((window) => window?.usedPercent ?? "")
.concat(provider.cost?.used ?? "")
.join("|");
useEffect(() => {
if (!providerSupportsChartData(provider.providerId) || accountScoped) return;
let cancelled = false;
Expand All @@ -207,7 +212,7 @@ export default function MenuCard({
if (frame !== undefined) cancelAnimationFrame(frame);
};
// "Track costs" flips local estimates on or off; the backend answers for the new value.
}, [provider.providerId, provider.accountEmail, localDay, costTrackingEnabled, accountScoped, onLayoutChange]);
}, [provider.providerId, provider.accountEmail, usageRevision, localDay, costTrackingEnabled, accountScoped, onLayoutChange]);

// The Conversations list reads the same backend-cached summary as Usage & Spend,
// which masks titles itself; a summary fetched under the other Hide Personal
Expand Down
28 changes: 25 additions & 3 deletions apps/desktop-tauri/src/components/card/InlineDashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,17 @@ vi.mock("../../hooks/useLocale", () => ({

import InlineDashboard, { barHeight, barOpacity } from "./InlineDashboard";
import type { InlineDashboardModel } from "../../lib/inlineDashboard";
import { inlineDashboardModel } from "../../lib/inlineDashboard";

const model: InlineDashboardModel = {
kpis: [
{ title: "Today", value: "$0.80", emphasis: true },
{ title: "30d cost", value: "$25.42", emphasis: false },
],
points: [
{ date: "2026-10-09", value: 2, title: "2026-10-09: $2.00 · 10K tokens" },
{ date: "2026-10-10", value: null, title: "2026-10-10" },
{ date: "2026-10-11", value: 8, title: "2026-10-11: $8.00 · 40K tokens" },
{ date: "2026-10-09", value: 2, hasUnpricedUsage: false, title: "2026-10-09: $2.00 · 10K tokens" },
{ date: "2026-10-10", value: null, hasUnpricedUsage: false, title: "2026-10-10" },
{ date: "2026-10-11", value: 8, hasUnpricedUsage: false, title: "2026-10-11: $8.00 · 40K tokens" },
],
maxValue: 8,
scaleLabel: "$8",
Expand Down Expand Up @@ -58,6 +59,27 @@ describe("bar geometry", () => {
});

describe("InlineDashboard", () => {
it("keeps token usage visible when its day has no known price", () => {
const unpriced = inlineDashboardModel({
providerId: "codex",
summary: {
todayCost: null, thirtyDayCost: null, thirtyDayTokens: 1000,
periodCost: null, periodTokens: 1000, reportingPeriod: "rolling:30",
latestTokens: 1000, topModel: null, estimateNote: "", tokenCostUpdatedAtMs: 0,
},
costHistory: [{ date: "2026-10-21", value: null }],
tokensHistory: [{ date: "2026-10-21", tokens: 1000 }],
quotaHistory: null,
t: (key) => key === "UsageSpendUnpriced" ? "Unpriced" : key,
})!;
const { container } = render(<InlineDashboard model={unpriced} />);
const bar = container.querySelector<HTMLElement>(".card-dash__bar")!;
expect(Number(bar.style.opacity)).toBeGreaterThan(0);
expect(parseFloat(bar.style.height)).toBeGreaterThanOrEqual(3);
expect(bar.title).toContain("Unpriced");
expect(bar.title).not.toContain("$0");
});

it("renders the KPIs, the chart, the recent windows and the detail lines", () => {
const { container } = render(<InlineDashboard model={model} />);
expect(screen.getByText("Today")).toHaveClass("card-dash__kpi-title");
Expand Down
9 changes: 6 additions & 3 deletions apps/desktop-tauri/src/components/card/InlineDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,15 @@ function UsageBars({
<span
key={`${point.date}-${index}`}
className={`card-dash__bar${hovered === index ? " card-dash__bar--selected" : ""}`}
data-empty={point.value == null ? "true" : undefined}
data-empty={point.value == null && !point.hasUnpricedUsage ? "true" : undefined}
data-unpriced={point.hasUnpricedUsage ? "true" : undefined}
title={point.title}
onMouseEnter={() => setHovered(index)}
style={{
height: `${barHeight(point.value, maxValue)}px`,
opacity: barOpacity(point.value, maxValue),
height: `${point.hasUnpricedUsage ? 3 : barHeight(point.value, maxValue)}px`,
opacity: point.hasUnpricedUsage ? 0.55 : barOpacity(point.value, maxValue),
outline: point.hasUnpricedUsage ? "1px dashed currentColor" : undefined,
background: point.hasUnpricedUsage ? "transparent" : undefined,
}}
/>
))}
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop-tauri/src/lib/inlineDashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface DashboardKpi {
export interface DashboardPoint {
date: string;
value: number | null;
hasUnpricedUsage: boolean;
title: string;
}

Expand Down Expand Up @@ -180,13 +181,16 @@ function chartPoints(input: InlineDashboardInput): DashboardPoint[] {
const tokensByDate = new Map(input.tokensHistory.map((point) => [point.date, point.tokens]));
return input.costHistory.slice(-CHART_DAYS).map((point) => {
const tokens = tokensByDate.get(point.date);
const hasUnpricedUsage = point.value == null && tokens != null && tokens > 0;
const cost = hasUnpricedUsage ? input.t("UsageSpendUnpriced")
: point.value == null ? "—" : formatCostAmount(point.value, "USD");
const title =
point.value == null && tokens == null
? point.date
: `${point.date}: ${point.value == null ? "—" : formatCostAmount(point.value, "USD")} · ${
: `${point.date}: ${cost} · ${
tokens == null ? "—" : `${compactCount(tokens)} ${input.t("UsageSpendTokens")}`
}`;
return { date: point.date, value: point.value, title };
return { date: point.date, value: point.value, hasUnpricedUsage, title };
});
}

Expand Down