diff --git a/apps/admin/src/components/Sidebar.test.tsx b/apps/admin/src/components/Sidebar.test.tsx
new file mode 100644
index 0000000..c174780
--- /dev/null
+++ b/apps/admin/src/components/Sidebar.test.tsx
@@ -0,0 +1,95 @@
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import Sidebar from './Sidebar';
+
+const signOut = vi.hoisted(() => vi.fn());
+const meState = vi.hoisted(() => ({ email: 'admin@solelog.local' as string | undefined }));
+
+vi.mock('../auth/AuthContext', () => ({
+ useAuth: () => ({ isAuthed: true, signIn: vi.fn(), signOut }),
+}));
+vi.mock('../api/me', () => ({
+ useMe: () => ({ data: meState.email ? { user: { email: meState.email } } : undefined }),
+}));
+
+// Render the Sidebar next to a stub route table so navigation can be observed.
+function renderSidebar(initial = '/') {
+ return render(
+
+
+
+ SCREEN Live} />
+ SCREEN Handelingen} />
+ SCREEN Sessies} />
+ SCREEN Rapporten} />
+ SCREEN Gebruikers} />
+
+ ,
+ );
+}
+
+describe('Sidebar', () => {
+ afterEach(() => {
+ signOut.mockReset();
+ meState.email = 'admin@solelog.local';
+ vi.restoreAllMocks();
+ });
+
+ it('renders the brand, every nav link, and the signed-in email', () => {
+ renderSidebar();
+ expect(screen.getByText('SoleLog Admin')).toBeInTheDocument();
+ const nav = within(screen.getByRole('navigation'));
+ for (const label of ['Live', 'Handelingen', 'Sessies', 'Rapporten', 'Gebruikers']) {
+ expect(nav.getByRole('link', { name: label })).toBeInTheDocument();
+ }
+ expect(screen.getByText('admin@solelog.local')).toBeInTheDocument();
+ });
+
+ it('highlights the active route and only that route', () => {
+ renderSidebar('/');
+ expect(screen.getByText('SCREEN Live')).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Live' }).className).toContain('nav-link-active');
+ expect(screen.getByRole('link', { name: 'Handelingen' }).className).not.toContain(
+ 'nav-link-active',
+ );
+ });
+
+ it('keeps Live inactive when a non-root route is active (end match on /)', () => {
+ renderSidebar('/handelingen');
+ expect(screen.getByText('SCREEN Handelingen')).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Handelingen' }).className).toContain(
+ 'nav-link-active',
+ );
+ expect(screen.getByRole('link', { name: 'Live' }).className).not.toContain('nav-link-active');
+ });
+
+ it('clicking a nav link switches the rendered screen and moves the highlight', async () => {
+ const user = userEvent.setup();
+ renderSidebar('/');
+ expect(screen.getByText('SCREEN Live')).toBeInTheDocument();
+
+ await user.click(screen.getByRole('link', { name: 'Rapporten' }));
+
+ expect(screen.getByText('SCREEN Rapporten')).toBeInTheDocument();
+ expect(screen.queryByText('SCREEN Live')).not.toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Rapporten' }).className).toContain('nav-link-active');
+ expect(screen.getByRole('link', { name: 'Live' }).className).not.toContain('nav-link-active');
+ });
+
+ it('clicking Uitloggen calls signOut', async () => {
+ const user = userEvent.setup();
+ renderSidebar();
+ await user.click(screen.getByRole('button', { name: 'Uitloggen' }));
+ expect(signOut).toHaveBeenCalledTimes(1);
+ });
+
+ it('hides the email span when useMe has no data', () => {
+ meState.email = undefined;
+ renderSidebar();
+ expect(screen.queryByText('admin@solelog.local')).not.toBeInTheDocument();
+ // The logout control is still present.
+ expect(screen.getByRole('button', { name: 'Uitloggen' })).toBeInTheDocument();
+ });
+});
diff --git a/apps/admin/src/screens/Activities.test.tsx b/apps/admin/src/screens/Activities.test.tsx
index 264138d..4388ada 100644
--- a/apps/admin/src/screens/Activities.test.tsx
+++ b/apps/admin/src/screens/Activities.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, waitFor } from '@testing-library/react';
+import { render, screen, waitFor, within } 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';
@@ -163,4 +163,232 @@ describe('Activities', () => {
expect(screen.getByRole('button', { name: 'Verplaats Frezen omlaag' })).toBeEnabled();
expect(screen.getByRole('button', { name: 'Verplaats Polijsten omhoog' })).toBeEnabled();
});
+
+ it('type toggles deselect and reselect insole types (add form)', async () => {
+ const user = userEvent.setup();
+ renderActivities();
+ await screen.findByText('Frezen');
+
+ const kurk = screen.getByRole('button', { name: 'Kurk' });
+ expect(kurk).toHaveAttribute('aria-pressed', 'true');
+
+ // Remove branch of toggle().
+ await user.click(kurk);
+ expect(kurk).toHaveAttribute('aria-pressed', 'false');
+ // Re-select — add branch of toggle().
+ await user.click(kurk);
+ expect(kurk).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ it('adds a handeling with a reduced insole-type subset', async () => {
+ const user = userEvent.setup();
+ renderActivities();
+ await screen.findByText('Frezen');
+
+ await user.type(screen.getByPlaceholderText('Naam van de stap, bijv. Leerrand'), 'Randjes');
+ // Default is all three; drop Berk so the POST carries only the remaining subset.
+ await user.click(screen.getByRole('button', { name: 'Berk' }));
+ await user.click(screen.getByRole('button', { name: 'Stap toevoegen' }));
+
+ await waitFor(() => {
+ expect(mockApiFetch).toHaveBeenCalledWith('/api/activities', {
+ method: 'POST',
+ body: JSON.stringify({ name: 'Randjes', insole_types: ['Kurk', '3D'] }),
+ });
+ });
+ });
+
+ it('disables Stap toevoegen when every insole type is deselected', async () => {
+ const user = userEvent.setup();
+ renderActivities();
+ await screen.findByText('Frezen');
+
+ await user.type(screen.getByPlaceholderText('Naam van de stap, bijv. Leerrand'), 'Randjes');
+ const add = screen.getByRole('button', { name: 'Stap toevoegen' });
+ expect(add).toBeEnabled();
+
+ await user.click(screen.getByRole('button', { name: 'Kurk' }));
+ await user.click(screen.getByRole('button', { name: 'Berk' }));
+ await user.click(screen.getByRole('button', { name: '3D' }));
+ expect(add).toBeDisabled();
+ });
+
+ it('editing insole types PUTs the updated subset', async () => {
+ const user = userEvent.setup();
+ renderActivities();
+ await screen.findByText('Frezen'); // Frezen has ['Kurk', 'Berk']
+
+ await user.click(screen.getByRole('button', { name: 'Bewerk Frezen' }));
+ const li = screen.getByDisplayValue('Frezen').closest('li')!;
+ // Add 3D via the edit-form toggles.
+ await user.click(within(li).getByRole('button', { name: '3D' }));
+ await user.click(within(li).getByRole('button', { name: 'Opslaan' }));
+
+ await waitFor(() => {
+ expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/1', {
+ method: 'PUT',
+ body: JSON.stringify({ name: 'Frezen', insole_types: ['Kurk', 'Berk', '3D'] }),
+ });
+ });
+ });
+
+ it('cancel exits edit mode without saving', async () => {
+ const user = userEvent.setup();
+ renderActivities();
+ await screen.findByText('Frezen');
+
+ await user.click(screen.getByRole('button', { name: 'Bewerk Frezen' }));
+ expect(screen.getByDisplayValue('Frezen')).toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: 'Annuleren' }));
+
+ expect(screen.queryByDisplayValue('Frezen')).not.toBeInTheDocument();
+ expect(mockApiFetch).not.toHaveBeenCalledWith(
+ '/api/activities/1',
+ expect.objectContaining({ method: 'PUT' }),
+ );
+ });
+
+ it('editing an activity with no insole types pre-selects all three', async () => {
+ const user = userEvent.setup();
+ mockApiFetch.mockResolvedValue([
+ {
+ id: 9,
+ name: 'Blanco',
+ insole_types: [],
+ created_at: '2026-06-17T00:00:00.000Z',
+ sort_order: 0,
+ },
+ ]);
+ renderActivities();
+ await screen.findByText('Blanco');
+
+ await user.click(screen.getByRole('button', { name: 'Bewerk Blanco' }));
+ const li = screen.getByDisplayValue('Blanco').closest('li')!;
+ for (const type of ['Kurk', 'Berk', '3D']) {
+ expect(within(li).getByRole('button', { name: type })).toHaveAttribute(
+ 'aria-pressed',
+ 'true',
+ );
+ }
+ });
+
+ it('shows the empty state when there are no activities', async () => {
+ mockApiFetch.mockResolvedValue([]);
+ renderActivities();
+
+ expect(
+ await screen.findByText('Nog geen stappen. Voeg er een toe hierboven.'),
+ ).toBeInTheDocument();
+ expect(screen.getByText('Huidige stappen (0)')).toBeInTheDocument();
+ });
+
+ it('falls back to the empty state when the query errors (no dedicated error UI)', async () => {
+ mockApiFetch.mockRejectedValue(new Error('boom'));
+ renderActivities();
+
+ expect(
+ await screen.findByText('Nog geen stappen. Voeg er een toe hierboven.'),
+ ).toBeInTheDocument();
+ });
+
+ // Lifecycle: create → edit insole types → reorder → delete, each step reflected in the list.
+ it('lifecycle: create → edit insole types → reorder → delete', async () => {
+ vi.spyOn(window, 'confirm').mockReturnValue(true);
+ const user = userEvent.setup();
+
+ let list: Activity[] = [
+ {
+ id: 1,
+ name: 'Lijmen',
+ insole_types: ['3D'],
+ created_at: '2026-06-17T00:00:00.000Z',
+ sort_order: 0,
+ },
+ ];
+ let nextId = 2;
+ mockApiFetch.mockImplementation((path: string, init?: RequestInit) => {
+ const method = init?.method ?? 'GET';
+ if (path === '/api/activities' && method === 'GET') return Promise.resolve(list);
+ if (path === '/api/activities' && method === 'POST') {
+ const body = JSON.parse(init!.body as string);
+ const created: Activity = {
+ id: nextId++,
+ name: body.name,
+ insole_types: body.insole_types,
+ created_at: '2026-07-23T00:00:00.000Z',
+ sort_order: list.length,
+ };
+ list = [...list, created];
+ return Promise.resolve(created);
+ }
+ if (path === '/api/activities/reorder' && method === 'PUT') {
+ const { ids } = JSON.parse(init!.body as string) as { ids: number[] };
+ list = ids.map((id, i) => ({ ...list.find((a) => a.id === id)!, sort_order: i }));
+ return Promise.resolve(list);
+ }
+ const idMatch = path.match(/^\/api\/activities\/(\d+)$/);
+ if (idMatch && method === 'PUT') {
+ const id = Number(idMatch[1]);
+ const body = JSON.parse(init!.body as string);
+ list = list.map((a) =>
+ a.id === id ? { ...a, name: body.name, insole_types: body.insole_types } : a,
+ );
+ return Promise.resolve(list.find((a) => a.id === id)!);
+ }
+ if (idMatch && method === 'DELETE') {
+ const id = Number(idMatch[1]);
+ list = list.filter((a) => a.id !== id);
+ return Promise.resolve({ success: true });
+ }
+ return Promise.reject(new Error(`unexpected ${method} ${path}`));
+ });
+
+ const { container } = renderActivities();
+
+ // 1. Create Frezen (defaults to all three types).
+ await screen.findByText('Lijmen');
+ await user.type(screen.getByPlaceholderText('Naam van de stap, bijv. Leerrand'), 'Frezen');
+ await user.click(screen.getByRole('button', { name: 'Stap toevoegen' }));
+ expect(await screen.findByText('Frezen')).toBeInTheDocument();
+ expect(screen.getByText('Huidige stappen (2)')).toBeInTheDocument();
+
+ // 2. Edit its insole types down to just Kurk.
+ await user.click(screen.getByRole('button', { name: 'Bewerk Frezen' }));
+ const editLi = screen.getByDisplayValue('Frezen').closest('li')!;
+ await user.click(within(editLi).getByRole('button', { name: 'Berk' }));
+ await user.click(within(editLi).getByRole('button', { name: '3D' }));
+ await user.click(within(editLi).getByRole('button', { name: 'Opslaan' }));
+ await waitFor(() => {
+ expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/2', {
+ method: 'PUT',
+ body: JSON.stringify({ name: 'Frezen', insole_types: ['Kurk'] }),
+ });
+ });
+
+ // 3. Reorder: move Frezen above Lijmen.
+ await user.click(await screen.findByRole('button', { name: 'Verplaats Frezen omhoog' }));
+ await waitFor(() => {
+ expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/reorder', {
+ method: 'PUT',
+ body: JSON.stringify({ ids: [2, 1] }),
+ });
+ });
+ await waitFor(() => {
+ const names = Array.from(container.querySelectorAll('.activity-name')).map(
+ (n) => n.textContent,
+ );
+ expect(names).toEqual(['Frezen', 'Lijmen']);
+ });
+
+ // 4. Delete Frezen.
+ await user.click(screen.getByRole('button', { name: 'Verwijder Frezen' }));
+ await waitFor(() => {
+ expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/2', { method: 'DELETE' });
+ });
+ await waitFor(() => {
+ expect(screen.queryByText('Frezen')).not.toBeInTheDocument();
+ });
+ expect(screen.getByText('Huidige stappen (1)')).toBeInTheDocument();
+ });
});
diff --git a/apps/admin/src/screens/Activities.tsx b/apps/admin/src/screens/Activities.tsx
index 36c44f1..02f67bb 100644
--- a/apps/admin/src/screens/Activities.tsx
+++ b/apps/admin/src/screens/Activities.tsx
@@ -92,6 +92,7 @@ export default function Activities() {
const addEnabled = newName.trim().length > 0 && newTypes.length > 0;
function handleAdd() {
+ /* c8 ignore next -- defensive: the Stap toevoegen button is disabled when !addEnabled */
if (!addEnabled) return;
createActivity.mutate(
{ name: newName.trim(), insole_types: newTypes },
@@ -115,6 +116,7 @@ export default function Activities() {
}
function handleSave(id: number) {
+ /* c8 ignore next -- defensive: the Opslaan button is disabled when name/types are empty */
if (editName.trim().length === 0 || editTypes.length === 0) return;
updateActivity.mutate(
{ id, input: { name: editName.trim(), insole_types: editTypes } },
@@ -131,6 +133,7 @@ export default function Activities() {
function handleMove(index: number, direction: -1 | 1) {
const target = index + direction;
+ /* c8 ignore next -- defensive: up/down buttons are disabled at the list bounds */
if (target < 0 || target >= activities.length) return;
const ids = activities.map((a) => a.id);
[ids[index], ids[target]] = [ids[target], ids[index]];
diff --git a/docs/reference/domain-test-checklist.md b/docs/reference/domain-test-checklist.md
index bcdbf84..968ae71 100644
--- a/docs/reference/domain-test-checklist.md
+++ b/docs/reference/domain-test-checklist.md
@@ -516,3 +516,45 @@ Guaranteed behaviors:
- Lifecycle (`src/screens/Users.test.tsx`): create a worker via the form → it appears in the refreshed
list as `Werker` → promote it via `Maak admin` → the pill flips to `Beheerder` after the refetch →
reset its password inline (`Opslaan`) → the POST fires and the inline field closes.
+
+## Activities + app/nav — admin client
+
+Src covered (admin `apps/admin`): `src/screens/Activities.tsx`, `src/api/activities.ts`,
+`src/App.tsx`, and `src/components/Sidebar.tsx` — all at 100% stmts/branch/funcs/lines. Three
+`c8 ignore`s with reasons in `Activities.tsx`: the early-return guards in `handleAdd`, `handleSave`,
+and `handleMove` are unreachable via the UI because the button that triggers each is disabled in
+exactly that state (`Stap toevoegen` when `!addEnabled`, `Opslaan` when the name/types are empty,
+the ↑/↓ move buttons at the list bounds).
+
+Guaranteed behaviors:
+
+- Activities renders the Dutch header (`Handelingen` / `Beheer handelingen per zooltype`), the
+ add-handeling card, and the current-steps list with a live count (`Huidige stappen ()`).
+- The insole-type toggles select/deselect a type (both the remove and re-add branches), reflected via
+ `aria-pressed`; a new handeling defaults to all three types (`Kurk`, `Berk`, `3D`).
+- Creating a handeling POSTs `/api/activities` with the trimmed name and the selected `insole_types`
+ subset (a deselected type is dropped from the payload); `Stap toevoegen` is disabled while the name
+ is blank or every type is deselected.
+- Editing a handeling opens an inline editor prefilled from the row and PUTs `/api/activities/:id` with
+ the updated name + `insole_types`; an activity stored with no insole types pre-selects all three in the
+ editor; `Annuleren` exits edit mode without issuing a PUT.
+- Deleting a handeling confirms first (`window.confirm`); DELETE `/api/activities/:id` fires only when
+ confirmed, and is skipped when cancelled.
+- Reorder: ↑/↓ move a row and PUT `/api/activities/reorder` with the swapped id order; ↑ is disabled on
+ the first row and ↓ on the last.
+- The list shows the loading placeholder (`Laden...`) while the query is pending and the empty state
+ (`Nog geen stappen. Voeg er een toe hierboven.`) when there are none; a query error falls back to the
+ same empty state (there is no dedicated error UI).
+- `api/activities.ts` hooks hit the right path/method — `useActivities` → GET `/api/activities`;
+ `useCreateActivity` → POST; `useUpdateActivity` → PUT `/:id`; `useDeleteActivity` → DELETE `/:id`;
+ `useReorderActivities` → PUT `/reorder` — and every mutation invalidates the `['activities']` query on
+ success.
+- App/nav: `App` gates on auth (no token → `Login`; a token → the authed shell with `Sidebar` + routed
+ screens). `Sidebar` renders the brand, the five nav links, and the signed-in email (hidden when
+ `useMe` has no data), and `Uitloggen` calls `signOut`.
+- `Sidebar` highlights the active route via `nav-link-active` and, with `end` matching on `/`, keeps the
+ Live link inactive on any non-root route; clicking a nav link switches the rendered screen and moves
+ the highlight.
+- Lifecycle (`src/screens/Activities.test.tsx`): create a handeling → edit its insole types down to a
+ single type → reorder it above another → delete it, with each step reflected in the refetched list;
+ the Sidebar test (`src/components/Sidebar.test.tsx`) covers navigation switching screens.