diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-03-01 12:21:21 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-03-01 12:21:21 +0900 |
| commit | 7f29d334f26229753e68d20a5aaab33c39de9f06 (patch) | |
| tree | 483f02d06fe8562113a7085c38c6ad36e8fd7452 /frontend/app/components/Gaming | |
| parent | 94f940cddc9ca39d484996616f9f4b322c1ff7f8 (diff) | |
| download | phperkaigi-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/app/components/Gaming')
| -rw-r--r-- | frontend/app/components/Gaming/DataTable.test.tsx | 70 | ||||
| -rw-r--r-- | frontend/app/components/Gaming/DataTable.tsx | 45 | ||||
| -rw-r--r-- | frontend/app/components/Gaming/RankingTable.tsx | 81 |
3 files changed, 136 insertions, 60 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> ); } |
