From 77a85cee09debd1a9c198af2bce7778e7f283e88 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 7 May 2026 16:15:31 +0800 Subject: [PATCH] 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) --- .../src/__tests__/components/Quota.test.tsx | 111 ++++++++++++++++++ apps/readest-app/src/components/Quota.tsx | 80 +++++++++---- apps/readest-app/src/hooks/useQuotaStats.ts | 7 ++ apps/readest-app/src/types/quota.ts | 1 + apps/readest-app/src/utils/time.ts | 3 + 5 files changed, 179 insertions(+), 23 deletions(-) create mode 100644 apps/readest-app/src/__tests__/components/Quota.test.tsx diff --git a/apps/readest-app/src/__tests__/components/Quota.test.tsx b/apps/readest-app/src/__tests__/components/Quota.test.tsx new file mode 100644 index 00000000..b54bdac5 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/Quota.test.tsx @@ -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) => { + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(screen.getByText('Resets in 0 hr 0 min')).toBeTruthy(); + }); +}); diff --git a/apps/readest-app/src/components/Quota.tsx b/apps/readest-app/src/components/Quota.tsx index 071f87d9..46274d74 100644 --- a/apps/readest-app/src/components/Quota.tsx +++ b/apps/readest-app/src/components/Quota.tsx @@ -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 = ({ 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 (
{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 = ({ quotas, showProgress, className, labelCla bgColor = 'bg-yellow-500'; } - return ( -
- {showProgress && ( -
- )} + const showResetRow = showProgress && quota.resetAt; + const resetIn = showResetRow + ? dayjs.duration(Math.max(0, quota.resetAt! - now)).format('H [hr] m [min]') + : ''; + return ( +
- - {quota.name} - -
- {quota.used} / {quota.total} {quota.unit} + {showProgress && ( +
+ )} + +
+ + {quota.name} + +
+ {quota.used} / {quota.total} {quota.unit} +
+ {showResetRow && ( +
+ {_('{{percentage}}% used', { percentage: usagePercentageRounded })} + {_('Resets in {{duration}}', { duration: resetIn })} +
+ )}
); })} diff --git a/apps/readest-app/src/hooks/useQuotaStats.ts b/apps/readest-app/src/hooks/useQuotaStats.ts index 7c05dbce..927e95f2 100644 --- a/apps/readest-app/src/hooks/useQuotaStats.ts +++ b/apps/readest-app/src/hooks/useQuotaStats.ts @@ -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]); diff --git a/apps/readest-app/src/types/quota.ts b/apps/readest-app/src/types/quota.ts index 7ecb922b..120befba 100644 --- a/apps/readest-app/src/types/quota.ts +++ b/apps/readest-app/src/types/quota.ts @@ -13,6 +13,7 @@ export type QuotaType = { used: number; total: number; unit: string; + resetAt?: number; }; export type QuotaFeature = 'storage' | 'translation' | 'tokens' | 'customization' | 'generic'; diff --git a/apps/readest-app/src/utils/time.ts b/apps/readest-app/src/utils/time.ts index 38cb3fca..1a9611dd 100644 --- a/apps/readest-app/src/utils/time.ts +++ b/apps/readest-app/src/utils/time.ts @@ -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';