feat(worker): poll + reconcile stopwatch to server (admin stop/cancel)

This commit is contained in:
Bas van Rossem
2026-06-17 22:50:31 +02:00
parent f1ec249ee7
commit 7d3daaa760
3 changed files with 95 additions and 5 deletions

View File

@@ -13,6 +13,8 @@ export function useActiveSessions() {
return useQuery({ return useQuery({
queryKey: ['sessions', 'active'], queryKey: ['sessions', 'active'],
queryFn: () => apiFetch<WorkSession[]>('/api/sessions/active'), queryFn: () => apiFetch<WorkSession[]>('/api/sessions/active'),
// Poll so the stopwatch converges to admin changes (stop/cancel) within ~15s.
refetchInterval: 15000,
}); });
} }

View File

@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { Activity, WorkSession } from '@solelog/shared'; import type { Activity, WorkSession } from '@solelog/shared';
import Stopwatch from './Stopwatch'; import Stopwatch from './Stopwatch';
import { ApiError } from '../lib/api';
import { useActivities } from '../api/activities'; import { useActivities } from '../api/activities';
import { import {
useActiveSessions, useActiveSessions,
@@ -247,4 +248,52 @@ describe('Stopwatch', () => {
expect(await screen.findByText('Gepauzeerd — tik om te hervatten')).toBeInTheDocument(); 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<ReturnType<typeof useActiveSessions>>([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<ReturnType<typeof useActiveSessions>>([]));
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
rerender(
<QueryClientProvider client={queryClient}>
<Stopwatch />
</QueryClientProvider>,
);
// 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<ReturnType<typeof useActiveSessions>>([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();
});
}); });

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { InsoleType, WorkSession } from '@solelog/shared'; import type { InsoleType, WorkSession } from '@solelog/shared';
import { ApiError } from '../lib/api';
import { useActivities } from '../api/activities'; import { useActivities } from '../api/activities';
import { import {
useActiveSessions, useActiveSessions,
@@ -44,13 +45,26 @@ export default function Stopwatch() {
const [discardPending, setDiscardPending] = useState(false); const [discardPending, setDiscardPending] = useState(false);
const discardTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const discardTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Transient notice when the admin stopped/cancelled this session out from under us.
const [stoppedByAdmin, setStoppedByAdmin] = useState(false);
const isRunning = sessionId !== null; 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(() => { useEffect(() => {
if (isRunning) return;
const active = activeSessionsQuery.data; 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]; const session: WorkSession = active[0];
setSessionId(session.id); setSessionId(session.id);
setStartMs(new Date(session.start_time).getTime()); setStartMs(new Date(session.start_time).getTime());
@@ -107,6 +121,7 @@ export default function Stopwatch() {
function handleStart() { function handleStart() {
if (!canStart || activeActivityId === null) return; if (!canStart || activeActivityId === null) return;
setStoppedByAdmin(false); // clear any prior admin-stopped notice
startSession.mutate( startSession.mutate(
{ activity_id: activeActivityId, insole_type: insoleType, pair_count: pairCount }, { 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() { function handleStop() {
if (sessionId === null) return; if (sessionId === null) return;
const id = sessionId; const id = sessionId;
stopSession.mutate(id, { onSuccess: () => resetTimer() }); stopSession.mutate(id, { onSuccess: () => resetTimer(), onError: resetIfConflict });
// Selections (zool/handling/count) persist for the next session. // Selections (zool/handling/count) persist for the next session.
} }
@@ -177,7 +198,7 @@ export default function Stopwatch() {
discardTimerRef.current = null; discardTimerRef.current = null;
} }
const id = sessionId; const id = sessionId;
discardSession.mutate(id, { onSuccess: () => resetTimer() }); discardSession.mutate(id, { onSuccess: () => resetTimer(), onError: resetIfConflict });
} }
const statusPill = !isRunning const statusPill = !isRunning
@@ -192,6 +213,24 @@ export default function Stopwatch() {
<div className="screen"> <div className="screen">
<h1 className="screen-title">Stopwatch</h1> <h1 className="screen-title">Stopwatch</h1>
{stoppedByAdmin && !isRunning && (
<div
role="status"
style={{
marginBottom: 20,
padding: '12px 16px',
borderRadius: 12,
border: '1px solid #FDE68A',
background: '#FEF3C7',
color: '#92400E',
fontSize: 14,
fontWeight: 600,
}}
>
Deze sessie is door de beheerder gestopt.
</div>
)}
{/* Section 1 — Type zool */} {/* Section 1 — Type zool */}
<h2 className="section-label">Type zool</h2> <h2 className="section-label">Type zool</h2>
<div className="segmented" style={{ display: 'flex', gap: 8, marginBottom: 20 }}> <div className="segmented" style={{ display: 'flex', gap: 8, marginBottom: 20 }}>