feat(quota): replace soonest recovery logic with urgent recovery emphasis

This commit is contained in:
Supra4E8C
2026-08-03 22:39:06 +08:00
parent f50f9b0f64
commit afee40ec23
11 changed files with 76 additions and 50 deletions
@@ -72,15 +72,6 @@ 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;
@@ -20,17 +20,6 @@
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;
@@ -7,7 +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 { collectQuotaRowInstants, pickUrgentRowId } from '../../resetSchedule';
import type { QuotaBodyProps } from '../../types';
import { getNextAntigravityCountdownUpdateDelay } from './countdown';
@@ -146,9 +146,9 @@ 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.
// shared one, so the final-hour warning and the countdown always agree.
const soonestRowId = useMemo(
() => pickSoonestRowId(collectQuotaRowInstants('antigravity', quota), nowMs),
() => pickUrgentRowId(collectQuotaRowInstants('antigravity', quota), nowMs),
[quota, nowMs]
);
@@ -208,12 +208,7 @@ export function AntigravityQuotaBody({ quota, classes }: QuotaBodyProps<Antigrav
const soon = bucket.id === soonestRowId;
return (
<div
key={bucket.id}
className={
soon ? `${classes.quotaRow} ${classes.quotaRowSoon}` : classes.quotaRow
}
>
<div key={bucket.id} className={classes.quotaRow}>
<div className={classes.quotaRowHeader}>
<span className={classes.quotaModel} title={bucketDescription}>
{bucketLabel}
@@ -9,14 +9,14 @@ 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 { collectQuotaRowInstants, pickUrgentRowId } 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),
() => pickUrgentRowId(collectQuotaRowInstants('claude', quota), now),
[quota, now]
);
const windows = quota.windows ?? [];
@@ -61,7 +61,7 @@ export function ClaudeQuotaBody({ quota, classes }: QuotaBodyProps<ClaudeQuotaSt
return (
<div
key={window.id}
className={soon ? `${classes.quotaRow} ${classes.quotaRowSoon}` : classes.quotaRow}
className={classes.quotaRow}
title={soon ? t('quota_management.soonest_row_hint') : undefined}
>
<div className={classes.quotaRowHeader}>
@@ -20,7 +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 { collectQuotaRowInstants, pickUrgentRowId, resetCreditRowId } from '../../resetSchedule';
import type { QuotaBodyProps, QuotaClassMap } from '../../types';
const getPlanValueClass = (planType: string | null, classes: QuotaClassMap): string => {
@@ -35,10 +35,10 @@ 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.
// Windows and reset credits compete for the same emphasis, but only during
// the final hour before the reset or expiry.
const soonestRowId = useMemo(
() => pickSoonestRowId(collectQuotaRowInstants('codex', quota), now),
() => pickUrgentRowId(collectQuotaRowInstants('codex', quota), now),
[quota, now]
);
const windows = quota.windows ?? [];
@@ -171,7 +171,7 @@ export function CodexQuotaBody({ quota, classes }: QuotaBodyProps<CodexQuotaStat
return (
<div
key={window.id}
className={soon ? `${classes.quotaRow} ${classes.quotaRowSoon}` : classes.quotaRow}
className={classes.quotaRow}
title={soon ? t('quota_management.soonest_row_hint') : undefined}
>
<div className={classes.quotaRowHeader}>
@@ -8,7 +8,7 @@ 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 { collectQuotaRowInstants, pickUrgentRowId } from '../../resetSchedule';
import type { QuotaBodyProps } from '../../types';
export function KimiQuotaBody({ quota, classes }: QuotaBodyProps<KimiQuotaState>) {
@@ -16,7 +16,7 @@ export function KimiQuotaBody({ quota, classes }: QuotaBodyProps<KimiQuotaState>
// Ahead of the early return below — hooks cannot be conditional.
const now = useNow();
const soonestRowId = useMemo(
() => pickSoonestRowId(collectQuotaRowInstants('kimi', quota), now),
() => pickUrgentRowId(collectQuotaRowInstants('kimi', quota), now),
[quota, now]
);
const rows = quota.rows ?? [];
@@ -46,7 +46,7 @@ export function KimiQuotaBody({ quota, classes }: QuotaBodyProps<KimiQuotaState>
return (
<div
key={row.id}
className={soon ? `${classes.quotaRow} ${classes.quotaRowSoon}` : classes.quotaRow}
className={classes.quotaRow}
title={soon ? t('quota_management.soonest_row_hint') : undefined}
>
<div className={classes.quotaRowHeader}>
@@ -10,7 +10,7 @@ import { buildResetDisplay, formatQuotaResetTime, parseIsoToMs } from '@/utils/q
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 { XAI_WEEKLY_ROW_ID, collectQuotaRowInstants, pickUrgentRowId } from '../../resetSchedule';
import type { QuotaBodyProps } from '../../types';
const formatUsdFromCents = (cents: number | null): string => {
@@ -71,7 +71,7 @@ export function XaiQuotaBody({ quota, classes }: QuotaBodyProps<XaiQuotaState>)
// 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,
() => pickUrgentRowId(collectQuotaRowInstants('xai', quota), now) === XAI_WEEKLY_ROW_ID,
[quota, now]
);
const billing = quota.billing;
@@ -149,7 +149,7 @@ export function XaiQuotaBody({ quota, classes }: QuotaBodyProps<XaiQuotaState>)
)}
{hasWeeklyData && (
<div
className={weeklySoon ? `${classes.quotaRow} ${classes.quotaRowSoon}` : classes.quotaRow}
className={classes.quotaRow}
title={weeklySoon ? t('quota_management.soonest_row_hint') : undefined}
>
<div className={classes.quotaRowHeader}>
+18
View File
@@ -19,6 +19,7 @@
*/
import { parseIsoToMs } from '@/utils/quota';
import { HOUR_MS } from '@/utils/time/durations';
import type { QuotaProviderType } from './providers/types';
export interface QuotaRowInstant {
@@ -156,6 +157,23 @@ export function pickSoonestRowId(
return best?.rowId ?? null;
}
/**
* The recovery row whose countdown should receive warning emphasis.
*
* A nearest reset that is still hours or days away is informational, not
* urgent. Only the final hour is highlighted, and exactly one hour remaining
* is deliberately excluded to match the "less than one hour" UI rule.
*/
export function pickUrgentRowId(
instants: readonly QuotaRowInstant[],
nowMs: number
): string | null {
return pickSoonestRowId(
instants.filter((instant) => instant.atMs - nowMs > 0 && instant.atMs - nowMs < HOUR_MS),
nowMs
);
}
/**
* Soonest upcoming recovery instant for a whole credential — the sort key for
* "soonest recovery first". Null when nothing is loaded or nothing is pending.
-3
View File
@@ -10,8 +10,6 @@
export interface QuotaClassMap {
// 额度行(五个提供商共用)
quotaRow: string;
/** Applied to the row that recovers first on this credential. */
quotaRowSoon: string;
quotaRowHeader: string;
quotaModel: string;
quotaMeta: string;
@@ -52,7 +50,6 @@ export interface QuotaClassMap {
export const QUOTA_CLASS_KEYS: readonly (keyof QuotaClassMap)[] = [
'quotaRow',
'quotaRowSoon',
'quotaRowHeader',
'quotaModel',
'quotaMeta',
+15 -4
View File
@@ -74,7 +74,7 @@ describe('CodexQuotaBody', () => {
expect(markup).toMatch(/11 days/);
});
test('highlights the credit when it expires before every window resets', () => {
test('highlights a credit expiring within the final hour', () => {
const creditFirst: CodexQuotaState = {
...quota,
windows: [{ ...quota.windows[0], resetAtMs: now + 5 * DAY_MS }],
@@ -83,7 +83,7 @@ describe('CodexQuotaBody', () => {
id: 'credit-1',
status: 'available',
grantedAt: new Date(now - DAY_MS).toISOString(),
expiresAt: new Date(now + 2 * HOUR_MS).toISOString(),
expiresAt: new Date(now + 30 * 60_000).toISOString(),
},
],
};
@@ -95,13 +95,24 @@ describe('CodexQuotaBody', () => {
expect(markup).not.toContain('quotaRowSoon');
});
test('highlights the window when it resets before any credit expires', () => {
test('does not emphasize a reset countdown more than one hour away', () => {
const markup = renderToStaticMarkup(createElement(CodexQuotaBody, { quota, classes }));
expect(markup).toContain('quotaRowSoon');
expect(markup).not.toContain('quotaRowSoon');
expect(markup).not.toContain('quotaResetRelativeSoon');
expect(markup).not.toContain('codexResetCreditRowSoon');
});
test('emphasizes a reset countdown within the final hour', () => {
const urgent: CodexQuotaState = {
...quota,
windows: [{ ...quota.windows[0], resetAtMs: now + 30 * 60_000 }],
};
const markup = renderToStaticMarkup(createElement(CodexQuotaBody, { quota: urgent, classes }));
expect(markup).toContain('quotaResetRelativeSoon');
});
test('highlights nothing once every instant is in the past', () => {
const stale: CodexQuotaState = {
...quota,
+25
View File
@@ -8,6 +8,7 @@ import {
collectQuotaRowInstants,
nextRecoveryMs,
pickSoonestRowId,
pickUrgentRowId,
resetCreditRowId,
} from '@/features/quota/resetSchedule';
import { DAY_MS, HOUR_MS } from '@/utils/time/durations';
@@ -169,6 +170,30 @@ describe('pickSoonestRowId', () => {
});
});
describe('pickUrgentRowId', () => {
test('highlights only the nearest reset strictly inside the final hour', () => {
const instants = [
{ rowId: 'later-urgent', atMs: NOW + 45 * 60_000, kind: 'window' as const },
{ rowId: 'nearest-urgent', atMs: NOW + 30 * 60_000, kind: 'window' as const },
{ rowId: 'past', atMs: NOW - 1, kind: 'window' as const },
];
expect(pickUrgentRowId(instants, NOW)).toBe('nearest-urgent');
});
test('does not highlight at exactly one hour or beyond', () => {
expect(
pickUrgentRowId(
[
{ rowId: 'exactly-one-hour', atMs: NOW + HOUR_MS, kind: 'window' },
{ rowId: 'later', atMs: NOW + 2 * HOUR_MS, kind: 'window' },
],
NOW
)
).toBeNull();
});
});
describe('resetCreditRowId', () => {
test('prefers the credit id', () => {
expect(resetCreditRowId({ id: 'credit-a', expiresAt: 'x' }, 3)).toBe('credit-a');