fix(clients): clarify renewal preview times and estimates (#6662)

* fix(clients): clarify renewal preview times and estimates

The preview rendered RFC3339 strings directly and labeled the server's Local
zone without explaining which clock it represented. Its renewal count also
looked like a record of renewals already performed.

Display readable dates with an explicit UTC offset while preserving the
server's wall time, per-date DST offset and fractional cutoff precision.
Explain the panel/server timezone and read-only preview, and label the
renewal count as estimated allowance consumption in all 13 locales.
Let the first-cutoff button wrap on narrow screens and update the English
and Chinese client documentation plus display/form regression coverage.

This changes presentation only: renewal calculations, stored expiry values,
API payloads and first-use behavior remain unchanged.

* test(clients): remove redundant renewal preview copy checks

The locale-only preview case repeated the Local timezone branch without
covering a distinct behavior. Exact textContent comparisons after matching
the same text with getByText also only pinned constant wording, contrary
to the repository's testing guidance.

Remove that duplicate case, its locale setup and the redundant copy
assertions. Keep the UTC, per-boundary DST offset, fractional precision
and original datetime checks, together with the existing real form-save
tests. Production rendering, translations and renewal behavior are unchanged.

---------

Co-authored-by: JacktheRanger <219502738+JacktheRanger@users.noreply.github.com>
This commit is contained in:
Jack
2026-10-07 16:55:06 +08:00
committed by GitHub
parent b3f1cced70
commit 54d1712544
19 changed files with 194 additions and 35 deletions
@@ -12,6 +12,19 @@ import type { ClientFormValues } from '@/schemas/client';
type RenewalFields = Pick<ClientFormValues, 'reset' | 'resetDay' | 'resetWeekday' | 'resetMax'>;
type RenewalMode = 'none' | 'interval' | 'weekly' | 'monthly';
function RenewalDate({ value }: { value: string }) {
// Keep the server's wall time and per-date offset, including across DST.
// Parsing into a browser-local Date would show a different renewal boundary.
const display = value
.replace('T', ' ')
.replace(/(Z|[+-]\d{2}:\d{2})$/, (offset) => ` UTC${offset === 'Z' ? '+00:00' : offset}`);
return (
<time dateTime={value} dir="ltr">
{display}
</time>
);
}
export default function ClientRenewalFields({
active,
expiryTime,
@@ -146,9 +159,17 @@ export default function ClientRenewalFields({
</Typography.Paragraph>
)}
{preview && (
<Space orientation="vertical" size={4} style={{ marginBottom: 16 }}>
<Space orientation="vertical" size={4} style={{ width: '100%', marginBottom: 16 }}>
<Typography.Text>
{t('pages.clients.renewPreview', { zone: preview.timeZone })}
{t('pages.clients.renewPreview', {
zone:
preview.timeZone === 'Local'
? t('pages.clients.renewServerLocal')
: preview.timeZone,
})}
</Typography.Text>
<Typography.Text type="secondary">
{t('pages.clients.renewPreviewHint')}
</Typography.Text>
{delayedStart || preview.delayedStart ? (
<Typography.Text type="secondary">
@@ -160,22 +181,30 @@ export default function ClientRenewalFields({
{t('pages.clients.renewNeedsExpiry')}
</Typography.Text>
{preview.suggestedExpiryTime > 0 && (
<Button onClick={() => setExpiry(preview.suggestedExpiryTime)}>
{t('pages.clients.renewSetExpiry')}: {preview.suggestedExpiry}
<Button
style={{ maxWidth: '100%', height: 'auto', whiteSpace: 'normal' }}
onClick={() => setExpiry(preview.suggestedExpiryTime)}
>
<span>
{t('pages.clients.renewSetExpiry')}:{' '}
<RenewalDate value={preview.suggestedExpiry} />
</span>
</Button>
)}
</>
) : (
<>
<Typography.Text>
{t('pages.clients.renewAt')}: {preview.renewAt}
{t('pages.clients.renewAt')}: <RenewalDate value={preview.renewAt} />
</Typography.Text>
<Typography.Text>
{t('pages.clients.renewValidThrough')}: {preview.validThrough}
{t('pages.clients.renewValidThrough')}:{' '}
<RenewalDate value={preview.validThrough} />
</Typography.Text>
{preview.nextExpiry && (
<Typography.Text>
{t('pages.clients.renewNextExpiry')}: {preview.nextExpiry}
{t('pages.clients.renewNextExpiry')}:{' '}
<RenewalDate value={preview.nextExpiry} />
</Typography.Text>
)}
<Typography.Text>
@@ -74,7 +74,11 @@ it('keeps bulk renewal disabled by default and requires explicit cutoff selectio
}),
]),
);
fireEvent.click(screen.getByRole('button', { name: /Set first cycle cutoff/ }));
fireEvent.click(
screen.getByRole('button', {
name: 'Set first cycle cutoff: 2030-01-01 00:00:00 UTC+08:00',
}),
);
await submit();
await waitFor(() =>
expect(bulkCreate).toHaveBeenLastCalledWith([
@@ -50,7 +50,9 @@ it('preserves monthly clients, previews backend dates, and saves exclusive weekl
);
const mode = screen.getByLabelText('Auto renewal');
expect(mode.closest('.ant-select')?.textContent).toContain('Calendar monthly');
await waitFor(() => expect(document.body.textContent).toContain('2029-12-31T23:59:59+08:00'));
await waitFor(() =>
expect(document.body.textContent).toContain('2029-12-31 23:59:59 UTC+08:00'),
);
expect(post).toHaveBeenCalledWith(
'/panel/api/clients/renewalPreview',
expect.objectContaining({ resetMax: 3, resetCount: 2 }),
@@ -0,0 +1,80 @@
import { expect, it, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { FormProvider, useForm } from 'react-hook-form';
import ClientRenewalFields from '@/pages/clients/ClientRenewalFields';
import { HttpUtil, Msg } from '@/utils';
import { renderWithProviders } from './test-utils';
function PreviewForm() {
const form = useForm({ defaultValues: { reset: 1, resetDay: 0, resetWeekday: 0, resetMax: 0 } });
return (
<FormProvider {...form}>
<ClientRenewalFields active expiryTime={1790485200000} setExpiry={() => {}} />
</FormProvider>
);
}
it.each([
{
name: 'explains Local UTC without converting into the browser timezone',
zone: 'Local',
label: 'Server local time',
dates: ['2026-09-27T05:00:00Z', '2026-09-27T04:59:59Z', '2026-09-28T05:00:00Z'],
displayed: [
'2026-09-27 05:00:00 UTC+00:00',
'2026-09-27 04:59:59 UTC+00:00',
'2026-09-28 05:00:00 UTC+00:00',
],
},
{
name: 'preserves different offsets across a daylight-saving transition',
zone: 'America/New_York',
label: 'America/New_York',
dates: ['2030-03-10T00:00:00-05:00', '2030-03-09T23:59:59-05:00', '2030-03-17T00:00:00-04:00'],
displayed: [
'2030-03-10 00:00:00 UTC-05:00',
'2030-03-09 23:59:59 UTC-05:00',
'2030-03-17 00:00:00 UTC-04:00',
],
},
{
name: 'keeps fractional cutoffs and fractional-hour server offsets',
zone: 'Local',
label: 'Server local time',
dates: [
'2030-01-01T00:00:00.123+05:45',
'2030-01-01T00:00:00+05:45',
'2030-01-02T00:00:00.123+05:45',
],
displayed: [
'2030-01-01 00:00:00.123 UTC+05:45',
'2030-01-01 00:00:00 UTC+05:45',
'2030-01-02 00:00:00.123 UTC+05:45',
],
},
])('$name', async ({ zone, label, dates, displayed }) => {
const post = vi.spyOn(HttpUtil, 'post').mockResolvedValue(
new Msg(true, '', {
timeZone: zone,
renewAt: dates[0],
validThrough: dates[1],
nextExpiry: dates[2],
suggestedExpiryTime: 0,
suggestedExpiry: '',
renewals: 1,
canRenew: true,
delayedStart: false,
}),
);
try {
renderWithProviders(<PreviewForm />);
await screen.findByText(`Renewal preview (panel time zone: ${label})`);
const times = Array.from(document.querySelectorAll('time'));
expect(times.map((el) => el.textContent)).toEqual(displayed);
expect(times.map((el) => el.dateTime)).toEqual(dates);
expect(times.map((el) => el.dir)).toEqual(['ltr', 'ltr', 'ltr']);
} finally {
post.mockRestore();
}
});