From 9e4d4480beb470d8a2a5936882b27a54c3f9fb99 Mon Sep 17 00:00:00 2001 From: Yusufa09 Date: Sun, 9 Aug 2026 11:28:55 -0400 Subject: [PATCH 1/6] analytics page --- PanTS-Demo/src/App.tsx | 2 + .../src/components/AIAssistant/AISidebar.tsx | 2 + .../src/components/AnalyticsRouteTracker.tsx | 57 + PanTS-Demo/src/components/AuthModal.tsx | 3 + PanTS-Demo/src/components/UpgradeDialog.tsx | 8 +- PanTS-Demo/src/contexts/authContext.tsx | 58 +- PanTS-Demo/src/helpers/accountProfile.test.ts | 70 +- PanTS-Demo/src/helpers/accountProfile.ts | 56 +- PanTS-Demo/src/helpers/analytics.test.ts | 169 ++ PanTS-Demo/src/helpers/analytics.ts | 197 ++ .../src/routes/Homepage/hooks/useDashboard.ts | 3 + .../src/routes/Settings/PlanSettings.tsx | 2 + .../src/routes/Settings/ProfileSettings.tsx | 24 +- PanTS-Demo/src/routes/Settings/index.tsx | 5 + PanTS-Demo/src/routes/UploadPage.tsx | 11 +- PanTS-Demo/src/routes/VisualizationPage.tsx | 14 +- PanTS-Demo/src/test/accountPage.test.tsx | 32 +- README.md | 10 + analytics/.gitignore | 2 + analytics/README.md | 56 + analytics/index.html | 17 + analytics/package-lock.json | 1841 +++++++++++++++++ analytics/package.json | 23 + analytics/src/App.tsx | 257 +++ analytics/src/api.ts | 71 + analytics/src/components/BarList.tsx | 54 + analytics/src/components/TrendLine.tsx | 102 + analytics/src/dashboard.css | 219 ++ analytics/src/format.ts | 48 + analytics/src/main.tsx | 15 + analytics/tsconfig.json | 21 + analytics/tsconfig.tsbuildinfo | 1 + analytics/vite.config.ts | 19 + flask-server/api/analytics_blueprint.py | 130 ++ flask-server/api/auth_blueprint.py | 32 +- flask-server/app.py | 11 +- ...f4d28_account_type_and_analytics_events.py | 85 + flask-server/models/analytics_event.py | 79 + flask-server/models/engine.py | 1 + flask-server/models/user.py | 5 + flask-server/services/analytics_store.py | 292 +++ flask-server/services/auth_store.py | 23 + .../functional/test_analytics_endpoints.py | 171 ++ .../tests/unit/test_analytics_store.py | 258 +++ .../tests/unit/test_analytics_vocabulary.py | 65 + 45 files changed, 4453 insertions(+), 168 deletions(-) create mode 100644 PanTS-Demo/src/components/AnalyticsRouteTracker.tsx create mode 100644 PanTS-Demo/src/helpers/analytics.test.ts create mode 100644 PanTS-Demo/src/helpers/analytics.ts create mode 100644 analytics/.gitignore create mode 100644 analytics/README.md create mode 100644 analytics/index.html create mode 100644 analytics/package-lock.json create mode 100644 analytics/package.json create mode 100644 analytics/src/App.tsx create mode 100644 analytics/src/api.ts create mode 100644 analytics/src/components/BarList.tsx create mode 100644 analytics/src/components/TrendLine.tsx create mode 100644 analytics/src/dashboard.css create mode 100644 analytics/src/format.ts create mode 100644 analytics/src/main.tsx create mode 100644 analytics/tsconfig.json create mode 100644 analytics/tsconfig.tsbuildinfo create mode 100644 analytics/vite.config.ts create mode 100644 flask-server/api/analytics_blueprint.py create mode 100644 flask-server/migrations/versions/c7e3a91f4d28_account_type_and_analytics_events.py create mode 100644 flask-server/models/analytics_event.py create mode 100644 flask-server/services/analytics_store.py create mode 100644 flask-server/tests/functional/test_analytics_endpoints.py create mode 100644 flask-server/tests/unit/test_analytics_store.py create mode 100644 flask-server/tests/unit/test_analytics_vocabulary.py diff --git a/PanTS-Demo/src/App.tsx b/PanTS-Demo/src/App.tsx index 4e08e1b..1aec7ef 100644 --- a/PanTS-Demo/src/App.tsx +++ b/PanTS-Demo/src/App.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense } from "react"; import { BrowserRouter, Navigate, Route, Routes } from "react-router"; import "./App.css"; +import AnalyticsRouteTracker from "./components/AnalyticsRouteTracker"; import AuthModal from "./components/AuthModal"; import { AnnotationProvider } from "./contexts/annotationContexts"; import { AuthProvider } from "./contexts/authContext"; @@ -62,6 +63,7 @@ function App() {
+ }> diff --git a/PanTS-Demo/src/components/AIAssistant/AISidebar.tsx b/PanTS-Demo/src/components/AIAssistant/AISidebar.tsx index 0c8ba48..a5643fc 100644 --- a/PanTS-Demo/src/components/AIAssistant/AISidebar.tsx +++ b/PanTS-Demo/src/components/AIAssistant/AISidebar.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useAuth } from "../../contexts/authContext"; +import { track } from "../../helpers/analytics"; import { API_BASE } from "../../helpers/constants"; import type { AIAction, @@ -785,6 +786,7 @@ export default function AISidebar({ .slice(-12) .map((message) => ({ role: message.role, content: message.content })); + track("assistant_send_message"); setInput(""); setAttachments([]); if (textareaRef.current) textareaRef.current.style.height = "auto"; diff --git a/PanTS-Demo/src/components/AnalyticsRouteTracker.tsx b/PanTS-Demo/src/components/AnalyticsRouteTracker.tsx new file mode 100644 index 0000000..0a5e978 --- /dev/null +++ b/PanTS-Demo/src/components/AnalyticsRouteTracker.tsx @@ -0,0 +1,57 @@ +import { useEffect, useRef } from "react"; +import { useLocation } from "react-router-dom"; +import { flush, routePattern, trackPageView } from "../helpers/analytics"; + +// Turns navigation into "time spent here" numbers. Mounted once, inside the +// router, renders nothing. +// +// Time is only counted while the tab is actually in front. Without that, a tab +// left open overnight would report the viewer as the most-used feature in the +// product by a wide margin, which is true of the tab and false of the person. +const AnalyticsRouteTracker: React.FC = () => { + const { pathname } = useLocation(); + // Held in refs, not state: this component must never re-render anything. + const route = useRef(null); + const since = useRef(Date.now()); + + useEffect(() => { + // Close out the previous route before opening the new one. + const record = () => { + if (route.current) trackPageView(route.current, Date.now() - since.current); + }; + + record(); + route.current = routePattern(pathname); + since.current = Date.now(); + + return record; + // Only on a route change: the effect's whole job is the transition. + }, [pathname]); + + useEffect(() => { + const onHidden = () => { + if (document.visibilityState !== "hidden") { + // Back in front: start a fresh stretch rather than counting the + // time the tab spent in the background. + since.current = Date.now(); + return; + } + if (route.current) trackPageView(route.current, Date.now() - since.current); + since.current = Date.now(); + // The tab may not come back — get what we have to the server now. + flush(true); + }; + + document.addEventListener("visibilitychange", onHidden); + // pagehide rather than unload: unload doesn't fire on mobile Safari. + window.addEventListener("pagehide", onHidden); + return () => { + document.removeEventListener("visibilitychange", onHidden); + window.removeEventListener("pagehide", onHidden); + }; + }, []); + + return null; +}; + +export default AnalyticsRouteTracker; diff --git a/PanTS-Demo/src/components/AuthModal.tsx b/PanTS-Demo/src/components/AuthModal.tsx index 1fda64a..f1f50b7 100644 --- a/PanTS-Demo/src/components/AuthModal.tsx +++ b/PanTS-Demo/src/components/AuthModal.tsx @@ -2,6 +2,7 @@ import { IconBrandGithub, IconBrandGoogle } from "@tabler/icons-react"; import React, { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { useAuth } from "../contexts/authContext"; +import { track } from "../helpers/analytics"; import "./AuthModal.css"; // The one auth popup: signing in and creating an account are the same card with @@ -73,6 +74,8 @@ const AuthModal: React.FC = () => { try { if (isSignup) await signUp(email, password); else await signIn(email, password); + // After the await: this counts successful sign-ins, not attempts. + track(isSignup ? "auth_sign_up" : "auth_sign_in"); // authContext auto-closes the popup once the user is set. } catch (err) { // Surface the API's message ("Invalid email or password", "An account diff --git a/PanTS-Demo/src/components/UpgradeDialog.tsx b/PanTS-Demo/src/components/UpgradeDialog.tsx index 90c1f9e..6a526b2 100644 --- a/PanTS-Demo/src/components/UpgradeDialog.tsx +++ b/PanTS-Demo/src/components/UpgradeDialog.tsx @@ -1,6 +1,7 @@ import React, { useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { nextPlanUp, planLabel, PLANS, type PlanId } from "../helpers/accountProfile"; +import { track } from "../helpers/analytics"; import "./UpgradeDialog.css"; // The one place a plan limit is explained. Every blocker — a locked model, a @@ -93,6 +94,7 @@ const UpgradeDialog: React.FC<{ block: UpgradeBlock | null; onClose: () => void useEffect(() => { if (!block) return; + track("plan_limit_hit"); const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); @@ -133,7 +135,11 @@ const UpgradeDialog: React.FC<{ block: UpgradeBlock | null; onClose: () => void diff --git a/PanTS-Demo/src/contexts/authContext.tsx b/PanTS-Demo/src/contexts/authContext.tsx index a3479a0..05d67d9 100644 --- a/PanTS-Demo/src/contexts/authContext.tsx +++ b/PanTS-Demo/src/contexts/authContext.tsx @@ -20,10 +20,11 @@ // limits are enforced server-side (see flask-server/services/plan_store.py). // There is no payment step — pricing hasn't been set — but the limits bite. // +// Account type is real too: user_account.account_type, set through PATCH +// /auth/me. It still gates nothing — it exists so activity can be grouped by it +// — but it is no longer a localStorage value the server has never seen. +// // Not wired yet: emailNotifications -> B3, still a client-only localStorage pref. -// Also client-only: account type (helpers/accountProfile.ts), which is -// self-reported and gates nothing. The name is NOT part of that — it has a real -// column and goes through updateName. import { createContext, useCallback, @@ -34,12 +35,11 @@ import { type ReactNode, } from "react"; import { - DEFAULT_PROFILE, - loadProfile, - updateProfile as persistProfilePatch, type AccountProfile, + type AccountType, type PlanId, } from "../helpers/accountProfile"; +import { track } from "../helpers/analytics"; import { API_BASE } from "../helpers/constants"; export type AuthUser = { @@ -52,7 +52,7 @@ export type AuthUser = { emailNotifications: boolean; // client-only preference until B3 /** Billing plan, from user_account.plan. Its limits are enforced server-side. */ plan: PlanId; - /** Self-reported account type. Client-only, and gates nothing. */ + /** Self-reported account type, from user_account.account_type. Gates nothing. */ profile: AccountProfile; }; @@ -91,8 +91,8 @@ type AuthContextValue = { * in — resolves with the deadline and the grace period, so the UI can say so. */ deleteAccount: () => Promise<{ restoreBy: string; graceDays: number }>; - /** Patch the self-reported account type. */ - updateAccountProfile: (patch: Partial) => void; + /** Patch the self-reported account type. Persisted server-side. */ + updateAccountProfile: (patch: Partial) => Promise; /** Move to another plan. No payment step — pricing isn't set. */ setPlan: (plan: PlanId) => Promise; /** Current plan usage, or null until loaded. Refreshed by refreshUsage(). */ @@ -143,7 +143,13 @@ const savePref = (id: string, on: boolean) => { } }; -type ApiUser = { id: string; email: string; name?: string | null; plan?: string | null }; +type ApiUser = { + id: string; + email: string; + name?: string | null; + plan?: string | null; + account_type?: string | null; +}; const mapApiUser = (u: ApiUser): AuthUser => { const custom = (u.name || "").trim(); return { @@ -154,7 +160,7 @@ const mapApiUser = (u: ApiUser): AuthUser => { hasCustomName: custom.length > 0, emailNotifications: loadPref(u.id), plan: (u.plan as PlanId) || "free", - profile: loadProfile(u.id) ?? DEFAULT_PROFILE, + profile: { accountType: (u.account_type as AccountType) || null }, }; }; @@ -286,6 +292,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); const signOut = useCallback(async () => { + track("auth_sign_out"); // Clear locally first so the UI updates instantly, then revoke server-side. setUser(null); pingOtherTabs(); @@ -296,10 +303,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, []); - const promptAuth = useCallback( - (mode: AuthMode = "signin") => setAuthPrompt({ open: true, mode }), - [] - ); + const promptAuth = useCallback((mode: AuthMode = "signin") => { + track("auth_open_modal"); + setAuthPrompt({ open: true, mode }); + }, []); const closeAuthPrompt = useCallback( () => setAuthPrompt((p) => ({ ...p, open: false })), [] @@ -363,16 +370,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { return { restoreBy: data.restore_by as string, graceDays: Number(data.grace_days) }; }, []); - // Writes storage first, then state — deliberately not inside the setUser - // updater, which StrictMode invokes twice. - const updateAccountProfile = useCallback( - (patch: Partial) => { - if (!user) return; - const profile = persistProfilePatch(user.id, patch); - setUser((prev) => (prev ? { ...prev, profile } : prev)); - }, - [user] - ); + const updateAccountProfile = useCallback(async (patch: Partial) => { + if (!("accountType" in patch)) return; + const res = await authFetch("/api/auth/me", { + method: "PATCH", + // "" clears it: the server reads an empty string as "not set". + body: JSON.stringify({ account_type: patch.accountType ?? "" }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || "Couldn't save your role. Try again."); + setUser(mapApiUser(data.user)); + }, []); const refreshUsage = useCallback(async () => { if (!user) { diff --git a/PanTS-Demo/src/helpers/accountProfile.test.ts b/PanTS-Demo/src/helpers/accountProfile.test.ts index 5fa7d42..4cb4ef9 100644 --- a/PanTS-Demo/src/helpers/accountProfile.test.ts +++ b/PanTS-Demo/src/helpers/accountProfile.test.ts @@ -1,26 +1,14 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { canPostprocess, - clearProfile, - DEFAULT_PROFILE, isModelLocked, limitsFor, - loadProfile, maxConcurrentScans, nextPlanUp, - persistProfile, PLAN_LIMITS, PLANS, - PROFILE_KEY_PREFIX, - updateProfile, } from "./accountProfile"; -const USER = "user-1"; - -beforeEach(() => { - localStorage.clear(); -}); - describe("plan limits", () => { it("gives Free only the free model", () => { expect(PLAN_LIMITS.free.models).toEqual(["LesionSegmenter"]); @@ -104,59 +92,3 @@ describe("plan cards", () => { expect(PLANS.map((p) => p.id).sort()).toEqual(Object.keys(PLAN_LIMITS).sort()); }); }); - -describe("loadProfile", () => { - it("returns null when the user has no stored profile", () => { - expect(loadProfile(USER)).toBeNull(); - }); - - it("returns null when storage holds malformed JSON", () => { - localStorage.setItem(`${PROFILE_KEY_PREFIX}${USER}`, "{not json"); - expect(loadProfile(USER)).toBeNull(); - }); - - it("ignores plan and onboarding keys left by an older build", () => { - // Both moved server-side; a stale local copy must not resurface. - localStorage.setItem( - `${PROFILE_KEY_PREFIX}${USER}`, - JSON.stringify({ plan: "enterprise", onboardingCompletedAt: "2026-01-01", accountType: "clinician" }) - ); - expect(loadProfile(USER)).toEqual({ accountType: "clinician" }); - }); - - it("keeps profiles for different users separate", () => { - persistProfile(USER, { accountType: "clinician" }); - persistProfile("user-2", { accountType: "researcher" }); - expect(loadProfile(USER)?.accountType).toBe("clinician"); - expect(loadProfile("user-2")?.accountType).toBe("researcher"); - }); -}); - -describe("updateProfile", () => { - it("starts from the defaults when nothing is stored yet", () => { - expect(updateProfile(USER, { accountType: "clinician" })).toEqual({ - ...DEFAULT_PROFILE, - accountType: "clinician", - }); - }); - - it("persists across a reload", () => { - updateProfile(USER, { accountType: "researcher" }); - expect(loadProfile(USER)?.accountType).toBe("researcher"); - }); - - it("can clear the account type back to unset", () => { - updateProfile(USER, { accountType: "student" }); - expect(updateProfile(USER, { accountType: null }).accountType).toBeNull(); - }); -}); - -describe("clearProfile", () => { - it("removes only the target user's profile", () => { - persistProfile(USER, { accountType: "clinician" }); - persistProfile("user-2", { accountType: "researcher" }); - clearProfile(USER); - expect(loadProfile(USER)).toBeNull(); - expect(loadProfile("user-2")?.accountType).toBe("researcher"); - }); -}); diff --git a/PanTS-Demo/src/helpers/accountProfile.ts b/PanTS-Demo/src/helpers/accountProfile.ts index bde76f3..e2cb263 100644 --- a/PanTS-Demo/src/helpers/accountProfile.ts +++ b/PanTS-Demo/src/helpers/accountProfile.ts @@ -1,4 +1,4 @@ -// Plans, what each one allows, and the one remaining client-only profile field. +// Plans, what each one allows, and the account-type vocabulary. // // PLAN_LIMITS mirrors flask-server/services/plan_store.py. The server is what // actually decides — it returns 402 with a reason, and that reason is what the @@ -6,9 +6,9 @@ // out up front instead of letting you click into a rejection. A drift between // the two is a cosmetic bug here and a real one there; change them together. // -// The plan itself is NOT stored here. It lives on user_account.plan and arrives -// through authContext. What's left in localStorage is `accountType`, which is -// self-reported, optional, and gates nothing. +// Neither the plan nor the account type is stored here: both are columns on +// user_account and arrive through authContext. What's left in this file is the +// vocabulary — the ids, labels and limits the UI renders. export type AccountType = "patient" | "clinician" | "researcher" | "student"; export type PlanId = "free" | "pro" | "team" | "enterprise"; @@ -205,7 +205,7 @@ export const canCreateReports = (plan: PlanId): boolean => limitsFor(plan).creat export const maxConcurrentScans = (plan: PlanId): number => limitsFor(plan).concurrentScans ?? Infinity; -// ---- account type (self-reported, optional, gates nothing) ----------------- +// ---- account type (self-reported, optional, gates nothing but is reported on) ---- export const ACCOUNT_TYPES: { id: AccountType; label: string }[] = [ { id: "patient", label: "Patient" }, @@ -221,49 +221,3 @@ export type AccountProfile = { /** null until the user picks one in settings. Nothing depends on it. */ accountType: AccountType | null; }; - -export const DEFAULT_PROFILE: AccountProfile = { accountType: null }; - -export const PROFILE_KEY_PREFIX = "accountProfile:"; - -const profileKey = (userId: string) => `${PROFILE_KEY_PREFIX}${userId}`; - -export const loadProfile = (userId: string): AccountProfile | null => { - try { - const raw = localStorage.getItem(profileKey(userId)); - if (!raw) return null; - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== "object") return null; - // Merge over defaults so a profile written by an older build still loads. - // Older builds also wrote plan/onboarding keys here; they're ignored now - // that the plan is a server column, and dropped on the next write. - return { accountType: parsed.accountType ?? null }; - } catch { - return null; - } -}; - -export const persistProfile = (userId: string, profile: AccountProfile) => { - try { - localStorage.setItem(profileKey(userId), JSON.stringify(profile)); - } catch (e) { - console.warn("persistProfile failed", e); - } -}; - -export const updateProfile = ( - userId: string, - patch: Partial -): AccountProfile => { - const next = { ...(loadProfile(userId) ?? DEFAULT_PROFILE), ...patch }; - persistProfile(userId, next); - return next; -}; - -export const clearProfile = (userId: string) => { - try { - localStorage.removeItem(profileKey(userId)); - } catch (e) { - console.warn("clearProfile failed", e); - } -}; diff --git a/PanTS-Demo/src/helpers/analytics.test.ts b/PanTS-Demo/src/helpers/analytics.test.ts new file mode 100644 index 0000000..c341c0a --- /dev/null +++ b/PanTS-Demo/src/helpers/analytics.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { flush, routePattern, track, trackPageView } from "./analytics"; + +// The contract that matters here is what leaves the browser: a feature name, a +// route pattern, a duration — and never anything identifying the scan. + +const bodies = () => + (global.fetch as ReturnType).mock.calls.map( + ([, init]) => JSON.parse(String((init as RequestInit).body)).events + ); + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + global.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ stored: 1 }) })) as never; + window.history.pushState({}, "", "/dashboard"); +}); + +afterEach(() => { + flush(); + vi.restoreAllMocks(); +}); + +describe("routePattern", () => { + it("keeps the static routes it knows", () => { + expect(routePattern("/")).toBe("/"); + expect(routePattern("/upload")).toBe("/upload"); + expect(routePattern("/account/plan")).toBe("/account/plan"); + }); + + it("reduces a case URL to its pattern, dropping the id", () => { + expect(routePattern("/case/BDMAP_00000123")).toBe("/case/:caseId"); + expect(routePattern("/session/8f3c-4e21")).toBe("/session/:sessionId"); + }); + + it("ignores a trailing slash", () => { + expect(routePattern("/upload/")).toBe("/upload"); + }); + + it("returns null for a route it doesn't recognise", () => { + // Better to lose the row than to store a URL we haven't vetted. + expect(routePattern("/some/unknown/page")).toBeNull(); + }); +}); + +describe("track", () => { + it("sends the event name and the current route, not the URL", async () => { + window.history.pushState({}, "", "/case/BDMAP_00000123"); + track("viewer_open_case"); + flush(); + + const [event] = bodies()[0]; + expect(event.kind).toBe("action"); + expect(event.name).toBe("viewer_open_case"); + expect(event.route).toBe("/case/:caseId"); + expect(JSON.stringify(event)).not.toContain("BDMAP_00000123"); + }); + + it("batches events into one request rather than one request per click", () => { + track("upload_select_model"); + track("upload_start_inference"); + track("viewer_measure"); + expect(global.fetch).not.toHaveBeenCalled(); + + flush(); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(bodies()[0]).toHaveLength(3); + }); + + it("flushes on its own once the batch gets big", () => { + for (let i = 0; i < 20; i++) track("viewer_toggle_organ"); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(bodies()[0]).toHaveLength(20); + }); + + it("gives every event the same anonymous id across a visit", () => { + track("auth_open_modal"); + track("dataset_search"); + flush(); + + const [first, second] = bodies()[0]; + expect(first.anon_id).toBeTruthy(); + expect(first.anon_id).toBe(second.anon_id); + expect(first.session_id).toBe(second.session_id); + }); + + it("reuses the same anonymous id on a later visit", async () => { + // A fresh module is a fresh page load: the id is cached in memory for the + // life of the page, so persistence is only observable across one. + localStorage.setItem("bmAnalyticsAnon", "known-browser"); + vi.resetModules(); + const fresh = await import("./analytics"); + + fresh.track("auth_open_modal"); + fresh.flush(); + + expect(bodies()[0][0].anon_id).toBe("known-browser"); + }); + + it("never reports a plan or account type — the server decides that", () => { + track("account_change_plan"); + flush(); + + const [event] = bodies()[0]; + expect(event).not.toHaveProperty("plan"); + expect(event).not.toHaveProperty("account_type"); + expect(event).not.toHaveProperty("user_id"); + }); + + it("swallows a failed send instead of surfacing it to the app", async () => { + global.fetch = vi.fn(async () => { + throw new Error("offline"); + }) as never; + + expect(() => { + track("viewer_measure"); + flush(); + }).not.toThrow(); + }); + + it("does nothing visible when storage is unavailable", () => { + const getItem = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("blocked"); + }); + const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("blocked"); + }); + + expect(() => { + track("dataset_search"); + flush(); + }).not.toThrow(); + + getItem.mockRestore(); + setItem.mockRestore(); + }); +}); + +describe("trackPageView", () => { + it("records the route with how long was spent on it", () => { + trackPageView("/upload", 4200); + flush(); + + const [event] = bodies()[0]; + expect(event.kind).toBe("page_view"); + expect(event.name).toBe("/upload"); + expect(event.duration_ms).toBe(4200); + }); + + it("never reports a negative duration", () => { + trackPageView("/upload", -50); + flush(); + expect(bodies()[0][0].duration_ms).toBe(0); + }); +}); + +describe("flush", () => { + it("sends nothing when there is nothing queued", () => { + flush(); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("empties the queue, so a second flush doesn't resend", () => { + track("viewer_measure"); + flush(); + flush(); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/PanTS-Demo/src/helpers/analytics.ts b/PanTS-Demo/src/helpers/analytics.ts new file mode 100644 index 0000000..75a3e85 --- /dev/null +++ b/PanTS-Demo/src/helpers/analytics.ts @@ -0,0 +1,197 @@ +// Product analytics: which features get used, and how long people spend on +// each part of the app. +// +// Deliberately hand-rolled and deliberately small. There is no third-party +// script here — this app handles medical imaging, and a tag manager that can +// ship arbitrary JS into the viewer is not a trade worth making for a bar chart. +// +// What leaves the browser is a feature name, a route PATTERN, and a duration. +// Never a case id, a filename, a search term, or anything typed into the app: +// `track("dataset_search")` records that a search happened, not what was +// searched for. The server enforces the same thing from its side by dropping +// anything not on its list (flask-server/services/analytics_store.py). +// +// Signed-out visitors are tracked too, under `anonId` alone. The server +// attributes a batch to an account only when the session cookie happens to be +// on the request, and stamps plan/account type itself — this file never sends +// them, because a client-asserted plan would be worthless. + +import { API_BASE } from "./constants"; + +/** The curated vocabulary. Mirrors ACTION_NAMES in analytics_store.py — an + * event the server doesn't know is dropped on arrival, so the two lists have + * to be changed together. */ +export type TrackedAction = + | "upload_files_selected" + | "upload_start_inference" + | "upload_cancel_inference" + | "upload_select_model" + | "upload_select_postprocessing" + | "upload_open_batch_details" + | "viewer_open_case" + | "viewer_change_layout" + | "viewer_toggle_organ" + | "viewer_measure" + | "report_open" + | "assistant_open" + | "assistant_send_message" + | "dataset_search" + | "dataset_open_compare" + | "account_open_settings" + | "account_change_plan" + | "account_set_account_type" + | "auth_open_modal" + | "auth_sign_in" + | "auth_sign_up" + | "auth_sign_out" + | "plan_limit_hit" + | "plan_limit_dialog_cta"; + +type TrackedEvent = { + kind: "action" | "page_view"; + name: string; + route: string | null; + duration_ms?: number; + ts: number; + anon_id: string; + session_id: string; +}; + +// Static routes, and the prefixes that stand in for a parameterised one. The +// pattern is what gets sent: "/case/:caseId", never "/case/BDMAP_00000123". +const STATIC_ROUTES = new Set([ + "/", "/dashboard", "/dicom", "/local-nifti", "/upload", "/signup", + "/account", "/account/plan", "/account/history", "/account/privacy", + "/terms", "/privacy", "/team", "/compare", "/compare-viewer", +]); + +const PARAM_ROUTES: Record = { + case: "/case/:caseId", + session: "/session/:sessionId", + reconstruction: "/reconstruction/:reconstructionId", +}; + +/** A pathname reduced to the pattern it matched, or null if we don't know it. + * Unknown routes are dropped rather than guessed at — a route we can't name is + * a route whose URL might carry something we shouldn't be storing. */ +export const routePattern = (pathname: string): string | null => { + const path = pathname.replace(/\/+$/, "") || "/"; + if (STATIC_ROUTES.has(path)) return path; + const head = path.split("/")[1] ?? ""; + return PARAM_ROUTES[head] ?? null; +}; + +const ANON_KEY = "bmAnalyticsAnon"; +const SESSION_KEY = "bmAnalyticsSession"; + +const randomId = (): string => { + try { + return crypto.randomUUID(); + } catch { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + } +}; + +/** Stable per-browser id. Storage being unavailable (private mode, blocked + * cookies) isn't an error — the visit is just counted as its own. */ +const readStored = (storage: "local" | "session", key: string): string => { + try { + const store = storage === "local" ? localStorage : sessionStorage; + const existing = store.getItem(key); + if (existing) return existing; + const fresh = randomId(); + store.setItem(key, fresh); + return fresh; + } catch { + return randomId(); + } +}; + +let anonId: string | null = null; +let sessionId: string | null = null; + +const ids = () => { + if (!anonId) anonId = readStored("local", ANON_KEY); + if (!sessionId) sessionId = readStored("session", SESSION_KEY); + return { anon_id: anonId, session_id: sessionId }; +}; + +// ---- the queue ------------------------------------------------------------- +// +// Events are batched rather than sent one per click: a viewer session produces +// a lot of small interactions, and one request per interaction would put +// analytics traffic in the same order of magnitude as the app's real traffic. + +const FLUSH_AFTER_MS = 10_000; +const FLUSH_AT = 20; + +let queue: TrackedEvent[] = []; +let timer: ReturnType | null = null; + +const send = (events: TrackedEvent[], keepalive: boolean) => { + if (!events.length) return; + // keepalive lets the request outlive the page on a pagehide flush. fetch + // rather than sendBeacon because the beacon can't carry credentials + // cross-origin, which is exactly the dev setup (5173 -> 5001). + fetch(`${API_BASE}/api/analytics/collect`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ events }), + keepalive, + }).catch(() => { + // Analytics must never surface in the app it measures. A dropped batch + // is a missing bar on a chart, and that's the end of it. + }); +}; + +export const flush = (keepalive = false) => { + if (timer) { + clearTimeout(timer); + timer = null; + } + const batch = queue; + queue = []; + send(batch, keepalive); +}; + +const enqueue = (event: TrackedEvent) => { + queue.push(event); + if (queue.length >= FLUSH_AT) { + flush(); + return; + } + if (!timer) timer = setTimeout(() => flush(), FLUSH_AFTER_MS); +}; + +const currentRoute = (): string | null => { + if (typeof window === "undefined") return null; + return routePattern(window.location.pathname); +}; + +/** Record that a feature was used. Fire-and-forget: never awaited, never throws. */ +export const track = (name: TrackedAction) => { + if (typeof window === "undefined") return; + try { + enqueue({ kind: "action", name, route: currentRoute(), ts: Date.now(), ...ids() }); + } catch { + /* never let tracking break a click handler */ + } +}; + +/** Record a completed visit to a route, with how long it lasted. */ +export const trackPageView = (route: string, durationMs: number) => { + if (typeof window === "undefined") return; + try { + enqueue({ + kind: "page_view", + name: route, + route, + duration_ms: Math.max(0, Math.round(durationMs)), + ts: Date.now(), + ...ids(), + }); + } catch { + /* as above */ + } +}; diff --git a/PanTS-Demo/src/routes/Homepage/hooks/useDashboard.ts b/PanTS-Demo/src/routes/Homepage/hooks/useDashboard.ts index 47e7c6d..51ee657 100644 --- a/PanTS-Demo/src/routes/Homepage/hooks/useDashboard.ts +++ b/PanTS-Demo/src/routes/Homepage/hooks/useDashboard.ts @@ -23,6 +23,7 @@ import type { PreviewType } from "../../../types"; import { API_BASE } from "../../../helpers/constants"; import { CARD_COUNT, PER_PAGE } from "../constants"; import type { FacetData } from "../types"; +import { track } from "../../../helpers/analytics"; export function useDashboard() { const [previewIds, setPreviewIds] = useState([]); @@ -293,6 +294,7 @@ export function useDashboard() { }; const handleSearch = () => { + track("dataset_search"); if (searchId) { const clamped = Math.max(1, Math.min(9901, searchId)); navigation("/case/" + clamped); @@ -302,6 +304,7 @@ export function useDashboard() { }; const handleCompare = () => { + track("dataset_open_compare"); navigation(`/compare?a=${compareIds[0]}&b=${compareIds[1]}`); }; diff --git a/PanTS-Demo/src/routes/Settings/PlanSettings.tsx b/PanTS-Demo/src/routes/Settings/PlanSettings.tsx index fd013cc..c9b6ede 100644 --- a/PanTS-Demo/src/routes/Settings/PlanSettings.tsx +++ b/PanTS-Demo/src/routes/Settings/PlanSettings.tsx @@ -2,6 +2,7 @@ import { IconCheck } from "@tabler/icons-react"; import React, { useState } from "react"; import { useAuth } from "../../contexts/authContext"; import { PLANS, planLabel, type PlanGroup, type PlanId } from "../../helpers/accountProfile"; +import { track } from "../../helpers/analytics"; import { useSettings } from "./context"; /** "in 6 hrs" / "in 24 min" from an ISO timestamp, or null once it's passed. */ @@ -65,6 +66,7 @@ const PlanSettings: React.FC = () => { const choose = (id: PlanId) => run(async () => { + track("account_change_plan"); await setPlan(id); await refreshUsage(); notify(`You're on ${planLabel(id)}.`); diff --git a/PanTS-Demo/src/routes/Settings/ProfileSettings.tsx b/PanTS-Demo/src/routes/Settings/ProfileSettings.tsx index 73c42c9..52f6ce1 100644 --- a/PanTS-Demo/src/routes/Settings/ProfileSettings.tsx +++ b/PanTS-Demo/src/routes/Settings/ProfileSettings.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "../../contexts/authContext"; -import { ACCOUNT_TYPES, type AccountType } from "../../helpers/accountProfile"; +import { ACCOUNT_TYPES, accountTypeLabel, type AccountType } from "../../helpers/accountProfile"; +import { track } from "../../helpers/analytics"; import { useSettings } from "./context"; // Profile: who you are, one preference, and the way out. @@ -11,8 +12,8 @@ import { useSettings } from "./context"; // fewer for the same result. // // The account type used to be a required signup step with four descriptive -// cards. Nothing reads it, so it's an optional select here — asking a question -// that changes nothing is worse than not asking it. +// cards. It's an optional select here: it still gates nothing, it's just +// reported on. It lives on the account, so it follows the user between browsers. const ProfileSettings: React.FC = () => { const navigate = useNavigate(); const { @@ -87,11 +88,18 @@ const ProfileSettings: React.FC = () => { id="set-role" className="set-select" value={user.profile.accountType ?? ""} - onChange={(e) => - updateAccountProfile({ - accountType: (e.target.value || null) as AccountType | null, - }) - } + onChange={(e) => { + const accountType = (e.target.value || null) as AccountType | null; + run(async () => { + track("account_set_account_type"); + await updateAccountProfile({ accountType }); + notify( + accountType + ? `Your role is set to ${accountTypeLabel(accountType)}.` + : "Your role has been cleared." + ); + }); + }} > {ACCOUNT_TYPES.map((t) => ( diff --git a/PanTS-Demo/src/routes/Settings/index.tsx b/PanTS-Demo/src/routes/Settings/index.tsx index 693fd39..dbebb85 100644 --- a/PanTS-Demo/src/routes/Settings/index.tsx +++ b/PanTS-Demo/src/routes/Settings/index.tsx @@ -5,6 +5,7 @@ import React, { useEffect, useState } from "react"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; import Header from "../../components/Header"; import { useAuth } from "../../contexts/authContext"; +import { track } from "../../helpers/analytics"; import { SettingsContext } from "./context"; import "./Settings.css"; @@ -33,6 +34,10 @@ const SECTIONS = [ const SettingsPage: React.FC = () => { const navigate = useNavigate(); + // Once per visit to the settings area, not once per section. + useEffect(() => { + track("account_open_settings"); + }, []); const { isAuthenticated, loading, promptAuth } = useAuth(); const [busy, setBusy] = useState(false); diff --git a/PanTS-Demo/src/routes/UploadPage.tsx b/PanTS-Demo/src/routes/UploadPage.tsx index ec2fbdc..a3a9941 100644 --- a/PanTS-Demo/src/routes/UploadPage.tsx +++ b/PanTS-Demo/src/routes/UploadPage.tsx @@ -67,6 +67,7 @@ import { import Header from "../components/Header"; import ProcessingSummaryBar from "../components/ProcessingSummaryBar"; import BatchDetailsModal from "../components/BatchDetailsModal"; +import { track } from "../helpers/analytics"; import UpgradeDialog, { type UpgradeBlock } from "../components/UpgradeDialog"; import { useAuth } from "../contexts/authContext"; import { @@ -244,6 +245,7 @@ const UploadPage: React.FC = () => { alert("Please select .nii or .nii.gz files only"); return; } + track("upload_files_selected"); setSelectedItems((prev) => [ ...prev, ...filteredFiles.map((f) => ({ @@ -267,6 +269,7 @@ const UploadPage: React.FC = () => { alert("Please drop .nii or .nii.gz files only"); return; } + track("upload_files_selected"); setSelectedItems((prev) => [ ...prev, ...filteredFiles.map((f) => ({ @@ -406,6 +409,7 @@ const UploadPage: React.FC = () => { // kills a queued/running server job. const cancelRun = (upload: RecentUpload) => { const sid = upload.sessionId; + track("upload_cancel_inference"); stopPolling(sid); setPhase(sid); @@ -951,6 +955,7 @@ const UploadPage: React.FC = () => { const sid = crypto.randomUUID(); const label = (item.kind === "dicom" ? item.label : item.file.name) || sid; + track("upload_start_inference"); setRecentUploads( addRecentUpload({ sessionId: sid, @@ -1443,6 +1448,7 @@ const UploadPage: React.FC = () => { }); return; } + track("upload_select_model"); setSelectedModel(m.id as typeof selectedModel); setModelDropOpen(false); }} @@ -1568,6 +1574,7 @@ const UploadPage: React.FC = () => { }); return; } + track("upload_select_postprocessing"); setPostValue(opt.id); setPostDropOpen(false); }} @@ -1876,7 +1883,7 @@ const UploadPage: React.FC = () => {
- + removeBatch(uploads)} />
@@ -1908,7 +1915,7 @@ const UploadPage: React.FC = () => { setDetailsBatchId(g.batchId)} + onViewDetails={() => { track("upload_open_batch_details"); setDetailsBatchId(g.batchId); }} onCancelAll={() => running.forEach(u => cancelRun(u))} /> ); })} diff --git a/PanTS-Demo/src/routes/VisualizationPage.tsx b/PanTS-Demo/src/routes/VisualizationPage.tsx index c667489..cb552de 100644 --- a/PanTS-Demo/src/routes/VisualizationPage.tsx +++ b/PanTS-Demo/src/routes/VisualizationPage.tsx @@ -40,6 +40,7 @@ import { createPortal } from "react-dom"; import { buildMaskFilter } from "../helpers/CornerstoneNifti2"; import { useLocation, useParams } from "react-router-dom"; import AISidebar from "../components/AIAssistant/AISidebar"; +import { track } from "../helpers/analytics"; import { buildViewerActions } from "../components/AIAssistant/assistantActions"; import MeasurementPanel from "../components/MeasurementPanel/MeasurementPanel"; import { SegmentationMeshViewer } from "../components/viewer/MeshViewer"; @@ -502,6 +503,12 @@ function VisualizationPage() { const [outlineOpacityValue, setOutlineOpacityValue] = useState(0); // Current/total slice per MPR pane, for the "245/519" caption + drag scrollbar. + // One event per case actually opened in the viewer — not per re-render, and + // not for a viewer opened on a local file, which has no case behind it. + useEffect(() => { + if (pantsCase || sessionId) track("viewer_open_case"); + }, [pantsCase, sessionId]); + // Populated by subscribeToSliceChanges once the volume is ready; null until then. const [sliceInfo, setSliceInfo] = useState>({ axial: null, @@ -706,6 +713,7 @@ function VisualizationPage() { }; const handleToggleSegmentVisibility = (id: number) => { + track("viewer_toggle_organ"); setSegmentVisibility((prev) => { const next = { ...prev, [id]: prev[id] === false ? true : false }; setCheckState((cs) => { @@ -1310,6 +1318,7 @@ function VisualizationPage() { const unsubscribe = subscribeToMeasurementChanges((kind, m) => { if (!sessionRef.current) return; if (kind === "completed") { + track("viewer_measure"); sessionRef.current.log("measure", `${toolDisplayName(m.tool)} measured: ${m.value}`); requestAnimationFrame(() => { void takeSnapshot(`${toolDisplayName(m.tool)} — ${m.value}`); @@ -2187,6 +2196,7 @@ function VisualizationPage() { const handleToggleAISidebar = () => { const opening = !showAISidebar; + if (opening) track("assistant_open"); setShowAISidebar(opening); if (opening) { @@ -2472,7 +2482,7 @@ const aiAvailableOrgans = useMemo(() => { {LAYOUT_PRESETS.map(({ id, label }) => ( ))} @@ -3044,7 +3054,7 @@ const aiAvailableOrgans = useMemo(() => { {!isLocal && ( + )} + + + {disabled && ( +
+ {error} +
+ )} + {error && !disabled && ( +
+ {error}{" "} + +
+ )} + + {loading && !data &&

Loading…

} + + {data && ( + <> +
+ + + + +
+ +
+

Activity

+

Events per day across the selected range.

+ +
+ +
+

Most-used features

+

+ Every tracked action, most frequent first. Counted per event, with + the number of distinct people beside it — one person clicking forty + times is not forty people. +

+ +
+ +
+

Where the time goes

+

+ Total time on each route, counted only while the tab was in front. +

+ +
+ +
+
+

By plan

+

+ The plan each person was on when the event was recorded. +

+ +
+ +
+

By account type

+

+ Self-reported. "Not set" is a signed-in user who never chose one. +

+ +
+
+ + )} + + + ); +}; + +const Tile: React.FC<{ label: string; value: string; note?: string }> = ({ + label, value, note, +}) => ( +
+ {label} + {value} + {note && {note}} +
+); + +export default App; diff --git a/analytics/src/api.ts b/analytics/src/api.ts new file mode 100644 index 0000000..b1ef670 --- /dev/null +++ b/analytics/src/api.ts @@ -0,0 +1,71 @@ +// The two endpoints this dashboard reads. Both 404 unless the server was +// started with ANALYTICS_DASHBOARD=true — that's the expected response, not a +// bug, and the UI says so rather than showing an empty chart. + +const API_BASE = + String(import.meta.env.VITE_API_BASE || "http://localhost:5001").replace(/\/$/, ""); + +export type Audience = "all" | "signed_in" | "anonymous"; + +export type Filters = { + from: string; + to: string; + plan: string; + accountType: string; + audience: Audience; +}; + +export type Overview = { + range: { start: string; end: string }; + totals: { + events: number; + people: number; + sessions: number; + signed_in_people: number; + time_ms: number; + }; + top_actions: { name: string; count: number; people: number }[]; + time_by_route: { + route: string; views: number; total_ms: number; avg_ms: number; people: number; + }[]; + by_plan: { plan: string; events: number; people: number }[]; + by_account_type: { account_type: string; events: number; people: number }[]; + daily: { day: string; events: number; people: number }[]; +}; + +export type Meta = { + plans: string[]; + account_types: string[]; + audiences: Audience[]; +}; + +/** Thrown when the server is up but the dashboard endpoints are switched off. */ +export class DashboardDisabled extends Error {} + +const get = async (path: string): Promise => { + let res: Response; + try { + res = await fetch(`${API_BASE}${path}`, { credentials: "include" }); + } catch { + throw new Error(`Can't reach the API at ${API_BASE}. Is flask-server running?`); + } + if (res.status === 404) { + throw new DashboardDisabled( + "The analytics endpoints are switched off. Restart flask-server with ANALYTICS_DASHBOARD=true." + ); + } + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Request failed (${res.status}).`); + } + return res.json() as Promise; +}; + +export const fetchMeta = () => get("/api/analytics/meta"); + +export const fetchOverview = (f: Filters) => { + const params = new URLSearchParams({ from: f.from, to: f.to, audience: f.audience }); + if (f.plan) params.set("plan", f.plan); + if (f.accountType) params.set("account_type", f.accountType); + return get(`/api/analytics/overview?${params}`); +}; diff --git a/analytics/src/components/BarList.tsx b/analytics/src/components/BarList.tsx new file mode 100644 index 0000000..2af05de --- /dev/null +++ b/analytics/src/components/BarList.tsx @@ -0,0 +1,54 @@ +// A ranked bar list: the answer to "which of these is used most". +// +// One hue for every bar, on purpose. Colour here would encode rank, and rank is +// already encoded by length and order — a per-row colour would mean a filter +// that drops one row repaints all the others, which reads as the data changing +// when only the selection did. The row label carries identity; the bar carries +// magnitude and nothing else. + +export type Bar = { + /** The row's identity. Also the tooltip's title. */ + label: string; + /** What the bar length is proportional to. */ + value: number; + /** Shown at the end of the bar. Defaults to the value. */ + display?: string; + /** Small grey text after the label — a count, a share, a second measure. */ + note?: string; + /** Full sentence on hover. */ + title?: string; +}; + +const BarList: React.FC<{ bars: Bar[]; empty?: string }> = ({ + bars, + empty = "Nothing recorded in this range.", +}) => { + if (!bars.length) return

