|
1 | 1 | /** |
2 | | - * useSyncedTimer Hook |
3 | | - * Provides a high-precision countdown timer driven by `requestAnimationFrame`. |
4 | | - * Computes an absolute end-time once and derives remaining seconds and a smooth |
5 | | - * progress percentage (100 → 0) on every animation frame, avoiding drift that |
6 | | - * `setInterval`-based timers are prone to. |
| 2 | + * Custom React hook for a synchronized countdown timer. |
| 3 | + * |
| 4 | + * The hook keeps the timer aligned to the server-provided end timestamp and |
| 5 | + * uses `requestAnimationFrame` plus `performance.now()` to avoid drift from |
| 6 | + * tab throttling or local clock changes. |
7 | 7 | */ |
8 | 8 |
|
9 | 9 | import { useState, useEffect, useRef } from 'react'; |
10 | 10 |
|
11 | 11 | /** |
12 | | - * Custom React hook that runs a frame-accurate countdown timer. |
| 12 | + * Tracks a countdown that stays in sync with a server-defined phase end time. |
13 | 13 | * |
14 | | - * On mount (or when `durationSeconds` changes) the hook calculates an absolute |
15 | | - * end-time and uses `requestAnimationFrame` to update both a whole-second |
16 | | - * `timeLeft` value (for display) and a smooth `progress` percentage (for |
17 | | - * progress-bar animations). When the timer reaches zero, the optional |
18 | | - * `onExpire` callback is invoked exactly once. |
| 14 | + * The hook derives the remaining time from `localPhaseEndTime`, updates the |
| 15 | + * display state on each animation frame, and calls `onExpire` once when the |
| 16 | + * countdown reaches zero. |
19 | 17 | * |
20 | | - * @param {number} durationSeconds - Total countdown length in seconds. |
21 | | - * @param {() => void} [onExpire] - Optional callback fired when the timer reaches zero. |
22 | | - * @returns {{ timeLeft: number, progress: number }} An object with `timeLeft` (whole seconds remaining) and `progress` (percentage 100 → 0). |
| 18 | + * @param localPhaseEndTime - Absolute end timestamp from the server, in milliseconds. |
| 19 | + * @param durationSeconds - Total countdown length in seconds. |
| 20 | + * @param onExpire - Optional callback invoked when the timer expires. |
| 21 | + * @returns An object containing `timeLeft` and `progress`. |
23 | 22 | */ |
24 | | -export const useSyncedTimer = (durationSeconds: number, onExpire?: () => void) => { |
| 23 | +export const useSyncedTimer = ( |
| 24 | + localPhaseEndTime: number | null, |
| 25 | + durationSeconds: number, |
| 26 | + onExpire?: () => void, |
| 27 | +) => { |
25 | 28 | const [timeLeft, setTimeLeft] = useState(durationSeconds); |
26 | 29 | const [progress, setProgress] = useState(100); |
27 | 30 |
|
28 | | - // We use refs to hold values that don't need to trigger re-renders |
29 | | - const endTimeRef = useRef<number>(0); |
| 31 | + // Store the animation frame id without triggering re-renders. |
30 | 32 | const rafRef = useRef<number>(0); |
31 | 33 |
|
32 | 34 | useEffect(() => { |
33 | | - // Use the provided server startTime, or fallback to Date.now() |
34 | | - endTimeRef.current = Date.now() + durationSeconds * 1000; |
| 35 | + // Do not start ticking until the server provides an anchor. |
| 36 | + if (!localPhaseEndTime) return; |
| 37 | + |
| 38 | + // Capture the remaining time at the moment this effect starts. |
| 39 | + const initialRemainingMs = Math.max(0, localPhaseEndTime - Date.now()); |
| 40 | + |
| 41 | + // Lock in a monotonic timestamp so local clock changes do not affect the countdown. |
| 42 | + const startTimeMono = performance.now(); |
35 | 43 |
|
36 | 44 | const updateTimer = () => { |
37 | | - const now = Date.now(); |
38 | | - const remainingMs = Math.max(0, endTimeRef.current - now); |
| 45 | + // Calculate elapsed time using the monotonic clock only. |
| 46 | + const elapsedMono = performance.now() - startTimeMono; |
| 47 | + const currentRemainingMs = Math.max(0, initialRemainingMs - elapsedMono); |
39 | 48 |
|
40 | | - // Calculate smooth percentage for the progress bar (100 down to 0) |
41 | | - const newProgress = (remainingMs / (durationSeconds * 1000)) * 100; |
| 49 | + // Update the display state. |
| 50 | + const newProgress = |
| 51 | + durationSeconds > 0 ? (currentRemainingMs / (durationSeconds * 1000)) * 100 : 0; |
42 | 52 | setProgress(newProgress); |
| 53 | + setTimeLeft(Math.ceil(currentRemainingMs / 1000)); |
43 | 54 |
|
44 | | - // Calculate clean whole seconds for text display |
45 | | - setTimeLeft(Math.ceil(remainingMs / 1000)); |
46 | | - |
47 | | - if (remainingMs > 0) { |
| 55 | + // Continue until the timer expires. |
| 56 | + if (currentRemainingMs > 0) { |
48 | 57 | rafRef.current = requestAnimationFrame(updateTimer); |
49 | 58 | } else { |
50 | 59 | if (onExpire) onExpire(); |
51 | 60 | } |
52 | 61 | }; |
53 | 62 |
|
54 | | - // Kick off the loop |
55 | 63 | rafRef.current = requestAnimationFrame(updateTimer); |
56 | 64 |
|
57 | | - // Cleanup loop on unmount |
58 | 65 | return () => { |
59 | 66 | if (rafRef.current) cancelAnimationFrame(rafRef.current); |
60 | 67 | }; |
61 | | - }, [durationSeconds, onExpire]); |
| 68 | + }, [localPhaseEndTime, durationSeconds, onExpire]); |
62 | 69 |
|
63 | 70 | return { timeLeft, progress }; |
64 | 71 | }; |
0 commit comments