From 9477788709127ffd5611caed0fa7ee191326ce00 Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Sat, 10 Aug 2024 13:31:52 +0900
Subject: feat(frontend): partially implement watch page

---
 .../GolfWatchApps/GolfWatchAppGaming.tsx           | 137 +++++++++++++++++----
 1 file changed, 113 insertions(+), 24 deletions(-)

(limited to 'frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx')

diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
index 22277f8..470a00c 100644
--- a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
@@ -1,41 +1,130 @@
 type Props = {
 	problem: string;
-	codeA: string;
-	scoreA: number | null;
-	codeB: string;
-	scoreB: number | null;
+	playerInfoA: PlayerInfo;
+	playerInfoB: PlayerInfo;
+	leftTimeSeconds: number;
+};
+
+export type PlayerInfo = {
+	displayName: string | null;
+	iconPath: string | null;
+	score: number | null;
+	code: string | null;
+	submissionResult?: SubmissionResult;
+};
+
+type SubmissionResult = {
+	status: string;
+	nextScore: number;
+	executionResults: ExecutionResult[];
+};
+
+type ExecutionResult = {
+	status: string;
+	label: string;
+	output: string;
 };
 
 export default function GolfWatchAppGaming({
 	problem,
-	codeA,
-	scoreA,
-	codeB,
-	scoreB,
+	playerInfoA,
+	playerInfoB,
+	leftTimeSeconds,
 }: Props) {
+	const leftTime = (() => {
+		const m = Math.floor(leftTimeSeconds / 60);
+		const s = leftTimeSeconds % 60;
+		return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
+	})();
+	const scoreRatio = (() => {
+		const scoreA = playerInfoA.score ?? 0;
+		const scoreB = playerInfoB.score ?? 0;
+		const totalScore = scoreA + scoreB;
+		return totalScore === 0 ? 50 : (scoreA / totalScore) * 100;
+	})();
+
 	return (
-		<div style={{ display: "flex", flexDirection: "column" }}>
-			<div style={{ display: "flex", flex: 1, justifyContent: "center" }}>
-				<div>{problem}</div>
+		<div className="grid h-full w-full grid-rows-[auto_auto_1fr_auto]">
+			<div className="grid grid-cols-[1fr_auto_1fr]">
+				<div className="grid justify-start bg-red-500 p-2 text-white">
+					{playerInfoA.displayName}
+				</div>
+				<div className="grid justify-center p-2">{leftTime}</div>
+				<div className="grid justify-end bg-blue-500 p-2 text-white">
+					{playerInfoB.displayName}
+				</div>
 			</div>
-			<div style={{ display: "flex", flex: 3 }}>
-				<div style={{ display: "flex", flex: 3, flexDirection: "column" }}>
-					<div style={{ flex: 1, justifyContent: "center" }}>{scoreA}</div>
-					<div style={{ flex: 3 }}>
-						<pre>
-							<code>{codeA}</code>
-						</pre>
+			<div className="grid grid-cols-[auto_1fr_auto]">
+				<div className="grid justify-start bg-red-500 p-2 text-lg font-bold text-white">
+					{playerInfoA.score ?? "-"}
+				</div>
+				<div className="w-full bg-blue-500">
+					<div
+						className="h-full bg-red-500"
+						style={{ width: `${scoreRatio}%` }}
+					></div>
+				</div>
+				<div className="grid justify-end bg-blue-500 p-2 text-lg font-bold text-white">
+					{playerInfoB.score ?? "-"}
+				</div>
+			</div>
+			<div className="grid grid-cols-[2fr_1fr_2fr_1fr] p-2">
+				<div>
+					<pre>
+						<code>{playerInfoA.code}</code>
+					</pre>
+				</div>
+				<div>
+					<div>
+						{playerInfoA.submissionResult?.status}(
+						{playerInfoA.submissionResult?.nextScore})
+					</div>
+					<div>
+						<ol>
+							{playerInfoA.submissionResult?.executionResults.map(
+								(result, idx) => (
+									<li key={idx}>
+										<div>
+											<div>
+												{result.status} {result.label}
+											</div>
+											<div>{result.output}</div>
+										</div>
+									</li>
+								),
+							)}
+						</ol>
 					</div>
 				</div>
-				<div style={{ display: "flex", flex: 3, flexDirection: "column" }}>
-					<div style={{ flex: 1, justifyContent: "center" }}>{scoreB}</div>
-					<div style={{ flex: 3 }}>
-						<pre>
-							<code>{codeB}</code>
-						</pre>
+				<div>
+					<pre>
+						<code>{playerInfoB.code}</code>
+					</pre>
+				</div>
+				<div>
+					<div>
+						{playerInfoB.submissionResult?.status}(
+						{playerInfoB.submissionResult?.nextScore})
+					</div>
+					<div>
+						<ol>
+							{playerInfoB.submissionResult?.executionResults.map(
+								(result, idx) => (
+									<li key={idx}>
+										<div>
+											<div>
+												{result.status} {result.label}
+											</div>
+											<div>{result.output}</div>
+										</div>
+									</li>
+								),
+							)}
+						</ol>
 					</div>
 				</div>
 			</div>
+			<div className="grid justify-center p-2 bg-slate-300">{problem}</div>
 		</div>
 	);
 }
