234 lines
9.6 KiB
Markdown
234 lines
9.6 KiB
Markdown
# 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 `<head>`,
|
|
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
|
|
`<button>`/`<select>` controls, keyboard-reachable, `aria-live` on any
|
|
count that updates without a page reload, focus visible throughout.
|
|
|
|
**Done when:**
|
|
|
|
- [x] the tab is reachable only by an admin (the card lives inside admin.html,
|
|
already gated client-side by gateByRole(); the API underneath it is
|
|
independently gated server-side by require_user_manager regardless)
|
|
- [x] every filter is a real form control, keyboard-operable (date/select/text
|
|
inputs and a `<button>`, no click-div)
|
|
- [x] both export buttons produce the files T11.4 defines (verified against
|
|
the live endpoint in T11.4's own checks, and present/wired here)
|
|
- [x] works at 390px and 1440px — verified 2026-09-23 via
|
|
`tests/baseline_shots.py --pages admin` run locally on Windows (this
|
|
sandbox has no headless browser available; the script was run on the
|
|
user's machine instead, after installing Python via winget since it
|
|
wasn't present). Screenshots in `docs/reference/baseline/admin-390.png`
|
|
/ `admin-1440.png`. No JS errors, no horizontal overflow at either
|
|
width; the card rendered with real seeded data (events, by-tool,
|
|
per-user-last-active tables) confirming the filters and summary read
|
|
correctly, not just that the markup exists.
|
|
|
|
---
|
|
|
|
### T11.6 — CR-019: retire the per-browser Usage report
|
|
|
|
- **Items:** `CR-019`
|
|
- **Depends on:** T11.5
|
|
- **Blocks:** T11.7
|
|
- **Surface:** `html/`
|
|
- **Files:** `html/admin.js` (the `usage-admin` panel, `admin.js:666-699`),
|
|
`html/wp-usage.js` and its two call sites
|
|
|
|
**Do:** Remove the old per-browser `usage-admin` panel from `admin.js` now that
|
|
the real one exists, per the 2026-09-17 decision. Decide what happens to
|
|
`wp-usage.js`'s recording calls (wizard dwell-tracking, the creator's
|
|
equivalent): they were never reliably tied to a real identity, so they are not
|
|
a data source the new report can adopt. Default to removing the recorder too
|
|
unless it is still doing something useful on its own (re-read what it actually
|
|
records before deciding — do not assume from this file alone).
|
|
|
|
**Do not:** leave the old panel in place "just in case." Two activity reports
|
|
showing two different numbers is worse than one.
|
|
|
|
**Done when:**
|
|
|
|
- [ ] the old `usage-admin` panel and its markup are gone from `admin.html`/
|
|
`admin.js`
|
|
- [ ] a decision on `wp-usage.js` itself is recorded (removed, or kept with a
|
|
stated reason) — not left ambiguous
|
|
- [ ] nothing else in the app references the removed code; grep confirms
|
|
|
|
---
|
|
|
|
### T11.7 — CR-019: verification
|
|
|
|
- **Items:** `CR-019`
|
|
- **Depends on:** T11.6
|
|
- **Blocks:** nothing
|
|
- **Surface:** `html/` + `server/`
|
|
- **Files:** as touched above
|
|
|
|
**Do:** Full verification per `CLAUDE.md`: run the app locally, exercise the
|
|
new tab at 390px and 1440px, before/after screenshots, run the existing smoke
|
|
test and `seed_demo.py`, run the full suite.
|
|
|
|
**Done when:**
|
|
|
|
- [ ] all `CR-019` acceptance criteria in `decisions-2026-09-17.md` are met or
|
|
a failure is stated with a reason
|
|
- [ ] screenshots committed
|
|
- [ ] smoke test and `seed_demo.py` both still pass
|
|
- [ ] full test suite passes
|
|
|
|
---
|
|
|
|
## Wave 11 exit criteria
|
|
|
|
- [ ] real, server-side activity data exists per user, indefinitely retained
|
|
- [ ] the admin console shows it, filterable by date/project/user/tool
|
|
- [ ] export works in both raw and sanitized form
|
|
- [ ] the old per-browser report is gone, not duplicated
|
|
- [ ] `CR-019` fully accounted for, no open acceptance criteria
|