diff --git a/PanTS-Demo/.gitignore b/PanTS-Demo/.gitignore index 97959d8..90300f2 100644 --- a/PanTS-Demo/.gitignore +++ b/PanTS-Demo/.gitignore @@ -30,3 +30,8 @@ Pants/ProfileTr/* .vercel + +# Locally generated preview assets (thumbnails + meshes), ~157MB. Built from +# the dataset, not source — regenerate rather than commit. +public/thumbs/ +public/meshes/ diff --git a/PanTS-Demo/src/App.tsx b/PanTS-Demo/src/App.tsx index 4e08e1b..3e8b426 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"; @@ -22,6 +23,10 @@ const ProfileSettings = lazy(() => import("./routes/Settings/ProfileSettings")); const PlanSettings = lazy(() => import("./routes/Settings/PlanSettings")); const HistorySettings = lazy(() => import("./routes/Settings/HistorySettings")); const PrivacySettings = lazy(() => import("./routes/Settings/PrivacySettings")); +// Admin-only sections: split out so the charts and the account list stay out of +// everyone else's bundle. +const AnalyticsSettings = lazy(() => import("./routes/Settings/AnalyticsSettings")); +const PeopleSettings = lazy(() => import("./routes/Settings/PeopleSettings")); const SignupRedirect = lazy(() => import("./routes/SignupRedirect")); const LegalPage = lazy(() => import("./routes/LegalPage")); const RotatingHeartLoader = lazy(() => import("./components/Loading")); @@ -62,6 +67,7 @@ function App() {
+ }> @@ -97,6 +103,10 @@ function App() { } /> } /> } /> + {/* Admin-only. Both check the role themselves and the API + refuses either way — the nav just doesn't offer them. */} + } /> + } /> } /> } /> 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..f12374e 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 @@ -75,7 +76,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": @@ -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,9 +135,13 @@ const UpgradeDialog: React.FC<{ block: UpgradeBlock | null; onClose: () => void )} + ))} +
+ + + + + {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..1c234ce --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/PeopleSettings.tsx @@ -0,0 +1,206 @@ +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 { titleCase } from "./analytics/format"; +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, titleCase(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. +

+ )} +
+ ); +}; + +export default PeopleSettings; 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/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..a64b427 --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/analytics/dashboard.css @@ -0,0 +1,247 @@ +/* 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); +} + +/* ── 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..18face6 --- /dev/null +++ b/PanTS-Demo/src/routes/Settings/analytics/format.ts @@ -0,0 +1,50 @@ +// 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(); + +/** First letter up, rest untouched — for plan names, account types, roles. */ +export const titleCase = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1); + +// 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); + return [titleCase(words[0]), ...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 693fd39..47b69eb 100644 --- a/PanTS-Demo/src/routes/Settings/index.tsx +++ b/PanTS-Demo/src/routes/Settings/index.tsx @@ -1,10 +1,11 @@ import { - IconCreditCard, 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"; import Header from "../../components/Header"; import { useAuth } from "../../contexts/authContext"; +import { track } from "../../helpers/analytics"; import { SettingsContext } from "./context"; import "./Settings.css"; @@ -24,16 +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: IconCreditCard }, + { 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(); - const { isAuthenticated, loading, promptAuth } = useAuth(); + // Once per visit to the settings area, not once per section. + useEffect(() => { + track("account_open_settings"); + }, []); + 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(""); @@ -80,7 +104,7 @@ const SettingsPage: React.FC = () => {
- {locked && Upgrade} + {locked && Donate} {hasSubmenu ? ( { }); return; } + track("upload_select_postprocessing"); setPostValue(opt.id); setPostDropOpen(false); }} @@ -1638,7 +1645,7 @@ const UploadPage: React.FC = () => {
- {locked && Upgrade} + {locked && Donate} {!locked && postValue === opt.id && ( {
- + removeBatch(uploads)} />
@@ -1965,7 +1972,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 45af9ac..c442718 100644 --- a/PanTS-Demo/src/routes/VisualizationPage.tsx +++ b/PanTS-Demo/src/routes/VisualizationPage.tsx @@ -41,6 +41,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"; @@ -546,6 +547,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, @@ -779,6 +786,7 @@ function VisualizationPage() { }; const handleToggleSegmentVisibility = (id: number) => { + track("viewer_toggle_organ"); setSegmentVisibility((prev) => { const next = { ...prev, [id]: prev[id] === false ? true : false }; setCheckState((cs) => { @@ -1450,6 +1458,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}`); @@ -2327,6 +2336,7 @@ function VisualizationPage() { const handleToggleAISidebar = () => { const opening = !showAISidebar; + if (opening) track("assistant_open"); setShowAISidebar(opening); if (opening) { @@ -2645,7 +2655,7 @@ const aiAvailableOrgans = useMemo(() => { {LAYOUT_PRESETS.map(({ id, label }) => ( ))} @@ -3223,7 +3233,7 @@ const aiAvailableOrgans = useMemo(() => { {!isLocal && (