diff options
85 files changed, 3403 insertions, 3403 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 & ~E_WARNING & ~E_NOTICE & ~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 & ~E_WARNING & ~E_NOTICE & ~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}"], + }, }); diff --git a/worker/php/biome.json b/worker/php/biome.json index 7f791c6..f0aaa73 100644 --- a/worker/php/biome.json +++ b/worker/php/biome.json @@ -1,34 +1,34 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.15/schema.json", - "vcs": { - "enabled": false, - "clientKind": "git", - "useIgnoreFile": false - }, - "files": { - "ignoreUnknown": false, - "includes": ["**"] - }, - "formatter": { - "enabled": true, - "indentStyle": "tab" - }, - "assist": { - "actions": { - "source": { - "organizeImports": "on" - } - } - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true - } - }, - "javascript": { - "formatter": { - "quoteStyle": "double" - } - } + "$schema": "https://biomejs.dev/schemas/2.3.15/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "includes": ["**"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space" + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } } diff --git a/worker/php/exec.mjs b/worker/php/exec.mjs index 7403dfc..f1cb361 100644 --- a/worker/php/exec.mjs +++ b/worker/php/exec.mjs @@ -2,22 +2,22 @@ import { buildResult, createIOCallbacks, preprocessCode } from "./lib.mjs"; import PHPWasm from "./php-wasm.js"; process.once("message", async ({ code: originalCode, input }) => { - const code = preprocessCode(originalCode); - const io = createIOCallbacks(input); + const code = preprocessCode(originalCode); + const io = createIOCallbacks(input); - const { ccall } = await PHPWasm({ - stdin: io.stdin, - stdout: io.stdout, - stderr: io.stderr, - }); + const { ccall } = await PHPWasm({ + stdin: io.stdin, + stdout: io.stdout, + stderr: io.stderr, + }); - let err; - let result; - try { - result = ccall("php_wasm_run", "number", ["string"], [code]); - } catch (e) { - err = e; - } + let err; + let result; + try { + result = ccall("php_wasm_run", "number", ["string"], [code]); + } catch (e) { + err = e; + } - process.send(buildResult(err, result, io.getStdout, io.getStderr)); + process.send(buildResult(err, result, io.getStdout, io.getStderr)); }); diff --git a/worker/php/index.mjs b/worker/php/index.mjs index 9950ffa..c2bd818 100644 --- a/worker/php/index.mjs +++ b/worker/php/index.mjs @@ -3,37 +3,37 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; const execPhp = (code, input, timeoutMsec) => { - return new Promise((resolve, _reject) => { - const proc = fork("./exec.mjs"); + return new Promise((resolve, _reject) => { + const proc = fork("./exec.mjs"); - proc.send({ code, input }); + proc.send({ code, input }); - proc.on("message", (result) => { - resolve(result); - proc.kill(); - }); + proc.on("message", (result) => { + resolve(result); + proc.kill(); + }); - setTimeout(() => { - resolve({ - status: "timeout", - stdout: "", - stderr: `Time Limit Exceeded: ${timeoutMsec} msec`, - }); - proc.kill(); - }, timeoutMsec); - }); + setTimeout(() => { + resolve({ + status: "timeout", + stdout: "", + stderr: `Time Limit Exceeded: ${timeoutMsec} msec`, + }); + proc.kill(); + }, timeoutMsec); + }); }; const app = new Hono(); app.post("/exec", async (c) => { - console.log("worker/exec"); - const { code, stdin, max_duration_ms } = await c.req.json(); - const result = await execPhp(code, stdin, max_duration_ms); - return c.json(result); + console.log("worker/exec"); + const { code, stdin, max_duration_ms } = await c.req.json(); + const result = await execPhp(code, stdin, max_duration_ms); + return c.json(result); }); serve({ - fetch: app.fetch, - port: 80, + fetch: app.fetch, + port: 80, }); diff --git a/worker/php/lib.mjs b/worker/php/lib.mjs index 4a34733..d877856 100644 --- a/worker/php/lib.mjs +++ b/worker/php/lib.mjs @@ -10,70 +10,70 @@ const PRELUDE = ` const BUFFER_MAX = 10 * 1024; export function preprocessCode(originalCode) { - if (originalCode.startsWith("<?php")) { - return PRELUDE + originalCode.slice(5); - } - if (originalCode.startsWith("<?")) { - return PRELUDE + originalCode.slice(2); - } - return PRELUDE + originalCode; + if (originalCode.startsWith("<?php")) { + return PRELUDE + originalCode.slice(5); + } + if (originalCode.startsWith("<?")) { + return PRELUDE + originalCode.slice(2); + } + return PRELUDE + originalCode; } export function createIOCallbacks(input) { - let stdinPos = 0; - const stdinBuf = Buffer.from(input); - let stdoutPos = 0; - const stdoutBuf = Buffer.alloc(BUFFER_MAX); - let stderrPos = 0; - const stderrBuf = Buffer.alloc(BUFFER_MAX); + let stdinPos = 0; + const stdinBuf = Buffer.from(input); + let stdoutPos = 0; + const stdoutBuf = Buffer.alloc(BUFFER_MAX); + let stderrPos = 0; + const stderrBuf = Buffer.alloc(BUFFER_MAX); - return { - stdin: () => { - if (stdinBuf.length <= stdinPos) { - return null; - } - return stdinBuf.readUInt8(stdinPos++); - }, - stdout: (asciiCode) => { - if (asciiCode === null) { - return; - } - if (BUFFER_MAX <= stdoutPos) { - return; - } - stdoutBuf.writeUInt8( - asciiCode < 0 ? asciiCode + 256 : asciiCode, - stdoutPos++, - ); - }, - stderr: (asciiCode) => { - if (asciiCode === null) { - return; - } - if (BUFFER_MAX <= stderrPos) { - return; - } - stderrBuf.writeUInt8( - asciiCode < 0 ? asciiCode + 256 : asciiCode, - stderrPos++, - ); - }, - getStdout: () => stdoutBuf.subarray(0, stdoutPos).toString(), - getStderr: () => stderrBuf.subarray(0, stderrPos).toString(), - }; + return { + stdin: () => { + if (stdinBuf.length <= stdinPos) { + return null; + } + return stdinBuf.readUInt8(stdinPos++); + }, + stdout: (asciiCode) => { + if (asciiCode === null) { + return; + } + if (BUFFER_MAX <= stdoutPos) { + return; + } + stdoutBuf.writeUInt8( + asciiCode < 0 ? asciiCode + 256 : asciiCode, + stdoutPos++, + ); + }, + stderr: (asciiCode) => { + if (asciiCode === null) { + return; + } + if (BUFFER_MAX <= stderrPos) { + return; + } + stderrBuf.writeUInt8( + asciiCode < 0 ? asciiCode + 256 : asciiCode, + stderrPos++, + ); + }, + getStdout: () => stdoutBuf.subarray(0, stdoutPos).toString(), + getStderr: () => stderrBuf.subarray(0, stderrPos).toString(), + }; } export function buildResult(err, ccallResult, getStdout, getStderr) { - if (err) { - return { - status: "runtime_error", - stdout: getStdout(), - stderr: `${getStderr()}\n${err.toString()}`, - }; - } - return { - status: ccallResult === 0 ? "success" : "runtime_error", - stdout: getStdout(), - stderr: getStderr(), - }; + if (err) { + return { + status: "runtime_error", + stdout: getStdout(), + stderr: `${getStderr()}\n${err.toString()}`, + }; + } + return { + status: ccallResult === 0 ? "success" : "runtime_error", + stdout: getStdout(), + stderr: getStderr(), + }; } diff --git a/worker/php/lib.test.mjs b/worker/php/lib.test.mjs index d4f420f..ef01852 100644 --- a/worker/php/lib.test.mjs +++ b/worker/php/lib.test.mjs @@ -2,164 +2,164 @@ import { describe, expect, it } from "vitest"; import { buildResult, createIOCallbacks, preprocessCode } from "./lib.mjs"; describe("preprocessCode", () => { - it("removes <?php tag and prepends PRELUDE", () => { - const result = preprocessCode('<?php echo "hello";'); - expect(result).toContain('echo "hello";'); - expect(result).toContain("error_reporting"); - expect(result).not.toContain("<?php"); - }); + it("removes <?php tag and prepends PRELUDE", () => { + const result = preprocessCode('<?php echo "hello";'); + expect(result).toContain('echo "hello";'); + expect(result).toContain("error_reporting"); + expect(result).not.toContain("<?php"); + }); - it("removes <? short tag and prepends PRELUDE", () => { - const result = preprocessCode('<? echo "hello";'); - expect(result).toContain('echo "hello";'); - expect(result).toContain("error_reporting"); - expect(result).not.toContain("<?"); - }); + it("removes <? short tag and prepends PRELUDE", () => { + const result = preprocessCode('<? echo "hello";'); + expect(result).toContain('echo "hello";'); + expect(result).toContain("error_reporting"); + expect(result).not.toContain("<?"); + }); - it("prepends PRELUDE when no php tag present", () => { - const result = preprocessCode('echo "hello";'); - expect(result).toContain('echo "hello";'); - expect(result).toContain("error_reporting"); - }); + it("prepends PRELUDE when no php tag present", () => { + const result = preprocessCode('echo "hello";'); + expect(result).toContain('echo "hello";'); + expect(result).toContain("error_reporting"); + }); - it("handles empty string", () => { - const result = preprocessCode(""); - expect(result).toContain("error_reporting"); - }); + it("handles empty string", () => { + const result = preprocessCode(""); + expect(result).toContain("error_reporting"); + }); - it("does not remove <?php when not at the start", () => { - const result = preprocessCode('echo "x"; <?php echo "y";'); - expect(result).toContain("<?php"); - }); + it("does not remove <?php when not at the start", () => { + const result = preprocessCode('echo "x"; <?php echo "y";'); + expect(result).toContain("<?php"); + }); }); describe("createIOCallbacks", () => { - describe("stdin", () => { - it("reads input byte by byte", () => { - const io = createIOCallbacks("AB"); - expect(io.stdin()).toBe(65); // 'A' - expect(io.stdin()).toBe(66); // 'B' - }); + describe("stdin", () => { + it("reads input byte by byte", () => { + const io = createIOCallbacks("AB"); + expect(io.stdin()).toBe(65); // 'A' + expect(io.stdin()).toBe(66); // 'B' + }); - it("returns null at EOF", () => { - const io = createIOCallbacks("A"); - io.stdin(); // consume 'A' - expect(io.stdin()).toBeNull(); - expect(io.stdin()).toBeNull(); - }); + it("returns null at EOF", () => { + const io = createIOCallbacks("A"); + io.stdin(); // consume 'A' + expect(io.stdin()).toBeNull(); + expect(io.stdin()).toBeNull(); + }); - it("returns null immediately for empty input", () => { - const io = createIOCallbacks(""); - expect(io.stdin()).toBeNull(); - }); - }); + it("returns null immediately for empty input", () => { + const io = createIOCallbacks(""); + expect(io.stdin()).toBeNull(); + }); + }); - describe("stdout", () => { - it("captures ASCII writes", () => { - const io = createIOCallbacks(""); - io.stdout(72); // 'H' - io.stdout(105); // 'i' - expect(io.getStdout()).toBe("Hi"); - }); + describe("stdout", () => { + it("captures ASCII writes", () => { + const io = createIOCallbacks(""); + io.stdout(72); // 'H' + io.stdout(105); // 'i' + expect(io.getStdout()).toBe("Hi"); + }); - it("ignores null (flush)", () => { - const io = createIOCallbacks(""); - io.stdout(65); - io.stdout(null); - io.stdout(66); - expect(io.getStdout()).toBe("AB"); - }); + it("ignores null (flush)", () => { + const io = createIOCallbacks(""); + io.stdout(65); + io.stdout(null); + io.stdout(66); + expect(io.getStdout()).toBe("AB"); + }); - it("corrects negative asciiCode by adding 256", () => { - const io = createIOCallbacks(""); - // -191 + 256 = 65 = 'A' - io.stdout(-191); - expect(io.getStdout()).toBe("A"); - }); + it("corrects negative asciiCode by adding 256", () => { + const io = createIOCallbacks(""); + // -191 + 256 = 65 = 'A' + io.stdout(-191); + expect(io.getStdout()).toBe("A"); + }); - it("truncates output at 10KB buffer limit", () => { - const io = createIOCallbacks(""); - const limit = 10 * 1024; - for (let i = 0; i < limit + 100; i++) { - io.stdout(65); - } - expect(io.getStdout().length).toBe(limit); - }); - }); + it("truncates output at 10KB buffer limit", () => { + const io = createIOCallbacks(""); + const limit = 10 * 1024; + for (let i = 0; i < limit + 100; i++) { + io.stdout(65); + } + expect(io.getStdout().length).toBe(limit); + }); + }); - describe("stderr", () => { - it("captures ASCII writes", () => { - const io = createIOCallbacks(""); - io.stderr(69); // 'E' - io.stderr(114); // 'r' - expect(io.getStderr()).toBe("Er"); - }); + describe("stderr", () => { + it("captures ASCII writes", () => { + const io = createIOCallbacks(""); + io.stderr(69); // 'E' + io.stderr(114); // 'r' + expect(io.getStderr()).toBe("Er"); + }); - it("ignores null (flush)", () => { - const io = createIOCallbacks(""); - io.stderr(65); - io.stderr(null); - expect(io.getStderr()).toBe("A"); - }); + it("ignores null (flush)", () => { + const io = createIOCallbacks(""); + io.stderr(65); + io.stderr(null); + expect(io.getStderr()).toBe("A"); + }); - it("corrects negative asciiCode by adding 256", () => { - const io = createIOCallbacks(""); - // -156 + 256 = 100 = 'd' - io.stderr(-156); - expect(io.getStderr()).toBe("d"); - }); + it("corrects negative asciiCode by adding 256", () => { + const io = createIOCallbacks(""); + // -156 + 256 = 100 = 'd' + io.stderr(-156); + expect(io.getStderr()).toBe("d"); + }); - it("truncates output at 10KB buffer limit", () => { - const io = createIOCallbacks(""); - const limit = 10 * 1024; - for (let i = 0; i < limit + 100; i++) { - io.stderr(65); - } - expect(io.getStderr().length).toBe(limit); - }); - }); + it("truncates output at 10KB buffer limit", () => { + const io = createIOCallbacks(""); + const limit = 10 * 1024; + for (let i = 0; i < limit + 100; i++) { + io.stderr(65); + } + expect(io.getStderr().length).toBe(limit); + }); + }); }); describe("buildResult", () => { - it("returns success when err is null and result is 0", () => { - const result = buildResult( - null, - 0, - () => "out", - () => "", - ); - expect(result).toEqual({ - status: "success", - stdout: "out", - stderr: "", - }); - }); + it("returns success when err is null and result is 0", () => { + const result = buildResult( + null, + 0, + () => "out", + () => "", + ); + expect(result).toEqual({ + status: "success", + stdout: "out", + stderr: "", + }); + }); - it("returns runtime_error when result is non-zero", () => { - const result = buildResult( - null, - 1, - () => "out", - () => "err", - ); - expect(result).toEqual({ - status: "runtime_error", - stdout: "out", - stderr: "err", - }); - }); + it("returns runtime_error when result is non-zero", () => { + const result = buildResult( + null, + 1, + () => "out", + () => "err", + ); + expect(result).toEqual({ + status: "runtime_error", + stdout: "out", + stderr: "err", + }); + }); - it("returns runtime_error with concatenated stderr when err is thrown", () => { - const err = new Error("fatal"); - const result = buildResult( - err, - undefined, - () => "out", - () => "err", - ); - expect(result.status).toBe("runtime_error"); - expect(result.stdout).toBe("out"); - expect(result.stderr).toContain("err"); - expect(result.stderr).toContain("Error: fatal"); - }); + it("returns runtime_error with concatenated stderr when err is thrown", () => { + const err = new Error("fatal"); + const result = buildResult( + err, + undefined, + () => "out", + () => "err", + ); + expect(result.status).toBe("runtime_error"); + expect(result.stdout).toBe("out"); + expect(result.stderr).toContain("err"); + expect(result.stderr).toContain("Error: fatal"); + }); }); diff --git a/worker/php/package.json b/worker/php/package.json index 12127bd..2430199 100644 --- a/worker/php/package.json +++ b/worker/php/package.json @@ -1,19 +1,19 @@ { - "name": "albatross-2026-worker-php", - "private": true, - "type": "module", - "main": "index.mjs", - "scripts": { - "check": "npm run check:biome", - "check:biome": "biome check --write", - "test": "vitest run" - }, - "dependencies": { - "@hono/node-server": "^1.19.9", - "hono": "^4.11.9" - }, - "devDependencies": { - "@biomejs/biome": "^2.3.15", - "vitest": "^3.2.1" - } + "name": "albatross-2026-worker-php", + "private": true, + "type": "module", + "main": "index.mjs", + "scripts": { + "check": "npm run check:biome", + "check:biome": "biome check --write", + "test": "vitest run" + }, + "dependencies": { + "@hono/node-server": "^1.19.9", + "hono": "^4.11.9" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.15", + "vitest": "^3.2.1" + } } diff --git a/worker/php/vitest.config.mjs b/worker/php/vitest.config.mjs index def6022..77a73cf 100644 --- a/worker/php/vitest.config.mjs +++ b/worker/php/vitest.config.mjs @@ -1,5 +1,5 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ - test: {}, + test: {}, }); |
