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,