-- 
cgit v1.3.1


From b4ab693aa438f3f1a335369568aabe7849fc1370 Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Sat, 10 Aug 2024 20:25:59 +0900
Subject: feat: implement watch page

---
 backend/game/hub.go                                | 153 +++++++++++++++++----
 backend/game/message.go                            |  14 +-
 frontend/app/components/GolfWatchApp.client.tsx    |  88 ++++++++++--
 .../GolfWatchApps/GolfWatchAppGaming.tsx           |  82 ++++++++---
 frontend/app/root.tsx                              |   2 +-
 5 files changed, 283 insertions(+), 56 deletions(-)

(limited to 'frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx')

diff --git a/backend/game/hub.go b/backend/game/hub.go
index aa1b9f2..54c559c 100644
--- a/backend/game/hub.go
+++ b/backend/game/hub.go
@@ -10,6 +10,7 @@ import (
 	"time"
 
 	"github.com/jackc/pgx/v5/pgtype"
+	"github.com/oapi-codegen/nullable"
 
 	"github.com/nsfisis/iosdc-japan-2024-albatross/backend/api"
 	"github.com/nsfisis/iosdc-japan-2024-albatross/backend/db"
@@ -118,14 +119,12 @@ func (hub *gameHub) run() {
 							},
 						}
 					}
-					for watcher := range hub.watchers {
-						watcher.s2cMessages <- &watcherMessageS2CStart{
-							Type: watcherMessageTypeS2CStart,
-							Data: watcherMessageS2CStartPayload{
-								StartAt: int(startAt.Unix()),
-							},
-						}
-					}
+					hub.broadcastToWatchers(&watcherMessageS2CStart{
+						Type: watcherMessageTypeS2CStart,
+						Data: watcherMessageS2CStartPayload{
+							StartAt: int(startAt.Unix()),
+						},
+					})
 					err := hub.q.UpdateGameStartedAt(hub.ctx, db.UpdateGameStartedAtParams{
 						GameID: int32(hub.game.gameID),
 						StartedAt: pgtype.Timestamp{
@@ -151,15 +150,13 @@ func (hub *gameHub) run() {
 				// TODO: assert game state is gaming
 				log.Printf("code: %v", message.message)
 				code := msg.Data.Code
-				for watcher := range hub.watchers {
-					watcher.s2cMessages <- &watcherMessageS2CCode{
-						Type: watcherMessageTypeS2CCode,
-						Data: watcherMessageS2CCodePayload{
-							PlayerID: message.client.playerID,
-							Code:     code,
-						},
-					}
-				}
+				hub.broadcastToWatchers(&watcherMessageS2CCode{
+					Type: watcherMessageTypeS2CCode,
+					Data: watcherMessageS2CCodePayload{
+						PlayerID: message.client.playerID,
+						Code:     code,
+					},
+				})
 			case *playerMessageC2SSubmit:
 				// TODO: assert game state is gaming
 				log.Printf("submit: %v", message.message)
@@ -176,6 +173,13 @@ func (hub *gameHub) run() {
 					// TODO: notify failure to player
 					log.Fatalf("failed to enqueue task: %v", err)
 				}
+				hub.broadcastToWatchers(&watcherMessageS2CSubmit{
+					Type: watcherMessageTypeS2CSubmit,
+					Data: watcherMessageS2CSubmitPayload{
+						PlayerID:         message.client.playerID,
+						PreliminaryScore: codeSize,
+					},
+				})
 			default:
 				log.Printf("unexpected message type: %T", message.message)
 			}
@@ -209,6 +213,12 @@ func (hub *gameHub) run() {
 	}
 }
 
+func (hub *gameHub) broadcastToWatchers(msg watcherMessageS2C) {
+	for watcher := range hub.watchers {
+		watcher.s2cMessages <- msg
+	}
+}
+
 type codeSubmissionError struct {
 	Status string
 	Stdout string
@@ -237,7 +247,13 @@ func (hub *gameHub) processTaskResults() {
 						},
 					}
 				}
-				// TODO: broadcast to watchers
+				hub.broadcastToWatchers(&watcherMessageS2CSubmitResult{
+					Type: watcherMessageTypeS2CSubmitResult,
+					Data: watcherMessageS2CSubmitResultPayload{
+						PlayerID: taskResult.TaskPayload.UserID(),
+						Status:   api.GameWatcherMessageS2CSubmitResultPayloadStatus(err.Status),
+					},
+				})
 			}
 		case *taskqueue.TaskResultCompileSwiftToWasm:
 			err := hub.processTaskResultCompileSwiftToWasm(taskResult)
