From 478b34737f81b2188be7187aba8275513dabe31e Mon Sep 17 00:00:00 2001 From: Brooke Date: Mon, 17 Aug 2026 22:10:31 -0700 Subject: [PATCH] add per user stats page --- server/src/routes/stats.ts | 72 ++++++++++++++ web/src/AccountStatsModal.tsx | 177 ++++++++++++++++++++++++++++++++++ web/src/AccountsPanel.tsx | 19 ++++ web/src/StatsPanel.tsx | 8 +- web/src/api.ts | 26 +++++ web/src/style.css | 63 ++++++++++++ 6 files changed, 361 insertions(+), 4 deletions(-) create mode 100644 web/src/AccountStatsModal.tsx diff --git a/server/src/routes/stats.ts b/server/src/routes/stats.ts index 55111fe..3a203d8 100644 --- a/server/src/routes/stats.ts +++ b/server/src/routes/stats.ts @@ -188,4 +188,76 @@ export function registerStatsRoutes(app: FastifyInstance, db: Db, labelers: Watc labels, }; }); + + app.get("/api/stats/accounts/:did", { preHandler: requireAuth }, async (req, reply) => { + const { did } = req.params as { did: string }; + const query = req.query as { days?: string; tz?: string }; + const days = [7, 30, 90].includes(Number(query.days)) ? Number(query.days) : 30; + const tz = query.tz && isValidTimeZone(query.tz) ? query.tz : "UTC"; + const localDay = (d: Date) => d.toLocaleDateString("en-CA", { timeZone: tz }); + + const account = db + .prepare("SELECT did, handle, email, status, avatar, indexed_at FROM accounts WHERE did = ?") + .get(did) as + | { + did: string; + handle: string; + email: string | null; + status: string; + avatar: string | null; + indexed_at: string; + } + | undefined; + if (!account) return reply.code(404).send({ error: "account not found" }); + + const labels = db + .prepare("SELECT src, val, cts FROM labels WHERE did = ? ORDER BY cts") + .all(did) as { src: string; val: string; cts: string }[]; + + const now = Date.now(); + const hourCutoff = (offsetDays: number) => + new Date(now - offsetDays * 86_400_000).toISOString().slice(0, 13); + + // same hour-to-local-day regrouping as the site-wide chart, scoped to one did + const hourRows = db + .prepare("SELECT hour, SUM(events) AS n FROM activity WHERE did = ? AND hour >= ? GROUP BY hour") + .all(did, hourCutoff(days + 1)) as { hour: string; n: number }[]; + const byDay = new Map(); + for (const r of hourRows) { + const day = localDay(new Date(`${r.hour}:00:00Z`)); + byDay.set(day, (byDay.get(day) ?? 0) + r.n); + } + const writesByDay: { day: string; n: number }[] = []; + for (let i = days - 1; i >= 0; i--) { + const day = localDay(new Date(now - i * 86_400_000)); + writesByDay.push({ day, n: byDay.get(day) ?? 0 }); + } + + const totals = db + .prepare( + `SELECT COALESCE(SUM(events), 0) AS allTimeWrites, MIN(hour) AS firstHour, MAX(hour) AS lastHour + FROM activity WHERE did = ?`, + ) + .get(did) as { allTimeWrites: number; firstHour: string | null; lastHour: string | null }; + + return { + account: { + did: account.did, + handle: account.handle, + email: account.email ?? undefined, + status: account.status, + avatar: account.avatar ?? undefined, + indexedAt: account.indexed_at, + }, + labels, + activity: { + writesByDay, + windowWrites: writesByDay.reduce((sum, d) => sum + d.n, 0), + activeDaysInWindow: writesByDay.filter((d) => d.n > 0).length, + allTimeWrites: totals.allTimeWrites, + firstActive: totals.firstHour ? `${totals.firstHour}:00:00Z` : null, + lastActive: totals.lastHour ? `${totals.lastHour}:00:00Z` : null, + }, + }; + }); } diff --git a/web/src/AccountStatsModal.tsx b/web/src/AccountStatsModal.tsx new file mode 100644 index 0000000..7984af7 --- /dev/null +++ b/web/src/AccountStatsModal.tsx @@ -0,0 +1,177 @@ +import { useEffect, useState } from "react"; +import { api, type AccountStats } from "./api.js"; +import { Bars, ChartHead, dayLabel, usePersisted } from "./StatsPanel.js"; +import { useToast } from "./useToast.js"; + +interface AuditEntry { + at: string; + operator: string; + action: string; + target?: string; +} + +export function AccountStatsModal({ + did, + appviewUrl, + showEmail, + onClose, +}: { + did: string; + appviewUrl: string; + showEmail: boolean; + onClose: () => void; +}) { + const [days, setDays] = usePersisted("accountStatsDays", [7, 30, 90], 30); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + // operator actions on this account, filtered client-side from the shared audit feed + const [actions, setActions] = useState(null); + const [toast, showToast] = useToast(); + + useEffect(() => { + api + .accountStats(did, days) + .then((d) => { + setData(d); + setError(null); + }) + .catch((e) => setError(e.message)); + }, [did, days]); + + useEffect(() => { + api + .audit() + .then((r) => setActions(r.entries.filter((e) => e.target === did))) + .catch(() => setActions([])); + }, [did]); + + useEffect(() => { + const close = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", close); + return () => document.removeEventListener("keydown", close); + }, [onClose]); + + const act = data?.activity; + const noActivity = act != null && act.allTimeWrites === 0; + const tiles: { label: string; value: string | null | undefined }[] = [ + { + label: "Joined", + value: data && new Date(data.account.indexedAt).toLocaleDateString(), + }, + { + label: "Last Active", + value: act && (act.lastActive ? new Date(act.lastActive).toLocaleDateString() : "never"), + }, + { label: `Writes / ${days}d`, value: act?.windowWrites.toLocaleString() }, + { label: `Active Days / ${days}d`, value: act?.activeDaysInWindow.toLocaleString() }, + { label: "All-Time Writes", value: act?.allTimeWrites.toLocaleString() }, + ]; + + return ( +
+
e.stopPropagation()} + > +
+ {data?.account.avatar ? ( + + ) : ( +
+ {error &&
{error}
} + {data && act && ( + <> +
+ {tiles.map((t) => ( + + {t.value ?? "—"} + {t.label} + + ))} +
+
+ + {noActivity ? ( +
no activity recorded for this account yet
+ ) : ( + ({ + key: d.day, + n: d.n, + title: `${d.day}: ${d.n.toLocaleString()}`, + }))} + labels={act.writesByDay.map((d, i) => + i % (days === 7 ? 1 : days === 30 ? 5 : 15) === 0 ? dayLabel(d.day) : "", + )} + /> + )} +
+ {data.labels.length > 0 && ( +
+ Labels +
+ {data.labels.map((l) => ( + + {l.val}{" "} + {new Date(l.cts).toLocaleDateString()} + + ))} +
+
+ )} + {(actions?.length ?? 0) > 0 && ( +
+ Operator Actions +
    + {actions!.map((a, i) => ( +
  • + {new Date(a.at).toLocaleString()} — {a.action} by {a.operator} +
  • + ))} +
+
+ )} + + )} +
+ +
+ {toast} +
+
+ ); +} diff --git a/web/src/AccountsPanel.tsx b/web/src/AccountsPanel.tsx index c31a518..85f8c7f 100644 --- a/web/src/AccountsPanel.tsx +++ b/web/src/AccountsPanel.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { api, type Account } from "./api.js"; +import { AccountStatsModal } from "./AccountStatsModal.js"; import { useToast } from "./useToast.js"; const PAGE_SIZES = [25, 50, 100, 250]; @@ -42,6 +43,7 @@ export function AccountsPanel({ const [passwordResult, setPasswordResult] = useState<{ handle: string; password: string } | null>( null, ); + const [statsDid, setStatsDid] = useState(null); const [toast, showToast] = useToast(); const copy = (text: string, what: string, e: React.MouseEvent) => { @@ -214,6 +216,15 @@ export function AccountsPanel({ ) : ( <> +