From 0d477d84025d715db70f86226762b9755276e0e9 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 26 Apr 2026 11:26:43 -0500 Subject: [PATCH] feat: add device detail page with charts and stats --- app/devices/[slug]/device-detail-client.tsx | 371 ++++++++++++++++++++ app/devices/[slug]/page.tsx | 41 +++ 2 files changed, 412 insertions(+) create mode 100644 app/devices/[slug]/device-detail-client.tsx create mode 100644 app/devices/[slug]/page.tsx diff --git a/app/devices/[slug]/device-detail-client.tsx b/app/devices/[slug]/device-detail-client.tsx new file mode 100644 index 0000000..cbb0dc7 --- /dev/null +++ b/app/devices/[slug]/device-detail-client.tsx @@ -0,0 +1,371 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { motion } from "motion/react" +import { Gamepad2Icon, TrendingUpIcon, DatabaseIcon, CheckCircleIcon, ArrowRightIcon, Loader2 } from "lucide-react" +import { EChartWrapper, CHART_THEME, getDeviceColor } from "@/components/charts/EChartWrapper" +import type { EChartsOption } from "echarts" + +interface DeviceInfo { + slug: string + name: string + deviceType: string +} + +interface DeviceStats { + slug: string + name: string + deviceType: string + totalBenchmarks: number + avgFps: number | null + verifiedCount: number + gameCount: number + boxplot: Array<{ + gameId: string + gameTitle: string + min: number + q1: number + median: number + q3: number + max: number + count: number + }> + historical: Array<{ + period: string + avgFps: number + count: number + }> + topGames: Array<{ + gameId: string + gameTitle: string + headerImage: string | null + avgFps: number + benchmarkCount: number + }> + genreBreakdown: Array<{ genre: string; count: number }> + protonBreakdown: Array<{ version: string; count: number }> + fsrBreakdown: Array<{ version: string; count: number; avgFps: number }> +} + +const deviceTypeLabel: Record = { + handled: "Handheld", + console: "Console", +} + +export function DeviceDetailClient({ device }: { device: DeviceInfo }) { + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let cancelled = false + async function fetchStats() { + setLoading(true) + try { + const res = await fetch(`/api/hardware/${device.slug}/stats`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data = await res.json() + if (!cancelled) setStats(data) + } catch (err) { + console.error("Failed to fetch device stats:", err) + } finally { + if (!cancelled) setLoading(false) + } + } + fetchStats() + return () => { cancelled = true } + }, [device.slug]) + + const deviceColor = getDeviceColor(0) + + // ── Chart Options ────────────────────────────────────────── + const historicalOption = useMemo(() => { + if (!stats || stats.historical.length === 0) return {} + return { + tooltip: { + trigger: "axis", + backgroundColor: "#1a1020", + borderColor: CHART_THEME.border, + textStyle: { color: CHART_THEME.text }, + }, + grid: { left: 50, right: 20, top: 10, bottom: 30 }, + xAxis: { + type: "category", + data: stats.historical.map((h) => h.period), + axisLine: { lineStyle: { color: CHART_THEME.border } }, + axisLabel: { color: CHART_THEME.textMuted, fontSize: 11 }, + }, + yAxis: { + type: "value", + axisLine: { lineStyle: { color: CHART_THEME.border } }, + splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } }, + axisLabel: { color: CHART_THEME.textMuted, fontSize: 11 }, + }, + series: [ + { + type: "line", + data: stats.historical.map((h) => h.avgFps), + smooth: true, + lineStyle: { color: deviceColor, width: 2 }, + areaStyle: { + color: { + type: "linear", + x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: deviceColor + "40" }, + { offset: 1, color: deviceColor + "05" }, + ], + }, + }, + symbol: "circle", + symbolSize: 4, + itemStyle: { color: deviceColor }, + }, + ], + } + }, [stats, deviceColor]) + + const boxplotOption = useMemo(() => { + if (!stats || stats.boxplot.length === 0) return {} + return { + tooltip: { + trigger: "item", + backgroundColor: "#1a1020", + borderColor: CHART_THEME.border, + textStyle: { color: CHART_THEME.text }, + }, + grid: { left: 80, right: 20, top: 10, bottom: 40 }, + xAxis: { + type: "category", + data: stats.boxplot.map((b) => b.gameTitle), + axisLine: { lineStyle: { color: CHART_THEME.border } }, + axisLabel: { color: CHART_THEME.textMuted, fontSize: 10, rotate: 30 }, + }, + yAxis: { + type: "value", + name: "FPS", + axisLine: { lineStyle: { color: CHART_THEME.border } }, + splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } }, + axisLabel: { color: CHART_THEME.textMuted, fontSize: 11 }, + nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 11 }, + }, + series: [ + { + type: "boxplot", + data: stats.boxplot.map((b) => [b.min, b.q1, b.median, b.q3, b.max]), + itemStyle: { color: deviceColor + "30", borderColor: deviceColor }, + }, + ], + } + }, [stats, deviceColor]) + + const genreOption = useMemo(() => { + if (!stats || stats.genreBreakdown.length === 0) return {} + return { + tooltip: { + trigger: "item", + backgroundColor: "#1a1020", + borderColor: CHART_THEME.border, + textStyle: { color: CHART_THEME.text }, + }, + series: [ + { + type: "pie", + radius: ["40%", "70%"], + center: ["50%", "50%"], + data: stats.genreBreakdown.map((g, i) => ({ + name: g.genre, + value: g.count, + itemStyle: { color: CHART_THEME.deviceColors[i % CHART_THEME.deviceColors.length] }, + })), + label: { + color: CHART_THEME.textMuted, + fontSize: 10, + }, + emphasis: { + itemStyle: { shadowBlur: 10, shadowColor: "rgba(0,0,0,0.5)" }, + }, + }, + ], + } + }, [stats]) + + return ( +
+ {/* ── Hero Header ──────────────────────────────────── */} + +
+
+

