Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/AI_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

## Missing fields in the GraphQL schema (`backend/src/schema.graphql`, `docs/API.md`)

- **Tool:** Claude Code, and Claude for the research.
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import './index.css'
import './styles/variables.css'
import './styles/global.css'
import ElementDetailPage from './components/elements/ElementDetailPage'
import YourGymnastsPage from './components/gymnasts/YourGymnastsPage'

function App() {
return (
Expand All @@ -12,6 +13,7 @@ function App() {
element={<Navigate to="/elements/UB-1.101-a" replace />}
/>
<Route path="/elements/:elementId" element={<ElementDetailPage />} />
<Route path="/gymnasts" element={<YourGymnastsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
Expand Down
34 changes: 34 additions & 0 deletions src/components/gymnasts/GymnastList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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(
<GymnastList
gymnasts={[
{ id: "one", name: "Ingrid Haugen" },
{ id: "two", name: "Emma Berg" },
]}
/>,
);

expect(markup).toContain("Ingrid Haugen");
expect(markup).toContain("IH");
expect(markup).toContain("Emma Berg");
expect(markup).toContain("EB");
});

it("has a heading for the list", () => {
const markup = renderToString(<GymnastList gymnasts={[]} />);

expect(markup).toMatch(/<h2[^>]*>Gymnast list<\/h2>/);
});

it("announces the empty list state", () => {
const markup = renderToString(<GymnastList gymnasts={[]} />);

expect(markup).toContain('role="status"');
expect(markup).toContain("You haven’t added any gymnasts yet.");
});
});
30 changes: 30 additions & 0 deletions src/components/gymnasts/GymnastList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import GymnastRow from "@/components/gymnasts/GymnastRow";
import type { GymnastSummary } from "@/lib/gymnasts";

export default function GymnastList({
gymnasts,
}: {
gymnasts: GymnastSummary[];
}) {
return (
<section
aria-labelledby="gymnast-list-heading"
className="mt-20 sm:mt-24"
>
<h2 id="gymnast-list-heading" className="sr-only">
Gymnast list
</h2>
{gymnasts.length === 0 ? (
<p role="status" className="text-(--text-color-dark)">
You haven’t added any gymnasts yet.
</p>
) : (
<ul className="grid list-none gap-8 p-0 sm:gap-12">
{gymnasts.map((gymnast) => (
<GymnastRow key={gymnast.id} gymnast={gymnast} />
))}
</ul>
)}
</section>
);
}
18 changes: 18 additions & 0 deletions src/components/gymnasts/GymnastRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { GymnastSummary } from "@/lib/gymnasts";
import { getGymnastInitials } from "@/lib/gymnasts";

export default function GymnastRow({ gymnast }: { gymnast: GymnastSummary }) {
return (
<li className="rounded-lg bg-(--primary-color) px-5 py-4 sm:px-6">
<div className="flex min-h-12 items-center gap-4">
<span
aria-hidden="true"
className="inline-flex size-10 shrink-0 items-center justify-center rounded-full bg-(--accent-color) text-sm font-bold text-(--text-color-dark)"
>
{getGymnastInitials(gymnast.name)}
</span>
<h2>{gymnast.name}</h2>
</div>
</li>
);
}
150 changes: 150 additions & 0 deletions src/components/gymnasts/YourGymnastsPage.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(null);
const addButton = useRef<HTMLButtonElement>(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<HTMLFormElement>) {
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 (
<main className="your-gymnasts-page min-h-svh bg-(--background-color-element) px-5 py-7 sm:px-8 sm:py-8">
<div className="mx-auto max-w-5xl">
<p className="sr-only" role="status" aria-live="polite">
{announcement}
</p>
<header className="flex items-start justify-between gap-4">
<h1>Your gymnasts</h1>
{!isFormOpen && (
<Button
ref={addButton}
type="button"
className="h-9 rounded-full border border-(--box-stroke) bg-(--text-color-dark) px-4 text-(--text-color-light) hover:bg-(--text-color-dark)"
onClick={() => {
setError("");
setAnnouncement("");
setIsFormOpen(true);
}}
>
<Plus aria-hidden="true" />
Add gymnast
</Button>
)}
</header>

{isFormOpen && (
<form
aria-labelledby="add-gymnast-heading"
className="mt-8 space-y-4 rounded-lg border border-(--box-stroke) bg-(--primary-color) p-5 sm:p-6"
onSubmit={addGymnast}
noValidate
>
<div className="flex items-start justify-between gap-4">
<h2 id="add-gymnast-heading">Add gymnast</h2>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Close add gymnast form"
onClick={closeForm}
>
<X aria-hidden="true" />
</Button>
</div>
<div className="space-y-2">
<label
htmlFor="gymnast-name"
className="block text-sm font-medium text-(--text-color-dark)"
>
Gymnast name
</label>
<input
ref={nameInput}
id="gymnast-name"
name="name"
autoComplete="name"
required
maxLength={70}
value={name}
onChange={(event) => {
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 && (
<p id="gymnast-name-error" role="alert" className="text-sm text-(--text-color-dark)">
{error}
</p>
)}
</div>
<div className="flex flex-wrap gap-3">
<Button
type="submit"
className="border border-(--box-stroke) bg-(--text-color-dark) text-(--text-color-light) hover:bg-(--text-color-dark)"
>
Save gymnast
</Button>
<Button type="button" variant="outline" onClick={closeForm}>
Cancel
</Button>
</div>
</form>
)}

<GymnastList gymnasts={gymnasts} />
</div>
</main>
);
}
29 changes: 29 additions & 0 deletions src/lib/gymnasts.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
33 changes: 33 additions & 0 deletions src/lib/gymnasts.ts
Original file line number Diff line number Diff line change
@@ -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;
}
16 changes: 16 additions & 0 deletions src/styles/global.css
Original file line number Diff line number Diff line change
@@ -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;
}