feat(worker): server-authoritative Stopwatch screen with active-session recovery
This commit is contained in:
46
apps/worker/src/api/sessions.ts
Normal file
46
apps/worker/src/api/sessions.ts
Normal 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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
19
apps/worker/src/lib/stopwatch.test.ts
Normal file
19
apps/worker/src/lib/stopwatch.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
16
apps/worker/src/lib/stopwatch.ts
Normal file
16
apps/worker/src/lib/stopwatch.ts
Normal 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));
|
||||||
|
}
|
||||||
185
apps/worker/src/screens/Stopwatch.test.tsx
Normal file
185
apps/worker/src/screens/Stopwatch.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user