diff --git a/html/auth-guard.js b/html/auth-guard.js
index 6d55683..1fb3f9f 100644
--- a/html/auth-guard.js
+++ b/html/auth-guard.js
@@ -125,12 +125,40 @@
// Nothing replaces it. Every signed-in page mounts the drawer, so there is no page
// left that would need a floating fallback pill.
+ // ── CR-019: usage ping ───────────────────────────────────────────────────
+ // One page_open event per authenticated load, sent from exactly ONE place
+ // (here) rather than from each page's own script - the shared-chrome lesson
+ // S4 and the token-drift lesson S5 both taught this codebase the hard way.
+ // Fire-and-forget: never blocks reveal(), never retries, never surfaces an
+ // error to the person using the app - a missed usage ping is not something
+ // anyone here should notice happening.
+ var TOOL_BY_PAGE = {
+ 'index.html': 'launcher',
+ 'work-package-suite.html': 'wizard',
+ 'wp-creation-index.html': 'creator',
+ 'field.html': 'field_view',
+ 'admin.html': 'admin',
+ 'users.html': 'directory'
+ };
+ function pingUsage() {
+ var page = (location.pathname.split('/').pop() || 'index.html');
+ var tool = TOOL_BY_PAGE[page] || page.replace(/\.html$/, '');
+ try {
+ fetch('/api/usage/ping', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tool: tool })
+ }).catch(function () {});
+ } catch (e) {}
+ }
+
function proceed(user) {
clearTimeout(safety);
window.WP_USER = user;
reveal();
if (window.WP_USER) {
window.wpFlags(); // start the feature-flag fetch; pages await it as needed
+ pingUsage();
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
}
}
diff --git a/server/app.py b/server/app.py
index 3df79e3..24f18e8 100644
--- a/server/app.py
+++ b/server/app.py
@@ -756,6 +756,11 @@ async def okta_callback(request: Request, db: Session = Depends(get_db)):
user.failed_attempts = 0
user.locked_until = None
user.last_login_at = models.utcnow()
+ # CR-019: one login event per Okta sign-in, written here rather than
+ # inferred from session creation elsewhere, so there is exactly one
+ # source of truth for "did this person sign in" - not one per page load
+ # afterward (T11.2 covers that separately, as page_open events).
+ db.add(models.UsageEvent(id=gen_id("uev"), username=user.username, tool="", event="login"))
db.commit()
db.refresh(user)
@@ -3374,6 +3379,29 @@ def create_feedback(body: CommentIn, user: models.User = Depends(auth.get_curren
return _save_comment(body, db, user)
+# ── CR-019: usage/activity metrics ──────────────────────────────────────────
+class UsageIn(BaseModel):
+ tool: str = ""
+ project_id: Optional[str] = None
+
+
+@app.post("/api/usage/ping")
+def usage_ping(body: UsageIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
+ """One usage_events row per authenticated page load. Called exactly once,
+ from auth-guard.js after wp-auth-ready fires (T11.2) - never duplicated
+ per page's own script, the same lesson S4's per-page nav already taught
+ this codebase. `user` comes from the session via get_current_user, never
+ from anything the client claims - identity here is a server-enforced
+ fact, matching every other write in this file, not a client-reported one."""
+ tool = (body.tool or "").strip()[:40]
+ db.add(models.UsageEvent(
+ id=gen_id("uev"), username=user.username, project_id=body.project_id,
+ tool=tool, event="page_open",
+ ))
+ db.commit()
+ return {"ok": True}
+
+
@app.get("/api/comments")
def list_comments(
source: Optional[str] = Query(None),