diff --git a/src/features/authFiles/components/AuthFileQuota.module.scss b/src/features/authFiles/components/AuthFileQuota.module.scss index 3afc5d39..7456b5a0 100644 --- a/src/features/authFiles/components/AuthFileQuota.module.scss +++ b/src/features/authFiles/components/AuthFileQuota.module.scss @@ -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; diff --git a/src/features/quota/components/QuotaBody.module.scss b/src/features/quota/components/QuotaBody.module.scss index c75677b4..ed481217 100644 --- a/src/features/quota/components/QuotaBody.module.scss +++ b/src/features/quota/components/QuotaBody.module.scss @@ -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; diff --git a/src/features/quota/providers/antigravity/AntigravityQuotaBody.tsx b/src/features/quota/providers/antigravity/AntigravityQuotaBody.tsx index 010efeb7..71dc5e5d 100644 --- a/src/features/quota/providers/antigravity/AntigravityQuotaBody.tsx +++ b/src/features/quota/providers/antigravity/AntigravityQuotaBody.tsx @@ -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 pickSoonestRowId(collectQuotaRowInstants('antigravity', quota), nowMs), + () => pickUrgentRowId(collectQuotaRowInstants('antigravity', quota), nowMs), [quota, nowMs] ); @@ -208,12 +208,7 @@ export function AntigravityQuotaBody({ quota, classes }: QuotaBodyProps +
{bucketLabel} diff --git a/src/features/quota/providers/claude/ClaudeQuotaBody.tsx b/src/features/quota/providers/claude/ClaudeQuotaBody.tsx index 014f9e62..af347dfc 100644 --- a/src/features/quota/providers/claude/ClaudeQuotaBody.tsx +++ b/src/features/quota/providers/claude/ClaudeQuotaBody.tsx @@ -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) { 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
diff --git a/src/features/quota/providers/codex/CodexQuotaBody.tsx b/src/features/quota/providers/codex/CodexQuotaBody.tsx index 21e201a2..8e8d44a8 100644 --- a/src/features/quota/providers/codex/CodexQuotaBody.tsx +++ b/src/features/quota/providers/codex/CodexQuotaBody.tsx @@ -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 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
diff --git a/src/features/quota/providers/kimi/KimiQuotaBody.tsx b/src/features/quota/providers/kimi/KimiQuotaBody.tsx index c78edd9c..0d176755 100644 --- a/src/features/quota/providers/kimi/KimiQuotaBody.tsx +++ b/src/features/quota/providers/kimi/KimiQuotaBody.tsx @@ -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) { @@ -16,7 +16,7 @@ export function KimiQuotaBody({ quota, classes }: QuotaBodyProps // 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 return (
diff --git a/src/features/quota/providers/xai/XaiQuotaBody.tsx b/src/features/quota/providers/xai/XaiQuotaBody.tsx index 63fb6bab..c8ca6159 100644 --- a/src/features/quota/providers/xai/XaiQuotaBody.tsx +++ b/src/features/quota/providers/xai/XaiQuotaBody.tsx @@ -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) // 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) )} {hasWeeklyData && (
diff --git a/src/features/quota/resetSchedule.ts b/src/features/quota/resetSchedule.ts index a403a167..abdb7e13 100644 --- a/src/features/quota/resetSchedule.ts +++ b/src/features/quota/resetSchedule.ts @@ -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. diff --git a/src/features/quota/types.ts b/src/features/quota/types.ts index 26bb5c9e..6458f563 100644 --- a/src/features/quota/types.ts +++ b/src/features/quota/types.ts @@ -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', diff --git a/tests/quotaBodyRendering.test.ts b/tests/quotaBodyRendering.test.ts index 988745a2..cad19c6f 100644 --- a/tests/quotaBodyRendering.test.ts +++ b/tests/quotaBodyRendering.test.ts @@ -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, diff --git a/tests/quotaResetSchedule.test.ts b/tests/quotaResetSchedule.test.ts index 0056dbc0..ba2cbca4 100644 --- a/tests/quotaResetSchedule.test.ts +++ b/tests/quotaResetSchedule.test.ts @@ -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');