mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-08-28 16:31:13 +08:00
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:
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Управление плагинами",
|
||||
|
||||
@@ -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": "插件管理",
|
||||
|
||||
@@ -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": "插件管理",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export * from './constants';
|
||||
export * from './errors';
|
||||
export * from './resetInstants';
|
||||
export * from './parsers';
|
||||
export * from './planTier';
|
||||
export * from './resolvers';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user