From d04b2d3ecb791d9593ecdb70727d67f53d8982d6 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Thu, 3 Sep 2026 23:36:22 -0400 Subject: [PATCH] feat(qualification): add aim trainer leaderboard Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../qualification/AimTrainerLeaderboard.tsx | 220 ++++++++++++++++++ .../aimTrainerLeaderboardData.ts | 110 +++++++++ 2 files changed, 330 insertions(+) create mode 100644 src/components/frontend/qualification/AimTrainerLeaderboard.tsx create mode 100644 src/components/frontend/qualification/aimTrainerLeaderboardData.ts diff --git a/src/components/frontend/qualification/AimTrainerLeaderboard.tsx b/src/components/frontend/qualification/AimTrainerLeaderboard.tsx new file mode 100644 index 0000000..3f3a861 --- /dev/null +++ b/src/components/frontend/qualification/AimTrainerLeaderboard.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Trophy } from "lucide-react"; + +import type { AimTrainerDifficulty } from "@/lib/aim-trainer"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { PresenceParticipant } from "@/hooks/useRealtimePresence"; +import { cn } from "@/lib/utils"; +import { + AIM_TRAINER_LEADERBOARD_FIELDS, + readAimTrainerProfiles, +} from "./aimTrainerLeaderboardData"; + +type LeaderboardRow = { + readonly id: number; + readonly name: string; + readonly gamesPlayed: number; + readonly bestScore: number; + readonly bestAccuracy: number; + readonly isCurrentUser: boolean; + readonly isOutsideTopTen?: boolean; +}; + +function rankClass(index: number): string { + if (index === 0) return "font-bold text-primary"; + if (index === 1) return "font-semibold text-foreground"; + if (index === 2) return "font-semibold text-muted-foreground"; + return "text-muted-foreground"; +} + +export function AimTrainerLeaderboard({ + refreshKey = 0, + difficulty, + currentUserId, + activeTrainees = [], +}: { + readonly refreshKey?: number; + readonly difficulty: AimTrainerDifficulty; + readonly currentUserId?: number | string; + readonly activeTrainees?: readonly PresenceParticipant[]; +}) { + const [rows, setRows] = useState(null); + const [failed, setFailed] = useState(null); + const tier = AIM_TRAINER_LEADERBOARD_FIELDS[difficulty]; + + useEffect(() => { + let cancelled = false; + setFailed(null); + setRows(null); + const field = `progression.minigames.aimTrainer.${tier.scoreField}`; + const params = new URLSearchParams({ + sort: `-${field}`, + limit: "100", + depth: "1", + }); + + fetch(`/api/profiles?${params.toString()}`) + .then(async (response) => { + if (!response.ok) throw new Error("leaderboard fetch failed"); + const payload: unknown = await response.json(); + return readAimTrainerProfiles(payload); + }) + .then((profiles) => { + if (cancelled) return; + const mapped: LeaderboardRow[] = profiles.map((profile) => { + const user = + typeof profile.user === "object" && profile.user !== null ? profile.user : null; + const userId = user?.id ?? (typeof profile.user === "number" ? profile.user : null); + const name = + user?.displayName || + user?.payloadDisplayName || + profile.profileTitle || + user?.username || + `Operator #${profile.id}`; + const aimTrainer = profile.aimTrainer; + return { + id: profile.id, + name, + gamesPlayed: aimTrainer?.gamesPlayed ?? 0, + bestScore: aimTrainer?.[tier.scoreField] ?? 0, + bestAccuracy: aimTrainer?.[tier.accuracyField] ?? 0, + isCurrentUser: userId !== null && String(userId) === String(currentUserId), + }; + }); + const scored = mapped + .filter((row) => row.bestScore > 0) + .sort( + (left, right) => + right.bestScore - left.bestScore || right.bestAccuracy - left.bestAccuracy, + ); + const visible = scored.slice(0, 10); + const current = mapped.find((row) => row.isCurrentUser); + if (current && !visible.some((row) => row.id === current.id)) { + visible.push({ ...current, isOutsideTopTen: true }); + } + setRows(visible); + }) + .catch((error: unknown) => { + if (cancelled) return; + setFailed(error instanceof Error ? error.message : "leaderboard fetch failed"); + }); + + return () => { + cancelled = true; + }; + }, [currentUserId, difficulty, refreshKey, tier.accuracyField, tier.scoreField]); + + return ( + + +
+
+ Best score for the selected qualification lane. +
+
+ {activeTrainees.length}{" "} + {activeTrainees.length === 1 ? "trainee is" : "trainees are"} on the line + {activeTrainees.length > 0 && ( + + ({activeTrainees.map((trainee) => trainee.name).join(", ")}) + + )} +
+ + + {failed !== null ? ( +

Could not load the leaderboard.

+ ) : rows === null ? ( +
+ + + +
+ ) : rows.length === 0 ? ( +

+ No scores logged for this lane yet โ€” take the first qualification slot. +

+ ) : ( +
+ + + + + + + + + + + + {rows.map((row, index) => ( + + + + + + + + ))} + +
#OperatorGames + Score + Accuracy
+ + {row.isOutsideTopTen ? "โ€”" : index + 1} + + + + {row.name} + {row.isCurrentUser && ( + (you) + )} + {row.isOutsideTopTen && ( + + unranked + + )} + {activeTrainees.some((trainee) => trainee.id === row.id) && ( + + )} + + + {row.gamesPlayed} + + {row.bestScore} + + {Math.round(row.bestAccuracy * 100)}% +
+
+ )} +
+
+ ); +} diff --git a/src/components/frontend/qualification/aimTrainerLeaderboardData.ts b/src/components/frontend/qualification/aimTrainerLeaderboardData.ts new file mode 100644 index 0000000..6719b57 --- /dev/null +++ b/src/components/frontend/qualification/aimTrainerLeaderboardData.ts @@ -0,0 +1,110 @@ +import type { AimTrainerDifficulty } from "@/lib/aim-trainer"; + +export type AimTrainerLeaderboardFields = { + readonly label: string; + readonly scoreField: keyof AimTrainerApiStats; + readonly accuracyField: keyof AimTrainerApiStats; +}; + +export type AimTrainerApiStats = { + readonly gamesPlayed: number | null; + readonly recruitBestScore: number | null; + readonly veteranBestScore: number | null; + readonly eliteBestScore: number | null; + readonly recruitBestAccuracy: number | null; + readonly veteranBestAccuracy: number | null; + readonly eliteBestAccuracy: number | null; +}; + +type ApiUser = { + readonly id: number; + readonly username: string | null; + readonly displayName: string | null; + readonly payloadDisplayName: string | null; +}; + +type ApiProfile = { + readonly id: number; + readonly profileTitle: string | null; + readonly user: ApiUser | number | null; + readonly aimTrainer: AimTrainerApiStats | null; +}; + +export const AIM_TRAINER_LEADERBOARD_FIELDS = { + recruit: { + label: "Recruit", + scoreField: "recruitBestScore", + accuracyField: "recruitBestAccuracy", + }, + veteran: { + label: "Veteran", + scoreField: "veteranBestScore", + accuracyField: "veteranBestAccuracy", + }, + elite: { + label: "Elite", + scoreField: "eliteBestScore", + accuracyField: "eliteBestAccuracy", + }, +} as const satisfies Record; + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null; +} + +function readNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function readString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function readUser(value: unknown): ApiUser | number | null { + const numericId = readNumber(value); + if (numericId !== null) return numericId; + if (!isRecord(value)) return null; + const id = readNumber(value.id); + if (id === null) return null; + return { + id, + username: readString(value.username), + displayName: readString(value.displayName), + payloadDisplayName: readString(value.payloadDisplayName), + }; +} + +function readAimTrainer(value: unknown): AimTrainerApiStats | null { + if (!isRecord(value)) return null; + return { + gamesPlayed: readNumber(value.gamesPlayed), + recruitBestScore: readNumber(value.recruitBestScore), + veteranBestScore: readNumber(value.veteranBestScore), + eliteBestScore: readNumber(value.eliteBestScore), + recruitBestAccuracy: readNumber(value.recruitBestAccuracy), + veteranBestAccuracy: readNumber(value.veteranBestAccuracy), + eliteBestAccuracy: readNumber(value.eliteBestAccuracy), + }; +} + +function readProfile(value: unknown): ApiProfile | null { + if (!isRecord(value)) return null; + const id = readNumber(value.id); + if (id === null) return null; + const progression = isRecord(value.progression) ? value.progression : null; + const minigames = progression && isRecord(progression.minigames) ? progression.minigames : null; + return { + id, + profileTitle: readString(value.profileTitle), + user: readUser(value.user), + aimTrainer: minigames ? readAimTrainer(minigames.aimTrainer) : null, + }; +} + +export function readAimTrainerProfiles(value: unknown): readonly ApiProfile[] { + if (!isRecord(value) || !Array.isArray(value.docs)) return []; + return value.docs.flatMap((item) => { + const profile = readProfile(item); + return profile ? [profile] : []; + }); +}