From 1532bdb05622748c14a3ec04d3699dbc1bf5be08 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Mon, 5 Oct 2026 00:31:39 +0800 Subject: [PATCH] feat(quota): display all Codex reset credit expiry times - List each expiry chronologically instead of showing only the earliest - Update auth file panels, quota cards, and reset confirmation dialogs - Preserve separate entries for credits with identical expiry times - Add translations and regression coverage --- src/components/AuthFileQuotaPanel.tsx | 5 +++-- src/components/ResetCreditExpiries.css | 13 +++++++++++++ src/components/ResetCreditExpiries.tsx | 16 ++++++++++++++++ src/components/useQuotaReset.ts | 7 +++++-- src/i18n/ja.ts | 2 ++ src/i18n/locales/en.ts | 2 ++ src/i18n/locales/zh-CN.ts | 2 ++ src/pages/QuotaPage.tsx | 3 ++- src/services/quotaService.ts | 17 +++++++++++++---- tests/authFileQuotaPanel.test.tsx | 18 ++++++++++++++++++ tests/fixtures/quota-layout.tsx | 2 +- tests/quotaService.test.ts | 5 +++-- 12 files changed, 80 insertions(+), 12 deletions(-) create mode 100644 src/components/ResetCreditExpiries.css create mode 100644 src/components/ResetCreditExpiries.tsx diff --git a/src/components/AuthFileQuotaPanel.tsx b/src/components/AuthFileQuotaPanel.tsx index 832c197..d058bc4 100644 --- a/src/components/AuthFileQuotaPanel.tsx +++ b/src/components/AuthFileQuotaPanel.tsx @@ -1,3 +1,4 @@ +import { ResetCreditExpiries } from './ResetCreditExpiries'; import { LoaderCircle, RefreshCw } from 'lucide-react'; import { useI18n } from '../i18n'; import { MessageNotice } from '../appNotice'; @@ -30,7 +31,6 @@ export function AuthFileQuotaPanel({ quota, file, disabled, onRefresh, onReset, const denseMetadata = dense ? [ quota.resetCreditsApplicable !== undefined ? t('quota.resetApplicable', { count: quota.resetCreditsApplicable }) : '', quota.subscriptionActiveUntil ? t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) }) : '', - quota.resetCreditsEarliestExpiry ? t('authFiles.quota.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) }) : '', accountCreditsLabel ? t('quota.creditBalance') + ': ' + accountCreditsLabel : '', ].filter(Boolean).join(' · ') : ''; const renderRow = (row: QuotaState['rows'][number], index: number) => { @@ -83,8 +83,9 @@ export function AuthFileQuotaPanel({ quota, file, disabled, onRefresh, onReset, {quota.resetCreditsApplicable !== undefined ? {t('quota.resetApplicable', { count: quota.resetCreditsApplicable })} : null} {quota.subscriptionActiveUntil ? {t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })} : null} {accountCreditsLabel ? {t('quota.creditBalance')}: {accountCreditsLabel} : null} - {quota.resetCreditsEarliestExpiry ? {t('authFiles.quota.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) })} : null} + : null} + : null} diff --git a/src/components/ResetCreditExpiries.css b/src/components/ResetCreditExpiries.css new file mode 100644 index 0000000..a57e8f2 --- /dev/null +++ b/src/components/ResetCreditExpiries.css @@ -0,0 +1,13 @@ +.reset-credit-expiries { + width: 100%; + min-width: 0; + display: grid; + gap: 4px; + color: var(--text-secondary); + font-size: var(--font-size-meta); +} +.reset-credit-expiries > strong { font-size: inherit; font-weight: 500; } +.reset-credit-expiries ol { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; } +.reset-credit-expiries li { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 2px 8px; } +.reset-credit-expiries li span { overflow-wrap: anywhere; } +.credential-quota-dense .reset-credit-expiries { font-size: 10px; line-height: 1.4; } diff --git a/src/components/ResetCreditExpiries.tsx b/src/components/ResetCreditExpiries.tsx new file mode 100644 index 0000000..69e0de0 --- /dev/null +++ b/src/components/ResetCreditExpiries.tsx @@ -0,0 +1,16 @@ +import { useI18n } from '../i18n'; +import { formatQuotaTimestamp, quotaResetExpiries, type QuotaState } from '../services/quotaService'; +import './ResetCreditExpiries.css'; + +export function ResetCreditExpiries({ quota }: { quota: QuotaState }) { + const { locale, t } = useI18n(); + const expiries = quotaResetExpiries(quota); + if (!expiries.length) return null; + return
+ {t('quota.resetCreditExpiries')} +
    {expiries.map((expiry, index) =>
  1. + {t('quota.resetCreditExpiry', { index: index + 1 })} + {formatQuotaTimestamp(expiry, locale)} +
  2. )}
+
; +} diff --git a/src/components/useQuotaReset.ts b/src/components/useQuotaReset.ts index b14f222..126aebf 100644 --- a/src/components/useQuotaReset.ts +++ b/src/components/useQuotaReset.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { useI18n } from '../i18n'; import { hasPendingClaudeReset, resetQuotaWithConfirmation } from '../services/quotaActions'; -import { fileName, formatQuotaTimestamp, providerForFile, type AuthFile, type QuotaState } from '../services/quotaService'; +import { fileName, formatQuotaTimestamp, quotaResetExpiries, providerForFile, type AuthFile, type QuotaState } from '../services/quotaService'; import type { ConfirmationOptions } from './ConfirmationDialog'; export function useQuotaReset( @@ -18,7 +18,10 @@ export function useQuotaReset( confirmText: t(hasPendingClaudeReset(file) ? 'quota.claude.retry' : 'quota.confirm.button'), details: [ { label: t('quota.resetCredits'), value: String(quota.resetCredits ?? '—') }, - { label: t('quota.earliestExpiry'), value: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) }, + ...quotaResetExpiries(quota).map((expiry, index) => ({ + label: t('quota.resetCreditExpiry', { index: index + 1 }), + value: formatQuotaTimestamp(expiry, locale), + })), ], warning: t(providerForFile(file) === 'claude' ? hasPendingClaudeReset(file) ? 'quota.claude.unknown' : 'quota.claude.warning' : 'quota.confirm.warning'), })); diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 3fdad40..f67aa38 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -487,6 +487,8 @@ export const jaOverrides = { 'quota.resetCredits': '手動リセット回数', 'quota.creditBalance': 'クレジット残高', 'quota.creditUnlimited': '無制限', + 'quota.resetCreditExpiries': 'リセット権利の有効期限', + 'quota.resetCreditExpiry': '{index} 回目の有効期限', 'quota.earliestExpiry': '最も早い有効期限', 'quota.remaining': '残り {percent}%', 'quota.resetPassed': 'リセット時刻到達。更新して確認してください', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index fa7cc1a..de1dc1e 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -474,6 +474,8 @@ export const en: Record = { 'quota.resetCredits': 'Manual resets', 'quota.creditBalance': 'Credit balance', 'quota.creditUnlimited': 'Unlimited', + 'quota.resetCreditExpiries': 'Reset credit expiry times', + 'quota.resetCreditExpiry': 'Reset {index} expires', 'quota.earliestExpiry': 'Earliest expiry', 'quota.remaining': '{percent}% remaining', 'quota.resetPassed': 'Reset time reached; refresh to verify', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 240a36a..c25cb9e 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -473,6 +473,8 @@ export const zhCN = { 'quota.resetCredits': '主动重置次数', 'quota.creditBalance': 'Credit 余额', 'quota.creditUnlimited': '无限额', + 'quota.resetCreditExpiries': '重置次数过期时间', + 'quota.resetCreditExpiry': '第 {index} 次过期时间', 'quota.earliestExpiry': '最早过期', 'quota.remaining': '剩余 {percent}%', 'quota.resetPassed': '重置时间已到,请刷新确认', diff --git a/src/pages/QuotaPage.tsx b/src/pages/QuotaPage.tsx index 60fc9ec..1d7e881 100644 --- a/src/pages/QuotaPage.tsx +++ b/src/pages/QuotaPage.tsx @@ -1,3 +1,4 @@ +import { ResetCreditExpiries } from '../components/ResetCreditExpiries'; import { MessageNotice } from '../appNotice'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { AlertCircle, Gauge, ListRestart, LoaderCircle, RefreshCw } from 'lucide-react'; @@ -210,7 +211,7 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile; {(quota.creditsUnlimited || quota.creditBalance !== undefined) ? {t('quota.creditBalance')} {quota.creditsUnlimited ? t('quota.creditUnlimited') : quota.creditBalance} : null} {t('quota.resetCredits')} {quota.resetCredits ?? '—'} {quota.resetCreditsApplicable !== undefined ? {t('quota.resetApplicable', { count: quota.resetCreditsApplicable })} : null} - {t('quota.earliestExpiry')} {formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale)} + : null} {quota.status === 'success' ?
{quota.rows.map((row, index) => { diff --git a/src/services/quotaService.ts b/src/services/quotaService.ts index 298efff..4bbbdb6 100644 --- a/src/services/quotaService.ts +++ b/src/services/quotaService.ts @@ -43,6 +43,7 @@ export type QuotaState = { resetCreditsApplicable?: number; resetCreditsError?: string; resetCreditsEarliestExpiry?: string; + resetCreditExpiries?: string[]; subscriptionActiveUntil?: string; serverTimeOffsetMs?: number; fetchedAt?: number; @@ -332,7 +333,7 @@ export const codexAccountCreditsFor = ( export const codexResetCreditDetailsFor = ( payload: unknown, nowMs = Date.now(), -): { availableCount?: number; applicableAvailableCount?: number; earliestExpiry?: string } => { +): { availableCount?: number; applicableAvailableCount?: number; earliestExpiry?: string; expiries?: string[] } => { const value = parseBody(payload); if (!isRecord(value)) return {}; const credits = Array.isArray(value.credits) @@ -349,7 +350,7 @@ export const codexResetCreditDetailsFor = ( const expiry = quotaResetInstant(credit.expires_at ?? credit.expiresAt); return expiry !== undefined && expiry > nowMs; }); - const earliestExpiry = validCredits + const expiries = validCredits .map((credit) => readString(credit, 'expires_at', 'expiresAt')) .map((expiresAt) => ({ expiresAt, expiresAtMs: quotaResetInstant(expiresAt) ?? NaN })) .filter((credit) => @@ -357,14 +358,16 @@ export const codexResetCreditDetailsFor = ( && Number.isFinite(credit.expiresAtMs) && credit.expiresAtMs > nowMs, ) - .sort((left, right) => left.expiresAtMs - right.expiresAtMs)[0]?.expiresAt; + .sort((left, right) => left.expiresAtMs - right.expiresAtMs) + .map(credit => credit.expiresAt); return { availableCount: availableCount === null ? validCredits.length || undefined : Math.max(0, Math.floor(availableCount)), ...(applicableCount === null ? {} : { applicableAvailableCount: Math.max(0, Math.floor(applicableCount)) }), - earliestExpiry, + earliestExpiry: expiries[0], + expiries, }; }; @@ -930,6 +933,7 @@ async function loadQuotaSnapshot(file: AuthFile): Promise { resetCreditsApplicable: provider === 'claude' ? undefined : usageCreditDetails.applicableAvailableCount ?? resetCreditDetails?.applicableAvailableCount ?? resetCredits, resetCredits, + resetCreditExpiries: resetCreditDetails?.expiries ?? usageCreditDetails.expiries, resetCreditsEarliestExpiry: resetCreditDetails?.earliestExpiry ?? claudeResetGrants?.grants.filter(grant => grant.resetsLeft > 0 && grant.endsAt).map(grant => grant.endsAt!).sort((a, b) => Date.parse(a) - Date.parse(b))[0], serverTimeOffsetMs: responseClock.serverTimeOffsetMs, fetchedAt: Date.now(), @@ -1036,3 +1040,8 @@ export function consumeClaudeResetCredit(file: AuthFile): Promise { return loadQuotaSnapshot(file); }); } + +export function quotaResetExpiries(quota: QuotaState): string[] { + return (quota.resetCreditExpiries ?? (quota.resetCreditsEarliestExpiry ? [quota.resetCreditsEarliestExpiry] : [])) + .slice().sort((left, right) => (quotaResetInstant(left) ?? Infinity) - (quotaResetInstant(right) ?? Infinity)); +} diff --git a/tests/authFileQuotaPanel.test.tsx b/tests/authFileQuotaPanel.test.tsx index 4994084..0eb7a33 100644 --- a/tests/authFileQuotaPanel.test.tsx +++ b/tests/authFileQuotaPanel.test.tsx @@ -7,6 +7,24 @@ import { QuotaCard } from '../src/pages/QuotaPage'; const render = (quota: QuotaState) => renderToStaticMarkup( {}} />); +it('shows every reset expiry in chronological order, including duplicates, on both surfaces', () => { + const quota: QuotaState = { + status: 'success', rows: [], resetCredits: 3, + resetCreditExpiries: ['2030-12-20T00:00:00Z', '2030-10-10T00:00:00Z', '2030-10-10T00:00:00Z'], + }; + const file = { name: 'codex.json', provider: 'codex' }; + for (const content of [ + {}} />, + {}} />, + ]) { + const html = renderToStaticMarkup({content}); + expect(html.match(/
  • /g)).toHaveLength(3); + expect(html).toContain('Reset 3 expires'); + expect(html).not.toContain('Earliest expiry'); + expect(html.indexOf('10/10/2030')).toBeLessThan(html.indexOf('12/20/2030')); + } +}); + it('renders real quota values with bounded bars and keeps unknown distinct from zero', () => { const html = render({ status: 'success', rows: [ { label: 'empty', remainingPercent: 0 }, { label: 'full', remainingPercent: 100 }, diff --git a/tests/fixtures/quota-layout.tsx b/tests/fixtures/quota-layout.tsx index 40886c9..4259754 100644 --- a/tests/fixtures/quota-layout.tsx +++ b/tests/fixtures/quota-layout.tsx @@ -24,7 +24,7 @@ createRoot(document.getElementById('root')!).render( {[1, 2, 3, 4, 5, 6].map((id) => {}} />)}
  • - {}} onReset={() => {}} /> + {}} onReset={() => {}} /> {}} onReset={() => {}} /> {}} /> {}} /> diff --git a/tests/quotaService.test.ts b/tests/quotaService.test.ts index 604b4c0..70cf398 100644 --- a/tests/quotaService.test.ts +++ b/tests/quotaService.test.ts @@ -136,11 +136,12 @@ describe('quotaRowsFor', () => { expect(result).toEqual({ availableCount: 2, earliestExpiry: '2026-08-12T18:06:25Z', + expiries: ['2026-08-12T18:06:25Z', '2026-08-20T00:00:00Z'], }); }); it('重置次数支持 applicable 字段、空列表,不把过期积分算入推断次数', () => { - expect(codexResetCreditDetailsFor({ credits: [] })).toEqual({ availableCount: undefined, earliestExpiry: undefined }); + expect(codexResetCreditDetailsFor({ credits: [] })).toEqual({ availableCount: undefined, earliestExpiry: undefined, expiries: [] }); expect(codexResetCreditDetailsFor({ applicableAvailableCount: '0', credits: [ @@ -150,7 +151,7 @@ describe('quotaRowsFor', () => { { resetType: 'other', status: 'available', expiresAt: '2030-01-01T00:00:00Z' }, ], }, Date.parse('2026-01-01T00:00:00Z'))).toEqual({ - availableCount: 1, applicableAvailableCount: 0, earliestExpiry: '2030-01-01T00:00:00Z', + availableCount: 1, applicableAvailableCount: 0, earliestExpiry: '2030-01-01T00:00:00Z', expiries: ['2030-01-01T00:00:00Z'], }); });