From fb142ce8b48c1612099f53350d3b2839b829b036 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 16 Oct 2026 17:36:55 +0200 Subject: [PATCH 01/14] feat: add catalog lookup for element details --- src/lib/catalog.test.ts | 20 +++++++++++ src/lib/catalog.ts | 79 +++++++++++++++++++++++++++++++++++++++++ src/lib/difficulty.ts | 11 ++++++ tsconfig.app.json | 1 + 4 files changed, 111 insertions(+) create mode 100644 src/lib/catalog.test.ts create mode 100644 src/lib/catalog.ts diff --git a/src/lib/catalog.test.ts b/src/lib/catalog.test.ts new file mode 100644 index 0000000..791edda --- /dev/null +++ b/src/lib/catalog.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { getElementDetail } from "./catalog"; + +describe("getElementDetail", () => { + it("returns catalog metadata, other variants, and the diagram URL", async () => { + const detail = await getElementDetail("UB-1.101-a"); + + expect(detail).toMatchObject({ + id: "UB-1.101-a", + apparatusName: "Uneven bars", + elementGroupName: "Mounts", + variants: [{ id: "UB-1.101-b" }], + }); + expect(detail?.imageUrl).toMatch(/UB-1\.101-a\.webp/); + }); + + it("returns null when the catalog id does not exist", async () => { + await expect(getElementDetail("BB-0.000-a")).resolves.toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/lib/catalog.ts b/src/lib/catalog.ts new file mode 100644 index 0000000..a0b71c1 --- /dev/null +++ b/src/lib/catalog.ts @@ -0,0 +1,79 @@ +import apparatusRecords from "../../data/apparatus.json"; +import elementGroupRecords from "../../data/elementGroups.json"; +import elementRecords from "../../data/elements.json"; +import variantGroupRecords from "../../data/variantGroups.json"; +import vaultRecords from "../../data/vaults.json"; + +type ElementRecord = (typeof elementRecords)[number]; +type VaultRecord = (typeof vaultRecords)[number]; +type CatalogRecord = ElementRecord | VaultRecord; + +export type CatalogVariant = { + id: string; + code: string; + name: string | null; + description: string; + value: number; + isVault: boolean; +}; + +export type ElementDetail = CatalogVariant & { + apparatusId: string; + apparatusName: string; + elementGroupName: string; + imageUrl: string | null; + variants: CatalogVariant[]; +}; + +const catalogById = new Map( + [...elementRecords, ...vaultRecords].map((record) => [record.id, record]), +); + +const apparatusById = new Map( + apparatusRecords.map((apparatus) => [apparatus.id, apparatus]), +); + +const elementGroupById = new Map( + elementGroupRecords.map((group) => [group.id, group]), +); + +const variantGroupById = new Map( + variantGroupRecords.map((group) => [group.id, group]), +); + +function toCatalogVariant(record: CatalogRecord): CatalogVariant { + const isVault = "dValue" in record; + + return { + id: record.id, + code: record.code, + name: record.name, + description: record.description, + value: isVault ? record.dValue : record.value, + isVault, + }; +} + +export async function getElementDetail(id: string): Promise { + const record = catalogById.get(id); + + if (!record) return null; + + const apparatusId = id.slice(0, 2); + const variantGroupId = `${apparatusId}-${record.code}`; + const relatedVariantIds = variantGroupById.get(variantGroupId)?.variantIds ?? []; + const variants = relatedVariantIds + .filter((variantId) => variantId !== id) + .map((variantId) => catalogById.get(variantId)) + .filter((variant): variant is CatalogRecord => variant !== undefined) + .map(toCatalogVariant); + return { + ...toCatalogVariant(record), + apparatusId, + apparatusName: apparatusById.get(apparatusId)?.name ?? apparatusId, + elementGroupName: + elementGroupById.get(record.elementGroupId)?.name ?? "Element group", + imageUrl: `${import.meta.env.BASE_URL}data/images/${apparatusId}/${record.image}`, + variants, + }; +} \ No newline at end of file diff --git a/src/lib/difficulty.ts b/src/lib/difficulty.ts index 4ae74bb..f7ae6d6 100644 --- a/src/lib/difficulty.ts +++ b/src/lib/difficulty.ts @@ -13,6 +13,11 @@ export const DIFFICULTY_VALUE_BY_LETTER = { export type DifficultyLetter = keyof typeof DIFFICULTY_VALUE_BY_LETTER; +const difficultyLetters = Object.entries(DIFFICULTY_VALUE_BY_LETTER) as [ + DifficultyLetter, + number, +][]; + export function getDifficultyValue(letter: string): number | undefined { const normalizedLetter = letter.toUpperCase(); @@ -21,4 +26,10 @@ export function getDifficultyValue(letter: string): number | undefined { } return DIFFICULTY_VALUE_BY_LETTER[normalizedLetter as DifficultyLetter]; +} + +export function getDifficultyLetter(value: number): DifficultyLetter | undefined { + const normalizedValue = Number(value.toFixed(1)); + + return difficultyLetters.find(([, difficultyValue]) => difficultyValue === normalizedValue)?.[0]; } \ No newline at end of file diff --git a/tsconfig.app.json b/tsconfig.app.json index 4ca43f0..099956c 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -7,6 +7,7 @@ "types": ["vite/client"], "allowArbitraryExtensions": true, "skipLibCheck": true, + "resolveJsonModule": true, /* Bundler mode */ "moduleResolution": "bundler", From 3c1c37038fe8a88e45aad751051dd34e205e132c Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 16 Oct 2026 17:37:25 +0200 Subject: [PATCH 02/14] fix: reuse difficulty badge in element variants --- src/components/ui/DifficultyBadge.tsx | 4 +- src/components/ui/ElementValueBadge.tsx | 19 +++++ src/components/ui/ElementVariants.test.tsx | 44 +++++++++++ src/components/ui/ElementVariants.tsx | 87 ++++++++++++++++------ 4 files changed, 128 insertions(+), 26 deletions(-) create mode 100644 src/components/ui/ElementValueBadge.tsx create mode 100644 src/components/ui/ElementVariants.test.tsx diff --git a/src/components/ui/DifficultyBadge.tsx b/src/components/ui/DifficultyBadge.tsx index cfaad2b..6acdf66 100644 --- a/src/components/ui/DifficultyBadge.tsx +++ b/src/components/ui/DifficultyBadge.tsx @@ -24,13 +24,13 @@ export default function DifficultyBadge({ {displayDifficulty} {value !== undefined && ( - + {value.toFixed(1)} )} diff --git a/src/components/ui/ElementValueBadge.tsx b/src/components/ui/ElementValueBadge.tsx new file mode 100644 index 0000000..d315f3e --- /dev/null +++ b/src/components/ui/ElementValueBadge.tsx @@ -0,0 +1,19 @@ +export default function ElementValueBadge({ + label, + value, +}: { + label: string; + value: number; +}) { + const formattedValue = value.toFixed(1); + + return ( + + {formattedValue} + + ); +} \ No newline at end of file diff --git a/src/components/ui/ElementVariants.test.tsx b/src/components/ui/ElementVariants.test.tsx new file mode 100644 index 0000000..25f42af --- /dev/null +++ b/src/components/ui/ElementVariants.test.tsx @@ -0,0 +1,44 @@ +import { renderToString } from "react-dom/server"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import ElementVariants from "./ElementVariants"; + +describe("ElementVariants", () => { + it("renders navigable variant cards with their values", () => { + const markup = renderToString( + + + , + ); + + expect(markup).toMatch(/Variants of this element.*1.*<\/h2>/); + expect(markup).toContain('href="/elements/UB-1.101-b"'); + expect(markup).toContain("Glide kip with half turn"); + expect(markup).toContain('aria-label="Difficulty A, value 0.1"'); + expect(markup).toContain("bg-(--text-color-dark)"); + expect(markup).toContain("text-(--text-color-light)"); + expect(markup).toContain(">A"); + expect(markup).toContain(">0.1"); + }); + + it("announces when no other variants exist", () => { + const markup = renderToString( + + + , + ); + + expect(markup).toContain("This element has no other variants."); + }); +}); \ No newline at end of file diff --git a/src/components/ui/ElementVariants.tsx b/src/components/ui/ElementVariants.tsx index 62a1197..c4f5e0c 100644 --- a/src/components/ui/ElementVariants.tsx +++ b/src/components/ui/ElementVariants.tsx @@ -1,28 +1,67 @@ -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Link } from "react-router-dom"; +import { Card, CardContent, CardTitle } from "@/components/ui/card"; import DifficultyBadge from "@/components/ui/DifficultyBadge"; -import elementImage from "../../../data/images/BB/BB-1.101-a.webp"; +import ElementValueBadge from "@/components/ui/ElementValueBadge"; +import type { CatalogVariant } from "@/lib/catalog"; +import { getDifficultyLetter } from "@/lib/difficulty"; -const ElementVariant = () => { +export default function ElementVariants({ + variants, +}: { + variants: CatalogVariant[]; +}) { return ( - - - - Code - Name (if any) - Element variant id (a,b,c) - - - -
-

Element description goes here.

- -
- Balance beam element diagram -
-
- ); -}; +
+

+ Variants of this element ({variants.length}) +

+ {variants.length === 0 ? ( +

+ This element has no other variants. +

+ ) : ( +
    + {variants.map((variant) => { + const difficulty = variant.isVault + ? undefined + : getDifficultyLetter(variant.value); -export default ElementVariant; + return ( +
  • + + + +
    + + {variant.code} + {variant.name && ` · ${variant.name}`} + +

    + {variant.description} +

    +
    + {difficulty ? ( + + ) : ( + + )} +
    +
    + +
  • + ); + })} +
+ )} +
+ ); +} From 9f23af7ade370ac215f0c087cbed07c08e50be6d Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 16 Oct 2026 17:38:59 +0200 Subject: [PATCH 03/14] feat: build modular element detail page --- docs/AI_USAGE.md | 13 ++ package-lock.json | 60 ++++++++- package.json | 3 +- src/App.tsx | 122 +----------------- src/components/elements/ElementActions.tsx | 40 ++++++ .../elements/ElementDetailHeader.tsx | 38 ++++++ src/components/elements/ElementDetailPage.tsx | 120 +++++++++++++++++ src/components/elements/ElementDiagram.tsx | 31 +++++ .../elements/ElementMetadataCard.tsx | 63 +++++++++ src/main.tsx | 5 +- src/styles/global.css | 3 + 11 files changed, 380 insertions(+), 118 deletions(-) create mode 100644 src/components/elements/ElementActions.tsx create mode 100644 src/components/elements/ElementDetailHeader.tsx create mode 100644 src/components/elements/ElementDetailPage.tsx create mode 100644 src/components/elements/ElementDiagram.tsx create mode 100644 src/components/elements/ElementMetadataCard.tsx diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 960bcbd..57911b4 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -174,6 +174,19 @@ - **Review:** The team refined the layout, colors, difficulty display, and accessibility semantics during review. +## Element detail page (`src/components/elements/`, `src/lib/catalog.ts`) + +- **Tool:** GitHub Copilot +- **What the team did:** Provided the detail-page design and specified modular + sections, reuse of the existing variant and difficulty components, existing + CSS colors only, and accessibility requirements. +- **What GitHub Copilot did:** Added a route-backed page, a local catalog data + adapter, reusable header, metadata, diagram and action sections, and tests + for catalog lookup and variant navigation. The page composes the existing + `ElementVariants` and `DifficultyBadge` components. +- **Review:** The team checked the page in the browser, corrected theme-token + use and image sizing, and reviewed keyboard navigation and accessible names. + ## Catalog search (`backend/src/catalog/search.ts`, `eslint.config.js`, `docs/API.md`) - **Tool:** Claude Code diff --git a/package-lock.json b/package-lock.json index 4f8d90f..e335eaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "cn": "^0.4.0", "lucide-react": "^1.58.0", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4" }, "devDependencies": { "@babel/core": "^7.29.7", @@ -8114,6 +8115,57 @@ "react": "^19.3.0" } }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/readdirp": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", @@ -8429,6 +8481,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", diff --git a/package.json b/package.json index 3999596..b5233a4 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "cn": "^0.4.0", "lucide-react": "^1.58.0", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4" }, "devDependencies": { "@babel/core": "^7.29.7", diff --git a/src/App.tsx b/src/App.tsx index 9ca8e8d..d7852cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,121 +1,13 @@ -import { useState } from 'react' -import heroImg from './assets/hero.png' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import './App.css' +import { Navigate, Route, Routes } from 'react-router-dom' +import ElementDetailPage from './components/elements/ElementDetailPage' function App() { - const [count, setCount] = useState(0) - return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- + + } /> + } /> + } /> + ) } diff --git a/src/components/elements/ElementActions.tsx b/src/components/elements/ElementActions.tsx new file mode 100644 index 0000000..dd19f02 --- /dev/null +++ b/src/components/elements/ElementActions.tsx @@ -0,0 +1,40 @@ +import { useState } from "react"; +import { LockKeyhole, Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { ElementDetail } from "@/lib/catalog"; + +export default function ElementActions({ + element, +}: { + element: ElementDetail; +}) { + const [status, setStatus] = useState(""); + + return ( +
+ + +

+ {status} +

+
+ ); +} \ No newline at end of file diff --git a/src/components/elements/ElementDetailHeader.tsx b/src/components/elements/ElementDetailHeader.tsx new file mode 100644 index 0000000..e5c1762 --- /dev/null +++ b/src/components/elements/ElementDetailHeader.tsx @@ -0,0 +1,38 @@ +import { ArrowLeft } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import type { ElementDetail } from "@/lib/catalog"; + +export default function ElementDetailHeader({ + element, +}: { + element: ElementDetail; +}) { + const navigate = useNavigate(); + + return ( +
+ +
+

+ {element.code} + {element.name && ( + <> + + {element.name} + + )} +

+

+ {element.description} +

+
+
+ ); +} \ No newline at end of file diff --git a/src/components/elements/ElementDetailPage.tsx b/src/components/elements/ElementDetailPage.tsx new file mode 100644 index 0000000..76bb36f --- /dev/null +++ b/src/components/elements/ElementDetailPage.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import ElementActions from "@/components/elements/ElementActions"; +import ElementDetailHeader from "@/components/elements/ElementDetailHeader"; +import ElementDiagram from "@/components/elements/ElementDiagram"; +import ElementMetadataCard from "@/components/elements/ElementMetadataCard"; +import ElementVariants from "@/components/ui/ElementVariants"; +import { getElementDetail, type ElementDetail } from "@/lib/catalog"; + +type DetailState = + | { status: "loading" } + | { status: "not-found" } + | { status: "error" } + | { status: "success"; element: ElementDetail }; + +export default function ElementDetailPage() { + const { elementId } = useParams(); + const [retryCount, setRetryCount] = useState(0); + const requestKey = `${elementId ?? ""}:${retryCount}`; + const [result, setResult] = useState<{ + requestKey: string; + state: DetailState; + } | null>(null); + + useEffect(() => { + let isCurrent = true; + + getElementDetail(elementId ?? "") + .then((element) => { + if (!isCurrent) return; + setResult({ + requestKey, + state: element ? { status: "success", element } : { status: "not-found" }, + }); + }) + .catch(() => { + if (isCurrent) setResult({ requestKey, state: { status: "error" } }); + }); + + return () => { + isCurrent = false; + }; + }, [elementId, requestKey]); + + const state = + result?.requestKey === requestKey + ? result.state + : ({ status: "loading" } as const); + + if (state.status === "loading") { + return ( +
+

+ Loading element details +

+
+
+
+
+
+
+ ); + } + + if (state.status === "not-found") { + return ( +
+
+

Element not found

+

+ No catalog item matches this address. +

+ + View a catalog element + +
+
+ ); + } + + if (state.status === "error") { + return ( +
+
+

Element details unavailable

+

+ The catalog item could not be loaded. Please try again. +

+ +
+
+ ); + } + + const { element } = state; + + return ( +
+
+ + + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/elements/ElementDiagram.tsx b/src/components/elements/ElementDiagram.tsx new file mode 100644 index 0000000..228a282 --- /dev/null +++ b/src/components/elements/ElementDiagram.tsx @@ -0,0 +1,31 @@ +import { Card, CardContent } from "@/components/ui/card"; +import type { ElementDetail } from "@/lib/catalog"; + +export default function ElementDiagram({ + element, +}: { + element: ElementDetail; +}) { + return ( +
+ + + {element.imageUrl ? ( + {`Gymnastics + ) : ( +

+ No illustration is available for this element. +

+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/elements/ElementMetadataCard.tsx b/src/components/elements/ElementMetadataCard.tsx new file mode 100644 index 0000000..0323bac --- /dev/null +++ b/src/components/elements/ElementMetadataCard.tsx @@ -0,0 +1,63 @@ +import type { ReactNode } from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import DifficultyBadge from "@/components/ui/DifficultyBadge"; +import ElementValueBadge from "@/components/ui/ElementValueBadge"; +import type { ElementDetail } from "@/lib/catalog"; +import { getDifficultyLetter } from "@/lib/difficulty"; + +function MetadataItem({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( +
+
+ {label} +
+
+ {children} +
+
+ ); +} + +export default function ElementMetadataCard({ + element, +}: { + element: ElementDetail; +}) { + const difficulty = element.isVault + ? undefined + : getDifficultyLetter(element.value); + + return ( +
+ + +
+ + {element.isVault || !difficulty ? ( + + ) : ( + + )} + + {element.apparatusName} + + {element.elementGroupName} + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index 17d5695..6422f22 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' import './index.css' import './styles/variables.css' import './styles/global.css' @@ -7,6 +8,8 @@ import App from './App.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/src/styles/global.css b/src/styles/global.css index e69de29..46b398a 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -0,0 +1,3 @@ +#root .element-detail-page :is(h1, h2) { + color: var(--text-color-dark); +} From 419666f2f90aa1145ee0664c515f60b74b43a409 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 16 Oct 2026 17:40:36 +0200 Subject: [PATCH 04/14] fix: use existing light text token for actions --- src/components/elements/ElementActions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/elements/ElementActions.tsx b/src/components/elements/ElementActions.tsx index dd19f02..a695179 100644 --- a/src/components/elements/ElementActions.tsx +++ b/src/components/elements/ElementActions.tsx @@ -26,7 +26,7 @@ export default function ElementActions({ -

+

{status}

From d8c745996ca879452ba3f9b12ea169e60d74a2c7 Mon Sep 17 00:00:00 2001 From: Ronja Date: Wed, 21 Oct 2026 12:36:16 +0200 Subject: [PATCH 06/14] fix: label the back button and skip an empty description --- src/components/elements/ElementDetailHeader.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/elements/ElementDetailHeader.tsx b/src/components/elements/ElementDetailHeader.tsx index fe781cc..7c35d3d 100644 --- a/src/components/elements/ElementDetailHeader.tsx +++ b/src/components/elements/ElementDetailHeader.tsx @@ -14,6 +14,7 @@ export default function ElementDetailHeader({