feat: display 1% low FPS in presets, stats API, and game details

This commit is contained in:
2026-04-28 22:56:43 +08:00
parent 4d759160db
commit 741c19e840
4 changed files with 48 additions and 3 deletions
+21 -3
View File
@@ -15,6 +15,7 @@ import {
Plus, Plus,
ThumbsUpIcon, ThumbsUpIcon,
ThumbsDownIcon, ThumbsDownIcon,
GaugeIcon,
} from "lucide-react" } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useSession } from "@/lib/auth-client" import { useSession } from "@/lib/auth-client"
@@ -81,6 +82,7 @@ interface Preset {
fpsAvg: number | null fpsAvg: number | null
fpsLow: number | null fpsLow: number | null
fpsHigh: number | null fpsHigh: number | null
fpsOnePercentLow: number | null
upscalerType: string | null upscalerType: string | null
upscalerVersion: string | null upscalerVersion: string | null
frameGenMethod: string | null frameGenMethod: string | null
@@ -102,6 +104,8 @@ interface StatsResponse {
bestDevice: string bestDevice: string
verifiedCount: number verifiedCount: number
versionCount: number versionCount: number
avgStability: number | null
bestOnePercentLow: number | null
} }
isRawPerformer: boolean isRawPerformer: boolean
isPoorPerformance: boolean isPoorPerformance: boolean
@@ -111,6 +115,7 @@ interface StatsResponse {
min: number min: number
q1: number q1: number
median: number median: number
onePercentLow?: number
q3: number q3: number
max: number max: number
}> }>
@@ -136,6 +141,7 @@ interface StatsResponse {
fpsLow: number fpsLow: number
fpsAvg: number fpsAvg: number
fpsHigh: number fpsHigh: number
fpsOnePercentLow: number | null
isRawPerformer: boolean isRawPerformer: boolean
}> }>
deviceBreakdown: Array<{ deviceBreakdown: Array<{
@@ -840,11 +846,16 @@ export function GamePageClient({
{" "} {" "}
avg avg
</span> </span>
{preset.fpsLow !== null && ( {preset.fpsOnePercentLow !== null && (
<span className='text-text/40'> <span className='text-text/40'>
{" "} {" "}
({preset.fpsLow} · {preset.fpsOnePercentLow} 1% low
{preset.fpsHigh}) </span>
)}
{preset.fpsLow !== null && preset.fpsHigh !== null && !preset.fpsOnePercentLow && (
<span className='text-text/40'>
{" "}
({preset.fpsLow}{preset.fpsHigh})
</span> </span>
)} )}
</div> </div>
@@ -953,6 +964,13 @@ export function GamePageClient({
)} )}
icon={DatabaseIcon} icon={DatabaseIcon}
/> />
{filteredStats.summary.avgStability !== null && (
<StatCard
label='Avg Stability'
value={`${Math.round(filteredStats.summary.avgStability * 100)}%`}
icon={GaugeIcon}
/>
)}
</div> </div>
</div> </div>
)} )}
+2
View File
@@ -219,6 +219,7 @@ export default async function GamePage({
fpsAvg: performanceEntries.fpsAvg, fpsAvg: performanceEntries.fpsAvg,
fpsLow: performanceEntries.fpsLow, fpsLow: performanceEntries.fpsLow,
fpsHigh: performanceEntries.fpsHigh, fpsHigh: performanceEntries.fpsHigh,
fpsOnePercentLow: performanceEntries.fpsOnePercentLow,
upscalerType: performanceEntries.upscalerType, upscalerType: performanceEntries.upscalerType,
upscalerVersion: performanceEntries.upscalerVersion, upscalerVersion: performanceEntries.upscalerVersion,
frameGenMethod: performanceEntries.frameGenMethod, frameGenMethod: performanceEntries.frameGenMethod,
@@ -292,6 +293,7 @@ export default async function GamePage({
fpsAvg: p.fpsAvg, fpsAvg: p.fpsAvg,
fpsLow: p.fpsLow, fpsLow: p.fpsLow,
fpsHigh: p.fpsHigh, fpsHigh: p.fpsHigh,
fpsOnePercentLow: p.fpsOnePercentLow ?? null,
upscalerType: p.upscalerType, upscalerType: p.upscalerType,
upscalerVersion: p.upscalerVersion, upscalerVersion: p.upscalerVersion,
frameGenMethod: p.frameGenMethod, frameGenMethod: p.frameGenMethod,
+9
View File
@@ -33,6 +33,7 @@ interface Preset {
fpsAvg: number | null fpsAvg: number | null
fpsLow: number | null fpsLow: number | null
fpsHigh: number | null fpsHigh: number | null
fpsOnePercentLow: number | null
upscalerType: string | null upscalerType: string | null
upscalerVersion: string | null upscalerVersion: string | null
frameGenMethod: string | null frameGenMethod: string | null
@@ -295,6 +296,14 @@ export function PresetDetailModal({
</div> </div>
</div> </div>
)} )}
{preset.fpsOnePercentLow !== null && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-text/50 uppercase tracking-wider">1% Low FPS</span>
<span className="text-sm font-semibold tabular-nums text-text">
{preset.fpsOnePercentLow} fps
</span>
</div>
)}
<div className="h-px bg-border" /> <div className="h-px bg-border" />
+16
View File
@@ -34,6 +34,7 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
fpsAvg: performanceEntries.fpsAvg, fpsAvg: performanceEntries.fpsAvg,
fpsLow: performanceEntries.fpsLow, fpsLow: performanceEntries.fpsLow,
fpsHigh: performanceEntries.fpsHigh, fpsHigh: performanceEntries.fpsHigh,
fpsOnePercentLow: performanceEntries.fpsOnePercentLow,
upscalerType: performanceEntries.upscalerType, upscalerType: performanceEntries.upscalerType,
upscalerVersion: performanceEntries.upscalerVersion, upscalerVersion: performanceEntries.upscalerVersion,
frameGenMethod: performanceEntries.frameGenMethod, frameGenMethod: performanceEntries.frameGenMethod,
@@ -140,12 +141,14 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
const q1Idx = Math.floor(n * 0.25) const q1Idx = Math.floor(n * 0.25)
const medIdx = Math.floor(n * 0.5) const medIdx = Math.floor(n * 0.5)
const q3Idx = Math.floor(n * 0.75) const q3Idx = Math.floor(n * 0.75)
const onePercentLow = sorted.length > 0 ? sorted[Math.max(0, Math.floor(sorted.length * 0.01))] : sorted[0]
return { return {
hardwareSlug: slug, hardwareSlug: slug,
hardwareName, hardwareName,
min: sorted[0], min: sorted[0],
q1: sorted[q1Idx], q1: sorted[q1Idx],
median: sorted[medIdx], median: sorted[medIdx],
onePercentLow,
q3: sorted[q3Idx], q3: sorted[q3Idx],
max: sorted[n - 1], max: sorted[n - 1],
} }
@@ -222,6 +225,7 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
fpsLow: e.fpsLow!, fpsLow: e.fpsLow!,
fpsAvg: e.fpsAvg ?? 0, fpsAvg: e.fpsAvg ?? 0,
fpsHigh: e.fpsHigh!, fpsHigh: e.fpsHigh!,
fpsOnePercentLow: e.fpsOnePercentLow ?? null,
isRawPerformer: isRawPerformer:
(e.fpsAvg ?? 0) >= 60 && (e.fpsAvg ?? 0) >= 60 &&
e.upscalerType === "none" && e.upscalerType === "none" &&
@@ -229,6 +233,16 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
isPoorPerformer: (e.fpsAvg ?? 0) < 30, isPoorPerformer: (e.fpsAvg ?? 0) < 30,
})) }))
// Stability score computation
const stabilityScores = entries
.filter(e => e.fpsOnePercentLow != null && (e.fpsAvg ?? 0) > 0)
.map(e => Math.min(1, e.fpsOnePercentLow! / (e.fpsAvg ?? 1)))
const avgStability = stabilityScores.length > 0
? Math.round((stabilityScores.reduce((a, b) => a + b, 0) / stabilityScores.length) * 100) / 100
: null
const bestOnePercentLow = entries.reduce((best, e) =>
e.fpsOnePercentLow != null && e.fpsOnePercentLow > (best ?? 0) ? e.fpsOnePercentLow : best, null as number | null)
// ── 8. Device breakdown ─────────────────────────────────────── // ── 8. Device breakdown ───────────────────────────────────────
const deviceBreakdown = Array.from(boxplotMap.entries()).map( const deviceBreakdown = Array.from(boxplotMap.entries()).map(
([slug, { hardwareName, values }]) => ({ ([slug, { hardwareName, values }]) => ({
@@ -253,6 +267,8 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
bestDevice, bestDevice,
verifiedCount, verifiedCount,
versionCount, versionCount,
avgStability,
bestOnePercentLow,
}, },
isRawPerformer, isRawPerformer,
isPoorPerformance, isPoorPerformance,