From ed8c5dd973ee9abc94a0d6e4e4545f1d18824c53 Mon Sep 17 00:00:00 2001 From: Ramanan Balamurugan <58137577+Ramanan-Bala@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:56:35 +0530 Subject: [PATCH 1/2] feat(web,mobile): pick This month or an earlier month on Usage Usage only offered rolling windows (24h, 7, 30, 90 days), so there was no way to see the current calendar month or last month's totals. The period control now has a month menu: This month (month to date) and every earlier month that starts inside the server's 92-day usage history, so a listed month is never silently incomplete. The server already takes a sinceDay/untilDay range in the viewer's time zone, so no contract or server logic changes; the history window is now one shared constant. - shared: makePeriodWindow, selectableUsageMonths, month/range formatting - web: month segment with menu, narrow-header Months group, range under the total, Mod+Shift+5 for This month, single-day chart renders - mobile: Month segment with a native month menu and range - docs: month menu and shortcut Co-Authored-By: Claude Opus 5.5 --- .../src/features/usage/UsageRouteScreen.tsx | 110 +++++++++-- apps/server/src/usage/UsageService.ts | 4 +- apps/web/src/components/usage/UsagePage.tsx | 160 ++++++++++++---- .../components/usage/UsageProviderChart.tsx | 15 +- .../usage/usagePagePreferences.test.ts | 5 + .../components/usage/usagePagePreferences.ts | 2 +- .../src/components/usage/usageShortcuts.ts | 14 +- apps/web/src/keybindings.test.ts | 36 ++-- docs/user/usage.md | 8 +- packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + packages/shared/src/usageFormat.test.ts | 118 +++++++++++- packages/shared/src/usageFormat.ts | 177 ++++++++++++++---- 13 files changed, 535 insertions(+), 116 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 7e794ac089b9c..7fa440481716f 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,7 +1,12 @@ import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { ChatGptUsageSummary } from "./ChatGptUsageSummary"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; -import { EnvironmentId, USAGE_CONTRACT_VERSION, type UsageProviderKind } from "@t3tools/contracts"; +import { + EnvironmentId, + USAGE_CONTRACT_VERSION, + type UsageProviderKind, + type UsageSummaryInput, +} from "@t3tools/contracts"; import { type RouteProp, useIsFocused, useRoute } from "@react-navigation/native"; import { updatingProvidersLabel, @@ -18,21 +23,30 @@ import { enumerateDays, enumerateHourStarts, formatCount, + formatDayRange, formatDayShort, formatHourShort, formatPercent, formatTokens, formatUsageContractMismatch, + formatUsageMonth, formatUsd, - makeWindow, + makePeriodWindow, + parseUsagePeriod, + selectableUsageMonths, usageSourceMessages, + type UsageMonthKey, + type UsagePeriod, } from "@t3tools/shared/usageFormat"; +import type { MenuAction } from "@react-native-menu/menu"; import { useRef, useState, type ReactNode } from "react"; import { RefreshControl, View } from "react-native"; import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SegmentedControl } from "../../components/ui/SegmentedControl"; +import { ControlPill } from "../../components/ui/ControlPill"; +import { Menu } from "../../components/ui/Menu"; import { useReduceMotionEnabled } from "../../lib/appState"; import { AppText as Text } from "../../components/ui/AppText"; import { useWorkspaceSettledContentWidth } from "../layout/workspace-content-width"; @@ -59,6 +73,7 @@ const WINDOW_OPTIONS = [ { value: 7, label: "7d", accessibilityLabel: "Past 7 days" }, { value: 30, label: "30d", accessibilityLabel: "Past 30 days" }, { value: 90, label: "90d", accessibilityLabel: "Past 90 days" }, + { value: "month", label: "Month", accessibilityLabel: "This month" }, ] as const; const CHART_HEIGHT = 180; @@ -94,12 +109,13 @@ function UsageScreen(props: { // A workspace pane knows its width in the render that resizes it; measuring lags a frame. const width = useWorkspaceSettledContentWidth() ?? measuredWidth; const wide = width >= WIDE_WIDTH; - const [windowSelection, setWindowSelection] = useState(() => ({ - days: 30, - window: makeWindow(30), - })); - const { days: windowDays, window } = windowSelection; - const isPast24Hours = windowDays === 1; + const [windowSelection, setWindowSelection] = useState<{ + readonly period: UsagePeriod; + readonly window: UsageSummaryInput; + }>(() => ({ period: 30, window: makePeriodWindow(30) })); + const { period, window } = windowSelection; + const isPast24Hours = period === 1; + const isMonthPeriod = typeof period === "string"; const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState | null>(null); const { merged, environments, selectedEnvironments, isPending, refresh } = useUsage( @@ -137,22 +153,19 @@ function UsageScreen(props: { refreshing: refreshingUsage, providerLabel, }); - const selectWindow = (days: number) => { - setWindowSelection({ - days, - window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), - }); + const selectPeriod = (period: UsagePeriod) => { + setWindowSelection({ period, window: makePeriodWindow(period) }); }; const refreshWindow = () => { if (refreshingRef.current) return; - const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); + const nextWindow = makePeriodWindow(period); if ( nextWindow.sinceDay !== window.sinceDay || nextWindow.untilDay !== window.untilDay || nextWindow.sinceTime !== window.sinceTime || nextWindow.untilTime !== window.untilTime ) { - setWindowSelection({ days: windowDays, window: nextWindow }); + setWindowSelection({ period, window: nextWindow }); } refreshingRef.current = true; setRefreshingUsage(true); @@ -173,6 +186,15 @@ function UsageScreen(props: { timeZone: window.timeZone, }; const providers = ; + const monthPicker = + isMonthPeriod && !showingLimits ? ( + + ) : null; const showEnvironmentFilter = environments.length > 0 || selectedEnvironmentIds !== null; const selectEnvironment = (value: string) => { @@ -237,8 +259,8 @@ function UsageScreen(props: { ) : ( @@ -260,6 +282,7 @@ function UsageScreen(props: { role="tab" /> )} + {wide ? monthPicker : null} )} + {monthPicker} {merged.duplicateSources.length > 0 ? ( @@ -361,6 +385,54 @@ function UsageScreen(props: { ); } +function UsageMonthPicker(props: { + readonly period: "month" | UsageMonthKey; + readonly sinceDay: string; + readonly untilDay: string; + readonly onSelect: (period: UsagePeriod) => void; +}) { + const monthKeys = selectableUsageMonths(); + const actions: MenuAction[] = [ + { id: "month", title: "This month", state: props.period === "month" ? "on" : "off" }, + ...monthKeys.slice(1).map((key): MenuAction => ({ + id: key, + title: formatUsageMonth(key), + state: props.period === key ? "on" : "off", + })), + ]; + return ( + + { + const next = parseUsagePeriod(nativeEvent.event); + if (next !== undefined) props.onSelect(next); + }} + > + + + + {formatDayRange(props.sinceDay, props.untilDay)} + + + ); +} + function UsageUpdating({ dimmed, label, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 6b6762c089ba6..bd124390fd5b3 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -28,7 +28,7 @@ import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientResponse } from "effect/http"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; -import { formatMalformedUsageWarning } from "@t3tools/shared/usageFormat"; +import { formatMalformedUsageWarning, USAGE_HISTORY_DAYS } from "@t3tools/shared/usageFormat"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import { BUILT_IN_USAGE_DRIVERS, type BuiltInUsageReadersEnv } from "../provider/builtInDrivers.ts"; @@ -70,7 +70,7 @@ const RATES_REFRESH_FLOOR_MS = 60 * 1000; const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; -const CACHE_RETENTION_DAYS = 92; +const CACHE_RETENTION_DAYS = USAGE_HISTORY_DAYS; const TRANSCRIPT_READ_CONCURRENCY = 4; diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 8bc8a93a43d69..9253e25b342dc 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -45,14 +45,19 @@ import { enumerateHourStarts, formatCount, formatDateTimeShort, + formatDayRange, formatDayShort, formatHourShort, formatPercent, formatTokens, formatUsageContractMismatch, + formatUsageMonth, formatUsd, - makeWindow, + makePeriodWindow, + parseUsagePeriod, + selectableUsageMonths, usageSourceMessages, + type UsagePeriod, } from "@t3tools/shared/usageFormat"; import { Button, InlineButton } from "../ui/button"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; @@ -60,12 +65,24 @@ import { Menu, MenuCheckboxItem, MenuItem, + MenuItemDetail, MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuRadioItemIndicator, MenuSeparator, MenuTrigger, } from "../ui/menu"; import { ScrollArea } from "../ui/scroll-area"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { + Select, + SelectGroup, + SelectGroupLabel, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "../ui/select"; import { AppRouteModal, AppRouteModalClose } from "../AppRouteModal"; import { Skeleton } from "../ui/skeleton"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; @@ -91,6 +108,7 @@ import { } from "./usageBreakdown"; import { METRIC_OPTIONS, + THIS_MONTH_OPTION, WINDOW_OPTIONS, resolveUsageShortcut, type UsageMetric, @@ -111,10 +129,6 @@ function isUsageMetric(value: string | null | undefined): value is UsageMetric { return METRIC_OPTIONS.some((option) => option.value === value); } -function isUsageWindowDays(value: number): value is UsagePagePreferences["windowDays"] { - return WINDOW_OPTIONS.some((option) => option.days === value); -} - const providerLabel = (provider: UsageProviderKind) => PROVIDER_PRESENTATION[provider].label; function usageLoading( @@ -179,20 +193,22 @@ export function UsagePage() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const hour12 = usePrimarySettings((settings) => resolveHour12(settings.timestampFormat)); const shortcutTitle = ( - option: (typeof METRIC_OPTIONS)[number] | (typeof WINDOW_OPTIONS)[number], + option: + | (typeof METRIC_OPTIONS)[number] + | (typeof WINDOW_OPTIONS)[number] + | typeof THIS_MONTH_OPTION, ) => { const shortcut = shortcutLabelForCommand(keybindings, option.command, { context: { usagePageOpen: true }, }); return shortcut ? `${option.label} (${shortcut})` : option.label; }; - const [windowSelection, setWindowSelection] = useState(() => ({ - days: preferences.windowDays, - window: makeWindow( - preferences.windowDays, - undefined, - preferences.windowDays === 1 ? "hour" : "day", - ), + const [windowSelection, setWindowSelection] = useState<{ + readonly period: UsagePeriod; + readonly window: UsageSummaryInput; + }>(() => ({ + period: preferences.windowDays, + window: makePeriodWindow(preferences.windowDays), })); const metric = preferences.metric; const showingLimits = metric === "limits"; @@ -203,8 +219,7 @@ export function UsagePage() { const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState | null>(null); const hiddenProviders = new Set(preferences.hiddenProviders); - const { days: windowDays, window } = windowSelection; - const isPast24Hours = windowDays === 1; + const { period, window } = windowSelection; const { merged: answeredUsage, environments, @@ -235,13 +250,12 @@ export function UsagePage() { setPreferences(nextPreferences); saveUsagePagePreferences(nextPreferences); }; - const selectWindow = (days: number) => { - if (!isUsageWindowDays(days)) return; - updatePreferences({ windowDays: days }); - setWindowSelection({ - days, - window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), + const selectPeriod = (nextPeriod: UsagePeriod) => { + // A past month is not persisted; reloading falls back to the current month. + updatePreferences({ + windowDays: typeof nextPeriod === "string" && nextPeriod !== "month" ? "month" : nextPeriod, }); + setWindowSelection({ period: nextPeriod, window: makePeriodWindow(nextPeriod) }); }; const selectMetric = (nextMetric: UsageMetric) => { if (nextMetric === "limits") setLimitsNow(Date.now()); @@ -279,12 +293,14 @@ export function UsagePage() { const command = resolveUsageShortcut(event, keybindings); const metricOption = METRIC_OPTIONS.find((option) => option.command === command); const periodOption = WINDOW_OPTIONS.find((option) => option.command === command); - if (!metricOption && !periodOption) return; + const isThisMonth = command === THIS_MONTH_OPTION.command; + if (!metricOption && !periodOption && !isThisMonth) return; event.preventDefault(); event.stopPropagation(); if (metricOption) selectMetric(metricOption.value); - if (periodOption && !showingLimits) selectWindow(periodOption.days); + if (isThisMonth && !showingLimits) selectPeriod("month"); + if (periodOption && !showingLimits) selectPeriod(periodOption.period); }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window capture keydown listener for usage metric and period shortcuts @@ -305,14 +321,14 @@ export function UsagePage() { }); return; } - const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); + const nextWindow = makePeriodWindow(period); if ( nextWindow.sinceDay !== window.sinceDay || nextWindow.untilDay !== window.untilDay || nextWindow.sinceTime !== window.sinceTime || nextWindow.untilTime !== window.untilTime ) { - setWindowSelection({ days: windowDays, window: nextWindow }); + setWindowSelection({ period, window: nextWindow }); } refreshingRef.current = true; setIsRefreshing(true); @@ -352,6 +368,22 @@ export function UsagePage() { (presentation.serverConfig?.providers ?? []).some(usesChatGptSharing), ); + const isMonthPeriod = typeof period === "string"; + const months = selectableUsageMonths(); + const currentYear = months[0]?.slice(0, 4); + const monthWindow = makePeriodWindow("month"); + const thisMonthRange = formatDayRange(monthWindow.sinceDay, monthWindow.untilDay); + const isPastMonth = isMonthPeriod && period !== "month"; + const monthSegmentLabel = isPastMonth + ? formatUsageMonth(period, period.slice(0, 4) !== currentYear) + : THIS_MONTH_OPTION.label; + const monthToggleTitle = isPastMonth + ? formatUsageMonth(period) + : shortcutTitle(THIS_MONTH_OPTION); + const rangeLabel = isMonthPeriod + ? `${formatDayRange(shownWindow.sinceDay, shownWindow.untilDay)} · ` + : undefined; + const windowLabel = shownHourly && shownWindow.sinceTime !== undefined && shownWindow.untilTime !== undefined ? `${formatDateTimeShort(shownWindow.sinceTime, shownWindow.timeZone, hour12)} to ${formatDateTimeShort(shownWindow.untilTime, shownWindow.timeZone, hour12)}` @@ -397,17 +429,54 @@ export function UsagePage() { { const value = next[0]; - if (value) selectWindow(Number(value)); + if (value === undefined || value === "month") return; + const nextPeriod = parseUsagePeriod(value); + if (nextPeriod !== undefined) selectPeriod(nextPeriod); }} > {WINDOW_OPTIONS.map((option) => ( - + {option.label} ))} + + }> + {monthSegmentLabel} + + + + { + const nextPeriod = parseUsagePeriod(value); + if (nextPeriod !== undefined) selectPeriod(nextPeriod); + }} + > + + + This month + {thisMonthRange} + + + + {months.slice(1).map((month) => ( + + + {formatUsageMonth(month)} + + + + ))} + + + )}
{showingLimits ? null : ( - { + const nextPeriod = value === null ? undefined : parseUsagePeriod(value); + if (nextPeriod !== undefined) selectPeriod(nextPeriod); + }} + > - {WINDOW_OPTIONS.find((option) => option.days === windowDays)?.label} + {typeof period === "number" + ? WINDOW_OPTIONS.find((option) => option.period === period)?.label + : period === "month" + ? THIS_MONTH_OPTION.label + : formatUsageMonth(period, period.slice(0, 4) !== currentYear)} {WINDOW_OPTIONS.map((option) => ( {option.label} ))} + + Months + + This month + + {months.slice(1).map((month) => ( + + {formatUsageMonth(month)} + + ))} + )} @@ -542,6 +632,7 @@ export function UsagePage() { hours={hours} showingKept={showingKept} metric={metric === "tokens" ? "tokens" : "cost"} + rangeLabel={rangeLabel} selectedEnvironments={selectedEnvironments} hiddenProviders={hiddenProviders} refreshingUsage={refreshingUsage} @@ -590,6 +681,7 @@ function UsageOverview({ hours, showingKept, metric, + rangeLabel, selectedEnvironments, hiddenProviders, refreshingUsage, @@ -603,6 +695,7 @@ function UsageOverview({ readonly hours: readonly string[]; readonly showingKept: boolean; readonly metric: "tokens" | "cost"; + readonly rangeLabel: string | undefined; readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; readonly hiddenProviders: ReadonlySet; readonly refreshingUsage: boolean; @@ -668,6 +761,7 @@ function UsageOverview({ + {rangeLabel} {formatCount(merged.sessions)} sessions diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 1745be4df897b..38f6074bccba8 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -201,12 +201,15 @@ function buildChart( const stepX = periods.length < 2 ? 0 : VIEW_WIDTH / (periods.length - 1); const paths = providers.map((provider) => { const slot = PROVIDER_ORDER.indexOf(provider); + const points = columns.map((column, periodIndex) => ({ + x: periodIndex * stepX, + y: valueToY(column.bands[slot]?.value ?? 0, scale.max), + })); + const [only] = points; + // smoothCurve needs two points, so a single period draws as a flat line across the width. const line = curvePath( smoothCurve( - columns.map((column, periodIndex) => ({ - x: periodIndex * stepX, - y: valueToY(column.bands[slot]?.value ?? 0, scale.max), - })), + points.length === 1 && only !== undefined ? [only, { x: VIEW_WIDTH, y: only.y }] : points, ), ); return { @@ -512,12 +515,12 @@ export function UsageProviderChart({
{periods[0] === undefined ? "" : formatPeriod(periods[0])} - {periods[Math.floor(periods.length / 2)] === undefined + {periods.length === 1 || periods[Math.floor(periods.length / 2)] === undefined ? "" : formatPeriod(periods[Math.floor(periods.length / 2)] ?? "")} - {periods[periods.length - 1] === undefined + {periods.length === 1 || periods[periods.length - 1] === undefined ? "" : formatPeriod(periods[periods.length - 1] ?? "")} diff --git a/apps/web/src/components/usage/usagePagePreferences.test.ts b/apps/web/src/components/usage/usagePagePreferences.test.ts index 075076a9b3c52..f76a218c016bb 100644 --- a/apps/web/src/components/usage/usagePagePreferences.test.ts +++ b/apps/web/src/components/usage/usagePagePreferences.test.ts @@ -32,6 +32,11 @@ describe("Usage page preferences", () => { expect(readUsagePagePreferences()).toEqual({ metric: "tokens", windowDays: 30 }); }); + it("round-trips the this-month period", () => { + saveUsagePagePreferences({ metric: "cost", windowDays: "month" }); + expect(readUsagePagePreferences().windowDays).toBe("month"); + }); + // regression: #10189 an invalid saved usage selection was never replaced it.each([ "not-json", diff --git a/apps/web/src/components/usage/usagePagePreferences.ts b/apps/web/src/components/usage/usagePagePreferences.ts index 1aeaefc43da12..d3db35eacbc78 100644 --- a/apps/web/src/components/usage/usagePagePreferences.ts +++ b/apps/web/src/components/usage/usagePagePreferences.ts @@ -6,7 +6,7 @@ import { getLocalStorageItem, setLocalStorageItem } from "../../hooks/useLocalSt const STORAGE_KEY = "t3code:usage-page-preferences:v1"; const UsagePagePreferencesSchema = Schema.Struct({ metric: Schema.Literals(["cost", "tokens", "limits"]), - windowDays: Schema.Literals([1, 7, 30, 90]), + windowDays: Schema.Literals([1, 7, 30, 90, "month"]), hiddenProviders: Schema.optional(Schema.Array(UsageProviderKind)), }); export type UsagePagePreferences = typeof UsagePagePreferencesSchema.Type; diff --git a/apps/web/src/components/usage/usageShortcuts.ts b/apps/web/src/components/usage/usageShortcuts.ts index f8e66a8533637..f687a1781b2da 100644 --- a/apps/web/src/components/usage/usageShortcuts.ts +++ b/apps/web/src/components/usage/usageShortcuts.ts @@ -10,12 +10,18 @@ export const METRIC_OPTIONS = [ ] as const satisfies readonly { value: UsageMetric; label: string; command: KeybindingCommand }[]; export const WINDOW_OPTIONS = [ - { days: 1, label: "Past 24h", command: "usage.period.day" }, - { days: 7, label: "7 days", command: "usage.period.week" }, - { days: 30, label: "30 days", command: "usage.period.month" }, - { days: 90, label: "90 days", command: "usage.period.quarter" }, + { period: 1, label: "Past 24h", command: "usage.period.day" }, + { period: 7, label: "7 days", command: "usage.period.week" }, + { period: 30, label: "30 days", command: "usage.period.month" }, + { period: 90, label: "90 days", command: "usage.period.quarter" }, ] as const; +export const THIS_MONTH_OPTION = { + period: "month", + label: "This month", + command: "usage.period.thisMonth", +} as const; + export function resolveUsageShortcut( event: ShortcutEventLike & { target: EventTarget | null }, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 8708bbd297a91..33adaf4d32797 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -921,19 +921,25 @@ describe("Usage shortcuts", () => { }, ); - it("matches shifted number keys for periods and only on Usage", () => { - const shortcut = event({ key: "!", code: "Digit1", ctrlKey: true, shiftKey: true }); - assert.strictEqual( - resolveShortcutCommand(shortcut, DEFAULT_RESOLVED_KEYBINDINGS, { - platform: "Linux", - context: { usagePageOpen: true }, - }), - "usage.period.day", - ); - assert.isNull( - resolveShortcutCommand(shortcut, DEFAULT_RESOLVED_KEYBINDINGS, { - platform: "Linux", - }), - ); - }); + it.each([ + { key: "!", code: "Digit1", command: "usage.period.day" }, + { key: "%", code: "Digit5", command: "usage.period.thisMonth" }, + ])( + "matches shifted number keys for periods and only on Usage ($key)", + ({ key, code, command }) => { + const shortcut = event({ key, code, ctrlKey: true, shiftKey: true }); + assert.strictEqual( + resolveShortcutCommand(shortcut, DEFAULT_RESOLVED_KEYBINDINGS, { + platform: "Linux", + context: { usagePageOpen: true }, + }), + command, + ); + assert.isNull( + resolveShortcutCommand(shortcut, DEFAULT_RESOLVED_KEYBINDINGS, { + platform: "Linux", + }), + ); + }, + ); }); diff --git a/docs/user/usage.md b/docs/user/usage.md index d7e0dad901bef..3313b8b8f0e8c 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -15,6 +15,10 @@ cost, split by token type and by speed. These estimates are not your subscriptio split, such as a provider-reported cost for a model without public rates, shows as **Other**. Select a model under **Breakdown** to see its trend, cache hit rate, and cost per million tokens. +Pick a rolling period, or open the month menu to see **This month** or an earlier calendar month. +The menu lists only months that start within the last 92 days, because Usage keeps about that much +history. + Totals depend on the history available on each server. Grok turns without a saved completed-turn record are missing from the totals. Text generation (thread titles, commit messages, PR text, branch names and review guides) is not in @@ -191,7 +195,7 @@ Session, Weekly, or both for each provider. Reopen T3 to refresh expired reading On web and desktop, open Usage from the command palette. While on Usage, press `C`, `T`, or `L` for Cost, Tokens, or Limits while not typing in a field. -Use `Ctrl+Shift+1/2/3/4` (`Cmd+Shift+1/2/3/4` on macOS) for the past -24 hours, 7 days, 30 days, or 90 days. Period shortcuts do nothing on Limits. +Use `Ctrl+Shift+1/2/3/4/5` (`Cmd+Shift+1/2/3/4/5` on macOS) for the past +24 hours, 7 days, 30 days, 90 days, or this month. Period shortcuts do nothing on Limits. Press `Escape` to return to the previous page. Customize these shortcuts in **Settings → Keybindings**. diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index d17309dcc3306..d65926e902f09 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -112,6 +112,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "usage.period.week", "usage.period.month", "usage.period.quarter", + "usage.period.thisMonth", ...MODEL_PICKER_KEYBINDING_COMMANDS, ...THREAD_KEYBINDING_COMMANDS, ] as const; diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index ba4d1d925be48..3ec64065f0833 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -147,6 +147,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+2", command: "usage.period.week", when: "usagePageOpen" }, { key: "mod+shift+3", command: "usage.period.month", when: "usagePageOpen" }, { key: "mod+shift+4", command: "usage.period.quarter", when: "usagePageOpen" }, + { key: "mod+shift+5", command: "usage.period.thisMonth", when: "usagePageOpen" }, ]; function normalizeKeyToken(token: string): string { diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index c847f323a2065..e46ac7fb59af5 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -2,7 +2,17 @@ import { describe, expect, it, vi } from "vite-plus/test"; import type { UsageSource } from "@t3tools/contracts"; -import { formatHourShort, makeWindow, usageSourceMessages } from "./usageFormat.ts"; +import { + formatDayRange, + formatHourShort, + formatUsageMonth, + makePeriodWindow, + makeWindow, + parseUsagePeriod, + selectableUsageMonths, + type UsagePeriod, + usageSourceMessages, +} from "./usageFormat.ts"; function malformedSource(count: number, overrides: Partial = {}): UsageSource { return { @@ -176,3 +186,109 @@ describe("hourly usage formatting", () => { } }); }); + +describe("usage periods", () => { + function withResolvedZone(timeZone: string, run: () => T): T { + const resolved = new Intl.DateTimeFormat().resolvedOptions(); + const resolvedOptions = vi + .spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions") + .mockReturnValue({ ...resolved, timeZone }); + try { + return run(); + } finally { + resolvedOptions.mockRestore(); + } + } + + const periodCases: { + zone: string; + now: string; + period: UsagePeriod; + sinceDay: string; + untilDay: string; + }[] = [ + { + zone: "America/Los_Angeles", + now: "2026-11-01T03:00:00Z", + period: "month", + sinceDay: "2026-10-01", + untilDay: "2026-10-31", + }, + { + zone: "UTC", + now: "2026-11-08T12:00:00Z", + period: "month", + sinceDay: "2026-11-01", + untilDay: "2026-11-08", + }, + { + zone: "UTC", + now: "2026-11-01T12:00:00Z", + period: "month", + sinceDay: "2026-11-01", + untilDay: "2026-11-01", + }, + { + zone: "UTC", + now: "2026-11-08T12:00:00Z", + period: "2026-10", + sinceDay: "2026-10-01", + untilDay: "2026-10-31", + }, + { + zone: "UTC", + now: "2026-11-08T12:00:00Z", + period: "2028-02", + sinceDay: "2026-11-01", + untilDay: "2026-11-08", + }, + { + zone: "UTC", + now: "2028-03-10T12:00:00Z", + period: "2028-02", + sinceDay: "2028-02-01", + untilDay: "2028-02-29", + }, + ]; + + it.each(periodCases)( + "makePeriodWindow($period) at $now spans $sinceDay to $untilDay", + ({ zone, now, period, sinceDay, untilDay }) => { + const window = withResolvedZone(zone, () => makePeriodWindow(period, new Date(now))); + expect(window.sinceDay).toBe(sinceDay); + expect(window.untilDay).toBe(untilDay); + expect(window.resolution).toBe("day"); + }, + ); + + it.each([ + { now: "2026-11-08T12:00:00Z", months: ["2026-11", "2026-10", "2026-09"] }, + { now: "2027-03-01T12:00:00Z", months: ["2027-03", "2027-02", "2027-01", "2026-12"] }, + ])("selectableUsageMonths at $now offers $months", ({ now, months }) => { + expect(withResolvedZone("UTC", () => selectableUsageMonths(new Date(now)))).toEqual(months); + }); + + it.each([ + { sinceDay: "2026-11-01", untilDay: "2026-11-08", label: "Nov 1 – 8" }, + { sinceDay: "2026-11-08", untilDay: "2026-11-08", label: "Nov 8" }, + { sinceDay: "2026-10-10", untilDay: "2026-11-08", label: "Oct 10 – Nov 8" }, + { sinceDay: "2025-12-12", untilDay: "2026-01-08", label: "Dec 12, 2025 – Jan 8, 2026" }, + ])("formatDayRange($sinceDay, $untilDay) -> $label", ({ sinceDay, untilDay, label }) => { + expect(formatDayRange(sinceDay, untilDay)).toBe(label); + }); + + it("formatUsageMonth uses full month names with and without the year", () => { + expect(formatUsageMonth("2026-10")).toBe("October 2026"); + expect(formatUsageMonth("2026-10", false)).toBe("October"); + }); + + it.each([ + { value: "7", parsed: 7 }, + { value: "month", parsed: "month" }, + { value: "2026-10", parsed: "2026-10" }, + { value: "2026-13", parsed: undefined }, + { value: "abc", parsed: undefined }, + ])("parseUsagePeriod($value) -> $parsed", ({ value, parsed }) => { + expect(parseUsagePeriod(value)).toBe(parsed); + }); +}); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index 1c6ba7b8fd75a..c8cb953563e69 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -94,24 +94,40 @@ export function formatUsageContractMismatch( : `This client is older than the server on ${environmentLabel}; its usage is excluded from totals.`; } +const SHORT_MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +const FULL_MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + export function formatDayShort(day: string): string { const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part)); if (year === undefined || month === undefined || dayOfMonth === undefined) return day; - const MONTHS = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ]; - return `${MONTHS[month - 1] ?? ""} ${dayOfMonth}`; + return `${SHORT_MONTHS[month - 1] ?? ""} ${dayOfMonth}`; } export function enumerateDays(sinceDay: string, untilDay: string): readonly string[] { @@ -244,29 +260,37 @@ export function formatRelativeHourShort( return formatDateTimeShort(hourStart, timeZone, hour12); } +function zoneDayFormatter(): { timeZone: string; format: Intl.DateTimeFormat } { + const requested = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + try { + return { + timeZone: requested, + format: new Intl.DateTimeFormat("en-CA", { + timeZone: requested, + year: "numeric", + month: "2-digit", + day: "2-digit", + }), + }; + } catch { + return { + timeZone: "UTC", + format: new Intl.DateTimeFormat("en-CA", { + timeZone: "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + }), + }; + } +} + export function makeWindow( days: number, now = new Date(), resolution: UsageResolution = "day", ): UsageSummaryInput { - let timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - let format: Intl.DateTimeFormat; - try { - format = new Intl.DateTimeFormat("en-CA", { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - }); - } catch { - timeZone = "UTC"; - format = new Intl.DateTimeFormat("en-CA", { - timeZone: "UTC", - year: "numeric", - month: "2-digit", - day: "2-digit", - }); - } + const { timeZone, format } = zoneDayFormatter(); const untilDay = format.format(now); if (resolution === "hour") { const untilTimeMs = Math.floor(now.getTime() / 60_000) * 60_000; @@ -294,3 +318,90 @@ export function makeWindow( resolution, }; } + +export const USAGE_HISTORY_DAYS = 92; + +export type UsageRollingDays = 1 | 7 | 30 | 90; + +// "YYYY-MM" calendar month keys, e.g. "2026-10". +export type UsageMonthKey = `${number}-${number}`; + +export type UsagePeriod = UsageRollingDays | "month" | UsageMonthKey; + +export function makePeriodWindow(period: UsagePeriod, now = new Date()): UsageSummaryInput { + if (typeof period === "number") { + return period === 1 ? makeWindow(1, now, "hour") : makeWindow(period, now); + } + const { timeZone, format } = zoneDayFormatter(); + const untilDay = format.format(now); + const currentMonth = untilDay.slice(0, 7); + if (period === "month" || period >= currentMonth) { + return { + sinceDay: UsageDay.make(`${currentMonth}-01`), + untilDay: UsageDay.make(untilDay), + timeZone, + resolution: "day", + }; + } + const [year = 0, month = 1] = period.split("-").map((part) => Number.parseInt(part, 10)); + // Date.UTC normalizes day 0 to the last day of the previous month, leap years included. + const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); + return { + sinceDay: UsageDay.make(`${period}-01`), + untilDay: UsageDay.make(`${period}-${lastDay}`), + timeZone, + resolution: "day", + }; +} + +export function selectableUsageMonths(now = new Date()): readonly UsageMonthKey[] { + const { format } = zoneDayFormatter(); + const [year = 0, month = 1, dayOfMonth = 1] = format + .format(now) + .split("-") + .map((part) => Number.parseInt(part, 10)); + const cutoffDay = new Date(Date.UTC(year, month - 1, dayOfMonth - USAGE_HISTORY_DAYS)) + .toISOString() + .slice(0, 10); + const months: UsageMonthKey[] = []; + let cursorYear = year; + let cursorMonth = month; + while (`${cursorYear}-${String(cursorMonth).padStart(2, "0")}-01` > cutoffDay) { + months.push(`${cursorYear}-${String(cursorMonth).padStart(2, "0")}` as UsageMonthKey); + cursorMonth -= 1; + if (cursorMonth === 0) { + cursorMonth = 12; + cursorYear -= 1; + } + } + return months; +} + +export function formatUsageMonth(month: UsageMonthKey, withYear = true): string { + const [year, monthNumber] = month.split("-"); + const name = FULL_MONTHS[Number(monthNumber) - 1]; + if (name === undefined) return month; + return withYear ? `${name} ${year}` : name; +} + +export function formatDayRange(sinceDay: string, untilDay: string): string { + const [sinceYear = "", sinceMonth = "", sinceDayOfMonth = ""] = sinceDay.split("-"); + const [untilYear = "", untilMonth = "", untilDayOfMonth = ""] = untilDay.split("-"); + const sinceLabel = formatDayShort(sinceDay); + const untilLabel = formatDayShort(untilDay); + if (sinceDay === untilDay) return untilLabel; + if (sinceYear === untilYear && sinceMonth === untilMonth) { + return `${SHORT_MONTHS[Number(sinceMonth) - 1] ?? ""} ${Number(sinceDayOfMonth)} – ${Number(untilDayOfMonth)}`; + } + if (sinceYear === untilYear) return `${sinceLabel} – ${untilLabel}`; + return `${sinceLabel}, ${sinceYear} – ${untilLabel}, ${untilYear}`; +} + +export function parseUsagePeriod(value: string): UsagePeriod | undefined { + if (value === "1") return 1; + if (value === "7") return 7; + if (value === "30") return 30; + if (value === "90") return 90; + if (value === "month") return "month"; + return /^\d{4}-(0[1-9]|1[0-2])$/.test(value) ? (value as UsageMonthKey) : undefined; +} From 1de6436f4fcd8bf9fc8913275f6cc93d03c46e41 Mon Sep 17 00:00:00 2001 From: Ramanan Balamurugan <58137577+Ramanan-Bala@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:56:35 +0530 Subject: [PATCH 2/2] feat(web): month segment reads Select month while a rolling period is active Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/usage/UsagePage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 9253e25b342dc..e5be801ca8630 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -376,7 +376,9 @@ export function UsagePage() { const isPastMonth = isMonthPeriod && period !== "month"; const monthSegmentLabel = isPastMonth ? formatUsageMonth(period, period.slice(0, 4) !== currentYear) - : THIS_MONTH_OPTION.label; + : isMonthPeriod + ? THIS_MONTH_OPTION.label + : "Select month"; const monthToggleTitle = isPastMonth ? formatUsageMonth(period) : shortcutTitle(THIS_MONTH_OPTION);