From 7d3daaa760dc3dba56aea889e64ad2af0c70e94e Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 17 Jun 2026 22:50:31 +0200 Subject: [PATCH] feat(worker): poll + reconcile stopwatch to server (admin stop/cancel) --- apps/worker/src/api/sessions.ts | 2 + apps/worker/src/screens/Stopwatch.test.tsx | 49 ++++++++++++++++++++++ apps/worker/src/screens/Stopwatch.tsx | 49 +++++++++++++++++++--- 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/apps/worker/src/api/sessions.ts b/apps/worker/src/api/sessions.ts index 218e7de..2886204 100644 --- a/apps/worker/src/api/sessions.ts +++ b/apps/worker/src/api/sessions.ts @@ -13,6 +13,8 @@ export function useActiveSessions() { return useQuery({ queryKey: ['sessions', 'active'], queryFn: () => apiFetch('/api/sessions/active'), + // Poll so the stopwatch converges to admin changes (stop/cancel) within ~15s. + refetchInterval: 15000, }); } diff --git a/apps/worker/src/screens/Stopwatch.test.tsx b/apps/worker/src/screens/Stopwatch.test.tsx index a3a938e..d3e04cd 100644 --- a/apps/worker/src/screens/Stopwatch.test.tsx +++ b/apps/worker/src/screens/Stopwatch.test.tsx @@ -4,6 +4,7 @@ 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 { ApiError } from '../lib/api'; import { useActivities } from '../api/activities'; import { useActiveSessions, @@ -247,4 +248,52 @@ describe('Stopwatch', () => { expect(await screen.findByText('Gepauzeerd — tik om te hervatten')).toBeInTheDocument(); }); + + it('resets and shows a notice when the running session is stopped by the admin', async () => { + // Start with the session present so the screen adopts the running state. + mockedUseActiveSessions.mockReturnValue( + query>([activeSession()]), + ); + const { rerender } = renderStopwatch(); + + // Confirm we are running (the Stop & Opslaan button is shown). + await screen.findByRole('button', { name: 'Stop & Opslaan' }); + + // The admin stops the session: the active list now no longer contains it. + mockedUseActiveSessions.mockReturnValue(query>([])); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + rerender( + + + , + ); + + // The stopwatch resets to idle and shows the admin-stopped notice. + expect( + await screen.findByText('Deze sessie is door de beheerder gestopt.'), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Stop & Opslaan' })).not.toBeInTheDocument(); + }); + + it('resets locally when stop fails with a 409 (already closed)', async () => { + const user = userEvent.setup(); + stopMutate.mockImplementation((_id: number, opts?: { onError?: (e: unknown) => void }) => { + opts?.onError?.(new ApiError(409, 'Conflict')); + }); + mockedUseActiveSessions.mockReturnValue( + query>([activeSession()]), + ); + renderStopwatch(); + + const stopBtn = await screen.findByRole('button', { name: 'Stop & Opslaan' }); + await user.click(stopBtn); + + expect(stopMutate).toHaveBeenCalledTimes(1); + // The timer resets to idle despite the 409 (no stuck running state). + await waitFor(() => + expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument(), + ); + expect(screen.queryByRole('button', { name: 'Stop & Opslaan' })).not.toBeInTheDocument(); + }); }); diff --git a/apps/worker/src/screens/Stopwatch.tsx b/apps/worker/src/screens/Stopwatch.tsx index dd1c606..98fcd66 100644 --- a/apps/worker/src/screens/Stopwatch.tsx +++ b/apps/worker/src/screens/Stopwatch.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import type { InsoleType, WorkSession } from '@solelog/shared'; +import { ApiError } from '../lib/api'; import { useActivities } from '../api/activities'; import { useActiveSessions, @@ -44,13 +45,26 @@ export default function Stopwatch() { const [discardPending, setDiscardPending] = useState(false); const discardTimerRef = useRef | null>(null); + // Transient notice when the admin stopped/cancelled this session out from under us. + const [stoppedByAdmin, setStoppedByAdmin] = useState(false); + const isRunning = sessionId !== null; - // Recover an active session on load (phone-died / resume-elsewhere path). + // Reconcile against server truth on every active-sessions poll: + // - running locally but our session is gone from the active list → admin stopped/cancelled it. + // - idle but the server has an active session → adopt it (phone-died / resume-elsewhere path). useEffect(() => { - if (isRunning) return; const active = activeSessionsQuery.data; - if (!active || active.length === 0) return; + if (!active) return; + if (isRunning) { + const stillActive = active.some((s) => s.id === sessionId); + if (!stillActive) { + resetTimer(); + setStoppedByAdmin(true); + } + return; + } + if (active.length === 0) return; const session: WorkSession = active[0]; setSessionId(session.id); setStartMs(new Date(session.start_time).getTime()); @@ -107,6 +121,7 @@ export default function Stopwatch() { function handleStart() { if (!canStart || activeActivityId === null) return; + setStoppedByAdmin(false); // clear any prior admin-stopped notice startSession.mutate( { activity_id: activeActivityId, insole_type: insoleType, pair_count: pairCount }, { @@ -155,10 +170,16 @@ export default function Stopwatch() { } } + // A 409 means the session is already closed server-side (e.g. admin stopped it) — + // reset locally instead of leaving the timer stuck. + function resetIfConflict(error: unknown) { + if (error instanceof ApiError && error.status === 409) resetTimer(); + } + function handleStop() { if (sessionId === null) return; const id = sessionId; - stopSession.mutate(id, { onSuccess: () => resetTimer() }); + stopSession.mutate(id, { onSuccess: () => resetTimer(), onError: resetIfConflict }); // Selections (zool/handling/count) persist for the next session. } @@ -177,7 +198,7 @@ export default function Stopwatch() { discardTimerRef.current = null; } const id = sessionId; - discardSession.mutate(id, { onSuccess: () => resetTimer() }); + discardSession.mutate(id, { onSuccess: () => resetTimer(), onError: resetIfConflict }); } const statusPill = !isRunning @@ -192,6 +213,24 @@ export default function Stopwatch() {

Stopwatch

+ {stoppedByAdmin && !isRunning && ( +
+ Deze sessie is door de beheerder gestopt. +
+ )} + {/* Section 1 — Type zool */}

Type zool