@@ -254,7 +270,22 @@ func (hub *gameHub) processTaskResults() {
 						},
 					}
 				}
-				// TODO: broadcast to watchers
+				hub.broadcastToWatchers(&watcherMessageS2CExecResult{
+					Type: watcherMessageTypeS2CExecResult,
+					Data: watcherMessageS2CExecResultPayload{
+						PlayerID: taskResult.TaskPayload.UserID(),
+						Status:   api.GameWatcherMessageS2CExecResultPayloadStatus(err.Status),
+						Stdout:   err.Stdout,
+						Stderr:   err.Stderr,
+					},
+				})
+				hub.broadcastToWatchers(&watcherMessageS2CSubmitResult{
+					Type: watcherMessageTypeS2CSubmitResult,
+					Data: watcherMessageS2CSubmitResultPayload{
+						PlayerID: taskResult.TaskPayload.UserID(),
+						Status:   api.GameWatcherMessageS2CSubmitResultPayloadStatus(err.Status),
+					},
+				})
 			}
 		case *taskqueue.TaskResultCompileWasmToNativeExecutable:
 			err := hub.processTaskResultCompileWasmToNativeExecutable(taskResult)
@@ -271,11 +302,38 @@ func (hub *gameHub) processTaskResults() {
 						},
 					}
 				}
-				// TODO: broadcast to watchers
+				hub.broadcastToWatchers(&watcherMessageS2CExecResult{
+					Type: watcherMessageTypeS2CExecResult,
+					Data: watcherMessageS2CExecResultPayload{
+						PlayerID: taskResult.TaskPayload.UserID(),
+						Status:   api.GameWatcherMessageS2CExecResultPayloadStatus(err.Status),
+						Stdout:   err.Stdout,
+						Stderr:   err.Stderr,
+					},
+				})
+				hub.broadcastToWatchers(&watcherMessageS2CSubmitResult{
+					Type: watcherMessageTypeS2CSubmitResult,
+					Data: watcherMessageS2CSubmitResultPayload{
+						PlayerID: taskResult.TaskPayload.UserID(),
+						Status:   api.GameWatcherMessageS2CSubmitResultPayloadStatus(err.Status),
+					},
+				})
+			} else {
+				hub.broadcastToWatchers(&watcherMessageS2CExecResult{
+					Type: watcherMessageTypeS2CExecResult,
+					Data: watcherMessageS2CExecResultPayload{
+						PlayerID: taskResult.TaskPayload.UserID(),
+						Status:   api.GameWatcherMessageS2CExecResultPayloadStatus("success"),
+						// TODO: inherit the command stdout/stderr.
+						Stdout: "Successfully compiled",
+						Stderr: "",
+					},
+				})
 			}
 		case *taskqueue.TaskResultRunTestcase:
