diff --git a/src/components/Timer/Timer.jsx b/src/components/Timer/Timer.jsx index a227f21aec..bd2a455e27 100644 --- a/src/components/Timer/Timer.jsx +++ b/src/components/Timer/Timer.jsx @@ -13,6 +13,7 @@ import { FaStopCircle, FaUndoAlt, } from 'react-icons/fa'; +import jwtDecode from 'jwt-decode'; import { connect, useDispatch } from 'react-redux'; import { toast } from 'react-toastify'; import useWebSocket, { ReadyState } from 'react-use-websocket'; @@ -26,6 +27,23 @@ import css from './Timer.module.css'; import TimerPopout from './TimerPopout'; import TimerStatus from './TimerStatus'; +// Reconnect backoff: 1s, 2s, 4s, 8s, 16s, then every 30s thereafter, forever, +// for as long as the stored token is usable. A real backend restart/deploy can +// easily take longer than a short attempt ladder (this one used to give up +// after ~61s and strand a visible, still-open tab on "Disconnected" with +// nothing left to retry it - no visibilitychange/online event fires for a tab +// that was never backgrounded or actually offline). Only a bad/expired token +// should ever show Disconnected + the manual refresh button. +const RECONNECT_BASE_DELAY_MS = 1000; +const RECONNECT_MAX_DELAY_MS = 30000; +// Caps the backoff exponent, not the number of attempts - purely to keep +// 2 ** n from ever overflowing across a very long-running disconnection. +const RECONNECT_BACKOFF_EXPONENT_CAP = 10; +// After this many failed attempts in a row, assume we're genuinely offline (sleep, +// wifi drop, etc.) and chime locally if a session was actually running when the +// connection dropped - we may not be able to reach the server to confirm either way. +const OFFLINE_CHIME_ATTEMPT_THRESHOLD = 3; + function Timer({ authUser, darkMode, isPopout }) { const dispatch = useDispatch(); const realIsPopout = typeof isPopout === 'boolean' ? isPopout : !!globalThis.opener; @@ -37,14 +55,138 @@ function Timer({ authUser, darkMode, isPopout }) { * */ const [customReadyState, setCustomReadyState] = useState(ReadyState.CONNECTING); const [wsKey, setWsKey] = useState(0); + // True only once the stored token is unusable - that's the one thing that + // should ever stop auto-reconnect and show "Disconnected" + the manual + // refresh button instead of "Reconnecting...". While the token stays valid, + // reconnect attempts never give up. + const [reconnectExhausted, setReconnectExhausted] = useState(false); + const reconnectAttemptRef = useRef(0); + const reconnectTimeoutRef = useRef(null); + const offlineChimeFiredRef = useRef(false); + // The heartbeat-miss path calls handleConnectionLost() directly (for immediate + // feedback) and then best-effort closes the socket, whose resulting onClose would + // call handleConnectionLost() again for the same outage. This guards against that + // double count; it's cleared whenever a new connection attempt actually begins + // (scheduleReconnect's timeout, onOpen, or a manual/immediate reconnect), so each + // *new* attempt's own failure still counts once. + const connectionLostHandledRef = useRef(false); + // started/paused aren't declared until later (they come from the message state), + // but WSoptions/handleConnectionLost must exist before the useWebSocket() call that + // produces them, so this ref (kept in sync below, once they exist) lets the + // close/error handlers check "was a session actually running when we dropped?" + // without a direct reference that would be used before it's declared. + const timerFlagsRef = useRef({ started: false, paused: false }); + + const clearScheduledReconnect = useCallback(() => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + }, []); + + // No point retrying with a token the server will reject anyway (expired, or just + // malformed/missing) - same expiry convention as utils/authInit.js. + const isStoredTokenUsable = useCallback(() => { + const token = localStorage.getItem(config.tokenKey); + if (!token) return false; + // prettier-ignore + try { + const decoded = jwtDecode(token); + const expirySec = new Date(decoded.expiryTimestamp).getTime() / 1000; + return expirySec > Date.now() / 1000; + } catch (_error) { // NOSONAR - a malformed/undecodable token is just as + // unusable as an expired one; treating it the same way (not usable) + // instead of letting the decode error bubble up and break the reconnect + // check is intentional, not a swallowed error. + return false; + } + }, []); + + // Once exhausted, nothing was going to retry on its own again - without this, a + // tab left on "Disconnected" stays stuck until the user notices and clicks the + // manual refresh button. Re-checking the token here (rather than just retrying + // blindly) means a genuinely bad/expired token is the only thing that still + // leaves it stuck - everything else gets a fresh attempt. + const resetAndReconnectIfTokenUsable = useCallback(() => { + if (!isStoredTokenUsable()) return false; + clearScheduledReconnect(); + reconnectAttemptRef.current = 0; + connectionLostHandledRef.current = false; + setReconnectExhausted(false); + setWsKey(k => k + 1); + return true; + }, [isStoredTokenUsable, clearScheduledReconnect]); + + const scheduleReconnect = useCallback(() => { + clearScheduledReconnect(); + + if (!isStoredTokenUsable()) { + setReconnectExhausted(true); + return; + } + + const exponent = Math.min(reconnectAttemptRef.current - 1, RECONNECT_BACKOFF_EXPONENT_CAP); + const delay = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** exponent, RECONNECT_MAX_DELAY_MS); + reconnectTimeoutRef.current = setTimeout(() => { + connectionLostHandledRef.current = false; // this next attempt's own failure should count + setWsKey(k => k + 1); + }, delay); + }, [clearScheduledReconnect, isStoredTokenUsable]); + + // Shared handling for "the socket just went down" - called from onClose, onError, + // and the heartbeat-miss path below, so there's one place deciding whether to + // chime and/or keep retrying, instead of each path guessing independently. + const handleConnectionLost = useCallback(() => { + if (connectionLostHandledRef.current) return; // already processed this disconnect once + connectionLostHandledRef.current = true; + + setRunning(false); + setCustomReadyState(ReadyState.CLOSED); + + reconnectAttemptRef.current += 1; + + const { started: wasStarted, paused: wasPaused } = timerFlagsRef.current; + if ( + reconnectAttemptRef.current >= OFFLINE_CHIME_ATTEMPT_THRESHOLD && + wasStarted && + !wasPaused && + !offlineChimeFiredRef.current + ) { + offlineChimeFiredRef.current = true; + // Reflect the pause locally (button/goal display) too, not just the modal - + // we can't tell the server while offline, so this is a local-only guess that + // a genuine reconnect resync (the sendGetTimer effect below, once + // customReadyState goes back to OPEN) always overwrites with the server's + // real paused state, whether or not the server itself ever learns we dropped. + setMessage(prev => ({ ...prev, paused: true })); + setInacModal(true); + } + + scheduleReconnect(); + }, [scheduleReconnect]); + const WSoptions = { share: false, protocols: localStorage.getItem(config.tokenKey), - onOpen: () => setCustomReadyState(ReadyState.OPEN), - onClose: () => setCustomReadyState(ReadyState.CLOSED), + onOpen: () => { + setCustomReadyState(ReadyState.OPEN); + clearScheduledReconnect(); + reconnectAttemptRef.current = 0; + offlineChimeFiredRef.current = false; + connectionLostHandledRef.current = false; + setReconnectExhausted(false); + }, + onClose: () => handleConnectionLost(), + // A real WebSocket always follows an error with a close event, which does the + // actual reconnect bookkeeping - this just gets the UI/countdown out of a + // "connected" state immediately instead of waiting for that close to land. onError: () => { + setRunning(false); setCustomReadyState(ReadyState.CLOSED); }, + // We drive all reconnection ourselves via wsKey (see scheduleReconnect) so the + // library never also retries on its own. + shouldReconnect: () => false, }; /** @@ -103,6 +245,10 @@ function Timer({ authUser, darkMode, isPopout }) { const [message, setMessage] = useState(defaultMessage); const { time, paused, started, goal, startAt } = message; + useEffect(() => { + timerFlagsRef.current = { started, paused }; + }, [started, paused]); + const [running, setRunning] = useState(false); const [confirmationResetModal, setConfirmationResetModal] = useState(false); const [confirmSubmitModalOpen, setConfirmSubmitModalOpen] = useState(false); @@ -278,12 +424,24 @@ function Timer({ authUser, darkMode, isPopout }) { [timerState], ); + // Deliberately does NOT reset message/goal/remaining to defaultMessage - the + // automatic backoff-retry path never does either, and doing it here optimistically + // wiped the real accumulated session (e.g. a goal built up over hours) back to the + // 15-minute default before a fresh GET_TIMER confirmed anything. If the token is + // still bad, the retry just fails again and reconnectExhausted flips back on - the + // user is left looking at a wiped-out default on top of "Disconnected" instead of + // their last known real state. The real GET_TIMER response (sent once OPEN) is + // what should ever overwrite message - same as every other reconnect path. const handleRefreshTimer = useCallback(() => { + clearScheduledReconnect(); + reconnectAttemptRef.current = 0; + offlineChimeFiredRef.current = false; + connectionLostHandledRef.current = false; + setReconnectExhausted(false); getWebSocket()?.close(); setCustomReadyState(ReadyState.CONNECTING); - setMessage(defaultMessage); setWsKey(k => k + 1); - }, [getWebSocket]); + }, [getWebSocket, clearScheduledReconnect]); // Initialize session ID on component mount useEffect(() => { @@ -623,18 +781,30 @@ function Timer({ authUser, darkMode, isPopout }) { * at the same time. */ useEffect(() => { + // react-use-websocket resets lastJsonMessage to null/undefined every time it + // creates a fresh socket instance - which a reconnect attempt does on every + // retry (see scheduleReconnect's wsKey bump), not just on first mount. Without + // this guard, each retry re-ran this effect with no real message, fell through + // to `lastJsonMessage || defaultMessage` below, and wiped message.started/paused + // back to the (false/false) defaults - erasing the very "was a session running + // when we dropped" state handleConnectionLost's chime threshold depends on, so + // the chime could never fire no matter how long the outage lasted. There is + // nothing to apply on a null message - the component's initial state is already + // defaultMessage, so this effect only ever needs to react to a genuine message. + if (!lastJsonMessage) return; + // Exclude heartbeat message and timelog event messages - if (lastJsonMessage && lastJsonMessage.heartbeat === 'pong') { + if (lastJsonMessage.heartbeat === 'pong') { isWSOpenRef.current = 0; return; } // Ignore TIMELOG_EVENT messages - they're for the timestamps tab, not the timer - if (lastJsonMessage && lastJsonMessage.type === 'TIMELOG_EVENT') { + if (lastJsonMessage.type === 'TIMELOG_EVENT') { return; } if ( - lastJsonMessage?.requestId && + lastJsonMessage.requestId && pendingStopRef.current?.requestId === lastJsonMessage.requestId ) { const { timeoutId, resolve } = pendingStopRef.current; @@ -643,54 +813,78 @@ function Timer({ authUser, darkMode, isPopout }) { resolve(lastJsonMessage.started === false); } // Handle explicit week close pause action messages - if (lastJsonMessage && lastJsonMessage.action === 'WEEK_CLOSE_PAUSE') { + if (lastJsonMessage.action === 'WEEK_CLOSE_PAUSE') { handleWeekEndPause(); return; // Exit early to prevent other modal logic } - const { - paused: pausedLJM, - forcedPause: forcedPauseLJM, - started: startedLJM, - chiming: chimingLJM, - weekEndPause: weekEndPauseLJM, - } = lastJsonMessage || defaultMessage; - - // console.log('DEBUG WS TIMER RECEIVED', { - // time: lastJsonMessage?.time, - // started: lastJsonMessage?.started, - // paused: lastJsonMessage?.paused, - // receivedAt: Date.now(), - // }); - - setMessage(lastJsonMessage || defaultMessage); + const { paused: pausedLJM, forcedPause: forcedPauseLJM, started: startedLJM } = lastJsonMessage; + + setMessage(lastJsonMessage); setRunning(startedLJM && !pausedLJM); if (!startedLJM || pausedLJM) { setRemaining(lastJsonMessage?.time ?? defaultMessage.time); } - // Show inactivity or time-over modals based on message state + // Show inactivity modal based on message state setInacModal(forcedPauseLJM); + // Deliberately NOT depending on customReadyState/weekEndModal here: this effect + // should only re-derive message/running/remaining when a genuinely NEW message + // arrives. Reconnecting (customReadyState flipping CLOSED -> OPEN) with no fresh + // GET_TIMER reply yet must not re-apply a stale lastJsonMessage and resurrect + // running=true from before the disconnect - see the time-over-modal effect below + // for the one case that does want to react to customReadyState. + }, [lastJsonMessage]); + + // The time-over ("Time Complete!") modal specifically does want to react to + // reconnects/week-end-pause changes (e.g. show it once back online if the + // session was already chiming), so this stays a separate effect rather than + // folding it into the one above. + useEffect(() => { + if ( + lastJsonMessage && + (lastJsonMessage.heartbeat === 'pong' || + lastJsonMessage.type === 'TIMELOG_EVENT' || + lastJsonMessage.action === 'WEEK_CLOSE_PAUSE') + ) { + return; + } + const { chiming: chimingLJM } = lastJsonMessage || defaultMessage; setTimeIsOverModalIsOpen(chimingLJM && (customReadyState === ReadyState.OPEN || !weekEndModal)); }, [lastJsonMessage, customReadyState, weekEndModal]); - // This useEffect is to make sure that the WS connection is maintained by sending a heartbeat every 60 seconds + // Keeps the WS connection alive AND is the fast, independent detector for "the + // server is gone but our socket never got a close/error event" - e.g. an abruptly + // killed backend process doesn't always deliver a prompt TCP close to an already- + // established connection (confirmed empirically: ~20-30s before onClose/onError + // fired in this environment, well past a short reviewer test window). A 60s + // heartbeat needing >3 misses took 4+ minutes to notice - far too slow for Jae's + // requirement that a dropped connection pause+chime promptly (verified against a + // real 25s/80s+ backend outage). HEARTBEAT_INTERVAL_MS/HEARTBEAT_TIMEOUT_MS/ + // MISSES_BEFORE_LOST together land detection around 3 * 5s = 15s - fast enough for + // a short outage, but still a few consecutive misses (not one blip) before acting. useEffect(() => { + const HEARTBEAT_INTERVAL_MS = 5000; + const HEARTBEAT_TIMEOUT_MS = 4000; + const MISSES_BEFORE_LOST = 3; const interval = setInterval(() => { if (running) { isWSOpenRef.current += 1; sendHeartbeat(); setTimeout(() => { - // make sure to notify the user if the heartbeat is not responded for 3 times - if (isWSOpenRef.current > 3) { - setRunning(false); - setInacModal(true); - getWebSocket().close(); // try to close the WS connection, but it might not work when internet is cut off - setCustomReadyState(ReadyState.CLOSED); + if (isWSOpenRef.current >= MISSES_BEFORE_LOST) { + // Don't decide locally that this is a genuine forced pause - a background/ + // throttled tab can miss heartbeats for a long time and then "wake up" with + // a wildly stale assumption. Route through the same handling as a real + // close/error so reconnecting resyncs with the server's actual state + // (already logged elsewhere, or a real forced pause) instead of guessing. + handleConnectionLost(); + getWebSocket()?.close(); // best-effort; may not fire a close event if truly offline + isWSOpenRef.current = 0; } - }, 10000); // close the WS if no response after 10 seconds + }, HEARTBEAT_TIMEOUT_MS); } - }, 60000); + }, HEARTBEAT_INTERVAL_MS); return () => clearInterval(interval); }, [running]); @@ -792,6 +986,51 @@ function Timer({ authUser, darkMode, isPopout }) { sendGetTimer(); }, [customReadyState, viewingUserId, wsKey]); + // Resync with the server as soon as a backgrounded tab becomes visible again, and + // either resync or jump straight to a reconnect attempt (instead of waiting out + // the rest of the backoff) as soon as the browser reports it's back online. + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState !== 'visible') return; + if (customReadyState === ReadyState.OPEN) { + sendGetTimer(); + } else if (reconnectExhausted) { + // Only a still-bad/expired token should leave this stuck - anything else + // deserves a fresh attempt now that the tab is actually being looked at. + resetAndReconnectIfTokenUsable(); + } + }; + + const handleOnline = () => { + if (customReadyState === ReadyState.OPEN) { + sendGetTimer(); + } else if (reconnectExhausted) { + resetAndReconnectIfTokenUsable(); + } else { + // Already mid-backoff (not yet exhausted) - don't make the user wait out + // the rest of it now that the browser says we're back online. + clearScheduledReconnect(); + setWsKey(k => k + 1); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + globalThis.addEventListener('online', handleOnline); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + globalThis.removeEventListener('online', handleOnline); + }; + }, [ + customReadyState, + reconnectExhausted, + sendGetTimer, + clearScheduledReconnect, + resetAndReconnectIfTokenUsable, + ]); + + // Cancel any pending reconnect attempt on unmount. + useEffect(() => clearScheduledReconnect, [clearScheduledReconnect]); + // Enhanced cleanup when viewing user changes useEffect(() => { clearSubmittedTime(); @@ -1013,6 +1252,7 @@ function Timer({ authUser, darkMode, isPopout }) { message={message} toggleTimer={() => globalThis.close()} handleRefreshTimer={handleRefreshTimer} + reconnectExhausted={reconnectExhausted} /> )} @@ -1057,16 +1297,18 @@ function Timer({ authUser, darkMode, isPopout }) { ) : (
- Disconnected - + {reconnectExhausted ? 'Disconnected' : 'Reconnecting...'} + {reconnectExhausted && ( + + )}
)} @@ -1221,6 +1463,7 @@ function Timer({ authUser, darkMode, isPopout }) { message={message} toggleTimer={toggleTimer} handleRefreshTimer={handleRefreshTimer} + reconnectExhausted={reconnectExhausted} /> )} diff --git a/src/components/Timer/TimerStatus.jsx b/src/components/Timer/TimerStatus.jsx index 0281ff3230..fdbc2cf140 100644 --- a/src/components/Timer/TimerStatus.jsx +++ b/src/components/Timer/TimerStatus.jsx @@ -3,20 +3,37 @@ import { BsXLg, BsArrowClockwise } from 'react-icons/bs'; import cs from 'classnames'; import css from './Countdown.module.css'; -export default function TimerStatus({ readyState, toggleTimer, handleRefreshTimer }) { +const READY_STATE_LABELS = { + [ReadyState.CONNECTING]: 'Connecting to the timer service', + [ReadyState.OPEN]: 'Open', + [ReadyState.CLOSING]: 'Closing the connection', + [ReadyState.CLOSED]: 'The connection with the timer service is closed', + [ReadyState.UNINSTANTIATED]: 'Uninstantiated', +}; + +// While auto-reconnect is still retrying in the background, show "Reconnecting..." +// regardless of the exact underlying readyState - only once attempts are +// exhausted (or the stored token is unusable) do we ask the user to act. +function getConnectionStatus(isReconnecting, reconnectExhausted, readyState) { + if (isReconnecting) return 'Reconnecting...'; + if (reconnectExhausted) return 'Disconnected'; + return READY_STATE_LABELS[readyState]; +} + +export default function TimerStatus({ + readyState, + toggleTimer, + handleRefreshTimer, + reconnectExhausted, +}) { /* This is the status of the connection with the timer service We just use the readyState of the websocket connection to show the status */ - const connectionStatus = { - [ReadyState.CONNECTING]: 'Connecting to the timer service', - [ReadyState.OPEN]: 'Open', - [ReadyState.CLOSING]: 'Closing the connection', - [ReadyState.CLOSED]: 'The connection with the timer service is closed', - [ReadyState.UNINSTANTIATED]: 'Uninstantiated', - }[readyState]; + const isReconnecting = readyState !== ReadyState.OPEN && !reconnectExhausted; + const connectionStatus = getConnectionStatus(isReconnecting, reconnectExhausted, readyState); - const showRefreshButton = readyState === ReadyState.CLOSED; + const showRefreshButton = reconnectExhausted; /* Here is the component to show the timer status @@ -33,6 +50,9 @@ export default function TimerStatus({ readyState, toggleTimer, handleRefreshTime )} diff --git a/src/components/Timer/__test__/Timer.test.jsx b/src/components/Timer/__test__/Timer.test.jsx index 24f61c39d9..508fd8972b 100644 --- a/src/components/Timer/__test__/Timer.test.jsx +++ b/src/components/Timer/__test__/Timer.test.jsx @@ -1,32 +1,66 @@ // eslint-disable-next-line no-unused-vars import '@testing-library/jest-dom'; import { act, fireEvent, render, screen } from '@testing-library/react'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Provider } from 'react-redux'; import configureMockStore from 'redux-mock-store'; +import config from '../../../config.json'; import Timer from '../Timer'; +// Minimal (unsigned, never verified - jwtDecode only reads the payload) JWT so +// Timer's reconnect-auth-check (isStoredTokenUsable) sees a usable token, using +// the same `expiryTimestamp` claim utils/authInit.js expects. +const base64url = obj => + btoa(JSON.stringify(obj)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +const makeFakeToken = expiryTimestamp => + `${base64url({ alg: 'none', typ: 'JWT' })}.${base64url({ expiryTimestamp })}.sig`; + // Shared, hoisted so the vi.mock factory below (which Vitest hoists above this // file's imports) can read/write it, and the test body can push fake server // messages through it. const mocks = vi.hoisted(() => ({ lastJsonMessage: null, setLastJsonMessage: null, + // Latest options Timer.jsx passed to useWebSocket - lets tests fire onClose/ + // onError/onOpen directly, the same way a real socket event would. + latestOptions: null, + // How many of the next (re)connection attempts - including the very first + // mount - should fail instead of succeeding. Decremented on each attempt. + failNextAttempts: 0, })); // Timer.jsx talks to the backend only through react-use-websocket. Mocking it -// here lets us drive "server" messages directly and assert on Timer's local -// (optimistic) state updates in isolation, without a real websocket/server. +// here lets us drive "server" messages and connection lifecycle events (open/ +// close/error, reconnect attempts) directly, and assert on Timer's local state +// in isolation, without a real websocket/server. // Named useMockWebSocket (not "default") so eslint's react-hooks/rules-of-hooks // recognizes the useState/useEffect calls inside as belonging to a hook. function useMockWebSocket(_url, options) { const [lastJsonMessage, setLastJsonMessage] = useState(mocks.lastJsonMessage); mocks.setLastJsonMessage = setLastJsonMessage; + mocks.latestOptions = options; + + // Timer.jsx forces a fresh connection attempt by bumping wsKey, which flows in + // here as queryParams.reconnect - mirrors the real library opening a new socket + // whenever the computed URL changes. + const reconnectToken = options.queryParams?.reconnect; + const hasHandledTokenRef = useRef(); useEffect(() => { - options.onOpen?.(); + if (hasHandledTokenRef.current === reconnectToken) return; + hasHandledTokenRef.current = reconnectToken; + + if (mocks.failNextAttempts > 0) { + mocks.failNextAttempts -= 1; + options.onClose?.({ code: 1006, reason: 'mock failure' }); + } else { + options.onOpen?.(); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [reconnectToken]); return { sendMessage: vi.fn(), @@ -91,6 +125,16 @@ afterAll(() => { beforeEach(() => { mocks.lastJsonMessage = null; mocks.setLastJsonMessage = null; + mocks.latestOptions = null; + mocks.failNextAttempts = 0; + localStorage.setItem( + config.tokenKey, + makeFakeToken(new Date(Date.now() + 86400000).toISOString()), + ); +}); + +afterEach(() => { + localStorage.clear(); }); describe('Timer handleStartButton', () => { @@ -157,3 +201,355 @@ describe('Timer Log Time form after a reload', () => { expect(formData).toHaveTextContent('0:1'); }); }); + +// Pushes a GET_TIMER-shaped message for a timer that is actively running +// (started, not paused) with the given goal, nothing elapsed yet. +const pushRunningMessage = goal => { + act(() => { + mocks.setLastJsonMessage({ + started: true, + paused: false, + chiming: false, + forcedPause: false, + weekEndPause: false, + time: goal, + goal, + initialGoal: goal, + startAt: Date.now(), + }); + }); +}; + +describe('Timer connection loss and recovery', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('stops the local countdown immediately on a dropped connection, so it cannot hit 0 and fire Time Complete on its own', () => { + renderTimer(); + pushRunningMessage(2000); // 2-second goal + + expect(screen.getByLabelText('Stop timer and log timer')).toBeInTheDocument(); + + // Simulate the connection dropping (sleep, wifi drop, server restart, etc.). + act(() => { + mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' }); + }); + + // Immediately: UI should reflect "not connected", not the normal running + // countdown (checked before auto-reconnect's first attempt has a chance to + // fire and resync, so this specifically proves the *local* response to the + // drop, not just that a later resync happened to fix things up). + expect(screen.queryByLabelText('Stop timer and log timer')).not.toBeInTheDocument(); + + // If the countdown had kept running locally, 5 real seconds would be enough + // for a 2-second goal to hit 0 and pop "Time Complete!". It shouldn't, because + // running was forced false the moment the connection was lost. + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(screen.queryByText('Time Complete!')).not.toBeInTheDocument(); + }); + + it('shows "Reconnecting..." (not "Disconnected") while retrying, and recovers on its own once reconnected, with no manual click', () => { + renderTimer(); + pushRunningMessage(60000); + + act(() => { + mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' }); + }); + + expect(screen.getByText('Reconnecting...')).toBeInTheDocument(); + expect(screen.queryByText('Disconnected')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Reload timer')).not.toBeInTheDocument(); + + // First backoff step (1s) - mock succeeds (failNextAttempts defaults to 0). + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(screen.queryByText('Reconnecting...')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Stop timer and log timer')).toBeInTheDocument(); + }); + + it('does not chime after reconnecting if the server says the timer was already stopped elsewhere', () => { + renderTimer(); + pushRunningMessage(60000); + + act(() => { + mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' }); + }); + act(() => { + vi.advanceTimersByTime(1000); // first retry succeeds + }); + + // The reconnect resync (sendGetTimer) comes back showing it was already + // stopped/logged in another tab while this one was down. + act(() => { + mocks.setLastJsonMessage({ + started: false, + paused: false, + chiming: false, + forcedPause: false, + weekEndPause: false, + time: 60000, + goal: 60000, + initialGoal: 60000, + startAt: null, + }); + }); + + expect(screen.queryByText('Timer Paused')).not.toBeInTheDocument(); + }); + + it('chimes after reconnecting if the server confirms a genuine forced pause', () => { + renderTimer(); + pushRunningMessage(60000); + + act(() => { + mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' }); + }); + act(() => { + vi.advanceTimersByTime(1000); // first retry succeeds + }); + + // The server's own liveness check caught a real dead connection while this + // was the only tab, and already marked it forced-paused. + act(() => { + mocks.setLastJsonMessage({ + started: true, + paused: true, + chiming: false, + forcedPause: true, + weekEndPause: false, + time: 45000, + goal: 60000, + initialGoal: 60000, + startAt: null, + }); + }); + + expect(screen.getByText('Timer Paused')).toBeInTheDocument(); + }); + + it('does not flash the 00:15:00 default goal/time right after reconnecting, before the fresh resync reply lands', () => { + renderTimer(); + pushRunningMessage(30 * 60 * 1000); // 30-minute goal + expect(screen.getByText('Goal: 00:30:00')).toBeInTheDocument(); + + act(() => { + mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' }); + }); + + // A real reconnect (the library opening a fresh socket on every retry, not + // just the first) resets the underlying lastJsonMessage to null until a new + // reply arrives - simulate that here, same as the real library would. + act(() => { + mocks.setLastJsonMessage(null); + }); + act(() => { + vi.advanceTimersByTime(1000); // first backoff step - mock reconnect succeeds + }); + + // Reconnected (customReadyState is OPEN again, Countdown is back on screen), + // but no fresh GET_TIMER reply has arrived yet - exactly the moment the old + // code read the still-null lastJsonMessage as "nothing yet, apply + // defaultMessage" and flashed the goal/time display to the hardcoded + // 15-minute default for a moment on every reconnect. + expect(screen.queryByText('Goal: 00:15:00')).not.toBeInTheDocument(); + expect(screen.getByText('Goal: 00:30:00')).toBeInTheDocument(); + }); + + it('does not lose track of "a session was running" across repeated reconnect attempts, so the offline chime still fires after enough of them', () => { + // Every attempt in this window fails, so the chime threshold is reached + // through genuine repeated drops, not a lucky early reconnect. + mocks.failNextAttempts = 3; + + renderTimer(); + pushRunningMessage(60000); + + // Attempt 1 (the initial drop). + act(() => { + mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' }); + }); + expect(screen.queryByText('Timer Paused')).not.toBeInTheDocument(); + + // Each retry's own fresh socket resets lastJsonMessage to null before it + // fails again - exactly the transient state that used to wipe + // started/paused back to the (false/false) defaults and permanently block + // the chime, no matter how many attempts followed or how long the outage + // lasted (confirmed against a real 25s/80s+ backend outage: the chime + // never fired at all before this fix). + act(() => { + mocks.setLastJsonMessage(null); + }); + act(() => { + vi.advanceTimersByTime(1000); // 1st backoff step -> attempt 2 (fails) + }); + expect(screen.queryByText('Timer Paused')).not.toBeInTheDocument(); + + act(() => { + mocks.setLastJsonMessage(null); + }); + act(() => { + vi.advanceTimersByTime(2000); // 2nd backoff step -> attempt 3 (fails) + }); + + // Attempt 3 crosses the chime threshold - and only gets there because the + // null resets above did not erase "this session was running and not + // paused" in between. + expect(screen.getByText('Timer Paused')).toBeInTheDocument(); + }); + + it('stops retrying and shows Disconnected + a manual refresh button once the stored token is unusable', () => { + localStorage.removeItem(config.tokenKey); // simulate an expired/cleared token + + renderTimer(); + pushRunningMessage(60000); + + act(() => { + mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' }); + }); + + // No retry gets scheduled at all - exhausted immediately. + expect(screen.getByText('Disconnected')).toBeInTheDocument(); + expect(screen.getByLabelText('Reload timer')).toBeInTheDocument(); + }); + + it('keeps retrying every 30s indefinitely (never shows Disconnected) as long as the token stays valid', () => { + // A real backend restart/deploy can easily take longer than a short fixed + // attempt ladder - this test runs well past where the old 6-attempt/~61s + // ladder used to give up, with every single attempt still failing + // (failNextAttempts stays ahead of however many retries fire). + mocks.failNextAttempts = 20; + + renderTimer(); + pushRunningMessage(60000); + + act(() => { + mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' }); + }); + + // ~6.5 minutes: backoff ramps 1s/2s/4s/8s/16s then every 30s after - + // roughly 17 failed attempts fit in this window, far beyond the old cap. + act(() => { + vi.advanceTimersByTime(400000); + }); + + expect(screen.getByText('Reconnecting...')).toBeInTheDocument(); + expect(screen.queryByText('Disconnected')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Reload timer')).not.toBeInTheDocument(); + }); + + it('reconnects on an online event once exhausted, if the token has since become usable again', () => { + localStorage.removeItem(config.tokenKey); // simulate an expired/cleared token + + renderTimer(); + pushRunningMessage(60000); + + act(() => { + mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' }); + }); + + expect(screen.getByText('Disconnected')).toBeInTheDocument(); + + // Token becomes usable again (e.g. refreshed in another tab) before the + // browser reports we're back online - should no longer be stuck. + localStorage.setItem( + config.tokenKey, + makeFakeToken(new Date(Date.now() + 86400000).toISOString()), + ); + + act(() => { + window.dispatchEvent(new Event('online')); + }); + + expect(screen.queryByText('Disconnected')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Stop timer and log timer')).toBeInTheDocument(); + }); + + it('eventually stops the countdown via the heartbeat-miss path when pongs stop arriving', () => { + renderTimer(); + // Goal must outlast the heartbeat-miss window, otherwise the countdown would + // legitimately reach 0 on its own well before heartbeat-miss matters. + pushRunningMessage(3600000); // 1 hour + + expect(screen.getByLabelText('Stop timer and log timer')).toBeInTheDocument(); + + // 3 heartbeat cycles (5s interval + 4s miss-check each) with no pong ever + // arriving - the same background-tab-throttling scenario that used to chime + // hours later (with the old 60s/10s/4-miss timing) based on a stale + // assumption, and which a reviewer found never chimed at all within a real + // 25s/80s+ backend outage because of the message-reset bug fixed alongside + // this. Stop just past the 3rd miss-check (3*5000 + 4000 = 19000ms) but + // before the first reconnect retry (+1000ms) fires, so this specifically + // catches the moment disconnection is detected. + act(() => { + vi.advanceTimersByTime(19100); + }); + + expect(screen.queryByText('Time Complete!')).not.toBeInTheDocument(); + expect(screen.getByText('Reconnecting...')).toBeInTheDocument(); + }); + + it('preserves the real accumulated goal through a manual reload instead of wiping it to the 15-minute default - covers "after 5 hours it says Disconnected, refresh resets ... and the time is lost"', () => { + localStorage.removeItem(config.tokenKey); // simulate an expired token + renderTimer(); + + const longGoal = 4.5 * 60 * 60 * 1000; // a long accumulated session/goal + pushRunningMessage(longGoal); + expect(screen.getByText('Goal: 04:30:00')).toBeInTheDocument(); + + // Connection drops with a bad token - exhausted immediately, shows Disconnected. + act(() => { + mocks.latestOptions.onClose({ code: 1006, reason: 'mock drop' }); + }); + expect(screen.getByText('Disconnected')).toBeInTheDocument(); + + // The user actually logs back in (token becomes usable again) before clicking + // the manual "Reload timer" button. + localStorage.setItem( + config.tokenKey, + makeFakeToken(new Date(Date.now() + 86400000).toISOString()), + ); + + fireEvent.click(screen.getByLabelText('Reload timer')); + + // The mock socket auto-opens (failNextAttempts is 0), but no fresh GET_TIMER + // response has arrived yet - this is exactly the moment the old code wiped + // message to the 15-minute default (would show "Goal: 00:15:00" here). The + // real goal must still be showing; only a genuine GET_TIMER reply should ever + // change it. + expect(screen.queryByText('Disconnected')).not.toBeInTheDocument(); + expect(screen.getByText('Goal: 04:30:00')).toBeInTheDocument(); + + // The real GET_TIMER resync now arrives (the server's own state was never at + // risk - only the client's display was) and the Log Time form reflects the + // true accumulated time, same as the already-fixed "0:0 after a reload" bug. + act(() => { + mocks.setLastJsonMessage({ + started: true, + paused: true, + chiming: false, + forcedPause: true, + weekEndPause: false, + time: 30 * 60 * 1000, // 30 minutes left of the 4.5h goal + goal: longGoal, + initialGoal: longGoal, + startAt: null, + }); + }); + + fireEvent.click(screen.getByLabelText('Stop timer and log timer')); + // 4 hours logged (>= 2) shows a "long session" confirmation modal first. + fireEvent.click(screen.getByRole('button', { name: /yes, submit/i })); + + const formData = screen.getByTestId('time-entry-form-data'); + expect(formData).toHaveTextContent('4:0'); // 4.5h goal - 30min left = 4h worked + }); +});