{device.name}

+ + + {deviceTypeLabel[device.deviceType] || device.deviceType} + +
+

+ Performance benchmarks and statistics +

+
+
+ + {/* ── Overview Stats ────────────────────────────────── */} + {stats && ( + +
+ + + + +
+
+ )} + + {/* ── Loading State ─────────────────────────────────── */} + {loading && ( +
+ +
+ )} + + {/* ── Charts Section ────────────────────────────────── */} + {stats && !loading && ( + +
+ {/* Row 1: Historical FPS */} + {stats.historical.length > 0 && ( +
+

Historical Performance

+ +
+ )} + + {/* Row 2: FPS Distribution + Genre Breakdown */} +
+ {stats.boxplot.length > 0 && ( +
+

FPS Distribution by Game

+ +
+ )} + {stats.genreBreakdown.length > 0 && ( +
+

Genre Breakdown

+ +
+ )} +
+ + {/* Row 3: Proton & FSR Breakdown */} + {(stats.protonBreakdown.length > 0 || stats.fsrBreakdown.length > 0) && ( +
+ {stats.protonBreakdown.length > 0 && ( +
+

Proton Version Distribution

+
+ {stats.protonBreakdown.map((p) => ( +
+ {p.version} +
+
+
+
+ {p.count} +
+
+ ))} +
+
+ )} + {stats.fsrBreakdown.length > 0 && ( +
+

FSR Version Performance

+
+ {stats.fsrBreakdown.map((f) => ( +
+ {f.version === "none" ? "Native" : f.version.toUpperCase()} +
+ {f.avgFps} FPS + ({f.count}) +
+
+ ))} +
+
+ )} +
+ )} + + {/* Row 4: Top Games */} + {stats.topGames.length > 0 && ( +
+

Top Games by Average FPS

+
+ {stats.topGames.slice(0, 8).map((game, i) => ( + +
{i + 1}
+
+

{game.gameTitle}

+
+ {game.avgFps} FPS + {game.benchmarkCount} runs +
+
+ + + ))} +
+
+ )} +
+ + )} + + {/* ── Empty state ──────────────────────────────────── */} + {stats && !loading && stats.totalBenchmarks === 0 && ( +
+
+ +

No benchmark data yet for this device

+

Data will appear as benchmarks are submitted

+
+
+ )} +
+ ) +} + +function StatCard({ icon: Icon, label, value }: { icon: React.ElementType; label: string; value: string }) { + return ( +
+
+ +
+
+ {label} + {value} +
+
+ ) +} diff --git a/app/devices/[slug]/page.tsx b/app/devices/[slug]/page.tsx new file mode 100644 index 0000000..14d86b8 --- /dev/null +++ b/app/devices/[slug]/page.tsx @@ -0,0 +1,41 @@ +import { db } from "@/lib/db/index" +import { hardware } from "@/lib/db/schema" +import { eq } from "drizzle-orm" +import { notFound } from "next/navigation" +import { DeviceDetailClient } from "./device-detail-client" + +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const [device] = await db + .select({ name: hardware.name }) + .from(hardware) + .where(eq(hardware.slug, slug)) + .limit(1) + + if (!device) return { title: "Device Not Found — DeckyVault" } + + return { + title: `${device.name} — DeckyVault`, + description: `Benchmark data and performance stats for ${device.name} on DeckyVault`, + } +} + +export default async function DevicePage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + + const [device] = await db + .select({ + slug: hardware.slug, + name: hardware.name, + deviceType: hardware.deviceType, + }) + .from(hardware) + .where(eq(hardware.slug, slug)) + .limit(1) + + if (!device) { + notFound() + } + + return +}