From 2278921dd3aff3d5a0a6243481faef7dcdc5fa8e Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 16 Oct 2026 19:24:02 +0200 Subject: [PATCH 1/3] feat: add your gymnasts overview page --- docs/AI_USAGE.md | 12 ++ src/App.tsx | 2 + src/components/gymnasts/GymnastList.test.tsx | 28 ++++ src/components/gymnasts/GymnastList.tsx | 24 +++ src/components/gymnasts/GymnastRow.tsx | 18 +++ src/components/gymnasts/YourGymnastsPage.tsx | 150 +++++++++++++++++++ src/lib/gymnasts.test.ts | 29 ++++ src/lib/gymnasts.ts | 33 ++++ src/styles/global.css | 16 ++ 9 files changed, 312 insertions(+) create mode 100644 src/components/gymnasts/GymnastList.test.tsx create mode 100644 src/components/gymnasts/GymnastList.tsx create mode 100644 src/components/gymnasts/GymnastRow.tsx create mode 100644 src/components/gymnasts/YourGymnastsPage.tsx create mode 100644 src/lib/gymnasts.test.ts create mode 100644 src/lib/gymnasts.ts diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 57911b4..2c7ced2 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -187,6 +187,18 @@ - **Review:** The team checked the page in the browser, corrected theme-token use and image sizing, and reviewed keyboard navigation and accessible names. +## Your gymnasts overview (`src/components/gymnasts/`, `src/lib/gymnasts.ts`) + +- **Tool:** GitHub Copilot +- **What the team did:** Provided the page mockup and required existing theme + colors, accessible rows, and an add-gymnast flow. +- **What GitHub Copilot did:** Added the `/gymnasts` route, gymnast rows and + initials, name normalization, and an accessible inline add form with local + state. It used the project's existing CSS variables rather than the mockup's + placeholder colors. +- **Review:** The team exercised the form in the browser at desktop and mobile + widths. The list remains local until the GraphQL API is available. + ## Catalog search (`backend/src/catalog/search.ts`, `eslint.config.js`, `docs/API.md`) - **Tool:** Claude Code diff --git a/src/App.tsx b/src/App.tsx index d7852cc..5ba2e91 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,13 @@ import { Navigate, Route, Routes } from 'react-router-dom' import ElementDetailPage from './components/elements/ElementDetailPage' +import YourGymnastsPage from './components/gymnasts/YourGymnastsPage' function App() { return ( } /> } /> + } /> } /> ) diff --git a/src/components/gymnasts/GymnastList.test.tsx b/src/components/gymnasts/GymnastList.test.tsx new file mode 100644 index 0000000..c1336dc --- /dev/null +++ b/src/components/gymnasts/GymnastList.test.tsx @@ -0,0 +1,28 @@ +import { renderToString } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import GymnastList from "./GymnastList"; + +describe("GymnastList", () => { + it("renders names with initials", () => { + const markup = renderToString( + , + ); + + expect(markup).toContain("Ingrid Haugen"); + expect(markup).toContain("IH"); + expect(markup).toContain("Emma Berg"); + expect(markup).toContain("EB"); + }); + + it("announces the empty list state", () => { + const markup = renderToString(); + + expect(markup).toContain('role="status"'); + expect(markup).toContain("You haven’t added any gymnasts yet."); + }); +}); \ No newline at end of file diff --git a/src/components/gymnasts/GymnastList.tsx b/src/components/gymnasts/GymnastList.tsx new file mode 100644 index 0000000..fceeba4 --- /dev/null +++ b/src/components/gymnasts/GymnastList.tsx @@ -0,0 +1,24 @@ +import GymnastRow from "@/components/gymnasts/GymnastRow"; +import type { GymnastSummary } from "@/lib/gymnasts"; + +export default function GymnastList({ + gymnasts, +}: { + gymnasts: GymnastSummary[]; +}) { + return ( +
+ {gymnasts.length === 0 ? ( +

+ You haven’t added any gymnasts yet. +

+ ) : ( +
    + {gymnasts.map((gymnast) => ( + + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/gymnasts/GymnastRow.tsx b/src/components/gymnasts/GymnastRow.tsx new file mode 100644 index 0000000..2a955f8 --- /dev/null +++ b/src/components/gymnasts/GymnastRow.tsx @@ -0,0 +1,18 @@ +import type { GymnastSummary } from "@/lib/gymnasts"; +import { getGymnastInitials } from "@/lib/gymnasts"; + +export default function GymnastRow({ gymnast }: { gymnast: GymnastSummary }) { + return ( +
  • +
    + +

    {gymnast.name}

    +
    +
  • + ); +} \ No newline at end of file diff --git a/src/components/gymnasts/YourGymnastsPage.tsx b/src/components/gymnasts/YourGymnastsPage.tsx new file mode 100644 index 0000000..5f5a1cc --- /dev/null +++ b/src/components/gymnasts/YourGymnastsPage.tsx @@ -0,0 +1,150 @@ +import { useEffect, useRef, useState } from "react"; +import { Plus, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import GymnastList from "@/components/gymnasts/GymnastList"; +import { + exampleGymnasts, + getGymnastNameError, + normalizeGymnastName, + type GymnastSummary, +} from "@/lib/gymnasts"; + +export default function YourGymnastsPage() { + const [gymnasts, setGymnasts] = useState(exampleGymnasts); + const [isFormOpen, setIsFormOpen] = useState(false); + const [name, setName] = useState(""); + const [error, setError] = useState(""); + const [announcement, setAnnouncement] = useState(""); + const nameInput = useRef(null); + const addButton = useRef(null); + const wasFormOpen = useRef(false); + + useEffect(() => { + if (isFormOpen) { + nameInput.current?.focus(); + wasFormOpen.current = true; + } else if (wasFormOpen.current) { + addButton.current?.focus(); + wasFormOpen.current = false; + } + }, [isFormOpen]); + + function closeForm() { + setIsFormOpen(false); + setName(""); + setError(""); + } + + function addGymnast(event: React.FormEvent) { + event.preventDefault(); + const normalizedName = normalizeGymnastName(name); + const nameError = getGymnastNameError(name); + + if (nameError) { + setError(nameError); + nameInput.current?.focus(); + return; + } + + const gymnast: GymnastSummary = { + id: crypto.randomUUID(), + name: normalizedName, + }; + + setGymnasts((currentGymnasts) => [...currentGymnasts, gymnast]); + setAnnouncement(`${normalizedName} added to your gymnasts.`); + closeForm(); + } + + return ( +
    +
    +
    +

    Your gymnasts

    + {!isFormOpen && ( + + )} +
    + + {isFormOpen && ( +
    +
    +

    Add gymnast

    + +
    +
    + + { + setName(event.target.value); + setError(""); + }} + aria-invalid={Boolean(error)} + aria-describedby={error ? "gymnast-name-error" : undefined} + className="h-11 w-full rounded-md border border-(--box-stroke) bg-(--background-color-element) px-3 text-(--text-color-dark) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--text-color-dark)" + /> + {error && ( + + )} +
    +
    + + +
    +
    + )} + + +

    + {announcement} +

    +
    +
    + ); +} \ No newline at end of file diff --git a/src/lib/gymnasts.test.ts b/src/lib/gymnasts.test.ts new file mode 100644 index 0000000..4104be1 --- /dev/null +++ b/src/lib/gymnasts.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + getGymnastInitials, + getGymnastNameError, + normalizeGymnastName, +} from "./gymnasts"; + +describe("gymnast helpers", () => { + it("creates initials from the first and last name", () => { + expect(getGymnastInitials("Ingrid Haugen")).toBe("IH"); + }); + + it("handles a single name and empty input", () => { + expect(getGymnastInitials("Madonna")).toBe("MA"); + expect(getGymnastInitials(" ")).toBe("?"); + }); + + it("trims and collapses whitespace in names", () => { + expect(normalizeGymnastName(" Emma Berg ")).toBe("Emma Berg"); + }); + + it("rejects blank and overlong names", () => { + expect(getGymnastNameError(" ")).toBe("Enter a gymnast name."); + expect(getGymnastNameError("A".repeat(71))).toBe( + "Names must be 70 characters or fewer.", + ); + expect(getGymnastNameError("Sofia Hansen")).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/src/lib/gymnasts.ts b/src/lib/gymnasts.ts new file mode 100644 index 0000000..ef27b6e --- /dev/null +++ b/src/lib/gymnasts.ts @@ -0,0 +1,33 @@ +export type GymnastSummary = { + id: string; + name: string; +}; + +export const exampleGymnasts: GymnastSummary[] = [ + { id: "gymnast-1", name: "Ingrid Haugen" }, + { id: "gymnast-2", name: "Emma Berg" }, +]; + +export function getGymnastInitials(name: string): string { + const words = name.trim().split(/\s+/).filter(Boolean); + + if (words.length === 0) return "?"; + if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); + + return `${words[0][0]}${words.at(-1)?.[0] ?? ""}`.toUpperCase(); +} + +export function normalizeGymnastName(name: string): string { + return name.trim().replace(/\s+/g, " "); +} + +export function getGymnastNameError(name: string): string | undefined { + const normalizedName = normalizeGymnastName(name); + + if (!normalizedName) return "Enter a gymnast name."; + if (normalizedName.length > 70) { + return "Names must be 70 characters or fewer."; + } + + return undefined; +} \ No newline at end of file diff --git a/src/styles/global.css b/src/styles/global.css index 46b398a..f372833 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,3 +1,19 @@ #root .element-detail-page :is(h1, h2) { color: var(--text-color-dark); } + +#root .your-gymnasts-page :is(h1, h2) { + color: var(--text-color-dark); +} + +#root .your-gymnasts-page h1 { + font-size: 42px; + line-height: 1.1; + margin: 0; +} + +#root .your-gymnasts-page h2 { + font-size: 19px; + line-height: 1.2; + margin: 0; +} From 5bc2ad3a4bccc03097860c237045842c46f6e166 Mon Sep 17 00:00:00 2001 From: Ronja Date: Wed, 21 Oct 2026 12:39:10 +0200 Subject: [PATCH 2/3] fix: add a heading to the gymnast list and move the live region first --- src/components/gymnasts/GymnastList.test.tsx | 6 ++++++ src/components/gymnasts/GymnastList.tsx | 8 +++++++- src/components/gymnasts/YourGymnastsPage.tsx | 6 +++--- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/components/gymnasts/GymnastList.test.tsx b/src/components/gymnasts/GymnastList.test.tsx index c1336dc..0dfd7bd 100644 --- a/src/components/gymnasts/GymnastList.test.tsx +++ b/src/components/gymnasts/GymnastList.test.tsx @@ -19,6 +19,12 @@ describe("GymnastList", () => { expect(markup).toContain("EB"); }); + it("has a heading for the list", () => { + const markup = renderToString(); + + expect(markup).toMatch(/]*>Gymnast list/); + }); + it("announces the empty list state", () => { const markup = renderToString(); diff --git a/src/components/gymnasts/GymnastList.tsx b/src/components/gymnasts/GymnastList.tsx index fceeba4..39f56d0 100644 --- a/src/components/gymnasts/GymnastList.tsx +++ b/src/components/gymnasts/GymnastList.tsx @@ -7,7 +7,13 @@ export default function GymnastList({ gymnasts: GymnastSummary[]; }) { return ( -
    +
    +

    + Gymnast list +

    {gymnasts.length === 0 ? (

    You haven’t added any gymnasts yet. diff --git a/src/components/gymnasts/YourGymnastsPage.tsx b/src/components/gymnasts/YourGymnastsPage.tsx index 5f5a1cc..f2700a7 100644 --- a/src/components/gymnasts/YourGymnastsPage.tsx +++ b/src/components/gymnasts/YourGymnastsPage.tsx @@ -59,6 +59,9 @@ export default function YourGymnastsPage() { return (

    +

    + {announcement} +

    Your gymnasts

    {!isFormOpen && ( @@ -141,9 +144,6 @@ export default function YourGymnastsPage() { )} -

    - {announcement} -

    ); From 946217a71a9ccea4c8d19404560995cd45874a47 Mon Sep 17 00:00:00 2001 From: Ronja Date: Wed, 21 Oct 2026 12:39:52 +0200 Subject: [PATCH 3/3] fix: close the heading regex in the gymnast list test --- src/components/gymnasts/GymnastList.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/gymnasts/GymnastList.test.tsx b/src/components/gymnasts/GymnastList.test.tsx index 0dfd7bd..e3b933b 100644 --- a/src/components/gymnasts/GymnastList.test.tsx +++ b/src/components/gymnasts/GymnastList.test.tsx @@ -22,7 +22,7 @@ describe("GymnastList", () => { it("has a heading for the list", () => { const markup = renderToString(); - expect(markup).toMatch(/]*>Gymnast list/); + expect(markup).toMatch(/]*>Gymnast list<\/h2>/); }); it("announces the empty list state", () => {