From 93a474ef45b8b32192a8a2b913611d3012cd88a5 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 26 Apr 2026 11:21:41 -0500 Subject: [PATCH] feat: add hardware stats API endpoints (list + detail) --- app/api/[[...slugs]]/route.ts | 2 + lib/api/hardware-stats.ts | 285 ++++++++++++++++++++++++++++++++++ lib/api/index.ts | 1 + 3 files changed, 288 insertions(+) create mode 100644 lib/api/hardware-stats.ts diff --git a/app/api/[[...slugs]]/route.ts b/app/api/[[...slugs]]/route.ts index 81f5a10..9bb0d4f 100644 --- a/app/api/[[...slugs]]/route.ts +++ b/app/api/[[...slugs]]/route.ts @@ -7,6 +7,7 @@ import { gamesRoutes, gameVersionsRoutes, hardwareRoutes, + hardwareStatsRoutes, performanceRoutes, performanceVerifyRoutes, performanceSubmitRoutes, @@ -62,6 +63,7 @@ export const app = new Elysia({ prefix: "/api" }) .use(gameVersionsRoutes) // Hardware .use(hardwareRoutes) + .use(hardwareStatsRoutes) // Performance .use(performanceRoutes) .use(performanceVerifyRoutes) diff --git a/lib/api/hardware-stats.ts b/lib/api/hardware-stats.ts new file mode 100644 index 0000000..fe23102 --- /dev/null +++ b/lib/api/hardware-stats.ts @@ -0,0 +1,285 @@ +import { Elysia, t } from "elysia" +import { db } from "@/lib/db/index" +import { + hardware, + performanceEntries, + gameVersions, + games, +} from "@/lib/db/schema" +import { eq, and, sql, desc } from "drizzle-orm" + +export const hardwareStatsRoutes = new Elysia({ prefix: "/hardware" }) + // ── All devices with aggregated stats ────────────────────── + .get( + "/stats", + async () => { + // Get all hardware devices ordered by sortOrder + const devices = await db + .select({ + slug: hardware.slug, + name: hardware.name, + deviceType: hardware.deviceType, + sortOrder: hardware.sortOrder, + }) + .from(hardware) + .orderBy(hardware.sortOrder) + + // Get aggregated stats per device + const statsPerDevice = await db + .select({ + hardwareSlug: performanceEntries.hardwareSlug, + totalBenchmarks: sql`count(*)::int`, + avgFps: sql`round(avg(${performanceEntries.fpsAvg})::numeric, 1)`, + verifiedCount: sql`count(*) filter (where ${performanceEntries.verifiedAt} is not null)::int`, + gameCount: sql`count(distinct ${gameVersions.gameId})::int`, + }) + .from(performanceEntries) + .innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id)) + .innerJoin(games, eq(gameVersions.gameId, games.id)) + .innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug)) + .where(eq(performanceEntries.isRemoved, false)) + .groupBy(performanceEntries.hardwareSlug) + + const statsMap = new Map(statsPerDevice.map((s) => [s.hardwareSlug, s])) + + // Best game per device (highest avg FPS) + const bestGames = await db + .select({ + hardwareSlug: performanceEntries.hardwareSlug, + gameId: games.id, + gameTitle: games.title, + gameHeaderImage: games.headerImage, + fpsAvg: sql`round(avg(${performanceEntries.fpsAvg})::numeric, 1)`, + }) + .from(performanceEntries) + .innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id)) + .innerJoin(games, eq(gameVersions.gameId, games.id)) + .where(eq(performanceEntries.isRemoved, false)) + .groupBy(performanceEntries.hardwareSlug, games.id, games.title, games.headerImage) + .orderBy(desc(sql`avg(${performanceEntries.fpsAvg})`)) + + // For each device, pick the best game (first result per device slug) + const bestGameMap = new Map() + for (const bg of bestGames) { + if (!bestGameMap.has(bg.hardwareSlug)) { + bestGameMap.set(bg.hardwareSlug, { + id: bg.gameId, + title: bg.gameTitle, + headerImage: bg.gameHeaderImage, + fpsAvg: Number(bg.fpsAvg), + }) + } + } + + return devices.map((device) => { + const stats = statsMap.get(device.slug) + const bestGame = bestGameMap.get(device.slug) + return { + slug: device.slug, + name: device.name, + deviceType: device.deviceType, + sortOrder: device.sortOrder, + totalBenchmarks: stats?.totalBenchmarks ?? 0, + avgFps: stats?.avgFps ? Number(stats.avgFps) : null, + gameCount: stats?.gameCount ?? 0, + verifiedCount: stats?.verifiedCount ?? 0, + bestGame: bestGame ?? null, + } + }) + }, + ) + // ── Single device detailed stats ─────────────────────────── + .get( + "/:slug/stats", + async ({ params, set }) => { + const { slug } = params + + // Verify hardware exists + 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) { + set.status = 404 + return { error: "Device not found" } + } + + // All entries for this device + const entries = await db + .select({ + id: performanceEntries.id, + gameId: games.id, + gameTitle: games.title, + gameHeaderImage: games.headerImage, + fpsAvg: performanceEntries.fpsAvg, + fpsLow: performanceEntries.fpsLow, + fpsHigh: performanceEntries.fpsHigh, + fsrVersion: performanceEntries.fsrVersion, + frameGenMethod: performanceEntries.frameGenMethod, + protonVersion: performanceEntries.protonVersion, + osVersion: performanceEntries.osVersion, + upvotes: performanceEntries.upvotes, + downvotes: performanceEntries.downvotes, + verifiedAt: performanceEntries.verifiedAt, + createdAt: performanceEntries.createdAt, + genres: games.genres, + }) + .from(performanceEntries) + .innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id)) + .innerJoin(games, eq(gameVersions.gameId, games.id)) + .where( + and( + eq(performanceEntries.hardwareSlug, slug), + eq(performanceEntries.isRemoved, false), + ) + ) + + if (entries.length === 0) { + return { + ...device, + totalBenchmarks: 0, + avgFps: null, + verifiedCount: 0, + gameCount: 0, + boxplot: [], + historical: [], + topGames: [], + genreBreakdown: [], + protonBreakdown: [], + fsrBreakdown: [], + } + } + + const totalBenchmarks = entries.length + const avgFps = Math.round( + (entries.reduce((s, e) => s + (e.fpsAvg ?? 0), 0) / totalBenchmarks) * 10 + ) / 10 + const verifiedCount = entries.filter((e) => e.verifiedAt !== null).length + + // Unique game count + const gameIds = new Set(entries.map((e) => e.gameId)) + const gameCount = gameIds.size + + // ── Boxplot: FPS distribution per game ─────────────── + const gameFpsMap = new Map() + for (const e of entries) { + if (!gameFpsMap.has(e.gameId)) { + gameFpsMap.set(e.gameId, { title: e.gameTitle, values: [] }) + } + gameFpsMap.get(e.gameId)!.values.push(e.fpsAvg ?? 0) + } + + // Top 10 games by benchmark count for boxplot + const topGameEntries = [...gameFpsMap.entries()] + .sort((a, b) => b[1].values.length - a[1].values.length) + .slice(0, 10) + + const boxplot = topGameEntries.map(([gameId, { title, values }]) => { + const sorted = [...values].sort((a, b) => a - b) + const n = sorted.length + return { + gameId, + gameTitle: title, + min: sorted[0], + q1: sorted[Math.floor(n * 0.25)] ?? sorted[0], + median: sorted[Math.floor(n * 0.5)] ?? sorted[0], + q3: sorted[Math.floor(n * 0.75)] ?? sorted[n - 1], + max: sorted[n - 1], + count: n, + } + }) + + // ── Historical: avg FPS per month ─────────────────── + const monthMap = new Map() + for (const e of entries) { + const month = `${e.createdAt.getFullYear()}-${String(e.createdAt.getMonth() + 1).padStart(2, "0")}` + if (!monthMap.has(month)) monthMap.set(month, { sum: 0, count: 0 }) + const m = monthMap.get(month)! + m.sum += e.fpsAvg ?? 0 + m.count++ + } + + const historical = [...monthMap.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([period, { sum, count }]) => ({ + period, + avgFps: Math.round((sum / count) * 10) / 10, + count, + })) + + // ── Top Games by avg FPS ──────────────────────────── + const topGames = [...gameFpsMap.entries()] + .map(([gameId, { title, values }]) => ({ + gameId, + gameTitle: title, + headerImage: entries.find((e) => e.gameId === gameId)?.gameHeaderImage ?? null, + avgFps: Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 10) / 10, + benchmarkCount: values.length, + })) + .sort((a, b) => b.avgFps - a.avgFps) + .slice(0, 20) + + // ── Genre breakdown ───────────────────────────────── + const genreMap = new Map() + for (const e of entries) { + if (e.genres && Array.isArray(e.genres)) { + for (const g of e.genres) { + genreMap.set(g, (genreMap.get(g) || 0) + 1) + } + } + } + const genreBreakdown = [...genreMap.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([genre, count]) => ({ genre, count })) + + // ── Proton breakdown ──────────────────────────────── + const protonMap = new Map() + for (const e of entries) { + if (e.protonVersion) { + protonMap.set(e.protonVersion, (protonMap.get(e.protonVersion) || 0) + 1) + } + } + const protonBreakdown = [...protonMap.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([version, count]) => ({ version, count })) + + // ── FSR breakdown ─────────────────────────────────── + const fsrMap = new Map() + for (const e of entries) { + const key = e.fsrVersion ?? "none" + if (!fsrMap.has(key)) fsrMap.set(key, { count: 0, avgFps: 0 }) + const f = fsrMap.get(key)! + f.count++ + f.avgFps += e.fpsAvg ?? 0 + } + const fsrBreakdown = [...fsrMap.entries()].map(([version, data]) => ({ + version, + count: data.count, + avgFps: Math.round((data.avgFps / data.count) * 10) / 10, + })) + + return { + ...device, + totalBenchmarks, + avgFps, + verifiedCount, + gameCount, + boxplot, + historical, + topGames, + genreBreakdown, + protonBreakdown, + fsrBreakdown, + } + }, + { + params: t.Object({ slug: t.String() }), + }, + ) diff --git a/lib/api/index.ts b/lib/api/index.ts index a38c443..3346583 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -11,4 +11,5 @@ export { } from "./presets" export { commentsRoutes } from "./comments" export { gameStatsRoutes } from "./game-stats" +export { hardwareStatsRoutes } from "./hardware-stats" export { savedGamesRoutes } from "./saved-games"