{empty}

; + + // Scaled to the largest bar, not to the sum: this answers "which is biggest", + // and a share-of-total scale would leave every bar short and unreadable when + // there are twenty of them. + const max = Math.max(...bars.map((b) => b.value), 1); + + return ( +
    + {bars.map((b) => ( +
  1. + + {b.label} + {b.note && {b.note}} + + + 0 ? 1.5 : 0)}%` }} + /> + + {b.display ?? b.value.toLocaleString()} +
  2. + ))} +
+ ); +}; + +export default BarList; diff --git a/analytics/src/components/TrendLine.tsx b/analytics/src/components/TrendLine.tsx new file mode 100644 index 0000000..aa8aaca --- /dev/null +++ b/analytics/src/components/TrendLine.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { count, shortDay } from "../format"; + +// Activity over the range: one series, one line, hover for the day's numbers. +// +// A line rather than bars because the question is the shape of the trend, not +// the comparison of individual days. Single series, so no legend — the panel +// heading names it. + +type Point = { day: string; events: number; people: number }; + +const W = 720; +const H = 160; +const PAD = { top: 12, right: 12, bottom: 22, left: 12 }; + +const TrendLine: React.FC<{ points: Point[] }> = ({ points }) => { + const [hover, setHover] = useState(null); + + if (points.length < 2) { + return

Not enough days in this range to draw a trend.

; + } + + const max = Math.max(...points.map((p) => p.events), 1); + const innerW = W - PAD.left - PAD.right; + const innerH = H - PAD.top - PAD.bottom; + + const x = (i: number) => PAD.left + (i / (points.length - 1)) * innerW; + const y = (v: number) => PAD.top + innerH - (v / max) * innerH; + + const line = points.map((p, i) => `${i ? "L" : "M"}${x(i)},${y(p.events)}`).join(" "); + const area = `${line} L${x(points.length - 1)},${PAD.top + innerH} L${x(0)},${PAD.top + innerH} Z`; + + // Only the ends are labelled: a date under every point collides as soon as + // the range is longer than a fortnight. + const active = hover !== null ? points[hover] : null; + + return ( +
+ setHover(null)} + > + + + + {active && ( + + )} + {points.map((p, i) => ( + + ))} + + {/* Hit areas: a full-height column per point, so the line doesn't + have to be hit precisely. */} + {points.map((p, i) => ( + setHover(i)} + /> + ))} + + + {shortDay(points[0].day)} + + + {shortDay(points[points.length - 1].day)} + + + +
+ {active ? ( + <> + {shortDay(active.day)} · {count(active.events)} events ·{" "} + {count(active.people)} {active.people === 1 ? "person" : "people"} + + ) : ( + Hover a day for its numbers + )} +
+
+ ); +}; + +export default TrendLine; diff --git a/analytics/src/dashboard.css b/analytics/src/dashboard.css new file mode 100644 index 0000000..c8ee814 --- /dev/null +++ b/analytics/src/dashboard.css @@ -0,0 +1,219 @@ +/* Only what the main site has no equivalent for: the tiles, the bars and the + trend line. Everything else — panels, headings, rows, buttons, inputs — comes + from the site's own Settings.css, imported in main.tsx. + + The palette is the site's: JHU blue #002d72 on #fafafa, Space Grotesk for + text, JetBrains Mono for figures. Bars are one hue throughout; colour here + would encode rank, which length and order already encode. */ + +:root { + --dash-brand: #002d72; + --dash-ink: #111111; + --dash-muted: #8f8f8f; + --dash-hairline: rgba(0, 0, 0, 0.07); + --dash-track: rgba(0, 45, 114, 0.08); +} + +body { + margin: 0; +} + +.dash-main { + max-width: 1080px; +} + +.dash-header { + margin-bottom: 22px; +} +.dash-title { + margin-bottom: 4px; +} + +/* ── Filters: one row, above everything they affect ── */ +.dash-filters { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; + margin-bottom: 22px; +} +.dash-field { + display: flex; + flex-direction: column; + gap: 5px; +} +.dash-field-label { + font-size: 12px; + color: var(--dash-muted); +} +/* The site's inputs are sized for a settings row; these sit in a toolbar. */ +.dash-input { + min-width: 0; + width: auto; + font-size: 13px; + padding: 7px 10px; +} +.dash-reset { + margin-bottom: 1px; +} + +.dash-banner { + margin-bottom: 20px; +} +.dash-retry { + background: none; + border: none; + padding: 0; + font: inherit; + color: inherit; + text-decoration: underline; + cursor: pointer; +} + +.dash-empty { + font-size: 13px; + color: var(--dash-muted); + margin: 6px 0 0; +} + +/* ── Stat tiles: the headline numbers, no plot ── */ +.dash-tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} +.dash-tile { + display: flex; + flex-direction: column; + gap: 2px; + background: #ffffff; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 14px; + padding: 16px 18px; +} +.dash-tile-label { + font-size: 12.5px; + color: var(--dash-muted); +} +.dash-tile-value { + font-family: 'JetBrains Mono', monospace; + font-size: 24px; + font-weight: 600; + color: var(--dash-ink); + letter-spacing: -0.5px; +} +.dash-tile-note { + font-size: 12px; + color: var(--dash-muted); +} + +.dash-panel { + margin-bottom: 20px; +} +.dash-split { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 20px; +} + +/* ── Bars ── */ +.dash-bars { + list-style: none; + margin: 14px 0 0; + padding: 0; + display: flex; + flex-direction: column; +} +.dash-bar-row { + display: grid; + grid-template-columns: minmax(140px, 260px) minmax(60px, 1fr) auto; + align-items: center; + gap: 14px; + padding: 9px 0; + border-bottom: 1px solid var(--dash-hairline); +} +.dash-bar-row:last-child { + border-bottom: none; +} +.dash-bar-row:hover { + background: rgba(0, 45, 114, 0.03); +} +.dash-bar-label { + font-size: 13.5px; + color: var(--dash-ink); + min-width: 0; +} +.dash-bar-note { + display: block; + font-size: 11.5px; + color: var(--dash-muted); + margin-top: 2px; +} +.dash-bar-track { + background: var(--dash-track); + border-radius: 4px; + height: 10px; + overflow: hidden; +} +/* Anchored to the start, rounded at the data end. */ +.dash-bar-fill { + display: block; + height: 100%; + background: var(--dash-brand); + border-radius: 0 4px 4px 0; + transition: width 0.2s ease; +} +.dash-bar-value { + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + color: var(--dash-ink); + text-align: right; + white-space: nowrap; +} + +/* ── Trend ── */ +.dash-trend { + margin-top: 14px; +} +.dash-trend-svg { + width: 100%; + height: auto; + display: block; + overflow: visible; +} +.dash-trend-area { + fill: rgba(0, 45, 114, 0.07); +} +.dash-trend-line { + fill: none; + stroke: var(--dash-brand); + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; +} +.dash-trend-dot { + fill: var(--dash-brand); + stroke: #ffffff; + stroke-width: 2; + transition: r 0.1s ease; +} +.dash-trend-crosshair { + stroke: rgba(0, 45, 114, 0.35); + stroke-width: 1; + stroke-dasharray: 3 3; +} +.dash-trend-axis { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + fill: var(--dash-muted); +} +.dash-trend-readout { + margin-top: 8px; + font-size: 13px; + color: var(--dash-ink); + min-height: 18px; +} +.dash-trend-hint { + color: var(--dash-muted); +} diff --git a/analytics/src/format.ts b/analytics/src/format.ts new file mode 100644 index 0000000..18f9ca7 --- /dev/null +++ b/analytics/src/format.ts @@ -0,0 +1,48 @@ +// Turning stored values into something readable at a glance. + +/** "4m 12s", "1h 20m", "3.2s" — the largest unit that isn't a lie. */ +export const duration = (ms: number): string => { + if (!ms) return "0s"; + const seconds = ms / 1000; + if (seconds < 60) return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ${Math.round(seconds % 60)}s`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; +}; + +export const count = (n: number): string => n.toLocaleString(); + +// Event names are stored as they're fired ("upload_start_inference") because +// that's the string the code uses and the one to grep for. They're only +// prettified at the last moment, here. +const WORDS: Record = { + ai: "AI", + cta: "CTA", +}; + +export const eventLabel = (name: string): string => { + const words = name.split("_").map((w) => WORDS[w] ?? w); + const first = words[0]; + return [first.charAt(0).toUpperCase() + first.slice(1), ...words.slice(1)].join(" "); +}; + +/** The area of the app an event belongs to, from its prefix. */ +export const eventArea = (name: string): string => { + const area = name.split("_")[0]; + return area === "auth" ? "account" : area; +}; + +/** "8 Aug" — the axis is a range of days, so the year would be noise. */ +export const shortDay = (iso: string): string => { + const d = new Date(`${iso}T00:00:00`); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleDateString(undefined, { day: "numeric", month: "short" }); +}; + +/** YYYY-MM-DD for an , n days back from today. */ +export const dateInput = (daysAgo = 0): string => { + const d = new Date(); + d.setDate(d.getDate() - daysAgo); + return d.toISOString().slice(0, 10); +}; diff --git a/analytics/src/main.tsx b/analytics/src/main.tsx new file mode 100644 index 0000000..b269615 --- /dev/null +++ b/analytics/src/main.tsx @@ -0,0 +1,15 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; + +// The main site's settings chrome, imported rather than reimplemented: panels, +// rows, headings, buttons and inputs all come from there, so this dashboard +// follows the product's look automatically when it changes. +import "../../PanTS-Demo/src/routes/Settings/Settings.css"; +import "./dashboard.css"; + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/analytics/tsconfig.json b/analytics/tsconfig.json new file mode 100644 index 0000000..e30b12c --- /dev/null +++ b/analytics/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/analytics/tsconfig.tsbuildinfo b/analytics/tsconfig.tsbuildinfo new file mode 100644 index 0000000..2a89d71 --- /dev/null +++ b/analytics/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/api.ts","./src/format.ts","./src/main.tsx","./src/components/barlist.tsx","./src/components/trendline.tsx"],"version":"5.8.3"} \ No newline at end of file diff --git a/analytics/vite.config.ts b/analytics/vite.config.ts new file mode 100644 index 0000000..aa1413f --- /dev/null +++ b/analytics/vite.config.ts @@ -0,0 +1,19 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// The internal analytics dashboard. Separate app, same repo, deliberately not +// part of any deploy — see README.md. +export default defineConfig({ + plugins: [react()], + server: { + // Fixed: the Flask CORS allowlist names this origin in dev. + port: 5174, + strictPort: true, + fs: { + // This app imports the main site's Settings stylesheet directly rather + // than keeping a second copy of it, so Vite has to be allowed to read + // one level up. + allow: [".."], + }, + }, +}); diff --git a/flask-server/api/analytics_blueprint.py b/flask-server/api/analytics_blueprint.py new file mode 100644 index 0000000..875c510 --- /dev/null +++ b/flask-server/api/analytics_blueprint.py @@ -0,0 +1,130 @@ +"""Analytics: one write endpoint for the product, one read endpoint for the +internal dashboard. + +Routes (registered under /api): + POST /analytics/collect {events: [...]} -> {stored: n} + GET /analytics/overview?from&to&... -> aggregates for the dashboard + GET /analytics/meta -> the filter values the UI offers + +**The read endpoints are off unless ANALYTICS_DASHBOARD=true.** They report on +every account's activity, and the dashboard that reads them has no login of its +own, so shipping them enabled would put per-user behaviour behind a plain GET. +Off by default means a normal deploy of this server does not expose them at all; +turning them on is a deliberate local act. When disabled they 404 rather than +403 — a 403 confirms the endpoint exists. + +Collecting is deliberately NOT gated: the main site should keep recording +whether or not anyone is looking at the dashboard, and the events it writes are +feature names, not content. +""" + +import os +from datetime import datetime, timedelta + +from flask import Blueprint, jsonify, request + +from api.auth import current_user +from models.job import utcnow +from services import analytics_store + +analytics_blueprint = Blueprint("analytics", __name__) + +DEFAULT_DAYS = 30 +MAX_DAYS = 365 + + +def _dashboard_enabled() -> bool: + """Read live rather than at import: tests flip it per-case, and it means a + restart is enough to turn the dashboard off.""" + return os.environ.get("ANALYTICS_DASHBOARD", "false").lower() == "true" + + +@analytics_blueprint.route("/analytics/collect", methods=["POST"]) +def collect(): + """Take a batch of events. Anonymous is fine — signed-out visitors are half + the point — so there's no require_auth here; the session cookie is read only + to attribute the batch when one happens to be present. + + Always 200 with a count. A tracking call must never surface as an error in + the app it is measuring, so a batch that is entirely junk is a stored:0, not + a 400. + """ + body = request.get_json(silent=True) or {} + user = current_user() + stored = analytics_store.record_events( + body.get("events"), user_id=user["id"] if user else None + ) + return jsonify({"stored": stored}), 200 + + +def _parse_range(): + """(start, end) from ?from=&to= ISO dates, defaulting to the last 30 days. + + `to` is inclusive of the whole day: a range picker that says 1st-8th should + include everything that happened on the 8th. + """ + now = utcnow() + end = now + start = now - timedelta(days=DEFAULT_DAYS) + + raw_from = request.args.get("from") + raw_to = request.args.get("to") + try: + if raw_from: + start = datetime.fromisoformat(raw_from) + if raw_to: + end = datetime.fromisoformat(raw_to) + timedelta(days=1) + except ValueError: + return None, None + + if end <= start: + return None, None + if end - start > timedelta(days=MAX_DAYS): + start = end - timedelta(days=MAX_DAYS) + return start, end + + +@analytics_blueprint.route("/analytics/overview", methods=["GET"]) +def overview(): + if not _dashboard_enabled(): + return jsonify({"error": "Not found"}), 404 + + start, end = _parse_range() + if start is None: + return jsonify({"error": "Invalid date range"}), 400 + + audience = request.args.get("audience") or analytics_store.AUDIENCE_ALL + if audience not in ( + analytics_store.AUDIENCE_ALL, + analytics_store.AUDIENCE_SIGNED_IN, + analytics_store.AUDIENCE_ANONYMOUS, + ): + return jsonify({"error": "Invalid audience"}), 400 + + return jsonify(analytics_store.overview( + start, end, + plan=request.args.get("plan") or None, + account_type=request.args.get("account_type") or None, + audience=audience, + )), 200 + + +@analytics_blueprint.route("/analytics/meta", methods=["GET"]) +def meta(): + """The filter values the dashboard offers, so its dropdowns come from the + server's idea of the world rather than a second hardcoded copy.""" + if not _dashboard_enabled(): + return jsonify({"error": "Not found"}), 404 + + from services.plan_store import PLAN_IDS + return jsonify({ + "plans": list(PLAN_IDS), + "account_types": ["patient", "clinician", "researcher", "student"], + "audiences": [ + analytics_store.AUDIENCE_ALL, + analytics_store.AUDIENCE_SIGNED_IN, + analytics_store.AUDIENCE_ANONYMOUS, + ], + "action_names": sorted(analytics_store.ACTION_NAMES), + "routes": sorted(analytics_store.ROUTE_PATTERNS), + }), 200 diff --git a/flask-server/api/auth_blueprint.py b/flask-server/api/auth_blueprint.py index ea56546..bf8ee45 100644 --- a/flask-server/api/auth_blueprint.py +++ b/flask-server/api/auth_blueprint.py @@ -6,7 +6,7 @@ POST /auth/login {email, password} -> logs in POST /auth/logout -> revokes session GET /auth/me -> current user (401 if none) - PATCH /auth/me {name} -> update the display name + PATCH /auth/me {name?, account_type?} -> update name / account type POST /me/plan {plan} -> change plan (no payment) GET /me/usage -> plan limits + usage so far GET /me/jobs -> the current user's jobs @@ -81,15 +81,31 @@ def me(): @auth_blueprint.route("/auth/me", methods=["PATCH"]) @require_auth def update_me(): - """Update the display name. An empty string clears it, and the client falls - back to deriving one from the email.""" + """Update the display name and/or the self-reported account type. Either + may be sent alone. An empty name clears it and the client falls back to + deriving one from the email; an empty account_type clears it to "not set".""" data = _json() - if "name" not in data: + if "name" not in data and "account_type" not in data: return jsonify({"error": "Nothing to update"}), 400 - name = data.get("name") - if name is not None and not isinstance(name, str): - return jsonify({"error": "Name must be text"}), 400 - user = auth_store.update_name(current_user()["id"], name) + + user_id = current_user()["id"] + user = None + + if "name" in data: + name = data.get("name") + if name is not None and not isinstance(name, str): + return jsonify({"error": "Name must be text"}), 400 + user = auth_store.update_name(user_id, name) + + if "account_type" in data: + account_type = data.get("account_type") + if account_type is not None and not isinstance(account_type, str): + return jsonify({"error": "Account type must be text"}), 400 + try: + user = auth_store.update_account_type(user_id, account_type) + except ValueError: + return jsonify({"error": "Unknown account type"}), 400 + if user is None: return jsonify({"error": "Account not found"}), 404 return jsonify({"user": user}), 200 diff --git a/flask-server/app.py b/flask-server/app.py index c7dd817..953a7bb 100644 --- a/flask-server/app.py +++ b/flask-server/app.py @@ -13,6 +13,7 @@ from constants import Constants #print("DEBUG_CONSTANT:", Constants.SESSIONS_DIR_NAME) +from api.analytics_blueprint import analytics_blueprint from api.api_blueprint import api_blueprint from api.auth_blueprint import auth_blueprint from api.oauth_blueprint import init_oauth, oauth_blueprint @@ -41,6 +42,9 @@ def create_app(): app.register_blueprint(api_blueprint, url_prefix=f'{Constants.BASE_PATH}/api') app.register_blueprint(auth_blueprint, url_prefix=f'{Constants.BASE_PATH}/api') app.register_blueprint(oauth_blueprint, url_prefix=f'{Constants.BASE_PATH}/api') + # /analytics/collect is always live; the dashboard's read endpoints inside + # this blueprint 404 unless ANALYTICS_DASHBOARD=true. + app.register_blueprint(analytics_blueprint, url_prefix=f'{Constants.BASE_PATH}/api') app.config['MAX_CONTENT_LENGTH'] = 2 * 1024 * 1024 * 1024 # 2 GB, for overcoming size limits in file uploads @@ -101,7 +105,12 @@ def filter(self, record): # and would let any site make authenticated requests as a logged-in user). # Set ALLOWED_ORIGINS on the server (comma-separated); defaults to local dev. allowed_origins = [ - o.strip() for o in os.environ.get("ALLOWED_ORIGINS", "http://localhost:5173").split(",") + # 5174 is the local analytics dashboard (analytics/), which is a + # dev-only tool — it is never part of a deploy, where ALLOWED_ORIGINS is + # set explicitly anyway. + o.strip() for o in os.environ.get( + "ALLOWED_ORIGINS", "http://localhost:5173,http://localhost:5174" + ).split(",") if o.strip() ] CORS(app, resources={r"/*": {"origins": allowed_origins}}, supports_credentials=True) diff --git a/flask-server/migrations/versions/c7e3a91f4d28_account_type_and_analytics_events.py b/flask-server/migrations/versions/c7e3a91f4d28_account_type_and_analytics_events.py new file mode 100644 index 0000000..015c5d1 --- /dev/null +++ b/flask-server/migrations/versions/c7e3a91f4d28_account_type_and_analytics_events.py @@ -0,0 +1,85 @@ +"""account type + product analytics events + +Two things, both additive: + +* ``user_account.account_type`` — the self-reported patient/clinician/ + researcher/student value. It used to live in the browser's localStorage and + gate nothing, which meant the server had never seen it and analytics could not + be sliced by it. Nullable: existing accounts genuinely have no answer, and + "not set" is a real category rather than a value to invent. +* ``analytics_event`` — one row per tracked interaction, written from the + browser in batches. Kept apart from ``usage_event`` because that table backs + plan quotas; this one backs a dashboard. + +Safe against a live database (nothing is rewritten or dropped) and reversible. + +Revision ID: c7e3a91f4d28 +Revises: b4d21f907ac3 +Create Date: 2026-08-08 12:04:11.882170 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c7e3a91f4d28' +down_revision: Union[str, None] = 'b4d21f907ac3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('user_account', schema=None) as batch_op: + batch_op.add_column(sa.Column('account_type', sa.String(length=16), nullable=True)) + + op.create_table( + 'analytics_event', + sa.Column('id', sa.String(length=36), nullable=False), + # Nullable: signed-out visitors are tracked under anon_id alone. + sa.Column('user_id', sa.String(length=36), nullable=True), + sa.Column('anon_id', sa.String(length=64), nullable=False), + sa.Column('session_id', sa.String(length=64), nullable=False), + sa.Column('kind', sa.String(length=16), nullable=False), + sa.Column('name', sa.String(length=64), nullable=False), + sa.Column('route', sa.String(length=120), nullable=True), + sa.Column('duration_ms', sa.Integer(), nullable=True), + sa.Column('plan', sa.String(length=16), nullable=True), + sa.Column('account_type', sa.String(length=16), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user_account.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('analytics_event', schema=None) as batch_op: + batch_op.create_index('ix_analytics_event_user_id', ['user_id'], unique=False) + batch_op.create_index('ix_analytics_event_anon_id', ['anon_id'], unique=False) + batch_op.create_index('ix_analytics_event_session_id', ['session_id'], unique=False) + batch_op.create_index('ix_analytics_event_kind', ['kind'], unique=False) + batch_op.create_index('ix_analytics_event_name', ['name'], unique=False) + batch_op.create_index('ix_analytics_event_plan', ['plan'], unique=False) + batch_op.create_index('ix_analytics_event_account_type', ['account_type'], unique=False) + batch_op.create_index( + 'ix_analytics_event_created_kind', ['created_at', 'kind'], unique=False + ) + batch_op.create_index( + 'ix_analytics_event_name_created', ['name', 'created_at'], unique=False + ) + + +def downgrade() -> None: + with op.batch_alter_table('analytics_event', schema=None) as batch_op: + batch_op.drop_index('ix_analytics_event_name_created') + batch_op.drop_index('ix_analytics_event_created_kind') + batch_op.drop_index('ix_analytics_event_account_type') + batch_op.drop_index('ix_analytics_event_plan') + batch_op.drop_index('ix_analytics_event_name') + batch_op.drop_index('ix_analytics_event_kind') + batch_op.drop_index('ix_analytics_event_session_id') + batch_op.drop_index('ix_analytics_event_anon_id') + batch_op.drop_index('ix_analytics_event_user_id') + op.drop_table('analytics_event') + + with op.batch_alter_table('user_account', schema=None) as batch_op: + batch_op.drop_column('account_type') diff --git a/flask-server/models/analytics_event.py b/flask-server/models/analytics_event.py new file mode 100644 index 0000000..2be61a5 --- /dev/null +++ b/flask-server/models/analytics_event.py @@ -0,0 +1,79 @@ +"""Product analytics: one row per tracked interaction. + +Separate from ``usage_event`` on purpose. That table is load-bearing — plan +quotas are counted off it, so it stays narrow and every row is written by the +server as part of enforcing a limit. This one is the opposite: rows arrive from +the browser in batches, nothing depends on them, and losing a batch costs a +number on a dashboard rather than a wrong quota decision. + +``user_id`` is nullable because signed-out visitors are tracked too, under +``anon_id`` (a random id kept in the browser's localStorage). Every row has an +``anon_id``; only signed-in rows also have a ``user_id``. + +``plan`` and ``account_type`` are SNAPSHOTS, written by the server from the +account at the time the event lands, never taken from the request body. Two +reasons: the client must not be able to claim a plan it isn't on, and a user who +moves from free to pro shouldn't retroactively rewrite what their earlier +activity is attributed to. Both are null for anonymous rows. +""" + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import db +from models.job import utcnow + +# Wire values for `kind`. +KIND_ACTION = "action" # a discrete thing the user did (clicked, ran, opened) +KIND_PAGE_VIEW = "page_view" # a route the user was on, with how long they stayed + +KINDS = frozenset({KIND_ACTION, KIND_PAGE_VIEW}) + + +class AnalyticsEvent(db.Model): + __tablename__ = "analytics_event" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + # CASCADE: analytics is bookkeeping, not the user's own data, so a purged + # account takes its rows with it rather than blocking the delete. Matches + # usage_event. Null for anonymous visitors. + user_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("user_account.id", ondelete="CASCADE"), + nullable=True, index=True, + ) + # Random per-browser id. Present on every row, so "how many distinct people" + # is answerable across signed-in and signed-out alike. + anon_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + # Random per-tab-visit id, so a page view and the clicks inside it can be + # tied together without needing a user. + session_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + + kind: Mapped[str] = mapped_column(String(16), nullable=False, index=True) + # For an action: the curated event name ("run_inference"). For a page view: + # the route pattern ("/case/:caseId"). + name: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + # Where it happened. Always the route pattern, never the raw URL — a case id + # or session id in the path is user data and has no business in analytics. + route: Mapped[str | None] = mapped_column(String(120), nullable=True) + # Page views carry dwell time. Null on actions. + duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) + + # Server-written snapshots. Null for anonymous rows. + plan: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True) + account_type: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True) + + # When it happened in the browser, as reported by the client, clamped + # server-side to a sane window. Batching means this can be a little older + # than the moment the row was written. + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) + + __table_args__ = ( + # The dashboard's shape: a time range, narrowed by kind, grouped by name. + Index("ix_analytics_event_created_kind", "created_at", "kind"), + Index("ix_analytics_event_name_created", "name", "created_at"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/flask-server/models/engine.py b/flask-server/models/engine.py index 4190a5a..d25d54d 100644 --- a/flask-server/models/engine.py +++ b/flask-server/models/engine.py @@ -38,6 +38,7 @@ def _set_sqlite_pragmas(dbapi_conn, _record): from models import auth_session as _auth_session # noqa: F401,E402 from models import oauth_identity as _oauth_identity # noqa: F401,E402 from models import usage_event as _usage_event # noqa: F401,E402 +from models import analytics_event as _analytics_event # noqa: F401,E402 _engine = None _SessionLocal = None diff --git a/flask-server/models/user.py b/flask-server/models/user.py index bf924bc..04c2236 100644 --- a/flask-server/models/user.py +++ b/flask-server/models/user.py @@ -41,6 +41,10 @@ class User(db.Model): # nothing is charged, but the limits attached to it are enforced for real. plan: Mapped[str] = mapped_column(String(16), nullable=False, default="free", server_default="free") + # Self-reported patient/clinician/researcher/student. Gates nothing — it is + # here so activity can be grouped by it. Nullable because "not set" is the + # honest answer for anyone who never picked one. + account_type: Mapped[str | None] = mapped_column(String(16), nullable=True) # Set when the user asks to delete their account. Non-null means the account # is scheduled for removal: it can't be used, but signing back in within the # grace window clears this and restores it. @@ -60,6 +64,7 @@ def to_public_dict(self) -> dict: "email": self.email, "name": self.name, "plan": self.plan or "free", + "account_type": self.account_type, "email_verified": self.email_verified_at is not None, "created_at": self.created_at.isoformat() if self.created_at else None, } diff --git a/flask-server/services/analytics_store.py b/flask-server/services/analytics_store.py new file mode 100644 index 0000000..4f58756 --- /dev/null +++ b/flask-server/services/analytics_store.py @@ -0,0 +1,292 @@ +"""Product analytics: recording tracked events, and the aggregates the +dashboard reads back. + +The single seam for the ``analytics_event`` table — nothing else writes to it or +queries it. + +Two rules shape everything here: + +1. **The client is not trusted.** It says what happened and when; the server + decides who it belongs to. ``plan`` and ``account_type`` are read from the + account, never from the request body, and timestamps are clamped to a sane + window so a wrong clock (or a hand-crafted POST) can't park rows in 2041. + +2. **Cardinality is bounded on the way in.** Event names and routes are checked + against the lists below and anything unrecognised is dropped. That keeps a + case id or a filename from ever reaching this table — the events describe + which feature was used, not what it was used on — and it keeps "top features" + a list of features rather than a list of typos. +""" + +import uuid +from datetime import datetime, timedelta, timezone + +from sqlalchemy import case, distinct, func, select + +from models.analytics_event import KIND_ACTION, KIND_PAGE_VIEW, KINDS, AnalyticsEvent +from models.engine import session_scope +from models.job import utcnow +from models.user import User + +# The curated list. Adding a tracked action means adding it here and calling +# track() with it on the client; an unlisted name is dropped on arrival, which +# is what keeps the dashboard readable. +ACTION_NAMES = frozenset({ + # upload + inference + "upload_files_selected", "upload_start_inference", "upload_cancel_inference", + "upload_select_model", "upload_select_postprocessing", "upload_open_batch_details", + # viewer + "viewer_open_case", "viewer_change_layout", "viewer_toggle_organ", "viewer_measure", + # reports + "report_open", + # assistant + "assistant_open", "assistant_send_message", + # search + browse + "dataset_search", "dataset_open_compare", + # account + "account_open_settings", "account_change_plan", "account_set_account_type", + "auth_open_modal", "auth_sign_in", "auth_sign_up", "auth_sign_out", + # plan limits + "plan_limit_hit", "plan_limit_dialog_cta", +}) + +# Route patterns, never raw URLs — "/case/:caseId", not "/case/BDMAP_00000123". +# Mirrors the route table in PanTS-Demo/src/App.tsx. +ROUTE_PATTERNS = frozenset({ + "/", "/dashboard", "/case/:caseId", "/session/:sessionId", + "/reconstruction/:reconstructionId", "/dicom", "/local-nifti", + "/upload", "/compare", "/compare-viewer", "/team", "/signup", + "/account", "/account/plan", "/account/history", "/account/privacy", + "/terms", "/privacy", +}) + +# A batch bigger than this is a bug or an attack; take the first N and move on. +MAX_BATCH = 100 +# Reject events claiming to be older than this. Long enough that a laptop closed +# overnight still reports its last batch, short enough to bound backfill. +MAX_AGE = timedelta(hours=48) +# A page view longer than this is a tab left open, not time spent. Clamped +# rather than dropped so the visit still counts. +MAX_DURATION_MS = 60 * 60 * 1000 # 1 hour + +AUDIENCE_ALL = "all" +AUDIENCE_SIGNED_IN = "signed_in" +AUDIENCE_ANONYMOUS = "anonymous" + + +# ---- recording ------------------------------------------------------------- + +def _clean(event: dict, now) -> dict | None: + """One event from the wire -> row kwargs, or None if it doesn't belong here.""" + kind = event.get("kind") + name = event.get("name") + if kind not in KINDS or not isinstance(name, str): + return None + + if kind == KIND_ACTION and name not in ACTION_NAMES: + return None + if kind == KIND_PAGE_VIEW and name not in ROUTE_PATTERNS: + return None + + route = event.get("route") + if route not in ROUTE_PATTERNS: + route = None + + anon_id = event.get("anon_id") + session_id = event.get("session_id") + if not isinstance(anon_id, str) or not isinstance(session_id, str): + return None + if not anon_id or not session_id or len(anon_id) > 64 or len(session_id) > 64: + return None + + duration = event.get("duration_ms") + if isinstance(duration, bool) or not isinstance(duration, (int, float)): + duration = None + else: + duration = max(0, min(int(duration), MAX_DURATION_MS)) + + # The client's clock, clamped: never in the future, never older than MAX_AGE. + created_at = now + ts = event.get("ts") + if isinstance(ts, (int, float)) and not isinstance(ts, bool): + try: + claimed = datetime.fromtimestamp(ts / 1000, tz=timezone.utc).replace(tzinfo=None) + created_at = min(max(claimed, now - MAX_AGE), now) + except (OverflowError, OSError, ValueError): + created_at = now + + return { + "kind": kind, + "name": name, + "route": route, + "duration_ms": duration, + "anon_id": anon_id, + "session_id": session_id, + "created_at": created_at, + } + + +def record_events(events: list, user_id: str | None = None) -> int: + """Write a batch. Returns how many rows were actually stored. + + Anything unrecognised is dropped silently rather than failing the batch: one + stale event name from a cached tab shouldn't cost the other 19 events in the + request. + """ + if not isinstance(events, list) or not events: + return 0 + + now = utcnow() + cleaned = [c for c in (_clean(e, now) for e in events[:MAX_BATCH] if isinstance(e, dict)) if c] + if not cleaned: + return 0 + + with session_scope() as s: + # Snapshot the account's plan/type once for the whole batch. Read from + # the row, never from the request. + plan = account_type = None + if user_id: + user = s.get(User, user_id) + if user is not None and not user.is_system: + plan = user.plan or "free" + account_type = user.account_type + else: + user_id = None + + for row in cleaned: + s.add(AnalyticsEvent( + id=str(uuid.uuid4()), + user_id=user_id, + plan=plan, + account_type=account_type, + **row, + )) + return len(cleaned) + + +# ---- reading --------------------------------------------------------------- + +def _apply_filters(stmt, start, end, plan, account_type, audience): + stmt = stmt.where(AnalyticsEvent.created_at >= start, AnalyticsEvent.created_at < end) + if plan: + stmt = stmt.where(AnalyticsEvent.plan == plan) + if account_type: + stmt = stmt.where(AnalyticsEvent.account_type == account_type) + if audience == AUDIENCE_SIGNED_IN: + stmt = stmt.where(AnalyticsEvent.user_id.isnot(None)) + elif audience == AUDIENCE_ANONYMOUS: + stmt = stmt.where(AnalyticsEvent.user_id.is_(None)) + return stmt + + +def _rows(s, stmt): + return list(s.execute(stmt).all()) + + +def overview(start, end, plan=None, account_type=None, audience=AUDIENCE_ALL) -> dict: + """Everything the dashboard shows, in one query set. + + One call rather than six endpoints: every panel shares the same filters, so + splitting them up would mean the panels could disagree with each other while + a range change was in flight. + """ + def f(stmt): + return _apply_filters(stmt, start, end, plan, account_type, audience) + + people = func.count(distinct(AnalyticsEvent.anon_id)) + + with session_scope() as s: + totals = s.execute(f(select( + func.count(AnalyticsEvent.id), + people, + func.count(distinct(AnalyticsEvent.session_id)), + func.count(distinct(AnalyticsEvent.user_id)), + ))).one() + + actions = _rows(s, f( + select(AnalyticsEvent.name, func.count(AnalyticsEvent.id), people) + .where(AnalyticsEvent.kind == KIND_ACTION) + .group_by(AnalyticsEvent.name) + .order_by(func.count(AnalyticsEvent.id).desc()) + )) + + # Time spent is per route: a page view carries the dwell time for the + # feature the route represents. + routes = _rows(s, f( + select( + AnalyticsEvent.name, + func.count(AnalyticsEvent.id), + func.coalesce(func.sum(AnalyticsEvent.duration_ms), 0), + people, + ) + .where(AnalyticsEvent.kind == KIND_PAGE_VIEW) + .group_by(AnalyticsEvent.name) + .order_by(func.coalesce(func.sum(AnalyticsEvent.duration_ms), 0).desc()) + )) + + by_plan = _rows(s, f( + select(AnalyticsEvent.plan, func.count(AnalyticsEvent.id), people) + .group_by(AnalyticsEvent.plan) + .order_by(func.count(AnalyticsEvent.id).desc()) + )) + + # Grouped by the anonymous flag as well as the value, so a signed-in user + # who never picked a type ("not set") stays distinct from a signed-out + # visitor — both have a null account_type, and merging them would read as + # if half the anonymous traffic had declined to answer a question they + # were never asked. + is_anon = case((AnalyticsEvent.user_id.is_(None), 1), else_=0) + by_type = _rows(s, f( + select(is_anon, AnalyticsEvent.account_type, func.count(AnalyticsEvent.id), people) + .group_by(is_anon, AnalyticsEvent.account_type) + .order_by(func.count(AnalyticsEvent.id).desc()) + )) + + day = func.date(AnalyticsEvent.created_at) + daily = _rows(s, f( + select(day, func.count(AnalyticsEvent.id), people) + .group_by(day).order_by(day) + )) + + total_events, total_people, total_sessions, signed_in_people = totals + return { + "range": {"start": start.isoformat(), "end": end.isoformat()}, + "filters": { + "plan": plan, "account_type": account_type, "audience": audience or AUDIENCE_ALL, + }, + "totals": { + "events": total_events or 0, + "people": total_people or 0, + "sessions": total_sessions or 0, + "signed_in_people": signed_in_people or 0, + "time_ms": sum(int(r[2] or 0) for r in routes), + }, + "top_actions": [ + {"name": name, "count": count, "people": ppl} for name, count, ppl in actions + ], + "time_by_route": [ + { + "route": name, + "views": views, + "total_ms": int(total_ms or 0), + "avg_ms": int((total_ms or 0) / views) if views else 0, + "people": ppl, + } + for name, views, total_ms, ppl in routes + ], + "by_plan": [ + {"plan": p or "anonymous", "events": count, "people": ppl} + for p, count, ppl in by_plan + ], + "by_account_type": [ + { + "account_type": "anonymous" if anon else (t or "not set"), + "events": count, + "people": ppl, + } + for anon, t, count, ppl in by_type + ], + "daily": [ + {"day": str(d), "events": count, "people": ppl} for d, count, ppl in daily + ], + } diff --git a/flask-server/services/auth_store.py b/flask-server/services/auth_store.py index 824b85f..e33b428 100644 --- a/flask-server/services/auth_store.py +++ b/flask-server/services/auth_store.py @@ -125,6 +125,29 @@ def update_name(user_id: str, name: str | None) -> dict | None: return user.to_public_dict() +ACCOUNT_TYPES = ("patient", "clinician", "researcher", "student") + + +def update_account_type(user_id: str, account_type: str | None) -> dict | None: + """Set the self-reported account type. None (or empty) clears it back to + "not set", which is a real answer and not a failure. + + Raises ValueError on anything outside ACCOUNT_TYPES — unlike the display + name this is a closed set, and analytics grouped by a free-text field would + be worthless. + """ + value = (account_type or "").strip().lower() or None + if value is not None and value not in ACCOUNT_TYPES: + raise ValueError(f"Unknown account type {account_type!r}") + with session_scope() as s: + user = s.get(User, user_id) + if user is None or user.is_system: + return None + user.account_type = value + s.flush() + return user.to_public_dict() + + def authenticate(email: str, password: str) -> dict | None: """Return the user's public dict if the password matches, else None. diff --git a/flask-server/tests/functional/test_analytics_endpoints.py b/flask-server/tests/functional/test_analytics_endpoints.py new file mode 100644 index 0000000..603ba20 --- /dev/null +++ b/flask-server/tests/functional/test_analytics_endpoints.py @@ -0,0 +1,171 @@ +"""End-to-end tests for the analytics endpoints via a Flask test client. + +The auth blueprint is registered alongside the analytics one so a batch can be +posted with a real session cookie, which is the only way to check that events +are attributed to the account rather than to whatever the request body claims. +""" + +import importlib + +import pytest + + +@pytest.fixture() +def client(tmp_path, monkeypatch): + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'analytics_ep.db'}") + # Off unless a test says otherwise — that default is the thing being tested. + monkeypatch.delenv("ANALYTICS_DASHBOARD", raising=False) + + import constants + importlib.reload(constants) + import models.engine as engine + importlib.reload(engine) + import models.user # noqa: F401 + import models.auth_session # noqa: F401 + import models.usage_event # noqa: F401 + import models.analytics_event # noqa: F401 + import services.auth_store as auth_store + importlib.reload(auth_store) + import services.plan_store # noqa: F401 + import services.analytics_store as analytics_store + importlib.reload(analytics_store) + import api.auth as auth_mod + importlib.reload(auth_mod) + import api.auth_blueprint as auth_bp + importlib.reload(auth_bp) + import api.analytics_blueprint as analytics_bp + importlib.reload(analytics_bp) + + engine.reset_engine_for_tests() + engine.create_all() + auth_store.ensure_system_user() + + from flask import Flask + app = Flask(__name__) + app.register_blueprint(auth_bp.auth_blueprint, url_prefix="/api") + app.register_blueprint(analytics_bp.analytics_blueprint, url_prefix="/api") + with app.test_client() as c: + yield c + engine.reset_engine_for_tests() + + +def an_action(name="viewer_open_case", **extra): + base = {"kind": "action", "name": name, "anon_id": "anon-1", "session_id": "sess-1"} + base.update(extra) + return base + + +# ---- collecting ------------------------------------------------------------ + +def test_collect_accepts_events_from_a_signed_out_visitor(client): + r = client.post("/api/analytics/collect", json={"events": [an_action("auth_open_modal")]}) + assert r.status_code == 200 + assert r.get_json() == {"stored": 1} + + +def test_collect_never_errors_on_junk(client): + """A tracking call must not surface as a failure in the app it measures.""" + for body in ({}, {"events": []}, {"events": "nonsense"}, {"events": [{"kind": "nope"}]}): + r = client.post("/api/analytics/collect", json=body) + assert r.status_code == 200 + assert r.get_json()["stored"] == 0 + + +def test_a_signed_in_batch_is_attributed_to_that_account(client, monkeypatch): + client.post("/api/auth/register", json={"email": "a@b.com", "password": "password1"}) + client.patch("/api/auth/me", json={"account_type": "researcher"}) + + assert client.post( + "/api/analytics/collect", json={"events": [an_action(plan="enterprise")]} + ).get_json() == {"stored": 1} + + monkeypatch.setenv("ANALYTICS_DASHBOARD", "true") + data = client.get("/api/analytics/overview").get_json() + assert data["totals"]["signed_in_people"] == 1 + assert data["by_plan"] == [{"plan": "free", "events": 1, "people": 1}] + assert data["by_account_type"][0]["account_type"] == "researcher" + + +# ---- the gate -------------------------------------------------------------- + +def test_the_dashboard_endpoints_are_404_by_default(client): + assert client.get("/api/analytics/overview").status_code == 404 + assert client.get("/api/analytics/meta").status_code == 404 + + +def test_collect_still_works_while_the_dashboard_is_off(client): + assert client.post( + "/api/analytics/collect", json={"events": [an_action()]} + ).status_code == 200 + + +def test_the_endpoints_open_when_the_flag_is_set(client, monkeypatch): + monkeypatch.setenv("ANALYTICS_DASHBOARD", "true") + assert client.get("/api/analytics/overview").status_code == 200 + assert client.get("/api/analytics/meta").status_code == 200 + + +# ---- querying -------------------------------------------------------------- + +def test_overview_rejects_a_backwards_range(client, monkeypatch): + monkeypatch.setenv("ANALYTICS_DASHBOARD", "true") + r = client.get("/api/analytics/overview?from=2026-08-08&to=2026-08-01") + assert r.status_code == 400 + + +def test_overview_rejects_an_unknown_audience(client, monkeypatch): + monkeypatch.setenv("ANALYTICS_DASHBOARD", "true") + assert client.get("/api/analytics/overview?audience=everyone").status_code == 400 + + +def test_the_to_date_includes_that_whole_day(client, monkeypatch): + """A range ending today must contain events recorded today.""" + monkeypatch.setenv("ANALYTICS_DASHBOARD", "true") + client.post("/api/analytics/collect", json={"events": [an_action()]}) + + from models.job import utcnow + today = utcnow().date().isoformat() + data = client.get(f"/api/analytics/overview?from={today}&to={today}").get_json() + assert data["totals"]["events"] == 1 + + +def test_meta_lists_the_filters_the_dashboard_offers(client, monkeypatch): + monkeypatch.setenv("ANALYTICS_DASHBOARD", "true") + meta = client.get("/api/analytics/meta").get_json() + assert "free" in meta["plans"] and "enterprise" in meta["plans"] + assert meta["account_types"] == ["patient", "clinician", "researcher", "student"] + assert "viewer_open_case" in meta["action_names"] + + +# ---- the account_type write path ------------------------------------------- + +def test_account_type_round_trips_through_patch_me(client): + client.post("/api/auth/register", json={"email": "a@b.com", "password": "password1"}) + + r = client.patch("/api/auth/me", json={"account_type": "clinician"}) + assert r.status_code == 200 + assert r.get_json()["user"]["account_type"] == "clinician" + assert client.get("/api/auth/me").get_json()["user"]["account_type"] == "clinician" + + +def test_account_type_can_be_cleared(client): + client.post("/api/auth/register", json={"email": "a@b.com", "password": "password1"}) + client.patch("/api/auth/me", json={"account_type": "student"}) + + r = client.patch("/api/auth/me", json={"account_type": ""}) + assert r.get_json()["user"]["account_type"] is None + + +def test_an_invented_account_type_is_refused(client): + client.post("/api/auth/register", json={"email": "a@b.com", "password": "password1"}) + assert client.patch("/api/auth/me", json={"account_type": "wizard"}).status_code == 400 + + +def test_name_and_account_type_can_be_sent_together(client): + client.post("/api/auth/register", json={"email": "a@b.com", "password": "password1"}) + + user = client.patch( + "/api/auth/me", json={"name": "Sam", "account_type": "patient"} + ).get_json()["user"] + assert user["name"] == "Sam" + assert user["account_type"] == "patient" diff --git a/flask-server/tests/unit/test_analytics_store.py b/flask-server/tests/unit/test_analytics_store.py new file mode 100644 index 0000000..1ae5336 --- /dev/null +++ b/flask-server/tests/unit/test_analytics_store.py @@ -0,0 +1,258 @@ +"""Unit tests for the analytics store: what it accepts, what it refuses, and +whether the aggregates the dashboard reads are actually right. + +Each test gets its own temp-file database, following test_plan_store's fixture. +""" + +import importlib +from datetime import timedelta + +import pytest + +from models.job import utcnow + + +@pytest.fixture() +def store(tmp_path, monkeypatch): + """Fresh temp DB; hands back (analytics_store, auth_store, user_id).""" + db_path = tmp_path / "analytics.db" + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{db_path}") + + import constants + importlib.reload(constants) + import models.engine as engine + importlib.reload(engine) + import models.job # noqa: F401 + import models.user # noqa: F401 + import models.auth_session # noqa: F401 + import models.usage_event # noqa: F401 + import models.analytics_event # noqa: F401 + import services.auth_store as auth_store + importlib.reload(auth_store) + import services.analytics_store as analytics_store + importlib.reload(analytics_store) + + engine.reset_engine_for_tests() + engine.create_all() + user = auth_store.create_user("tracked@example.com", "correct-horse-battery") + yield analytics_store, auth_store, user["id"] + engine.reset_engine_for_tests() + + +def action(name, **extra): + base = {"kind": "action", "name": name, "anon_id": "anon-1", "session_id": "sess-1"} + base.update(extra) + return base + + +def page(name, duration_ms, **extra): + base = { + "kind": "page_view", "name": name, "route": name, + "duration_ms": duration_ms, "anon_id": "anon-1", "session_id": "sess-1", + } + base.update(extra) + return base + + +def wide_range(): + now = utcnow() + return now - timedelta(days=1), now + timedelta(days=1) + + +# ---- recording ------------------------------------------------------------- + +def test_records_a_batch_and_reports_how_many_landed(store): + analytics_store, _, user_id = store + stored = analytics_store.record_events( + [action("upload_start_inference"), page("/upload", 5000)], user_id=user_id + ) + assert stored == 2 + + +def test_unknown_event_names_are_dropped_without_failing_the_batch(store): + analytics_store, _, user_id = store + stored = analytics_store.record_events([ + action("upload_start_inference"), + action("something_someone_made_up"), + {"kind": "action", "name": "viewer_open_case"}, # no anon/session id + ], user_id=user_id) + assert stored == 1 + + +def test_a_route_outside_the_pattern_list_is_not_stored(store): + """A raw URL carrying a case id must never become a row.""" + analytics_store, _, user_id = store + assert analytics_store.record_events( + [page("/case/BDMAP_00000123", 1000)], user_id=user_id + ) == 0 + + +def test_plan_and_type_are_taken_from_the_account_not_the_request(store): + analytics_store, auth_store, user_id = store + auth_store.update_account_type(user_id, "clinician") + + analytics_store.record_events( + [action("viewer_open_case", plan="enterprise", account_type="patient")], + user_id=user_id, + ) + + data = analytics_store.overview(*wide_range()) + assert data["by_plan"] == [{"plan": "free", "events": 1, "people": 1}] + assert data["by_account_type"] == [ + {"account_type": "clinician", "events": 1, "people": 1} + ] + + +def test_anonymous_events_are_stored_with_no_account(store): + analytics_store, _, _ = store + assert analytics_store.record_events([action("auth_open_modal")], user_id=None) == 1 + + data = analytics_store.overview(*wide_range()) + assert data["totals"]["people"] == 1 + assert data["totals"]["signed_in_people"] == 0 + assert data["by_plan"] == [{"plan": "anonymous", "events": 1, "people": 1}] + + +def test_a_signed_in_user_without_a_type_is_not_counted_as_anonymous(store): + analytics_store, _, user_id = store + analytics_store.record_events([action("viewer_open_case")], user_id=user_id) + analytics_store.record_events( + [action("auth_open_modal", anon_id="anon-2", session_id="sess-2")], user_id=None + ) + + labels = {r["account_type"] for r in analytics_store.overview(*wide_range())["by_account_type"]} + assert labels == {"not set", "anonymous"} + + +def test_a_future_timestamp_is_clamped_to_now(store): + analytics_store, _, user_id = store + year_3000 = 32503680000000 + analytics_store.record_events( + [action("viewer_open_case", ts=year_3000)], user_id=user_id + ) + # Still inside a range that ends tomorrow, so it was not stored in the future. + assert analytics_store.overview(*wide_range())["totals"]["events"] == 1 + + +def test_an_absurd_page_duration_is_clamped_rather_than_dropped(store): + analytics_store, _, user_id = store + a_week_ms = 7 * 24 * 60 * 60 * 1000 + analytics_store.record_events([page("/dashboard", a_week_ms)], user_id=user_id) + + route = analytics_store.overview(*wide_range())["time_by_route"][0] + assert route["views"] == 1 + assert route["total_ms"] == analytics_store.MAX_DURATION_MS + + +def test_an_oversized_batch_is_truncated(store): + analytics_store, _, user_id = store + events = [action("viewer_open_case")] * (analytics_store.MAX_BATCH + 50) + assert analytics_store.record_events(events, user_id=user_id) == analytics_store.MAX_BATCH + + +def test_empty_and_malformed_batches_are_a_no_op(store): + analytics_store, _, user_id = store + assert analytics_store.record_events([], user_id=user_id) == 0 + assert analytics_store.record_events(None, user_id=user_id) == 0 + assert analytics_store.record_events(["not a dict"], user_id=user_id) == 0 + + +# ---- aggregates ------------------------------------------------------------ + +def test_top_actions_are_ordered_by_how_often_they_happened(store): + analytics_store, _, user_id = store + analytics_store.record_events( + [action("viewer_open_case")] * 3 + [action("report_open")] * 5, user_id=user_id + ) + + top = analytics_store.overview(*wide_range())["top_actions"] + assert [t["name"] for t in top] == ["report_open", "viewer_open_case"] + assert top[0]["count"] == 5 + + +def test_time_by_route_sums_and_averages_dwell_time(store): + analytics_store, _, user_id = store + analytics_store.record_events( + [page("/dashboard", 1000), page("/dashboard", 3000), page("/upload", 500)], + user_id=user_id, + ) + + data = analytics_store.overview(*wide_range()) + dashboard = next(r for r in data["time_by_route"] if r["route"] == "/dashboard") + assert dashboard["views"] == 2 + assert dashboard["total_ms"] == 4000 + assert dashboard["avg_ms"] == 2000 + assert data["totals"]["time_ms"] == 4500 + + +def test_filtering_by_plan_excludes_everyone_else(store): + analytics_store, auth_store, user_id = store + other = auth_store.create_user("pro@example.com", "correct-horse-battery") + import services.plan_store as plan_store + plan_store.set_plan(other["id"], "pro") + + analytics_store.record_events([action("viewer_open_case")], user_id=user_id) + analytics_store.record_events( + [action("report_open", anon_id="anon-2", session_id="sess-2")], user_id=other["id"] + ) + + start, end = wide_range() + pro_only = analytics_store.overview(start, end, plan="pro") + assert pro_only["totals"]["events"] == 1 + assert [t["name"] for t in pro_only["top_actions"]] == ["report_open"] + + +def test_filtering_by_account_type_excludes_everyone_else(store): + analytics_store, auth_store, user_id = store + auth_store.update_account_type(user_id, "researcher") + other = auth_store.create_user("student@example.com", "correct-horse-battery") + auth_store.update_account_type(other["id"], "student") + + analytics_store.record_events([action("viewer_open_case")], user_id=user_id) + analytics_store.record_events( + [action("report_open", anon_id="anon-2", session_id="sess-2")], user_id=other["id"] + ) + + start, end = wide_range() + researchers = analytics_store.overview(start, end, account_type="researcher") + assert [t["name"] for t in researchers["top_actions"]] == ["viewer_open_case"] + + +def test_audience_filter_splits_signed_in_from_anonymous(store): + analytics_store, _, user_id = store + analytics_store.record_events([action("viewer_open_case")], user_id=user_id) + analytics_store.record_events( + [action("auth_open_modal", anon_id="anon-2", session_id="sess-2")], user_id=None + ) + + start, end = wide_range() + assert analytics_store.overview( + start, end, audience=analytics_store.AUDIENCE_SIGNED_IN + )["totals"]["events"] == 1 + assert analytics_store.overview( + start, end, audience=analytics_store.AUDIENCE_ANONYMOUS + )["totals"]["events"] == 1 + assert analytics_store.overview(start, end)["totals"]["events"] == 2 + + +def test_events_outside_the_range_are_excluded(store): + analytics_store, _, user_id = store + analytics_store.record_events([action("viewer_open_case")], user_id=user_id) + + now = utcnow() + long_ago = analytics_store.overview(now - timedelta(days=30), now - timedelta(days=20)) + assert long_ago["totals"]["events"] == 0 + assert long_ago["top_actions"] == [] + + +def test_people_counts_distinct_browsers_not_events(store): + analytics_store, _, user_id = store + analytics_store.record_events([action("viewer_open_case")] * 4, user_id=user_id) + analytics_store.record_events( + [action("viewer_open_case", anon_id="anon-2", session_id="sess-2")], user_id=user_id + ) + + data = analytics_store.overview(*wide_range()) + assert data["totals"]["events"] == 5 + assert data["totals"]["people"] == 2 + assert data["top_actions"][0]["people"] == 2 diff --git a/flask-server/tests/unit/test_analytics_vocabulary.py b/flask-server/tests/unit/test_analytics_vocabulary.py new file mode 100644 index 0000000..2d7c26a --- /dev/null +++ b/flask-server/tests/unit/test_analytics_vocabulary.py @@ -0,0 +1,65 @@ +"""The client and server both hold the list of tracked event names, and the +server drops anything not on its copy. A drift is therefore silent: the client +keeps firing an event and it simply never appears on the dashboard. + +These tests read the TypeScript and compare, so the drift fails here instead. +""" + +import pathlib +import re + +import pytest + +from services.analytics_store import ACTION_NAMES, ROUTE_PATTERNS + +REPO = pathlib.Path(__file__).resolve().parents[3] +ANALYTICS_TS = REPO / "PanTS-Demo" / "src" / "helpers" / "analytics.ts" +SRC = REPO / "PanTS-Demo" / "src" + + +def _client_action_type() -> set[str]: + """The names in the TrackedAction union.""" + text = ANALYTICS_TS.read_text() + body = text.split("export type TrackedAction =", 1)[1].split(";", 1)[0] + return set(re.findall(r'"([a-z_]+)"', body)) + + +def _tracked_calls() -> set[str]: + """Every name actually passed to track() anywhere in the app.""" + names: set[str] = set() + for path in SRC.rglob("*.ts*"): + if path.name.endswith(".test.ts") or path.name.endswith(".test.tsx"): + continue + if path == ANALYTICS_TS: + continue + names.update(re.findall(r'track\(\s*"([a-z_]+)"', path.read_text())) + # track(cond ? "a" : "b") + for a, b in re.findall(r'track\([^)]*\?\s*"([a-z_]+)"\s*:\s*"([a-z_]+)"', path.read_text()): + names.update({a, b}) + return names + + +@pytest.mark.skipif(not ANALYTICS_TS.exists(), reason="frontend not present") +def test_the_two_event_lists_match(): + assert _client_action_type() == set(ACTION_NAMES) + + +@pytest.mark.skipif(not ANALYTICS_TS.exists(), reason="frontend not present") +def test_every_name_the_app_fires_is_one_the_server_stores(): + unknown = _tracked_calls() - set(ACTION_NAMES) + assert not unknown, f"these would be dropped on arrival: {sorted(unknown)}" + + +@pytest.mark.skipif(not ANALYTICS_TS.exists(), reason="frontend not present") +def test_every_declared_action_is_actually_fired_somewhere(): + """An event nobody sends is a promise the dashboard can't keep.""" + unused = set(ACTION_NAMES) - _tracked_calls() + assert not unused, f"declared but never tracked: {sorted(unused)}" + + +@pytest.mark.skipif(not ANALYTICS_TS.exists(), reason="frontend not present") +def test_the_route_lists_match(): + text = ANALYTICS_TS.read_text() + static = set(re.findall(r'"(/[a-z-]*(?:/[a-z-]+)*)"', text.split("STATIC_ROUTES", 1)[1].split("]", 1)[0])) + params = set(re.findall(r'"(/[a-z]+/:[A-Za-z]+)"', text)) + assert static | params == set(ROUTE_PATTERNS) From 480e034a81c511aa69f3f9ea149f8b692cad5786 Mon Sep 17 00:00:00 2001 From: Yusufa09 Date: Sun, 9 Aug 2026 11:35:38 -0400 Subject: [PATCH 2/6] switching wording to donation --- PanTS-Demo/src/components/UpgradeDialog.tsx | 4 ++-- PanTS-Demo/src/helpers/accountProfile.ts | 12 ++++++------ PanTS-Demo/src/routes/LegalPage.tsx | 4 ++-- PanTS-Demo/src/routes/Settings/index.tsx | 4 ++-- PanTS-Demo/src/routes/UploadPage.tsx | 4 ++-- PanTS-Demo/src/test/accountPage.test.tsx | 5 +++-- PanTS-Demo/src/test/planGating.test.tsx | 6 +++--- PanTS-Demo/vite.config.ts | 4 +++- 8 files changed, 23 insertions(+), 20 deletions(-) diff --git a/PanTS-Demo/src/components/UpgradeDialog.tsx b/PanTS-Demo/src/components/UpgradeDialog.tsx index 90c1f9e..7304a93 100644 --- a/PanTS-Demo/src/components/UpgradeDialog.tsx +++ b/PanTS-Demo/src/components/UpgradeDialog.tsx @@ -75,7 +75,7 @@ const detail = (b: UpgradeBlock): string => { ? `The ${planLabel(b.plan)} plan includes ${b.limit} messages a day. More ${reset}.` : `The ${planLabel(b.plan)} plan includes ${b.limit} messages a day.`; case "concurrent_scans": - return "Wait for the current scan to finish, or upgrade to run several at once."; + return "Wait for the current scan to finish, or donate to run several at once."; case "model_locked": return `${planLabel(b.plan)} includes LesionSegmenter. Every other model is on Pro.`; case "postprocessing": @@ -135,7 +135,7 @@ const UpgradeDialog: React.FC<{ block: UpgradeBlock | null; onClose: () => void className="upg-primary" onClick={() => { onClose(); navigate("/account/plan"); }} > - See plans + See donation options )} + ))} + + + + + + {filtered && ( + + )} + + + {error && ( +
+ {error}{" "} + {!fatal && ( + + )} +
+ )} + + {loading && !data && !error &&

Loading…

} + + {data && ( + <> +
+ + + + +
+ +
+

Activity

+

Events per day across the selected range.

+ +
+ +
+

Most-used features

+

+ Counted per event, with the number of distinct people beside it — + one person clicking forty times is not forty people. +

+ +
+ +
+

Least-used features

+

+ The quietest {LEAST_USED_SHOWN} tracked actions. "Nobody" means not + once in this range — either it's hard to find, or it isn't wanted. +

+ +
+ +
+

Where the time goes

+

+ Total time on each route, counted only while the tab was in front. +

+ +
+ +
+
+

By plan

+

+ The plan each person was on when the event was recorded. +

+ +
+ +
+

By account type

+

+ Self-reported. "Not set" is a signed-in user who never chose one. +

+ +
+
+ + )} + + ); +}; + +const Tile: React.FC<{ label: string; value: string; note?: string }> = ({ + label, value, note, +}) => ( +
+ {label} + {value} + {note && {note}} +
+); + +export default AnalyticsSettings; diff --git a/PanTS-Demo/src/routes/Settings/PeopleSettings.tsx b/PanTS-Demo/src/routes/Settings/PeopleSettings.tsx new file mode 100644 index 0000000..9fa10a5 --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/PeopleSettings.tsx @@ -0,0 +1,207 @@ +import { useCallback, useEffect, useState } from "react"; +import { useAuth } from "../../contexts/authContext"; +import { API_BASE } from "../../helpers/constants"; +import { track } from "../../helpers/analytics"; +import { useSettings } from "./context"; +import "./analytics/dashboard.css"; + +// People: every account, and the roles they hold. +// +// Admin-only, checked here as well as on the server — the settings nav hides the +// link for everyone else, but a hidden link is not access control. +// +// The two roles do very different things and the page says so, because +// "annotator" currently grants nothing: it marks who *will* be able to edit +// segmentation masks once that exists. Promising access this page can't yet +// deliver is worse than saying it plainly. + +type Person = { + id: string; + email: string; + name: string | null; + plan: string; + account_type: string | null; + created_at: string | null; + roles: string[]; +}; + +const ROLE_BLURB: Record = { + admin: "Sees usage and manages roles", + annotator: "Will be able to edit scans (not wired up yet)", +}; + +const joined = (iso: string | null) => { + if (!iso) return ""; + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? "" + : d.toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }); +}; + +const PeopleSettings: React.FC = () => { + const { user } = useAuth(); + const { fail, notify } = useSettings(); + const isAdmin = !!user?.roles.includes("admin"); + + const [query, setQuery] = useState(""); + const [people, setPeople] = useState([]); + const [roles, setRoles] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + // The row being changed, so only its toggles go dead rather than the page. + const [pending, setPending] = useState(null); + + const load = useCallback(async (q: string) => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams(); + if (q.trim()) params.set("q", q.trim()); + const res = await fetch(`${API_BASE}/api/admin/people?${params}`, { + credentials: "include", + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Couldn't load accounts (${res.status}).`); + } + const body = await res.json(); + setPeople(body.people); + setRoles(body.roles); + setTotal(body.total); + } catch (e) { + setError(e instanceof Error ? e.message : "Couldn't load accounts."); + setPeople([]); + } finally { + setLoading(false); + } + }, []); + + // Debounced so typing an email doesn't fire a request per keystroke. + useEffect(() => { + if (!isAdmin) return; + const t = setTimeout(() => load(query), 250); + return () => clearTimeout(t); + }, [isAdmin, query, load]); + + const toggle = async (person: Person, role: string, held: boolean) => { + setPending(person.id); + try { + const res = await fetch( + held + ? `${API_BASE}/api/admin/people/${person.id}/roles/${role}` + : `${API_BASE}/api/admin/people/${person.id}/roles`, + { + method: held ? "DELETE" : "POST", + credentials: "include", + headers: held ? undefined : { "Content-Type": "application/json" }, + body: held ? undefined : JSON.stringify({ role }), + }, + ); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || "That didn't work."); + + // Trust the server's list rather than assuming the toggle took: it + // refuses some revokes, and this is the row that has to show it. + setPeople((current) => + current.map((p) => (p.id === person.id ? { ...p, roles: body.roles } : p))); + track(held ? "admin_revoke_role" : "admin_grant_role"); + notify( + held + ? `Removed ${role} from ${person.email}.` + : `${person.email} is now an ${role}.`, + ); + } catch (e) { + fail(e instanceof Error ? e.message : "That didn't work."); + } finally { + setPending(null); + } + }; + + if (!isAdmin) { + return ( +
+

