Files
freno-dev/src/routes/analytics.tsx
Michael Freno a524f23be2 downloads: track every door and surface them on /analytics
The analytics page could not answer "how many downloads, of which
product". Three of the four download doors recorded nothing: the tRPC
click proxy only left the generic request row, whose path is the raw URL
with the asset buried in `?batch=1&input={"asset_name":...}`, and the S3
file route that serves Sparkle updates and the Nook app logged nothing
at all. The one door that did log (the Nook redirect) kept a private copy
of the logVisit/enrich/flush sequence, under its own path.

All doors now go through trackDownload, which writes one row per event
under /api/downloads/<asset> and flushes it inside the request — a click
is low-volume and serverless is not guaranteed to run the 30s batch
timer after the response. The file route maps its prefix allowlist to
asset keys, so a version bump stays one asset rather than one per
release. getAnalyticsSummary groups those rows into a downloads block and
the freno.me panel renders it under the overview cards.

History is recovered to the degree it exists: the Nook door's rows and
the tRPC clicks still carry enough to derive from (asset name in the URL).
scripts/backfill-downloads.ts inserts those derived rows with a
`backfill-<source id>` primary key and INSERT OR IGNORE, leaving the
originals untouched, so re-running is a no-op. Run against the live table
it added 66 rows: gaze 42, thenook 19, inputhalo 4, lineage 1.

Tests: new backfill suite (derivation, visitor detail carried over,
idempotency, empty history) and summariseDownloads cases; the Nook route
test now pins the canonical path, and the NOOK-337 allowlist audit reads
the map keys instead of quoted strings. Red-checked by stubbing the
downloads block back to zeros — the summary case fails, then passes.
Full suite matches master.

Co-Authored-By: nw-flash <noreply@nw-flash.local>
MISC-48
2026-10-22 12:59:48 -04:00

