diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 817e6d7f9543..6086d4c39f72 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -9,6 +9,7 @@ import { formatPercent, formatTokens, formatUsd, + makeMonthToDateWindow, makeWindow, } from "@t3tools/shared/usageFormat"; import { useMemo, useState } from "react"; @@ -25,24 +26,32 @@ import type { UsageChartMetric } from "./usageChartData"; import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; const WINDOW_OPTIONS = [ - { days: 1, label: "Past 24h" }, - { days: 7, label: "7 days" }, - { days: 30, label: "30 days" }, - { days: 90, label: "90 days" }, + { value: 1, label: "Past 24h" }, + { value: 7, label: "7 days" }, + { value: "mtd", label: "MTD" }, + { value: 30, label: "30 days" }, + { value: 90, label: "90 days" }, ] as const; +type WindowPeriod = (typeof WINDOW_OPTIONS)[number]["value"]; + +function makeWindowForPeriod(period: WindowPeriod) { + if (period === "mtd") return makeMonthToDateWindow(); + return makeWindow(period, undefined, period === 1 ? "hour" : "day"); +} + const CHART_HEIGHT = 180; export function UsageRouteScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); const [windowSelection, setWindowSelection] = useState(() => ({ - days: 30, + period: 30 as WindowPeriod, window: makeWindow(30), })); const [metric, setMetric] = useState("cost"); - const { days: windowDays, window } = windowSelection; - const isPast24Hours = windowDays === 1; + const { period: windowPeriod, window } = windowSelection; + const isPast24Hours = windowPeriod === 1; const { merged, environments, isPending, isPartial, refresh } = useUsage(window); const days = useMemo( @@ -73,14 +82,14 @@ export function UsageRouteScreen() { // before. The initial scan renders its own placeholder, and an unreachable // environment stays pending forever — neither may pin the spinner on. const refreshing = environments.some((entry) => entry.isPending && entry.summary !== null); - const selectWindow = (days: number) => { + const selectWindow = (period: WindowPeriod) => { setWindowSelection({ - days, - window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), + period, + window: makeWindowForPeriod(period), }); }; const refreshWindow = () => { - const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); + const nextWindow = makeWindowForPeriod(windowPeriod); if ( nextWindow.sinceDay === window.sinceDay && nextWindow.untilDay === window.untilDay && @@ -89,7 +98,7 @@ export function UsageRouteScreen() { ) { refresh(); } else { - setWindowSelection({ days: windowDays, window: nextWindow }); + setWindowSelection({ period: windowPeriod, window: nextWindow }); } }; @@ -110,8 +119,8 @@ export function UsageRouteScreen() { refreshControl={} > ({ value: option.days, label: option.label }))} - selected={windowDays} + options={WINDOW_OPTIONS} + selected={windowPeriod} onSelect={selectWindow} /> diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 8e86b521e890..92d353642bbb 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -16,7 +16,7 @@ vi.mock("react", async (importOriginal) => { useState: vi.fn((initial: unknown) => [ typeof initial === "function" ? { - days: 1, + period: 1, window: { sinceDay: "2026-08-10", untilDay: "2026-08-11", diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 7474bb9d6120..28ad74abcfc3 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -17,6 +17,7 @@ import { formatPercent, formatTokens, formatUsd, + makeMonthToDateWindow, makeWindow, } from "@t3tools/shared/usageFormat"; import { Button } from "../ui/button"; @@ -35,21 +36,29 @@ import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart" import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; const WINDOW_OPTIONS = [ - { days: 1, label: "Past 24h" }, - { days: 7, label: "7 days" }, - { days: 30, label: "30 days" }, - { days: 90, label: "90 days" }, + { value: 1, label: "Past 24h" }, + { value: 7, label: "7 days" }, + { value: "mtd", label: "MTD" }, + { value: 30, label: "30 days" }, + { value: 90, label: "90 days" }, ] as const; +type WindowPeriod = (typeof WINDOW_OPTIONS)[number]["value"]; + +function makeWindowForPeriod(period: WindowPeriod) { + if (period === "mtd") return makeMonthToDateWindow(); + return makeWindow(period, undefined, period === 1 ? "hour" : "day"); +} + export function UsagePage() { const [windowSelection, setWindowSelection] = useState(() => ({ - days: 30, + period: 30 as WindowPeriod, window: makeWindow(30), })); const [metric, setMetric] = useState("cost"); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); - const { days: windowDays, window } = windowSelection; - const isPast24Hours = windowDays === 1; + const { period: windowPeriod, window } = windowSelection; + const isPast24Hours = windowPeriod === 1; const { merged, environments, isPending, isPartial, refresh } = useUsage(window); // Hold the content until every environment is terminal. Rendering merged @@ -86,14 +95,14 @@ export function UsagePage() { const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]); const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; - const selectWindow = (days: number) => { + const selectWindow = (period: WindowPeriod) => { setWindowSelection({ - days, - window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), + period, + window: makeWindowForPeriod(period), }); }; const refreshWindow = () => { - const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); + const nextWindow = makeWindowForPeriod(windowPeriod); if ( nextWindow.sinceDay === window.sinceDay && nextWindow.untilDay === window.untilDay && @@ -102,7 +111,7 @@ export function UsagePage() { ) { refresh(); } else { - setWindowSelection({ days: windowDays, window: nextWindow }); + setWindowSelection({ period: windowPeriod, window: nextWindow }); } }; const windowLabel = @@ -139,14 +148,15 @@ export function UsagePage() { { const value = next[0]; - if (value) selectWindow(Number(value)); + const option = WINDOW_OPTIONS.find((candidate) => String(candidate.value) === value); + if (option) selectWindow(option.value); }} > {WINDOW_OPTIONS.map((option) => ( - + {option.label} ))} @@ -175,7 +185,13 @@ export function UsagePage() { Tokens - { + const option = WINDOW_OPTIONS.find((candidate) => String(candidate.value) === value); + if (option) selectWindow(option.value); + }} + > - {WINDOW_OPTIONS.find((option) => option.days === windowDays)?.label} + {WINDOW_OPTIONS.find((option) => option.value === windowPeriod)?.label} {WINDOW_OPTIONS.map((option) => ( - + {option.label} ))} diff --git a/docs/user/usage.md b/docs/user/usage.md index ff38c730c1cd..945d3ba591a1 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -9,5 +9,6 @@ Grok Build totals come from persisted session updates. Interactive turns that ne completed-turn record will not appear. Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, -**30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the -headline and chart, and refreshing rescans every connected environment. +**30 days**, and **90 days** ranges use daily resolution. **MTD** covers the first day of the current +month through today in your local time zone. Cost and token toggles update both the headline and +chart, and refreshing rescans every connected environment. diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index fb231fbacb20..4c1183c83e32 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -6,6 +6,7 @@ import { formatDateTimeShort, formatHourShort, formatRelativeHourShort, + makeMonthToDateWindow, makeWindow, } from "./usageFormat.ts"; @@ -55,6 +56,26 @@ describe("hourly usage formatting", () => { expect(window.untilTime).toBe("2026-08-11T12:37:00.000Z"); }); + it("builds a month-to-date request in the viewer's time zone", () => { + const resolved = new Intl.DateTimeFormat().resolvedOptions(); + const resolvedOptions = vi + .spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions") + .mockReturnValue({ ...resolved, timeZone: "America/Los_Angeles" }); + + try { + const window = makeMonthToDateWindow(new Date("2026-09-01T06:30:00.000Z")); + + expect(window).toMatchObject({ + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + timeZone: "America/Los_Angeles", + resolution: "day", + }); + } finally { + resolvedOptions.mockRestore(); + } + }); + it("degrades an unknown resolved zone to UTC instead of crashing", () => { const resolved = new Intl.DateTimeFormat().resolvedOptions(); const resolvedOptions = vi diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index bd751829dd87..acea30126f64 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -230,3 +230,12 @@ export function makeWindow( resolution, }; } + +/** The current calendar month through today in the viewer's own time zone. */ +export function makeMonthToDateWindow(now = new Date()): UsageSummaryInput { + const window = makeWindow(1, now); + return { + ...window, + sinceDay: UsageDay.make(`${window.untilDay.slice(0, 7)}-01`), + }; +}