From 7811846a9df7d52db5f57f23980bdabee3482499 Mon Sep 17 00:00:00 2001 From: mijinummi Date: Sat, 30 May 2026 11:16:59 +0100 Subject: [PATCH 1/2] 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 From 6c68717f4417e2073e55ab20ab7131ecc71d05ff Mon Sep 17 00:00:00 2001 From: mijinummi Date: Sat, 30 May 2026 11:24:33 +0100 Subject: [PATCH 2/2] feat(frontend): add multi-step progress indicator component --- .../cntr/StepProgress/StepProgress.test.tsx | 196 ++++++++++++++++++ frontend/cntr/StepProgress/StepProgress.tsx | 148 +++++++++++++ frontend/cntr/StepProgress/index.ts | 5 + 3 files changed, 349 insertions(+) create mode 100644 frontend/cntr/StepProgress/StepProgress.test.tsx create mode 100644 frontend/cntr/StepProgress/StepProgress.tsx create mode 100644 frontend/cntr/StepProgress/index.ts diff --git a/frontend/cntr/StepProgress/StepProgress.test.tsx b/frontend/cntr/StepProgress/StepProgress.test.tsx new file mode 100644 index 0000000..526c78a --- /dev/null +++ b/frontend/cntr/StepProgress/StepProgress.test.tsx @@ -0,0 +1,196 @@ +import React from "react"; + +import { + render, + screen, +} from "@testing-library/react"; + +import { + StepProgress, +} from "./StepProgress"; + +describe( + "StepProgress", + () => { + const steps = [ + { + label: + "Details", + }, + { + label: + "Payment", + }, + { + label: + "Review", + }, + { + label: + "Complete", + }, + ]; + + it( + "renders all step labels", + () => { + render( + , + ); + + expect( + screen.getByText( + "Details", + ), + ).toBeInTheDocument(); + + expect( + screen.getByText( + "Payment", + ), + ).toBeInTheDocument(); + + expect( + screen.getByText( + "Review", + ), + ).toBeInTheDocument(); + + expect( + screen.getByText( + "Complete", + ), + ).toBeInTheDocument(); + }, + ); + + it( + "renders completed state", + () => { + render( + , + ); + + expect( + screen.getByLabelText( + "Details - Completed", + ), + ).toBeInTheDocument(); + }, + ); + + it( + "renders current state", + () => { + render( + , + ); + + expect( + screen.getByLabelText( + "Payment - Current", + ), + ).toBeInTheDocument(); + }, + ); + + it( + "renders upcoming state", + () => { + render( + , + ); + + expect( + screen.getByLabelText( + "Review - Upcoming", + ), + ).toBeInTheDocument(); + + expect( + screen.getByLabelText( + "Complete - Upcoming", + ), + ).toBeInTheDocument(); + }, + ); + + it( + "renders connectors", + () => { + render( + , + ); + + expect( + screen.getByTestId( + "connector-0", + ), + ).toBeInTheDocument(); + + expect( + screen.getByTestId( + "connector-1", + ), + ).toBeInTheDocument(); + + expect( + screen.getByTestId( + "connector-2", + ), + ).toBeInTheDocument(); + }, + ); + + it( + "renders proper aria labels", + () => { + render( + , + ); + + expect( + screen.getByLabelText( + "Details - Completed", + ), + ).toBeInTheDocument(); + + expect( + screen.getByLabelText( + "Payment - Completed", + ), + ).toBeInTheDocument(); + + expect( + screen.getByLabelText( + "Review - Current", + ), + ).toBeInTheDocument(); + + expect( + screen.getByLabelText( + "Complete - Upcoming", + ), + ).toBeInTheDocument(); + }, + ); + }, +); \ No newline at end of file diff --git a/frontend/cntr/StepProgress/StepProgress.tsx b/frontend/cntr/StepProgress/StepProgress.tsx new file mode 100644 index 0000000..fac2c89 --- /dev/null +++ b/frontend/cntr/StepProgress/StepProgress.tsx @@ -0,0 +1,148 @@ +import React from "react"; + +export interface Step { + label: string; +} + +export interface StepProgressProps { + steps: Step[]; + currentStep: number; +} + +type StepState = + | "completed" + | "current" + | "upcoming"; + +export const StepProgress = ({ + steps, + currentStep, +}: StepProgressProps) => { + const getState = ( + index: number, + ): StepState => { + if (index < currentStep) { + return "completed"; + } + + if (index === currentStep) { + return "current"; + } + + return "upcoming"; + }; + + return ( +
+
+ {steps.map( + (step, index) => { + const state = + getState(index); + + const isCompleted = + state === + "completed"; + + const isCurrent = + state === + "current"; + + const isUpcoming = + state === + "upcoming"; + + return ( + +
+
+ {isCompleted + ? "✓" + : index + 1} +
+ + + {step.label} + +
+ + {index < + steps.length - + 1 && ( +
+
+
+ )} + + ); + }, + )} +
+
+ ); +}; + +export default StepProgress; \ No newline at end of file diff --git a/frontend/cntr/StepProgress/index.ts b/frontend/cntr/StepProgress/index.ts new file mode 100644 index 0000000..c0563e4 --- /dev/null +++ b/frontend/cntr/StepProgress/index.ts @@ -0,0 +1,5 @@ +export * from "./StepProgress"; + +export { + default, +} from "./StepProgress"; \ No newline at end of file