backend phase 14 & frontend phase 7
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
|
||||
// next-intl mocked so the locale is `en` and the countdown formats with ASCII digits we can assert on.
|
||||
jest.mock('next-intl', () => ({ useLocale: () => 'en' }));
|
||||
|
||||
import CountdownTimer, { CountdownTimerProps } from './CountdownTimer';
|
||||
|
||||
const deadlineInSeconds = (seconds: number) => new Date(Date.now() + seconds * 1000).toISOString();
|
||||
|
||||
function renderTimer(props: CountdownTimerProps) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<CountdownTimer {...props} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<CountdownTimer/> component', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-07-09T10:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders MM:SS remaining for a sub-hour deadline', () => {
|
||||
renderTimer({ deadlineIso: deadlineInSeconds(90), elapsedText: 'time up' });
|
||||
expect(screen.getByText('01:30')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders HH:MM:SS for a multi-hour deadline', () => {
|
||||
renderTimer({ deadlineIso: deadlineInSeconds(3661), elapsedText: 'time up' });
|
||||
expect(screen.getByText('01:01:01')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ticks down each second without lifting state to the page', () => {
|
||||
renderTimer({ deadlineIso: deadlineInSeconds(90), elapsedText: 'time up' });
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(screen.getByText('01:29')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the elapsed text and fires onElapsed exactly once at zero', () => {
|
||||
const onElapsed = jest.fn();
|
||||
renderTimer({ deadlineIso: deadlineInSeconds(2), elapsedText: 'time up', onElapsed });
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
expect(screen.getByText('time up')).toBeInTheDocument();
|
||||
expect(onElapsed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
|
||||
export interface CountdownTimerProps {
|
||||
/**
|
||||
* The **server-supplied** absolute UTC instant to count down to (e.g. `nurseResponseDeadlineAt`). The
|
||||
* client only renders the difference against `Date.now()` — it never computes or recomputes a deadline.
|
||||
*/
|
||||
deadlineIso: string;
|
||||
/** Optional label above the digits (already translated by the caller). */
|
||||
label?: string;
|
||||
/** Shown once the deadline has passed — the poll then resolves the real terminal status. */
|
||||
elapsedText: string;
|
||||
/** Terracotta-accented urgency styling for the money-adjacent payment window. */
|
||||
urgent?: boolean;
|
||||
/** Fired once when the countdown reaches zero (e.g. to nudge a refetch). */
|
||||
onElapsed?: () => void;
|
||||
}
|
||||
|
||||
const MS_PER_SECOND = 1000;
|
||||
const SECONDS_PER_MINUTE = 60;
|
||||
const SECONDS_PER_HOUR = 3600;
|
||||
|
||||
/**
|
||||
* A pure presentational countdown to a server-frozen deadline. It owns its own one-second tick so only
|
||||
* this component re-renders each second — never the page around it (the summary card / form stay put).
|
||||
* The ticking stops the moment the deadline passes; crossing zero shows `elapsedText` and fires
|
||||
* `onElapsed` once. Digits render in the active locale (Persian for `fa`), forced LTR so the `HH:MM:SS`
|
||||
* order is correct under RTL.
|
||||
* @component CountdownTimer
|
||||
*/
|
||||
const CountdownTimer: FunctionComponent<CountdownTimerProps> = ({
|
||||
deadlineIso,
|
||||
label,
|
||||
elapsedText,
|
||||
urgent = false,
|
||||
onElapsed,
|
||||
}) => {
|
||||
const locale = useLocale();
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const target = useMemo(() => Date.parse(deadlineIso), [deadlineIso]);
|
||||
const remainingMs = Number.isFinite(target) ? Math.max(0, target - now) : 0;
|
||||
const elapsed = remainingMs <= 0;
|
||||
|
||||
// Recreated only when `elapsed` flips (once) — not every tick, since `elapsed` stays false until zero.
|
||||
useEffect(() => {
|
||||
if (elapsed) return undefined;
|
||||
const interval = setInterval(() => setNow(Date.now()), MS_PER_SECOND);
|
||||
return () => clearInterval(interval);
|
||||
}, [elapsed]);
|
||||
|
||||
const firedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (elapsed && !firedRef.current) {
|
||||
firedRef.current = true;
|
||||
onElapsed?.();
|
||||
} else if (!elapsed) {
|
||||
firedRef.current = false;
|
||||
}
|
||||
}, [elapsed, onElapsed]);
|
||||
|
||||
const accent = urgent ? 'var(--bal-secondary)' : 'var(--bal-primary)';
|
||||
|
||||
if (elapsed) {
|
||||
return (
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', color: 'text.secondary' }}>
|
||||
<AppIcon icon="pending" size={18} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{elapsedText}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const totalSeconds = Math.floor(remainingMs / MS_PER_SECOND);
|
||||
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
|
||||
const minutes = Math.floor((totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
|
||||
const seconds = totalSeconds % SECONDS_PER_MINUTE;
|
||||
|
||||
const pad = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
minimumIntegerDigits: 2,
|
||||
useGrouping: false,
|
||||
});
|
||||
const clock = [hours > 0 ? pad.format(hours) : null, pad.format(minutes), pad.format(seconds)]
|
||||
.filter((part) => part !== null)
|
||||
.join(':');
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 0.25, alignItems: 'center' }}>
|
||||
{label ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="pending" size={20} color={accent} />
|
||||
<Typography
|
||||
component="span"
|
||||
dir="ltr"
|
||||
sx={{ fontWeight: 700, fontSize: '1.5rem', fontVariantNumeric: 'tabular-nums', color: accent }}
|
||||
>
|
||||
{clock}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CountdownTimer;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './CountdownTimer';
|
||||
export type { CountdownTimerProps } from './CountdownTimer';
|
||||
Reference in New Issue
Block a user