From 9baf84ee3267ca9e04126c87b8ef9a33b1f07556 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Thu, 3 Sep 2026 16:03:13 -0400 Subject: [PATCH] feat(radio): improve mode gameplay and leaderboards Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/components/frontend/radio/RadioArena.tsx | 18 +- .../frontend/radio/RadioLeaderboard.tsx | 212 ++++++++++++------ .../frontend/radio/RadioTraffic.tsx | 205 +++++++++++++---- 3 files changed, 324 insertions(+), 111 deletions(-) diff --git a/src/components/frontend/radio/RadioArena.tsx b/src/components/frontend/radio/RadioArena.tsx index a08b99f..5ab1392 100644 --- a/src/components/frontend/radio/RadioArena.tsx +++ b/src/components/frontend/radio/RadioArena.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import type { RadioStats } from "@/app/(frontend)/radio/actions"; import { useRealtimePresence } from "@/hooks/useRealtimePresence"; import { RadioLeaderboard } from "./RadioLeaderboard"; @@ -15,15 +15,29 @@ export function RadioArena({ }) { const [refreshKey, setRefreshKey] = useState(0); const [playing, setPlaying] = useState(false); + const [mode, setMode] = useState<"sprint" | "watch">("sprint"); + const [modeHydrated, setModeHydrated] = useState(false); const activeOperators = useRealtimePresence("radio", playing, true); const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => { setPlaying(phase === "playing"); }, []); + useEffect(() => { + const savedMode = window.localStorage.getItem("radio-traffic-mode"); + if (savedMode === "sprint" || savedMode === "watch") setMode(savedMode); + setModeHydrated(true); + }, []); + + useEffect(() => { + if (modeHydrated) window.localStorage.setItem("radio-traffic-mode", mode); + }, [mode, modeHydrated]); + return (
setRefreshKey((key) => key + 1)} onPhaseChangeAction={onPhaseChange} /> @@ -47,6 +61,8 @@ export function RadioArena({
diff --git a/src/components/frontend/radio/RadioLeaderboard.tsx b/src/components/frontend/radio/RadioLeaderboard.tsx index 5426a75..6ca4ea9 100644 --- a/src/components/frontend/radio/RadioLeaderboard.tsx +++ b/src/components/frontend/radio/RadioLeaderboard.tsx @@ -1,12 +1,17 @@ "use client"; import { useEffect, useState } from "react"; -import { Trophy } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { ArrowDown, ArrowUp, Trophy } from "lucide-react"; import { cn } from "@/lib/utils"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { PresenceParticipant } from "@/hooks/useRealtimePresence"; type LeaderboardTab = "score" | "accuracy" | "messages" | "time"; +type Mode = "sprint" | "watch"; + +function isLeaderboardTab(value: string): value is LeaderboardTab { + return value === "score" || value === "accuracy" || value === "messages" || value === "time"; +} interface ApiUser { id: number; @@ -22,9 +27,12 @@ interface ApiProfile { progression?: { minigames?: { radioTraffic?: { - totalMessages?: number | null; - bestScore?: number | null; - bestAccuracy?: number | null; + sprintTotalMessages?: number | null; + sprintBestScore?: number | null; + sprintBestAccuracy?: number | null; + watchTotalMessages?: number | null; + watchBestScore?: number | null; + watchBestAccuracy?: number | null; bestSprintTimeMs?: number | null; } | null; } | null; @@ -42,46 +50,20 @@ interface LeaderboardRow { isOutsideTopTen?: boolean; } -interface TabConfig { - readonly label: string; - readonly sort: string; - readonly matches: (row: LeaderboardRow) => boolean; - readonly empty: string; -} - interface RadioLeaderboardProps { refreshKey?: number; + mode: Mode; + modeHydrated: boolean; currentUserId?: number | string; activeOperators?: PresenceParticipant[]; } -const TAB_ORDER = ["score", "accuracy", "messages", "time"] as const satisfies readonly LeaderboardTab[]; const TAB_CONFIG = { - score: { - label: "Top Score", - sort: "-progression.minigames.radioTraffic.bestScore", - matches: (row) => row.bestScore > 0, - empty: "No scored transmissions yet. Take the first slot!", - }, - accuracy: { - label: "Top Accuracy", - sort: "-progression.minigames.radioTraffic.bestAccuracy", - matches: (row) => row.bestAccuracy > 0, - empty: "No accuracy records yet. Relay a perfect transmission!", - }, - messages: { - label: "Most Messages", - sort: "-progression.minigames.radioTraffic.totalMessages", - matches: (row) => row.totalMessages > 0, - empty: "No accurate messages yet. Get on frequency!", - }, - time: { - label: "Fastest Sprint", - sort: "progression.minigames.radioTraffic.bestSprintTimeMs", - matches: (row) => row.bestSprintTimeMs > 0, - empty: "No sprint times yet. Complete a 10-message sprint!", - }, -} satisfies Record; + score: { label: "Top Score", empty: "No scored transmissions yet. Take the first slot!" }, + accuracy: { label: "Top Accuracy", empty: "No accuracy records yet. Relay a perfect transmission!" }, + messages: { label: "Most Correct", empty: "No correct transmissions yet. Get on frequency!" }, + time: { label: "Fastest Sprint", empty: "No sprint times yet. Complete a 10-message sprint!" }, +} as const; function rankClass(index: number) { if (index === 0) return "font-bold text-amber-400"; @@ -92,6 +74,8 @@ function rankClass(index: number) { export function RadioLeaderboard({ refreshKey = 0, + mode, + modeHydrated, currentUserId, activeOperators = [], }: RadioLeaderboardProps) { @@ -99,13 +83,45 @@ export function RadioLeaderboard({ const [rows, setRows] = useState(null); const [failed, setFailed] = useState(false); const tabConfig = TAB_CONFIG[tab]; + const tabOrder: readonly LeaderboardTab[] = + mode === "sprint" + ? ["score", "accuracy", "messages", "time"] + : ["score", "accuracy", "messages"]; + const sortDirection = tab === "time" ? "ascending" : "descending"; + const SortMarker = ({ column }: { column: LeaderboardTab }) => { + if (tab !== column) return null; + + const Icon = sortDirection === "ascending" ? ArrowUp : ArrowDown; + return ( +