feat: enrich game cards with bestFps, raw/poor performer badges, and battery estimates (Tasks 5.1-5.6)

This commit is contained in:
2026-05-10 05:17:55 +08:00
parent c704f35f51
commit 5875595219
5 changed files with 215 additions and 1 deletions
+24
View File
@@ -40,6 +40,10 @@ interface GamesListItem {
deckStatus: string | null deckStatus: string | null
antiCheatRelevant: boolean antiCheatRelevant: boolean
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown" | null antiCheatStatus: "none" | "supported" | "unsupported" | "unknown" | null
bestFps: number | null
isRawPerformer: boolean
isPoorPerformance: boolean
estimatedBatteryMin: number | null
} }
interface DeviceOption { interface DeviceOption {
@@ -914,6 +918,26 @@ function GameCard({ game }: { game: GamesListItem }) {
{game.steamReviewScore}% {game.steamReviewScore}%
</span> </span>
)} )}
{game.isRawPerformer && (
<span className='inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-[10px] font-semibold shrink-0'>
RAW PERFORMER
</span>
)}
{game.isPoorPerformance && (
<span className='inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 text-[10px] font-semibold shrink-0'>
POOR PERFORMANCE
</span>
)}
{game.bestFps != null && (
<span className='inline-flex items-center gap-1 text-[10px] text-text/50'>
{Math.round(game.bestFps)}fps best
</span>
)}
{game.estimatedBatteryMin != null && (
<span className='inline-flex items-center gap-1 text-[10px] text-text/50'>
🔋 ~{Math.round(game.estimatedBatteryMin / 60)}h
</span>
)}
</div> </div>
<div className='mt-1.5 flex items-center gap-2 flex-wrap'> <div className='mt-1.5 flex items-center gap-2 flex-wrap'>
{game.benchmarkCount > 0 ? ( {game.benchmarkCount > 0 ? (
+69
View File
@@ -123,6 +123,71 @@ export default async function GamesPage() {
} }
} }
// Performance stats: best FPS, raw performer, poor performance, battery estimate
const rawPerformerMap = new Map<string, boolean>()
const poorPerformerMap = new Map<string, boolean>()
const bestFpsMap = new Map<string, number>()
const batteryMinMap = new Map<string, number>()
if (gameIds.length > 0) {
const perfStats = await db
.select({
gameId: gameVersions.gameId,
bestFps: sql<number>`MAX(${performanceEntries.fpsAvg})::real`,
isRawPerformer: sql<boolean>`BOOL_OR(
${performanceEntries.fpsAvg} >= 60
AND ${performanceEntries.upscalerType} = 'none'
AND ${performanceEntries.frameGenMethod} = 'none'
)`,
isPoorPerformance: sql<boolean>`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<number>`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<string>()
for (const row of batteryStats) {
if (!seenGames.has(row.gameId)) {
seenGames.add(row.gameId)
batteryMinMap.set(row.gameId, row.estimatedBatteryMin)
}
}
}
// Fetch all genres // Fetch all genres
const genreRows = await db const genreRows = await db
.select({ genres: games.genres }) .select({ genres: games.genres })
@@ -161,6 +226,10 @@ export default async function GamesPage() {
deckStatus: platformMap.get(g.id) ?? null, deckStatus: platformMap.get(g.id) ?? null,
antiCheatRelevant: antiCheatMap.get(g.id)?.antiCheatRelevant ?? false, antiCheatRelevant: antiCheatMap.get(g.id)?.antiCheatRelevant ?? false,
antiCheatStatus: antiCheatMap.get(g.id)?.antiCheatStatus ?? null, 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() const allGenres = Array.from(genreSet).sort()
+14
View File
@@ -49,6 +49,7 @@ interface UnifiedResult {
isRawPerformer?: boolean isRawPerformer?: boolean
isPoorPerformance?: boolean isPoorPerformance?: boolean
bestFps?: number | null bestFps?: number | null
estimatedBatteryMin?: number | null
latestVersion?: string | null latestVersion?: string | null
tinyImage?: string | null tinyImage?: string | null
// Badges & review fields // Badges & review fields
@@ -567,6 +568,11 @@ function SearchResultCard({
POOR PERFORMANCE POOR PERFORMANCE
</span> </span>
)} )}
{result.estimatedBatteryMin != null && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 text-[10px] font-semibold shrink-0">
🔋 ~{Math.round(result.estimatedBatteryMin / 60)}h
</span>
)}
</div> </div>
{(result.developer || result.publisher) && ( {(result.developer || result.publisher) && (
<p className="text-[11px] text-text/45 mt-0.5 truncate"> <p className="text-[11px] text-text/45 mt-0.5 truncate">
@@ -751,6 +757,14 @@ function SearchResultCard({
color={result.bestFps != null && result.bestFps >= 60 ? "text-green-400" : undefined} color={result.bestFps != null && result.bestFps >= 60 ? "text-green-400" : undefined}
/> />
{/* Battery Estimate */}
{result.estimatedBatteryMin != null && (
<DataField
label="Battery"
value={`~${Math.round(result.estimatedBatteryMin / 60)}h`}
/>
)}
{/* Version */} {/* Version */}
<DataField <DataField
label="Version" label="Version"
+69
View File
@@ -358,6 +358,71 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
} }
} }
// ── Performance stats: best FPS, raw performer, poor performance ──
const rawPerformerMap = new Map<string, boolean>()
const poorPerformerMap = new Map<string, boolean>()
const bestFpsMap = new Map<string, number>()
const batteryMinMap = new Map<string, number>()
if (gameIds.length > 0) {
const perfStats = await db
.select({
gameId: gameVersions.gameId,
bestFps: sql<number>`MAX(${performanceEntries.fpsAvg})::real`,
isRawPerformer: sql<boolean>`BOOL_OR(
${performanceEntries.fpsAvg} >= 60
AND ${performanceEntries.upscalerType} = 'none'
AND ${performanceEntries.frameGenMethod} = 'none'
)`,
isPoorPerformance: sql<boolean>`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<number>`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<string>()
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) => ({ const enrichedData = data.map((g) => ({
id: g.id, id: g.id,
steamAppId: g.steamAppId, steamAppId: g.steamAppId,
@@ -377,6 +442,10 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
deckStatus: platformMap.get(g.id) ?? null, deckStatus: platformMap.get(g.id) ?? null,
antiCheatRelevant: antiCheatMap.get(g.id)?.antiCheatRelevant ?? false, antiCheatRelevant: antiCheatMap.get(g.id)?.antiCheatRelevant ?? false,
antiCheatStatus: antiCheatMap.get(g.id)?.antiCheatStatus ?? null, 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 // If sorting by benchmarks, re-sort the enriched data
+39 -1
View File
@@ -6,7 +6,7 @@ import {
performanceEntries, performanceEntries,
gameComments, gameComments,
} from "@/lib/db/schema" } 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" import { fuzzySearchTerm } from "@/lib/db/search"
interface SteamSearchItem { interface SteamSearchItem {
@@ -323,6 +323,43 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
} }
} }
// ── 2b2. Battery estimate for handheld devices ────────────────────
const batteryMinMap = new Map<string, number>()
if (finalIds.length > 0) {
const { hardware: hardwareTable } = await import("@/lib/db/schema")
const batteryStats = await db
.select({
gameId: gameVersions.gameId,
estimatedBatteryMin: sql<number>`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<string>()
for (const row of batteryStats) {
if (!seenGames.has(row.gameId)) {
seenGames.add(row.gameId)
batteryMinMap.set(row.gameId, row.estimatedBatteryMin)
}
}
}
// ── 2c. Latest version ────────────────────────────────────────── // ── 2c. Latest version ──────────────────────────────────────────
const latestVersionMap = new Map<string, string>() const latestVersionMap = new Map<string, string>()
@@ -432,6 +469,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
isRawPerformer: rawPerformerMap.get(g.id) ?? false, isRawPerformer: rawPerformerMap.get(g.id) ?? false,
isPoorPerformance: poorPerformerMap.get(g.id) ?? false, isPoorPerformance: poorPerformerMap.get(g.id) ?? false,
bestFps: bestFpsMap.get(g.id) ?? null, bestFps: bestFpsMap.get(g.id) ?? null,
estimatedBatteryMin: batteryMinMap.get(g.id) ?? null,
latestVersion: latestVersionMap.get(g.id) ?? null, latestVersion: latestVersionMap.get(g.id) ?? null,
playabilityStatus: g.playabilityStatus, playabilityStatus: g.playabilityStatus,
steamReviewScore: g.steamReviewScore, steamReviewScore: g.steamReviewScore,