diff --git a/src/routes/analytics.tsx b/src/routes/analytics.tsx index 701c14f..91b6aa5 100644 --- a/src/routes/analytics.tsx +++ b/src/routes/analytics.tsx @@ -1,7 +1,39 @@ -import { createSignal, Show, For, createEffect, ErrorBoundary } from "solid-js"; +import { createSignal, Show, For } from "solid-js"; import { PageHead } from "~/components/PageHead"; -import { redirect, query, createAsync, useNavigate } from "@solidjs/router"; -import { api } from "~/lib/api"; +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"; @@ -9,13 +41,16 @@ const checkAdmin = query(async (): Promise => { const userState = await getUserState(); if (!userState.isAdmin) { - console.log("redirect"); 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"); @@ -43,49 +78,388 @@ const getPathData = query(async (path: string, days: number) => { 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 initial data with default timeWindow of 7 days + // Preload freno.me dataset (the default tab) void getSummaryData(7); void getPerformanceData(7); - } + }, }; -interface PerformanceTarget { - good: number; - acceptable: number; - label: string; - unit: string; +/* ─────────────────────────────────────────────── + * Helpers + * ─────────────────────────────────────────────── */ + +function formatNumber(num: number): string { + return new Intl.NumberFormat().format(Math.round(num)); } -const PERFORMANCE_TARGETS: Record = { - lcp: { good: 1500, acceptable: 2500, label: "LCP", unit: "ms" }, - fcp: { good: 1000, acceptable: 1800, label: "FCP", unit: "ms" }, - ttfb: { good: 500, acceptable: 800, label: "TTFB", unit: "ms" }, - cls: { good: 0.05, acceptable: 0.1, label: "CLS", unit: "" }, - avgDuration: { - good: 2000, - acceptable: 3000, - label: "Avg Duration", - unit: "ms" - } +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 }) => ( + + + + + + + + + )} + + +
PageLCPFCPCLSTTFBSamples
+ {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 getPerformanceRating( - metric: string, - value: number -): "good" | "acceptable" | "poor" { - const target = PERFORMANCE_TARGETS[metric]; - if (!target) return "acceptable"; - - if (value <= target.good) return "good"; - if (value <= target.acceptable) return "acceptable"; +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 getRatingColor(rating: "good" | "acceptable" | "poor"): string { - switch (rating) { +function ratingColor(metric: string, value: number): string { + const r = perfRating(metric, value); + switch (r) { case "good": return "text-green"; case "acceptable": @@ -95,8 +469,9 @@ function getRatingColor(rating: "good" | "acceptable" | "poor"): string { } } -function getRatingBgColor(rating: "good" | "acceptable" | "poor"): string { - switch (rating) { +function ratingBg(metric: string, value: number): string { + const r = perfRating(metric, value); + switch (r) { case "good": return "bg-green/10"; case "acceptable": @@ -106,66 +481,381 @@ function getRatingBgColor(rating: "good" | "acceptable" | "poor"): string { } } -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes.toFixed(0)}B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; - return `${(bytes / (1024 * 1024)).toFixed(2)}MB`; +function PerformanceCard(props: { + label: string; + value: string | null; + metric: string; + targetLabel: string; + target: number; +}) { + return ( + +
+
{props.label}
+
+ {props.value} +
+
Target: {props.targetLabel}
+
+
+ ); } -function formatNumber(num: number): string { - return new Intl.NumberFormat().format(Math.round(num)); +/* ─────────────────────────────────────────────── + * 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 IDClassSpellsJobsProficienciesDungeon 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 summary = createAsync(() => getSummaryData(timeWindow())); - - const performanceStats = createAsync(() => getPerformanceData(timeWindow())); - - const pathStats = createAsync(() => { - const path = selectedPath(); - if (!path) return Promise.resolve(null); - return getPathData(path, timeWindow()); - }); + const activeDef = () => + DATASETS.find((d) => d.id === dataset()) ?? DATASETS[0]; return ( <>
+ {/* Header */}

Analytics Dashboard

-

- Visitor analytics and performance metrics -

+

{activeDef().description}

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

Error loading analytics

@@ -173,493 +863,27 @@ export default function AnalyticsPage() {
- - {(data) => ( - <> - {/* Overview Cards */} -
-
-
Total Requests
-
- {formatNumber(data().totalVisits)} -
-
- {formatNumber(data().totalPageVisits)} pages,{" "} - {formatNumber(data().totalApiCalls)} API -
-
+ {/* Dataset content */} + + -
-
- Unique Visitors -
-
- {formatNumber(data().uniqueVisitors)} -
-
- -
-
- Authenticated Users -
-
- {formatNumber(data().uniqueUsers)} -
-
- -
-
- Avg. Visits/Day -
-
- {formatNumber(data().totalVisits / timeWindow())} -
-
-
- - {/* Performance Metrics Section */} - 0 - } - > -
-

- Core Web Vitals -

- - {/* Performance Overview Cards */} -
- -
-
- LCP (Largest Contentful Paint) -
-
- {Math.round(performanceStats()!.avgLcp!)}ms -
-
- Target: <1.5s (good), <2.5s (ok) -
-
-
- - -
-
- FCP (First Contentful Paint) -
-
- {Math.round(performanceStats()!.avgFcp!)}ms -
-
- Target: <1s (good), <1.8s (ok) -
-
-
- - -
-
- CLS (Cumulative Layout Shift) -
-
- {performanceStats()!.avgCls!.toFixed(3)} -
-
- Target: <0.05 (good), <0.1 (ok) -
-
-
- - -
-
- TTFB (Time to First Byte) -
-
- {Math.round(performanceStats()!.avgTtfb!)}ms -
-
- Target: <500ms (good), <800ms (ok) -
-
-
-
- - {/* Performance by Page */} - 0 - } - > -
-
-

- Performance by Page -

-

- {performanceStats()!.totalWithMetrics} page loads - with performance data -

-
-
-
- - - - - - - - - - - - - - {(page) => ( - - - - - - - - - )} - - -
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) => { - const percentage = - (pathData.count / data().totalPageVisits) * 100; - return ( -
setSelectedPath(pathData.path)} - > -
- - {pathData.path} - - - {formatNumber(pathData.count)} visits - -
-
-
-
-
- {percentage.toFixed(1)}% of page traffic -
-
- ); - }} - -
-
-
- - {/* Top API Calls */} -
-
-

Top API Calls

-
-
-
- - {(apiData) => { - const percentage = - (apiData.count / data().totalApiCalls) * 100; - return ( -
-
- - {apiData.path} - - - {formatNumber(apiData.count)} - -
-
-
-
-
- {percentage.toFixed(1)}% of API traffic -
-
- ); - }} - -
-
-
- - {/* Device & Browser Stats */} -
- {/* Device Types */} -
-
-

Device Types

-
-
-
- - {(device) => { - const totalDevices = data().deviceTypes.reduce( - (sum, d) => sum + d.count, - 0 - ); - const percentage = - totalDevices > 0 - ? (device.count / totalDevices) * 100 - : 0; - return ( -
-
- - {device.type} - - - {formatNumber(device.count)} ( - {percentage.toFixed(1)}%) - -
-
-
-
-
- ); - }} - -
-
-
- - {/* Browsers */} -
-
-

Browsers

-
-
-
- - {(browser) => { - const totalBrowsers = data().browsers.reduce( - (sum, b) => sum + b.count, - 0 - ); - const percentage = - totalBrowsers > 0 - ? (browser.count / totalBrowsers) * 100 - : 0; - return ( -
-
- - {browser.browser} - - - {formatNumber(browser.count)} ( - {percentage.toFixed(1)}%) - -
-
-
-
-
- ); - }} - -
-
-
-
- - {/* Top Referrers */} - 0}> -
-
-

- Top Referrers -

-
-
-
- - {(referrer) => ( -
- - {referrer.referrer} - - - {formatNumber(referrer.count)} - -
- )} -
-
-
-
-
- - )} + {/* Path detail overlay */} + + {(path) => ( + setSelectedPath(null)} + /> + )} + - {/* Path Details Modal/Section */} - - {(stats) => ( -
-
-

- Path Details: {selectedPath()} -

- -
-
-
-
-
Total Visits
-
- {formatNumber(stats().totalVisits)} -
-
-
-
Unique Visitors
-
- {formatNumber(stats().uniqueVisitors)} -
-
-
-
Avg. Duration
-
- {stats().avgDurationMs - ? `${(stats().avgDurationMs! / 1000).toFixed(1)}s` - : "N/A"} -
-
-
- - {/* Visits by Day */} - 0}> -
-

- Visits by Day -

-
- - {(day) => { - const maxVisits = Math.max( - ...stats().visitsByDay.map((d) => d.count) - ); - const percentage = (day.count / maxVisits) * 100; - return ( -
-
- - {new Date(day.date).toLocaleDateString()} - - - {formatNumber(day.count)} - -
-
-
-
-
- ); - }} - -
-
- -
-
- )} + +
diff --git a/src/server/api/routers/lineage/misc.ts b/src/server/api/routers/lineage/misc.ts index 9be61a0..66b4297 100644 --- a/src/server/api/routers/lineage/misc.ts +++ b/src/server/api/routers/lineage/misc.ts @@ -1,4 +1,4 @@ -import { createTRPCRouter, publicProcedure } from "../../utils"; +import { createTRPCRouter, publicProcedure, adminProcedure } from "../../utils"; import { z } from "zod"; import { LineageConnectionFactory } from "~/server/utils"; import { env } from "~/env/server"; @@ -92,4 +92,42 @@ export const lineageMiscRouter = createTRPCRouter({ offlineSecret: publicProcedure.query(() => { return { secret: env.LINEAGE_OFFLINE_SERIALIZATION_SECRET }; }), + + getLineageStats: adminProcedure.query(async () => { + const conn = LineageConnectionFactory(); + + try { + const res = await conn.execute({ + sql: `SELECT * FROM Analytics`, + args: [], + }); + + const rows = res.rows.map((row: any) => ({ + playerID: row.playerID as string, + dungeonProgression: safeJsonParse(row.dungeonProgression), + playerClass: row.playerClass as string, + spellCount: row.spellCount as number, + proficiencies: safeJsonParse(row.proficiencies), + jobs: safeJsonParse(row.jobs), + resistanceTable: safeJsonParse(row.resistanceTable), + damageTable: safeJsonParse(row.damageTable), + })); + + return { success: true, players: rows }; + } catch (e) { + console.error("Failed to fetch lineage analytics:", e); + return { success: false, players: [] }; + } + }), }); + +function safeJsonParse(val: unknown): unknown { + if (typeof val === "string") { + try { + return JSON.parse(val); + } catch { + return val; + } + } + return val; +}