# Wave 11 — Usage and activity metrics **Items:** `CR-019` **Depends on:** wave 10 merged (it is; this wave does not wait on anything else) **Decision record:** `docs/waves/decisions-2026-09-17.md` Seven tasks, one concern each, in build order. Do not start a task whose dependency is not merged. `CR-020` (bulk user editing) is reserved but not scoped — it does not belong in this wave. --- ### T11.1 — CR-019: `usage_events` table + migration - **Items:** `CR-019` - **Depends on:** nothing (first task) - **Blocks:** T11.2 - **Surface:** `server/` - **Files:** `server/models.py`, `server/alembic/versions/` **Do:** Add a `UsageEvent` model — append-only, same spirit as `AuditLog` but for navigation/feature-open events rather than business mutations. Suggested shape: `id`, `at` (indexed), `user_id` (or username — match whatever `AuditLog.actor` does today for consistency), `project_id` (nullable — not every event is project-scoped, e.g. opening the admin console), `tool` (e.g. `creator`, `wizard`, `field_view`, `dashboard`, `admin`, `directory`), `event` (e.g. `page_open`, `login`), `detail` (JSON, optional). Write the migration. Do not touch `AuditLog` — this is a new table, not an extension of it (see the decision record's reasoning). **Do not:** fold this into `AuditLog`. They serve different questions and mixing them makes the existing audit trail noisier for its existing readers. **Done when:** - [ ] `UsageEvent` exists with an indexed `at` column (this table will be scanned by date range constantly) - [ ] migration applies cleanly against both SQLite (dev) and Postgres (prod) — render `alembic upgrade --sql` for postgresql and read it before calling this done, per the class of defect `BL-027` logged - [ ] no change to `AuditLog`'s shape or behavior --- ### T11.2 — CR-019: capture the events - **Items:** `CR-019` - **Depends on:** T11.1 - **Blocks:** T11.3 - **Surface:** `server/` + `html/` - **Files:** `server/app.py` (new endpoint), `html/auth-guard.js` **Problem:** Six pages need this and none should implement it separately — that is exactly how `S4`'s "no global nav on two pages" and the four parallel token systems (`S5`) happened. `auth-guard.js` is already loaded first, in the `
`, on all six protected pages (`index.html`, `field.html`, `users.html`, `wp-creation-index.html`, `work-package-suite.html`, `admin.html`) and already knows the verified user once the `wp-auth-ready` event fires. That is the one place this belongs. **Do:** Add a small `POST /api/usage/ping`-style endpoint that writes one `UsageEvent` row per call, keyed to the session (server trusts the session, not anything the client claims about identity). Call it once from `auth-guard.js` after `wp-auth-ready`, tagging `tool` from the page's own path. Also write a `login` event at the point a session is actually established (reuse whatever `okta_callback` already does at sign-in — do not add a second source of truth for "did this person log in"). **Do not:** build a per-page capture call. If a page needs this and `auth-guard.js` does not cover it, fix `auth-guard.js`, not the page. **Done when:** - [ ] one `page_open` event is recorded for a real sign-in on each of the six pages, verified per page - [ ] exactly one `login` event per Okta sign-in, not one per page load after it - [ ] the endpoint rejects a request with no valid session (this is server- enforced identity, not client-reported) - [ ] no page other than `auth-guard.js` calls this endpoint directly --- ### T11.3 — CR-019: aggregation endpoint with filters - **Items:** `CR-019` - **Depends on:** T11.2 - **Blocks:** T11.4, T11.5 - **Surface:** `server/` - **Files:** `server/app.py` **Do:** Build the read side: active-user counts by day/week/month, per-user last-active timestamp (derived from `UsageEvent`, not `User.last_login_at`, which only ever holds one value), and a per-tool usage breakdown. Accept query filters: date range, project, user, tool — combinable, per the decision record. This is server aggregation, the same principle `B4` established for the pipeline strip: the browser asks for a number, the server computes it from real rows, nothing is derived client-side from a partial cache. **Done when:** - [ ] active-user counts are correct against a seeded fixture with known dates - [ ] filters combine correctly (verified: user + date range + tool together narrows correctly, not just each alone) - [ ] a project filter that matches nothing returns an empty result, not an error or the unfiltered total --- ### T11.4 — CR-019: export, raw and sanitized - **Items:** `CR-019` - **Depends on:** T11.3 - **Blocks:** T11.5 - **Surface:** `server/` - **Files:** `server/app.py` **Do:** A CSV export endpoint over the same filtered query T11.3 exposes. Two modes: raw (real usernames, the console's default) and sanitized. Sanitized mode replaces the actor field with a stable pseudonymous id — a per-user hash, consistent across rows in the same export and across separate export runs — so an external system (Power BI or similar) can still group and trend "by user" without ever receiving a real name. Do not simply drop the identity column; that breaks per-user grouping downstream, which defeats the point of an activity export. **Done when:** - [ ] raw export contains real usernames - [ ] sanitized export never contains a real username or email anywhere in the file, including in a `detail` blob if one is included - [ ] the same real user maps to the same pseudonymous id within one export AND across two separate export runs (a hash of something stable, not a per-request random id) - [ ] both modes otherwise contain identical rows for the same filter --- ### T11.5 — CR-019: admin console Activity tab - **Items:** `CR-019` - **Depends on:** T11.3, T11.4 - **Blocks:** T11.7 - **Surface:** `html/` - **Files:** `html/admin.html`, `html/admin.js` **Do:** New tab, same role gate as the User Directory. Filters (date range, project, user, tool) driving the tables from T11.3; export buttons (raw and sanitized) calling T11.4. Build accessible from the start per `C1` — this is a new component, not a legacy one carrying an old defect forward: real `