diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 1b31460842f39..30074d143a0cf 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 771393dac2579..98bf6d096c061 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 STALE_TEMP_DIRECTORY_MS = 60 * 60 * 1000; 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..e5be801ca8630 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,24 @@ 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) + : isMonthPeriod + ? THIS_MONTH_OPTION.label + : "Select month"; + 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 +431,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 +634,7 @@ export function UsagePage() { hours={hours} showingKept={showingKept} metric={metric === "tokens" ? "tokens" : "cost"} + rangeLabel={rangeLabel} selectedEnvironments={selectedEnvironments} hiddenProviders={hiddenProviders} refreshingUsage={refreshingUsage} @@ -590,6 +683,7 @@ function UsageOverview({ hours, showingKept, metric, + rangeLabel, selectedEnvironments, hiddenProviders, refreshingUsage, @@ -603,6 +697,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 +763,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 3b596f838fa0d..9b03f9589832e 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -968,19 +968,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 e6b3a46d5a14e..3e5bb22dbca0e 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 @@ -195,7 +199,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 3338c1a456002..610c2b0a2e64d 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -114,6 +114,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 9a67264d8bd1f..06a8da986d967 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -148,6 +148,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; +}