docs: spec + plan for phase 3b.1 manual session entry/edit + admin stop/fix
This commit is contained in:
295
docs/superpowers/plans/2026-06-17-phase-3b1-manual-sessions.md
Normal file
295
docs/superpowers/plans/2026-06-17-phase-3b1-manual-sessions.md
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# Phase 3b·1 — Manual Session Entry/Edit + Admin Stop/Fix — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** Implement task-by-task with TDD. Steps use checkbox (`- [ ]`).
|
||||||
|
> Spec: `docs/superpowers/specs/2026-06-17-phase-3b1-manual-sessions-design.md`.
|
||||||
|
|
||||||
|
**Goal:** Admin can list/filter all sessions, manually create a completed session for a worker,
|
||||||
|
edit any session (server recomputes duration), and stop/cancel a worker's active session — and the
|
||||||
|
worker's stopwatch converges to admin changes within ~15s (no stuck state).
|
||||||
|
|
||||||
|
**Architecture:** New admin-gated write endpoints + a users-roster endpoint in `routes/admin.ts`;
|
||||||
|
a new admin **Sessies** screen + shared create/edit form; Stop/Annuleer actions on Live; worker
|
||||||
|
polls + reconciles. **No DB migration** (reuses existing `work_sessions` columns).
|
||||||
|
|
||||||
|
**Tech Stack:** Hono + Drizzle + libsql (api), Vite+React+react-query (admin, worker),
|
||||||
|
`@solelog/shared` zod, vitest. Yarn 4 monorepo.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **TDD**: failing test → see it fail → minimal implementation → green → commit.
|
||||||
|
- **Commit per task**, conventional-commit message; commit **locally only** (no push/remote/amend);
|
||||||
|
stage only your task's files.
|
||||||
|
- **oxlint + oxfmt on changed files only.** `.oxfmtrc.json` now uses `trailingComma: "all"`
|
||||||
|
(prettier-style) — keep trailing commas on multiline params/args/arrays/objects; `docs/**` and
|
||||||
|
`**/drizzle/**` are ignored by the formatter. 2-space, single quotes, semicolons, width 100.
|
||||||
|
- **Dutch UI strings.**
|
||||||
|
- **No DB migration** this cycle. **Do not start the API server** (tests use in-process
|
||||||
|
`app.request`); if you must, kill the tree + free port 3000 afterward (Windows lock trap).
|
||||||
|
- Reuse `toWorkSession` (`apps/api/src/lib/work-session.ts`) for session responses. New admin
|
||||||
|
routes live in `apps/api/src/routes/admin.ts`, already behind the `/api/admin/*` admin guard.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/shared/src/index.ts MODIFY CreateManualSessionInput, AdminUpdateSessionInput
|
||||||
|
apps/api/src/routes/admin.ts MODIFY GET /api/admin/users; POST/PUT/stop/discard sessions
|
||||||
|
apps/api/test/admin.test.ts MODIFY
|
||||||
|
apps/admin/src/api/admin-sessions.ts MODIFY add list/users/create/update/stop/discard hooks
|
||||||
|
apps/admin/src/screens/Sessions.tsx CREATE list + status filter + row actions
|
||||||
|
apps/admin/src/components/SessionForm.tsx CREATE create/edit form
|
||||||
|
apps/admin/src/components/Sidebar.tsx MODIFY add 'Sessies' nav; drop 'Handmatig' from soon
|
||||||
|
apps/admin/src/App.tsx MODIFY add /sessies route
|
||||||
|
apps/admin/src/screens/Live.tsx MODIFY Stop/Annuleer on LiveCard
|
||||||
|
apps/admin/src/styles.css MODIFY table/form/action styles
|
||||||
|
apps/admin/src/screens/Sessions.test.tsx, components/SessionForm.test.tsx, screens/Live.test.tsx TEST
|
||||||
|
apps/worker/src/api/sessions.ts MODIFY refetchInterval 15000
|
||||||
|
apps/worker/src/screens/Stopwatch.tsx MODIFY reconcile + 409 handling
|
||||||
|
apps/worker/src/screens/Stopwatch.test.tsx TEST
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Shared contracts
|
||||||
|
|
||||||
|
**Files:** `packages/shared/src/index.ts`; test `packages/shared` (or assert via api tests — add a
|
||||||
|
tiny parse test if shared has a test setup, else cover via Task 2's api tests).
|
||||||
|
|
||||||
|
**Interfaces — Produces:**
|
||||||
|
- `CreateManualSessionInput = z.object({ user_id: z.string(), activity_id: z.number().int(),
|
||||||
|
insole_type: InsoleType, pair_count: z.number().int().min(1), start_time: z.string(),
|
||||||
|
end_time: z.string(), paused_seconds: z.number().int().min(0).default(0),
|
||||||
|
notes: z.string().nullable().optional() })`.
|
||||||
|
- `AdminUpdateSessionInput = z.object({ activity_id: z.number().int(), insole_type:
|
||||||
|
InsoleType.nullable(), pair_count: z.number().int().min(1), start_time: z.string(),
|
||||||
|
end_time: z.string().nullable(), paused_seconds: z.number().int().min(0),
|
||||||
|
notes: z.string().nullable(), status: SessionStatus })`.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** If `packages/shared` has no test runner, skip a standalone test here and rely on
|
||||||
|
Task 2's API tests to exercise the schemas (note this in the commit). Otherwise add a parse
|
||||||
|
test (valid input parses; `pair_count: 0` fails).
|
||||||
|
- [ ] **Step 2:** Add both schemas + inferred types after `StartSessionInput`/`AdminUser`.
|
||||||
|
- [ ] **Step 3:** `yarn workspace @solelog/api typecheck` (shared is consumed there) — green.
|
||||||
|
- [ ] **Step 4: Commit** — `feat(shared): manual-session create/update contracts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Backend — admin session write endpoints + users roster
|
||||||
|
|
||||||
|
**Files:** `apps/api/src/routes/admin.ts`; test `apps/api/test/admin.test.ts`.
|
||||||
|
|
||||||
|
**Interfaces — Produces** `GET /api/admin/users`, `POST /api/admin/sessions`,
|
||||||
|
`PUT /api/admin/sessions/:id`, `POST /api/admin/sessions/:id/stop`,
|
||||||
|
`POST /api/admin/sessions/:id/discard`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests** (helpers: `createTestUser`/`bearer`/`seedActivity`; create an admin
|
||||||
|
with `authToken(app, email, 'admin')` per the existing pattern):
|
||||||
|
- `GET /api/admin/users` (admin) returns objects with `id/name/email`; 403 for a worker.
|
||||||
|
- `POST /api/admin/sessions` (admin) with a worker's id + activity + `start_time`/`end_time` 1h
|
||||||
|
apart + `paused_seconds: 600` → 201/200 with `source==='manual'`, `status==='completed'`,
|
||||||
|
`duration_seconds === 3000` (3600−600).
|
||||||
|
- `POST` with `end < start` → 400; unknown `user_id`/`activity_id` → 404/400.
|
||||||
|
- `PUT /api/admin/sessions/:id` edits a session and recomputes duration; `end<start` → 400.
|
||||||
|
- `POST …/:id/stop` on another user's **active** session → completed with computed duration;
|
||||||
|
`…/:id/discard` → `status==='discarded'`. Worker token on any of these → 403.
|
||||||
|
- [ ] **Step 2: Run — fail** (routes 404).
|
||||||
|
- [ ] **Step 3: Implement** in `admin.ts` (imports: `and, eq, asc, desc` from drizzle-orm,
|
||||||
|
`CreateManualSessionInput`, `AdminUpdateSessionInput` from `@solelog/shared`, `activities`,
|
||||||
|
`user`, `workSessions` from schema, `toWorkSession`). All routes sit after the existing
|
||||||
|
`/api/admin/*` guard (already admin-gated).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
adminRoutes.get('/api/admin/users', async (c) => {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: user.id, name: user.name, email: user.email })
|
||||||
|
.from(user)
|
||||||
|
.orderBy(asc(user.name));
|
||||||
|
return c.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
function computeDuration(startMs: number, endMs: number, paused: number) {
|
||||||
|
return Math.max(0, Math.round((endMs - startMs) / 1000) - paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
adminRoutes.post('/api/admin/sessions', async (c) => {
|
||||||
|
const parsed = CreateManualSessionInput.safeParse(await c.req.json().catch(() => null));
|
||||||
|
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
|
||||||
|
const d = parsed.data;
|
||||||
|
const start = new Date(d.start_time);
|
||||||
|
const end = new Date(d.end_time);
|
||||||
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start)
|
||||||
|
return c.json({ error: 'Invalid input' }, 400);
|
||||||
|
const [u] = await db.select({ id: user.id }).from(user).where(eq(user.id, d.user_id));
|
||||||
|
if (!u) return c.json({ error: 'User not found' }, 404);
|
||||||
|
const [act] = await db.select().from(activities).where(eq(activities.id, d.activity_id));
|
||||||
|
if (!act) return c.json({ error: 'Activity not found' }, 404);
|
||||||
|
if (d.paused_seconds > Math.round((end.getTime() - start.getTime()) / 1000))
|
||||||
|
return c.json({ error: 'Invalid input' }, 400);
|
||||||
|
const [row] = await db
|
||||||
|
.insert(workSessions)
|
||||||
|
.values({
|
||||||
|
userId: d.user_id,
|
||||||
|
activityId: d.activity_id,
|
||||||
|
insoleType: d.insole_type,
|
||||||
|
pairCount: d.pair_count,
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
durationSeconds: computeDuration(start.getTime(), end.getTime(), d.paused_seconds),
|
||||||
|
pausedSeconds: d.paused_seconds,
|
||||||
|
pausedAt: null,
|
||||||
|
status: 'completed',
|
||||||
|
source: 'manual',
|
||||||
|
notes: d.notes ?? null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return c.json(toWorkSession(row, { activityName: act.name }));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- **PUT `:id`** — load the row (any user); 404 if missing; validate activity; if `end_time`
|
||||||
|
present validate `end ≥ start` and `paused ≤ span`, set `durationSeconds` via `computeDuration`,
|
||||||
|
else `durationSeconds = null` and (if `status==='active'`) keep it open. Set the editable
|
||||||
|
fields; return `toWorkSession`.
|
||||||
|
- **stop `:id`** — load active row; if `pausedAt`, fold the open span into `pausedSeconds`;
|
||||||
|
`end=now`; `durationSeconds=computeDuration(...)`; `status='completed'`, `pausedAt=null`.
|
||||||
|
- **discard `:id`** — load active row; `status='discarded'`, `end=now`.
|
||||||
|
- Register `stop`/`discard` (literal subpaths) and the bare `:id` PUT so they don't collide
|
||||||
|
(Hono matches `/api/admin/sessions/:id/stop` distinctly from `/:id`).
|
||||||
|
- [ ] **Step 4: Run tests + typecheck — green.**
|
||||||
|
- [ ] **Step 5: Commit** — `feat(api): admin manual-session create/edit/stop/discard + users roster`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Admin — api hooks + Sessions screen (list + filter + nav/route)
|
||||||
|
|
||||||
|
**Files:** `apps/admin/src/api/admin-sessions.ts`, `apps/admin/src/screens/Sessions.tsx` (create),
|
||||||
|
`apps/admin/src/components/Sidebar.tsx`, `apps/admin/src/App.tsx`, `apps/admin/src/styles.css`;
|
||||||
|
test `apps/admin/src/screens/Sessions.test.tsx`.
|
||||||
|
|
||||||
|
**Interfaces — Produces** `useAllSessions`, `useAdminUsers`, `useCreateManualSession`,
|
||||||
|
`useUpdateSession`, `useAdminStopSession`, `useAdminDiscardSession`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing test** (mock `apiFetch`): `Sessions` renders a row per session (worker +
|
||||||
|
activity + worked); the status filter narrows the list (e.g. selecting "actief" shows only
|
||||||
|
active); `Stop`/`Annuleer` appear only on active rows and call the right endpoints; ✎ present on
|
||||||
|
all rows.
|
||||||
|
- [ ] **Step 2: Run — fail.**
|
||||||
|
- [ ] **Step 3: Hooks** in `api/admin-sessions.ts` (keep `useActiveSessions`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useAllSessions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin', 'sessions', 'all'],
|
||||||
|
queryFn: () => apiFetch<WorkSession[]>('/api/admin/sessions'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function useAdminUsers() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin', 'users'],
|
||||||
|
queryFn: () => apiFetch<{ id: string; name: string; email: string }[]>('/api/admin/users'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// useCreateManualSession / useUpdateSession / useAdminStopSession / useAdminDiscardSession:
|
||||||
|
// useMutation hitting POST /api/admin/sessions, PUT /api/admin/sessions/:id,
|
||||||
|
// POST /api/admin/sessions/:id/stop|/discard; each onSuccess invalidates ['admin','sessions'].
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Sidebar + route** — add `{ to: '/sessies', label: 'Sessies' }` to `navItems` in
|
||||||
|
`Sidebar.tsx`; remove `'Handmatig'` from `soonItems` (leaves `['Rapporten', 'Gebruikers']`). In
|
||||||
|
`App.tsx` import `Sessions` and add `<Route path="/sessies" element={<Sessions />} />`.
|
||||||
|
- [ ] **Step 5: Screen** — `Sessions.tsx`: title "Sessies", a status `<select>` filter
|
||||||
|
(alle/actief/voltooid/geannuleerd), `+ Nieuwe registratie` button (opens the form from Task 4 —
|
||||||
|
for now a stub/`onCreate` prop or local state placeholder), a table/list of rows with worked
|
||||||
|
time (reuse `formatTime` from `lib/elapsed`; show `Pauze …` when `paused_seconds>0`), ✎ edit and
|
||||||
|
(active only) `Stop`/`Annuleer` buttons wired to the hooks. Loading/error/empty states in Dutch.
|
||||||
|
- [ ] **Step 6: Styles** — add `.sessions-*`/table/action-button CSS to `styles.css`.
|
||||||
|
- [ ] **Step 7: Run admin tests + typecheck + build — green.**
|
||||||
|
- [ ] **Step 8: Commit** — `feat(admin): sessions management screen (list + filter + actions)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Admin — create/edit session form
|
||||||
|
|
||||||
|
**Files:** `apps/admin/src/components/SessionForm.tsx` (create), wire into
|
||||||
|
`apps/admin/src/screens/Sessions.tsx`, `apps/admin/src/styles.css`; test
|
||||||
|
`apps/admin/src/components/SessionForm.test.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests** (mock `apiFetch`/hooks): in **create** mode the form shows a worker
|
||||||
|
picker (from `useAdminUsers`) and submitting posts `CreateManualSessionInput` with the entered
|
||||||
|
values; in **edit** mode it prefills from the session, hides the worker picker, and submitting
|
||||||
|
PUTs the changed fields. The "gewerkt" preview = `end − start − paused`.
|
||||||
|
- [ ] **Step 2: Run — fail.**
|
||||||
|
- [ ] **Step 3: Implement `SessionForm.tsx`** — props `{ mode: 'create' | 'edit', session?,
|
||||||
|
onClose }`. Fields: worker `<select>` (create only), activity `<select>` (from `useActivities`),
|
||||||
|
insole-type toggles, pair-count stepper, start/end `datetime-local`, paused (minutes or H:MM),
|
||||||
|
status `<select>` (edit only), notes `<textarea>`, and a derived "gewerkt" line. Build the ISO
|
||||||
|
`start_time`/`end_time` from the datetime-local values. Submit via `useCreateManualSession` /
|
||||||
|
`useUpdateSession`; close on success. Inline error on 400.
|
||||||
|
- [ ] **Step 4: Wire into Sessions** — `+ Nieuwe registratie` opens it in create mode; ✎ opens it
|
||||||
|
in edit mode with the row's session. Modal or inline panel — keep it simple.
|
||||||
|
- [ ] **Step 5: Run admin tests + typecheck + build — green.**
|
||||||
|
- [ ] **Step 6: Commit** — `feat(admin): manual session create/edit form`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Admin — Stop/Annuleer on the Live view
|
||||||
|
|
||||||
|
**Files:** `apps/admin/src/screens/Live.tsx`, `apps/admin/src/styles.css`; test
|
||||||
|
`apps/admin/src/screens/Live.test.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing test:** an active LiveCard shows `Stop` and `Annuleer`; clicking `Stop`
|
||||||
|
calls `POST /api/admin/sessions/:id/stop`, `Annuleer` calls `…/discard`.
|
||||||
|
- [ ] **Step 2: Run — fail.**
|
||||||
|
- [ ] **Step 3: Implement** — add the two buttons to `LiveCard`, wired to `useAdminStopSession` /
|
||||||
|
`useAdminDiscardSession`; on success the active query invalidates and the card drops off.
|
||||||
|
- [ ] **Step 4: Run admin tests + typecheck + build — green.**
|
||||||
|
- [ ] **Step 5: Commit** — `feat(admin): stop/cancel an active session from the live view`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Worker — poll + reconcile to server truth
|
||||||
|
|
||||||
|
**Files:** `apps/worker/src/api/sessions.ts`, `apps/worker/src/screens/Stopwatch.tsx`; test
|
||||||
|
`apps/worker/src/screens/Stopwatch.test.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests** (mock `apiFetch`): with a session running locally, when the
|
||||||
|
active-sessions query resolves to a list **without** that session, the stopwatch resets to the
|
||||||
|
idle/start state and shows **"Deze sessie is door de beheerder gestopt."**; a 409 from the stop
|
||||||
|
mutation resets locally (no error surfaced).
|
||||||
|
- [ ] **Step 2: Run — fail.**
|
||||||
|
- [ ] **Step 3: Poll** — in `api/sessions.ts`, add `refetchInterval: 15000` to `useActiveSessions`.
|
||||||
|
- [ ] **Step 4: Reconcile** — in `Stopwatch.tsx`, extend the active-session effect: when
|
||||||
|
`activeSessionsQuery.data` is present and the worker has a local `sessionId` that is **not** in
|
||||||
|
the returned active list, call `resetTimer()` and set a transient `stoppedByAdmin` notice
|
||||||
|
(cleared on next start). Keep the existing "adopt an active session when idle" recovery.
|
||||||
|
- [ ] **Step 5: 409 handling** — give `useStopSession`/`useDiscardSession` (or the `handleStop`/
|
||||||
|
`handleDiscard` callers) an error path: if the error is `ApiError` with `status === 409`, call
|
||||||
|
`resetTimer()` (it's already closed) instead of leaving the timer stuck.
|
||||||
|
- [ ] **Step 6: Run worker tests + typecheck + build — green.**
|
||||||
|
- [ ] **Step 7: Commit** — `feat(worker): poll + reconcile stopwatch to server (admin stop/cancel)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Docs, lint, verification
|
||||||
|
|
||||||
|
**Files:** `docs/roadmap.md`, `docs/sessions/2026-06-17-phase-3b1-manual-sessions.md` (create).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Lint/format** — `npx oxlint` clean; `npx oxfmt` on changed files only.
|
||||||
|
- [ ] **Step 2: Full green** — `yarn workspace @solelog/api typecheck && test`;
|
||||||
|
`yarn workspace @solelog/admin typecheck && test && build`;
|
||||||
|
`yarn workspace @solelog/worker typecheck && test && build`.
|
||||||
|
- [ ] **Step 3: Live smoke (preferred)** — start API, seed; as admin: `GET /api/admin/users`,
|
||||||
|
`POST /api/admin/sessions` (manual, confirm duration excludes paused + `source=manual`),
|
||||||
|
`PUT` edit, `stop`/`discard` on a worker's active session; then **kill the server tree + free
|
||||||
|
port 3000**.
|
||||||
|
- [ ] **Step 4: Docs** — session log (goal/work/verification/outcome) + a roadmap note (Phase 3b·1
|
||||||
|
done; reports/export and user management remain).
|
||||||
|
- [ ] **Step 5: Commit** — `docs: phase 3b·1 manual-sessions session log + roadmap note`.
|
||||||
|
|
||||||
|
## Self-Review notes
|
||||||
|
|
||||||
|
- Duration is always derived server-side (`computeDuration`) — never accepted from the client.
|
||||||
|
- `stop`/`discard` literal subpaths vs the bare `:id` PUT don't collide in Hono.
|
||||||
|
- Worker reconciliation keys off "my local session id is absent from the server's active list" —
|
||||||
|
covers admin stop, admin discard, and admin edit-to-completed alike.
|
||||||
|
- No migration: all fields already exist on `work_sessions` (incl. pause fields from the prior
|
||||||
|
cycle).
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Phase 3b·1 — Manual Session Entry/Edit + Admin Stop/Fix — Design
|
||||||
|
|
||||||
|
- **Created:** 2026-06-17
|
||||||
|
- **Status:** Approved (brainstorming) — ready for implementation plan
|
||||||
|
- **Tracker:** Plane (workspace `solelog`, project SoleLog)
|
||||||
|
- **Cycle:** First of three Phase 3b cycles (then reports/export, then user management)
|
||||||
|
- **Touches:** `packages/shared`, `apps/api`, `apps/admin`, `apps/worker`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
An admin can find any work session, **create** a manual one for a worker, **edit/correct** any
|
||||||
|
session, and **stop or cancel** a worker's stuck active session — the "manual fallback wherever
|
||||||
|
something fails" from the vision. Because the backend is the source of truth, the **worker
|
||||||
|
converges** to admin changes (no stuck stopwatch).
|
||||||
|
|
||||||
|
_Done when:_ an admin can list/filter all sessions, hand-create a completed session for a worker,
|
||||||
|
edit any session's fields (server recomputes duration), and stop/cancel a worker's active
|
||||||
|
session — and within ~15s the worker's stopwatch reflects a stop/cancel done by the admin.
|
||||||
|
|
||||||
|
## Scope decisions (confirmed during brainstorming, 2026-06-17)
|
||||||
|
|
||||||
|
1. **Slicing:** three sequenced 3b cycles; **this one first** (manual entry/edit + admin
|
||||||
|
stop/fix), then reports/export, then user management.
|
||||||
|
2. **Sessions UI:** a full **"Sessies"** admin screen (the all-sessions list deferred from 3a
|
||||||
|
lands here; the reports cycle reuses it).
|
||||||
|
3. **Editable fields:** start/end time, activity, insole type, pair count, paused seconds,
|
||||||
|
notes, status. The worker is chosen **only on create** — no reassignment on edit. Duration is
|
||||||
|
always **derived** server-side (`end − start − paused`), never typed.
|
||||||
|
4. **Worker convergence:** folded into this cycle — the worker polls and reconciles to server
|
||||||
|
truth (an admin stop/cancel reflects on the phone within ~15s; no stuck state).
|
||||||
|
|
||||||
|
## A. Backend (under the existing admin-gated `/api/admin/*` guard in `routes/admin.ts`)
|
||||||
|
|
||||||
|
- `GET /api/admin/users` — `{ id, name, email }[]` from the `user` table (ordered by name), to
|
||||||
|
populate the create form's worker picker. Direct DB query — no better-auth client dependency.
|
||||||
|
- `POST /api/admin/sessions` — manual create. Body `CreateManualSessionInput`:
|
||||||
|
`user_id, activity_id, insole_type, pair_count, start_time, end_time, paused_seconds?, notes?`.
|
||||||
|
Produces a **completed** session, `source='manual'`, `paused_at=null`,
|
||||||
|
`duration_seconds = max(0, round((end−start)/1000) − paused_seconds)`.
|
||||||
|
- `PUT /api/admin/sessions/:id` — edit any session. Body `AdminUpdateSessionInput`:
|
||||||
|
`activity_id, insole_type, pair_count, start_time, end_time(nullable), paused_seconds, notes,
|
||||||
|
status`. Recomputes `duration_seconds` from times − paused when `end_time` is present; when
|
||||||
|
`status='active'`/`end_time` null, `duration_seconds=null`. No user reassignment.
|
||||||
|
- `POST /api/admin/sessions/:id/stop` — quick "stop now": fold any open pause, `end=now`,
|
||||||
|
`status='completed'`, recompute duration.
|
||||||
|
- `POST /api/admin/sessions/:id/discard` — `status='discarded'`, `end=now`.
|
||||||
|
|
||||||
|
New `@solelog/shared` contracts: `CreateManualSessionInput`, `AdminUpdateSessionInput`. **No DB
|
||||||
|
migration** — reuses existing `work_sessions` columns (incl. the pause fields). Responses use the
|
||||||
|
existing `toWorkSession` mapper (so they carry `user_name`/`activity_name` where joined).
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
`end ≥ start`; `pair_count ≥ 1`; `paused_seconds ≥ 0` and `≤ (end−start)`; activity must exist;
|
||||||
|
user must exist (create); `insole_type` a valid `InsoleType`. Invalid → 400; missing
|
||||||
|
session/user → 404. No hard delete — cancellation is `status='discarded'` (already excluded from
|
||||||
|
exports).
|
||||||
|
|
||||||
|
## B. Admin UI
|
||||||
|
|
||||||
|
- **`components/Sidebar.tsx`** — add `{ to: '/sessies', label: 'Sessies' }` to `navItems`; drop
|
||||||
|
`'Handmatig'` from the muted `soonItems` (now built → leaves `['Rapporten', 'Gebruikers']`).
|
||||||
|
- **`App.tsx`** — add `<Route path="/sessies" element={<Sessions />} />`.
|
||||||
|
- **`screens/Sessions.tsx`** — lists all sessions via `useAllSessions` (`GET /api/admin/sessions`,
|
||||||
|
newest first), a status filter (alle / actief / voltooid / geannuleerd), and a
|
||||||
|
`+ Nieuwe registratie` button. Each row: worker · activity · type · worked (+ pauze) · date.
|
||||||
|
**Active** rows show `[Stop]` `[Annuleer]`; **all** rows show ✎ edit.
|
||||||
|
- **`components/SessionForm.tsx`** — shared create/edit form: worker picker (create only, from
|
||||||
|
`useAdminUsers`), activity dropdown, insole-type, pair count, start/end datetime-local, paused,
|
||||||
|
status (edit only), notes, and a live **"gewerkt"** preview. Submits create or update.
|
||||||
|
- **`api/admin-sessions.ts`** — add `useAllSessions`, `useAdminUsers`, `useCreateManualSession`,
|
||||||
|
`useUpdateSession`, `useAdminStopSession`, `useAdminDiscardSession` (all invalidate the
|
||||||
|
`['admin','sessions']` query family; keep the existing `useActiveSessions`).
|
||||||
|
- **`screens/Live.tsx`** — add `[Stop]` `[Annuleer]` to each `LiveCard`, wired to the admin
|
||||||
|
stop/discard hooks (invalidate the active query).
|
||||||
|
|
||||||
|
## C. Worker convergence (`apps/worker`)
|
||||||
|
|
||||||
|
- **`api/sessions.ts`** — give `useActiveSessions` a `refetchInterval: 15000` (poll), plus the
|
||||||
|
default refetch-on-window-focus.
|
||||||
|
- **`screens/Stopwatch.tsx`** — extend the active-session effect to **reconcile**: if a session is
|
||||||
|
running locally (`sessionId` set) but the latest `activeSessionsQuery.data` no longer contains a
|
||||||
|
matching **active** session for it, the session was stopped/cancelled elsewhere → reset the
|
||||||
|
timer and surface a brief notice **"Deze sessie is door de beheerder gestopt."** Also: treat a
|
||||||
|
**409** from the worker's own stop/discard as already-closed → reset locally instead of erroring.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Worker reconciliation notice is transient (dismissible / auto-clears on next start).
|
||||||
|
- Admin form: inline validation errors mirror the API 400s (end before start, count < 1).
|
||||||
|
- Stop/discard on a non-active session → 409; admin UI refetches and the row reflects truth.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **API** (`admin.test.ts`): create computes duration + `source='manual'`; edit recomputes and
|
||||||
|
rejects `end<start`; stop/discard act on **another** user's session; all 401/403 gated;
|
||||||
|
`GET /api/admin/users` returns the roster.
|
||||||
|
- **Admin:** Sessions list renders + status filter; create form posts the right body; edit
|
||||||
|
prefills + PUTs; Live `Stop`/`Annuleer` fire the right mutations.
|
||||||
|
- **Worker:** when the active query returns without a locally-running session, the stopwatch
|
||||||
|
resets + shows the notice; a 409 on stop resets instead of erroring.
|
||||||
|
|
||||||
|
## Out of scope (later 3b cycles)
|
||||||
|
|
||||||
|
- Aggregated/on-screen **reporting** + all-users filtered CSV (reports cycle).
|
||||||
|
- Full **user management** UI — create/role/deactivate via `/api/auth/admin/*` (user-mgmt cycle);
|
||||||
|
this cycle only *reads* the roster (`GET /api/admin/users`) for the picker.
|
||||||
|
- Real-time push (SSE) — polling is sufficient at this scale.
|
||||||
|
|
||||||
|
## Build approach
|
||||||
|
|
||||||
|
spec → `writing-plans` → one **Workflow** (~7 TDD tasks), commit per task, final verify. Tracked
|
||||||
|
as a Plane epic.
|
||||||
Reference in New Issue
Block a user