From 1e6df136d8202c8adf65948527f4c3e7583b338c Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Tue, 4 Mar 2025 22:55:01 +0900
Subject: websocket to polling

---
 frontend/app/components/GolfWatchApp.tsx | 127 +++++++++++++++++++++++++++++++
 1 file changed, 127 insertions(+)
 create mode 100644 frontend/app/components/GolfWatchApp.tsx

(limited to 'frontend/app/components/GolfWatchApp.tsx')

diff --git a/frontend/app/components/GolfWatchApp.tsx b/frontend/app/components/GolfWatchApp.tsx
new file mode 100644
index 0000000..fe71932
--- /dev/null
+++ b/frontend/app/components/GolfWatchApp.tsx
@@ -0,0 +1,127 @@
+import { useAtomValue, useSetAtom } from "jotai";
+import { useContext, useEffect, useState } from "react";
+import { useTimer } from "react-use-precision-timer";
+import {
+	ApiAuthTokenContext,
+	apiGetGame,
+	apiGetGameWatchLatestStates,
+	apiGetGameWatchRanking,
+} from "../api/client";
+import type { components } from "../api/schema";
+import {
+	gameStateKindAtom,
+	setCurrentTimestampAtom,
+	setGameStartedAtAtom,
+	setLatestGameStatesAtom,
+	setRankingAtom,
+} from "../states/watch";
+import GolfWatchAppGaming from "./GolfWatchApps/GolfWatchAppGaming";
+import GolfWatchAppStarting from "./GolfWatchApps/GolfWatchAppStarting";
+import GolfWatchAppWaiting from "./GolfWatchApps/GolfWatchAppWaiting";
+
+type Game = components["schemas"]["Game"];
+
+export type Props = {
+	game: Game;
+};
+
+export default function GolfWatchApp({ game }: Props) {
+	const apiAuthToken = useContext(ApiAuthTokenContext);
+
+	const gameStateKind = useAtomValue(gameStateKindAtom);
+	const setGameStartedAt = useSetAtom(setGameStartedAtAtom);
+	const setCurrentTimestamp = useSetAtom(setCurrentTimestampAtom);
+	const setLatestGameStates = useSetAtom(setLatestGameStatesAtom);
+	const setRanking = useSetAtom(setRankingAtom);
+
+	useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp);
+
+	const playerA = game.main_players[0]!;
+	const playerB = game.main_players[1]!;
+
+	const playerProfileA = {
+		id: playerA.user_id,
+		displayName: playerA.display_name,
+		iconPath: playerA.icon_path ?? null,
+	};
+	const playerProfileB = {
+		id: playerB.user_id,
+		displayName: playerB.display_name,
+		iconPath: playerB.icon_path ?? null,
+	};
+
+	const [isDataPolling, setIsDataPolling] = useState(false);
+
+	useEffect(() => {
+		if (isDataPolling) {
+			return;
+		}
+		const timerId = setInterval(async () => {
+			if (isDataPolling) {
+				return;
+			}
+			setIsDataPolling(true);
+
+			try {
+				if (gameStateKind === "waiting") {
+					const { game: g } = await apiGetGame(apiAuthToken, game.game_id);
+					if (g.started_at != null) {
+						setGameStartedAt(g.started_at);
+					}
+				} else if (gameStateKind === "gaming") {
+					const { states } = await apiGetGameWatchLatestStates(
+						apiAuthToken,
+						game.game_id,
+					);
+					setLatestGameStates(states);
+					const { ranking } = await apiGetGameWatchRanking(
+						apiAuthToken,
+						game.game_id,
+					);
+					setRanking(ranking);
+				}
+			} catch (error) {
+				console.error(error);
+			} finally {
+				setIsDataPolling(false);
+			}
+		}, 1000);
+
+		return () => {
+			clearInterval(timerId);
+		};
+	}, [
+		isDataPolling,
+		apiAuthToken,
+		game.game_id,
+		gameStateKind,
+		setGameStartedAt,
+		setLatestGameStates,
+		setRanking,
+	]);
+
+	if (gameStateKind === "waiting") {
+		return (
+			<GolfWatchAppWaiting
+				gameDisplayName={game.display_name}
+				playerProfileA={playerProfileA}
+				playerProfileB={playerProfileB}
+			/>
+		);
+	} else if (gameStateKind === "starting") {
+		return <GolfWatchAppStarting gameDisplayName={game.display_name} />;
+	} else if (gameStateKind === "gaming" || gameStateKind === "finished") {
+		return (
+			<GolfWatchAppGaming
+				gameDisplayName={game.display_name}
+				playerProfileA={playerProfileA}
+				playerProfileB={playerProfileB}
+				problemTitle={game.problem.title}
+				problemDescription={game.problem.description}
+				gameResult={null /* TODO */}
+			/>
+		);
+	} else {
+		return null;
+	}
+}
-- 
cgit v1.2.3-70-g09d2


