diff --git a/src/features/quota/QuotaPage.tsx b/src/features/quota/QuotaPage.tsx index c009f873..b6ff445c 100644 --- a/src/features/quota/QuotaPage.tsx +++ b/src/features/quota/QuotaPage.tsx @@ -21,6 +21,7 @@ import type { AuthFileItem, ResolvedTheme } from '@/types'; import { ProviderTabs } from '@/features/authFiles/components/ProviderTabs'; import { QuotaHeader } from './components/QuotaHeader'; import { QuotaCard } from './components/QuotaCard'; +import { QuotaTimeline } from './components/QuotaTimeline'; import { CARD_ENTRANCE_BUDGET_MS, QUOTA_PAGE_SIZE, @@ -44,6 +45,12 @@ import styles from './QuotaPage.module.scss'; const TAB_IDS: string[] = ['all', ...QUOTA_TAB_ORDER]; const SKELETON_CARD_COUNT = 6; +/** + * 时间线泳道名 = 卡片标题,两者必须一致。卡片显示的就是文件名,所以这里是恒等。 + * 提到模块级是为了引用稳定 —— 它进了泳道 memo 的依赖数组。 + */ +const displayNameFor = (name: string) => name; + export function QuotaPage() { const { t } = useTranslation(); const connectionStatus = useAuthStore((state) => state.connectionStatus); @@ -304,6 +311,15 @@ export function QuotaPage() { )} + + {/* 时间线跟随当前 tab,但不跟随分页:它回答的是「额度会不会同时恢复」, + 那是泳道之间的关系,只画当页 12 条就失去了对比的意义。 */} + ); diff --git a/src/features/quota/components/QuotaTimeline.module.scss b/src/features/quota/components/QuotaTimeline.module.scss new file mode 100644 index 00000000..43b50936 --- /dev/null +++ b/src/features/quota/components/QuotaTimeline.module.scss @@ -0,0 +1,360 @@ +@use '../../../styles/variables' as *; +@use '../../../styles/mixins' as *; + +// Fixed-width lane column so every row's bars start at the same x — the whole +// point of the chart is comparing when lanes end relative to each other. +$lane-width: 210px; + +.timeline { + display: flex; + flex-direction: column; + gap: $spacing-md; +} + +.head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: $spacing-md; + flex-wrap: wrap; +} + +.title { + margin: 0; + font-size: 18px; + font-weight: 650; + color: var(--text-primary); +} + +.range { + margin: 2px 0 0; + font-size: 12px; + color: var(--text-quaternary); + font-variant-numeric: tabular-nums; +} + +.controls { + display: flex; + align-items: center; + gap: $spacing-sm; + flex-wrap: wrap; +} + +.nav, +.modes { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 999px; + + button { + padding: 4px 12px; + font: inherit; + font-size: 12.5px; + color: var(--text-secondary); + background: transparent; + border: 1px solid transparent; + border-radius: 999px; + cursor: pointer; + transition: all $transition-fast; + + &:hover:not(:disabled) { + color: var(--text-primary); + } + + &:disabled { + opacity: 0.5; + cursor: default; + } + + &[aria-pressed='true'] { + background: var(--floating-surface, var(--bg-primary)); + border-color: var(--border-hover); + color: var(--text-primary); + } + } +} + +.chart { + border: 1px solid var(--border-color); + border-radius: $radius-lg; + background: var(--bg-primary); + overflow: hidden; +} + +.axis, +.lane { + display: grid; + grid-template-columns: $lane-width 1fr; +} + +.axis { + border-bottom: 1px solid var(--border-color); +} + +.axisLabel { + padding: 8px 12px; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-quaternary); + display: flex; + align-items: flex-end; +} + +.axisCells { + display: flex; +} + +.axisCell { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + padding: 6px 2px; + border-left: 1px solid var(--border-color); + + &[data-weekend='1'] { + background: rgba(255, 255, 255, 0.014); + } + + // The current day is the reference point for reading the whole chart. + &[data-today='1'] { + background: rgba(255, 255, 255, 0.03); + } +} + +.axisWeekday { + font-size: 10px; + color: var(--text-quaternary); + min-height: 12px; +} + +.axisDate { + font-size: 11.5px; + font-weight: 600; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.lane { + border-top: 1px solid var(--border-color); + + &:hover { + background: rgba(255, 255, 255, 0.012); + } +} + +.laneHead { + display: flex; + flex-direction: column; + gap: 3px; + padding: 9px 12px; + min-width: 0; +} + +.laneTop { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.laneDot { + flex: none; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--provider-accent, var(--text-quaternary)); +} + +.laneName { + font-size: 12.5px; + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lanePeriod { + flex: none; + padding: 0 6px; + border-radius: 999px; + font-size: 10px; + font-weight: 600; + color: var(--count-badge-text, var(--text-secondary)); + background: var(--count-badge-bg, var(--bg-tertiary)); +} + +.laneLimits { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.laneLimit { + padding: 1px 6px; + border-radius: $radius-sm; + font-size: 10.5px; + color: var(--text-quaternary); + background: var(--bg-tertiary); + + b { + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + } +} + +.track { + position: relative; + min-height: 46px; + display: flex; + align-items: center; +} + +.trackGrid { + position: absolute; + inset: 0; + display: flex; + + > span { + flex: 1 1 0; + border-left: 1px solid var(--border-color); + + &[data-weekend='1'] { + background: rgba(255, 255, 255, 0.014); + } + } +} + +// Vertical "now" marker, above the grid but below the bars so it never hides +// a label. +.nowLine { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: var(--text-tertiary, var(--text-quaternary)); + opacity: 0.8; + z-index: 1; +} + +.window { + position: relative; + z-index: 2; + height: 22px; + display: flex; + align-items: center; + padding: 0 8px; + border-radius: 999px; + overflow: hidden; + font-size: 10.5px; + white-space: nowrap; + position: absolute; +} + +// Current window: solid, in the provider's colour — the row's focal point. +.windowLive { + background: color-mix(in srgb, var(--provider-accent) 26%, transparent); + border: 1px solid color-mix(in srgb, var(--provider-accent) 45%, transparent); + color: var(--text-primary); +} + +// Upcoming: dashed outline, nothing consumed yet. +.windowNext { + background: transparent; + border: 1px dashed color-mix(in srgb, var(--provider-accent) 32%, transparent); + color: var(--text-quaternary); +} + +// Elapsed: recedes. +.windowPast { + background: color-mix(in srgb, var(--provider-accent) 8%, transparent); + border: 1px solid transparent; + color: var(--text-quaternary); +} + +// Consumed portion of the current window, drawn behind its label. +.windowFill { + position: absolute; + left: 0; + top: 0; + bottom: 0; + background: color-mix(in srgb, var(--provider-accent) 34%, transparent); + pointer-events: none; +} + +.windowLabel { + position: relative; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; +} + +.laneIdle { + position: relative; + z-index: 2; + padding-left: 10px; + font-size: 11px; + color: var(--text-quaternary); +} + +.legend { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: $spacing-md; + font-size: 11px; + color: var(--text-quaternary); +} + +.legendItem { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.swatch { + width: 22px; + height: 10px; + border-radius: 999px; +} + +.swatchLive { + background: color-mix(in srgb, var(--text-tertiary, #888) 34%, transparent); + border: 1px solid color-mix(in srgb, var(--text-tertiary, #888) 50%, transparent); +} + +.swatchNext { + border: 1px dashed color-mix(in srgb, var(--text-tertiary, #888) 45%, transparent); +} + +.swatchPast { + background: color-mix(in srgb, var(--text-tertiary, #888) 12%, transparent); +} + +.legendNote { + flex: 1 1 320px; + min-width: 0; +} + +@include mobile { + // The lane column can't shrink much before names become unreadable, so the + // chart scrolls horizontally rather than compressing to illegibility. + .chart { + overflow-x: auto; + } + + .axis, + .lane { + min-width: 720px; + } +} diff --git a/src/features/quota/components/QuotaTimeline.tsx b/src/features/quota/components/QuotaTimeline.tsx new file mode 100644 index 00000000..c134d8e9 --- /dev/null +++ b/src/features/quota/components/QuotaTimeline.tsx @@ -0,0 +1,380 @@ +/** + * Quota windows timeline. + * + * The cards answer "how much is left"; this answers "when does it come back, + * and does it all come back at once". Four credentials resetting the same + * evening is a very different position from four staggered across a week, and + * no per-card percentage shows that. + * + * All projection maths lives in quotaTimelineModel.ts — this file is layout + * only. (The model is named ...Model rather than matching this component, + * because a case-insensitive filesystem cannot hold both QuotaTimeline.tsx and + * quotaTimeline.ts.) + */ + +import { useEffect, useMemo, useState } from 'react'; +import type { CSSProperties } from 'react'; +import { useTranslation } from 'react-i18next'; +import { TYPE_COLORS } from '@/utils/quota'; +import type { ResolvedTheme, ThemeColors } from '@/types'; +import { + buildTimelineLane, + laneHasWindow, + projectLane, + timelineSpan, + DAY_MS, +} from '../quotaTimelineModel'; +import type { TimelineLane, TimelineMode } from '../quotaTimelineModel'; +import type { QuotaFileEntry } from '../logic'; +import type { QuotaCardState } from '../providers'; +import styles from './QuotaTimeline.module.scss'; + +const WEEKDAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] as const; + +const pad = (value: number) => String(value).padStart(2, '0'); +const formatDay = (ms: number) => { + const d = new Date(ms); + return `${pad(d.getMonth() + 1)}/${pad(d.getDate())}`; +}; +const formatTime = (ms: number) => { + const d = new Date(ms); + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; +}; + +export interface QuotaTimelineProps { + entries: QuotaFileEntry[]; + /** + * Quota state for an entry. An entry carries only the file and its provider — + * loaded quota lives in the store — so the lookup is injected rather than read + * off the entry, and lanes see exactly what the cards see. + */ + quotaFor: (entry: QuotaFileEntry) => QuotaCardState | undefined; + displayNameFor: (name: string) => string; + resolvedTheme: ResolvedTheme; + /** Injectable for tests/screenshots; defaults to the real clock. */ + now?: number; +} + +export function QuotaTimeline({ + entries, + quotaFor, + displayNameFor, + resolvedTheme, + now: nowProp, +}: QuotaTimelineProps) { + const { t } = useTranslation(); + const [mode, setMode] = useState('weekly'); + const [offset, setOffset] = useState(0); + + // The clock is state, not a read during render: bars are classified + // past/live/next against it and the marker is positioned by it, so it has to + // advance on its own or the chart quietly goes stale on a long-lived tab. + // A minute is finer than any window boundary here (the shortest is 5 hours). + const [tick, setTick] = useState(() => Date.now()); + useEffect(() => { + if (nowProp !== undefined) return; // fixed clock: tests and screenshots + const id = setInterval(() => setTick(Date.now()), 60_000); + return () => clearInterval(id); + }, [nowProp]); + + const now = nowProp ?? tick; + + const span = useMemo(() => timelineSpan(mode, offset, now), [mode, offset, now]); + + // Lanes with nothing to draw are dropped, not rendered blank: an xAI account + // on a monthly plan has no weekly window and never will, and a credential + // whose quota hasn't been loaded has nothing to show yet. The chart grows as + // data arrives rather than opening as a wall of empty rows. + const lanes = useMemo( + () => + entries + .map((entry) => + buildTimelineLane({ + name: entry.file.name, + displayName: displayNameFor(entry.file.name), + provider: entry.type, + quota: quotaFor(entry), + // Bound the lane's window to the visible span, so a fortnight view + // draws weekly bars rather than ~67 five-hour slivers. + maxPeriodHours: span.days * 24, + }) + ) + .filter(laneHasWindow), + [entries, quotaFor, displayNameFor, span.days] + ); + + /** Weekly: one cell per day. Session: one per 6 hours. */ + const cells = useMemo(() => { + const zoomed = mode === 'session'; + const count = zoomed ? span.days * 4 : span.days; + const cellMs = (span.endMs - span.startMs) / count; + const todayStart = new Date(now).setHours(0, 0, 0, 0); + + return Array.from({ length: count }, (_, index) => { + const at = span.startMs + index * cellMs; + const date = new Date(at); + const isDayStart = !zoomed || date.getHours() === 0; + return { + at, + isDayStart, + isToday: new Date(at).setHours(0, 0, 0, 0) === todayStart, + isWeekend: date.getDay() === 0 || date.getDay() === 6, + weekday: t(`quota_management.weekday_${WEEKDAY_KEYS[date.getDay()]}`, { + defaultValue: WEEKDAY_KEYS[date.getDay()], + }), + label: isDayStart ? formatDay(at) : `${pad(date.getHours())}:00`, + }; + }); + }, [mode, span, now, t]); + + // Only draw the marker when the current moment is actually on screen. + const nowPercent = + now >= span.startMs && now < span.endMs + ? ((now - span.startMs) / (span.endMs - span.startMs)) * 100 + : null; + + if (lanes.length === 0) return null; + + return ( +
+
+
+

