Files
Project-SDE-WP-Suite/docs/waves/wave-11.md

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:

  • 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:

  • 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 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