+			// FIXME: error handling
 			var err error
-			err = hub.processTaskResultRunTestcase(taskResult)
+			err1 := hub.processTaskResultRunTestcase(taskResult)
 			_ = err // TODO: handle err?
 			aggregatedStatus, err := hub.q.AggregateTestcaseResults(hub.ctx, int32(taskResult.TaskPayload.SubmissionID))
 			_ = err // TODO: handle err?
@@ -298,7 +356,24 @@ func (hub *gameHub) processTaskResults() {
 						},
 					}
 				}
-				// TODO: broadcast to watchers
+				hub.broadcastToWatchers(&watcherMessageS2CExecResult{
+					Type: watcherMessageTypeS2CExecResult,
+					Data: watcherMessageS2CExecResultPayload{
+						PlayerID:   taskResult.TaskPayload.UserID(),
+						TestcaseID: nullable.NewNullableWithValue(int(taskResult.TaskPayload.TestcaseID)),
+						Status:     api.GameWatcherMessageS2CExecResultPayloadStatus("internal_error"),
+						// TODO: inherit the command stdout/stderr?
+						Stdout: "",
+						Stderr: "internal error",
+					},
+				})
+				hub.broadcastToWatchers(&watcherMessageS2CSubmitResult{
+					Type: watcherMessageTypeS2CSubmitResult,
+					Data: watcherMessageS2CSubmitResultPayload{
+						PlayerID: taskResult.TaskPayload.UserID(),
+						Status:   api.GameWatcherMessageS2CSubmitResultPayloadStatus("internal_error"),
+					},
+				})
 				continue
 			}
 			for player := range hub.players {
@@ -312,7 +387,39 @@ func (hub *gameHub) processTaskResults() {
 						Status: api.GamePlayerMessageS2CExecResultPayloadStatus(aggregatedStatus),
 					},
 				}
-				// TODO: broadcast to watchers
+			}
+			if err1 != nil {
+				hub.broadcastToWatchers(&watcherMessageS2CExecResult{
+					Type: watcherMessageTypeS2CExecResult,
+					Data: watcherMessageS2CExecResultPayload{
+						PlayerID:   taskResult.TaskPayload.UserID(),
+						TestcaseID: nullable.NewNullableWithValue(int(taskResult.TaskPayload.TestcaseID)),
+						Status:     api.GameWatcherMessageS2CExecResultPayloadStatus(err1.Status),
+						Stdout:     err1.Stdout,
+						Stderr:     err1.Stderr,
+					},
+				})
+			} else {
+				hub.broadcastToWatchers(&watcherMessageS2CExecResult{
+					Type: watcherMessageTypeS2CExecResult,
+					Data: watcherMessageS2CExecResultPayload{
+						PlayerID:   taskResult.TaskPayload.UserID(),
+						TestcaseID: nullable.NewNullableWithValue(int(taskResult.TaskPayload.TestcaseID)),
+						Status:     api.GameWatcherMessageS2CExecResultPayloadStatus("success"),
+						// TODO: inherit the command stdout/stderr?
+						Stdout: "Testcase passed",
+						Stderr: "",
+					},
+				})
+			}
+			if aggregatedStatus != "running" {
+				hub.broadcastToWatchers(&watcherMessageS2CSubmitResult{
+					Type: watcherMessageTypeS2CSubmitResult,
+					Data: watcherMessageS2CSubmitResultPayload{
+						PlayerID: taskResult.TaskPayload.UserID(),
+						Status:   api.GameWatcherMessageS2CSubmitResultPayloadStatus(aggregatedStatus),
+					},
+				})
 			}
 		default:
 			panic("unexpected task result type")
diff --git a/backend/game/message.go b/backend/game/message.go
index 031222d..1fb30cb 100644
--- a/backend/game/message.go
+++ b/backend/game/message.go
@@ -77,9 +77,11 @@ func asPlayerMessageC2S(raw map[string]json.RawMessage) (playerMessageC2S, error
 }
 
 const (
-	watcherMessageTypeS2CStart      = "watcher:s2c:start"
-	watcherMessageTypeS2CExecResult = "watcher:s2c:execresult"
-	watcherMessageTypeS2CCode       = "watcher:s2c:code"
+	watcherMessageTypeS2CStart        = "watcher:s2c:start"
+	watcherMessageTypeS2CCode         = "watcher:s2c:code"
+	watcherMessageTypeS2CSubmit       = "watcher:s2c:submit"
+	watcherMessageTypeS2CExecResult   = "watcher:s2c:execresult"
+	watcherMessageTypeS2CSubmitResult = "watcher:s2c:submitresult"
 )
 
 type watcherMessageS2C = interface{}