From c889a9ad33374eae03cec5b0358d79016d6fd97e Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Sat, 8 Mar 2025 10:32:05 +0900
Subject: show ranking

---
 frontend/app/components/GolfWatchApp.tsx           |  66 +++++++----
 .../GolfWatchApps/GolfWatchAppGaming.tsx           | 128 ---------------------
 .../GolfWatchApps/GolfWatchAppGaming1v1.tsx        | 128 +++++++++++++++++++++
 .../GolfWatchAppGamingMultiplayer.tsx              | 102 ++++++++++++++++
 .../GolfWatchApps/GolfWatchAppWaiting.tsx          |  27 -----
 .../GolfWatchApps/GolfWatchAppWaiting1v1.tsx       |  27 +++++
 .../GolfWatchAppWaitingMultiplayer.tsx             |  15 +++
 frontend/app/routes/golf.$gameId.watch.tsx         |   4 +-
 frontend/app/states/watch.ts                       |   5 +-
 9 files changed, 316 insertions(+), 186 deletions(-)
 delete mode 100644 frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
 create mode 100644 frontend/app/components/GolfWatchApps/GolfWatchAppGaming1v1.tsx
 create mode 100644 frontend/app/components/GolfWatchApps/GolfWatchAppGamingMultiplayer.tsx
 delete mode 100644 frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx
 create mode 100644 frontend/app/components/GolfWatchApps/GolfWatchAppWaiting1v1.tsx
 create mode 100644 frontend/app/components/GolfWatchApps/GolfWatchAppWaitingMultiplayer.tsx

(limited to 'frontend/app/components/GolfWatchApp.tsx')

