feat(claude): add ClaudeResetGrantDetails component and related tests; update styles and translations

This commit is contained in:
Supra4E8C
2026-10-07 23:49:59 +08:00
parent 715cab426f
commit 2ce627defa
10 changed files with 168 additions and 2 deletions
@@ -259,8 +259,9 @@
.codexResetCreditLabel {
color: var(--text-tertiary);
flex: 0 0 auto;
white-space: nowrap;
flex: 0 1 auto;
min-width: 0;
overflow-wrap: anywhere;
}
.codexResetCreditTime {
@@ -23,6 +23,7 @@ import { bindQuotaClasses } from '../types';
import { QUOTA_ADAPTERS, type QuotaCardState } from '../providers';
import { isQuotaRefreshDisabled, type QuotaFileEntry } from '../logic';
import { useClaudeResetGrants } from '../providers/claude/ClaudeResetGrants';
import { ClaudeResetGrantDetails } from '../providers/claude/ClaudeResetGrantDetails';
import bodyStyles from './QuotaBody.module.scss';
import styles from './QuotaCard.module.scss';
@@ -121,6 +122,7 @@ export function QuotaCard(props: QuotaCardProps) {
<span className={quotaClasses.codexPlanValue}>{claudeReset.count ?? '--'}</span>
</span>
</div>
<ClaudeResetGrantDetails grants={claudeReset.grants} classes={quotaClasses} />
{claudeReset.message && (
<div role="status" className={quotaClasses.codexResetCreditsError}>
{t(`claude_reset.${claudeReset.message}`)}
@@ -0,0 +1,55 @@
import { useTranslation } from 'react-i18next';
import { useNow } from '@/hooks/useNow';
import type { AnthropicResetGrant } from '@/services/api/claudeResetGrants';
import { buildResetDisplay, parseIsoToMs } from '@/utils/quota';
import { resolveTimeZoneLabel } from '@/utils/time/timezone';
import { QuotaResetLabel } from '../../components/QuotaResetLabel';
import type { QuotaClassMap } from '../../types';
interface ClaudeResetGrantDetailsProps {
grants: readonly AnthropicResetGrant[];
classes: QuotaClassMap;
}
/** Match Codex's reset-credit details without treating a multi-reset grant as one credit. */
export function ClaudeResetGrantDetails({ grants, classes }: ClaudeResetGrantDetailsProps) {
const { t, i18n } = useTranslation();
const now = useNow();
const remaining = grants
.filter((grant) => grant.resetsLeft > 0)
.sort((a, b) => (parseIsoToMs(a.endsAt) ?? Infinity) - (parseIsoToMs(b.endsAt) ?? Infinity));
if (remaining.length === 0) return null;
return (
<div className={classes.codexResetCredits}>
<div className={classes.codexResetCreditsTitle}>
{t('claude_reset.expiry_title', { timezone: resolveTimeZoneLabel() })}
</div>
{remaining.map((grant, index) => {
const display = buildResetDisplay(
null,
parseIsoToMs(grant.endsAt),
now,
i18n.resolvedLanguage
);
return (
<div key={grant.id} className={classes.codexResetCreditRow}>
<span className={classes.codexResetCreditLabel}>
{grant.label || t('claude_reset.grant_number', { index: index + 1 })}
{' · '}
{t('claude_reset.count', { left: grant.resetsLeft, total: grant.resetsTotal })}
</span>
<span className={classes.codexResetCreditTime}>
{display ? (
<QuotaResetLabel display={display} classes={classes} />
) : (
<span className={classes.quotaReset}>{t('claude_reset.expiry_unknown')}</span>
)}
</span>
</div>
);
})}
</div>
);
}
@@ -112,6 +112,7 @@ export function useClaudeResetGrants(
};
return {
count: status?.grants.reduce((sum, grant) => sum + grant.resetsLeft, 0) ?? null,
grants: status?.grants ?? [],
busy,
blocked,
confirm,
+3
View File
@@ -2205,6 +2205,9 @@
}
},
"claude_reset": {
"expiry_title": "Manual reset expiry ({{timezone}})",
"grant_number": "Grant {{index}}",
"expiry_unknown": "Expiry unknown",
"retry_confirm": "Retry the unknown reset for “{{name}}” with its original request ID? A reset may already have been spent. Retry within 10 minutes; do not reload or start another claim in another tab.",
"remaining": "Resets remaining",
"title": "Banked resets",
+3
View File
@@ -2199,6 +2199,9 @@
}
},
"claude_reset": {
"expiry_title": "Срок действия ручных сбросов ({{timezone}})",
"grant_number": "Пакет {{index}}",
"expiry_unknown": "Срок действия неизвестен",
"retry_confirm": "Повторить сброс «{{name}}» с неизвестным результатом и исходным ID запроса? Сброс мог быть потрачен. Повторите в течение 10 минут; не перезагружайте страницу и не создавайте запрос в другой вкладке.",
"remaining": "Осталось сбросов",
"title": "Запас сбросов",
+3
View File
@@ -2210,6 +2210,9 @@
"remaining": "Còn lại {{percent}}%"
},
"claude_reset": {
"expiry_title": "Hết hạn đặt lại thủ công ({{timezone}})",
"grant_number": "Gói {{index}}",
"expiry_unknown": "Chưa rõ thời gian hết hạn",
"retry_confirm": "Thử lại lượt đặt lại chưa rõ kết quả cho “{{name}}” với ID yêu cầu ban đầu? Một lượt đặt lại có thể đã được tiêu tốn. Hãy thử lại trong vòng 10 phút; không tải lại trang hoặc bắt đầu nhận ở tab khác.",
"remaining": "Lượt đặt lại còn lại",
"title": "Lượt đặt lại dự trữ",
+3
View File
@@ -2204,6 +2204,9 @@
}
},
"claude_reset": {
"expiry_title": "主动重置过期时间({{timezone}})",
"grant_number": "第 {{index}} 批",
"expiry_unknown": "到期时间未知",
"retry_confirm": "使用原请求 ID 重试“{{name}}”结果未知的重置?可能已消耗一次重置。请在 10 分钟内重试;请勿重新加载页面或在其他标签页发起新请求。",
"remaining": "剩余重置次数",
"title": "储备重置次数",
+3
View File
@@ -2230,6 +2230,9 @@
}
},
"claude_reset": {
"expiry_title": "主動重置過期時間({{timezone}})",
"grant_number": "第 {{index}} 批",
"expiry_unknown": "到期時間未知",
"retry_confirm": "使用原請求 ID 重試「{{name}}」結果未知的重置?可能已消耗一次重置。請在 10 分鐘內重試;請勿重新載入頁面或在其他分頁發起新請求。",
"remaining": "剩餘重置次數",
"title": "儲備重置次數",
+92
View File
@@ -0,0 +1,92 @@
import { beforeAll, describe, expect, test } from 'bun:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import i18n from '@/i18n';
import { ClaudeResetGrantDetails } from '@/features/quota/providers/claude/ClaudeResetGrantDetails';
import { bindQuotaClasses, QUOTA_CLASS_KEYS } from '@/features/quota/types';
import type { AnthropicResetGrant } from '@/services/api/claudeResetGrants';
import { formatInstantShort } from '@/utils/quota';
import { resolveTimeZoneLabel } from '@/utils/time/timezone';
const classes = bindQuotaClasses(
Object.fromEntries(QUOTA_CLASS_KEYS.map((key) => [key, key])),
'test-host'
);
const expiry = Date.now() + 11 * 86400_000;
const grant = (overrides: Partial<AnthropicResetGrant> = {}): AnthropicResetGrant => ({
id: 'launch',
label: 'Launch',
resetsTotal: 3,
resetsLeft: 2,
startsAt: null,
endsAt: new Date(expiry).toISOString(),
clears: ['five_hour'],
paused: false,
usableNow: true,
useRequiresLimit: true,
percentUsed: {},
...overrides,
});
const render = (grants: AnthropicResetGrant[]) =>
renderToStaticMarkup(createElement(ClaudeResetGrantDetails, { grants, classes }));
beforeAll(async () => {
await i18n.changeLanguage('en');
});
describe('Claude reset grant details', () => {
test('uses Codex detail classes, timezone, remaining count and live relative date', () => {
const markup = render([grant()]);
expect(markup).toContain('class="codexResetCredits"');
expect(markup).toContain('class="codexResetCreditRow"');
expect(markup).toContain(resolveTimeZoneLabel());
expect(markup).toContain('Launch · 2 / 3 resets remaining');
expect(markup).toContain(formatInstantShort(expiry));
expect(markup).toContain('class="quotaResetRelative"');
expect(markup).toContain('in 11 days');
});
test('hides empty and spent grants', () => {
expect(render([])).toBe('');
expect(render([grant({ resetsLeft: 0 })])).toBe('');
expect(render([grant(), grant({ id: 'spent', label: 'Spent', resetsLeft: 0 })])).not.toContain(
'Spent'
);
});
test('sorts by deadline without mutating input and keeps unknown dates last', () => {
const grants = [
grant({ id: 'unknown', label: 'Unknown', endsAt: null }),
grant({ id: 'later', label: 'Later', endsAt: new Date(expiry + 86400_000).toISOString() }),
grant({ id: 'first', label: 'First' }),
];
const markup = render(grants);
expect(markup.indexOf('First')).toBeLessThan(markup.indexOf('Later'));
expect(markup.indexOf('Later')).toBeLessThan(markup.indexOf('Unknown'));
expect(markup).toContain('Expiry unknown');
expect(grants.map((item) => item.id)).toEqual(['unknown', 'later', 'first']);
});
test('keeps paused and expired unspent grants visible without implying usability', () => {
const markup = render([
grant({ paused: true, usableNow: false, endsAt: '2020-01-01T00:00:00Z' }),
]);
expect(markup).toContain(formatInstantShort(Date.parse('2020-01-01T00:00:00Z')));
expect(markup).not.toContain('<button');
});
test.each(['en', 'zh-CN', 'zh-TW', 'ru', 'vi'])(
'translates fallback labels in %s',
async (lng) => {
await i18n.changeLanguage(lng);
try {
const markup = render([grant({ label: '', endsAt: null })]);
expect(markup).toContain(i18n.t('claude_reset.grant_number', { index: 1 }));
expect(markup).toContain(i18n.t('claude_reset.expiry_unknown'));
expect(markup).not.toContain('claude_reset.');
} finally {
await i18n.changeLanguage('en');
}
}
);
});