From 5875595219db844beedb19ae643058af8333abef Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 10 May 2026 05:17:55 +0800 Subject: [PATCH] feat: enrich game cards with bestFps, raw/poor performer badges, and battery estimates (Tasks 5.1-5.6) --- app/games/games-page-client.tsx | 24 ++++++++++++ app/games/page.tsx | 69 +++++++++++++++++++++++++++++++++ app/search/page.tsx | 14 +++++++ lib/api/games-listing.ts | 69 +++++++++++++++++++++++++++++++++ lib/api/search-unified.ts | 40 ++++++++++++++++++- 5 files changed, 215 insertions(+), 1 deletion(-) diff --git a/app/games/games-page-client.tsx b/app/games/games-page-client.tsx index ff7d7f1..a8845ea 100644 --- a/app/games/games-page-client.tsx +++ b/app/games/games-page-client.tsx @@ -40,6 +40,10 @@ interface GamesListItem { deckStatus: string | null antiCheatRelevant: boolean antiCheatStatus: "none" | "supported" | "unsupported" | "unknown" | null + bestFps: number | null + isRawPerformer: boolean + isPoorPerformance: boolean + estimatedBatteryMin: number | null } interface DeviceOption { @@ -914,6 +918,26 @@ function GameCard({ game }: { game: GamesListItem }) { {game.steamReviewScore}% )} + {game.isRawPerformer && ( + + ⚡ RAW PERFORMER + + )} + {game.isPoorPerformance && ( + + ⚠ POOR PERFORMANCE + + )} + {game.bestFps != null && ( + + {Math.round(game.bestFps)}fps best + + )} + {game.estimatedBatteryMin != null && ( + + 🔋 ~{Math.round(game.estimatedBatteryMin / 60)}h + + )}
{game.benchmarkCount > 0 ? ( diff --git a/app/games/page.tsx b/app/games/page.tsx index b1d5d96..b1e97b5 100644 --- a/app/games/page.tsx +++ b/app/games/page.tsx @@ -123,6 +123,71 @@ export default async function GamesPage() { } } + // Performance stats: best FPS, raw performer, poor performance, battery estimate + const rawPerformerMap = new Map() + const poorPerformerMap = new Map() + const bestFpsMap = new Map() + const batteryMinMap = new Map() + + if (gameIds.length > 0) { + const perfStats = await db + .select({ + gameId: gameVersions.gameId, + bestFps: sql`MAX(${performanceEntries.fpsAvg})::real`, + isRawPerformer: sql`BOOL_OR( + ${performanceEntries.fpsAvg} >= 60 + AND ${performanceEntries.upscalerType} = 'none' + AND ${performanceEntries.frameGenMethod} = 'none' + )`, + isPoorPerformance: sql`BOOL_OR(${performanceEntries.fpsAvg} < 30)`, + }) + .from(performanceEntries) + .innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id)) + .where( + and( + inArray(gameVersions.gameId, gameIds), + eq(performanceEntries.isRemoved, false), + ), + ) + .groupBy(gameVersions.gameId) + + for (const row of perfStats) { + bestFpsMap.set(row.gameId, row.bestFps) + rawPerformerMap.set(row.gameId, row.isRawPerformer) + poorPerformerMap.set(row.gameId, row.isPoorPerformance) + } + + // Battery estimate for handheld devices + const batteryStats = await db + .select({ + gameId: gameVersions.gameId, + estimatedBatteryMin: sql`ROUND( + (${hardware.wattHours}::real / ${performanceEntries.tdpWatts}) * 60 + )::int`, + }) + .from(performanceEntries) + .innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id)) + .innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug)) + .where( + and( + inArray(gameVersions.gameId, gameIds), + eq(performanceEntries.isRemoved, false), + eq(hardware.deviceType, "handheld"), + sql`${performanceEntries.tdpWatts} IS NOT NULL AND ${performanceEntries.tdpWatts} > 0`, + sql`${hardware.wattHours} IS NOT NULL`, + ), + ) + .orderBy(desc(performanceEntries.fpsAvg)) + + const seenGames = new Set() + for (const row of batteryStats) { + if (!seenGames.has(row.gameId)) { + seenGames.add(row.gameId) + batteryMinMap.set(row.gameId, row.estimatedBatteryMin) + } + } + } + // Fetch all genres const genreRows = await db .select({ genres: games.genres }) @@ -161,6 +226,10 @@ export default async function GamesPage() { deckStatus: platformMap.get(g.id) ?? null, antiCheatRelevant: antiCheatMap.get(g.id)?.antiCheatRelevant ?? false, antiCheatStatus: antiCheatMap.get(g.id)?.antiCheatStatus ?? null, + bestFps: bestFpsMap.get(g.id) ?? null, + isRawPerformer: rawPerformerMap.get(g.id) ?? false, + isPoorPerformance: poorPerformerMap.get(g.id) ?? false, + estimatedBatteryMin: batteryMinMap.get(g.id) ?? null, })) const allGenres = Array.from(genreSet).sort() diff --git a/app/search/page.tsx b/app/search/page.tsx index a4b65bb..7c3a0e5 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -49,6 +49,7 @@ interface UnifiedResult { isRawPerformer?: boolean isPoorPerformance?: boolean bestFps?: number | null + estimatedBatteryMin?: number | null latestVersion?: string | null tinyImage?: string | null // Badges & review fields @@ -567,6 +568,11 @@ function SearchResultCard({ ⚠ POOR PERFORMANCE )} + {result.estimatedBatteryMin != null && ( + + 🔋 ~{Math.round(result.estimatedBatteryMin / 60)}h + + )}
{(result.developer || result.publisher) && (

@@ -751,6 +757,14 @@ function SearchResultCard({ color={result.bestFps != null && result.bestFps >= 60 ? "text-green-400" : undefined} /> + {/* Battery Estimate */} + {result.estimatedBatteryMin != null && ( + + )} + {/* Version */} () + const poorPerformerMap = new Map() + const bestFpsMap = new Map() + const batteryMinMap = new Map() + + if (gameIds.length > 0) { + const perfStats = await db + .select({ + gameId: gameVersions.gameId, + bestFps: sql`MAX(${performanceEntries.fpsAvg})::real`, + isRawPerformer: sql`BOOL_OR( + ${performanceEntries.fpsAvg} >= 60 + AND ${performanceEntries.upscalerType} = 'none' + AND ${performanceEntries.frameGenMethod} = 'none' + )`, + isPoorPerformance: sql`BOOL_OR(${performanceEntries.fpsAvg} < 30)`, + }) + .from(performanceEntries) + .innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id)) + .where( + and( + inArray(gameVersions.gameId, gameIds), + eq(performanceEntries.isRemoved, false), + ), + ) + .groupBy(gameVersions.gameId) + + for (const row of perfStats) { + bestFpsMap.set(row.gameId, row.bestFps) + rawPerformerMap.set(row.gameId, row.isRawPerformer) + poorPerformerMap.set(row.gameId, row.isPoorPerformance) + } + + // Battery estimate for handheld devices + const batteryStats = await db + .select({ + gameId: gameVersions.gameId, + estimatedBatteryMin: sql`ROUND( + (${hardware.wattHours}::real / ${performanceEntries.tdpWatts}) * 60 + )::int`, + }) + .from(performanceEntries) + .innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id)) + .innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug)) + .where( + and( + inArray(gameVersions.gameId, gameIds), + eq(performanceEntries.isRemoved, false), + eq(hardware.deviceType, "handheld"), + sql`${performanceEntries.tdpWatts} IS NOT NULL AND ${performanceEntries.tdpWatts} > 0`, + sql`${hardware.wattHours} IS NOT NULL`, + ), + ) + .orderBy(desc(performanceEntries.fpsAvg)) + + const seenGames = new Set() + for (const row of batteryStats) { + if (!seenGames.has(row.gameId)) { + seenGames.add(row.gameId) + batteryMinMap.set(row.gameId, row.estimatedBatteryMin) + } + } + } + const enrichedData = data.map((g) => ({ id: g.id, steamAppId: g.steamAppId, @@ -377,6 +442,10 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get( deckStatus: platformMap.get(g.id) ?? null, antiCheatRelevant: antiCheatMap.get(g.id)?.antiCheatRelevant ?? false, antiCheatStatus: antiCheatMap.get(g.id)?.antiCheatStatus ?? null, + bestFps: bestFpsMap.get(g.id) ?? null, + isRawPerformer: rawPerformerMap.get(g.id) ?? false, + isPoorPerformance: poorPerformerMap.get(g.id) ?? false, + estimatedBatteryMin: batteryMinMap.get(g.id) ?? null, })) // If sorting by benchmarks, re-sort the enriched data diff --git a/lib/api/search-unified.ts b/lib/api/search-unified.ts index 5cb6659..477ce62 100644 --- a/lib/api/search-unified.ts +++ b/lib/api/search-unified.ts @@ -6,7 +6,7 @@ import { performanceEntries, gameComments, } from "@/lib/db/schema" -import { ilike, or, sql, eq, inArray, and, gte } from "drizzle-orm" +import { ilike, or, sql, eq, inArray, and, gte, desc } from "drizzle-orm" import { fuzzySearchTerm } from "@/lib/db/search" interface SteamSearchItem { @@ -323,6 +323,43 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get( } } + // ── 2b2. Battery estimate for handheld devices ──────────────────── + const batteryMinMap = new Map() + + if (finalIds.length > 0) { + const { hardware: hardwareTable } = await import("@/lib/db/schema") + + const batteryStats = await db + .select({ + gameId: gameVersions.gameId, + estimatedBatteryMin: sql`ROUND( + (${hardwareTable.wattHours}::real / ${performanceEntries.tdpWatts}) * 60 + )::int`, + }) + .from(performanceEntries) + .innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id)) + .innerJoin(hardwareTable, eq(performanceEntries.hardwareSlug, hardwareTable.slug)) + .where( + and( + inArray(gameVersions.gameId, finalIds), + eq(performanceEntries.isRemoved, false), + eq(hardwareTable.deviceType, "handheld"), + sql`${performanceEntries.tdpWatts} IS NOT NULL AND ${performanceEntries.tdpWatts} > 0`, + sql`${hardwareTable.wattHours} IS NOT NULL`, + ), + ) + .orderBy(desc(performanceEntries.fpsAvg)) + + // Deduplicate — keep only the first (best fps) entry per game + const seenGames = new Set() + for (const row of batteryStats) { + if (!seenGames.has(row.gameId)) { + seenGames.add(row.gameId) + batteryMinMap.set(row.gameId, row.estimatedBatteryMin) + } + } + } + // ── 2c. Latest version ────────────────────────────────────────── const latestVersionMap = new Map() @@ -432,6 +469,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get( isRawPerformer: rawPerformerMap.get(g.id) ?? false, isPoorPerformance: poorPerformerMap.get(g.id) ?? false, bestFps: bestFpsMap.get(g.id) ?? null, + estimatedBatteryMin: batteryMinMap.get(g.id) ?? null, latestVersion: latestVersionMap.get(g.id) ?? null, playabilityStatus: g.playabilityStatus, steamReviewScore: g.steamReviewScore,