aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-03-01 12:21:21 +0900
committernsfisis <nsfisis@gmail.com>2026-03-01 12:21:21 +0900
commit7f29d334f26229753e68d20a5aaab33c39de9f06 (patch)
tree483f02d06fe8562113a7085c38c6ad36e8fd7452 /frontend
parent94f940cddc9ca39d484996616f9f4b322c1ff7f8 (diff)
downloadphperkaigi-2026-albatross-7f29d334f26229753e68d20a5aaab33c39de9f06.tar.gz
phperkaigi-2026-albatross-7f29d334f26229753e68d20a5aaab33c39de9f06.tar.zst
phperkaigi-2026-albatross-7f29d334f26229753e68d20a5aaab33c39de9f06.zip
feat(frontend): show submission history on play screen
Replace the placeholder submission status section with a full submission history table using the existing getGamePlaySubmissions API. Extract shared DataTable, DataTableCell, and formatUnixTimestamp from RankingTable into a reusable Gaming/DataTable component. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'frontend')
-rw-r--r--frontend/app/components/Gaming/DataTable.test.tsx70
-rw-r--r--frontend/app/components/Gaming/DataTable.tsx45
-rw-r--r--frontend/app/components/Gaming/RankingTable.tsx81
-rw-r--r--frontend/app/components/GolfPlayApp.tsx25
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppGaming.test.tsx107
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx62
6 files changed, 303 insertions, 87 deletions
diff --git a/frontend/app/components/Gaming/DataTable.test.tsx b/frontend/app/components/Gaming/DataTable.test.tsx
new file mode 100644
index 0000000..2a4446c
--- /dev/null
+++ b/frontend/app/components/Gaming/DataTable.test.tsx
@@ -0,0 +1,70 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, test } from "vitest";
+import DataTable, { DataTableCell, formatUnixTimestamp } from "./DataTable";
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("DataTable", () => {
+ test("renders headers", () => {
+ render(
+ <DataTable headers={["A", "B", "C"]}>
+ <tr>
+ <DataTableCell>1</DataTableCell>
+ <DataTableCell>2</DataTableCell>
+ <DataTableCell>3</DataTableCell>
+ </tr>
+ </DataTable>,
+ );
+ expect(screen.getByText("A")).toBeDefined();
+ expect(screen.getByText("B")).toBeDefined();
+ expect(screen.getByText("C")).toBeDefined();
+ });
+
+ test("renders body cells", () => {
+ render(
+ <DataTable headers={["H"]}>
+ <tr>
+ <DataTableCell>cell content</DataTableCell>
+ </tr>
+ </DataTable>,
+ );
+ expect(screen.getByText("cell content")).toBeDefined();
+ });
+
+ test("renders multiple rows", () => {
+ render(
+ <DataTable headers={["Name"]}>
+ <tr>
+ <DataTableCell>Alice</DataTableCell>
+ </tr>
+ <tr>
+ <DataTableCell>Bob</DataTableCell>
+ </tr>
+ </DataTable>,
+ );
+ expect(screen.getByText("Alice")).toBeDefined();
+ expect(screen.getByText("Bob")).toBeDefined();
+ });
+});
+
+describe("formatUnixTimestamp", () => {
+ test("formats timestamp correctly", () => {
+ // 2026-03-01 12:30 JST (UTC+9) = 2026-03-01 03:30 UTC
+ const timestamp = Date.UTC(2026, 2, 1, 3, 30, 0) / 1000;
+ const result = formatUnixTimestamp(timestamp);
+ // Result depends on local timezone; just check the format pattern
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
+ });
+
+ test("pads single-digit months and days", () => {
+ // Use a date where month and day are single digits
+ const timestamp = Date.UTC(2026, 0, 5, 0, 0, 0) / 1000;
+ const result = formatUnixTimestamp(timestamp);
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
+ });
+});
diff --git a/frontend/app/components/Gaming/DataTable.tsx b/frontend/app/components/Gaming/DataTable.tsx
new file mode 100644
index 0000000..098f4a2
--- /dev/null
+++ b/frontend/app/components/Gaming/DataTable.tsx
@@ -0,0 +1,45 @@
+import type React from "react";
+
+type Props = {
+ headers: React.ReactNode[];
+ children: React.ReactNode;
+};
+
+export default function DataTable({ headers, children }: Props) {
+ return (
+ <div className="overflow-x-auto border-2 border-brand-600 rounded-xl">
+ <table className="min-w-full divide-y divide-gray-400 border-collapse">
+ <thead className="bg-gray-50">
+ <tr>
+ {headers.map((header, i) => (
+ <th
+ key={i}
+ scope="col"
+ className="px-6 py-3 text-left font-medium text-gray-800"
+ >
+ {header}
+ </th>
+ ))}
+ </tr>
+ </thead>
+ <tbody className="bg-white divide-y divide-gray-300">{children}</tbody>
+ </table>
+ </div>
+ );
+}
+
+export function DataTableCell({ children }: { children: React.ReactNode }) {
+ return (
+ <td className="px-6 py-4 whitespace-nowrap text-gray-900">{children}</td>
+ );
+}
+
+export function formatUnixTimestamp(timestamp: number): string {
+ const date = new Date(timestamp * 1000);
+ const year = date.getFullYear();
+ const month = (date.getMonth() + 1).toString().padStart(2, "0");
+ const day = date.getDate().toString().padStart(2, "0");
+ const hours = date.getHours().toString().padStart(2, "0");
+ const minutes = date.getMinutes().toString().padStart(2, "0");
+ return `${year}-${month}-${day} ${hours}:${minutes}`;
+}
diff --git a/frontend/app/components/Gaming/RankingTable.tsx b/frontend/app/components/Gaming/RankingTable.tsx
index 4bfdad3..4ba3987 100644
--- a/frontend/app/components/Gaming/RankingTable.tsx
+++ b/frontend/app/components/Gaming/RankingTable.tsx
@@ -1,34 +1,8 @@
import { useAtomValue } from "jotai";
-import React from "react";
import { rankingAtom } from "../../states/watch";
import type { SupportedLanguage } from "../../types/SupportedLanguage";
import CodePopover from "./CodePopover";
-
-function TableHeaderCell({ children }: { children: React.ReactNode }) {
- return (
- <th scope="col" className="px-6 py-3 text-left font-medium text-gray-800">
- {children}
- </th>
- );
-}
-
-function TableBodyCell({ children }: { children: React.ReactNode }) {
- return (
- <td className="px-6 py-4 whitespace-nowrap text-gray-900">{children}</td>
- );
-}
-
-function formatUnixTimestamp(timestamp: number) {
- const date = new Date(timestamp * 1000);
-
- const year = date.getFullYear();
- const month = (date.getMonth() + 1).toString().padStart(2, "0");
- const day = date.getDate().toString().padStart(2, "0");
- const hours = date.getHours().toString().padStart(2, "0");
- const minutes = date.getMinutes().toString().padStart(2, "0");
-
- return `${year}-${month}-${day} ${hours}:${minutes}`;
-}
+import DataTable, { DataTableCell, formatUnixTimestamp } from "./DataTable";
type Props = {
problemLanguage: SupportedLanguage;
@@ -38,38 +12,25 @@ export default function RankingTable({ problemLanguage }: Props) {
const ranking = useAtomValue(rankingAtom);
return (
- <div className="overflow-x-auto border-2 border-brand-600 rounded-xl">
- <table className="min-w-full divide-y divide-gray-400 border-collapse">
- <thead className="bg-gray-50">
- <tr>
- <TableHeaderCell>順位</TableHeaderCell>
- <TableHeaderCell>プレイヤー</TableHeaderCell>
- <TableHeaderCell>スコア</TableHeaderCell>
- <TableHeaderCell>提出時刻</TableHeaderCell>
- <TableHeaderCell>コード</TableHeaderCell>
- </tr>
- </thead>
- <tbody className="bg-white divide-y divide-gray-300">
- {ranking.map((entry, index) => (
- <tr key={entry.player.user_id}>
- <TableBodyCell>{index + 1}</TableBodyCell>
- <TableBodyCell>
- {entry.player.display_name}
- {entry.player.label && ` (${entry.player.label})`}
- </TableBodyCell>
- <TableBodyCell>{entry.score}</TableBodyCell>
- <TableBodyCell>
- {formatUnixTimestamp(entry.submitted_at)}
- </TableBodyCell>
- <TableBodyCell>
- {entry.code && (
- <CodePopover code={entry.code} language={problemLanguage} />
- )}
- </TableBodyCell>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
+ <DataTable headers={["順位", "プレイヤー", "スコア", "提出時刻", "コード"]}>
+ {ranking.map((entry, index) => (
+ <tr key={entry.player.user_id}>
+ <DataTableCell>{index + 1}</DataTableCell>
+ <DataTableCell>
+ {entry.player.display_name}
+ {entry.player.label && ` (${entry.player.label})`}
+ </DataTableCell>
+ <DataTableCell>{entry.score}</DataTableCell>
+ <DataTableCell>
+ {formatUnixTimestamp(entry.submitted_at)}
+ </DataTableCell>
+ <DataTableCell>
+ {entry.code && (
+ <CodePopover code={entry.code} language={problemLanguage} />
+ )}
+ </DataTableCell>
+ </tr>
+ ))}
+ </DataTable>
);
}
diff --git a/frontend/app/components/GolfPlayApp.tsx b/frontend/app/components/GolfPlayApp.tsx
index 1c1e7ae..5d00239 100644
--- a/frontend/app/components/GolfPlayApp.tsx
+++ b/frontend/app/components/GolfPlayApp.tsx
@@ -1,6 +1,6 @@
import { useAtomValue, useSetAtom } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
-import { useContext, useEffect, useState } from "react";
+import { useCallback, useContext, useEffect, useState } from "react";
import { useTimer } from "react-use-precision-timer";
import { useDebouncedCallback } from "use-debounce";
import { ApiClientContext } from "../api/client";
@@ -21,6 +21,7 @@ import GolfPlayAppWaiting from "./GolfPlayApps/GolfPlayAppWaiting";
type Game = components["schemas"]["Game"];
type User = components["schemas"]["User"];
+type Submission = components["schemas"]["Submission"];
type LatestGameState = components["schemas"]["LatestGameState"];
type Props = {
@@ -75,8 +76,26 @@ export default function GolfPlayApp({ game, player, initialGameState }: Props) {
{ leading: true },
);
+ const [submissions, setSubmissions] = useState<Submission[]>([]);
const [isDataPolling, setIsDataPolling] = useState(false);
+ const fetchSubmissions = useCallback(async () => {
+ try {
+ const { submissions } = await apiClient.getGamePlaySubmissions(
+ game.game_id,
+ );
+ setSubmissions(submissions);
+ } catch (error) {
+ console.error(error);
+ }
+ }, [apiClient, game.game_id]);
+
+ useEffect(() => {
+ if (gameStateKind === "finished") {
+ fetchSubmissions();
+ }
+ }, [gameStateKind, fetchSubmissions]);
+
useEffect(() => {
if (isDataPolling) {
return;
@@ -98,6 +117,7 @@ export default function GolfPlayApp({ game, player, initialGameState }: Props) {
game.game_id,
);
setLatestGameState(state);
+ await fetchSubmissions();
}
} catch (error) {
console.error(error);
@@ -116,6 +136,7 @@ export default function GolfPlayApp({ game, player, initialGameState }: Props) {
gameStateKind,
setGameStartedAt,
setLatestGameState,
+ fetchSubmissions,
]);
if (gameStateKind === "loading") {
@@ -134,6 +155,7 @@ export default function GolfPlayApp({ game, player, initialGameState }: Props) {
onCodeChange={onCodeChange}
onCodeSubmit={onCodeSubmit}
isFinished={false}
+ submissions={submissions}
/>
);
}
@@ -158,6 +180,7 @@ export default function GolfPlayApp({ game, player, initialGameState }: Props) {
onCodeChange={onCodeChange}
onCodeSubmit={onCodeSubmit}
isFinished={gameStateKind === "finished"}
+ submissions={submissions}
/>
);
}
diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.test.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.test.tsx
new file mode 100644
index 0000000..2d51d66
--- /dev/null
+++ b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.test.tsx
@@ -0,0 +1,107 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { cleanup, render, screen } from "@testing-library/react";
+import { createStore, Provider } from "jotai";
+import { afterEach, describe, expect, test } from "vitest";
+import {
+ setCurrentTimestampAtom,
+ setDurationSecondsAtom,
+ setGameStartedAtAtom,
+ setLatestGameStateAtom,
+} from "../../states/play";
+import GolfPlayAppGaming from "./GolfPlayAppGaming";
+
+afterEach(() => {
+ cleanup();
+});
+
+function createTestStore() {
+ const store = createStore();
+ const now = Math.floor(Date.now() / 1000);
+ store.set(setCurrentTimestampAtom);
+ store.set(setDurationSecondsAtom, 600);
+ store.set(setGameStartedAtAtom, now - 60);
+ store.set(setLatestGameStateAtom, {
+ status: "none",
+ code: "",
+ score: null,
+ best_score_submitted_at: null,
+ });
+ return store;
+}
+
+const defaultProps = {
+ gameDisplayName: "Test Game",
+ playerProfile: {
+ id: 1,
+ displayName: "Test Player",
+ iconPath: null,
+ },
+ problemTitle: "Test Problem",
+ problemDescription: "Description",
+ problemLanguage: "php" as const,
+ sampleCode: "<?php echo 1;",
+ initialCode: "",
+ onCodeChange: () => {},
+ onCodeSubmit: () => {},
+ isFinished: false,
+};
+
+describe("GolfPlayAppGaming submission history", () => {
+ test("shows placeholder row when no submissions", () => {
+ const store = createTestStore();
+ render(
+ <Provider store={store}>
+ <GolfPlayAppGaming {...defaultProps} submissions={[]} />
+ </Provider>,
+ );
+ expect(screen.getByText("提出待ち")).toBeDefined();
+ const dashes = screen.getAllByText("-");
+ expect(dashes.length).toBe(3);
+ });
+
+ test("renders submission rows with status and code size", () => {
+ const store = createTestStore();
+ const submissions = [
+ {
+ submission_id: 1,
+ game_id: 1,
+ status: "success" as const,
+ code: "<?php echo 1;",
+ code_size: 7,
+ created_at: 1740000000,
+ },
+ {
+ submission_id: 2,
+ game_id: 1,
+ status: "wrong_answer" as const,
+ code: "<?php echo 2;",
+ code_size: 10,
+ created_at: 1740000060,
+ },
+ ];
+ render(
+ <Provider store={store}>
+ <GolfPlayAppGaming {...defaultProps} submissions={submissions} />
+ </Provider>,
+ );
+ expect(screen.getByText("成功")).toBeDefined();
+ expect(screen.getByText("テスト失敗")).toBeDefined();
+ expect(screen.getByText("7")).toBeDefined();
+ expect(screen.getByText("10")).toBeDefined();
+ });
+
+ test("renders table headers", () => {
+ const store = createTestStore();
+ render(
+ <Provider store={store}>
+ <GolfPlayAppGaming {...defaultProps} submissions={[]} />
+ </Provider>,
+ );
+ expect(screen.getByText("ステータス")).toBeDefined();
+ expect(screen.getByText("スコア")).toBeDefined();
+ expect(screen.getByText("提出時刻")).toBeDefined();
+ expect(screen.getByText("コード")).toBeDefined();
+ });
+});
diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx
index fa9a2b4..e590df0 100644
--- a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx
+++ b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx
@@ -1,6 +1,7 @@
import { useAtomValue } from "jotai";
import React, { useRef, useState } from "react";
import { Link } from "wouter";
+import type { components } from "../../api/schema";
import {
calcCodeSize,
gamingLeftTimeSecondsAtom,
@@ -10,6 +11,11 @@ import {
import type { PlayerProfile } from "../../types/PlayerProfile";
import type { SupportedLanguage } from "../../types/SupportedLanguage";
import BorderedContainer from "../BorderedContainer";
+import CodePopover from "../Gaming/CodePopover";
+import DataTable, {
+ DataTableCell,
+ formatUnixTimestamp,
+} from "../Gaming/DataTable";
import LeftTime from "../Gaming/LeftTime";
import ProblemColumn from "../Gaming/ProblemColumn";
import SubmitButton from "../SubmitButton";
@@ -18,6 +24,8 @@ import ThreeColumnLayout from "../ThreeColumnLayout";
import TitledColumn from "../TitledColumn";
import UserIcon from "../UserIcon";
+type Submission = components["schemas"]["Submission"];
+
type Props = {
gameDisplayName: string;
playerProfile: PlayerProfile;
@@ -29,6 +37,7 @@ type Props = {
onCodeChange: (code: string) => void;
onCodeSubmit: (code: string) => void;
isFinished: boolean;
+ submissions: Submission[];
};
export default function GolfPlayAppGaming({
@@ -42,6 +51,7 @@ export default function GolfPlayAppGaming({
onCodeChange,
onCodeSubmit,
isFinished,
+ submissions,
}: Props) {
const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom);
const score = useAtomValue(scoreAtom);
@@ -124,33 +134,33 @@ export default function GolfPlayAppGaming({
</BorderedContainer>
</TitledColumn>
<TitledColumn title="提出結果">
- <div className="overflow-hidden border-2 border-brand-600 rounded-xl">
- <table className="min-w-full divide-y divide-gray-400 border-collapse">
- <thead className="bg-gray-50">
- <tr>
- <th
- scope="col"
- className="px-6 py-3 text-left font-medium text-gray-800"
- >
- ステータス
- </th>
+ <DataTable headers={["ステータス", "スコア", "提出時刻", "コード"]}>
+ {submissions.length === 0 ? (
+ <tr>
+ <DataTableCell>
+ <SubmitStatusLabel status={status} />
+ </DataTableCell>
+ <DataTableCell>-</DataTableCell>
+ <DataTableCell>-</DataTableCell>
+ <DataTableCell>-</DataTableCell>
+ </tr>
+ ) : (
+ submissions.map((s) => (
+ <tr key={s.submission_id}>
+ <DataTableCell>
+ <SubmitStatusLabel status={s.status} />
+ </DataTableCell>
+ <DataTableCell>{s.code_size}</DataTableCell>
+ <DataTableCell>
+ {formatUnixTimestamp(s.created_at)}
+ </DataTableCell>
+ <DataTableCell>
+ <CodePopover code={s.code} language={problemLanguage} />
+ </DataTableCell>
</tr>
- </thead>
- <tbody className="bg-white divide-y divide-gray-300">
- {[status].map((status) => (
- <tr key={99999}>
- <td className="px-6 py-4 whitespace-nowrap text-gray-900">
- <SubmitStatusLabel status={status} />
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- <p>
- NOTE:
- 過去の提出結果を閲覧する機能は現在実装中です。それまでは提出コードをお手元に保管しておいてください。
- </p>
+ ))
+ )}
+ </DataTable>
</TitledColumn>
</ThreeColumnLayout>
</div>