diff --git a/frontend/app/components/GolfWatchApp.tsx b/frontend/app/components/GolfWatchApp.tsx
index fe71932..402884f 100644
--- a/frontend/app/components/GolfWatchApp.tsx
+++ b/frontend/app/components/GolfWatchApp.tsx
@@ -1,4 +1,4 @@
-import { useAtomValue, useSetAtom } from "jotai";
+import { useAtom, useAtomValue, useSetAtom } from "jotai";
 import { useContext, useEffect, useState } from "react";
 import { useTimer } from "react-use-precision-timer";
 import {
@@ -10,14 +10,16 @@ import {
 import type { components } from "../api/schema";
 import {
 	gameStateKindAtom,
+	rankingAtom,
 	setCurrentTimestampAtom,
 	setGameStartedAtAtom,
 	setLatestGameStatesAtom,
-	setRankingAtom,
 } from "../states/watch";
-import GolfWatchAppGaming from "./GolfWatchApps/GolfWatchAppGaming";
+import GolfWatchAppGaming1v1 from "./GolfWatchApps/GolfWatchAppGaming1v1";
+import GolfWatchAppGamingMultiplayer from "./GolfWatchApps/GolfWatchAppGamingMultiplayer";
 import GolfWatchAppStarting from "./GolfWatchApps/GolfWatchAppStarting";
-import GolfWatchAppWaiting from "./GolfWatchApps/GolfWatchAppWaiting";
+import GolfWatchAppWaiting1v1 from "./GolfWatchApps/GolfWatchAppWaiting1v1";
+import GolfWatchAppWaitingMultiplayer from "./GolfWatchApps/GolfWatchAppWaitingMultiplayer";
 
 type Game = components["schemas"]["Game"];
 
@@ -32,23 +34,27 @@ export default function GolfWatchApp({ game }: Props) {
 	const setGameStartedAt = useSetAtom(setGameStartedAtAtom);
 	const setCurrentTimestamp = useSetAtom(setCurrentTimestampAtom);
 	const setLatestGameStates = useSetAtom(setLatestGameStatesAtom);
-	const setRanking = useSetAtom(setRankingAtom);
+	const [ranking, setRanking] = useAtom(rankingAtom);
 
 	useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp);
 
-	const playerA = game.main_players[0]!;
-	const playerB = game.main_players[1]!;
+	const playerA = game.main_players[0];
+	const playerB = game.main_players[1];
 
-	const playerProfileA = {
-		id: playerA.user_id,
-		displayName: playerA.display_name,
-		iconPath: playerA.icon_path ?? null,
-	};
-	const playerProfileB = {
-		id: playerB.user_id,
-		displayName: playerB.display_name,
-		iconPath: playerB.icon_path ?? null,
-	};
+	const playerProfileA = playerA
+		? {
+				id: playerA.user_id,
+				displayName: playerA.display_name,
+				iconPath: playerA.icon_path ?? null,
+			}
+		: null;
+	const playerProfileB = playerB
+		? {
+				id: playerB.user_id,
+				displayName: playerB.display_name,
+				iconPath: playerB.icon_path ?? null,
+			}
+		: null;
 
 	const [isDataPolling, setIsDataPolling] = useState(false);
 
@@ -101,21 +107,31 @@ export default function GolfWatchApp({ game }: Props) {
 	]);
 
 	if (gameStateKind === "waiting") {
-		return (
-			<GolfWatchAppWaiting
+		return game.game_type === "1v1" ? (
+			<GolfWatchAppWaiting1v1
 				gameDisplayName={game.display_name}
-				playerProfileA={playerProfileA}
-				playerProfileB={playerProfileB}
+				playerProfileA={playerProfileA!}
+				playerProfileB={playerProfileB!}
 			/>
+		) : (
+			<GolfWatchAppWaitingMultiplayer gameDisplayName={game.display_name} />
 		);
 	} else if (gameStateKind === "starting") {
 		return <GolfWatchAppStarting gameDisplayName={game.display_name} />;
 	} else if (gameStateKind === "gaming" || gameStateKind === "finished") {
-		return (
-			<GolfWatchAppGaming
+		return game.game_type === "1v1" ? (
+			<GolfWatchAppGaming1v1
+				gameDisplayName={game.display_name}
+				playerProfileA={playerProfileA!}
+				playerProfileB={playerProfileB!}
+				problemTitle={game.problem.title}
+				problemDescription={game.problem.description}
+				gameResult={null /* TODO */}
+			/>
+		) : (
+			<GolfWatchAppGamingMultiplayer
 				gameDisplayName={game.display_name}
-				playerProfileA={playerProfileA}
-				playerProfileB={playerProfileB}
+				ranking={ranking}
 				problemTitle={game.problem.title}
 				problemDescription={game.problem.description}
 				gameResult={null /* TODO */}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
deleted file mode 100644
index afb8bfe..0000000
--- a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import { useAtomValue } from "jotai";
-import {
-	gamingLeftTimeSecondsAtom,
-	latestGameStatesAtom,
-} from "../../states/watch";
-import type { PlayerProfile } from "../../types/PlayerProfile";
-import BorderedContainer from "../BorderedContainer";
-import CodeBlock from "../Gaming/CodeBlock";
-import ScoreBar from "../Gaming/ScoreBar";
-import SubmitResult from "../Gaming/SubmitResult";
-import UserIcon from "../UserIcon";
-
-type Props = {
-	gameDisplayName: string;
-	playerProfileA: PlayerProfile;
-	playerProfileB: PlayerProfile;
-	problemTitle: string;
-	problemDescription: string;
-	gameResult: "winA" | "winB" | "draw" | null;
-};
-
-export default function GolfWatchAppGaming({
-	gameDisplayName,
-	playerProfileA,
-	playerProfileB,
-	problemTitle,
-	problemDescription,
-	gameResult,
-}: Props) {
-	const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom)!;
-	const latestGameStates = useAtomValue(latestGameStatesAtom);
-
-	const stateA = latestGameStates[playerProfileA.id]!;
-	const codeA = stateA.code;
-	const scoreA = stateA.score;
-	const statusA = stateA.status;
-	const stateB = latestGameStates[playerProfileB.id]!;
-	const codeB = stateB.code;
-	const scoreB = stateB.score;
-	const statusB = stateB.status;
-
-	const leftTime = (() => {
-		const m = Math.floor(leftTimeSeconds / 60);
-		const s = leftTimeSeconds % 60;
-		return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
-	})();
-
-	const topBg = gameResult
-		? gameResult === "winA"
-			? "bg-orange-400"
-			: gameResult === "winB"
-				? "bg-purple-400"
-				: "bg-pink-500"
-		: "bg-sky-600";
-
-	return (
-		<div className="min-h-screen bg-gray-100 flex flex-col">
-			<div className={`text-white ${topBg} grid grid-cols-3 px-4 py-2`}>
-				<div className="font-bold flex justify-between my-auto">
-					<div className="flex gap-6">
-						{playerProfileA.iconPath && (
-							<UserIcon
-								iconPath={playerProfileA.iconPath}
-								displayName={playerProfileA.displayName}
-								className="w-12 h-12 my-auto"
-							/>
-						)}
-						<div>
-							<div className="text-gray-100">Player 1</div>
-							<div className="text-2xl">{playerProfileA.displayName}</div>
-						</div>
-					</div>
-					<div className="text-6xl">{scoreA}</div>
-				</div>
-				<div className="font-bold text-center">
-					<div className="text-gray-100">{gameDisplayName}</div>
-					<div className="text-3xl">
-						{gameResult
-							? gameResult === "winA"
-								? `勝者 ${playerProfileA.displayName}`
-								: gameResult === "winB"
-									? `勝者 ${playerProfileB.displayName}`
-									: "引き分け"
-							: leftTime}
-					</div>
-				</div>
-				<div className="font-bold flex justify-between my-auto">
-					<div className="text-6xl">{scoreB}</div>
-					<div className="flex gap-6 text-end">
-						<div>
-							<div className="text-gray-100">Player 2</div>
-							<div className="text-2xl">{playerProfileB.displayName}</div>
-						</div>
-						{playerProfileB.iconPath && (
-							<UserIcon
-								iconPath={playerProfileB.iconPath}
-								displayName={playerProfileB.displayName}
-								className="w-12 h-12 my-auto"
-							/>
-						)}
-					</div>
-				</div>
-			</div>
-			<ScoreBar
-				scoreA={scoreA}
-				scoreB={scoreB}
-				bgA="bg-orange-400"
-				bgB="bg-purple-400"
-			/>
-			<div className="grow grid grid-cols-3 p-4 gap-4">
-				<CodeBlock code={codeA} language="php" />
-				<div className="flex flex-col gap-4">
-					<div className="grid grid-cols-2 gap-4">
-						<SubmitResult status={statusA} />
-						<SubmitResult status={statusB} />
-					</div>
-					<div>
-						<div className="mb-2 text-center text-xl font-bold">
-							{problemTitle}
-						</div>
-						<BorderedContainer>{problemDescription}</BorderedContainer>
-					</div>
-				</div>
-				<CodeBlock code={codeB} language="php" />
-			</div>
-		</div>
-	);
-}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming1v1.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming1v1.tsx
new file mode 100644
index 0000000..033186c
--- /dev/null
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming1v1.tsx
@@ -0,0 +1,128 @@
+import { useAtomValue } from "jotai";
+import {
+	gamingLeftTimeSecondsAtom,
+	latestGameStatesAtom,
+} from "../../states/watch";
+import type { PlayerProfile } from "../../types/PlayerProfile";
+import BorderedContainer from "../BorderedContainer";
+import CodeBlock from "../Gaming/CodeBlock";
+import ScoreBar from "../Gaming/ScoreBar";
+import SubmitResult from "../Gaming/SubmitResult";
+import UserIcon from "../UserIcon";
+
+type Props = {
+	gameDisplayName: string;
+	playerProfileA: PlayerProfile;
+	playerProfileB: PlayerProfile;
+	problemTitle: string;
+	problemDescription: string;
+	gameResult: "winA" | "winB" | "draw" | null;
+};
+
+export default function GolfWatchAppGaming1v1({
+	gameDisplayName,
+	playerProfileA,
+	playerProfileB,
+	problemTitle,
+	problemDescription,
+	gameResult,
+}: Props) {
+	const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom)!;
+	const latestGameStates = useAtomValue(latestGameStatesAtom);
+
+	const stateA = latestGameStates[playerProfileA.id];
+	const codeA = stateA?.code ?? "";
+	const scoreA = stateA?.score ?? null;
+	const statusA = stateA?.status ?? "none";
+	const stateB = latestGameStates[playerProfileB.id];
+	const codeB = stateB?.code ?? "";
+	const scoreB = stateB?.score ?? null;
+	const statusB = stateB?.status ?? "none";
+
+	const leftTime = (() => {
+		const m = Math.floor(leftTimeSeconds / 60);
+		const s = leftTimeSeconds % 60;
+		return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
+	})();
+
+	const topBg = gameResult
+		? gameResult === "winA"
+			? "bg-orange-400"
+			: gameResult === "winB"
+				? "bg-purple-400"
+				: "bg-pink-500"
+		: "bg-sky-600";
+
+	return (
+		<div className="min-h-screen bg-gray-100 flex flex-col">
+			<div className={`text-white ${topBg} grid grid-cols-3 px-4 py-2`}>
+				<div className="font-bold flex justify-between my-auto">
+					<div className="flex gap-6">
+						{playerProfileA.iconPath && (
+							<UserIcon
+								iconPath={playerProfileA.iconPath}
+								displayName={playerProfileA.displayName}
+								className="w-12 h-12 my-auto"
+							/>
+						)}
+						<div>
+							<div className="text-gray-100">Player 1</div>
+							<div className="text-2xl">{playerProfileA.displayName}</div>
+						</div>
+					</div>
+					<div className="text-6xl">{scoreA}</div>
+				</div>
+				<div className="font-bold text-center">
+					<div className="text-gray-100">{gameDisplayName}</div>
+					<div className="text-3xl">
+						{gameResult
+							? gameResult === "winA"
+								? `勝者 ${playerProfileA.displayName}`
+								: gameResult === "winB"
+									? `勝者 ${playerProfileB.displayName}`
+									: "引き分け"
+							: leftTime}
+					</div>
+				</div>
+				<div className="font-bold flex justify-between my-auto">
+					<div className="text-6xl">{scoreB}</div>
+					<div className="flex gap-6 text-end">
+						<div>
+							<div className="text-gray-100">Player 2</div>
+							<div className="text-2xl">{playerProfileB.displayName}</div>
+						</div>
+						{playerProfileB.iconPath && (
+							<UserIcon
+								iconPath={playerProfileB.iconPath}
+								displayName={playerProfileB.displayName}
+								className="w-12 h-12 my-auto"
+							/>
+						)}
+					</div>
+				</div>
+			</div>
+			<ScoreBar
+				scoreA={scoreA}
+				scoreB={scoreB}
+				bgA="bg-orange-400"
+				bgB="bg-purple-400"
+			/>
+			<div className="grow grid grid-cols-3 p-4 gap-4">
+				<CodeBlock code={codeA} language="php" />
+				<div className="flex flex-col gap-4">
+					<div className="grid grid-cols-2 gap-4">
+						<SubmitResult status={statusA} />
+						<SubmitResult status={statusB} />
+					</div>
+					<div>
+						<div className="mb-2 text-center text-xl font-bold">
+							{problemTitle}
+						</div>
+						<BorderedContainer>{problemDescription}</BorderedContainer>
+					</div>
+				</div>
+				<CodeBlock code={codeB} language="php" />
+			</div>
+		</div>
+	);
+}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGamingMultiplayer.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGamingMultiplayer.tsx
new file mode 100644
index 0000000..b6d2ac3
--- /dev/null
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGamingMultiplayer.tsx
@@ -0,0 +1,102 @@
+import { useAtomValue } from "jotai";
+import type { components } from "../../api/schema";
+import { gamingLeftTimeSecondsAtom } from "../../states/watch";
+import BorderedContainer from "../BorderedContainer";
+
+type RankingEntry = components["schemas"]["RankingEntry"];
+
+type Props = {
+	gameDisplayName: string;
+	ranking: RankingEntry[];
+	problemTitle: string;
+	problemDescription: string;
+	gameResult: "winA" | "winB" | "draw" | null;
+};
+
+export default function GolfWatchAppGamingMultiplayer({
+	gameDisplayName,
+	ranking,
+	problemTitle,
+	problemDescription,
+	gameResult,
+}: Props) {
+	const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom)!;
+
+	const leftTime = (() => {
+		const m = Math.floor(leftTimeSeconds / 60);
+		const s = leftTimeSeconds % 60;
+		return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
+	})();
+
+	const topBg = gameResult
+		? gameResult === "winA"
+			? "bg-orange-400"
+			: gameResult === "winB"
+				? "bg-purple-400"
+				: "bg-pink-500"
+		: "bg-sky-600";
+
+	return (
+		<div className="min-h-screen bg-gray-100 flex flex-col">
+			<div className={`text-white ${topBg} grid grid-cols-3 px-4 py-2`}>
+				<div className="font-bold flex justify-between my-auto"></div>
+				<div className="font-bold text-center">
+					<div className="text-gray-100">{gameDisplayName}</div>
+					<div className="text-3xl">{leftTime}</div>
+				</div>
+				<div className="font-bold flex justify-between my-auto"></div>
+			</div>
+			<div className="grow grid grid-cols-2 p-4 gap-4">
+				<div className="flex flex-col gap-4">
+					<div>
+						<div className="mb-2 text-center text-xl font-bold">
+							{problemTitle}
+						</div>
+						<BorderedContainer>{problemDescription}</BorderedContainer>
+					</div>
+				</div>
+				<div>
+					<table className="min-w-full divide-y divide-gray-200">
+						<thead className="bg-gray-50">
+							<tr>
+								<th
+									scope="col"
+									className="px-6 py-3 text-left font-medium text-gray-800 uppercase tracking-wider"
+								>
+									順位
+								</th>
+								<th
+									scope="col"
+									className="px-6 py-3 text-left font-medium text-gray-800 uppercase tracking-wider"
+								>
+									名前
+								</th>
+								<th
+									scope="col"
+									className="px-6 py-3 text-left font-medium text-gray-800 uppercase tracking-wider"
+								>
+									スコア
+								</th>
+							</tr>
+						</thead>
+						<tbody className="bg-white divide-y divide-gray-200">
+							{ranking.map((entry, index) => (
+								<tr key={entry.player.user_id}>
+									<td className="px-6 py-4 whitespace-nowrap text-gray-900">
+										{index + 1}
+									</td>
+									<td className="px-6 py-4 whitespace-nowrap text-gray-900">
+										{entry.player.display_name}
+									</td>
+									<td className="px-6 py-4 whitespace-nowrap text-gray-900">
+										{entry.score}
+									</td>
+								</tr>
+							))}
+						</tbody>
+					</table>
+				</div>
+			</div>
+		</div>
+	);
+}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx
deleted file mode 100644
index c2a5431..0000000
--- a/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import type { PlayerProfile } from "../../types/PlayerProfile";
-import PlayerNameAndIcon from "../PlayerNameAndIcon";
-
-type Props = {
-	gameDisplayName: string;
-	playerProfileA: PlayerProfile;
-	playerProfileB: PlayerProfile;
-};
-
-export default function GolfWatchAppWaiting({
-	gameDisplayName,
-	playerProfileA,
-	playerProfileB,
-}: Props) {
-	return (
-		<div className="min-h-screen bg-gray-100 flex flex-col font-bold text-center">
-			<div className="text-white bg-sky-600 p-10">
-				<div className="text-4xl">{gameDisplayName}</div>
-			</div>
-			<div className="grow grid grid-cols-3 gap-10 mx-auto text-black">
-				<PlayerNameAndIcon label="Player 1" profile={playerProfileA} />
-				<div className="text-8xl my-auto">vs.</div>
-				<PlayerNameAndIcon label="Player 2" profile={playerProfileB} />
-			</div>
-		</div>
-	);
-}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting1v1.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting1v1.tsx
new file mode 100644
index 0000000..fb315ff
--- /dev/null
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting1v1.tsx
@@ -0,0 +1,27 @@
+import type { PlayerProfile } from "../../types/PlayerProfile";
+import PlayerNameAndIcon from "../PlayerNameAndIcon";
+
+type Props = {
+	gameDisplayName: string;
+	playerProfileA: PlayerProfile;
+	playerProfileB: PlayerProfile;
+};
+
+export default function GolfWatchAppWaiting1v1({
+	gameDisplayName,
+	playerProfileA,
+	playerProfileB,
+}: Props) {
+	return (
+		<div className="min-h-screen bg-gray-100 flex flex-col font-bold text-center">
+			<div className="text-white bg-sky-600 p-10">
+				<div className="text-4xl">{gameDisplayName}</div>
+			</div>
+			<div className="grow grid grid-cols-3 gap-10 mx-auto text-black">
+				<PlayerNameAndIcon label="Player 1" profile={playerProfileA} />
+				<div className="text-8xl my-auto">vs.</div>
+				<PlayerNameAndIcon label="Player 2" profile={playerProfileB} />
+			</div>
+		</div>
+	);
+}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppWaitingMultiplayer.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppWaitingMultiplayer.tsx
new file mode 100644
index 0000000..13bcc10
--- /dev/null
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppWaitingMultiplayer.tsx
@@ -0,0 +1,15 @@
+type Props = {
+	gameDisplayName: string;
+};
+
+export default function GolfWatchAppWaitingMultiplayer({
+	gameDisplayName,
+}: Props) {
+	return (
+		<div className="min-h-screen bg-gray-100 flex flex-col font-bold text-center">
+			<div className="text-white bg-sky-600 p-10">
+				<div className="text-4xl">{gameDisplayName}</div>
+			</div>
+		</div>
+	);
+}
diff --git a/frontend/app/routes/golf.$gameId.watch.tsx b/frontend/app/routes/golf.$gameId.watch.tsx
index 0c07633..fed06aa 100644
--- a/frontend/app/routes/golf.$gameId.watch.tsx
+++ b/frontend/app/routes/golf.$gameId.watch.tsx
@@ -10,11 +10,11 @@ import {
 } from "../api/client";
 import GolfWatchApp from "../components/GolfWatchApp";
 import {
+	rankingAtom,
 	setCurrentTimestampAtom,
 	setDurationSecondsAtom,
 	setGameStartedAtAtom,
 	setLatestGameStatesAtom,
-	setRankingAtom,
 } from "../states/watch";
 
 export const meta: MetaFunction<typeof loader> = ({ data }) => [
@@ -59,10 +59,10 @@ export default function GolfWatch() {
 		useLoaderData<typeof loader>();
 
 	useHydrateAtoms([
+		[rankingAtom, ranking],
 		[setCurrentTimestampAtom, undefined],
 		[setDurationSecondsAtom, game.duration_seconds],
 		[setGameStartedAtAtom, game.started_at ?? null],
-		[setRankingAtom, ranking],
 		[setLatestGameStatesAtom, gameStates],
 	]);
 
diff --git a/frontend/app/states/watch.ts b/frontend/app/states/watch.ts
index ad95e0a..d3cc723 100644
--- a/frontend/app/states/watch.ts
+++ b/frontend/app/states/watch.ts
@@ -58,10 +58,7 @@ export const gamingLeftTimeSecondsAtom = atom<number | null>((get) => {
 	return Math.min(durationSeconds, Math.max(0, finishedAt - currentTimestamp));
 });
 
-const rankingAtom = atom<RankingEntry[]>([]);
-export const setRankingAtom = atom(null, (_, set, value: RankingEntry[]) => {
-	set(rankingAtom, value);
-});
+export const rankingAtom = atom<RankingEntry[]>([]);
 
 const rawLatestGameStatesAtom = atom<{
 	[key: string]: LatestGameState | undefined;
-- 
cgit v1.2.3-70-g09d2

