aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend/app/components/Gaming
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/app/components/Gaming')
-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
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>
);
}