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 => { "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 (
{props.label} {formatNumber(props.count)}{" "} ({formatPercent(props.count, props.total)}%)
); } /* ─────────────────────────────────────────────── * Overview card component * ─────────────────────────────────────────────── */ function OverviewCard(props: { title: string; value: string; subtitle?: string; }) { return (
{props.title}
{props.value}
{props.subtitle}
); } /* ─────────────────────────────────────────────── * 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 ( {(data) => ( <> {/* Overview cards */}
{/* Performance */} {(ps) => ( 0}>

Core Web Vitals

0}>

Performance by Page

{ps.totalWithMetrics} page loads with performance data

{(page: { path: string; avgLcp: number; avgFcp: number; avgCls: number; avgTtfb: number; count: number }) => ( )}
Page LCP FCP CLS TTFB Samples
{page.path} {Math.round(page.avgLcp)}ms {Math.round(page.avgFcp)}ms {page.avgCls.toFixed(3)} {Math.round(page.avgTtfb)}ms {page.count}
)}
{/* Top pages */}

Top Pages

{(pathData) => ( props.setPath(pathData.path)} /> )}
{/* Top API calls */}

Top API Calls

{(apiData) => ( )}
{/* Devices & browsers */}

Device Types

{(device) => ( s + d.count, 0, )} color="bg-purple-600" /> )}

Browsers

{(browser: { browser: string; count: number }) => ( s + b.count, 0)} color="bg-green-600" /> )}
{/* Top referrers */} 0}>

Top Referrers

{(referrer) => (
{referrer.referrer} {formatNumber(referrer.count)}
)}
)}
); } /* ─────────────────────────────────────────────── * Performance card sub-component * ─────────────────────────────────────────────── */ interface PerfThresholds { good: number; acceptable: number; } const PERF_THRESHOLDS: Record = { 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 (
{props.label}
{props.value}
Target: {props.targetLabel}
); } /* ─────────────────────────────────────────────── * Path detail panel (freno.me) * ─────────────────────────────────────────────── */ function PathDetailPanel(props: { path: string; days: number; onClose: () => void; }) { const stats = createAsync(() => getPathData(props.path, props.days)); return ( {(s) => (

Path: {props.path}

0}>

Visits by Day

{(day) => { const maxV = Math.max(...s.visitsByDay.map((d) => d.count)); const pct = maxV > 0 ? (day.count / maxV) * 100 : 0; return (
{new Date(day.date).toLocaleDateString()} {formatNumber(day.count)}
); }}
)} ); } /* ─────────────────────────────────────────────── * Lineage dashboard panel * ─────────────────────────────────────────────── */ function LineagePanel() { const data = createAsync(() => getLineageStats()); return ( {(result) => { if (!result.success || result.players.length === 0) { return (

No Lineage player analytics data available yet.

Data appears after players send telemetry from the mobile app.

); } const players = result.players; const totalPlayers = players.length; const classCounts: Record = {}; 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 = {}; for (const p of players) { if (typeof p.jobs === "object" && p.jobs) { for (const [job, level] of Object.entries(p.jobs as Record)) { 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 | null; if (dp && typeof dp.completedFloors === "number") { totalCompletion += dp.completedFloors; playersWithDungeons++; } } const avgDungeonPct = playersWithDungeons > 0 ? ((totalCompletion / playersWithDungeons) * 100).toFixed(1) : "—"; const profCounts: Record = {}; for (const p of players) { if (typeof p.proficiencies === "object" && p.proficiencies) { for (const [prof, val] of Object.entries(p.proficiencies as Record)) { 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 */}
0 ? `${playersWithDungeons} players with progression data` : "No progression data yet"} />

Class Distribution

{([cls, count]) => }

Top Jobs

0} fallback={

No job data recorded yet.

}> {([job, count]) => }
0}>

Magic Proficiencies

{([prof, count]) => }

All Players

{totalPlayers} total players

{(p: Record) => { const dp = p.dungeonProgression as Record | 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).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).filter(([, v]) => typeof v === "number" && (v as number) > 0).length : 0; return ( ); }}
Player ID Class Spells Jobs Proficiencies Dungeon Completion
{p.playerID as string} {(p.playerClass as string) || "—"} {p.spellCount != null ? String(p.spellCount) : "—"} {jobList} {profList} {dungeonPct}
); }}
); } /* ─────────────────────────────────────────────── * Dataset toggle component * ─────────────────────────────────────────────── */ function DatasetToggle(props: { datasets: DatasetDef[]; active: DatasetId; onSelect: (id: DatasetId) => void; }) { return (
{(ds) => ( )}
); } /* ─────────────────────────────────────────────── * Page component * ─────────────────────────────────────────────── */ export default function AnalyticsPage() { const [dataset, setDataset] = createSignal("frenome"); const [timeWindow, setTimeWindow] = createSignal(7); const [selectedPath, setSelectedPath] = createSignal(null); const [error, setError] = createSignal(null); const activeDef = () => DATASETS.find((d) => d.id === dataset()) ?? DATASETS[0]; return ( <>
{/* Header */}

Analytics Dashboard

{activeDef().description}

{/* Dataset toggle */} { setDataset(id); setSelectedPath(null); }} /> {/* Time window — only for freno.me for now */}
{(days) => ( )}
{/* Error banner */}

Error loading analytics

{error()}

{/* Dataset content */} {/* Path detail overlay */} {(path) => ( setSelectedPath(null)} /> )}
); }