@@ -87,3 +89,9 @@ type watcherMessageS2CStart = api.GameWatcherMessageS2CStart
 type watcherMessageS2CStartPayload = api.GameWatcherMessageS2CStartPayload
 type watcherMessageS2CCode = api.GameWatcherMessageS2CCode
 type watcherMessageS2CCodePayload = api.GameWatcherMessageS2CCodePayload
+type watcherMessageS2CSubmit = api.GameWatcherMessageS2CSubmit
+type watcherMessageS2CSubmitPayload = api.GameWatcherMessageS2CSubmitPayload
+type watcherMessageS2CExecResult = api.GameWatcherMessageS2CExecResult
+type watcherMessageS2CExecResultPayload = api.GameWatcherMessageS2CExecResultPayload
+type watcherMessageS2CSubmitResult = api.GameWatcherMessageS2CSubmitResult
+type watcherMessageS2CSubmitResultPayload = api.GameWatcherMessageS2CSubmitResultPayload
diff --git a/frontend/app/components/GolfWatchApp.client.tsx b/frontend/app/components/GolfWatchApp.client.tsx
index 355f7e3..a9c9989 100644
--- a/frontend/app/components/GolfWatchApp.client.tsx
+++ b/frontend/app/components/GolfWatchApp.client.tsx
@@ -116,29 +116,91 @@ export default function GolfWatchApp({
 					}
 				} else if (lastJsonMessage.type === "watcher:s2c:code") {
 					const { player_id, code } = lastJsonMessage.data;
-					if (player_id === playerA?.user_id) {
-						setPlayerInfoA((prev) => ({ ...prev, code }));
-					} else if (player_id === playerB?.user_id) {
-						setPlayerInfoB((prev) => ({ ...prev, code }));
-					} else {
-						throw new Error("Unknown player_id");
-					}
+					const setter =
+						player_id === playerA?.user_id ? setPlayerInfoA : setPlayerInfoB;
+					setter((prev) => ({ ...prev, code }));
+				} else if (lastJsonMessage.type === "watcher:s2c:submit") {
+					const { player_id, preliminary_score } = lastJsonMessage.data;
+					const setter =
+						player_id === playerA?.user_id ? setPlayerInfoA : setPlayerInfoB;
+					setter((prev) => ({
+						...prev,
+						submissionResult: {
+							status: "running",
+							preliminaryScore: preliminary_score,
+							verificationResults: game.verification_steps.map((v) => ({
+								testcase_id: v.testcase_id,
+								status: "running",
+								label: v.label,
+								stdout: "",
+								stderr: "",
+							})),
+						},
+					}));
 				} else if (lastJsonMessage.type === "watcher:s2c:execresult") {
-					// const { score } = lastJsonMessage.data;
-					// if (score !== null && (scoreA === null || score < scoreA)) {
-					// 	setScoreA(score);
-					// }
+					const { player_id, testcase_id, status, stdout, stderr } =
+						lastJsonMessage.data;
+					const setter =
+						player_id === playerA?.user_id ? setPlayerInfoA : setPlayerInfoB;
+					setter((prev) => {
+						const ret = { ...prev };
+						if (ret.submissionResult === undefined) {
+							return ret;
+						}
+						ret.submissionResult = {
+							...ret.submissionResult,
+							verificationResults: ret.submissionResult.verificationResults.map(
+								(v) =>
+									v.testcase_id === testcase_id && v.status === "running"
+										? {
+												...v,
+												status,
+												stdout,
+												stderr,
+											}
+										: v,
+							),
+						};
+						return ret;
+					});
+				} else if (lastJsonMessage.type === "watcher:s2c:submitresult") {
+					const { player_id, status } = lastJsonMessage.data;
+					const setter =
+						player_id === playerA?.user_id ? setPlayerInfoA : setPlayerInfoB;
+					setter((prev) => {
+						const ret = { ...prev };
+						if (ret.submissionResult === undefined) {
+							return ret;
+						}
+						ret.submissionResult = {
+							...ret.submissionResult,
+							status,
+						};
+						if (status === "success") {
+							if (
+								ret.score === null ||
+								ret.submissionResult.preliminaryScore < ret.score
+							) {
+								ret.score = ret.submissionResult.preliminaryScore;
+							}
+						} else {
+							ret.submissionResult.verificationResults =
+								ret.submissionResult.verificationResults.map((v) =>
+									v.status === "running" ? { ...v, status: "canceled" } : v,
+								);
+						}
+						return ret;
+					});
 				}
 			} else {
 				setGameState("waiting");
 			}
 		}
 	}, [
+		game.verification_steps,
 		lastJsonMessage,
 		readyState,
 		gameState,
-		playerInfoA,
-		playerInfoB,
 		playerA?.user_id,
 		playerB?.user_id,
 	]);
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
index 470a00c..992ce7a 100644
--- a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
@@ -14,17 +14,57 @@ export type PlayerInfo = {
 };
 
 type SubmissionResult = {
-	status: string;
-	nextScore: number;
-	executionResults: ExecutionResult[];
+	status:
+		| "running"
+		| "success"
+		| "wrong_answer"
+		| "timeout"
+		| "compile_error"
+		| "runtime_error"
+		| "internal_error";
+	preliminaryScore: number;
+	verificationResults: VerificationResult[];
 };
 
