From 7811846a9df7d52db5f57f23980bdabee3482499 Mon Sep 17 00:00:00 2001 From: mijinummi Date: Sat, 30 May 2026 11:16:59 +0100 Subject: [PATCH] feat(frontend): add countdown timer for active booking sessions --- .../CountdownTimer/CountdownTimer.test.tsx | 165 ++++++++++++++++++ .../cntr/CountdownTimer/CountdownTimer.tsx | 151 ++++++++++++++++ frontend/cntr/CountdownTimer/index.ts | 4 + 3 files changed, 320 insertions(+) create mode 100644 frontend/cntr/CountdownTimer/CountdownTimer.test.tsx create mode 100644 frontend/cntr/CountdownTimer/CountdownTimer.tsx create mode 100644 frontend/cntr/CountdownTimer/index.ts diff --git a/frontend/cntr/CountdownTimer/CountdownTimer.test.tsx b/frontend/cntr/CountdownTimer/CountdownTimer.test.tsx new file mode 100644 index 0000000..dbc06b6 --- /dev/null +++ b/frontend/cntr/CountdownTimer/CountdownTimer.test.tsx @@ -0,0 +1,165 @@ +import React from "react"; + +import { + render, + screen, + act, +} from "@testing-library/react"; + +import { + CountdownTimer, +} from "./CountdownTimer"; + +describe( + "CountdownTimer", + () => { + beforeEach(() => { + jest.useFakeTimers(); + + jest.setSystemTime( + new Date( + "2026-01-01T12:00:00Z", + ), + ); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it( + "renders HH:MM:SS format", + () => { + render( + , + ); + + expect( + screen.getByText( + "01:01:05", + ), + ).toBeInTheDocument(); + }, + ); + + it( + "turns red when 5 minutes remain", + () => { + render( + , + ); + + expect( + screen.getByTestId( + "countdown-timer", + ), + ).toHaveClass( + "text-red-600", + ); + }, + ); + + it( + "calls onExpire exactly once", + () => { + const onExpire = + jest.fn(); + + render( + , + ); + + act(() => { + jest.advanceTimersByTime( + 5000, + ); + }); + + expect( + onExpire, + ).toHaveBeenCalledTimes( + 1, + ); + }, + ); + + it( + "updates every second", + () => { + render( + , + ); + + act(() => { + jest.advanceTimersByTime( + 1000, + ); + }); + + expect( + screen.getByText( + "00:00:09", + ), + ).toBeInTheDocument(); + }, + ); + + it( + "clears interval on unmount", + () => { + const clearSpy = + jest.spyOn( + window, + "clearInterval", + ); + + const { + unmount, + } = render( + , + ); + + unmount(); + + expect( + clearSpy, + ).toHaveBeenCalled(); + + clearSpy.mockRestore(); + }, + ); + }, +); \ No newline at end of file diff --git a/frontend/cntr/CountdownTimer/CountdownTimer.tsx b/frontend/cntr/CountdownTimer/CountdownTimer.tsx new file mode 100644 index 0000000..3dc4e09 --- /dev/null +++ b/frontend/cntr/CountdownTimer/CountdownTimer.tsx @@ -0,0 +1,151 @@ +import React, { + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +export interface CountdownTimerProps { + endsAt: Date | string; + onExpire?: () => void; +} + +const WARNING_THRESHOLD_SECONDS = 300; + +function getRemainingSeconds( + endsAt: Date | string, +): number { + const endTime = + new Date(endsAt).getTime(); + + const now = Date.now(); + + return Math.max( + 0, + Math.floor( + (endTime - now) / 1000, + ), + ); +} + +function formatTime( + totalSeconds: number, +): string { + const hours = Math.floor( + totalSeconds / 3600, + ); + + const minutes = Math.floor( + (totalSeconds % 3600) / 60, + ); + + const seconds = + totalSeconds % 60; + + return [ + hours, + minutes, + seconds, + ] + .map((value) => + String(value).padStart( + 2, + "0", + ), + ) + .join(":"); +} + +export const CountdownTimer = + ({ + endsAt, + onExpire, + }: CountdownTimerProps) => { + const [ + remainingSeconds, + setRemainingSeconds, + ] = useState(() => + getRemainingSeconds( + endsAt, + ), + ); + + const hasExpiredRef = + useRef(false); + + useEffect(() => { + setRemainingSeconds( + getRemainingSeconds( + endsAt, + ), + ); + + hasExpiredRef.current = + false; + }, [endsAt]); + + useEffect(() => { + const tick = () => { + const remaining = + getRemainingSeconds( + endsAt, + ); + + setRemainingSeconds( + remaining, + ); + + if ( + remaining === 0 && + !hasExpiredRef.current + ) { + hasExpiredRef.current = + true; + + onExpire?.(); + } + }; + + tick(); + + const intervalId = + window.setInterval( + tick, + 1000, + ); + + return () => { + window.clearInterval( + intervalId, + ); + }; + }, [endsAt, onExpire]); + + const formattedTime = + useMemo( + () => + formatTime( + remainingSeconds, + ), + [remainingSeconds], + ); + + const isWarning = + remainingSeconds <= + WARNING_THRESHOLD_SECONDS; + + return ( + + {formattedTime} + + ); + }; + +export default CountdownTimer; \ No newline at end of file diff --git a/frontend/cntr/CountdownTimer/index.ts b/frontend/cntr/CountdownTimer/index.ts new file mode 100644 index 0000000..b0c8bc5 --- /dev/null +++ b/frontend/cntr/CountdownTimer/index.ts @@ -0,0 +1,4 @@ +export * from "./CountdownTimer"; +export { + default, +} from "./CountdownTimer"; \ No newline at end of file