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) => -
+ {t('quota.resetCreditExpiry', { index: index + 1 })}
+ {formatQuotaTimestamp(expiry, locale)}
+
)}
+
;
+}
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'],
});
});