diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 0bcc0cd74958..227b861d99f8 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -6,6 +6,7 @@ import { collectLimitNotices, collectLimitPools, cursorUsageWindowDetails, + describeWindowPace, displayLimitWindows, formatDuration, formatResetsIn, @@ -23,11 +24,10 @@ import { AppText as Text } from "../../components/AppText"; import { ProviderIcon } from "../../components/ProviderIcon"; import { SettingsScreen } from "../settings/components/SettingsScreen"; import { environmentPresentations } from "../../state/presentation"; -import { ResetCredits } from "./UsageLimitsSection"; +import { PaceLine, ResetCredits } from "./UsageLimitsSection"; import { useProviderColors } from "./usageProviders"; const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude" }; -const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const; function accountName(account: LimitAccount) { if (account.displayName) return account.displayName; @@ -110,9 +110,6 @@ function PoolWindowCard({ left - {pool.pace ? ( - {PACE_LABEL[pool.pace]} - ) : null} {description ? {description} : null} {nextRefill ? ( @@ -124,21 +121,28 @@ function PoolWindowCard({ {pool.columns.map(({ account, window }, index) => { if (!window) return ; + const pace = describeWindowPace(window, now); return ( openAccount(account)} - className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle" + className="relative h-7 min-w-0 flex-1 overflow-visible rounded-md bg-transparent" > - - + + + + {pace.timeLeft !== null ? : null} + {index + 1} @@ -215,7 +219,7 @@ export function UsageLimitsSection({ selectedEnvironmentIds === null ? presentations : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); - const pools = collectLimitPools(collectLimitAccounts(selected), now); + const pools = collectLimitPools(collectLimitAccounts(selected)); const notices = collectLimitNotices(selected); const colors = useProviderColors(); const cursorPromptAt = @@ -304,6 +308,29 @@ type AccountScreenProps = StaticScreenProps<{ now: number; }>; +function AccountPace({ + window, + now, +}: { + readonly window: Parameters[0]; + readonly now: number; +}) { + const pace = describeWindowPace(window, now); + return ( + <> + + {pace.remaining}% left + + {pace.detail ? ( + + {pace.summary} + {pace.detail} + + ) : null} + + ); +} + /** Resolve the account again so live quota and credit updates reach the open detail screen. */ export function UsageLimitAccountScreen({ route }: AccountScreenProps) { const insets = useSafeAreaInsets(); @@ -317,7 +344,7 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) { : new Map([...presentations].filter(([id]) => selectedIds.has(id))); const accounts = collectLimitAccounts(selected); const account = accounts.find((candidate) => candidate.key === accountKey); - const pool = collectLimitPools(accounts, now) + const pool = collectLimitPools(accounts) .find((candidate) => candidate.driver === account?.driver) ?.windows.find((candidate) => candidate.id === windowId && candidate.kind === windowKind); const window = pool?.members.find((member) => member.account.key === accountKey)?.window; @@ -363,9 +390,7 @@ export function UsageLimitAccountScreen({ route }: AccountScreenProps) { {window.label} - - {remainingPercent(window)}% left - + {window.resetsAt ? ( Resets{" "} diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.test.ts b/apps/mobile/src/features/usage/UsageLimitsSection.test.ts index f8d769355f77..35faeea9f12a 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.test.ts +++ b/apps/mobile/src/features/usage/UsageLimitsSection.test.ts @@ -33,6 +33,7 @@ vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations } vi.mock("react-native", () => ({ Alert: {}, Pressable: "button", View: "div" })); vi.mock("../../components/AppText", () => ({ AppText: "span" })); vi.mock("../../components/ProviderIcon", () => ({ ProviderIcon: () => null })); +vi.mock("../../components/AppSymbol", () => ({ SymbolView: () => null })); vi.mock("./usageProviders", () => ({ useProviderColors: () => ({}) })); vi.mock("../../state/presentation", () => ({ environmentPresentations: { presentationsAtom: null }, diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index e0f1827aaa5a..1f4cf8b4a8d1 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -9,12 +9,10 @@ import type { UsageProviderKind, } from "@t3tools/contracts"; import { - elapsedShare, + describeWindowPace, formatDuration, formatResetsIn, limitsNotice, - paceOf, - remainingPercent, } from "@t3tools/shared/usageLimits"; import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; @@ -27,8 +25,6 @@ import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { useProviderColors } from "./usageProviders"; -const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; - type Driver = ServerProvider["driver"]; /** The series colour the usage chart uses for this driver, so the two views read as one. */ @@ -39,11 +35,20 @@ function useBarColor(driver: Driver): string | null { return kind ? colors[kind] : null; } +/** Grey tick, 10% taller than the bar on each side. Same mark as the web bar. */ +export function PaceLine({ percent }: { readonly percent: number }) { + return ( + + ); +} + /** - * One window as a bar spanning its whole duration: the fill is quota left, - * the hairline is how much of the window is left, so even spending keeps the - * fill on the line. Pace sits under the left edge, the countdown under the - * right, so a row reads in one glance. + * One window as a bar spanning its whole duration. The fill is quota left. + * The line is how much of the window is left. The countdown sits under the bar. */ function WindowRow(props: { readonly window: ServerProviderUsageWindow; @@ -51,48 +56,39 @@ function WindowRow(props: { readonly now: number; }) { const { window, now } = props; - const remaining = remainingPercent(window); - const elapsed = elapsedShare(window, now); - const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100); - const pace = paceOf(window, now); + const pace = describeWindowPace(window, now); const resetsIn = formatResetsIn(window, now); return ( {window.label} - - {remaining}% left - + + + {pace.remaining}% left + + - + 30 && props.color ? { backgroundColor: props.color } : null, + { flex: pace.remaining }, + pace.remaining > 30 && props.color ? { backgroundColor: props.color } : null, ]} /> - + - {timeLeft !== null ? ( - - ) : null} + {pace.timeLeft !== null ? : null} - {pace || resetsIn ? ( - - {pace ? PACE_LABEL[pace] : ""} - {resetsIn ?? ""} - + {resetsIn ? ( + {resetsIn} ) : null} ); diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts index 44f2c6ef4bed..420a5feb12f1 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -49,7 +49,7 @@ function subscriptionUsageProps( configuredDrivers: ReadonlySet, maxWindowsPerProvider: number, ): SubscriptionUsageSnapshot { - const pools = collectLimitPools(accounts, now); + const pools = collectLimitPools(accounts); const checked = accounts .filter((account) => account.driver === "codex" || account.driver === "claudeAgent") .map((account) => Date.parse(account.limits.checkedAt)); diff --git a/apps/server/src/provider/Layers/codexUsageLimits.test.ts b/apps/server/src/provider/Layers/codexUsageLimits.test.ts index 37006789986c..0801bd68a44f 100644 --- a/apps/server/src/provider/Layers/codexUsageLimits.test.ts +++ b/apps/server/src/provider/Layers/codexUsageLimits.test.ts @@ -58,7 +58,6 @@ describe("codexRateLimitsToLimits", () => { kind: "monthly", label: "Monthly", usedPercent: 80, - windowDurationMins: 43_200, }, ]); }); diff --git a/apps/server/src/provider/Layers/codexUsageLimits.ts b/apps/server/src/provider/Layers/codexUsageLimits.ts index 74ce04a7895f..78a4a4aafbe9 100644 --- a/apps/server/src/provider/Layers/codexUsageLimits.ts +++ b/apps/server/src/provider/Layers/codexUsageLimits.ts @@ -81,16 +81,20 @@ function codexRateLimitsToWindows( const windows: ServerProviderUsageWindow[] = []; for (const [id, window, fallbackMins] of positions) { if (!window || !Number.isFinite(window.usedPercent)) continue; - const windowDurationMins = - typeof window.windowDurationMins === "number" ? window.windowDurationMins : fallbackMins; - const kind = kindForDuration(windowDurationMins); + // The fallback only names the window. Storing it as a duration would pace + // a Free/Go month as if every cycle were 30 days. + const reportedMins = + typeof window.windowDurationMins === "number" && window.windowDurationMins > 0 + ? window.windowDurationMins + : undefined; + const kind = kindForDuration(reportedMins ?? fallbackMins); const resetsAt = isoFromEpochSeconds(window.resetsAt); windows.push({ id, kind, label: labelForKind(kind), usedPercent: clampPercent(window.usedPercent), - windowDurationMins, + ...(reportedMins !== undefined ? { windowDurationMins: reportedMins } : {}), ...(resetsAt ? { resetsAt } : {}), }); } diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 585bd6ccf100..4a132eb5d537 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -9,14 +9,12 @@ import { } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { - elapsedShare, + describeWindowPace, formatDuration, formatResetsIn, - type LimitPace, - paceOf, remainingPercent, + type WindowPace, } from "@t3tools/shared/usageLimits"; -import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; import { Fragment, type ReactNode, useState } from "react"; import { usePrimarySettings } from "../../hooks/useSettings"; @@ -38,12 +36,6 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { UsageLimitsPooled } from "./UsageLimitsPooled"; import { PROVIDER_PRESENTATION } from "./usageProviders"; -const PACE: Record = { - ahead: { label: "Ahead of pace: spending faster than the window elapses", icon: TrendingUpIcon }, - on: { label: "On pace with the window", icon: GaugeIcon }, - under: { label: "Under pace: headroom left for the rest of the window", icon: TrendingDownIcon }, -}; - /** The series colour the cost chart uses for this driver, so the two views read as one. */ export function barColor(driver: ServerProvider["driver"]): string { const kind: UsageProviderKind | undefined = @@ -51,32 +43,48 @@ export function barColor(driver: ServerProvider["driver"]): string { return kind ? PROVIDER_PRESENTATION[kind].color : "var(--foreground)"; } -/** Pace as a glyph with the words on hover. */ -export function PaceIcon({ pace }: { readonly pace: LimitPace }) { - const Icon = PACE[pace].icon; +/** + * Grey tick, 10% taller than the bar on each side. `WindowBar` draws it, and + * Usage Limits segments draw this same element. + */ +export function PaceLine({ percent }: { readonly percent: number }) { return ( - - - } - > - - - {PACE[pace].label} - + + ); +} + +/** The composer tooltip. Usage Limits segment details render this same block. */ +export function WindowPaceCopy({ + pace, + resetsAt, + resetsIn, +}: { + readonly pace: WindowPace; + readonly resetsAt: string | null; + readonly resetsIn: string | null; +}) { + return ( +
+ {pace.summary} + {pace.detail ? {pace.detail} : null} + {resetsAt ? ( + + Resets {resetsAt} + {resetsIn ? ` · ${resetsIn}` : ""} + + ) : null} +
); } /** - * One window as a full-width bar from the moment it opened to its reset. - * The fill is the share of quota spent; the hairline is how far into the - * window the clock is, which is also where even spending would have put the - * fill. Hover for the exact figures and reset time. + * One window, from open to reset. The fill is quota left. The line is how + * much of the window is left, which is where even spending would have put + * the fill. */ function WindowBar({ color, @@ -88,17 +96,12 @@ function WindowBar({ readonly now: number; }) { const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); - const remaining = remainingPercent(window); - const elapsed = elapsedShare(window, now); - // The fill is quota left, so the even-spending mark is the time left. - const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100); + const pace = describeWindowPace(window, now); const resetsIn = formatResetsIn(window, now); const resetsAt = window.resetsAt ? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now) : null; - const summary = `${window.label}: ${remaining}% left${ - timeLeft === null ? "" : `, ${timeLeft}% of the window left` - }${resetsIn ? `, ${resetsIn}` : ""}`; + const summary = `${window.label}: ${pace.summary}${resetsIn ? `, ${resetsIn}` : ""}`; return ( @@ -112,44 +115,30 @@ function WindowBar({ /> } > -
- {remaining > 0 ? ( -
- ) : null} - {timeLeft !== null ? ( - - ) : null} - - -
- - {remaining}% left{timeLeft !== null ? ` · ${timeLeft}% of the window left` : ""} - - {timeLeft !== null ? ( - The line is where even spending would be. - ) : null} - {resetsAt ? ( - - Resets {resetsAt} - {resetsIn ? ` · ${resetsIn}` : ""} - - ) : null} +
+
+
+ {pace.remaining > 0 ? ( +
+ ) : null} +
+ {pace.timeLeft !== null ? : null} +
+ + + ); } /** - * One account's windows as rows: label and percent, bar, pace and countdown. - * Compact rows fit the composer panel with narrower columns. + * One account's windows. Each row is the label, the percent left, the bar, + * and the countdown. Compact rows use narrower columns in the composer. */ export function LimitWindows({ driver, @@ -168,11 +157,10 @@ export function LimitWindows({ className={ compact ? "grid grid-cols-[minmax(0,9rem)_minmax(3rem,1fr)_auto] gap-x-3 gap-y-0.5" - : "grid grid-cols-[11rem_minmax(0,1fr)_7rem] gap-x-4 gap-y-1" + : "grid grid-cols-[11rem_minmax(0,1fr)_minmax(7rem,auto)] gap-x-4 gap-y-1" } > {windows.map((window) => { - const pace = paceOf(window, now); const resetsIn = formatResetsIn(window, now); return ( @@ -183,9 +171,8 @@ export function LimitWindows({ - - {pace ? : null} - {resetsIn ?? ""} + + {resetsIn ?? ""} ); diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index bdb291836328..53f580474e43 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -3,6 +3,7 @@ import { collectLimitNotices, collectLimitPools, cursorUsageWindowDetails, + describeWindowPace, displayLimitWindows, formatResetsIn, type LimitAccount, @@ -24,8 +25,9 @@ import { Button } from "../ui/button"; import { Alert, AlertTitle } from "../ui/alert"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { - PaceIcon, + PaceLine, ResetCreditDialog, + WindowPaceCopy, barColor, resetCreditsSummary, useResetCredit, @@ -148,8 +150,11 @@ function SegmentPopover({ readonly onRedeem: () => void; }) { const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); - const remaining = remainingPercent(window); + const pace = describeWindowPace(window, now); const resetsIn = formatResetsIn(window, now); + const resetsAt = window.resetsAt + ? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now) + : null; const where = account.environments.length > 0 ? account.environments.map((environment) => environment.label).join(", ") @@ -182,13 +187,7 @@ function SegmentPopover({ ) : null}
- {remaining}% - {window.resetsAt ? ( - - {formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)} - {resetsIn ? ` · ${resetsIn.replace("resets in ", "in ")}` : ""} - - ) : null} + {reset && reset.restoresPercent > 0 ? ( +{reset.restoresPercent}% of pool ) : null} @@ -237,7 +236,7 @@ function PoolSegment({ readonly showAccountName: boolean; }) { const [open, setOpen] = useState(false); - const remaining = remainingPercent(window); + const pace = describeWindowPace(window, now); const resetsIn = formatResetsIn(window, now); const credits = account.limits.resetCredits?.availableCount ?? 0; return ( @@ -248,59 +247,68 @@ function PoolSegment({