feat: enrich game cards with bestFps, raw/poor performer badges, and battery estimates (Tasks 5.1-5.6)
This commit is contained in:
@@ -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 ? (
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user