People

+

You need an admin account to see this.

+
+ ); + } + + return ( +
+
+

People

+

+ Every account, and what it can do. Admins see usage and manage roles. + Annotators will be able to edit and create segmentation masks — the role + can be granted now, but nothing reads it yet. +

+ + setQuery(e.target.value)} + aria-label="Search accounts" + /> +
+ + {error &&
{error}
} + + {loading && !people.length &&

Loading…

} + + {!loading && !people.length && !error && ( +

+ {query ? `No account matches "${query}".` : "No accounts yet."} +

+ )} + + {people.map((person) => ( +
+ + {person.email} + {person.id === user?.id && you} + + {[person.name, titleCasePlan(person.plan), joined(person.created_at)] + .filter(Boolean) + .join(" · ")} + + + + {roles.map((role) => { + const held = person.roles.includes(role); + return ( + + ); + })} + +
+ ))} + + {total > people.length && ( +

+ Showing {people.length} of {total}. Search to narrow it down. +

+ )} +
+ ); +}; + +const titleCasePlan = (plan: string) => plan.charAt(0).toUpperCase() + plan.slice(1); + +export default PeopleSettings; diff --git a/PanTS-Demo/src/routes/Settings/analytics/BarList.tsx b/PanTS-Demo/src/routes/Settings/analytics/BarList.tsx new file mode 100644 index 0000000..2af05de --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/analytics/BarList.tsx @@ -0,0 +1,54 @@ +// A ranked bar list: the answer to "which of these is used most". +// +// One hue for every bar, on purpose. Colour here would encode rank, and rank is +// already encoded by length and order — a per-row colour would mean a filter +// that drops one row repaints all the others, which reads as the data changing +// when only the selection did. The row label carries identity; the bar carries +// magnitude and nothing else. + +export type Bar = { + /** The row's identity. Also the tooltip's title. */ + label: string; + /** What the bar length is proportional to. */ + value: number; + /** Shown at the end of the bar. Defaults to the value. */ + display?: string; + /** Small grey text after the label — a count, a share, a second measure. */ + note?: string; + /** Full sentence on hover. */ + title?: string; +}; + +const BarList: React.FC<{ bars: Bar[]; empty?: string }> = ({ + bars, + empty = "Nothing recorded in this range.", +}) => { + if (!bars.length) return

