From 20213560f0ee8fa0d6bf2d589465ab4605fb9bb8 Mon Sep 17 00:00:00 2001 From: helgeovs Date: Fri, 30 Oct 2026 11:38:38 +0100 Subject: [PATCH 1/9] Changed team size to 12. Made format default to SV. --- frontend/src/App.test.tsx | 48 ++++++++++++++++++++ frontend/src/assets/pages/home/page.test.tsx | 4 +- frontend/src/search/SearchModal.test.tsx | 2 +- frontend/src/team.ts | 2 +- 4 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 frontend/src/App.test.tsx diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 0000000..4e76d6a --- /dev/null +++ b/frontend/src/App.test.tsx @@ -0,0 +1,48 @@ +import { MockedProvider } from "@apollo/client/testing/react"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it } from "vitest"; +import App from "./App.tsx"; +import { TYPE_CHART } from "./search/search.ts"; + +afterEach(cleanup); + +describe("App format selector", () => { + it("defaults to SV and has no placeholder option", async () => { + const mocks = [{ + request: { query: TYPE_CHART }, + result: { + data: { + typeChart: { + types: [], + effectiveness: [], + }, + }, + }, + }]; + + render( + + + + + , + ); + + // Wait for the type chart query so state updates settle before asserting. + await screen.findByText( + "Add Pokémon to your team to see their type matchups.", + ); + + const selector = screen.getByRole("combobox") as HTMLSelectElement; + + expect(selector.value).toBe("gen9"); + expect( + within(selector).queryByRole("option", { name: "Select format" }), + ).toBeNull(); + expect( + (within(selector).getByRole("option", { name: "SV" }) as HTMLOptionElement) + .selected, + ).toBe(true); + }); +}); diff --git a/frontend/src/assets/pages/home/page.test.tsx b/frontend/src/assets/pages/home/page.test.tsx index 264d495..a2abd85 100644 --- a/frontend/src/assets/pages/home/page.test.tsx +++ b/frontend/src/assets/pages/home/page.test.tsx @@ -61,7 +61,7 @@ describe("HomePage type matchups", () => { expect(within(row).getByRole("cell", { name: "0x" })).toBeTruthy(); expect(screen.getByRole("columnheader", { name: "Rock" })).toBeTruthy(); - expect(screen.getByText("1 / 6")).toBeTruthy(); + expect(screen.getByText(`1 / ${MAX_TEAM_SIZE}`)).toBeTruthy(); const card = screen.getByRole("article", { name: "Charizard" }); expect( @@ -113,7 +113,7 @@ describe("HomePage type matchups", () => { }); expect(addButton.hasAttribute("disabled")).toBe(true); - expect(screen.getByText("6 / 6")).toBeTruthy(); + expect(screen.getByText(`${MAX_TEAM_SIZE} / ${MAX_TEAM_SIZE}`)).toBeTruthy(); expect(screen.getByText(TEAM_FULL_MESSAGE)).toBeTruthy(); }); }); diff --git a/frontend/src/search/SearchModal.test.tsx b/frontend/src/search/SearchModal.test.tsx index 51232aa..cc7b7f0 100644 --- a/frontend/src/search/SearchModal.test.tsx +++ b/frontend/src/search/SearchModal.test.tsx @@ -244,7 +244,7 @@ describe("SearchModal", () => { ).toBeNull(); }); - it("prevents adding a seventh Pokémon when the team is full", async () => { + it("prevents adding another Pokémon when the team is full", async () => { const mocks = [ { request: { diff --git a/frontend/src/team.ts b/frontend/src/team.ts index 72ad1ba..ac1ad17 100644 --- a/frontend/src/team.ts +++ b/frontend/src/team.ts @@ -1,4 +1,4 @@ -export const MAX_TEAM_SIZE = 6; +export const MAX_TEAM_SIZE = 12; export const TEAM_FULL_MESSAGE = `Your team is full (${MAX_TEAM_SIZE}/${MAX_TEAM_SIZE}). Remove a Pokémon to add another.`; From ddd4ed6e0edd712af246d405dc61d66acdcd9b3b Mon Sep 17 00:00:00 2001 From: helgeovs Date: Fri, 30 Oct 2026 11:52:46 +0100 Subject: [PATCH 2/9] Added the "Save Team" button --- frontend/src/App.test.tsx | 156 ++++++++++++++++--- frontend/src/App.tsx | 84 +++++++++- frontend/src/assets/pages/home/home.css | 72 +++++++++ frontend/src/assets/pages/home/page.test.tsx | 85 +++++++++- frontend/src/assets/pages/home/page.tsx | 125 +++++++++++---- frontend/src/save/SaveTeamModal.test.tsx | 43 +++++ frontend/src/save/SaveTeamModal.tsx | 111 +++++++++++++ frontend/src/save/save-team-modal.css | 110 +++++++++++++ frontend/src/savedTeams.test.ts | 67 ++++++++ frontend/src/savedTeams.ts | 119 ++++++++++++++ 10 files changed, 914 insertions(+), 58 deletions(-) create mode 100644 frontend/src/save/SaveTeamModal.test.tsx create mode 100644 frontend/src/save/SaveTeamModal.tsx create mode 100644 frontend/src/save/save-team-modal.css create mode 100644 frontend/src/savedTeams.test.ts create mode 100644 frontend/src/savedTeams.ts diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 4e76d6a..16cd69f 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,33 +1,94 @@ import { MockedProvider } from "@apollo/client/testing/react"; -import { cleanup, render, screen, within } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import App from "./App.tsx"; -import { TYPE_CHART } from "./search/search.ts"; +import { loadSavedTeams } from "./savedTeams.ts"; +import { SEARCH_POKEMONS, TYPE_CHART } from "./search/search.ts"; afterEach(cleanup); +beforeEach(() => { + window.localStorage.clear(); +}); + +const typeChartMock = { + request: { query: TYPE_CHART }, + result: { + data: { + typeChart: { + types: [], + effectiveness: [], + }, + }, + }, +}; + +const searchMock = { + request: { + query: SEARCH_POKEMONS, + variables: { + format: "gen9", + name: "pikachu", + types: [], + abilities: [], + moves: [], + limit: 21, + offset: 0, + sortBy: "number", + sortDirection: "ASC", + }, + }, + result: { + data: { + searchPokemons: [ + { + __typename: "PokemonSearchResult", + id: "pikachu", + name: "Pikachu", + type: "Electric", + gen: 1, + sprite: "pikachu.png", + }, + ], + }, + }, +}; + +function renderApp() { + render( + + + + + , + ); +} + +async function addPikachuToTeam() { + fireEvent.click(screen.getByRole("button", { name: "Search" })); + + fireEvent.change(screen.getByRole("searchbox", { name: "Pokémon name" }), { + target: { value: "pikachu" }, + }); + + fireEvent.click( + await screen.findByRole("button", { name: "Add Pikachu to team" }), + ); + + fireEvent.click(screen.getByRole("button", { name: "Close search" })); +} + describe("App format selector", () => { it("defaults to SV and has no placeholder option", async () => { - const mocks = [{ - request: { query: TYPE_CHART }, - result: { - data: { - typeChart: { - types: [], - effectiveness: [], - }, - }, - }, - }]; - - render( - - - - - , - ); + renderApp(); // Wait for the type chart query so state updates settle before asserting. await screen.findByText( @@ -46,3 +107,54 @@ describe("App format selector", () => { ).toBe(true); }); }); + +describe("Saving the active team", () => { + it("prompts for a name the first time and updates the saved team later", async () => { + renderApp(); + + await screen.findByText( + "Add Pokémon to your team to see their type matchups.", + ); + + await addPikachuToTeam(); + + fireEvent.click(screen.getByRole("button", { name: "Save team" })); + + const nameInput = await screen.findByRole("textbox", { name: "Team name" }); + fireEvent.change(nameInput, { target: { value: "Kanto Classics" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + expect(screen.getByText("Saved: Kanto Classics")).toBeTruthy(); + expect(screen.getByText("Team saved as “Kanto Classics”.")).toBeTruthy(); + + const savedTeams = loadSavedTeams(); + expect(savedTeams).toHaveLength(1); + expect(savedTeams[0]).toMatchObject({ + name: "Kanto Classics", + format: "gen9", + pokemon: [{ id: "pikachu", name: "Pikachu" }], + }); + + fireEvent.click(screen.getByRole("button", { name: "Save team" })); + + expect(screen.queryByRole("dialog")).toBeNull(); + expect(loadSavedTeams()).toHaveLength(1); + }); + + it("disables saving when the active team is empty", async () => { + renderApp(); + + await screen.findByText( + "Add Pokémon to your team to see their type matchups.", + ); + + const saveButton = screen.getByRole("button", { name: "Save team" }); + + expect(saveButton.hasAttribute("disabled")).toBe(true); + }); +}); + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e2d6ca..2eb4d19 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,8 @@ import { } from "react-router-dom"; import HomePage from "./assets/pages/home/page.tsx"; import TeamsPage from "./assets/pages/teams/page.tsx"; +import SaveTeamModal from "./save/SaveTeamModal.tsx"; +import { saveTeam as persistSavedTeam } from "./savedTeams.ts"; import SearchModal from "./search/SearchModal.tsx"; import { MAX_TEAM_SIZE, type TeamPokemon } from "./team.ts"; import { useAuth } from "./auth/AuthContext.tsx"; @@ -24,6 +26,10 @@ type ApplicationContext = { addPokemon: (pokemon: TeamPokemon) => void; removePokemon: (id: string) => void; openSearch: () => void; + onSaveTeam: () => void; + savedTeamName: string | null; + saveMessage: string | null; + saveError: string | null; }; function ApplicationLayout() { @@ -35,6 +41,13 @@ function ApplicationLayout() { localStorage.getItem(`pokemon-draft-planner-format-${user?.id}`) || "gen9", ); + const [activeSavedTeam, setActiveSavedTeam] = useState<{ + id: string; + name: string; + } | null>(null); + const [saveModalOpen, setSaveModalOpen] = useState(false); + const [saveMessage, setSaveMessage] = useState(null); + const [saveError, setSaveError] = useState(null); const [team, setTeam] = useState(() => { const savedTeam = localStorage.getItem( @@ -69,9 +82,13 @@ function ApplicationLayout() { function handleFormatChange(newFormat: string) { setFormat(newFormat); localStorage.setItem(`pokemon-draft-planner-format-${user?.id}`, newFormat); + setSaveMessage(null); + setSaveError(null); } function addPokemon(pokemon: TeamPokemon) { + setSaveMessage(null); + setSaveError(null); setTeam((currentTeam) => currentTeam.length >= MAX_TEAM_SIZE || currentTeam.some((member) => member.id === pokemon.id) @@ -81,11 +98,47 @@ function ApplicationLayout() { } function removePokemon(id: string) { + setSaveMessage(null); + setSaveError(null); setTeam((currentTeam) => currentTeam.filter((pokemon) => pokemon.id !== id), ); } + function saveCurrentTeam(name: string, id: string | null) { + try { + const savedTeam = persistSavedTeam({ + id, + name, + format, + pokemon: team, + }); + + setActiveSavedTeam({ id: savedTeam.id, name: savedTeam.name }); + setSaveModalOpen(false); + setSaveError(null); + setSaveMessage(`Team saved as “${savedTeam.name}”.`); + } catch (error) { + setSaveError( + error instanceof Error ? error.message : "Could not save your team.", + ); + } + } + + function handleSaveTeam() { + if (team.length === 0) return; + + setSaveMessage(null); + + if (activeSavedTeam) { + saveCurrentTeam(activeSavedTeam.name, activeSavedTeam.id); + return; + } + + setSaveError(null); + setSaveModalOpen(true); + } + return ( <>
@@ -176,6 +229,10 @@ function ApplicationLayout() { addPokemon, removePokemon, openSearch: () => setSearchOpen(true), + onSaveTeam: handleSaveTeam, + savedTeamName: activeSavedTeam?.name ?? null, + saveMessage, + saveError: saveModalOpen ? null : saveError, } satisfies ApplicationContext } /> @@ -190,13 +247,32 @@ function ApplicationLayout() { onClose={() => setSearchOpen(false)} /> )} + {saveModalOpen && ( + saveCurrentTeam(name, null)} + onClose={() => { + setSaveModalOpen(false); + setSaveError(null); + }} + /> + )} ); } function HomeRoute() { - const { format, setFormat, team, removePokemon, openSearch } = - useOutletContext(); + const { + format, + setFormat, + team, + removePokemon, + openSearch, + onSaveTeam, + savedTeamName, + saveMessage, + saveError, + } = useOutletContext(); return ( ); } diff --git a/frontend/src/assets/pages/home/home.css b/frontend/src/assets/pages/home/home.css index 5afc4ee..6006569 100644 --- a/frontend/src/assets/pages/home/home.css +++ b/frontend/src/assets/pages/home/home.css @@ -97,6 +97,70 @@ font-weight: 600; } +.your-team-saved-name { + padding: 0.1rem 0.55rem; + border: 1px solid var(--accent); + border-radius: 999px; + background: var(--accent-bg); + color: var(--text-h); + font-size: 0.8rem; + font-weight: 600; +} + +.your-team-actions { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +.saveTeam { + padding: 0.6rem 1.4rem; + border: 1px solid var(--accent); + border-radius: 999px; + background: var(--bg); + color: var(--accent); + font-size: 1rem; + font-weight: 600; + font-family: var(--sans); + cursor: pointer; + transition: transform 0.1s ease, filter 0.2s ease; +} + +.saveTeam:hover { + filter: brightness(1.05); +} + +.saveTeam:active { + transform: scale(0.98); +} + +.your-team .saveTeam:disabled { + filter: grayscale(0.4); + opacity: 0.55; + cursor: not-allowed; +} + +.your-team-status, +.your-team-error { + margin: 0.75rem 0 0; + padding: 0.6rem 0.8rem; + border-radius: 0.5rem; + background: var(--bg); + font-size: 0.9rem; + text-align: left; +} + +.your-team-status { + border: 1px solid var(--accent); + color: var(--text-h); +} + +.your-team-error { + border: 1px solid #c0392b; + color: #c0392b; +} + .your-team .addPokemon:disabled { filter: grayscale(0.4); opacity: 0.55; @@ -197,6 +261,14 @@ align-items: flex-start; flex-direction: column; } + + .your-team-actions { + width: 100%; + } + + .your-team-actions button { + flex: 1; + } } .top-bar { diff --git a/frontend/src/assets/pages/home/page.test.tsx b/frontend/src/assets/pages/home/page.test.tsx index a2abd85..857fa37 100644 --- a/frontend/src/assets/pages/home/page.test.tsx +++ b/frontend/src/assets/pages/home/page.test.tsx @@ -13,7 +13,7 @@ import { TYPE_CHART } from "../../../search/search"; afterEach(cleanup); -describe("HomePage type matchups", () => { +describe("HomePage", () => { it("shows each type and combines defensive multipliers for dual-type Pokémon", async () => { const typeEffectiveness = [ { attackingType: "Rock", defendingType: "Fire", multiplier: 2 }, @@ -51,6 +51,7 @@ describe("HomePage type matchups", () => { sprite: "charizard.png", }]} onRemovePokemon={onRemovePokemon} + onSaveTeam={() => {}} /> , ); @@ -104,6 +105,7 @@ describe("HomePage type matchups", () => { onOpenSearch={() => {}} team={team} onRemovePokemon={() => {}} + onSaveTeam={() => {}} /> , ); @@ -116,4 +118,85 @@ describe("HomePage type matchups", () => { expect(screen.getByText(`${MAX_TEAM_SIZE} / ${MAX_TEAM_SIZE}`)).toBeTruthy(); expect(screen.getByText(TEAM_FULL_MESSAGE)).toBeTruthy(); }); + + it("saves the active team and shows save feedback", async () => { + const mocks = [{ + request: { query: TYPE_CHART }, + result: { + data: { + typeChart: { + types: ["Fire"], + effectiveness: [], + }, + }, + }, + }]; + const onSaveTeam = vi.fn(); + + render( + + {}} + onOpenSearch={() => {}} + team={[{ + id: "charizard", + name: "Charizard", + type: "Fire, Flying", + gen: 1, + sprite: "charizard.png", + }]} + onRemovePokemon={() => {}} + onSaveTeam={onSaveTeam} + savedTeamName="Kanto Classics" + saveMessage="Team saved as “Kanto Classics”." + /> + , + ); + + const saveButton = await screen.findByRole("button", { + name: "Save team", + }); + + expect(saveButton.hasAttribute("disabled")).toBe(false); + + fireEvent.click(saveButton); + + expect(onSaveTeam).toHaveBeenCalledTimes(1); + expect(screen.getByText("Saved: Kanto Classics")).toBeTruthy(); + expect(screen.getByText("Team saved as “Kanto Classics”.")).toBeTruthy(); + }); + + it("disables Save team when the team is empty", async () => { + const mocks = [{ + request: { query: TYPE_CHART }, + result: { + data: { + typeChart: { + types: ["Fire"], + effectiveness: [], + }, + }, + }, + }]; + + render( + + {}} + onOpenSearch={() => {}} + team={[]} + onRemovePokemon={() => {}} + onSaveTeam={() => {}} + /> + , + ); + + const saveButton = await screen.findByRole("button", { + name: "Save team", + }); + + expect(saveButton.hasAttribute("disabled")).toBe(true); + }); }); diff --git a/frontend/src/assets/pages/home/page.tsx b/frontend/src/assets/pages/home/page.tsx index 18ce345..8dac373 100644 --- a/frontend/src/assets/pages/home/page.tsx +++ b/frontend/src/assets/pages/home/page.tsx @@ -27,6 +27,10 @@ type HomePageProps = { onOpenSearch: () => void; team: TeamPokemon[]; onRemovePokemon: (id: string) => void; + onSaveTeam: () => void; + savedTeamName?: string | null; + saveMessage?: string | null; + saveError?: string | null; }; function HomePage({ @@ -35,6 +39,10 @@ function HomePage({ onOpenSearch, team, onRemovePokemon, + onSaveTeam, + savedTeamName = null, + saveMessage = null, + saveError = null, }: HomePageProps) { const { data, loading, error } = useQuery(TYPE_CHART); @@ -62,41 +70,62 @@ function HomePage({ return ( <> -
-
- -
+
+
+ +
-
-
-
-

Your team

- - {team.length} / {MAX_TEAM_SIZE} +
+
+
+

Your team

+ + {team.length} / {MAX_TEAM_SIZE} + + {savedTeamName && ( + + Saved: {savedTeamName} -
+ )} +
+
+
+
+ + {saveMessage && ( +

+ {saveMessage} +

+ )} + + {saveError && ( +

+ {saveError} +

+ )} + + {teamFull && ( +

+ {TEAM_FULL_MESSAGE} +

+ )} + + {team.length > 0 ? ( +
+ {team.map((pokemon) => ( + onRemovePokemon(pokemon.id)} + /> + ))} +
{teamFull && (

diff --git a/frontend/src/save/SaveTeamModal.test.tsx b/frontend/src/save/SaveTeamModal.test.tsx new file mode 100644 index 0000000..6ac344b --- /dev/null +++ b/frontend/src/save/SaveTeamModal.test.tsx @@ -0,0 +1,43 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import SaveTeamModal from "./SaveTeamModal"; + +afterEach(cleanup); + +describe("SaveTeamModal", () => { + it("submits the trimmed team name", () => { + const onSave = vi.fn(); + + render( {}} />); + + fireEvent.change(screen.getByRole("textbox", { name: "Team name" }), { + target: { value: " Kanto Classics " }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(onSave).toHaveBeenCalledWith("Kanto Classics"); + }); + + it("shows an error instead of saving when the name is blank", () => { + const onSave = vi.fn(); + + render( {}} />); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(screen.getByRole("alert").textContent).toBe( + "Enter a name for your team.", + ); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("closes when Escape is pressed", () => { + const onClose = vi.fn(); + + render( {}} onClose={onClose} />); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/save/SaveTeamModal.tsx b/frontend/src/save/SaveTeamModal.tsx new file mode 100644 index 0000000..4686600 --- /dev/null +++ b/frontend/src/save/SaveTeamModal.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState, type FormEvent } from "react"; +import "./save-team-modal.css"; + +type SaveTeamModalProps = { + error?: string | null; + onSave: (name: string) => void; + onClose: () => void; +}; + +function SaveTeamModal({ error, onSave, onClose }: SaveTeamModalProps) { + const [name, setName] = useState(""); + const [validationError, setValidationError] = useState(null); + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + + const trimmedName = name.trim(); + + if (!trimmedName) { + setValidationError("Enter a name for your team."); + return; + } + + onSave(trimmedName); + } + + return ( +

{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+
+

Name your team

+ +
+ +

+ Give this team a name so you can recognize it later. +

+ +
+ + + {validationError && ( +

+ {validationError} +

+ )} + + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+
+
+ ); +} + +export default SaveTeamModal; diff --git a/frontend/src/save/save-team-modal.css b/frontend/src/save/save-team-modal.css new file mode 100644 index 0000000..bf6637c --- /dev/null +++ b/frontend/src/save/save-team-modal.css @@ -0,0 +1,110 @@ +.save-team-modal-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgb(0 0 0 / 55%); +} + +.save-team-modal { + width: min(100%, 420px); + padding: 1.5rem; + border: 1px solid var(--border); + border-radius: 1rem; + background: var(--bg); + box-shadow: var(--shadow); + text-align: left; +} + +.save-team-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.save-team-modal-header h2 { + margin: 0; +} + +.save-team-modal-close { + border: 0; + background: transparent; + color: var(--text); + font-size: 1.75rem; + line-height: 1; + cursor: pointer; +} + +.save-team-modal-description { + margin: 0.5rem 0 1rem; + font-size: 0.9rem; +} + +.save-team-form label { + display: flex; + flex-direction: column; + gap: 0.35rem; + color: var(--text-h); + font-size: 0.9rem; +} + +.save-team-form input { + width: 100%; + box-sizing: border-box; + padding: 0.65rem 0.8rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--code-bg); + color: var(--text-h); + font: inherit; +} + +.save-team-form input:focus { + border-color: var(--accent); + outline: 2px solid var(--accent-bg); +} + +.save-team-error { + margin: 0.5rem 0 0; + color: #c0392b; + font-size: 0.9rem; +} + +.save-team-modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + margin-top: 1.25rem; +} + +.save-team-modal-actions button { + padding: 0.55rem 1rem; + border-radius: 999px; + font: 600 0.95rem var(--sans); + cursor: pointer; + transition: filter 0.2s ease, border-color 0.2s ease; +} + +.save-team-primary { + border: 0; + background: var(--accent); + color: aliceblue; +} + +.save-team-primary:hover { + filter: brightness(1.1); +} + +.save-team-secondary { + border: 1px solid var(--border); + background: var(--code-bg); + color: var(--text-h); +} + +.save-team-secondary:hover { + border-color: var(--accent); +} diff --git a/frontend/src/savedTeams.test.ts b/frontend/src/savedTeams.test.ts new file mode 100644 index 0000000..338e82f --- /dev/null +++ b/frontend/src/savedTeams.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + loadSavedTeams, + SAVED_TEAMS_STORAGE_KEY, + saveTeam, +} from "./savedTeams.ts"; +import type { TeamPokemon } from "./team.ts"; + +const pikachu: TeamPokemon = { + id: "pikachu", + name: "Pikachu", + type: "Electric", + gen: 1, + sprite: "pikachu.png", +}; + +beforeEach(() => { + window.localStorage.clear(); +}); + +describe("saved teams storage", () => { + it("saves a new team under a trimmed name", () => { + const saved = saveTeam({ + name: " Kanto Classics ", + format: "gen9", + pokemon: [pikachu], + }); + + expect(saved.name).toBe("Kanto Classics"); + expect(saved.id).toBeTruthy(); + expect(loadSavedTeams()).toEqual([saved]); + }); + + it("updates an existing saved team instead of creating a duplicate", () => { + const saved = saveTeam({ + name: "Kanto Classics", + format: "gen9", + pokemon: [pikachu], + }); + + const updated = saveTeam({ + id: saved.id, + name: saved.name, + format: "gen8", + pokemon: [{ ...pikachu, id: "raichu", name: "Raichu" }], + }); + + expect(updated.id).toBe(saved.id); + expect(loadSavedTeams()).toEqual([updated]); + }); + + it("rejects a team without a name", () => { + expect(() => + saveTeam({ name: " ", format: "gen9", pokemon: [pikachu] }), + ).toThrow("Enter a name for your team."); + expect(loadSavedTeams()).toEqual([]); + }); + + it("ignores invalid data in storage", () => { + window.localStorage.setItem( + SAVED_TEAMS_STORAGE_KEY, + JSON.stringify([{ id: 1 }, null]), + ); + + expect(loadSavedTeams()).toEqual([]); + }); +}); diff --git a/frontend/src/savedTeams.ts b/frontend/src/savedTeams.ts new file mode 100644 index 0000000..38e40a2 --- /dev/null +++ b/frontend/src/savedTeams.ts @@ -0,0 +1,119 @@ +import type { TeamPokemon } from "./team.ts"; + +export const SAVED_TEAMS_STORAGE_KEY = "pokemon-draft-planner.saved-teams"; + +export type SavedTeam = { + id: string; + name: string; + format: string; + pokemon: TeamPokemon[]; + updatedAt: string; +}; + +type SaveTeamInput = { + id?: string | null; + name: string; + format: string; + pokemon: TeamPokemon[]; +}; + +function isTeamPokemon(value: unknown): value is TeamPokemon { + if (typeof value !== "object" || value === null) return false; + + const pokemon = value as Record; + + return ( + typeof pokemon.id === "string" && + typeof pokemon.name === "string" && + typeof pokemon.type === "string" && + typeof pokemon.gen === "number" && + (typeof pokemon.sprite === "string" || pokemon.sprite === null) + ); +} + +function isSavedTeam(value: unknown): value is SavedTeam { + if (typeof value !== "object" || value === null) return false; + + const team = value as Record; + + return ( + typeof team.id === "string" && + typeof team.name === "string" && + typeof team.format === "string" && + typeof team.updatedAt === "string" && + Array.isArray(team.pokemon) && + team.pokemon.every(isTeamPokemon) + ); +} + +function createTeamId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + + return `team-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function storeSavedTeams(teams: SavedTeam[]): void { + window.localStorage.setItem(SAVED_TEAMS_STORAGE_KEY, JSON.stringify(teams)); +} + +export function loadSavedTeams(): SavedTeam[] { + try { + const stored = window.localStorage.getItem(SAVED_TEAMS_STORAGE_KEY); + if (!stored) return []; + + const parsed: unknown = JSON.parse(stored); + return Array.isArray(parsed) ? parsed.filter(isSavedTeam) : []; + } catch { + return []; + } +} + +/** + * Creates a saved team or updates it when `id` matches an existing one. + * Throws when the name is blank or the browser refuses to store the team. + */ +export function saveTeam({ + id, + name, + format, + pokemon, +}: SaveTeamInput): SavedTeam { + const trimmedName = name.trim(); + + if (!trimmedName) { + throw new Error("Enter a name for your team."); + } + + const savedTeam: SavedTeam = { + id: id ?? createTeamId(), + name: trimmedName, + format, + pokemon: pokemon.map((member) => ({ + id: member.id, + name: member.name, + type: member.type, + gen: member.gen, + sprite: member.sprite, + })), + updatedAt: new Date().toISOString(), + }; + + const teams = loadSavedTeams(); + const existingIndex = teams.findIndex((team) => team.id === savedTeam.id); + + if (existingIndex === -1) { + teams.unshift(savedTeam); + } else { + teams[existingIndex] = savedTeam; + } + + try { + storeSavedTeams(teams); + } catch { + throw new Error("Could not save the team in this browser."); + } + + return savedTeam; +} From 05e49ca40d0606c98599369c2f82687ef4b78414 Mon Sep 17 00:00:00 2001 From: helgeovs Date: Mon, 2 Nov 2026 10:12:47 +0100 Subject: [PATCH 3/9] Added teams --- frontend/src/App.test.tsx | 48 +++++ frontend/src/App.tsx | 67 +++++- .../confirmdialog/confirmdialog.css | 64 ++++++ .../confirmdialog/confirmdialog.tsx | 70 +++++++ frontend/src/assets/pages/home/page.tsx | 19 +- frontend/src/assets/pages/teams/page.test.tsx | 159 +++++++++++++++ frontend/src/assets/pages/teams/page.tsx | 193 +++++++++++++++++- frontend/src/assets/pages/teams/teams.css | 163 +++++++++++++++ frontend/src/formats.ts | 27 +++ frontend/src/savedTeams.test.ts | 35 ++++ frontend/src/savedTeams.ts | 27 +++ frontend/vitest.config.ts | 3 + 12 files changed, 857 insertions(+), 18 deletions(-) create mode 100644 frontend/src/assets/components/confirmdialog/confirmdialog.css create mode 100644 frontend/src/assets/components/confirmdialog/confirmdialog.tsx create mode 100644 frontend/src/assets/pages/teams/page.test.tsx create mode 100644 frontend/src/formats.ts diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 16cd69f..889af59 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -158,3 +158,51 @@ describe("Saving the active team", () => { }); }); +describe("Teams page", () => { + it("lists a saved team, loads it as active, and deletes it with confirmation", async () => { + renderApp(); + + await screen.findByText( + "Add Pokémon to your team to see their type matchups.", + ); + await addPikachuToTeam(); + + fireEvent.click(screen.getByRole("button", { name: "Save team" })); + const nameInput = await screen.findByRole("textbox", { name: "Team name" }); + fireEvent.change(nameInput, { target: { value: "Kanto Classics" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + fireEvent.click(screen.getByRole("link", { name: "Teams" })); + + expect( + await screen.findByRole("heading", { name: "SV Kanto Classics" }), + ).toBeTruthy(); + expect(screen.getByRole("img", { name: "Pikachu sprite" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Load team" })); + + expect( + await screen.findByRole("heading", { name: "Your team" }), + ).toBeTruthy(); + expect(screen.getByRole("article", { name: "Pikachu" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("link", { name: "Teams" })); + fireEvent.click( + await screen.findByRole("button", { name: "Delete team" }), + ); + + expect( + screen.getByRole("dialog", { name: "Delete saved team?" }), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Yes, delete" })); + + expect(await screen.findByText(/No saved teams yet/)).toBeTruthy(); + expect(loadSavedTeams()).toEqual([]); + }); +}); + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2eb4d19..4ac26f5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,7 +12,13 @@ import { import HomePage from "./assets/pages/home/page.tsx"; import TeamsPage from "./assets/pages/teams/page.tsx"; import SaveTeamModal from "./save/SaveTeamModal.tsx"; -import { saveTeam as persistSavedTeam } from "./savedTeams.ts"; +import { + deleteSavedTeam, + loadSavedTeams, + matchesSavedTeam, + saveTeam as persistSavedTeam, + type SavedTeam, +} from "./savedTeams.ts"; import SearchModal from "./search/SearchModal.tsx"; import { MAX_TEAM_SIZE, type TeamPokemon } from "./team.ts"; import { useAuth } from "./auth/AuthContext.tsx"; @@ -30,6 +36,10 @@ type ApplicationContext = { savedTeamName: string | null; saveMessage: string | null; saveError: string | null; + savedTeams: SavedTeam[]; + activeTeamHasUnsavedChanges: boolean; + onLoadTeam: (team: SavedTeam) => void; + onDeleteTeam: (id: string) => void; }; function ApplicationLayout() { @@ -41,6 +51,9 @@ function ApplicationLayout() { localStorage.getItem(`pokemon-draft-planner-format-${user?.id}`) || "gen9", ); + const [savedTeams, setSavedTeams] = useState(() => + loadSavedTeams(), + ); const [activeSavedTeam, setActiveSavedTeam] = useState<{ id: string; name: string; @@ -115,6 +128,7 @@ function ApplicationLayout() { }); setActiveSavedTeam({ id: savedTeam.id, name: savedTeam.name }); + setSavedTeams(loadSavedTeams()); setSaveModalOpen(false); setSaveError(null); setSaveMessage(`Team saved as “${savedTeam.name}”.`); @@ -139,6 +153,32 @@ function ApplicationLayout() { setSaveModalOpen(true); } + function loadTeam(savedTeam: SavedTeam) { + setTeam(savedTeam.pokemon.map((pokemon) => ({ ...pokemon }))); + setFormat(savedTeam.format); + setActiveSavedTeam({ id: savedTeam.id, name: savedTeam.name }); + setSaveMessage(`Loaded “${savedTeam.name}”.`); + setSaveError(null); + } + + function deleteTeam(id: string) { + const teams = deleteSavedTeam(id); + setSavedTeams(teams); + setSaveMessage(null); + setSaveError(null); + + if (activeSavedTeam?.id === id) { + setActiveSavedTeam(null); + } + } + + const activeSavedRecord = activeSavedTeam + ? savedTeams.find((savedTeam) => savedTeam.id === activeSavedTeam.id) + : undefined; + const activeTeamIsSaved = + activeSavedRecord !== undefined && + matchesSavedTeam(activeSavedRecord, format, team); + return ( <>
@@ -233,6 +273,11 @@ function ApplicationLayout() { savedTeamName: activeSavedTeam?.name ?? null, saveMessage, saveError: saveModalOpen ? null : saveError, + savedTeams, + activeTeamHasUnsavedChanges: + team.length > 0 && !activeTeamIsSaved, + onLoadTeam: loadTeam, + onDeleteTeam: deleteTeam, } satisfies ApplicationContext } /> @@ -289,6 +334,24 @@ function HomeRoute() { ); } +function TeamsRoute() { + const { + savedTeams, + activeTeamHasUnsavedChanges, + onLoadTeam, + onDeleteTeam, + } = useOutletContext(); + + return ( + + ); +} + function App() { return ( @@ -298,7 +361,7 @@ function App() { }> } /> } /> - } /> + } /> } /> diff --git a/frontend/src/assets/components/confirmdialog/confirmdialog.css b/frontend/src/assets/components/confirmdialog/confirmdialog.css new file mode 100644 index 0000000..9113672 --- /dev/null +++ b/frontend/src/assets/components/confirmdialog/confirmdialog.css @@ -0,0 +1,64 @@ +.confirm-dialog-overlay { + position: fixed; + inset: 0; + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgb(0 0 0 / 55%); +} + +.confirm-dialog { + width: min(100%, 440px); + padding: 1.5rem; + border: 1px solid var(--border); + border-radius: 1rem; + background: var(--bg); + box-shadow: var(--shadow); + text-align: left; +} + +.confirm-dialog h2 { + margin: 0; +} + +.confirm-dialog-message { + margin: 0.75rem 0 0; + font-size: 0.95rem; +} + +.confirm-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + margin-top: 1.5rem; +} + +.confirm-dialog-actions button { + padding: 0.55rem 1rem; + border-radius: 999px; + font: 600 0.95rem var(--sans); + cursor: pointer; + transition: filter 0.2s ease, border-color 0.2s ease; +} + +.confirm-dialog-cancel { + border: 1px solid var(--border); + background: var(--code-bg); + color: var(--text-h); +} + +.confirm-dialog-cancel:hover { + border-color: var(--accent); +} + +.confirm-dialog-confirm { + border: 1px solid #c0392b; + background: #c0392b; + color: #fff; +} + +.confirm-dialog-confirm:hover { + filter: brightness(1.1); +} diff --git a/frontend/src/assets/components/confirmdialog/confirmdialog.tsx b/frontend/src/assets/components/confirmdialog/confirmdialog.tsx new file mode 100644 index 0000000..18e3873 --- /dev/null +++ b/frontend/src/assets/components/confirmdialog/confirmdialog.tsx @@ -0,0 +1,70 @@ +import { useEffect } from "react"; +import "./confirmdialog.css"; + +type ConfirmDialogProps = { + title: string; + message: string; + confirmLabel: string; + cancelLabel?: string; + onConfirm: () => void; + onCancel: () => void; +}; + +function ConfirmDialog({ + title, + message, + confirmLabel, + cancelLabel = "Cancel", + onConfirm, + onCancel, +}: ConfirmDialogProps) { + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") onCancel(); + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onCancel]); + + return ( +
{ + if (event.target === event.currentTarget) onCancel(); + }} + > +
+

{title}

+

+ {message} +

+
+ + +
+
+
+ ); +} + +export default ConfirmDialog; diff --git a/frontend/src/assets/pages/home/page.tsx b/frontend/src/assets/pages/home/page.tsx index 8dac373..71a70fb 100644 --- a/frontend/src/assets/pages/home/page.tsx +++ b/frontend/src/assets/pages/home/page.tsx @@ -2,6 +2,7 @@ import "./home.css"; import { useQuery } from "@apollo/client/react"; import PokemonCard from "../../components/pokemoncard/pokemoncard.tsx"; import { TYPE_CHART } from "../../../search/search"; +import { FORMAT_OPTIONS } from "../../../formats.ts"; import { MAX_TEAM_SIZE, TEAM_FULL_MESSAGE, @@ -78,19 +79,11 @@ function HomePage({ value={format} onChange={(event) => onFormatChange(event.target.value)} > - - - - - - - - - - - - - + {FORMAT_OPTIONS.map((option) => ( + + ))}
diff --git a/frontend/src/assets/pages/teams/page.test.tsx b/frontend/src/assets/pages/teams/page.test.tsx new file mode 100644 index 0000000..40eeea1 --- /dev/null +++ b/frontend/src/assets/pages/teams/page.test.tsx @@ -0,0 +1,159 @@ +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { SavedTeam } from "../../../savedTeams.ts"; +import TeamsPage from "./page"; + +afterEach(cleanup); + +const pikachu = { + id: "pikachu", + name: "Pikachu", + type: "Electric", + gen: 1, + sprite: "pikachu.png", +}; + +const charizard = { + id: "charizard", + name: "Charizard", + type: "Fire, Flying", + gen: 1, + sprite: "charizard.png", +}; + +const missingno = { + id: "missingno", + name: "Missingno", + type: "Normal", + gen: 1, + sprite: null, +}; + +const savedTeamFixtures: SavedTeam[] = [ + { + id: "team-1", + name: "Kanto Classics", + format: "gen9", + pokemon: [pikachu, charizard], + updatedAt: "2026-01-01T00:00:00.000Z", + }, + { + id: "team-2", + name: "Johto Squad", + format: "gen8", + pokemon: [missingno], + updatedAt: "2026-01-02T00:00:00.000Z", + }, +]; + +type TeamsPageProps = Parameters[0]; + +function renderTeams(overrides: Partial = {}) { + const onLoadTeam = vi.fn(); + const onDeleteTeam = vi.fn(); + + render( + + + + } + /> + Home page

} /> +
+
, + ); + + return { onLoadTeam, onDeleteTeam }; +} + +describe("TeamsPage", () => { + it("shows each saved team's format, name and Pokémon sprites", () => { + renderTeams(); + + expect( + screen.getByRole("heading", { name: "SV Kanto Classics" }), + ).toBeTruthy(); + expect( + screen.getByRole("heading", { name: "SS Johto Squad" }), + ).toBeTruthy(); + + expect(screen.getByRole("img", { name: "Pikachu sprite" })).toBeTruthy(); + expect(screen.getByRole("img", { name: "Charizard sprite" })).toBeTruthy(); + expect( + screen.getByRole("img", { name: "Missingno sprite unavailable" }), + ).toBeTruthy(); + + expect(screen.getAllByRole("button", { name: "Load team" })).toHaveLength( + 2, + ); + expect(screen.getAllByRole("button", { name: "Delete team" })).toHaveLength( + 2, + ); + }); + + it("loads a saved team immediately when nothing would be overwritten", async () => { + const { onLoadTeam } = renderTeams(); + + fireEvent.click(screen.getAllByRole("button", { name: "Load team" })[0]); + + expect(onLoadTeam).toHaveBeenCalledWith(savedTeamFixtures[0]); + expect(await screen.findByText("Home page")).toBeTruthy(); + }); + + it("asks before loading over a non-saved active team", async () => { + const { onLoadTeam } = renderTeams({ + activeTeamHasUnsavedChanges: true, + }); + + fireEvent.click(screen.getAllByRole("button", { name: "Load team" })[0]); + + const dialog = screen.getByRole("dialog", { name: "Load saved team?" }); + expect(within(dialog).getByText(/Are you sure/)).toBeTruthy(); + expect(onLoadTeam).not.toHaveBeenCalled(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + expect(onLoadTeam).not.toHaveBeenCalled(); + + fireEvent.click(screen.getAllByRole("button", { name: "Load team" })[0]); + fireEvent.click(screen.getByRole("button", { name: "Yes, load" })); + + expect(onLoadTeam).toHaveBeenCalledWith(savedTeamFixtures[0]); + expect(await screen.findByText("Home page")).toBeTruthy(); + }); + + it("asks before deleting a saved team", () => { + const { onDeleteTeam } = renderTeams(); + + fireEvent.click(screen.getAllByRole("button", { name: "Delete team" })[0]); + + const dialog = screen.getByRole("dialog", { name: "Delete saved team?" }); + expect(within(dialog).getByText(/Are you sure/)).toBeTruthy(); + expect(onDeleteTeam).not.toHaveBeenCalled(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Yes, delete" })); + + expect(onDeleteTeam).toHaveBeenCalledWith("team-1"); + }); + + it("shows an empty state when there are no saved teams", () => { + renderTeams({ savedTeams: [] }); + + expect(screen.getByText(/No saved teams yet/)).toBeTruthy(); + }); +}); diff --git a/frontend/src/assets/pages/teams/page.tsx b/frontend/src/assets/pages/teams/page.tsx index 5e3f57f..6f09392 100644 --- a/frontend/src/assets/pages/teams/page.tsx +++ b/frontend/src/assets/pages/teams/page.tsx @@ -1,10 +1,197 @@ -function TeamsPage() { +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import ConfirmDialog from "../../components/confirmdialog/confirmdialog.tsx"; +import { getSpriteUrl } from "../../components/pokemoncard/sprite.ts"; +import { getFormatLabel } from "../../../formats.ts"; +import type { SavedTeam } from "../../../savedTeams.ts"; +import type { TeamPokemon } from "../../../team.ts"; +import "./teams.css"; + +type TeamsPageProps = { + savedTeams: SavedTeam[]; + activeTeamHasUnsavedChanges: boolean; + onLoadTeam: (team: SavedTeam) => void; + onDeleteTeam: (id: string) => void; +}; + +type PendingAction = { + type: "load" | "delete"; + team: SavedTeam; +}; + +function TeamsPage({ + savedTeams, + activeTeamHasUnsavedChanges, + onLoadTeam, + onDeleteTeam, +}: TeamsPageProps) { + const navigate = useNavigate(); + const [pendingAction, setPendingAction] = useState( + null, + ); + const [error, setError] = useState(null); + + function loadTeam(team: SavedTeam) { + setError(null); + + if (activeTeamHasUnsavedChanges) { + setPendingAction({ type: "load", team }); + return; + } + + onLoadTeam(team); + navigate("/home"); + } + + function requestDelete(team: SavedTeam) { + setError(null); + setPendingAction({ type: "delete", team }); + } + + function confirmPendingAction() { + if (!pendingAction) return; + + try { + if (pendingAction.type === "delete") { + onDeleteTeam(pendingAction.team.id); + } else { + onLoadTeam(pendingAction.team); + navigate("/home"); + } + + setPendingAction(null); + } catch (caughtError) { + setPendingAction(null); + setError( + caughtError instanceof Error + ? caughtError.message + : "Something went wrong. Please try again.", + ); + } + } + return ( -
+

Teams

-

Your teams will appear here.

+ + {error && ( +

+ {error} +

+ )} + + {savedTeams.length === 0 ? ( +

+ No saved teams yet. Build a team on the Home page and press “Save + team”. +

+ ) : ( +
    + {savedTeams.map((team) => { + const formatLabel = getFormatLabel(team.format); + + return ( +
  • +
    +

    + {formatLabel}{" "} + {team.name} +

    + +

    + {team.pokemon.length} Pokémon +

    + + {team.pokemon.length > 0 ? ( +
      + {team.pokemon.map((pokemon) => ( +
    • + +
    • + ))} +
    + ) : ( +

    No Pokémon in this team.

    + )} + +
    + + +
    +
    +
  • + ); + })} +
+ )} + + {pendingAction && ( + setPendingAction(null)} + /> + )}
); } +function TeamSprite({ pokemon }: { pokemon: TeamPokemon }) { + const [imageFailed, setImageFailed] = useState(false); + const spriteUrl = getSpriteUrl(pokemon.sprite); + + return ( + + {spriteUrl && !imageFailed ? ( + {`${pokemon.name} setImageFailed(true)} + /> + ) : ( + + ? + + )} + + ); +} + export default TeamsPage; \ No newline at end of file diff --git a/frontend/src/assets/pages/teams/teams.css b/frontend/src/assets/pages/teams/teams.css index e69de29..9c09a8d 100644 --- a/frontend/src/assets/pages/teams/teams.css +++ b/frontend/src/assets/pages/teams/teams.css @@ -0,0 +1,163 @@ +.teams-page { + padding: 2rem; + text-align: left; +} + +.teams-page h2 { + margin: 0 0 1rem; +} + +.teams-empty { + margin: 0; + color: var(--text); +} + +.teams-error { + margin: 0 0 1rem; + padding: 0.6rem 0.8rem; + border: 1px solid #c0392b; + border-radius: 0.5rem; + background: var(--bg); + color: #c0392b; + font-size: 0.9rem; +} + +.saved-teams-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; + margin: 0; + padding: 0; + list-style: none; +} + +.saved-team-card { + height: 100%; + box-sizing: border-box; + padding: 1rem; + border: 1px solid var(--border); + border-radius: 1rem; + background: var(--code-bg); +} + +.saved-team-title { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0; + color: var(--text-h); + font-size: 1.1rem; +} + +.saved-team-format { + padding: 0.1rem 0.5rem; + border: 1px solid var(--accent); + border-radius: 999px; + background: var(--accent-bg); + color: var(--accent); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.05em; +} + +.saved-team-name { + overflow-wrap: anywhere; +} + +.saved-team-count { + margin: 0.4rem 0 0.75rem; + font-size: 0.8rem; +} + +.saved-team-sprites { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin: 0; + padding: 0; + list-style: none; +} + +.saved-team-sprite { + display: flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--bg); +} + +.saved-team-sprite img { + width: 60px; + height: 60px; + object-fit: contain; +} + +.saved-team-sprite-fallback { + color: var(--text); + font-size: 1.4rem; + font-weight: 700; +} + +.saved-team-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; +} + +.saved-team-actions button { + padding: 0.5rem 0.9rem; + border-radius: 999px; + font: 600 0.85rem var(--sans); + cursor: pointer; + transition: filter 0.2s ease, border-color 0.2s ease; +} + +.load-team { + border: 1px solid var(--accent); + background: var(--accent); + color: aliceblue; +} + +.load-team:hover { + filter: brightness(1.1); +} + +.delete-team { + border: 1px solid #c0392b; + background: transparent; + color: #c0392b; +} + +.delete-team:hover { + background: #fdecea; +} + +@media (max-width: 600px) { + .teams-page { + padding: 1rem; + } + + .saved-teams-list { + grid-template-columns: 1fr; + } + + .saved-team-sprites { + gap: 0.25rem; + } + + .saved-team-sprite { + width: 54px; + height: 54px; + } + + .saved-team-sprite img { + width: 50px; + height: 50px; + } +} + diff --git a/frontend/src/formats.ts b/frontend/src/formats.ts new file mode 100644 index 0000000..b58b1b1 --- /dev/null +++ b/frontend/src/formats.ts @@ -0,0 +1,27 @@ +export type FormatOption = { + value: string; + label: string; +}; + +export const FORMAT_OPTIONS: FormatOption[] = [ + { value: "gen1", label: "RBY" }, + { value: "gen2", label: "GSC" }, + { value: "gen3", label: "RSE" }, + { value: "gen4", label: "DPP" }, + { value: "gen5", label: "BW" }, + { value: "gen6", label: "XY" }, + { value: "gen7", label: "SM" }, + { value: "gen8", label: "SS" }, + { value: "gen8natdex", label: "SS NatDex" }, + { value: "gen9", label: "SV" }, + { value: "gen9natdex", label: "SV NatDex" }, + { value: "champions", label: "Champions" }, + { value: "championsnatdex", label: "Champions NatDex" }, +]; + +export function getFormatLabel(format: string): string { + return ( + FORMAT_OPTIONS.find((option) => option.value === format)?.label ?? + format.toUpperCase() + ); +} diff --git a/frontend/src/savedTeams.test.ts b/frontend/src/savedTeams.test.ts index 338e82f..171984b 100644 --- a/frontend/src/savedTeams.test.ts +++ b/frontend/src/savedTeams.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it } from "vitest"; import { + deleteSavedTeam, loadSavedTeams, + matchesSavedTeam, SAVED_TEAMS_STORAGE_KEY, saveTeam, } from "./savedTeams.ts"; @@ -64,4 +66,37 @@ describe("saved teams storage", () => { expect(loadSavedTeams()).toEqual([]); }); + + it("deletes a saved team by id", () => { + const first = saveTeam({ + name: "Kanto Classics", + format: "gen9", + pokemon: [pikachu], + }); + const second = saveTeam({ + name: "Johto Squad", + format: "gen8", + pokemon: [pikachu], + }); + + expect(deleteSavedTeam(first.id).map((team) => team.id)).toEqual([ + second.id, + ]); + expect(loadSavedTeams()).toEqual([second]); + }); + + it("detects whether the active format and roster match a saved team", () => { + const saved = saveTeam({ + name: "Kanto Classics", + format: "gen9", + pokemon: [pikachu], + }); + + expect(matchesSavedTeam(saved, "gen9", [pikachu])).toBe(true); + expect(matchesSavedTeam(saved, "gen8", [pikachu])).toBe(false); + expect(matchesSavedTeam(saved, "gen9", [])).toBe(false); + expect( + matchesSavedTeam(saved, "gen9", [{ ...pikachu, id: "raichu" }]), + ).toBe(false); + }); }); diff --git a/frontend/src/savedTeams.ts b/frontend/src/savedTeams.ts index 38e40a2..c15a0d1 100644 --- a/frontend/src/savedTeams.ts +++ b/frontend/src/savedTeams.ts @@ -70,6 +70,33 @@ export function loadSavedTeams(): SavedTeam[] { } } +export function deleteSavedTeam(id: string): SavedTeam[] { + const teams = loadSavedTeams().filter((team) => team.id !== id); + + try { + storeSavedTeams(teams); + } catch { + throw new Error("Could not delete the team in this browser."); + } + + return teams; +} + +/** True when the active format/roster is exactly what is stored for the team. */ +export function matchesSavedTeam( + savedTeam: SavedTeam, + format: string, + pokemon: TeamPokemon[], +): boolean { + return ( + savedTeam.format === format && + savedTeam.pokemon.length === pokemon.length && + savedTeam.pokemon.every( + (member, index) => member.id === pokemon[index]?.id, + ) + ); +} + /** * Creates a saved team or updates it when `id` matches an existing one. * Throws when the name is blank or the browser refuses to store the team. diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 32cb081..ccbd260 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -6,5 +6,8 @@ export default defineConfig({ test: { environment: "jsdom", include: ["src/**/*.test.{ts,tsx}"], + // Several tests wait on debounced UI and Apollo mocks; keep headroom when + // test files run in parallel on slower machines. + testTimeout: 15_000, }, }); From a45c96595961c8e12ba6b0494305128fa54d6266 Mon Sep 17 00:00:00 2001 From: helgeovs Date: Mon, 2 Nov 2026 11:51:51 +0100 Subject: [PATCH 4/9] Cleanup save function and format selector after rebase with login. Added the stats-section to the home page. --- backend/README.md | 24 ++- backend/src/graphql/api.test.ts | 83 ++++++++ backend/src/graphql/resolvers.ts | 51 +++++ backend/src/graphql/schema.ts | 13 ++ frontend/src/App.test.tsx | 79 ++++++- frontend/src/App.tsx | 53 +++-- frontend/src/assets/pages/home/home.css | 98 +++++++++ frontend/src/assets/pages/home/page.test.tsx | 211 +++++++++++++++++-- frontend/src/assets/pages/home/page.tsx | 143 ++++++++++--- frontend/src/auth/AuthContext.tsx | 9 +- frontend/src/savedTeams.test.ts | 53 ++++- frontend/src/savedTeams.ts | 29 ++- frontend/src/stats.ts | 53 +++++ 13 files changed, 814 insertions(+), 85 deletions(-) create mode 100644 frontend/src/stats.ts diff --git a/backend/README.md b/backend/README.md index e2183e9..702168e 100644 --- a/backend/README.md +++ b/backend/README.md @@ -68,7 +68,7 @@ The GraphQL server listens on port `3001`. When running locally, the endpoint is ## GraphQL API -The API currently supports retrieving lists of Pokémon and individual Pokémon by ID. +The API currently supports retrieving lists of Pokémon, individual Pokémon by ID, and base stats for a set of Pokémon. ### Retrieve Pokémon @@ -100,6 +100,26 @@ query { This query retrieves a single Pokémon. If no Pokémon matches the supplied ID, the resolver returns `null`. +### Retrieve base stats for several Pokémon + +```graphql +query { + pokemonStats(ids: ["pikachu", "charizard"]) { + id + name + hp + atk + def + spa + spd + spe + total + } +} +``` + +This query returns base stats and the Base Stat Total (`total`) for up to 100 Pokémon at once. Unknown IDs are ignored. The home page uses this query to show its team stats overview. + ## Frontend Integration The React frontend uses Apollo Client to communicate with the GraphQL API. The frontend must be configured to use an endpoint that is reachable from the browser, and the backend must be running for API requests to succeed. @@ -153,7 +173,9 @@ The tests use `http://localhost:3001/` by default. To test another endpoint, set - Retrieving the Pokémon list - Retrieving a Pokémon by ID - Retrieving a Pokémon's sprite file name +- Retrieving base stats and Base Stat Total for several Pokémon - Returning `null` for an unknown Pokémon ID +- Ignoring unknown IDs when requesting base stats - Rejecting a query containing an invalid GraphQL field The tests are read-only and use the existing database. They do not create or modify database records. diff --git a/backend/src/graphql/api.test.ts b/backend/src/graphql/api.test.ts index c1d233a..177d941 100644 --- a/backend/src/graphql/api.test.ts +++ b/backend/src/graphql/api.test.ts @@ -17,6 +17,18 @@ type SearchPokemon = { num: number; }; +type PokemonStats = { + id: string; + name: string; + hp: number; + atk: number; + def: number; + spa: number; + spd: number; + spe: number; + total: number; +}; + type GraphQLResponse = { data?: T; errors?: { message: string }[]; @@ -158,6 +170,77 @@ describe("GraphQL API integration", () => { expect(body.errors!.length).toBeGreaterThan(0); }); + it("returns base stats and the base stat total for the requested Pokémon", async () => { + const { response, body } = await sendQuery<{ + pokemonStats: PokemonStats[]; + }>( + ` + query PokemonStats($ids: [ID!]!) { + pokemonStats(ids: $ids) { + id + name + hp + atk + def + spa + spd + spe + total + } + } + `, + { ids: ["pikachu", "charizard", "blissey"] }, + ); + + expect(response.status).toBe(200); + expect(body.errors).toBeUndefined(); + + const statsById = new Map( + body.data!.pokemonStats.map((stats) => [stats.id, stats]), + ); + + expect(statsById.get("pikachu")).toEqual({ + id: "pikachu", + name: "Pikachu", + hp: 35, + atk: 55, + def: 40, + spa: 50, + spd: 50, + spe: 90, + total: 320, + }); + + const blissey = statsById.get("blissey")!; + + expect(blissey.total).toBe( + blissey.hp + + blissey.atk + + blissey.def + + blissey.spa + + blissey.spd + + blissey.spe, + ); + }); + + it("ignores unknown IDs when requesting base stats", async () => { + const { body } = await sendQuery<{ + pokemonStats: { id: string }[]; + }>( + ` + query PokemonStats($ids: [ID!]!) { + pokemonStats(ids: $ids) { + id + } + } + `, + { ids: ["pikachu", "__integration_test_missing_pokemon__"] }, + ); + + expect(body.errors).toBeUndefined(); + expect(body.data!.pokemonStats).toEqual([{ id: "pikachu" }]); + }); + it("respects the requested result limit", async () => { const { response, body } = await sendQuery<{ searchPokemons: SearchPokemon[]; diff --git a/backend/src/graphql/resolvers.ts b/backend/src/graphql/resolvers.ts index 8289ae4..2251315 100644 --- a/backend/src/graphql/resolvers.ts +++ b/backend/src/graphql/resolvers.ts @@ -13,6 +13,22 @@ type SearchPokemonArgs = { sortDirection?: string | null; }; +type PokemonStatsArgs = { + ids: string[]; +}; + +type PokemonStatsRow = { + id: string; + name: string; + hp: number; + atk: number; + def: number; + spa: number; + spd: number; + spe: number; + total: bigint | number; +}; + type UserRow = { id: number; username: string; @@ -112,6 +128,41 @@ export const resolvers = { return pokemonRows[0] ?? null; }, + pokemonStats: async (_: unknown, { ids }: PokemonStatsArgs) => { + const uniqueIds = [...new Set(ids)]; + + if (uniqueIds.length === 0) return []; + + if (uniqueIds.length > 100) { + throw new Error( + "At most 100 Pokémon IDs can be requested at once", + ); + } + + const placeholders = uniqueIds.map(() => "?").join(", "); + + const rows = (await pool.execute( + `SELECT + p.id, + p.name, + p.hp, + p.atk, + p.def, + p.spa, + p.spd, + p.spe, + p.hp + p.atk + p.def + p.spa + p.spd + p.spe AS total + FROM pokemon p + WHERE p.id IN (${placeholders}) + ORDER BY p.num, p.name`, + uniqueIds, + )) as PokemonStatsRow[]; + + // MariaDB returns the computed BIGINT total as a BigInt, which the + // GraphQL Int scalar cannot serialize. + return rows.map((row) => ({ ...row, total: Number(row.total) })); + }, + typeChart: async () => { const [types, effectiveness] = await Promise.all([ pool.query("SELECT name FROM types ORDER BY name"), diff --git a/backend/src/graphql/schema.ts b/backend/src/graphql/schema.ts index 18135be..e1692ab 100644 --- a/backend/src/graphql/schema.ts +++ b/backend/src/graphql/schema.ts @@ -15,6 +15,18 @@ export const typeDefs = `#graphql sprite: String } + type PokemonStats { + id: ID! + name: String! + hp: Int! + atk: Int! + def: Int! + spa: Int! + spd: Int! + spe: Int! + total: Int! + } + type TypeEffectiveness { attackingType: String! defendingType: String! @@ -34,6 +46,7 @@ export const typeDefs = `#graphql type Query { pokemons: [Pokemon!]! pokemon(id: ID!): Pokemon + pokemonStats(ids: [ID!]!): [PokemonStats!]! typeChart: TypeChart! searchPokemons( format: String! diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 889af59..def4922 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -10,8 +10,13 @@ import { import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import App from "./App.tsx"; +import { AUTH_STORAGE_KEY, AuthProvider } from "./auth/AuthContext.tsx"; +import { USER_BY_ID } from "./graphql/users.ts"; import { loadSavedTeams } from "./savedTeams.ts"; import { SEARCH_POKEMONS, TYPE_CHART } from "./search/search.ts"; +import { POKEMON_STATS } from "./stats.ts"; + +const USER_ID = "user-1"; afterEach(cleanup); @@ -19,6 +24,22 @@ beforeEach(() => { window.localStorage.clear(); }); +const userMock = { + request: { + query: USER_BY_ID, + variables: { id: USER_ID }, + }, + result: { + data: { + userById: { + __typename: "User", + id: USER_ID, + username: "ash", + }, + }, + }, +}; + const typeChartMock = { request: { query: TYPE_CHART }, result: { @@ -62,11 +83,40 @@ const searchMock = { }, }; +const statsMock = { + request: { + query: POKEMON_STATS, + variables: { ids: ["pikachu"] }, + }, + result: { + data: { + pokemonStats: [ + { + __typename: "PokemonStats", + id: "pikachu", + name: "Pikachu", + hp: 35, + atk: 55, + def: 40, + spa: 50, + spd: 50, + spe: 90, + total: 320, + }, + ], + }, + }, +}; + function renderApp() { + window.localStorage.setItem(AUTH_STORAGE_KEY, USER_ID); + render( - + - + + + , ); @@ -95,7 +145,9 @@ describe("App format selector", () => { "Add Pokémon to your team to see their type matchups.", ); - const selector = screen.getByRole("combobox") as HTMLSelectElement; + const selector = screen.getByRole("combobox", { + name: "Format", + }) as HTMLSelectElement; expect(selector.value).toBe("gen9"); expect( @@ -106,6 +158,21 @@ describe("App format selector", () => { .selected, ).toBe(true); }); + + it("falls back to SV when the stored format is invalid", async () => { + window.localStorage.setItem("pokemon-draft-planner-format", "invalid"); + renderApp(); + + await screen.findByText( + "Add Pokémon to your team to see their type matchups.", + ); + + const selector = screen.getByRole("combobox", { + name: "Format", + }) as HTMLSelectElement; + + expect(selector.value).toBe("gen9"); + }); }); describe("Saving the active team", () => { @@ -131,7 +198,7 @@ describe("Saving the active team", () => { expect(screen.getByText("Saved: Kanto Classics")).toBeTruthy(); expect(screen.getByText("Team saved as “Kanto Classics”.")).toBeTruthy(); - const savedTeams = loadSavedTeams(); + const savedTeams = loadSavedTeams(USER_ID); expect(savedTeams).toHaveLength(1); expect(savedTeams[0]).toMatchObject({ name: "Kanto Classics", @@ -142,7 +209,7 @@ describe("Saving the active team", () => { fireEvent.click(screen.getByRole("button", { name: "Save team" })); expect(screen.queryByRole("dialog")).toBeNull(); - expect(loadSavedTeams()).toHaveLength(1); + expect(loadSavedTeams(USER_ID)).toHaveLength(1); }); it("disables saving when the active team is empty", async () => { @@ -202,7 +269,7 @@ describe("Teams page", () => { fireEvent.click(screen.getByRole("button", { name: "Yes, delete" })); expect(await screen.findByText(/No saved teams yet/)).toBeTruthy(); - expect(loadSavedTeams()).toEqual([]); + expect(loadSavedTeams(USER_ID)).toEqual([]); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4ac26f5..57cb466 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,7 +21,8 @@ import { } from "./savedTeams.ts"; import SearchModal from "./search/SearchModal.tsx"; import { MAX_TEAM_SIZE, type TeamPokemon } from "./team.ts"; -import { useAuth } from "./auth/AuthContext.tsx"; +import { FORMAT_OPTIONS } from "./formats.ts"; +import { useAuth, type User } from "./auth/AuthContext.tsx"; import ProtectedRoute from "./auth/ProtectedRoute.tsx"; import LoginPage from "./auth/LoginPage.tsx"; @@ -29,7 +30,6 @@ type ApplicationContext = { format: string; setFormat: (format: string) => void; team: TeamPokemon[]; - addPokemon: (pokemon: TeamPokemon) => void; removePokemon: (id: string) => void; openSearch: () => void; onSaveTeam: () => void; @@ -42,17 +42,40 @@ type ApplicationContext = { onDeleteTeam: (id: string) => void; }; +type SignedInLayoutProps = { + user: User; + logout: () => void; +}; + function ApplicationLayout() { const { user, logout } = useAuth(); + + if (!user) { + return null; + } + + return ; +} + +function SignedInLayout({ user, logout }: SignedInLayoutProps) { const [menuOpen, setMenuOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); - const [format, setFormat] = useState( - () => - localStorage.getItem(`pokemon-draft-planner-format-${user?.id}`) || - "gen9", - ); + const [format, setFormat] = useState(() => { + const storedFormat = localStorage.getItem( + `pokemon-draft-planner-format-${user.id}`, + ); + + if ( + storedFormat && + FORMAT_OPTIONS.some((option) => option.value === storedFormat) + ) { + return storedFormat; + } + + return "gen9"; + }); const [savedTeams, setSavedTeams] = useState(() => - loadSavedTeams(), + loadSavedTeams(user.id), ); const [activeSavedTeam, setActiveSavedTeam] = useState<{ id: string; @@ -64,20 +87,18 @@ function ApplicationLayout() { const [team, setTeam] = useState(() => { const savedTeam = localStorage.getItem( - `pokemon-draft-planner-team-${user?.id}`, + `pokemon-draft-planner-team-${user.id}`, ); return savedTeam ? JSON.parse(savedTeam) : []; }); useEffect(() => { - if (!user) return; - localStorage.setItem( `pokemon-draft-planner-team-${user.id}`, JSON.stringify(team), ); - }, [team, user]); + }, [team, user.id]); const navigate = useNavigate(); @@ -94,7 +115,7 @@ function ApplicationLayout() { function handleFormatChange(newFormat: string) { setFormat(newFormat); - localStorage.setItem(`pokemon-draft-planner-format-${user?.id}`, newFormat); + localStorage.setItem(`pokemon-draft-planner-format-${user.id}`, newFormat); setSaveMessage(null); setSaveError(null); } @@ -121,6 +142,7 @@ function ApplicationLayout() { function saveCurrentTeam(name: string, id: string | null) { try { const savedTeam = persistSavedTeam({ + userId: user.id, id, name, format, @@ -128,7 +150,7 @@ function ApplicationLayout() { }); setActiveSavedTeam({ id: savedTeam.id, name: savedTeam.name }); - setSavedTeams(loadSavedTeams()); + setSavedTeams(loadSavedTeams(user.id)); setSaveModalOpen(false); setSaveError(null); setSaveMessage(`Team saved as “${savedTeam.name}”.`); @@ -162,7 +184,7 @@ function ApplicationLayout() { } function deleteTeam(id: string) { - const teams = deleteSavedTeam(id); + const teams = deleteSavedTeam(user.id, id); setSavedTeams(teams); setSaveMessage(null); setSaveError(null); @@ -266,7 +288,6 @@ function ApplicationLayout() { format, setFormat: handleFormatChange, team, - addPokemon, removePokemon, openSearch: () => setSearchOpen(true), onSaveTeam: handleSaveTeam, diff --git a/frontend/src/assets/pages/home/home.css b/frontend/src/assets/pages/home/home.css index 6006569..3a43baf 100644 --- a/frontend/src/assets/pages/home/home.css +++ b/frontend/src/assets/pages/home/home.css @@ -12,6 +12,95 @@ min-width: 0; } +.stats { + padding: 0 2rem 2rem; + min-width: 0; +} + +.stats-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 0.75rem; +} + +.stats-header h2 { + margin: 0; +} + +.stats-sort { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text); + font-size: 0.9rem; +} + +.stats-sort select { + padding: 0.45rem 0.9rem; + border: 1px solid var(--border); + border-radius: 999px; + background-color: var(--code-bg); + color: var(--text); + font-size: 0.9rem; + font-family: var(--sans); + cursor: pointer; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.stats-sort select:hover, +.stats-sort select:focus { + border-color: var(--accent); +} + +.stats-sort select:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.stats-table-wrapper { + overflow-x: auto; + max-width: 100%; +} + +.stats-table { + border-collapse: collapse; + min-width: 100%; + font-size: 0.85rem; +} + +.stats-table th, +.stats-table td { + border: 1px solid var(--border); + padding: 0.5rem 0.6rem; + white-space: nowrap; +} + +.stats-table thead th { + position: sticky; + top: 0; + background: var(--code-bg); +} + +.stats-table tbody th { + background: var(--code-bg); + text-align: left; +} + +.stats-table td { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.stats-table .stats-sorted-column { + background: var(--accent-bg); + color: var(--text-h); + font-weight: 600; +} + .type-matchup-table-wrapper { overflow-x: auto; max-width: 100%; @@ -269,6 +358,15 @@ .your-team-actions button { flex: 1; } + + .stats { + padding: 0 1rem 1.5rem; + } + + .stats-header { + align-items: flex-start; + flex-direction: column; + } } .top-bar { diff --git a/frontend/src/assets/pages/home/page.test.tsx b/frontend/src/assets/pages/home/page.test.tsx index 857fa37..5d589cd 100644 --- a/frontend/src/assets/pages/home/page.test.tsx +++ b/frontend/src/assets/pages/home/page.test.tsx @@ -8,11 +8,38 @@ import { } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { MAX_TEAM_SIZE, TEAM_FULL_MESSAGE } from "../../../team.ts"; +import { POKEMON_STATS, type PokemonStats } from "../../../stats.ts"; import HomePage from "./page"; import { TYPE_CHART } from "../../../search/search"; afterEach(cleanup); +const charizardStats: PokemonStats = { + id: "charizard", + name: "Charizard", + hp: 78, + atk: 84, + def: 78, + spa: 109, + spd: 85, + spe: 100, + total: 534, +}; + +function createStatsMock(ids: string[], stats: PokemonStats[]) { + return { + request: { query: POKEMON_STATS, variables: { ids } }, + result: { + data: { + pokemonStats: stats.map((pokemon) => ({ + __typename: "PokemonStats", + ...pokemon, + })), + }, + }, + }; +} + describe("HomePage", () => { it("shows each type and combines defensive multipliers for dual-type Pokémon", async () => { const typeEffectiveness = [ @@ -33,7 +60,7 @@ describe("HomePage", () => { }, }, }, - }]; + }, createStatsMock(["charizard"], [charizardStats])]; const onRemovePokemon = vi.fn(); @@ -56,7 +83,10 @@ describe("HomePage", () => { , ); - const row = await screen.findByRole("row", { name: /Charizard/ }); + const typeTable = await screen.findByRole("table", { + name: "Type matchups", + }); + const row = within(typeTable).getByRole("row", { name: /Charizard/ }); expect(within(row).getByRole("cell", { name: "4x" })).toBeTruthy(); expect(within(row).getByRole("cell", { name: "0.25x" })).toBeTruthy(); expect(within(row).getByRole("cell", { name: "0x" })).toBeTruthy(); @@ -78,17 +108,6 @@ describe("HomePage", () => { }); it("disables Add Pokémon + when the team is full", async () => { - const mocks = [{ - request: { query: TYPE_CHART }, - result: { - data: { - typeChart: { - types: ["Fire"], - effectiveness: [], - }, - }, - }, - }]; const team = Array.from({ length: MAX_TEAM_SIZE }, (_, index) => ({ id: `pokemon-${index}`, name: `Pokemon ${index}`, @@ -96,6 +115,34 @@ describe("HomePage", () => { gen: 1, sprite: null, })); + const stats = team.map((pokemon, index) => ({ + id: pokemon.id, + name: pokemon.name, + hp: 50, + atk: 40, + def: 30, + spa: 20, + spd: 10, + spe: index, + total: 150 + index, + })); + const mocks = [ + { + request: { query: TYPE_CHART }, + result: { + data: { + typeChart: { + types: ["Fire"], + effectiveness: [], + }, + }, + }, + }, + createStatsMock( + team.map((pokemon) => pokemon.id), + stats, + ), + ]; render( @@ -117,6 +164,7 @@ describe("HomePage", () => { expect(addButton.hasAttribute("disabled")).toBe(true); expect(screen.getByText(`${MAX_TEAM_SIZE} / ${MAX_TEAM_SIZE}`)).toBeTruthy(); expect(screen.getByText(TEAM_FULL_MESSAGE)).toBeTruthy(); + expect(screen.getAllByRole("article")).toHaveLength(MAX_TEAM_SIZE); }); it("saves the active team and shows save feedback", async () => { @@ -130,7 +178,7 @@ describe("HomePage", () => { }, }, }, - }]; + }, createStatsMock(["charizard"], [charizardStats])]; const onSaveTeam = vi.fn(); render( @@ -198,5 +246,140 @@ describe("HomePage", () => { }); expect(saveButton.hasAttribute("disabled")).toBe(true); + expect( + screen + .getByRole("combobox", { name: "Sort by" }) + .hasAttribute("disabled"), + ).toBe(true); + expect( + screen.getByText("Add Pokémon to your team to see their base stats."), + ).toBeTruthy(); + }); + + it("sorts base stats in descending order and defaults to speed", async () => { + const stats: PokemonStats[] = [ + { + id: "pikachu", + name: "Pikachu", + hp: 35, + atk: 55, + def: 40, + spa: 50, + spd: 50, + spe: 90, + total: 320, + }, + { + id: "charizard", + name: "Charizard", + hp: 78, + atk: 84, + def: 78, + spa: 109, + spd: 85, + spe: 100, + total: 534, + }, + { + id: "blissey", + name: "Blissey", + hp: 255, + atk: 10, + def: 10, + spa: 75, + spd: 135, + spe: 55, + total: 540, + }, + ]; + const team = stats.map((pokemon) => ({ + id: pokemon.id, + name: pokemon.name, + type: "Normal", + gen: 1, + sprite: null, + })); + + render( + pokemon.id), + stats, + ), + ]} + > + {}} + onOpenSearch={() => {}} + team={team} + onRemovePokemon={() => {}} + onSaveTeam={() => {}} + /> + , + ); + + const statsTable = await screen.findByRole("table", { + name: "Team base stats", + }); + const sortSelect = screen.getByRole("combobox", { name: "Sort by" }); + + expect((sortSelect as HTMLSelectElement).value).toBe("spe"); + expect( + within(sortSelect) + .getAllByRole("option") + .map((option) => (option as HTMLOptionElement).value), + ).toEqual(["hp", "atk", "def", "spa", "spd", "spe", "total"]); + + const rowNames = () => + within(statsTable) + .getAllByRole("rowheader") + .map((header) => header.textContent); + + expect(rowNames()).toEqual(["Charizard", "Pikachu", "Blissey"]); + expect( + within(statsTable) + .getByRole("columnheader", { name: "Speed" }) + .getAttribute("aria-sort"), + ).toBe("descending"); + + const charizardRow = within(statsTable).getByRole("row", { + name: /Charizard/, + }); + expect( + within(charizardRow) + .getAllByRole("cell") + .map((cell) => cell.textContent), + ).toEqual(["78", "84", "78", "109", "85", "100", "534"]); + + fireEvent.change(sortSelect, { target: { value: "total" } }); + + expect(rowNames()).toEqual(["Blissey", "Charizard", "Pikachu"]); + expect( + within(statsTable) + .getByRole("columnheader", { name: "Base Stat Total" }) + .getAttribute("aria-sort"), + ).toBe("descending"); + + fireEvent.change(sortSelect, { target: { value: "spa" } }); + + expect(rowNames()).toEqual(["Charizard", "Blissey", "Pikachu"]); + expect( + within(statsTable) + .getByRole("columnheader", { name: "Sp. Atk" }) + .getAttribute("aria-sort"), + ).toBe("descending"); }); }); diff --git a/frontend/src/assets/pages/home/page.tsx b/frontend/src/assets/pages/home/page.tsx index 71a70fb..940d33c 100644 --- a/frontend/src/assets/pages/home/page.tsx +++ b/frontend/src/assets/pages/home/page.tsx @@ -1,8 +1,17 @@ import "./home.css"; import { useQuery } from "@apollo/client/react"; +import { useState } from "react"; import PokemonCard from "../../components/pokemoncard/pokemoncard.tsx"; import { TYPE_CHART } from "../../../search/search"; import { FORMAT_OPTIONS } from "../../../formats.ts"; +import { + DEFAULT_STAT_SORT, + POKEMON_STATS, + STAT_OPTIONS, + sortPokemonStats, + type PokemonStats, + type StatKey, +} from "../../../stats.ts"; import { MAX_TEAM_SIZE, TEAM_FULL_MESSAGE, @@ -22,6 +31,14 @@ type TypeEffectivenessResponse = { }; }; +type PokemonStatsResponse = { + pokemonStats: PokemonStats[]; +}; + +type PokemonStatsVariables = { + ids: string[]; +}; + type HomePageProps = { format: string; onFormatChange: (format: string) => void; @@ -50,6 +67,21 @@ function HomePage({ const types = data?.typeChart.types ?? []; const teamFull = team.length >= MAX_TEAM_SIZE; + const [statSort, setStatSort] = useState(DEFAULT_STAT_SORT); + const teamIds = team.map((pokemon) => pokemon.id); + const { + data: statsData, + loading: statsLoading, + error: statsError, + } = useQuery(POKEMON_STATS, { + variables: { ids: teamIds }, + skip: teamIds.length === 0, + }); + const sortedStats = sortPokemonStats( + statsData?.pokemonStats ?? [], + statSort, + ); + const multiplierFor = (pokemon: TeamPokemon, attackingType: string) => { const defendingTypes = pokemon.type .split(",") @@ -76,6 +108,7 @@ function HomePage({ + setStatSort(event.target.value as StatKey) + } + disabled={team.length === 0} + > + {STAT_OPTIONS.map((option) => ( + + ))} + + +
+ + {team.length === 0 ? ( +

+ Add Pokémon to your team to see their base stats. +

+ ) : statsLoading ? ( +

Loading base stats…

+ ) : statsError ? ( +

Could not load base stats: {statsError.message}

+ ) : sortedStats.length === 0 ? ( +

No base stats available for this team.

+ ) : ( +
+ + + + + {STAT_OPTIONS.map((option) => ( + + ))} + + + + {sortedStats.map((pokemon) => ( + + + {STAT_OPTIONS.map((option) => ( + + ))} + + ))} + +
Pokémon + {option.label} +
{pokemon.name} + {pokemon[option.value]} +
+
+ )}

Moves

diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx index 62a683d..a001a6e 100644 --- a/frontend/src/auth/AuthContext.tsx +++ b/frontend/src/auth/AuthContext.tsx @@ -23,18 +23,20 @@ type AuthContextType = { const AuthContext = createContext(undefined); -const STORAGE_KEY = "pokemon-draft-planner-user-id"; +export const AUTH_STORAGE_KEY = "pokemon-draft-planner-user-id"; +const STORAGE_KEY = AUTH_STORAGE_KEY; export function AuthProvider({ children }: { children: ReactNode }) { const client = useApolloClient(); const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState( + () => localStorage.getItem(STORAGE_KEY) !== null, + ); useEffect(() => { const storedUserId = localStorage.getItem(STORAGE_KEY); if (!storedUserId) { - setLoading(false); return; } @@ -127,6 +129,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); } +// eslint-disable-next-line react-refresh/only-export-components export function useAuth(): AuthContextType { const context = useContext(AuthContext); diff --git a/frontend/src/savedTeams.test.ts b/frontend/src/savedTeams.test.ts index 171984b..12056cc 100644 --- a/frontend/src/savedTeams.test.ts +++ b/frontend/src/savedTeams.test.ts @@ -3,11 +3,14 @@ import { deleteSavedTeam, loadSavedTeams, matchesSavedTeam, - SAVED_TEAMS_STORAGE_KEY, + savedTeamsStorageKey, saveTeam, } from "./savedTeams.ts"; import type { TeamPokemon } from "./team.ts"; +const USER_ID = "user-1"; +const OTHER_USER_ID = "user-2"; + const pikachu: TeamPokemon = { id: "pikachu", name: "Pikachu", @@ -23,6 +26,7 @@ beforeEach(() => { describe("saved teams storage", () => { it("saves a new team under a trimmed name", () => { const saved = saveTeam({ + userId: USER_ID, name: " Kanto Classics ", format: "gen9", pokemon: [pikachu], @@ -30,17 +34,19 @@ describe("saved teams storage", () => { expect(saved.name).toBe("Kanto Classics"); expect(saved.id).toBeTruthy(); - expect(loadSavedTeams()).toEqual([saved]); + expect(loadSavedTeams(USER_ID)).toEqual([saved]); }); it("updates an existing saved team instead of creating a duplicate", () => { const saved = saveTeam({ + userId: USER_ID, name: "Kanto Classics", format: "gen9", pokemon: [pikachu], }); const updated = saveTeam({ + userId: USER_ID, id: saved.id, name: saved.name, format: "gen8", @@ -48,45 +54,74 @@ describe("saved teams storage", () => { }); expect(updated.id).toBe(saved.id); - expect(loadSavedTeams()).toEqual([updated]); + expect(loadSavedTeams(USER_ID)).toEqual([updated]); + }); + + it("keeps saved teams unique to each user", () => { + const ashTeam = saveTeam({ + userId: USER_ID, + name: "Kanto Classics", + format: "gen9", + pokemon: [pikachu], + }); + const mistyTeam = saveTeam({ + userId: OTHER_USER_ID, + name: "Cerulean Squad", + format: "gen8", + pokemon: [pikachu], + }); + + expect(loadSavedTeams(USER_ID)).toEqual([ashTeam]); + expect(loadSavedTeams(OTHER_USER_ID)).toEqual([mistyTeam]); + expect(savedTeamsStorageKey(USER_ID)).not.toBe( + savedTeamsStorageKey(OTHER_USER_ID), + ); }); it("rejects a team without a name", () => { expect(() => - saveTeam({ name: " ", format: "gen9", pokemon: [pikachu] }), + saveTeam({ + userId: USER_ID, + name: " ", + format: "gen9", + pokemon: [pikachu], + }), ).toThrow("Enter a name for your team."); - expect(loadSavedTeams()).toEqual([]); + expect(loadSavedTeams(USER_ID)).toEqual([]); }); it("ignores invalid data in storage", () => { window.localStorage.setItem( - SAVED_TEAMS_STORAGE_KEY, + savedTeamsStorageKey(USER_ID), JSON.stringify([{ id: 1 }, null]), ); - expect(loadSavedTeams()).toEqual([]); + expect(loadSavedTeams(USER_ID)).toEqual([]); }); it("deletes a saved team by id", () => { const first = saveTeam({ + userId: USER_ID, name: "Kanto Classics", format: "gen9", pokemon: [pikachu], }); const second = saveTeam({ + userId: USER_ID, name: "Johto Squad", format: "gen8", pokemon: [pikachu], }); - expect(deleteSavedTeam(first.id).map((team) => team.id)).toEqual([ + expect(deleteSavedTeam(USER_ID, first.id).map((team) => team.id)).toEqual([ second.id, ]); - expect(loadSavedTeams()).toEqual([second]); + expect(loadSavedTeams(USER_ID)).toEqual([second]); }); it("detects whether the active format and roster match a saved team", () => { const saved = saveTeam({ + userId: USER_ID, name: "Kanto Classics", format: "gen9", pokemon: [pikachu], diff --git a/frontend/src/savedTeams.ts b/frontend/src/savedTeams.ts index c15a0d1..66dffc5 100644 --- a/frontend/src/savedTeams.ts +++ b/frontend/src/savedTeams.ts @@ -1,6 +1,10 @@ import type { TeamPokemon } from "./team.ts"; -export const SAVED_TEAMS_STORAGE_KEY = "pokemon-draft-planner.saved-teams"; +export const SAVED_TEAMS_STORAGE_PREFIX = "pokemon-draft-planner.saved-teams"; + +export function savedTeamsStorageKey(userId: string): string { + return `${SAVED_TEAMS_STORAGE_PREFIX}.${userId}`; +} export type SavedTeam = { id: string; @@ -11,6 +15,7 @@ export type SavedTeam = { }; type SaveTeamInput = { + userId: string; id?: string | null; name: string; format: string; @@ -54,13 +59,16 @@ function createTeamId(): string { return `team-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -function storeSavedTeams(teams: SavedTeam[]): void { - window.localStorage.setItem(SAVED_TEAMS_STORAGE_KEY, JSON.stringify(teams)); +function storeSavedTeams(userId: string, teams: SavedTeam[]): void { + window.localStorage.setItem( + savedTeamsStorageKey(userId), + JSON.stringify(teams), + ); } -export function loadSavedTeams(): SavedTeam[] { +export function loadSavedTeams(userId: string): SavedTeam[] { try { - const stored = window.localStorage.getItem(SAVED_TEAMS_STORAGE_KEY); + const stored = window.localStorage.getItem(savedTeamsStorageKey(userId)); if (!stored) return []; const parsed: unknown = JSON.parse(stored); @@ -70,11 +78,11 @@ export function loadSavedTeams(): SavedTeam[] { } } -export function deleteSavedTeam(id: string): SavedTeam[] { - const teams = loadSavedTeams().filter((team) => team.id !== id); +export function deleteSavedTeam(userId: string, id: string): SavedTeam[] { + const teams = loadSavedTeams(userId).filter((team) => team.id !== id); try { - storeSavedTeams(teams); + storeSavedTeams(userId, teams); } catch { throw new Error("Could not delete the team in this browser."); } @@ -102,6 +110,7 @@ export function matchesSavedTeam( * Throws when the name is blank or the browser refuses to store the team. */ export function saveTeam({ + userId, id, name, format, @@ -127,7 +136,7 @@ export function saveTeam({ updatedAt: new Date().toISOString(), }; - const teams = loadSavedTeams(); + const teams = loadSavedTeams(userId); const existingIndex = teams.findIndex((team) => team.id === savedTeam.id); if (existingIndex === -1) { @@ -137,7 +146,7 @@ export function saveTeam({ } try { - storeSavedTeams(teams); + storeSavedTeams(userId, teams); } catch { throw new Error("Could not save the team in this browser."); } diff --git a/frontend/src/stats.ts b/frontend/src/stats.ts new file mode 100644 index 0000000..8aae8dc --- /dev/null +++ b/frontend/src/stats.ts @@ -0,0 +1,53 @@ +import { gql } from "@apollo/client"; + +export type StatKey = "hp" | "atk" | "def" | "spa" | "spd" | "spe" | "total"; + +export type PokemonStats = { + id: string; + name: string; + hp: number; + atk: number; + def: number; + spa: number; + spd: number; + spe: number; + total: number; +}; + +export const STAT_OPTIONS: { value: StatKey; label: string }[] = [ + { value: "hp", label: "HP" }, + { value: "atk", label: "Attack" }, + { value: "def", label: "Defense" }, + { value: "spa", label: "Sp. Atk" }, + { value: "spd", label: "Sp. Def" }, + { value: "spe", label: "Speed" }, + { value: "total", label: "Base Stat Total" }, +]; + +export const DEFAULT_STAT_SORT: StatKey = "spe"; + +export const POKEMON_STATS = gql` + query PokemonStats($ids: [ID!]!) { + pokemonStats(ids: $ids) { + id + name + hp + atk + def + spa + spd + spe + total + } + } +`; + +/** Orders Pokémon by the selected stat in descending order. */ +export function sortPokemonStats( + stats: PokemonStats[], + stat: StatKey, +): PokemonStats[] { + return [...stats].sort( + (a, b) => b[stat] - a[stat] || a.name.localeCompare(b.name), + ); +} From 4b249b480688af9db291d81b3f211c72bd1b11a4 Mon Sep 17 00:00:00 2001 From: helgeovs Date: Mon, 2 Nov 2026 12:08:20 +0100 Subject: [PATCH 5/9] =?UTF-8?q?Added=20the=20Moves-section=20to=20the=20Ho?= =?UTF-8?q?me=20page.=20This=20section=20allows=20users=20to=20select=20mo?= =?UTF-8?q?ves=20for=20their=20Pok=C3=A9mon=20team=20and=20displays=20the?= =?UTF-8?q?=20type=20of=20each=20selected=20move.=20The=20section=20also?= =?UTF-8?q?=20includes=20a=20feature=20that=20disables=20the=20"Add=20Pok?= =?UTF-8?q?=C3=A9mon"=20button=20when=20the=20team=20is=20full,=20preventi?= =?UTF-8?q?ng=20users=20from=20adding=20more=20than=20the=20maximum=20allo?= =?UTF-8?q?wed=20number=20of=20Pok=C3=A9mon.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/README.md | 35 ++- backend/src/graphql/api.test.ts | 120 +++++++++ backend/src/graphql/resolvers.ts | 78 ++++++ backend/src/graphql/schema.ts | 17 ++ frontend/README.md | 8 +- frontend/src/App.test.tsx | 17 +- .../components/movesection/movesection.css | 246 ++++++++++++++++++ .../components/movesection/movesection.tsx | 226 ++++++++++++++++ frontend/src/assets/pages/home/page.test.tsx | 166 +++++++++++- frontend/src/assets/pages/home/page.tsx | 5 +- frontend/src/moves.ts | 41 +++ 11 files changed, 949 insertions(+), 10 deletions(-) create mode 100644 frontend/src/assets/components/movesection/movesection.css create mode 100644 frontend/src/assets/components/movesection/movesection.tsx create mode 100644 frontend/src/moves.ts diff --git a/backend/README.md b/backend/README.md index 702168e..6b53d7a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -68,7 +68,7 @@ The GraphQL server listens on port `3001`. When running locally, the endpoint is ## GraphQL API -The API currently supports retrieving lists of Pokémon, individual Pokémon by ID, and base stats for a set of Pokémon. +The API currently supports retrieving lists of Pokémon, individual Pokémon by ID, base stats for a set of Pokémon, the available moves in a format, and which Pokémon can learn a set of moves. ### Retrieve Pokémon @@ -120,6 +120,37 @@ query { This query returns base stats and the Base Stat Total (`total`) for up to 100 Pokémon at once. Unknown IDs are ignored. The home page uses this query to show its team stats overview. +### Retrieve the moves available in a format + +```graphql +query { + moves(format: "gen9") { + id + name + type + } +} +``` + +This query returns every move introduced up to the selected format's generation, sorted by name. `type` is the move's type and is used by the frontend to colour the move slots. + +### Retrieve which Pokémon can learn selected moves + +```graphql +query { + moveLearners( + format: "gen9" + pokemonIds: ["garchomp", "pikachu", "skarmory"] + moveIds: ["stealthrock", "aeroblast"] + ) { + moveId + pokemonIds + } +} +``` + +For each requested move, this query returns the subset of the requested Pokémon that can learn it in the selected format. Up to 100 Pokémon IDs and 100 move IDs can be requested at once, and unknown IDs are ignored. The home page uses this query to fill its 16 move slots with the team members that learn each selected move. + ## Frontend Integration The React frontend uses Apollo Client to communicate with the GraphQL API. The frontend must be configured to use an endpoint that is reachable from the browser, and the backend must be running for API requests to succeed. @@ -174,6 +205,8 @@ The tests use `http://localhost:3001/` by default. To test another endpoint, set - Retrieving a Pokémon by ID - Retrieving a Pokémon's sprite file name - Retrieving base stats and Base Stat Total for several Pokémon +- Retrieving the moves available in a format +- Retrieving which team members can learn each selected move - Returning `null` for an unknown Pokémon ID - Ignoring unknown IDs when requesting base stats - Rejecting a query containing an invalid GraphQL field diff --git a/backend/src/graphql/api.test.ts b/backend/src/graphql/api.test.ts index 177d941..a0bf0f5 100644 --- a/backend/src/graphql/api.test.ts +++ b/backend/src/graphql/api.test.ts @@ -241,6 +241,126 @@ describe("GraphQL API integration", () => { expect(body.data!.pokemonStats).toEqual([{ id: "pikachu" }]); }); + it("returns moves available in a format with their types", async () => { + const { response, body } = await sendQuery<{ + moves: { id: string; name: string; type: string }[]; + }>(` + query { + moves(format: "gen9") { + id + name + type + } + } + `); + + expect(response.status).toBe(200); + expect(body.errors).toBeUndefined(); + + const moves = body.data!.moves; + const stealthRock = moves.find((move) => move.id === "stealthrock"); + + expect(stealthRock).toEqual({ + id: "stealthrock", + name: "Stealth Rock", + type: "Rock", + }); + + const names = moves.map((move) => move.name); + const sortedNames = [...names].sort((a, b) => a.localeCompare(b)); + + expect(names).toEqual(sortedNames); + }); + + it("leaves out moves introduced after the selected format", async () => { + const { response, body } = await sendQuery<{ + moves: { id: string }[]; + }>(` + query { + moves(format: "gen1") { + id + } + } + `); + + expect(response.status).toBe(200); + expect(body.errors).toBeUndefined(); + + const ids = body.data!.moves.map((move) => move.id); + + expect(ids).toContain("thunderwave"); + expect(ids).not.toContain("stealthrock"); + }); + + it("returns which team members can learn each requested move", async () => { + const { response, body } = await sendQuery<{ + moveLearners: { moveId: string; pokemonIds: string[] }[]; + }>( + ` + query MoveLearners( + $format: String! + $pokemonIds: [ID!]! + $moveIds: [ID!]! + ) { + moveLearners( + format: $format + pokemonIds: $pokemonIds + moveIds: $moveIds + ) { + moveId + pokemonIds + } + } + `, + { + format: "gen9", + pokemonIds: ["garchomp", "pikachu", "skarmory"], + moveIds: ["stealthrock", "earthquake", "aeroblast"], + }, + ); + + expect(response.status).toBe(200); + expect(body.errors).toBeUndefined(); + expect(body.data!.moveLearners).toEqual([ + { moveId: "stealthrock", pokemonIds: ["garchomp", "skarmory"] }, + { moveId: "earthquake", pokemonIds: ["garchomp"] }, + { moveId: "aeroblast", pokemonIds: [] }, + ]); + }); + + it("ignores unknown move IDs when finding team members that learn a move", async () => { + const { body } = await sendQuery<{ + moveLearners: { moveId: string; pokemonIds: string[] }[]; + }>( + ` + query MoveLearners( + $format: String! + $pokemonIds: [ID!]! + $moveIds: [ID!]! + ) { + moveLearners( + format: $format + pokemonIds: $pokemonIds + moveIds: $moveIds + ) { + moveId + pokemonIds + } + } + `, + { + format: "gen9", + pokemonIds: ["pikachu"], + moveIds: ["__integration_test_missing_move__"], + }, + ); + + expect(body.errors).toBeUndefined(); + expect(body.data!.moveLearners).toEqual([ + { moveId: "__integration_test_missing_move__", pokemonIds: [] }, + ]); + }); + it("respects the requested result limit", async () => { const { response, body } = await sendQuery<{ searchPokemons: SearchPokemon[]; diff --git a/backend/src/graphql/resolvers.ts b/backend/src/graphql/resolvers.ts index 2251315..a9d8e7c 100644 --- a/backend/src/graphql/resolvers.ts +++ b/backend/src/graphql/resolvers.ts @@ -17,6 +17,12 @@ type PokemonStatsArgs = { ids: string[]; }; +type MoveLearnersArgs = { + format: string; + pokemonIds: string[]; + moveIds: string[]; +}; + type PokemonStatsRow = { id: string; name: string; @@ -163,6 +169,78 @@ export const resolvers = { return rows.map((row) => ({ ...row, total: Number(row.total) })); }, + moves: async (_: unknown, { format }: { format: string }) => { + const generation = generationForFormat(format); + + return pool.execute( + `SELECT + m.id, + m.name, + t.name AS type + FROM moves m + JOIN types t ON t.id = m.type_id + WHERE m.gen <= ? + ORDER BY m.name ASC, m.id ASC`, + [generation], + ); + }, + + moveLearners: async ( + _: unknown, + { format, pokemonIds, moveIds }: MoveLearnersArgs, + ) => { + const generation = generationForFormat(format); + const uniquePokemonIds = [...new Set(pokemonIds)]; + const uniqueMoveIds = [...new Set(moveIds)]; + + if (uniquePokemonIds.length === 0 || uniqueMoveIds.length === 0) { + return uniqueMoveIds.map((moveId) => ({ + moveId, + pokemonIds: [], + })); + } + + if (uniquePokemonIds.length > 100 || uniqueMoveIds.length > 100) { + throw new Error( + "At most 100 Pokémon IDs and 100 move IDs can be requested at once", + ); + } + + const pokemonPlaceholders = uniquePokemonIds.map(() => "?").join(", "); + const movePlaceholders = uniqueMoveIds.map(() => "?").join(", "); + + const rows = (await pool.execute( + `SELECT + ls.move_id AS moveId, + ls.pokemon_id AS pokemonId + FROM learnsets ls + JOIN pokemon p ON p.id = ls.pokemon_id + WHERE ls.pokemon_id IN (${pokemonPlaceholders}) + AND ls.move_id IN (${movePlaceholders}) + AND ls.gen <= ? + AND p.gen <= ? + GROUP BY ls.move_id, ls.pokemon_id`, + [...uniquePokemonIds, ...uniqueMoveIds, generation, generation], + )) as { moveId: string; pokemonId: string }[]; + + const learnersByMove = new Map>(); + + for (const row of rows) { + const learners = learnersByMove.get(row.moveId) ?? new Set(); + learners.add(row.pokemonId); + learnersByMove.set(row.moveId, learners); + } + + // Keep the requested order so the frontend can render slots and team + // members in a predictable order. + return uniqueMoveIds.map((moveId) => ({ + moveId, + pokemonIds: uniquePokemonIds.filter((pokemonId) => + learnersByMove.get(moveId)?.has(pokemonId), + ), + })); + }, + typeChart: async () => { const [types, effectiveness] = await Promise.all([ pool.query("SELECT name FROM types ORDER BY name"), diff --git a/backend/src/graphql/schema.ts b/backend/src/graphql/schema.ts index e1692ab..29988b5 100644 --- a/backend/src/graphql/schema.ts +++ b/backend/src/graphql/schema.ts @@ -27,6 +27,17 @@ export const typeDefs = `#graphql total: Int! } + type Move { + id: ID! + name: String! + type: String! + } + + type MoveLearners { + moveId: ID! + pokemonIds: [ID!]! + } + type TypeEffectiveness { attackingType: String! defendingType: String! @@ -47,6 +58,12 @@ export const typeDefs = `#graphql pokemons: [Pokemon!]! pokemon(id: ID!): Pokemon pokemonStats(ids: [ID!]!): [PokemonStats!]! + moves(format: String!): [Move!]! + moveLearners( + format: String! + pokemonIds: [ID!]! + moveIds: [ID!]! + ): [MoveLearners!]! typeChart: TypeChart! searchPokemons( format: String! diff --git a/frontend/README.md b/frontend/README.md index 3e8b3b5..c57cb99 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -31,7 +31,7 @@ Vite starts the frontend development server. Follow the URL printed in the termi The frontend uses Apollo Client to retrieve Pokémon data from the backend. The GraphQL endpoint must be configured to point to an address accessible from the browser. -The backend currently provides queries for retrieving a list of Pokémon and an individual Pokémon by ID. See the [backend README](../backend/README.md) for setup instructions and example queries. +The backend currently provides queries for searching Pokémon, retrieving base stats, the type chart, the moves available in a format, and which Pokémon can learn selected moves. See the [backend README](../backend/README.md) for setup instructions and example queries. ## Sprites @@ -44,11 +44,13 @@ from somewhere else. ## Current Functionality -The frontend lets users search for Pokémon and build a team of up to six +The frontend lets users search for Pokémon and build a team of up to twelve Pokémon. Search results and team members are displayed as Pokémon cards with their sprite, name and types. Pokémon already on the team can be removed from either the team panel or a search result. When the team is full, the interface -explains that a Pokémon must be removed before another can be added. Additional +explains that a Pokémon must be removed before another can be added. The home +page also shows type matchups, base stats and a 16-slot move overview that +highlights which team members can learn each selected move. Additional features are still under development. ## Related Documentation diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index def4922..54009a6 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -15,6 +15,7 @@ import { USER_BY_ID } from "./graphql/users.ts"; import { loadSavedTeams } from "./savedTeams.ts"; import { SEARCH_POKEMONS, TYPE_CHART } from "./search/search.ts"; import { POKEMON_STATS } from "./stats.ts"; +import { MOVES } from "./moves.ts"; const USER_ID = "user-1"; @@ -108,11 +109,25 @@ const statsMock = { }, }; +const movesMock = { + request: { + query: MOVES, + variables: { format: "gen9" }, + }, + result: { + data: { + moves: [], + }, + }, +}; + function renderApp() { window.localStorage.setItem(AUTH_STORAGE_KEY, USER_ID); render( - + diff --git a/frontend/src/assets/components/movesection/movesection.css b/frontend/src/assets/components/movesection/movesection.css new file mode 100644 index 0000000..ff37f8a --- /dev/null +++ b/frontend/src/assets/components/movesection/movesection.css @@ -0,0 +1,246 @@ +.moves { + padding: 0 2rem 2rem; + min-width: 0; +} + +.moves-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem 1rem; + flex-wrap: wrap; + margin-bottom: 0.75rem; +} + +.moves-header h2 { + margin: 0; +} + +.moves-hint { + color: var(--text); + font-size: 0.9rem; +} + +.move-slots { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin: 0; + padding: 0; + list-style: none; +} + +.move-slot { + display: grid; + grid-template-columns: minmax(170px, 240px) minmax(0, 1fr); + align-items: center; + gap: 0.75rem; + min-height: 50px; + padding: 0.25rem 0.6rem 0.25rem 0.35rem; + border: 1px solid var(--border); + border-left: 6px solid var(--border); + border-radius: 0.5rem; + background: var(--bg); + box-sizing: border-box; + text-align: left; +} + +.move-slot select { + width: 100%; + padding: 0.5rem 0.6rem; + border: 1px solid var(--border); + border-radius: 0.4rem; + background: var(--code-bg); + color: var(--text-h); + font: 600 0.9rem var(--sans); + cursor: pointer; +} + +.move-slot select:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.move-slot-learners { + display: flex; + align-items: center; + min-width: 0; + min-height: 46px; +} + +.move-slot-sprites { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.1rem; + margin: 0; + padding: 0; + list-style: none; +} + +.move-slot-sprites img { + display: block; + width: 44px; + height: 44px; + image-rendering: pixelated; +} + +.move-slot-sprite-fallback { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 50%; + background: rgb(255 255 255 / 60%); + color: var(--text-h); + font-size: 0.9rem; + font-weight: 700; +} + +.move-slot-empty { + color: var(--text); + font-size: 0.8rem; +} + +/* Rows take a light tint from the selected move's type. */ +.move-slot[data-type] { + border-left-color: var(--move-type-border); + background: var(--move-type-bg); +} + +.move-slot[data-type] select { + border-color: var(--move-type-border); + background: rgb(255 255 255 / 65%); + color: var(--move-type-text); +} + +.move-slot[data-type="normal"] { + --move-type-bg: #e8e6dc; + --move-type-border: #8f8a6b; + --move-type-text: #4a4833; +} + +.move-slot[data-type="fire"] { + --move-type-bg: #fde4d4; + --move-type-border: #d96a2b; + --move-type-text: #8a3c09; +} + +.move-slot[data-type="fighting"] { + --move-type-bg: #f7dcdb; + --move-type-border: #b53a33; + --move-type-text: #7a1713; +} + +.move-slot[data-type="poison"] { + --move-type-bg: #f1dcef; + --move-type-border: #8f3a8e; + --move-type-text: #5f1f5e; +} + +.move-slot[data-type="ground"] { + --move-type-bg: #f7eed6; + --move-type-border: #a88a3a; + --move-type-text: #6b5418; +} + +.move-slot[data-type="flying"] { + --move-type-bg: #ece7fd; + --move-type-border: #7158c9; + --move-type-text: #48348c; +} + +.move-slot[data-type="psychic"] { + --move-type-bg: #fedde7; + --move-type-border: #c94377; + --move-type-text: #8c1f42; +} + +.move-slot[data-type="bug"] { + --move-type-bg: #eef1d1; + --move-type-border: #7c8a1e; + --move-type-text: #4d5606; +} + +.move-slot[data-type="rock"] { + --move-type-bg: #f3efd7; + --move-type-border: #a08a33; + --move-type-text: #5f5310; +} + +.move-slot[data-type="ghost"] { + --move-type-bg: #e7e1ee; + --move-type-border: #6a4f91; + --move-type-text: #3f2b58; +} + +.move-slot[data-type="dragon"] { + --move-type-bg: #e5dcfe; + --move-type-border: #5b3fc9; + --move-type-text: #3a14a0; +} + +.move-slot[data-type="dark"] { + --move-type-bg: #e9e2dd; + --move-type-border: #5f4636; + --move-type-text: #3c2b21; +} + +.move-slot[data-type="steel"] { + --move-type-bg: #ebebf2; + --move-type-border: #77778f; + --move-type-text: #46465c; +} + +.move-slot[data-type="fairy"] { + --move-type-bg: #f7e3ec; + --move-type-border: #b85b8c; + --move-type-text: #7a3356; +} + +.move-slot[data-type="stellar"] { + --move-type-bg: #efe4fb; + --move-type-border: #7c4dbe; + --move-type-text: #45227a; +} + +.move-slot[data-type="water"] { + --move-type-bg: #dde7fd; + --move-type-border: #3f6fc4; + --move-type-text: #1f3f8f; +} + +.move-slot[data-type="electric"] { + --move-type-bg: #fdf6d0; + --move-type-border: #c9a51a; + --move-type-text: #6b5a00; +} + +.move-slot[data-type="grass"] { + --move-type-bg: #e1f3d8; + --move-type-border: #4f8a2e; + --move-type-text: #2f5c19; +} + +.move-slot[data-type="ice"] { + --move-type-bg: #ddf3f2; + --move-type-border: #3f9a95; + --move-type-text: #1d5553; +} + +@media (max-width: 600px) { + .moves { + padding: 0 1rem 1.5rem; + } + + .move-slot { + grid-template-columns: 1fr; + padding: 0.4rem; + } + + .move-slot-learners { + padding: 0 0.15rem 0.2rem; + } +} + diff --git a/frontend/src/assets/components/movesection/movesection.tsx b/frontend/src/assets/components/movesection/movesection.tsx new file mode 100644 index 0000000..ce5898f --- /dev/null +++ b/frontend/src/assets/components/movesection/movesection.tsx @@ -0,0 +1,226 @@ +import { useQuery } from "@apollo/client/react"; +import { useState } from "react"; +import { + MOVE_LEARNERS, + MOVES, + MOVE_SLOT_COUNT, + type Move, + type MoveLearners as MoveLearnersResult, +} from "../../../moves.ts"; +import type { TeamPokemon } from "../../../team.ts"; +import { getSpriteUrl } from "../pokemoncard/sprite.ts"; +import "./movesection.css"; + +type MovesSectionProps = { + format: string; + team: TeamPokemon[]; +}; + +type MovesResponse = { + moves: Move[]; +}; + +type MovesVariables = { + format: string; +}; + +type MoveLearnersResponse = { + moveLearners: MoveLearnersResult[]; +}; + +type MoveLearnersVariables = { + format: string; + pokemonIds: string[]; + moveIds: string[]; +}; + +function MovesSection({ format, team }: MovesSectionProps) { + const [selectedMoves, setSelectedMoves] = useState(() => + Array.from({ length: MOVE_SLOT_COUNT }, () => ""), + ); + + const teamIds = team.map((pokemon) => pokemon.id); + + const { + data: movesData, + loading: movesLoading, + error: movesError, + } = useQuery(MOVES, { + variables: { format }, + skip: team.length === 0, + }); + + const moves = movesData?.moves ?? []; + const movesById = new Map(moves.map((move) => [move.id, move])); + const selectedMoveIds = [...new Set(selectedMoves.filter(Boolean))]; + + const { + data: learnersData, + loading: learnersLoading, + error: learnersError, + } = useQuery(MOVE_LEARNERS, { + variables: { + format, + pokemonIds: teamIds, + moveIds: selectedMoveIds, + }, + skip: teamIds.length === 0 || selectedMoveIds.length === 0, + }); + + const learnersByMove = new Map( + (learnersData?.moveLearners ?? []).map((entry) => [ + entry.moveId, + entry.pokemonIds, + ]), + ); + + function selectMove(slotIndex: number, moveId: string) { + setSelectedMoves((currentMoves) => + currentMoves.map((selectedMove, index) => + index === slotIndex ? moveId : selectedMove, + ), + ); + } + + return ( +
+
+

Moves

+ {team.length > 0 && ( +

+ Select up to {MOVE_SLOT_COUNT} moves to see which of your Pokémon + can learn them. +

+ )} +
+ + {team.length === 0 ? ( +

+ Add Pokémon to your team to see which moves they can learn. +

+ ) : movesLoading ? ( +

Loading moves…

+ ) : movesError ? ( +

Could not load moves: {movesError.message}

+ ) : ( + <> + {learnersError && ( +

+ Could not check which Pokémon learn these moves:{" "} + {learnersError.message} +

+ )} + +
    + {selectedMoves.map((moveId, slotIndex) => { + const move = movesById.get(moveId); + const learnerIds = learnersByMove.get(moveId) ?? []; + const learners = learnerIds + .map((id) => team.find((pokemon) => pokemon.id === id)) + .filter( + (pokemon): pokemon is TeamPokemon => pokemon !== undefined, + ); + + return ( +
  1. + + +
    + {!move ? ( + + No move selected. + + ) : learnersLoading ? ( + Checking… + ) : learnersError ? ( + + Could not check learnsets. + + ) : learners.length > 0 ? ( +
      + {learners.map((pokemon) => ( +
    • + +
    • + ))} +
    + ) : ( + + No Pokémon on your team can learn this move. + + )} +
    +
  2. + ); + })} +
+ + )} +
+ ); +} + + +function LearnerSprite({ + pokemon, + moveName, +}: { + pokemon: TeamPokemon; + moveName: string; +}) { + const [imageFailed, setImageFailed] = useState(false); + const spriteUrl = getSpriteUrl(pokemon.sprite); + const description = `${pokemon.name} learns ${moveName}`; + + if (spriteUrl && !imageFailed) { + return ( + {description} setImageFailed(true)} + /> + ); + } + + return ( + + {pokemon.name.slice(0, 1)} + + ); +} + +export default MovesSection; diff --git a/frontend/src/assets/pages/home/page.test.tsx b/frontend/src/assets/pages/home/page.test.tsx index 5d589cd..75c2a4a 100644 --- a/frontend/src/assets/pages/home/page.test.tsx +++ b/frontend/src/assets/pages/home/page.test.tsx @@ -11,6 +11,12 @@ import { MAX_TEAM_SIZE, TEAM_FULL_MESSAGE } from "../../../team.ts"; import { POKEMON_STATS, type PokemonStats } from "../../../stats.ts"; import HomePage from "./page"; import { TYPE_CHART } from "../../../search/search"; +import { + MOVE_LEARNERS, + MOVES, + MOVE_SLOT_COUNT, + type Move, +} from "../../../moves.ts"; afterEach(cleanup); @@ -40,6 +46,17 @@ function createStatsMock(ids: string[], stats: PokemonStats[]) { }; } +function createMovesMock(format: string, moves: Move[] = []) { + return { + request: { query: MOVES, variables: { format } }, + result: { + data: { + moves: moves.map((move) => ({ __typename: "Move", ...move })), + }, + }, + }; +} + describe("HomePage", () => { it("shows each type and combines defensive multipliers for dual-type Pokémon", async () => { const typeEffectiveness = [ @@ -60,7 +77,7 @@ describe("HomePage", () => { }, }, }, - }, createStatsMock(["charizard"], [charizardStats])]; + }, createStatsMock(["charizard"], [charizardStats]), createMovesMock("gen9")]; const onRemovePokemon = vi.fn(); @@ -107,6 +124,149 @@ describe("HomePage", () => { expect(onRemovePokemon).toHaveBeenCalledWith("charizard"); }); + it("shows the team members that can learn each selected move", async () => { + const team = [ + { + id: "garchomp", + name: "Garchomp", + type: "Dragon, Ground", + gen: 4, + sprite: "garchomp.png", + }, + { + id: "pikachu", + name: "Pikachu", + type: "Electric", + gen: 1, + sprite: "pikachu.png", + }, + { + id: "skarmory", + name: "Skarmory", + type: "Steel, Flying", + gen: 2, + sprite: "skarmory.png", + }, + ]; + const teamIds = team.map((pokemon) => pokemon.id); + const stats = team.map((pokemon, index) => ({ + id: pokemon.id, + name: pokemon.name, + hp: 50, + atk: 60, + def: 70, + spa: 80, + spd: 90, + spe: index + 1, + total: 351, + })); + + const mocks = [ + { + request: { query: TYPE_CHART }, + result: { + data: { + typeChart: { + types: [], + effectiveness: [], + }, + }, + }, + }, + createStatsMock(teamIds, stats), + createMovesMock("gen9", [ + { id: "aeroblast", name: "Aeroblast", type: "Flying" }, + { id: "earthquake", name: "Earthquake", type: "Ground" }, + { id: "stealthrock", name: "Stealth Rock", type: "Rock" }, + ]), + { + request: { + query: MOVE_LEARNERS, + variables: { + format: "gen9", + pokemonIds: teamIds, + moveIds: ["stealthrock"], + }, + }, + result: { + data: { + moveLearners: [ + { + __typename: "MoveLearners", + moveId: "stealthrock", + pokemonIds: ["garchomp", "skarmory"], + }, + ], + }, + }, + }, + { + request: { + query: MOVE_LEARNERS, + variables: { + format: "gen9", + pokemonIds: teamIds, + moveIds: ["aeroblast"], + }, + }, + result: { + data: { + moveLearners: [ + { + __typename: "MoveLearners", + moveId: "aeroblast", + pokemonIds: [], + }, + ], + }, + }, + }, + ]; + + render( + + {}} + onOpenSearch={() => {}} + team={team} + onRemovePokemon={() => {}} + onSaveTeam={() => {}} + /> + , + ); + + const moveSelects = await screen.findAllByRole("combobox", { + name: /^Move slot \d+ move$/, + }); + + expect(moveSelects).toHaveLength(MOVE_SLOT_COUNT); + + fireEvent.change(moveSelects[0], { target: { value: "stealthrock" } }); + + expect( + await screen.findByRole("img", { name: "Garchomp learns Stealth Rock" }), + ).toBeTruthy(); + expect( + screen.getByRole("img", { name: "Skarmory learns Stealth Rock" }), + ).toBeTruthy(); + expect( + screen.queryByRole("img", { name: "Pikachu learns Stealth Rock" }), + ).toBeNull(); + + const firstSlot = screen.getByRole("listitem", { name: "Move slot 1" }); + expect(firstSlot.getAttribute("data-type")).toBe("rock"); + + fireEvent.change(moveSelects[0], { target: { value: "aeroblast" } }); + + expect( + await screen.findByText( + "No Pokémon on your team can learn this move.", + ), + ).toBeTruthy(); + expect(firstSlot.getAttribute("data-type")).toBe("flying"); + }); + it("disables Add Pokémon + when the team is full", async () => { const team = Array.from({ length: MAX_TEAM_SIZE }, (_, index) => ({ id: `pokemon-${index}`, @@ -142,6 +302,7 @@ describe("HomePage", () => { team.map((pokemon) => pokemon.id), stats, ), + createMovesMock("gen9"), ]; render( @@ -178,7 +339,7 @@ describe("HomePage", () => { }, }, }, - }, createStatsMock(["charizard"], [charizardStats])]; + }, createStatsMock(["charizard"], [charizardStats]), createMovesMock("gen9")]; const onSaveTeam = vi.fn(); render( @@ -318,6 +479,7 @@ describe("HomePage", () => { team.map((pokemon) => pokemon.id), stats, ), + createMovesMock("gen9"), ]} > )}
-
-

Moves

-
+
); diff --git a/frontend/src/moves.ts b/frontend/src/moves.ts new file mode 100644 index 0000000..ba1f593 --- /dev/null +++ b/frontend/src/moves.ts @@ -0,0 +1,41 @@ +import { gql } from "@apollo/client"; + +export const MOVE_SLOT_COUNT = 16; + +export type Move = { + id: string; + name: string; + type: string; +}; + +export type MoveLearners = { + moveId: string; + pokemonIds: string[]; +}; + +export const MOVES = gql` + query Moves($format: String!) { + moves(format: $format) { + id + name + type + } + } +`; + +export const MOVE_LEARNERS = gql` + query MoveLearners( + $format: String! + $pokemonIds: [ID!]! + $moveIds: [ID!]! + ) { + moveLearners( + format: $format + pokemonIds: $pokemonIds + moveIds: $moveIds + ) { + moveId + pokemonIds + } + } +`; From 116d2a19f45997556fe9ca3bd765e0133c55ca9d Mon Sep 17 00:00:00 2001 From: helgeovs Date: Mon, 2 Nov 2026 12:59:38 +0100 Subject: [PATCH 6/9] Changed to a more suitable approach to move serch. Ensured that move search remains persistent across page reloads. Added a test to verify that the move plan is correctly saved and loaded. --- backend/README.md | 2 +- frontend/README.md | 5 +- frontend/src/App.test.tsx | 1 + frontend/src/App.tsx | 4 + .../components/movesection/movesection.css | 88 ++++- .../components/movesection/movesection.tsx | 305 ++++++++++++------ frontend/src/assets/pages/home/page.test.tsx | 226 ++++++++++--- frontend/src/assets/pages/home/page.tsx | 9 +- frontend/src/movePlannerStorage.ts | 45 +++ frontend/src/moves.ts | 2 +- 10 files changed, 517 insertions(+), 170 deletions(-) create mode 100644 frontend/src/movePlannerStorage.ts diff --git a/backend/README.md b/backend/README.md index 6b53d7a..5789e52 100644 --- a/backend/README.md +++ b/backend/README.md @@ -149,7 +149,7 @@ query { } ``` -For each requested move, this query returns the subset of the requested Pokémon that can learn it in the selected format. Up to 100 Pokémon IDs and 100 move IDs can be requested at once, and unknown IDs are ignored. The home page uses this query to fill its 16 move slots with the team members that learn each selected move. +For each requested move, this query returns the subset of the requested Pokémon that can learn it in the selected format. Up to 100 Pokémon IDs and 100 move IDs can be requested at once, and unknown IDs are ignored. The home page runs this query once per move line (up to 16 lines) to show the team members that learn the searched move. ## Frontend Integration diff --git a/frontend/README.md b/frontend/README.md index c57cb99..56ceb51 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -49,8 +49,9 @@ Pokémon. Search results and team members are displayed as Pokémon cards with their sprite, name and types. Pokémon already on the team can be removed from either the team panel or a search result. When the team is full, the interface explains that a Pokémon must be removed before another can be added. The home -page also shows type matchups, base stats and a 16-slot move overview that -highlights which team members can learn each selected move. Additional +page also shows type matchups, base stats and a move planner that searches for +moves and highlights which team members can learn each selected move. Move lines +are kept in session storage for the rest of the browser session. Additional features are still under development. ## Related Documentation diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 54009a6..f07edc3 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -23,6 +23,7 @@ afterEach(cleanup); beforeEach(() => { window.localStorage.clear(); + window.sessionStorage.clear(); }); const userMock = { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 57cb466..25ee3da 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import ProtectedRoute from "./auth/ProtectedRoute.tsx"; import LoginPage from "./auth/LoginPage.tsx"; type ApplicationContext = { + userId: string; format: string; setFormat: (format: string) => void; team: TeamPokemon[]; @@ -285,6 +286,7 @@ function SignedInLayout({ user, logout }: SignedInLayoutProps) { (() => - Array.from({ length: MOVE_SLOT_COUNT }, () => ""), +type MoveSlotState = { + key: number; + query: string; +}; + +function createMoveSlots(moveQueries: string[]): MoveSlotState[] { + const queries = moveQueries.length > 0 ? moveQueries : [""]; + + return queries.map((query, index) => ({ key: index + 1, query })); +} + +function MovesSection({ userId, format, team }: MovesSectionProps) { + const [slots, setSlots] = useState(() => + createMoveSlots(loadMovePlan(userId, format)), ); + const nextSlotKey = useRef(slots.length + 1); + const moveOptionsId = useId(); - const teamIds = team.map((pokemon) => pokemon.id); + useEffect(() => { + saveMovePlan( + userId, + format, + slots.map((slot) => slot.query), + ); + }, [userId, format, slots]); const { data: movesData, @@ -51,33 +75,33 @@ function MovesSection({ format, team }: MovesSectionProps) { }); const moves = movesData?.moves ?? []; - const movesById = new Map(moves.map((move) => [move.id, move])); - const selectedMoveIds = [...new Set(selectedMoves.filter(Boolean))]; + const movesByName = new Map( + moves.map((move) => [move.name.toLowerCase(), move]), + ); - const { - data: learnersData, - loading: learnersLoading, - error: learnersError, - } = useQuery(MOVE_LEARNERS, { - variables: { - format, - pokemonIds: teamIds, - moveIds: selectedMoveIds, - }, - skip: teamIds.length === 0 || selectedMoveIds.length === 0, - }); + function addSlot() { + setSlots((currentSlots) => + currentSlots.length >= MAX_MOVE_SLOTS + ? currentSlots + : [ + ...currentSlots, + { key: nextSlotKey.current++, query: "" }, + ], + ); + } - const learnersByMove = new Map( - (learnersData?.moveLearners ?? []).map((entry) => [ - entry.moveId, - entry.pokemonIds, - ]), - ); + function removeSlot(key: number) { + setSlots((currentSlots) => + currentSlots.length <= 1 + ? currentSlots + : currentSlots.filter((slot) => slot.key !== key), + ); + } - function selectMove(slotIndex: number, moveId: string) { - setSelectedMoves((currentMoves) => - currentMoves.map((selectedMove, index) => - index === slotIndex ? moveId : selectedMove, + function updateSlotQuery(key: number, query: string) { + setSlots((currentSlots) => + currentSlots.map((slot) => + slot.key === key ? { ...slot, query } : slot, ), ); } @@ -88,8 +112,8 @@ function MovesSection({ format, team }: MovesSectionProps) {

Moves

{team.length > 0 && (

- Select up to {MOVE_SLOT_COUNT} moves to see which of your Pokémon - can learn them. + Search for a move to see which of your Pokémon can learn it, and + add more lines to plan several moves at once.

)} @@ -104,87 +128,156 @@ function MovesSection({ format, team }: MovesSectionProps) {

Could not load moves: {movesError.message}

) : ( <> - {learnersError && ( -

- Could not check which Pokémon learn these moves:{" "} - {learnersError.message} -

- )} + + {moves.map((move) => ( +
    - {selectedMoves.map((moveId, slotIndex) => { - const move = movesById.get(moveId); - const learnerIds = learnersByMove.get(moveId) ?? []; - const learners = learnerIds - .map((id) => team.find((pokemon) => pokemon.id === id)) - .filter( - (pokemon): pokemon is TeamPokemon => pokemon !== undefined, - ); - - return ( -
  1. - - -
    - {!move ? ( - - No move selected. - - ) : learnersLoading ? ( - Checking… - ) : learnersError ? ( - - Could not check learnsets. - - ) : learners.length > 0 ? ( -
      - {learners.map((pokemon) => ( -
    • - -
    • - ))} -
    - ) : ( - - No Pokémon on your team can learn this move. - - )} -
    -
  2. - ); - })} + {slots.map((slot, slotIndex) => ( + 1} + onChangeQuery={(query) => updateSlotQuery(slot.key, query)} + onRemove={() => removeSlot(slot.key)} + /> + ))}
+ +
+ +
)} ); } +type MoveSlotProps = { + slot: MoveSlotState; + slotIndex: number; + move: Move | undefined; + format: string; + team: TeamPokemon[]; + moveOptionsId: string; + canRemove: boolean; + onChangeQuery: (query: string) => void; + onRemove: () => void; +}; + +function MoveSlot({ + slot, + slotIndex, + move, + format, + team, + moveOptionsId, + canRemove, + onChangeQuery, + onRemove, +}: MoveSlotProps) { + const teamIds = team.map((pokemon) => pokemon.id); + const { data, loading, error } = useQuery< + MoveLearnersResponse, + MoveLearnersVariables + >(MOVE_LEARNERS, { + variables: { + format, + pokemonIds: teamIds, + moveIds: move ? [move.id] : [], + }, + skip: !move || teamIds.length === 0, + }); + + const learnerIds = data?.moveLearners[0]?.pokemonIds ?? []; + const learners = learnerIds + .map((id) => team.find((pokemon) => pokemon.id === id)) + .filter((pokemon): pokemon is TeamPokemon => pokemon !== undefined); + const hasUnmatchedQuery = slot.query.trim() !== "" && !move; + + return ( +
  • +
    + onChangeQuery(event.target.value)} + /> +
    + +
    + {!move ? ( + + {hasUnmatchedQuery + ? "Choose a move from the suggestions." + : "Type a move name to see which team members learn it."} + + ) : loading ? ( + Checking… + ) : error ? ( + Could not check learnsets. + ) : learners.length > 0 ? ( +
      + {learners.map((pokemon) => ( +
    • + +
    • + ))} +
    + ) : ( + + No Pokémon on your team can learn this move. + + )} +
    + + {canRemove && ( + + )} +
  • + ); +} function LearnerSprite({ pokemon, diff --git a/frontend/src/assets/pages/home/page.test.tsx b/frontend/src/assets/pages/home/page.test.tsx index 75c2a4a..1627043 100644 --- a/frontend/src/assets/pages/home/page.test.tsx +++ b/frontend/src/assets/pages/home/page.test.tsx @@ -6,20 +6,28 @@ import { screen, within, } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MAX_TEAM_SIZE, TEAM_FULL_MESSAGE } from "../../../team.ts"; import { POKEMON_STATS, type PokemonStats } from "../../../stats.ts"; import HomePage from "./page"; import { TYPE_CHART } from "../../../search/search"; import { + MAX_MOVE_SLOTS, MOVE_LEARNERS, MOVES, - MOVE_SLOT_COUNT, type Move, } from "../../../moves.ts"; +import { + loadMovePlan, + movePlannerStorageKey, +} from "../../../movePlannerStorage.ts"; afterEach(cleanup); +beforeEach(() => { + window.sessionStorage.clear(); +}); + const charizardStats: PokemonStats = { id: "charizard", name: "Charizard", @@ -57,6 +65,26 @@ function createMovesMock(format: string, moves: Move[] = []) { }; } +function createMoveLearnersMock( + pokemonIds: string[], + moveId: string, + learners: string[], +) { + return { + request: { + query: MOVE_LEARNERS, + variables: { format: "gen9", pokemonIds, moveIds: [moveId] }, + }, + result: { + data: { + moveLearners: [ + { __typename: "MoveLearners", moveId, pokemonIds: learners }, + ], + }, + }, + }; +} + describe("HomePage", () => { it("shows each type and combines defensive multipliers for dual-type Pokémon", async () => { const typeEffectiveness = [ @@ -84,6 +112,7 @@ describe("HomePage", () => { render( {}} onOpenSearch={() => {}} @@ -124,7 +153,7 @@ describe("HomePage", () => { expect(onRemovePokemon).toHaveBeenCalledWith("charizard"); }); - it("shows the team members that can learn each selected move", async () => { + it("searches for moves, adds lines, and keeps earlier learner results", async () => { const team = [ { id: "garchomp", @@ -179,53 +208,17 @@ describe("HomePage", () => { { id: "earthquake", name: "Earthquake", type: "Ground" }, { id: "stealthrock", name: "Stealth Rock", type: "Rock" }, ]), - { - request: { - query: MOVE_LEARNERS, - variables: { - format: "gen9", - pokemonIds: teamIds, - moveIds: ["stealthrock"], - }, - }, - result: { - data: { - moveLearners: [ - { - __typename: "MoveLearners", - moveId: "stealthrock", - pokemonIds: ["garchomp", "skarmory"], - }, - ], - }, - }, - }, - { - request: { - query: MOVE_LEARNERS, - variables: { - format: "gen9", - pokemonIds: teamIds, - moveIds: ["aeroblast"], - }, - }, - result: { - data: { - moveLearners: [ - { - __typename: "MoveLearners", - moveId: "aeroblast", - pokemonIds: [], - }, - ], - }, - }, - }, + createMoveLearnersMock(teamIds, "stealthrock", [ + "garchomp", + "skarmory", + ]), + createMoveLearnersMock(teamIds, "aeroblast", []), ]; render( {}} onOpenSearch={() => {}} @@ -236,13 +229,13 @@ describe("HomePage", () => { , ); - const moveSelects = await screen.findAllByRole("combobox", { - name: /^Move slot \d+ move$/, - }); + const moveInputs = await screen.findAllByLabelText( + /^Move slot \d+ move$/, + ); - expect(moveSelects).toHaveLength(MOVE_SLOT_COUNT); + expect(moveInputs).toHaveLength(1); - fireEvent.change(moveSelects[0], { target: { value: "stealthrock" } }); + fireEvent.change(moveInputs[0], { target: { value: "Stealth Rock" } }); expect( await screen.findByRole("img", { name: "Garchomp learns Stealth Rock" }), @@ -256,15 +249,138 @@ describe("HomePage", () => { const firstSlot = screen.getByRole("listitem", { name: "Move slot 1" }); expect(firstSlot.getAttribute("data-type")).toBe("rock"); + expect(loadMovePlan("user-1", "gen9")).toEqual(["Stealth Rock"]); + + fireEvent.click(screen.getByRole("button", { name: "+ Add move" })); + + const addedInputs = screen.getAllByLabelText(/^Move slot \d+ move$/); + expect(addedInputs).toHaveLength(2); + expect(loadMovePlan("user-1", "gen9")).toEqual(["Stealth Rock", ""]); + + // Adding a line must not reload the line that was already resolved. + expect( + screen.getByRole("img", { name: "Garchomp learns Stealth Rock" }), + ).toBeTruthy(); + expect( + screen.getByRole("img", { name: "Skarmory learns Stealth Rock" }), + ).toBeTruthy(); - fireEvent.change(moveSelects[0], { target: { value: "aeroblast" } }); + fireEvent.change(addedInputs[1], { target: { value: "Aeroblast" } }); + const secondSlot = screen.getByRole("listitem", { name: "Move slot 2" }); expect( - await screen.findByText( + await within(secondSlot).findByText( "No Pokémon on your team can learn this move.", ), ).toBeTruthy(); - expect(firstSlot.getAttribute("data-type")).toBe("flying"); + expect(secondSlot.getAttribute("data-type")).toBe("flying"); + expect(loadMovePlan("user-1", "gen9")).toEqual([ + "Stealth Rock", + "Aeroblast", + ]); + expect( + screen.getByRole("img", { name: "Garchomp learns Stealth Rock" }), + ).toBeTruthy(); + + fireEvent.change(addedInputs[1], { target: { value: "Aeroblas" } }); + expect( + within(secondSlot).getByText("Choose a move from the suggestions."), + ).toBeTruthy(); + + fireEvent.click( + screen.getByRole("button", { name: "Remove move slot 2" }), + ); + + expect( + screen.getAllByLabelText(/^Move slot \d+ move$/), + ).toHaveLength(1); + expect(screen.queryByRole("listitem", { name: "Move slot 2" })).toBeNull(); + expect( + screen.getByRole("img", { name: "Garchomp learns Stealth Rock" }), + ).toBeTruthy(); + + const addButton = screen.getByRole("button", { name: "+ Add move" }); + for (let index = 1; index < MAX_MOVE_SLOTS; index++) { + fireEvent.click(addButton); + } + + expect( + screen.getAllByLabelText(/^Move slot \d+ move$/), + ).toHaveLength(MAX_MOVE_SLOTS); + expect(addButton.hasAttribute("disabled")).toBe(true); + }); + + it("restores saved move lines from session storage", async () => { + const team = [ + { + id: "garchomp", + name: "Garchomp", + type: "Dragon, Ground", + gen: 4, + sprite: "garchomp.png", + }, + ]; + const teamIds = ["garchomp"]; + const stats = [ + { + id: "garchomp", + name: "Garchomp", + hp: 108, + atk: 130, + def: 95, + spa: 80, + spd: 85, + spe: 102, + total: 600, + }, + ]; + + window.sessionStorage.setItem( + movePlannerStorageKey("user-1", "gen9"), + JSON.stringify(["Stealth Rock"]), + ); + + render( + + {}} + onOpenSearch={() => {}} + team={team} + onRemovePokemon={() => {}} + onSaveTeam={() => {}} + /> + , + ); + + const moveInput = await screen.findByLabelText("Move slot 1 move"); + + expect((moveInput as HTMLInputElement).value).toBe("Stealth Rock"); + expect( + await screen.findByRole("img", { + name: "Garchomp learns Stealth Rock", + }), + ).toBeTruthy(); }); it("disables Add Pokémon + when the team is full", async () => { @@ -308,6 +424,7 @@ describe("HomePage", () => { render( {}} onOpenSearch={() => {}} @@ -345,6 +462,7 @@ describe("HomePage", () => { render( {}} onOpenSearch={() => {}} @@ -392,6 +510,7 @@ describe("HomePage", () => { render( {}} onOpenSearch={() => {}} @@ -483,6 +602,7 @@ describe("HomePage", () => { ]} > {}} onOpenSearch={() => {}} diff --git a/frontend/src/assets/pages/home/page.tsx b/frontend/src/assets/pages/home/page.tsx index ec77a01..1729602 100644 --- a/frontend/src/assets/pages/home/page.tsx +++ b/frontend/src/assets/pages/home/page.tsx @@ -41,6 +41,7 @@ type PokemonStatsVariables = { }; type HomePageProps = { + userId: string; format: string; onFormatChange: (format: string) => void; onOpenSearch: () => void; @@ -53,6 +54,7 @@ type HomePageProps = { }; function HomePage({ + userId, format, onFormatChange, onOpenSearch, @@ -331,7 +333,12 @@ function HomePage({ )} - + ); diff --git a/frontend/src/movePlannerStorage.ts b/frontend/src/movePlannerStorage.ts new file mode 100644 index 0000000..5327e72 --- /dev/null +++ b/frontend/src/movePlannerStorage.ts @@ -0,0 +1,45 @@ +import { MAX_MOVE_SLOTS } from "./moves.ts"; + +export const MOVE_PLANNER_STORAGE_PREFIX = "pokemon-draft-planner.moves"; + +export function movePlannerStorageKey(userId: string, format: string): string { + return `${MOVE_PLANNER_STORAGE_PREFIX}.${userId}.${format}`; +} + +/** Returns the saved move search texts for a user's planner, first line first. */ +export function loadMovePlan(userId: string, format: string): string[] { + try { + const stored = window.sessionStorage.getItem( + movePlannerStorageKey(userId, format), + ); + if (!stored) return []; + + const parsed: unknown = JSON.parse(stored); + if (!Array.isArray(parsed)) return []; + + return parsed + .filter((value): value is string => typeof value === "string") + .slice(0, MAX_MOVE_SLOTS); + } catch { + return []; + } +} + +/** + * Persists the planner for the rest of the browser session. Storage failures + * are ignored so a blocked or full sessionStorage does not break the planner. + */ +export function saveMovePlan( + userId: string, + format: string, + moveQueries: string[], +): void { + try { + window.sessionStorage.setItem( + movePlannerStorageKey(userId, format), + JSON.stringify(moveQueries.slice(0, MAX_MOVE_SLOTS)), + ); + } catch { + // Persistence is best-effort. + } +} diff --git a/frontend/src/moves.ts b/frontend/src/moves.ts index ba1f593..c2e03a6 100644 --- a/frontend/src/moves.ts +++ b/frontend/src/moves.ts @@ -1,6 +1,6 @@ import { gql } from "@apollo/client"; -export const MOVE_SLOT_COUNT = 16; +export const MAX_MOVE_SLOTS = 16; export type Move = { id: string; From 3a9478185fb27e28563bc5ab0c4773327b83b2c5 Mon Sep 17 00:00:00 2001 From: helgeovs Date: Mon, 2 Nov 2026 13:30:14 +0100 Subject: [PATCH 7/9] Made confimation dialogs trap keyboard focus while active. --- .../confirmdialog/confirmdialog.test.tsx | 108 ++++++++++++++++++ .../confirmdialog/confirmdialog.tsx | 6 +- frontend/src/hooks/useFocusTrap.ts | 108 ++++++++++++++++++ frontend/src/save/SaveTeamModal.test.tsx | 74 +++++++++++- frontend/src/save/SaveTeamModal.tsx | 11 +- 5 files changed, 303 insertions(+), 4 deletions(-) create mode 100644 frontend/src/assets/components/confirmdialog/confirmdialog.test.tsx create mode 100644 frontend/src/hooks/useFocusTrap.ts diff --git a/frontend/src/assets/components/confirmdialog/confirmdialog.test.tsx b/frontend/src/assets/components/confirmdialog/confirmdialog.test.tsx new file mode 100644 index 0000000..7332f9e --- /dev/null +++ b/frontend/src/assets/components/confirmdialog/confirmdialog.test.tsx @@ -0,0 +1,108 @@ +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import ConfirmDialog from "./confirmdialog"; + +afterEach(cleanup); + +function renderDialog() { + render( + <> + + {}} + onCancel={() => {}} + /> + , + ); + + const dialog = screen.getByRole("dialog", { name: "Delete saved team?" }); + + return { + behindButton: screen.getByRole("button", { name: "Behind dialog" }), + cancelButton: within(dialog).getByRole("button", { name: "Cancel" }), + confirmButton: within(dialog).getByRole("button", { name: "Yes, delete" }), + }; +} + +function ConfirmDialogHarness() { + const [open, setOpen] = useState(false); + + return ( + <> + + {open && ( + setOpen(false)} + onCancel={() => setOpen(false)} + /> + )} + + ); +} + +describe("ConfirmDialog", () => { + it("moves focus to the cancel button when it opens", () => { + const { cancelButton } = renderDialog(); + + expect(document.activeElement).toBe(cancelButton); + }); + + it("wraps focus from the last button back to the first with Tab", () => { + const { cancelButton, confirmButton } = renderDialog(); + + confirmButton.focus(); + fireEvent.keyDown(confirmButton, { key: "Tab" }); + + expect(document.activeElement).toBe(cancelButton); + }); + + it("wraps focus from the first button back to the last with Shift+Tab", () => { + const { cancelButton, confirmButton } = renderDialog(); + + cancelButton.focus(); + fireEvent.keyDown(cancelButton, { key: "Tab", shiftKey: true }); + + expect(document.activeElement).toBe(confirmButton); + }); + + it("pulls focus back inside the dialog when focus is behind it", () => { + const { behindButton, cancelButton, confirmButton } = renderDialog(); + + behindButton.focus(); + fireEvent.keyDown(behindButton, { key: "Tab" }); + expect(document.activeElement).toBe(cancelButton); + + behindButton.focus(); + fireEvent.keyDown(behindButton, { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(confirmButton); + }); + + it("returns focus to the element that opened it when it closes", () => { + render(); + + const opener = screen.getByRole("button", { name: "Delete team" }); + opener.focus(); + fireEvent.click(opener); + + const dialog = screen.getByRole("dialog", { name: "Delete saved team?" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.activeElement).toBe(opener); + }); +}); diff --git a/frontend/src/assets/components/confirmdialog/confirmdialog.tsx b/frontend/src/assets/components/confirmdialog/confirmdialog.tsx index 18e3873..cb74860 100644 --- a/frontend/src/assets/components/confirmdialog/confirmdialog.tsx +++ b/frontend/src/assets/components/confirmdialog/confirmdialog.tsx @@ -1,4 +1,5 @@ import { useEffect } from "react"; +import { useFocusTrap } from "../../../hooks/useFocusTrap.ts"; import "./confirmdialog.css"; type ConfirmDialogProps = { @@ -18,6 +19,8 @@ function ConfirmDialog({ onConfirm, onCancel, }: ConfirmDialogProps) { + const dialogRef = useFocusTrap(); + useEffect(() => { function handleKeyDown(event: KeyboardEvent) { if (event.key === "Escape") onCancel(); @@ -35,11 +38,13 @@ function ConfirmDialog({ }} >

    {title}

    @@ -50,7 +55,6 @@ function ConfirmDialog({ type="button" className="confirm-dialog-cancel" onClick={onCancel} - autoFocus > {cancelLabel} diff --git a/frontend/src/hooks/useFocusTrap.ts b/frontend/src/hooks/useFocusTrap.ts new file mode 100644 index 0000000..86fc8dd --- /dev/null +++ b/frontend/src/hooks/useFocusTrap.ts @@ -0,0 +1,108 @@ +import { useEffect, useRef, type RefObject } from "react"; + +type FocusTrapOptions = { + initialFocusRef?: RefObject; +}; + +const FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled]):not([type='hidden'])", + "select:not([disabled])", + "textarea:not([disabled])", + "[tabindex]:not([tabindex='-1'])", +].join(","); + +function getFocusableElements(container: HTMLElement): HTMLElement[] { + return Array.from( + container.querySelectorAll(FOCUSABLE_SELECTOR), + ).filter( + (element) => + !element.hasAttribute("hidden") && + element.getAttribute("aria-hidden") !== "true", + ); +} + +/** + * Keeps keyboard focus inside a dialog and restores focus to the element that + * opened it when the dialog unmounts. + * + * Attach the returned ref to the dialog container and give the container + * `tabIndex={-1}` so it can receive focus when it has no focusable children. + */ +export function useFocusTrap({ + initialFocusRef, +}: FocusTrapOptions = {}): RefObject { + const containerRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + + if (!container) return; + + const dialogElement: HTMLElement = container; + + const previouslyFocusedElement = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + + const [firstFocusableElement] = getFocusableElements(dialogElement); + (initialFocusRef?.current ?? firstFocusableElement ?? dialogElement).focus(); + + function handleKeyDown(event: KeyboardEvent) { + if ( + event.key !== "Tab" || + event.altKey || + event.ctrlKey || + event.metaKey + ) { + return; + } + + const focusableElements = getFocusableElements(dialogElement); + + if (focusableElements.length === 0) { + event.preventDefault(); + dialogElement.focus(); + return; + } + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + const activeElementIndex = focusableElements.indexOf( + document.activeElement as HTMLElement, + ); + + if (event.shiftKey) { + // Wrap backwards from the first element, the container, or outside. + if (activeElementIndex <= 0) { + event.preventDefault(); + lastElement.focus(); + } + return; + } + + // Wrap forwards from the last element, the container, or outside. + if ( + activeElementIndex === -1 || + activeElementIndex === focusableElements.length - 1 + ) { + event.preventDefault(); + firstElement.focus(); + } + } + + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + + if (previouslyFocusedElement?.isConnected) { + previouslyFocusedElement.focus(); + } + }; + }, [initialFocusRef]); + + return containerRef; +} diff --git a/frontend/src/save/SaveTeamModal.test.tsx b/frontend/src/save/SaveTeamModal.test.tsx index 6ac344b..b58942d 100644 --- a/frontend/src/save/SaveTeamModal.test.tsx +++ b/frontend/src/save/SaveTeamModal.test.tsx @@ -1,9 +1,31 @@ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import SaveTeamModal from "./SaveTeamModal"; afterEach(cleanup); +function SaveTeamModalHarness() { + const [open, setOpen] = useState(false); + + return ( + <> + + {open && ( + {}} onClose={() => setOpen(false)} /> + )} + + ); +} + describe("SaveTeamModal", () => { it("submits the trimmed team name", () => { const onSave = vi.fn(); @@ -40,4 +62,54 @@ describe("SaveTeamModal", () => { expect(onClose).toHaveBeenCalledTimes(1); }); + + it("traps Tab and Shift+Tab focus inside the dialog", () => { + render( + <> + + {}} onClose={() => {}} /> + , + ); + + const dialog = screen.getByRole("dialog", { name: "Name your team" }); + const behindButton = screen.getByRole("button", { name: "Behind dialog" }); + const closeButton = within(dialog).getByRole("button", { + name: "Close save team dialog", + }); + const nameInput = within(dialog).getByRole("textbox", { name: "Team name" }); + const saveButton = within(dialog).getByRole("button", { name: "Save" }); + + expect(document.activeElement).toBe(nameInput); + + saveButton.focus(); + fireEvent.keyDown(saveButton, { key: "Tab" }); + expect(document.activeElement).toBe(closeButton); + + closeButton.focus(); + fireEvent.keyDown(closeButton, { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(saveButton); + + behindButton.focus(); + fireEvent.keyDown(behindButton, { key: "Tab" }); + expect(document.activeElement).toBe(closeButton); + + behindButton.focus(); + fireEvent.keyDown(behindButton, { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(saveButton); + }); + + it("returns focus to the element that opened it when it closes", () => { + render(); + + const opener = screen.getByRole("button", { name: "Save team" }); + opener.focus(); + fireEvent.click(opener); + + expect(screen.getByRole("dialog", { name: "Name your team" })).toBeTruthy(); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.activeElement).toBe(opener); + }); }); diff --git a/frontend/src/save/SaveTeamModal.tsx b/frontend/src/save/SaveTeamModal.tsx index 4686600..8850f42 100644 --- a/frontend/src/save/SaveTeamModal.tsx +++ b/frontend/src/save/SaveTeamModal.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState, type FormEvent } from "react"; +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { useFocusTrap } from "../hooks/useFocusTrap.ts"; import "./save-team-modal.css"; type SaveTeamModalProps = { @@ -10,6 +11,10 @@ type SaveTeamModalProps = { function SaveTeamModal({ error, onSave, onClose }: SaveTeamModalProps) { const [name, setName] = useState(""); const [validationError, setValidationError] = useState(null); + const nameInputRef = useRef(null); + const dialogRef = useFocusTrap({ + initialFocusRef: nameInputRef, + }); useEffect(() => { function handleKeyDown(event: KeyboardEvent) { @@ -41,10 +46,12 @@ function SaveTeamModal({ error, onSave, onClose }: SaveTeamModalProps) { }} >

    Name your team

    @@ -66,6 +73,7 @@ function SaveTeamModal({ error, onSave, onClose }: SaveTeamModalProps) { From 05b3cbae414ce89a72a505519a6c2fee86610719 Mon Sep 17 00:00:00 2001 From: helgeovs Date: Mon, 2 Nov 2026 13:50:50 +0100 Subject: [PATCH 8/9] Slight color correction. --- frontend/src/assets/components/movesection/movesection.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/assets/components/movesection/movesection.css b/frontend/src/assets/components/movesection/movesection.css index 14c27b6..7020d3d 100644 --- a/frontend/src/assets/components/movesection/movesection.css +++ b/frontend/src/assets/components/movesection/movesection.css @@ -174,9 +174,10 @@ color: var(--move-type-text); } +/* Keep placeholders fully opaque so the type tint never lowers text contrast. */ .move-slot[data-type] input::placeholder { color: var(--move-type-text); - opacity: 0.7; + opacity: 1; } .move-slot[data-type="normal"] { From 5d88f63f62a02f215b508c0b3c0c8bf2a445ef7b Mon Sep 17 00:00:00 2001 From: helgeovs Date: Tue, 3 Nov 2026 10:18:31 +0100 Subject: [PATCH 9/9] Added aria-describedby attribute to the modal for better accessibility. --- frontend/src/save/SaveTeamModal.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/save/SaveTeamModal.tsx b/frontend/src/save/SaveTeamModal.tsx index 8850f42..04416da 100644 --- a/frontend/src/save/SaveTeamModal.tsx +++ b/frontend/src/save/SaveTeamModal.tsx @@ -51,6 +51,7 @@ function SaveTeamModal({ error, onSave, onClose }: SaveTeamModalProps) { role="dialog" aria-modal="true" aria-labelledby="save-team-modal-title" + aria-describedby="save-team-modal-description" tabIndex={-1} >
    @@ -65,7 +66,7 @@ function SaveTeamModal({ error, onSave, onClose }: SaveTeamModalProps) {
    -

    +

    Give this team a name so you can recognize it later.