diff --git a/apps/worker/src/api/sessions.ts b/apps/worker/src/api/sessions.ts new file mode 100644 index 0000000..127ce91 --- /dev/null +++ b/apps/worker/src/api/sessions.ts @@ -0,0 +1,46 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { StartSessionInput, WorkSession } from '@solelog/shared'; +import { apiFetch } from '../lib/api'; + +export function useActiveSessions() { + return useQuery({ + queryKey: ['sessions', 'active'], + queryFn: () => apiFetch('/api/sessions/active'), + }); +} + +export function useStartSession() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: StartSessionInput) => + apiFetch('/api/sessions/start', { + method: 'POST', + body: JSON.stringify(input), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sessions'] }); + }, + }); +} + +export function useStopSession() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + apiFetch(`/api/sessions/${id}/stop`, { method: 'POST' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sessions'] }); + }, + }); +} + +export function useDiscardSession() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + apiFetch(`/api/sessions/${id}/discard`, { method: 'POST' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sessions'] }); + }, + }); +} diff --git a/apps/worker/src/lib/stopwatch.test.ts b/apps/worker/src/lib/stopwatch.test.ts new file mode 100644 index 0000000..c145929 --- /dev/null +++ b/apps/worker/src/lib/stopwatch.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { elapsedSeconds, formatTime } from './stopwatch'; + +describe('stopwatch logic', () => { + it('formats seconds as HH:MM:SS', () => { + expect(formatTime(0)).toBe('00:00:00'); + expect(formatTime(65)).toBe('00:01:05'); + expect(formatTime(3661)).toBe('01:01:01'); + }); + + it('computes elapsed seconds since start excluding paused time', () => { + expect(elapsedSeconds(1000, 6000, 0)).toBe(5); + expect(elapsedSeconds(1000, 6000, 2000)).toBe(3); + }); + + it('never returns a negative elapsed value', () => { + expect(elapsedSeconds(5000, 1000, 0)).toBe(0); + }); +}); diff --git a/apps/worker/src/lib/stopwatch.ts b/apps/worker/src/lib/stopwatch.ts new file mode 100644 index 0000000..0f1d094 --- /dev/null +++ b/apps/worker/src/lib/stopwatch.ts @@ -0,0 +1,16 @@ +// Pure timing helpers for the Stopwatch screen — server-authoritative elapsed. +// Elapsed is computed from the server start_time (wall-clock), not a tick counter, +// so it survives backgrounding. Pause accumulates paused time client-side. + +export function formatTime(totalSeconds: number): string { + const s = Math.max(0, Math.floor(totalSeconds)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; +} + +// elapsed seconds since startMs, excluding accumulated paused ms, evaluated at nowMs. +export function elapsedSeconds(startMs: number, nowMs: number, pausedMs: number): number { + return Math.max(0, Math.floor((nowMs - startMs - pausedMs) / 1000)); +} diff --git a/apps/worker/src/screens/Stopwatch.test.tsx b/apps/worker/src/screens/Stopwatch.test.tsx new file mode 100644 index 0000000..9f085a3 --- /dev/null +++ b/apps/worker/src/screens/Stopwatch.test.tsx @@ -0,0 +1,185 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { Activity, WorkSession } from '@solelog/shared'; +import Stopwatch from './Stopwatch'; +import { useActivities } from '../api/activities'; +import { + useActiveSessions, + useStartSession, + useStopSession, + useDiscardSession, +} from '../api/sessions'; + +vi.mock('../api/activities', () => ({ + useActivities: vi.fn(), +})); + +vi.mock('../api/sessions', () => ({ + useActiveSessions: vi.fn(), + useStartSession: vi.fn(), + useStopSession: vi.fn(), + useDiscardSession: vi.fn(), +})); + +const mockedUseActivities = vi.mocked(useActivities); +const mockedUseActiveSessions = vi.mocked(useActiveSessions); +const mockedUseStartSession = vi.mocked(useStartSession); +const mockedUseStopSession = vi.mocked(useStopSession); +const mockedUseDiscardSession = vi.mocked(useDiscardSession); + +const FREZEN: Activity = { + id: 1, + name: 'Frezen', + insole_types: ['Kurk', 'Berk'], + created_at: '2026-01-01T00:00:00.000Z', +}; +const PRINTEN: Activity = { + id: 2, + name: 'Printen', + insole_types: ['3D'], + created_at: '2026-01-01T00:00:00.000Z', +}; + +function activeSession(overrides: Partial = {}): WorkSession { + return { + id: 99, + user_id: 'u1', + activity_id: 1, + activity_name: 'Frezen', + insole_type: 'Kurk', + pair_count: 2, + start_time: new Date().toISOString(), + end_time: null, + duration_seconds: null, + status: 'active', + source: 'app', + notes: null, + created_at: new Date().toISOString(), + ...overrides, + }; +} + +// Build a fake RQ query/mutation result object the screen can consume. +function query(data: unknown): R { + return { data, isLoading: false, isError: false } as unknown as R; +} + +let startMutate: ReturnType; +let stopMutate: ReturnType; +let discardMutate: ReturnType; + +function mutation(mutate: ReturnType): R { + return { mutate, isPending: false } as unknown as R; +} + +function renderStopwatch() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe('Stopwatch', () => { + beforeEach(() => { + startMutate = vi.fn(); + stopMutate = vi.fn(); + discardMutate = vi.fn(); + mockedUseActivities.mockReturnValue(query>([FREZEN, PRINTEN])); + mockedUseActiveSessions.mockReturnValue(query>([])); + mockedUseStartSession.mockReturnValue(mutation>(startMutate)); + mockedUseStopSession.mockReturnValue(mutation>(stopMutate)); + mockedUseDiscardSession.mockReturnValue( + mutation>(discardMutate), + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('renders the three sections and Start button in Dutch', () => { + renderStopwatch(); + expect(screen.getByText('Type zool')).toBeInTheDocument(); + expect(screen.getByText('Type handeling')).toBeInTheDocument(); + expect(screen.getByText('Aantal zolen')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Kurk' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Berk' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '3D' })).toBeInTheDocument(); + // Default count is 2 (free-typed text mirror, so the value is the string '2'). + expect(screen.getByLabelText('Aantal zolen')).toHaveValue('2'); + }); + + it('disables Start until a handling is chosen', async () => { + const user = userEvent.setup(); + renderStopwatch(); + + const startBtn = screen.getByRole('button', { name: 'Start Stopwatch' }); + expect(startBtn).toBeDisabled(); + + // Kurk is the default zooltype; pick the Frezen handling. + await user.selectOptions(screen.getByLabelText('Type handeling'), 'Frezen'); + await waitFor(() => expect(startBtn).toBeEnabled()); + }); + + it('filters handlings by the chosen zooltype', async () => { + const user = userEvent.setup(); + renderStopwatch(); + + const select = screen.getByLabelText('Type handeling'); + // Kurk selected by default -> Frezen present, Printen absent. + expect(screen.getByRole('option', { name: 'Frezen' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'Printen' })).not.toBeInTheDocument(); + + // Switch to 3D -> Printen present, Frezen absent. + await user.click(screen.getByRole('button', { name: '3D' })); + expect(screen.getByRole('option', { name: 'Printen' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'Frezen' })).not.toBeInTheDocument(); + expect(select).toBeInTheDocument(); + }); + + it('calls start with the selected values', async () => { + const user = userEvent.setup(); + renderStopwatch(); + + // Pick Berk; Frezen applies to Berk. + await user.click(screen.getByRole('button', { name: 'Berk' })); + await user.selectOptions(screen.getByLabelText('Type handeling'), 'Frezen'); + // Bump count to 3. + await user.click(screen.getByRole('button', { name: 'Meer zolen' })); + + await user.click(screen.getByRole('button', { name: 'Start Stopwatch' })); + + expect(startMutate).toHaveBeenCalledTimes(1); + expect(startMutate.mock.calls[0][0]).toMatchObject({ + activity_id: 1, + insole_type: 'Berk', + pair_count: 3, + }); + }); + + it('arms discard on first Annuleren tap and discards on the second', async () => { + const user = userEvent.setup(); + // Recover an active session so the screen renders the running UI. + mockedUseActiveSessions.mockReturnValue( + query>([activeSession()]), + ); + renderStopwatch(); + + const cancel = await screen.findByRole('button', { name: 'Annuleren' }); + await user.click(cancel); + + expect( + screen.getByRole('button', { name: 'Nogmaals tikken ter bevestiging' }), + ).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Nogmaals tikken ter bevestiging' })); + expect(discardMutate).toHaveBeenCalledTimes(1); + expect(discardMutate.mock.calls[0][0]).toBe(99); + }); +}); diff --git a/apps/worker/src/screens/Stopwatch.tsx b/apps/worker/src/screens/Stopwatch.tsx index 8e1d718..498a141 100644 --- a/apps/worker/src/screens/Stopwatch.tsx +++ b/apps/worker/src/screens/Stopwatch.tsx @@ -1,7 +1,366 @@ +import { useEffect, useRef, useState } from 'react'; +import type { InsoleType, WorkSession } from '@solelog/shared'; +import { useActivities } from '../api/activities'; +import { + useActiveSessions, + useStartSession, + useStopSession, + useDiscardSession, +} from '../api/sessions'; +import { elapsedSeconds, formatTime } from '../lib/stopwatch'; + +const ALL_TYPES: InsoleType[] = ['Kurk', 'Berk', '3D']; +const DISCARD_WINDOW_MS = 3000; + export default function Stopwatch() { + const activitiesQuery = useActivities(); + const activeSessionsQuery = useActiveSessions(); + const startSession = useStartSession(); + const stopSession = useStopSession(); + const discardSession = useDiscardSession(); + + const activities = activitiesQuery.data ?? []; + + // Selection state. + const [insoleType, setInsoleType] = useState('Kurk'); + const [activeActivityId, setActiveActivityId] = useState(null); + const [pairCount, setPairCount] = useState(2); + const [pairCountText, setPairCountText] = useState('2'); + + // Running state — server-authoritative. + const [sessionId, setSessionId] = useState(null); + const [startMs, setStartMs] = useState(null); + const [isPaused, setIsPaused] = useState(false); + // Accumulated paused ms + the moment the current pause began (client-only). + const [pausedMs, setPausedMs] = useState(0); + const [pauseStartedMs, setPauseStartedMs] = useState(null); + const [nowMs, setNowMs] = useState(() => Date.now()); + + // Double-press discard arming. + const [discardPending, setDiscardPending] = useState(false); + const discardTimerRef = useRef | null>(null); + + const isRunning = sessionId !== null; + + // Recover an active session on load (phone-died / resume-elsewhere path). + useEffect(() => { + if (isRunning) return; + const active = activeSessionsQuery.data; + if (!active || active.length === 0) return; + const session: WorkSession = active[0]; + setSessionId(session.id); + setStartMs(new Date(session.start_time).getTime()); + setActiveActivityId(session.activity_id); + if (session.insole_type) setInsoleType(session.insole_type); + setPairCount(session.pair_count); + setPairCountText(String(session.pair_count)); + setIsPaused(false); + setPausedMs(0); + setPauseStartedMs(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeSessionsQuery.data]); + + // Live 1s tick while running and not paused. + useEffect(() => { + if (!isRunning || isPaused) return; + setNowMs(Date.now()); + const id = setInterval(() => setNowMs(Date.now()), 1000); + return () => clearInterval(id); + }, [isRunning, isPaused]); + + useEffect(() => { + return () => { + if (discardTimerRef.current) clearTimeout(discardTimerRef.current); + }; + }, []); + + const filteredActivities = activities.filter((a) => a.insole_types.includes(insoleType)); + const canStart = activeActivityId !== null; + + // While paused, freeze the clock at the moment the pause began. + const effectiveNow = isPaused && pauseStartedMs !== null ? pauseStartedMs : nowMs; + const elapsed = startMs !== null ? elapsedSeconds(startMs, effectiveNow, pausedMs) : 0; + + function handleSelectZool(type: InsoleType) { + if (isRunning) return; + setInsoleType(type); + setActiveActivityId(null); // changing the zool clears the chosen handling + } + + function handlePairCountText(text: string) { + setPairCountText(text); + const parsed = parseInt(text, 10); + if (!Number.isNaN(parsed) && parsed > 0) setPairCount(parsed); + } + + function adjustPairCount(delta: number) { + const next = Math.max(1, pairCount + delta); + setPairCount(next); + setPairCountText(String(next)); + } + + function handleStart() { + if (!canStart || activeActivityId === null) return; + startSession.mutate( + { activity_id: activeActivityId, insole_type: insoleType, pair_count: pairCount }, + { + onSuccess: (session) => { + setSessionId(session.id); + setStartMs(new Date(session.start_time).getTime()); + setIsPaused(false); + setPausedMs(0); + setPauseStartedMs(null); + setNowMs(Date.now()); + }, + }, + ); + } + + function handleTapDisplay() { + if (!isRunning) { + if (canStart) handleStart(); + return; + } + if (isPaused) { + // Resume: fold the just-finished pause span into the accumulator. + if (pauseStartedMs !== null) setPausedMs((prev) => prev + (Date.now() - pauseStartedMs)); + setPauseStartedMs(null); + setIsPaused(false); + } else { + // Pause. + setPauseStartedMs(Date.now()); + setIsPaused(true); + } + } + + function resetTimer() { + setSessionId(null); + setStartMs(null); + setIsPaused(false); + setPausedMs(0); + setPauseStartedMs(null); + setDiscardPending(false); + if (discardTimerRef.current) { + clearTimeout(discardTimerRef.current); + discardTimerRef.current = null; + } + } + + function handleStop() { + if (sessionId === null) return; + const id = sessionId; + stopSession.mutate(id, { onSuccess: () => resetTimer() }); + // Selections (zool/handling/count) persist for the next session. + } + + function handleDiscard() { + if (sessionId === null) return; + if (!discardPending) { + setDiscardPending(true); + discardTimerRef.current = setTimeout(() => { + setDiscardPending(false); + discardTimerRef.current = null; + }, DISCARD_WINDOW_MS); + return; + } + if (discardTimerRef.current) { + clearTimeout(discardTimerRef.current); + discardTimerRef.current = null; + } + const id = sessionId; + discardSession.mutate(id, { onSuccess: () => resetTimer() }); + } + + const statusPill = !isRunning + ? canStart + ? 'Tik om te starten' + : null + : isPaused + ? 'Gepauzeerd — tik om te hervatten' + : 'Tik om te pauzeren'; + return (

Stopwatch

+ + {/* Section 1 — Type zool */} +

Type zool

+
+ {ALL_TYPES.map((type) => { + const selected = insoleType === type; + return ( + + ); + })} +
+ + {/* Section 2 — Type handeling */} +

Type handeling

+ {filteredActivities.length === 0 ? ( +

+ Geen handelingen beschikbaar voor {insoleType} zolen. Voeg ze toe via Instellingen. +

+ ) : ( + + )} + + {/* Section 3 — Aantal zolen */} +

Aantal zolen

+
+ + handlePairCountText(e.target.value)} + style={{ flex: 1, textAlign: 'center', border: 'none', borderRadius: 0 }} + /> + +
+ + {/* Section 4 — Stopwatch display */} + + + {/* Section 5 — Action buttons */} + {!isRunning ? ( + + ) : ( +
+ + +
+ )}
); }