feat(worker): poll + reconcile stopwatch to server (admin stop/cancel)
This commit is contained in:
@@ -13,6 +13,8 @@ export function useActiveSessions() {
|
||||
return useQuery({
|
||||
queryKey: ['sessions', 'active'],
|
||||
queryFn: () => apiFetch<WorkSession[]>('/api/sessions/active'),
|
||||
// Poll so the stopwatch converges to admin changes (stop/cancel) within ~15s.
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
// 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() {
|
||||
<div className="screen">
|
||||
<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 */}
|
||||
<h2 className="section-label">Type zool</h2>
|
||||
<div className="segmented" style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
||||
|
||||
Reference in New Issue
Block a user