{empty}

; + + // Scaled to the largest bar, not to the sum: this answers "which is biggest", + // and a share-of-total scale would leave every bar short and unreadable when + // there are twenty of them. + const max = Math.max(...bars.map((b) => b.value), 1); + + return ( +
    + {bars.map((b) => ( +
  1. + + {b.label} + {b.note && {b.note}} + + + 0 ? 1.5 : 0)}%` }} + /> + + {b.display ?? b.value.toLocaleString()} +
  2. + ))} +
+ ); +}; + +export default BarList; diff --git a/PanTS-Demo/src/routes/Settings/analytics/TrendLine.tsx b/PanTS-Demo/src/routes/Settings/analytics/TrendLine.tsx new file mode 100644 index 0000000..a580896 --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/analytics/TrendLine.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { count, shortDay } from "./format"; + +// Activity over the range: one series, one line, hover for the day's numbers. +// +// A line rather than bars because the question is the shape of the trend, not +// the comparison of individual days. Single series, so no legend — the panel +// heading names it. + +type Point = { day: string; events: number; people: number }; + +const W = 720; +const H = 160; +const PAD = { top: 12, right: 12, bottom: 22, left: 12 }; + +const TrendLine: React.FC<{ points: Point[] }> = ({ points }) => { + const [hover, setHover] = useState(null); + + if (points.length < 2) { + return

Not enough days in this range to draw a trend.

; + } + + const max = Math.max(...points.map((p) => p.events), 1); + const innerW = W - PAD.left - PAD.right; + const innerH = H - PAD.top - PAD.bottom; + + const x = (i: number) => PAD.left + (i / (points.length - 1)) * innerW; + const y = (v: number) => PAD.top + innerH - (v / max) * innerH; + + const line = points.map((p, i) => `${i ? "L" : "M"}${x(i)},${y(p.events)}`).join(" "); + const area = `${line} L${x(points.length - 1)},${PAD.top + innerH} L${x(0)},${PAD.top + innerH} Z`; + + // Only the ends are labelled: a date under every point collides as soon as + // the range is longer than a fortnight. + const active = hover !== null ? points[hover] : null; + + return ( +
+ setHover(null)} + > + + + + {active && ( + + )} + {points.map((p, i) => ( + + ))} + + {/* Hit areas: a full-height column per point, so the line doesn't + have to be hit precisely. */} + {points.map((p, i) => ( + setHover(i)} + /> + ))} + + + {shortDay(points[0].day)} + + + {shortDay(points[points.length - 1].day)} + + + +
+ {active ? ( + <> + {shortDay(active.day)} · {count(active.events)} events ·{" "} + {count(active.people)} {active.people === 1 ? "person" : "people"} + + ) : ( + Hover a day for its numbers + )} +
+
+ ); +}; + +export default TrendLine; diff --git a/PanTS-Demo/src/routes/Settings/analytics/api.ts b/PanTS-Demo/src/routes/Settings/analytics/api.ts new file mode 100644 index 0000000..19a5112 --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/analytics/api.ts @@ -0,0 +1,89 @@ +// The two endpoints the usage dashboard reads. +// +// Both need the server started with ANALYTICS_DASHBOARD=true *and* an admin +// session. Those two refusals are different things and the page says so +// differently: a 404 is "this deploy doesn't serve analytics at all", a 403 is +// "you're signed in and this isn't yours". Neither is a bug to retry. + +import { API_BASE } from "../../../helpers/constants"; + +export type Audience = "all" | "signed_in" | "anonymous"; + +export type Filters = { + from: string; + to: string; + plan: string; + accountType: string; + audience: Audience; + /** "Ever": ignores from/to and starts at the oldest event on record. */ + allTime: boolean; +}; + +export type Overview = { + range: { start: string; end: string }; + totals: { + events: number; + people: number; + sessions: number; + signed_in_people: number; + time_ms: number; + }; + top_actions: { name: string; count: number; people: number }[]; + time_by_route: { + route: string; views: number; total_ms: number; avg_ms: number; people: number; + }[]; + by_plan: { plan: string; events: number; people: number }[]; + by_account_type: { account_type: string; events: number; people: number }[]; + daily: { day: string; events: number; people: number }[]; +}; + +export type Meta = { + plans: string[]; + account_types: string[]; + audiences: Audience[]; + /** Every action the server will store. The ones missing from top_actions are + * exactly the features nobody has used — which is half the question. */ + action_names: string[]; + routes: string[]; +}; + +/** The server is up but this deploy doesn't serve the dashboard endpoints. */ +export class DashboardDisabled extends Error {} +/** Signed in, but not an admin. */ +export class DashboardForbidden extends Error {} + +const get = async (path: string): Promise => { + let res: Response; + try { + res = await fetch(`${API_BASE}${path}`, { credentials: "include" }); + } catch { + throw new Error("Can't reach the API. Is the server running?"); + } + if (res.status === 404) { + throw new DashboardDisabled( + "This server isn't serving analytics. It needs to be started with ANALYTICS_DASHBOARD=true." + ); + } + if (res.status === 401 || res.status === 403) { + throw new DashboardForbidden("You need an admin account to see usage data."); + } + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Request failed (${res.status}).`); + } + return res.json() as Promise; +}; + +export const fetchMeta = () => get("/api/analytics/meta"); + +export const fetchOverview = (f: Filters) => { + const params = new URLSearchParams({ audience: f.audience }); + if (f.allTime) params.set("range", "all"); + else { + params.set("from", f.from); + params.set("to", f.to); + } + if (f.plan) params.set("plan", f.plan); + if (f.accountType) params.set("account_type", f.accountType); + return get(`/api/analytics/overview?${params}`); +}; diff --git a/PanTS-Demo/src/routes/Settings/analytics/dashboard.css b/PanTS-Demo/src/routes/Settings/analytics/dashboard.css new file mode 100644 index 0000000..67d5906 --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/analytics/dashboard.css @@ -0,0 +1,251 @@ +/* Only what the settings chrome has no equivalent for: the tiles, the bars and + the trend line. Everything else — panels, headings, rows, buttons, inputs — + comes from Settings.css, which the settings shell already imports. + + The palette is the site's: JHU blue #002d72 on #fafafa, Space Grotesk for + text, JetBrains Mono for figures. Bars are one hue throughout; colour here + would encode rank, which length and order already encode. + + Every rule is under .dash-*, and the variables are scoped to the dashboard's + own root rather than :root, so loading this file can't reach the rest of the + site. It was a standalone app's stylesheet before it lived here. */ + +.dash { + --dash-brand: #002d72; + --dash-ink: #111111; + --dash-muted: #8f8f8f; + --dash-hairline: rgba(0, 0, 0, 0.07); + --dash-track: rgba(0, 45, 114, 0.08); +} + +.dash-header { + margin-bottom: 22px; +} + +/* ── Filters: one row, above everything they affect ── */ +.dash-filters { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; + margin-bottom: 22px; +} +.dash-field { + display: flex; + flex-direction: column; + gap: 5px; +} +.dash-field-label { + font-size: 12px; + color: var(--dash-muted); +} +/* The site's inputs are sized for a settings row; these sit in a toolbar. */ +.dash-input { + min-width: 0; + width: auto; + font-size: 13px; + padding: 7px 10px; +} +.dash-reset { + margin-bottom: 1px; +} +/* The site's segmented control is centred for the plan picker; in a filter row + it belongs where it sits. */ +.dash-segmented { + margin: 0; +} + +.dash-banner { + margin-bottom: 20px; +} +.dash-retry { + background: none; + border: none; + padding: 0; + font: inherit; + color: inherit; + text-decoration: underline; + cursor: pointer; +} + +.dash-empty { + font-size: 13px; + color: var(--dash-muted); + margin: 6px 0 0; +} + +/* ── Stat tiles: the headline numbers, no plot ── */ +.dash-tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} +.dash-tile { + display: flex; + flex-direction: column; + gap: 2px; + background: #ffffff; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 14px; + padding: 16px 18px; +} +.dash-tile-label { + font-size: 12.5px; + color: var(--dash-muted); +} +.dash-tile-value { + font-family: 'JetBrains Mono', monospace; + font-size: 24px; + font-weight: 600; + color: var(--dash-ink); + letter-spacing: -0.5px; +} +.dash-tile-note { + font-size: 12px; + color: var(--dash-muted); +} + +.dash-panel { + margin-bottom: 20px; +} +.dash-split { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 20px; +} + +/* ── People: an account per row, its roles on the right ── */ +.dash-person { + gap: 16px; +} +/* Long addresses wrap rather than shoving the toggles off the row. */ +.dash-person .set-row-label { + min-width: 0; + overflow-wrap: anywhere; +} +.dash-you { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--dash-muted); +} +.dash-roles { + display: flex; + flex-shrink: 0; + gap: 4px; + padding: 3px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.05); +} +/* Off reads as available-to-grant, on as held. Same pill either way, so the + toggle looks like one control rather than two states of two controls. */ +.dash-role { + text-transform: capitalize; +} +.dash-role:disabled { + opacity: 0.5; + cursor: default; +} + +/* ── Bars ── */ +.dash-bars { + list-style: none; + margin: 14px 0 0; + padding: 0; + display: flex; + flex-direction: column; +} +.dash-bar-row { + display: grid; + grid-template-columns: minmax(140px, 260px) minmax(60px, 1fr) auto; + align-items: center; + gap: 14px; + padding: 9px 0; + border-bottom: 1px solid var(--dash-hairline); +} +.dash-bar-row:last-child { + border-bottom: none; +} +.dash-bar-row:hover { + background: rgba(0, 45, 114, 0.03); +} +.dash-bar-label { + font-size: 13.5px; + color: var(--dash-ink); + min-width: 0; +} +.dash-bar-note { + display: block; + font-size: 11.5px; + color: var(--dash-muted); + margin-top: 2px; +} +.dash-bar-track { + background: var(--dash-track); + border-radius: 4px; + height: 10px; + overflow: hidden; +} +/* Anchored to the start, rounded at the data end. */ +.dash-bar-fill { + display: block; + height: 100%; + background: var(--dash-brand); + border-radius: 0 4px 4px 0; + transition: width 0.2s ease; +} +.dash-bar-value { + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + color: var(--dash-ink); + text-align: right; + white-space: nowrap; +} + +/* ── Trend ── */ +.dash-trend { + margin-top: 14px; +} +.dash-trend-svg { + width: 100%; + height: auto; + display: block; + overflow: visible; +} +.dash-trend-area { + fill: rgba(0, 45, 114, 0.07); +} +.dash-trend-line { + fill: none; + stroke: var(--dash-brand); + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; +} +.dash-trend-dot { + fill: var(--dash-brand); + stroke: #ffffff; + stroke-width: 2; + transition: r 0.1s ease; +} +.dash-trend-crosshair { + stroke: rgba(0, 45, 114, 0.35); + stroke-width: 1; + stroke-dasharray: 3 3; +} +.dash-trend-axis { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + fill: var(--dash-muted); +} +.dash-trend-readout { + margin-top: 8px; + font-size: 13px; + color: var(--dash-ink); + min-height: 18px; +} +.dash-trend-hint { + color: var(--dash-muted); +} diff --git a/PanTS-Demo/src/routes/Settings/analytics/format.ts b/PanTS-Demo/src/routes/Settings/analytics/format.ts new file mode 100644 index 0000000..18f9ca7 --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/analytics/format.ts @@ -0,0 +1,48 @@ +// Turning stored values into something readable at a glance. + +/** "4m 12s", "1h 20m", "3.2s" — the largest unit that isn't a lie. */ +export const duration = (ms: number): string => { + if (!ms) return "0s"; + const seconds = ms / 1000; + if (seconds < 60) return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ${Math.round(seconds % 60)}s`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; +}; + +export const count = (n: number): string => n.toLocaleString(); + +// Event names are stored as they're fired ("upload_start_inference") because +// that's the string the code uses and the one to grep for. They're only +// prettified at the last moment, here. +const WORDS: Record = { + ai: "AI", + cta: "CTA", +}; + +export const eventLabel = (name: string): string => { + const words = name.split("_").map((w) => WORDS[w] ?? w); + const first = words[0]; + return [first.charAt(0).toUpperCase() + first.slice(1), ...words.slice(1)].join(" "); +}; + +/** The area of the app an event belongs to, from its prefix. */ +export const eventArea = (name: string): string => { + const area = name.split("_")[0]; + return area === "auth" ? "account" : area; +}; + +/** "8 Aug" — the axis is a range of days, so the year would be noise. */ +export const shortDay = (iso: string): string => { + const d = new Date(`${iso}T00:00:00`); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleDateString(undefined, { day: "numeric", month: "short" }); +}; + +/** YYYY-MM-DD for an , n days back from today. */ +export const dateInput = (daysAgo = 0): string => { + const d = new Date(); + d.setDate(d.getDate() - daysAgo); + return d.toISOString().slice(0, 10); +}; diff --git a/PanTS-Demo/src/routes/Settings/index.tsx b/PanTS-Demo/src/routes/Settings/index.tsx index 065a27c..47b69eb 100644 --- a/PanTS-Demo/src/routes/Settings/index.tsx +++ b/PanTS-Demo/src/routes/Settings/index.tsx @@ -1,5 +1,5 @@ import { - IconHeart, IconHistory, IconShieldLock, IconUser, + IconChartBar, IconHeart, IconHistory, IconShieldLock, IconUser, IconUsers, } from "@tabler/icons-react"; import React, { useEffect, useState } from "react"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; @@ -25,20 +25,39 @@ import "./Settings.css"; // Notifications is deliberately not a section: it's one switch, and a whole // page for one switch is a mostly-empty panel. It lives on Profile, the way // Claude keeps small preferences inside General. -const SECTIONS = [ +type Section = { + to: string; + label: string; + icon: typeof IconUser; + /** Exact-match the URL, so "/account" isn't active on every child route. */ + end?: boolean; +}; + +const SECTIONS: Section[] = [ { to: "/account", label: "Profile", icon: IconUser, end: true }, { to: "/account/plan", label: "Plan", icon: IconHeart }, { to: "/account/history", label: "History", icon: IconHistory }, { to: "/account/privacy", label: "Privacy", icon: IconShieldLock }, ]; +// Admin-only sections, appended below the rest so the rail's ordinary shape +// doesn't shift for the people who have them. Hiding these is presentation, not +// protection — both pages and both APIs check the role themselves. +const ADMIN_SECTIONS: Section[] = [ + { to: "/account/analytics", label: "Usage", icon: IconChartBar }, + { to: "/account/people", label: "People", icon: IconUsers }, +]; + const SettingsPage: React.FC = () => { const navigate = useNavigate(); // Once per visit to the settings area, not once per section. useEffect(() => { track("account_open_settings"); }, []); - const { isAuthenticated, loading, promptAuth } = useAuth(); + const { isAuthenticated, loading, promptAuth, user } = useAuth(); + const sections = user?.roles.includes("admin") + ? [...SECTIONS, ...ADMIN_SECTIONS] + : SECTIONS; const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(""); @@ -85,7 +104,7 @@ const SettingsPage: React.FC = () => {