feat(worker): server-authoritative pause/resume on the stopwatch

This commit is contained in:
Bas van Rossem
2026-06-17 21:06:10 +02:00
parent 56e0162230
commit ce396ecf2d
3 changed files with 98 additions and 6 deletions

View File

@@ -51,3 +51,25 @@ export function useDiscardSession() {
}, },
}); });
} }
export function usePauseSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiFetch<WorkSession>(`/api/sessions/${id}/pause`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
}
export function useResumeSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiFetch<WorkSession>(`/api/sessions/${id}/resume`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
}

View File

@@ -10,6 +10,8 @@ import {
useStartSession, useStartSession,
useStopSession, useStopSession,
useDiscardSession, useDiscardSession,
usePauseSession,
useResumeSession,
} from '../api/sessions'; } from '../api/sessions';
vi.mock('../api/activities', () => ({ vi.mock('../api/activities', () => ({
@@ -21,6 +23,8 @@ vi.mock('../api/sessions', () => ({
useStartSession: vi.fn(), useStartSession: vi.fn(),
useStopSession: vi.fn(), useStopSession: vi.fn(),
useDiscardSession: vi.fn(), useDiscardSession: vi.fn(),
usePauseSession: vi.fn(),
useResumeSession: vi.fn(),
})); }));
const mockedUseActivities = vi.mocked(useActivities); const mockedUseActivities = vi.mocked(useActivities);
@@ -28,17 +32,21 @@ const mockedUseActiveSessions = vi.mocked(useActiveSessions);
const mockedUseStartSession = vi.mocked(useStartSession); const mockedUseStartSession = vi.mocked(useStartSession);
const mockedUseStopSession = vi.mocked(useStopSession); const mockedUseStopSession = vi.mocked(useStopSession);
const mockedUseDiscardSession = vi.mocked(useDiscardSession); const mockedUseDiscardSession = vi.mocked(useDiscardSession);
const mockedUsePauseSession = vi.mocked(usePauseSession);
const mockedUseResumeSession = vi.mocked(useResumeSession);
const FREZEN: Activity = { const FREZEN: Activity = {
id: 1, id: 1,
name: 'Frezen', name: 'Frezen',
insole_types: ['Kurk', 'Berk'], insole_types: ['Kurk', 'Berk'],
sort_order: 0,
created_at: '2026-01-01T00:00:00.000Z', created_at: '2026-01-01T00:00:00.000Z',
}; };
const PRINTEN: Activity = { const PRINTEN: Activity = {
id: 2, id: 2,
name: 'Printen', name: 'Printen',
insole_types: ['3D'], insole_types: ['3D'],
sort_order: 1,
created_at: '2026-01-01T00:00:00.000Z', created_at: '2026-01-01T00:00:00.000Z',
}; };
@@ -53,6 +61,8 @@ function activeSession(overrides: Partial<WorkSession> = {}): WorkSession {
start_time: new Date().toISOString(), start_time: new Date().toISOString(),
end_time: null, end_time: null,
duration_seconds: null, duration_seconds: null,
paused_seconds: 0,
paused_at: null,
status: 'active', status: 'active',
source: 'app', source: 'app',
notes: null, notes: null,
@@ -69,6 +79,8 @@ function query<R>(data: unknown): R {
let startMutate: ReturnType<typeof vi.fn>; let startMutate: ReturnType<typeof vi.fn>;
let stopMutate: ReturnType<typeof vi.fn>; let stopMutate: ReturnType<typeof vi.fn>;
let discardMutate: ReturnType<typeof vi.fn>; let discardMutate: ReturnType<typeof vi.fn>;
let pauseMutate: ReturnType<typeof vi.fn>;
let resumeMutate: ReturnType<typeof vi.fn>;
function mutation<R>(mutate: ReturnType<typeof vi.fn>): R { function mutation<R>(mutate: ReturnType<typeof vi.fn>): R {
return { mutate, isPending: false } as unknown as R; return { mutate, isPending: false } as unknown as R;
@@ -88,6 +100,8 @@ describe('Stopwatch', () => {
startMutate = vi.fn(); startMutate = vi.fn();
stopMutate = vi.fn(); stopMutate = vi.fn();
discardMutate = vi.fn(); discardMutate = vi.fn();
pauseMutate = vi.fn();
resumeMutate = vi.fn();
mockedUseActivities.mockReturnValue(query<ReturnType<typeof useActivities>>([FREZEN, PRINTEN])); mockedUseActivities.mockReturnValue(query<ReturnType<typeof useActivities>>([FREZEN, PRINTEN]));
mockedUseActiveSessions.mockReturnValue(query<ReturnType<typeof useActiveSessions>>([])); mockedUseActiveSessions.mockReturnValue(query<ReturnType<typeof useActiveSessions>>([]));
mockedUseStartSession.mockReturnValue(mutation<ReturnType<typeof useStartSession>>(startMutate)); mockedUseStartSession.mockReturnValue(mutation<ReturnType<typeof useStartSession>>(startMutate));
@@ -95,6 +109,10 @@ describe('Stopwatch', () => {
mockedUseDiscardSession.mockReturnValue( mockedUseDiscardSession.mockReturnValue(
mutation<ReturnType<typeof useDiscardSession>>(discardMutate), mutation<ReturnType<typeof useDiscardSession>>(discardMutate),
); );
mockedUsePauseSession.mockReturnValue(mutation<ReturnType<typeof usePauseSession>>(pauseMutate));
mockedUseResumeSession.mockReturnValue(
mutation<ReturnType<typeof useResumeSession>>(resumeMutate),
);
}); });
afterEach(() => { afterEach(() => {
@@ -182,4 +200,47 @@ describe('Stopwatch', () => {
expect(discardMutate).toHaveBeenCalledTimes(1); expect(discardMutate).toHaveBeenCalledTimes(1);
expect(discardMutate.mock.calls[0][0]).toBe(99); 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<ReturnType<typeof useActiveSessions>>([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<ReturnType<typeof useActiveSessions>>([
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<ReturnType<typeof useActiveSessions>>([
activeSession({ paused_at: new Date().toISOString(), paused_seconds: 30 }),
]),
);
renderStopwatch();
expect(await screen.findByText('Gepauzeerd — tik om te hervatten')).toBeInTheDocument();
});
}); });

View File

@@ -6,6 +6,8 @@ import {
useStartSession, useStartSession,
useStopSession, useStopSession,
useDiscardSession, useDiscardSession,
usePauseSession,
useResumeSession,
} from '../api/sessions'; } from '../api/sessions';
import { elapsedSeconds, formatTime } from '../lib/stopwatch'; import { elapsedSeconds, formatTime } from '../lib/stopwatch';
@@ -18,6 +20,8 @@ export default function Stopwatch() {
const startSession = useStartSession(); const startSession = useStartSession();
const stopSession = useStopSession(); const stopSession = useStopSession();
const discardSession = useDiscardSession(); const discardSession = useDiscardSession();
const pauseSession = usePauseSession();
const resumeSession = useResumeSession();
const activities = activitiesQuery.data ?? []; const activities = activitiesQuery.data ?? [];
@@ -54,9 +58,11 @@ export default function Stopwatch() {
if (session.insole_type) setInsoleType(session.insole_type); if (session.insole_type) setInsoleType(session.insole_type);
setPairCount(session.pair_count); setPairCount(session.pair_count);
setPairCountText(String(session.pair_count)); setPairCountText(String(session.pair_count));
setIsPaused(false); // Restore pause state from the server (source of truth).
setPausedMs(0); const pausedAtMs = session.paused_at ? new Date(session.paused_at).getTime() : null;
setPauseStartedMs(null); setIsPaused(pausedAtMs !== null);
setPausedMs((session.paused_seconds ?? 0) * 1000);
setPauseStartedMs(pausedAtMs);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSessionsQuery.data]); }, [activeSessionsQuery.data]);
@@ -117,19 +123,22 @@ export default function Stopwatch() {
} }
function handleTapDisplay() { function handleTapDisplay() {
if (!isRunning) { if (!isRunning || sessionId === null) {
if (canStart) handleStart(); if (canStart) handleStart();
return; return;
} }
if (isPaused) { 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)); if (pauseStartedMs !== null) setPausedMs((prev) => prev + (Date.now() - pauseStartedMs));
setPauseStartedMs(null); setPauseStartedMs(null);
setIsPaused(false); setIsPaused(false);
resumeSession.mutate(sessionId);
} else { } else {
// Pause. // Pause locally for snappy feedback; the server records the authoritative pause.
setPauseStartedMs(Date.now()); setPauseStartedMs(Date.now());
setIsPaused(true); setIsPaused(true);
pauseSession.mutate(sessionId);
} }
} }