feat(quotaTimeline): add manual reset credit handling and related UI updates

This commit is contained in:
Supra4E8C
2026-08-01 23:45:30 +08:00
parent b1aefecf94
commit 285d9e6753
9 changed files with 277 additions and 14 deletions
@@ -309,6 +309,25 @@ $lane-width: 210px;
font-variant-numeric: tabular-nums;
}
// A reset credit is use-it-or-lose-it, so its expiry sits above the window bar
// as a narrow amber tick without competing with the bar's own label.
.resetCreditTick {
position: absolute;
z-index: 3;
top: 6px;
bottom: 6px;
width: 2px;
transform: translateX(-50%);
border-radius: 999px;
background: var(--amber-color);
box-shadow: 0 0 0 1px var(--bg-primary);
cursor: help;
&:hover {
width: 4px;
}
}
.laneIdle {
position: relative;
z-index: 2;
@@ -351,6 +370,13 @@ $lane-width: 210px;
background: color-mix(in srgb, var(--text-tertiary, #888) 12%, transparent);
}
.swatchCredit {
width: 2px;
height: 14px;
border-radius: 999px;
background: var(--amber-color);
}
.legendNote {
flex: 1 1 320px;
min-width: 0;
@@ -21,8 +21,10 @@ import {
buildTimelineLane,
laneHasWindow,
projectLane,
projectResetCredits,
timelineSpan,
DAY_MS,
HOUR_MS,
} from '../quotaTimelineModel';
import type { TimelineLane, TimelineMode } from '../quotaTimelineModel';
import type { QuotaFileEntry } from '../logic';
@@ -41,6 +43,19 @@ const formatTime = (ms: number) => {
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
const formatRelativeTime = (expiresAtMs: number, now: number, locale?: string) => {
const remainingMs = Math.max(0, expiresAtMs - now);
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'always' });
if (remainingMs >= DAY_MS) {
return formatter.format(Math.ceil(remainingMs / DAY_MS), 'day');
}
if (remainingMs >= HOUR_MS) {
return formatter.format(Math.ceil(remainingMs / HOUR_MS), 'hour');
}
return formatter.format(Math.max(1, Math.ceil(remainingMs / 60_000)), 'minute');
};
export interface QuotaTimelineProps {
entries: QuotaFileEntry[];
/**
@@ -271,6 +286,12 @@ export function QuotaTimeline({
<span className={`${styles.swatch} ${styles.swatchPast}`} />
{t('quota_management.windows_legend_elapsed', { defaultValue: 'elapsed' })}
</span>
<span className={styles.legendItem}>
<span className={styles.swatchCredit} />
{t('quota_management.windows_legend_reset_credit', {
defaultValue: 'manual reset expiry',
})}
</span>
<span className={styles.legendNote}>
{mode === 'weekly'
? t('quota_management.windows_note_weekly', {
@@ -299,12 +320,16 @@ interface LaneProps {
}
function Lane({ lane, span, now, mode, cells, nowPercent, resolvedTheme }: LaneProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const windows = useMemo(
() => projectLane(lane, span.startMs, span.endMs, now, mode),
[lane, span, now, mode]
);
const resetCredits = useMemo(
() => projectResetCredits(lane, span.startMs, span.endMs, now),
[lane, span, now]
);
const colorSet = TYPE_COLORS[lane.provider] || TYPE_COLORS.unknown;
const color: ThemeColors =
@@ -399,6 +424,36 @@ function Lane({ lane, span, now, mode, cells, nowPercent, resolvedTheme }: LaneP
);
})
)}
{resetCredits.map((credit, index) => {
const grantedLabel = t('quota_management.windows_credit_granted', {
defaultValue: 'Granted',
});
const expiresLabel = t('quota_management.windows_credit_expires', {
defaultValue: 'Expires',
});
const title = [
t('quota_management.windows_reset_credit', { defaultValue: 'Manual reset' }),
credit.grantedAtMs !== null
? `${grantedLabel}: ${formatDay(credit.grantedAtMs)} ${formatTime(credit.grantedAtMs)}`
: null,
`${expiresLabel}: ${formatDay(credit.expiresAtMs)} ${formatTime(credit.expiresAtMs)}`,
formatRelativeTime(credit.expiresAtMs, now, i18n.resolvedLanguage),
]
.filter((line): line is string => line !== null)
.join('\n');
return (
<span
key={credit.id || `${credit.expiresAtMs}-${index}`}
className={styles.resetCreditTick}
style={{ left: `${credit.leftPercent}%` }}
title={title}
role="img"
aria-label={title.split('\n').join(', ')}
/>
);
})}
</div>
</div>
);
+66 -5
View File
@@ -33,6 +33,18 @@ export interface TimelineLimit {
remaining: number;
}
/** A manual quota-reset credit attached to a Codex credential. */
export interface TimelineResetCredit {
id: string;
grantedAtMs: number | null;
expiresAtMs: number;
}
/** A reset-credit expiry projected onto the visible span. */
export interface TimelineResetCreditMark extends TimelineResetCredit {
leftPercent: number;
}
/** One credential's row in the chart. */
export interface TimelineLane {
name: string;
@@ -45,6 +57,7 @@ export interface TimelineLane {
/** Remaining percent reported for the window ending at `anchorMs`. */
remaining: number | null;
limits: TimelineLimit[];
resetCredits: TimelineResetCredit[];
}
/** One drawn bar: a single window occurrence within the visible span. */
@@ -174,6 +187,29 @@ export function projectLane(
.filter((window): window is TimelineWindow => window !== null);
}
/** Project unexpired reset-credit expiry instants onto the visible span. */
export function projectResetCredits(
lane: TimelineLane,
spanStartMs: number,
spanEndMs: number,
now: number
): TimelineResetCreditMark[] {
const span = spanEndMs - spanStartMs;
if (span <= 0) return [];
return lane.resetCredits
.filter(
(credit) =>
credit.expiresAtMs > now &&
credit.expiresAtMs >= spanStartMs &&
credit.expiresAtMs < spanEndMs
)
.map((credit) => ({
...credit,
leftPercent: ((credit.expiresAtMs - spanStartMs) / span) * 100,
}));
}
/**
* Pick the window a lane is drawn from: the one whose period best fits the
* visible span, tie-broken by the soonest reset.
@@ -188,10 +224,9 @@ export function projectLane(
* 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 {
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)
);
@@ -239,6 +274,13 @@ interface WindowLike {
periodHours?: number | null;
}
interface ResetCreditLike {
id?: string;
status?: string;
grantedAt?: string;
expiresAt?: string;
}
interface KimiRowLike {
label?: string;
labelKey?: string;
@@ -301,6 +343,7 @@ export function buildTimelineLane(input: TimelineLaneInput): TimelineLane {
periodHours: null,
remaining: null,
limits: [],
resetCredits: [],
};
if (!quota || quota.status !== 'success') return empty;
@@ -312,6 +355,24 @@ export function buildTimelineLane(input: TimelineLaneInput): TimelineLane {
const chosen = pickLaneWindow(windows, maxPeriodHours);
if (!chosen) return empty;
const resetCredits =
provider === 'codex'
? ((quota as { rateLimitResetCredits?: ResetCreditLike[] }).rateLimitResetCredits ?? [])
.filter((credit) => credit.status === 'available')
.map((credit): TimelineResetCredit | null => {
const expiresAtMs = new Date(credit.expiresAt ?? '').getTime();
if (!Number.isFinite(expiresAtMs)) return null;
const grantedAtMs = new Date(credit.grantedAt ?? '').getTime();
return {
id: credit.id ?? '',
grantedAtMs: Number.isFinite(grantedAtMs) ? grantedAtMs : null,
expiresAtMs,
};
})
.filter((credit): credit is TimelineResetCredit => credit !== null)
: [];
return {
...empty,
anchorMs: chosen.resetAtMs ?? null,
@@ -325,6 +386,7 @@ export function buildTimelineLane(input: TimelineLaneInput): TimelineLane {
label: window.label ?? '',
remaining: clampPercent(100 - (window.usedPercent as number)),
})),
resetCredits,
};
}
@@ -409,4 +471,3 @@ export function buildTimelineLane(input: TimelineLaneInput): TimelineLane {
return empty;
}
+4
View File
@@ -1172,6 +1172,10 @@
"windows_legend_current": "current window",
"windows_legend_upcoming": "upcoming",
"windows_legend_elapsed": "elapsed",
"windows_legend_reset_credit": "manual reset expiry",
"windows_reset_credit": "Manual reset",
"windows_credit_granted": "Granted",
"windows_credit_expires": "Expires",
"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",
+4
View File
@@ -1159,6 +1159,10 @@
"windows_legend_current": "текущее окно",
"windows_legend_upcoming": "предстоящее",
"windows_legend_elapsed": "прошедшее",
"windows_legend_reset_credit": "срок ручного сброса",
"windows_reset_credit": "Ручной сброс",
"windows_credit_granted": "Предоставлен",
"windows_credit_expires": "Истекает",
"windows_note_weekly": "Каждая полоса — одно окно квоты, от открытия до сброса. Окна, заканчивающиеся вместе, конкурируют за одни и те же дни.",
"windows_note_session": "Каждая полоса — одно 5-часовое окно. Показаны только учётные данные с активным окном; остальные остаются пустыми.",
"weekday_sun": "Вс",
+4
View File
@@ -1172,6 +1172,10 @@
"windows_legend_current": "当前窗口",
"windows_legend_upcoming": "即将开始",
"windows_legend_elapsed": "已结束",
"windows_legend_reset_credit": "主动重置过期",
"windows_reset_credit": "主动重置",
"windows_credit_granted": "获得时间",
"windows_credit_expires": "过期时间",
"windows_note_weekly": "每条表示一个完整的配额窗口,从开启到重置。同时结束的窗口会争用相同的日期。",
"windows_note_session": "每条表示一个 5 小时窗口。仅显示有正在计时窗口的凭证,其余保持为空。",
"weekday_sun": "日",
+4
View File
@@ -1198,6 +1198,10 @@
"windows_legend_current": "目前視窗",
"windows_legend_upcoming": "即將開始",
"windows_legend_elapsed": "已結束",
"windows_legend_reset_credit": "主動重設過期",
"windows_reset_credit": "主動重設",
"windows_credit_granted": "取得時間",
"windows_credit_expires": "過期時間",
"windows_note_weekly": "每條表示一個完整的配額視窗,從開啟到重設。同時結束的視窗會競用相同的日期。",
"windows_note_session": "每條表示一個 5 小時視窗。僅顯示有正在計時視窗的憑證,其餘保持為空。",
"weekday_sun": "日",
+75 -8
View File
@@ -6,6 +6,7 @@ import {
laneHasWindow,
pickLaneWindow,
projectLane,
projectResetCredits,
startOfDay,
startOfWeek,
timelineSpan,
@@ -13,8 +14,7 @@ import {
} 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();
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', () => {
@@ -107,6 +107,7 @@ describe('projectLane', () => {
periodHours: 24 * 7,
remaining: 40,
limits: [],
resetCredits: [],
...over,
});
@@ -170,6 +171,29 @@ describe('projectLane', () => {
);
expect(windows.some((w) => w.endMs - w.startMs === 5 * HOUR_MS)).toBe(true);
});
test('projects only unexpired reset credits inside the visible span', () => {
const now = at(2026, 6, 29, 12);
const visibleExpiry = at(2026, 7, 2, 12);
const marks = projectResetCredits(
lane({
resetCredits: [
{ id: 'expired', grantedAtMs: null, expiresAtMs: now - HOUR_MS },
{ id: 'visible', grantedAtMs: now - DAY_MS, expiresAtMs: visibleExpiry },
{ id: 'outside', grantedAtMs: now, expiresAtMs: span.endMs + HOUR_MS },
],
}),
span.startMs,
span.endMs,
now
);
expect(marks).toHaveLength(1);
expect(marks[0].id).toBe('visible');
expect(marks[0].leftPercent).toBe(
((visibleExpiry - span.startMs) / (span.endMs - span.startMs)) * 100
);
});
});
describe('pickLaneWindow', () => {
@@ -230,7 +254,10 @@ describe('buildTimelineLane', () => {
// Fortnight view: the weekly window, even though the 5-hour resets sooner.
const weekly = buildTimelineLane({
...base, provider: 'claude', quota, maxPeriodHours: 14 * 24,
...base,
provider: 'claude',
quota,
maxPeriodHours: 14 * 24,
});
expect(weekly.anchorMs).toBe(later);
expect(weekly.periodHours).toBe(168);
@@ -238,7 +265,10 @@ describe('buildTimelineLane', () => {
// 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,
...base,
provider: 'claude',
quota,
maxPeriodHours: 3 * 24,
});
expect(session.anchorMs).toBe(soon);
expect(session.periodHours).toBe(5);
@@ -251,6 +281,41 @@ describe('buildTimelineLane', () => {
]);
});
test('codex: includes available reset credits with parseable expiry dates', () => {
const expiresAt = '2026-08-02T12:00:00Z';
const lane = buildTimelineLane({
...base,
provider: 'codex',
quota: {
status: 'success',
windows: [{ label: '7-day', usedPercent: 90, resetAtMs: 5000, periodHours: 168 }],
rateLimitResetCredits: [
{
id: 'credit-1',
status: 'available',
grantedAt: '2026-07-01T12:00:00Z',
expiresAt,
},
{
id: 'spent',
status: 'consumed',
grantedAt: '2026-07-01T12:00:00Z',
expiresAt,
},
{ id: 'invalid', status: 'available', grantedAt: '', expiresAt: 'not-a-date' },
],
},
});
expect(lane.resetCredits).toEqual([
{
id: 'credit-1',
grantedAtMs: new Date('2026-07-01T12:00:00Z').getTime(),
expiresAtMs: new Date(expiresAt).getTime(),
},
]);
});
test('kimi: derives remaining from raw used/limit counts', () => {
const lane = buildTimelineLane({
...base,
@@ -381,9 +446,9 @@ describe('buildTimelineLane', () => {
});
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
);
expect(
laneHasWindow(buildTimelineLane({ ...base, provider: 'claude', quota: undefined }))
).toBe(false);
});
test('providers with no usable reset produce an empty lane, not a dropped one', () => {
@@ -402,7 +467,9 @@ describe('buildTimelineLane', () => {
});
test('unloaded or errored quota produces an empty lane', () => {
expect(buildTimelineLane({ ...base, provider: 'claude', quota: undefined }).anchorMs).toBeNull();
expect(
buildTimelineLane({ ...base, provider: 'claude', quota: undefined }).anchorMs
).toBeNull();
expect(
buildTimelineLane({ ...base, provider: 'claude', quota: { status: 'error' } }).anchorMs
).toBeNull();
+38
View File
@@ -86,6 +86,44 @@ describe('QuotaTimeline rendering', () => {
expect(markup).not.toContain('role="status"');
});
test('renders an unexpired Codex reset credit as an expiry tick', () => {
const markup = renderToStaticMarkup(
createElement(QuotaTimeline, {
entries: [
{
file: { name: 'codex-credit.json', type: 'codex' },
type: 'codex',
},
],
displayNameFor: (name: string) => name,
resolvedTheme: 'light',
now: new Date(2026, 6, 29, 12).getTime(),
quotaFor: () => ({
status: 'success',
windows: [
{
label: '7-day',
usedPercent: 90,
resetAtMs: new Date(2026, 7, 1, 12).getTime(),
periodHours: 168,
},
],
rateLimitResetCredits: [
{
id: 'credit-1',
status: 'available',
grantedAt: '2026-07-20T12:00:00Z',
expiresAt: '2026-08-03T12:00:00Z',
},
],
}),
})
);
expect(markup).toContain('role="img"');
expect(markup).toContain('08/03 12:00');
});
test('stays hidden before any credential exposes a usable quota window', () => {
const markup = renderToStaticMarkup(
createElement(QuotaTimeline, {