919 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createSignal, Show, For } from "solid-js";
import { PageHead } from "~/components/PageHead";
import { redirect, query, createAsync } from "@solidjs/router";
/* ───────────────────────────────────────────────
* Dataset registry — extend this record to add
* more data sources to the dashboard over time.
* ─────────────────────────────────────────────── */
type DatasetId = "frenome" | "lineage";
interface DatasetDef {
id: DatasetId;
label: string;
description: string;
badge: string;
}
const DATASETS: DatasetDef[] = [
{
id: "frenome",
label: "freno.me",
description: "Website visitor analytics and performance metrics",
badge: "Web",
},
{
id: "lineage",
label: "Life & Lineage",
description: "Mobile game player analytics and telemetry",
badge: "Game",
},
];
/* ───────────────────────────────────────────────
* Auth guard
* ─────────────────────────────────────────────── */
const checkAdmin = query(async (): Promise<boolean> => {
"use server";
const { getUserState } = await import("~/lib/auth-query");
const userState = await getUserState();
if (!userState.isAdmin) {
throw redirect("/");
}
return true;
}, "checkAdminAccess");
/* ───────────────────────────────────────────────
* Server queries – freno.me website data
* ─────────────────────────────────────────────── */
const getSummaryData = query(async (days: number) => {
"use server";
const { createCaller } = await import("~/server/api/root");
const { getEvent } = await import("vinxi/http");
const caller = await createCaller(getEvent());
return await caller.analytics.getSummary({ days });
}, "getSummaryData");
const getPerformanceData = query(async (days: number) => {
"use server";
const { createCaller } = await import("~/server/api/root");
const { getEvent } = await import("vinxi/http");
const caller = await createCaller(getEvent());
return await caller.analytics.getPerformanceStats({ days });
}, "getPerformanceData");
const getPathData = query(async (path: string, days: number) => {
"use server";
const { createCaller } = await import("~/server/api/root");
const { getEvent } = await import("vinxi/http");
const caller = await createCaller(getEvent());
return await caller.analytics.getPathStats({ path, days });
}, "getPathData");
/* ───────────────────────────────────────────────
* Server queries – Lineage game data
* ─────────────────────────────────────────────── */
const getLineageStats = query(async () => {
"use server";
const { createCaller } = await import("~/server/api/root");
const { getEvent } = await import("vinxi/http");
const caller = await createCaller(getEvent());
return await caller.lineage.misc.getLineageStats();
}, "getLineageStats");
export const route = {
load: async () => {
await checkAdmin();
// Preload freno.me dataset (the default tab)
void getSummaryData(7);
void getPerformanceData(7);
},
};
/* ───────────────────────────────────────────────
* Helpers
* ─────────────────────────────────────────────── */
function formatNumber(num: number): string {
return new Intl.NumberFormat().format(Math.round(num));
}
function formatPercent(value: number, total: number): string {
if (total === 0) return "0.0";
return ((value / total) * 100).toFixed(1);
}
/** Render a solid bar behind a label + count */
function StatBar(props: {
label: string;
count: number;
total: number;
color?: string;
onClick?: () => void;
}) {
const pct = () => Number(formatPercent(props.count, props.total));
return (
<div
class="hover:bg-surface1 cursor-pointer rounded-lg p-3 transition-colors"
onClick={props.onClick}
>
<div class="mb-2 flex items-center justify-between">
<span class="text-text text-sm">{props.label}</span>
<span class="text-text text-sm font-semibold">
{formatNumber(props.count)}{" "}
<span class="text-subtext0 font-normal">({formatPercent(props.count, props.total)}%)</span>
</span>
</div>
<div class="bg-surface1 h-2 w-full rounded-full">
<div
class={`h-2 rounded-full ${props.color || "bg-blue-600"}`}
style={{ width: `${Math.max(Number(formatPercent(props.count, props.total)), 0.5)}%` }}
/>
</div>
</div>
);
}
/* ───────────────────────────────────────────────
* Overview card component
* ─────────────────────────────────────────────── */
function OverviewCard(props: {
title: string;
value: string;
subtitle?: string;
}) {
return (
<div class="bg-surface0 border-surface1 rounded-lg border p-6 shadow">
<div class="text-subtext0 mb-1 text-sm">{props.title}</div>
<div class="text-text text-3xl font-bold">{props.value}</div>
<Show when={props.subtitle}>
<div class="text-subtext1 mt-1 text-xs">{props.subtitle}</div>
</Show>
</div>
);
}
/* ───────────────────────────────────────────────
* Freno.me dashboard panel (original content)
* ─────────────────────────────────────────────── */
interface FrenomePanelProps {
days: number;
setPath: (path: string | null) => void;
}
function FrenomePanel(props: FrenomePanelProps) {
const summary = createAsync(() => getSummaryData(props.days));
const performanceStats = createAsync(() => getPerformanceData(props.days));
return (
<Show when={summary()} keyed>
{(data) => (
<>
{/* Overview cards */}
<div class="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<OverviewCard
title="Total Requests"
value={formatNumber(data.totalVisits)}
subtitle={`${formatNumber(data.totalPageVisits)} pages, ${formatNumber(data.totalApiCalls)} API`}
/>
<OverviewCard
title="Unique Visitors"
value={formatNumber(data.uniqueVisitors)}
/>
<OverviewCard
title="Authenticated Users"
value={formatNumber(data.uniqueUsers)}
/>
<OverviewCard
title="Avg. Visits/Day"
value={formatNumber(data.totalVisits / props.days)}
/>
</div>
{/* Downloads — every door logs one row at /api/downloads/<asset> */}
<Show when={data.downloads.total > 0}>
<div class="bg-surface0 border-surface1 mb-8 rounded-lg border shadow">
<div class="border-surface1 border-b p-6">
<h2 class="text-text text-2xl font-bold">Downloads</h2>
<p class="text-subtext0 mt-1 text-sm">
{formatNumber(data.downloads.total)} events in the last
{props.days === 1 ? " 24 hours" : ` ${props.days} days`} —
landing page clicks and files served, by product
</p>
</div>
<div class="space-y-3 p-6">
<For each={data.downloads.byAsset}>
{(download) => (
<StatBar
label={download.asset}
count={download.count}
total={data.downloads.total}
color="bg-teal-600"
/>
)}
</For>
</div>
</div>
</Show>
{/* Performance */}
<Show when={performanceStats()} keyed>
{(ps) => (
<Show when={ps.totalWithMetrics > 0}>
<div class="mb-8">
<h2 class="text-text mb-4 text-2xl font-bold">
Core Web Vitals
</h2>
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<PerformanceCard
label="LCP (Largest Contentful Paint)"
value={
ps.avgLcp != null
? `${Math.round(ps.avgLcp)}ms`
: null
}
metric="lcp"
targetLabel="<1.5s (good), <2.5s (ok)"
target={ps.avgLcp ?? 0}
/>
<PerformanceCard
label="FCP (First Contentful Paint)"
value={
ps.avgFcp != null
? `${Math.round(ps.avgFcp)}ms`
: null
}
metric="fcp"
targetLabel="<1s (good), <1.8s (ok)"
target={ps.avgFcp ?? 0}
/>
<PerformanceCard
label="CLS (Cumulative Layout Shift)"
value={
ps.avgCls != null ? ps.avgCls.toFixed(3) : null
}
metric="cls"
targetLabel="<0.05 (good), <0.1 (ok)"
target={ps.avgCls ?? 0}
/>
<PerformanceCard
label="TTFB (Time to First Byte)"
value={
ps.avgTtfb != null
? `${Math.round(ps.avgTtfb)}ms`
: null
}
metric="ttfb"
targetLabel="<500ms (good), <800ms (ok)"
target={ps.avgTtfb ?? 0}
/>
</div>
<Show when={ps.byPath && ps.byPath.length > 0}>
<div class="bg-surface0 border-surface1 rounded-lg border shadow">
<div class="border-surface1 border-b p-6">
<h3 class="text-text text-xl font-bold">
Performance by Page
</h3>
<p class="text-subtext0 mt-1 text-sm">
{ps.totalWithMetrics} page loads with performance data
</p>
</div>
<div class="p-6">
<table class="w-full text-sm">
<thead class="border-surface1 border-b">
<tr class="text-subtext0 text-left">
<th class="pr-4 pb-3 font-medium">Page</th>
<th class="pr-4 pb-3 text-right font-medium">LCP</th>
<th class="pr-4 pb-3 text-right font-medium">FCP</th>
<th class="pr-4 pb-3 text-right font-medium">CLS</th>
<th class="pr-4 pb-3 text-right font-medium">TTFB</th>
<th class="pb-3 text-right font-medium">Samples</th>
</tr>
</thead>
<tbody>
<For each={ps.byPath}>
{(page: { path: string; avgLcp: number; avgFcp: number; avgCls: number; avgTtfb: number; count: number }) => (
<tr class="border-surface1 border-b">
<td class="text-text py-3 pr-4 font-mono text-xs">
{page.path}
</td>
<td
class={`py-3 pr-4 text-right font-medium ${ratingColor("lcp", page.avgLcp)}`}
>
{Math.round(page.avgLcp)}ms
</td>
<td
class={`py-3 pr-4 text-right font-medium ${ratingColor("fcp", page.avgFcp)}`}
>
{Math.round(page.avgFcp)}ms
</td>
<td
class={`py-3 pr-4 text-right font-medium ${ratingColor("cls", page.avgCls)}`}
>
{page.avgCls.toFixed(3)}
</td>
<td
class={`py-3 pr-4 text-right font-medium ${ratingColor("ttfb", page.avgTtfb)}`}
>
{Math.round(page.avgTtfb)}ms
</td>
<td class="text-subtext0 py-3 text-right">
{page.count}
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</Show>
</div>
</Show>
)}
</Show>
{/* Top pages */}
<div class="bg-surface0 border-surface1 mb-8 rounded-lg border shadow">
<div class="border-surface1 border-b p-6">
<h2 class="text-text text-2xl font-bold">Top Pages</h2>
</div>
<div class="p-6">
<div class="space-y-3">
<For each={data.topPages}>
{(pathData) => (
<StatBar
label={pathData.path}
count={pathData.count}
total={data.totalPageVisits}
color="bg-blue-600"
onClick={() => props.setPath(pathData.path)}
/>
)}
</For>
</div>
</div>
</div>
{/* Top API calls */}
<div class="bg-surface0 border-surface1 mb-8 rounded-lg border shadow">
<div class="border-surface1 border-b p-6">
<h2 class="text-text text-2xl font-bold">Top API Calls</h2>
</div>
<div class="p-6">
<div class="space-y-3">
<For each={data.topApiCalls}>
{(apiData) => (
<StatBar
label={apiData.path}
count={apiData.count}
total={data.totalApiCalls}
color="bg-purple-600"
/>
)}
</For>
</div>
</div>
</div>
{/* Devices & browsers */}
<div class="mb-8 grid grid-cols-1 gap-8 md:grid-cols-2">
<div class="bg-surface0 border-surface1 rounded-lg border shadow">
<div class="border-surface1 border-b p-6">
<h2 class="text-text text-2xl font-bold">Device Types</h2>
</div>
<div class="p-6 space-y-4">
<For each={data.deviceTypes}>
{(device) => (
<StatBar
label={device.type}
count={device.count}
total={data.deviceTypes.reduce(
(s: number, d: { type: string; count: number }) => s + d.count,
0,
)}
color="bg-purple-600"
/>
)}
</For>
</div>
</div>
<div class="bg-surface0 border-surface1 rounded-lg border shadow">
<div class="border-surface1 border-b p-6">
<h2 class="text-text text-2xl font-bold">Browsers</h2>
</div>
<div class="p-6 space-y-4">
<For each={data.browsers}>
{(browser: { browser: string; count: number }) => (
<StatBar
label={browser.browser}
count={browser.count}
total={data.browsers.reduce((s: number, b: { browser: string; count: number }) => s + b.count, 0)}
color="bg-green-600"
/>
)}
</For>
</div>
</div>
</div>
{/* Top referrers */}
<Show when={data.topReferrers.length > 0}>
<div class="bg-surface0 border-surface1 mb-8 rounded-lg border shadow">
<div class="border-surface1 border-b p-6">
<h2 class="text-text text-2xl font-bold">Top Referrers</h2>
</div>
<div class="p-6 space-y-2">
<For each={data.topReferrers}>
{(referrer) => (
<div class="border-surface1 flex justify-between border-b py-2">
<span class="text-text max-w-md truncate text-sm">
{referrer.referrer}
</span>
<span class="text-text text-sm font-semibold">
{formatNumber(referrer.count)}
</span>
</div>
)}
</For>
</div>
</div>
</Show>
</>
)}
</Show>
);
}
/* ───────────────────────────────────────────────
* Performance card sub-component
* ─────────────────────────────────────────────── */
interface PerfThresholds {
good: number;
acceptable: number;
}
const PERF_THRESHOLDS: Record<string, PerfThresholds> = {
lcp: { good: 1500, acceptable: 2500 },
fcp: { good: 1000, acceptable: 1800 },
ttfb: { good: 500, acceptable: 800 },
cls: { good: 0.05, acceptable: 0.1 },
};
function perfRating(metric: string, value: number): "good" | "acceptable" | "poor" {
const t = PERF_THRESHOLDS[metric];
if (!t) return "acceptable";
if (value <= t.good) return "good";
if (value <= t.acceptable) return "acceptable";
return "poor";
}
function ratingColor(metric: string, value: number): string {
const r = perfRating(metric, value);
switch (r) {
case "good":
return "text-green";
case "acceptable":
return "text-yellow";
case "poor":
return "text-red";
}
}
function ratingBg(metric: string, value: number): string {
const r = perfRating(metric, value);
switch (r) {
case "good":
return "bg-green/10";
case "acceptable":
return "bg-yellow/10";
case "poor":
return "bg-red/10";
}
}
function PerformanceCard(props: {
label: string;
value: string | null;
metric: string;
targetLabel: string;
target: number;
}) {
return (
<Show when={props.value != null}>
<div
class={`border-surface1 rounded-lg border p-6 shadow ${ratingBg(props.metric, props.target)}`}
>
<div class="text-subtext0 mb-1 text-sm font-medium">{props.label}</div>
<div
class={`text-3xl font-bold ${ratingColor(props.metric, props.target)}`}
>
{props.value}
</div>
<div class="text-subtext1 mt-1 text-xs">Target: {props.targetLabel}</div>
</div>
</Show>
);
}
/* ───────────────────────────────────────────────
* Path detail panel (freno.me)
* ─────────────────────────────────────────────── */
function PathDetailPanel(props: {
path: string;
days: number;
onClose: () => void;
}) {
const stats = createAsync(() => getPathData(props.path, props.days));
return (
<Show when={stats()} keyed>
{(s) => (
<div class="bg-surface0 border-surface1 mb-8 rounded-lg border shadow">
<div class="border-surface1 flex items-center justify-between border-b p-6">
<h2 class="text-text break-all text-2xl font-bold">
Path: {props.path}
</h2>
<button
onClick={props.onClose}
class="text-subtext0 hover:text-text shrink-0 ml-4"
>
✕
</button>
</div>
<div class="p-6">
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
<OverviewCard title="Total Visits" value={formatNumber(s.totalVisits)} />
<OverviewCard
title="Unique Visitors"
value={formatNumber(s.uniqueVisitors)}
/>
<OverviewCard
title="Avg. Duration"
value={
s.avgDurationMs
? `${(s.avgDurationMs / 1000).toFixed(1)}s`
: "N/A"
}
/>
</div>
<Show when={s.visitsByDay.length > 0}>
<h3 class="text-text mb-4 text-lg font-semibold">
Visits by Day
</h3>
<div class="space-y-2">
<For each={s.visitsByDay}>
{(day) => {
const maxV = Math.max(...s.visitsByDay.map((d) => d.count));
const pct = maxV > 0 ? (day.count / maxV) * 100 : 0;
return (
<div>
<div class="mb-1 flex justify-between">
<span class="text-text text-sm">
{new Date(day.date).toLocaleDateString()}
</span>
<span class="text-text text-sm font-semibold">
{formatNumber(day.count)}
</span>
</div>
<div class="bg-surface1 h-2 w-full rounded-full">
<div
class="h-2 rounded-full bg-blue-600"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}}
</For>
</div>
</Show>
</div>
</div>
)}
</Show>
);
}
/* ───────────────────────────────────────────────
* Lineage dashboard panel
* ─────────────────────────────────────────────── */
function LineagePanel() {
const data = createAsync(() => getLineageStats());
return (
<Show when={data()} keyed>
{(result) => {
if (!result.success || result.players.length === 0) {
return (
<div class="bg-surface0 border-surface1 rounded-lg border p-12 text-center">
<p class="text-subtext0 text-lg">
No Lineage player analytics data available yet.
</p>
<p class="text-subtext1 mt-2 text-sm">
Data appears after players send telemetry from the mobile app.
</p>
</div>
);
}
const players = result.players;
const totalPlayers = players.length;
const classCounts: Record<string, number> = {};
for (const p of players) {
const cls = p.playerClass || "unknown";
classCounts[cls] = (classCounts[cls] || 0) + 1;
}
const classEntries = Object.entries(classCounts).sort((a, b) => b[1] - a[1]);
const jobCounts: Record<string, number> = {};
for (const p of players) {
if (typeof p.jobs === "object" && p.jobs) {
for (const [job, level] of Object.entries(p.jobs as Record<string, unknown>)) {
if (typeof level === "number" && level > 0) {
jobCounts[job] = (jobCounts[job] || 0) + 1;
}
}
}
}
const jobEntries = Object.entries(jobCounts).sort((a, b) => b[1] - a[1]);
const totalSpells = players.reduce((s: number, p) => s + (p.spellCount || 0), 0);
const avgSpells = totalPlayers > 0 ? (totalSpells / totalPlayers).toFixed(1) : "0";
let totalCompletion = 0;
let playersWithDungeons = 0;
for (const p of players) {
const dp = p.dungeonProgression as Record<string, unknown> | null;
if (dp && typeof dp.completedFloors === "number") {
totalCompletion += dp.completedFloors;
playersWithDungeons++;
}
}
const avgDungeonPct =
playersWithDungeons > 0
? ((totalCompletion / playersWithDungeons) * 100).toFixed(1)
: "—";
const profCounts: Record<string, number> = {};
for (const p of players) {
if (typeof p.proficiencies === "object" && p.proficiencies) {
for (const [prof, val] of Object.entries(p.proficiencies as Record<string, unknown>)) {
if (typeof val === "number" && val > 0) {
profCounts[prof] = (profCounts[prof] || 0) + 1;
}
}
}
}
const profEntries = Object.entries(profCounts).sort((a, b) => b[1] - a[1]);
return (
<>
{/* Overview cards */}
<div class="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<OverviewCard title="Total Players" value={formatNumber(totalPlayers)} subtitle="Unique player IDs" />
<OverviewCard title="Avg Spells Known" value={avgSpells} subtitle={`${totalSpells} total across ${totalPlayers} players`} />
<OverviewCard title="Class Diversity" value={formatNumber(classEntries.length)} subtitle="Unique classes played" />
<OverviewCard title="Avg Dungeon Completion" value={`${avgDungeonPct}%`} subtitle={playersWithDungeons > 0 ? `${playersWithDungeons} players with progression data` : "No progression data yet"} />
</div>
<div class="mb-8 grid grid-cols-1 gap-8 lg:grid-cols-2">
<div class="bg-surface0 border-surface1 rounded-lg border shadow">
<div class="border-surface1 border-b p-6">
<h2 class="text-text text-2xl font-bold">Class Distribution</h2>
</div>
<div class="p-6 space-y-3">
<For each={classEntries.slice(0, 10)}>
{([cls, count]) => <StatBar label={cls} count={count} total={totalPlayers} />}
</For>
</div>
</div>
<div class="bg-surface0 border-surface1 rounded-lg border shadow">
<div class="border-surface1 border-b p-6">
<h2 class="text-text text-2xl font-bold">Top Jobs</h2>
</div>
<div class="p-6 space-y-3">
<Show when={jobEntries.length > 0} fallback={<p class="text-subtext0 py-4 text-center text-sm">No job data recorded yet.</p>}>
<For each={jobEntries.slice(0, 10)}>
{([job, count]) => <StatBar label={job} count={count} total={totalPlayers} color="bg-purple-600" />}
</For>
</Show>
</div>
</div>
</div>
<Show when={profEntries.length > 0}>
<div class="bg-surface0 border-surface1 mb-8 rounded-lg border shadow">
<div class="border-surface1 border-b p-6">
<h2 class="text-text text-2xl font-bold">Magic Proficiencies</h2>
</div>
<div class="p-6 space-y-3">
<For each={profEntries.slice(0, 10)}>
{([prof, count]) => <StatBar label={prof} count={count} total={totalPlayers} color="bg-cyan-600" />}
</For>
</div>
</div>
</Show>
<div class="bg-surface0 border-surface1 mb-8 rounded-lg border shadow">
<div class="border-surface1 border-b p-6">
<h2 class="text-text text-2xl font-bold">All Players</h2>
<p class="text-subtext0 mt-1 text-sm">{totalPlayers} total players</p>
</div>
<div class="p-6 overflow-x-auto">
<table class="w-full text-sm">
<thead class="border-surface1 border-b">
<tr class="text-subtext0 text-left">
<th class="pr-4 pb-3 font-medium">Player ID</th>
<th class="pr-4 pb-3 font-medium">Class</th>
<th class="pr-4 pb-3 text-right font-medium">Spells</th>
<th class="pr-4 pb-3 text-right font-medium">Jobs</th>
<th class="pr-4 pb-3 text-right font-medium">Proficiencies</th>
<th class="pb-3 text-right font-medium">Dungeon Completion</th>
</tr>
</thead>
<tbody>
<For each={players}>
{(p: Record<string, unknown>) => {
const dp = p.dungeonProgression as Record<string, unknown> | null | undefined;
const dungeonPct = dp && typeof dp.completedFloors === "number" ? `${(dp.completedFloors * 100).toFixed(0)}%` : "—";
const jobList = typeof p.jobs === "object" && p.jobs ? Object.entries(p.jobs as Record<string, unknown>).filter(([, v]) => typeof v === "number" && (v as number) > 0).length : 0;
const profList = typeof p.proficiencies === "object" && p.proficiencies ? Object.entries(p.proficiencies as Record<string, unknown>).filter(([, v]) => typeof v === "number" && (v as number) > 0).length : 0;
return (
<tr class="border-surface1 border-b hover:bg-surface1 transition-colors">
<td class="text-text py-3 pr-4 font-mono text-xs max-w-[200px] truncate">{p.playerID as string}</td>
<td class="text-text py-3 pr-4">{(p.playerClass as string) || "—"}</td>
<td class="text-text py-3 pr-4 text-right">{p.spellCount != null ? String(p.spellCount) : "—"}</td>
<td class="text-text py-3 pr-4 text-right">{jobList}</td>
<td class="text-text py-3 pr-4 text-right">{profList}</td>
<td class="text-text py-3 text-right">{dungeonPct}</td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
</div>
</>
);
}}
</Show>
);
}
/* ───────────────────────────────────────────────
* Dataset toggle component
* ─────────────────────────────────────────────── */
function DatasetToggle(props: {
datasets: DatasetDef[];
active: DatasetId;
onSelect: (id: DatasetId) => void;
}) {
return (
<div class="mb-6 flex flex-wrap gap-2" role="radiogroup">
<For each={props.datasets}>
{(ds) => (
<button
onClick={() => props.onSelect(ds.id)}
class={`flex items-center gap-2 rounded-lg px-4 py-2 font-medium transition-colors ${
props.active === ds.id
? "bg-blue text-base"
: "bg-surface0 text-text hover:bg-surface1 border-surface1 border"
}`}
role="radio"
aria-checked={props.active === ds.id}
>
<span
class={`text-[10px] uppercase tracking-wider font-bold px-1.5 py-0.5 rounded ${
props.active === ds.id
? "bg-white/20"
: "bg-surface1 text-subtext0"
}`}
>
{ds.badge}
</span>
{ds.label}
</button>
)}
</For>
</div>
);
}
/* ───────────────────────────────────────────────
* Page component
* ─────────────────────────────────────────────── */
export default function AnalyticsPage() {
const [dataset, setDataset] = createSignal<DatasetId>("frenome");
const [timeWindow, setTimeWindow] = createSignal(7);
const [selectedPath, setSelectedPath] = createSignal<string | null>(null);
const [error, setError] = createSignal<string | null>(null);
const activeDef = () =>
DATASETS.find((d) => d.id === dataset()) ?? DATASETS[0];
return (
<>
<PageHead
title="Analytics Dashboard - Admin"
description="Multi-dataset analytics dashboard"
/>
<div class="bg-base min-h-screen px-4 py-8">
<div class="mx-auto max-w-7xl">
{/* Header */}
<div class="mb-8">
<h1 class="text-text mb-2 text-4xl font-bold">
Analytics Dashboard
</h1>
<p class="text-subtext0">{activeDef().description}</p>
</div>
{/* Dataset toggle */}
<DatasetToggle
datasets={DATASETS}
active={dataset()}
onSelect={(id) => {
setDataset(id);
setSelectedPath(null);
}}
/>
{/* Time window — only for freno.me for now */}
<Show when={dataset() === "frenome"}>
<div class="mb-6 flex gap-2">
<For each={[1, 7, 30, 90]}>
{(days) => (
<button
onClick={() => setTimeWindow(days)}
class={`rounded-lg px-4 py-2 font-medium transition-colors ${
timeWindow() === days
? "bg-blue text-base"
: "bg-surface0 text-text hover:bg-surface1 border-surface1 border"
}`}
>
{days === 1 ? "24h" : `${days}d`}
</button>
)}
</For>
</div>
</Show>
{/* Error banner */}
<Show when={error()}>
<div class="bg-red/20 border-red text-red mb-6 rounded-lg border p-4">
<p class="font-semibold">Error loading analytics</p>
<p class="text-sm">{error()}</p>
</div>
</Show>
{/* Dataset content */}
<Show when={dataset() === "frenome"}>
<FrenomePanel
days={timeWindow()}
setPath={setSelectedPath}
/>
{/* Path detail overlay */}
<Show when={selectedPath()}>
{(path) => (
<PathDetailPanel
path={path()}
days={timeWindow()}
onClose={() => setSelectedPath(null)}
/>
)}
</Show>
</Show>
<Show when={dataset() === "lineage"}>
<LineagePanel />
</Show>
</div>
</div>
</>
);
}