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
This commit is contained in:
1 parent
20be08f388
commit
1532bdb056
12 files changed
+80
-12
No files matched your search
@@ -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 ? <small>{t('quota.resetApplicable', { count: quota.resetCreditsApplicable })}</small> : null}
|
||||
{quota.subscriptionActiveUntil ? <small>{t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })}</small> : null}
|
||||
{accountCreditsLabel ? <small>{t('quota.creditBalance')}: {accountCreditsLabel}</small> : null}
|
||||
{quota.resetCreditsEarliestExpiry ? <small>{t('authFiles.quota.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) })}</small> : null}
|
||||
|
||||
</div> : null}
|
||||
<ResetCreditExpiries quota={quota} />
|
||||
<MessageNotice message={quota.resetCreditsError ? name + ': ' + t('quota.resetCreditsWarning', { error: quota.resetCreditsError }) : null} />
|
||||
</> : null}
|
||||
</section>
|
||||
|
||||
@@ -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; }
|
||||
@@ -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 <div className="reset-credit-expiries">
|
||||
<strong>{t('quota.resetCreditExpiries')}</strong>
|
||||
<ol>{expiries.map((expiry, index) => <li key={`${expiry}-${index}`}>
|
||||
<span>{t('quota.resetCreditExpiry', { index: index + 1 })}</span>
|
||||
<span>{formatQuotaTimestamp(expiry, locale)}</span>
|
||||
</li>)}</ol>
|
||||
</div>;
|
||||
}
|
||||
@@ -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'),
|
||||
}));
|
||||
|
||||
@@ -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': 'リセット時刻到達。更新して確認してください',
|
||||
|
||||
@@ -474,6 +474,8 @@ export const en: Record<MessageKey, string> = {
|
||||
'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',
|
||||
|
||||
@@ -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': '重置时间已到,请刷新确认',
|
||||
|
||||
@@ -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) ? <span>{t('quota.creditBalance')} <strong>{quota.creditsUnlimited ? t('quota.creditUnlimited') : quota.creditBalance}</strong></span> : null}
|
||||
<span>{t('quota.resetCredits')} <strong>{quota.resetCredits ?? '—'}</strong></span>
|
||||
{quota.resetCreditsApplicable !== undefined ? <span>{t('quota.resetApplicable', { count: quota.resetCreditsApplicable })}</span> : null}
|
||||
<span>{t('quota.earliestExpiry')} <strong>{formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale)}</strong></span>
|
||||
<ResetCreditExpiries quota={quota} />
|
||||
<MessageNotice message={quota.resetCreditsError ? name + ': ' + t('quota.resetCreditsWarning', { error: quota.resetCreditsError }) : null} />
|
||||
</div> : null}
|
||||
{quota.status === 'success' ? <div className="quota-row-list">{quota.rows.map((row, index) => {
|
||||
|
||||
@@ -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<QuotaState> {
|
||||
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<QuotaState> {
|
||||
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));
|
||||
}
|
||||
Reference in new issue
Block a user