From 7811846a9df7d52db5f57f23980bdabee3482499 Mon Sep 17 00:00:00 2001 From: mijinummi Date: Sat, 30 May 2026 11:16:59 +0100 Subject: [PATCH 1/3] 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/3] 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 From 5defbc8b3b7874d4f704dcc5df39397c3a418423 Mon Sep 17 00:00:00 2001 From: mijinummi Date: Sat, 30 May 2026 11:42:19 +0100 Subject: [PATCH 3/3] feat(copy-button): add reusable clipboard component --- frontend/cntr/CopyButton/CopyButton.test.tsx | 116 +++++++++++++++++++ frontend/cntr/CopyButton/CopyButton.tsx | 95 +++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 frontend/cntr/CopyButton/CopyButton.test.tsx create mode 100644 frontend/cntr/CopyButton/CopyButton.tsx diff --git a/frontend/cntr/CopyButton/CopyButton.test.tsx b/frontend/cntr/CopyButton/CopyButton.test.tsx new file mode 100644 index 0000000..ce3addb --- /dev/null +++ b/frontend/cntr/CopyButton/CopyButton.test.tsx @@ -0,0 +1,116 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import CopyButton from './CopyButton'; + +describe('CopyButton', () => { + beforeEach(() => { + jest.useFakeTimers(); + + Object.assign(navigator, { + clipboard: { + writeText: jest.fn().mockResolvedValue(undefined), + }, + }); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('renders default Copy label', () => { + render(); + + expect( + screen.getByRole('button', { name: /copy/i }), + ).toBeInTheDocument(); + }); + + it('renders custom label', () => { + render( + , + ); + + expect( + screen.getByRole('button', { + name: /copy api key/i, + }), + ).toBeInTheDocument(); + }); + + it('copies text using clipboard api', async () => { + render(); + + fireEvent.click( + screen.getByRole('button'), + ); + + await waitFor(() => { + expect( + navigator.clipboard.writeText, + ).toHaveBeenCalledWith( + 'workspace-123', + ); + }); + }); + + it('shows copied confirmation', async () => { + render(); + + fireEvent.click( + screen.getByRole('button'), + ); + + expect( + await screen.findByText(/copied!/i), + ).toBeInTheDocument(); + }); + + it('returns to default label after 2000ms', async () => { + render(); + + fireEvent.click( + screen.getByRole('button'), + ); + + expect( + await screen.findByText(/copied!/i), + ).toBeInTheDocument(); + + jest.advanceTimersByTime(2000); + + expect( + screen.getByRole('button', { + name: /copy/i, + }), + ).toBeInTheDocument(); + }); + + it('falls back to execCommand when clipboard api is unavailable', () => { + Object.defineProperty( + navigator, + 'clipboard', + { + value: undefined, + configurable: true, + }, + ); + + const execSpy = jest + .spyOn(document, 'execCommand') + .mockImplementation(() => true); + + render(); + + fireEvent.click( + screen.getByRole('button'), + ); + + expect(execSpy).toHaveBeenCalledWith( + 'copy', + ); + }); +}); \ No newline at end of file diff --git a/frontend/cntr/CopyButton/CopyButton.tsx b/frontend/cntr/CopyButton/CopyButton.tsx new file mode 100644 index 0000000..e409023 --- /dev/null +++ b/frontend/cntr/CopyButton/CopyButton.tsx @@ -0,0 +1,95 @@ +import React, { useEffect, useRef, useState } from 'react'; + +interface CopyButtonProps { + text: string; + label?: string; +} + +const COPY_TIMEOUT_MS = 2000; + +export default function CopyButton({ + text, + label = 'Copy', +}: CopyButtonProps) { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef(null); + + const fallbackCopy = (value: string) => { + const textarea = document.createElement('textarea'); + + textarea.value = value; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'absolute'; + textarea.style.left = '-9999px'; + + document.body.appendChild(textarea); + + textarea.select(); + + try { + document.execCommand('copy'); + } finally { + document.body.removeChild(textarea); + } + }; + + const copyToClipboard = async () => { + try { + if ( + typeof navigator !== 'undefined' && + navigator.clipboard && + navigator.clipboard.writeText + ) { + await navigator.clipboard.writeText(text); + } else { + fallbackCopy(text); + } + + setCopied(true); + + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + } + + timeoutRef.current = window.setTimeout(() => { + setCopied(false); + }, COPY_TIMEOUT_MS); + } catch { + fallbackCopy(text); + + setCopied(true); + + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + } + + timeoutRef.current = window.setTimeout(() => { + setCopied(false); + }, COPY_TIMEOUT_MS); + } + }; + + useEffect(() => { + return () => { + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + } + }; + }, []); + + return ( + + ); +} \ No newline at end of file