9.6 KiB
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:
UsageEventexists with an indexedatcolumn (this table will be scanned by date range constantly)- migration applies cleanly against both SQLite (dev) and Postgres (prod) —
render
alembic upgrade --sqlfor postgresql and read it before calling this done, per the class of defectBL-027logged - 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_openevent is recorded for a real sign-in on each of the six pages, verified per page - exactly one
loginevent 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.jscalls 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
detailblob 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:
- 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)
- every filter is a real form control, keyboard-operable (date/select/text
inputs and a
<button>, no click-div) - both export buttons produce the files T11.4 defines (verified against the live endpoint in T11.4's own checks, and present/wired here)
- works at 390px and 1440px — verified 2026-09-23 via
tests/baseline_shots.py --pages adminrun 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 indocs/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(theusage-adminpanel,admin.js:666-699),html/wp-usage.jsand 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-adminpanel and its markup are gone fromadmin.html/admin.js - a decision on
wp-usage.jsitself 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-019acceptance criteria indecisions-2026-09-17.mdare met or a failure is stated with a reason - screenshots committed
- smoke test and
seed_demo.pyboth 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-019fully accounted for, no open acceptance criteria