From c25f083a99ae385189ee08a64e10ae94ded05fca Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:30:37 +0700 Subject: [PATCH] Port upstream 0.64.0: group Usage & Spend models by provider, consolidate chart selector, clear selected day --- apps/desktop-tauri/src/i18n/keys.ts | 6 + .../src/lib/usageSpendBreakdown.test.ts | 101 ++++++++++++ .../src/lib/usageSpendBreakdown.ts | 78 +++++++++ .../tabs/UsageSpendBreakdownPanels.test.tsx | 98 +++++++++++ .../tabs/UsageSpendProviderGroups.tsx | 108 ++++++++++++ .../surfaces/settings/tabs/UsageSpendTab.tsx | 104 ++++-------- .../settings/tabs/UsageSpendTrendPanel.tsx | 155 ++++++++++++++++++ rust/src/locale.rs | 6 + rust/src/locale/en-US.ftl | 6 + 9 files changed, 592 insertions(+), 70 deletions(-) create mode 100644 apps/desktop-tauri/src/lib/usageSpendBreakdown.test.ts create mode 100644 apps/desktop-tauri/src/lib/usageSpendBreakdown.ts create mode 100644 apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendBreakdownPanels.test.tsx create mode 100644 apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendProviderGroups.tsx create mode 100644 apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTrendPanel.tsx diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 195620e3bf..299ad11fd2 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -440,6 +440,12 @@ export const ALL_LOCALE_KEYS = [ "UsageSpendShare", "UsageSpendCopyJson", "UsageSpendSaveJson", + "UsageSpendDailySpend", + "UsageSpendTrendDaily", + "UsageSpendTrendHourly", + "UsageSpendClearSelectedDay", + "UsageSpendSelectedDayScope", + "UsageSpendPartialModelBreakdown", "AgentSessionsTitle", "AgentSessionsEnableLabel", "AgentSessionsEnableHelper", diff --git a/apps/desktop-tauri/src/lib/usageSpendBreakdown.test.ts b/apps/desktop-tauri/src/lib/usageSpendBreakdown.test.ts new file mode 100644 index 0000000000..c8ab8ba408 --- /dev/null +++ b/apps/desktop-tauri/src/lib/usageSpendBreakdown.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import { + buildSpendProviderGroups, + compareSpendProviderGroups, + findSpendDay, + formatSpendDay, + type SpendProviderGroup, +} from "./usageSpendBreakdown"; +import type { SpendContract, SpendModelRow } from "../types/bridge"; + +const model = (name: string, costUsd: number | null, totalTokens: number): SpendModelRow => ({ + model: name, + costUsd, + inputTokens: totalTokens, + outputTokens: 0, + cacheReadTokens: 0, + totalTokens, + customPricing: false, +}); + +const contract = (overrides: Partial = {}): SpendContract => + ({ + providerId: "codex", + knownCostUsd: 3.5, + historyCoverageEstablished: true, + priceCoverage: { priced: 2, unpriced: 0, unmetered: 0, estimated: 0 }, + models: [model("gpt-5", 3, 1000), model("gpt-5-mini", 0.5, 500)], + daily: [ + { day: "2026-09-18", costUsd: 1, totalTokens: 100 }, + { day: "2026-09-19", costUsd: null, totalTokens: null }, + ], + ...overrides, + }) as SpendContract; + +const group = (displayName: string, costUsd: number | null, totalTokens: number | null): SpendProviderGroup => ({ + providerId: displayName.toLowerCase(), + displayName, + costUsd, + totalTokens, + costIsPartial: false, + hasPartialModelHistory: false, + models: [], +}); + +describe("usage spend provider breakdown", () => { + it("groups the contract models under their provider with summed tokens", () => { + const [codex] = buildSpendProviderGroups(contract(), "Codex"); + expect(codex).toMatchObject({ + providerId: "codex", + displayName: "Codex", + costUsd: 3.5, + totalTokens: 1500, + costIsPartial: false, + hasPartialModelHistory: false, + }); + expect(codex.models.map((row) => row.model)).toEqual(["gpt-5", "gpt-5-mini"]); + }); + + it("returns no groups without model-level history", () => { + expect(buildSpendProviderGroups(contract({ models: [] }), "Codex")).toEqual([]); + }); + + it("flags unpriced models and incomplete history as partial", () => { + const [codex] = buildSpendProviderGroups( + contract({ + priceCoverage: { priced: 1, unpriced: 1, unmetered: 0, estimated: 0 }, + historyCoverageEstablished: false, + }), + "Codex", + ); + expect(codex.costIsPartial).toBe(true); + expect(codex.hasPartialModelHistory).toBe(true); + }); + + it("orders groups by cost, then tokens, then name with unknown values last", () => { + const ordered = [ + group("Zed", null, null), + group("Beta", 2, 10), + group("Alpha", 2, 10), + group("Gamma", 5, 1), + group("Tokens", null, 99), + ] + .sort(compareSpendProviderGroups) + .map((entry) => entry.displayName); + expect(ordered).toEqual(["Gamma", "Alpha", "Beta", "Tokens", "Zed"]); + }); + + it("finds a selected day only while it is inside the current range", () => { + const { daily } = contract(); + expect(findSpendDay(daily, "2026-09-18")?.costUsd).toBe(1); + expect(findSpendDay(daily, "2026-01-01")).toBeNull(); + expect(findSpendDay(daily, null)).toBeNull(); + }); + + it("formats dashboard days and falls back for invalid dates", () => { + expect(formatSpendDay("2026-09-19")).toContain("2026"); + expect(formatSpendDay("2026-02-31")).toBe("2026-02-31"); + expect(formatSpendDay("not-a-day")).toBe("not-a-day"); + }); +}); diff --git a/apps/desktop-tauri/src/lib/usageSpendBreakdown.ts b/apps/desktop-tauri/src/lib/usageSpendBreakdown.ts new file mode 100644 index 0000000000..227ca228a9 --- /dev/null +++ b/apps/desktop-tauri/src/lib/usageSpendBreakdown.ts @@ -0,0 +1,78 @@ +import type { SpendContract, SpendDailyPoint, SpendModelRow } from "../types/bridge"; + +/** Upstream 0.64.0 (#3353) `spendProviderModelDisplayLimit`. */ +export const PROVIDER_MODEL_DISPLAY_LIMIT = 6; + +/** One provider group in the Usage & Spend breakdown: its spend plus its models. */ +export interface SpendProviderGroup { + providerId: string; + displayName: string; + costUsd: number | null; + totalTokens: number | null; + /** True when the cost is a lower bound (unpriced models) and renders with "~". */ + costIsPartial: boolean; + /** True when model history is incomplete, so the model list is a partial view. */ + hasPartialModelHistory: boolean; + models: SpendModelRow[]; +} + +/** + * Upstream `spendDashboardProviderBreakdowns` ordering: highest known cost + * first, then highest tokens, unknown values last, then display name. + */ +export function compareSpendProviderGroups(a: SpendProviderGroup, b: SpendProviderGroup): number { + const byValue = (left: number | null, right: number | null): number => { + if (left != null && right != null) return left === right ? 0 : right - left; + if (left != null) return -1; + if (right != null) return 1; + return 0; + }; + return ( + byValue(a.costUsd, b.costUsd) || + byValue(a.totalTokens, b.totalTokens) || + a.displayName.localeCompare(b.displayName, undefined, { numeric: true, sensitivity: "base" }) + ); +} + +/** + * Group the contract's model rows by the provider that produced them. + * + * The desktop shell builds model-level history for one provider contract + * (`contract.providerId`), so that is the only group with models; providers + * without model-level history stay in the provider table. + */ +export function buildSpendProviderGroups( + contract: SpendContract, + providerName: string, +): SpendProviderGroup[] { + if (contract.models.length === 0) return []; + const totalTokens = contract.models.reduce((sum, model) => sum + model.totalTokens, 0); + const group: SpendProviderGroup = { + providerId: contract.providerId, + displayName: providerName, + costUsd: contract.knownCostUsd, + totalTokens: Number.isSafeInteger(totalTokens) ? totalTokens : null, + costIsPartial: contract.priceCoverage.unpriced > 0, + hasPartialModelHistory: !contract.historyCoverageEstablished, + models: contract.models, + }; + return [group].sort(compareSpendProviderGroups); +} + +/** The daily point for `day`, or null when that day is not in the current range. */ +export function findSpendDay(daily: SpendDailyPoint[], day: string | null): SpendDailyPoint | null { + if (!day) return null; + return daily.find((point) => point.day === day) ?? null; +} + +/** Medium local date for a `YYYY-MM-DD` dashboard day; the raw value when it does not parse. */ +export function formatSpendDay(day: string): string { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day); + if (!match) return day; + const [year, month, date] = [Number(match[1]), Number(match[2]), Number(match[3])]; + const parsed = new Date(year, month - 1, date); + if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || parsed.getDate() !== date) { + return day; + } + return parsed.toLocaleDateString(undefined, { dateStyle: "medium" }); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendBreakdownPanels.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendBreakdownPanels.test.tsx new file mode 100644 index 0000000000..faa04f15bf --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendBreakdownPanels.test.tsx @@ -0,0 +1,98 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { describe, expect, it } from "vitest"; + +import type { SpendContract, SpendModelRow } from "../../../types/bridge"; +import type { LocaleKey } from "../../../i18n/keys"; +import { buildSpendProviderGroups } from "../../../lib/usageSpendBreakdown"; +import { UsageSpendProviderGroups } from "./UsageSpendProviderGroups"; +import { UsageSpendTrendPanel } from "./UsageSpendTrendPanel"; + +const t = (key: LocaleKey) => key; + +const models: SpendModelRow[] = Array.from({ length: 8 }, (_, index) => ({ + model: `model-${index + 1}`, + costUsd: 8 - index, + inputTokens: 10, + outputTokens: 0, + cacheReadTokens: 0, + totalTokens: 10, + customPricing: false, +})); + +const contract = { + providerId: "codex", + historyDays: 30, + knownCostUsd: 36, + historyCoverageEstablished: false, + priceCoverage: { priced: 8, unpriced: 0, unmetered: 0, estimated: 0 }, + models, + daily: [ + { day: "2026-09-18", costUsd: 4, totalTokens: 40 }, + { day: "2026-09-19", costUsd: 2, totalTokens: 20 }, + ], + hourlyActivity: [{ weekday: 0, hour: 9, conversations: 3 }], +} as unknown as SpendContract; + +describe("UsageSpendProviderGroups", () => { + const groups = buildSpendProviderGroups(contract, "Codex"); + + it("shows six models per provider, expands, and collapses the group", () => { + render(); + expect(screen.getByText("Codex")).toBeTruthy(); + expect(screen.getByText("UsageSpendPartialModelBreakdown")).toBeTruthy(); + expect(screen.queryByText("model-7")).toBeNull(); + expect(screen.getByText("model-6")).toBeTruthy(); + + fireEvent.click(screen.getByText(/UsageSpendShowAll/)); + expect(screen.getByText("model-8")).toBeTruthy(); + fireEvent.click(screen.getByText("UsageSpendShowLess")); + expect(screen.queryByText("model-8")).toBeNull(); + + const header = screen.getByRole("button", { expanded: true }); + fireEvent.click(header); + expect(screen.queryByText("model-1")).toBeNull(); + expect(header.getAttribute("aria-expanded")).toBe("false"); + }); + + it("shows the empty state without groups", () => { + render(); + expect(screen.getByText("UsageSpendNoModels")).toBeTruthy(); + }); +}); + +describe("UsageSpendTrendPanel", () => { + function Harness() { + const [day, setDay] = useState(null); + return ; + } + + it("selects a day from the daily chart and clears it with one control", () => { + render(); + expect(screen.queryByTestId("usage-spend-selected-day")).toBeNull(); + const bars = screen.getAllByRole("button", { pressed: false }).filter((node) => node.getAttribute("title")); + expect(bars).toHaveLength(2); + + fireEvent.click(bars[0]); + expect(screen.getByTestId("usage-spend-selected-day")).toBeTruthy(); + expect(screen.getByText("UsageSpendSelectedDayScope")).toBeTruthy(); + + fireEvent.click(screen.getByLabelText("UsageSpendClearSelectedDay")); + expect(screen.queryByTestId("usage-spend-selected-day")).toBeNull(); + }); + + it("switches between the daily and hourly views with one selector", () => { + render(); + expect(screen.getByText("UsageSpendDailySpend")).toBeTruthy(); + fireEvent.click(screen.getByText("UsageSpendTrendHourly")); + expect(screen.getByLabelText("UsageSpendHourlyActivity")).toBeTruthy(); + expect(screen.queryByText("UsageSpendDailySpend")).toBeNull(); + }); + + it("drops the selector when only one view has data", () => { + const hourlyOnly = { ...contract, daily: [] } as unknown as SpendContract; + render( {}} t={t} />); + expect(screen.queryByText("UsageSpendTrendDaily")).toBeNull(); + expect(screen.getByLabelText("UsageSpendHourlyActivity")).toBeTruthy(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendProviderGroups.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendProviderGroups.tsx new file mode 100644 index 0000000000..94e7a42e40 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendProviderGroups.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import { formatUsd } from "../../../lib/usageSpendSharing"; +import { + PROVIDER_MODEL_DISPLAY_LIMIT, + type SpendProviderGroup, +} from "../../../lib/usageSpendBreakdown"; +import type { LocaleKey } from "../../../i18n/keys"; + +interface Props { + groups: SpendProviderGroup[]; + historyDays: number; + t: (key: LocaleKey) => string; +} + +/** + * Upstream 0.64.0 (#3353): models grouped by provider, one collapsible group + * per provider with its spend and its models. + */ +export function UsageSpendProviderGroups({ groups, historyDays, t }: Props) { + const [collapsed, setCollapsed] = useState>(() => new Set()); + const [expandedModels, setExpandedModels] = useState>(() => new Set()); + const toggle = (set: Set, id: string) => { + const next = new Set(set); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }; + + return ( +
+

{t("UsageSpendModels")}

+

{t("UsageSpendAllTimeHistory")} {historyDays} days of local history.

+ {groups.length === 0 ? ( +

{t("UsageSpendNoModels")}

+ ) : ( +
+ {groups.map((group) => { + const isCollapsed = collapsed.has(group.providerId); + const showAll = expandedModels.has(group.providerId); + const models = showAll ? group.models : group.models.slice(0, PROVIDER_MODEL_DISPLAY_LIMIT); + return ( +
+ + {!isCollapsed && ( +
+ {group.hasPartialModelHistory && ( + {t("UsageSpendPartialModelBreakdown")} + )} + {models.map((model) => ( +
+ + {model.model} + + {model.totalTokens.toLocaleString()} {t("UsageSpendTokens")} + {model.customPricing ? " · " + t("UsageSpendCustomPricing") : ""} + + + {model.costUsd == null ? t("UsageSpendUnpriced") : formatUsd(model.costUsd, "USD")} +
+ ))} + {group.models.length > PROVIDER_MODEL_DISPLAY_LIMIT && ( + + )} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} + +function groupMetric(group: SpendProviderGroup, tokenLabel: string): string { + const parts: string[] = []; + if (group.costUsd != null) parts.push(`${group.costIsPartial ? "~" : ""}${formatUsd(group.costUsd, "USD")}`); + if (group.totalTokens != null) parts.push(`${group.totalTokens.toLocaleString()} ${tokenLabel}`); + return parts.length > 0 ? parts.join(" · ") : "—"; +} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx index 0f7cc7ee89..94baf863ed 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx @@ -13,9 +13,12 @@ import { formatUsd, shareUsageSpendPng, } from "../../../lib/usageSpendSharing"; +import { buildSpendProviderGroups, findSpendDay, formatSpendDay } from "../../../lib/usageSpendBreakdown"; import type { CostSummaryDisplayStyle, SettingsSnapshot, SpendContract, UsageSpendSummary } from "../../../types/bridge"; import type { LocaleKey } from "../../../i18n/keys"; import type { TabProps } from "../settingsTabs"; +import { UsageSpendProviderGroups } from "./UsageSpendProviderGroups"; +import { UsageSpendTrendPanel } from "./UsageSpendTrendPanel"; export default function UsageSpendTab(_props: TabProps) { const { t } = useLocale(); @@ -26,7 +29,7 @@ export default function UsageSpendTab(_props: TabProps) { const [shareError, setShareError] = useState(null); const [includeOpenCodex, setIncludeOpenCodex] = useState(false); const [hideNativeCodex, setHideNativeCodex] = useState(false); - const [showAllModels, setShowAllModels] = useState(false); + const [selectedDay, setSelectedDay] = useState(null); const [showAllProjects, setShowAllProjects] = useState(false); const [expandedProjects, setExpandedProjects] = useState>(() => new Set()); const tableRef = useRef(null); @@ -61,6 +64,11 @@ export default function UsageSpendTab(_props: TabProps) { load(false); }, [load]); + // A selected day belongs to one history range; changing the range clears it. + useEffect(() => { + setSelectedDay(null); + }, [selectedDays]); + // Upstream 0.55.0 #3106 parity: provider token/cost publications refresh // Usage & Spend silently while the pane is open. Coalesce bursts so a // multi-provider refresh does not trigger one dashboard rebuild per event. @@ -256,10 +264,22 @@ export default function UsageSpendTab(_props: TabProps) { )} - {!error && summary && } + {!error && summary && } {!error && summary && ( - setShowAllModels((value) => !value)} t={t} /> + + )} + + {!error && summary && ( + row.providerId === summary.contract.providerId)?.displayName + ?? summary.contract.providerId, + )} + historyDays={summary.contract.historyDays} + t={t} + /> )} {!error && summary && ( @@ -285,7 +305,8 @@ export default function UsageSpendTab(_props: TabProps) { -function SpendContractOverview({ contract, t }: { contract: SpendContract; t: (key: LocaleKey) => string }) { +function SpendContractOverview({ contract, selectedDay, t }: { contract: SpendContract; selectedDay: string | null; t: (key: LocaleKey) => string }) { + const day = findSpendDay(contract.daily, selectedDay); const coverage = contract.priceCoverageRatio == null ? t("UsageSpendUnknown") : `${Math.round(contract.priceCoverageRatio * 100)}%`; @@ -308,12 +329,19 @@ function SpendContractOverview({ contract, t }: { contract: SpendContract; t: (k return (
- + {day ? ( + + ) : ( + + )}
- {contract.imports.map((source) => (

{source.displayName}: {source.requestCount.toLocaleString()} {t("UsageSpendRequests")} · {source.conversationCount.toLocaleString()} {t("UsageSpendConversations").toLowerCase()} @@ -333,70 +361,6 @@ function MetricCard({ label, value, detail }: { label: string; value: string; de ); } -function ActivityHeatmap({ cells, t }: { cells: SpendContract["hourlyActivity"]; t: (key: LocaleKey) => string }) { - const lookup = new Map(cells.map((cell) => [`${cell.weekday}:${cell.hour}`, cell.conversations])); - const max = Math.max(1, ...cells.map((cell) => cell.conversations)); - return ( -

-

{t("UsageSpendHourlyActivity")}

-
- {Array.from({ length: 7 * 24 }, (_, index) => { - const weekday = Math.floor(index / 24); - const hour = index % 24; - const value = lookup.get(`${weekday}:${hour}`) ?? 0; - const alpha = value === 0 ? 0.08 : 0.18 + (value / max) * 0.72; - return ( - - ); - })} -
-
- ); -} - -function ContractModelsPanel({ contract, showAll, onToggleAll, t }: { contract: SpendContract; showAll: boolean; onToggleAll: () => void; t: (key: LocaleKey) => string }) { - const visible = showAll ? contract.models : contract.models.slice(0, 8); - return ( -
-
-
-

{t("UsageSpendModels")}

-

{t("UsageSpendAllTimeHistory")} {contract.historyDays} days of local history.

-
- {contract.models.length > 8 && ( - - )} -
- {visible.length === 0 ? ( -

{t("UsageSpendNoModels")}

- ) : ( -
- {visible.map((model) => ( -
- - {model.model} - - {model.totalTokens.toLocaleString()} {t("UsageSpendTokens")}{model.customPricing ? " · " + t("UsageSpendCustomPricing") : ""} - - - {model.costUsd == null ? t("UsageSpendUnpriced") : formatUsd(model.costUsd, "USD")} -
- ))} -
- )} -
- ); -} - function ProjectsPanel({ contract, showAll, diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTrendPanel.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTrendPanel.tsx new file mode 100644 index 0000000000..1ece78aac4 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTrendPanel.tsx @@ -0,0 +1,155 @@ +import { useState } from "react"; +import { formatSpendMetric } from "../../../lib/usageSpendSharing"; +import { findSpendDay, formatSpendDay } from "../../../lib/usageSpendBreakdown"; +import type { SpendContract } from "../../../types/bridge"; +import type { LocaleKey } from "../../../i18n/keys"; + +type TrendSection = "daily" | "hourly"; + +interface Props { + contract: SpendContract; + selectedDay: string | null; + onSelectDay: (day: string | null) => void; + t: (key: LocaleKey) => string; +} + +/** + * Upstream 0.64.0 (#3353): one panel with a compact Day/Hour selector in place + * of separate chart sections, plus a control that clears the selected day. + */ +export function UsageSpendTrendPanel({ contract, selectedDay, onSelectDay, t }: Props) { + const [section, setSection] = useState("daily"); + const hasDaily = contract.daily.length > 0; + const hasHourly = contract.hourlyActivity.length > 0; + const sections: TrendSection[] = [...(hasDaily ? (["daily"] as const) : []), ...(hasHourly ? (["hourly"] as const) : [])]; + if (sections.length === 0) return null; + const active = sections.includes(section) ? section : sections[0]; + const day = findSpendDay(contract.daily, selectedDay); + + return ( +
+
+

+ {active === "daily" ? t("UsageSpendDailySpend") : t("UsageSpendHourlyActivity")} +

+
+ {day && ( + + {formatSpendDay(day.day)} + + + )} + {sections.length > 1 && ( +
+ {sections.map((value) => ( + + ))} +
+ )} +
+
+ {active === "daily" ? ( + + ) : ( + + )} +
+ ); +} + +function DailySpendBars({ + contract, + selectedDay, + onSelectDay, + t, +}: { + contract: SpendContract; + selectedDay: string | null; + onSelectDay: (day: string | null) => void; + t: (key: LocaleKey) => string; +}) { + const max = Math.max(0, ...contract.daily.map((point) => point.costUsd ?? 0)); + return ( +
+
+ {contract.daily.map((point) => { + const selected = point.day === selectedDay; + const height = point.costUsd == null || max === 0 ? 2 : Math.max(2, (point.costUsd / max) * 72); + return ( +
+ {selectedDay && ( +

+ {t("UsageSpendSelectedDayScope")} +

+ )} +
+ ); +} + +export function ActivityHeatmap({ cells, t }: { cells: SpendContract["hourlyActivity"]; t: (key: LocaleKey) => string }) { + const lookup = new Map(cells.map((cell) => [`${cell.weekday}:${cell.hour}`, cell.conversations])); + const max = Math.max(1, ...cells.map((cell) => cell.conversations)); + return ( +
+
+ {Array.from({ length: 7 * 24 }, (_, index) => { + const weekday = Math.floor(index / 24); + const hour = index % 24; + const value = lookup.get(`${weekday}:${hour}`) ?? 0; + const alpha = value === 0 ? 0.08 : 0.18 + (value / max) * 0.72; + return ( + + ); + })} +
+
+ ); +} diff --git a/rust/src/locale.rs b/rust/src/locale.rs index e6ec918ac4..eac85878e0 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -682,6 +682,12 @@ locale_keys! { UsageSpendShare, UsageSpendCopyJson, UsageSpendSaveJson, + UsageSpendDailySpend, + UsageSpendTrendDaily, + UsageSpendTrendHourly, + UsageSpendClearSelectedDay, + UsageSpendSelectedDayScope, + UsageSpendPartialModelBreakdown, UsageSpendShareEmpty, UsageSpendShareFailed, AgentSessionsTitle, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index fb96ecd4b8..0e70c49c13 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -803,6 +803,12 @@ PromoteTrayIconUnsupportedHint = Not available on this operating system. Drag th UsageSpendShare = Export share card (PNG) UsageSpendCopyJson = Copy JSON UsageSpendSaveJson = Save JSON… +UsageSpendDailySpend = Daily spend +UsageSpendTrendDaily = Day +UsageSpendTrendHourly = Hour +UsageSpendClearSelectedDay = Clear selected day +UsageSpendSelectedDayScope = The selected day applies to the Spend card. Models, projects and hourly activity still cover the full range. +UsageSpendPartialModelBreakdown = Partial model breakdown UsageSpendShareEmpty = Nothing to export yet. UsageSpendShareFailed = Could not create the share card image. NetworkProxyTitle = Network proxy