aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-03-06 02:18:40 +0900
committernsfisis <nsfisis@gmail.com>2026-03-06 02:18:40 +0900
commit46f9ba5d8c295454381655e6ec02ad3cf8bd79db (patch)
treec54719cb129ee05f96c4898219588062f71daa36 /frontend
parent27f509ccf4fbfeaa1bc2580ae2251461dc44ebfa (diff)
downloadphperkaigi-2026-albatross-46f9ba5d8c295454381655e6ec02ad3cf8bd79db.tar.gz
phperkaigi-2026-albatross-46f9ba5d8c295454381655e6ec02ad3cf8bd79db.tar.zst
phperkaigi-2026-albatross-46f9ba5d8c295454381655e6ec02ad3cf8bd79db.zip
style: switch from tab to space indentation in frontend and worker/php
Update biome.json indentStyle from "tab" to "space" and reformat all files in both workspaces. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'frontend')
-rw-r--r--frontend/app/App.tsx88
-rw-r--r--frontend/app/api/client.ts212
-rw-r--r--frontend/app/components/BorderedContainer.test.tsx38
-rw-r--r--frontend/app/components/BorderedContainer.tsx18
-rw-r--r--frontend/app/components/BorderedContainerWithCaption.test.tsx54
-rw-r--r--frontend/app/components/BorderedContainerWithCaption.tsx24
-rw-r--r--frontend/app/components/FoldableBorderedContainerWithCaption.test.tsx82
-rw-r--r--frontend/app/components/FoldableBorderedContainerWithCaption.tsx64
-rw-r--r--frontend/app/components/Gaming/CodeBlock.tsx94
-rw-r--r--frontend/app/components/Gaming/CodePopover.tsx62
-rw-r--r--frontend/app/components/Gaming/DataTable.test.tsx104
-rw-r--r--frontend/app/components/Gaming/DataTable.tsx64
-rw-r--r--frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx72
-rw-r--r--frontend/app/components/Gaming/InlineCode.tsx12
-rw-r--r--frontend/app/components/Gaming/LeftTime.test.tsx58
-rw-r--r--frontend/app/components/Gaming/LeftTime.tsx38
-rw-r--r--frontend/app/components/Gaming/ProblemColumn.tsx34
-rw-r--r--frontend/app/components/Gaming/ProblemColumnContent.tsx146
-rw-r--r--frontend/app/components/Gaming/RankingTable.tsx70
-rw-r--r--frontend/app/components/Gaming/Score.tsx48
-rw-r--r--frontend/app/components/Gaming/ScoreBar.tsx48
-rw-r--r--frontend/app/components/GolfPlayApp.tsx300
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppGaming.test.tsx168
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx272
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppLoading.tsx14
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx24
-rw-r--r--frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx28
-rw-r--r--frontend/app/components/GolfWatchApp.tsx236
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppGaming1v1.tsx264
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppGamingMultiplayer.tsx68
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppLoading.tsx14
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx24
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppWaiting1v1.tsx52
-rw-r--r--frontend/app/components/GolfWatchApps/GolfWatchAppWaitingMultiplayer.tsx18
-rw-r--r--frontend/app/components/InputText.test.tsx42
-rw-r--r--frontend/app/components/InputText.tsx12
-rw-r--r--frontend/app/components/NavigateLink.tsx24
-rw-r--r--frontend/app/components/PlayerNameAndIcon.test.tsx50
-rw-r--r--frontend/app/components/PlayerNameAndIcon.tsx26
-rw-r--r--frontend/app/components/ProtectedRoute.tsx20
-rw-r--r--frontend/app/components/PublicOnlyRoute.tsx20
-rw-r--r--frontend/app/components/SubmitButton.test.tsx50
-rw-r--r--frontend/app/components/SubmitButton.tsx12
-rw-r--r--frontend/app/components/SubmitStatusLabel.test.tsx66
-rw-r--r--frontend/app/components/SubmitStatusLabel.tsx38
-rw-r--r--frontend/app/components/ThreeColumnLayout.tsx12
-rw-r--r--frontend/app/components/TitledColumn.tsx18
-rw-r--r--frontend/app/components/TwoColumnLayout.tsx12
-rw-r--r--frontend/app/components/UserIcon.test.tsx94
-rw-r--r--frontend/app/components/UserIcon.tsx28
-rw-r--r--frontend/app/config.test.ts18
-rw-r--r--frontend/app/highlight.ts36
-rw-r--r--frontend/app/hooks/useAuth.ts44
-rw-r--r--frontend/app/hooks/usePageTitle.test.ts24
-rw-r--r--frontend/app/hooks/usePageTitle.ts6
-rw-r--r--frontend/app/main.tsx8
-rw-r--r--frontend/app/pages/DashboardPage.tsx202
-rw-r--r--frontend/app/pages/GolfPlayPage.tsx94
-rw-r--r--frontend/app/pages/GolfProblemPreviewPage.tsx90
-rw-r--r--frontend/app/pages/GolfWatchPage.tsx116
-rw-r--r--frontend/app/pages/IndexPage.tsx62
-rw-r--r--frontend/app/pages/LoginPage.tsx166
-rw-r--r--frontend/app/pages/TournamentPage.test.tsx78
-rw-r--r--frontend/app/pages/TournamentPage.tsx510
-rw-r--r--frontend/app/shiki.css20
-rw-r--r--frontend/app/states/play.test.ts318
-rw-r--r--frontend/app/states/play.ts146
-rw-r--r--frontend/app/states/watch.test.ts350
-rw-r--r--frontend/app/states/watch.ts178
-rw-r--r--frontend/app/tailwind.css22
-rw-r--r--frontend/app/types/PlayerProfile.ts6
-rw-r--r--frontend/biome.json82
-rw-r--r--frontend/eslint.config.js46
-rw-r--r--frontend/package.json118
-rw-r--r--frontend/postcss.config.js6
-rw-r--r--frontend/tsconfig.json44
-rw-r--r--frontend/vite.config.ts4
-rw-r--r--frontend/vitest.config.ts8
78 files changed, 3119 insertions, 3119 deletions
diff --git a/frontend/app/App.tsx b/frontend/app/App.tsx
index 762dab6..2b095ab 100644
--- a/frontend/app/App.tsx
+++ b/frontend/app/App.tsx
@@ -11,48 +11,48 @@ import LoginPage from "./pages/LoginPage";
import TournamentPage from "./pages/TournamentPage";
export default function App() {
- return (
- <Router base={BASE_PATH.replace(/\/$/, "")}>
- <Switch>
- <Route path="/">
- <PublicOnlyRoute>
- <IndexPage />
- </PublicOnlyRoute>
- </Route>
- <Route path="/login">
- <PublicOnlyRoute>
- <LoginPage />
- </PublicOnlyRoute>
- </Route>
- <Route path="/dashboard">
- <DashboardPage />
- </Route>
- <Route path="/golf/:gameId/preview">
- {(params) => (
- <ProtectedRoute>
- <GolfProblemPreviewPage gameId={params.gameId} />
- </ProtectedRoute>
- )}
- </Route>
- <Route path="/golf/:gameId/play">
- {(params) => (
- <ProtectedRoute>
- <GolfPlayPage gameId={params.gameId} />
- </ProtectedRoute>
- )}
- </Route>
- <Route path="/golf/:gameId/watch">
- {(params) => <GolfWatchPage gameId={params.gameId} />}
- </Route>
- <Route path="/tournament/:tournamentId">
- {(params) => <TournamentPage tournamentId={params.tournamentId} />}
- </Route>
- <Route>
- <div className="min-h-screen bg-gray-100 flex items-center justify-center">
- <p className="text-gray-500 text-xl">404 - Page not found</p>
- </div>
- </Route>
- </Switch>
- </Router>
- );
+ return (
+ <Router base={BASE_PATH.replace(/\/$/, "")}>
+ <Switch>
+ <Route path="/">
+ <PublicOnlyRoute>
+ <IndexPage />
+ </PublicOnlyRoute>
+ </Route>
+ <Route path="/login">
+ <PublicOnlyRoute>
+ <LoginPage />
+ </PublicOnlyRoute>
+ </Route>
+ <Route path="/dashboard">
+ <DashboardPage />
+ </Route>
+ <Route path="/golf/:gameId/preview">
+ {(params) => (
+ <ProtectedRoute>
+ <GolfProblemPreviewPage gameId={params.gameId} />
+ </ProtectedRoute>
+ )}
+ </Route>
+ <Route path="/golf/:gameId/play">
+ {(params) => (
+ <ProtectedRoute>
+ <GolfPlayPage gameId={params.gameId} />
+ </ProtectedRoute>
+ )}
+ </Route>
+ <Route path="/golf/:gameId/watch">
+ {(params) => <GolfWatchPage gameId={params.gameId} />}
+ </Route>
+ <Route path="/tournament/:tournamentId">
+ {(params) => <TournamentPage tournamentId={params.tournamentId} />}
+ </Route>
+ <Route>
+ <div className="min-h-screen bg-gray-100 flex items-center justify-center">
+ <p className="text-gray-500 text-xl">404 - Page not found</p>
+ </div>
+ </Route>
+ </Switch>
+ </Router>
+ );
}
diff --git a/frontend/app/api/client.ts b/frontend/app/api/client.ts
index acc1ee9..9835e62 100644
--- a/frontend/app/api/client.ts
+++ b/frontend/app/api/client.ts
@@ -4,140 +4,140 @@ import { API_BASE_PATH } from "../config";
import type { paths } from "./schema";
const apiOrigin =
- import.meta.env.VITE_API_BASE_URL ??
- (import.meta.env.DEV ? "http://localhost:8007" : "");
+ import.meta.env.VITE_API_BASE_URL ??
+ (import.meta.env.DEV ? "http://localhost:8007" : "");
const client = createClient<paths>({
- baseUrl: `${apiOrigin}${API_BASE_PATH}`,
- credentials: "include",
+ baseUrl: `${apiOrigin}${API_BASE_PATH}`,
+ credentials: "include",
});
export async function apiLogin(username: string, password: string) {
- const { data, error } = await client.POST("/login", {
- body: {
- username,
- password,
- },
- });
- if (error) throw new Error(error.message);
- return data;
+ const { data, error } = await client.POST("/login", {
+ body: {
+ username,
+ password,
+ },
+ });
+ if (error) throw new Error(error.message);
+ return data;
}
export async function apiLogout() {
- const { error } = await client.POST("/logout");
- if (error) throw new Error(error.message);
+ const { error } = await client.POST("/logout");
+ if (error) throw new Error(error.message);
}
export async function apiGetMe() {
- const { data, error } = await client.GET("/me");
- if (error) return null;
- return data;
+ const { data, error } = await client.GET("/me");
+ if (error) return null;
+ return data;
}
class AuthenticatedApiClient {
- async getGames() {
- const { data, error } = await client.GET("/games");
- if (error) throw new Error(error.message);
- return data;
- }
+ async getGames() {
+ const { data, error } = await client.GET("/games");
+ if (error) throw new Error(error.message);
+ return data;
+ }
- async getGame(gameId: number) {
- const { data, error } = await client.GET("/games/{game_id}", {
- params: {
- path: { game_id: gameId },
- },
- });
- if (error) throw new Error(error.message);
- return data;
- }
+ async getGame(gameId: number) {
+ const { data, error } = await client.GET("/games/{game_id}", {
+ params: {
+ path: { game_id: gameId },
+ },
+ });
+ if (error) throw new Error(error.message);
+ return data;
+ }
- async getGamePlayLatestState(gameId: number) {
- const { data, error } = await client.GET(
- "/games/{game_id}/play/latest_state",
- {
- params: {
- path: { game_id: gameId },
- },
- },
- );
- if (error) throw new Error(error.message);
- return data;
- }
+ async getGamePlayLatestState(gameId: number) {
+ const { data, error } = await client.GET(
+ "/games/{game_id}/play/latest_state",
+ {
+ params: {
+ path: { game_id: gameId },
+ },
+ },
+ );
+ if (error) throw new Error(error.message);
+ return data;
+ }
- async postGamePlayCode(gameId: number, code: string) {
- const { error } = await client.POST("/games/{game_id}/play/code", {
- params: {
- path: { game_id: gameId },
- },
- body: { code },
- });
- if (error) throw new Error(error.message);
- }
+ async postGamePlayCode(gameId: number, code: string) {
+ const { error } = await client.POST("/games/{game_id}/play/code", {
+ params: {
+ path: { game_id: gameId },
+ },
+ body: { code },
+ });
+ if (error) throw new Error(error.message);
+ }
- async postGamePlaySubmit(gameId: number, code: string) {
- const { data, error } = await client.POST("/games/{game_id}/play/submit", {
- params: {
- path: { game_id: gameId },
- },
- body: { code },
- });
- if (error) throw new Error(error.message);
- return data;
- }
+ async postGamePlaySubmit(gameId: number, code: string) {
+ const { data, error } = await client.POST("/games/{game_id}/play/submit", {
+ params: {
+ path: { game_id: gameId },
+ },
+ body: { code },
+ });
+ if (error) throw new Error(error.message);
+ return data;
+ }
- async getGamePlaySubmissions(gameId: number) {
- const { data, error } = await client.GET(
- "/games/{game_id}/play/submissions",
- {
- params: {
- path: { game_id: gameId },
- },
- },
- );
- if (error) throw new Error(error.message);
- return data;
- }
+ async getGamePlaySubmissions(gameId: number) {
+ const { data, error } = await client.GET(
+ "/games/{game_id}/play/submissions",
+ {
+ params: {
+ path: { game_id: gameId },
+ },
+ },
+ );
+ if (error) throw new Error(error.message);
+ return data;
+ }
- async getGameWatchRanking(gameId: number) {
- const { data, error } = await client.GET("/games/{game_id}/watch/ranking", {
- params: {
- path: { game_id: gameId },
- },
- });
- if (error) throw new Error(error.message);
- return data;
- }
+ async getGameWatchRanking(gameId: number) {
+ const { data, error } = await client.GET("/games/{game_id}/watch/ranking", {
+ params: {
+ path: { game_id: gameId },
+ },
+ });
+ if (error) throw new Error(error.message);
+ return data;
+ }
- async getGameWatchLatestStates(gameId: number) {
- const { data, error } = await client.GET(
- "/games/{game_id}/watch/latest_states",
- {
- params: {
- path: { game_id: gameId },
- },
- },
- );
- if (error) throw new Error(error.message);
- return data;
- }
+ async getGameWatchLatestStates(gameId: number) {
+ const { data, error } = await client.GET(
+ "/games/{game_id}/watch/latest_states",
+ {
+ params: {
+ path: { game_id: gameId },
+ },
+ },
+ );
+ if (error) throw new Error(error.message);
+ return data;
+ }
- async getTournament(tournamentId: number) {
- const { data, error } = await client.GET("/tournaments/{tournament_id}", {
- params: {
- path: { tournament_id: tournamentId },
- },
- });
- if (error) throw new Error(error.message);
- return data;
- }
+ async getTournament(tournamentId: number) {
+ const { data, error } = await client.GET("/tournaments/{tournament_id}", {
+ params: {
+ path: { tournament_id: tournamentId },
+ },
+ });
+ if (error) throw new Error(error.message);
+ return data;
+ }
}
const apiClient = new AuthenticatedApiClient();
export function createApiClient() {
- return apiClient;
+ return apiClient;
}
export const ApiClientContext = createContext<AuthenticatedApiClient | null>(
- null,
+ null,
);
diff --git a/frontend/app/components/BorderedContainer.test.tsx b/frontend/app/components/BorderedContainer.test.tsx
index 22a0aec..2f60499 100644
--- a/frontend/app/components/BorderedContainer.test.tsx
+++ b/frontend/app/components/BorderedContainer.test.tsx
@@ -6,28 +6,28 @@ import { afterEach, describe, expect, test } from "vitest";
import BorderedContainer from "./BorderedContainer";
afterEach(() => {
- cleanup();
+ cleanup();
});
describe("BorderedContainer", () => {
- test("renders children", () => {
- render(<BorderedContainer>Hello World</BorderedContainer>);
- expect(screen.getByText("Hello World")).toBeDefined();
- });
+ test("renders children", () => {
+ render(<BorderedContainer>Hello World</BorderedContainer>);
+ expect(screen.getByText("Hello World")).toBeDefined();
+ });
- test("applies custom className", () => {
- render(
- <BorderedContainer className="custom-class">Content</BorderedContainer>,
- );
- const container = screen.getByText("Content").closest("div");
- expect(container?.className).toContain("custom-class");
- });
+ test("applies custom className", () => {
+ render(
+ <BorderedContainer className="custom-class">Content</BorderedContainer>,
+ );
+ const container = screen.getByText("Content").closest("div");
+ expect(container?.className).toContain("custom-class");
+ });
- test("has default border styling", () => {
- render(<BorderedContainer>Styled</BorderedContainer>);
- const container = screen.getByText("Styled").closest("div");
- expect(container?.className).toContain("border-2");
- expect(container?.className).toContain("border-brand-600");
- expect(container?.className).toContain("rounded-xl");
- });
+ test("has default border styling", () => {
+ render(<BorderedContainer>Styled</BorderedContainer>);
+ const container = screen.getByText("Styled").closest("div");
+ expect(container?.className).toContain("border-2");
+ expect(container?.className).toContain("border-brand-600");
+ expect(container?.className).toContain("rounded-xl");
+ });
});
diff --git a/frontend/app/components/BorderedContainer.tsx b/frontend/app/components/BorderedContainer.tsx
index a1bff35..ec6c00e 100644
--- a/frontend/app/components/BorderedContainer.tsx
+++ b/frontend/app/components/BorderedContainer.tsx
@@ -1,16 +1,16 @@
import React from "react";
type Props = {
- children: React.ReactNode;
- className?: string;
+ children: React.ReactNode;
+ className?: string;
};
export default function BorderedContainer({ children, className }: Props) {
- return (
- <div
- className={`bg-white border-2 border-brand-600 rounded-xl p-4 ${className}`}
- >
- {children}
- </div>
- );
+ return (
+ <div
+ className={`bg-white border-2 border-brand-600 rounded-xl p-4 ${className}`}
+ >
+ {children}
+ </div>
+ );
}
diff --git a/frontend/app/components/BorderedContainerWithCaption.test.tsx b/frontend/app/components/BorderedContainerWithCaption.test.tsx
index 621dcb3..da6246b 100644
--- a/frontend/app/components/BorderedContainerWithCaption.test.tsx
+++ b/frontend/app/components/BorderedContainerWithCaption.test.tsx
@@ -6,36 +6,36 @@ import { afterEach, describe, expect, test } from "vitest";
import BorderedContainerWithCaption from "./BorderedContainerWithCaption";
afterEach(() => {
- cleanup();
+ cleanup();
});
describe("BorderedContainerWithCaption", () => {
- test("renders caption as heading", () => {
- render(
- <BorderedContainerWithCaption caption="Test Caption">
- Content
- </BorderedContainerWithCaption>,
- );
- expect(screen.getByText("Test Caption")).toBeDefined();
- expect(screen.getByText("Test Caption").tagName).toBe("H2");
- });
+ test("renders caption as heading", () => {
+ render(
+ <BorderedContainerWithCaption caption="Test Caption">
+ Content
+ </BorderedContainerWithCaption>,
+ );
+ expect(screen.getByText("Test Caption")).toBeDefined();
+ expect(screen.getByText("Test Caption").tagName).toBe("H2");
+ });
- test("renders children", () => {
- render(
- <BorderedContainerWithCaption caption="Title">
- Child Content
- </BorderedContainerWithCaption>,
- );
- expect(screen.getByText("Child Content")).toBeDefined();
- });
+ test("renders children", () => {
+ render(
+ <BorderedContainerWithCaption caption="Title">
+ Child Content
+ </BorderedContainerWithCaption>,
+ );
+ expect(screen.getByText("Child Content")).toBeDefined();
+ });
- test("wraps in bordered container with blue border", () => {
- render(
- <BorderedContainerWithCaption caption="Title">
- Content
- </BorderedContainerWithCaption>,
- );
- const container = screen.getByText("Content").closest(".border-2");
- expect(container).not.toBeNull();
- });
+ test("wraps in bordered container with blue border", () => {
+ render(
+ <BorderedContainerWithCaption caption="Title">
+ Content
+ </BorderedContainerWithCaption>,
+ );
+ const container = screen.getByText("Content").closest(".border-2");
+ expect(container).not.toBeNull();
+ });
});
diff --git a/frontend/app/components/BorderedContainerWithCaption.tsx b/frontend/app/components/BorderedContainerWithCaption.tsx
index 5446ddc..4cee1c9 100644
--- a/frontend/app/components/BorderedContainerWithCaption.tsx
+++ b/frontend/app/components/BorderedContainerWithCaption.tsx
@@ -2,20 +2,20 @@ import React from "react";
import BorderedContainer from "./BorderedContainer";
type Props = {
- caption: string;
- children: React.ReactNode;
+ caption: string;
+ children: React.ReactNode;
};
export default function BorderedContainerWithCaption({
- caption,
- children,
+ caption,
+ children,
}: Props) {
- return (
- <BorderedContainer>
- <div className="flex flex-col gap-4">
- <h2 className="text-center text-lg font-semibold">{caption}</h2>
- {children}
- </div>
- </BorderedContainer>
- );
+ return (
+ <BorderedContainer>
+ <div className="flex flex-col gap-4">
+ <h2 className="text-center text-lg font-semibold">{caption}</h2>
+ {children}
+ </div>
+ </BorderedContainer>
+ );
}
diff --git a/frontend/app/components/FoldableBorderedContainerWithCaption.test.tsx b/frontend/app/components/FoldableBorderedContainerWithCaption.test.tsx
index a4434ff..bfbb496 100644
--- a/frontend/app/components/FoldableBorderedContainerWithCaption.test.tsx
+++ b/frontend/app/components/FoldableBorderedContainerWithCaption.test.tsx
@@ -6,51 +6,51 @@ import { afterEach, describe, expect, test } from "vitest";
import FoldableBorderedContainerWithCaption from "./FoldableBorderedContainerWithCaption";
afterEach(() => {
- cleanup();
+ cleanup();
});
describe("FoldableBorderedContainerWithCaption", () => {
- test("renders caption", () => {
- render(
- <FoldableBorderedContainerWithCaption caption="Foldable Title">
- Content
- </FoldableBorderedContainerWithCaption>,
- );
- expect(screen.getByText("Foldable Title")).toBeDefined();
- });
+ test("renders caption", () => {
+ render(
+ <FoldableBorderedContainerWithCaption caption="Foldable Title">
+ Content
+ </FoldableBorderedContainerWithCaption>,
+ );
+ expect(screen.getByText("Foldable Title")).toBeDefined();
+ });
- test("shows children by default (open state)", () => {
- render(
- <FoldableBorderedContainerWithCaption caption="Title">
- <div data-testid="child">Visible</div>
- </FoldableBorderedContainerWithCaption>,
- );
- const child = screen.getByTestId("child");
- expect(child.parentElement?.className).not.toContain("hidden");
- });
+ test("shows children by default (open state)", () => {
+ render(
+ <FoldableBorderedContainerWithCaption caption="Title">
+ <div data-testid="child">Visible</div>
+ </FoldableBorderedContainerWithCaption>,
+ );
+ const child = screen.getByTestId("child");
+ expect(child.parentElement?.className).not.toContain("hidden");
+ });
- test("hides children when toggle button is clicked", () => {
- render(
- <FoldableBorderedContainerWithCaption caption="Title">
- <div data-testid="child">Content</div>
- </FoldableBorderedContainerWithCaption>,
- );
- const toggleButton = screen.getByRole("button");
- fireEvent.click(toggleButton);
- const child = screen.getByTestId("child");
- expect(child.parentElement?.className).toContain("hidden");
- });
+ test("hides children when toggle button is clicked", () => {
+ render(
+ <FoldableBorderedContainerWithCaption caption="Title">
+ <div data-testid="child">Content</div>
+ </FoldableBorderedContainerWithCaption>,
+ );
+ const toggleButton = screen.getByRole("button");
+ fireEvent.click(toggleButton);
+ const child = screen.getByTestId("child");
+ expect(child.parentElement?.className).toContain("hidden");
+ });
- test("shows children again when toggle button is clicked twice", () => {
- render(
- <FoldableBorderedContainerWithCaption caption="Title">
- <div data-testid="child">Content</div>
- </FoldableBorderedContainerWithCaption>,
- );
- const toggleButton = screen.getByRole("button");
- fireEvent.click(toggleButton);
- fireEvent.click(toggleButton);
- const child = screen.getByTestId("child");
- expect(child.parentElement?.className).not.toContain("hidden");
- });
+ test("shows children again when toggle button is clicked twice", () => {
+ render(
+ <FoldableBorderedContainerWithCaption caption="Title">
+ <div data-testid="child">Content</div>
+ </FoldableBorderedContainerWithCaption>,
+ );
+ const toggleButton = screen.getByRole("button");
+ fireEvent.click(toggleButton);
+ fireEvent.click(toggleButton);
+ const child = screen.getByTestId("child");
+ expect(child.parentElement?.className).not.toContain("hidden");
+ });
});
diff --git a/frontend/app/components/FoldableBorderedContainerWithCaption.tsx b/frontend/app/components/FoldableBorderedContainerWithCaption.tsx
index 2d21b61..49b1d2e 100644
--- a/frontend/app/components/FoldableBorderedContainerWithCaption.tsx
+++ b/frontend/app/components/FoldableBorderedContainerWithCaption.tsx
@@ -4,42 +4,42 @@ import React, { useState } from "react";
import BorderedContainer from "./BorderedContainer";
type Props = {
- caption: string;
- children: React.ReactNode;
+ caption: string;
+ children: React.ReactNode;
};
export default function FoldableBorderedContainerWithCaption({
- caption,
- children,
+ caption,
+ children,
}: Props) {
- const [isOpen, setIsOpen] = useState(true);
+ const [isOpen, setIsOpen] = useState(true);
- const handleToggle = () => {
- setIsOpen((prev) => !prev);
- };
+ const handleToggle = () => {
+ setIsOpen((prev) => !prev);
+ };
- return (
- <BorderedContainer>
- <div className="flex flex-col gap-4">
- <div className="flex items-center">
- <div className="flex-1 text-center">
- <h2 className="text-lg font-semibold">{caption}</h2>
- </div>
- <div className="flex-shrink-0">
- <button
- onClick={handleToggle}
- className="p-1 bg-gray-50 border-1 border-gray-300 rounded-sm"
- >
- <FontAwesomeIcon
- icon={isOpen ? faChevronUp : faChevronDown}
- fixedWidth
- className="text-gray-500"
- />
- </button>
- </div>
- </div>
- <div className={isOpen ? "" : "hidden"}>{children}</div>
- </div>
- </BorderedContainer>
- );
+ return (
+ <BorderedContainer>
+ <div className="flex flex-col gap-4">
+ <div className="flex items-center">
+ <div className="flex-1 text-center">
+ <h2 className="text-lg font-semibold">{caption}</h2>
+ </div>
+ <div className="flex-shrink-0">
+ <button
+ onClick={handleToggle}
+ className="p-1 bg-gray-50 border-1 border-gray-300 rounded-sm"
+ >
+ <FontAwesomeIcon
+ icon={isOpen ? faChevronUp : faChevronDown}
+ fixedWidth
+ className="text-gray-500"
+ />
+ </button>
+ </div>
+ </div>
+ <div className={isOpen ? "" : "hidden"}>{children}</div>
+ </div>
+ </BorderedContainer>
+ );
}
diff --git a/frontend/app/components/Gaming/CodeBlock.tsx b/frontend/app/components/Gaming/CodeBlock.tsx
index 2107f94..f048a38 100644
--- a/frontend/app/components/Gaming/CodeBlock.tsx
+++ b/frontend/app/components/Gaming/CodeBlock.tsx
@@ -4,60 +4,60 @@ import { JSX, useLayoutEffect, useState } from "react";
import { type BundledLanguage, highlight } from "../../highlight";
type Props = {
- code: string;
- language: BundledLanguage;
+ code: string;
+ language: BundledLanguage;
};
function Plaintext({ code }: { code: string }) {
- const lines = code.split("\n");
- return (
- <pre>
- <code>
- {lines.map((line, i) => (
- <span key={i} className="line">
- {line}
- {i < lines.length - 1 ? "\n" : ""}
- </span>
- ))}
- </code>
- </pre>
- );
+ const lines = code.split("\n");
+ return (
+ <pre>
+ <code>
+ {lines.map((line, i) => (
+ <span key={i} className="line">
+ {line}
+ {i < lines.length - 1 ? "\n" : ""}
+ </span>
+ ))}
+ </code>
+ </pre>
+ );
}
export default function CodeBlock({ code, language }: Props) {
- const [nodes, setNodes] = useState<JSX.Element | null>(null);
- const [showCopied, setShowCopied] = useState(false);
+ const [nodes, setNodes] = useState<JSX.Element | null>(null);
+ const [showCopied, setShowCopied] = useState(false);
- useLayoutEffect(() => {
- highlight(code, language)
- .then(setNodes)
- .catch(() => setNodes(null));
- }, [code, language]);
+ useLayoutEffect(() => {
+ highlight(code, language)
+ .then(setNodes)
+ .catch(() => setNodes(null));
+ }, [code, language]);
- const handleCopy = () => {
- navigator.clipboard.writeText(code).then(() => {
- setShowCopied(true);
- setTimeout(() => setShowCopied(false), 3000);
- });
- };
+ const handleCopy = () => {
+ navigator.clipboard.writeText(code).then(() => {
+ setShowCopied(true);
+ setTimeout(() => setShowCopied(false), 3000);
+ });
+ };
- return (
- <div className="relative">
- {code !== "" && (
- <button
- onClick={handleCopy}
- className="absolute top-2 right-2 z-10 px-2 py-1 bg-white border border-gray-300 rounded shadow-md hover:bg-gray-100 transition-colors"
- title="コードをコピーする"
- >
- <FontAwesomeIcon icon={faCopy} className="text-gray-600" />
- {showCopied && (
- <span className="ml-1 text-xs text-brand-600">Copied!</span>
- )}
- </button>
- )}
- <div className="shiki h-full w-full p-2 pr-12 bg-white rounded-lg border border-gray-300 whitespace-pre-wrap break-words">
- {nodes ?? <Plaintext code={code} />}
- </div>
- </div>
- );
+ return (
+ <div className="relative">
+ {code !== "" && (
+ <button
+ onClick={handleCopy}
+ className="absolute top-2 right-2 z-10 px-2 py-1 bg-white border border-gray-300 rounded shadow-md hover:bg-gray-100 transition-colors"
+ title="コードをコピーする"
+ >
+ <FontAwesomeIcon icon={faCopy} className="text-gray-600" />
+ {showCopied && (
+ <span className="ml-1 text-xs text-brand-600">Copied!</span>
+ )}
+ </button>
+ )}
+ <div className="shiki h-full w-full p-2 pr-12 bg-white rounded-lg border border-gray-300 whitespace-pre-wrap break-words">
+ {nodes ?? <Plaintext code={code} />}
+ </div>
+ </div>
+ );
}
diff --git a/frontend/app/components/Gaming/CodePopover.tsx b/frontend/app/components/Gaming/CodePopover.tsx
index 91245df..c4065ed 100644
--- a/frontend/app/components/Gaming/CodePopover.tsx
+++ b/frontend/app/components/Gaming/CodePopover.tsx
@@ -7,39 +7,39 @@ import BorderedContainer from "../BorderedContainer";
import CodeBlock from "../Gaming/CodeBlock";
type Props = {
- code: string;
- language: SupportedLanguage;
+ code: string;
+ language: SupportedLanguage;
};
export default function CodePopover({ code, language }: Props) {
- const codeSize = calcCodeSize(code, language);
+ const codeSize = calcCodeSize(code, language);
- return (
- <Popover.Root>
- <Popover.Trigger>
- <FontAwesomeIcon icon={faCode} fixedWidth />
- </Popover.Trigger>
- <Popover.Portal>
- <Popover.Positioner>
- <Popover.Popup>
- <BorderedContainer className="grow flex flex-col gap-4">
- <div className="flex flex-row gap-2 items-center">
- <div className="grow font-semibold text-lg">
- コードサイズ: {codeSize}
- </div>
- <Popover.Close className="p-1 bg-gray-50 border-1 border-gray-300 rounded-sm">
- <FontAwesomeIcon
- icon={faXmark}
- fixedWidth
- className="text-gray-500"
- />
- </Popover.Close>
- </div>
- <CodeBlock code={code} language={language} />
- </BorderedContainer>
- </Popover.Popup>
- </Popover.Positioner>
- </Popover.Portal>
- </Popover.Root>
- );
+ return (
+ <Popover.Root>
+ <Popover.Trigger>
+ <FontAwesomeIcon icon={faCode} fixedWidth />
+ </Popover.Trigger>
+ <Popover.Portal>
+ <Popover.Positioner>
+ <Popover.Popup>
+ <BorderedContainer className="grow flex flex-col gap-4">
+ <div className="flex flex-row gap-2 items-center">
+ <div className="grow font-semibold text-lg">
+ コードサイズ: {codeSize}
+ </div>
+ <Popover.Close className="p-1 bg-gray-50 border-1 border-gray-300 rounded-sm">
+ <FontAwesomeIcon
+ icon={faXmark}
+ fixedWidth
+ className="text-gray-500"
+ />
+ </Popover.Close>
+ </div>
+ <CodeBlock code={code} language={language} />
+ </BorderedContainer>
+ </Popover.Popup>
+ </Popover.Positioner>
+ </Popover.Portal>
+ </Popover.Root>
+ );
}
diff --git a/frontend/app/components/Gaming/DataTable.test.tsx b/frontend/app/components/Gaming/DataTable.test.tsx
index 2a4446c..08c7336 100644
--- a/frontend/app/components/Gaming/DataTable.test.tsx
+++ b/frontend/app/components/Gaming/DataTable.test.tsx
@@ -6,65 +6,65 @@ import { afterEach, describe, expect, test } from "vitest";
import DataTable, { DataTableCell, formatUnixTimestamp } from "./DataTable";
afterEach(() => {
- cleanup();
+ 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 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 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();
- });
+ 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("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}$/);
- });
+ 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
index 098f4a2..f909b1e 100644
--- a/frontend/app/components/Gaming/DataTable.tsx
+++ b/frontend/app/components/Gaming/DataTable.tsx
@@ -1,45 +1,45 @@
import type React from "react";
type Props = {
- headers: React.ReactNode[];
- children: React.ReactNode;
+ 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>
- );
+ 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>
- );
+ 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}`;
+ 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/ExecStatusIndicatorIcon.tsx b/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx
index e4260ec..2bf088b 100644
--- a/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx
+++ b/frontend/app/components/Gaming/ExecStatusIndicatorIcon.tsx
@@ -1,46 +1,46 @@
import {
- faCircle,
- faCircleCheck,
- faCircleExclamation,
- faRotate,
+ faCircle,
+ faCircleCheck,
+ faCircleExclamation,
+ faRotate,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { components } from "../../api/schema";
type Props = {
- status: components["schemas"]["ExecutionStatus"];
+ status: components["schemas"]["ExecutionStatus"];
};
export default function ExecStatusIndicatorIcon({ status }: Props) {
- switch (status) {
- case "none":
- return (
- <FontAwesomeIcon icon={faCircle} fixedWidth className="text-gray-400" />
- );
- case "running":
- return (
- <FontAwesomeIcon
- icon={faRotate}
- spin
- fixedWidth
- className="text-gray-700"
- />
- );
- case "success":
- return (
- <FontAwesomeIcon
- icon={faCircleCheck}
- fixedWidth
- className="text-brand-500"
- />
- );
- default:
- return (
- <FontAwesomeIcon
- icon={faCircleExclamation}
- fixedWidth
- className="text-red-500"
- />
- );
- }
+ switch (status) {
+ case "none":
+ return (
+ <FontAwesomeIcon icon={faCircle} fixedWidth className="text-gray-400" />
+ );
+ case "running":
+ return (
+ <FontAwesomeIcon
+ icon={faRotate}
+ spin
+ fixedWidth
+ className="text-gray-700"
+ />
+ );
+ case "success":
+ return (
+ <FontAwesomeIcon
+ icon={faCircleCheck}
+ fixedWidth
+ className="text-brand-500"
+ />
+ );
+ default:
+ return (
+ <FontAwesomeIcon
+ icon={faCircleExclamation}
+ fixedWidth
+ className="text-red-500"
+ />
+ );
+ }
}
diff --git a/frontend/app/components/Gaming/InlineCode.tsx b/frontend/app/components/Gaming/InlineCode.tsx
index c90cad4..0b5e061 100644
--- a/frontend/app/components/Gaming/InlineCode.tsx
+++ b/frontend/app/components/Gaming/InlineCode.tsx
@@ -1,11 +1,11 @@
type Props = {
- code: string;
+ code: string;
};
export default function InlineCode({ code }: Props) {
- return (
- <code className="bg-gray-50 rounded-lg border border-gray-300 p-1">
- {code}
- </code>
- );
+ return (
+ <code className="bg-gray-50 rounded-lg border border-gray-300 p-1">
+ {code}
+ </code>
+ );
}
diff --git a/frontend/app/components/Gaming/LeftTime.test.tsx b/frontend/app/components/Gaming/LeftTime.test.tsx
index 742d8eb..28f2fc4 100644
--- a/frontend/app/components/Gaming/LeftTime.test.tsx
+++ b/frontend/app/components/Gaming/LeftTime.test.tsx
@@ -6,42 +6,42 @@ import { afterEach, describe, expect, test } from "vitest";
import LeftTime from "./LeftTime";
afterEach(() => {
- cleanup();
+ cleanup();
});
describe("LeftTime", () => {
- test("renders MM:SS format for short durations", () => {
- render(<LeftTime sec={65} />);
- expect(screen.getByText("01:05")).toBeDefined();
- });
+ test("renders MM:SS format for short durations", () => {
+ render(<LeftTime sec={65} />);
+ expect(screen.getByText("01:05")).toBeDefined();
+ });
- test("renders 00:00 for zero seconds", () => {
- render(<LeftTime sec={0} />);
- expect(screen.getByText("00:00")).toBeDefined();
- });
+ test("renders 00:00 for zero seconds", () => {
+ render(<LeftTime sec={0} />);
+ expect(screen.getByText("00:00")).toBeDefined();
+ });
- test("renders MM:SS with leading zeros", () => {
- render(<LeftTime sec={5} />);
- expect(screen.getByText("00:05")).toBeDefined();
- });
+ test("renders MM:SS with leading zeros", () => {
+ render(<LeftTime sec={5} />);
+ expect(screen.getByText("00:05")).toBeDefined();
+ });
- test("renders 59:59 for max MM:SS range", () => {
- render(<LeftTime sec={3599} />);
- expect(screen.getByText("59:59")).toBeDefined();
- });
+ test("renders 59:59 for max MM:SS range", () => {
+ render(<LeftTime sec={3599} />);
+ expect(screen.getByText("59:59")).toBeDefined();
+ });
- test("renders long format with hours", () => {
- render(<LeftTime sec={3661} />);
- expect(screen.getByText("1h 1m 1s")).toBeDefined();
- });
+ test("renders long format with hours", () => {
+ render(<LeftTime sec={3661} />);
+ expect(screen.getByText("1h 1m 1s")).toBeDefined();
+ });
- test("renders long format with days", () => {
- render(<LeftTime sec={90061} />);
- expect(screen.getByText("1d 1h 1m 1s")).toBeDefined();
- });
+ test("renders long format with days", () => {
+ render(<LeftTime sec={90061} />);
+ expect(screen.getByText("1d 1h 1m 1s")).toBeDefined();
+ });
- test("renders long format omitting zero day and minute", () => {
- render(<LeftTime sec={3605} />);
- expect(screen.getByText("1h 5s")).toBeDefined();
- });
+ test("renders long format omitting zero day and minute", () => {
+ render(<LeftTime sec={3605} />);
+ expect(screen.getByText("1h 5s")).toBeDefined();
+ });
});
diff --git a/frontend/app/components/Gaming/LeftTime.tsx b/frontend/app/components/Gaming/LeftTime.tsx
index 5013c76..a7678d6 100644
--- a/frontend/app/components/Gaming/LeftTime.tsx
+++ b/frontend/app/components/Gaming/LeftTime.tsx
@@ -1,26 +1,26 @@
type Props = {
- sec: number;
+ sec: number;
};
export default function LeftTime({ sec }: Props) {
- const s = sec % 60;
- const m = Math.floor(sec / 60) % 60;
- const h = Math.floor(sec / 3600) % 24;
- const d = Math.floor(sec / 86400);
+ const s = sec % 60;
+ const m = Math.floor(sec / 60) % 60;
+ const h = Math.floor(sec / 3600) % 24;
+ const d = Math.floor(sec / 86400);
- let leftTime = "";
- if (d > 0 || h > 0) {
- // 1d 2h 3m 4s
- leftTime = [
- d > 0 ? `${d}d` : "",
- h > 0 ? `${h}h` : "",
- m > 0 ? `${m}m` : "",
- `${s}s`,
- ].join(" ");
- } else {
- // 03:04
- leftTime = `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
- }
+ let leftTime = "";
+ if (d > 0 || h > 0) {
+ // 1d 2h 3m 4s
+ leftTime = [
+ d > 0 ? `${d}d` : "",
+ h > 0 ? `${h}h` : "",
+ m > 0 ? `${m}m` : "",
+ `${s}s`,
+ ].join(" ");
+ } else {
+ // 03:04
+ leftTime = `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
+ }
- return <div className="text-2xl md:text-3xl">{leftTime}</div>;
+ return <div className="text-2xl md:text-3xl">{leftTime}</div>;
}
diff --git a/frontend/app/components/Gaming/ProblemColumn.tsx b/frontend/app/components/Gaming/ProblemColumn.tsx
index a355ac4..40d0716 100644
--- a/frontend/app/components/Gaming/ProblemColumn.tsx
+++ b/frontend/app/components/Gaming/ProblemColumn.tsx
@@ -3,25 +3,25 @@ import TitledColumn from "../TitledColumn";
import ProblemColumnContent from "./ProblemColumnContent";
type Props = {
- title: string;
- description: string;
- language: SupportedLanguage;
- sampleCode: string;
+ title: string;
+ description: string;
+ language: SupportedLanguage;
+ sampleCode: string;
};
export default function ProblemColumn({
- title,
- description,
- language,
- sampleCode,
+ title,
+ description,
+ language,
+ sampleCode,
}: Props) {
- return (
- <TitledColumn title={title}>
- <ProblemColumnContent
- description={description}
- sampleCode={sampleCode}
- language={language}
- />
- </TitledColumn>
- );
+ return (
+ <TitledColumn title={title}>
+ <ProblemColumnContent
+ description={description}
+ sampleCode={sampleCode}
+ language={language}
+ />
+ </TitledColumn>
+ );
}
diff --git a/frontend/app/components/Gaming/ProblemColumnContent.tsx b/frontend/app/components/Gaming/ProblemColumnContent.tsx
index 1a7cb36..bc6b43a 100644
--- a/frontend/app/components/Gaming/ProblemColumnContent.tsx
+++ b/frontend/app/components/Gaming/ProblemColumnContent.tsx
@@ -4,87 +4,87 @@ import CodeBlock from "./CodeBlock";
import InlineCode from "./InlineCode";
function PhpNotice() {
- return (
- <FoldableBorderedContainerWithCaption caption="スコア計算・PHP 環境">
- <div className="text-gray-700 flex flex-col gap-2">
- <p>
- スコアはコード中の全 ASCII
- 空白文字を除去した後のバイト数です。また、先頭や末尾に置かれた PHP
- タグ (<InlineCode code="<?php" />、<InlineCode code="<?" />、
- <InlineCode code="?>" />) はカウントされません。
- </p>
- <p>
- 同じスコアを出した場合、より提出が早かったプレイヤーの勝ちとなります。
- </p>
- <p>
- この環境の PHP バージョンは{" "}
- <strong className="font-bold">8.5.3</strong> です。 mbstring
- を除くほとんどの拡張は無効化されています。
- また、ファイルやネットワークアクセスはできません。
- </p>
- <p>
- テストの成否は、標準出力へ出力された文字列を比較して判定されます。
- 末尾の改行はあってもなくても構いません。
- 標準エラー出力の内容は無視されますが、fatal error
- 等で実行が中断された場合は失敗扱いとなります。
- </p>
- <p>
- なお、
- <InlineCode code="error_reporting" /> は{" "}
- <InlineCode code="E_ALL &amp; ~E_WARNING &amp; ~E_NOTICE &amp; ~E_DEPRECATED" />{" "}
- に設定されています。
- </p>
- </div>
- </FoldableBorderedContainerWithCaption>
- );
+ return (
+ <FoldableBorderedContainerWithCaption caption="スコア計算・PHP 環境">
+ <div className="text-gray-700 flex flex-col gap-2">
+ <p>
+ スコアはコード中の全 ASCII
+ 空白文字を除去した後のバイト数です。また、先頭や末尾に置かれた PHP
+ タグ (<InlineCode code="<?php" />、<InlineCode code="<?" />、
+ <InlineCode code="?>" />) はカウントされません。
+ </p>
+ <p>
+ 同じスコアを出した場合、より提出が早かったプレイヤーの勝ちとなります。
+ </p>
+ <p>
+ この環境の PHP バージョンは{" "}
+ <strong className="font-bold">8.5.3</strong> です。 mbstring
+ を除くほとんどの拡張は無効化されています。
+ また、ファイルやネットワークアクセスはできません。
+ </p>
+ <p>
+ テストの成否は、標準出力へ出力された文字列を比較して判定されます。
+ 末尾の改行はあってもなくても構いません。
+ 標準エラー出力の内容は無視されますが、fatal error
+ 等で実行が中断された場合は失敗扱いとなります。
+ </p>
+ <p>
+ なお、
+ <InlineCode code="error_reporting" /> は{" "}
+ <InlineCode code="E_ALL &amp; ~E_WARNING &amp; ~E_NOTICE &amp; ~E_DEPRECATED" />{" "}
+ に設定されています。
+ </p>
+ </div>
+ </FoldableBorderedContainerWithCaption>
+ );
}
function SwiftNotice() {
- return (
- <FoldableBorderedContainerWithCaption caption="スコア計算・Swift 環境">
- <div className="text-gray-700 flex flex-col gap-2">
- <p>スコアはコード中の全 ASCII 空白文字を除去した後のバイト数です。</p>
- <p>
- 同じスコアを出した場合、より提出が早かったプレイヤーの勝ちとなります。
- </p>
- <p>
- この環境の Swift バージョンは{" "}
- <strong className="font-bold">6.1.2</strong> です。
- ファイルアクセスやネットワークアクセスはできません。
- </p>
- <p>
- テストの成否は、標準出力へ出力された文字列を比較して判定されます。
- 末尾の改行はあってもなくても構いません。
- 標準エラー出力の内容は無視されますが、fatal error
- 等で実行が中断された場合は失敗扱いとなります。
- </p>
- </div>
- </FoldableBorderedContainerWithCaption>
- );
+ return (
+ <FoldableBorderedContainerWithCaption caption="スコア計算・Swift 環境">
+ <div className="text-gray-700 flex flex-col gap-2">
+ <p>スコアはコード中の全 ASCII 空白文字を除去した後のバイト数です。</p>
+ <p>
+ 同じスコアを出した場合、より提出が早かったプレイヤーの勝ちとなります。
+ </p>
+ <p>
+ この環境の Swift バージョンは{" "}
+ <strong className="font-bold">6.1.2</strong> です。
+ ファイルアクセスやネットワークアクセスはできません。
+ </p>
+ <p>
+ テストの成否は、標準出力へ出力された文字列を比較して判定されます。
+ 末尾の改行はあってもなくても構いません。
+ 標準エラー出力の内容は無視されますが、fatal error
+ 等で実行が中断された場合は失敗扱いとなります。
+ </p>
+ </div>
+ </FoldableBorderedContainerWithCaption>
+ );
}
type Props = {
- description: string;
- language: SupportedLanguage;
- sampleCode: string;
+ description: string;
+ language: SupportedLanguage;
+ sampleCode: string;
};
export default function ProblemColumnContent({
- description,
- language,
- sampleCode,
+ description,
+ language,
+ sampleCode,
}: Props) {
- return (
- <>
- <FoldableBorderedContainerWithCaption caption="問題">
- <pre className="text-gray-700 whitespace-pre-wrap break-words">
- {description}
- </pre>
- </FoldableBorderedContainerWithCaption>
- <FoldableBorderedContainerWithCaption caption="サンプルコード">
- <CodeBlock code={sampleCode} language={language} />
- </FoldableBorderedContainerWithCaption>
- {language === "php" ? <PhpNotice /> : <SwiftNotice />}
- </>
- );
+ return (
+ <>
+ <FoldableBorderedContainerWithCaption caption="問題">
+ <pre className="text-gray-700 whitespace-pre-wrap break-words">
+ {description}
+ </pre>
+ </FoldableBorderedContainerWithCaption>
+ <FoldableBorderedContainerWithCaption caption="サンプルコード">
+ <CodeBlock code={sampleCode} language={language} />
+ </FoldableBorderedContainerWithCaption>
+ {language === "php" ? <PhpNotice /> : <SwiftNotice />}
+ </>
+ );
}
diff --git a/frontend/app/components/Gaming/RankingTable.tsx b/frontend/app/components/Gaming/RankingTable.tsx
index 60f4808..b0a6116 100644
--- a/frontend/app/components/Gaming/RankingTable.tsx
+++ b/frontend/app/components/Gaming/RankingTable.tsx
@@ -5,43 +5,43 @@ import CodePopover from "./CodePopover";
import DataTable, { DataTableCell, formatUnixTimestamp } from "./DataTable";
type Props = {
- problemLanguage: SupportedLanguage;
+ problemLanguage: SupportedLanguage;
};
export default function RankingTable({ problemLanguage }: Props) {
- const ranking = useAtomValue(rankingAtom);
- const showCode = ranking.some((entry) => entry.code != null);
+ const ranking = useAtomValue(rankingAtom);
+ const showCode = ranking.some((entry) => entry.code != null);
- return (
- <DataTable
- headers={[
- "順位",
- "プレイヤー",
- "スコア",
- "提出時刻",
- ...(showCode ? ["コード"] : []),
- ]}
- >
- {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>
- {showCode && (
- <DataTableCell>
- {entry.code && (
- <CodePopover code={entry.code} language={problemLanguage} />
- )}
- </DataTableCell>
- )}
- </tr>
- ))}
- </DataTable>
- );
+ return (
+ <DataTable
+ headers={[
+ "順位",
+ "プレイヤー",
+ "スコア",
+ "提出時刻",
+ ...(showCode ? ["コード"] : []),
+ ]}
+ >
+ {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>
+ {showCode && (
+ <DataTableCell>
+ {entry.code && (
+ <CodePopover code={entry.code} language={problemLanguage} />
+ )}
+ </DataTableCell>
+ )}
+ </tr>
+ ))}
+ </DataTable>
+ );
}
diff --git a/frontend/app/components/Gaming/Score.tsx b/frontend/app/components/Gaming/Score.tsx
index ee23a6c..8e1e61d 100644
--- a/frontend/app/components/Gaming/Score.tsx
+++ b/frontend/app/components/Gaming/Score.tsx
@@ -1,36 +1,36 @@
import { useEffect, useState } from "react";
type Props = {
- status: string | null;
- score: number | null;
+ status: string | null;
+ score: number | null;
};
export default function Score({ status, score }: Props) {
- const [randomScore, setRandomScore] = useState<number | null>(null);
+ const [randomScore, setRandomScore] = useState<number | null>(null);
- useEffect(() => {
- if (status !== "running") {
- return;
- }
+ useEffect(() => {
+ if (status !== "running") {
+ return;
+ }
- const intervalId = setInterval(() => {
- const maxValue = Math.pow(10, String(score ?? 100).length) - 1;
- const minValue = Math.pow(10, String(score ?? 100).length - 1);
- const randomValue =
- Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue;
- setRandomScore(randomValue);
- }, 50);
+ const intervalId = setInterval(() => {
+ const maxValue = Math.pow(10, String(score ?? 100).length) - 1;
+ const minValue = Math.pow(10, String(score ?? 100).length - 1);
+ const randomValue =
+ Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue;
+ setRandomScore(randomValue);
+ }, 50);
- return () => {
- clearInterval(intervalId);
- };
- }, [status, score]);
+ return () => {
+ clearInterval(intervalId);
+ };
+ }, [status, score]);
- const displayScore = status === "running" ? randomScore : score;
+ const displayScore = status === "running" ? randomScore : score;
- return (
- <span className={status === "running" ? "animate-pulse" : ""}>
- {displayScore}
- </span>
- );
+ return (
+ <span className={status === "running" ? "animate-pulse" : ""}>
+ {displayScore}
+ </span>
+ );
}
diff --git a/frontend/app/components/Gaming/ScoreBar.tsx b/frontend/app/components/Gaming/ScoreBar.tsx
index 6a291cd..50a2402 100644
--- a/frontend/app/components/Gaming/ScoreBar.tsx
+++ b/frontend/app/components/Gaming/ScoreBar.tsx
@@ -1,30 +1,30 @@
type Props = {
- scoreA: number | null;
- scoreB: number | null;
- bgA: string;
- bgB: string;
+ scoreA: number | null;
+ scoreB: number | null;
+ bgA: string;
+ bgB: string;
};
export default function ScoreBar({ scoreA, scoreB, bgA, bgB }: Props) {
- let scoreRatio;
- if (scoreA === null && scoreB === null) {
- scoreRatio = 50;
- } else if (scoreA === null) {
- scoreRatio = 0;
- } else if (scoreB === null) {
- scoreRatio = 100;
- } else {
- const rawRatio = scoreB / (scoreA + scoreB);
- const k = 3.0;
- const emphasizedRatio =
- Math.pow(rawRatio, k) /
- (Math.pow(rawRatio, k) + Math.pow(1 - rawRatio, k));
- scoreRatio = emphasizedRatio * 100;
- }
+ let scoreRatio;
+ if (scoreA === null && scoreB === null) {
+ scoreRatio = 50;
+ } else if (scoreA === null) {
+ scoreRatio = 0;
+ } else if (scoreB === null) {
+ scoreRatio = 100;
+ } else {
+ const rawRatio = scoreB / (scoreA + scoreB);
+ const k = 3.0;
+ const emphasizedRatio =
+ Math.pow(rawRatio, k) /
+ (Math.pow(rawRatio, k) + Math.pow(1 - rawRatio, k));
+ scoreRatio = emphasizedRatio * 100;
+ }
- return (
- <div className={`w-full ${bgB}`}>
- <div className={`h-10 ${bgA}`} style={{ width: `${scoreRatio}%` }}></div>
- </div>
- );
+ return (
+ <div className={`w-full ${bgB}`}>
+ <div className={`h-10 ${bgA}`} style={{ width: `${scoreRatio}%` }}></div>
+ </div>
+ );
}
diff --git a/frontend/app/components/GolfPlayApp.tsx b/frontend/app/components/GolfPlayApp.tsx
index 5d00239..e9fa410 100644
--- a/frontend/app/components/GolfPlayApp.tsx
+++ b/frontend/app/components/GolfPlayApp.tsx
@@ -6,13 +6,13 @@ import { useDebouncedCallback } from "use-debounce";
import { ApiClientContext } from "../api/client";
import type { components } from "../api/schema";
import {
- gameStateKindAtom,
- handleSubmitCodePostAtom,
- handleSubmitCodePreAtom,
- setCurrentTimestampAtom,
- setDurationSecondsAtom,
- setGameStartedAtAtom,
- setLatestGameStateAtom,
+ gameStateKindAtom,
+ handleSubmitCodePostAtom,
+ handleSubmitCodePreAtom,
+ setCurrentTimestampAtom,
+ setDurationSecondsAtom,
+ setGameStartedAtAtom,
+ setLatestGameStateAtom,
} from "../states/play";
import GolfPlayAppGaming from "./GolfPlayApps/GolfPlayAppGaming";
import GolfPlayAppLoading from "./GolfPlayApps/GolfPlayAppLoading";
@@ -25,163 +25,163 @@ type Submission = components["schemas"]["Submission"];
type LatestGameState = components["schemas"]["LatestGameState"];
type Props = {
- game: Game;
- player: User;
- initialGameState: LatestGameState;
+ game: Game;
+ player: User;
+ initialGameState: LatestGameState;
};
export default function GolfPlayApp({ game, player, initialGameState }: Props) {
- useHydrateAtoms([
- [setDurationSecondsAtom, game.duration_seconds],
- [setGameStartedAtAtom, game.started_at ?? null],
- [setLatestGameStateAtom, initialGameState],
- ]);
+ useHydrateAtoms([
+ [setDurationSecondsAtom, game.duration_seconds],
+ [setGameStartedAtAtom, game.started_at ?? null],
+ [setLatestGameStateAtom, initialGameState],
+ ]);
- const apiClient = useContext(ApiClientContext)!;
+ const apiClient = useContext(ApiClientContext)!;
- const gameStateKind = useAtomValue(gameStateKindAtom);
- const setGameStartedAt = useSetAtom(setGameStartedAtAtom);
- const setCurrentTimestamp = useSetAtom(setCurrentTimestampAtom);
- const handleSubmitCodePre = useSetAtom(handleSubmitCodePreAtom);
- const handleSubmitCodePost = useSetAtom(handleSubmitCodePostAtom);
- const setLatestGameState = useSetAtom(setLatestGameStateAtom);
+ const gameStateKind = useAtomValue(gameStateKindAtom);
+ const setGameStartedAt = useSetAtom(setGameStartedAtAtom);
+ const setCurrentTimestamp = useSetAtom(setCurrentTimestampAtom);
+ const handleSubmitCodePre = useSetAtom(handleSubmitCodePreAtom);
+ const handleSubmitCodePost = useSetAtom(handleSubmitCodePostAtom);
+ const setLatestGameState = useSetAtom(setLatestGameStateAtom);
- useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp);
+ useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp);
- const playerProfile = {
- id: player.user_id,
- displayName: player.display_name,
- iconPath: player.icon_path ?? null,
- };
+ const playerProfile = {
+ id: player.user_id,
+ displayName: player.display_name,
+ iconPath: player.icon_path ?? null,
+ };
- const onCodeChange = useDebouncedCallback(async (code: string) => {
- if (game.game_type === "1v1") {
- console.log("player:c2s:code");
- await apiClient.postGamePlayCode(game.game_id, code);
- }
- }, 1000);
+ const onCodeChange = useDebouncedCallback(async (code: string) => {
+ if (game.game_type === "1v1") {
+ console.log("player:c2s:code");
+ await apiClient.postGamePlayCode(game.game_id, code);
+ }
+ }, 1000);
- const onCodeSubmit = useDebouncedCallback(
- async (code: string) => {
- if (code === "") {
- return;
- }
- console.log("player:c2s:submit");
- handleSubmitCodePre();
- await apiClient.postGamePlaySubmit(game.game_id, code);
- await new Promise((resolve) => setTimeout(resolve, 1000));
- handleSubmitCodePost();
- },
- 1000,
- { leading: true },
- );
+ const onCodeSubmit = useDebouncedCallback(
+ async (code: string) => {
+ if (code === "") {
+ return;
+ }
+ console.log("player:c2s:submit");
+ handleSubmitCodePre();
+ await apiClient.postGamePlaySubmit(game.game_id, code);
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ handleSubmitCodePost();
+ },
+ 1000,
+ { leading: true },
+ );
- const [submissions, setSubmissions] = useState<Submission[]>([]);
- const [isDataPolling, setIsDataPolling] = useState(false);
+ 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]);
+ 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 (gameStateKind === "finished") {
+ fetchSubmissions();
+ }
+ }, [gameStateKind, fetchSubmissions]);
- useEffect(() => {
- if (isDataPolling) {
- return;
- }
- const timerId = setInterval(async () => {
- if (isDataPolling) {
- return;
- }
- setIsDataPolling(true);
+ useEffect(() => {
+ if (isDataPolling) {
+ return;
+ }
+ const timerId = setInterval(async () => {
+ if (isDataPolling) {
+ return;
+ }
+ setIsDataPolling(true);
- try {
- if (gameStateKind === "waiting") {
- const { game: g } = await apiClient.getGame(game.game_id);
- if (g.started_at != null) {
- setGameStartedAt(g.started_at);
- }
- } else if (gameStateKind === "gaming") {
- const { state } = await apiClient.getGamePlayLatestState(
- game.game_id,
- );
- setLatestGameState(state);
- await fetchSubmissions();
- }
- } catch (error) {
- console.error(error);
- } finally {
- setIsDataPolling(false);
- }
- }, 1000);
+ try {
+ if (gameStateKind === "waiting") {
+ const { game: g } = await apiClient.getGame(game.game_id);
+ if (g.started_at != null) {
+ setGameStartedAt(g.started_at);
+ }
+ } else if (gameStateKind === "gaming") {
+ const { state } = await apiClient.getGamePlayLatestState(
+ game.game_id,
+ );
+ setLatestGameState(state);
+ await fetchSubmissions();
+ }
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setIsDataPolling(false);
+ }
+ }, 1000);
- return () => {
- clearInterval(timerId);
- };
- }, [
- isDataPolling,
- apiClient,
- game.game_id,
- gameStateKind,
- setGameStartedAt,
- setLatestGameState,
- fetchSubmissions,
- ]);
+ return () => {
+ clearInterval(timerId);
+ };
+ }, [
+ isDataPolling,
+ apiClient,
+ game.game_id,
+ gameStateKind,
+ setGameStartedAt,
+ setLatestGameState,
+ fetchSubmissions,
+ ]);
- if (gameStateKind === "loading") {
- return <GolfPlayAppLoading />;
- } else if (gameStateKind === "waiting") {
- if (player.is_admin) {
- return (
- <GolfPlayAppGaming
- gameDisplayName={game.display_name}
- playerProfile={playerProfile}
- problemTitle={game.problem.title}
- problemDescription={game.problem.description}
- problemLanguage={game.problem.language}
- sampleCode={game.problem.sample_code}
- initialCode={initialGameState.code}
- onCodeChange={onCodeChange}
- onCodeSubmit={onCodeSubmit}
- isFinished={false}
- submissions={submissions}
- />
- );
- }
- return (
- <GolfPlayAppWaiting
- gameDisplayName={game.display_name}
- playerProfile={playerProfile}
- />
- );
- } else if (gameStateKind === "starting") {
- return <GolfPlayAppStarting gameDisplayName={game.display_name} />;
- } else if (gameStateKind === "gaming" || gameStateKind === "finished") {
- return (
- <GolfPlayAppGaming
- gameDisplayName={game.display_name}
- playerProfile={playerProfile}
- problemTitle={game.problem.title}
- problemDescription={game.problem.description}
- problemLanguage={game.problem.language}
- sampleCode={game.problem.sample_code}
- initialCode={initialGameState.code}
- onCodeChange={onCodeChange}
- onCodeSubmit={onCodeSubmit}
- isFinished={gameStateKind === "finished"}
- submissions={submissions}
- />
- );
- }
+ if (gameStateKind === "loading") {
+ return <GolfPlayAppLoading />;
+ } else if (gameStateKind === "waiting") {
+ if (player.is_admin) {
+ return (
+ <GolfPlayAppGaming
+ gameDisplayName={game.display_name}
+ playerProfile={playerProfile}
+ problemTitle={game.problem.title}
+ problemDescription={game.problem.description}
+ problemLanguage={game.problem.language}
+ sampleCode={game.problem.sample_code}
+ initialCode={initialGameState.code}
+ onCodeChange={onCodeChange}
+ onCodeSubmit={onCodeSubmit}
+ isFinished={false}
+ submissions={submissions}
+ />
+ );
+ }
+ return (
+ <GolfPlayAppWaiting
+ gameDisplayName={game.display_name}
+ playerProfile={playerProfile}
+ />
+ );
+ } else if (gameStateKind === "starting") {
+ return <GolfPlayAppStarting gameDisplayName={game.display_name} />;
+ } else if (gameStateKind === "gaming" || gameStateKind === "finished") {
+ return (
+ <GolfPlayAppGaming
+ gameDisplayName={game.display_name}
+ playerProfile={playerProfile}
+ problemTitle={game.problem.title}
+ problemDescription={game.problem.description}
+ problemLanguage={game.problem.language}
+ sampleCode={game.problem.sample_code}
+ initialCode={initialGameState.code}
+ 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
index 2d51d66..ae5381b 100644
--- a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.test.tsx
+++ b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.test.tsx
@@ -5,103 +5,103 @@ 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,
+ setCurrentTimestampAtom,
+ setDurationSecondsAtom,
+ setGameStartedAtAtom,
+ setLatestGameStateAtom,
} from "../../states/play";
import GolfPlayAppGaming from "./GolfPlayAppGaming";
afterEach(() => {
- cleanup();
+ 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 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,
+ 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("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 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();
- });
+ 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 e590df0..3e1ab67 100644
--- a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx
+++ b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx
@@ -3,18 +3,18 @@ import React, { useRef, useState } from "react";
import { Link } from "wouter";
import type { components } from "../../api/schema";
import {
- calcCodeSize,
- gamingLeftTimeSecondsAtom,
- scoreAtom,
- statusAtom,
+ calcCodeSize,
+ gamingLeftTimeSecondsAtom,
+ scoreAtom,
+ statusAtom,
} from "../../states/play";
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,
+ DataTableCell,
+ formatUnixTimestamp,
} from "../Gaming/DataTable";
import LeftTime from "../Gaming/LeftTime";
import ProblemColumn from "../Gaming/ProblemColumn";
@@ -27,142 +27,142 @@ import UserIcon from "../UserIcon";
type Submission = components["schemas"]["Submission"];
type Props = {
- gameDisplayName: string;
- playerProfile: PlayerProfile;
- problemTitle: string;
- problemDescription: string;
- problemLanguage: SupportedLanguage;
- sampleCode: string;
- initialCode: string;
- onCodeChange: (code: string) => void;
- onCodeSubmit: (code: string) => void;
- isFinished: boolean;
- submissions: Submission[];
+ gameDisplayName: string;
+ playerProfile: PlayerProfile;
+ problemTitle: string;
+ problemDescription: string;
+ problemLanguage: SupportedLanguage;
+ sampleCode: string;
+ initialCode: string;
+ onCodeChange: (code: string) => void;
+ onCodeSubmit: (code: string) => void;
+ isFinished: boolean;
+ submissions: Submission[];
};
export default function GolfPlayAppGaming({
- gameDisplayName,
- playerProfile,
- problemTitle,
- problemDescription,
- problemLanguage,
- sampleCode,
- initialCode,
- onCodeChange,
- onCodeSubmit,
- isFinished,
- submissions,
+ gameDisplayName,
+ playerProfile,
+ problemTitle,
+ problemDescription,
+ problemLanguage,
+ sampleCode,
+ initialCode,
+ onCodeChange,
+ onCodeSubmit,
+ isFinished,
+ submissions,
}: Props) {
- const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom);
- const score = useAtomValue(scoreAtom);
- const status = useAtomValue(statusAtom);
+ const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom);
+ const score = useAtomValue(scoreAtom);
+ const status = useAtomValue(statusAtom);
- const [codeSize, setCodeSize] = useState(
- calcCodeSize(initialCode, problemLanguage),
- );
- const textareaRef = useRef<HTMLTextAreaElement>(null);
+ const [codeSize, setCodeSize] = useState(
+ calcCodeSize(initialCode, problemLanguage),
+ );
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
- const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
- setCodeSize(calcCodeSize(e.target.value, problemLanguage));
- if (!isFinished) {
- onCodeChange(e.target.value);
- }
- };
+ const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+ setCodeSize(calcCodeSize(e.target.value, problemLanguage));
+ if (!isFinished) {
+ onCodeChange(e.target.value);
+ }
+ };
- const handleSubmitButtonClick = () => {
- if (textareaRef.current && !isFinished) {
- onCodeSubmit(textareaRef.current.value);
- }
- };
+ const handleSubmitButtonClick = () => {
+ if (textareaRef.current && !isFinished) {
+ onCodeSubmit(textareaRef.current.value);
+ }
+ };
- return (
- <div className="min-h-screen bg-gray-100 flex flex-col">
- <div className="text-white bg-brand-600 flex flex-row justify-between px-4 py-2">
- <div className="font-bold">
- <div className="text-gray-100">{gameDisplayName}</div>
- {isFinished ? (
- <div className="text-2xl md:text-3xl">終了</div>
- ) : leftTimeSeconds === null ? (
- <div className="text-2xl md:text-3xl">未開始</div>
- ) : (
- <LeftTime sec={leftTimeSeconds} />
- )}
- </div>
- <Link to={"/dashboard"}>
- <div className="flex gap-6 items-center font-bold">
- <div className="text-2xl md:text-6xl">{score}</div>
- <div className="hidden md:block text-4xl">
- {playerProfile.displayName}
- </div>
- {playerProfile.iconPath && (
- <UserIcon
- iconPath={playerProfile.iconPath}
- displayName={playerProfile.displayName}
- className="w-12 h-12 my-auto"
- />
- )}
- </div>
- </Link>
- </div>
- <ThreeColumnLayout>
- <ProblemColumn
- title={problemTitle}
- description={problemDescription}
- language={problemLanguage}
- sampleCode={sampleCode}
- />
- <TitledColumn title="ソースコード">
- <BorderedContainer className="grow flex flex-col gap-4">
- <div className="flex flex-row gap-2 items-center">
- <div className="grow font-semibold text-lg">
- コードサイズ: {codeSize}
- </div>
- <SubmitButton
- onClick={handleSubmitButtonClick}
- disabled={isFinished}
- >
- 提出
- </SubmitButton>
- </div>
- <textarea
- ref={textareaRef}
- defaultValue={initialCode}
- onChange={handleTextChange}
- className="grow resize-none h-full w-full p-2 bg-gray-50 rounded-lg border border-gray-300 focus:outline-hidden focus:ring-2 focus:ring-gray-400 transition duration-300"
- rows={10}
- />
- </BorderedContainer>
- </TitledColumn>
- <TitledColumn title="提出結果">
- <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>
- ))
- )}
- </DataTable>
- </TitledColumn>
- </ThreeColumnLayout>
- </div>
- );
+ return (
+ <div className="min-h-screen bg-gray-100 flex flex-col">
+ <div className="text-white bg-brand-600 flex flex-row justify-between px-4 py-2">
+ <div className="font-bold">
+ <div className="text-gray-100">{gameDisplayName}</div>
+ {isFinished ? (
+ <div className="text-2xl md:text-3xl">終了</div>
+ ) : leftTimeSeconds === null ? (
+ <div className="text-2xl md:text-3xl">未開始</div>
+ ) : (
+ <LeftTime sec={leftTimeSeconds} />
+ )}
+ </div>
+ <Link to={"/dashboard"}>
+ <div className="flex gap-6 items-center font-bold">
+ <div className="text-2xl md:text-6xl">{score}</div>
+ <div className="hidden md:block text-4xl">
+ {playerProfile.displayName}
+ </div>
+ {playerProfile.iconPath && (
+ <UserIcon
+ iconPath={playerProfile.iconPath}
+ displayName={playerProfile.displayName}
+ className="w-12 h-12 my-auto"
+ />
+ )}
+ </div>
+ </Link>
+ </div>
+ <ThreeColumnLayout>
+ <ProblemColumn
+ title={problemTitle}
+ description={problemDescription}
+ language={problemLanguage}
+ sampleCode={sampleCode}
+ />
+ <TitledColumn title="ソースコード">
+ <BorderedContainer className="grow flex flex-col gap-4">
+ <div className="flex flex-row gap-2 items-center">
+ <div className="grow font-semibold text-lg">
+ コードサイズ: {codeSize}
+ </div>
+ <SubmitButton
+ onClick={handleSubmitButtonClick}
+ disabled={isFinished}
+ >
+ 提出
+ </SubmitButton>
+ </div>
+ <textarea
+ ref={textareaRef}
+ defaultValue={initialCode}
+ onChange={handleTextChange}
+ className="grow resize-none h-full w-full p-2 bg-gray-50 rounded-lg border border-gray-300 focus:outline-hidden focus:ring-2 focus:ring-gray-400 transition duration-300"
+ rows={10}
+ />
+ </BorderedContainer>
+ </TitledColumn>
+ <TitledColumn title="提出結果">
+ <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>
+ ))
+ )}
+ </DataTable>
+ </TitledColumn>
+ </ThreeColumnLayout>
+ </div>
+ );
}
diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppLoading.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppLoading.tsx
index 7b424f2..ccb716f 100644
--- a/frontend/app/components/GolfPlayApps/GolfPlayAppLoading.tsx
+++ b/frontend/app/components/GolfPlayApps/GolfPlayAppLoading.tsx
@@ -1,9 +1,9 @@
export default function GolfPlayAppLoading() {
- return (
- <div className="min-h-screen bg-gray-100 flex items-center justify-center">
- <div className="text-center">
- <div className="text-6xl font-bold text-black">読込中</div>
- </div>
- </div>
- );
+ return (
+ <div className="min-h-screen bg-gray-100 flex items-center justify-center">
+ <div className="text-center">
+ <div className="text-6xl font-bold text-black">読込中</div>
+ </div>
+ </div>
+ );
}
diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx
index b3378fc..951e001 100644
--- a/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx
+++ b/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx
@@ -2,20 +2,20 @@ import { useAtomValue } from "jotai";
import { startingLeftTimeSecondsAtom } from "../../states/play";
type Props = {
- gameDisplayName: string;
+ gameDisplayName: string;
};
export default function GolfPlayAppStarting({ gameDisplayName }: Props) {
- const leftTimeSeconds = useAtomValue(startingLeftTimeSecondsAtom)!;
+ const leftTimeSeconds = useAtomValue(startingLeftTimeSecondsAtom)!;
- return (
- <div className="min-h-screen bg-gray-100 flex flex-col">
- <div className="text-white bg-brand-600 p-10 text-center">
- <div className="text-4xl font-bold">{gameDisplayName}</div>
- </div>
- <div className="text-center text-black font-black text-10xl">
- {leftTimeSeconds}
- </div>
- </div>
- );
+ return (
+ <div className="min-h-screen bg-gray-100 flex flex-col">
+ <div className="text-white bg-brand-600 p-10 text-center">
+ <div className="text-4xl font-bold">{gameDisplayName}</div>
+ </div>
+ <div className="text-center text-black font-black text-10xl">
+ {leftTimeSeconds}
+ </div>
+ </div>
+ );
}
diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx
index 1341073..5c4e94f 100644
--- a/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx
+++ b/frontend/app/components/GolfPlayApps/GolfPlayAppWaiting.tsx
@@ -2,22 +2,22 @@ import type { PlayerProfile } from "../../types/PlayerProfile";
import PlayerNameAndIcon from "../PlayerNameAndIcon";
type Props = {
- gameDisplayName: string;
- playerProfile: PlayerProfile;
+ gameDisplayName: string;
+ playerProfile: PlayerProfile;
};
export default function GolfPlayAppWaiting({
- gameDisplayName,
- playerProfile,
+ gameDisplayName,
+ playerProfile,
}: Props) {
- return (
- <div className="min-h-screen bg-gray-100 flex flex-col font-bold text-center">
- <div className="text-white bg-brand-600 p-10">
- <div className="text-4xl">{gameDisplayName}</div>
- </div>
- <div className="grow grid mx-auto text-black">
- <PlayerNameAndIcon profile={playerProfile} />
- </div>
- </div>
- );
+ return (
+ <div className="min-h-screen bg-gray-100 flex flex-col font-bold text-center">
+ <div className="text-white bg-brand-600 p-10">
+ <div className="text-4xl">{gameDisplayName}</div>
+ </div>
+ <div className="grow grid mx-auto text-black">
+ <PlayerNameAndIcon profile={playerProfile} />
+ </div>
+ </div>
+ );
}
diff --git a/frontend/app/components/GolfWatchApp.tsx b/frontend/app/components/GolfWatchApp.tsx
index 41b5a01..b7feae7 100644
--- a/frontend/app/components/GolfWatchApp.tsx
+++ b/frontend/app/components/GolfWatchApp.tsx
@@ -5,12 +5,12 @@ import { useTimer } from "react-use-precision-timer";
import { ApiClientContext } from "../api/client";
import type { components } from "../api/schema";
import {
- gameStateKindAtom,
- rankingAtom,
- setCurrentTimestampAtom,
- setDurationSecondsAtom,
- setGameStartedAtAtom,
- setLatestGameStatesAtom,
+ gameStateKindAtom,
+ rankingAtom,
+ setCurrentTimestampAtom,
+ setDurationSecondsAtom,
+ setGameStartedAtAtom,
+ setLatestGameStatesAtom,
} from "../states/watch";
import GolfWatchAppGaming1v1 from "./GolfWatchApps/GolfWatchAppGaming1v1";
import GolfWatchAppGamingMultiplayer from "./GolfWatchApps/GolfWatchAppGamingMultiplayer";
@@ -24,130 +24,130 @@ type LatestGameState = components["schemas"]["LatestGameState"];
type RankingEntry = components["schemas"]["RankingEntry"];
export type Props = {
- game: Game;
- initialGameStates: { [key: string]: LatestGameState };
- initialRanking: RankingEntry[];
+ game: Game;
+ initialGameStates: { [key: string]: LatestGameState };
+ initialRanking: RankingEntry[];
};
export default function GolfWatchApp({
- game,
- initialGameStates,
- initialRanking,
+ game,
+ initialGameStates,
+ initialRanking,
}: Props) {
- useHydrateAtoms([
- [rankingAtom, initialRanking],
- [setDurationSecondsAtom, game.duration_seconds],
- [setGameStartedAtAtom, game.started_at ?? null],
- [setLatestGameStatesAtom, initialGameStates],
- ]);
+ useHydrateAtoms([
+ [rankingAtom, initialRanking],
+ [setDurationSecondsAtom, game.duration_seconds],
+ [setGameStartedAtAtom, game.started_at ?? null],
+ [setLatestGameStatesAtom, initialGameStates],
+ ]);
- const apiClient = useContext(ApiClientContext)!;
+ const apiClient = useContext(ApiClientContext)!;
- const gameStateKind = useAtomValue(gameStateKindAtom);
- const setGameStartedAt = useSetAtom(setGameStartedAtAtom);
- const setCurrentTimestamp = useSetAtom(setCurrentTimestampAtom);
- const setLatestGameStates = useSetAtom(setLatestGameStatesAtom);
- const setRanking = useSetAtom(rankingAtom);
+ const gameStateKind = useAtomValue(gameStateKindAtom);
+ const setGameStartedAt = useSetAtom(setGameStartedAtAtom);
+ const setCurrentTimestamp = useSetAtom(setCurrentTimestampAtom);
+ const setLatestGameStates = useSetAtom(setLatestGameStatesAtom);
+ const setRanking = useSetAtom(rankingAtom);
- useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp);
+ 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 = 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 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);
+ const [isDataPolling, setIsDataPolling] = useState(false);
- useEffect(() => {
- if (isDataPolling) {
- return;
- }
- const timerId = setInterval(async () => {
- if (isDataPolling) {
- return;
- }
- setIsDataPolling(true);
+ useEffect(() => {
+ if (isDataPolling) {
+ return;
+ }
+ const timerId = setInterval(async () => {
+ if (isDataPolling) {
+ return;
+ }
+ setIsDataPolling(true);
- try {
- if (gameStateKind === "waiting") {
- const { game: g } = await apiClient.getGame(game.game_id);
- if (g.started_at != null) {
- setGameStartedAt(g.started_at);
- }
- } else if (gameStateKind === "gaming") {
- const { states } = await apiClient.getGameWatchLatestStates(
- game.game_id,
- );
- setLatestGameStates(states);
- const { ranking } = await apiClient.getGameWatchRanking(game.game_id);
- setRanking(ranking);
- }
- } catch (error) {
- console.error(error);
- } finally {
- setIsDataPolling(false);
- }
- }, 1000);
+ try {
+ if (gameStateKind === "waiting") {
+ const { game: g } = await apiClient.getGame(game.game_id);
+ if (g.started_at != null) {
+ setGameStartedAt(g.started_at);
+ }
+ } else if (gameStateKind === "gaming") {
+ const { states } = await apiClient.getGameWatchLatestStates(
+ game.game_id,
+ );
+ setLatestGameStates(states);
+ const { ranking } = await apiClient.getGameWatchRanking(game.game_id);
+ setRanking(ranking);
+ }
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setIsDataPolling(false);
+ }
+ }, 1000);
- return () => {
- clearInterval(timerId);
- };
- }, [
- isDataPolling,
- apiClient,
- game.game_id,
- gameStateKind,
- setGameStartedAt,
- setLatestGameStates,
- setRanking,
- ]);
+ return () => {
+ clearInterval(timerId);
+ };
+ }, [
+ isDataPolling,
+ apiClient,
+ game.game_id,
+ gameStateKind,
+ setGameStartedAt,
+ setLatestGameStates,
+ setRanking,
+ ]);
- if (gameStateKind === "loading") {
- return <GolfWatchAppLoading />;
- } else if (gameStateKind === "waiting") {
- return game.game_type === "1v1" ? (
- <GolfWatchAppWaiting1v1
- gameDisplayName={game.display_name}
- 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 game.game_type === "1v1" ? (
- <GolfWatchAppGaming1v1
- gameDisplayName={game.display_name}
- playerProfileA={playerProfileA}
- playerProfileB={playerProfileB}
- problemTitle={game.problem.title}
- problemDescription={game.problem.description}
- problemLanguage={game.problem.language}
- sampleCode={game.problem.sample_code}
- />
- ) : (
- <GolfWatchAppGamingMultiplayer
- gameDisplayName={game.display_name}
- problemTitle={game.problem.title}
- problemDescription={game.problem.description}
- problemLanguage={game.problem.language}
- sampleCode={game.problem.sample_code}
- />
- );
- }
+ if (gameStateKind === "loading") {
+ return <GolfWatchAppLoading />;
+ } else if (gameStateKind === "waiting") {
+ return game.game_type === "1v1" ? (
+ <GolfWatchAppWaiting1v1
+ gameDisplayName={game.display_name}
+ 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 game.game_type === "1v1" ? (
+ <GolfWatchAppGaming1v1
+ gameDisplayName={game.display_name}
+ playerProfileA={playerProfileA}
+ playerProfileB={playerProfileB}
+ problemTitle={game.problem.title}
+ problemDescription={game.problem.description}
+ problemLanguage={game.problem.language}
+ sampleCode={game.problem.sample_code}
+ />
+ ) : (
+ <GolfWatchAppGamingMultiplayer
+ gameDisplayName={game.display_name}
+ problemTitle={game.problem.title}
+ problemDescription={game.problem.description}
+ problemLanguage={game.problem.language}
+ sampleCode={game.problem.sample_code}
+ />
+ );
+ }
}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming1v1.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming1v1.tsx
index 3d2784e..f032607 100644
--- a/frontend/app/components/GolfWatchApps/GolfWatchAppGaming1v1.tsx
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGaming1v1.tsx
@@ -1,10 +1,10 @@
import { useAtomValue } from "jotai";
import {
- calcCodeSize,
- checkGameResultKind,
- gameStateKindAtom,
- gamingLeftTimeSecondsAtom,
- latestGameStatesAtom,
+ calcCodeSize,
+ checkGameResultKind,
+ gameStateKindAtom,
+ gamingLeftTimeSecondsAtom,
+ latestGameStatesAtom,
} from "../../states/watch";
import type { PlayerProfile } from "../../types/PlayerProfile";
import type { SupportedLanguage } from "../../types/SupportedLanguage";
@@ -21,140 +21,140 @@ import TitledColumn from "../TitledColumn";
import UserIcon from "../UserIcon";
type Props = {
- gameDisplayName: string;
- playerProfileA: PlayerProfile | null;
- playerProfileB: PlayerProfile | null;
- problemTitle: string;
- problemDescription: string;
- problemLanguage: SupportedLanguage;
- sampleCode: string;
+ gameDisplayName: string;
+ playerProfileA: PlayerProfile | null;
+ playerProfileB: PlayerProfile | null;
+ problemTitle: string;
+ problemDescription: string;
+ problemLanguage: SupportedLanguage;
+ sampleCode: string;
};
export default function GolfWatchAppGaming1v1({
- gameDisplayName,
- playerProfileA,
- playerProfileB,
- problemTitle,
- problemDescription,
- problemLanguage,
- sampleCode,
+ gameDisplayName,
+ playerProfileA,
+ playerProfileB,
+ problemTitle,
+ problemDescription,
+ problemLanguage,
+ sampleCode,
}: Props) {
- const gameStateKind = useAtomValue(gameStateKindAtom);
- const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom)!;
- const latestGameStates = useAtomValue(latestGameStatesAtom);
+ const gameStateKind = useAtomValue(gameStateKindAtom);
+ const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom)!;
+ const latestGameStates = useAtomValue(latestGameStatesAtom);
- const stateA =
- playerProfileA && (latestGameStates[`${playerProfileA.id}`] ?? null);
- const codeA = stateA?.code ?? "";
- const scoreA = stateA?.score ?? null;
- const statusA = stateA?.status ?? "none";
- const stateB =
- playerProfileB && (latestGameStates[`${playerProfileB.id}`] ?? null);
- const codeB = stateB?.code ?? "";
- const scoreB = stateB?.score ?? null;
- const statusB = stateB?.status ?? "none";
+ const stateA =
+ playerProfileA && (latestGameStates[`${playerProfileA.id}`] ?? null);
+ const codeA = stateA?.code ?? "";
+ const scoreA = stateA?.score ?? null;
+ const statusA = stateA?.status ?? "none";
+ const stateB =
+ playerProfileB && (latestGameStates[`${playerProfileB.id}`] ?? null);
+ const codeB = stateB?.code ?? "";
+ const scoreB = stateB?.score ?? null;
+ const statusB = stateB?.status ?? "none";
- const codeSizeA = calcCodeSize(codeA, problemLanguage);
- const codeSizeB = calcCodeSize(codeB, problemLanguage);
+ const codeSizeA = calcCodeSize(codeA, problemLanguage);
+ const codeSizeB = calcCodeSize(codeB, problemLanguage);
- const gameResultKind = checkGameResultKind(gameStateKind, stateA, stateB);
+ const gameResultKind = checkGameResultKind(gameStateKind, stateA, stateB);
- const topBg = gameResultKind
- ? gameResultKind === "winA"
- ? "bg-orange-400"
- : gameResultKind === "winB"
- ? "bg-purple-400"
- : "bg-brand-600"
- : "bg-brand-600";
+ const topBg = gameResultKind
+ ? gameResultKind === "winA"
+ ? "bg-orange-400"
+ : gameResultKind === "winB"
+ ? "bg-purple-400"
+ : "bg-brand-600"
+ : "bg-brand-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 gap-4 justify-start md:justify-between items-center my-auto">
- <div className="flex gap-6 items-center">
- {playerProfileA?.iconPath && (
- <UserIcon
- iconPath={playerProfileA.iconPath}
- displayName={playerProfileA.displayName}
- className="w-12 h-12 my-auto"
- />
- )}
- <div className="hidden md:block text-4xl">
- {playerProfileA?.displayName}
- </div>
- </div>
- <div className="text-2xl md:text-6xl">
- <Score status={statusA} score={scoreA} />
- </div>
- </div>
- <div className="font-bold text-center">
- <div className="text-gray-100">{gameDisplayName}</div>
- {gameResultKind ? (
- <div className="text-3xl">
- {gameResultKind === "winA"
- ? `勝者 ${playerProfileA!.displayName}`
- : gameResultKind === "winB"
- ? `勝者 ${playerProfileB!.displayName}`
- : "引き分け"}
- </div>
- ) : (
- <LeftTime sec={leftTimeSeconds} />
- )}
- </div>
- <div className="font-bold flex gap-4 justify-end md:justify-between items-center my-auto">
- <div className="text-2xl md:text-6xl">
- <Score status={statusB} score={scoreB} />
- </div>
- <div className="flex gap-6 items-center text-end">
- <div className="hidden md:block text-4xl">
- {playerProfileB?.displayName}
- </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"
- />
- <ThreeColumnLayout>
- <TitledColumn
- title={<SubmitStatusLabel status={statusA} />}
- className="order-2 md:order-1"
- >
- <FoldableBorderedContainerWithCaption
- caption={`コードサイズ: ${codeSizeA}`}
- >
- <CodeBlock code={codeA} language={problemLanguage} />
- </FoldableBorderedContainerWithCaption>
- </TitledColumn>
- <TitledColumn title={problemTitle} className="order-1 md:order-2">
- <ProblemColumnContent
- description={problemDescription}
- language={problemLanguage}
- sampleCode={sampleCode}
- />
- <RankingTable problemLanguage={problemLanguage} />
- </TitledColumn>
- <TitledColumn
- title={<SubmitStatusLabel status={statusB} />}
- className="order-3"
- >
- <FoldableBorderedContainerWithCaption
- caption={`コードサイズ: ${codeSizeB}`}
- >
- <CodeBlock code={codeB} language={problemLanguage} />
- </FoldableBorderedContainerWithCaption>
- </TitledColumn>
- </ThreeColumnLayout>
- </div>
- );
+ 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 gap-4 justify-start md:justify-between items-center my-auto">
+ <div className="flex gap-6 items-center">
+ {playerProfileA?.iconPath && (
+ <UserIcon
+ iconPath={playerProfileA.iconPath}
+ displayName={playerProfileA.displayName}
+ className="w-12 h-12 my-auto"
+ />
+ )}
+ <div className="hidden md:block text-4xl">
+ {playerProfileA?.displayName}
+ </div>
+ </div>
+ <div className="text-2xl md:text-6xl">
+ <Score status={statusA} score={scoreA} />
+ </div>
+ </div>
+ <div className="font-bold text-center">
+ <div className="text-gray-100">{gameDisplayName}</div>
+ {gameResultKind ? (
+ <div className="text-3xl">
+ {gameResultKind === "winA"
+ ? `勝者 ${playerProfileA!.displayName}`
+ : gameResultKind === "winB"
+ ? `勝者 ${playerProfileB!.displayName}`
+ : "引き分け"}
+ </div>
+ ) : (
+ <LeftTime sec={leftTimeSeconds} />
+ )}
+ </div>
+ <div className="font-bold flex gap-4 justify-end md:justify-between items-center my-auto">
+ <div className="text-2xl md:text-6xl">
+ <Score status={statusB} score={scoreB} />
+ </div>
+ <div className="flex gap-6 items-center text-end">
+ <div className="hidden md:block text-4xl">
+ {playerProfileB?.displayName}
+ </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"
+ />
+ <ThreeColumnLayout>
+ <TitledColumn
+ title={<SubmitStatusLabel status={statusA} />}
+ className="order-2 md:order-1"
+ >
+ <FoldableBorderedContainerWithCaption
+ caption={`コードサイズ: ${codeSizeA}`}
+ >
+ <CodeBlock code={codeA} language={problemLanguage} />
+ </FoldableBorderedContainerWithCaption>
+ </TitledColumn>
+ <TitledColumn title={problemTitle} className="order-1 md:order-2">
+ <ProblemColumnContent
+ description={problemDescription}
+ language={problemLanguage}
+ sampleCode={sampleCode}
+ />
+ <RankingTable problemLanguage={problemLanguage} />
+ </TitledColumn>
+ <TitledColumn
+ title={<SubmitStatusLabel status={statusB} />}
+ className="order-3"
+ >
+ <FoldableBorderedContainerWithCaption
+ caption={`コードサイズ: ${codeSizeB}`}
+ >
+ <CodeBlock code={codeB} language={problemLanguage} />
+ </FoldableBorderedContainerWithCaption>
+ </TitledColumn>
+ </ThreeColumnLayout>
+ </div>
+ );
}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppGamingMultiplayer.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppGamingMultiplayer.tsx
index 320709b..ca2e16f 100644
--- a/frontend/app/components/GolfWatchApps/GolfWatchAppGamingMultiplayer.tsx
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppGamingMultiplayer.tsx
@@ -8,43 +8,43 @@ import TitledColumn from "../TitledColumn";
import TwoColumnLayout from "../TwoColumnLayout";
type Props = {
- gameDisplayName: string;
- problemTitle: string;
- problemDescription: string;
- problemLanguage: SupportedLanguage;
- sampleCode: string;
+ gameDisplayName: string;
+ problemTitle: string;
+ problemDescription: string;
+ problemLanguage: SupportedLanguage;
+ sampleCode: string;
};
export default function GolfWatchAppGamingMultiplayer({
- gameDisplayName,
- problemTitle,
- problemDescription,
- problemLanguage,
- sampleCode,
+ gameDisplayName,
+ problemTitle,
+ problemDescription,
+ problemLanguage,
+ sampleCode,
}: Props) {
- const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom)!;
+ const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom)!;
- return (
- <div className="min-h-screen bg-gray-100 flex flex-col">
- <div className="text-white bg-brand-600 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>
- <LeftTime sec={leftTimeSeconds} />
- </div>
- <div className="font-bold flex justify-between my-auto"></div>
- </div>
- <TwoColumnLayout>
- <ProblemColumn
- title={problemTitle}
- description={problemDescription}
- language={problemLanguage}
- sampleCode={sampleCode}
- />
- <TitledColumn title="順位表">
- <RankingTable problemLanguage={problemLanguage} />
- </TitledColumn>
- </TwoColumnLayout>
- </div>
- );
+ return (
+ <div className="min-h-screen bg-gray-100 flex flex-col">
+ <div className="text-white bg-brand-600 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>
+ <LeftTime sec={leftTimeSeconds} />
+ </div>
+ <div className="font-bold flex justify-between my-auto"></div>
+ </div>
+ <TwoColumnLayout>
+ <ProblemColumn
+ title={problemTitle}
+ description={problemDescription}
+ language={problemLanguage}
+ sampleCode={sampleCode}
+ />
+ <TitledColumn title="順位表">
+ <RankingTable problemLanguage={problemLanguage} />
+ </TitledColumn>
+ </TwoColumnLayout>
+ </div>
+ );
}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppLoading.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppLoading.tsx
index 7169d24..a1b3f77 100644
--- a/frontend/app/components/GolfWatchApps/GolfWatchAppLoading.tsx
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppLoading.tsx
@@ -1,9 +1,9 @@
export default function GolfWatchAppLoading() {
- return (
- <div className="min-h-screen bg-gray-100 flex items-center justify-center">
- <div className="text-center">
- <div className="text-6xl font-bold text-black">読込中</div>
- </div>
- </div>
- );
+ return (
+ <div className="min-h-screen bg-gray-100 flex items-center justify-center">
+ <div className="text-center">
+ <div className="text-6xl font-bold text-black">読込中</div>
+ </div>
+ </div>
+ );
}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx
index d9e004e..3083a07 100644
--- a/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppStarting.tsx
@@ -2,20 +2,20 @@ import { useAtomValue } from "jotai";
import { startingLeftTimeSecondsAtom } from "../../states/watch";
type Props = {
- gameDisplayName: string;
+ gameDisplayName: string;
};
export default function GolfWatchAppStarting({ gameDisplayName }: Props) {
- const leftTimeSeconds = useAtomValue(startingLeftTimeSecondsAtom)!;
+ const leftTimeSeconds = useAtomValue(startingLeftTimeSecondsAtom)!;
- return (
- <div className="min-h-screen bg-gray-100 flex flex-col">
- <div className="text-white bg-brand-600 p-10 text-center">
- <div className="text-4xl font-bold">{gameDisplayName}</div>
- </div>
- <div className="text-center text-black font-black text-10xl">
- {leftTimeSeconds}
- </div>
- </div>
- );
+ return (
+ <div className="min-h-screen bg-gray-100 flex flex-col">
+ <div className="text-white bg-brand-600 p-10 text-center">
+ <div className="text-4xl font-bold">{gameDisplayName}</div>
+ </div>
+ <div className="text-center text-black font-black text-10xl">
+ {leftTimeSeconds}
+ </div>
+ </div>
+ );
}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting1v1.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting1v1.tsx
index c7db3a7..48c3ab2 100644
--- a/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting1v1.tsx
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppWaiting1v1.tsx
@@ -2,34 +2,34 @@ import type { PlayerProfile } from "../../types/PlayerProfile";
import PlayerNameAndIcon from "../PlayerNameAndIcon";
type Props = {
- gameDisplayName: string;
- playerProfileA: PlayerProfile | null;
- playerProfileB: PlayerProfile | null;
+ gameDisplayName: string;
+ playerProfileA: PlayerProfile | null;
+ playerProfileB: PlayerProfile | null;
};
export default function GolfWatchAppWaiting1v1({
- gameDisplayName,
- playerProfileA,
- playerProfileB,
+ 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-brand-600 p-10">
- <div className="text-4xl">{gameDisplayName}</div>
- </div>
- <div className="grow grid grid-cols-3 gap-10 mx-auto text-black">
- {playerProfileA ? (
- <PlayerNameAndIcon profile={playerProfileA} />
- ) : (
- <div></div>
- )}
- <div className="text-8xl my-auto">vs.</div>
- {playerProfileB ? (
- <PlayerNameAndIcon profile={playerProfileB} />
- ) : (
- <div></div>
- )}
- </div>
- </div>
- );
+ return (
+ <div className="min-h-screen bg-gray-100 flex flex-col font-bold text-center">
+ <div className="text-white bg-brand-600 p-10">
+ <div className="text-4xl">{gameDisplayName}</div>
+ </div>
+ <div className="grow grid grid-cols-3 gap-10 mx-auto text-black">
+ {playerProfileA ? (
+ <PlayerNameAndIcon profile={playerProfileA} />
+ ) : (
+ <div></div>
+ )}
+ <div className="text-8xl my-auto">vs.</div>
+ {playerProfileB ? (
+ <PlayerNameAndIcon profile={playerProfileB} />
+ ) : (
+ <div></div>
+ )}
+ </div>
+ </div>
+ );
}
diff --git a/frontend/app/components/GolfWatchApps/GolfWatchAppWaitingMultiplayer.tsx b/frontend/app/components/GolfWatchApps/GolfWatchAppWaitingMultiplayer.tsx
index 72757f3..de742b4 100644
--- a/frontend/app/components/GolfWatchApps/GolfWatchAppWaitingMultiplayer.tsx
+++ b/frontend/app/components/GolfWatchApps/GolfWatchAppWaitingMultiplayer.tsx
@@ -1,15 +1,15 @@
type Props = {
- gameDisplayName: string;
+ gameDisplayName: string;
};
export default function GolfWatchAppWaitingMultiplayer({
- gameDisplayName,
+ gameDisplayName,
}: Props) {
- return (
- <div className="min-h-screen bg-gray-100 flex flex-col font-bold text-center">
- <div className="text-white bg-brand-600 p-10">
- <div className="text-4xl">{gameDisplayName}</div>
- </div>
- </div>
- );
+ return (
+ <div className="min-h-screen bg-gray-100 flex flex-col font-bold text-center">
+ <div className="text-white bg-brand-600 p-10">
+ <div className="text-4xl">{gameDisplayName}</div>
+ </div>
+ </div>
+ );
}
diff --git a/frontend/app/components/InputText.test.tsx b/frontend/app/components/InputText.test.tsx
index 6fbdd27..8acf6f2 100644
--- a/frontend/app/components/InputText.test.tsx
+++ b/frontend/app/components/InputText.test.tsx
@@ -6,31 +6,31 @@ import { afterEach, describe, expect, test } from "vitest";
import InputText from "./InputText";
afterEach(() => {
- cleanup();
+ cleanup();
});
describe("InputText", () => {
- test("renders an input element", () => {
- render(<InputText data-testid="input" />);
- const input = screen.getByTestId("input");
- expect(input.tagName).toBe("INPUT");
- });
+ test("renders an input element", () => {
+ render(<InputText data-testid="input" />);
+ const input = screen.getByTestId("input");
+ expect(input.tagName).toBe("INPUT");
+ });
- test("passes placeholder prop", () => {
- render(<InputText placeholder="Enter text" data-testid="input" />);
- const input = screen.getByTestId("input") as HTMLInputElement;
- expect(input.placeholder).toBe("Enter text");
- });
+ test("passes placeholder prop", () => {
+ render(<InputText placeholder="Enter text" data-testid="input" />);
+ const input = screen.getByTestId("input") as HTMLInputElement;
+ expect(input.placeholder).toBe("Enter text");
+ });
- test("passes type prop", () => {
- render(<InputText type="password" data-testid="input" />);
- const input = screen.getByTestId("input") as HTMLInputElement;
- expect(input.type).toBe("password");
- });
+ test("passes type prop", () => {
+ render(<InputText type="password" data-testid="input" />);
+ const input = screen.getByTestId("input") as HTMLInputElement;
+ expect(input.type).toBe("password");
+ });
- test("has border styling", () => {
- render(<InputText data-testid="input" />);
- const input = screen.getByTestId("input");
- expect(input.className).toContain("border-brand-600");
- });
+ test("has border styling", () => {
+ render(<InputText data-testid="input" />);
+ const input = screen.getByTestId("input");
+ expect(input.className).toContain("border-brand-600");
+ });
});
diff --git a/frontend/app/components/InputText.tsx b/frontend/app/components/InputText.tsx
index 9b57a74..76ca860 100644
--- a/frontend/app/components/InputText.tsx
+++ b/frontend/app/components/InputText.tsx
@@ -3,10 +3,10 @@ import React from "react";
type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
export default function InputText(props: InputProps) {
- return (
- <input
- {...props}
- className="p-2 block w-full border border-brand-600 rounded-md transition duration-300 focus:ring-3 focus:ring-brand-400 focus:outline-hidden"
- />
- );
+ return (
+ <input
+ {...props}
+ className="p-2 block w-full border border-brand-600 rounded-md transition duration-300 focus:ring-3 focus:ring-brand-400 focus:outline-hidden"
+ />
+ );
}
diff --git a/frontend/app/components/NavigateLink.tsx b/frontend/app/components/NavigateLink.tsx
index 8741121..712c5ce 100644
--- a/frontend/app/components/NavigateLink.tsx
+++ b/frontend/app/components/NavigateLink.tsx
@@ -1,18 +1,18 @@
import { Link } from "wouter";
export default function NavigateLink({
- to,
- children,
+ to,
+ children,
}: {
- to: string;
- children: React.ReactNode;
+ to: string;
+ children: React.ReactNode;
}) {
- return (
- <Link
- to={to}
- className="text-lg text-white bg-brand-600 px-4 py-2 border-2 border-brand-50 rounded-sm transition duration-300 hover:bg-brand-500 focus:ring-3 focus:ring-brand-400 focus:outline-hidden"
- >
- {children}
- </Link>
- );
+ return (
+ <Link
+ to={to}
+ className="text-lg text-white bg-brand-600 px-4 py-2 border-2 border-brand-50 rounded-sm transition duration-300 hover:bg-brand-500 focus:ring-3 focus:ring-brand-400 focus:outline-hidden"
+ >
+ {children}
+ </Link>
+ );
}
diff --git a/frontend/app/components/PlayerNameAndIcon.test.tsx b/frontend/app/components/PlayerNameAndIcon.test.tsx
index 5a9dd49..e7b851c 100644
--- a/frontend/app/components/PlayerNameAndIcon.test.tsx
+++ b/frontend/app/components/PlayerNameAndIcon.test.tsx
@@ -6,34 +6,34 @@ import { afterEach, describe, expect, test } from "vitest";
import PlayerNameAndIcon from "./PlayerNameAndIcon";
afterEach(() => {
- cleanup();
+ cleanup();
});
describe("PlayerNameAndIcon", () => {
- test("renders display name", () => {
- render(
- <PlayerNameAndIcon
- profile={{ id: 1, displayName: "Alice", iconPath: null }}
- />,
- );
- expect(screen.getByText("Alice")).toBeDefined();
- });
+ test("renders display name", () => {
+ render(
+ <PlayerNameAndIcon
+ profile={{ id: 1, displayName: "Alice", iconPath: null }}
+ />,
+ );
+ expect(screen.getByText("Alice")).toBeDefined();
+ });
- test("does not render icon when iconPath is null", () => {
- render(
- <PlayerNameAndIcon
- profile={{ id: 1, displayName: "Bob", iconPath: null }}
- />,
- );
- expect(screen.queryByRole("img")).toBeNull();
- });
+ test("does not render icon when iconPath is null", () => {
+ render(
+ <PlayerNameAndIcon
+ profile={{ id: 1, displayName: "Bob", iconPath: null }}
+ />,
+ );
+ expect(screen.queryByRole("img")).toBeNull();
+ });
- test("renders icon when iconPath is provided", () => {
- render(
- <PlayerNameAndIcon
- profile={{ id: 1, displayName: "Carol", iconPath: "icons/carol.png" }}
- />,
- );
- expect(screen.getByAltText("Carol のアイコン")).toBeDefined();
- });
+ test("renders icon when iconPath is provided", () => {
+ render(
+ <PlayerNameAndIcon
+ profile={{ id: 1, displayName: "Carol", iconPath: "icons/carol.png" }}
+ />,
+ );
+ expect(screen.getByAltText("Carol のアイコン")).toBeDefined();
+ });
});
diff --git a/frontend/app/components/PlayerNameAndIcon.tsx b/frontend/app/components/PlayerNameAndIcon.tsx
index 92b757d..56423c1 100644
--- a/frontend/app/components/PlayerNameAndIcon.tsx
+++ b/frontend/app/components/PlayerNameAndIcon.tsx
@@ -2,20 +2,20 @@ import { PlayerProfile } from "../types/PlayerProfile";
import UserIcon from "./UserIcon";
type Props = {
- profile: PlayerProfile;
+ profile: PlayerProfile;
};
export default function PlayerNameAndIcon({ profile }: Props) {
- return (
- <div className="flex flex-col gap-6 my-auto items-center">
- <div className="text-6xl">{profile.displayName}</div>
- {profile.iconPath && (
- <UserIcon
- iconPath={profile.iconPath}
- displayName={profile.displayName}
- className="w-48 h-48"
- />
- )}
- </div>
- );
+ return (
+ <div className="flex flex-col gap-6 my-auto items-center">
+ <div className="text-6xl">{profile.displayName}</div>
+ {profile.iconPath && (
+ <UserIcon
+ iconPath={profile.iconPath}
+ displayName={profile.displayName}
+ className="w-48 h-48"
+ />
+ )}
+ </div>
+ );
}
diff --git a/frontend/app/components/ProtectedRoute.tsx b/frontend/app/components/ProtectedRoute.tsx
index b943696..5f564a0 100644
--- a/frontend/app/components/ProtectedRoute.tsx
+++ b/frontend/app/components/ProtectedRoute.tsx
@@ -2,19 +2,19 @@ import { Redirect } from "wouter";
import { useAuth } from "../hooks/useAuth";
export default function ProtectedRoute({
- children,
+ children,
}: {
- children: React.ReactNode;
+ children: React.ReactNode;
}) {
- const { isLoggedIn, isLoading } = useAuth();
+ const { isLoggedIn, isLoading } = useAuth();
- if (isLoading) {
- return null;
- }
+ if (isLoading) {
+ return null;
+ }
- if (!isLoggedIn) {
- return <Redirect to="/login" />;
- }
+ if (!isLoggedIn) {
+ return <Redirect to="/login" />;
+ }
- return <>{children}</>;
+ return <>{children}</>;
}
diff --git a/frontend/app/components/PublicOnlyRoute.tsx b/frontend/app/components/PublicOnlyRoute.tsx
index 7b3ef9d..e28f5ee 100644
--- a/frontend/app/components/PublicOnlyRoute.tsx
+++ b/frontend/app/components/PublicOnlyRoute.tsx
@@ -2,19 +2,19 @@ import { Redirect } from "wouter";
import { useAuth } from "../hooks/useAuth";
export default function PublicOnlyRoute({
- children,
+ children,
}: {
- children: React.ReactNode;
+ children: React.ReactNode;
}) {
- const { isLoggedIn, isLoading } = useAuth();
+ const { isLoggedIn, isLoading } = useAuth();
- if (isLoading) {
- return null;
- }
+ if (isLoading) {
+ return null;
+ }
- if (isLoggedIn) {
- return <Redirect to="/dashboard" />;
- }
+ if (isLoggedIn) {
+ return <Redirect to="/dashboard" />;
+ }
- return <>{children}</>;
+ return <>{children}</>;
}
diff --git a/frontend/app/components/SubmitButton.test.tsx b/frontend/app/components/SubmitButton.test.tsx
index 9a8085e..06cf5f5 100644
--- a/frontend/app/components/SubmitButton.test.tsx
+++ b/frontend/app/components/SubmitButton.test.tsx
@@ -6,36 +6,36 @@ import { afterEach, describe, expect, test } from "vitest";
import SubmitButton from "./SubmitButton";
afterEach(() => {
- cleanup();
+ cleanup();
});
describe("SubmitButton", () => {
- test("renders children text", () => {
- render(<SubmitButton>Submit</SubmitButton>);
- expect(screen.getByText("Submit")).toBeDefined();
- });
+ test("renders children text", () => {
+ render(<SubmitButton>Submit</SubmitButton>);
+ expect(screen.getByText("Submit")).toBeDefined();
+ });
- test("renders as a button element", () => {
- render(<SubmitButton>Click</SubmitButton>);
- const button = screen.getByText("Click");
- expect(button.tagName).toBe("BUTTON");
- });
+ test("renders as a button element", () => {
+ render(<SubmitButton>Click</SubmitButton>);
+ const button = screen.getByText("Click");
+ expect(button.tagName).toBe("BUTTON");
+ });
- test("can be disabled", () => {
- render(<SubmitButton disabled>Submit</SubmitButton>);
- const button = screen.getByText("Submit") as HTMLButtonElement;
- expect(button.disabled).toBe(true);
- });
+ test("can be disabled", () => {
+ render(<SubmitButton disabled>Submit</SubmitButton>);
+ const button = screen.getByText("Submit") as HTMLButtonElement;
+ expect(button.disabled).toBe(true);
+ });
- test("is not disabled by default", () => {
- render(<SubmitButton>Submit</SubmitButton>);
- const button = screen.getByText("Submit") as HTMLButtonElement;
- expect(button.disabled).toBe(false);
- });
+ test("is not disabled by default", () => {
+ render(<SubmitButton>Submit</SubmitButton>);
+ const button = screen.getByText("Submit") as HTMLButtonElement;
+ expect(button.disabled).toBe(false);
+ });
- test("has brand-600 background styling", () => {
- render(<SubmitButton>Submit</SubmitButton>);
- const button = screen.getByText("Submit");
- expect(button.className).toContain("bg-brand-600");
- });
+ test("has brand-600 background styling", () => {
+ render(<SubmitButton>Submit</SubmitButton>);
+ const button = screen.getByText("Submit");
+ expect(button.className).toContain("bg-brand-600");
+ });
});
diff --git a/frontend/app/components/SubmitButton.tsx b/frontend/app/components/SubmitButton.tsx
index 916c20f..acb66f6 100644
--- a/frontend/app/components/SubmitButton.tsx
+++ b/frontend/app/components/SubmitButton.tsx
@@ -3,10 +3,10 @@ import React from "react";
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
export default function SubmitButton(props: ButtonProps) {
- return (
- <button
- {...props}
- className="text-lg text-white px-4 py-2 bg-brand-600 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-sm transition duration-300 hover:bg-brand-500 focus:ring-3 focus:ring-brand-400 focus:outline-hidden"
- />
- );
+ return (
+ <button
+ {...props}
+ className="text-lg text-white px-4 py-2 bg-brand-600 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-sm transition duration-300 hover:bg-brand-500 focus:ring-3 focus:ring-brand-400 focus:outline-hidden"
+ />
+ );
}
diff --git a/frontend/app/components/SubmitStatusLabel.test.tsx b/frontend/app/components/SubmitStatusLabel.test.tsx
index cdedf9e..08ba636 100644
--- a/frontend/app/components/SubmitStatusLabel.test.tsx
+++ b/frontend/app/components/SubmitStatusLabel.test.tsx
@@ -6,47 +6,47 @@ import { afterEach, describe, expect, test } from "vitest";
import SubmitStatusLabel from "./SubmitStatusLabel";
afterEach(() => {
- cleanup();
+ cleanup();
});
describe("SubmitStatusLabel", () => {
- test("renders '提出待ち' for none status", () => {
- render(<SubmitStatusLabel status="none" />);
- expect(screen.getByText("提出待ち")).toBeDefined();
- });
+ test("renders '提出待ち' for none status", () => {
+ render(<SubmitStatusLabel status="none" />);
+ expect(screen.getByText("提出待ち")).toBeDefined();
+ });
- test("renders '実行中...' for running status", () => {
- render(<SubmitStatusLabel status="running" />);
- expect(screen.getByText("実行中...")).toBeDefined();
- });
+ test("renders '実行中...' for running status", () => {
+ render(<SubmitStatusLabel status="running" />);
+ expect(screen.getByText("実行中...")).toBeDefined();
+ });
- test("renders '成功' for success status", () => {
- render(<SubmitStatusLabel status="success" />);
- expect(screen.getByText("成功")).toBeDefined();
- });
+ test("renders '成功' for success status", () => {
+ render(<SubmitStatusLabel status="success" />);
+ expect(screen.getByText("成功")).toBeDefined();
+ });
- test("renders 'テスト失敗' for wrong_answer status", () => {
- render(<SubmitStatusLabel status="wrong_answer" />);
- expect(screen.getByText("テスト失敗")).toBeDefined();
- });
+ test("renders 'テスト失敗' for wrong_answer status", () => {
+ render(<SubmitStatusLabel status="wrong_answer" />);
+ expect(screen.getByText("テスト失敗")).toBeDefined();
+ });
- test("renders '時間切れ' for timeout status", () => {
- render(<SubmitStatusLabel status="timeout" />);
- expect(screen.getByText("時間切れ")).toBeDefined();
- });
+ test("renders '時間切れ' for timeout status", () => {
+ render(<SubmitStatusLabel status="timeout" />);
+ expect(screen.getByText("時間切れ")).toBeDefined();
+ });
- test("renders 'コンパイルエラー' for compile_error status", () => {
- render(<SubmitStatusLabel status="compile_error" />);
- expect(screen.getByText("コンパイルエラー")).toBeDefined();
- });
+ test("renders 'コンパイルエラー' for compile_error status", () => {
+ render(<SubmitStatusLabel status="compile_error" />);
+ expect(screen.getByText("コンパイルエラー")).toBeDefined();
+ });
- test("renders '実行時エラー' for runtime_error status", () => {
- render(<SubmitStatusLabel status="runtime_error" />);
- expect(screen.getByText("実行時エラー")).toBeDefined();
- });
+ test("renders '実行時エラー' for runtime_error status", () => {
+ render(<SubmitStatusLabel status="runtime_error" />);
+ expect(screen.getByText("実行時エラー")).toBeDefined();
+ });
- test("renders '!内部エラー!' for internal_error status", () => {
- render(<SubmitStatusLabel status="internal_error" />);
- expect(screen.getByText("!内部エラー!")).toBeDefined();
- });
+ test("renders '!内部エラー!' for internal_error status", () => {
+ render(<SubmitStatusLabel status="internal_error" />);
+ expect(screen.getByText("!内部エラー!")).toBeDefined();
+ });
});
diff --git a/frontend/app/components/SubmitStatusLabel.tsx b/frontend/app/components/SubmitStatusLabel.tsx
index b3091b0..e511db2 100644
--- a/frontend/app/components/SubmitStatusLabel.tsx
+++ b/frontend/app/components/SubmitStatusLabel.tsx
@@ -1,26 +1,26 @@
import type { components } from "../api/schema";
type Props = {
- status: components["schemas"]["ExecutionStatus"];
+ status: components["schemas"]["ExecutionStatus"];
};
export default function SubmitStatusLabel({ status }: Props) {
- switch (status) {
- case "none":
- return "提出待ち";
- case "running":
- return "実行中...";
- case "success":
- return "成功";
- case "wrong_answer":
- return "テスト失敗";
- case "timeout":
- return "時間切れ";
- case "compile_error":
- return "コンパイルエラー";
- case "runtime_error":
- return "実行時エラー";
- case "internal_error":
- return "!内部エラー!";
- }
+ switch (status) {
+ case "none":
+ return "提出待ち";
+ case "running":
+ return "実行中...";
+ case "success":
+ return "成功";
+ case "wrong_answer":
+ return "テスト失敗";
+ case "timeout":
+ return "時間切れ";
+ case "compile_error":
+ return "コンパイルエラー";
+ case "runtime_error":
+ return "実行時エラー";
+ case "internal_error":
+ return "!内部エラー!";
+ }
}
diff --git a/frontend/app/components/ThreeColumnLayout.tsx b/frontend/app/components/ThreeColumnLayout.tsx
index d2a5ba5..694b9cb 100644
--- a/frontend/app/components/ThreeColumnLayout.tsx
+++ b/frontend/app/components/ThreeColumnLayout.tsx
@@ -1,13 +1,13 @@
import React from "react";
type Props = {
- children: React.ReactNode;
+ children: React.ReactNode;
};
export default function ThreeColumnLayout({ children }: Props) {
- return (
- <div className="grow grid grid-cols-1 md:grid-cols-3 md:divide-x divide-gray-300">
- {children}
- </div>
- );
+ return (
+ <div className="grow grid grid-cols-1 md:grid-cols-3 md:divide-x divide-gray-300">
+ {children}
+ </div>
+ );
}
diff --git a/frontend/app/components/TitledColumn.tsx b/frontend/app/components/TitledColumn.tsx
index a26271b..ac43091 100644
--- a/frontend/app/components/TitledColumn.tsx
+++ b/frontend/app/components/TitledColumn.tsx
@@ -1,16 +1,16 @@
import React from "react";
type Props = {
- children: React.ReactNode;
- title: React.ReactNode;
- className?: string;
+ children: React.ReactNode;
+ title: React.ReactNode;
+ className?: string;
};
export default function TitledColumn({ children, title, className }: Props) {
- return (
- <div className={`p-4 flex flex-col gap-4 ${className}`}>
- <div className="text-center text-xl font-bold">{title}</div>
- {children}
- </div>
- );
+ return (
+ <div className={`p-4 flex flex-col gap-4 ${className}`}>
+ <div className="text-center text-xl font-bold">{title}</div>
+ {children}
+ </div>
+ );
}
diff --git a/frontend/app/components/TwoColumnLayout.tsx b/frontend/app/components/TwoColumnLayout.tsx
index 68eab32..83640ec 100644
--- a/frontend/app/components/TwoColumnLayout.tsx
+++ b/frontend/app/components/TwoColumnLayout.tsx
@@ -1,13 +1,13 @@
import React from "react";
type Props = {
- children: React.ReactNode;
+ children: React.ReactNode;
};
export default function TwoColumnLayout({ children }: Props) {
- return (
- <div className="grow grid grid-cols-1 md:grid-cols-2 md:divide-x divide-gray-300">
- {children}
- </div>
- );
+ return (
+ <div className="grow grid grid-cols-1 md:grid-cols-2 md:divide-x divide-gray-300">
+ {children}
+ </div>
+ );
}
diff --git a/frontend/app/components/UserIcon.test.tsx b/frontend/app/components/UserIcon.test.tsx
index 964aae1..5311234 100644
--- a/frontend/app/components/UserIcon.test.tsx
+++ b/frontend/app/components/UserIcon.test.tsx
@@ -6,57 +6,57 @@ import { afterEach, describe, expect, test } from "vitest";
import UserIcon from "./UserIcon";
afterEach(() => {
- cleanup();
+ cleanup();
});
describe("UserIcon", () => {
- test("renders an img element", () => {
- render(
- <UserIcon
- iconPath="icons/test.png"
- displayName="TestUser"
- className="w-16 h-16"
- />,
- );
- const img = screen.getByAltText("TestUser のアイコン");
- expect(img.tagName).toBe("IMG");
- });
+ test("renders an img element", () => {
+ render(
+ <UserIcon
+ iconPath="icons/test.png"
+ displayName="TestUser"
+ className="w-16 h-16"
+ />,
+ );
+ const img = screen.getByAltText("TestUser のアイコン");
+ expect(img.tagName).toBe("IMG");
+ });
- test("sets alt text with display name", () => {
- render(
- <UserIcon
- iconPath="icons/test.png"
- displayName="Alice"
- className="w-16 h-16"
- />,
- );
- expect(screen.getByAltText("Alice のアイコン")).toBeDefined();
- });
+ test("sets alt text with display name", () => {
+ render(
+ <UserIcon
+ iconPath="icons/test.png"
+ displayName="Alice"
+ className="w-16 h-16"
+ />,
+ );
+ expect(screen.getByAltText("Alice のアイコン")).toBeDefined();
+ });
- test("applies rounded-full and border classes", () => {
- render(
- <UserIcon
- iconPath="icons/test.png"
- displayName="Bob"
- className="w-16 h-16"
- />,
- );
- const img = screen.getByAltText("Bob のアイコン");
- expect(img.className).toContain("rounded-full");
- expect(img.className).toContain("border-4");
- expect(img.className).toContain("border-white");
- });
+ test("applies rounded-full and border classes", () => {
+ render(
+ <UserIcon
+ iconPath="icons/test.png"
+ displayName="Bob"
+ className="w-16 h-16"
+ />,
+ );
+ const img = screen.getByAltText("Bob のアイコン");
+ expect(img.className).toContain("rounded-full");
+ expect(img.className).toContain("border-4");
+ expect(img.className).toContain("border-white");
+ });
- test("applies custom className", () => {
- render(
- <UserIcon
- iconPath="icons/test.png"
- displayName="Bob"
- className="w-48 h-48"
- />,
- );
- const img = screen.getByAltText("Bob のアイコン");
- expect(img.className).toContain("w-48");
- expect(img.className).toContain("h-48");
- });
+ test("applies custom className", () => {
+ render(
+ <UserIcon
+ iconPath="icons/test.png"
+ displayName="Bob"
+ className="w-48 h-48"
+ />,
+ );
+ const img = screen.getByAltText("Bob のアイコン");
+ expect(img.className).toContain("w-48");
+ expect(img.className).toContain("h-48");
+ });
});
diff --git a/frontend/app/components/UserIcon.tsx b/frontend/app/components/UserIcon.tsx
index b2750dd..9c57c42 100644
--- a/frontend/app/components/UserIcon.tsx
+++ b/frontend/app/components/UserIcon.tsx
@@ -1,21 +1,21 @@
import { BASE_PATH } from "../config";
type Props = {
- iconPath: string;
- displayName: string;
- className: string;
+ iconPath: string;
+ displayName: string;
+ className: string;
};
export default function UserIcon({ iconPath, displayName, className }: Props) {
- return (
- <img
- src={
- process.env.NODE_ENV === "development"
- ? `http://localhost:8007${BASE_PATH}${iconPath}`
- : `${BASE_PATH}${iconPath}`
- }
- alt={`${displayName} のアイコン`}
- className={`rounded-full border-4 border-white ${className}`}
- />
- );
+ return (
+ <img
+ src={
+ process.env.NODE_ENV === "development"
+ ? `http://localhost:8007${BASE_PATH}${iconPath}`
+ : `${BASE_PATH}${iconPath}`
+ }
+ alt={`${displayName} のアイコン`}
+ className={`rounded-full border-4 border-white ${className}`}
+ />
+ );
}
diff --git a/frontend/app/config.test.ts b/frontend/app/config.test.ts
index 6989635..b44d999 100644
--- a/frontend/app/config.test.ts
+++ b/frontend/app/config.test.ts
@@ -2,15 +2,15 @@ import { describe, expect, test } from "vitest";
import { API_BASE_PATH, APP_NAME, BASE_PATH } from "./config";
describe("config", () => {
- test("BASE_PATH defaults to /", () => {
- expect(BASE_PATH).toBe("/");
- });
+ test("BASE_PATH defaults to /", () => {
+ expect(BASE_PATH).toBe("/");
+ });
- test("API_BASE_PATH is based on BASE_PATH", () => {
- expect(API_BASE_PATH).toBe(`${BASE_PATH}api/`);
- });
+ test("API_BASE_PATH is based on BASE_PATH", () => {
+ expect(API_BASE_PATH).toBe(`${BASE_PATH}api/`);
+ });
- test("APP_NAME is defined", () => {
- expect(APP_NAME).toBeTruthy();
- });
+ test("APP_NAME is defined", () => {
+ expect(APP_NAME).toBeTruthy();
+ });
});
diff --git a/frontend/app/highlight.ts b/frontend/app/highlight.ts
index ccf0683..9d76850 100644
--- a/frontend/app/highlight.ts
+++ b/frontend/app/highlight.ts
@@ -7,23 +7,23 @@ export type { BundledLanguage };
// https://shiki.matsu.io/packages/next
export async function highlight(code: string, lang: BundledLanguage) {
- let out;
- try {
- out = await codeToHast(code.trimEnd(), {
- lang,
- theme: "github-light",
- });
- } catch {
- // Fallback to plaintext (no highlight).
- out = await codeToHast(code.trimEnd(), {
- lang: "text",
- theme: "github-light",
- });
- }
+ let out;
+ try {
+ out = await codeToHast(code.trimEnd(), {
+ lang,
+ theme: "github-light",
+ });
+ } catch {
+ // Fallback to plaintext (no highlight).
+ out = await codeToHast(code.trimEnd(), {
+ lang: "text",
+ theme: "github-light",
+ });
+ }
- return toJsxRuntime(out, {
- Fragment,
- jsx,
- jsxs,
- }) as JSX.Element;
+ return toJsxRuntime(out, {
+ Fragment,
+ jsx,
+ jsxs,
+ }) as JSX.Element;
}
diff --git a/frontend/app/hooks/useAuth.ts b/frontend/app/hooks/useAuth.ts
index 7913a0e..35eafaa 100644
--- a/frontend/app/hooks/useAuth.ts
+++ b/frontend/app/hooks/useAuth.ts
@@ -3,31 +3,31 @@ import { apiGetMe, apiLogin, apiLogout } from "../api/client";
import type { User } from "../auth";
export function useAuth(): {
- user: User | null;
- isLoggedIn: boolean;
- isLoading: boolean;
- login: (username: string, password: string) => Promise<void>;
- logout: () => Promise<void>;
+ user: User | null;
+ isLoggedIn: boolean;
+ isLoading: boolean;
+ login: (username: string, password: string) => Promise<void>;
+ logout: () => Promise<void>;
} {
- const [user, setUser] = useState<User | null>(null);
- const [isLoading, setIsLoading] = useState(true);
+ const [user, setUser] = useState<User | null>(null);
+ const [isLoading, setIsLoading] = useState(true);
- useEffect(() => {
- apiGetMe()
- .then((data) => setUser(data?.user ?? null))
- .catch(() => setUser(null))
- .finally(() => setIsLoading(false));
- }, []);
+ useEffect(() => {
+ apiGetMe()
+ .then((data) => setUser(data?.user ?? null))
+ .catch(() => setUser(null))
+ .finally(() => setIsLoading(false));
+ }, []);
- const login = useCallback(async (username: string, password: string) => {
- const { user } = await apiLogin(username, password);
- setUser(user);
- }, []);
+ const login = useCallback(async (username: string, password: string) => {
+ const { user } = await apiLogin(username, password);
+ setUser(user);
+ }, []);
- const logout = useCallback(async () => {
- await apiLogout();
- setUser(null);
- }, []);
+ const logout = useCallback(async () => {
+ await apiLogout();
+ setUser(null);
+ }, []);
- return { user, isLoggedIn: user !== null, isLoading, login, logout };
+ return { user, isLoggedIn: user !== null, isLoading, login, logout };
}
diff --git a/frontend/app/hooks/usePageTitle.test.ts b/frontend/app/hooks/usePageTitle.test.ts
index 2e1a3ec..ff9e510 100644
--- a/frontend/app/hooks/usePageTitle.test.ts
+++ b/frontend/app/hooks/usePageTitle.test.ts
@@ -6,18 +6,18 @@ import { describe, expect, test } from "vitest";
import { usePageTitle } from "./usePageTitle";
describe("usePageTitle", () => {
- test("sets document title", () => {
- renderHook(() => usePageTitle("Test Page"));
- expect(document.title).toBe("Test Page");
- });
+ test("sets document title", () => {
+ renderHook(() => usePageTitle("Test Page"));
+ expect(document.title).toBe("Test Page");
+ });
- test("updates document title when value changes", () => {
- const { rerender } = renderHook(({ title }) => usePageTitle(title), {
- initialProps: { title: "First" },
- });
- expect(document.title).toBe("First");
+ test("updates document title when value changes", () => {
+ const { rerender } = renderHook(({ title }) => usePageTitle(title), {
+ initialProps: { title: "First" },
+ });
+ expect(document.title).toBe("First");
- rerender({ title: "Second" });
- expect(document.title).toBe("Second");
- });
+ rerender({ title: "Second" });
+ expect(document.title).toBe("Second");
+ });
});
diff --git a/frontend/app/hooks/usePageTitle.ts b/frontend/app/hooks/usePageTitle.ts
index fb8def5..6cee3c7 100644
--- a/frontend/app/hooks/usePageTitle.ts
+++ b/frontend/app/hooks/usePageTitle.ts
@@ -1,7 +1,7 @@
import { useEffect } from "react";
export function usePageTitle(title: string) {
- useEffect(() => {
- document.title = title;
- }, [title]);
+ useEffect(() => {
+ document.title = title;
+ }, [title]);
}
diff --git a/frontend/app/main.tsx b/frontend/app/main.tsx
index 89ed944..ca839a3 100644
--- a/frontend/app/main.tsx
+++ b/frontend/app/main.tsx
@@ -10,11 +10,11 @@ config.autoAddCss = false;
const root = document.getElementById("root");
if (!root) {
- throw new Error("Root element not found");
+ throw new Error("Root element not found");
}
createRoot(root).render(
- <StrictMode>
- <App />
- </StrictMode>,
+ <StrictMode>
+ <App />
+ </StrictMode>,
);
diff --git a/frontend/app/pages/DashboardPage.tsx b/frontend/app/pages/DashboardPage.tsx
index 54bfdd6..74be96e 100644
--- a/frontend/app/pages/DashboardPage.tsx
+++ b/frontend/app/pages/DashboardPage.tsx
@@ -12,111 +12,111 @@ import { usePageTitle } from "../hooks/usePageTitle";
type Game = components["schemas"]["Game"];
export default function DashboardPage() {
- usePageTitle(`Dashboard | ${APP_NAME}`);
+ usePageTitle(`Dashboard | ${APP_NAME}`);
- const { user, isLoggedIn, isLoading: authLoading, logout } = useAuth();
- const [, navigate] = useLocation();
+ const { user, isLoggedIn, isLoading: authLoading, logout } = useAuth();
+ const [, navigate] = useLocation();
- const [games, setGames] = useState<Game[]>([]);
- const [loading, setLoading] = useState(true);
+ const [games, setGames] = useState<Game[]>([]);
+ const [loading, setLoading] = useState(true);
- useEffect(() => {
- const apiClient = createApiClient();
- apiClient
- .getGames()
- .then(({ games }) => setGames(games))
- .finally(() => setLoading(false));
- }, []);
+ useEffect(() => {
+ const apiClient = createApiClient();
+ apiClient
+ .getGames()
+ .then(({ games }) => setGames(games))
+ .finally(() => setLoading(false));
+ }, []);
- async function handleLogout() {
- await logout();
- navigate("/");
- }
+ async function handleLogout() {
+ await logout();
+ navigate("/");
+ }
- if (loading || authLoading) {
- return (
- <div className="min-h-screen bg-gray-100 flex items-center justify-center">
- <p className="text-gray-500">Loading...</p>
- </div>
- );
- }
+ if (loading || authLoading) {
+ return (
+ <div className="min-h-screen bg-gray-100 flex items-center justify-center">
+ <p className="text-gray-500">Loading...</p>
+ </div>
+ );
+ }
- return (
- <div className="p-6 bg-gray-100 min-h-screen flex flex-col items-center gap-4">
- {isLoggedIn && user?.icon_path && (
- <UserIcon
- iconPath={user.icon_path}
- displayName={user.display_name}
- className="w-24 h-24"
- />
- )}
- {isLoggedIn ? (
- <h1 className="text-3xl font-bold text-gray-800">
- {user?.display_name}
- </h1>
- ) : (
- <h1 className="text-3xl font-bold text-gray-800">試合一覧</h1>
- )}
- <BorderedContainerWithCaption caption="試合一覧">
- <div className="px-4">
- {games.length === 0 ? (
- <p>試合はありません</p>
- ) : (
- <ul className="divide-y divide-gray-300">
- {games.map((game) => (
- <li
- key={game.game_id}
- className="flex justify-between items-center py-2 gap-4"
- >
- <div>
- <span className="font-medium text-gray-800">
- {game.display_name}
- </span>
- </div>
- <div className="flex gap-2">
- {isLoggedIn && game.started_at == null && (
- <NavigateLink to={`/golf/${game.game_id}/preview`}>
- 問題を見る
- </NavigateLink>
- )}
- {isLoggedIn && (
- <NavigateLink to={`/golf/${game.game_id}/play`}>
- 対戦
- </NavigateLink>
- )}
- <NavigateLink to={`/golf/${game.game_id}/watch`}>
- 観戦
- </NavigateLink>
- </div>
- </li>
- ))}
- </ul>
- )}
- </div>
- </BorderedContainerWithCaption>
- {isLoggedIn ? (
- <button
- type="button"
- onClick={handleLogout}
- className="px-4 py-2 bg-red-500 text-white rounded-sm transition duration-300 hover:bg-red-700 focus:ring-3 focus:ring-red-400 focus:outline-hidden"
- >
- ログアウト
- </button>
- ) : (
- <NavigateLink to="/login">ログイン</NavigateLink>
- )}
- {isLoggedIn && user?.is_admin && (
- <a
- href={
- import.meta.env.DEV
- ? `http://localhost:8007${BASE_PATH}admin/dashboard`
- : `${BASE_PATH}admin/dashboard`
- }
- className="text-lg text-white bg-brand-600 px-4 py-2 rounded-sm transition duration-300 hover:bg-brand-500 focus:ring-3 focus:ring-brand-400 focus:outline-hidden"
- >
- Admin Dashboard
- </a>
- )}
- </div>
- );
+ return (
+ <div className="p-6 bg-gray-100 min-h-screen flex flex-col items-center gap-4">
+ {isLoggedIn && user?.icon_path && (
+ <UserIcon
+ iconPath={user.icon_path}
+ displayName={user.display_name}
+ className="w-24 h-24"
+ />
+ )}
+ {isLoggedIn ? (
+ <h1 className="text-3xl font-bold text-gray-800">
+ {user?.display_name}
+ </h1>
+ ) : (
+ <h1 className="text-3xl font-bold text-gray-800">試合一覧</h1>
+ )}
+ <BorderedContainerWithCaption caption="試合一覧">
+ <div className="px-4">
+ {games.length === 0 ? (
+ <p>試合はありません</p>
+ ) : (
+ <ul className="divide-y divide-gray-300">
+ {games.map((game) => (
+ <li
+ key={game.game_id}
+ className="flex justify-between items-center py-2 gap-4"
+ >
+ <div>
+ <span className="font-medium text-gray-800">
+ {game.display_name}
+ </span>
+ </div>
+ <div className="flex gap-2">
+ {isLoggedIn && game.started_at == null && (
+ <NavigateLink to={`/golf/${game.game_id}/preview`}>
+ 問題を見る
+ </NavigateLink>
+ )}
+ {isLoggedIn && (
+ <NavigateLink to={`/golf/${game.game_id}/play`}>
+ 対戦
+ </NavigateLink>
+ )}
+ <NavigateLink to={`/golf/${game.game_id}/watch`}>
+ 観戦
+ </NavigateLink>
+ </div>
+ </li>
+ ))}
+ </ul>
+ )}
+ </div>
+ </BorderedContainerWithCaption>
+ {isLoggedIn ? (
+ <button
+ type="button"
+ onClick={handleLogout}
+ className="px-4 py-2 bg-red-500 text-white rounded-sm transition duration-300 hover:bg-red-700 focus:ring-3 focus:ring-red-400 focus:outline-hidden"
+ >
+ ログアウト
+ </button>
+ ) : (
+ <NavigateLink to="/login">ログイン</NavigateLink>
+ )}
+ {isLoggedIn && user?.is_admin && (
+ <a
+ href={
+ import.meta.env.DEV
+ ? `http://localhost:8007${BASE_PATH}admin/dashboard`
+ : `${BASE_PATH}admin/dashboard`
+ }
+ className="text-lg text-white bg-brand-600 px-4 py-2 rounded-sm transition duration-300 hover:bg-brand-500 focus:ring-3 focus:ring-brand-400 focus:outline-hidden"
+ >
+ Admin Dashboard
+ </a>
+ )}
+ </div>
+ );
}
diff --git a/frontend/app/pages/GolfPlayPage.tsx b/frontend/app/pages/GolfPlayPage.tsx
index 49f47f6..ff6273d 100644
--- a/frontend/app/pages/GolfPlayPage.tsx
+++ b/frontend/app/pages/GolfPlayPage.tsx
@@ -12,58 +12,58 @@ type Game = components["schemas"]["Game"];
type LatestGameState = components["schemas"]["LatestGameState"];
export default function GolfPlayPage({ gameId }: { gameId: string }) {
- const { user } = useAuth();
- const [, navigate] = useLocation();
+ const { user } = useAuth();
+ const [, navigate] = useLocation();
- const [game, setGame] = useState<Game | null>(null);
- const [gameState, setGameState] = useState<LatestGameState | null>(null);
- const [loading, setLoading] = useState(true);
+ const [game, setGame] = useState<Game | null>(null);
+ const [gameState, setGameState] = useState<LatestGameState | null>(null);
+ const [loading, setLoading] = useState(true);
- const gameIdNum = Number(gameId);
+ const gameIdNum = Number(gameId);
- usePageTitle(
- game
- ? `Golf Playing ${game.display_name} | ${APP_NAME}`
- : `Golf Playing | ${APP_NAME}`,
- );
+ usePageTitle(
+ game
+ ? `Golf Playing ${game.display_name} | ${APP_NAME}`
+ : `Golf Playing | ${APP_NAME}`,
+ );
- useEffect(() => {
- const apiClient = createApiClient();
- Promise.all([
- apiClient.getGame(gameIdNum),
- apiClient.getGamePlayLatestState(gameIdNum),
- ])
- .then(([{ game }, { state }]) => {
- setGame(game);
- setGameState(state);
- })
- .catch(() => navigate("/dashboard"))
- .finally(() => setLoading(false));
- }, [gameIdNum, navigate]);
+ useEffect(() => {
+ const apiClient = createApiClient();
+ Promise.all([
+ apiClient.getGame(gameIdNum),
+ apiClient.getGamePlayLatestState(gameIdNum),
+ ])
+ .then(([{ game }, { state }]) => {
+ setGame(game);
+ setGameState(state);
+ })
+ .catch(() => navigate("/dashboard"))
+ .finally(() => setLoading(false));
+ }, [gameIdNum, navigate]);
- const store = useMemo(() => {
- if (!game || !user) return null;
- return createStore();
- }, [game, user]);
+ const store = useMemo(() => {
+ if (!game || !user) return null;
+ return createStore();
+ }, [game, user]);
- if (loading || !game || !gameState || !user || !store) {
- return (
- <div className="min-h-screen bg-gray-100 flex items-center justify-center">
- <p className="text-gray-500">Loading...</p>
- </div>
- );
- }
+ if (loading || !game || !gameState || !user || !store) {
+ return (
+ <div className="min-h-screen bg-gray-100 flex items-center justify-center">
+ <p className="text-gray-500">Loading...</p>
+ </div>
+ );
+ }
- return (
- <JotaiProvider store={store}>
- <ApiClientContext.Provider value={createApiClient()}>
- <GolfPlayApp
- key={game.game_id}
- game={game}
- player={user}
- initialGameState={gameState}
- />
- </ApiClientContext.Provider>
- </JotaiProvider>
- );
+ return (
+ <JotaiProvider store={store}>
+ <ApiClientContext.Provider value={createApiClient()}>
+ <GolfPlayApp
+ key={game.game_id}
+ game={game}
+ player={user}
+ initialGameState={gameState}
+ />
+ </ApiClientContext.Provider>
+ </JotaiProvider>
+ );
}
diff --git a/frontend/app/pages/GolfProblemPreviewPage.tsx b/frontend/app/pages/GolfProblemPreviewPage.tsx
index 4a84809..9eee691 100644
--- a/frontend/app/pages/GolfProblemPreviewPage.tsx
+++ b/frontend/app/pages/GolfProblemPreviewPage.tsx
@@ -10,54 +10,54 @@ import { usePageTitle } from "../hooks/usePageTitle";
type Game = components["schemas"]["Game"];
export default function GolfProblemPreviewPage({ gameId }: { gameId: string }) {
- const [, navigate] = useLocation();
- const [game, setGame] = useState<Game | null>(null);
- const [loading, setLoading] = useState(true);
+ const [, navigate] = useLocation();
+ const [game, setGame] = useState<Game | null>(null);
+ const [loading, setLoading] = useState(true);
- const gameIdNum = Number(gameId);
+ const gameIdNum = Number(gameId);
- usePageTitle(
- game
- ? `${game.display_name} - 問題プレビュー | ${APP_NAME}`
- : `問題プレビュー | ${APP_NAME}`,
- );
+ usePageTitle(
+ game
+ ? `${game.display_name} - 問題プレビュー | ${APP_NAME}`
+ : `問題プレビュー | ${APP_NAME}`,
+ );
- useEffect(() => {
- const apiClient = createApiClient();
- apiClient
- .getGame(gameIdNum)
- .then(({ game }) => setGame(game))
- .catch(() => navigate("/dashboard"))
- .finally(() => setLoading(false));
- }, [gameIdNum, navigate]);
+ useEffect(() => {
+ const apiClient = createApiClient();
+ apiClient
+ .getGame(gameIdNum)
+ .then(({ game }) => setGame(game))
+ .catch(() => navigate("/dashboard"))
+ .finally(() => setLoading(false));
+ }, [gameIdNum, navigate]);
- if (loading || !game) {
- return (
- <div className="min-h-screen bg-gray-100 flex items-center justify-center">
- <p className="text-gray-500">Loading...</p>
- </div>
- );
- }
+ if (loading || !game) {
+ return (
+ <div className="min-h-screen bg-gray-100 flex items-center justify-center">
+ <p className="text-gray-500">Loading...</p>
+ </div>
+ );
+ }
- return (
- <div className="p-6 bg-gray-100 min-h-screen flex flex-col items-center gap-4">
- <h1 className="text-3xl font-bold text-gray-800">{game.display_name}</h1>
- <div className="w-full max-w-3xl flex flex-col gap-4">
- <ProblemColumnContent
- description={game.problem.description}
- language={game.problem.language}
- sampleCode={game.problem.sample_code}
- />
- </div>
- <div className="flex gap-4">
- <NavigateLink to={`/golf/${game.game_id}/play`}>
- 対戦ページへ
- </NavigateLink>
- <NavigateLink to={`/golf/${game.game_id}/watch`}>
- 観戦ページへ
- </NavigateLink>
- </div>
- <NavigateLink to="/dashboard">ダッシュボードへ戻る</NavigateLink>
- </div>
- );
+ return (
+ <div className="p-6 bg-gray-100 min-h-screen flex flex-col items-center gap-4">
+ <h1 className="text-3xl font-bold text-gray-800">{game.display_name}</h1>
+ <div className="w-full max-w-3xl flex flex-col gap-4">
+ <ProblemColumnContent
+ description={game.problem.description}
+ language={game.problem.language}
+ sampleCode={game.problem.sample_code}
+ />
+ </div>
+ <div className="flex gap-4">
+ <NavigateLink to={`/golf/${game.game_id}/play`}>
+ 対戦ページへ
+ </NavigateLink>
+ <NavigateLink to={`/golf/${game.game_id}/watch`}>
+ 観戦ページへ
+ </NavigateLink>
+ </div>
+ <NavigateLink to="/dashboard">ダッシュボードへ戻る</NavigateLink>
+ </div>
+ );
}
diff --git a/frontend/app/pages/GolfWatchPage.tsx b/frontend/app/pages/GolfWatchPage.tsx
index 168bd6f..013e1a0 100644
--- a/frontend/app/pages/GolfWatchPage.tsx
+++ b/frontend/app/pages/GolfWatchPage.tsx
@@ -11,69 +11,69 @@ type LatestGameState = components["schemas"]["LatestGameState"];
type RankingEntry = components["schemas"]["RankingEntry"];
export default function GolfWatchPage({ gameId }: { gameId: string }) {
- const [game, setGame] = useState<Game | null>(null);
- const [ranking, setRanking] = useState<RankingEntry[]>([]);
- const [gameStates, setGameStates] = useState<{
- [key: string]: LatestGameState;
- }>({});
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(false);
+ const [game, setGame] = useState<Game | null>(null);
+ const [ranking, setRanking] = useState<RankingEntry[]>([]);
+ const [gameStates, setGameStates] = useState<{
+ [key: string]: LatestGameState;
+ }>({});
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(false);
- const gameIdNum = Number(gameId);
+ const gameIdNum = Number(gameId);
- usePageTitle(
- game
- ? `Golf Watching ${game.display_name} | ${APP_NAME}`
- : `Golf Watching | ${APP_NAME}`,
- );
+ usePageTitle(
+ game
+ ? `Golf Watching ${game.display_name} | ${APP_NAME}`
+ : `Golf Watching | ${APP_NAME}`,
+ );
- useEffect(() => {
- const apiClient = createApiClient();
- Promise.all([
- apiClient.getGame(gameIdNum),
- apiClient.getGameWatchRanking(gameIdNum),
- apiClient.getGameWatchLatestStates(gameIdNum),
- ])
- .then(([{ game }, { ranking }, { states }]) => {
- setGame(game);
- setRanking(ranking);
- setGameStates(states);
- })
- .catch(() => setError(true))
- .finally(() => setLoading(false));
- }, [gameIdNum]);
+ useEffect(() => {
+ const apiClient = createApiClient();
+ Promise.all([
+ apiClient.getGame(gameIdNum),
+ apiClient.getGameWatchRanking(gameIdNum),
+ apiClient.getGameWatchLatestStates(gameIdNum),
+ ])
+ .then(([{ game }, { ranking }, { states }]) => {
+ setGame(game);
+ setRanking(ranking);
+ setGameStates(states);
+ })
+ .catch(() => setError(true))
+ .finally(() => setLoading(false));
+ }, [gameIdNum]);
- const store = useMemo(() => {
- if (!game) return null;
- return createStore();
- }, [game]);
+ const store = useMemo(() => {
+ if (!game) return null;
+ return createStore();
+ }, [game]);
- if (loading) {
- return (
- <div className="min-h-screen bg-gray-100 flex items-center justify-center">
- <p className="text-gray-500">Loading...</p>
- </div>
- );
- }
+ if (loading) {
+ return (
+ <div className="min-h-screen bg-gray-100 flex items-center justify-center">
+ <p className="text-gray-500">Loading...</p>
+ </div>
+ );
+ }
- if (error || !game || !store) {
- return (
- <div className="min-h-screen bg-gray-100 flex items-center justify-center">
- <p className="text-red-500">試合が見つかりませんでした</p>
- </div>
- );
- }
+ if (error || !game || !store) {
+ return (
+ <div className="min-h-screen bg-gray-100 flex items-center justify-center">
+ <p className="text-red-500">試合が見つかりませんでした</p>
+ </div>
+ );
+ }
- return (
- <JotaiProvider store={store}>
- <ApiClientContext.Provider value={createApiClient()}>
- <GolfWatchApp
- key={game.game_id}
- game={game}
- initialGameStates={gameStates}
- initialRanking={ranking}
- />
- </ApiClientContext.Provider>
- </JotaiProvider>
- );
+ return (
+ <JotaiProvider store={store}>
+ <ApiClientContext.Provider value={createApiClient()}>
+ <GolfWatchApp
+ key={game.game_id}
+ game={game}
+ initialGameStates={gameStates}
+ initialRanking={ranking}
+ />
+ </ApiClientContext.Provider>
+ </JotaiProvider>
+ );
}
diff --git a/frontend/app/pages/IndexPage.tsx b/frontend/app/pages/IndexPage.tsx
index 8dfbefe..a3aa9f1 100644
--- a/frontend/app/pages/IndexPage.tsx
+++ b/frontend/app/pages/IndexPage.tsx
@@ -4,36 +4,36 @@ import { APP_NAME, BASE_PATH } from "../config";
import { usePageTitle } from "../hooks/usePageTitle";
export default function IndexPage() {
- usePageTitle(APP_NAME);
+ usePageTitle(APP_NAME);
- return (
- <div className="min-h-screen bg-gray-100 flex flex-col items-center justify-center gap-y-6">
- <img
- src={`${BASE_PATH}logo.svg`}
- alt="PHPerKaigi 2026"
- className="w-96 h-auto"
- />
- <div className="text-center">
- <div className="font-bold text-transparent bg-clip-text bg-brand-600">
- <div className="text-6xl">PHPER CODE BATTLE</div>
- </div>
- </div>
- <div className="mx-2">
- <BorderedContainer>
- <p className="text-gray-900 max-w-prose">
- PHPer コードバトルは指示された動作をする PHP
- コードをより短く書けた方が勝ち、という 1 対 1 の対戦コンテンツです。
- 3/20 の day 0 と 3/21 の day 1 では、2/28
- に実施されたオフライン予選と、当日まで開催しているオンライン予選を勝ち抜いたプレイヤーによるトーナメント形式での
- PHPer コードバトルを実施します。ここでは短いコードが正義です!
- 可読性も保守性も放り投げた、イベントならではのコードをお楽しみください!
- </p>
- </BorderedContainer>
- </div>
- <div className="flex gap-4">
- <NavigateLink to="/dashboard">観戦する</NavigateLink>
- <NavigateLink to="/login">ログイン</NavigateLink>
- </div>
- </div>
- );
+ return (
+ <div className="min-h-screen bg-gray-100 flex flex-col items-center justify-center gap-y-6">
+ <img
+ src={`${BASE_PATH}logo.svg`}
+ alt="PHPerKaigi 2026"
+ className="w-96 h-auto"
+ />
+ <div className="text-center">
+ <div className="font-bold text-transparent bg-clip-text bg-brand-600">
+ <div className="text-6xl">PHPER CODE BATTLE</div>
+ </div>
+ </div>
+ <div className="mx-2">
+ <BorderedContainer>
+ <p className="text-gray-900 max-w-prose">
+ PHPer コードバトルは指示された動作をする PHP
+ コードをより短く書けた方が勝ち、という 1 対 1 の対戦コンテンツです。
+ 3/20 の day 0 と 3/21 の day 1 では、2/28
+ に実施されたオフライン予選と、当日まで開催しているオンライン予選を勝ち抜いたプレイヤーによるトーナメント形式での
+ PHPer コードバトルを実施します。ここでは短いコードが正義です!
+ 可読性も保守性も放り投げた、イベントならではのコードをお楽しみください!
+ </p>
+ </BorderedContainer>
+ </div>
+ <div className="flex gap-4">
+ <NavigateLink to="/dashboard">観戦する</NavigateLink>
+ <NavigateLink to="/login">ログイン</NavigateLink>
+ </div>
+ </div>
+ );
}
diff --git a/frontend/app/pages/LoginPage.tsx b/frontend/app/pages/LoginPage.tsx
index 139b1f0..4130518 100644
--- a/frontend/app/pages/LoginPage.tsx
+++ b/frontend/app/pages/LoginPage.tsx
@@ -8,94 +8,94 @@ import { useAuth } from "../hooks/useAuth";
import { usePageTitle } from "../hooks/usePageTitle";
export default function LoginPage() {
- usePageTitle(`Login | ${APP_NAME}`);
+ usePageTitle(`Login | ${APP_NAME}`);
- const { login } = useAuth();
- const [, navigate] = useLocation();
+ const { login } = useAuth();
+ const [, navigate] = useLocation();
- const [error, setError] = useState<string | null>(null);
- const [fieldErrors, setFieldErrors] = useState<{
- username?: string;
- password?: string;
- }>({});
- const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [fieldErrors, setFieldErrors] = useState<{
+ username?: string;
+ password?: string;
+ }>({});
+ const [submitting, setSubmitting] = useState(false);
- async function handleSubmit(e: FormEvent<HTMLFormElement>) {
- e.preventDefault();
- const formData = new FormData(e.currentTarget);
- const username = String(formData.get("username"));
- const password = String(formData.get("password"));
+ async function handleSubmit(e: FormEvent<HTMLFormElement>) {
+ e.preventDefault();
+ const formData = new FormData(e.currentTarget);
+ const username = String(formData.get("username"));
+ const password = String(formData.get("password"));
- const errors: { username?: string; password?: string } = {};
- if (username === "") errors.username = "ユーザー名を入力してください";
- if (password === "") errors.password = "パスワードを入力してください";
- if (Object.keys(errors).length > 0) {
- setFieldErrors(errors);
- setError("ユーザー名またはパスワードが誤っています");
- return;
- }
+ const errors: { username?: string; password?: string } = {};
+ if (username === "") errors.username = "ユーザー名を入力してください";
+ if (password === "") errors.password = "パスワードを入力してください";
+ if (Object.keys(errors).length > 0) {
+ setFieldErrors(errors);
+ setError("ユーザー名またはパスワードが誤っています");
+ return;
+ }
- setSubmitting(true);
- setError(null);
- setFieldErrors({});
+ setSubmitting(true);
+ setError(null);
+ setFieldErrors({});
- try {
- await login(username, password);
- navigate("/dashboard");
- } catch (err) {
- setError(err instanceof Error ? err.message : "ログインに失敗しました");
- } finally {
- setSubmitting(false);
- }
- }
+ try {
+ await login(username, password);
+ navigate("/dashboard");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "ログインに失敗しました");
+ } finally {
+ setSubmitting(false);
+ }
+ }
- return (
- <div className="min-h-screen bg-gray-100 flex items-center justify-center">
- <div className="mx-2">
- <BorderedContainer>
- <form onSubmit={handleSubmit} className="w-full max-w-sm p-2">
- <h2 className="text-2xl mb-6 text-center">
- fortee アカウントでログイン
- </h2>
- {error && <p className="text-brand-500 text-sm mb-4">{error}</p>}
- <div className="mb-4 flex flex-col gap-1">
- <label
- htmlFor="username"
- className="block text-sm font-medium text-gray-700"
- >
- ユーザー名
- </label>
- <InputText type="text" name="username" id="username" required />
- {fieldErrors.username && (
- <p className="text-red-500 text-sm">{fieldErrors.username}</p>
- )}
- </div>
- <div className="mb-6 flex flex-col gap-1">
- <label
- htmlFor="password"
- className="block text-sm font-medium text-gray-700"
- >
- パスワード
- </label>
- <InputText
- type="password"
- name="password"
- id="password"
- autoComplete="current-password"
- required
- />
- {fieldErrors.password && (
- <p className="text-red-500 text-sm">{fieldErrors.password}</p>
- )}
- </div>
- <div className="flex justify-center">
- <SubmitButton type="submit" disabled={submitting}>
- {submitting ? "ログイン中..." : "ログイン"}
- </SubmitButton>
- </div>
- </form>
- </BorderedContainer>
- </div>
- </div>
- );
+ return (
+ <div className="min-h-screen bg-gray-100 flex items-center justify-center">
+ <div className="mx-2">
+ <BorderedContainer>
+ <form onSubmit={handleSubmit} className="w-full max-w-sm p-2">
+ <h2 className="text-2xl mb-6 text-center">
+ fortee アカウントでログイン
+ </h2>
+ {error && <p className="text-brand-500 text-sm mb-4">{error}</p>}
+ <div className="mb-4 flex flex-col gap-1">
+ <label
+ htmlFor="username"
+ className="block text-sm font-medium text-gray-700"
+ >
+ ユーザー名
+ </label>
+ <InputText type="text" name="username" id="username" required />
+ {fieldErrors.username && (
+ <p className="text-red-500 text-sm">{fieldErrors.username}</p>
+ )}
+ </div>
+ <div className="mb-6 flex flex-col gap-1">
+ <label
+ htmlFor="password"
+ className="block text-sm font-medium text-gray-700"
+ >
+ パスワード
+ </label>
+ <InputText
+ type="password"
+ name="password"
+ id="password"
+ autoComplete="current-password"
+ required
+ />
+ {fieldErrors.password && (
+ <p className="text-red-500 text-sm">{fieldErrors.password}</p>
+ )}
+ </div>
+ <div className="flex justify-center">
+ <SubmitButton type="submit" disabled={submitting}>
+ {submitting ? "ログイン中..." : "ログイン"}
+ </SubmitButton>
+ </div>
+ </form>
+ </BorderedContainer>
+ </div>
+ </div>
+ );
}
diff --git a/frontend/app/pages/TournamentPage.test.tsx b/frontend/app/pages/TournamentPage.test.tsx
index 3c6f116..e9e4987 100644
--- a/frontend/app/pages/TournamentPage.test.tsx
+++ b/frontend/app/pages/TournamentPage.test.tsx
@@ -6,55 +6,55 @@ import { afterEach, describe, expect, test } from "vitest";
import TournamentPage, { standardBracketSeedsForTest } from "./TournamentPage";
afterEach(() => {
- cleanup();
+ cleanup();
});
describe("standardBracketSeeds", () => {
- test("bracket_size=2 returns [1, 2]", () => {
- const seeds = standardBracketSeedsForTest(2);
- expect(seeds).toEqual([1, 2]);
- });
+ test("bracket_size=2 returns [1, 2]", () => {
+ const seeds = standardBracketSeedsForTest(2);
+ expect(seeds).toEqual([1, 2]);
+ });
- test("bracket_size=4 returns [1, 4, 2, 3]", () => {
- const seeds = standardBracketSeedsForTest(4);
- expect(seeds).toEqual([1, 4, 2, 3]);
- });
+ test("bracket_size=4 returns [1, 4, 2, 3]", () => {
+ const seeds = standardBracketSeedsForTest(4);
+ expect(seeds).toEqual([1, 4, 2, 3]);
+ });
- test("bracket_size=8 returns [1, 8, 4, 5, 2, 7, 3, 6]", () => {
- const seeds = standardBracketSeedsForTest(8);
- expect(seeds).toEqual([1, 8, 4, 5, 2, 7, 3, 6]);
- });
+ test("bracket_size=8 returns [1, 8, 4, 5, 2, 7, 3, 6]", () => {
+ const seeds = standardBracketSeedsForTest(8);
+ expect(seeds).toEqual([1, 8, 4, 5, 2, 7, 3, 6]);
+ });
- test("all seeds present for size 16", () => {
- const seeds = standardBracketSeedsForTest(16);
- expect(seeds).toHaveLength(16);
- const sorted = [...seeds].sort((a, b) => a - b);
- expect(sorted).toEqual(Array.from({ length: 16 }, (_, i) => i + 1));
- });
+ test("all seeds present for size 16", () => {
+ const seeds = standardBracketSeedsForTest(16);
+ expect(seeds).toHaveLength(16);
+ const sorted = [...seeds].sort((a, b) => a - b);
+ expect(sorted).toEqual(Array.from({ length: 16 }, (_, i) => i + 1));
+ });
- test("seed 1 and seed 2 on opposite sides for size 8", () => {
- const seeds = standardBracketSeedsForTest(8);
- const pos1 = seeds.indexOf(1);
- const pos2 = seeds.indexOf(2);
- // Seed 1 in first half (0-3), Seed 2 in second half (4-7)
- expect(pos1).toBeLessThan(4);
- expect(pos2).toBeGreaterThanOrEqual(4);
- });
+ test("seed 1 and seed 2 on opposite sides for size 8", () => {
+ const seeds = standardBracketSeedsForTest(8);
+ const pos1 = seeds.indexOf(1);
+ const pos2 = seeds.indexOf(2);
+ // Seed 1 in first half (0-3), Seed 2 in second half (4-7)
+ expect(pos1).toBeLessThan(4);
+ expect(pos2).toBeGreaterThanOrEqual(4);
+ });
});
describe("TournamentPage", () => {
- test("shows loading state initially", () => {
- render(<TournamentPage tournamentId="1" />);
- expect(screen.getByText("Loading...")).toBeDefined();
- });
+ test("shows loading state initially", () => {
+ render(<TournamentPage tournamentId="1" />);
+ expect(screen.getByText("Loading...")).toBeDefined();
+ });
- test("shows error for invalid tournament ID", () => {
- render(<TournamentPage tournamentId="abc" />);
- expect(screen.getByText("Invalid tournament ID")).toBeDefined();
- });
+ test("shows error for invalid tournament ID", () => {
+ render(<TournamentPage tournamentId="abc" />);
+ expect(screen.getByText("Invalid tournament ID")).toBeDefined();
+ });
- test("shows error for zero tournament ID", () => {
- render(<TournamentPage tournamentId="0" />);
- expect(screen.getByText("Invalid tournament ID")).toBeDefined();
- });
+ test("shows error for zero tournament ID", () => {
+ render(<TournamentPage tournamentId="0" />);
+ expect(screen.getByText("Invalid tournament ID")).toBeDefined();
+ });
});
diff --git a/frontend/app/pages/TournamentPage.tsx b/frontend/app/pages/TournamentPage.tsx
index 0bf4895..f555ba0 100644
--- a/frontend/app/pages/TournamentPage.tsx
+++ b/frontend/app/pages/TournamentPage.tsx
@@ -11,304 +11,304 @@ type TournamentMatch = components["schemas"]["TournamentMatch"];
type TournamentEntry = components["schemas"]["TournamentEntry"];
function getBorderColor(match: TournamentMatch, userID?: number): string {
- if (!match.winner_user_id) {
- return "border-black";
- }
- if (userID !== undefined && match.winner_user_id === userID) {
- return "border-pink-700";
- }
- return "border-gray-400";
+ if (!match.winner_user_id) {
+ return "border-black";
+ }
+ if (userID !== undefined && match.winner_user_id === userID) {
+ return "border-pink-700";
+ }
+ return "border-gray-400";
}
function PlayerCard({ entry }: { entry: TournamentEntry | undefined }) {
- if (!entry) {
- return (
- <div className="flex flex-col items-center gap-1 p-2 opacity-30">
- <span className="text-gray-400 text-sm">BYE</span>
- </div>
- );
- }
- return (
- <BorderedContainer>
- <div className="flex flex-col items-center gap-1">
- <span className="text-gray-600 text-xs">Seed {entry.seed}</span>
- <span className="font-medium text-sm truncate max-w-full">
- {entry.user.display_name}
- </span>
- {entry.user.icon_path && (
- <UserIcon
- iconPath={entry.user.icon_path}
- displayName={entry.user.display_name}
- className="w-12 h-12"
- />
- )}
- </div>
- </BorderedContainer>
- );
+ if (!entry) {
+ return (
+ <div className="flex flex-col items-center gap-1 p-2 opacity-30">
+ <span className="text-gray-400 text-sm">BYE</span>
+ </div>
+ );
+ }
+ return (
+ <BorderedContainer>
+ <div className="flex flex-col items-center gap-1">
+ <span className="text-gray-600 text-xs">Seed {entry.seed}</span>
+ <span className="font-medium text-sm truncate max-w-full">
+ {entry.user.display_name}
+ </span>
+ {entry.user.icon_path && (
+ <UserIcon
+ iconPath={entry.user.icon_path}
+ displayName={entry.user.display_name}
+ className="w-12 h-12"
+ />
+ )}
+ </div>
+ </BorderedContainer>
+ );
}
function MatchCell({ match }: { match: TournamentMatch }) {
- if (match.is_bye) {
- return (
- <div className="flex items-center justify-center h-full opacity-30">
- <span className="text-gray-400 text-xs">BYE</span>
- </div>
- );
- }
+ if (match.is_bye) {
+ return (
+ <div className="flex items-center justify-center h-full opacity-30">
+ <span className="text-gray-400 text-xs">BYE</span>
+ </div>
+ );
+ }
- const p1Color = match.winner_user_id
- ? match.winner_user_id === match.player1?.user_id
- ? "border-pink-700"
- : "border-gray-400"
- : "border-black";
- const p2Color = match.winner_user_id
- ? match.winner_user_id === match.player2?.user_id
- ? "border-pink-700"
- : "border-gray-400"
- : "border-black";
+ const p1Color = match.winner_user_id
+ ? match.winner_user_id === match.player1?.user_id
+ ? "border-pink-700"
+ : "border-gray-400"
+ : "border-black";
+ const p2Color = match.winner_user_id
+ ? match.winner_user_id === match.player2?.user_id
+ ? "border-pink-700"
+ : "border-gray-400"
+ : "border-black";
- return (
- <div className="flex flex-col gap-1 p-1">
- <div
- className={`border-2 ${p1Color} rounded px-2 py-1 text-xs flex justify-between`}
- >
- <span className="truncate">{match.player1?.display_name ?? "?"}</span>
- {match.player1_score !== undefined && (
- <span className="font-bold ml-1">{match.player1_score}</span>
- )}
- </div>
- <div
- className={`border-2 ${p2Color} rounded px-2 py-1 text-xs flex justify-between`}
- >
- <span className="truncate">{match.player2?.display_name ?? "?"}</span>
- {match.player2_score !== undefined && (
- <span className="font-bold ml-1">{match.player2_score}</span>
- )}
- </div>
- </div>
- );
+ return (
+ <div className="flex flex-col gap-1 p-1">
+ <div
+ className={`border-2 ${p1Color} rounded px-2 py-1 text-xs flex justify-between`}
+ >
+ <span className="truncate">{match.player1?.display_name ?? "?"}</span>
+ {match.player1_score !== undefined && (
+ <span className="font-bold ml-1">{match.player1_score}</span>
+ )}
+ </div>
+ <div
+ className={`border-2 ${p2Color} rounded px-2 py-1 text-xs flex justify-between`}
+ >
+ <span className="truncate">{match.player2?.display_name ?? "?"}</span>
+ {match.player2_score !== undefined && (
+ <span className="font-bold ml-1">{match.player2_score}</span>
+ )}
+ </div>
+ </div>
+ );
}
function Connector({
- position,
- colSpan,
- match,
+ position,
+ colSpan,
+ match,
}: {
- position: number;
- colSpan: number;
- match: TournamentMatch | undefined;
+ position: number;
+ colSpan: number;
+ match: TournamentMatch | undefined;
}) {
- const leftHalf = colSpan / 2;
- const rightHalf = colSpan - leftHalf;
+ const leftHalf = colSpan / 2;
+ const rightHalf = colSpan - leftHalf;
- const leftColor = match
- ? getBorderColor(match, match.player1?.user_id)
- : "border-black";
- const rightColor = match
- ? getBorderColor(match, match.player2?.user_id)
- : "border-black";
+ const leftColor = match
+ ? getBorderColor(match, match.player1?.user_id)
+ : "border-black";
+ const rightColor = match
+ ? getBorderColor(match, match.player2?.user_id)
+ : "border-black";
- return (
- <div
- className="grid h-8"
- style={{
- gridColumn: `${position * colSpan + 1} / span ${colSpan}`,
- }}
- >
- <div
- className="grid"
- style={{
- gridTemplateColumns: `repeat(${colSpan}, 1fr)`,
- }}
- >
- <div
- className={`border-t-4 border-r-2 ${leftColor}`}
- style={{ gridColumn: `1 / span ${leftHalf}` }}
- />
- <div
- className={`border-t-4 border-l-2 ${rightColor}`}
- style={{ gridColumn: `${leftHalf + 1} / span ${rightHalf}` }}
- />
- </div>
- </div>
- );
+ return (
+ <div
+ className="grid h-8"
+ style={{
+ gridColumn: `${position * colSpan + 1} / span ${colSpan}`,
+ }}
+ >
+ <div
+ className="grid"
+ style={{
+ gridTemplateColumns: `repeat(${colSpan}, 1fr)`,
+ }}
+ >
+ <div
+ className={`border-t-4 border-r-2 ${leftColor}`}
+ style={{ gridColumn: `1 / span ${leftHalf}` }}
+ />
+ <div
+ className={`border-t-4 border-l-2 ${rightColor}`}
+ style={{ gridColumn: `${leftHalf + 1} / span ${rightHalf}` }}
+ />
+ </div>
+ </div>
+ );
}
function TournamentBracket({ tournament }: { tournament: Tournament }) {
- const { bracket_size, num_rounds, entries, matches } = tournament;
+ const { bracket_size, num_rounds, entries, matches } = tournament;
- const matchByKey = new Map<string, TournamentMatch>();
- for (const m of matches) {
- matchByKey.set(`${m.round}-${m.position}`, m);
- }
+ const matchByKey = new Map<string, TournamentMatch>();
+ for (const m of matches) {
+ matchByKey.set(`${m.round}-${m.position}`, m);
+ }
- const entryBySeed = new Map<number, TournamentEntry>();
- for (const e of entries) {
- entryBySeed.set(e.seed, e);
- }
+ const entryBySeed = new Map<number, TournamentEntry>();
+ for (const e of entries) {
+ entryBySeed.set(e.seed, e);
+ }
- const bracketSeeds = standardBracketSeeds(bracket_size);
+ const bracketSeeds = standardBracketSeeds(bracket_size);
- // Build rows top-to-bottom: final → ... → round 0 → players
- const rows: React.ReactNode[] = [];
+ // Build rows top-to-bottom: final → ... → round 0 → players
+ const rows: React.ReactNode[] = [];
- // Rounds from top (final) to bottom (round 0)
- for (let round = num_rounds - 1; round >= 0; round--) {
- const numPositions = bracket_size / (1 << (round + 1));
- const colSpan = bracket_size / numPositions;
+ // Rounds from top (final) to bottom (round 0)
+ for (let round = num_rounds - 1; round >= 0; round--) {
+ const numPositions = bracket_size / (1 << (round + 1));
+ const colSpan = bracket_size / numPositions;
- // Match cells for this round
- const matchCells: React.ReactNode[] = [];
- for (let pos = 0; pos < numPositions; pos++) {
- const match = matchByKey.get(`${round}-${pos}`);
- matchCells.push(
- <div
- key={`match-${round}-${pos}`}
- style={{
- gridColumn: `${pos * colSpan + 1} / span ${colSpan}`,
- }}
- >
- {match ? <MatchCell match={match} /> : null}
- </div>,
- );
- }
- rows.push(
- <div
- key={`round-${round}`}
- className="grid"
- style={{
- gridTemplateColumns: `repeat(${bracket_size}, 1fr)`,
- }}
- >
- {matchCells}
- </div>,
- );
+ // Match cells for this round
+ const matchCells: React.ReactNode[] = [];
+ for (let pos = 0; pos < numPositions; pos++) {
+ const match = matchByKey.get(`${round}-${pos}`);
+ matchCells.push(
+ <div
+ key={`match-${round}-${pos}`}
+ style={{
+ gridColumn: `${pos * colSpan + 1} / span ${colSpan}`,
+ }}
+ >
+ {match ? <MatchCell match={match} /> : null}
+ </div>,
+ );
+ }
+ rows.push(
+ <div
+ key={`round-${round}`}
+ className="grid"
+ style={{
+ gridTemplateColumns: `repeat(${bracket_size}, 1fr)`,
+ }}
+ >
+ {matchCells}
+ </div>,
+ );
- // Connectors below this round's matches
- const connectors: React.ReactNode[] = [];
- for (let pos = 0; pos < numPositions; pos++) {
- const match = matchByKey.get(`${round}-${pos}`);
- connectors.push(
- <Connector
- key={`conn-${round}-${pos}`}
- position={pos}
- colSpan={colSpan}
- match={match}
- />,
- );
- }
- rows.push(
- <div
- key={`conn-row-${round}`}
- className="grid"
- style={{
- gridTemplateColumns: `repeat(${bracket_size}, 1fr)`,
- }}
- >
- {connectors}
- </div>,
- );
- }
+ // Connectors below this round's matches
+ const connectors: React.ReactNode[] = [];
+ for (let pos = 0; pos < numPositions; pos++) {
+ const match = matchByKey.get(`${round}-${pos}`);
+ connectors.push(
+ <Connector
+ key={`conn-${round}-${pos}`}
+ position={pos}
+ colSpan={colSpan}
+ match={match}
+ />,
+ );
+ }
+ rows.push(
+ <div
+ key={`conn-row-${round}`}
+ className="grid"
+ style={{
+ gridTemplateColumns: `repeat(${bracket_size}, 1fr)`,
+ }}
+ >
+ {connectors}
+ </div>,
+ );
+ }
- // Player cards row (bottom)
- const playerCards: React.ReactNode[] = [];
- for (let slot = 0; slot < bracket_size; slot++) {
- const seed = bracketSeeds[slot]!;
- const entry = entryBySeed.get(seed);
- playerCards.push(
- <div
- key={`player-${slot}`}
- style={{ gridColumn: `${slot + 1} / span 1` }}
- >
- <PlayerCard entry={entry} />
- </div>,
- );
- }
- rows.push(
- <div
- key="players"
- className="grid gap-1"
- style={{ gridTemplateColumns: `repeat(${bracket_size}, 1fr)` }}
- >
- {playerCards}
- </div>,
- );
+ // Player cards row (bottom)
+ const playerCards: React.ReactNode[] = [];
+ for (let slot = 0; slot < bracket_size; slot++) {
+ const seed = bracketSeeds[slot]!;
+ const entry = entryBySeed.get(seed);
+ playerCards.push(
+ <div
+ key={`player-${slot}`}
+ style={{ gridColumn: `${slot + 1} / span 1` }}
+ >
+ <PlayerCard entry={entry} />
+ </div>,
+ );
+ }
+ rows.push(
+ <div
+ key="players"
+ className="grid gap-1"
+ style={{ gridTemplateColumns: `repeat(${bracket_size}, 1fr)` }}
+ >
+ {playerCards}
+ </div>,
+ );
- return <div className="flex flex-col gap-0">{rows}</div>;
+ return <div className="flex flex-col gap-0">{rows}</div>;
}
// Exported for testing as standardBracketSeedsForTest
export { standardBracketSeeds as standardBracketSeedsForTest };
function standardBracketSeeds(bracketSize: number): number[] {
- const seeds = new Array<number>(bracketSize).fill(0);
- seeds[0] = 1;
- for (let size = 2; size <= bracketSize; size *= 2) {
- const temp = new Array<number>(size).fill(0);
- for (let i = 0; i < size / 2; i++) {
- temp[i * 2] = seeds[i]!;
- temp[i * 2 + 1] = size + 1 - seeds[i]!;
- }
- for (let i = 0; i < size; i++) {
- seeds[i] = temp[i]!;
- }
- }
- return seeds;
+ const seeds = new Array<number>(bracketSize).fill(0);
+ seeds[0] = 1;
+ for (let size = 2; size <= bracketSize; size *= 2) {
+ const temp = new Array<number>(size).fill(0);
+ for (let i = 0; i < size / 2; i++) {
+ temp[i * 2] = seeds[i]!;
+ temp[i * 2 + 1] = size + 1 - seeds[i]!;
+ }
+ for (let i = 0; i < size; i++) {
+ seeds[i] = temp[i]!;
+ }
+ }
+ return seeds;
}
export default function TournamentPage({
- tournamentId,
+ tournamentId,
}: {
- tournamentId: string;
+ tournamentId: string;
}) {
- usePageTitle(`Tournament | ${APP_NAME}`);
+ usePageTitle(`Tournament | ${APP_NAME}`);
- const id = Number(tournamentId);
- const isValidId = id > 0;
+ const id = Number(tournamentId);
+ const isValidId = id > 0;
- const [tournament, setTournament] = useState<Tournament | null>(null);
- const [loading, setLoading] = useState(isValidId);
- const [error, setError] = useState<string | null>(
- isValidId ? null : "Invalid tournament ID",
- );
+ const [tournament, setTournament] = useState<Tournament | null>(null);
+ const [loading, setLoading] = useState(isValidId);
+ const [error, setError] = useState<string | null>(
+ isValidId ? null : "Invalid tournament ID",
+ );
- useEffect(() => {
- if (!isValidId) {
- return;
- }
+ useEffect(() => {
+ if (!isValidId) {
+ return;
+ }
- const apiClient = createApiClient();
- apiClient
- .getTournament(id)
- .then(({ tournament }) => setTournament(tournament))
- .catch(() => setError("Failed to load tournament"))
- .finally(() => setLoading(false));
- }, [id, isValidId]);
+ const apiClient = createApiClient();
+ apiClient
+ .getTournament(id)
+ .then(({ tournament }) => setTournament(tournament))
+ .catch(() => setError("Failed to load tournament"))
+ .finally(() => setLoading(false));
+ }, [id, isValidId]);
- if (loading) {
- return (
- <div className="min-h-screen bg-gray-100 flex items-center justify-center">
- <p className="text-gray-500">Loading...</p>
- </div>
- );
- }
+ if (loading) {
+ return (
+ <div className="min-h-screen bg-gray-100 flex items-center justify-center">
+ <p className="text-gray-500">Loading...</p>
+ </div>
+ );
+ }
- if (error || !tournament) {
- return (
- <div className="min-h-screen bg-gray-100 flex items-center justify-center">
- <p className="text-red-500">{error || "Failed to load tournament"}</p>
- </div>
- );
- }
+ if (error || !tournament) {
+ return (
+ <div className="min-h-screen bg-gray-100 flex items-center justify-center">
+ <p className="text-red-500">{error || "Failed to load tournament"}</p>
+ </div>
+ );
+ }
- return (
- <div className="p-6 bg-gray-100 min-h-screen">
- <div className="max-w-6xl mx-auto">
- <h1 className="text-3xl font-bold text-transparent bg-clip-text bg-brand-600 text-center mb-8">
- {tournament.display_name}
- </h1>
- <TournamentBracket tournament={tournament} />
- </div>
- </div>
- );
+ return (
+ <div className="p-6 bg-gray-100 min-h-screen">
+ <div className="max-w-6xl mx-auto">
+ <h1 className="text-3xl font-bold text-transparent bg-clip-text bg-brand-600 text-center mb-8">
+ {tournament.display_name}
+ </h1>
+ <TournamentBracket tournament={tournament} />
+ </div>
+ </div>
+ );
}
diff --git a/frontend/app/shiki.css b/frontend/app/shiki.css
index 9e456fe..746d63a 100644
--- a/frontend/app/shiki.css
+++ b/frontend/app/shiki.css
@@ -1,19 +1,19 @@
.shiki {
- white-space: pre-wrap;
+ white-space: pre-wrap;
}
/* https://github.com/shikijs/shiki/issues/3 */
.shiki code {
- counter-reset: line-number;
- counter-increment: line-number 0;
+ counter-reset: line-number;
+ counter-increment: line-number 0;
}
.shiki code .line::before {
- content: counter(line-number);
- counter-increment: line-number;
- width: 2rem;
- margin-right: 1.5rem;
- display: inline-block;
- text-align: right;
- color: #aaa;
+ content: counter(line-number);
+ counter-increment: line-number;
+ width: 2rem;
+ margin-right: 1.5rem;
+ display: inline-block;
+ text-align: right;
+ color: #aaa;
}
diff --git a/frontend/app/states/play.test.ts b/frontend/app/states/play.test.ts
index 0f73039..299cb22 100644
--- a/frontend/app/states/play.test.ts
+++ b/frontend/app/states/play.test.ts
@@ -1,194 +1,194 @@
import { createStore } from "jotai";
import { describe, expect, test } from "vitest";
import {
- calcCodeSize,
- gameStateKindAtom,
- gamingLeftTimeSecondsAtom,
- handleSubmitCodePostAtom,
- handleSubmitCodePreAtom,
- scoreAtom,
- setCurrentTimestampAtom,
- setDurationSecondsAtom,
- setGameStartedAtAtom,
- setLatestGameStateAtom,
- startingLeftTimeSecondsAtom,
- statusAtom,
+ calcCodeSize,
+ gameStateKindAtom,
+ gamingLeftTimeSecondsAtom,
+ handleSubmitCodePostAtom,
+ handleSubmitCodePreAtom,
+ scoreAtom,
+ setCurrentTimestampAtom,
+ setDurationSecondsAtom,
+ setGameStartedAtAtom,
+ setLatestGameStateAtom,
+ startingLeftTimeSecondsAtom,
+ statusAtom,
} from "./play";
describe("calcCodeSize", () => {
- test("counts UTF-8 bytes after removing whitespace (swift)", () => {
- expect(calcCodeSize("print(1)", "swift")).toBe(8);
- });
+ test("counts UTF-8 bytes after removing whitespace (swift)", () => {
+ expect(calcCodeSize("print(1)", "swift")).toBe(8);
+ });
- test("removes all whitespace for swift", () => {
- expect(calcCodeSize("print( 1 )\n", "swift")).toBe(8);
- });
+ test("removes all whitespace for swift", () => {
+ expect(calcCodeSize("print( 1 )\n", "swift")).toBe(8);
+ });
- test("removes <?php tag for php", () => {
- expect(calcCodeSize("<?php echo 1;", "php")).toBe(6);
- });
+ test("removes <?php tag for php", () => {
+ expect(calcCodeSize("<?php echo 1;", "php")).toBe(6);
+ });
- test("removes <? short tag for php", () => {
- expect(calcCodeSize("<? echo 1;", "php")).toBe(6);
- });
+ test("removes <? short tag for php", () => {
+ expect(calcCodeSize("<? echo 1;", "php")).toBe(6);
+ });
- test("removes ?> closing tag for php", () => {
- expect(calcCodeSize("<?php echo 1;?>", "php")).toBe(6);
- });
+ test("removes ?> closing tag for php", () => {
+ expect(calcCodeSize("<?php echo 1;?>", "php")).toBe(6);
+ });
- test("removes whitespace and tags together for php", () => {
- expect(calcCodeSize("<?php\n echo 1; \n?>", "php")).toBe(6);
- });
+ test("removes whitespace and tags together for php", () => {
+ expect(calcCodeSize("<?php\n echo 1; \n?>", "php")).toBe(6);
+ });
- test("returns 0 for empty string", () => {
- expect(calcCodeSize("", "swift")).toBe(0);
- });
+ test("returns 0 for empty string", () => {
+ expect(calcCodeSize("", "swift")).toBe(0);
+ });
- test("counts multi-byte characters correctly", () => {
- // "あ" is 3 bytes in UTF-8
- expect(calcCodeSize("あ", "swift")).toBe(3);
- });
+ test("counts multi-byte characters correctly", () => {
+ // "あ" is 3 bytes in UTF-8
+ expect(calcCodeSize("あ", "swift")).toBe(3);
+ });
- test("php with only tags and whitespace returns 0", () => {
- expect(calcCodeSize("<?php ?>", "php")).toBe(0);
- });
+ test("php with only tags and whitespace returns 0", () => {
+ expect(calcCodeSize("<?php ?>", "php")).toBe(0);
+ });
});
describe("Jotai atoms", () => {
- test("gameStateKindAtom returns 'loading' initially", () => {
- const store = createStore();
- expect(store.get(gameStateKindAtom)).toBe("loading");
- });
+ test("gameStateKindAtom returns 'loading' initially", () => {
+ const store = createStore();
+ expect(store.get(gameStateKindAtom)).toBe("loading");
+ });
- test("gameStateKindAtom returns 'waiting' when timestamp set but no startedAt", () => {
- const store = createStore();
- store.set(setCurrentTimestampAtom);
- expect(store.get(gameStateKindAtom)).toBe("waiting");
- });
+ test("gameStateKindAtom returns 'waiting' when timestamp set but no startedAt", () => {
+ const store = createStore();
+ store.set(setCurrentTimestampAtom);
+ expect(store.get(gameStateKindAtom)).toBe("waiting");
+ });
- test("gameStateKindAtom returns 'starting' when now < startedAt", () => {
- const store = createStore();
- const now = Math.floor(Date.now() / 1000);
- store.set(setCurrentTimestampAtom);
- store.set(setGameStartedAtAtom, now + 60);
- store.set(setDurationSecondsAtom, 300);
- expect(store.get(gameStateKindAtom)).toBe("starting");
- });
+ test("gameStateKindAtom returns 'starting' when now < startedAt", () => {
+ const store = createStore();
+ const now = Math.floor(Date.now() / 1000);
+ store.set(setCurrentTimestampAtom);
+ store.set(setGameStartedAtAtom, now + 60);
+ store.set(setDurationSecondsAtom, 300);
+ expect(store.get(gameStateKindAtom)).toBe("starting");
+ });
- test("gameStateKindAtom returns 'gaming' when now >= startedAt and now < finishedAt", () => {
- const store = createStore();
- const now = Math.floor(Date.now() / 1000);
- store.set(setCurrentTimestampAtom);
- store.set(setGameStartedAtAtom, now - 10);
- store.set(setDurationSecondsAtom, 300);
- expect(store.get(gameStateKindAtom)).toBe("gaming");
- });
+ test("gameStateKindAtom returns 'gaming' when now >= startedAt and now < finishedAt", () => {
+ const store = createStore();
+ const now = Math.floor(Date.now() / 1000);
+ store.set(setCurrentTimestampAtom);
+ store.set(setGameStartedAtAtom, now - 10);
+ store.set(setDurationSecondsAtom, 300);
+ expect(store.get(gameStateKindAtom)).toBe("gaming");
+ });
- test("gameStateKindAtom returns 'finished' when now >= finishedAt", () => {
- const store = createStore();
- const now = Math.floor(Date.now() / 1000);
- store.set(setCurrentTimestampAtom);
- store.set(setGameStartedAtAtom, now - 400);
- store.set(setDurationSecondsAtom, 300);
- expect(store.get(gameStateKindAtom)).toBe("finished");
- });
+ test("gameStateKindAtom returns 'finished' when now >= finishedAt", () => {
+ const store = createStore();
+ const now = Math.floor(Date.now() / 1000);
+ store.set(setCurrentTimestampAtom);
+ store.set(setGameStartedAtAtom, now - 400);
+ store.set(setDurationSecondsAtom, 300);
+ expect(store.get(gameStateKindAtom)).toBe("finished");
+ });
- test("startingLeftTimeSecondsAtom returns null when startedAt is null", () => {
- const store = createStore();
- expect(store.get(startingLeftTimeSecondsAtom)).toBeNull();
- });
+ test("startingLeftTimeSecondsAtom returns null when startedAt is null", () => {
+ const store = createStore();
+ expect(store.get(startingLeftTimeSecondsAtom)).toBeNull();
+ });
- test("startingLeftTimeSecondsAtom returns null when currentTimestamp is null", () => {
- const store = createStore();
- store.set(setGameStartedAtAtom, 1000);
- expect(store.get(startingLeftTimeSecondsAtom)).toBeNull();
- });
+ test("startingLeftTimeSecondsAtom returns null when currentTimestamp is null", () => {
+ const store = createStore();
+ store.set(setGameStartedAtAtom, 1000);
+ expect(store.get(startingLeftTimeSecondsAtom)).toBeNull();
+ });
- test("startingLeftTimeSecondsAtom returns remaining time before start", () => {
- const store = createStore();
- const now = Math.floor(Date.now() / 1000);
- store.set(setCurrentTimestampAtom);
- store.set(setGameStartedAtAtom, now + 30);
- const leftTime = store.get(startingLeftTimeSecondsAtom);
- expect(leftTime).toBeGreaterThanOrEqual(29);
- expect(leftTime).toBeLessThanOrEqual(30);
- });
+ test("startingLeftTimeSecondsAtom returns remaining time before start", () => {
+ const store = createStore();
+ const now = Math.floor(Date.now() / 1000);
+ store.set(setCurrentTimestampAtom);
+ store.set(setGameStartedAtAtom, now + 30);
+ const leftTime = store.get(startingLeftTimeSecondsAtom);
+ expect(leftTime).toBeGreaterThanOrEqual(29);
+ expect(leftTime).toBeLessThanOrEqual(30);
+ });
- test("startingLeftTimeSecondsAtom returns 0 when past start time", () => {
- const store = createStore();
- const now = Math.floor(Date.now() / 1000);
- store.set(setCurrentTimestampAtom);
- store.set(setGameStartedAtAtom, now - 10);
- expect(store.get(startingLeftTimeSecondsAtom)).toBe(0);
- });
+ test("startingLeftTimeSecondsAtom returns 0 when past start time", () => {
+ const store = createStore();
+ const now = Math.floor(Date.now() / 1000);
+ store.set(setCurrentTimestampAtom);
+ store.set(setGameStartedAtAtom, now - 10);
+ expect(store.get(startingLeftTimeSecondsAtom)).toBe(0);
+ });
- test("gamingLeftTimeSecondsAtom returns null when startedAt is null", () => {
- const store = createStore();
- expect(store.get(gamingLeftTimeSecondsAtom)).toBeNull();
- });
+ test("gamingLeftTimeSecondsAtom returns null when startedAt is null", () => {
+ const store = createStore();
+ expect(store.get(gamingLeftTimeSecondsAtom)).toBeNull();
+ });
- test("gamingLeftTimeSecondsAtom returns remaining game time", () => {
- const store = createStore();
- const now = Math.floor(Date.now() / 1000);
- store.set(setCurrentTimestampAtom);
- store.set(setGameStartedAtAtom, now - 10);
- store.set(setDurationSecondsAtom, 300);
- const leftTime = store.get(gamingLeftTimeSecondsAtom);
- expect(leftTime).toBeGreaterThanOrEqual(289);
- expect(leftTime).toBeLessThanOrEqual(290);
- });
+ test("gamingLeftTimeSecondsAtom returns remaining game time", () => {
+ const store = createStore();
+ const now = Math.floor(Date.now() / 1000);
+ store.set(setCurrentTimestampAtom);
+ store.set(setGameStartedAtAtom, now - 10);
+ store.set(setDurationSecondsAtom, 300);
+ const leftTime = store.get(gamingLeftTimeSecondsAtom);
+ expect(leftTime).toBeGreaterThanOrEqual(289);
+ expect(leftTime).toBeLessThanOrEqual(290);
+ });
- test("gamingLeftTimeSecondsAtom clamps to 0 when game is over", () => {
- const store = createStore();
- const now = Math.floor(Date.now() / 1000);
- store.set(setCurrentTimestampAtom);
- store.set(setGameStartedAtAtom, now - 400);
- store.set(setDurationSecondsAtom, 300);
- expect(store.get(gamingLeftTimeSecondsAtom)).toBe(0);
- });
+ test("gamingLeftTimeSecondsAtom clamps to 0 when game is over", () => {
+ const store = createStore();
+ const now = Math.floor(Date.now() / 1000);
+ store.set(setCurrentTimestampAtom);
+ store.set(setGameStartedAtAtom, now - 400);
+ store.set(setDurationSecondsAtom, 300);
+ expect(store.get(gamingLeftTimeSecondsAtom)).toBe(0);
+ });
- test("gamingLeftTimeSecondsAtom clamps to duration before start", () => {
- const store = createStore();
- const now = Math.floor(Date.now() / 1000);
- store.set(setCurrentTimestampAtom);
- store.set(setGameStartedAtAtom, now + 60);
- store.set(setDurationSecondsAtom, 300);
- expect(store.get(gamingLeftTimeSecondsAtom)).toBe(300);
- });
+ test("gamingLeftTimeSecondsAtom clamps to duration before start", () => {
+ const store = createStore();
+ const now = Math.floor(Date.now() / 1000);
+ store.set(setCurrentTimestampAtom);
+ store.set(setGameStartedAtAtom, now + 60);
+ store.set(setDurationSecondsAtom, 300);
+ expect(store.get(gamingLeftTimeSecondsAtom)).toBe(300);
+ });
- test("statusAtom returns 'running' when submitting code", () => {
- const store = createStore();
- store.set(handleSubmitCodePreAtom);
- expect(store.get(statusAtom)).toBe("running");
- });
+ test("statusAtom returns 'running' when submitting code", () => {
+ const store = createStore();
+ store.set(handleSubmitCodePreAtom);
+ expect(store.get(statusAtom)).toBe("running");
+ });
- test("statusAtom returns raw status when not submitting", () => {
- const store = createStore();
- expect(store.get(statusAtom)).toBe("none");
- });
+ test("statusAtom returns raw status when not submitting", () => {
+ const store = createStore();
+ expect(store.get(statusAtom)).toBe("none");
+ });
- test("handleSubmitCodePostAtom resets submitting state", () => {
- const store = createStore();
- store.set(handleSubmitCodePreAtom);
- expect(store.get(statusAtom)).toBe("running");
- store.set(handleSubmitCodePostAtom);
- expect(store.get(statusAtom)).toBe("none");
- });
+ test("handleSubmitCodePostAtom resets submitting state", () => {
+ const store = createStore();
+ store.set(handleSubmitCodePreAtom);
+ expect(store.get(statusAtom)).toBe("running");
+ store.set(handleSubmitCodePostAtom);
+ expect(store.get(statusAtom)).toBe("none");
+ });
- test("setLatestGameStateAtom updates status and score", () => {
- const store = createStore();
- store.set(setLatestGameStateAtom, {
- code: "",
- status: "success",
- score: 42,
- best_score_submitted_at: null,
- });
- expect(store.get(statusAtom)).toBe("success");
- expect(store.get(scoreAtom)).toBe(42);
- });
+ test("setLatestGameStateAtom updates status and score", () => {
+ const store = createStore();
+ store.set(setLatestGameStateAtom, {
+ code: "",
+ status: "success",
+ score: 42,
+ best_score_submitted_at: null,
+ });
+ expect(store.get(statusAtom)).toBe("success");
+ expect(store.get(scoreAtom)).toBe(42);
+ });
- test("scoreAtom returns null initially", () => {
- const store = createStore();
- expect(store.get(scoreAtom)).toBeNull();
- });
+ test("scoreAtom returns null initially", () => {
+ const store = createStore();
+ expect(store.get(scoreAtom)).toBeNull();
+ });
});
diff --git a/frontend/app/states/play.ts b/frontend/app/states/play.ts
index 22d338c..a8a4727 100644
--- a/frontend/app/states/play.ts
+++ b/frontend/app/states/play.ts
@@ -4,121 +4,121 @@ import type { SupportedLanguage } from "../types/SupportedLanguage";
const gameStartedAtAtom = atom<number | null>(null);
export const setGameStartedAtAtom = atom(null, (_, set, value: number | null) =>
- set(gameStartedAtAtom, value),
+ set(gameStartedAtAtom, value),
);
export type GameStateKind =
- | "loading"
- | "waiting"
- | "starting"
- | "gaming"
- | "finished";
+ | "loading"
+ | "waiting"
+ | "starting"
+ | "gaming"
+ | "finished";
type ExecutionStatus = components["schemas"]["ExecutionStatus"];
type LatestGameState = components["schemas"]["LatestGameState"];
export const gameStateKindAtom = atom<GameStateKind>((get) => {
- const now = get(currentTimestampAtom);
- if (!now) {
- return "loading";
- }
- const startedAt = get(gameStartedAtAtom);
- if (!startedAt) {
- return "waiting";
- }
- const durationSeconds = get(durationSecondsAtom);
- const finishedAt = startedAt + durationSeconds;
- if (now < startedAt) {
- return "starting";
- } else if (now < finishedAt) {
- return "gaming";
- } else {
- return "finished";
- }
+ const now = get(currentTimestampAtom);
+ if (!now) {
+ return "loading";
+ }
+ const startedAt = get(gameStartedAtAtom);
+ if (!startedAt) {
+ return "waiting";
+ }
+ const durationSeconds = get(durationSecondsAtom);
+ const finishedAt = startedAt + durationSeconds;
+ if (now < startedAt) {
+ return "starting";
+ } else if (now < finishedAt) {
+ return "gaming";
+ } else {
+ return "finished";
+ }
});
const currentTimestampAtom = atom<number | null>(null);
export const setCurrentTimestampAtom = atom(null, (_, set) =>
- set(currentTimestampAtom, Math.floor(Date.now() / 1000)),
+ set(currentTimestampAtom, Math.floor(Date.now() / 1000)),
);
const durationSecondsAtom = atom<number>(0);
export const setDurationSecondsAtom = atom(null, (_, set, value: number) =>
- set(durationSecondsAtom, value),
+ set(durationSecondsAtom, value),
);
export const startingLeftTimeSecondsAtom = atom<number | null>((get) => {
- const startedAt = get(gameStartedAtAtom);
- if (startedAt === null) {
- return null;
- }
- const currentTimestamp = get(currentTimestampAtom);
- if (currentTimestamp === null) {
- return null;
- }
- return Math.max(0, startedAt - currentTimestamp);
+ const startedAt = get(gameStartedAtAtom);
+ if (startedAt === null) {
+ return null;
+ }
+ const currentTimestamp = get(currentTimestampAtom);
+ if (currentTimestamp === null) {
+ return null;
+ }
+ return Math.max(0, startedAt - currentTimestamp);
});
export const gamingLeftTimeSecondsAtom = atom<number | null>((get) => {
- const startedAt = get(gameStartedAtAtom);
- if (startedAt === null) {
- return null;
- }
- const durationSeconds = get(durationSecondsAtom);
- const finishedAt = startedAt + durationSeconds;
- const currentTimestamp = get(currentTimestampAtom);
- if (currentTimestamp === null) {
- return null;
- }
- return Math.min(durationSeconds, Math.max(0, finishedAt - currentTimestamp));
+ const startedAt = get(gameStartedAtAtom);
+ if (startedAt === null) {
+ return null;
+ }
+ const durationSeconds = get(durationSecondsAtom);
+ const finishedAt = startedAt + durationSeconds;
+ const currentTimestamp = get(currentTimestampAtom);
+ if (currentTimestamp === null) {
+ return null;
+ }
+ return Math.min(durationSeconds, Math.max(0, finishedAt - currentTimestamp));
});
const rawStatusAtom = atom<ExecutionStatus>("none");
const rawScoreAtom = atom<number | null>(null);
export const statusAtom = atom<ExecutionStatus>((get) => {
- const isSubmittingCode = get(isSubmittingCodeAtom);
- if (isSubmittingCode) {
- return "running";
- } else {
- return get(rawStatusAtom);
- }
+ const isSubmittingCode = get(isSubmittingCodeAtom);
+ if (isSubmittingCode) {
+ return "running";
+ } else {
+ return get(rawStatusAtom);
+ }
});
export const scoreAtom = atom<number | null>((get) => {
- return get(rawScoreAtom);
+ return get(rawScoreAtom);
});
const isSubmittingCodeAtom = atom(false);
export const handleSubmitCodePreAtom = atom(null, (_, set) => {
- set(isSubmittingCodeAtom, true);
+ set(isSubmittingCodeAtom, true);
});
export const handleSubmitCodePostAtom = atom(null, (_, set) => {
- set(isSubmittingCodeAtom, false);
+ set(isSubmittingCodeAtom, false);
});
export const setLatestGameStateAtom = atom(
- null,
- (_, set, value: LatestGameState) => {
- set(rawStatusAtom, value.status);
- set(rawScoreAtom, value.score);
- },
+ null,
+ (_, set, value: LatestGameState) => {
+ set(rawStatusAtom, value.status);
+ set(rawScoreAtom, value.score);
+ },
);
function cleanCode(code: string, language: SupportedLanguage) {
- if (language === "php") {
- return code
- .replace(/\s+/g, "")
- .replace(/^<\?php/, "")
- .replace(/^<\?/, "")
- .replace(/\?>$/, "");
- } else {
- return code.replace(/\s+/g, "");
- }
+ if (language === "php") {
+ return code
+ .replace(/\s+/g, "")
+ .replace(/^<\?php/, "")
+ .replace(/^<\?/, "")
+ .replace(/\?>$/, "");
+ } else {
+ return code.replace(/\s+/g, "");
+ }
}
export function calcCodeSize(
- code: string,
- language: SupportedLanguage,
+ code: string,
+ language: SupportedLanguage,
): number {
- const trimmed = cleanCode(code, language);
- const utf8Encoded = new TextEncoder().encode(trimmed);
- return utf8Encoded.length;
+ const trimmed = cleanCode(code, language);
+ const utf8Encoded = new TextEncoder().encode(trimmed);
+ return utf8Encoded.length;
}
diff --git a/frontend/app/states/watch.test.ts b/frontend/app/states/watch.test.ts
index dae1cb9..db33c87 100644
--- a/frontend/app/states/watch.test.ts
+++ b/frontend/app/states/watch.test.ts
@@ -1,206 +1,206 @@
import { createStore } from "jotai";
import { describe, expect, test } from "vitest";
import {
- calcCodeSize,
- checkGameResultKind,
- gameStateKindAtom,
- gamingLeftTimeSecondsAtom,
- latestGameStatesAtom,
- rankingAtom,
- setCurrentTimestampAtom,
- setDurationSecondsAtom,
- setGameStartedAtAtom,
- setLatestGameStatesAtom,
- startingLeftTimeSecondsAtom,
+ calcCodeSize,
+ checkGameResultKind,
+ gameStateKindAtom,
+ gamingLeftTimeSecondsAtom,
+ latestGameStatesAtom,
+ rankingAtom,
+ setCurrentTimestampAtom,
+ setDurationSecondsAtom,
+ setGameStartedAtAtom,
+ setLatestGameStatesAtom,
+ startingLeftTimeSecondsAtom,
} from "./watch";
describe("checkGameResultKind", () => {
- test("returns null when game is not finished", () => {
- expect(checkGameResultKind("gaming", null, null)).toBeNull();
- expect(checkGameResultKind("waiting", null, null)).toBeNull();
- expect(checkGameResultKind("starting", null, null)).toBeNull();
- expect(checkGameResultKind("loading", null, null)).toBeNull();
- });
+ test("returns null when game is not finished", () => {
+ expect(checkGameResultKind("gaming", null, null)).toBeNull();
+ expect(checkGameResultKind("waiting", null, null)).toBeNull();
+ expect(checkGameResultKind("starting", null, null)).toBeNull();
+ expect(checkGameResultKind("loading", null, null)).toBeNull();
+ });
- test("returns draw when both scores are null", () => {
- expect(checkGameResultKind("finished", null, null)).toBe("draw");
- });
+ test("returns draw when both scores are null", () => {
+ expect(checkGameResultKind("finished", null, null)).toBe("draw");
+ });
- test("returns draw when both states have null scores", () => {
- const stateA = {
- code: "",
- status: "none" as const,
- score: null,
- best_score_submitted_at: null,
- };
- const stateB = {
- code: "",
- status: "none" as const,
- score: null,
- best_score_submitted_at: null,
- };
- expect(checkGameResultKind("finished", stateA, stateB)).toBe("draw");
- });
+ test("returns draw when both states have null scores", () => {
+ const stateA = {
+ code: "",
+ status: "none" as const,
+ score: null,
+ best_score_submitted_at: null,
+ };
+ const stateB = {
+ code: "",
+ status: "none" as const,
+ score: null,
+ best_score_submitted_at: null,
+ };
+ expect(checkGameResultKind("finished", stateA, stateB)).toBe("draw");
+ });
- test("returns winB when only A has null score", () => {
- const stateA = {
- code: "",
- status: "none" as const,
- score: null,
- best_score_submitted_at: null,
- };
- const stateB = {
- code: "echo 1;",
- status: "success" as const,
- score: 10,
- best_score_submitted_at: 1000,
- };
- expect(checkGameResultKind("finished", stateA, stateB)).toBe("winB");
- });
+ test("returns winB when only A has null score", () => {
+ const stateA = {
+ code: "",
+ status: "none" as const,
+ score: null,
+ best_score_submitted_at: null,
+ };
+ const stateB = {
+ code: "echo 1;",
+ status: "success" as const,
+ score: 10,
+ best_score_submitted_at: 1000,
+ };
+ expect(checkGameResultKind("finished", stateA, stateB)).toBe("winB");
+ });
- test("returns winA when only B has null score", () => {
- const stateA = {
- code: "echo 1;",
- status: "success" as const,
- score: 10,
- best_score_submitted_at: 1000,
- };
- const stateB = {
- code: "",
- status: "none" as const,
- score: null,
- best_score_submitted_at: null,
- };
- expect(checkGameResultKind("finished", stateA, stateB)).toBe("winA");
- });
+ test("returns winA when only B has null score", () => {
+ const stateA = {
+ code: "echo 1;",
+ status: "success" as const,
+ score: 10,
+ best_score_submitted_at: 1000,
+ };
+ const stateB = {
+ code: "",
+ status: "none" as const,
+ score: null,
+ best_score_submitted_at: null,
+ };
+ expect(checkGameResultKind("finished", stateA, stateB)).toBe("winA");
+ });
- test("returns winA when A has lower score (code golf)", () => {
- const stateA = {
- code: "a",
- status: "success" as const,
- score: 5,
- best_score_submitted_at: 1000,
- };
- const stateB = {
- code: "abcdefghij",
- status: "success" as const,
- score: 10,
- best_score_submitted_at: 1000,
- };
- expect(checkGameResultKind("finished", stateA, stateB)).toBe("winA");
- });
+ test("returns winA when A has lower score (code golf)", () => {
+ const stateA = {
+ code: "a",
+ status: "success" as const,
+ score: 5,
+ best_score_submitted_at: 1000,
+ };
+ const stateB = {
+ code: "abcdefghij",
+ status: "success" as const,
+ score: 10,
+ best_score_submitted_at: 1000,
+ };
+ expect(checkGameResultKind("finished", stateA, stateB)).toBe("winA");
+ });
- test("returns winB when B has lower score (code golf)", () => {
- const stateA = {
- code: "abcdefghij",
- status: "success" as const,
- score: 10,
- best_score_submitted_at: 1000,
- };
- const stateB = {
- code: "a",
- status: "success" as const,
- score: 5,
- best_score_submitted_at: 1000,
- };
- expect(checkGameResultKind("finished", stateA, stateB)).toBe("winB");
- });
+ test("returns winB when B has lower score (code golf)", () => {
+ const stateA = {
+ code: "abcdefghij",
+ status: "success" as const,
+ score: 10,
+ best_score_submitted_at: 1000,
+ };
+ const stateB = {
+ code: "a",
+ status: "success" as const,
+ score: 5,
+ best_score_submitted_at: 1000,
+ };
+ expect(checkGameResultKind("finished", stateA, stateB)).toBe("winB");
+ });
- test("breaks tie by earlier submission time - A wins", () => {
- const stateA = {
- code: "echo 1;",
- status: "success" as const,
- score: 10,
- best_score_submitted_at: 1000,
- };
- const stateB = {
- code: "echo 1;",
- status: "success" as const,
- score: 10,
- best_score_submitted_at: 1060,
- };
- expect(checkGameResultKind("finished", stateA, stateB)).toBe("winA");
- });
+ test("breaks tie by earlier submission time - A wins", () => {
+ const stateA = {
+ code: "echo 1;",
+ status: "success" as const,
+ score: 10,
+ best_score_submitted_at: 1000,
+ };
+ const stateB = {
+ code: "echo 1;",
+ status: "success" as const,
+ score: 10,
+ best_score_submitted_at: 1060,
+ };
+ expect(checkGameResultKind("finished", stateA, stateB)).toBe("winA");
+ });
- test("breaks tie by earlier submission time - B wins", () => {
- const stateA = {
- code: "echo 1;",
- status: "success" as const,
- score: 10,
- best_score_submitted_at: 1060,
- };
- const stateB = {
- code: "echo 1;",
- status: "success" as const,
- score: 10,
- best_score_submitted_at: 1000,
- };
- expect(checkGameResultKind("finished", stateA, stateB)).toBe("winB");
- });
+ test("breaks tie by earlier submission time - B wins", () => {
+ const stateA = {
+ code: "echo 1;",
+ status: "success" as const,
+ score: 10,
+ best_score_submitted_at: 1060,
+ };
+ const stateB = {
+ code: "echo 1;",
+ status: "success" as const,
+ score: 10,
+ best_score_submitted_at: 1000,
+ };
+ expect(checkGameResultKind("finished", stateA, stateB)).toBe("winB");
+ });
});
describe("watch calcCodeSize", () => {
- test("works the same as play calcCodeSize", () => {
- expect(calcCodeSize("<?php echo 1;", "php")).toBe(6);
- expect(calcCodeSize("print(1)", "swift")).toBe(8);
- });
+ test("works the same as play calcCodeSize", () => {
+ expect(calcCodeSize("<?php echo 1;", "php")).toBe(6);
+ expect(calcCodeSize("print(1)", "swift")).toBe(8);
+ });
});
describe("watch Jotai atoms", () => {
- test("gameStateKindAtom returns 'loading' initially", () => {
- const store = createStore();
- expect(store.get(gameStateKindAtom)).toBe("loading");
- });
+ test("gameStateKindAtom returns 'loading' initially", () => {
+ const store = createStore();
+ expect(store.get(gameStateKindAtom)).toBe("loading");
+ });
- test("gameStateKindAtom transitions through states correctly", () => {
- const store = createStore();
- const now = Math.floor(Date.now() / 1000);
+ test("gameStateKindAtom transitions through states correctly", () => {
+ const store = createStore();
+ const now = Math.floor(Date.now() / 1000);
- store.set(setCurrentTimestampAtom);
- expect(store.get(gameStateKindAtom)).toBe("waiting");
+ store.set(setCurrentTimestampAtom);
+ expect(store.get(gameStateKindAtom)).toBe("waiting");
- store.set(setGameStartedAtAtom, now + 60);
- store.set(setDurationSecondsAtom, 300);
- expect(store.get(gameStateKindAtom)).toBe("starting");
+ store.set(setGameStartedAtAtom, now + 60);
+ store.set(setDurationSecondsAtom, 300);
+ expect(store.get(gameStateKindAtom)).toBe("starting");
- store.set(setGameStartedAtAtom, now - 10);
- expect(store.get(gameStateKindAtom)).toBe("gaming");
+ store.set(setGameStartedAtAtom, now - 10);
+ expect(store.get(gameStateKindAtom)).toBe("gaming");
- store.set(setGameStartedAtAtom, now - 400);
- expect(store.get(gameStateKindAtom)).toBe("finished");
- });
+ store.set(setGameStartedAtAtom, now - 400);
+ expect(store.get(gameStateKindAtom)).toBe("finished");
+ });
- test("rankingAtom is empty initially", () => {
- const store = createStore();
- expect(store.get(rankingAtom)).toEqual([]);
- });
+ test("rankingAtom is empty initially", () => {
+ const store = createStore();
+ expect(store.get(rankingAtom)).toEqual([]);
+ });
- test("latestGameStatesAtom is empty initially", () => {
- const store = createStore();
- expect(store.get(latestGameStatesAtom)).toEqual({});
- });
+ test("latestGameStatesAtom is empty initially", () => {
+ const store = createStore();
+ expect(store.get(latestGameStatesAtom)).toEqual({});
+ });
- test("setLatestGameStatesAtom updates states", () => {
- const store = createStore();
- const states = {
- player1: {
- code: "echo 1;",
- status: "success" as const,
- score: 10,
- best_score_submitted_at: 1000,
- },
- };
- store.set(setLatestGameStatesAtom, states);
- expect(store.get(latestGameStatesAtom)).toEqual(states);
- });
+ test("setLatestGameStatesAtom updates states", () => {
+ const store = createStore();
+ const states = {
+ player1: {
+ code: "echo 1;",
+ status: "success" as const,
+ score: 10,
+ best_score_submitted_at: 1000,
+ },
+ };
+ store.set(setLatestGameStatesAtom, states);
+ expect(store.get(latestGameStatesAtom)).toEqual(states);
+ });
- test("startingLeftTimeSecondsAtom returns null initially", () => {
- const store = createStore();
- expect(store.get(startingLeftTimeSecondsAtom)).toBeNull();
- });
+ test("startingLeftTimeSecondsAtom returns null initially", () => {
+ const store = createStore();
+ expect(store.get(startingLeftTimeSecondsAtom)).toBeNull();
+ });
- test("gamingLeftTimeSecondsAtom returns null initially", () => {
- const store = createStore();
- expect(store.get(gamingLeftTimeSecondsAtom)).toBeNull();
- });
+ test("gamingLeftTimeSecondsAtom returns null initially", () => {
+ const store = createStore();
+ expect(store.get(gamingLeftTimeSecondsAtom)).toBeNull();
+ });
});
diff --git a/frontend/app/states/watch.ts b/frontend/app/states/watch.ts
index 50fa425..3431b6d 100644
--- a/frontend/app/states/watch.ts
+++ b/frontend/app/states/watch.ts
@@ -4,136 +4,136 @@ import type { SupportedLanguage } from "../types/SupportedLanguage";
const gameStartedAtAtom = atom<number | null>(null);
export const setGameStartedAtAtom = atom(null, (_, set, value: number | null) =>
- set(gameStartedAtAtom, value),
+ set(gameStartedAtAtom, value),
);
export type GameStateKind =
- | "loading"
- | "waiting"
- | "starting"
- | "gaming"
- | "finished";
+ | "loading"
+ | "waiting"
+ | "starting"
+ | "gaming"
+ | "finished";
type LatestGameState = components["schemas"]["LatestGameState"];
type RankingEntry = components["schemas"]["RankingEntry"];
export const gameStateKindAtom = atom<GameStateKind>((get) => {
- const now = get(currentTimestampAtom);
- if (!now) {
- return "loading";
- }
- const startedAt = get(gameStartedAtAtom);
- if (!startedAt) {
- return "waiting";
- }
- const durationSeconds = get(durationSecondsAtom);
- const finishedAt = startedAt + durationSeconds;
- if (now < startedAt) {
- return "starting";
- } else if (now < finishedAt) {
- return "gaming";
- } else {
- return "finished";
- }
+ const now = get(currentTimestampAtom);
+ if (!now) {
+ return "loading";
+ }
+ const startedAt = get(gameStartedAtAtom);
+ if (!startedAt) {
+ return "waiting";
+ }
+ const durationSeconds = get(durationSecondsAtom);
+ const finishedAt = startedAt + durationSeconds;
+ if (now < startedAt) {
+ return "starting";
+ } else if (now < finishedAt) {
+ return "gaming";
+ } else {
+ return "finished";
+ }
});
const currentTimestampAtom = atom<number | null>(null);
export const setCurrentTimestampAtom = atom(null, (_, set) =>
- set(currentTimestampAtom, Math.floor(Date.now() / 1000)),
+ set(currentTimestampAtom, Math.floor(Date.now() / 1000)),
);
const durationSecondsAtom = atom<number>(0);
export const setDurationSecondsAtom = atom(null, (_, set, value: number) =>
- set(durationSecondsAtom, value),
+ set(durationSecondsAtom, value),
);
export const startingLeftTimeSecondsAtom = atom<number | null>((get) => {
- const startedAt = get(gameStartedAtAtom);
- if (startedAt === null) {
- return null;
- }
- const currentTimestamp = get(currentTimestampAtom);
- if (currentTimestamp === null) {
- return null;
- }
- return Math.max(0, startedAt - currentTimestamp);
+ const startedAt = get(gameStartedAtAtom);
+ if (startedAt === null) {
+ return null;
+ }
+ const currentTimestamp = get(currentTimestampAtom);
+ if (currentTimestamp === null) {
+ return null;
+ }
+ return Math.max(0, startedAt - currentTimestamp);
});
export const gamingLeftTimeSecondsAtom = atom<number | null>((get) => {
- const startedAt = get(gameStartedAtAtom);
- if (startedAt === null) {
- return null;
- }
- const durationSeconds = get(durationSecondsAtom);
- const finishedAt = startedAt + durationSeconds;
- const currentTimestamp = get(currentTimestampAtom);
- if (currentTimestamp === null) {
- return null;
- }
- return Math.min(durationSeconds, Math.max(0, finishedAt - currentTimestamp));
+ const startedAt = get(gameStartedAtAtom);
+ if (startedAt === null) {
+ return null;
+ }
+ const durationSeconds = get(durationSecondsAtom);
+ const finishedAt = startedAt + durationSeconds;
+ const currentTimestamp = get(currentTimestampAtom);
+ if (currentTimestamp === null) {
+ return null;
+ }
+ return Math.min(durationSeconds, Math.max(0, finishedAt - currentTimestamp));
});
export const rankingAtom = atom<RankingEntry[]>([]);
const rawLatestGameStatesAtom = atom<{
- [key: string]: LatestGameState | undefined;
+ [key: string]: LatestGameState | undefined;
}>({});
export const latestGameStatesAtom = atom((get) => get(rawLatestGameStatesAtom));
export const setLatestGameStatesAtom = atom(
- null,
- (_, set, value: { [key: string]: LatestGameState | undefined }) => {
- set(rawLatestGameStatesAtom, value);
- },
+ null,
+ (_, set, value: { [key: string]: LatestGameState | undefined }) => {
+ set(rawLatestGameStatesAtom, value);
+ },
);
function cleanCode(code: string, language: SupportedLanguage) {
- if (language === "php") {
- return code
- .replace(/\s+/g, "")
- .replace(/^<\?php/, "")
- .replace(/^<\?/, "")
- .replace(/\?>$/, "");
- } else {
- return code.replace(/\s+/g, "");
- }
+ if (language === "php") {
+ return code
+ .replace(/\s+/g, "")
+ .replace(/^<\?php/, "")
+ .replace(/^<\?/, "")
+ .replace(/\?>$/, "");
+ } else {
+ return code.replace(/\s+/g, "");
+ }
}
export function calcCodeSize(
- code: string,
- language: SupportedLanguage,
+ code: string,
+ language: SupportedLanguage,
): number {
- const trimmed = cleanCode(code, language);
- const utf8Encoded = new TextEncoder().encode(trimmed);
- return utf8Encoded.length;
+ const trimmed = cleanCode(code, language);
+ const utf8Encoded = new TextEncoder().encode(trimmed);
+ return utf8Encoded.length;
}
export type GameResultKind = "winA" | "winB" | "draw";
export function checkGameResultKind(
- gameStateKind: GameStateKind,
- stateA: LatestGameState | null,
- stateB: LatestGameState | null,
+ gameStateKind: GameStateKind,
+ stateA: LatestGameState | null,
+ stateB: LatestGameState | null,
): GameResultKind | null {
- if (gameStateKind !== "finished") {
- return null;
- }
+ if (gameStateKind !== "finished") {
+ return null;
+ }
- const scoreA = stateA?.score;
- const scoreB = stateB?.score;
- if (scoreA == null && scoreB == null) {
- return "draw";
- }
- if (scoreA == null) {
- return "winB";
- }
- if (scoreB == null) {
- return "winA";
- }
- if (scoreA === scoreB) {
- // If score is non-null, state and best_score_submitted_at should also be non-null.
- const submittedAtA = stateA!.best_score_submitted_at!;
- const submittedAtB = stateB!.best_score_submitted_at!;
- return submittedAtA < submittedAtB ? "winA" : "winB";
- } else {
- return scoreA < scoreB ? "winA" : "winB";
- }
+ const scoreA = stateA?.score;
+ const scoreB = stateB?.score;
+ if (scoreA == null && scoreB == null) {
+ return "draw";
+ }
+ if (scoreA == null) {
+ return "winB";
+ }
+ if (scoreB == null) {
+ return "winA";
+ }
+ if (scoreA === scoreB) {
+ // If score is non-null, state and best_score_submitted_at should also be non-null.
+ const submittedAtA = stateA!.best_score_submitted_at!;
+ const submittedAtB = stateB!.best_score_submitted_at!;
+ return submittedAtA < submittedAtB ? "winA" : "winB";
+ } else {
+ return scoreA < scoreB ? "winA" : "winB";
+ }
}
diff --git a/frontend/app/tailwind.css b/frontend/app/tailwind.css
index 4bcb95e..8243231 100644
--- a/frontend/app/tailwind.css
+++ b/frontend/app/tailwind.css
@@ -1,15 +1,15 @@
@import "tailwindcss";
@theme {
- --text-10xl: 16rem;
- --color-brand-50: #fdf2f7;
- --color-brand-100: #fce7f1;
- --color-brand-200: #fbcfe4;
- --color-brand-300: #f9a8d0;
- --color-brand-400: #f472b0;
- --color-brand-500: #e8458c;
- --color-brand-600: #a0185d;
- --color-brand-700: #8b1350;
- --color-brand-800: #6e1041;
- --color-brand-900: #5c1239;
+ --text-10xl: 16rem;
+ --color-brand-50: #fdf2f7;
+ --color-brand-100: #fce7f1;
+ --color-brand-200: #fbcfe4;
+ --color-brand-300: #f9a8d0;
+ --color-brand-400: #f472b0;
+ --color-brand-500: #e8458c;
+ --color-brand-600: #a0185d;
+ --color-brand-700: #8b1350;
+ --color-brand-800: #6e1041;
+ --color-brand-900: #5c1239;
}
diff --git a/frontend/app/types/PlayerProfile.ts b/frontend/app/types/PlayerProfile.ts
index 2e9c16a..3ca0896 100644
--- a/frontend/app/types/PlayerProfile.ts
+++ b/frontend/app/types/PlayerProfile.ts
@@ -1,5 +1,5 @@
export type PlayerProfile = {
- id: number;
- displayName: string;
- iconPath: string | null;
+ id: number;
+ displayName: string;
+ iconPath: string | null;
};
diff --git a/frontend/biome.json b/frontend/biome.json
index 0d71d59..dec378e 100644
--- a/frontend/biome.json
+++ b/frontend/biome.json
@@ -1,43 +1,43 @@
{
- "$schema": "https://biomejs.dev/schemas/2.3.15/schema.json",
- "vcs": {
- "enabled": true,
- "clientKind": "git",
- "useIgnoreFile": true
- },
- "files": {
- "ignoreUnknown": false,
- "includes": [
- "**",
- "!.cache",
- "!app/api/schema.d.ts",
- "!app/shiki.bundle.ts",
- "!dist"
- ]
- },
- "formatter": {
- "enabled": true,
- "indentStyle": "tab"
- },
- "assist": {
- "actions": {
- "source": {
- "organizeImports": "on"
- }
- }
- },
- "linter": {
- "enabled": false
- },
- "javascript": {
- "formatter": {
- "quoteStyle": "double"
- }
- },
- "css": {
- "parser": {
- "cssModules": true,
- "tailwindDirectives": true
- }
- }
+ "$schema": "https://biomejs.dev/schemas/2.3.15/schema.json",
+ "vcs": {
+ "enabled": true,
+ "clientKind": "git",
+ "useIgnoreFile": true
+ },
+ "files": {
+ "ignoreUnknown": false,
+ "includes": [
+ "**",
+ "!.cache",
+ "!app/api/schema.d.ts",
+ "!app/shiki.bundle.ts",
+ "!dist"
+ ]
+ },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "space"
+ },
+ "assist": {
+ "actions": {
+ "source": {
+ "organizeImports": "on"
+ }
+ }
+ },
+ "linter": {
+ "enabled": false
+ },
+ "javascript": {
+ "formatter": {
+ "quoteStyle": "double"
+ }
+ },
+ "css": {
+ "parser": {
+ "cssModules": true,
+ "tailwindDirectives": true
+ }
+ }
}
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index 900096f..bd43dfa 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -7,27 +7,27 @@ import globals from "globals";
import ts from "typescript-eslint";
export default defineConfig(
- globalIgnores(["node_modules/", "dist/"]),
- js.configs.recommended,
- ts.configs.recommended,
- react.configs.flat.recommended,
- react.configs.flat["jsx-runtime"],
- reactHooks.configs.flat["recommended-latest"],
- jsxA11y.flatConfigs.recommended,
- {
- languageOptions: {
- globals: {
- ...globals.browser,
- ...globals.node,
- },
- },
- },
- {
- settings: {
- react: {
- version: "detect",
- },
- linkComponents: [{ name: "Link", linkAttribute: "to" }],
- },
- },
+ globalIgnores(["node_modules/", "dist/"]),
+ js.configs.recommended,
+ ts.configs.recommended,
+ react.configs.flat.recommended,
+ react.configs.flat["jsx-runtime"],
+ reactHooks.configs.flat["recommended-latest"],
+ jsxA11y.flatConfigs.recommended,
+ {
+ languageOptions: {
+ globals: {
+ ...globals.browser,
+ ...globals.node,
+ },
+ },
+ },
+ {
+ settings: {
+ react: {
+ version: "detect",
+ },
+ linkComponents: [{ name: "Link", linkAttribute: "to" }],
+ },
+ },
);
diff --git a/frontend/package.json b/frontend/package.json
index 62c8547..9d913cb 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,61 +1,61 @@
{
- "name": "albatross-2026-frontend",
- "private": true,
- "sideEffects": false,
- "type": "module",
- "scripts": {
- "build": "vite build",
- "check": "npm run check:biome && npm run check:ts && npm run check:eslint",
- "check:biome": "biome check --write",
- "check:eslint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
- "check:ts": "tsc --noEmit",
- "dev": "vite",
- "test": "vitest run",
- "openapi-typescript": "openapi-typescript --output ./app/api/schema.d.ts ../openapi/api-server.yaml",
- "shiki-codegen": "shiki-codegen --langs php,swift --themes github-light --engine javascript ./app/shiki.bundle.ts"
- },
- "dependencies": {
- "@base-ui-components/react": "^1.0.0-rc.0",
- "@fortawesome/fontawesome-svg-core": "^7.2.0",
- "@fortawesome/free-solid-svg-icons": "^7.2.0",
- "@fortawesome/react-fontawesome": "^3.2.0",
- "hast-util-to-jsx-runtime": "^2.3.6",
- "jotai": "^2.17.1",
- "openapi-fetch": "^0.17.0",
- "react": "^19.2.4",
- "react-dom": "^19.2.4",
- "react-use-precision-timer": "^3.5.6",
- "shiki": "^3.22.0",
- "use-debounce": "^10.1.0",
- "wouter": "^3.9.0"
- },
- "devDependencies": {
- "@biomejs/biome": "^2.3.15",
- "@eslint/js": "^9.39.2",
- "@tailwindcss/postcss": "^4.1.18",
- "@tailwindcss/vite": "^4.1.18",
- "@testing-library/react": "^16.3.2",
- "@types/node": "^25.2.3",
- "@types/react": "^19.2.14",
- "@types/react-dom": "^19.2.3",
- "@vitejs/plugin-react": "^5.1.4",
- "eslint": "^9.39.2",
- "eslint-plugin-jsx-a11y": "^6.10.2",
- "eslint-plugin-react": "^7.37.5",
- "eslint-plugin-react-hooks": "^7.0.1",
- "globals": "^17.3.0",
- "jsdom": "^28.1.0",
- "openapi-typescript": "^7.13.0",
- "rollup-plugin-visualizer": "^6.0.5",
- "shiki-codegen": "^3.22.0",
- "tailwindcss": "^4.1.18",
- "typescript": "^5.9.3",
- "typescript-eslint": "^8.55.0",
- "vite": "^7.3.1",
- "vite-tsconfig-paths": "^6.1.1",
- "vitest": "^4.0.18"
- },
- "engines": {
- "node": ">=22.0.0"
- }
+ "name": "albatross-2026-frontend",
+ "private": true,
+ "sideEffects": false,
+ "type": "module",
+ "scripts": {
+ "build": "vite build",
+ "check": "npm run check:biome && npm run check:ts && npm run check:eslint",
+ "check:biome": "biome check --write",
+ "check:eslint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
+ "check:ts": "tsc --noEmit",
+ "dev": "vite",
+ "test": "vitest run",
+ "openapi-typescript": "openapi-typescript --output ./app/api/schema.d.ts ../openapi/api-server.yaml",
+ "shiki-codegen": "shiki-codegen --langs php,swift --themes github-light --engine javascript ./app/shiki.bundle.ts"
+ },
+ "dependencies": {
+ "@base-ui-components/react": "^1.0.0-rc.0",
+ "@fortawesome/fontawesome-svg-core": "^7.2.0",
+ "@fortawesome/free-solid-svg-icons": "^7.2.0",
+ "@fortawesome/react-fontawesome": "^3.2.0",
+ "hast-util-to-jsx-runtime": "^2.3.6",
+ "jotai": "^2.17.1",
+ "openapi-fetch": "^0.17.0",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "react-use-precision-timer": "^3.5.6",
+ "shiki": "^3.22.0",
+ "use-debounce": "^10.1.0",
+ "wouter": "^3.9.0"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "^2.3.15",
+ "@eslint/js": "^9.39.2",
+ "@tailwindcss/postcss": "^4.1.18",
+ "@tailwindcss/vite": "^4.1.18",
+ "@testing-library/react": "^16.3.2",
+ "@types/node": "^25.2.3",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.1.4",
+ "eslint": "^9.39.2",
+ "eslint-plugin-jsx-a11y": "^6.10.2",
+ "eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "globals": "^17.3.0",
+ "jsdom": "^28.1.0",
+ "openapi-typescript": "^7.13.0",
+ "rollup-plugin-visualizer": "^6.0.5",
+ "shiki-codegen": "^3.22.0",
+ "tailwindcss": "^4.1.18",
+ "typescript": "^5.9.3",
+ "typescript-eslint": "^8.55.0",
+ "vite": "^7.3.1",
+ "vite-tsconfig-paths": "^6.1.1",
+ "vitest": "^4.0.18"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
index 017b34b..c2ddf74 100644
--- a/frontend/postcss.config.js
+++ b/frontend/postcss.config.js
@@ -1,5 +1,5 @@
export default {
- plugins: {
- "@tailwindcss/postcss": {},
- },
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
};
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 41d9f36..ffdf56c 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -1,24 +1,24 @@
{
- "include": ["**/*"],
- "compilerOptions": {
- "lib": ["DOM", "DOM.Iterable", "ES2022"],
- "types": ["node", "vite/client"],
- "isolatedModules": true,
- "esModuleInterop": true,
- "jsx": "react-jsx",
- "module": "ESNext",
- "moduleResolution": "bundler",
- "resolveJsonModule": true,
- "target": "ES2022",
- "strict": true,
- "noUncheckedIndexedAccess": true,
- "allowJs": true,
- "skipLibCheck": true,
- "forceConsistentCasingInFileNames": true,
- "baseUrl": ".",
- "paths": {
- "~/*": ["./app/*"]
- },
- "noEmit": true
- }
+ "include": ["**/*"],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "types": ["node", "vite/client"],
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "target": "ES2022",
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "allowJs": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ },
+ "noEmit": true
+ }
}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 00bb8c5..a2fbe3f 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -5,6 +5,6 @@ import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
- base: process.env.ALBATROSS_BASE_PATH || "/",
- plugins: [tailwindcss(), react(), tsconfigPaths(), visualizer()],
+ base: process.env.ALBATROSS_BASE_PATH || "/",
+ plugins: [tailwindcss(), react(), tsconfigPaths(), visualizer()],
});
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
index ab1f2c3..8862852 100644
--- a/frontend/vitest.config.ts
+++ b/frontend/vitest.config.ts
@@ -3,8 +3,8 @@ import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";
export default defineConfig({
- plugins: [react(), tsconfigPaths()],
- test: {
- include: ["app/**/*.test.{ts,tsx}"],
- },
+ plugins: [react(), tsconfigPaths()],
+ test: {
+ include: ["app/**/*.test.{ts,tsx}"],
+ },
});