mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-08-28 16:31:13 +08:00
feat(quota): highlight the row that recovers first on each card
A card lists several limits at once — a 5-hour window, a weekly one, a handful of manual reset credits — and only the one that recovers first governs what the credential can do next. Finding it meant reading five timestamps. That row now carries a warning-tinted rule and a bolder countdown, with a title attribute so the state is not conveyed by colour alone. The emphasis is ranked across windows and credits together: a credit expiring tonight outranks a weekly window resetting on Friday. Only genuine capacity-return events are ranked. The Codex subscription renewal date and xAI's monthly billing rollover are excluded — a spend cap turning over is not a rate limit lifting — though both still render their own countdown. resetCreditRowId is exported and used for both the React key and the highlight comparison, so the two cannot drift and put the emphasis on the wrong row. Antigravity ranks against its own server-corrected clock so the highlight and the countdown beside it always agree. No transition on the highlight: it moves to another row the moment a window resets, and cross-fading a jumping target reads as flicker. Deliberately not merged with buildTimelineLane, which reads the same five shapes to pick one window per credential — the longest that fits the visible span. Same inputs, opposite selection rules.
This commit is contained in:
@@ -72,6 +72,15 @@ button.quotaMessageAction {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Compact host: same contract class, tighter gutter. */
|
||||
.quotaRowSoon {
|
||||
padding: 3px 6px;
|
||||
margin: 0 -6px;
|
||||
border-left: 2px solid var(--warning-border);
|
||||
border-radius: $radius-sm;
|
||||
background-color: var(--warning-bg);
|
||||
}
|
||||
|
||||
.quotaRowHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -117,6 +126,11 @@ button.quotaMessageAction {
|
||||
}
|
||||
}
|
||||
|
||||
.quotaResetRelativeSoon {
|
||||
color: var(--warning-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quotaAmount {
|
||||
font-family: $font-mono;
|
||||
font-size: 11px;
|
||||
@@ -272,6 +286,16 @@ button.quotaMessageAction {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
/* The compact host's credit rows have no border of their own, so the emphasis
|
||||
is carried by a left rule and a tint rather than a border-colour swap. */
|
||||
.codexResetCreditRowSoon {
|
||||
padding: 2px 6px;
|
||||
margin: 0 -6px;
|
||||
border-left: 2px solid var(--warning-border);
|
||||
border-radius: $radius-sm;
|
||||
background-color: var(--warning-bg);
|
||||
}
|
||||
|
||||
.codexResetCreditLabel {
|
||||
min-width: 0;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -20,6 +20,17 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The limit that governs what this credential can do next.
|
||||
Deliberately no transition: the emphasis moves to another row the moment a
|
||||
window resets, and cross-fading a target that jumps reads as flicker. */
|
||||
.quotaRowSoon {
|
||||
padding: 4px 8px;
|
||||
margin: 0 -8px;
|
||||
border-left: 2px solid var(--warning-border);
|
||||
border-radius: $radius-sm;
|
||||
background-color: var(--warning-bg);
|
||||
}
|
||||
|
||||
.quotaRowHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -71,6 +82,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.quotaResetRelativeSoon {
|
||||
color: var(--warning-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quotaAmount {
|
||||
font-family: $font-mono;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -247,6 +263,11 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.codexResetCreditRowSoon {
|
||||
border-color: var(--warning-border);
|
||||
background-color: var(--warning-bg);
|
||||
}
|
||||
|
||||
.codexResetCreditLabel {
|
||||
color: var(--text-tertiary);
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -12,13 +12,25 @@ import type { QuotaClassMap } from '../types';
|
||||
export interface QuotaResetLabelProps {
|
||||
display: ResetDisplay;
|
||||
classes: QuotaClassMap;
|
||||
/** True on the row that recovers first for this credential. */
|
||||
soon?: boolean;
|
||||
}
|
||||
|
||||
export function QuotaResetLabel({ display, classes }: QuotaResetLabelProps) {
|
||||
export function QuotaResetLabel({ display, classes, soon = false }: QuotaResetLabelProps) {
|
||||
return (
|
||||
<>
|
||||
<span className={classes.quotaReset}>{display.absolute}</span>
|
||||
{display.relative && <span className={classes.quotaResetRelative}>{display.relative}</span>}
|
||||
{display.relative && (
|
||||
<span
|
||||
className={
|
||||
soon
|
||||
? `${classes.quotaResetRelative} ${classes.quotaResetRelativeSoon}`
|
||||
: classes.quotaResetRelative
|
||||
}
|
||||
>
|
||||
{display.relative}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AntigravityQuotaState, AntigravityQuotaSubscription } from '@/types';
|
||||
import { QuotaMeter } from '../../components/QuotaMeter';
|
||||
import { collectQuotaRowInstants, pickSoonestRowId } from '../../resetSchedule';
|
||||
import type { QuotaBodyProps } from '../../types';
|
||||
import { getNextAntigravityCountdownUpdateDelay } from './countdown';
|
||||
|
||||
@@ -144,6 +145,13 @@ export function AntigravityQuotaBody({ quota, classes }: QuotaBodyProps<Antigrav
|
||||
};
|
||||
}, [resetTimestamps, serverTimeOffsetMs]);
|
||||
|
||||
// Ranked against this provider's own server-corrected clock rather than the
|
||||
// shared one, so the highlight and the countdown beside it always agree.
|
||||
const soonestRowId = useMemo(
|
||||
() => pickSoonestRowId(collectQuotaRowInstants('antigravity', quota), nowMs),
|
||||
[quota, nowMs]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{planLabel && (
|
||||
@@ -197,15 +205,31 @@ export function AntigravityQuotaBody({ quota, classes }: QuotaBodyProps<Antigrav
|
||||
t
|
||||
);
|
||||
|
||||
const soon = bucket.id === soonestRowId;
|
||||
|
||||
return (
|
||||
<div key={bucket.id} className={classes.quotaRow}>
|
||||
<div
|
||||
key={bucket.id}
|
||||
className={
|
||||
soon ? `${classes.quotaRow} ${classes.quotaRowSoon}` : classes.quotaRow
|
||||
}
|
||||
>
|
||||
<div className={classes.quotaRowHeader}>
|
||||
<span className={classes.quotaModel} title={bucketDescription}>
|
||||
{bucketLabel}
|
||||
</span>
|
||||
<div className={classes.quotaMeta}>
|
||||
<span className={classes.quotaPercent}>{percentLabel}</span>
|
||||
<span className={classes.quotaReset}>{resetLabel}</span>
|
||||
<span
|
||||
className={
|
||||
soon
|
||||
? `${classes.quotaReset} ${classes.quotaResetRelativeSoon}`
|
||||
: classes.quotaReset
|
||||
}
|
||||
title={soon ? t('quota_management.soonest_row_hint') : undefined}
|
||||
>
|
||||
{resetLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<QuotaMeter percent={percent} classes={classes} index={index} />
|
||||
|
||||
@@ -2,17 +2,23 @@
|
||||
* Claude 额度渲染体:套餐/额外用量 chip 行 + 用量窗口水位条。
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ClaudeQuotaState } from '@/types';
|
||||
import { buildResetDisplay } from '@/utils/quota';
|
||||
import { useNow } from '@/hooks/useNow';
|
||||
import { QuotaMeter } from '../../components/QuotaMeter';
|
||||
import { QuotaResetLabel } from '../../components/QuotaResetLabel';
|
||||
import { collectQuotaRowInstants, pickSoonestRowId } from '../../resetSchedule';
|
||||
import type { QuotaBodyProps } from '../../types';
|
||||
|
||||
export function ClaudeQuotaBody({ quota, classes }: QuotaBodyProps<ClaudeQuotaState>) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const now = useNow();
|
||||
const soonestRowId = useMemo(
|
||||
() => pickSoonestRowId(collectQuotaRowInstants('claude', quota), now),
|
||||
[quota, now]
|
||||
);
|
||||
const windows = quota.windows ?? [];
|
||||
const extraUsage = quota.extraUsage ?? null;
|
||||
const planType = quota.planType ?? null;
|
||||
@@ -50,13 +56,21 @@ export function ClaudeQuotaBody({ quota, classes }: QuotaBodyProps<ClaudeQuotaSt
|
||||
i18n.resolvedLanguage
|
||||
);
|
||||
|
||||
const soon = window.id === soonestRowId;
|
||||
|
||||
return (
|
||||
<div key={window.id} className={classes.quotaRow}>
|
||||
<div
|
||||
key={window.id}
|
||||
className={soon ? `${classes.quotaRow} ${classes.quotaRowSoon}` : classes.quotaRow}
|
||||
title={soon ? t('quota_management.soonest_row_hint') : undefined}
|
||||
>
|
||||
<div className={classes.quotaRowHeader}>
|
||||
<span className={classes.quotaModel}>{windowLabel}</span>
|
||||
<div className={classes.quotaMeta}>
|
||||
<span className={classes.quotaPercent}>{percentLabel}</span>
|
||||
{resetDisplay && <QuotaResetLabel display={resetDisplay} classes={classes} />}
|
||||
{resetDisplay && (
|
||||
<QuotaResetLabel display={resetDisplay} classes={classes} soon={soon} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<QuotaMeter percent={remaining} classes={classes} index={index} />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* 重置积分明细、用量窗口水位条。
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CodexQuotaState } from '@/types';
|
||||
import {
|
||||
@@ -19,6 +20,7 @@ import { formatDateTimeValue } from '@/utils/format';
|
||||
import { useNow } from '@/hooks/useNow';
|
||||
import { QuotaMeter } from '../../components/QuotaMeter';
|
||||
import { QuotaResetLabel } from '../../components/QuotaResetLabel';
|
||||
import { collectQuotaRowInstants, pickSoonestRowId, resetCreditRowId } from '../../resetSchedule';
|
||||
import type { QuotaBodyProps, QuotaClassMap } from '../../types';
|
||||
|
||||
const getPlanValueClass = (planType: string | null, classes: QuotaClassMap): string => {
|
||||
@@ -33,6 +35,12 @@ export function CodexQuotaBody({ quota, classes }: QuotaBodyProps<CodexQuotaStat
|
||||
const { t, i18n } = useTranslation();
|
||||
const now = useNow();
|
||||
const locale = i18n.resolvedLanguage;
|
||||
// Windows and reset credits compete for the same emphasis: a credit expiring
|
||||
// tonight matters more than a weekly window resetting on Friday.
|
||||
const soonestRowId = useMemo(
|
||||
() => pickSoonestRowId(collectQuotaRowInstants('codex', quota), now),
|
||||
[quota, now]
|
||||
);
|
||||
const windows = quota.windows ?? [];
|
||||
const planType = quota.planType ?? null;
|
||||
const subscriptionActiveUntil = quota.subscriptionActiveUntil ?? null;
|
||||
@@ -111,16 +119,27 @@ export function CodexQuotaBody({ quota, classes }: QuotaBodyProps<CodexQuotaStat
|
||||
now,
|
||||
locale
|
||||
);
|
||||
// One expression for both the key and the highlight — two copies
|
||||
// that drift would emphasize the wrong row.
|
||||
const rowId = resetCreditRowId(credit, index);
|
||||
const soon = rowId === soonestRowId;
|
||||
return (
|
||||
<div
|
||||
key={credit.id || `${credit.expiresAt}-${index}`}
|
||||
className={classes.codexResetCreditRow}
|
||||
key={rowId}
|
||||
className={
|
||||
soon
|
||||
? `${classes.codexResetCreditRow} ${classes.codexResetCreditRowSoon}`
|
||||
: classes.codexResetCreditRow
|
||||
}
|
||||
title={soon ? t('quota_management.soonest_row_hint') : undefined}
|
||||
>
|
||||
<span className={classes.codexResetCreditLabel}>
|
||||
{t('codex_quota.reset_credit_number', { index: index + 1 })}
|
||||
</span>
|
||||
<span className={classes.codexResetCreditTime}>
|
||||
{expiresDisplay && <QuotaResetLabel display={expiresDisplay} classes={classes} />}
|
||||
{expiresDisplay && (
|
||||
<QuotaResetLabel display={expiresDisplay} classes={classes} soon={soon} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -147,13 +166,21 @@ export function CodexQuotaBody({ quota, classes }: QuotaBodyProps<CodexQuotaStat
|
||||
: window.label;
|
||||
const resetDisplay = buildResetDisplay(window.resetLabel, window.resetAtMs, now, locale);
|
||||
|
||||
const soon = window.id === soonestRowId;
|
||||
|
||||
return (
|
||||
<div key={window.id} className={classes.quotaRow}>
|
||||
<div
|
||||
key={window.id}
|
||||
className={soon ? `${classes.quotaRow} ${classes.quotaRowSoon}` : classes.quotaRow}
|
||||
title={soon ? t('quota_management.soonest_row_hint') : undefined}
|
||||
>
|
||||
<div className={classes.quotaRowHeader}>
|
||||
<span className={classes.quotaModel}>{windowLabel}</span>
|
||||
<div className={classes.quotaMeta}>
|
||||
<span className={classes.quotaPercent}>{percentLabel}</span>
|
||||
{resetDisplay && <QuotaResetLabel display={resetDisplay} classes={classes} />}
|
||||
{resetDisplay && (
|
||||
<QuotaResetLabel display={resetDisplay} classes={classes} soon={soon} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<QuotaMeter percent={remaining} classes={classes} index={index} />
|
||||
|
||||
@@ -2,14 +2,23 @@
|
||||
* Kimi 额度渲染体:用量行水位条。
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { KimiQuotaState } from '@/types';
|
||||
import { formatKimiResetHint } from '@/utils/quota';
|
||||
import { useNow } from '@/hooks/useNow';
|
||||
import { QuotaMeter } from '../../components/QuotaMeter';
|
||||
import { collectQuotaRowInstants, pickSoonestRowId } from '../../resetSchedule';
|
||||
import type { QuotaBodyProps } from '../../types';
|
||||
|
||||
export function KimiQuotaBody({ quota, classes }: QuotaBodyProps<KimiQuotaState>) {
|
||||
const { t } = useTranslation();
|
||||
// Ahead of the early return below — hooks cannot be conditional.
|
||||
const now = useNow();
|
||||
const soonestRowId = useMemo(
|
||||
() => pickSoonestRowId(collectQuotaRowInstants('kimi', quota), now),
|
||||
[quota, now]
|
||||
);
|
||||
const rows = quota.rows ?? [];
|
||||
|
||||
if (rows.length === 0) {
|
||||
@@ -32,14 +41,29 @@ export function KimiQuotaBody({ quota, classes }: QuotaBodyProps<KimiQuotaState>
|
||||
? t(row.labelKey, (row.labelParams ?? {}) as Record<string, string | number>)
|
||||
: (row.label ?? '');
|
||||
const resetLabel = formatKimiResetHint(t, row.resetHint);
|
||||
const soon = row.id === soonestRowId;
|
||||
|
||||
return (
|
||||
<div key={row.id} className={classes.quotaRow}>
|
||||
<div
|
||||
key={row.id}
|
||||
className={soon ? `${classes.quotaRow} ${classes.quotaRowSoon}` : classes.quotaRow}
|
||||
title={soon ? t('quota_management.soonest_row_hint') : undefined}
|
||||
>
|
||||
<div className={classes.quotaRowHeader}>
|
||||
<span className={classes.quotaModel}>{rowLabel}</span>
|
||||
<div className={classes.quotaMeta}>
|
||||
<span className={classes.quotaPercent}>{percentLabel}</span>
|
||||
{resetLabel && <span className={classes.quotaReset}>{resetLabel}</span>}
|
||||
{resetLabel && (
|
||||
<span
|
||||
className={
|
||||
soon
|
||||
? `${classes.quotaReset} ${classes.quotaResetRelativeSoon}`
|
||||
: classes.quotaReset
|
||||
}
|
||||
>
|
||||
{resetLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<QuotaMeter percent={remaining} classes={classes} index={index} />
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
* 周/月账单水位条、按量付费余额。
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { XaiBillingSummary, XaiQuotaState } from '@/types';
|
||||
import { buildResetDisplay, formatQuotaResetTime, parseIsoToMs } from '@/utils/quota';
|
||||
import { useNow } from '@/hooks/useNow';
|
||||
import { QuotaMeter } from '../../components/QuotaMeter';
|
||||
import { QuotaResetLabel } from '../../components/QuotaResetLabel';
|
||||
import { XAI_WEEKLY_ROW_ID, collectQuotaRowInstants, pickSoonestRowId } from '../../resetSchedule';
|
||||
import type { QuotaBodyProps } from '../../types';
|
||||
|
||||
const formatUsdFromCents = (cents: number | null): string => {
|
||||
@@ -66,6 +68,12 @@ export function XaiQuotaBody({ quota, classes }: QuotaBodyProps<XaiQuotaState>)
|
||||
// Ahead of the early return below — hooks cannot be conditional.
|
||||
const now = useNow();
|
||||
const locale = i18n.resolvedLanguage;
|
||||
// Only the weekly limit is a quota window; the monthly figure is a billing
|
||||
// cycle, so it is never the row that "recovers first".
|
||||
const weeklySoon = useMemo(
|
||||
() => pickSoonestRowId(collectQuotaRowInstants('xai', quota), now) === XAI_WEEKLY_ROW_ID,
|
||||
[quota, now]
|
||||
);
|
||||
const billing = quota.billing;
|
||||
|
||||
if (!billing) {
|
||||
@@ -140,7 +148,10 @@ export function XaiQuotaBody({ quota, classes }: QuotaBodyProps<XaiQuotaState>)
|
||||
</div>
|
||||
)}
|
||||
{hasWeeklyData && (
|
||||
<div className={classes.quotaRow}>
|
||||
<div
|
||||
className={weeklySoon ? `${classes.quotaRow} ${classes.quotaRowSoon}` : classes.quotaRow}
|
||||
title={weeklySoon ? t('quota_management.soonest_row_hint') : undefined}
|
||||
>
|
||||
<div className={classes.quotaRowHeader}>
|
||||
<span className={classes.quotaModel}>{t('xai_quota.weekly_limit')}</span>
|
||||
<div className={classes.quotaMeta}>
|
||||
@@ -150,7 +161,7 @@ export function XaiQuotaBody({ quota, classes }: QuotaBodyProps<XaiQuotaState>)
|
||||
})}
|
||||
</span>
|
||||
{weeklyResetDisplay && (
|
||||
<QuotaResetLabel display={weeklyResetDisplay} classes={classes} />
|
||||
<QuotaResetLabel display={weeklyResetDisplay} classes={classes} soon={weeklySoon} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* When each row on a quota card gets its capacity back.
|
||||
*
|
||||
* A card lists several limits at once — a 5-hour window, a weekly one, a
|
||||
* handful of manual reset credits — and the only one that governs what you can
|
||||
* do next is whichever recovers first. Reading five timestamps to find it is
|
||||
* the work this moves off the user.
|
||||
*
|
||||
* Pure and React-free: `nowMs` is passed in, the quota state is read
|
||||
* structurally, and nothing here imports the store.
|
||||
*
|
||||
* Related but deliberately separate from `buildTimelineLane` in
|
||||
* quotaTimelineModel.ts. That reads the same five shapes to pick *one* window
|
||||
* per credential — the longest that fits the visible span, because a fortnight
|
||||
* drawn from 5-hour windows is 67 unreadable slivers. This wants *every*
|
||||
* instant and the *soonest* of them. Same inputs, opposite selection rules;
|
||||
* merging them would mean one function with a mode flag and two sets of pinned
|
||||
* tests fighting each other.
|
||||
*/
|
||||
|
||||
import { parseIsoToMs } from '@/utils/quota';
|
||||
import type { QuotaProviderType } from './providers/types';
|
||||
|
||||
export interface QuotaRowInstant {
|
||||
/** Matches the React key of the row it belongs to. */
|
||||
rowId: string;
|
||||
atMs: number;
|
||||
kind: 'window' | 'credit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Row identity for a Codex reset credit.
|
||||
*
|
||||
* Exported so `CodexQuotaBody` can use one expression for both its React key
|
||||
* and its highlight comparison. Two copies of `credit.id || fallback` that
|
||||
* drift apart would put the emphasis on the wrong row, which is worse than no
|
||||
* emphasis at all.
|
||||
*/
|
||||
export function resetCreditRowId(
|
||||
credit: { id?: string; expiresAt?: string },
|
||||
index: number
|
||||
): string {
|
||||
return credit.id || `${credit.expiresAt}-${index}`;
|
||||
}
|
||||
|
||||
/* Structural shapes — the five provider states disagree about where a window
|
||||
lives, so each is read on its own terms rather than through a lossy
|
||||
normalized model. */
|
||||
|
||||
interface WindowLike {
|
||||
id?: string;
|
||||
resetAtMs?: number | null;
|
||||
}
|
||||
|
||||
interface ResetCreditLike {
|
||||
id?: string;
|
||||
status?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
/** Row id used by the xAI weekly limit, which has no id of its own. */
|
||||
export const XAI_WEEKLY_ROW_ID = 'xai:weekly';
|
||||
|
||||
const isUsableMs = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
|
||||
const collectRows = (rows: readonly WindowLike[], fallbackPrefix: string): QuotaRowInstant[] =>
|
||||
rows
|
||||
.map((row, index): QuotaRowInstant | null =>
|
||||
isUsableMs(row.resetAtMs)
|
||||
? { rowId: row.id || `${fallbackPrefix}-${index}`, atMs: row.resetAtMs, kind: 'window' }
|
||||
: null
|
||||
)
|
||||
.filter((instant): instant is QuotaRowInstant => instant !== null);
|
||||
|
||||
/**
|
||||
* Every recovery instant on one credential, tagged with the row it belongs to.
|
||||
*
|
||||
* Only genuine capacity-return events are collected. The Codex subscription
|
||||
* renewal date and xAI's monthly billing rollover are excluded: a spend cap
|
||||
* turning over is not a rate limit lifting, and ranking cards by it would
|
||||
* answer a different question than the one being asked. Both still render
|
||||
* their own countdown.
|
||||
*/
|
||||
export function collectQuotaRowInstants(
|
||||
provider: QuotaProviderType,
|
||||
quota: unknown
|
||||
): QuotaRowInstant[] {
|
||||
const state = quota as { status?: string } | undefined;
|
||||
if (!state || state.status !== 'success') return [];
|
||||
|
||||
if (provider === 'claude' || provider === 'codex') {
|
||||
const windows = collectRows((quota as { windows?: WindowLike[] }).windows ?? [], 'window');
|
||||
if (provider !== 'codex') return windows;
|
||||
|
||||
const credits = (
|
||||
(quota as { rateLimitResetCredits?: ResetCreditLike[] }).rateLimitResetCredits ?? []
|
||||
)
|
||||
.map((credit, index): QuotaRowInstant | null => {
|
||||
if (credit.status !== 'available') return null;
|
||||
const atMs = parseIsoToMs(credit.expiresAt);
|
||||
return atMs === null
|
||||
? null
|
||||
: { rowId: resetCreditRowId(credit, index), atMs, kind: 'credit' };
|
||||
})
|
||||
.filter((instant): instant is QuotaRowInstant => instant !== null);
|
||||
|
||||
return [...windows, ...credits];
|
||||
}
|
||||
|
||||
if (provider === 'xai') {
|
||||
const billing = (
|
||||
quota as { billing?: { periodType?: string; resetAtMs?: number | null } | null }
|
||||
).billing;
|
||||
if (!billing || billing.periodType !== 'weekly' || !isUsableMs(billing.resetAtMs)) return [];
|
||||
return [{ rowId: XAI_WEEKLY_ROW_ID, atMs: billing.resetAtMs, kind: 'window' }];
|
||||
}
|
||||
|
||||
if (provider === 'antigravity') {
|
||||
// Buckets live inside groups; the grouping is a display concern here.
|
||||
const buckets = ((quota as { groups?: { buckets?: WindowLike[] }[] }).groups ?? []).flatMap(
|
||||
(group) => group.buckets ?? []
|
||||
);
|
||||
return collectRows(buckets, 'bucket');
|
||||
}
|
||||
|
||||
if (provider === 'kimi') {
|
||||
return collectRows((quota as { rows?: WindowLike[] }).rows ?? [], 'row');
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The row that recovers first, or null when nothing is still pending.
|
||||
*
|
||||
* Instants at or before `now` are ignored — a window that already reset must
|
||||
* not keep the emphasis. Ties break on row id so the choice is deterministic
|
||||
* rather than dependent on collection order.
|
||||
*/
|
||||
export function pickSoonestRowId(
|
||||
instants: readonly QuotaRowInstant[],
|
||||
nowMs: number
|
||||
): string | null {
|
||||
let best: QuotaRowInstant | null = null;
|
||||
for (const instant of instants) {
|
||||
if (instant.atMs <= nowMs) continue;
|
||||
if (
|
||||
best === null ||
|
||||
instant.atMs < best.atMs ||
|
||||
(instant.atMs === best.atMs && instant.rowId < best.rowId)
|
||||
) {
|
||||
best = instant;
|
||||
}
|
||||
}
|
||||
return best?.rowId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soonest upcoming recovery instant for a whole credential — the sort key for
|
||||
* "soonest recovery first". Null when nothing is loaded or nothing is pending.
|
||||
*/
|
||||
export function nextRecoveryMs(
|
||||
provider: QuotaProviderType,
|
||||
quota: unknown,
|
||||
nowMs: number
|
||||
): number | null {
|
||||
let best: number | null = null;
|
||||
for (const instant of collectQuotaRowInstants(provider, quota)) {
|
||||
if (instant.atMs <= nowMs) continue;
|
||||
if (best === null || instant.atMs < best) best = instant.atMs;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -10,12 +10,15 @@
|
||||
export interface QuotaClassMap {
|
||||
// 额度行(五个提供商共用)
|
||||
quotaRow: string;
|
||||
/** Applied to the row that recovers first on this credential. */
|
||||
quotaRowSoon: string;
|
||||
quotaRowHeader: string;
|
||||
quotaModel: string;
|
||||
quotaMeta: string;
|
||||
quotaPercent: string;
|
||||
quotaReset: string;
|
||||
quotaResetRelative: string;
|
||||
quotaResetRelativeSoon: string;
|
||||
quotaAmount: string;
|
||||
quotaMessage: string;
|
||||
// 套餐 chip 行(codex 命名,claude/antigravity/kimi/xai 复用;
|
||||
@@ -30,6 +33,7 @@ export interface QuotaClassMap {
|
||||
codexResetCredits: string;
|
||||
codexResetCreditsTitle: string;
|
||||
codexResetCreditRow: string;
|
||||
codexResetCreditRowSoon: string;
|
||||
codexResetCreditLabel: string;
|
||||
codexResetCreditTime: string;
|
||||
codexResetCreditsError: string;
|
||||
@@ -48,12 +52,14 @@ export interface QuotaClassMap {
|
||||
|
||||
export const QUOTA_CLASS_KEYS: readonly (keyof QuotaClassMap)[] = [
|
||||
'quotaRow',
|
||||
'quotaRowSoon',
|
||||
'quotaRowHeader',
|
||||
'quotaModel',
|
||||
'quotaMeta',
|
||||
'quotaPercent',
|
||||
'quotaReset',
|
||||
'quotaResetRelative',
|
||||
'quotaResetRelativeSoon',
|
||||
'quotaAmount',
|
||||
'quotaMessage',
|
||||
'codexPlan',
|
||||
@@ -65,6 +71,7 @@ export const QUOTA_CLASS_KEYS: readonly (keyof QuotaClassMap)[] = [
|
||||
'codexResetCredits',
|
||||
'codexResetCreditsTitle',
|
||||
'codexResetCreditRow',
|
||||
'codexResetCreditRowSoon',
|
||||
'codexResetCreditLabel',
|
||||
'codexResetCreditTime',
|
||||
'codexResetCreditsError',
|
||||
|
||||
@@ -1173,6 +1173,7 @@
|
||||
"windows_legend_upcoming": "upcoming",
|
||||
"windows_legend_elapsed": "elapsed",
|
||||
"windows_legend_reset_credit": "manual reset expiry",
|
||||
"soonest_row_hint": "Recovers first on this credential",
|
||||
"windows_reset_credit": "Manual reset",
|
||||
"windows_credit_granted": "Granted",
|
||||
"windows_credit_expires": "Expires",
|
||||
|
||||
@@ -1160,6 +1160,7 @@
|
||||
"windows_legend_upcoming": "предстоящее",
|
||||
"windows_legend_elapsed": "прошедшее",
|
||||
"windows_legend_reset_credit": "срок ручного сброса",
|
||||
"soonest_row_hint": "Восстановится первым у этих учётных данных",
|
||||
"windows_reset_credit": "Ручной сброс",
|
||||
"windows_credit_granted": "Предоставлен",
|
||||
"windows_credit_expires": "Истекает",
|
||||
|
||||
@@ -1173,6 +1173,7 @@
|
||||
"windows_legend_upcoming": "即将开始",
|
||||
"windows_legend_elapsed": "已结束",
|
||||
"windows_legend_reset_credit": "主动重置过期",
|
||||
"soonest_row_hint": "该凭证中最先恢复",
|
||||
"windows_reset_credit": "主动重置",
|
||||
"windows_credit_granted": "获得时间",
|
||||
"windows_credit_expires": "过期时间",
|
||||
|
||||
@@ -1199,6 +1199,7 @@
|
||||
"windows_legend_upcoming": "即將開始",
|
||||
"windows_legend_elapsed": "已結束",
|
||||
"windows_legend_reset_credit": "主動重設過期",
|
||||
"soonest_row_hint": "該憑證中最先恢復",
|
||||
"windows_reset_credit": "主動重設",
|
||||
"windows_credit_granted": "取得時間",
|
||||
"windows_credit_expires": "過期時間",
|
||||
|
||||
@@ -74,6 +74,46 @@ describe('CodexQuotaBody', () => {
|
||||
expect(markup).toMatch(/11 days/);
|
||||
});
|
||||
|
||||
test('highlights the credit when it expires before every window resets', () => {
|
||||
const creditFirst: CodexQuotaState = {
|
||||
...quota,
|
||||
windows: [{ ...quota.windows[0], resetAtMs: now + 5 * DAY_MS }],
|
||||
rateLimitResetCredits: [
|
||||
{
|
||||
id: 'credit-1',
|
||||
status: 'available',
|
||||
grantedAt: new Date(now - DAY_MS).toISOString(),
|
||||
expiresAt: new Date(now + 2 * HOUR_MS).toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(CodexQuotaBody, { quota: creditFirst, classes })
|
||||
);
|
||||
|
||||
expect(markup).toContain('codexResetCreditRowSoon');
|
||||
expect(markup).not.toContain('quotaRowSoon');
|
||||
});
|
||||
|
||||
test('highlights the window when it resets before any credit expires', () => {
|
||||
const markup = renderToStaticMarkup(createElement(CodexQuotaBody, { quota, classes }));
|
||||
|
||||
expect(markup).toContain('quotaRowSoon');
|
||||
expect(markup).not.toContain('codexResetCreditRowSoon');
|
||||
});
|
||||
|
||||
test('highlights nothing once every instant is in the past', () => {
|
||||
const stale: CodexQuotaState = {
|
||||
...quota,
|
||||
windows: [{ ...quota.windows[0], resetAtMs: now - HOUR_MS }],
|
||||
rateLimitResetCredits: [],
|
||||
rateLimitResetCreditsAvailableCount: null,
|
||||
};
|
||||
const markup = renderToStaticMarkup(createElement(CodexQuotaBody, { quota: stale, classes }));
|
||||
|
||||
expect(markup).not.toContain('Soon');
|
||||
});
|
||||
|
||||
test('keeps the baked label alone when the store entry predates resetAtMs', () => {
|
||||
const stale: CodexQuotaState = {
|
||||
...quota,
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Which row on a quota card recovers first, per provider.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
XAI_WEEKLY_ROW_ID,
|
||||
collectQuotaRowInstants,
|
||||
nextRecoveryMs,
|
||||
pickSoonestRowId,
|
||||
resetCreditRowId,
|
||||
} from '@/features/quota/resetSchedule';
|
||||
import { DAY_MS, HOUR_MS } from '@/utils/time/durations';
|
||||
|
||||
const NOW = new Date(2026, 7, 2, 12).getTime();
|
||||
const iso = (ms: number) => new Date(ms).toISOString();
|
||||
|
||||
const claudeQuota = {
|
||||
status: 'success',
|
||||
windows: [
|
||||
{ id: 'five_hour', resetAtMs: NOW + 3 * HOUR_MS },
|
||||
{ id: 'seven_day', resetAtMs: NOW + 4 * DAY_MS },
|
||||
],
|
||||
};
|
||||
|
||||
const codexQuota = {
|
||||
status: 'success',
|
||||
windows: [
|
||||
{ id: 'primary', resetAtMs: NOW + 3 * HOUR_MS },
|
||||
{ id: 'secondary', resetAtMs: NOW + 6 * DAY_MS },
|
||||
],
|
||||
rateLimitResetCredits: [
|
||||
{ id: 'credit-a', status: 'available', expiresAt: iso(NOW + 11 * DAY_MS) },
|
||||
{ id: 'credit-b', status: 'available', expiresAt: iso(NOW + 2 * DAY_MS) },
|
||||
],
|
||||
};
|
||||
|
||||
describe('collectQuotaRowInstants', () => {
|
||||
test('collects every Claude window', () => {
|
||||
expect(collectQuotaRowInstants('claude', claudeQuota)).toEqual([
|
||||
{ rowId: 'five_hour', atMs: NOW + 3 * HOUR_MS, kind: 'window' },
|
||||
{ rowId: 'seven_day', atMs: NOW + 4 * DAY_MS, kind: 'window' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('collects Codex windows and available reset credits together', () => {
|
||||
const instants = collectQuotaRowInstants('codex', codexQuota);
|
||||
expect(instants).toHaveLength(4);
|
||||
expect(instants.filter((i) => i.kind === 'credit').map((i) => i.rowId)).toEqual([
|
||||
'credit-a',
|
||||
'credit-b',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ignores reset credits that are not available', () => {
|
||||
const consumed = {
|
||||
...codexQuota,
|
||||
rateLimitResetCredits: [
|
||||
{ id: 'used', status: 'consumed', expiresAt: iso(NOW + HOUR_MS) },
|
||||
{ id: 'live', status: 'available', expiresAt: iso(NOW + 2 * HOUR_MS) },
|
||||
],
|
||||
};
|
||||
expect(
|
||||
collectQuotaRowInstants('codex', consumed)
|
||||
.filter((i) => i.kind === 'credit')
|
||||
.map((i) => i.rowId)
|
||||
).toEqual(['live']);
|
||||
});
|
||||
|
||||
test('collects the xAI weekly window', () => {
|
||||
const quota = { status: 'success', billing: { periodType: 'weekly', resetAtMs: NOW + DAY_MS } };
|
||||
expect(collectQuotaRowInstants('xai', quota)).toEqual([
|
||||
{ rowId: XAI_WEEKLY_ROW_ID, atMs: NOW + DAY_MS, kind: 'window' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('ignores an xAI monthly summary — a billing cycle is not capacity returning', () => {
|
||||
const quota = {
|
||||
status: 'success',
|
||||
billing: { periodType: 'monthly', resetAtMs: NOW + DAY_MS },
|
||||
};
|
||||
expect(collectQuotaRowInstants('xai', quota)).toEqual([]);
|
||||
});
|
||||
|
||||
test('flattens Antigravity buckets across groups', () => {
|
||||
const quota = {
|
||||
status: 'success',
|
||||
groups: [
|
||||
{ id: 'g1', buckets: [{ id: 'b1', resetAtMs: NOW + HOUR_MS }] },
|
||||
{ id: 'g2', buckets: [{ id: 'b2', resetAtMs: NOW + 2 * HOUR_MS }] },
|
||||
],
|
||||
};
|
||||
expect(collectQuotaRowInstants('antigravity', quota).map((i) => i.rowId)).toEqual(['b1', 'b2']);
|
||||
});
|
||||
|
||||
test('collects Kimi rows', () => {
|
||||
const quota = { status: 'success', rows: [{ id: 'r1', resetAtMs: NOW + HOUR_MS }] };
|
||||
expect(collectQuotaRowInstants('kimi', quota).map((i) => i.rowId)).toEqual(['r1']);
|
||||
});
|
||||
|
||||
test('returns nothing unless the credential loaded successfully', () => {
|
||||
for (const status of ['idle', 'loading', 'error']) {
|
||||
expect(collectQuotaRowInstants('claude', { ...claudeQuota, status })).toEqual([]);
|
||||
}
|
||||
expect(collectQuotaRowInstants('claude', undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
test('drops rows with no usable instant rather than emitting NaN', () => {
|
||||
const quota = {
|
||||
status: 'success',
|
||||
windows: [
|
||||
{ id: 'ok', resetAtMs: NOW + HOUR_MS },
|
||||
{ id: 'missing' },
|
||||
{ id: 'null', resetAtMs: null },
|
||||
{ id: 'nan', resetAtMs: Number.NaN },
|
||||
],
|
||||
};
|
||||
expect(collectQuotaRowInstants('claude', quota).map((i) => i.rowId)).toEqual(['ok']);
|
||||
});
|
||||
|
||||
test('drops a reset credit whose expiry will not parse', () => {
|
||||
const quota = {
|
||||
status: 'success',
|
||||
windows: [],
|
||||
rateLimitResetCredits: [{ id: 'bad', status: 'available', expiresAt: 'not a date' }],
|
||||
};
|
||||
expect(collectQuotaRowInstants('codex', quota)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickSoonestRowId', () => {
|
||||
test('picks the nearest upcoming instant', () => {
|
||||
expect(pickSoonestRowId(collectQuotaRowInstants('claude', claudeQuota), NOW)).toBe('five_hour');
|
||||
});
|
||||
|
||||
test('a credit expiring before every window wins the emphasis', () => {
|
||||
const quota = {
|
||||
...codexQuota,
|
||||
windows: [{ id: 'primary', resetAtMs: NOW + 5 * DAY_MS }],
|
||||
};
|
||||
expect(pickSoonestRowId(collectQuotaRowInstants('codex', quota), NOW)).toBe('credit-b');
|
||||
});
|
||||
|
||||
test('a window resetting before every credit wins the emphasis', () => {
|
||||
expect(pickSoonestRowId(collectQuotaRowInstants('codex', codexQuota), NOW)).toBe('primary');
|
||||
});
|
||||
|
||||
test('skips instants that have already passed', () => {
|
||||
const instants = [
|
||||
{ rowId: 'past', atMs: NOW - HOUR_MS, kind: 'window' as const },
|
||||
{ rowId: 'exactly-now', atMs: NOW, kind: 'window' as const },
|
||||
{ rowId: 'future', atMs: NOW + HOUR_MS, kind: 'window' as const },
|
||||
];
|
||||
expect(pickSoonestRowId(instants, NOW)).toBe('future');
|
||||
});
|
||||
|
||||
test('returns null when nothing is pending', () => {
|
||||
expect(pickSoonestRowId([], NOW)).toBeNull();
|
||||
expect(pickSoonestRowId([{ rowId: 'past', atMs: NOW - 1, kind: 'window' }], NOW)).toBeNull();
|
||||
});
|
||||
|
||||
test('breaks ties deterministically on row id', () => {
|
||||
const a = [
|
||||
{ rowId: 'b', atMs: NOW + HOUR_MS, kind: 'window' as const },
|
||||
{ rowId: 'a', atMs: NOW + HOUR_MS, kind: 'window' as const },
|
||||
];
|
||||
expect(pickSoonestRowId(a, NOW)).toBe('a');
|
||||
expect(pickSoonestRowId([...a].reverse(), NOW)).toBe('a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetCreditRowId', () => {
|
||||
test('prefers the credit id', () => {
|
||||
expect(resetCreditRowId({ id: 'credit-a', expiresAt: 'x' }, 3)).toBe('credit-a');
|
||||
});
|
||||
|
||||
test('falls back to expiry and index when the payload carries no id', () => {
|
||||
// Must stay byte-identical to CodexQuotaBody's React key.
|
||||
expect(resetCreditRowId({ id: '', expiresAt: '2026-08-13T00:00:00Z' }, 2)).toBe(
|
||||
'2026-08-13T00:00:00Z-2'
|
||||
);
|
||||
expect(resetCreditRowId({ expiresAt: '2026-08-13T00:00:00Z' }, 0)).toBe(
|
||||
'2026-08-13T00:00:00Z-0'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextRecoveryMs', () => {
|
||||
test('returns the soonest upcoming instant across windows and credits', () => {
|
||||
expect(nextRecoveryMs('codex', codexQuota, NOW)).toBe(NOW + 3 * HOUR_MS);
|
||||
});
|
||||
|
||||
test('ignores instants already in the past', () => {
|
||||
const quota = {
|
||||
status: 'success',
|
||||
windows: [
|
||||
{ id: 'stale', resetAtMs: NOW - DAY_MS },
|
||||
{ id: 'live', resetAtMs: NOW + DAY_MS },
|
||||
],
|
||||
};
|
||||
expect(nextRecoveryMs('claude', quota, NOW)).toBe(NOW + DAY_MS);
|
||||
});
|
||||
|
||||
test('is null for an unloaded credential, so sorting can sink it', () => {
|
||||
expect(nextRecoveryMs('claude', undefined, NOW)).toBeNull();
|
||||
expect(nextRecoveryMs('claude', { status: 'idle' }, NOW)).toBeNull();
|
||||
expect(nextRecoveryMs('claude', { status: 'error' }, NOW)).toBeNull();
|
||||
});
|
||||
|
||||
test('is null when every known instant has passed', () => {
|
||||
const quota = { status: 'success', windows: [{ id: 'stale', resetAtMs: NOW - 1 }] };
|
||||
expect(nextRecoveryMs('claude', quota, NOW)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user