feat(worker): server-authoritative pause/resume on the stopwatch
This commit is contained in:
@@ -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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user