feat(worker): server-authoritative Stopwatch screen with active-session recovery

This commit is contained in:
Bas van Rossem
2026-06-17 16:24:56 +02:00
parent 1ecad6bbb4
commit 5af5a9c2bb
5 changed files with 625 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { StartSessionInput, WorkSession } from '@solelog/shared';
import { apiFetch } from '../lib/api';
export function useActiveSessions() {
return useQuery({
queryKey: ['sessions', 'active'],
queryFn: () => apiFetch<WorkSession[]>('/api/sessions/active'),
});
}
export function useStartSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: StartSessionInput) =>
apiFetch<WorkSession>('/api/sessions/start', {
method: 'POST',
body: JSON.stringify(input),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
}
export function useStopSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiFetch<WorkSession>(`/api/sessions/${id}/stop`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
}
export function useDiscardSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiFetch<WorkSession>(`/api/sessions/${id}/discard`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { elapsedSeconds, formatTime } from './stopwatch';
describe('stopwatch logic', () => {
it('formats seconds as HH:MM:SS', () => {
expect(formatTime(0)).toBe('00:00:00');
expect(formatTime(65)).toBe('00:01:05');
expect(formatTime(3661)).toBe('01:01:01');
});
it('computes elapsed seconds since start excluding paused time', () => {
expect(elapsedSeconds(1000, 6000, 0)).toBe(5);
expect(elapsedSeconds(1000, 6000, 2000)).toBe(3);
});
it('never returns a negative elapsed value', () => {
expect(elapsedSeconds(5000, 1000, 0)).toBe(0);
});
});

View File

@@ -0,0 +1,16 @@
// Pure timing helpers for the Stopwatch screen — server-authoritative elapsed.
// Elapsed is computed from the server start_time (wall-clock), not a tick counter,
// so it survives backgrounding. Pause accumulates paused time client-side.
export function formatTime(totalSeconds: number): string {
const s = Math.max(0, Math.floor(totalSeconds));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}
// elapsed seconds since startMs, excluding accumulated paused ms, evaluated at nowMs.
export function elapsedSeconds(startMs: number, nowMs: number, pausedMs: number): number {
return Math.max(0, Math.floor((nowMs - startMs - pausedMs) / 1000));
}

View File

@@ -0,0 +1,185 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
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 { useActivities } from '../api/activities';
import {
useActiveSessions,
useStartSession,
useStopSession,
useDiscardSession,
} from '../api/sessions';
vi.mock('../api/activities', () => ({
useActivities: vi.fn(),
}));
vi.mock('../api/sessions', () => ({
useActiveSessions: vi.fn(),
useStartSession: vi.fn(),
useStopSession: vi.fn(),
useDiscardSession: vi.fn(),
}));
const mockedUseActivities = vi.mocked(useActivities);
const mockedUseActiveSessions = vi.mocked(useActiveSessions);
const mockedUseStartSession = vi.mocked(useStartSession);
const mockedUseStopSession = vi.mocked(useStopSession);
const mockedUseDiscardSession = vi.mocked(useDiscardSession);
const FREZEN: Activity = {
id: 1,
name: 'Frezen',
insole_types: ['Kurk', 'Berk'],
created_at: '2026-01-01T00:00:00.000Z',
};
const PRINTEN: Activity = {
id: 2,
name: 'Printen',
insole_types: ['3D'],
created_at: '2026-01-01T00:00:00.000Z',
};
function activeSession(overrides: Partial<WorkSession> = {}): WorkSession {
return {
id: 99,
user_id: 'u1',
activity_id: 1,
activity_name: 'Frezen',
insole_type: 'Kurk',
pair_count: 2,
start_time: new Date().toISOString(),
end_time: null,
duration_seconds: null,
status: 'active',
source: 'app',
notes: null,
created_at: new Date().toISOString(),
...overrides,
};
}
// Build a fake RQ query/mutation result object the screen can consume.
function query<R>(data: unknown): R {
return { data, isLoading: false, isError: false } as unknown as R;
}
let startMutate: ReturnType<typeof vi.fn>;
let stopMutate: ReturnType<typeof vi.fn>;
let discardMutate: ReturnType<typeof vi.fn>;
function mutation<R>(mutate: ReturnType<typeof vi.fn>): R {
return { mutate, isPending: false } as unknown as R;
}
function renderStopwatch() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<Stopwatch />
</QueryClientProvider>,
);
}
describe('Stopwatch', () => {
beforeEach(() => {
startMutate = vi.fn();
stopMutate = vi.fn();
discardMutate = vi.fn();
mockedUseActivities.mockReturnValue(query<ReturnType<typeof useActivities>>([FREZEN, PRINTEN]));
mockedUseActiveSessions.mockReturnValue(query<ReturnType<typeof useActiveSessions>>([]));
mockedUseStartSession.mockReturnValue(mutation<ReturnType<typeof useStartSession>>(startMutate));
mockedUseStopSession.mockReturnValue(mutation<ReturnType<typeof useStopSession>>(stopMutate));
mockedUseDiscardSession.mockReturnValue(
mutation<ReturnType<typeof useDiscardSession>>(discardMutate),
);
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
it('renders the three sections and Start button in Dutch', () => {
renderStopwatch();
expect(screen.getByText('Type zool')).toBeInTheDocument();
expect(screen.getByText('Type handeling')).toBeInTheDocument();
expect(screen.getByText('Aantal zolen')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Start Stopwatch' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Kurk' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Berk' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '3D' })).toBeInTheDocument();
// Default count is 2 (free-typed text mirror, so the value is the string '2').
expect(screen.getByLabelText('Aantal zolen')).toHaveValue('2');
});
it('disables Start until a handling is chosen', async () => {
const user = userEvent.setup();
renderStopwatch();
const startBtn = screen.getByRole('button', { name: 'Start Stopwatch' });
expect(startBtn).toBeDisabled();
// Kurk is the default zooltype; pick the Frezen handling.
await user.selectOptions(screen.getByLabelText('Type handeling'), 'Frezen');
await waitFor(() => expect(startBtn).toBeEnabled());
});
it('filters handlings by the chosen zooltype', async () => {
const user = userEvent.setup();
renderStopwatch();
const select = screen.getByLabelText('Type handeling');
// Kurk selected by default -> Frezen present, Printen absent.
expect(screen.getByRole('option', { name: 'Frezen' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'Printen' })).not.toBeInTheDocument();
// Switch to 3D -> Printen present, Frezen absent.
await user.click(screen.getByRole('button', { name: '3D' }));
expect(screen.getByRole('option', { name: 'Printen' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'Frezen' })).not.toBeInTheDocument();
expect(select).toBeInTheDocument();
});
it('calls start with the selected values', async () => {
const user = userEvent.setup();
renderStopwatch();
// Pick Berk; Frezen applies to Berk.
await user.click(screen.getByRole('button', { name: 'Berk' }));
await user.selectOptions(screen.getByLabelText('Type handeling'), 'Frezen');
// Bump count to 3.
await user.click(screen.getByRole('button', { name: 'Meer zolen' }));
await user.click(screen.getByRole('button', { name: 'Start Stopwatch' }));
expect(startMutate).toHaveBeenCalledTimes(1);
expect(startMutate.mock.calls[0][0]).toMatchObject({
activity_id: 1,
insole_type: 'Berk',
pair_count: 3,
});
});
it('arms discard on first Annuleren tap and discards on the second', async () => {
const user = userEvent.setup();
// Recover an active session so the screen renders the running UI.
mockedUseActiveSessions.mockReturnValue(
query<ReturnType<typeof useActiveSessions>>([activeSession()]),
);
renderStopwatch();
const cancel = await screen.findByRole('button', { name: 'Annuleren' });
await user.click(cancel);
expect(
screen.getByRole('button', { name: 'Nogmaals tikken ter bevestiging' }),
).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Nogmaals tikken ter bevestiging' }));
expect(discardMutate).toHaveBeenCalledTimes(1);
expect(discardMutate.mock.calls[0][0]).toBe(99);
});
});

View File

@@ -1,7 +1,366 @@
import { useEffect, useRef, useState } from 'react';
import type { InsoleType, WorkSession } from '@solelog/shared';
import { useActivities } from '../api/activities';
import {
useActiveSessions,
useStartSession,
useStopSession,
useDiscardSession,
} from '../api/sessions';
import { elapsedSeconds, formatTime } from '../lib/stopwatch';
const ALL_TYPES: InsoleType[] = ['Kurk', 'Berk', '3D'];
const DISCARD_WINDOW_MS = 3000;
export default function Stopwatch() { export default function Stopwatch() {
const activitiesQuery = useActivities();
const activeSessionsQuery = useActiveSessions();
const startSession = useStartSession();
const stopSession = useStopSession();
const discardSession = useDiscardSession();
const activities = activitiesQuery.data ?? [];
// Selection state.
const [insoleType, setInsoleType] = useState<InsoleType>('Kurk');
const [activeActivityId, setActiveActivityId] = useState<number | null>(null);
const [pairCount, setPairCount] = useState(2);
const [pairCountText, setPairCountText] = useState('2');
// Running state — server-authoritative.
const [sessionId, setSessionId] = useState<number | null>(null);
const [startMs, setStartMs] = useState<number | null>(null);
const [isPaused, setIsPaused] = useState(false);
// Accumulated paused ms + the moment the current pause began (client-only).
const [pausedMs, setPausedMs] = useState(0);
const [pauseStartedMs, setPauseStartedMs] = useState<number | null>(null);
const [nowMs, setNowMs] = useState(() => Date.now());
// Double-press discard arming.
const [discardPending, setDiscardPending] = useState(false);
const discardTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isRunning = sessionId !== null;
// Recover an active session on load (phone-died / resume-elsewhere path).
useEffect(() => {
if (isRunning) return;
const active = activeSessionsQuery.data;
if (!active || active.length === 0) return;
const session: WorkSession = active[0];
setSessionId(session.id);
setStartMs(new Date(session.start_time).getTime());
setActiveActivityId(session.activity_id);
if (session.insole_type) setInsoleType(session.insole_type);
setPairCount(session.pair_count);
setPairCountText(String(session.pair_count));
setIsPaused(false);
setPausedMs(0);
setPauseStartedMs(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSessionsQuery.data]);
// Live 1s tick while running and not paused.
useEffect(() => {
if (!isRunning || isPaused) return;
setNowMs(Date.now());
const id = setInterval(() => setNowMs(Date.now()), 1000);
return () => clearInterval(id);
}, [isRunning, isPaused]);
useEffect(() => {
return () => {
if (discardTimerRef.current) clearTimeout(discardTimerRef.current);
};
}, []);
const filteredActivities = activities.filter((a) => a.insole_types.includes(insoleType));
const canStart = activeActivityId !== null;
// While paused, freeze the clock at the moment the pause began.
const effectiveNow = isPaused && pauseStartedMs !== null ? pauseStartedMs : nowMs;
const elapsed = startMs !== null ? elapsedSeconds(startMs, effectiveNow, pausedMs) : 0;
function handleSelectZool(type: InsoleType) {
if (isRunning) return;
setInsoleType(type);
setActiveActivityId(null); // changing the zool clears the chosen handling
}
function handlePairCountText(text: string) {
setPairCountText(text);
const parsed = parseInt(text, 10);
if (!Number.isNaN(parsed) && parsed > 0) setPairCount(parsed);
}
function adjustPairCount(delta: number) {
const next = Math.max(1, pairCount + delta);
setPairCount(next);
setPairCountText(String(next));
}
function handleStart() {
if (!canStart || activeActivityId === null) return;
startSession.mutate(
{ activity_id: activeActivityId, insole_type: insoleType, pair_count: pairCount },
{
onSuccess: (session) => {
setSessionId(session.id);
setStartMs(new Date(session.start_time).getTime());
setIsPaused(false);
setPausedMs(0);
setPauseStartedMs(null);
setNowMs(Date.now());
},
},
);
}
function handleTapDisplay() {
if (!isRunning) {
if (canStart) handleStart();
return;
}
if (isPaused) {
// Resume: fold the just-finished pause span into the accumulator.
if (pauseStartedMs !== null) setPausedMs((prev) => prev + (Date.now() - pauseStartedMs));
setPauseStartedMs(null);
setIsPaused(false);
} else {
// Pause.
setPauseStartedMs(Date.now());
setIsPaused(true);
}
}
function resetTimer() {
setSessionId(null);
setStartMs(null);
setIsPaused(false);
setPausedMs(0);
setPauseStartedMs(null);
setDiscardPending(false);
if (discardTimerRef.current) {
clearTimeout(discardTimerRef.current);
discardTimerRef.current = null;
}
}
function handleStop() {
if (sessionId === null) return;
const id = sessionId;
stopSession.mutate(id, { onSuccess: () => resetTimer() });
// Selections (zool/handling/count) persist for the next session.
}
function handleDiscard() {
if (sessionId === null) return;
if (!discardPending) {
setDiscardPending(true);
discardTimerRef.current = setTimeout(() => {
setDiscardPending(false);
discardTimerRef.current = null;
}, DISCARD_WINDOW_MS);
return;
}
if (discardTimerRef.current) {
clearTimeout(discardTimerRef.current);
discardTimerRef.current = null;
}
const id = sessionId;
discardSession.mutate(id, { onSuccess: () => resetTimer() });
}
const statusPill = !isRunning
? canStart
? 'Tik om te starten'
: null
: isPaused
? 'Gepauzeerd — tik om te hervatten'
: 'Tik om te pauzeren';
return ( return (
<div className="screen"> <div className="screen">
<h1 className="screen-title">Stopwatch</h1> <h1 className="screen-title">Stopwatch</h1>
{/* Section 1 — Type zool */}
<h2 className="section-label">Type zool</h2>
<div className="segmented" style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
{ALL_TYPES.map((type) => {
const selected = insoleType === type;
return (
<button
key={type}
type="button"
disabled={isRunning}
aria-pressed={selected}
className={selected ? 'seg-btn seg-btn-active' : 'seg-btn'}
style={{
flex: 1,
padding: '12px 4px',
borderRadius: 12,
fontWeight: 600,
cursor: isRunning ? 'not-allowed' : 'pointer',
border: `1px solid ${selected ? '#2563EB' : '#E5E7EB'}`,
background: selected ? '#EFF6FF' : '#ffffff',
color: isRunning ? '#9CA3AF' : selected ? '#2563EB' : '#6B7280',
}}
onClick={() => handleSelectZool(type)}
>
{type}
</button>
);
})}
</div>
{/* Section 2 — Type handeling */}
<h2 className="section-label">Type handeling</h2>
{filteredActivities.length === 0 ? (
<p className="muted" style={{ marginBottom: 20 }}>
Geen handelingen beschikbaar voor {insoleType} zolen. Voeg ze toe via Instellingen.
</p>
) : (
<select
aria-label="Type handeling"
className="field-input"
disabled={isRunning}
value={activeActivityId ?? ''}
onChange={(e) => setActiveActivityId(e.target.value ? Number(e.target.value) : null)}
style={{ marginBottom: 20 }}
>
<option value="">Kies een handeling...</option>
{filteredActivities.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
)}
{/* Section 3 — Aantal zolen */}
<h2 className="section-label">Aantal zolen</h2>
<div
className="stepper"
style={{
display: 'flex',
alignItems: 'center',
border: '1px solid #E5E7EB',
borderRadius: 12,
overflow: 'hidden',
marginBottom: 24,
}}
>
<button
type="button"
aria-label="Minder zolen"
disabled={isRunning || pairCount <= 1}
onClick={() => adjustPairCount(-1)}
style={{ width: 64, padding: 14, border: 'none', background: '#F3F4F6', fontSize: 20 }}
>
</button>
<input
aria-label="Aantal zolen"
inputMode="numeric"
className="field-input"
value={pairCountText}
disabled={isRunning}
onChange={(e) => handlePairCountText(e.target.value)}
style={{ flex: 1, textAlign: 'center', border: 'none', borderRadius: 0 }}
/>
<button
type="button"
aria-label="Meer zolen"
disabled={isRunning}
onClick={() => adjustPairCount(1)}
style={{ width: 64, padding: 14, border: 'none', background: '#F3F4F6', fontSize: 20 }}
>
+
</button>
</div>
{/* Section 4 — Stopwatch display */}
<button
type="button"
className="stopwatch-display"
aria-label="Stopwatch"
onClick={handleTapDisplay}
style={{
display: 'block',
width: '100%',
textAlign: 'center',
padding: '32px 16px',
borderRadius: 16,
marginBottom: 24,
cursor: !isRunning && !canStart ? 'default' : 'pointer',
border: `1px solid ${isPaused ? '#FDE68A' : '#E5E7EB'}`,
background: '#ffffff',
}}
>
<div
style={{
fontSize: 64,
fontWeight: 700,
fontVariantNumeric: 'tabular-nums',
color: isPaused ? '#D97706' : isRunning ? '#111827' : '#9CA3AF',
}}
>
{formatTime(elapsed)}
</div>
{statusPill && (
<div
style={{
marginTop: 8,
fontSize: 14,
fontWeight: 600,
color: isPaused ? '#D97706' : '#2563EB',
}}
>
{statusPill}
</div>
)}
</button>
{/* Section 5 — Action buttons */}
{!isRunning ? (
<button
type="button"
className="btn-primary"
disabled={!canStart || startSession.isPending}
onClick={handleStart}
>
Start Stopwatch
</button>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<button
type="button"
className="btn-primary"
disabled={stopSession.isPending}
onClick={handleStop}
style={{ background: '#DC2626' }}
>
Stop & Opslaan
</button>
<button
type="button"
onClick={handleDiscard}
style={{
width: '100%',
padding: 16,
fontSize: 16,
fontWeight: 600,
borderRadius: 16,
border: 'none',
cursor: 'pointer',
background: discardPending ? '#374151' : '#F3F4F6',
color: discardPending ? '#ffffff' : '#6B7280',
}}
>
{discardPending ? 'Nogmaals tikken ter bevestiging' : 'Annuleren'}
</button>
</div>
)}
</div> </div>
); );
} }