diff --git a/apps/worker/src/api/sessions.ts b/apps/worker/src/api/sessions.ts index b70adef..218e7de 100644 --- a/apps/worker/src/api/sessions.ts +++ b/apps/worker/src/api/sessions.ts @@ -51,3 +51,25 @@ export function useDiscardSession() { }, }); } + +export function usePauseSession() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + apiFetch(`/api/sessions/${id}/pause`, { method: 'POST' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sessions'] }); + }, + }); +} + +export function useResumeSession() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + apiFetch(`/api/sessions/${id}/resume`, { method: 'POST' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sessions'] }); + }, + }); +} diff --git a/apps/worker/src/screens/Stopwatch.test.tsx b/apps/worker/src/screens/Stopwatch.test.tsx index 9f085a3..d2f6813 100644 --- a/apps/worker/src/screens/Stopwatch.test.tsx +++ b/apps/worker/src/screens/Stopwatch.test.tsx @@ -10,6 +10,8 @@ import { useStartSession, useStopSession, useDiscardSession, + usePauseSession, + useResumeSession, } from '../api/sessions'; vi.mock('../api/activities', () => ({ @@ -21,6 +23,8 @@ vi.mock('../api/sessions', () => ({ useStartSession: vi.fn(), useStopSession: vi.fn(), useDiscardSession: vi.fn(), + usePauseSession: vi.fn(), + useResumeSession: vi.fn(), })); const mockedUseActivities = vi.mocked(useActivities); @@ -28,17 +32,21 @@ const mockedUseActiveSessions = vi.mocked(useActiveSessions); const mockedUseStartSession = vi.mocked(useStartSession); const mockedUseStopSession = vi.mocked(useStopSession); const mockedUseDiscardSession = vi.mocked(useDiscardSession); +const mockedUsePauseSession = vi.mocked(usePauseSession); +const mockedUseResumeSession = vi.mocked(useResumeSession); const FREZEN: Activity = { id: 1, name: 'Frezen', insole_types: ['Kurk', 'Berk'], + sort_order: 0, created_at: '2026-01-01T00:00:00.000Z', }; const PRINTEN: Activity = { id: 2, name: 'Printen', insole_types: ['3D'], + sort_order: 1, created_at: '2026-01-01T00:00:00.000Z', }; @@ -53,6 +61,8 @@ function activeSession(overrides: Partial = {}): WorkSession { start_time: new Date().toISOString(), end_time: null, duration_seconds: null, + paused_seconds: 0, + paused_at: null, status: 'active', source: 'app', notes: null, @@ -69,6 +79,8 @@ function query(data: unknown): R { let startMutate: ReturnType; let stopMutate: ReturnType; let discardMutate: ReturnType; +let pauseMutate: ReturnType; +let resumeMutate: ReturnType; function mutation(mutate: ReturnType): R { return { mutate, isPending: false } as unknown as R; @@ -88,6 +100,8 @@ describe('Stopwatch', () => { startMutate = vi.fn(); stopMutate = vi.fn(); discardMutate = vi.fn(); + pauseMutate = vi.fn(); + resumeMutate = vi.fn(); mockedUseActivities.mockReturnValue(query>([FREZEN, PRINTEN])); mockedUseActiveSessions.mockReturnValue(query>([])); mockedUseStartSession.mockReturnValue(mutation>(startMutate)); @@ -95,6 +109,10 @@ describe('Stopwatch', () => { mockedUseDiscardSession.mockReturnValue( mutation>(discardMutate), ); + mockedUsePauseSession.mockReturnValue(mutation>(pauseMutate)); + mockedUseResumeSession.mockReturnValue( + mutation>(resumeMutate), + ); }); afterEach(() => { @@ -182,4 +200,47 @@ describe('Stopwatch', () => { expect(discardMutate).toHaveBeenCalledTimes(1); expect(discardMutate.mock.calls[0][0]).toBe(99); }); + + it('pauses via the server when the display is tapped while running', async () => { + const user = userEvent.setup(); + mockedUseActiveSessions.mockReturnValue( + query>([activeSession()]), + ); + renderStopwatch(); + + const display = await screen.findByRole('button', { name: 'Stopwatch' }); + await user.click(display); + + expect(pauseMutate).toHaveBeenCalledTimes(1); + expect(pauseMutate.mock.calls[0][0]).toBe(99); + expect(resumeMutate).not.toHaveBeenCalled(); + }); + + it('resumes via the server when the display is tapped while paused', async () => { + const user = userEvent.setup(); + mockedUseActiveSessions.mockReturnValue( + query>([ + activeSession({ paused_at: new Date().toISOString(), paused_seconds: 30 }), + ]), + ); + renderStopwatch(); + + const display = await screen.findByRole('button', { name: 'Stopwatch' }); + await user.click(display); + + expect(resumeMutate).toHaveBeenCalledTimes(1); + expect(resumeMutate.mock.calls[0][0]).toBe(99); + expect(pauseMutate).not.toHaveBeenCalled(); + }); + + it('recovers a paused session into the paused state on load', async () => { + mockedUseActiveSessions.mockReturnValue( + query>([ + activeSession({ paused_at: new Date().toISOString(), paused_seconds: 30 }), + ]), + ); + renderStopwatch(); + + expect(await screen.findByText('Gepauzeerd — tik om te hervatten')).toBeInTheDocument(); + }); }); diff --git a/apps/worker/src/screens/Stopwatch.tsx b/apps/worker/src/screens/Stopwatch.tsx index 498a141..dd1c606 100644 --- a/apps/worker/src/screens/Stopwatch.tsx +++ b/apps/worker/src/screens/Stopwatch.tsx @@ -6,6 +6,8 @@ import { useStartSession, useStopSession, useDiscardSession, + usePauseSession, + useResumeSession, } from '../api/sessions'; import { elapsedSeconds, formatTime } from '../lib/stopwatch'; @@ -18,6 +20,8 @@ export default function Stopwatch() { const startSession = useStartSession(); const stopSession = useStopSession(); const discardSession = useDiscardSession(); + const pauseSession = usePauseSession(); + const resumeSession = useResumeSession(); const activities = activitiesQuery.data ?? []; @@ -54,9 +58,11 @@ export default function Stopwatch() { if (session.insole_type) setInsoleType(session.insole_type); setPairCount(session.pair_count); setPairCountText(String(session.pair_count)); - setIsPaused(false); - setPausedMs(0); - setPauseStartedMs(null); + // Restore pause state from the server (source of truth). + const pausedAtMs = session.paused_at ? new Date(session.paused_at).getTime() : null; + setIsPaused(pausedAtMs !== null); + setPausedMs((session.paused_seconds ?? 0) * 1000); + setPauseStartedMs(pausedAtMs); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeSessionsQuery.data]); @@ -117,19 +123,22 @@ export default function Stopwatch() { } function handleTapDisplay() { - if (!isRunning) { + if (!isRunning || sessionId === null) { if (canStart) handleStart(); return; } if (isPaused) { - // Resume: fold the just-finished pause span into the accumulator. + // Resume: fold the just-finished pause span into the accumulator (snappy local clock); + // the server is the source of truth. if (pauseStartedMs !== null) setPausedMs((prev) => prev + (Date.now() - pauseStartedMs)); setPauseStartedMs(null); setIsPaused(false); + resumeSession.mutate(sessionId); } else { - // Pause. + // Pause locally for snappy feedback; the server records the authoritative pause. setPauseStartedMs(Date.now()); setIsPaused(true); + pauseSession.mutate(sessionId); } }