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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -13,6 +13,7 @@ export type QuotaType = {
|
||||
used: number;
|
||||
total: number;
|
||||
unit: string;
|
||||
resetAt?: number;
|
||||
};
|
||||
|
||||
export type QuotaFeature = 'storage' | 'translation' | 'tokens' | 'customization' | 'generic';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user