Skip to content
Closed
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
37 changes: 23 additions & 14 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
formatPercent,
formatTokens,
formatUsd,
makeMonthToDateWindow,
makeWindow,
} from "@t3tools/shared/usageFormat";
import { useMemo, useState } from "react";
Expand All @@ -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<UsageChartMetric>("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(
Expand Down Expand Up @@ -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 &&
Expand All @@ -89,7 +98,7 @@ export function UsageRouteScreen() {
) {
refresh();
} else {
setWindowSelection({ days: windowDays, window: nextWindow });
setWindowSelection({ period: windowPeriod, window: nextWindow });
}
};

Expand All @@ -110,8 +119,8 @@ export function UsageRouteScreen() {
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refreshWindow} />}
>
<SegmentedControl
options={WINDOW_OPTIONS.map((option) => ({ value: option.days, label: option.label }))}
selected={windowDays}
options={WINDOW_OPTIONS}
selected={windowPeriod}
onSelect={selectWindow}
/>

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/usage/UsagePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
52 changes: 34 additions & 18 deletions apps/web/src/components/usage/UsagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
formatPercent,
formatTokens,
formatUsd,
makeMonthToDateWindow,
makeWindow,
} from "@t3tools/shared/usageFormat";
import { Button } from "../ui/button";
Expand All @@ -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<UsageChartMetric>("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
Expand Down Expand Up @@ -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 &&
Expand All @@ -102,7 +111,7 @@ export function UsagePage() {
) {
refresh();
} else {
setWindowSelection({ days: windowDays, window: nextWindow });
setWindowSelection({ period: windowPeriod, window: nextWindow });
}
};
const windowLabel =
Expand Down Expand Up @@ -139,14 +148,15 @@ export function UsagePage() {
<ToggleGroup
aria-label="Usage period"
variant="segmented"
value={[String(windowDays)]}
value={[String(windowPeriod)]}
onValueChange={(next) => {
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) => (
<Toggle key={option.days} value={String(option.days)}>
<Toggle key={option.value} value={String(option.value)}>
{option.label}
</Toggle>
))}
Expand Down Expand Up @@ -175,20 +185,26 @@ export function UsagePage() {
<SelectItem value="tokens">Tokens</SelectItem>
</SelectPopup>
</Select>
<Select value={String(windowDays)} onValueChange={(value) => selectWindow(Number(value))}>
<Select
value={String(windowPeriod)}
onValueChange={(value) => {
const option = WINDOW_OPTIONS.find((candidate) => String(candidate.value) === value);
if (option) selectWindow(option.value);
}}
>
<SelectTrigger
aria-label="Usage period"
size="compact"
variant="ghost"
className="w-auto min-w-0"
>
<SelectValue>
{WINDOW_OPTIONS.find((option) => option.days === windowDays)?.label}
{WINDOW_OPTIONS.find((option) => option.value === windowPeriod)?.label}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
{WINDOW_OPTIONS.map((option) => (
<SelectItem key={option.days} value={String(option.days)}>
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
Expand Down
5 changes: 3 additions & 2 deletions docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
21 changes: 21 additions & 0 deletions packages/shared/src/usageFormat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
formatDateTimeShort,
formatHourShort,
formatRelativeHourShort,
makeMonthToDateWindow,
makeWindow,
} from "./usageFormat.ts";

Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions packages/shared/src/usageFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`),
};
}
Loading