feat(worker): poll + reconcile stopwatch to server (admin stop/cancel)
This commit is contained in:
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user