feat(account): show daily reset countdown under translation quota bar (#4082)

Adds a row beneath the translation characters bar on the user profile
page with "X% used" (start) and "Resets in H hr m min" (end). The
countdown points to the next UTC midnight, matching the server-side
daily-usage key in UsageStatsManager. Formatting goes through the dayjs
duration plugin and ticks every minute while the page is open.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-07 16:15:31 +08:00
committed by GitHub
parent 53936ad17c
commit 77a85cee09
5 changed files with 179 additions and 23 deletions
@@ -0,0 +1,111 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Quota from '@/components/Quota';
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (key: string, options?: Record<string, string | number>) => {
if (!options) return key;
return key.replace(/{{(\w+)}}/g, (_match, name) => String(options[name] ?? ''));
},
}));
afterEach(() => {
cleanup();
});
describe('Quota — reset indicator', () => {
beforeEach(() => {
vi.useFakeTimers();
// Pin "now" to a fixed UTC instant so reset countdown is deterministic.
vi.setSystemTime(new Date('2026-05-07T10:30:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('renders "x% used" and "Resets in h hr m min" when resetAt is set with showProgress', () => {
// Reset 13 hr 30 min from now (next UTC midnight).
const resetAt = new Date('2026-05-08T00:00:00Z').getTime();
render(
<Quota
showProgress
quotas={[
{
name: 'Translation Characters',
tooltip: '',
used: 25,
total: 100,
unit: 'K',
resetAt,
},
]}
/>,
);
expect(screen.getByText('25% used')).toBeTruthy();
expect(screen.getByText('Resets in 13 hr 30 min')).toBeTruthy();
});
it('does not render reset row when resetAt is missing', () => {
render(
<Quota
showProgress
quotas={[
{
name: 'Cloud Sync Storage',
tooltip: '',
used: 1,
total: 10,
unit: 'GB',
},
]}
/>,
);
expect(screen.queryByText(/Resets in/)).toBeNull();
expect(screen.queryByText(/% used/)).toBeNull();
});
it('does not render reset row when showProgress is false', () => {
const resetAt = new Date('2026-05-08T00:00:00Z').getTime();
render(
<Quota
quotas={[
{
name: 'Translation Characters',
tooltip: '',
used: 25,
total: 100,
unit: 'K',
resetAt,
},
]}
/>,
);
expect(screen.queryByText(/Resets in/)).toBeNull();
});
it('clamps the countdown to 0 hr 0 min when resetAt is in the past', () => {
const resetAt = new Date('2026-05-07T09:00:00Z').getTime(); // 1.5 hr ago
render(
<Quota
showProgress
quotas={[
{
name: 'Translation Characters',
tooltip: '',
used: 0,
total: 100,
unit: 'K',
resetAt,
},
]}
/>,
);
expect(screen.getByText('Resets in 0 hr 0 min')).toBeTruthy();
});
});
+57 -23
View File
@@ -1,5 +1,8 @@
import clsx from 'clsx';
import React from 'react';
import dayjs from 'dayjs';
import React, { useEffect, useState } from 'react';
import { useTranslation } from '@/hooks/useTranslation';
import '@/utils/time';
type QuotaProps = {
quotas: {
@@ -8,6 +11,7 @@ type QuotaProps = {
used: number;
total: number;
unit: string;
resetAt?: number;
}[];
className?: string;
labelClassName?: string;
@@ -15,10 +19,23 @@ type QuotaProps = {
};
const Quota: React.FC<QuotaProps> = ({ quotas, showProgress, className, labelClassName }) => {
const _ = useTranslation();
const [now, setNow] = useState(() => Date.now());
const hasResetIndicator = showProgress && quotas.some((q) => q.resetAt);
useEffect(() => {
if (!hasResetIndicator) return;
const interval = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(interval);
}, [hasResetIndicator]);
return (
<div className={clsx('text-base-content w-full rounded-md text-base sm:text-sm', className)}>
{quotas.map((quota) => {
const usagePercentage = (quota.used / quota.total) * 100;
const usageRatio = quota.total > 0 ? quota.used / quota.total : 0;
const usagePercentage = Math.min(100, usageRatio * 100);
const usagePercentageRounded = Math.round(usagePercentage);
let bgColor = 'bg-green-500';
if (usagePercentage > 80) {
bgColor = 'bg-red-500';
@@ -26,34 +43,51 @@ const Quota: React.FC<QuotaProps> = ({ quotas, showProgress, className, labelCla
bgColor = 'bg-yellow-500';
}
return (
<div
key={quota.name}
className={clsx(
'relative w-full overflow-hidden rounded-md',
showProgress && 'bg-base-300',
)}
>
{showProgress && (
<div
className={`absolute left-0 top-0 h-full ${bgColor}`}
style={{ width: `${usagePercentage}%` }}
></div>
)}
const showResetRow = showProgress && quota.resetAt;
const resetIn = showResetRow
? dayjs.duration(Math.max(0, quota.resetAt! - now)).format('H [hr] m [min]')
: '';
return (
<div key={quota.name} className='w-full'>
<div
className={clsx(
'relative flex items-center justify-between gap-4 p-2',
labelClassName,
'relative w-full overflow-hidden rounded-md',
showProgress && 'bg-base-300',
)}
>
<span className='truncate' title={quota.tooltip}>
{quota.name}
</span>
<div className='text-right text-sm'>
{quota.used} / {quota.total} {quota.unit}
{showProgress && (
<div
className={`absolute left-0 top-0 h-full ${bgColor}`}
style={{ width: `${usagePercentage}%` }}
></div>
)}
<div
className={clsx(
'relative flex items-center justify-between gap-4 p-2',
labelClassName,
)}
>
<span className='truncate' title={quota.tooltip}>
{quota.name}
</span>
<div className='text-right text-sm'>
{quota.used} / {quota.total} {quota.unit}
</div>
</div>
</div>
{showResetRow && (
<div
className={clsx(
'text-base-content/70 mt-1.5 flex items-center justify-between text-xs',
labelClassName,
)}
>
<span>{_('{{percentage}}% used', { percentage: usagePercentageRounded })}</span>
<span>{_('Resets in {{duration}}', { duration: resetIn })}</span>
</div>
)}
</div>
);
})}
@@ -25,6 +25,12 @@ export const useQuotaStats = (briefName = false) => {
unit: inGB ? 'GB' : 'MB',
};
const translationPlan = getTranslationPlanData(token);
const now = new Date();
const translationResetAt = Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate() + 1,
);
const translationQuota: QuotaType = {
name: briefName ? _('Translation') : _('Translation Characters'),
tooltip: _('{{percentage}}% of Daily Translation Characters Used.', {
@@ -33,6 +39,7 @@ export const useQuotaStats = (briefName = false) => {
used: Math.round(translationPlan.usage / 1024),
total: Math.round(translationPlan.quota / 1024),
unit: 'K',
resetAt: translationResetAt,
};
setUserProfilePlan(getUserProfilePlan(token));
setQuotas([storageQuota, translationQuota]);
+1
View File
@@ -13,6 +13,7 @@ export type QuotaType = {
used: number;
total: number;
unit: string;
resetAt?: number;
};
export type QuotaFeature = 'storage' | 'translation' | 'tokens' | 'customization' | 'generic';
+3
View File
@@ -1,6 +1,9 @@
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(duration);
import 'dayjs/locale/en';
import 'dayjs/locale/zh';
import 'dayjs/locale/de';