+ {t('quota_management.windows_title', { defaultValue: 'Quota windows' })} +

+

+ {formatDay(span.startMs)} – {formatDay(span.endMs - DAY_MS)} + {' · '} + {mode === 'weekly' + ? t('quota_management.windows_span_weekly', { defaultValue: 'two weeks' }) + : t('quota_management.windows_span_session', { defaultValue: 'three days' })} + {offset === 0 && ` · ${t('quota_management.windows_current', { defaultValue: 'current' })}`} +

+
+ +
+
+ + + +
+ +
+ {(['weekly', 'session'] as const).map((value) => ( + + ))} +
+
+
+ +
+
+
+ {t('quota_management.windows_credential', { defaultValue: 'Credential' })} +
+
+ {cells.map((cell) => ( +
+ {cell.isDayStart ? cell.weekday : ''} + {cell.label} +
+ ))} +
+
+ + {lanes.map((lane) => ( + + ))} +
+ +
+ + + {t('quota_management.windows_legend_current', { defaultValue: 'current window' })} + + + + {t('quota_management.windows_legend_upcoming', { defaultValue: 'upcoming' })} + + + + {t('quota_management.windows_legend_elapsed', { defaultValue: 'elapsed' })} + + + {mode === 'weekly' + ? t('quota_management.windows_note_weekly', { + defaultValue: + 'Each bar is one full quota window, drawn from when it opened to when it resets. Lanes ending together compete for the same days.', + }) + : t('quota_management.windows_note_session', { + defaultValue: + 'Each bar is one 5-hour window. Only credentials with a window counting down can be projected; the rest stay empty rather than invented.', + })} + +
+
+ ); +} + +interface LaneProps { + lane: TimelineLane; + span: { startMs: number; endMs: number; days: number }; + now: number; + mode: TimelineMode; + cells: { at: number; isWeekend: boolean; isDayStart: boolean }[]; + nowPercent: number | null; + resolvedTheme: ResolvedTheme; +} + +function Lane({ lane, span, now, mode, cells, nowPercent, resolvedTheme }: LaneProps) { + const { t } = useTranslation(); + + const windows = useMemo( + () => projectLane(lane, span.startMs, span.endMs, now, mode), + [lane, span, now, mode] + ); + + const colorSet = TYPE_COLORS[lane.provider] || TYPE_COLORS.unknown; + const color: ThemeColors = + resolvedTheme === 'dark' && colorSet.dark ? colorSet.dark : colorSet.light; + + // Sub-day windows are labelled in hours — rounding 5h to days gives "0d". + const periodLabel = + mode === 'session' + ? '5h' + : !lane.periodHours + ? '' + : lane.periodHours < 24 + ? `${Math.round(lane.periodHours)}h` + : `${Math.round(lane.periodHours / 24)}d`; + + return ( +
+
+
+ + + {lane.displayName} + + {periodLabel && {periodLabel}} +
+
+ {lane.limits.map((limit) => ( + + {limit.label} {limit.remaining}% + + ))} +
+
+ +
+
+ {cells.map((cell) => ( + + ))} +
+ + {nowPercent !== null && ( +
+ )} + + {windows.length === 0 ? ( + + {t('quota_management.windows_idle', { + defaultValue: 'no window counting down', + })} + + ) : ( + windows.map((window) => { + // A label needs room to read; below that the bar speaks for itself + // and the detail lives in the tooltip. + const showLabel = window.widthPercent > (mode === 'session' ? 4.5 : 9); + const endText = + mode === 'session' + ? formatTime(window.endMs) + : `${formatDay(window.endMs)} ${formatTime(window.endMs)}`; + + return ( +
+ {/* Consumed portion of the *current* window only — a past or + future window has no meaningful fill. */} + {window.state === 'live' && lane.remaining !== null && ( + + )} + {showLabel && ( + + {window.state === 'live' && lane.remaining !== null + ? `${lane.remaining}% · ` + : ''} + {endText} + + )} +
+ ); + }) + )} +
+
+ ); +} + +const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1); diff --git a/src/features/quota/providers/claude/data.ts b/src/features/quota/providers/claude/data.ts index bc3921d3..70f095c7 100644 --- a/src/features/quota/providers/claude/data.ts +++ b/src/features/quota/providers/claude/data.ts @@ -18,10 +18,12 @@ import { CLAUDE_USAGE_URL, CLAUDE_REQUEST_HEADERS, CLAUDE_USAGE_WINDOW_KEYS, + claudePeriodHours, normalizeNumberValue, normalizeStringValue, parseClaudeUsagePayload, formatQuotaResetTime, + resolveResetMs, createStatusError, isClaudeFile, isDisabledAuthFile, @@ -70,6 +72,10 @@ export const buildClaudeQuotaWindows = ( labelKey, usedPercent, resetLabel, + // Claude states the period nowhere in the payload, so it comes from the + // key: `five_hour` is the rolling window, everything else is weekly. + resetAtMs: resolveResetMs([typedWindow.resets_at]), + periodHours: claudePeriodHours(key), }); } @@ -82,6 +88,10 @@ export const buildClaudeQuotaWindows = ( labelKey: 'claude_quota.seven_day_fable', usedPercent, resetLabel: formatQuotaResetTime(fableLimit.resets_at ?? undefined), + // `weekly_scoped` is a 7-day window by definition, so the timeline can + // place this row alongside the ones derived from the named keys. + resetAtMs: resolveResetMs([fableLimit.resets_at]), + periodHours: claudePeriodHours('seven_day'), }); } } diff --git a/src/features/quota/providers/codex/data.ts b/src/features/quota/providers/codex/data.ts index 4b92a907..dfbb10c4 100644 --- a/src/features/quota/providers/codex/data.ts +++ b/src/features/quota/providers/codex/data.ts @@ -24,6 +24,9 @@ import { normalizeStringValue, normalizeCodexResetCreditsPayload, parseCodexUsagePayload, + parseOffsetSecondsToMs, + periodHoursFromSeconds, + resolveResetMs, resolveCodexChatgptAccountId, resolveCodexPlanType, resolveCodexSubscriptionActiveUntil, @@ -100,12 +103,21 @@ export const buildCodexQuotaWindows = ( const usedPercentRaw = normalizeNumberValue(window.used_percent ?? window.usedPercent); const isLimitReached = Boolean(limitReached) || allowed === false; const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null); + // Keep the raw instant beside the label — see utils/quota/resetInstants. + const resetAtMs = + resolveResetMs([window.reset_at, window.resetAt]) ?? + parseOffsetSecondsToMs(window.reset_after_seconds ?? window.resetAfterSeconds, Date.now()); + const periodHours = periodHoursFromSeconds( + window.limit_window_seconds ?? window.limitWindowSeconds + ); windows.push({ id, label, labelKey, labelParams, usedPercent, + resetAtMs, + periodHours, resetLabel, }); }; diff --git a/src/features/quota/quotaTimelineModel.ts b/src/features/quota/quotaTimelineModel.ts new file mode 100644 index 00000000..f8135bc1 --- /dev/null +++ b/src/features/quota/quotaTimelineModel.ts @@ -0,0 +1,406 @@ +/** + * Quota windows timeline: lane derivation and window projection. + * + * Pure functions over board entries — no React, no clock of its own (`now` is + * always passed in), so every case here is directly testable. + * + * The chart answers one question the cards can't: *when does capacity come + * back, and does it come back all at once?* Four credentials all resetting on + * the same evening is a very different situation from four staggered across a + * week, and no per-card percentage shows that. + */ + +import type { QuotaProviderType } from './providers/types'; + +export const HOUR_MS = 3_600_000; +export const DAY_MS = 24 * HOUR_MS; + +/** Weekly view spans a fortnight; the session view zooms to three days. */ +export type TimelineMode = 'weekly' | 'session'; + +export const TIMELINE_SPAN_DAYS: Record = { + weekly: 14, + session: 3, +}; + +/** The rolling window the session view projects, in hours. */ +const SESSION_PERIOD_HOURS = 5; + +/** A limit summarized in the lane's left column. */ +export interface TimelineLimit { + label: string; + /** Remaining percent, 0..100. */ + remaining: number; +} + +/** One credential's row in the chart. */ +export interface TimelineLane { + name: string; + displayName: string; + provider: QuotaProviderType; + /** Instant a window boundary falls on; all other boundaries derive from it. */ + anchorMs: number | null; + /** Window length in hours. */ + periodHours: number | null; + /** Remaining percent for the window being drawn, or null when unknown. */ + remaining: number | null; + limits: TimelineLimit[]; +} + +/** One drawn bar: a single window occurrence within the visible span. */ +export interface TimelineWindow { + startMs: number; + endMs: number; + /** Fractions of the span, 0..100, already clipped to the visible range. */ + leftPercent: number; + widthPercent: number; + state: 'past' | 'live' | 'next'; +} + +/** + * Every window boundary of `periodMs` aligned to `anchorMs`, covering + * [fromMs, toMs]. + * + * The anchor is a known *reset* instant, so windows are projected backwards and + * forwards from it by whole periods. Both directions matter: the visible span + * usually starts before the current window opened, and the point of the chart + * is what's coming. + */ +export function windowsIn( + anchorMs: number, + periodMs: number, + fromMs: number, + toMs: number +): { startMs: number; endMs: number }[] { + if (!Number.isFinite(anchorMs) || !(periodMs > 0)) return []; + if (!(toMs > fromMs)) return []; + + // Guard against a pathological period (a bad payload) turning this into a + // multi-million-iteration loop. + const maxWindows = Math.ceil((toMs - fromMs) / periodMs) + 2; + if (maxWindows > 1000) return []; + + let end = anchorMs + Math.ceil((fromMs - anchorMs) / periodMs) * periodMs; + const out: { startMs: number; endMs: number }[] = []; + while (end - periodMs < toMs) { + out.push({ startMs: end - periodMs, endMs: end }); + end += periodMs; + } + return out; +} + +/** Start of the local day containing `ms`. */ +export function startOfDay(ms: number): number { + const d = new Date(ms); + d.setHours(0, 0, 0, 0); + return d.getTime(); +} + +/** Start of the local week (Sunday) containing `ms`. */ +export function startOfWeek(ms: number): number { + const d = new Date(startOfDay(ms)); + d.setDate(d.getDate() - d.getDay()); + return d.getTime(); +} + +/** + * Visible span for a mode and offset. + * + * Weekly steps a week at a time from the containing Sunday; session steps a day + * at a time from today. Uses date arithmetic rather than adding fixed + * millisecond counts so a DST transition inside the span doesn't shift every + * subsequent day by an hour. + */ +export function timelineSpan( + mode: TimelineMode, + offset: number, + now: number +): { startMs: number; endMs: number; days: number } { + const days = TIMELINE_SPAN_DAYS[mode]; + const base = new Date(mode === 'weekly' ? startOfWeek(now) : startOfDay(now)); + base.setDate(base.getDate() + offset * (mode === 'weekly' ? 7 : 1)); + const startMs = base.getTime(); + + const end = new Date(startMs); + end.setDate(end.getDate() + days); + + return { startMs, endMs: end.getTime(), days }; +} + +/** + * Project one lane's windows onto a span, clipped and positioned. + * + * Windows falling entirely outside the span are dropped rather than returned + * with a zero width, so a caller can treat an empty result as "nothing to draw". + */ +export function projectLane( + lane: TimelineLane, + spanStartMs: number, + spanEndMs: number, + now: number, + mode: TimelineMode +): TimelineWindow[] { + const periodHours = mode === 'session' ? SESSION_PERIOD_HOURS : lane.periodHours; + if (lane.anchorMs === null || !periodHours) return []; + + const span = spanEndMs - spanStartMs; + if (span <= 0) return []; + + const toPercent = (ms: number) => ((ms - spanStartMs) / span) * 100; + + return windowsIn(lane.anchorMs, periodHours * HOUR_MS, spanStartMs, spanEndMs) + .map((window): TimelineWindow | null => { + const left = Math.max(0, toPercent(window.startMs)); + const right = Math.min(100, toPercent(window.endMs)); + if (right <= 0 || left >= 100 || right <= left) return null; + + const state: TimelineWindow['state'] = + window.endMs <= now ? 'past' : window.startMs <= now ? 'live' : 'next'; + + return { + startMs: window.startMs, + endMs: window.endMs, + leftPercent: left, + widthPercent: right - left, + state, + }; + }) + .filter((window): window is TimelineWindow => window !== null); +} + +/** + * Pick the window a lane is drawn from: the one whose period best fits the + * visible span, tie-broken by the soonest reset. + * + * A credential usually has several (5-hour, 7-day, per-model). Picking the + * soonest reset outright looks right and renders uselessly: across a fortnight + * the 5-hour window always resets first, so every lane becomes ~67 slivers + * instead of two readable weekly bars. The long window is what a two-week view + * is *for*; the session view exists precisely to see the short one. + * + * `maxPeriodHours` bounds what counts as fitting — the caller passes the span. + * With nothing under the bound, the shortest available window is used rather + * than drawing nothing. + */ +export function pickLaneWindow( + windows: readonly T[], + maxPeriodHours?: number +): T | null { + const usable = windows.filter( + (window) => typeof window.resetAtMs === 'number' && Number.isFinite(window.resetAtMs) + ); + if (usable.length === 0) return null; + + const periodOf = (window: T) => + typeof window.periodHours === 'number' && window.periodHours > 0 ? window.periodHours : 0; + + const fitting = + maxPeriodHours === undefined + ? usable + : usable.filter((window) => periodOf(window) <= maxPeriodHours); + + // Longest period that still fits; soonest reset breaks a tie. + const pool = fitting.length > 0 ? fitting : usable; + return pool.reduce((best, window) => { + const byPeriod = periodOf(window) - periodOf(best); + if (byPeriod !== 0) return byPeriod > 0 ? window : best; + return (window.resetAtMs as number) < (best.resetAtMs as number) ? window : best; + }); +} + +/** + * Whether a lane has anything to draw, in any mode. + * + * Without an anchor no bar can be projected at any span or zoom, so the row + * would be permanently blank. The card grid above already enumerates every + * credential, so a blank row here adds no information — it just makes the + * chart taller and the real lanes harder to compare against each other. + * + * Note this is about the lane, not the current view: an anchored lane whose + * windows fall outside the visible span still gets a row, and says so. + */ +export function laneHasWindow(lane: TimelineLane): boolean { + return lane.anchorMs !== null; +} + +/* ------------------------------------------------------------------ lanes */ + +/** Shape the lane builder reads. Deliberately structural — see the note below. */ +interface WindowLike { + label?: string; + usedPercent?: number | null; + resetAtMs?: number | null; + periodHours?: number | null; +} + +interface KimiRowLike { + label?: string; + labelKey?: string; + used: number; + limit: number; + resetAtMs?: number | null; + periodHours?: number | null; +} + +interface XaiBillingLike { + periodType?: string; + usagePercent?: number | null; + resetAtMs?: number | null; + periodHours?: number | null; + productUsage?: { product?: string; usagePercent?: number | null }[]; +} + +interface AntigravityBucketLike { + label?: string; + /** Fraction 0..1 of quota REMAINING — the inverse of the percent-used providers. */ + remainingFraction?: number | null; + resetAtMs?: number | null; + periodHours?: number | null; +} + +export interface TimelineLaneInput { + name: string; + displayName: string; + provider: QuotaProviderType; + quota: { status?: string } | undefined; + /** + * Longest window period worth drawing, in hours — normally the visible span. + * A window longer than the whole view can't show a boundary, and a much + * shorter one degenerates into slivers. + */ + maxPeriodHours?: number; +} + +const clampPercent = (value: number) => Math.min(100, Math.max(0, value)); + +/** + * Build a lane for one credential. + * + * Read structurally per provider rather than through a normalized model: the + * five state shapes disagree about where a window lives and what its percentage + * means, and flattening them would lose exactly the detail the chart needs. + * + * Providers that expose no usable reset instant produce a lane with a null + * anchor. That renders as an explicitly empty row rather than being dropped — + * a missing credential reads as an oversight, an empty one reads as "nothing + * scheduled", which is the truth. + */ +export function buildTimelineLane(input: TimelineLaneInput): TimelineLane { + const { name, displayName, provider, quota, maxPeriodHours } = input; + const empty: TimelineLane = { + name, + displayName, + provider, + anchorMs: null, + periodHours: null, + remaining: null, + limits: [], + }; + + if (!quota || quota.status !== 'success') return empty; + + if (provider === 'claude' || provider === 'codex') { + const windows = ((quota as { windows?: WindowLike[] }).windows ?? []).filter( + (window) => typeof window.resetAtMs === 'number' + ); + const chosen = pickLaneWindow(windows, maxPeriodHours); + if (!chosen) return empty; + + return { + ...empty, + anchorMs: chosen.resetAtMs ?? null, + periodHours: chosen.periodHours ?? null, + // Claude and Codex store percent USED. + remaining: + typeof chosen.usedPercent === 'number' ? clampPercent(100 - chosen.usedPercent) : null, + limits: windows + .filter((window) => typeof window.usedPercent === 'number') + .map((window) => ({ + label: window.label ?? '', + remaining: clampPercent(100 - (window.usedPercent as number)), + })), + }; + } + + if (provider === 'xai') { + const billing = (quota as { billing?: XaiBillingLike | null }).billing; + // Only the weekly limit is a quota window. The monthly figure on the same + // summary is a billing cycle — a spend cap rolling over, not rate-limited + // capacity coming back — so an account without a weekly limit contributes + // no lane rather than a lane that means something different from the rest. + if (!billing || billing.periodType !== 'weekly') return empty; + if (typeof billing.resetAtMs !== 'number' || !Number.isFinite(billing.resetAtMs)) return empty; + + const remaining = + typeof billing.usagePercent === 'number' ? clampPercent(100 - billing.usagePercent) : null; + + return { + ...empty, + anchorMs: billing.resetAtMs, + // A payload that states an end without a start can't derive its own + // length; weekly is what `periodType` already told us. + periodHours: billing.periodHours ?? 24 * 7, + remaining, + // Per-product usage is the closest analogue to the other providers' + // per-window breakdown. + limits: (billing.productUsage ?? []) + .map((entry) => ({ + label: entry.product ?? '', + remaining: + typeof entry.usagePercent === 'number' ? clampPercent(100 - entry.usagePercent) : null, + })) + .filter((limit): limit is TimelineLimit => limit.remaining !== null), + }; + } + + if (provider === 'antigravity') { + // Buckets live one level down, inside groups, and the groups are a display + // concern the chart doesn't care about — flatten them. + const buckets = ((quota as { groups?: { buckets?: AntigravityBucketLike[] }[] }).groups ?? []) + .flatMap((group) => group.buckets ?? []) + .filter((bucket) => typeof bucket.resetAtMs === 'number'); + const chosen = pickLaneWindow(buckets, maxPeriodHours); + if (!chosen) return empty; + + // Antigravity reports the fraction REMAINING, not percent used. + const remainingOf = (bucket: AntigravityBucketLike) => + typeof bucket.remainingFraction === 'number' + ? clampPercent(Math.round(bucket.remainingFraction * 100)) + : null; + + return { + ...empty, + anchorMs: chosen.resetAtMs ?? null, + periodHours: chosen.periodHours ?? null, + remaining: remainingOf(chosen), + limits: buckets + .map((bucket) => ({ label: bucket.label ?? '', remaining: remainingOf(bucket) })) + .filter((limit): limit is TimelineLimit => limit.remaining !== null), + }; + } + + if (provider === 'kimi') { + const rows = ((quota as { rows?: KimiRowLike[] }).rows ?? []).filter( + (row) => typeof row.resetAtMs === 'number' + ); + const chosen = pickLaneWindow(rows, maxPeriodHours); + if (!chosen) return empty; + + // Kimi reports raw counts; remaining is derived. + const remainingOf = (row: KimiRowLike) => + row.limit > 0 ? clampPercent(Math.round(((row.limit - row.used) / row.limit) * 100)) : null; + + return { + ...empty, + anchorMs: chosen.resetAtMs ?? null, + periodHours: chosen.periodHours ?? null, + remaining: remainingOf(chosen), + limits: rows + .map((row) => ({ label: row.label ?? '', remaining: remainingOf(row) })) + .filter((limit): limit is TimelineLimit => limit.remaining !== null), + }; + } + + return empty; +} + diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7a06dbd4..2b5ff059 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1154,7 +1154,30 @@ "meta_loaded": "{{count}} loaded", "meta_attention": "{{count}} need attention", "empty_title": "No quota-capable credentials", - "empty_desc": "Connect an Antigravity, Claude, Codex, xAI, or Kimi credential to track its quota here." + "empty_desc": "Connect an Antigravity, Claude, Codex, xAI, or Kimi credential to track its quota here.", + "windows_title": "Quota windows", + "windows_span_weekly": "two weeks", + "windows_span_session": "three days", + "windows_current": "current", + "windows_today": "Today", + "windows_prev": "Previous", + "windows_next": "Next", + "windows_mode_weekly": "Weekly", + "windows_mode_session": "5-hour", + "windows_credential": "Credential", + "windows_idle": "no window counting down", + "windows_legend_current": "current window", + "windows_legend_upcoming": "upcoming", + "windows_legend_elapsed": "elapsed", + "windows_note_weekly": "Each bar is one full quota window, drawn from when it opened to when it resets. Lanes ending together compete for the same days.", + "windows_note_session": "Each bar is one 5-hour window. Only credentials with a window counting down can be projected; the rest stay empty rather than invented.", + "weekday_sun": "Sun", + "weekday_mon": "Mon", + "weekday_tue": "Tue", + "weekday_wed": "Wed", + "weekday_thu": "Thu", + "weekday_fri": "Fri", + "weekday_sat": "Sat" }, "plugin_management": { "title": "Plugin Management", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 6561b1f4..9d644596 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1141,7 +1141,30 @@ "meta_loaded": "{{count}} загружено", "meta_attention": "{{count}} требуют внимания", "empty_title": "Нет учётных данных с квотами", - "empty_desc": "Подключите учётные данные Antigravity, Claude, Codex, xAI или Kimi, чтобы отслеживать квоту здесь." + "empty_desc": "Подключите учётные данные Antigravity, Claude, Codex, xAI или Kimi, чтобы отслеживать квоту здесь.", + "windows_title": "Окна квот", + "windows_span_weekly": "две недели", + "windows_span_session": "три дня", + "windows_current": "текущий", + "windows_today": "Сегодня", + "windows_prev": "Назад", + "windows_next": "Вперёд", + "windows_mode_weekly": "Неделя", + "windows_mode_session": "5 часов", + "windows_credential": "Учётные данные", + "windows_idle": "нет активного окна", + "windows_legend_current": "текущее окно", + "windows_legend_upcoming": "предстоящее", + "windows_legend_elapsed": "прошедшее", + "windows_note_weekly": "Каждая полоса — одно окно квоты, от открытия до сброса. Окна, заканчивающиеся вместе, конкурируют за одни и те же дни.", + "windows_note_session": "Каждая полоса — одно 5-часовое окно. Показаны только учётные данные с активным окном; остальные остаются пустыми.", + "weekday_sun": "Вс", + "weekday_mon": "Пн", + "weekday_tue": "Вт", + "weekday_wed": "Ср", + "weekday_thu": "Чт", + "weekday_fri": "Пт", + "weekday_sat": "Сб" }, "plugin_management": { "title": "Управление плагинами", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index f8c69af4..a5cf2c86 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1154,7 +1154,30 @@ "meta_loaded": "{{count}} 个已加载", "meta_attention": "{{count}} 个需关注", "empty_title": "暂无可查额度的凭证", - "empty_desc": "接入 Antigravity、Claude、Codex、xAI 或 Kimi 凭证后即可在此查看额度。" + "empty_desc": "接入 Antigravity、Claude、Codex、xAI 或 Kimi 凭证后即可在此查看额度。", + "windows_title": "配额窗口", + "windows_span_weekly": "两周", + "windows_span_session": "三天", + "windows_current": "当前", + "windows_today": "今天", + "windows_prev": "上一页", + "windows_next": "下一页", + "windows_mode_weekly": "按周", + "windows_mode_session": "5 小时", + "windows_credential": "凭证", + "windows_idle": "没有正在计时的窗口", + "windows_legend_current": "当前窗口", + "windows_legend_upcoming": "即将开始", + "windows_legend_elapsed": "已结束", + "windows_note_weekly": "每条表示一个完整的配额窗口,从开启到重置。同时结束的窗口会争用相同的日期。", + "windows_note_session": "每条表示一个 5 小时窗口。仅显示有正在计时窗口的凭证,其余保持为空。", + "weekday_sun": "日", + "weekday_mon": "一", + "weekday_tue": "二", + "weekday_wed": "三", + "weekday_thu": "四", + "weekday_fri": "五", + "weekday_sat": "六" }, "plugin_management": { "title": "插件管理", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 636b2739..0ecce484 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1180,7 +1180,30 @@ "meta_loaded": "{{count}} 個已載入", "meta_attention": "{{count}} 個需關注", "empty_title": "暫無可查配額的憑證", - "empty_desc": "接入 Antigravity、Claude、Codex、xAI 或 Kimi 憑證後即可在此查看配額。" + "empty_desc": "接入 Antigravity、Claude、Codex、xAI 或 Kimi 憑證後即可在此查看配額。", + "windows_title": "配額視窗", + "windows_span_weekly": "兩週", + "windows_span_session": "三天", + "windows_current": "目前", + "windows_today": "今天", + "windows_prev": "上一頁", + "windows_next": "下一頁", + "windows_mode_weekly": "按週", + "windows_mode_session": "5 小時", + "windows_credential": "憑證", + "windows_idle": "沒有正在計時的視窗", + "windows_legend_current": "目前視窗", + "windows_legend_upcoming": "即將開始", + "windows_legend_elapsed": "已結束", + "windows_note_weekly": "每條表示一個完整的配額視窗,從開啟到重設。同時結束的視窗會競用相同的日期。", + "windows_note_session": "每條表示一個 5 小時視窗。僅顯示有正在計時視窗的憑證,其餘保持為空。", + "weekday_sun": "日", + "weekday_mon": "一", + "weekday_tue": "二", + "weekday_wed": "三", + "weekday_thu": "四", + "weekday_fri": "五", + "weekday_sat": "六" }, "plugin_management": { "title": "插件管理", diff --git a/src/types/quota.ts b/src/types/quota.ts index 5acfa7d3..9202cc7e 100644 --- a/src/types/quota.ts +++ b/src/types/quota.ts @@ -156,6 +156,14 @@ export interface ClaudeQuotaWindow { labelKey?: string; usedPercent: number | null; resetLabel: string; + /** + * Reset instant in epoch ms, kept alongside the display label so callers that + * need to compute (ordering, the timeline) aren't stuck comparing formatted + * strings. Null when the payload carried no parseable timestamp. + */ + resetAtMs?: number | null; + /** Window length in hours — 5 for the rolling window, 168 for the weekly ones. */ + periodHours?: number | null; } export interface ClaudeQuotaState { @@ -188,6 +196,18 @@ export interface AntigravityQuotaBucket { remainingFraction: number; resetTime?: string; description?: string; + /** + * Reset instant in epoch ms, parsed from `resetTime`. Kept alongside the raw + * string so the timeline can position a bar without re-parsing. + * + * Not corrected by `serverTimeOffsetMs`: that offset is applied to *now* when + * rendering the card countdown, and every other provider's `resetAtMs` is an + * uncorrected instant too. Keeping them consistent matters more than the few + * seconds of clock skew it represents. + */ + resetAtMs?: number | null; + /** Window length in hours, from the bucket's `window` field. */ + periodHours?: number | null; } export interface AntigravityQuotaState { @@ -206,6 +226,10 @@ export interface CodexQuotaWindow { labelParams?: Record; usedPercent: number | null; resetLabel: string; + /** Reset instant in epoch ms; null when the payload carried no timestamp. */ + resetAtMs?: number | null; + /** Window length in hours, from the payload's limit_window_seconds. */ + periodHours?: number | null; } export interface CodexQuotaState { @@ -273,6 +297,10 @@ export interface KimiQuotaRow { used: number; limit: number; resetHint?: string; + /** Reset instant in epoch ms; null when only a relative hint was available. */ + resetAtMs?: number | null; + /** Window length in hours, inferred from the limit's daily/weekly/monthly scope. */ + periodHours?: number | null; } export interface KimiQuotaState { @@ -351,6 +379,16 @@ export interface XaiBillingSummary { billingPeriodStart?: string; billingPeriodEnd?: string; usedPercent: number | null; + /** + * Reset instant of the *active* period (`periodEnd`) in epoch ms. + * + * Only meaningful as a quota window when `periodType` is 'weekly' — for a + * monthly summary this is the billing cycle rollover, which is a spend cap + * resetting, not rate-limited capacity coming back. + */ + resetAtMs?: number | null; + /** Active period length in hours, derived from `periodStart` → `periodEnd`. */ + periodHours?: number | null; } export interface XaiQuotaState { diff --git a/src/utils/quota/builders.ts b/src/utils/quota/builders.ts index c3ca8497..30eddeef 100644 --- a/src/utils/quota/builders.ts +++ b/src/utils/quota/builders.ts @@ -18,6 +18,7 @@ import type { XaiProductUsageSummary, } from '@/types'; import { normalizeNumberValue, normalizeQuotaFraction, normalizeStringValue } from './parsers'; +import { parseOffsetSecondsToMs, resolveResetMs } from './resetInstants'; const ANTIGRAVITY_BUCKET_WINDOW_ORDER = new Map([ ['5h', 0], @@ -42,6 +43,27 @@ function getAntigravityWindowOrder(bucket: AntigravityQuotaBucket): number { return ANTIGRAVITY_BUCKET_WINDOW_ORDER.get(window) ?? Number.MAX_SAFE_INTEGER; } +/** + * Window length in hours for an Antigravity bucket. + * + * Antigravity states the period explicitly in `window`, so unlike Kimi this is + * a lookup rather than a keyword guess. The accepted spellings mirror + * ANTIGRAVITY_BUCKET_WINDOW_ORDER above. + */ +function antigravityPeriodHours(window: string | undefined): number | null { + switch ((window ?? '').trim().toLowerCase()) { + case '5h': + case 'five-hour': + case 'five_hour': + return 5; + case 'weekly': + case 'week': + return 24 * 7; + default: + return null; + } +} + export function buildAntigravityQuotaGroups( payload: AntigravityQuotaSummaryPayload ): AntigravityQuotaGroup[] { @@ -67,13 +89,18 @@ export function buildAntigravityQuotaGroups( `${groupId}-${window ?? `bucket-${bucketIndex + 1}`}`; const label = normalizeStringValue(bucket.displayName ?? bucket.display_name) ?? rawId; + const resetTime = + normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined; + return { id: rawId, label, window, remainingFraction, - resetTime: normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined, + resetTime, description: normalizeStringValue(bucket.description) ?? undefined, + resetAtMs: resolveResetMs([resetTime]), + periodHours: antigravityPeriodHours(window), }; }) .filter((bucket): bucket is AntigravityQuotaBucket => bucket !== null) @@ -198,10 +225,45 @@ function kimiLimitLabel( }; } +/** + * Absolute reset instant for a Kimi row. + * + * Kimi sends either an absolute timestamp or a relative countdown; the display + * hint collapses both to something like "3h 20m", which can't be positioned on + * a timeline. This keeps the instant. + */ +function kimiResetMs(data: Record): number | null { + const absolute = resolveResetMs([data.reset_at, data.resetAt, data.reset_time, data.resetTime]); + if (absolute !== null) return absolute; + + const now = Date.now(); + for (const key of ['reset_in', 'resetIn', 'ttl']) { + const relative = parseOffsetSecondsToMs(data[key], now); + if (relative !== null) return relative; + } + return null; +} + +/** Window length in hours implied by a row's label/scope. */ +function kimiPeriodHours(label: string | undefined): number | null { + const text = (label ?? '').toLowerCase(); + if (text.includes('daily') || text.includes('day')) return 24; + if (text.includes('weekly') || text.includes('week')) return 24 * 7; + if (text.includes('monthly') || text.includes('month')) return 24 * 30; + if (text.includes('hour')) return 5; + return null; +} + function toKimiUsageRow( data: Record, fallbackLabel: KimiRowLabel -): (KimiRowLabel & { used: number; limit: number; resetHint?: string }) | null { +): (KimiRowLabel & { + used: number; + limit: number; + resetHint?: string; + resetAtMs?: number | null; + periodHours?: number | null; +}) | null { const limit = toInt(data.limit); let used = toInt(data.used); if (used === null) { @@ -220,6 +282,8 @@ function toKimiUsageRow( used: used ?? 0, limit: limit ?? 0, resetHint: kimiResetHint(data), + resetAtMs: kimiResetMs(data), + periodHours: kimiPeriodHours(explicitLabel || fallbackLabel.label || fallbackLabel.labelKey), }; } @@ -302,6 +366,27 @@ const emptyXaiBillingSummary = (): XaiBillingSummary => ({ usedPercent: null, }); +/** + * Reset instant and length of an xAI period. + * + * The length comes from the period's own start → end span rather than being + * assumed, so a non-standard cycle still positions correctly. Null when the + * payload states an end without a start; the caller supplies the default it + * knows is right for the period type. + */ +function xaiPeriodInstants( + periodStart: string | undefined, + periodEnd: string | undefined +): { resetAtMs: number | null; periodHours: number | null } { + const resetAtMs = resolveResetMs([periodEnd]); + const startMs = resolveResetMs([periodStart]); + const periodHours = + resetAtMs !== null && startMs !== null && resetAtMs > startMs + ? (resetAtMs - startMs) / 3_600_000 + : null; + return { resetAtMs, periodHours }; +} + export function buildXaiBillingSummary( config: XaiBillingConfig | null | undefined ): XaiBillingSummary | null { @@ -385,6 +470,10 @@ export function buildXaiBillingSummary( summary.billingPeriodEnd = hasMonthlyData ? billingPeriodEnd : undefined; summary.usedPercent = usedPercent; + const periodInstants = xaiPeriodInstants(summary.periodStart, summary.periodEnd); + summary.resetAtMs = periodInstants.resetAtMs; + summary.periodHours = periodInstants.periodHours; + return summary; } @@ -395,13 +484,21 @@ export function mergeXaiBillingSummaries( if (!primary) return fallback; if (!fallback) return primary; + const periodStart = primary.periodStart ?? fallback.periodStart; + const periodEnd = primary.periodEnd ?? fallback.periodEnd; + // Recomputed from the merged pair rather than merged field-by-field, so the + // instant and the label can't come from different summaries. + const periodInstants = xaiPeriodInstants(periodStart, periodEnd); + return { mode: 'billing', source: 'cli-chat-proxy', periodType: primary.periodType !== 'unknown' ? primary.periodType : fallback.periodType, usagePercent: primary.usagePercent ?? fallback.usagePercent, - periodStart: primary.periodStart ?? fallback.periodStart, - periodEnd: primary.periodEnd ?? fallback.periodEnd, + periodStart, + periodEnd, + resetAtMs: periodInstants.resetAtMs, + periodHours: periodInstants.periodHours, productUsage: primary.productUsage.length > 0 ? primary.productUsage : fallback.productUsage, monthlyLimitCents: primary.monthlyLimitCents ?? fallback.monthlyLimitCents, usedCents: primary.usedCents ?? fallback.usedCents, diff --git a/src/utils/quota/index.ts b/src/utils/quota/index.ts index 21c6c658..e69d4c45 100644 --- a/src/utils/quota/index.ts +++ b/src/utils/quota/index.ts @@ -4,6 +4,7 @@ export * from './constants'; export * from './errors'; +export * from './resetInstants'; export * from './parsers'; export * from './planTier'; export * from './resolvers'; diff --git a/src/utils/quota/resetInstants.ts b/src/utils/quota/resetInstants.ts new file mode 100644 index 00000000..b3bfe085 --- /dev/null +++ b/src/utils/quota/resetInstants.ts @@ -0,0 +1,87 @@ +/** + * Raw reset instants, in epoch milliseconds. + * + * Every provider's payload carries a real reset timestamp, but each builder + * formatted it straight to a display string and dropped the original. That is + * fine for a card row and useless for anything that has to *compute* with the + * value — the quota timeline needs a real instant to position a bar, and + * comparing "MM/DD, HH:MM" strings gets the order wrong across a year boundary. + * + * These helpers keep the instant alongside the label rather than replacing it, + * so nothing about the existing rendering changes. + */ + +/** Milliseconds in an hour — window periods are expressed in hours throughout. */ +const HOUR_MS = 3_600_000; + +/** + * Parse an ISO-8601 timestamp to epoch ms. + * + * Tolerates the over-precise fractional seconds some providers emit + * (`.123456789`), which `new Date()` rejects on some engines. + */ +export function parseIsoToMs(value: unknown): number | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const normalized = trimmed.replace(/(\.\d{6})\d+/, '$1'); + const ms = new Date(normalized).getTime(); + return Number.isFinite(ms) ? ms : null; +} + +/** + * Parse a Unix timestamp to epoch ms, accepting seconds or milliseconds. + * + * Disambiguated by magnitude: seconds-since-epoch stays ~1e9 well past the year + * 2200, while a millisecond value is ~1e12 today, so the gap is unambiguous for + * any timestamp this application will encounter. + */ +export function parseUnixToMs(value: unknown): number | null { + const numeric = + typeof value === 'number' ? value : typeof value === 'string' ? Number(value.trim()) : NaN; + if (!Number.isFinite(numeric) || numeric <= 0) return null; + return numeric < 1e11 ? numeric * 1000 : numeric; +} + +/** Seconds-from-now to an absolute instant, relative to `now`. */ +export function parseOffsetSecondsToMs(value: unknown, now: number): number | null { + const numeric = + typeof value === 'number' ? value : typeof value === 'string' ? Number(value.trim()) : NaN; + if (!Number.isFinite(numeric) || numeric <= 0) return null; + return now + numeric * 1000; +} + +/** + * First reset instant found among `candidates`, trying ISO then Unix. + * + * Providers disagree about both key names and encodings, and some send several + * of them; taking the first parseable one keeps that mess in one place. + */ +export function resolveResetMs(candidates: readonly unknown[]): number | null { + for (const candidate of candidates) { + const iso = parseIsoToMs(candidate); + if (iso !== null) return iso; + const unix = parseUnixToMs(candidate); + if (unix !== null) return unix; + } + return null; +} + +/** Window length in hours from a seconds value, or null when absent/invalid. */ +export function periodHoursFromSeconds(value: unknown): number | null { + const numeric = + typeof value === 'number' ? value : typeof value === 'string' ? Number(value.trim()) : NaN; + if (!Number.isFinite(numeric) || numeric <= 0) return null; + return (numeric * 1000) / HOUR_MS; +} + +/** + * Window length implied by a Claude usage key. + * + * Claude reports named windows rather than durations — `five_hour` is a rolling + * 5-hour window, everything else on that payload is a 7-day one. Derived from + * the key because the payload states the period nowhere else. + */ +export function claudePeriodHours(windowKey: string): number { + return windowKey === 'five_hour' ? 5 : 24 * 7; +} diff --git a/tests/claudeFableQuota.test.ts b/tests/claudeFableQuota.test.ts index ace45538..0909ef96 100644 --- a/tests/claudeFableQuota.test.ts +++ b/tests/claudeFableQuota.test.ts @@ -33,6 +33,8 @@ describe('Claude Fable quota', () => { labelKey: 'claude_quota.seven_day_fable', usedPercent: 64, resetLabel: formatQuotaResetTime(modernReset), + resetAtMs: Date.parse(modernReset), + periodHours: 24 * 7, }, ]); }); @@ -55,6 +57,8 @@ describe('Claude Fable quota', () => { labelKey: 'claude_quota.seven_day_fable', usedPercent: 41, resetLabel: formatQuotaResetTime(legacyReset), + resetAtMs: Date.parse(legacyReset), + periodHours: 24 * 7, }, ]); }); @@ -86,6 +90,8 @@ describe('Claude Fable quota', () => { labelKey: 'claude_quota.seven_day_fable', usedPercent: 41, resetLabel: formatQuotaResetTime(legacyReset), + resetAtMs: Date.parse(legacyReset), + periodHours: 24 * 7, }, ]); }); @@ -155,6 +161,8 @@ describe('Claude Fable quota', () => { labelKey: 'claude_quota.seven_day_fable', usedPercent: 64, resetLabel: formatQuotaResetTime(modernReset), + resetAtMs: Date.parse(modernReset), + periodHours: 24 * 7, }, ]); }); diff --git a/tests/quotaResetInstants.test.ts b/tests/quotaResetInstants.test.ts new file mode 100644 index 00000000..04e8a907 --- /dev/null +++ b/tests/quotaResetInstants.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'bun:test'; +import { + claudePeriodHours, + parseIsoToMs, + parseOffsetSecondsToMs, + parseUnixToMs, + periodHoursFromSeconds, + resolveResetMs, +} from '../src/utils/quota/resetInstants'; + +describe('parseIsoToMs', () => { + test('parses a plain ISO timestamp', () => { + expect(parseIsoToMs('2026-07-29T14:59:00Z')).toBe(Date.UTC(2026, 6, 29, 14, 59)); + }); + + test('tolerates over-precise fractional seconds', () => { + // Some providers emit nanoseconds, which Date rejects on some engines. + expect(parseIsoToMs('2026-07-29T14:59:00.123456789Z')).toBe( + Date.UTC(2026, 6, 29, 14, 59, 0, 123) + ); + }); + + test('rejects non-strings, blanks and unparseable text', () => { + expect(parseIsoToMs(undefined)).toBeNull(); + expect(parseIsoToMs(' ')).toBeNull(); + expect(parseIsoToMs('not a date')).toBeNull(); + expect(parseIsoToMs(1754000000)).toBeNull(); + }); +}); + +describe('parseUnixToMs', () => { + test('treats small magnitudes as seconds and large as milliseconds', () => { + expect(parseUnixToMs(1_754_000_000)).toBe(1_754_000_000_000); + expect(parseUnixToMs(1_754_000_000_000)).toBe(1_754_000_000_000); + }); + + test('accepts numeric strings', () => { + expect(parseUnixToMs('1754000000')).toBe(1_754_000_000_000); + }); + + test('rejects zero, negatives and junk', () => { + expect(parseUnixToMs(0)).toBeNull(); + expect(parseUnixToMs(-5)).toBeNull(); + expect(parseUnixToMs('soon')).toBeNull(); + expect(parseUnixToMs(null)).toBeNull(); + }); +}); + +describe('parseOffsetSecondsToMs', () => { + test('projects a countdown forward from now', () => { + const now = 1_754_000_000_000; + expect(parseOffsetSecondsToMs(3600, now)).toBe(now + 3_600_000); + }); + + test('rejects a non-positive or unparseable offset', () => { + expect(parseOffsetSecondsToMs(0, 1000)).toBeNull(); + expect(parseOffsetSecondsToMs(-60, 1000)).toBeNull(); + expect(parseOffsetSecondsToMs('nope', 1000)).toBeNull(); + }); +}); + +describe('resolveResetMs', () => { + test('takes the first parseable candidate, ISO or Unix', () => { + expect(resolveResetMs([undefined, null, '2026-07-29T14:59:00Z'])).toBe( + Date.UTC(2026, 6, 29, 14, 59) + ); + expect(resolveResetMs([undefined, 1_754_000_000])).toBe(1_754_000_000_000); + }); + + test('prefers an earlier candidate over a later one in the list', () => { + // Order is priority, not chronology — callers list their preferred key first. + expect(resolveResetMs(['2026-01-01T00:00:00Z', 1_754_000_000])).toBe( + Date.UTC(2026, 0, 1) + ); + }); + + test('returns null when nothing parses', () => { + expect(resolveResetMs([])).toBeNull(); + expect(resolveResetMs([undefined, null, '', 'later'])).toBeNull(); + }); +}); + +describe('period derivation', () => { + test('converts a window length in seconds to hours', () => { + expect(periodHoursFromSeconds(18_000)).toBe(5); + expect(periodHoursFromSeconds(604_800)).toBe(168); + expect(periodHoursFromSeconds('18000')).toBe(5); + }); + + test('rejects an absent or non-positive window length', () => { + expect(periodHoursFromSeconds(0)).toBeNull(); + expect(periodHoursFromSeconds(undefined)).toBeNull(); + }); + + test('derives the Claude period from its window key', () => { + // Claude states the period nowhere in the payload — only the key implies it. + expect(claudePeriodHours('five_hour')).toBe(5); + expect(claudePeriodHours('seven_day')).toBe(168); + expect(claudePeriodHours('seven_day_opus')).toBe(168); + }); +}); diff --git a/tests/quotaTimeline.test.ts b/tests/quotaTimeline.test.ts new file mode 100644 index 00000000..7bb128c8 --- /dev/null +++ b/tests/quotaTimeline.test.ts @@ -0,0 +1,404 @@ +import { describe, expect, test } from 'bun:test'; +import { + DAY_MS, + HOUR_MS, + buildTimelineLane, + laneHasWindow, + pickLaneWindow, + projectLane, + startOfDay, + startOfWeek, + timelineSpan, + windowsIn, +} from '../src/features/quota/quotaTimelineModel'; +import type { TimelineLane } from '../src/features/quota/quotaTimelineModel'; + +const at = (y: number, m: number, d: number, h = 0, min = 0) => + new Date(y, m, d, h, min).getTime(); + +describe('windowsIn', () => { + test('projects backwards and forwards from the anchor', () => { + // Anchor is a known reset; the span opens before the current window did. + const anchor = at(2026, 6, 29, 12); + const from = anchor - 2.5 * DAY_MS; + const to = anchor + 1.5 * DAY_MS; + + const windows = windowsIn(anchor, DAY_MS, from, to); + + // A 4-day span of daily windows needs 5 bars: the span edges fall mid-window, + // so there's a partial window at each end. + expect(windows.length).toBe(5); + // Every boundary sits on a whole period from the anchor. + for (const window of windows) { + // `+ 0` normalizes JS's -0 from a negative remainder (windows before the anchor). + expect(((window.endMs - anchor) % DAY_MS) + 0).toBe(0); + expect(window.endMs - window.startMs).toBe(DAY_MS); + } + expect(windows.some((w) => w.startMs <= anchor && w.endMs >= anchor)).toBe(true); + // Fully covers the requested range. + expect(windows[0].startMs).toBeLessThanOrEqual(from); + expect(windows[windows.length - 1].endMs).toBeGreaterThanOrEqual(to); + }); + + test('covers the whole span with no gaps or overlaps', () => { + const windows = windowsIn(at(2026, 6, 29), 5 * HOUR_MS, at(2026, 6, 28), at(2026, 6, 30)); + for (let i = 1; i < windows.length; i += 1) { + expect(windows[i].startMs).toBe(windows[i - 1].endMs); + } + }); + + test('rejects a degenerate period, span or anchor', () => { + expect(windowsIn(1000, 0, 0, 5000)).toEqual([]); + expect(windowsIn(1000, -5, 0, 5000)).toEqual([]); + expect(windowsIn(NaN, 1000, 0, 5000)).toEqual([]); + expect(windowsIn(1000, 1000, 5000, 5000)).toEqual([]); + }); + + test('bails out rather than looping forever on an absurd period', () => { + // A bad payload could give a 1ms period over a fortnight. + expect(windowsIn(0, 1, 0, 14 * DAY_MS)).toEqual([]); + }); +}); + +describe('span boundaries', () => { + test('startOfDay and startOfWeek land on local midnight', () => { + const mid = at(2026, 6, 29, 14, 37); + expect(new Date(startOfDay(mid)).getHours()).toBe(0); + expect(new Date(startOfWeek(mid)).getDay()).toBe(0); + expect(new Date(startOfWeek(mid)).getHours()).toBe(0); + }); + + test('weekly span is a fortnight from the containing Sunday', () => { + const now = at(2026, 6, 29, 14, 0); // a Wednesday + const span = timelineSpan('weekly', 0, now); + + expect(new Date(span.startMs).getDay()).toBe(0); + expect(span.days).toBe(14); + expect(span.startMs).toBeLessThanOrEqual(now); + expect(span.endMs).toBeGreaterThan(now); + }); + + test('offsets step a week in weekly mode and a day in session mode', () => { + const now = at(2026, 6, 29, 14, 0); + const weekly = timelineSpan('weekly', 0, now); + const weeklyNext = timelineSpan('weekly', 1, now); + expect(Math.round((weeklyNext.startMs - weekly.startMs) / DAY_MS)).toBe(7); + + const session = timelineSpan('session', 0, now); + const sessionNext = timelineSpan('session', 1, now); + expect(Math.round((sessionNext.startMs - session.startMs) / DAY_MS)).toBe(1); + expect(session.days).toBe(3); + }); + + test('spans a whole number of days even across a DST transition', () => { + // US DST springs forward 2026-03-08; a fixed +14*DAY_MS would land at 23:00. + const span = timelineSpan('weekly', 0, at(2026, 2, 10, 12)); + expect(new Date(span.startMs).getHours()).toBe(0); + expect(new Date(span.endMs).getHours()).toBe(0); + }); +}); + +describe('projectLane', () => { + const lane = (over: Partial = {}): TimelineLane => ({ + name: 'a.json', + displayName: 'Alice', + provider: 'claude', + anchorMs: at(2026, 6, 29, 20), + periodHours: 24 * 7, + remaining: 40, + limits: [], + ...over, + }); + + const span = timelineSpan('weekly', 0, at(2026, 6, 29, 12)); + + test('classifies past, live and next windows against now', () => { + const now = at(2026, 6, 29, 12); + const windows = projectLane(lane(), span.startMs, span.endMs, now, 'weekly'); + + expect(windows.length).toBeGreaterThan(0); + const live = windows.filter((w) => w.state === 'live'); + expect(live.length).toBe(1); + expect(live[0].startMs).toBeLessThanOrEqual(now); + expect(live[0].endMs).toBeGreaterThan(now); + expect(windows.filter((w) => w.state === 'past').every((w) => w.endMs <= now)).toBe(true); + expect(windows.filter((w) => w.state === 'next').every((w) => w.startMs > now)).toBe(true); + }); + + test('clips bars to the visible span', () => { + const windows = projectLane(lane(), span.startMs, span.endMs, at(2026, 6, 29, 12), 'weekly'); + for (const window of windows) { + expect(window.leftPercent).toBeGreaterThanOrEqual(0); + expect(window.widthPercent).toBeGreaterThan(0); + expect(window.leftPercent + window.widthPercent).toBeLessThanOrEqual(100.0001); + } + }); + + test('returns nothing when the lane has no anchor or period', () => { + const now = at(2026, 6, 29, 12); + expect(projectLane(lane({ anchorMs: null }), span.startMs, span.endMs, now, 'weekly')).toEqual( + [] + ); + expect( + projectLane(lane({ periodHours: null }), span.startMs, span.endMs, now, 'weekly') + ).toEqual([]); + }); + + test('session mode projects 5-hour windows regardless of the lane period', () => { + const now = at(2026, 6, 29, 12); + const sessionSpan = timelineSpan('session', 0, now); + const windows = projectLane(lane(), sessionSpan.startMs, sessionSpan.endMs, now, 'session'); + + const full = windows.find((w) => w.endMs - w.startMs === 5 * HOUR_MS); + expect(full).toBeDefined(); + }); +}); + +describe('pickLaneWindow', () => { + test('ignores windows with no reset instant', () => { + const chosen = pickLaneWindow([ + { resetAtMs: null, periodHours: 168 }, + { resetAtMs: 1000, periodHours: 5 }, + { resetAtMs: undefined, periodHours: 168 }, + ]); + expect(chosen?.resetAtMs).toBe(1000); + }); + + /** + * The bug this rule exists for: across a fortnight, the 5-hour window always + * resets soonest, so "pick the soonest" drew ~67 slivers per lane instead of + * two readable weekly bars. + */ + test('prefers the longest window that fits the span, not the soonest reset', () => { + const fiveHour = { resetAtMs: 1_000, periodHours: 5 }; + const weekly = { resetAtMs: 9_000, periodHours: 168 }; + + expect(pickLaneWindow([fiveHour, weekly], 14 * 24)).toBe(weekly); + // A three-day span can't fit a weekly window, so the short one wins. + expect(pickLaneWindow([fiveHour, weekly], 3 * 24)).toBe(fiveHour); + }); + + test('breaks a period tie on the soonest reset', () => { + const later = { resetAtMs: 9_000, periodHours: 168 }; + const sooner = { resetAtMs: 5_000, periodHours: 168 }; + expect(pickLaneWindow([later, sooner], 14 * 24)).toBe(sooner); + }); + + test('falls back to the shortest available rather than drawing nothing', () => { + // Every window is longer than the span — still better to draw one. + const monthly = { resetAtMs: 5_000, periodHours: 720 }; + expect(pickLaneWindow([monthly], 3 * 24)).toBe(monthly); + }); + + test('returns null when nothing qualifies', () => { + expect(pickLaneWindow([{ resetAtMs: null }])).toBeNull(); + expect(pickLaneWindow([])).toBeNull(); + }); +}); + +describe('buildTimelineLane', () => { + const base = { name: 'a.json', displayName: 'Alice' }; + + test('claude/codex: anchors on the span-appropriate window, remaining from used', () => { + const soon = at(2026, 6, 29, 20); + const later = at(2026, 7, 1, 20); + const quota = { + status: 'success', + windows: [ + { label: '7-day', usedPercent: 93, resetAtMs: later, periodHours: 168 }, + { label: '5-hour', usedPercent: 20, resetAtMs: soon, periodHours: 5 }, + ], + }; + + // Fortnight view: the weekly window, even though the 5-hour resets sooner. + const weekly = buildTimelineLane({ + ...base, provider: 'claude', quota, maxPeriodHours: 14 * 24, + }); + expect(weekly.anchorMs).toBe(later); + expect(weekly.periodHours).toBe(168); + expect(weekly.remaining).toBe(7); // stored USED + + // Three-day view: the weekly window doesn't fit, so the short one is used. + const session = buildTimelineLane({ + ...base, provider: 'claude', quota, maxPeriodHours: 3 * 24, + }); + expect(session.anchorMs).toBe(soon); + expect(session.periodHours).toBe(5); + expect(session.remaining).toBe(80); + + // Every limit is still summarized in the lane head regardless of the pick. + expect(weekly.limits).toEqual([ + { label: '7-day', remaining: 7 }, + { label: '5-hour', remaining: 80 }, + ]); + }); + + test('kimi: derives remaining from raw used/limit counts', () => { + const lane = buildTimelineLane({ + ...base, + provider: 'kimi', + quota: { + status: 'success', + rows: [ + { label: 'Daily', used: 540, limit: 1000, resetAtMs: 5000, periodHours: 24 }, + { label: 'Monthly', used: 8100, limit: 30000, resetAtMs: 9000, periodHours: 720 }, + ], + }, + // A fortnight fits the daily window but not the monthly one. + maxPeriodHours: 14 * 24, + }); + + expect(lane.anchorMs).toBe(5000); + expect(lane.remaining).toBe(46); + expect(lane.limits).toEqual([ + { label: 'Daily', remaining: 46 }, + { label: 'Monthly', remaining: 73 }, + ]); + }); + + test('antigravity anchors on its bucket reset, with remaining from the fraction', () => { + const lane = buildTimelineLane({ + ...base, + provider: 'antigravity', + quota: { + status: 'success', + groups: [ + { + buckets: [ + { label: '5h', remainingFraction: 0.4, resetAtMs: 1000, periodHours: 5 }, + { label: 'Weekly', remainingFraction: 0.82, resetAtMs: 5000, periodHours: 168 }, + ], + }, + ], + }, + maxPeriodHours: 336, + }); + + // Longest window that fits wins, exactly as it does for claude/codex. + expect(lane.anchorMs).toBe(5000); + expect(lane.periodHours).toBe(168); + // remainingFraction is REMAINING, so it is not inverted. + expect(lane.remaining).toBe(82); + expect(lane.limits).toEqual([ + { label: '5h', remaining: 40 }, + { label: 'Weekly', remaining: 82 }, + ]); + }); + + test('antigravity buckets without a parseable reset do not anchor the lane', () => { + const lane = buildTimelineLane({ + ...base, + provider: 'antigravity', + quota: { + status: 'success', + groups: [{ buckets: [{ label: '5h', remainingFraction: 0.4, resetAtMs: null }] }], + }, + }); + expect(lane.anchorMs).toBeNull(); + expect(lane.limits).toEqual([]); + }); + + test('xai anchors on the weekly limit, with per-product limits', () => { + const lane = buildTimelineLane({ + ...base, + provider: 'xai', + quota: { + status: 'success', + billing: { + periodType: 'weekly', + usagePercent: 5, + resetAtMs: 9000, + periodHours: 168, + productUsage: [ + { product: 'GrokBuild', usagePercent: 5 }, + { product: 'GrokChat', usagePercent: null }, + ], + }, + }, + }); + + expect(lane.anchorMs).toBe(9000); + expect(lane.periodHours).toBe(168); + expect(lane.remaining).toBe(95); + // GrokChat has no percentage, so it is not summarized. + expect(lane.limits).toEqual([{ label: 'GrokBuild', remaining: 95 }]); + }); + + test('xai without a weekly limit produces no window at all', () => { + // A monthly summary carries periodEnd too, but that is a billing cycle. + for (const periodType of ['monthly', 'unknown'] as const) { + const lane = buildTimelineLane({ + ...base, + provider: 'xai', + quota: { + status: 'success', + billing: { periodType, usagePercent: 69, resetAtMs: 9000, periodHours: 720 }, + }, + }); + expect(lane.anchorMs).toBeNull(); + expect(laneHasWindow(lane)).toBe(false); + } + }); + + test('xai defaults to a 7-day period when the payload states no start', () => { + const lane = buildTimelineLane({ + ...base, + provider: 'xai', + quota: { + status: 'success', + billing: { periodType: 'weekly', usagePercent: 5, resetAtMs: 9000, periodHours: null }, + }, + }); + expect(lane.periodHours).toBe(24 * 7); + }); + + test('laneHasWindow drops only lanes that can never draw a bar', () => { + const drawable = buildTimelineLane({ + ...base, + provider: 'claude', + quota: { + status: 'success', + windows: [{ label: '7-day', usedPercent: 10, resetAtMs: 5000, periodHours: 168 }], + }, + }); + expect(laneHasWindow(drawable)).toBe(true); + // Unloaded quota has nothing to show yet, so it takes no row. + expect(laneHasWindow(buildTimelineLane({ ...base, provider: 'claude', quota: undefined }))).toBe( + false + ); + }); + + test('providers with no usable reset produce an empty lane, not a dropped one', () => { + // buildTimelineLane always returns a lane; dropping is the caller's job via + // laneHasWindow, so the two concerns stay separately testable. + for (const provider of ['antigravity', 'xai'] as const) { + const lane = buildTimelineLane({ + ...base, + provider, + quota: { status: 'success', groups: [] }, + }); + expect(lane.name).toBe('a.json'); + expect(lane.anchorMs).toBeNull(); + expect(lane.limits).toEqual([]); + } + }); + + test('unloaded or errored quota produces an empty lane', () => { + expect(buildTimelineLane({ ...base, provider: 'claude', quota: undefined }).anchorMs).toBeNull(); + expect( + buildTimelineLane({ ...base, provider: 'claude', quota: { status: 'error' } }).anchorMs + ).toBeNull(); + }); + + test('windows without a reset instant do not anchor the lane', () => { + const lane = buildTimelineLane({ + ...base, + provider: 'claude', + quota: { + status: 'success', + windows: [{ label: '7-day', usedPercent: 50, resetAtMs: null, periodHours: 168 }], + }, + }); + expect(lane.anchorMs).toBeNull(); + }); +});