feat: add quota window management and reset instant handling

- Introduced new localization strings for quota windows in English, Russian, Simplified Chinese, and Traditional Chinese.
- Enhanced quota types to include reset timestamps and period lengths for various providers (Claude, Antigravity, Codex, Kimi, Xai).
- Implemented utility functions for parsing reset instants from different formats (ISO, Unix, and offsets).
- Developed tests for new reset instant parsing utilities and timeline functionalities.
- Updated quota builders to utilize new reset instant logic and ensure accurate timeline projections.
This commit is contained in:
Supra4E8C
2026-07-31 10:49:20 +08:00
parent 31afcffd99
commit ea107fded0
17 changed files with 2020 additions and 8 deletions
+16
View File
@@ -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() {
</Button>
</div>
)}
{/* 时间线跟随当前 tab,但不跟随分页:它回答的是「额度会不会同时恢复」,
那是泳道之间的关系,只画当页 12 条就失去了对比的意义。 */}
<QuotaTimeline
entries={filteredEntries}
quotaFor={getQuota}
displayNameFor={displayNameFor}
resolvedTheme={resolvedTheme}
/>
</section>
</div>
);
@@ -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;
}
}
@@ -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<TimelineMode>('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 (
<section className={styles.timeline}>
<header className={styles.head}>
<div>
<h2 className={styles.title}>
{t('quota_management.windows_title', { defaultValue: 'Quota windows' })}
</h2>
<p className={styles.range}>
{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' })}`}
</p>
</div>
<div className={styles.controls}>
<div className={styles.nav}>
<button
type="button"
onClick={() => setOffset((value) => value - 1)}
aria-label={t('quota_management.windows_prev', { defaultValue: 'Previous' })}
>
</button>
<button type="button" onClick={() => setOffset(0)} disabled={offset === 0}>
{t('quota_management.windows_today', { defaultValue: 'Today' })}
</button>
<button
type="button"
onClick={() => setOffset((value) => value + 1)}
aria-label={t('quota_management.windows_next', { defaultValue: 'Next' })}
>
</button>
</div>
<div className={styles.modes} role="group">
{(['weekly', 'session'] as const).map((value) => (
<button
key={value}
type="button"
aria-pressed={mode === value}
onClick={() => {
setMode(value);
setOffset(0); // spans differ in size; an old offset means nothing
}}
>
{value === 'weekly'
? t('quota_management.windows_mode_weekly', { defaultValue: 'Weekly' })
: t('quota_management.windows_mode_session', { defaultValue: '5-hour' })}
</button>
))}
</div>
</div>
</header>
<div className={styles.chart}>
<div className={styles.axis}>
<div className={styles.axisLabel}>
{t('quota_management.windows_credential', { defaultValue: 'Credential' })}
</div>
<div className={styles.axisCells}>
{cells.map((cell) => (
<div
key={cell.at}
className={styles.axisCell}
data-today={cell.isToday ? 1 : 0}
data-weekend={cell.isWeekend ? 1 : 0}
data-daystart={cell.isDayStart ? 1 : 0}
>
<span className={styles.axisWeekday}>{cell.isDayStart ? cell.weekday : ''}</span>
<span className={styles.axisDate}>{cell.label}</span>
</div>
))}
</div>
</div>
{lanes.map((lane) => (
<Lane
key={lane.name}
lane={lane}
span={span}
now={now}
mode={mode}
cells={cells}
nowPercent={nowPercent}
resolvedTheme={resolvedTheme}
/>
))}
</div>
<footer className={styles.legend}>
<span className={styles.legendItem}>
<span className={`${styles.swatch} ${styles.swatchLive}`} />
{t('quota_management.windows_legend_current', { defaultValue: 'current window' })}
</span>
<span className={styles.legendItem}>
<span className={`${styles.swatch} ${styles.swatchNext}`} />
{t('quota_management.windows_legend_upcoming', { defaultValue: 'upcoming' })}
</span>
<span className={styles.legendItem}>
<span className={`${styles.swatch} ${styles.swatchPast}`} />
{t('quota_management.windows_legend_elapsed', { defaultValue: 'elapsed' })}
</span>
<span className={styles.legendNote}>
{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.',
})}
</span>
</footer>
</section>
);
}
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 (
<div className={styles.lane} style={{ '--provider-accent': color.text } as CSSProperties}>
<div className={styles.laneHead}>
<div className={styles.laneTop}>
<span className={styles.laneDot} />
<span className={styles.laneName} title={lane.displayName}>
{lane.displayName}
</span>
{periodLabel && <span className={styles.lanePeriod}>{periodLabel}</span>}
</div>
<div className={styles.laneLimits}>
{lane.limits.map((limit) => (
<span key={limit.label} className={styles.laneLimit}>
{limit.label} <b>{limit.remaining}%</b>
</span>
))}
</div>
</div>
<div className={styles.track}>
<div className={styles.trackGrid}>
{cells.map((cell) => (
<span
key={cell.at}
data-weekend={cell.isWeekend ? 1 : 0}
data-daystart={cell.isDayStart ? 1 : 0}
/>
))}
</div>
{nowPercent !== null && (
<div className={styles.nowLine} style={{ left: `${nowPercent}%` }} />
)}
{windows.length === 0 ? (
<span className={styles.laneIdle}>
{t('quota_management.windows_idle', {
defaultValue: 'no window counting down',
})}
</span>
) : (
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 (
<div
key={window.startMs}
className={`${styles.window} ${styles[`window${capitalize(window.state)}`]}`}
style={{ left: `${window.leftPercent}%`, width: `${window.widthPercent}%` }}
title={`${lane.displayName}\n${formatDay(window.startMs)} ${formatTime(
window.startMs
)}${formatDay(window.endMs)} ${formatTime(window.endMs)}${
window.state === 'live' && lane.remaining !== null
? `\n${lane.remaining}% remaining`
: ''
}`}
>
{/* Consumed portion of the *current* window only — a past or
future window has no meaningful fill. */}
{window.state === 'live' && lane.remaining !== null && (
<span
className={styles.windowFill}
style={{ width: `${100 - lane.remaining}%` }}
/>
)}
{showLabel && (
<span className={styles.windowLabel}>
{window.state === 'live' && lane.remaining !== null
? `${lane.remaining}% · `
: ''}
{endText}
</span>
)}
</div>
);
})
)}
</div>
</div>
);
}
const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
@@ -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'),
});
}
}
@@ -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,
});
};
+406
View File
@@ -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<TimelineMode, number> = {
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<T extends { resetAtMs?: number | null; periodHours?: number | null }>(
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;
}
+24 -1
View File
@@ -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",
+24 -1
View File
@@ -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": "Управление плагинами",
+24 -1
View File
@@ -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": "插件管理",
+24 -1
View File
@@ -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": "插件管理",
+38
View File
@@ -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<string, string | number>;
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 {
+101 -4
View File
@@ -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<string, number>([
['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<string, unknown>): 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<string, unknown>,
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,
+1
View File
@@ -4,6 +4,7 @@
export * from './constants';
export * from './errors';
export * from './resetInstants';
export * from './parsers';
export * from './planTier';
export * from './resolvers';
+87
View File
@@ -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;
}
+8
View File
@@ -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,
},
]);
});
+101
View File
@@ -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);
});
});
+404
View File
@@ -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> = {}): 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();
});
});