-type ExecutionResult = {
-	status: string;
+type VerificationResult = {
+	testcase_id: number | null;
+	status:
+		| "running"
+		| "success"
+		| "wrong_answer"
+		| "timeout"
+		| "compile_error"
+		| "runtime_error"
+		| "internal_error"
+		| "canceled";
 	label: string;
-	output: string;
+	stdout: string;
+	stderr: string;
 };
 
+function submissionResultStatusToLabel(
+	status: SubmissionResult["status"] | null,
+) {
+	switch (status) {
+		case null:
+			return "-";
+		case "running":
+			return "Running...";
+		case "success":
+			return "Accepted";
+		case "wrong_answer":
+			return "Wrong Answer";
+		case "timeout":
+			return "Time Limit Exceeded";
+		case "compile_error":
+			return "Compile Error";
+		case "runtime_error":
+			return "Runtime Error";
+		case "internal_error":
+			return "Internal Error";
+	}
+}
+
 export default function GolfWatchAppGaming({
 	problem,
 	playerInfoA,
@@ -40,7 +80,7 @@ export default function GolfWatchAppGaming({
 		const scoreA = playerInfoA.score ?? 0;
 		const scoreB = playerInfoB.score ?? 0;
 		const totalScore = scoreA + scoreB;
-		return totalScore === 0 ? 50 : (scoreA / totalScore) * 100;
+		return totalScore === 0 ? 50 : (scoreB / totalScore) * 100;
 	})();
 
 	return (
@@ -68,7 +108,7 @@ export default function GolfWatchAppGaming({
 					{playerInfoB.score ?? "-"}
 				</div>
 			</div>
-			<div className="grid grid-cols-[2fr_1fr_2fr_1fr] p-2">
+			<div className="grid grid-cols-[3fr_2fr_3fr_2fr] p-2">
 				<div>
 					<pre>
 						<code>{playerInfoA.code}</code>
@@ -76,19 +116,24 @@ export default function GolfWatchAppGaming({
 				</div>
 				<div>
 					<div>
-						{playerInfoA.submissionResult?.status}(
-						{playerInfoA.submissionResult?.nextScore})
+						{submissionResultStatusToLabel(
+							playerInfoA.submissionResult?.status ?? null,
+						)}{" "}
+						({playerInfoA.submissionResult?.preliminaryScore})
 					</div>
 					<div>
 						<ol>
-							{playerInfoA.submissionResult?.executionResults.map(
+							{playerInfoA.submissionResult?.verificationResults.map(
 								(result, idx) => (
 									<li key={idx}>
 										<div>
 											<div>
 												{result.status} {result.label}
 											</div>
-											<div>{result.output}</div>
+											<div>
+												{result.stdout}
+												{result.stderr}
+											</div>
 										</div>
 									</li>
 								),
@@ -103,19 +148,24 @@ export default function GolfWatchAppGaming({
 				</div>
 				<div>
 					<div>
-						{playerInfoB.submissionResult?.status}(
-						{playerInfoB.submissionResult?.nextScore})
+						{submissionResultStatusToLabel(
+							playerInfoB.submissionResult?.status ?? null,
+						)}{" "}
+						({playerInfoB.submissionResult?.preliminaryScore ?? "-"})
 					</div>
 					<div>
 						<ol>
-							{playerInfoB.submissionResult?.executionResults.map(
+							{playerInfoB.submissionResult?.verificationResults.map(
 								(result, idx) => (
 									<li key={idx}>
 										<div>
 											<div>
 												{result.status} {result.label}
 											</div>
-											<div>{result.output}</div>
+											<div>
+												{result.stdout}
+												{result.stderr}
+											</div>
 										</div>
 									</li>
 								),
diff --git a/frontend/app/root.tsx b/frontend/app/root.tsx
index 054474a..4d7a661 100644
--- a/frontend/app/root.tsx
+++ b/frontend/app/root.tsx
@@ -21,7 +21,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
 				<Meta />
 				<Links />
 			</head>
-			<body>
+			<body className="h-screen">
 				{children}
 				<ScrollRestoration />
 				<Scripts />
-- 
cgit v1.3.1


From 04ff82d35e9cbd3d2a86204260f58a370fda88da Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Sat, 10 Aug 2024 21:58:13 +0900
Subject: feat(frontend): show status indicator icon

---
 .../app/components/ExecStatusIndicatorIcon.tsx     | 45 +++++++++++++++++++
 .../GolfWatchApps/GolfWatchAppGaming.tsx           |  8 +++-
 frontend/app/routes/_index.tsx                     |  4 ++
 frontend/package-lock.json                         | 50 ++++++++++++++++++++--
 frontend/package.json                              |  3 ++
 5 files changed, 104 insertions(+), 6 deletions(-)
 create mode 100644 frontend/app/components/ExecStatusIndicatorIcon.tsx

(limited to 'frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx')

diff --git a/frontend/app/components/ExecStatusIndicatorIcon.tsx b/frontend/app/components/ExecStatusIndicatorIcon.tsx
new file mode 100644
index 0000000..a76e957
--- /dev/null
+++ b/frontend/app/components/ExecStatusIndicatorIcon.tsx
@@ -0,0 +1,45 @@
+import {
+	faBan,
+	faCircleCheck,
+	faCircleExclamation,
+	faRotate,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+type Props = {
+	status: string;
+};
+
+export default function ExecStatusIndicatorIcon({ status }: Props) {
+	switch (status) {
+		case "running":
+			return (
+				<FontAwesomeIcon
+					icon={faRotate}
+					spin
+					fixedWidth
+					className="text-gray-700"
+				/>
+			);
+		case "success":
+			return (
+				<FontAwesomeIcon
+					icon={faCircleCheck}
+					fixedWidth
+					className="text-green-500"
+				/>
+			);
+		case "canceled":
+			return (
+				<FontAwesomeIcon icon={faBan} fixedWidth className="text-gray-400" />
+			);
+		default:
+			return (
+				<FontAwesomeIcon
+					icon={faCircleExclamation}
+					fixedWidth
+					className="text-red-500"
+				/>
+			);
+	}
+}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
index 992ce7a..53d5bce 100644
--- a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming.tsx
@@ -1,3 +1,5 @@
+import ExecStatusIndicatorIcon from "../ExecStatusIndicatorIcon";
+
 type Props = {
 	problem: string;
 	playerInfoA: PlayerInfo;
@@ -128,7 +130,8 @@ export default function GolfWatchAppGaming({
 									<li key={idx}>
 										<div>
 											<div>
-												{result.status} {result.label}
+												<ExecStatusIndicatorIcon status={result.status} />{" "}
+												{result.label}
 											</div>
 											<div>
 												{result.stdout}
@@ -160,7 +163,8 @@ export default function GolfWatchAppGaming({
 									<li key={idx}>
 										<div>
 											<div>
-												{result.status} {result.label}
+												<ExecStatusIndicatorIcon status={result.status} />{" "}
+												{result.label}
 											</div>
 											<div>
 												{result.stdout}
diff --git a/frontend/app/routes/_index.tsx b/frontend/app/routes/_index.tsx
index 2fcf1f2..25b9c81 100644
--- a/frontend/app/routes/_index.tsx
+++ b/frontend/app/routes/_index.tsx
@@ -1,7 +1,11 @@
+import { config } from "@fortawesome/fontawesome-svg-core";
 import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
 import { Link } from "@remix-run/react";
+import "@fortawesome/fontawesome-svg-core/styles.css";
 import { ensureUserNotLoggedIn } from "../.server/auth";
 
+config.autoAddCss = false;
+
 export const meta: MetaFunction = () => [
 	{ title: "iOSDC Japan 2024 Albatross.swift" },
 ];
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index d5c12fe..b706788 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -6,6 +6,9 @@
 		"": {
 			"name": "iosdc-japan-2024-albatross-frontend",
 			"dependencies": {
+				"@fortawesome/fontawesome-svg-core": "^6.6.0",
+				"@fortawesome/free-solid-svg-icons": "^6.6.0",
+				"@fortawesome/react-fontawesome": "^0.2.2",
 				"@remix-run/node": "^2.10.3",
 				"@remix-run/react": "^2.10.3",
 				"@remix-run/serve": "^2.10.3",
@@ -1306,6 +1309,48 @@
 				"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
 			}
 		},
+		"node_modules/@fortawesome/fontawesome-common-types": {
+			"version": "6.6.0",
+			"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz",
+			"integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==",
+			"engines": {
+				"node": ">=6"
+			}
+		},
+		"node_modules/@fortawesome/fontawesome-svg-core": {
+			"version": "6.6.0",
+			"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz",
+			"integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==",
+			"dependencies": {
+				"@fortawesome/fontawesome-common-types": "6.6.0"
+			},
+			"engines": {
+				"node": ">=6"
+			}
+		},
+		"node_modules/@fortawesome/free-solid-svg-icons": {
+			"version": "6.6.0",
+			"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz",
+			"integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==",
+			"dependencies": {
+				"@fortawesome/fontawesome-common-types": "6.6.0"
+			},
+			"engines": {
+				"node": ">=6"
+			}
+		},
+		"node_modules/@fortawesome/react-fontawesome": {
+			"version": "0.2.2",
+			"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz",
+			"integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==",
+			"dependencies": {
+				"prop-types": "^15.8.1"
+			},
+			"peerDependencies": {
+				"@fortawesome/fontawesome-svg-core": "~1 || ~6",
+				"react": ">=16.3"
+			}
+		},
 		"node_modules/@humanwhocodes/config-array": {
 			"version": "0.11.14",
 			"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
@@ -8087,7 +8132,6 @@
 			"version": "4.1.1",
 			"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
 			"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
-			"dev": true,
 			"engines": {
 				"node": ">=0.10.0"
 			}
@@ -8953,7 +8997,6 @@
 			"version": "15.8.1",
 			"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
 			"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
-			"dev": true,
 			"dependencies": {
 				"loose-envify": "^1.4.0",
 				"object-assign": "^4.1.1",
@@ -9102,8 +9145,7 @@
 		"node_modules/react-is": {
 			"version": "16.13.1",
 			"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
-			"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
-			"dev": true
+			"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
 		},
 		"node_modules/react-refresh": {
 			"version": "0.14.2",
diff --git a/frontend/package.json b/frontend/package.json
index 44af089..4c630a0 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,6 +14,9 @@
 		"start": "remix-serve ./build/server/index.js"
 	},
 	"dependencies": {
+		"@fortawesome/fontawesome-svg-core": "^6.6.0",
+		"@fortawesome/free-solid-svg-icons": "^6.6.0",
+		"@fortawesome/react-fontawesome": "^0.2.2",
 		"@remix-run/node": "^2.10.3",
 		"@remix-run/react": "^2.10.3",
 		"@remix-run/serve": "^2.10.3",
-- 
cgit v1.3.1

