feat(quota): enhance QuotaTimeline with empty state handling and initial zoom configuration

This commit is contained in:
Supra4E8C
2026-07-31 12:47:17 +08:00
parent 7c072c4ab4
commit 8038469a2d
10 changed files with 320 additions and 114 deletions
@@ -85,6 +85,16 @@ $lane-width: 210px;
overflow: hidden;
}
.empty {
min-height: 96px;
display: grid;
place-items: center;
padding: $spacing-lg;
color: var(--text-quaternary);
font-size: 12px;
text-align: center;
}
.axis,
.lane {
display: grid;
+99 -71
View File
@@ -53,6 +53,8 @@ export interface QuotaTimelineProps {
resolvedTheme: ResolvedTheme;
/** Injectable for tests/screenshots; defaults to the real clock. */
now?: number;
/** Injectable initial zoom for tests/screenshots; defaults to the weekly view. */
initialMode?: TimelineMode;
}
export function QuotaTimeline({
@@ -61,9 +63,10 @@ export function QuotaTimeline({
displayNameFor,
resolvedTheme,
now: nowProp,
initialMode = 'weekly',
}: QuotaTimelineProps) {
const { t } = useTranslation();
const [mode, setMode] = useState<TimelineMode>('weekly');
const [mode, setMode] = useState<TimelineMode>(initialMode);
const [offset, setOffset] = useState(0);
// The clock is state, not a read during render: bars are classified
@@ -81,29 +84,39 @@ export function QuotaTimeline({
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 laneInputs = useMemo(
() =>
entries.map((entry) => ({
name: entry.file.name,
displayName: displayNameFor(entry.file.name),
provider: entry.type,
quota: quotaFor(entry),
})),
[entries, quotaFor, displayNameFor]
);
// Keep the timeline hidden until at least one loaded credential exposes a
// real quota window. Once there is timeline data, however, changing zoom must
// never remove the whole panel just because that mode has no matching lanes.
const hasAnyLane = useMemo(
() => laneInputs.some((input) => laneHasWindow(buildTimelineLane(input))),
[laneInputs]
);
const lanes = useMemo(
() =>
entries
.map((entry) =>
laneInputs
.map((input) =>
buildTimelineLane({
name: entry.file.name,
displayName: displayNameFor(entry.file.name),
provider: entry.type,
quota: quotaFor(entry),
...input,
// Weekly mode prefers the longest readable window. Session mode
// asks specifically for a real 5-hour window; longer periods must
// not be reinterpreted as 5-hour resets.
maxPeriodHours: mode === 'session' ? 5 : span.days * 24,
})
)
.filter(
(lane) => laneHasWindow(lane) && (mode !== 'session' || lane.periodHours === 5)
),
[entries, quotaFor, displayNameFor, mode, span.days]
.filter((lane) => laneHasWindow(lane) && (mode !== 'session' || lane.periodHours === 5)),
[laneInputs, mode, span.days]
);
/** Weekly: one cell per day. Session: one per 6 hours. */
@@ -136,7 +149,7 @@ export function QuotaTimeline({
? ((now - span.startMs) / (span.endMs - span.startMs)) * 100
: null;
if (lanes.length === 0) return null;
if (!hasAnyLane) return null;
return (
<section className={styles.timeline}>
@@ -151,7 +164,8 @@ export function QuotaTimeline({
{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' })}`}
{offset === 0 &&
` · ${t('quota_management.windows_current', { defaultValue: 'current' })}`}
</p>
</div>
@@ -197,65 +211,79 @@ export function QuotaTimeline({
</header>
<div className={styles.chart}>
<div className={styles.axis}>
<div className={styles.axisLabel}>
{t('quota_management.windows_credential', { defaultValue: 'Credential' })}
{lanes.length === 0 ? (
<div className={styles.empty} role="status">
{t('quota_management.windows_empty_session', {
defaultValue: 'No credentials on this page report a 5-hour quota window.',
})}
</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 className={styles.axis}>
<div className={styles.axisLabel}>
{t('quota_management.windows_credential', { defaultValue: 'Credential' })}
</div>
))}
</div>
</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}
/>
))}
{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>
{lanes.length > 0 && (
<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>
);
}
+1
View File
@@ -1166,6 +1166,7 @@
"windows_mode_session": "5-hour",
"windows_credential": "Credential",
"windows_idle": "no window counting down",
"windows_empty_session": "No credentials on this page report a 5-hour quota window.",
"windows_legend_current": "current window",
"windows_legend_upcoming": "upcoming",
"windows_legend_elapsed": "elapsed",
+1
View File
@@ -1153,6 +1153,7 @@
"windows_mode_session": "5 часов",
"windows_credential": "Учётные данные",
"windows_idle": "нет активного окна",
"windows_empty_session": "Ни одни учётные данные на этой странице не предоставляют 5-часовое окно квоты.",
"windows_legend_current": "текущее окно",
"windows_legend_upcoming": "предстоящее",
"windows_legend_elapsed": "прошедшее",
+1
View File
@@ -1166,6 +1166,7 @@
"windows_mode_session": "5 小时",
"windows_credential": "凭证",
"windows_idle": "没有正在计时的窗口",
"windows_empty_session": "当前页面没有凭证提供 5 小时配额窗口。",
"windows_legend_current": "当前窗口",
"windows_legend_upcoming": "即将开始",
"windows_legend_elapsed": "已结束",
+1
View File
@@ -1192,6 +1192,7 @@
"windows_mode_session": "5 小時",
"windows_credential": "憑證",
"windows_idle": "沒有正在計時的視窗",
"windows_empty_session": "目前頁面沒有憑證提供 5 小時配額視窗。",
"windows_legend_current": "目前視窗",
"windows_legend_upcoming": "即將開始",
"windows_legend_elapsed": "已結束",
+15 -15
View File
@@ -247,22 +247,22 @@ export interface CodexQuotaState {
// Kimi API payload types
export interface KimiUsageDetail {
used?: number;
limit?: number;
remaining?: number;
used?: number | string;
limit?: number | string;
remaining?: number | string;
name?: string;
title?: string;
resetAt?: string;
reset_at?: string;
resetTime?: string;
reset_time?: string;
resetIn?: number;
reset_in?: number;
ttl?: number;
resetIn?: number | string;
reset_in?: number | string;
ttl?: number | string;
}
export interface KimiLimitWindow {
duration?: number;
duration?: number | string;
timeUnit?: string;
}
@@ -272,16 +272,16 @@ export interface KimiLimitItem {
scope?: string;
detail?: KimiUsageDetail;
window?: KimiLimitWindow;
used?: number;
limit?: number;
remaining?: number;
duration?: number;
used?: number | string;
limit?: number | string;
remaining?: number | string;
duration?: number | string;
timeUnit?: string;
resetAt?: string;
reset_at?: string;
resetIn?: number;
reset_in?: number;
ttl?: number;
resetIn?: number | string;
reset_in?: number | string;
ttl?: number | string;
}
export interface KimiUsagePayload {
@@ -299,7 +299,7 @@ export interface KimiQuotaRow {
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. */
/** Window length in hours, derived from explicit duration metadata or the limit scope. */
periodHours?: number | null;
}
+70 -22
View File
@@ -177,14 +177,29 @@ function kimiResetHint(data: Record<string, unknown>): string | undefined {
return undefined;
}
type KimiTimeUnit = 'second' | 'minute' | 'hour' | 'day';
/** Kimi currently sends protobuf-style values such as TIME_UNIT_MINUTE. */
function normalizeKimiTimeUnit(rawTimeUnit: unknown): KimiTimeUnit | null {
const unit =
typeof rawTimeUnit === 'string'
? rawTimeUnit
.trim()
.toUpperCase()
.replace(/^TIME_UNIT_/, '')
: '';
if (unit === 'SECONDS' || unit === 'SECOND') return 'second';
if (!unit || unit === 'MINUTES' || unit === 'MINUTE') return 'minute';
if (unit === 'HOURS' || unit === 'HOUR') return 'hour';
if (unit === 'DAYS' || unit === 'DAY') return 'day';
return null;
}
function kimiDurationToken(duration: number, rawTimeUnit: unknown): string {
const unit = typeof rawTimeUnit === 'string' ? rawTimeUnit.trim().toUpperCase() : '';
if (unit === 'SECONDS' || unit === 'SECOND') return `${duration}s`;
if (!unit || unit === 'MINUTES' || unit === 'MINUTE') {
return duration % 60 === 0 ? `${duration / 60}h` : `${duration}m`;
}
if (unit === 'HOURS' || unit === 'HOUR') return `${duration}h`;
if (unit === 'DAYS' || unit === 'DAY') return `${duration}d`;
const unit = normalizeKimiTimeUnit(rawTimeUnit);
if (unit === 'second') return `${duration}s`;
if (unit === 'hour') return `${duration}h`;
if (unit === 'day') return `${duration}d`;
return duration % 60 === 0 ? `${duration / 60}h` : `${duration}m`;
}
@@ -244,26 +259,43 @@ function kimiResetMs(data: Record<string, unknown>): number | null {
return null;
}
/** Window length in hours implied by a row's label/scope. */
function kimiPeriodHours(label: string | undefined): number | null {
/** Window length in hours from explicit duration metadata, then label/scope. */
function kimiPeriodHours(
label: string | undefined,
duration: number | null = null,
rawTimeUnit?: unknown
): number | null {
if (duration !== null && duration > 0) {
const unit = normalizeKimiTimeUnit(rawTimeUnit);
if (unit === 'second') return duration / 3600;
if (unit === 'hour') return duration;
if (unit === 'day') return duration * 24;
// Match the card-label fallback: an absent or unknown unit is treated as minutes.
return duration / 60;
}
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;
if (text.includes('5h') || text.includes('hour')) return 5;
return null;
}
function toKimiUsageRow(
data: Record<string, unknown>,
fallbackLabel: KimiRowLabel
): (KimiRowLabel & {
used: number;
limit: number;
resetHint?: string;
resetAtMs?: number | null;
periodHours?: number | null;
}) | null {
fallbackLabel: KimiRowLabel,
duration: number | null = null,
timeUnit?: unknown
):
| (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) {
@@ -283,7 +315,11 @@ function toKimiUsageRow(
limit: limit ?? 0,
resetHint: kimiResetHint(data),
resetAtMs: kimiResetMs(data),
periodHours: kimiPeriodHours(explicitLabel || fallbackLabel.label || fallbackLabel.labelKey),
periodHours: kimiPeriodHours(
explicitLabel || fallbackLabel.label || fallbackLabel.labelKey,
duration,
timeUnit
),
};
}
@@ -294,13 +330,25 @@ export function buildKimiQuotaRows(payload: KimiUsagePayload): KimiQuotaRow[] {
if (Array.isArray(limits)) {
limits.forEach((item, idx) => {
const detail = (item.detail && typeof item.detail === 'object' ? item.detail : item) as
| KimiUsageDetail
| KimiLimitItem;
KimiUsageDetail | KimiLimitItem;
const window = (
item.window && typeof item.window === 'object' ? item.window : {}
) as KimiLimitWindow;
const fallbackLabel = kimiLimitLabel(item, detail, window, idx);
const row = toKimiUsageRow(detail as Record<string, unknown>, fallbackLabel);
const duration =
toInt(window.duration) ??
toInt((item as Record<string, unknown>).duration) ??
toInt((detail as Record<string, unknown>).duration);
const timeUnit =
(window as Record<string, unknown>).timeUnit ??
(item as Record<string, unknown>).timeUnit ??
(detail as Record<string, unknown>).timeUnit;
const row = toKimiUsageRow(
detail as Record<string, unknown>,
fallbackLabel,
duration,
timeUnit
);
if (row) {
rows.push({ id: `limit-${idx}`, ...row });
}
+23 -6
View File
@@ -1,22 +1,27 @@
import { describe, expect, test } from 'bun:test';
import { buildTimelineLane } from '@/features/quota/quotaTimelineModel';
import { buildKimiQuotaRows } from '@/utils/quota';
describe('Kimi quota ordering', () => {
test('shows the 5-hour limit before the weekly limit', () => {
test('shows the 5-hour limit before the weekly limit and exposes it to the timeline', () => {
const rows = buildKimiQuotaRows({
usage: {
used: 200,
limit: 1000,
used: '1',
limit: '100',
remaining: '99',
resetTime: '2099-08-06T13:59:23.136523Z',
},
limits: [
{
detail: {
used: 20,
limit: 100,
used: '2',
limit: '100',
remaining: '98',
resetTime: '2099-07-31T06:59:23.136523Z',
},
window: {
duration: 300,
timeUnit: 'MINUTES',
timeUnit: 'TIME_UNIT_MINUTE',
},
},
],
@@ -25,7 +30,19 @@ describe('Kimi quota ordering', () => {
expect(rows.map(({ id }) => id)).toEqual(['limit-0', 'summary']);
expect(rows[0]?.labelKey).toBe('kimi_quota.limit_window');
expect(rows[0]?.labelParams).toEqual({ duration: '5h' });
expect(rows[0]?.periodHours).toBe(5);
expect(rows[1]?.labelKey).toBe('kimi_quota.weekly_limit');
expect(rows[1]?.periodHours).toBe(168);
const lane = buildTimelineLane({
name: 'kimi.json',
displayName: 'Kimi',
provider: 'kimi',
quota: { status: 'success', rows },
maxPeriodHours: 5,
});
expect(lane.anchorMs).toBe(rows[0]?.resetAtMs);
expect(lane.periodHours).toBe(5);
});
});
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, test } from 'bun:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import '../src/i18n/index';
import { QuotaTimeline } from '../src/features/quota/components/QuotaTimeline';
import type { QuotaFileEntry } from '../src/features/quota/logic';
import { buildKimiQuotaRows } from '../src/utils/quota';
const entries: QuotaFileEntry[] = [
{
file: { name: 'weekly-only.json', type: 'claude' },
type: 'claude',
},
];
const baseProps = {
entries,
displayNameFor: (name: string) => name,
resolvedTheme: 'light' as const,
now: new Date(2026, 6, 29, 12).getTime(),
};
describe('QuotaTimeline rendering', () => {
test('keeps the panel and controls visible when 5-hour mode has no matching lanes', () => {
const weeklyOnlyQuota = {
status: 'success' as const,
windows: [
{
label: '7-day',
usedPercent: 25,
resetAtMs: new Date(2026, 7, 1, 12).getTime(),
periodHours: 168,
},
],
};
const markup = renderToStaticMarkup(
createElement(QuotaTimeline, {
...baseProps,
initialMode: 'session',
quotaFor: () => weeklyOnlyQuota,
})
);
expect(markup).toContain('<section');
expect(markup).toContain('aria-pressed="true"');
expect(markup).toContain('role="status"');
});
test('renders a Kimi 5-hour lane from the protobuf-style time unit', () => {
const rows = buildKimiQuotaRows({
usage: {
used: '1',
limit: '100',
resetTime: '2099-08-06T13:59:23.136523Z',
},
limits: [
{
window: { duration: 300, timeUnit: 'TIME_UNIT_MINUTE' },
detail: {
used: '2',
limit: '100',
resetTime: '2099-07-31T06:59:23.136523Z',
},
},
],
});
const markup = renderToStaticMarkup(
createElement(QuotaTimeline, {
entries: [
{
file: { name: 'kimi-real-response.json', type: 'kimi' },
type: 'kimi',
},
],
displayNameFor: (name: string) => name,
resolvedTheme: 'light',
now: new Date('2099-07-31T04:40:00Z').getTime(),
initialMode: 'session',
quotaFor: () => ({ status: 'success', rows }),
})
);
expect(markup).toContain('kimi-real-response.json');
expect(markup).not.toContain('role="status"');
});
test('stays hidden before any credential exposes a usable quota window', () => {
const markup = renderToStaticMarkup(
createElement(QuotaTimeline, {
...baseProps,
quotaFor: () => undefined,
})
);
expect(markup).toBe('');
});
});