From 36bbfa0b168ecc12cd43884848f93c87a19202f4 Mon Sep 17 00:00:00 2001 From: Anettkva Date: Thu, 5 Nov 2026 12:45:50 +0100 Subject: [PATCH 01/32] refactor: move the pages to src/pages - Move the Elements page, the element page, Your gymnasts and the login page with their tests from src/components/ to src/pages/ (git mv) - Move the Calculator, About and not-found placeholders out of App.tsx - Import the pages from @/pages in App.tsx - Keep the page theme test checking the two moved pages - Describe src/pages, src/components, src/components/ui and src/lib in the README, and update the links in the docs Closes #97 Co-Authored-By: Claude Opus 5.5 --- README.md | 52 ++++++++++--------- docs/ACCESSIBILITY.md | 6 +-- docs/AI_USAGE.md | 6 +++ docs/AUTHENTICATION.md | 2 +- docs/SUSTAINABILITY.md | 4 +- src/App.tsx | 34 +++--------- src/pages/AboutPage.tsx | 11 ++++ src/pages/CalculatorPage.tsx | 11 ++++ .../ElementDetailPage.test.tsx | 0 .../elements => pages}/ElementDetailPage.tsx | 0 .../elements => pages}/ElementsPage.test.tsx | 0 .../elements => pages}/ElementsPage.tsx | 0 .../auth => pages}/LoginPage.test.tsx | 2 +- src/{components/auth => pages}/LoginPage.tsx | 2 +- src/pages/NotFoundPage.tsx | 11 ++++ .../YourGymnastsPage.test.tsx | 0 .../gymnasts => pages}/YourGymnastsPage.tsx | 0 src/styles/gymnastTheme.test.ts | 11 ++-- 18 files changed, 91 insertions(+), 61 deletions(-) create mode 100644 src/pages/AboutPage.tsx create mode 100644 src/pages/CalculatorPage.tsx rename src/{components/elements => pages}/ElementDetailPage.test.tsx (100%) rename src/{components/elements => pages}/ElementDetailPage.tsx (100%) rename src/{components/elements => pages}/ElementsPage.test.tsx (100%) rename src/{components/elements => pages}/ElementsPage.tsx (100%) rename src/{components/auth => pages}/LoginPage.test.tsx (99%) rename src/{components/auth => pages}/LoginPage.tsx (99%) create mode 100644 src/pages/NotFoundPage.tsx rename src/{components/gymnasts => pages}/YourGymnastsPage.test.tsx (100%) rename src/{components/gymnasts => pages}/YourGymnastsPage.tsx (100%) diff --git a/README.md b/README.md index a92133c..84b577a 100644 --- a/README.md +++ b/README.md @@ -21,18 +21,18 @@ The calculator and About pages are placeholders. Adding items from the catalog t ## What to review -| Review | What to look at | -| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| [Running application](http://it2810-29.idi.ntnu.no/project2) | Search, load more, element details, and the Your gymnasts test login | -| [ElementsPage tests](src/components/elements/ElementsPage.test.tsx) | Search, result paging, URL state, loading, and error behavior | -| [Element detail tests](src/components/elements/ElementDetailPage.test.tsx) | Catalog detail rendering and error/loading states | -| [Gymnast page tests](src/components/gymnasts/YourGymnastsPage.test.tsx) | Test login, loading, profile creation, and error handling | -| [Backend catalog tests](backend/src/catalog/search.test.ts) | Search, filters, sorting, pagination, and count limits | -| [Backend server tests](backend/src/server/server.test.ts) | GraphQL API behavior; database-backed tests require a migrated and seeded database | -| [docs/API.md](docs/API.md) | API design, schema, search, pagination, authentication, and errors | -| [docs/AI_USAGE.md](docs/AI_USAGE.md) | How AI was used and how the work was reviewed | -| [docs/ACCESSIBILITY.md](docs/ACCESSIBILITY.md) | Accessibility choices, testing, and known open points | -| [docs/SUSTAINABILITY.md](docs/SUSTAINABILITY.md) | Sustainable design choices, measurements, and what remains unmeasured | +| Review | What to look at | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | +| [Running application](http://it2810-29.idi.ntnu.no/project2) | Search, load more, element details, and the Your gymnasts test login | +| [ElementsPage tests](src/pages/ElementsPage.test.tsx) | Search, result paging, URL state, loading, and error behavior | +| [Element detail tests](src/pages/ElementDetailPage.test.tsx) | Catalog detail rendering and error/loading states | +| [Gymnast page tests](src/pages/YourGymnastsPage.test.tsx) | Test login, loading, profile creation, and error handling | +| [Backend catalog tests](backend/src/catalog/search.test.ts) | Search, filters, sorting, pagination, and count limits | +| [Backend server tests](backend/src/server/server.test.ts) | GraphQL API behavior; database-backed tests require a migrated and seeded database | +| [docs/API.md](docs/API.md) | API design, schema, search, pagination, authentication, and errors | +| [docs/AI_USAGE.md](docs/AI_USAGE.md) | How AI was used and how the work was reviewed | +| [docs/ACCESSIBILITY.md](docs/ACCESSIBILITY.md) | Accessibility choices, testing, and known open points | +| [docs/SUSTAINABILITY.md](docs/SUSTAINABILITY.md) | Sustainable design choices, measurements, and what remains unmeasured | ### How to try it @@ -219,12 +219,12 @@ fetchMore({ variables: { after: data.searchCatalog.pageInfo.endCursor } }) - **Load more with `fetchMore`.** The cache keeps all the pages of one search in one list (the key is the search, filter and sort, not `first` or `after`). A query with `after` that is sent with `useQuery` or `client.query` is answered from the cache. `fetchMore` always asks the server, so use it for the next page. - **`totalCount` only with the first page:** the first-page query asks for the count, and later-page queries omit it; the cache keeps the count when those pages are loaded. -- **Authentication.** Session handling (`devLogin`, `logout`, and `restoreSession`) is in `src/lib/apollo/client.ts`. The development test login accepts any email address and is the only login flow planned; do not use real or sensitive data with it. See [Test login on the virtual machine](docs/VM_SETUP.md#6-test-login-on-the-virtual-machine) and [Login for development](docs/API.md#login-for-development). The login page (`src/components/auth/LoginPage.tsx`) offers the demo coach and a test coach of your own. The app reads who is logged in in one place, `useAuth()` (`src/components/auth/useAuth.ts`), which the menu, the protected pages and the gymnasts page share. +- **Authentication.** Session handling (`devLogin`, `logout`, and `restoreSession`) is in `src/lib/apollo/client.ts`. The development test login accepts any email address and is the only login flow planned; do not use real or sensitive data with it. See [Test login on the virtual machine](docs/VM_SETUP.md#6-test-login-on-the-virtual-machine) and [Login for development](docs/API.md#login-for-development). The login page (`src/pages/LoginPage.tsx`) offers the demo coach and a test coach of your own. The app reads who is logged in in one place, `useAuth()` (`src/components/auth/useAuth.ts`), which the menu, the protected pages and the gymnasts page share. - **Errors.** `describeError(error)` (`src/lib/apollo/errors.ts`) turns an error from the API or the network into a message for the user, and says whether trying again can help. ## The Elements page: search, filters and sort -The Elements page (`/elements`, `src/components/elements/ElementsPage.tsx`) is where a coach searches the catalog. It sends one `searchCatalog` request with the search text, the filters and the sort, and shows the result as a table (cards on a phone) with "Load more elements". The search, the filters and the sort are all in the address (`?search=salto&apparatus=UB&group=UB-6&difficulty=A,B&sort=difficulty&dir=desc`), so a result can be shared and the back button works. +The Elements page (`/elements`, `src/pages/ElementsPage.tsx`) is where a coach searches the catalog. It sends one `searchCatalog` request with the search text, the filters and the sort, and shows the result as a table (cards on a phone) with "Load more elements". The search, the filters and the sort are all in the address (`?search=salto&apparatus=UB&group=UB-6&difficulty=A,B&sort=difficulty&dir=desc`), so a result can be shared and the back button works. ### What the user can do @@ -253,7 +253,7 @@ The Elements page (`/elements`, `src/components/elements/ElementsPage.tsx`) is w ### Testing -- `src/components/elements/ElementsPage.test.tsx` runs the page on the real Apollo Client with a stub for `fetch` that answers like the server: each filter, several values, the address in both directions, the disabled filters, clear, the sort list, the filters and the sort together, a wrong address, a structure that fails, and "Load more" with a filter. Each behavior was broken on purpose to check that a test fails without it. +- `src/pages/ElementsPage.test.tsx` runs the page on the real Apollo Client with a stub for `fetch` that answers like the server: each filter, several values, the address in both directions, the disabled filters, clear, the sort list, the filters and the sort together, a wrong address, a structure that fails, and "Load more" with a filter. Each behavior was broken on purpose to check that a test fails without it. - The backend tests (`backend/src/catalog/search.test.ts`, `backend/src/server/server.test.ts`) cover the vault filter, the range of the values and `Apparatus.difficultyValues`, and need the seeded database like the rest. - Not everything can be tested in jsdom (for example the tooltip, which Base UI opens from real pointer events). Those parts were checked in a real browser against the real backend. @@ -585,15 +585,19 @@ Settings, all optional, as environment variables: ## Project structure -| Folder | Contents | -| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `backend/` | The backend package: TypeScript, Prisma, the seed script, the catalog search, the GraphQL server and the GraphQL schema | -| `data/` | The catalog data extracted from the Code of Points: JSON files and one image per element | -| `docs/` | Project plans, data model, API, accessibility, sustainability, AI usage, and catalog extraction documentation | -| `e2e/` | The end-to-end tests (Playwright): whole flows in a real browser, accessibility checks and the API over HTTP. See "End-to-end tests" | -| `public/` | Static files served as they are, such as the favicon | -| `scripts/extract-cop/` | The Python script that extracts `data/` from the Code of Points PDF | -| `src/` | The React frontend, shared UI components, API operations, Apollo Client, and frontend tests | +| Folder | Contents | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `backend/` | The backend package: TypeScript, Prisma, the seed script, the catalog search, the GraphQL server and the GraphQL schema | +| `data/` | The catalog data extracted from the Code of Points: JSON files and one image per element | +| `docs/` | Project plans, data model, API, accessibility, sustainability, AI usage, and catalog extraction documentation | +| `e2e/` | The end-to-end tests (Playwright): whole flows in a real browser, accessibility checks and the API over HTTP. See "End-to-end tests" | +| `public/` | Static files served as they are, such as the favicon | +| `scripts/extract-cop/` | The Python script that extracts `data/` from the Code of Points PDF | +| `src/` | The React frontend, shared UI components, API operations, Apollo Client, and frontend tests | +| `src/pages/` | One file for each route in `src/App.tsx` (the Elements page, the element page, Your gymnasts, Log in and the placeholder pages), with its tests | +| `src/components/` | The parts of the pages, in one folder for each part of the site (`elements/`, `gymnasts/`, `auth/`, `navbar/`) | +| `src/components/ui/` | The shadcn/ui components (written by the shadcn/ui CLI) and a few small shared components (`DifficultyBadge`, `ElementValueBadge`, `ElementVariants`) | +| `src/lib/` | The GraphQL operations, Apollo Client and the logic that is not a component | ## Working in this repository diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md index b4e4ad8..33b8ea6 100644 --- a/docs/ACCESSIBILITY.md +++ b/docs/ACCESSIBILITY.md @@ -23,19 +23,19 @@ The “Skip to main content” link is visually hidden until focused. The app us - The desktop results table has a screen-reader caption, column headers, and `scope="col"` relationships. On narrow screens the results are presented as cards instead. - Disabled actions for removed catalog items reference the explanatory text with `aria-describedby`. -**Where:** [ElementsPage.tsx](../src/components/elements/ElementsPage.tsx), [ResultsTable.tsx](../src/components/elements/ResultsTable.tsx), [ElementActions.tsx](../src/components/elements/ElementActions.tsx). +**Where:** [ElementsPage.tsx](../src/pages/ElementsPage.tsx), [ResultsTable.tsx](../src/components/elements/ResultsTable.tsx), [ElementActions.tsx](../src/components/elements/ElementActions.tsx). ### Status messages and errors Dynamic updates such as loading, result counts, and action feedback use status semantics (`role="status"` and, where needed, `aria-live`). Errors use alert semantics so important feedback is exposed to assistive technology. -**Where:** [ElementsPage.tsx](../src/components/elements/ElementsPage.tsx), [ElementActions.tsx](../src/components/elements/ElementActions.tsx), [ElementDetailPage.tsx](../src/components/elements/ElementDetailPage.tsx), and [YourGymnastsPage.tsx](../src/components/gymnasts/YourGymnastsPage.tsx). +**Where:** [ElementsPage.tsx](../src/pages/ElementsPage.tsx), [ElementActions.tsx](../src/components/elements/ElementActions.tsx), [ElementDetailPage.tsx](../src/pages/ElementDetailPage.tsx), and [YourGymnastsPage.tsx](../src/pages/YourGymnastsPage.tsx). ### Themes, contrast, and reduced motion The interface supports light and dark themes with separate color tokens. Interactive controls and links include visible focus styling. Animations and transitions use reduced-motion variants so they can be suppressed when the user requests reduced motion. -**Where:** theme state in [App.tsx](../src/App.tsx), theme colors in [variables.css](../src/styles/variables.css), and reduced-motion styles in [ElementsPage.tsx](../src/components/elements/ElementsPage.tsx), [ResultsTable.tsx](../src/components/elements/ResultsTable.tsx), and [ElementDetailPage.tsx](../src/components/elements/ElementDetailPage.tsx). +**Where:** theme state in [App.tsx](../src/App.tsx), theme colors in [variables.css](../src/styles/variables.css), and reduced-motion styles in [ElementsPage.tsx](../src/pages/ElementsPage.tsx), [ResultsTable.tsx](../src/components/elements/ResultsTable.tsx), and [ElementDetailPage.tsx](../src/pages/ElementDetailPage.tsx). The color themes have not yet been comprehensively measured against WCAG contrast criteria. Focus, contrast, zoom, and screen-reader behavior need verification in the complete application. diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index b1b9990..38babd8 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -526,3 +526,9 @@ - **Tool:** Claude Code - **What the team did:** Found in a review that the element page shows the long description of a vault group under "Element group", and wrote issue #125: show "Group N" for a vault, with the same rule as the Elements list. - **What Claude did:** Added `number` (and `id`, so Apollo's cache can recognise the group across queries) to the `catalogItem` query, and let the element page use `groupLabel` from the Elements list, so the two pages cannot disagree. Wrote one test for an element and one for a vault (with the real group of VT-1.22-a), and checked that each fails when the page shows the name for every item or "Group N" for every item. After a review of its own work, split one test that checked two cases, and replaced test data for the vault that was half an element. + +## The pages in `src/pages/` (`src/pages/`, `src/App.tsx`) + +- **Tool:** Claude Code +- **What the team did:** Wrote issue #97: move the pages out of `src/components/`, so it is clear which files are routes and which are parts of pages, and describe the folders in the README. +- **What Claude did:** Moved the four pages and their tests with `git mv` (so their history follows them), and moved the three placeholder pages out of `App.tsx` into their own files, so every route has a file in `src/pages/`. Updated the imports, the links in the README and the docs, and the "Project structure" table. Found that `src/styles/gymnastTheme.test.ts` reads the files of the gymnasts and login pages by folder, so after the move it would have stopped checking the two pages without failing; added them to the list. The shared components in `components/ui/` were left where they are (see the issue). diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 64e514d..8915ef5 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -123,7 +123,7 @@ With https and a real login, in this order of importance: | -------------------------------- | ------------------------------------------------------------------------ | | The question `me` | `src/lib/authOperations.ts` | | The provider and the hook | `src/components/auth/AuthProvider.tsx`, `src/components/auth/useAuth.ts` | -| The login page | `src/components/auth/LoginPage.tsx` | +| The login page | `src/pages/LoginPage.tsx` | | The test login form | `src/components/gymnasts/DevLoginButton.tsx` | | The protected pages | `src/components/ProtectedRoute.tsx`, `src/App.tsx` | | The menu | `src/components/navbar/Navbar.tsx` | diff --git a/docs/SUSTAINABILITY.md b/docs/SUSTAINABILITY.md index 3ea6241..3900b4c 100644 --- a/docs/SUSTAINABILITY.md +++ b/docs/SUSTAINABILITY.md @@ -14,7 +14,7 @@ With GraphQL, each operation selects the fields its page uses instead of downloa The search waits **300 ms after the user stops typing** before updating the address and sending the query. It requests **20 results per page**, with a “Load more” action for additional results, rather than downloading the whole catalog at once. This avoids requests for every keystroke and limits the initial response. -**Where:** `SEARCH_DELAY_MS` and `PAGE_SIZE` in [searchOperations.ts](../src/lib/searchOperations.ts); the debounce, initial query, and “Load more” behavior in [ElementsPage.tsx](../src/components/elements/ElementsPage.tsx). +**Where:** `SEARCH_DELAY_MS` and `PAGE_SIZE` in [searchOperations.ts](../src/lib/searchOperations.ts); the debounce, initial query, and “Load more” behavior in [ElementsPage.tsx](../src/pages/ElementsPage.tsx). ### Bound the cost of counting results @@ -32,7 +32,7 @@ Catalog drawings use WebP. Result-card images are lazy-loaded and decoded asynch The catalog JSON is no longer imported into the frontend to build the element detail view; the page fetches the selected item from the GraphQL API instead. The project history records the JavaScript bundle changing from **665 kB to 522 kB** after this move. On the current version, `npm run build` reports a JavaScript bundle of **548.06 kB (170.79 kB gzip)**. These are build-output sizes, not per-visit measurements; the historical before/after comparison and current build can differ because the code and build tooling have changed. -**Where:** the API-backed detail query in [catalogOperations.ts](../src/lib/catalogOperations.ts) and its use in [ElementDetailPage.tsx](../src/components/elements/ElementDetailPage.tsx); the historical comparison is recorded in [AI_USAGE.md](./AI_USAGE.md). +**Where:** the API-backed detail query in [catalogOperations.ts](../src/lib/catalogOperations.ts) and its use in [ElementDetailPage.tsx](../src/pages/ElementDetailPage.tsx); the historical comparison is recorded in [AI_USAGE.md](./AI_USAGE.md). ### Reuse cached search results diff --git a/src/App.tsx b/src/App.tsx index fdc7b72..5fec1fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,38 +4,20 @@ import { Navbar } from './components/navbar/Navbar'; import { SkipLink } from './components/skipLink/SkipLink'; import { ProtectedRoute } from './components/ProtectedRoute'; import { AuthProvider } from './components/auth/AuthProvider'; -import LoginPage from './components/auth/LoginPage'; -import PageTitle from './components/PageTitle'; -import ElementDetailPage from './components/elements/ElementDetailPage'; -import ElementsPage from './components/elements/ElementsPage'; -import YourGymnastsPage from './components/gymnasts/YourGymnastsPage'; +// One page for each route (src/pages/). The parts of the pages are in src/components/. +import AboutPage from '@/pages/AboutPage'; +import CalculatorPage from '@/pages/CalculatorPage'; +import ElementDetailPage from '@/pages/ElementDetailPage'; +import ElementsPage from '@/pages/ElementsPage'; +import LoginPage from '@/pages/LoginPage'; +import NotFoundPage from '@/pages/NotFoundPage'; +import YourGymnastsPage from '@/pages/YourGymnastsPage'; import './index.css'; import './styles/variables.css'; import './styles/global.css'; -// Placeholder pages for routes without full implementations yet. Each page -// has its own
, like the real pages. -const CalculatorPage = () => ( -
- Calculator -

Calculator

-
-); -const AboutPage = () => ( -
- About -

About

-
-); -const NotFoundPage = () => ( -
- Page not found -

404 - Page not found

-
-); - export function App() { const [isDarkMode, setIsDarkMode] = useState( () => window.localStorage.getItem('theme') === 'dark', diff --git a/src/pages/AboutPage.tsx b/src/pages/AboutPage.tsx new file mode 100644 index 0000000..6085613 --- /dev/null +++ b/src/pages/AboutPage.tsx @@ -0,0 +1,11 @@ +import PageTitle from '@/components/PageTitle' + +// A placeholder until the page is built. It has its own
, like the real pages. +export default function AboutPage() { + return ( +
+ About +

About

+
+ ) +} diff --git a/src/pages/CalculatorPage.tsx b/src/pages/CalculatorPage.tsx new file mode 100644 index 0000000..61585e3 --- /dev/null +++ b/src/pages/CalculatorPage.tsx @@ -0,0 +1,11 @@ +import PageTitle from '@/components/PageTitle' + +// A placeholder until the page is built. It has its own
, like the real pages. +export default function CalculatorPage() { + return ( +
+ Calculator +

Calculator

+
+ ) +} diff --git a/src/components/elements/ElementDetailPage.test.tsx b/src/pages/ElementDetailPage.test.tsx similarity index 100% rename from src/components/elements/ElementDetailPage.test.tsx rename to src/pages/ElementDetailPage.test.tsx diff --git a/src/components/elements/ElementDetailPage.tsx b/src/pages/ElementDetailPage.tsx similarity index 100% rename from src/components/elements/ElementDetailPage.tsx rename to src/pages/ElementDetailPage.tsx diff --git a/src/components/elements/ElementsPage.test.tsx b/src/pages/ElementsPage.test.tsx similarity index 100% rename from src/components/elements/ElementsPage.test.tsx rename to src/pages/ElementsPage.test.tsx diff --git a/src/components/elements/ElementsPage.tsx b/src/pages/ElementsPage.tsx similarity index 100% rename from src/components/elements/ElementsPage.tsx rename to src/pages/ElementsPage.tsx diff --git a/src/components/auth/LoginPage.test.tsx b/src/pages/LoginPage.test.tsx similarity index 99% rename from src/components/auth/LoginPage.test.tsx rename to src/pages/LoginPage.test.tsx index 31d07da..e1d45e9 100644 --- a/src/components/auth/LoginPage.test.tsx +++ b/src/pages/LoginPage.test.tsx @@ -11,7 +11,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AuthContext, type AuthState } from '@/components/auth/useAuth' import { devLogin } from '@/lib/apollo/client' import { ApiError } from '@/lib/apollo/api' -import { DEMO_EMAIL } from '../../../shared/demo' +import { DEMO_EMAIL } from '../../shared/demo' import LoginPage from './LoginPage' // The real function would call the server. The page only has to call devLogin. diff --git a/src/components/auth/LoginPage.tsx b/src/pages/LoginPage.tsx similarity index 99% rename from src/components/auth/LoginPage.tsx rename to src/pages/LoginPage.tsx index c60e17d..39bc96a 100644 --- a/src/components/auth/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -10,7 +10,7 @@ import { devLogin } from '@/lib/apollo/client' import { describeError } from '@/lib/apollo/errors' import { isDevLoginEnabled } from '@/lib/devLogin' import { isInsecureConnection } from '@/lib/insecureConnection' -import { DEMO_EMAIL } from '../../../shared/demo' +import { DEMO_EMAIL } from '../../shared/demo' /** Where the visitor goes after logging in when they did not come from a protected page. */ const DEFAULT_TARGET = '/gymnasts' diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..a5f8c8d --- /dev/null +++ b/src/pages/NotFoundPage.tsx @@ -0,0 +1,11 @@ +import PageTitle from '@/components/PageTitle' + +// The page for an address that does not exist. It has its own
, like the real pages. +export default function NotFoundPage() { + return ( +
+ Page not found +

404 - Page not found

+
+ ) +} diff --git a/src/components/gymnasts/YourGymnastsPage.test.tsx b/src/pages/YourGymnastsPage.test.tsx similarity index 100% rename from src/components/gymnasts/YourGymnastsPage.test.tsx rename to src/pages/YourGymnastsPage.test.tsx diff --git a/src/components/gymnasts/YourGymnastsPage.tsx b/src/pages/YourGymnastsPage.tsx similarity index 100% rename from src/components/gymnasts/YourGymnastsPage.tsx rename to src/pages/YourGymnastsPage.tsx diff --git a/src/styles/gymnastTheme.test.ts b/src/styles/gymnastTheme.test.ts index c0ad01e..95f038a 100644 --- a/src/styles/gymnastTheme.test.ts +++ b/src/styles/gymnastTheme.test.ts @@ -203,12 +203,16 @@ describe('the components of the gymnasts and login pages', () => { '--accent-color', ] - const files = ['../components/gymnasts/', '../components/auth/'].flatMap( - (dir) => + // The two pages (src/pages/) and the components they use. + const files = [ + '../pages/YourGymnastsPage.tsx', + '../pages/LoginPage.tsx', + ...['../components/gymnasts/', '../components/auth/'].flatMap((dir) => readdirSync(here(dir)) .filter((name) => name.endsWith('.tsx') && !name.endsWith('.test.tsx')) .map((name) => dir + name), - ) + ), + ] it.each(files)( '%s: a button with the outline variant also sets its colors for dark mode', @@ -231,6 +235,7 @@ describe('the components of the gymnasts and login pages', () => { it('looks at the components of both pages and not at their tests', () => { expect(files.some((f) => f.endsWith('GymnastRow.tsx'))).toBe(true) + expect(files.some((f) => f.endsWith('YourGymnastsPage.tsx'))).toBe(true) expect(files.some((f) => f.endsWith('LoginPage.tsx'))).toBe(true) expect(files.some((f) => f.endsWith('.test.tsx'))).toBe(false) }) From b65ef3e1bb4962217f967c0ac75127807473cf5a Mon Sep 17 00:00:00 2001 From: Anettkva Date: Thu, 5 Nov 2026 13:11:48 +0100 Subject: [PATCH 02/32] refactor: move the frontend tests to src/tests - Move all 29 frontend tests with git mv into src/tests/, in the same folders as the code they test - Import what they test with the @/ alias instead of relative paths - Read the theme files from the new place in gymnastTheme.test.ts - Describe src/tests in the README, and update the links to the tests Part of #97 Co-Authored-By: Claude Opus 5.5 --- README.md | 53 ++++++++++--------- docs/AI_USAGE.md | 2 +- docs/AUTHENTICATION.md | 2 +- src/{ => tests}/App.flow.test.tsx | 2 +- src/{ => tests}/App.test.tsx | 2 +- .../components/ProtectedRoute.test.tsx | 2 +- .../elements/ElementActions.test.tsx | 2 +- .../elements/ElementDetailHeader.test.tsx | 2 +- .../components/elements/ResultsTable.test.tsx | 2 +- .../gymnasts/DeleteGymnastButton.test.tsx | 2 +- .../gymnasts/DevLoginButton.test.tsx | 2 +- .../components/gymnasts/GymnastList.test.tsx | 2 +- .../components/navbar/Navbar.test.tsx | 2 +- .../components/skipLink/SkipLink.test.tsx | 2 +- .../components/ui/DifficultyBadge.test.tsx | 2 +- .../components/ui/ElementVariants.test.tsx | 2 +- src/{ => tests}/components/ui/button.test.tsx | 0 src/{ => tests}/lib/apollo/api.test.ts | 2 +- src/{ => tests}/lib/apollo/authLink.test.ts | 8 +-- src/{ => tests}/lib/apollo/cache.test.ts | 2 +- src/{ => tests}/lib/apollo/client.test.ts | 2 +- src/{ => tests}/lib/apollo/errors.test.ts | 4 +- src/{ => tests}/lib/apollo/session.test.ts | 8 ++- src/{ => tests}/lib/catalog.test.ts | 4 +- src/{ => tests}/lib/gymnasts.test.ts | 2 +- .../lib/insecureConnection.test.ts | 2 +- src/{ => tests}/lib/searchOperations.test.ts | 2 +- .../pages/ElementDetailPage.test.tsx | 2 +- src/{ => tests}/pages/ElementsPage.test.tsx | 2 +- src/{ => tests}/pages/LoginPage.test.tsx | 4 +- .../pages/YourGymnastsPage.test.tsx | 2 +- src/{ => tests}/styles/gymnastTheme.test.ts | 8 +-- 32 files changed, 71 insertions(+), 66 deletions(-) rename src/{ => tests}/App.flow.test.tsx (99%) rename src/{ => tests}/App.test.tsx (99%) rename src/{ => tests}/components/ProtectedRoute.test.tsx (97%) rename src/{ => tests}/components/elements/ElementActions.test.tsx (95%) rename src/{ => tests}/components/elements/ElementDetailHeader.test.tsx (93%) rename src/{ => tests}/components/elements/ResultsTable.test.tsx (99%) rename src/{ => tests}/components/gymnasts/DeleteGymnastButton.test.tsx (99%) rename src/{ => tests}/components/gymnasts/DevLoginButton.test.tsx (98%) rename src/{ => tests}/components/gymnasts/GymnastList.test.tsx (96%) rename src/{ => tests}/components/navbar/Navbar.test.tsx (99%) rename src/{ => tests}/components/skipLink/SkipLink.test.tsx (94%) rename src/{ => tests}/components/ui/DifficultyBadge.test.tsx (95%) rename src/{ => tests}/components/ui/ElementVariants.test.tsx (95%) rename src/{ => tests}/components/ui/button.test.tsx (100%) rename src/{ => tests}/lib/apollo/api.test.ts (98%) rename src/{ => tests}/lib/apollo/authLink.test.ts (96%) rename src/{ => tests}/lib/apollo/cache.test.ts (99%) rename src/{ => tests}/lib/apollo/client.test.ts (96%) rename src/{ => tests}/lib/apollo/errors.test.ts (96%) rename src/{ => tests}/lib/apollo/session.test.ts (98%) rename src/{ => tests}/lib/catalog.test.ts (93%) rename src/{ => tests}/lib/gymnasts.test.ts (98%) rename src/{ => tests}/lib/insecureConnection.test.ts (92%) rename src/{ => tests}/lib/searchOperations.test.ts (98%) rename src/{ => tests}/pages/ElementDetailPage.test.tsx (99%) rename src/{ => tests}/pages/ElementsPage.test.tsx (99%) rename src/{ => tests}/pages/LoginPage.test.tsx (98%) rename src/{ => tests}/pages/YourGymnastsPage.test.tsx (99%) rename src/{ => tests}/styles/gymnastTheme.test.ts (97%) diff --git a/README.md b/README.md index 84b577a..3607120 100644 --- a/README.md +++ b/README.md @@ -21,18 +21,18 @@ The calculator and About pages are placeholders. Adding items from the catalog t ## What to review -| Review | What to look at | -| ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | -| [Running application](http://it2810-29.idi.ntnu.no/project2) | Search, load more, element details, and the Your gymnasts test login | -| [ElementsPage tests](src/pages/ElementsPage.test.tsx) | Search, result paging, URL state, loading, and error behavior | -| [Element detail tests](src/pages/ElementDetailPage.test.tsx) | Catalog detail rendering and error/loading states | -| [Gymnast page tests](src/pages/YourGymnastsPage.test.tsx) | Test login, loading, profile creation, and error handling | -| [Backend catalog tests](backend/src/catalog/search.test.ts) | Search, filters, sorting, pagination, and count limits | -| [Backend server tests](backend/src/server/server.test.ts) | GraphQL API behavior; database-backed tests require a migrated and seeded database | -| [docs/API.md](docs/API.md) | API design, schema, search, pagination, authentication, and errors | -| [docs/AI_USAGE.md](docs/AI_USAGE.md) | How AI was used and how the work was reviewed | -| [docs/ACCESSIBILITY.md](docs/ACCESSIBILITY.md) | Accessibility choices, testing, and known open points | -| [docs/SUSTAINABILITY.md](docs/SUSTAINABILITY.md) | Sustainable design choices, measurements, and what remains unmeasured | +| Review | What to look at | +| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | +| [Running application](http://it2810-29.idi.ntnu.no/project2) | Search, load more, element details, and the Your gymnasts test login | +| [ElementsPage tests](src/tests/pages/ElementsPage.test.tsx) | Search, result paging, URL state, loading, and error behavior | +| [Element detail tests](src/tests/pages/ElementDetailPage.test.tsx) | Catalog detail rendering and error/loading states | +| [Gymnast page tests](src/tests/pages/YourGymnastsPage.test.tsx) | Test login, loading, profile creation, and error handling | +| [Backend catalog tests](backend/src/catalog/search.test.ts) | Search, filters, sorting, pagination, and count limits | +| [Backend server tests](backend/src/server/server.test.ts) | GraphQL API behavior; database-backed tests require a migrated and seeded database | +| [docs/API.md](docs/API.md) | API design, schema, search, pagination, authentication, and errors | +| [docs/AI_USAGE.md](docs/AI_USAGE.md) | How AI was used and how the work was reviewed | +| [docs/ACCESSIBILITY.md](docs/ACCESSIBILITY.md) | Accessibility choices, testing, and known open points | +| [docs/SUSTAINABILITY.md](docs/SUSTAINABILITY.md) | Sustainable design choices, measurements, and what remains unmeasured | ### How to try it @@ -253,7 +253,7 @@ The Elements page (`/elements`, `src/pages/ElementsPage.tsx`) is where a coach s ### Testing -- `src/pages/ElementsPage.test.tsx` runs the page on the real Apollo Client with a stub for `fetch` that answers like the server: each filter, several values, the address in both directions, the disabled filters, clear, the sort list, the filters and the sort together, a wrong address, a structure that fails, and "Load more" with a filter. Each behavior was broken on purpose to check that a test fails without it. +- `src/tests/pages/ElementsPage.test.tsx` runs the page on the real Apollo Client with a stub for `fetch` that answers like the server: each filter, several values, the address in both directions, the disabled filters, clear, the sort list, the filters and the sort together, a wrong address, a structure that fails, and "Load more" with a filter. Each behavior was broken on purpose to check that a test fails without it. - The backend tests (`backend/src/catalog/search.test.ts`, `backend/src/server/server.test.ts`) cover the vault filter, the range of the values and `Apparatus.difficultyValues`, and need the seeded database like the rest. - Not everything can be tested in jsdom (for example the tooltip, which Base UI opens from real pointer events). Those parts were checked in a real browser against the real backend. @@ -585,19 +585,20 @@ Settings, all optional, as environment variables: ## Project structure -| Folder | Contents | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| `backend/` | The backend package: TypeScript, Prisma, the seed script, the catalog search, the GraphQL server and the GraphQL schema | -| `data/` | The catalog data extracted from the Code of Points: JSON files and one image per element | -| `docs/` | Project plans, data model, API, accessibility, sustainability, AI usage, and catalog extraction documentation | -| `e2e/` | The end-to-end tests (Playwright): whole flows in a real browser, accessibility checks and the API over HTTP. See "End-to-end tests" | -| `public/` | Static files served as they are, such as the favicon | -| `scripts/extract-cop/` | The Python script that extracts `data/` from the Code of Points PDF | -| `src/` | The React frontend, shared UI components, API operations, Apollo Client, and frontend tests | -| `src/pages/` | One file for each route in `src/App.tsx` (the Elements page, the element page, Your gymnasts, Log in and the placeholder pages), with its tests | -| `src/components/` | The parts of the pages, in one folder for each part of the site (`elements/`, `gymnasts/`, `auth/`, `navbar/`) | -| `src/components/ui/` | The shadcn/ui components (written by the shadcn/ui CLI) and a few small shared components (`DifficultyBadge`, `ElementValueBadge`, `ElementVariants`) | -| `src/lib/` | The GraphQL operations, Apollo Client and the logic that is not a component | +| Folder | Contents | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `backend/` | The backend package: TypeScript, Prisma, the seed script, the catalog search, the GraphQL server and the GraphQL schema | +| `data/` | The catalog data extracted from the Code of Points: JSON files and one image per element | +| `docs/` | Project plans, data model, API, accessibility, sustainability, AI usage, and catalog extraction documentation | +| `e2e/` | The end-to-end tests (Playwright): whole flows in a real browser, accessibility checks and the API over HTTP. See "End-to-end tests" | +| `public/` | Static files served as they are, such as the favicon | +| `scripts/extract-cop/` | The Python script that extracts `data/` from the Code of Points PDF | +| `src/` | The React frontend, shared UI components, API operations, Apollo Client, and frontend tests | +| `src/pages/` | One file for each route in `src/App.tsx` (the Elements page, the element page, Your gymnasts, Log in and the placeholder pages) | +| `src/components/` | The parts of the pages, in one folder for each part of the site (`elements/`, `gymnasts/`, `auth/`, `navbar/`) | +| `src/components/ui/` | The shadcn/ui components (written by the shadcn/ui CLI) and a few small shared components (`DifficultyBadge`, `ElementValueBadge`, `ElementVariants`) | +| `src/lib/` | The GraphQL operations, Apollo Client and the logic that is not a component | +| `src/tests/` | The frontend tests (Vitest), in the same folders as the code they test (`pages/`, `components/elements/`, `lib/apollo/` ...), and the shared helpers (`apollo.ts` for the Apollo test client, `setupTests.ts`) | ## Working in this repository diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 38babd8..f278aa0 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -531,4 +531,4 @@ - **Tool:** Claude Code - **What the team did:** Wrote issue #97: move the pages out of `src/components/`, so it is clear which files are routes and which are parts of pages, and describe the folders in the README. -- **What Claude did:** Moved the four pages and their tests with `git mv` (so their history follows them), and moved the three placeholder pages out of `App.tsx` into their own files, so every route has a file in `src/pages/`. Updated the imports, the links in the README and the docs, and the "Project structure" table. Found that `src/styles/gymnastTheme.test.ts` reads the files of the gymnasts and login pages by folder, so after the move it would have stopped checking the two pages without failing; added them to the list. The shared components in `components/ui/` were left where they are (see the issue). +- **What Claude did:** Moved the four pages and their tests with `git mv` (so their history follows them), and moved the three placeholder pages out of `App.tsx` into their own files, so every route has a file in `src/pages/`. Updated the imports, the links in the README and the docs, and the "Project structure" table. Found that `src/styles/gymnastTheme.test.ts` reads the files of the gymnasts and login pages by folder, so after the move it would have stopped checking the two pages without failing; added them to the list. The shared components in `components/ui/` were left where they are (see the issue). After that, asked to move the frontend tests to `src/tests/` as well: moved all 29 with `git mv` into the same folders as the code they test, changed their relative imports to the `@/` alias (so a test does not depend on where it is), and updated the paths that `gymnastTheme.test.ts` reads files from. diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 8915ef5..64cf69c 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -102,7 +102,7 @@ AuthProvider --asks--> me { id name email } (the server) - A visitor who is not logged in is sent to `/login` with the page they wanted in the state of the navigation. After the login they go there, but only if it is a path on this site (a `from` such as `//evil.example` is ignored). - Logging out goes to `/elements` first, so the page the coach leaves does not send them to the login page. -The whole path (log in, save a gymnast, reload, log out, log in again, see the gymnast, delete it) is tested as one test in `src/App.flow.test.tsx`. +The whole path (log in, save a gymnast, reload, log out, log in again, see the gymnast, delete it) is tested as one test in `src/tests/App.flow.test.tsx`. ## What we would do in a real production system diff --git a/src/App.flow.test.tsx b/src/tests/App.flow.test.tsx similarity index 99% rename from src/App.flow.test.tsx rename to src/tests/App.flow.test.tsx index f834e55..5052ed9 100644 --- a/src/App.flow.test.tsx +++ b/src/tests/App.flow.test.tsx @@ -18,7 +18,7 @@ import { import { BrowserRouter } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createTestClient, ok, type Handler } from '@/tests/apollo' -import App from './App' +import App from '@/App' const BASE = import.meta.env.BASE_URL diff --git a/src/App.test.tsx b/src/tests/App.test.tsx similarity index 99% rename from src/App.test.tsx rename to src/tests/App.test.tsx index 74e8b3a..fe11b89 100644 --- a/src/App.test.tsx +++ b/src/tests/App.test.tsx @@ -4,7 +4,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { BrowserRouter } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createTestClient, ok, type Handler } from '@/tests/apollo'; -import App from './App'; +import App from '@/App'; /** * The app with the router and the Apollo client that main.tsx gives it. The real diff --git a/src/components/ProtectedRoute.test.tsx b/src/tests/components/ProtectedRoute.test.tsx similarity index 97% rename from src/components/ProtectedRoute.test.tsx rename to src/tests/components/ProtectedRoute.test.tsx index d45ab01..4cc2749 100644 --- a/src/components/ProtectedRoute.test.tsx +++ b/src/tests/components/ProtectedRoute.test.tsx @@ -3,7 +3,7 @@ import { cleanup, render, screen } from '@testing-library/react' import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' import { afterEach, describe, expect, it } from 'vitest' import { AuthContext, type AuthState } from '@/components/auth/useAuth' -import { ProtectedRoute } from './ProtectedRoute' +import { ProtectedRoute } from '@/components/ProtectedRoute' afterEach(cleanup) diff --git a/src/components/elements/ElementActions.test.tsx b/src/tests/components/elements/ElementActions.test.tsx similarity index 95% rename from src/components/elements/ElementActions.test.tsx rename to src/tests/components/elements/ElementActions.test.tsx index cf4306f..691bd24 100644 --- a/src/components/elements/ElementActions.test.tsx +++ b/src/tests/components/elements/ElementActions.test.tsx @@ -1,7 +1,7 @@ import { renderToString } from 'react-dom/server' import { describe, expect, it } from 'vitest' import type { ElementDetail } from '@/lib/catalog' -import ElementActions from './ElementActions' +import ElementActions from '@/components/elements/ElementActions' const element: ElementDetail = { id: 'UB-2.305-a', diff --git a/src/components/elements/ElementDetailHeader.test.tsx b/src/tests/components/elements/ElementDetailHeader.test.tsx similarity index 93% rename from src/components/elements/ElementDetailHeader.test.tsx rename to src/tests/components/elements/ElementDetailHeader.test.tsx index 83331e5..3238599 100644 --- a/src/components/elements/ElementDetailHeader.test.tsx +++ b/src/tests/components/elements/ElementDetailHeader.test.tsx @@ -2,7 +2,7 @@ import { renderToString } from 'react-dom/server' import { MemoryRouter } from 'react-router-dom' import { describe, expect, it } from 'vitest' import type { ElementDetail } from '@/lib/catalog' -import ElementDetailHeader from './ElementDetailHeader' +import ElementDetailHeader from '@/components/elements/ElementDetailHeader' const element: ElementDetail = { id: 'UB-2.305-a', diff --git a/src/components/elements/ResultsTable.test.tsx b/src/tests/components/elements/ResultsTable.test.tsx similarity index 99% rename from src/components/elements/ResultsTable.test.tsx rename to src/tests/components/elements/ResultsTable.test.tsx index ecab212..2e71d7a 100644 --- a/src/components/elements/ResultsTable.test.tsx +++ b/src/tests/components/elements/ResultsTable.test.tsx @@ -3,7 +3,7 @@ import { MemoryRouter } from 'react-router-dom' import { describe, expect, it } from 'vitest' import { groupLabel, type ElementListRow } from '@/lib/elementList' import { exampleElementRows } from '@/tests/elementRows' -import ResultsTable from './ResultsTable' +import ResultsTable from '@/components/elements/ResultsTable' const render = (rows: ElementListRow[]) => renderToString( diff --git a/src/components/gymnasts/DeleteGymnastButton.test.tsx b/src/tests/components/gymnasts/DeleteGymnastButton.test.tsx similarity index 99% rename from src/components/gymnasts/DeleteGymnastButton.test.tsx rename to src/tests/components/gymnasts/DeleteGymnastButton.test.tsx index 54e44d3..4240d7f 100644 --- a/src/components/gymnasts/DeleteGymnastButton.test.tsx +++ b/src/tests/components/gymnasts/DeleteGymnastButton.test.tsx @@ -13,7 +13,7 @@ import { } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { createTestClient, failure, ok, type Handler } from '@/tests/apollo' -import DeleteGymnastButton from './DeleteGymnastButton' +import DeleteGymnastButton from '@/components/gymnasts/DeleteGymnastButton' afterEach(cleanup) diff --git a/src/components/gymnasts/DevLoginButton.test.tsx b/src/tests/components/gymnasts/DevLoginButton.test.tsx similarity index 98% rename from src/components/gymnasts/DevLoginButton.test.tsx rename to src/tests/components/gymnasts/DevLoginButton.test.tsx index 8a45cb3..b17b341 100644 --- a/src/components/gymnasts/DevLoginButton.test.tsx +++ b/src/tests/components/gymnasts/DevLoginButton.test.tsx @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ApiError } from "@/lib/apollo/api"; import { devLogin } from "@/lib/apollo/client"; import { isDevLoginEnabled } from "@/lib/devLogin"; -import DevLoginButton from "./DevLoginButton"; +import DevLoginButton from "@/components/gymnasts/DevLoginButton"; vi.mock("@/lib/apollo/client", () => ({ devLogin: vi.fn() })); diff --git a/src/components/gymnasts/GymnastList.test.tsx b/src/tests/components/gymnasts/GymnastList.test.tsx similarity index 96% rename from src/components/gymnasts/GymnastList.test.tsx rename to src/tests/components/gymnasts/GymnastList.test.tsx index 6d59d1d..c052d5d 100644 --- a/src/components/gymnasts/GymnastList.test.tsx +++ b/src/tests/components/gymnasts/GymnastList.test.tsx @@ -2,7 +2,7 @@ import { ApolloProvider } from "@apollo/client/react"; import { renderToString } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { createTestClient } from "@/tests/apollo"; -import GymnastList from "./GymnastList"; +import GymnastList from "@/components/gymnasts/GymnastList"; // Each row has a delete button, which uses the client for its mutation. const { client } = createTestClient(() => new Promise(() => {})); diff --git a/src/components/navbar/Navbar.test.tsx b/src/tests/components/navbar/Navbar.test.tsx similarity index 99% rename from src/components/navbar/Navbar.test.tsx rename to src/tests/components/navbar/Navbar.test.tsx index 977c92a..d5e9c1b 100644 --- a/src/components/navbar/Navbar.test.tsx +++ b/src/tests/components/navbar/Navbar.test.tsx @@ -4,7 +4,7 @@ import { BrowserRouter, useLocation } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AuthContext, type AuthState } from '@/components/auth/useAuth'; import { logout } from '@/lib/apollo/client'; -import { Navbar } from './Navbar'; +import { Navbar } from '@/components/navbar/Navbar'; // The real functions would call the server. The Navbar only has to call logout. vi.mock('@/lib/apollo/client', () => ({ diff --git a/src/components/skipLink/SkipLink.test.tsx b/src/tests/components/skipLink/SkipLink.test.tsx similarity index 94% rename from src/components/skipLink/SkipLink.test.tsx rename to src/tests/components/skipLink/SkipLink.test.tsx index 6fa0275..9f52d96 100644 --- a/src/components/skipLink/SkipLink.test.tsx +++ b/src/tests/components/skipLink/SkipLink.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { SkipLink } from './SkipLink'; +import { SkipLink } from '@/components/skipLink/SkipLink'; describe('SkipLink', () => { it('links to the main content using the default id', () => { diff --git a/src/components/ui/DifficultyBadge.test.tsx b/src/tests/components/ui/DifficultyBadge.test.tsx similarity index 95% rename from src/components/ui/DifficultyBadge.test.tsx rename to src/tests/components/ui/DifficultyBadge.test.tsx index 978bf15..8b3afe6 100644 --- a/src/components/ui/DifficultyBadge.test.tsx +++ b/src/tests/components/ui/DifficultyBadge.test.tsx @@ -1,6 +1,6 @@ import { renderToString } from 'react-dom/server' import { describe, expect, it } from 'vitest' -import DifficultyBadge from './DifficultyBadge' +import DifficultyBadge from '@/components/ui/DifficultyBadge' /** The classes of the circle (the first span with rounded-full in the badge). */ const circle = (markup: string) => diff --git a/src/components/ui/ElementVariants.test.tsx b/src/tests/components/ui/ElementVariants.test.tsx similarity index 95% rename from src/components/ui/ElementVariants.test.tsx rename to src/tests/components/ui/ElementVariants.test.tsx index 17565aa..59e21c8 100644 --- a/src/components/ui/ElementVariants.test.tsx +++ b/src/tests/components/ui/ElementVariants.test.tsx @@ -1,7 +1,7 @@ import { renderToString } from 'react-dom/server' import { MemoryRouter } from 'react-router-dom' import { describe, expect, it } from 'vitest' -import ElementVariants from './ElementVariants' +import ElementVariants from '@/components/ui/ElementVariants' describe('ElementVariants', () => { it('renders navigable variant cards with their values', () => { diff --git a/src/components/ui/button.test.tsx b/src/tests/components/ui/button.test.tsx similarity index 100% rename from src/components/ui/button.test.tsx rename to src/tests/components/ui/button.test.tsx diff --git a/src/lib/apollo/api.test.ts b/src/tests/lib/apollo/api.test.ts similarity index 98% rename from src/lib/apollo/api.test.ts rename to src/tests/lib/apollo/api.test.ts index 991e87d..e787f0e 100644 --- a/src/lib/apollo/api.test.ts +++ b/src/tests/lib/apollo/api.test.ts @@ -4,7 +4,7 @@ import { REQUEST_TIMEOUT_MS, devLoginRequest, refreshRequest, -} from './api' +} from '@/lib/apollo/api' const answer = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }) diff --git a/src/lib/apollo/authLink.test.ts b/src/tests/lib/apollo/authLink.test.ts similarity index 96% rename from src/lib/apollo/authLink.test.ts rename to src/tests/lib/apollo/authLink.test.ts index 276871a..23b13ff 100644 --- a/src/lib/apollo/authLink.test.ts +++ b/src/tests/lib/apollo/authLink.test.ts @@ -2,9 +2,9 @@ import { ApolloLink } from '@apollo/client/link' import { gql } from '@apollo/client' import { firstValueFrom, of } from 'rxjs' import { type Mock, describe, expect, it, vi } from 'vitest' -import type { AuthPayload } from './api' -import { createAuthLink } from './authLink' -import { createSession, type TokenStorage } from './session' +import type { AuthPayload } from '@/lib/apollo/api' +import { createAuthLink } from '@/lib/apollo/authLink' +import { createSession, type TokenStorage } from '@/lib/apollo/session' const ME = gql` query Me { @@ -128,7 +128,7 @@ describe('the authorization link', () => { }) it('passes on the error, and forgets the login, when the refresh token is refused', async () => { - const { ApiError } = await import('./api') + const { ApiError } = await import('@/lib/apollo/api') const refreshRequest = vi .fn<(refreshToken: string) => Promise>() .mockRejectedValue(new ApiError('UNAUTHENTICATED', 'Log in again.')) diff --git a/src/lib/apollo/cache.test.ts b/src/tests/lib/apollo/cache.test.ts similarity index 99% rename from src/lib/apollo/cache.test.ts rename to src/tests/lib/apollo/cache.test.ts index b979b79..553267f 100644 --- a/src/lib/apollo/cache.test.ts +++ b/src/tests/lib/apollo/cache.test.ts @@ -1,6 +1,6 @@ import { gql } from '@apollo/client' import { describe, expect, it } from 'vitest' -import { createCache } from './cache' +import { createCache } from '@/lib/apollo/cache' const SEARCH = gql` query Search( diff --git a/src/lib/apollo/client.test.ts b/src/tests/lib/apollo/client.test.ts similarity index 96% rename from src/lib/apollo/client.test.ts rename to src/tests/lib/apollo/client.test.ts index 3397190..ca46d29 100644 --- a/src/lib/apollo/client.test.ts +++ b/src/tests/lib/apollo/client.test.ts @@ -3,7 +3,7 @@ // this is the test that the real ones reset the store. import { afterEach, describe, expect, it, vi } from 'vitest' -import { apolloClient, devLogin, logout, session } from './client' +import { apolloClient, devLogin, logout, session } from '@/lib/apollo/client' const payload = { accessToken: 'access-1', diff --git a/src/lib/apollo/errors.test.ts b/src/tests/lib/apollo/errors.test.ts similarity index 96% rename from src/lib/apollo/errors.test.ts rename to src/tests/lib/apollo/errors.test.ts index 8f234af..e8332e3 100644 --- a/src/lib/apollo/errors.test.ts +++ b/src/tests/lib/apollo/errors.test.ts @@ -1,7 +1,7 @@ import { CombinedGraphQLErrors } from '@apollo/client/errors' import { describe, expect, it } from 'vitest' -import { ApiError } from './api' -import { describeError } from './errors' +import { ApiError } from '@/lib/apollo/api' +import { describeError } from '@/lib/apollo/errors' const fromServer = (code: string, message: string) => new CombinedGraphQLErrors({ diff --git a/src/lib/apollo/session.test.ts b/src/tests/lib/apollo/session.test.ts similarity index 98% rename from src/lib/apollo/session.test.ts rename to src/tests/lib/apollo/session.test.ts index 4b75b84..cd77d40 100644 --- a/src/lib/apollo/session.test.ts +++ b/src/tests/lib/apollo/session.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { ApiError, type AuthPayload } from './api' -import { REFRESH_TOKEN_KEY, createSession, type TokenStorage } from './session' +import { ApiError, type AuthPayload } from '@/lib/apollo/api' +import { + REFRESH_TOKEN_KEY, + createSession, + type TokenStorage, +} from '@/lib/apollo/session' const payload = (n: number): AuthPayload => ({ accessToken: `access-${n}`, diff --git a/src/lib/catalog.test.ts b/src/tests/lib/catalog.test.ts similarity index 93% rename from src/lib/catalog.test.ts rename to src/tests/lib/catalog.test.ts index ef24f8e..b689ed6 100644 --- a/src/lib/catalog.test.ts +++ b/src/tests/lib/catalog.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { displayCode, toElementDetail } from './catalog' -import type { CatalogItemResult } from './catalogOperations' +import { displayCode, toElementDetail } from '@/lib/catalog' +import type { CatalogItemResult } from '@/lib/catalogOperations' const shared = { name: null, diff --git a/src/lib/gymnasts.test.ts b/src/tests/lib/gymnasts.test.ts similarity index 98% rename from src/lib/gymnasts.test.ts rename to src/tests/lib/gymnasts.test.ts index 2dacd40..f867d36 100644 --- a/src/lib/gymnasts.test.ts +++ b/src/tests/lib/gymnasts.test.ts @@ -4,7 +4,7 @@ import { getGymnastNameError, limitGymnastNameInput, normalizeGymnastName, -} from "./gymnasts"; +} from "@/lib/gymnasts"; describe("gymnast helpers", () => { it("creates initials from the first and last name", () => { diff --git a/src/lib/insecureConnection.test.ts b/src/tests/lib/insecureConnection.test.ts similarity index 92% rename from src/lib/insecureConnection.test.ts rename to src/tests/lib/insecureConnection.test.ts index 25db9cc..8ed6bdd 100644 --- a/src/lib/insecureConnection.test.ts +++ b/src/tests/lib/insecureConnection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isInsecureConnection } from './insecureConnection' +import { isInsecureConnection } from '@/lib/insecureConnection' describe('isInsecureConnection', () => { it.each([ diff --git a/src/lib/searchOperations.test.ts b/src/tests/lib/searchOperations.test.ts similarity index 98% rename from src/lib/searchOperations.test.ts rename to src/tests/lib/searchOperations.test.ts index c83da47..8524a9e 100644 --- a/src/lib/searchOperations.test.ts +++ b/src/tests/lib/searchOperations.test.ts @@ -4,7 +4,7 @@ import { MAX_COUNTED, toListRow, type SearchNode, -} from './searchOperations' +} from '@/lib/searchOperations' const group = { name: 'Mounts', diff --git a/src/pages/ElementDetailPage.test.tsx b/src/tests/pages/ElementDetailPage.test.tsx similarity index 99% rename from src/pages/ElementDetailPage.test.tsx rename to src/tests/pages/ElementDetailPage.test.tsx index f2c48ca..4a02b38 100644 --- a/src/pages/ElementDetailPage.test.tsx +++ b/src/tests/pages/ElementDetailPage.test.tsx @@ -21,7 +21,7 @@ import { type Handler, type Operation, } from '@/tests/apollo' -import ElementDetailPage from './ElementDetailPage' +import ElementDetailPage from '@/pages/ElementDetailPage' const variant = (id: string) => ({ __typename: 'Element', diff --git a/src/pages/ElementsPage.test.tsx b/src/tests/pages/ElementsPage.test.tsx similarity index 99% rename from src/pages/ElementsPage.test.tsx rename to src/tests/pages/ElementsPage.test.tsx index 15ee94f..c75b6df 100644 --- a/src/pages/ElementsPage.test.tsx +++ b/src/tests/pages/ElementsPage.test.tsx @@ -32,7 +32,7 @@ import { type Operation, } from '@/tests/apollo' import { exampleElementRows, exampleTotalCount } from '@/tests/elementRows' -import ElementsPage from './ElementsPage' +import ElementsPage from '@/pages/ElementsPage' afterEach(cleanup) diff --git a/src/pages/LoginPage.test.tsx b/src/tests/pages/LoginPage.test.tsx similarity index 98% rename from src/pages/LoginPage.test.tsx rename to src/tests/pages/LoginPage.test.tsx index e1d45e9..eb066ae 100644 --- a/src/pages/LoginPage.test.tsx +++ b/src/tests/pages/LoginPage.test.tsx @@ -11,8 +11,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AuthContext, type AuthState } from '@/components/auth/useAuth' import { devLogin } from '@/lib/apollo/client' import { ApiError } from '@/lib/apollo/api' -import { DEMO_EMAIL } from '../../shared/demo' -import LoginPage from './LoginPage' +import { DEMO_EMAIL } from '../../../shared/demo' +import LoginPage from '@/pages/LoginPage' // The real function would call the server. The page only has to call devLogin. vi.mock('@/lib/apollo/client', () => ({ devLogin: vi.fn() })) diff --git a/src/pages/YourGymnastsPage.test.tsx b/src/tests/pages/YourGymnastsPage.test.tsx similarity index 99% rename from src/pages/YourGymnastsPage.test.tsx rename to src/tests/pages/YourGymnastsPage.test.tsx index 76f0ef7..ff19938 100644 --- a/src/pages/YourGymnastsPage.test.tsx +++ b/src/tests/pages/YourGymnastsPage.test.tsx @@ -21,7 +21,7 @@ import { type Answer, type Handler, } from '@/tests/apollo' -import YourGymnastsPage from './YourGymnastsPage' +import YourGymnastsPage from '@/pages/YourGymnastsPage' /** What the server says to `YourGymnasts` for a coach with these gymnasts. */ const coachWith = (names: string[]) => diff --git a/src/styles/gymnastTheme.test.ts b/src/tests/styles/gymnastTheme.test.ts similarity index 97% rename from src/styles/gymnastTheme.test.ts rename to src/tests/styles/gymnastTheme.test.ts index 95f038a..983d389 100644 --- a/src/styles/gymnastTheme.test.ts +++ b/src/tests/styles/gymnastTheme.test.ts @@ -14,7 +14,7 @@ const here = (path: string) => fileURLToPath(new URL(path, import.meta.url)) const read = (path: string) => readFileSync(here(path), 'utf8').replace(/\r\n/g, '\n') -const css = read('./variables.css') +const css = read('../../styles/variables.css') const light = css.slice( css.indexOf(':root'), css.indexOf("[data-theme='dark']"), @@ -205,9 +205,9 @@ describe('the components of the gymnasts and login pages', () => { // The two pages (src/pages/) and the components they use. const files = [ - '../pages/YourGymnastsPage.tsx', - '../pages/LoginPage.tsx', - ...['../components/gymnasts/', '../components/auth/'].flatMap((dir) => + '../../pages/YourGymnastsPage.tsx', + '../../pages/LoginPage.tsx', + ...['../../components/gymnasts/', '../../components/auth/'].flatMap((dir) => readdirSync(here(dir)) .filter((name) => name.endsWith('.tsx') && !name.endsWith('.test.tsx')) .map((name) => dir + name), From 25ab14fb769e718ea4936d51f45aec4c365a4dba Mon Sep 17 00:00:00 2001 From: Anettkva Date: Thu, 5 Nov 2026 13:16:54 +0100 Subject: [PATCH 03/32] docs: explain where the frontend, backend and end-to-end tests are Co-Authored-By: Claude Opus 5.5 --- README.md | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 3607120..1d463c4 100644 --- a/README.md +++ b/README.md @@ -585,20 +585,31 @@ Settings, all optional, as environment variables: ## Project structure -| Folder | Contents | -| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `backend/` | The backend package: TypeScript, Prisma, the seed script, the catalog search, the GraphQL server and the GraphQL schema | -| `data/` | The catalog data extracted from the Code of Points: JSON files and one image per element | -| `docs/` | Project plans, data model, API, accessibility, sustainability, AI usage, and catalog extraction documentation | -| `e2e/` | The end-to-end tests (Playwright): whole flows in a real browser, accessibility checks and the API over HTTP. See "End-to-end tests" | -| `public/` | Static files served as they are, such as the favicon | -| `scripts/extract-cop/` | The Python script that extracts `data/` from the Code of Points PDF | -| `src/` | The React frontend, shared UI components, API operations, Apollo Client, and frontend tests | -| `src/pages/` | One file for each route in `src/App.tsx` (the Elements page, the element page, Your gymnasts, Log in and the placeholder pages) | -| `src/components/` | The parts of the pages, in one folder for each part of the site (`elements/`, `gymnasts/`, `auth/`, `navbar/`) | -| `src/components/ui/` | The shadcn/ui components (written by the shadcn/ui CLI) and a few small shared components (`DifficultyBadge`, `ElementValueBadge`, `ElementVariants`) | -| `src/lib/` | The GraphQL operations, Apollo Client and the logic that is not a component | -| `src/tests/` | The frontend tests (Vitest), in the same folders as the code they test (`pages/`, `components/elements/`, `lib/apollo/` ...), and the shared helpers (`apollo.ts` for the Apollo test client, `setupTests.ts`) | +| Folder | Contents | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `backend/` | The backend package: TypeScript, Prisma, the seed script, the catalog search, the GraphQL server and the GraphQL schema | +| `backend/src/` | The backend code, with its tests next to the code they test (`*.test.ts`). See "Where the tests are" below | +| `data/` | The catalog data extracted from the Code of Points: JSON files and one image per element | +| `docs/` | Project plans, data model, API, accessibility, sustainability, AI usage, and catalog extraction documentation | +| `e2e/` | The end-to-end tests (Playwright): whole flows in a real browser, accessibility checks and the API over HTTP. See "End-to-end tests" | +| `public/` | Static files served as they are, such as the favicon | +| `scripts/extract-cop/` | The Python script that extracts `data/` from the Code of Points PDF | +| `src/` | The React frontend, shared UI components, API operations, Apollo Client, and frontend tests | +| `src/pages/` | One file for each route in `src/App.tsx` (the Elements page, the element page, Your gymnasts, Log in and the placeholder pages) | +| `src/components/` | The parts of the pages, in one folder for each part of the site (`elements/`, `gymnasts/`, `auth/`, `navbar/`) | +| `src/components/ui/` | The shadcn/ui components (written by the shadcn/ui CLI) and a few small shared components (`DifficultyBadge`, `ElementValueBadge`, `ElementVariants`) | +| `src/lib/` | The GraphQL operations, Apollo Client and the logic that is not a component | +| `src/tests/` | The frontend tests (Vitest), in the same folders as the code they test, and the shared test helpers. See "Where the tests are" below | + +### Where the tests are + +The frontend and the backend keep their tests in different places, on purpose: + +- **Frontend: `src/tests/`.** The tests are in the same folders as the code they test (`src/tests/pages/ElementsPage.test.tsx` tests `src/pages/ElementsPage.tsx`), and they import it with the `@/` alias. The folder also has what the tests share: `setupTests.ts`, the Apollo test client (`apollo.ts`) and the example rows (`elementRows.ts`). Keeping the tests apart from the code means that `src/pages/` and `src/components/` only show what the app is made of. +- **Backend: next to the code, in `backend/src/`.** `backend/src/catalog/search.test.ts` tests `backend/src/catalog/search.ts`. The backend is its own package, with Node's module system and relative imports (`'../errors.js'`), and its folders are small and grouped by job (`auth/`, `catalog/`, `seed/`, `server/`), so a test next to its file is easy to find. This is the usual layout in a Node package. The tests that use the database need a migrated and seeded database (see "Database (local setup)"). +- **End-to-end: `e2e/`** (see "End-to-end tests"). + +A new test goes in the same place as the others of its kind: a frontend test in `src/tests/` with the same path as the file it tests, and a backend test next to its file. ## Working in this repository From 97325566033ed5db77f29d4c5357061c66392d2d Mon Sep 17 00:00:00 2001 From: Anettkva Date: Thu, 5 Nov 2026 13:39:35 +0100 Subject: [PATCH 04/32] style: format the whole project with Prettier Closes #133 --- src/App.tsx | 48 ++-- src/components/ProtectedRoute.tsx | 20 +- src/components/gymnasts/DevLoginButton.tsx | 44 +-- src/components/gymnasts/GymnastList.tsx | 19 +- src/components/navbar/Navbar.tsx | 47 ++-- src/components/skipLink/SkipLink.tsx | 6 +- src/lib/devLogin.ts | 2 +- src/lib/gymnasts.ts | 38 +-- src/pages/YourGymnastsPage.tsx | 124 +++++---- src/styles/global.css | 33 ++- src/styles/variables.css | 238 ++++++++-------- src/tests/App.test.tsx | 184 ++++++------ .../gymnasts/DevLoginButton.test.tsx | 194 ++++++------- .../components/gymnasts/GymnastList.test.tsx | 94 ++++--- src/tests/components/navbar/Navbar.test.tsx | 263 +++++++++--------- .../components/skipLink/SkipLink.test.tsx | 48 ++-- src/tests/lib/gymnasts.test.ts | 70 ++--- src/tests/setupTests.ts | 10 +- tsconfig.json | 3 - vite.config.ts | 2 +- 20 files changed, 764 insertions(+), 723 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 5fec1fd..b9bd1be 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,36 +1,36 @@ -import { useEffect, useState } from 'react'; -import { Navigate, Route, Routes } from 'react-router-dom'; -import { Navbar } from './components/navbar/Navbar'; -import { SkipLink } from './components/skipLink/SkipLink'; -import { ProtectedRoute } from './components/ProtectedRoute'; -import { AuthProvider } from './components/auth/AuthProvider'; +import { useEffect, useState } from 'react' +import { Navigate, Route, Routes } from 'react-router-dom' +import { Navbar } from './components/navbar/Navbar' +import { SkipLink } from './components/skipLink/SkipLink' +import { ProtectedRoute } from './components/ProtectedRoute' +import { AuthProvider } from './components/auth/AuthProvider' // One page for each route (src/pages/). The parts of the pages are in src/components/. -import AboutPage from '@/pages/AboutPage'; -import CalculatorPage from '@/pages/CalculatorPage'; -import ElementDetailPage from '@/pages/ElementDetailPage'; -import ElementsPage from '@/pages/ElementsPage'; -import LoginPage from '@/pages/LoginPage'; -import NotFoundPage from '@/pages/NotFoundPage'; -import YourGymnastsPage from '@/pages/YourGymnastsPage'; +import AboutPage from '@/pages/AboutPage' +import CalculatorPage from '@/pages/CalculatorPage' +import ElementDetailPage from '@/pages/ElementDetailPage' +import ElementsPage from '@/pages/ElementsPage' +import LoginPage from '@/pages/LoginPage' +import NotFoundPage from '@/pages/NotFoundPage' +import YourGymnastsPage from '@/pages/YourGymnastsPage' -import './index.css'; -import './styles/variables.css'; -import './styles/global.css'; +import './index.css' +import './styles/variables.css' +import './styles/global.css' export function App() { const [isDarkMode, setIsDarkMode] = useState( () => window.localStorage.getItem('theme') === 'dark', - ); + ) useEffect(() => { - document.documentElement.dataset.theme = isDarkMode ? 'dark' : 'light'; - window.localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); + document.documentElement.dataset.theme = isDarkMode ? 'dark' : 'light' + window.localStorage.setItem('theme', isDarkMode ? 'dark' : 'light') return () => { - delete document.documentElement.dataset.theme; - }; - }, [isDarkMode]); + delete document.documentElement.dataset.theme + } + }, [isDarkMode]) return ( @@ -59,7 +59,7 @@ export function App() { - ); + ) } -export default App; \ No newline at end of file +export default App diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index c4b2876..94f735d 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -1,10 +1,10 @@ -import { Navigate, Outlet, useLocation } from 'react-router-dom'; -import { useAuth } from '@/components/auth/useAuth'; -import PageTitle from '@/components/PageTitle'; +import { Navigate, Outlet, useLocation } from 'react-router-dom' +import { useAuth } from '@/components/auth/useAuth' +import PageTitle from '@/components/PageTitle' interface ProtectedRouteProps { /** Where an anonymous visitor is sent. The page they wanted is kept, so the login can send them back. */ - redirectPath?: string; + redirectPath?: string } /** @@ -16,8 +16,8 @@ interface ProtectedRouteProps { export const ProtectedRoute = ({ redirectPath = '/login', }: ProtectedRouteProps) => { - const { status } = useAuth(); - const location = useLocation(); + const { status } = useAuth() + const location = useLocation() if (status === 'loading') { return ( @@ -25,12 +25,12 @@ export const ProtectedRoute = ({ Checking your login

Checking your login…

- ); + ) } if (status === 'anonymous') { - return ; + return } - return ; -}; + return +} diff --git a/src/components/gymnasts/DevLoginButton.tsx b/src/components/gymnasts/DevLoginButton.tsx index 9efb942..50ab423 100644 --- a/src/components/gymnasts/DevLoginButton.tsx +++ b/src/components/gymnasts/DevLoginButton.tsx @@ -1,8 +1,8 @@ -import { useState, type FormEvent } from "react"; -import { Button } from "@/components/ui/button"; -import { PILL } from "@/components/ui/pill"; -import { devLogin } from "@/lib/apollo/client"; -import { describeError } from "@/lib/apollo/errors"; +import { useState, type FormEvent } from 'react' +import { Button } from '@/components/ui/button' +import { PILL } from '@/components/ui/pill' +import { devLogin } from '@/lib/apollo/client' +import { describeError } from '@/lib/apollo/errors' /** * Logs in as the coach with the e-mail address that the person types, without @@ -14,24 +14,24 @@ import { describeError } from "@/lib/apollo/errors"; export default function DevLoginButton({ onLoggedIn, }: { - onLoggedIn: () => Promise; + onLoggedIn: () => Promise }) { - const [email, setEmail] = useState(""); - const [isLoggingIn, setIsLoggingIn] = useState(false); - const [error, setError] = useState(""); + const [email, setEmail] = useState('') + const [isLoggingIn, setIsLoggingIn] = useState(false) + const [error, setError] = useState('') async function logIn(event: FormEvent) { - event.preventDefault(); - if (isLoggingIn) return; - setIsLoggingIn(true); - setError(""); + event.preventDefault() + if (isLoggingIn) return + setIsLoggingIn(true) + setError('') try { - await devLogin(email); - await onLoggedIn(); + await devLogin(email) + await onLoggedIn() } catch (problem) { - setError(describeError(problem).message); + setError(describeError(problem).message) } finally { - setIsLoggingIn(false); + setIsLoggingIn(false) } } @@ -58,11 +58,11 @@ export default function DevLoginButton({ required value={email} onChange={(event) => { - setEmail(event.target.value); - setError(""); + setEmail(event.target.value) + setError('') }} aria-invalid={Boolean(error)} - aria-describedby={error ? "dev-login-error" : undefined} + aria-describedby={error ? 'dev-login-error' : undefined} className="h-11 w-full rounded-md border border-(--field-border) bg-(--field-bg) px-3 text-(--text-color) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--text-color)" /> {error && (

)} - ); + ) } diff --git a/src/components/gymnasts/GymnastList.tsx b/src/components/gymnasts/GymnastList.tsx index 20c5f8f..86c95da 100644 --- a/src/components/gymnasts/GymnastList.tsx +++ b/src/components/gymnasts/GymnastList.tsx @@ -1,20 +1,17 @@ -import GymnastRow from "@/components/gymnasts/GymnastRow"; -import type { GymnastSummary } from "@/lib/gymnasts"; +import GymnastRow from '@/components/gymnasts/GymnastRow' +import type { GymnastSummary } from '@/lib/gymnasts' export default function GymnastList({ gymnasts, onDeleted, onFailed, }: { - gymnasts: GymnastSummary[]; - onDeleted: (name: string) => void; - onFailed: (message: string) => void; + gymnasts: GymnastSummary[] + onDeleted: (name: string) => void + onFailed: (message: string) => void }) { return ( -

+

Gymnast list

@@ -35,5 +32,5 @@ export default function GymnastList({ )}
- ); -} \ No newline at end of file + ) +} diff --git a/src/components/navbar/Navbar.tsx b/src/components/navbar/Navbar.tsx index ffe806e..da1a20e 100644 --- a/src/components/navbar/Navbar.tsx +++ b/src/components/navbar/Navbar.tsx @@ -1,41 +1,41 @@ -import React, { useState } from 'react'; -import { Menu, X } from 'lucide-react'; -import { Link, NavLink, useNavigate } from 'react-router-dom'; -import { useAuth } from '@/components/auth/useAuth'; -import { logout } from '@/lib/apollo/client'; +import React, { useState } from 'react' +import { Menu, X } from 'lucide-react' +import { Link, NavLink, useNavigate } from 'react-router-dom' +import { useAuth } from '@/components/auth/useAuth' +import { logout } from '@/lib/apollo/client' interface NavbarProps { - isDarkMode: boolean; - onToggleDarkMode: () => void; + isDarkMode: boolean + onToggleDarkMode: () => void } export const Navbar: React.FC = ({ isDarkMode, onToggleDarkMode, }) => { - const [menuOpen, setMenuOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false) // The same answer as the pages read: who is logged in, as the server says it. - const { status } = useAuth(); - const isLoggedIn = status === 'authenticated'; - const navigate = useNavigate(); + const { status } = useAuth() + const isLoggedIn = status === 'authenticated' + const navigate = useNavigate() async function logOut() { - setMenuOpen(false); + setMenuOpen(false) // Leave a page that is for coaches first, so the visitor is not sent to the // login page by the page they are leaving. - void navigate('/elements'); - await logout(); + void navigate('/elements') + await logout() } const focusRing = - 'focus-visible:outline-2 focus-visible:outline-(--text-color) focus-visible:outline-offset-4'; + 'focus-visible:outline-2 focus-visible:outline-(--text-color) focus-visible:outline-offset-4' const authButton = - 'inline-flex min-h-6 min-w-6 shrink items-center whitespace-nowrap rounded-corner border border-border-color bg-transparent px-[clamp(11px,12px,13px)] py-xs font-text text-[clamp(var(--font-size-row-title),1.2vw,1rem)] text-text-color'; + 'inline-flex min-h-6 min-w-6 shrink items-center whitespace-nowrap rounded-corner border border-border-color bg-transparent px-[clamp(11px,12px,13px)] py-xs font-text text-[clamp(var(--font-size-row-title),1.2vw,1rem)] text-text-color' const navLinks = [ { path: '/elements', label: 'Search' }, { path: '/calculator', label: 'Calculator' }, { path: '/about', label: 'About' }, ...(isLoggedIn ? [{ path: '/gymnasts', label: 'Gymnasts' }] : []), - ]; + ] return (
@@ -84,9 +84,7 @@ export const Navbar: React.FC = ({ aria-hidden="true" className="flex h-[20px] w-[20px] items-center justify-center rounded-full bg-primary-color text-xs transition-transform duration-200 ease-in-out" style={{ - transform: isDarkMode - ? 'translateX(22px)' - : 'translateX(0)', + transform: isDarkMode ? 'translateX(22px)' : 'translateX(0)', }} > {isDarkMode ? '🌙' : '☀️'} @@ -104,7 +102,10 @@ export const Navbar: React.FC = ({ Log out ) : ( - + Log in ))} @@ -125,5 +126,5 @@ export const Navbar: React.FC = ({
- ); -}; \ No newline at end of file + ) +} diff --git a/src/components/skipLink/SkipLink.tsx b/src/components/skipLink/SkipLink.tsx index 9fb8807..89eebb5 100644 --- a/src/components/skipLink/SkipLink.tsx +++ b/src/components/skipLink/SkipLink.tsx @@ -1,5 +1,5 @@ interface SkipLinkProps { - mainId?: string; + mainId?: string } export const SkipLink = ({ mainId = 'main-content' }: SkipLinkProps) => { @@ -10,5 +10,5 @@ export const SkipLink = ({ mainId = 'main-content' }: SkipLinkProps) => { > Skip to main content - ); -}; \ No newline at end of file + ) +} diff --git a/src/lib/devLogin.ts b/src/lib/devLogin.ts index 08df1a1..e6ad724 100644 --- a/src/lib/devLogin.ts +++ b/src/lib/devLogin.ts @@ -3,4 +3,4 @@ * build has no development login (see "Login for development" in docs/API.md). */ export const isDevLoginEnabled = (): boolean => - import.meta.env.VITE_DEV_LOGIN === "true"; + import.meta.env.VITE_DEV_LOGIN === 'true' diff --git a/src/lib/gymnasts.ts b/src/lib/gymnasts.ts index 91072b2..aa625c6 100644 --- a/src/lib/gymnasts.ts +++ b/src/lib/gymnasts.ts @@ -3,20 +3,20 @@ import { nameProblem, normalizeName, type NameProblem, -} from "../../shared/names"; +} from '../../shared/names' export type GymnastSummary = { - id: string; - name: string; -}; + id: string + name: string +} export function getGymnastInitials(name: string): string { - const words = name.trim().split(/\s+/).filter(Boolean); + const words = name.trim().split(/\s+/).filter(Boolean) - if (words.length === 0) return "?"; - if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); + 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(); + return `${words[0][0]}${words.at(-1)?.[0] ?? ''}`.toUpperCase() } /** @@ -25,10 +25,10 @@ export function getGymnastInitials(name: string): string { * emoji is two. Twice the limit never stops a valid name; the exact check is * getGymnastNameError. */ -export const GYMNAST_NAME_INPUT_MAX_LENGTH = MAX_NAME_LENGTH * 2; +export const GYMNAST_NAME_INPUT_MAX_LENGTH = MAX_NAME_LENGTH * 2 /** The most characters the form lets a gymnast name have. It is the rule of the server. */ -export const MAX_GYMNAST_NAME_LENGTH = MAX_NAME_LENGTH; +export const MAX_GYMNAST_NAME_LENGTH = MAX_NAME_LENGTH /** * What is typed or pasted in the name field, cut to the most characters a name @@ -36,25 +36,25 @@ export const MAX_GYMNAST_NAME_LENGTH = MAX_NAME_LENGTH; * (code points) like the server does, so an emoji is one and 70 of them fit. */ export function limitGymnastNameInput(value: string): string { - const characters = [...value]; + const characters = [...value] return characters.length > MAX_GYMNAST_NAME_LENGTH - ? characters.slice(0, MAX_GYMNAST_NAME_LENGTH).join("") - : value; + ? characters.slice(0, MAX_GYMNAST_NAME_LENGTH).join('') + : value } /** The name as the server stores it (the rule is in shared/names.ts). */ export function normalizeGymnastName(name: string): string { - return normalizeName(name); + return normalizeName(name) } const NAME_MESSAGES: Record = { - empty: "Name is required", - "too-long": `Names must be ${MAX_NAME_LENGTH} characters or fewer.`, -}; + empty: 'Name is required', + 'too-long': `Names must be ${MAX_NAME_LENGTH} characters or fewer.`, +} export function getGymnastNameError(name: string): string | undefined { - const problem = nameProblem(name); + const problem = nameProblem(name) - return problem ? NAME_MESSAGES[problem] : undefined; + return problem ? NAME_MESSAGES[problem] : undefined } diff --git a/src/pages/YourGymnastsPage.tsx b/src/pages/YourGymnastsPage.tsx index c84213c..8f9168a 100644 --- a/src/pages/YourGymnastsPage.tsx +++ b/src/pages/YourGymnastsPage.tsx @@ -1,102 +1,102 @@ -import { useEffect, useRef, useState } from "react"; -import { useMutation, useQuery } from "@apollo/client/react"; -import { Link } from "react-router-dom"; -import { Check, Plus, X } from "lucide-react"; -import { useAuth } from "@/components/auth/useAuth"; -import PageTitle from "@/components/PageTitle"; -import { Button } from "@/components/ui/button"; -import { PILL } from "@/components/ui/pill"; -import GymnastList from "@/components/gymnasts/GymnastList"; -import { describeError } from "@/lib/apollo/errors"; +import { useEffect, useRef, useState } from 'react' +import { useMutation, useQuery } from '@apollo/client/react' +import { Link } from 'react-router-dom' +import { Check, Plus, X } from 'lucide-react' +import { useAuth } from '@/components/auth/useAuth' +import PageTitle from '@/components/PageTitle' +import { Button } from '@/components/ui/button' +import { PILL } from '@/components/ui/pill' +import GymnastList from '@/components/gymnasts/GymnastList' +import { describeError } from '@/lib/apollo/errors' import { CREATE_GYMNAST_MUTATION, GYMNASTS_QUERY, -} from "@/lib/gymnastOperations"; +} from '@/lib/gymnastOperations' import { GYMNAST_NAME_INPUT_MAX_LENGTH, getGymnastNameError, limitGymnastNameInput, normalizeGymnastName, -} from "@/lib/gymnasts"; +} from '@/lib/gymnasts' export default function YourGymnastsPage() { - const { data, error: loadError, loading, refetch } = useQuery(GYMNASTS_QUERY); + const { data, error: loadError, loading, refetch } = useQuery(GYMNASTS_QUERY) // The list is fetched again after a new gymnast: the API sorts the gymnasts // by name, so the cache cannot know where the new one belongs. const [createGymnast, { loading: isSaving }] = useMutation( CREATE_GYMNAST_MUTATION, { refetchQueries: [GYMNASTS_QUERY] }, - ); - 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); + ) + 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) // A second submit before the first has an answer (Enter twice) must not // create a second gymnast. The state of the mutation changes one render too late. - const isSubmitting = useRef(false); + const isSubmitting = useRef(false) // The same answer as the menu reads. Nobody logged in, or a session that has // ended, is not the same as a coach with no gymnasts, so it must not look like // an empty list. (A visitor who is not logged in does not get here from the // menu: ProtectedRoute sends them to the login page. This is for a session // that ends while the page is open.) - const isLoggedOut = useAuth().status === "anonymous"; - const [deleteError, setDeleteError] = useState(""); - const loadProblem = loadError ? describeError(loadError) : null; + const isLoggedOut = useAuth().status === 'anonymous' + const [deleteError, setDeleteError] = useState('') + const loadProblem = loadError ? describeError(loadError) : null useEffect(() => { if (isFormOpen) { - nameInput.current?.focus(); - wasFormOpen.current = true; + nameInput.current?.focus() + wasFormOpen.current = true } else if (wasFormOpen.current) { - addButton.current?.focus(); - wasFormOpen.current = false; + addButton.current?.focus() + wasFormOpen.current = false } - }, [isFormOpen]); + }, [isFormOpen]) // The button of the deleted gymnast is gone, so the focus goes to the button // that adds one, and the visitor is told what happened. function onDeleted(deletedName: string) { - setDeleteError(""); - setAnnouncement(`${deletedName} deleted.`); - addButton.current?.focus(); + setDeleteError('') + setAnnouncement(`${deletedName} deleted.`) + addButton.current?.focus() } function closeForm() { - setIsFormOpen(false); - setName(""); - setError(""); + setIsFormOpen(false) + setName('') + setError('') } async function addGymnast(event: React.FormEvent) { - event.preventDefault(); - if (isSubmitting.current) return; - const normalizedName = normalizeGymnastName(name); - const nameError = getGymnastNameError(name); + event.preventDefault() + if (isSubmitting.current) return + const normalizedName = normalizeGymnastName(name) + const nameError = getGymnastNameError(name) if (nameError) { - setError(nameError); - nameInput.current?.focus(); - return; + setError(nameError) + nameInput.current?.focus() + return } - isSubmitting.current = true; + isSubmitting.current = true try { const result = await createGymnast({ variables: { name: normalizedName }, - }); - const addedName = result.data?.createGymnast.name ?? normalizedName; - setAnnouncement(`${addedName} added to your gymnasts.`); - closeForm(); + }) + const addedName = result.data?.createGymnast.name ?? normalizedName + setAnnouncement(`${addedName} added to your gymnasts.`) + closeForm() } catch (problem) { // The form stays open with what the user wrote, so it can be corrected. - setError(describeError(problem).message); - nameInput.current?.focus(); + setError(describeError(problem).message) + nameInput.current?.focus() } finally { - isSubmitting.current = false; + isSubmitting.current = false } } @@ -115,9 +115,9 @@ export default function YourGymnastsPage() { type="button" className={PILL} onClick={() => { - setError(""); - setAnnouncement(""); - setIsFormOpen(true); + setError('') + setAnnouncement('') + setIsFormOpen(true) }} >
- ); + ) } diff --git a/src/styles/global.css b/src/styles/global.css index 319c665..7feca8b 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -22,7 +22,11 @@ /* The text in the circle of the difficulty badge has the color of the text in "More variants" (--pill-text), in light and dark mode, so they match. Only the color changes; the badge itself is not changed. */ -#root .elements-page :is(td, dd) .inline-flex.shrink-0.flex-col > span:first-child { +#root + .elements-page + :is(td, dd) + .inline-flex.shrink-0.flex-col + > span:first-child { color: var(--pill-text); } @@ -30,13 +34,24 @@ be read on the dark table. Only that text changes, and only in dark mode (the circle and its letter keep their colors, and the badge is not changed). The selector has shrink-0, which is on the difficulty badge. */ -[data-theme='dark'] #root .elements-page :is(td, dd) .inline-flex.shrink-0.flex-col > span + span { +[data-theme='dark'] + #root + .elements-page + :is(td, dd) + .inline-flex.shrink-0.flex-col + > span + + span { color: var(--text-color); } /* The circle of the badge gets the light color of "More variants" in dark mode, with dark text, so it stands out from the dark rows. */ -[data-theme='dark'] #root .elements-page :is(td, dd) .inline-flex.shrink-0.flex-col > span:first-child { +[data-theme='dark'] + #root + .elements-page + :is(td, dd) + .inline-flex.shrink-0.flex-col + > span:first-child { background-color: var(--pill-bg); color: var(--pill-text); } @@ -52,8 +67,10 @@ margin-inline-end: -0.5rem; cursor: pointer; background-color: var(--text-color); - -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E") center / 1rem 1rem no-repeat; - mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E") center / 1rem 1rem no-repeat; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E") + center / 1rem 1rem no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E") + center / 1rem 1rem no-repeat; } #root .elements-page h1 { @@ -67,7 +84,7 @@ } #root .your-gymnasts-page h2 { - font-size: clamp(1.375rem, 4vw, 1.75rem); - line-height: 1.2; - margin: 0; + font-size: clamp(1.375rem, 4vw, 1.75rem); + line-height: 1.2; + margin: 0; } diff --git a/src/styles/variables.css b/src/styles/variables.css index aaf48c4..56f2df2 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -1,149 +1,153 @@ :root { - /* Color palette */ - --primary-color: #F0EDE7; - --secondary-color: #E0DFE4; - --accent-color: #C0D2F6; - --background-color: #ecf0f1; - --background-color-element: #F8F7F3; - --border-color: #EAE0CF; + /* Color palette */ + --primary-color: #f0ede7; + --secondary-color: #e0dfe4; + --accent-color: #c0d2f6; + --background-color: #ecf0f1; + --background-color-element: #f8f7f3; + --border-color: #eae0cf; - /* Text colors */ - --text-color: #101844; - --text-color-dark: #090F2F; - --text-color-light: #EAE0CF; + /* Text colors */ + --text-color: #101844; + --text-color-dark: #090f2f; + --text-color-light: #eae0cf; - /* Font settings */ - --font-family-title: 'Faustina', serif; - --font-family-text: 'Inter'; - --font-size-page-title: 42px; - --font-size-element-title: 19px; - --font-size-row-title: 15px; - --font-size-subtext: 13px; + /* Font settings */ + --font-family-title: 'Faustina', serif; + --font-family-text: 'Inter'; + --font-size-page-title: 42px; + --font-size-element-title: 19px; + --font-size-row-title: 15px; + --font-size-subtext: 13px; - /* Box settings */ - --corner-radius: 100px; - --box-color: #F0EDE7; - --box-stroke: #888394; - --box-shadow: 0 2px 5px rgba(3, 2, 2, 0.1); + /* Box settings */ + --corner-radius: 100px; + --box-color: #f0ede7; + --box-stroke: #888394; + --box-shadow: 0 2px 5px rgba(3, 2, 2, 0.1); - /* The Elements page. Each color has a light and a dark value (the dark + /* The Elements page. Each color has a light and a dark value (the dark ones are in the dark block below), so the page follows the theme. */ - --page-bg: var(--secondary-color); - --surface: var(--background-color-element); - /* The rows have no color change on hover in light mode (the design has none), and + --page-bg: var(--secondary-color); + --surface: var(--background-color-element); + /* The rows have no color change on hover in light mode (the design has none), and in dark mode they do. The buttons and the search field change in both. */ - --surface-hover: var(--surface); - --row-hover-amount: 0%; - /* Every other row in the results table: a light tint of the blue accent color */ - --table-row-alt: color-mix(in srgb, var(--background-color-element), var(--accent-color) 40%); - --table-head-bg: var(--text-color); - --table-head-text: var(--background-color-element); - --pill-bg: var(--text-color); - --pill-text: var(--background-color-element); - /* The buttons: light in the design. In dark mode they use the light color of + --surface-hover: var(--surface); + --row-hover-amount: 0%; + /* Every other row in the results table: a light tint of the blue accent color */ + --table-row-alt: color-mix( + in srgb, + var(--background-color-element), + var(--accent-color) 40% + ); + --table-head-bg: var(--text-color); + --table-head-text: var(--background-color-element); + --pill-bg: var(--text-color); + --pill-text: var(--background-color-element); + /* The buttons: light in the design. In dark mode they use the light color of "More variants", so they stand out from the dark page. */ - --button-bg: var(--surface); - --button-hover-bg: var(--primary-color); - --button-text: var(--text-color); - /* The search field: as the design in light mode. In dark mode it is see-through + --button-bg: var(--surface); + --button-hover-bg: var(--primary-color); + --button-text: var(--text-color); + /* The search field: as the design in light mode. In dark mode it is see-through with a tan line, so it can be seen without a solid color. */ - --search-bg: var(--surface); - --search-hover-bg: var(--primary-color); - --search-border: transparent; + --search-bg: var(--surface); + --search-hover-bg: var(--primary-color); + --search-border: transparent; - /* The gymnasts and the login pages. Each has a light and a dark value, so + /* The gymnasts and the login pages. Each has a light and a dark value, so these pages follow the theme like the Elements page does. */ - --avatar-bg: var(--accent-color); - --avatar-text: var(--text-color-dark); - /* Text fields: a solid field in light mode, see-through with a tan line in dark mode. */ - --field-bg: var(--background-color-element); - --field-border: var(--box-stroke); - /* Delete: red on the card (6.1:1 in light mode, 5.8:1 in dark mode), and the text + --avatar-bg: var(--accent-color); + --avatar-text: var(--text-color-dark); + /* Text fields: a solid field in light mode, see-through with a tan line in dark mode. */ + --field-bg: var(--background-color-element); + --field-border: var(--box-stroke); + /* Delete: red on the card (6.1:1 in light mode, 5.8:1 in dark mode), and the text on a red button. */ - --danger: #B42318; - --danger-contrast: #FFFFFF; - /* The save tick: green with a white tick (5.3:1), and 5.0:1 against the card, so + --danger: #b42318; + --danger-contrast: #ffffff; + /* The save tick: green with a white tick (5.3:1), and 5.0:1 against the card, so the button can be seen. The card color alone would not be enough. */ - --success-bg: #1E7B34; - --success-contrast: #FFFFFF; - /* The "No, keep it" button of the delete dialog. Its fill is close to the card + --success-bg: #1e7b34; + --success-contrast: #ffffff; + /* The "No, keep it" button of the delete dialog. Its fill is close to the card (1.2:1), so it has an edge of 2px that is at least 3:1 on the card (3.4:1). */ - --secondary-button-bg: var(--secondary-color); - --secondary-button-text: var(--text-color); - --secondary-button-border: var(--box-stroke); + --secondary-button-bg: var(--secondary-color); + --secondary-button-text: var(--text-color); + --secondary-button-border: var(--box-stroke); - /* D-value badge */ - --badge-d-value-bg: #BDE981; - --badge-d-value-text: var(--text-color-dark); - --border-color: var(--text-color); + /* D-value badge */ + --badge-d-value-bg: #bde981; + --badge-d-value-text: var(--text-color-dark); + --border-color: var(--text-color); - /* This part was generate with the help of Gemini 2.5 Flash using the prompt: + /* This part was generate with the help of Gemini 2.5 Flash using the prompt: "Create spacing variables and a dark mode colro pallette based on my previous design" */ - /* Space settings */ - --space-xs: 0.25rem; /* 4px - micro gaps (title to subtext) */ - --space-sm: 0.5rem; /* 8px - small padding (chips, badges) */ - --space-md: 1rem; /* 16px - default padding (inputs, row heights) */ - --space-lg: 1.5rem; /* 24px - component gaps (between search & filters) */ - --space-xl: 2.5rem; /* 40px - page section margins & outer padding */ + /* Space settings */ + --space-xs: 0.25rem; /* 4px - micro gaps (title to subtext) */ + --space-sm: 0.5rem; /* 8px - small padding (chips, badges) */ + --space-md: 1rem; /* 16px - default padding (inputs, row heights) */ + --space-lg: 1.5rem; /* 24px - component gaps (between search & filters) */ + --space-xl: 2.5rem; /* 40px - page section margins & outer padding */ } /* Dark mode colors */ [data-theme='dark'] { - /* Main Layout Colors */ - --primary-color: #0A1A3C; /* Deep blue for primary elements */ - --background-color: #090F2F; /* Dark blue background */ - --bg: var(--background-color); - --text: var(--text-color); - --header-background: #101844; /* Slightly lighter blue for nav */ - --element-background: #18225C; /* Inputs, dropdowns, table background */ - --surface-selected: #253788; /* Row selection tint */ + /* Main Layout Colors */ + --primary-color: #0a1a3c; /* Deep blue for primary elements */ + --background-color: #090f2f; /* Dark blue background */ + --bg: var(--background-color); + --text: var(--text-color); + --header-background: #101844; /* Slightly lighter blue for nav */ + --element-background: #18225c; /* Inputs, dropdowns, table background */ + --surface-selected: #253788; /* Row selection tint */ - /* Text Colors */ - --text-color: #EAE0CF; /* Headings and body text */ - --text-color-primary: #EAE0CF; /* Warm light text */ - --text-color-secondary: #A0AEC0; /* Muted text for labels */ - --text-color-on-dark: #090F2F; /* Inverted text for light buttons */ + /* Text Colors */ + --text-color: #eae0cf; /* Headings and body text */ + --text-color-primary: #eae0cf; /* Warm light text */ + --text-color-secondary: #a0aec0; /* Muted text for labels */ + --text-color-on-dark: #090f2f; /* Inverted text for light buttons */ - /* UI Components & Borders */ - --border-color: #3B426B; - --border-subtle: #2A3363; - --badge-d-value-bg: #82C43C; /* Slightly deeper green for contrast */ - --badge-d-value-text: #090F2F; + /* UI Components & Borders */ + --border-color: #3b426b; + --border-subtle: #2a3363; + --badge-d-value-bg: #82c43c; /* Slightly deeper green for contrast */ + --badge-d-value-text: #090f2f; - /* The Elements page, from the color inspiration (inspo.png): a gray slate + /* The Elements page, from the color inspiration (inspo.png): a gray slate backdrop, darker slate rows, the navbar color in the table heading and a warm tan for the "More variants" mark. */ - --page-bg: #40444F; - --surface: #2D3343; - --surface-hover: #434A60; - --row-hover-amount: 10%; - --table-row-alt: #373E52; - --table-head-bg: var(--primary-color); - --table-head-text: var(--text-color); - --pill-bg: #C19F83; - --pill-text: var(--primary-color); - --button-bg: var(--pill-bg); - --button-hover-bg: color-mix(in srgb, var(--pill-bg), white 25%); - --button-text: var(--pill-text); - --search-bg: color-mix(in srgb, var(--text-color) 10%, transparent); - --search-hover-bg: color-mix(in srgb, var(--text-color) 16%, transparent); - --search-border: var(--pill-bg); - --avatar-bg: var(--pill-bg); - --avatar-text: var(--primary-color); - --field-bg: color-mix(in srgb, var(--text-color) 10%, transparent); - --field-border: var(--pill-bg); - --danger: #FF8F8F; - --danger-contrast: #090F2F; - /* A mint green for the save tick in dark mode: 7.1:1 on the card, and the dark tick + --page-bg: #40444f; + --surface: #2d3343; + --surface-hover: #434a60; + --row-hover-amount: 10%; + --table-row-alt: #373e52; + --table-head-bg: var(--primary-color); + --table-head-text: var(--text-color); + --pill-bg: #c19f83; + --pill-text: var(--primary-color); + --button-bg: var(--pill-bg); + --button-hover-bg: color-mix(in srgb, var(--pill-bg), white 25%); + --button-text: var(--pill-text); + --search-bg: color-mix(in srgb, var(--text-color) 10%, transparent); + --search-hover-bg: color-mix(in srgb, var(--text-color) 16%, transparent); + --search-border: var(--pill-bg); + --avatar-bg: var(--pill-bg); + --avatar-text: var(--primary-color); + --field-bg: color-mix(in srgb, var(--text-color) 10%, transparent); + --field-border: var(--pill-bg); + --danger: #ff8f8f; + --danger-contrast: #090f2f; + /* A mint green for the save tick in dark mode: 7.1:1 on the card, and the dark tick on it is 10.6:1. */ - --success-bg: #01DF80; - --success-contrast: #090F2F; - /* White text (7.1:1) on a dark fill. White text and a fill that is 3:1 on the + --success-bg: #01df80; + --success-contrast: #090f2f; + /* White text (7.1:1) on a dark fill. White text and a fill that is 3:1 on the card cannot both be had (the fill would have to be both darker and lighter than a luminance of 0.19), so the edge of the control is a blue-grey line of 2px, 3.6:1 on the card, as the rule for the edge of a control asks (WCAG 1.4.11). */ - --secondary-button-bg: #4F5778; - --secondary-button-text: #FFFFFF; - --secondary-button-border: #8088A6; /* a blue-grey, 3.6:1 on the card: it does not draw the eye first */ + --secondary-button-bg: #4f5778; + --secondary-button-text: #ffffff; + --secondary-button-border: #8088a6; /* a blue-grey, 3.6:1 on the card: it does not draw the eye first */ } diff --git a/src/tests/App.test.tsx b/src/tests/App.test.tsx index fe11b89..cd9fbd2 100644 --- a/src/tests/App.test.tsx +++ b/src/tests/App.test.tsx @@ -1,10 +1,10 @@ // @vitest-environment jsdom -import { ApolloProvider } from '@apollo/client/react'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; -import { BrowserRouter } from 'react-router-dom'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createTestClient, ok, type Handler } from '@/tests/apollo'; -import App from '@/App'; +import { ApolloProvider } from '@apollo/client/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { BrowserRouter } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createTestClient, ok, type Handler } from '@/tests/apollo' +import App from '@/App' /** * The app with the router and the Apollo client that main.tsx gives it. The real @@ -12,124 +12,132 @@ import App from '@/App'; * answers, so the pages stay in their loading state. */ const renderApp = (handler: Handler = () => new Promise(() => {})) => { - const { client } = createTestClient(handler); + const { client } = createTestClient(handler) return render( , - ); -}; + ) +} beforeEach(() => { - window.localStorage.clear(); - window.history.replaceState({}, '', import.meta.env.BASE_URL); -}); + window.localStorage.clear() + window.history.replaceState({}, '', import.meta.env.BASE_URL) +}) afterEach(() => { - delete document.documentElement.dataset.theme; - window.localStorage.clear(); - window.history.replaceState({}, '', import.meta.env.BASE_URL); -}); + delete document.documentElement.dataset.theme + window.localStorage.clear() + window.history.replaceState({}, '', import.meta.env.BASE_URL) +}) describe('App', () => { it('applies and toggles the selected theme on the document', () => { - renderApp(); + renderApp() - expect(document.documentElement.getAttribute('data-theme')).toBe('light'); - expect(window.localStorage.getItem('theme')).toBe('light'); + expect(document.documentElement.getAttribute('data-theme')).toBe('light') + expect(window.localStorage.getItem('theme')).toBe('light') - const themeToggle = screen.getByRole('switch', { name: /toggle dark theme/i }); - fireEvent.click(themeToggle); + const themeToggle = screen.getByRole('switch', { + name: /toggle dark theme/i, + }) + fireEvent.click(themeToggle) - expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); - expect(window.localStorage.getItem('theme')).toBe('dark'); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark') + expect(window.localStorage.getItem('theme')).toBe('dark') - fireEvent.click(themeToggle); + fireEvent.click(themeToggle) - expect(document.documentElement.getAttribute('data-theme')).toBe('light'); - expect(window.localStorage.getItem('theme')).toBe('light'); - }); + expect(document.documentElement.getAttribute('data-theme')).toBe('light') + expect(window.localStorage.getItem('theme')).toBe('light') + }) it('restores the previously saved theme on page load', () => { - window.localStorage.setItem('theme', 'dark'); + window.localStorage.setItem('theme', 'dark') - renderApp(); + renderApp() - expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark') expect( screen .getByRole('switch', { name: /toggle dark theme/i }) .getAttribute('aria-checked'), - ).toBe('true'); - }); + ).toBe('true') + }) it('generates navigation URLs beneath the configured base path', () => { - renderApp(); + renderApp() - expect(screen.getByRole('link', { name: 'Calculator' }).getAttribute('href')).toBe( - `${import.meta.env.BASE_URL}calculator`, - ); - }); + expect( + screen.getByRole('link', { name: 'Calculator' }).getAttribute('href'), + ).toBe(`${import.meta.env.BASE_URL}calculator`) + }) it('renders routed content inside a single main landmark', () => { - renderApp(); + renderApp() - expect(screen.getAllByRole('main')).toHaveLength(1); + expect(screen.getAllByRole('main')).toHaveLength(1) - fireEvent.click(screen.getByRole('link', { name: 'About' })); + fireEvent.click(screen.getByRole('link', { name: 'About' })) - expect(screen.getByRole('heading', { name: 'About' })).toBeTruthy(); - expect(screen.getAllByRole('main')).toHaveLength(1); - }); + expect(screen.getByRole('heading', { name: 'About' })).toBeTruthy() + expect(screen.getAllByRole('main')).toHaveLength(1) + }) it('gives every page its own title, so the tab and the history say where you are', () => { - renderApp(); - expect(document.title).toBe('Elements – WAG CoP Planner'); + renderApp() + expect(document.title).toBe('Elements – WAG CoP Planner') - fireEvent.click(screen.getByRole('link', { name: 'About' })); - expect(document.title).toBe('About – WAG CoP Planner'); + fireEvent.click(screen.getByRole('link', { name: 'About' })) + expect(document.title).toBe('About – WAG CoP Planner') - fireEvent.click(screen.getByRole('link', { name: 'Calculator' })); - expect(document.title).toBe('Calculator – WAG CoP Planner'); + fireEvent.click(screen.getByRole('link', { name: 'Calculator' })) + expect(document.title).toBe('Calculator – WAG CoP Planner') - window.history.pushState({}, '', `${import.meta.env.BASE_URL}no-such-page`); - cleanup(); - renderApp(); - expect(document.title).toBe('Page not found – WAG CoP Planner'); - }); + window.history.pushState({}, '', `${import.meta.env.BASE_URL}no-such-page`) + cleanup() + renderApp() + expect(document.title).toBe('Page not found – WAG CoP Planner') + }) it('opens the search page from the start address, the logo and the Search link', () => { - renderApp(); - expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`); - expect(screen.getByRole('heading', { level: 1, name: 'Elements' })).toBeTruthy(); + renderApp() + expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`) + expect( + screen.getByRole('heading', { level: 1, name: 'Elements' }), + ).toBeTruthy() for (const link of [ () => screen.getByRole('link', { name: /wag cop planner home/i }), () => screen.getByRole('link', { name: 'Search' }), ]) { - fireEvent.click(screen.getByRole('link', { name: 'About' })); - expect(screen.getByRole('heading', { name: 'About' })).toBeTruthy(); - - fireEvent.click(link()); - expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`); - expect(screen.getByRole('heading', { level: 1, name: 'Elements' })).toBeTruthy(); + fireEvent.click(screen.getByRole('link', { name: 'About' })) + expect(screen.getByRole('heading', { name: 'About' })).toBeTruthy() + + fireEvent.click(link()) + expect(window.location.pathname).toBe( + `${import.meta.env.BASE_URL}elements`, + ) + expect( + screen.getByRole('heading', { level: 1, name: 'Elements' }), + ).toBeTruthy() } - }); + }) it('sends a visitor who is not logged in from the Gymnasts route to the login page', async () => { - window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`); - renderApp(() => ok({ me: null })); + window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`) + renderApp(() => ok({ me: null })) - expect(await screen.findByRole('heading', { name: 'Log in' })).toBeTruthy(); - expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}login`); - expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull(); - expect(screen.getByRole('link', { name: 'Log in' })).toBeTruthy(); - }); + expect(await screen.findByRole('heading', { name: 'Log in' })).toBeTruthy() + expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}login`) + expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull() + expect(screen.getByRole('link', { name: 'Log in' })).toBeTruthy() + }) it('keeps a logged-in coach on the Gymnasts route after a reload, and the menu agrees with the page', async () => { - window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`); + window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`) renderApp((operation) => ok({ me: { @@ -142,27 +150,27 @@ describe('App', () => { }), }, }), - ); + ) expect( await screen.findByRole('heading', { name: 'Your gymnasts' }), - ).toBeTruthy(); - expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}gymnasts`); + ).toBeTruthy() + expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}gymnasts`) // The menu and the page read the same answer. - expect(screen.getByRole('button', { name: 'Log out' })).toBeTruthy(); - expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull(); - expect(screen.getByRole('link', { name: 'Gymnasts' })).toBeTruthy(); - expect(await screen.findByText('Emma Berg')).toBeTruthy(); - }); + expect(screen.getByRole('button', { name: 'Log out' })).toBeTruthy() + expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull() + expect(screen.getByRole('link', { name: 'Gymnasts' })).toBeTruthy() + expect(await screen.findByText('Emma Berg')).toBeTruthy() + }) it('does not send a visitor away before the server has said who is logged in', async () => { - window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`); - renderApp(); // the server never answers + window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`) + renderApp() // the server never answers - expect(await screen.findByText('Checking your login…')).toBeTruthy(); - expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}gymnasts`); + expect(await screen.findByText('Checking your login…')).toBeTruthy() + expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}gymnasts`) // Neither "Log in" nor "Log out" is true yet. - expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull(); - }); -}); + expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull() + }) +}) diff --git a/src/tests/components/gymnasts/DevLoginButton.test.tsx b/src/tests/components/gymnasts/DevLoginButton.test.tsx index b17b341..6675057 100644 --- a/src/tests/components/gymnasts/DevLoginButton.test.tsx +++ b/src/tests/components/gymnasts/DevLoginButton.test.tsx @@ -1,134 +1,134 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { ApiError } from "@/lib/apollo/api"; -import { devLogin } from "@/lib/apollo/client"; -import { isDevLoginEnabled } from "@/lib/devLogin"; -import DevLoginButton from "@/components/gymnasts/DevLoginButton"; +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ApiError } from '@/lib/apollo/api' +import { devLogin } from '@/lib/apollo/client' +import { isDevLoginEnabled } from '@/lib/devLogin' +import DevLoginButton from '@/components/gymnasts/DevLoginButton' -vi.mock("@/lib/apollo/client", () => ({ devLogin: vi.fn() })); +vi.mock('@/lib/apollo/client', () => ({ devLogin: vi.fn() })) -const logIn = vi.mocked(devLogin); -const BUTTON = "Log in as test coach"; -const EMAIL = "emma@example.com"; -const coach = { id: "c1", name: "emma", email: EMAIL }; +const logIn = vi.mocked(devLogin) +const BUTTON = 'Log in as test coach' +const EMAIL = 'emma@example.com' +const coach = { id: 'c1', name: 'emma', email: EMAIL } afterEach(() => { - cleanup(); - vi.resetAllMocks(); - vi.unstubAllEnvs(); -}); + cleanup() + vi.resetAllMocks() + vi.unstubAllEnvs() +}) function typeEmail(address: string) { - fireEvent.change(screen.getByLabelText("Your e-mail address"), { + fireEvent.change(screen.getByLabelText('Your e-mail address'), { target: { value: address }, - }); + }) } -describe("isDevLoginEnabled", () => { - it("is on only when the build was made with VITE_DEV_LOGIN=true", () => { +describe('isDevLoginEnabled', () => { + it('is on only when the build was made with VITE_DEV_LOGIN=true', () => { for (const [value, expected] of [ - ["true", true], - ["", false], - ["false", false], - ["1", false], + ['true', true], + ['', false], + ['false', false], + ['1', false], ] as const) { - vi.stubEnv("VITE_DEV_LOGIN", value); - expect(isDevLoginEnabled()).toBe(expected); + vi.stubEnv('VITE_DEV_LOGIN', value) + expect(isDevLoginEnabled()).toBe(expected) } - }); -}); + }) +}) -describe("DevLoginButton", () => { - it("warns that anyone can log in as anyone", () => { - render( Promise.resolve()} />); +describe('DevLoginButton', () => { + it('warns that anyone can log in as anyone', () => { + render( Promise.resolve()} />) expect( screen.getByText( - "Test login: anyone can log in as anyone, so don’t enter real data.", + 'Test login: anyone can log in as anyone, so don’t enter real data.', ), - ).toBeTruthy(); - }); + ).toBeTruthy() + }) - it("logs in as the address that was typed, then loads what the page shows", async () => { - logIn.mockResolvedValue(coach); - const onLoggedIn = vi.fn().mockResolvedValue(undefined); - render(); + it('logs in as the address that was typed, then loads what the page shows', async () => { + logIn.mockResolvedValue(coach) + const onLoggedIn = vi.fn().mockResolvedValue(undefined) + render() - typeEmail(EMAIL); - fireEvent.click(screen.getByRole("button", { name: BUTTON })); + typeEmail(EMAIL) + fireEvent.click(screen.getByRole('button', { name: BUTTON })) - await vi.waitFor(() => expect(onLoggedIn).toHaveBeenCalledTimes(1)); - expect(logIn).toHaveBeenCalledExactlyOnceWith(EMAIL); - expect(screen.queryByRole("alert")).toBeNull(); - }); + await vi.waitFor(() => expect(onLoggedIn).toHaveBeenCalledTimes(1)) + expect(logIn).toHaveBeenCalledExactlyOnceWith(EMAIL) + expect(screen.queryByRole('alert')).toBeNull() + }) it("starts with no address, so nobody logs in as someone else's coach by default", () => { - render( Promise.resolve()} />); + render( Promise.resolve()} />) expect( - screen.getByLabelText("Your e-mail address").value, - ).toBe(""); - }); + screen.getByLabelText('Your e-mail address').value, + ).toBe('') + }) - it("is disabled while it logs in, so a double click makes one login", async () => { - let finish!: () => void; + it('is disabled while it logs in, so a double click makes one login', async () => { + let finish!: () => void logIn.mockReturnValue( new Promise((done) => { - finish = () => done(coach); + finish = () => done(coach) }), - ); - render( Promise.resolve()} />); - - typeEmail(EMAIL); - fireEvent.click(screen.getByRole("button", { name: BUTTON })); - const busy = await screen.findByRole("button", { - name: "Logging in…", - }); - expect(busy.disabled).toBe(true); - fireEvent.click(busy); - finish(); - - await screen.findByRole("button", { name: BUTTON }); - expect(logIn).toHaveBeenCalledTimes(1); - }); + ) + render( Promise.resolve()} />) + + typeEmail(EMAIL) + fireEvent.click(screen.getByRole('button', { name: BUTTON })) + const busy = await screen.findByRole('button', { + name: 'Logging in…', + }) + expect(busy.disabled).toBe(true) + fireEvent.click(busy) + finish() + + await screen.findByRole('button', { name: BUTTON }) + expect(logIn).toHaveBeenCalledTimes(1) + }) it("shows the server's answer when it refuses the address, and keeps what was typed", async () => { logIn.mockRejectedValue( - new ApiError("BAD_USER_INPUT", "Enter a valid e-mail address."), - ); - const onLoggedIn = vi.fn(); - render(); - - typeEmail("emma@x"); - fireEvent.click(screen.getByRole("button", { name: BUTTON })); - - expect((await screen.findByRole("alert")).textContent).toBe( - "Enter a valid e-mail address.", - ); - expect(onLoggedIn).not.toHaveBeenCalled(); + new ApiError('BAD_USER_INPUT', 'Enter a valid e-mail address.'), + ) + const onLoggedIn = vi.fn() + render() + + typeEmail('emma@x') + fireEvent.click(screen.getByRole('button', { name: BUTTON })) + + expect((await screen.findByRole('alert')).textContent).toBe( + 'Enter a valid e-mail address.', + ) + expect(onLoggedIn).not.toHaveBeenCalled() expect( - screen.getByLabelText("Your e-mail address").value, - ).toBe("emma@x"); - }); + screen.getByLabelText('Your e-mail address').value, + ).toBe('emma@x') + }) - it("says so when the server has the development login turned off", async () => { + it('says so when the server has the development login turned off', async () => { logIn.mockRejectedValue( - new ApiError("NOT_IMPLEMENTED", "devLogin is not available yet."), - ); - const onLoggedIn = vi.fn(); - render(); - - typeEmail(EMAIL); - fireEvent.click(screen.getByRole("button", { name: BUTTON })); - - expect((await screen.findByRole("alert")).textContent).toBe( - "This is not available yet.", - ); - expect(onLoggedIn).not.toHaveBeenCalled(); + new ApiError('NOT_IMPLEMENTED', 'devLogin is not available yet.'), + ) + const onLoggedIn = vi.fn() + render() + + typeEmail(EMAIL) + fireEvent.click(screen.getByRole('button', { name: BUTTON })) + + expect((await screen.findByRole('alert')).textContent).toBe( + 'This is not available yet.', + ) + expect(onLoggedIn).not.toHaveBeenCalled() expect( - screen.getByRole("button", { name: BUTTON }).disabled, - ).toBe(false); - }); -}); + screen.getByRole('button', { name: BUTTON }).disabled, + ).toBe(false) + }) +}) diff --git a/src/tests/components/gymnasts/GymnastList.test.tsx b/src/tests/components/gymnasts/GymnastList.test.tsx index c052d5d..1b59017 100644 --- a/src/tests/components/gymnasts/GymnastList.test.tsx +++ b/src/tests/components/gymnasts/GymnastList.test.tsx @@ -1,52 +1,58 @@ -import { ApolloProvider } from "@apollo/client/react"; -import { renderToString } from "react-dom/server"; -import { describe, expect, it } from "vitest"; -import { createTestClient } from "@/tests/apollo"; -import GymnastList from "@/components/gymnasts/GymnastList"; +import { ApolloProvider } from '@apollo/client/react' +import { renderToString } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { createTestClient } from '@/tests/apollo' +import GymnastList from '@/components/gymnasts/GymnastList' // Each row has a delete button, which uses the client for its mutation. -const { client } = createTestClient(() => new Promise(() => {})); +const { client } = createTestClient(() => new Promise(() => {})) const renderList = (gymnasts: { id: string; name: string }[]) => renderToString( - {}} onFailed={() => {}} /> + {}} + onFailed={() => {}} + /> , - ); + ) -describe("GymnastList", () => { - it("renders names with initials", () => { +describe('GymnastList', () => { + it('renders names with initials', () => { const markup = renderList([ - { 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 = renderList([]); - - expect(markup).toMatch(/]*>Gymnast list<\/h2>/); - }); - - it("announces the empty list state", () => { - const markup = renderList([]); - - expect(markup).toContain('role="status"'); - expect(markup).toContain("You haven’t added any gymnasts yet."); - }); -}); - -describe("GymnastList: the cards show that they can be pressed", () => { - it("has a tint and a deeper shadow on hover, a pointer, and no movement for a visitor who asks for less", () => { - const markup = renderList([{ id: "one", name: "Ingrid Haugen" }]); - - expect(markup).toContain("cursor-pointer"); - expect(markup).toContain("hover:bg-[color-mix(in_srgb,var(--surface),var(--text-color)_8%)]"); - expect(markup).toContain("hover:shadow-[0_6px_16px_rgba(3,2,2,0.22)]"); - expect(markup).toContain("motion-reduce:transition-none"); - }); -}); + { 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 = renderList([]) + + expect(markup).toMatch(/]*>Gymnast list<\/h2>/) + }) + + it('announces the empty list state', () => { + const markup = renderList([]) + + expect(markup).toContain('role="status"') + expect(markup).toContain('You haven’t added any gymnasts yet.') + }) +}) + +describe('GymnastList: the cards show that they can be pressed', () => { + it('has a tint and a deeper shadow on hover, a pointer, and no movement for a visitor who asks for less', () => { + const markup = renderList([{ id: 'one', name: 'Ingrid Haugen' }]) + + expect(markup).toContain('cursor-pointer') + expect(markup).toContain( + 'hover:bg-[color-mix(in_srgb,var(--surface),var(--text-color)_8%)]', + ) + expect(markup).toContain('hover:shadow-[0_6px_16px_rgba(3,2,2,0.22)]') + expect(markup).toContain('motion-reduce:transition-none') + }) +}) diff --git a/src/tests/components/navbar/Navbar.test.tsx b/src/tests/components/navbar/Navbar.test.tsx index d5e9c1b..c111fcf 100644 --- a/src/tests/components/navbar/Navbar.test.tsx +++ b/src/tests/components/navbar/Navbar.test.tsx @@ -1,15 +1,15 @@ // @vitest-environment jsdom -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { BrowserRouter, useLocation } from 'react-router-dom'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { AuthContext, type AuthState } from '@/components/auth/useAuth'; -import { logout } from '@/lib/apollo/client'; -import { Navbar } from '@/components/navbar/Navbar'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { BrowserRouter, useLocation } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AuthContext, type AuthState } from '@/components/auth/useAuth' +import { logout } from '@/lib/apollo/client' +import { Navbar } from '@/components/navbar/Navbar' // The real functions would call the server. The Navbar only has to call logout. vi.mock('@/lib/apollo/client', () => ({ logout: vi.fn(async () => undefined), -})); +})) const states: Record = { loading: { status: 'loading', coach: null }, @@ -18,198 +18,203 @@ const states: Record = { status: 'authenticated', coach: { id: 'c1', name: 'Anna', email: 'anna@example.com' }, }, -}; +} /** Shows the address, so a test can see where the navigation went. */ function Address() { - const location = useLocation(); - return {location.pathname}; + const location = useLocation() + return {location.pathname} } beforeEach(() => { - window.history.replaceState({}, '', '/'); -}); + window.history.replaceState({}, '', '/') +}) afterEach(() => { - window.history.replaceState({}, '', '/'); -}); + window.history.replaceState({}, '', '/') +}) const renderNavbar = ({ auth = 'anonymous', isDarkMode = false, onToggleDarkMode = vi.fn(), }: { - auth?: keyof typeof states; - isDarkMode?: boolean; - onToggleDarkMode?: () => void; + auth?: keyof typeof states + isDarkMode?: boolean + onToggleDarkMode?: () => void } = {}) => render( - +
, - ); + ) describe('Navbar', () => { it('shows the brand and public navigation links', () => { - renderNavbar(); - - const brand = screen.getByRole('link', { name: /wag cop planner home/i }); - expect(brand).toBeTruthy(); - expect(brand.className).toContain('justify-self-start'); - expect(brand.className).toContain('text-left'); - expect(brand.className).toContain('min-w-0'); - const header = screen.getByRole('banner'); - expect(header.className).toContain('px-[clamp(0.5rem,2vw,2.5rem)]'); + renderNavbar() + + const brand = screen.getByRole('link', { name: /wag cop planner home/i }) + expect(brand).toBeTruthy() + expect(brand.className).toContain('justify-self-start') + expect(brand.className).toContain('text-left') + expect(brand.className).toContain('min-w-0') + const header = screen.getByRole('banner') + expect(header.className).toContain('px-[clamp(0.5rem,2vw,2.5rem)]') expect(header.className).toContain( 'min-[720px]:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]', - ); - expect(header.className).toContain('relative'); - expect(screen.getByRole('link', { name: 'Search' })).toBeTruthy(); - expect(screen.getByRole('link', { name: 'Calculator' })).toBeTruthy(); - expect(screen.getByRole('link', { name: 'About' })).toBeTruthy(); - expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull(); - }); + ) + expect(header.className).toContain('relative') + expect(screen.getByRole('link', { name: 'Search' })).toBeTruthy() + expect(screen.getByRole('link', { name: 'Calculator' })).toBeTruthy() + expect(screen.getByRole('link', { name: 'About' })).toBeTruthy() + expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull() + }) it('shows the Gymnasts link only when logged in', () => { - renderNavbar({ auth: 'authenticated' }); + renderNavbar({ auth: 'authenticated' }) - expect(screen.getByRole('link', { name: 'Gymnasts' })).toBeTruthy(); - }); + expect(screen.getByRole('link', { name: 'Gymnasts' })).toBeTruthy() + }) it('opens and closes the mobile navigation and closes after a link is selected', () => { - const { container } = renderNavbar(); - - const menuButton = screen.getByRole('button', { name: 'Open menu' }); - expect(menuButton.getAttribute('aria-expanded')).toBe('false'); - expect(menuButton.getAttribute('aria-controls')).toBe('main-menu'); - expect(container.querySelector('#main-menu')?.className).toContain('hidden'); - - fireEvent.click(menuButton); - expect(screen.getByRole('button', { name: 'Close menu' }).getAttribute('aria-expanded')).toBe( - 'true', - ); - expect(screen.getByRole('navigation', { name: 'Primary navigation' }).className).toContain( - 'flex', - ); - - fireEvent.click(screen.getByRole('link', { name: 'About' })); - expect(screen.getByRole('button', { name: 'Open menu' }).getAttribute('aria-expanded')).toBe( - 'false', - ); - expect(container.querySelector('#main-menu')?.className).toContain('hidden'); - }); + const { container } = renderNavbar() + + const menuButton = screen.getByRole('button', { name: 'Open menu' }) + expect(menuButton.getAttribute('aria-expanded')).toBe('false') + expect(menuButton.getAttribute('aria-controls')).toBe('main-menu') + expect(container.querySelector('#main-menu')?.className).toContain('hidden') + + fireEvent.click(menuButton) + expect( + screen + .getByRole('button', { name: 'Close menu' }) + .getAttribute('aria-expanded'), + ).toBe('true') + expect( + screen.getByRole('navigation', { name: 'Primary navigation' }).className, + ).toContain('flex') + + fireEvent.click(screen.getByRole('link', { name: 'About' })) + expect( + screen + .getByRole('button', { name: 'Open menu' }) + .getAttribute('aria-expanded'), + ).toBe('false') + expect(container.querySelector('#main-menu')?.className).toContain('hidden') + }) it('keeps the navigation visible at 720px and hides the menu button', () => { - renderNavbar(); + renderNavbar() - expect(screen.getByRole('navigation', { hidden: true }).className).toContain( - 'min-[720px]:flex', - ); - expect(screen.getByRole('button', { name: 'Open menu' }).className).toContain( - 'min-[720px]:hidden', - ); - }); + expect( + screen.getByRole('navigation', { hidden: true }).className, + ).toContain('min-[720px]:flex') + expect( + screen.getByRole('button', { name: 'Open menu' }).className, + ).toContain('min-[720px]:hidden') + }) it('provides hover underlines and visible keyboard focus styles for navigation links', () => { - renderNavbar(); + renderNavbar() - const navLink = screen.getByRole('link', { name: 'About' }); - expect(navLink.className).toContain("after:content-['']"); - expect(navLink.className).toContain('after:bottom-0'); - expect(navLink.className).toContain('self-start'); - expect(navLink.className).toContain('hover:after:scale-x-100'); - expect(navLink.className).toContain('focus-visible:outline-2'); - expect(navLink.className).toContain('focus-visible:outline-(--text-color)'); - }); + const navLink = screen.getByRole('link', { name: 'About' }) + expect(navLink.className).toContain("after:content-['']") + expect(navLink.className).toContain('after:bottom-0') + expect(navLink.className).toContain('self-start') + expect(navLink.className).toContain('hover:after:scale-x-100') + expect(navLink.className).toContain('focus-visible:outline-2') + expect(navLink.className).toContain('focus-visible:outline-(--text-color)') + }) it('sends the logo and the Search link to the same page, the search page', () => { - renderNavbar(); + renderNavbar() - const logo = screen.getByRole('link', { name: /wag cop planner home/i }); - const search = screen.getByRole('link', { name: 'Search' }); - expect(logo.getAttribute('href')).toBe('/elements'); - expect(search.getAttribute('href')).toBe('/elements'); - }); + const logo = screen.getByRole('link', { name: /wag cop planner home/i }) + const search = screen.getByRole('link', { name: 'Search' }) + expect(logo.getAttribute('href')).toBe('/elements') + expect(search.getAttribute('href')).toBe('/elements') + }) it('keeps the underline visible on the current page link', () => { - window.history.replaceState({}, '', '/elements'); - renderNavbar(); + window.history.replaceState({}, '', '/elements') + renderNavbar() - const currentPageLink = screen.getByRole('link', { name: 'Search' }); - expect(currentPageLink.className).toContain('after:scale-x-100'); - expect(screen.getByRole('navigation').className).not.toContain('pb-[6px]'); - }); + const currentPageLink = screen.getByRole('link', { name: 'Search' }) + expect(currentPageLink.className).toContain('after:scale-x-100') + expect(screen.getByRole('navigation').className).not.toContain('pb-[6px]') + }) it('reports theme toggle clicks to its owner', () => { - const onToggleDarkMode = vi.fn(); - renderNavbar({ onToggleDarkMode }); + const onToggleDarkMode = vi.fn() + renderNavbar({ onToggleDarkMode }) const themeToggle = screen.getByRole('switch', { name: /toggle dark theme/i, - }); - expect(themeToggle.getAttribute('aria-checked')).toBe('false'); + }) + expect(themeToggle.getAttribute('aria-checked')).toBe('false') - fireEvent.click(themeToggle); + fireEvent.click(themeToggle) - expect(onToggleDarkMode).toHaveBeenCalledOnce(); - }); + expect(onToggleDarkMode).toHaveBeenCalledOnce() + }) it('reflects the controlled dark-mode state', () => { - renderNavbar({ isDarkMode: true }); + renderNavbar({ isDarkMode: true }) const themeToggle = screen.getByRole('switch', { name: /toggle dark theme/i, - }); - expect(themeToggle.getAttribute('aria-checked')).toBe('true'); - expect(themeToggle.querySelector('span')?.style.transform).toBe('translateX(22px)'); - }); + }) + expect(themeToggle.getAttribute('aria-checked')).toBe('true') + expect(themeToggle.querySelector('span')?.style.transform).toBe( + 'translateX(22px)', + ) + }) it('keeps the theme toggle thumb at its starting point in light mode', () => { - renderNavbar({ isDarkMode: false }); + renderNavbar({ isDarkMode: false }) const themeToggle = screen.getByRole('switch', { name: /toggle dark theme/i, - }); - expect(themeToggle.querySelector('span')?.style.transform).toBe('translateX(0)'); - }); + }) + expect(themeToggle.querySelector('span')?.style.transform).toBe( + 'translateX(0)', + ) + }) it('has a Log in link to the login page when nobody is logged in', () => { - renderNavbar({ auth: 'anonymous' }); + renderNavbar({ auth: 'anonymous' }) - expect(screen.getByRole('link', { name: 'Log in' }).getAttribute('href')).toBe( - '/login', - ); - expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull(); - }); + expect( + screen.getByRole('link', { name: 'Log in' }).getAttribute('href'), + ).toBe('/login') + expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull() + }) it('has a Log out button, and no Log in, when a coach is logged in', () => { - renderNavbar({ auth: 'authenticated' }); + renderNavbar({ auth: 'authenticated' }) - expect(screen.getByRole('button', { name: 'Log out' })).toBeTruthy(); - expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull(); - }); + expect(screen.getByRole('button', { name: 'Log out' })).toBeTruthy() + expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull() + }) it('says neither Log in nor Log out until the server has answered', () => { - renderNavbar({ auth: 'loading' }); + renderNavbar({ auth: 'loading' }) - expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull(); - expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull(); - }); + expect(screen.queryByRole('link', { name: 'Log in' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull() + expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull() + }) it('logs out and leaves the page that is for coaches', async () => { - window.history.replaceState({}, '', '/gymnasts'); - renderNavbar({ auth: 'authenticated' }); + window.history.replaceState({}, '', '/gymnasts') + renderNavbar({ auth: 'authenticated' }) - fireEvent.click(screen.getByRole('button', { name: 'Log out' })); + fireEvent.click(screen.getByRole('button', { name: 'Log out' })) - await waitFor(() => expect(logout).toHaveBeenCalledOnce()); - expect(screen.getByTestId('address').textContent).toBe('/elements'); - }); -}); + await waitFor(() => expect(logout).toHaveBeenCalledOnce()) + expect(screen.getByTestId('address').textContent).toBe('/elements') + }) +}) diff --git a/src/tests/components/skipLink/SkipLink.test.tsx b/src/tests/components/skipLink/SkipLink.test.tsx index 9f52d96..7604ad3 100644 --- a/src/tests/components/skipLink/SkipLink.test.tsx +++ b/src/tests/components/skipLink/SkipLink.test.tsx @@ -1,33 +1,37 @@ // @vitest-environment jsdom -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { SkipLink } from '@/components/skipLink/SkipLink'; +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { SkipLink } from '@/components/skipLink/SkipLink' describe('SkipLink', () => { it('links to the main content using the default id', () => { - render(); + render() - expect(screen.getByRole('link', { name: 'Skip to main content' }).getAttribute('href')).toBe( - '#main-content', - ); - }); + expect( + screen + .getByRole('link', { name: 'Skip to main content' }) + .getAttribute('href'), + ).toBe('#main-content') + }) it('supports a custom main content id', () => { - render(); + render() - expect(screen.getByRole('link', { name: 'Skip to main content' }).getAttribute('href')).toBe( - '#page-content', - ); - }); + expect( + screen + .getByRole('link', { name: 'Skip to main content' }) + .getAttribute('href'), + ).toBe('#page-content') + }) it('uses Tailwind classes to hide the link until keyboard focus', () => { - render(); + render() - const link = screen.getByRole('link', { name: 'Skip to main content' }); - expect(link.className).toContain('sr-only'); - expect(link.className).toContain('px-md'); - expect(link.className).toContain('focus:not-sr-only'); - expect(link.className).toContain('focus:top-0'); - expect(link.className).toContain('focus:left-0'); - }); -}); + const link = screen.getByRole('link', { name: 'Skip to main content' }) + expect(link.className).toContain('sr-only') + expect(link.className).toContain('px-md') + expect(link.className).toContain('focus:not-sr-only') + expect(link.className).toContain('focus:top-0') + expect(link.className).toContain('focus:left-0') + }) +}) diff --git a/src/tests/lib/gymnasts.test.ts b/src/tests/lib/gymnasts.test.ts index f867d36..df44e5f 100644 --- a/src/tests/lib/gymnasts.test.ts +++ b/src/tests/lib/gymnasts.test.ts @@ -1,46 +1,46 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest' import { getGymnastInitials, getGymnastNameError, limitGymnastNameInput, normalizeGymnastName, -} from "@/lib/gymnasts"; +} from '@/lib/gymnasts' -describe("gymnast helpers", () => { - it("creates initials from the first and last name", () => { - expect(getGymnastInitials("Ingrid Haugen")).toBe("IH"); - }); +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('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('trims and collapses whitespace in names', () => { + expect(normalizeGymnastName(' Emma Berg ')).toBe('Emma Berg') + }) - it("rejects blank and overlong names", () => { - expect(getGymnastNameError(" ")).toBe("Name is required"); - expect(getGymnastNameError("A".repeat(71))).toBe( - "Names must be 70 characters or fewer.", - ); - expect(getGymnastNameError("Sofia Hansen")).toBeUndefined(); - }); + it('rejects blank and overlong names', () => { + expect(getGymnastNameError(' ')).toBe('Name is required') + expect(getGymnastNameError('A'.repeat(71))).toBe( + 'Names must be 70 characters or fewer.', + ) + expect(getGymnastNameError('Sofia Hansen')).toBeUndefined() + }) - it("asks for a name when there is none, and takes any name of 1 to 70 characters, like the API", () => { - expect(getGymnastNameError("")).toBe("Name is required"); - expect(getGymnastNameError(" \t ")).toBe("Name is required"); - expect(getGymnastNameError("A")).toBeUndefined(); - expect(getGymnastNameError("Al")).toBeUndefined(); - expect(getGymnastNameError("😀")).toBeUndefined(); - expect(getGymnastNameError("A".repeat(70))).toBeUndefined(); - }); + it('asks for a name when there is none, and takes any name of 1 to 70 characters, like the API', () => { + expect(getGymnastNameError('')).toBe('Name is required') + expect(getGymnastNameError(' \t ')).toBe('Name is required') + expect(getGymnastNameError('A')).toBeUndefined() + expect(getGymnastNameError('Al')).toBeUndefined() + expect(getGymnastNameError('😀')).toBeUndefined() + expect(getGymnastNameError('A'.repeat(70))).toBeUndefined() + }) - it("cuts what is typed to 70 characters, and counts an emoji as one", () => { - expect(limitGymnastNameInput("A".repeat(80))).toBe("A".repeat(70)); - expect(limitGymnastNameInput("A".repeat(70))).toBe("A".repeat(70)); - expect(limitGymnastNameInput("Emma")).toBe("Emma"); - expect(limitGymnastNameInput("😀".repeat(80))).toBe("😀".repeat(70)); - }); -}); \ No newline at end of file + it('cuts what is typed to 70 characters, and counts an emoji as one', () => { + expect(limitGymnastNameInput('A'.repeat(80))).toBe('A'.repeat(70)) + expect(limitGymnastNameInput('A'.repeat(70))).toBe('A'.repeat(70)) + expect(limitGymnastNameInput('Emma')).toBe('Emma') + expect(limitGymnastNameInput('😀'.repeat(80))).toBe('😀'.repeat(70)) + }) +}) diff --git a/src/tests/setupTests.ts b/src/tests/setupTests.ts index d8935b0..17cdac2 100644 --- a/src/tests/setupTests.ts +++ b/src/tests/setupTests.ts @@ -1,7 +1,7 @@ -import '@testing-library/jest-dom'; -import { cleanup } from '@testing-library/react'; -import { afterEach } from 'vitest'; +import '@testing-library/jest-dom' +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' afterEach(() => { - cleanup(); -}); \ No newline at end of file + cleanup() +}) diff --git a/tsconfig.json b/tsconfig.json index 36109fc..57b1335 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,12 +12,9 @@ } ], "compilerOptions": { - "paths": { "@/*": ["./src/*"] }, "types": ["jest", "@testing-library/jest-dom"] - - } } diff --git a/vite.config.ts b/vite.config.ts index 4510e4e..b9ab130 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -28,4 +28,4 @@ export default defineConfig({ setupFiles: ['./src/tests/setupTests.ts'], fileParallelism: false, }, -}); +}) From 1cfdae823dd0a29060fe987549b5d5cd1768409b Mon Sep 17 00:00:00 2001 From: xenia_grib Date: Thu, 5 Nov 2026 16:10:51 +0100 Subject: [PATCH 05/32] Update API URL and Vite proxy configuration for project structure - Changed default API URL from '/graphql' to '/project2/graphql' in Apollo client. - Updated Vite configuration to add a proxy for '/project2/graphql' to forward requests to the backend. --- README.md | 2 +- package-lock.json | 416 +++++++++++++++++++-------------------- src/lib/apollo/client.ts | 6 +- vite.config.ts | 4 + 4 files changed, 216 insertions(+), 212 deletions(-) diff --git a/README.md b/README.md index 1d463c4..334f629 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ Other commands: ### Calling the API from the frontend -The frontend uses [Apollo Client](https://www.apollographql.com/docs/react) (`src/lib/apollo/`), and `src/main.tsx` wraps the app in `ApolloProvider`. Start the backend (`npm run backend`, port 3001) and the frontend (`npm run dev`). In development Vite sends `/graphql` and `/images` to the backend, so the browser sees one address and there is no CORS to set up. Set `VITE_API_URL` (for example in `.env.local`) to call another address; the default is `/graphql`. The app is built for the virtual machine with `VITE_API_URL=/project2/graphql npm run build`, because it is served under `/project2/` and Apache must send that address to the backend (see "Connect the frontend to the backend" in [docs/VM_SETUP.md](docs/VM_SETUP.md)). +The frontend uses [Apollo Client](https://www.apollographql.com/docs/react) (`src/lib/apollo/`), and `src/main.tsx` wraps the app in `ApolloProvider`. Start the backend (`npm run backend`, port 3001) and the frontend (`npm run dev`). In development Vite forwards `/project2/graphql` to the backend at `/graphql` and sends `/graphql` and `/images` to the backend, so the browser sees one address and there is no CORS to set up. Set `VITE_API_URL` (for example in `.env.local`) to call another address; the default is `/project2/graphql`. The app is built for the virtual machine with `VITE_API_URL=/project2/graphql npm run build`, because it is served under `/project2/` and Apache must send that address to the backend (see "Connect the frontend to the backend" in [docs/VM_SETUP.md](docs/VM_SETUP.md)). **Bundle size.** A current `npm run build` reports a JavaScript bundle of about 548 kB (171 kB gzip), above Vite's 500 kB warning threshold. The catalog JSON was removed from the frontend when the detail page moved to the API; the historical before/after sizes and possible improvements are described in [docs/SUSTAINABILITY.md](docs/SUSTAINABILITY.md). diff --git a/package-lock.json b/package-lock.json index f73e602..3898243 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3353,9 +3353,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.151.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", - "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", "dev": true, "license": "MIT", "funding": { @@ -3844,9 +3844,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", - "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", + "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", "cpu": [ "arm" ], @@ -3861,9 +3861,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", - "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", + "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", "cpu": [ "arm64" ], @@ -3878,9 +3878,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", - "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", + "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", "cpu": [ "arm64" ], @@ -3895,9 +3895,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", - "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", + "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", "cpu": [ "x64" ], @@ -3912,9 +3912,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", - "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", + "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", "cpu": [ "x64" ], @@ -3929,9 +3929,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", - "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", + "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", "cpu": [ "arm" ], @@ -3946,9 +3946,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", - "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", + "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", "cpu": [ "arm64" ], @@ -3966,9 +3966,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", - "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", + "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", "cpu": [ "arm64" ], @@ -3986,9 +3986,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", - "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", + "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", "cpu": [ "ppc64" ], @@ -4006,9 +4006,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", - "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", + "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", "cpu": [ "s390x" ], @@ -4026,9 +4026,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", - "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", + "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", "cpu": [ "x64" ], @@ -4046,9 +4046,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", - "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", + "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", "cpu": [ "x64" ], @@ -4066,9 +4066,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", - "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", + "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", "cpu": [ "arm64" ], @@ -4083,9 +4083,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", - "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", + "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", "cpu": [ "arm64" ], @@ -4100,9 +4100,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", - "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", + "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", "cpu": [ "x64" ], @@ -5501,17 +5501,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", - "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz", + "integrity": "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.71.0", - "@typescript-eslint/type-utils": "8.71.0", - "@typescript-eslint/utils": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0", + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/type-utils": "8.72.1", + "@typescript-eslint/utils": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -5524,15 +5524,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.71.0", + "@typescript-eslint/parser": "^8.72.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", - "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz", + "integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==", "dev": true, "license": "MIT", "engines": { @@ -5540,16 +5540,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", - "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.1.tgz", + "integrity": "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.71.0", - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/typescript-estree": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0", + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", "debug": "^4.4.3" }, "engines": { @@ -5565,14 +5565,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", - "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz", + "integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.71.0", - "@typescript-eslint/types": "^8.71.0", + "@typescript-eslint/tsconfig-utils": "^8.72.1", + "@typescript-eslint/types": "^8.72.1", "debug": "^4.4.3" }, "engines": { @@ -5587,14 +5587,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", - "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz", + "integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0" + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5605,9 +5605,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", - "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz", + "integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==", "dev": true, "license": "MIT", "engines": { @@ -5622,15 +5622,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", - "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.1.tgz", + "integrity": "sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/typescript-estree": "8.71.0", - "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/utils": "8.72.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -5647,9 +5647,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", - "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz", + "integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==", "dev": true, "license": "MIT", "engines": { @@ -5661,16 +5661,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", - "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz", + "integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.71.0", - "@typescript-eslint/tsconfig-utils": "8.71.0", - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0", + "@typescript-eslint/project-service": "8.72.1", + "@typescript-eslint/tsconfig-utils": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -5689,9 +5689,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz", + "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==", "dev": true, "license": "ISC", "bin": { @@ -5702,16 +5702,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", - "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz", + "integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.71.0", - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/typescript-estree": "8.71.0" + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5726,13 +5726,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", - "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz", + "integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/types": "8.72.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -5925,22 +5925,22 @@ } }, "node_modules/@vitest/mocker": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz", - "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.1.0.tgz", + "integrity": "sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.2", + "@vitest/spy": "5.1.0", "estree-walker": "^3.0.3", - "magic-string": "^1.2.3" + "magic-string": "^1.4.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "msw": "^2.4.9", + "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { @@ -5953,9 +5953,9 @@ } }, "node_modules/@vitest/spy": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz", - "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.1.0.tgz", + "integrity": "sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==", "dev": true, "license": "MIT", "funding": { @@ -6345,18 +6345,18 @@ } }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", - "qs": "^6.15.2", + "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" }, @@ -6585,9 +6585,9 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -7605,9 +7605,9 @@ } }, "node_modules/eslint": { - "version": "10.11.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", - "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", + "version": "10.14.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.14.0.tgz", + "integrity": "sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==", "dev": true, "license": "MIT", "workspaces": [ @@ -7620,7 +7620,7 @@ "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.3", - "@humanfs/node": "^0.16.6", + "@humanfs/node": "^0.16.8", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", @@ -7641,7 +7641,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.5", + "minimatch": "^10.2.6", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -8031,9 +8031,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "devOptional": true, "funding": [ { @@ -10053,9 +10053,9 @@ } }, "node_modules/magic-string": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", - "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", "dev": true, "license": "MIT", "dependencies": { @@ -10291,9 +10291,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -10430,9 +10430,9 @@ } }, "node_modules/obug": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", - "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -10871,9 +10871,9 @@ } }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", "dev": true, "funding": [ { @@ -10891,9 +10891,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.18", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -11439,13 +11439,13 @@ "license": "Unlicense" }, "node_modules/rolldown": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", - "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", + "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.151.0", + "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -11455,21 +11455,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.11", - "@rolldown/binding-android-arm64": "1.2.11", - "@rolldown/binding-darwin-arm64": "1.2.11", - "@rolldown/binding-darwin-x64": "1.2.11", - "@rolldown/binding-freebsd-x64": "1.2.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", - "@rolldown/binding-linux-arm64-gnu": "1.2.11", - "@rolldown/binding-linux-arm64-musl": "1.2.11", - "@rolldown/binding-linux-ppc64-gnu": "1.2.11", - "@rolldown/binding-linux-s390x-gnu": "1.2.11", - "@rolldown/binding-linux-x64-gnu": "1.2.11", - "@rolldown/binding-linux-x64-musl": "1.2.11", - "@rolldown/binding-openharmony-arm64": "1.2.11", - "@rolldown/binding-win32-arm64-msvc": "1.2.11", - "@rolldown/binding-win32-x64-msvc": "1.2.11" + "@rolldown/binding-android-arm-eabi": "1.2.16", + "@rolldown/binding-android-arm64": "1.2.16", + "@rolldown/binding-darwin-arm64": "1.2.16", + "@rolldown/binding-darwin-x64": "1.2.16", + "@rolldown/binding-freebsd-x64": "1.2.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", + "@rolldown/binding-linux-arm64-gnu": "1.2.16", + "@rolldown/binding-linux-arm64-musl": "1.2.16", + "@rolldown/binding-linux-ppc64-gnu": "1.2.16", + "@rolldown/binding-linux-s390x-gnu": "1.2.16", + "@rolldown/binding-linux-x64-gnu": "1.2.16", + "@rolldown/binding-linux-x64-musl": "1.2.16", + "@rolldown/binding-openharmony-arm64": "1.2.16", + "@rolldown/binding-win32-arm64-msvc": "1.2.16", + "@rolldown/binding-win32-x64-msvc": "1.2.16" } }, "node_modules/router": { @@ -11849,9 +11849,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -12066,9 +12066,9 @@ } }, "node_modules/tinybench": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", - "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.3.tgz", + "integrity": "sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==", "dev": true, "license": "MIT", "engines": { @@ -12325,16 +12325,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz", - "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.1.tgz", + "integrity": "sha512-3Li9FfPmQp45nxEtFD1ZCrYFI3cYK5kpyNtmm1H7lMagm8u9sy6PTjy3ytwQZs7KNukOyKFHAZ6z6GjYFeaeXw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.71.0", - "@typescript-eslint/parser": "8.71.0", - "@typescript-eslint/typescript-estree": "8.71.0", - "@typescript-eslint/utils": "8.71.0" + "@typescript-eslint/eslint-plugin": "8.72.1", + "@typescript-eslint/parser": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/utils": "8.72.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -12478,16 +12478,16 @@ } }, "node_modules/vite": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", - "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.6.tgz", + "integrity": "sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.9", + "postcss": "^8.5.32", + "rolldown": "~1.2.16", "tinyglobby": "^0.2.17" }, "bin": { @@ -12504,7 +12504,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -12556,25 +12556,25 @@ } }, "node_modules/vitest": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz", - "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.1.0.tgz", + "integrity": "sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.2", - "chai": "^6.2.2", + "@vitest/mocker": "5.1.0", + "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", - "magic-string": "^1.2.3", - "obug": "^2.1.4", + "magic-string": "^1.4.3", + "obug": "^3.0.0", "picomatch": "^4.0.7", - "std-env": "^4.2.0", - "tinybench": "^6.1.4", - "tinyexec": "^1.3.0", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", - "why-is-node-running": "^3.2.1" + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" @@ -12589,12 +12589,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.2", - "@vitest/browser-preview": "5.0.2", + "@vitest/browser-playwright": "5.1.0", + "@vitest/browser-preview": "5.1.0", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.2", - "@vitest/coverage-v8": "5.0.2", - "@vitest/ui": "5.0.2", + "@vitest/coverage-istanbul": "5.1.0", + "@vitest/coverage-v8": "5.1.0", + "@vitest/ui": "5.1.0", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -12733,9 +12733,9 @@ } }, "node_modules/why-is-node-running": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz", - "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", "bin": { @@ -12925,9 +12925,9 @@ } }, "node_modules/zod": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "dev": true, "license": "MIT", "funding": { diff --git a/src/lib/apollo/client.ts b/src/lib/apollo/client.ts index 0868516..bcdc805 100644 --- a/src/lib/apollo/client.ts +++ b/src/lib/apollo/client.ts @@ -1,6 +1,6 @@ // The Apollo Client of the app, and the functions for the session. The address -// of the API is VITE_API_URL (default "/graphql"). In development Vite sends -// "/graphql" to the backend on port 3001 (vite.config.ts), so the browser sees +// of the API is VITE_API_URL (default "/project2/graphql"). In development Vite +// forwards it to the backend on port 3001 (vite.config.ts), so the browser sees // one address and there is no CORS to set up. import { ApolloClient } from '@apollo/client' @@ -17,7 +17,7 @@ import { createAuthLink } from './authLink' import { createCache } from './cache' import { type Session, type TokenStorage, createSession } from './session' -export const API_URL: string = import.meta.env.VITE_API_URL ?? '/graphql' +export const API_URL: string = import.meta.env.VITE_API_URL ?? '/project2/graphql' export function createApolloClient(options: { uri: string diff --git a/vite.config.ts b/vite.config.ts index b9ab130..6be60f7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,6 +17,10 @@ export default defineConfig({ // listens on port 3001 (npm run backend). proxy: { '/graphql': 'http://localhost:3001', + '/project2/graphql': { + target: 'http://localhost:3001', + rewrite: (path) => path.replace(/^\/project2/, ''), + }, '/images': 'http://localhost:3001', }, }, From 871d5ce9ef9e16f61dd7e7302075c6cfe8d60f44 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 11:55:59 +0100 Subject: [PATCH 06/32] feat(ui): add the shadcn/ui tabs component Added with the shadcn CLI (npx shadcn@latest add tabs) and formatted with Prettier. It is built on the Base UI tabs, so it follows the WAI-ARIA tabs pattern. --- src/components/ui/tabs.tsx | 82 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/components/ui/tabs.tsx diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..f25d771 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,82 @@ +// Added with `npx shadcn add tabs`. One change: the component asks for the attributes `data-horizontal` +// and `data-vertical`, but the Base UI that is installed (1.9) sets `data-orientation`, so those +// classes are written as `data-[orientation=horizontal]`. Nothing else is changed. +import { Tabs as TabsPrimitive } from '@base-ui/react/tabs' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from 'cn' + +function Tabs({ + className, + orientation = 'horizontal', + ...props +}: TabsPrimitive.Root.Props) { + return ( + + ) +} + +const tabsListVariants = cva( + 'group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none', + { + variants: { + variant: { + default: 'bg-muted', + line: 'gap-1 bg-transparent', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +function TabsList({ + className, + variant = 'default', + ...props +}: TabsPrimitive.List.Props & VariantProps) { + return ( + + ) +} + +function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { + return ( + + ) +} + +function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } From 9307bf606ff37d74722846e832373f825b29b76b Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 11:56:00 +0100 Subject: [PATCH 07/32] refactor(gymnasts): share the confirmation dialog as ConfirmDialog The dialog of DeleteGymnastButton becomes its own component, so that removing an element from the list can use the same "are you sure?" dialog. --- src/components/ConfirmDialog.tsx | 113 +++++++++++++ .../gymnasts/DeleteGymnastButton.tsx | 117 +++++-------- src/tests/components/ConfirmDialog.test.tsx | 160 ++++++++++++++++++ 3 files changed, 320 insertions(+), 70 deletions(-) create mode 100644 src/components/ConfirmDialog.tsx create mode 100644 src/tests/components/ConfirmDialog.test.tsx diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..ede7333 --- /dev/null +++ b/src/components/ConfirmDialog.tsx @@ -0,0 +1,113 @@ +import { useRef, useState, type ReactNode } from 'react' +import { AlertDialog } from '@base-ui/react/alert-dialog' +import { TriangleAlert } from 'lucide-react' +import { Button } from '@/components/ui/button' + +// "Solid" is needed: the button sets `outline-none`, which a width and a color alone do not undo. +const FOCUS = + 'focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-(--text-color)' + +/** + * A question before something is removed that cannot be undone (WCAG 3.3.4). It is the Base + * UI alert dialog, as in `DeleteGymnastButton`: the focus stays inside it, Escape closes it, + * and it is read as a dialog with a title and a description. The first thing that can be + * reached is "No, keep it", so pressing Enter right away does not remove anything. + * + * The page owns it (`open`), and opens it from a button of its own. That way it stays when + * the thing it asks about is gone from the page, which is when the focus has to be moved. + * When it closes, the focus goes back to the button that opened it, unless `finalFocus` says + * otherwise (after a removal that button is gone). + * + * `onConfirm` does the removal. The dialog closes when it is done, also when it fails: the + * caller shows the error itself, because it knows where on the page to say it. While it works + * the answer is disabled, and a second press (Enter twice) is ignored. + */ +export default function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel, + busyLabel, + onConfirm, + finalFocus, +}: { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description: ReactNode + confirmLabel: string + busyLabel: string + onConfirm: () => Promise + /** + * Where the focus goes when the dialog closes: an element, `true` for the way the dialog + * does it (back to what had the focus), or `false` to leave the focus alone (the caller moves it). + */ + finalFocus?: () => HTMLElement | boolean | null +}) { + const [busy, setBusy] = useState(false) + // The state changes one render too late for a second press that comes at once. + const working = useRef(false) + + async function confirm() { + if (working.current) return + working.current = true + setBusy(true) + try { + await onConfirm() + } catch { + // The caller has shown what went wrong; the dialog has nothing more to say. + } finally { + working.current = false + setBusy(false) + onOpenChange(false) + } + } + + return ( + + + {/* The page behind is dimmed. */} + + + + + + ) +} diff --git a/src/components/gymnasts/DeleteGymnastButton.tsx b/src/components/gymnasts/DeleteGymnastButton.tsx index 6108f67..81e3e85 100644 --- a/src/components/gymnasts/DeleteGymnastButton.tsx +++ b/src/components/gymnasts/DeleteGymnastButton.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' import { useMutation } from '@apollo/client/react' -import { AlertDialog } from '@base-ui/react/alert-dialog' -import { Trash2, TriangleAlert } from 'lucide-react' +import { Trash2 } from 'lucide-react' +import ConfirmDialog from '@/components/ConfirmDialog' import { Button } from '@/components/ui/button' import { describeError } from '@/lib/apollo/errors' import { @@ -13,10 +13,11 @@ import type { GymnastSummary } from '@/lib/gymnasts' /** * Deletes a gymnast, after the coach has confirmed it in a dialog. Deleting * cannot be undone and takes the gymnast's routines and list of elements with - * it, so a click on the icon only opens the question. The dialog is the Base UI - * alert dialog: the focus stays inside it, Escape closes it, and it is read as - * a dialog with a title and a description. The button is named after the - * gymnast, so a list of them can be told apart by a screen reader. + * it, so a click on the icon only opens the question. The question is the shared + * `ConfirmDialog` (the Base UI alert dialog: the focus stays inside it, Escape + * closes it, and it is read as a dialog with a title and a description). The + * button is named after the gymnast, so a list of them can be told apart by a + * screen reader. * * `onDeleted` is called with the name when the server has deleted the gymnast, * and `onFailed` with the message when it has not. The page tells the visitor @@ -32,62 +33,54 @@ export default function DeleteGymnastButton({ onFailed: (message: string) => void }) { const [open, setOpen] = useState(false) + // True after a deletion: the page has moved the focus, and the dialog must not move it back + // to this button, which is about to be gone. + const deleted = useRef(false) // The list is fetched again: the gymnast is gone from it, and the API sorts it. - const [deleteGymnast, { loading }] = useMutation(DELETE_GYMNAST_MUTATION, { + const [deleteGymnast] = useMutation(DELETE_GYMNAST_MUTATION, { refetchQueries: [GYMNASTS_QUERY], }) async function confirm() { + deleted.current = false try { await deleteGymnast({ variables: { id: gymnast.id } }) - setOpen(false) - onDeleted(gymnast.name) } catch (problem) { setOpen(false) onFailed(describeError(problem).message) + return } + deleted.current = true + setOpen(false) + onDeleted(gymnast.name) } return ( - - - } + <> + + You’ll permanently lose {gymnast.name}’s: @@ -96,29 +89,13 @@ export default function DeleteGymnastButton({
  • Routines
  • List of elements
  • - -
    - - } - > - No, keep it - - -
    - - -
    + + } + confirmLabel="Yes, delete" + busyLabel="Deleting…" + onConfirm={confirm} + finalFocus={() => !deleted.current} + /> + ) } diff --git a/src/tests/components/ConfirmDialog.test.tsx b/src/tests/components/ConfirmDialog.test.tsx new file mode 100644 index 0000000..bddaf17 --- /dev/null +++ b/src/tests/components/ConfirmDialog.test.tsx @@ -0,0 +1,160 @@ +// @vitest-environment jsdom +// The dialog that asks "are you sure?" before something is removed (WCAG 3.3.4). + +import { useRef, useState } from 'react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import ConfirmDialog from '@/components/ConfirmDialog' + +afterEach(cleanup) + +/** The page that owns the dialog: a button opens it, as the Gymnast page does. */ +function Page({ + onConfirm, + focusElsewhere, +}: { + onConfirm: () => Promise + focusElsewhere: boolean +}) { + const [open, setOpen] = useState(false) + const elsewhere = useRef(null) + return ( + <> + +

    + Somewhere else +

    + elsewhere.current : undefined} + /> + + ) +} + +const renderDialog = ( + onConfirm: () => Promise = () => Promise.resolve(), + focusElsewhere = false, +) => render() + +const open = async () => { + const opener = screen.getByRole('button', { name: 'Remove it' }) + opener.focus() // a click focuses a button in the browser; fireEvent does not + fireEvent.click(opener) + return screen.findByRole('alertdialog') +} + +describe('ConfirmDialog', () => { + it('is closed until the page opens it', () => { + renderDialog() + + expect(screen.queryByRole('alertdialog')).toBeNull() + }) + + it('has a title and a description that are read as the name and description of the dialog', async () => { + renderDialog() + + const dialog = await open() + expect( + screen.getByRole('alertdialog', { name: 'Remove element' }), + ).toBeTruthy() + expect(dialog.getAttribute('aria-describedby')).toBeTruthy() + expect(dialog.textContent).toContain('1.302 will be removed from the list.') + }) + + it('puts the focus on the answer that does nothing, so Enter right away does not remove', async () => { + renderDialog() + await open() + + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole('button', { name: 'No, keep it' }), + ), + ) + }) + + it('closes with Escape, does not confirm, and gives the focus back to the button that opened it', async () => { + const onConfirm = vi.fn(() => Promise.resolve()) + renderDialog(onConfirm) + await open() + + fireEvent.keyDown(screen.getByRole('alertdialog'), { key: 'Escape' }) + + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + expect(onConfirm).not.toHaveBeenCalled() + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole('button', { name: 'Remove it' }), + ), + ) + }) + + it('calls onConfirm once and closes when it is done', async () => { + const onConfirm = vi.fn(() => Promise.resolve()) + renderDialog(onConfirm) + await open() + + fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })) + + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + + it('moves the focus where finalFocus says when it closes', async () => { + renderDialog(() => Promise.resolve(), true) + await open() + + fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })) + + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole('heading', { name: 'Somewhere else' }), + ), + ) + }) + + it('shows the busy text and cannot be confirmed again while it works', async () => { + let finish!: () => void + const onConfirm = vi.fn( + () => new Promise((resolve) => (finish = resolve)), + ) + renderDialog(onConfirm) + await open() + + fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })) + const busy = await screen.findByRole('button', { name: 'Removing…' }) + fireEvent.click(busy) + + expect( + busy.hasAttribute('disabled') || + busy.getAttribute('aria-disabled') === 'true', + ).toBe(true) + expect(onConfirm).toHaveBeenCalledTimes(1) + finish() + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + }) + + it('closes also when onConfirm fails, so the page can say what happened', async () => { + const onConfirm = vi.fn(() => Promise.reject(new Error('no'))) + renderDialog(onConfirm) + await open() + + fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })) + + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + }) +}) From a001cdace5bfe9597c35f110a729d14ec04344e5 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 11:56:00 +0100 Subject: [PATCH 08/32] feat(gymnasts): group the data of one gymnast by apparatus One query asks for the gymnast with all routines and elements. gymnastDetail.ts groups them by apparatus and marks the highest routine, so changing apparatus costs no request. --- src/lib/gymnastDetail.ts | 173 +++++++++++++++++++++++++++ src/lib/gymnastDetailOperations.ts | 114 ++++++++++++++++++ src/tests/gymnastDetail.ts | 147 +++++++++++++++++++++++ src/tests/lib/gymnastDetail.test.ts | 178 ++++++++++++++++++++++++++++ 4 files changed, 612 insertions(+) create mode 100644 src/lib/gymnastDetail.ts create mode 100644 src/lib/gymnastDetailOperations.ts create mode 100644 src/tests/gymnastDetail.ts create mode 100644 src/tests/lib/gymnastDetail.test.ts diff --git a/src/lib/gymnastDetail.ts b/src/lib/gymnastDetail.ts new file mode 100644 index 0000000..b1501a6 --- /dev/null +++ b/src/lib/gymnastDetail.ts @@ -0,0 +1,173 @@ +// What the Gymnast page shows, worked out from the answer of the API: the routines and the +// elements of the gymnast grouped by apparatus, and what "highest" means. No React and no +// network, so it is tested by itself (src/tests/lib/gymnastDetail.test.ts). + +import type { + DetailApparatus, + DetailItem, + DetailRoutine, + GymnastDetailFields, +} from '@/lib/gymnastDetailOperations' + +export type RoutineView = { + id: string + name: string + /** The D-score, or null when the routine has an element that is no longer in the Code of Points. */ + score: number | null + hasRemovedItem: boolean + itemCount: number + /** ISO date of the last change; show it with `formatUpdated`. */ + updatedAt: string + /** One of the routines with the highest score (only marked when there are two or more to compare). */ + isHighest: boolean +} + +export type ElementView = { + id: string + apparatusId: string + code: string + name: string | null + description: string + removed: boolean + /** The value of an element, or the D-value of a vault. */ + value: number + isVault: boolean +} + +export type ApparatusView = { + id: string + name: string + /** The highest score of the routines on this apparatus, or null when there is none. */ + highest: number | null + routines: RoutineView[] + elements: ElementView[] +} + +export type GymnastView = { + name: string + routineCount: number + elementCount: number + apparatus: ApparatusView[] +} + +/** What names an element: the name it is known by, else its description. */ +export const elementLabel = ( + element: Pick, +) => element.name ?? element.description + +/** The id of the heading of the list of an apparatus, so the page can move the focus to it. */ +export const elementsHeadingId = (apparatusId: string) => + `elements-heading-${apparatusId}` + +/** Highest score first, a routine without a score last, and the name when the scores are equal. */ +function byScoreThenName(a: RoutineView, b: RoutineView): number { + if (a.score !== b.score) { + if (a.score === null) return 1 + if (b.score === null) return -1 + return b.score - a.score + } + return a.name.localeCompare(b.name) +} + +function toRoutineView(routine: DetailRoutine): RoutineView { + return { + id: routine.id, + name: routine.name, + score: routine.dScore, + hasRemovedItem: routine.items.some((item) => item.removed), + itemCount: routine.items.length, + updatedAt: routine.updatedAt, + isHighest: false, + } +} + +function toElementView(item: DetailItem): ElementView { + const isVault = item.__typename === 'Vault' + return { + id: item.id, + apparatusId: item.elementGroup.apparatus.id, + code: item.code, + name: item.name, + description: item.description, + removed: item.removed, + value: isVault ? item.dValue : item.value, + isVault, + } +} + +function toApparatusView( + apparatus: DetailApparatus, + gymnast: GymnastDetailFields, +): ApparatusView { + const routines = gymnast.routines + .filter((routine) => routine.apparatus.id === apparatus.id) + .map(toRoutineView) + .sort(byScoreThenName) + const scores = routines.flatMap((routine) => + routine.score === null ? [] : [routine.score], + ) + const highest = scores.length > 0 ? Math.max(...scores) : null + // "Highest" says nothing about a single routine, so it needs two to compare. + if (scores.length >= 2) { + for (const routine of routines) + routine.isHighest = routine.score === highest + } + return { + id: apparatus.id, + name: apparatus.name, + highest, + routines, + elements: gymnast.elements + .filter((item) => item.elementGroup.apparatus.id === apparatus.id) + .map(toElementView), + } +} + +/** + * The gymnast as the page shows it. `apparatus` is the structure from the API, so all the + * apparatus are there in the same order as everywhere else, also those with nothing yet. + */ +export function buildGymnastView( + gymnast: GymnastDetailFields, + apparatus: DetailApparatus[], +): GymnastView { + return { + name: gymnast.name, + routineCount: gymnast.routines.length, + elementCount: gymnast.elements.length, + apparatus: apparatus.map((one) => toApparatusView(one, gymnast)), + } +} + +/** The apparatus to open first: the first with a routine, else the first with an element, else the first. */ +export function defaultApparatusId(view: GymnastView): string { + const first = + view.apparatus.find((a) => a.routines.length > 0) ?? + view.apparatus.find((a) => a.elements.length > 0) ?? + view.apparatus[0] + return first?.id ?? '' +} + +const DAY_AND_MONTH = new Intl.DateTimeFormat('en-US', { + day: 'numeric', + month: 'short', +}) + +/** "27 Sep". The parts are put together here, because the order of them depends on the locale. */ +export function formatUpdated(iso: string): string { + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return '' + const parts = DAY_AND_MONTH.formatToParts(date) + const day = parts.find((part) => part.type === 'day')?.value + const month = parts.find((part) => part.type === 'month')?.value + return `${day} ${month}` +} + +/** "1 routine", "8 elements". */ +export function countText( + count: number, + singular: string, + plural = `${singular}s`, +): string { + return `${count} ${count === 1 ? singular : plural}` +} diff --git a/src/lib/gymnastDetailOperations.ts b/src/lib/gymnastDetailOperations.ts new file mode 100644 index 0000000..dc17ca6 --- /dev/null +++ b/src/lib/gymnastDetailOperations.ts @@ -0,0 +1,114 @@ +// The two operations of the Gymnast page. The types are written by hand (two operations +// do not need code generation); the fields are the ones in docs/API.md ("gymnast", +// "Gymnast.routines", "Gymnast.elements" and "removeGymnastElement"). + +import { gql, type TypedDocumentNode } from '@apollo/client' + +export type DetailApparatus = { id: string; name: string } + +/** An element or a vault in the gymnast's list: an Element has `value`, a Vault has `dValue`. */ +export type DetailItem = { + id: string + code: string + name: string | null + description: string + /** True when the item is no longer in the Code of Points. It stays in the list. */ + removed: boolean + elementGroup: { apparatus: DetailApparatus } +} & ( + | { __typename: 'Element'; value: number } + | { __typename: 'Vault'; dValue: number } +) + +export type DetailRoutine = { + id: string + name: string + /** null when the routine has an item that is removed from the Code of Points. */ + dScore: number | null + updatedAt: string + apparatus: DetailApparatus + items: { id: string; removed: boolean }[] +} + +export type GymnastDetailFields = { + id: string + name: string + routines: DetailRoutine[] + elements: DetailItem[] +} + +// The same fields for the query and for the answer of the mutation, so the answer of +// "remove" is written to the cache as the list the page shows. +const ELEMENT_FIELDS = gql` + fragment GymnastListItem on CatalogItem { + id + code + name + description + removed + elementGroup { + id + apparatus { + id + name + } + } + ... on Element { + value + } + ... on Vault { + dValue + } + } +` + +/** + * One gymnast with all the routines and the whole list of elements. Both are bounded by + * the API (50 routines and 100 elements per gymnast), so the page asks once and groups + * them by apparatus itself. It does not ask for the elements of each routine, only how + * many there are and whether one is removed. + */ +export const GYMNAST_DETAIL_QUERY: TypedDocumentNode< + { gymnast: GymnastDetailFields | null }, + { id: string } +> = gql` + query GymnastDetail($id: ID!) { + gymnast(id: $id) { + id + name + routines { + id + name + dScore + updatedAt + apparatus { + id + name + } + items { + id + removed + } + } + elements { + ...GymnastListItem + } + } + } + ${ELEMENT_FIELDS} +` + +export const REMOVE_GYMNAST_ELEMENT_MUTATION: TypedDocumentNode< + { removeGymnastElement: Pick }, + { gymnastId: string; itemId: string } +> = gql` + mutation RemoveGymnastElement($gymnastId: ID!, $itemId: ID!) { + removeGymnastElement(gymnastId: $gymnastId, itemId: $itemId) { + id + elements { + ...GymnastListItem + } + } + } + ${ELEMENT_FIELDS} +` diff --git a/src/tests/gymnastDetail.ts b/src/tests/gymnastDetail.ts new file mode 100644 index 0000000..b726e9d --- /dev/null +++ b/src/tests/gymnastDetail.ts @@ -0,0 +1,147 @@ +// What the server says to `GymnastDetail` and `CatalogStructure`, for the tests of the +// Gymnast page: one gymnast with routines and elements on three of the four apparatus. +// The ids, codes and texts are real ones of the catalog. + +import { ok } from '@/tests/apollo' + +export const APPARATUS = [ + { id: 'VT', name: 'Vault' }, + { id: 'UB', name: 'Uneven bars' }, + { id: 'BB', name: 'Balance beam' }, + { id: 'FX', name: 'Floor exercise' }, +] as const + +const apparatus = (id: string) => { + const found = APPARATUS.find((a) => a.id === id)! + return { __typename: 'Apparatus', ...found } +} + +/** What the server says to `CatalogStructure`: the four apparatus. */ +export const structureAnswer = () => + ok({ + apparatus: APPARATUS.map((a) => ({ + __typename: 'Apparatus', + ...a, + difficultyValues: [], + groups: [], + })), + }) + +/** An element of the gymnast's list (an Element, with `value`). */ +export function elementItem( + id: string, + code: string, + apparatusId: string, + fields: { + name?: string | null + description?: string + value?: number + removed?: boolean + } = {}, +) { + return { + __typename: 'Element', + id, + code, + name: fields.name ?? null, + description: fields.description ?? `Description of ${code}`, + removed: fields.removed ?? false, + value: fields.value ?? 0.3, + elementGroup: { + __typename: 'ElementGroup', + id: `${apparatusId}-1`, + apparatus: apparatus(apparatusId), + }, + } +} + +/** A vault of the gymnast's list (a Vault, with `dValue`). */ +export function vaultItem( + id: string, + code: string, + fields: { description?: string; dValue?: number } = {}, +) { + return { + __typename: 'Vault', + id, + code, + name: null, + description: fields.description ?? `Description of ${code}`, + removed: false, + dValue: fields.dValue ?? 5.8, + elementGroup: { + __typename: 'ElementGroup', + id: 'VT-1', + apparatus: apparatus('VT'), + }, + } +} + +/** A routine with `count` items (only their ids and whether they are removed are asked for). */ +export function routine( + id: string, + name: string, + apparatusId: string, + dScore: number | null, + fields: { count?: number; updatedAt?: string; removedItem?: boolean } = {}, +) { + const count = fields.count ?? 8 + return { + __typename: 'Routine', + id, + name, + dScore, + updatedAt: fields.updatedAt ?? '2026-09-27T12:00:00.000Z', + apparatus: apparatus(apparatusId), + items: Array.from({ length: count }, (_, index) => ({ + __typename: 'Element', + id: `${id}-item-${index + 1}`, + removed: fields.removedItem === true && index === 0, + })), + } +} + +export type ServerGymnast = ReturnType + +/** + * Ingrid Nilsen, as in the mockup: three routines on uneven bars (3.4, 3.0, 2.6), one on + * balance beam (2.8), nothing on floor and vault; elements on uneven bars and balance beam. + */ +export function exampleGymnast() { + return { + __typename: 'Gymnast', + id: 'gymnast-1', + name: 'Ingrid Nilsen', + routines: [ + routine('r-1', 'Competition 1', 'UB', 3.0, { + count: 10, + updatedAt: '2026-09-20T12:00:00.000Z', + }), + routine('r-2', 'Upgrade with Ricna', 'UB', 3.4, { count: 9 }), + routine('r-3', 'Safe set', 'UB', 2.6, { + count: 8, + updatedAt: '2026-09-01T12:00:00.000Z', + }), + routine('r-4', 'Beam set', 'BB', 2.8, { count: 7 }), + ], + elements: [ + elementItem('UB-1.302-a', '1.302', 'UB', { + description: 'Glide kip, cast to handstand with 1/2 turn', + value: 0.3, + }), + elementItem('UB-4.504-a', '4.504', 'UB', { + name: 'Ricna', + description: 'Stalder to handstand', + value: 0.5, + }), + elementItem('BB-5.205-a', '5.205', 'BB', { + description: 'Clear hip circle backward to handstand', + value: 0.2, + }), + ], + } +} + +/** The answer of `GymnastDetail` for a gymnast. */ +export const gymnastAnswer = (gymnast: unknown = exampleGymnast()) => + ok({ gymnast }) diff --git a/src/tests/lib/gymnastDetail.test.ts b/src/tests/lib/gymnastDetail.test.ts new file mode 100644 index 0000000..34f7249 --- /dev/null +++ b/src/tests/lib/gymnastDetail.test.ts @@ -0,0 +1,178 @@ +// The logic behind the Gymnast page: how the routines and the elements of a gymnast are +// grouped by apparatus, and what "highest" means. No React and no network. + +import { describe, expect, it } from 'vitest' +import { + buildGymnastView, + countText, + defaultApparatusId, + formatUpdated, +} from '@/lib/gymnastDetail' +import { + APPARATUS, + elementItem, + exampleGymnast, + routine, + vaultItem, +} from '@/tests/gymnastDetail' + +const apparatusList = APPARATUS.map((a) => ({ id: a.id, name: a.name })) + +const viewOf = (gymnast: ReturnType) => + buildGymnastView(gymnast as never, apparatusList) + +const byId = (view: ReturnType, id: string) => + view.apparatus.find((a) => a.id === id)! + +describe('buildGymnastView', () => { + it('has all four apparatus, in the order of the structure, even without data', () => { + const view = viewOf({ ...exampleGymnast(), routines: [], elements: [] }) + + expect(view.apparatus.map((a) => a.id)).toEqual(['VT', 'UB', 'BB', 'FX']) + expect(view.apparatus.every((a) => a.highest === null)).toBe(true) + }) + + it('groups routines and elements by apparatus', () => { + const view = viewOf(exampleGymnast()) + + expect(byId(view, 'UB').routines).toHaveLength(3) + expect(byId(view, 'UB').elements).toHaveLength(2) + expect(byId(view, 'BB').routines).toHaveLength(1) + expect(byId(view, 'BB').elements).toHaveLength(1) + expect(byId(view, 'FX').routines).toHaveLength(0) + expect(byId(view, 'VT').elements).toHaveLength(0) + }) + + it('counts the routines and the elements of the whole gymnast', () => { + const view = viewOf(exampleGymnast()) + + expect(view.name).toBe('Ingrid Nilsen') + expect(view.routineCount).toBe(4) + expect(view.elementCount).toBe(3) + }) + + it('gives the highest score of each apparatus, and null when there is none', () => { + const view = viewOf(exampleGymnast()) + + expect(byId(view, 'UB').highest).toBe(3.4) + expect(byId(view, 'BB').highest).toBe(2.8) + expect(byId(view, 'FX').highest).toBeNull() + }) + + it('sorts the routines by score, highest first, and by name when equal', () => { + const gymnast = exampleGymnast() + gymnast.routines = [ + routine('a', 'Zebra', 'UB', 3.0), + routine('b', 'Alpha', 'UB', 3.0), + routine('c', 'Top', 'UB', 3.4), + ] + const names = byId(viewOf(gymnast), 'UB').routines.map((r) => r.name) + + expect(names).toEqual(['Top', 'Alpha', 'Zebra']) + }) + + it('does not count a routine without a score (a removed element), and puts it last', () => { + const gymnast = exampleGymnast() + gymnast.routines = [ + routine('a', 'Broken', 'UB', null, { removedItem: true }), + routine('b', 'Fine', 'UB', 2.0), + ] + const ub = byId(viewOf(gymnast), 'UB') + + expect(ub.highest).toBe(2.0) + expect(ub.routines.map((r) => r.name)).toEqual(['Fine', 'Broken']) + expect(ub.routines[1].score).toBeNull() + expect(ub.routines[1].hasRemovedItem).toBe(true) + }) + + it('marks the highest routine only when there is more than one with a score', () => { + const view = viewOf(exampleGymnast()) + const ub = byId(view, 'UB').routines + const bb = byId(view, 'BB').routines + + expect(ub.map((r) => r.isHighest)).toEqual([true, false, false]) + expect(bb.map((r) => r.isHighest)).toEqual([false]) + }) + + it('marks every routine that shares the highest score', () => { + const gymnast = exampleGymnast() + gymnast.routines = [ + routine('a', 'One', 'UB', 3.4), + routine('b', 'Two', 'UB', 3.4), + routine('c', 'Three', 'UB', 2.0), + ] + const flags = byId(viewOf(gymnast), 'UB').routines.map((r) => r.isHighest) + + expect(flags).toEqual([true, true, false]) + }) + + it('counts the items and keeps the date of the last change', () => { + const routineView = byId(viewOf(exampleGymnast()), 'UB').routines[0] + + expect(routineView.itemCount).toBe(9) + expect(routineView.updatedAt).toBe('2026-09-27T12:00:00.000Z') + }) + + it('reads the value of an element and the D-value of a vault', () => { + const gymnast = exampleGymnast() + gymnast.elements = [ + elementItem('UB-1.302-a', '1.302', 'UB', { value: 0.3 }), + vaultItem('VT-2.50-a', '2.50', { dValue: 5.8 }), + ] as typeof gymnast.elements + const view = viewOf(gymnast) + + expect(byId(view, 'UB').elements[0]).toMatchObject({ + value: 0.3, + isVault: false, + }) + expect(byId(view, 'VT').elements[0]).toMatchObject({ + value: 5.8, + isVault: true, + }) + }) + + it('keeps removed elements and says so', () => { + const gymnast = exampleGymnast() + gymnast.elements = [ + elementItem('UB-9.999-a', '9.999', 'UB', { removed: true }), + ] + + expect(byId(viewOf(gymnast), 'UB').elements[0].removed).toBe(true) + }) +}) + +describe('defaultApparatusId', () => { + it('is the first apparatus that has a routine', () => { + expect(defaultApparatusId(viewOf(exampleGymnast()))).toBe('UB') + }) + + it('is the first apparatus with an element when there is no routine', () => { + const gymnast = { ...exampleGymnast(), routines: [] } + + expect(defaultApparatusId(viewOf(gymnast))).toBe('UB') + }) + + it('is the first apparatus when the gymnast has nothing', () => { + const gymnast = { ...exampleGymnast(), routines: [], elements: [] } + + expect(defaultApparatusId(viewOf(gymnast))).toBe('VT') + }) +}) + +describe('formatUpdated', () => { + it('writes the day and the short month', () => { + expect(formatUpdated('2026-09-27T12:00:00.000Z')).toBe('27 Sep') + }) + + it('gives an empty text for a date that is not valid', () => { + expect(formatUpdated('not a date')).toBe('') + }) +}) + +describe('countText', () => { + it('uses the singular for one and the plural for the rest', () => { + expect(countText(1, 'routine')).toBe('1 routine') + expect(countText(0, 'routine')).toBe('0 routines') + expect(countText(8, 'element')).toBe('8 elements') + }) +}) From 9992b7516d92ddf4e0815e77c4a70913f7d3cfc5 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 11:56:00 +0100 Subject: [PATCH 09/32] feat(gymnasts): add the gymnast page Route /gymnasts/:gymnastId behind the login check, with a tab for each apparatus, the routines of the chosen apparatus, the list of elements with a remove button for each element, and loading, error, empty and not-found states. Refs: #149 --- src/App.tsx | 2 + src/components/gymnasts/ApparatusTabs.tsx | 60 +++ .../gymnasts/GymnastElementList.tsx | 111 ++++ src/components/gymnasts/GymnastHeader.tsx | 18 + src/components/gymnasts/GymnastPageStates.tsx | 96 ++++ src/components/gymnasts/RoutineList.tsx | 81 +++ src/components/gymnasts/listCard.ts | 13 + src/components/gymnasts/useElementRemoval.ts | 76 +++ src/pages/GymnastPage.tsx | 128 +++++ src/styles/global.css | 32 +- src/tests/App.test.tsx | 12 + src/tests/pages/GymnastPage.test.tsx | 486 ++++++++++++++++++ src/tests/styles/gymnastTheme.test.ts | 1 + 13 files changed, 1113 insertions(+), 3 deletions(-) create mode 100644 src/components/gymnasts/ApparatusTabs.tsx create mode 100644 src/components/gymnasts/GymnastElementList.tsx create mode 100644 src/components/gymnasts/GymnastHeader.tsx create mode 100644 src/components/gymnasts/GymnastPageStates.tsx create mode 100644 src/components/gymnasts/RoutineList.tsx create mode 100644 src/components/gymnasts/listCard.ts create mode 100644 src/components/gymnasts/useElementRemoval.ts create mode 100644 src/pages/GymnastPage.tsx create mode 100644 src/tests/pages/GymnastPage.test.tsx diff --git a/src/App.tsx b/src/App.tsx index b9bd1be..558b1f2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import AboutPage from '@/pages/AboutPage' import CalculatorPage from '@/pages/CalculatorPage' import ElementDetailPage from '@/pages/ElementDetailPage' import ElementsPage from '@/pages/ElementsPage' +import GymnastPage from '@/pages/GymnastPage' import LoginPage from '@/pages/LoginPage' import NotFoundPage from '@/pages/NotFoundPage' import YourGymnastsPage from '@/pages/YourGymnastsPage' @@ -53,6 +54,7 @@ export function App() { {/* Protected routes requiring login */} }> } /> + } /> } /> diff --git a/src/components/gymnasts/ApparatusTabs.tsx b/src/components/gymnasts/ApparatusTabs.tsx new file mode 100644 index 0000000..b323545 --- /dev/null +++ b/src/components/gymnasts/ApparatusTabs.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import type { ApparatusView } from '@/lib/gymnastDetail' + +/** + * One tab for each apparatus, with only its name. It is the shadcn/ui + * tabs (`components/ui/tabs.tsx`, on the Base UI tabs, so the WAI-ARIA tabs pattern: arrow + * keys, Home and End, a panel for each tab) in its "line" look. The panel of the chosen + * apparatus is made by `renderPanel`. The tabs follow the focus, because a panel shows at + * once and costs nothing to open. + * + * The classes only set what the shadcn/ui defaults cannot do on this site: the colors of the + * site (the defaults are 60 % gray text and a faint focus ring, which are not enough, WCAG + * 1.4.3 and 2.4.7), a tab that is 44 px high, and a hover that can be seen. The chosen tab + * has the line of the component under it and a bold name, so it is not told by a tone alone + * (WCAG 1.4.1), and `aria-selected` tells a screen reader. + */ +export default function ApparatusTabs({ + apparatus, + value, + onValueChange, + renderPanel, +}: { + apparatus: ApparatusView[] + value: string + onValueChange: (id: string) => void + renderPanel: (apparatus: ApparatusView) => ReactNode +}) { + return ( + onValueChange(String(id))}> + + {apparatus.map((one) => ( + + + {one.name} + + + ))} + + {apparatus.map((one) => ( + + {renderPanel(one)} + + ))} + + ) +} diff --git a/src/components/gymnasts/GymnastElementList.tsx b/src/components/gymnasts/GymnastElementList.tsx new file mode 100644 index 0000000..977cde5 --- /dev/null +++ b/src/components/gymnasts/GymnastElementList.tsx @@ -0,0 +1,111 @@ +import { LIST_CARD, ROW_HOVER } from '@/components/gymnasts/listCard' +import { Link } from 'react-router-dom' +import { TriangleAlert, X } from 'lucide-react' +import DifficultyBadge from '@/components/ui/DifficultyBadge' +import { Button } from '@/components/ui/button' +import { getDifficultyLetter } from '@/lib/difficulty' +import { + elementLabel, + elementsHeadingId, + type ElementView, +} from '@/lib/gymnastDetail' + +/** One element of the list: its code and name (a link to the element), its difficulty, and a button that asks to remove it. */ +function ElementRow({ + element, + onAskRemove, +}: { + element: ElementView + onAskRemove: (element: ElementView, opener: HTMLElement) => void +}) { + const label = elementLabel(element) + // An element has a value 0.1-1.0 that is shown as a letter A-J; a vault has a D-value. + const difficulty = element.isVault + ? element.value + : (getDifficultyLetter(element.value) ?? element.value) + + return ( +
  • +
    + + {element.code}{' '} + {label} + + {element.removed && ( +

    +

    + )} +
    + + +
  • + ) +} + +/** + * The elements the gymnast can do or is practicing on one apparatus, in the order they were + * added. The page can move the focus to the heading (`elementsHeadingId`): after an element + * has been removed the button that was pressed is gone, and this is where the visitor was. + * The id has the apparatus in it, because the panel of the apparatus that was left is still + * in the page for a moment, and two headings must not have the same id. + */ +export default function GymnastElementList({ + apparatusId, + elements, + apparatusName, + onAskRemove, +}: { + apparatusId: string + elements: ElementView[] + apparatusName: string + /** The visitor pressed the remove button of an element. The page asks "are you sure?". */ + onAskRemove: (element: ElementView, opener: HTMLElement) => void +}) { + const headingId = elementsHeadingId(apparatusId) + return ( +
    +

    + Element list +

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

    No elements on {apparatusName} in the list yet.

    + ) : ( +
      + {elements.map((element) => ( + + ))} +
    + )} +
    + ) +} diff --git a/src/components/gymnasts/GymnastHeader.tsx b/src/components/gymnasts/GymnastHeader.tsx new file mode 100644 index 0000000..386ba5e --- /dev/null +++ b/src/components/gymnasts/GymnastHeader.tsx @@ -0,0 +1,18 @@ +import { ChevronLeft } from 'lucide-react' +import { Link } from 'react-router-dom' + +/** The way back and the name of the gymnast (the h1 of the page). */ +export default function GymnastHeader({ name }: { name: string }) { + return ( +
    + +
    + ) +} diff --git a/src/components/gymnasts/GymnastPageStates.tsx b/src/components/gymnasts/GymnastPageStates.tsx new file mode 100644 index 0000000..7f277e6 --- /dev/null +++ b/src/components/gymnasts/GymnastPageStates.tsx @@ -0,0 +1,96 @@ +import { Link } from 'react-router-dom' +import PageTitle from '@/components/PageTitle' +import { Button } from '@/components/ui/button' +import { PILL } from '@/components/ui/pill' +import type { DescribedError } from '@/lib/apollo/errors' +import { LIST_CARD } from '@/components/gymnasts/listCard' + +/** The classes of the `
    ` of the Gymnast page, in all its states. */ +export const GYMNAST_PAGE = + 'gymnast-page min-h-svh bg-(--page-bg) text-(--text-color) px-5 py-7 text-left sm:px-8 sm:py-8 lg:px-12' + +/** The server says that this gymnast is not one of the coach's (or does not exist). */ +export function GymnastNotFound() { + return ( +
    + Gymnast not found +

    Gymnast not found

    +

    + No gymnast of yours matches this address. +

    + +
    + ) +} + +/** The gymnast could not be loaded: what went wrong, and a way on (log in, or try again). */ +export function GymnastUnavailable({ + problem, + gymnastId, + onRetry, +}: { + problem: DescribedError + gymnastId: string + onRetry: () => void +}) { + return ( +
    + Gymnast unavailable +

    Gymnast unavailable

    +

    + {problem.message} +

    + {problem.code === 'UNAUTHENTICATED' ? ( + + ) : ( + problem.retryable && ( + + ) + )} +
    + ) +} + +/** A skeleton of the page while the first answer is on its way. */ +export function GymnastLoading() { + return ( +
    + Loading gymnast +

    + Loading gymnast +

    +
    + ) +} diff --git a/src/components/gymnasts/RoutineList.tsx b/src/components/gymnasts/RoutineList.tsx new file mode 100644 index 0000000..f459ce2 --- /dev/null +++ b/src/components/gymnasts/RoutineList.tsx @@ -0,0 +1,81 @@ +import { LIST_CARD, ROW_HOVER } from '@/components/gymnasts/listCard' +import { TriangleAlert } from 'lucide-react' +import { countText, formatUpdated, type RoutineView } from '@/lib/gymnastDetail' + +/** One routine: its name, how big it is, when it changed and its D-score. */ +function RoutineRow({ routine }: { routine: RoutineView }) { + return ( +
  • +
    +

    + + {routine.name} + + {routine.isHighest && ( + + Highest + + )} +

    +

    + {countText(routine.itemCount, 'element')} + Updated{' '} + +

    + {routine.hasRemovedItem && ( +

    +

    + )} +
    +

    + {routine.score === null ? ( + + ) : ( + <> + D-score + {routine.score.toFixed(1)} + + )} +

    +
  • + ) +} + +/** The routines of one apparatus, highest score first. */ +export default function RoutineList({ + routines, + apparatusName, +}: { + routines: RoutineView[] + apparatusName: string +}) { + return ( +
    +

    Routines

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

    No routines on {apparatusName} yet.

    + ) : ( +
      + {routines.map((routine) => ( + + ))} +
    + )} +
    + ) +} diff --git a/src/components/gymnasts/listCard.ts b/src/components/gymnasts/listCard.ts new file mode 100644 index 0000000..dd8fd5c --- /dev/null +++ b/src/components/gymnasts/listCard.ts @@ -0,0 +1,13 @@ +/** + * The card behind a list of the Gymnast page (the routines and the elements): the color of + * the cards on the Elements page and the gymnasts page (`--surface`) and their shadow. + */ +export const LIST_CARD = 'bg-(--surface) shadow-(--box-shadow)' + +/** + * The hover of a row in such a card, as on the gymnasts page: the card gets a tint of the + * text color. It is the same mix as there (8 %, which keeps the text above 4.5:1 in both + * themes, see gymnastTheme.test.ts). The pointer is for the rows that can be pressed. + */ +export const ROW_HOVER = + 'transition-[background-color] duration-150 hover:bg-[color-mix(in_srgb,var(--surface),var(--text-color)_8%)] motion-reduce:transition-none' diff --git a/src/components/gymnasts/useElementRemoval.ts b/src/components/gymnasts/useElementRemoval.ts new file mode 100644 index 0000000..c338f3e --- /dev/null +++ b/src/components/gymnasts/useElementRemoval.ts @@ -0,0 +1,76 @@ +import { useEffect, useRef, useState } from 'react' +import { useMutation } from '@apollo/client/react' +import { describeError } from '@/lib/apollo/errors' +import { elementsHeadingId, type ElementView } from '@/lib/gymnastDetail' +import { REMOVE_GYMNAST_ELEMENT_MUTATION } from '@/lib/gymnastDetailOperations' + +/** + * Removing an element from the list of a gymnast: the question "are you sure?" (the page + * shows it with `ConfirmDialog`), the removal, what is told to the visitor, and where the + * focus goes. The answer of the server updates the Apollo cache, so the list on the page is + * the new list without a second request. + */ +export function useElementRemoval(gymnastId: string) { + const [removeElement] = useMutation(REMOVE_GYMNAST_ELEMENT_MUTATION) + // The element the visitor is asked about, and whether the question is open. The element is + // kept after the question closes, so the text of it does not change while it closes. + const [pending, setPending] = useState(null) + const [asking, setAsking] = useState(false) + // Said to a screen reader (a status region) when an element has been removed. + const [announcement, setAnnouncement] = useState('') + const [error, setError] = useState('') + // True when the last removal worked: the button that was pressed is gone with its row, so + // the focus goes to the heading of the list, which is where the visitor was. + const removedLast = useRef(false) + // The button that opened the question, which gets the focus back when the answer is no. + // Not every browser focuses a button when it is clicked (Safari does not), so it is kept. + const opener = useRef(null) + + // After a removal the question has closed and the button is gone. The dialog is told to + // leave the focus alone (`finalFocus`), and it is moved here, when the dialog is gone. + // The heading is found by its id: the page has a list for each apparatus, and the one that + // was left is still there for a moment, so one ref for all of them would end up empty. + useEffect(() => { + if (!asking && removedLast.current && pending) { + removedLast.current = false + document.getElementById(elementsHeadingId(pending.apparatusId))?.focus() + } + }, [asking, pending]) + + function ask(element: ElementView, button: HTMLElement) { + opener.current = button + setPending(element) + setAsking(true) + } + + /** Removes the element that was asked about. On a failure it keeps the list and says why. */ + async function remove(): Promise { + if (!pending) return + setError('') + setAnnouncement('') + removedLast.current = false + try { + await removeElement({ variables: { gymnastId, itemId: pending.id } }) + } catch (problem) { + setError(describeError(problem).message) + return + } + removedLast.current = true + setAnnouncement(`${pending.code} removed from the list.`) + } + + /** Where the dialog gives the focus when it closes: nowhere after a removal (see above), else back. */ + const finalFocus = () => + removedLast.current ? false : (opener.current ?? true) + + return { + pending, + asking, + setAsking, + announcement, + error, + ask, + remove, + finalFocus, + } +} diff --git a/src/pages/GymnastPage.tsx b/src/pages/GymnastPage.tsx new file mode 100644 index 0000000..5b2a0e9 --- /dev/null +++ b/src/pages/GymnastPage.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react' +import { useQuery } from '@apollo/client/react' +import { useParams } from 'react-router-dom' +import ConfirmDialog from '@/components/ConfirmDialog' +import ApparatusTabs from '@/components/gymnasts/ApparatusTabs' +import GymnastElementList from '@/components/gymnasts/GymnastElementList' +import GymnastHeader from '@/components/gymnasts/GymnastHeader' +import { + GYMNAST_PAGE, + GymnastLoading, + GymnastNotFound, + GymnastUnavailable, +} from '@/components/gymnasts/GymnastPageStates' +import RoutineList from '@/components/gymnasts/RoutineList' +import { useElementRemoval } from '@/components/gymnasts/useElementRemoval' +import PageTitle from '@/components/PageTitle' +import { describeError } from '@/lib/apollo/errors' +import { STRUCTURE_QUERY } from '@/lib/elementFilters' +import { + buildGymnastView, + defaultApparatusId, + elementLabel, +} from '@/lib/gymnastDetail' +import { GYMNAST_DETAIL_QUERY } from '@/lib/gymnastDetailOperations' + +/** One gymnast of the logged-in coach: the routines and the list of elements, one apparatus at a time. */ +export default function GymnastPage() { + const { gymnastId = '' } = useParams() + // The gymnast belongs to the coach and can change in other places, so what is in the + // cache is shown at once and the server is asked in the same moment. + const detail = useQuery(GYMNAST_DETAIL_QUERY, { + variables: { id: gymnastId }, + fetchPolicy: 'cache-and-network', + }) + // The four apparatus in the order of the API: a few rows that are kept for the whole visit. + const structure = useQuery(STRUCTURE_QUERY) + const removal = useElementRemoval(gymnastId) + // The apparatus the visitor chose; until then, the first one that has something. + const [chosen, setChosen] = useState(null) + + const gymnast = detail.data?.gymnast + const apparatus = structure.data?.apparatus + const problem = + detail.error || structure.error + ? describeError(detail.error ?? structure.error) + : null + + if (problem?.code === 'NOT_FOUND' || (detail.data && gymnast === null)) { + return + } + if (problem) { + return ( + + void Promise.all([detail.refetch(), structure.refetch()]).catch( + () => undefined, + ) + } + /> + ) + } + if (!gymnast || !apparatus) return + + const view = buildGymnastView(gymnast, apparatus) + + return ( +
    + {view.name} + {/* React 19 moves a to the head of the document. Only this page has the description. */} + + {/* Says what happened when an element is removed, without moving the focus there. */} +

    + {removal.announcement} +

    + + {removal.error && ( +

    + {removal.error} +

    + )} +
    + ( +
    + + +
    + )} + /> +
    + + + {removal.pending.code} {elementLabel(removal.pending)} + + + It is removed from the list of elements. Routines are not + changed. + + + ) + } + confirmLabel="Yes, remove" + busyLabel="Removing…" + onConfirm={removal.remove} + finalFocus={removal.finalFocus} + /> +
    + ) +} diff --git a/src/styles/global.css b/src/styles/global.css index 7feca8b..dbee06d 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -9,9 +9,11 @@ border-inline: 0; } -/* The titles of the Elements, gymnasts and login pages are the same: the size +/* The titles of the Elements, gymnasts, gymnast and login pages are the same: the size of h1 comes from index.css (56px, 36px on a narrow window) for all of them. */ -#root :is(.elements-page, .your-gymnasts-page, .login-page) :is(h1, h2) { +#root + :is(.elements-page, .your-gymnasts-page, .gymnast-page, .login-page) + :is(h1, h2) { color: var(--text-color); font-weight: 700; letter-spacing: -0.01em; @@ -56,6 +58,30 @@ color: var(--pill-text); } +/* The same two changes for the difficulty badge in the list of elements of the Gymnast + page, only in dark mode: the value under the letter is dark navy and cannot be read on + the dark card, and the circle gets the light color of the pills. The badge itself is not + changed. */ +[data-theme='dark'] + #root + .gymnast-page + li + .inline-flex.shrink-0.flex-col + > span + + span { + color: var(--text-color); +} + +[data-theme='dark'] + #root + .gymnast-page + li + .inline-flex.shrink-0.flex-col + > span:first-child { + background-color: var(--pill-bg); + color: var(--pill-text); +} + /* The X that clears the search field (Chrome, Edge and Safari draw one in a color that cannot be seen on our field). It is drawn here in the color of the search icon (--text-color), in both themes, with a 24 px target around a 16 px X. */ @@ -83,7 +109,7 @@ margin-bottom: 0.75rem; } -#root .your-gymnasts-page h2 { +#root :is(.your-gymnasts-page, .gymnast-page) h2 { font-size: clamp(1.375rem, 4vw, 1.75rem); line-height: 1.2; margin: 0; diff --git a/src/tests/App.test.tsx b/src/tests/App.test.tsx index cd9fbd2..73ccfc4 100644 --- a/src/tests/App.test.tsx +++ b/src/tests/App.test.tsx @@ -136,6 +136,18 @@ describe('App', () => { expect(screen.getByRole('link', { name: 'Log in' })).toBeTruthy() }) + it('sends a visitor who is not logged in from the page of one gymnast to the login page', async () => { + window.history.replaceState( + {}, + '', + `${import.meta.env.BASE_URL}gymnasts/gymnast-1`, + ) + renderApp(() => ok({ me: null })) + + expect(await screen.findByRole('heading', { name: 'Log in' })).toBeTruthy() + expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}login`) + }) + it('keeps a logged-in coach on the Gymnasts route after a reload, and the menu agrees with the page', async () => { window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`) renderApp((operation) => diff --git a/src/tests/pages/GymnastPage.test.tsx b/src/tests/pages/GymnastPage.test.tsx new file mode 100644 index 0000000..f81362f --- /dev/null +++ b/src/tests/pages/GymnastPage.test.tsx @@ -0,0 +1,486 @@ +// @vitest-environment jsdom +// The Gymnast page runs on the real Apollo Client, with a stub for `fetch` (see +// src/tests/apollo.ts). The stub answers like the server does. + +import { ApolloProvider } from '@apollo/client/react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { afterEach, describe, expect, it } from 'vitest' +import { AuthProvider } from '@/components/auth/AuthProvider' +import GymnastPage from '@/pages/GymnastPage' +import { + createTestClient, + deferred, + failure, + ok, + type Answer, + type Handler, +} from '@/tests/apollo' +import { + exampleGymnast, + gymnastAnswer, + structureAnswer, + type ServerGymnast, +} from '@/tests/gymnastDetail' + +/** + * The page inside the same providers as in the app. `Me` (who is logged in) and the + * structure are answered here; `detail` answers `GymnastDetail`, `others` the rest. + */ +function renderPage( + detail: () => Answer | Promise = () => gymnastAnswer(), + others: Handler = () => failure('INTERNAL_SERVER_ERROR', 'unexpected'), + address = '/gymnasts/gymnast-1', +) { + const handler: Handler = (operation) => { + switch (operation.operationName) { + case 'Me': + return ok({ + me: { + __typename: 'Coach', + id: 'coach-1', + name: 'Anna', + email: 'anna@example.com', + }, + }) + case 'CatalogStructure': + return structureAnswer() + case 'GymnastDetail': + return detail() + default: + return others(operation) + } + } + const { client, calls } = createTestClient(handler) + render( + + + + + } /> + + + + , + ) + return { + calls, + removed: () => + calls.filter((c) => c.operationName === 'RemoveGymnastElement'), + } +} + +const tab = (name: RegExp | string) => screen.getByRole('tab', { name }) +const panel = () => screen.getByRole('tabpanel') +const routinesList = () => + within(panel()).getByRole('list', { name: 'Routines' }) +const elementsList = () => + within(panel()).getByRole('list', { name: 'Element list' }) + +afterEach(cleanup) + +describe('GymnastPage: loading', () => { + it('says that it is loading, and has a title for it (WCAG 2.4.2)', async () => { + const answer = deferred() + renderPage(() => answer.promise) + + expect(screen.getByRole('status').textContent).toContain('Loading') + expect(document.title).toBe('Loading gymnast – WAG CoP Planner') + answer.resolve(gymnastAnswer()) + expect( + await screen.findByRole('heading', { level: 1, name: 'Ingrid Nilsen' }), + ).toBeTruthy() + }) + + it('asks the server once for the gymnast and once for the apparatus', async () => { + const page = renderPage() + await screen.findByRole('heading', { level: 1 }) + + const names = page.calls.map((c) => c.operationName).sort() + expect(names.filter((n) => n === 'GymnastDetail')).toHaveLength(1) + expect(names.filter((n) => n === 'CatalogStructure')).toHaveLength(1) + expect( + page.calls.find((c) => c.operationName === 'GymnastDetail')?.variables, + ).toEqual({ + id: 'gymnast-1', + }) + }) +}) + +describe('GymnastPage: the header', () => { + it('has the name as the one h1 and the name in the title', async () => { + renderPage() + + const heading = await screen.findByRole('heading', { level: 1 }) + expect(heading.textContent).toBe('Ingrid Nilsen') + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1) + expect(document.title).toBe('Ingrid Nilsen – WAG CoP Planner') + }) + + it('has a description for search engines (the page is checked by Lighthouse)', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const meta = document.head.querySelector('meta[name="description"]') + expect(meta?.getAttribute('content')).toMatch(/routines/) + }) + + it('has a link back to all gymnasts', async () => { + renderPage() + + const link = await screen.findByRole('link', { name: 'All gymnasts' }) + expect(link.getAttribute('href')).toBe('/gymnasts') + }) +}) + +describe('GymnastPage: the apparatus', () => { + it('has a tab for each apparatus with only its name', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const tabs = within( + screen.getByRole('tablist', { name: 'Apparatus' }), + ).getAllByRole('tab') + expect(tabs.map((t) => t.textContent?.replace(/\s+/g, ' ').trim())).toEqual( + ['Vault', 'Uneven bars', 'Balance beam', 'Floor exercise'], + ) + }) + + it('opens the first apparatus that has a routine', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + expect(tab(/^Uneven bars/).getAttribute('aria-selected')).toBe('true') + expect(tab(/^Vault/).getAttribute('aria-selected')).toBe('false') + expect( + within(panel()) + .getAllByRole('heading', { level: 2 }) + .map((h) => h.textContent), + ).toEqual(['Routines', 'Element list']) + }) + + it('shows the routines and the elements of the chosen apparatus, and changes with the tab', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + expect(within(routinesList()).getAllByRole('listitem')).toHaveLength(3) + expect(within(elementsList()).getAllByRole('listitem')).toHaveLength(2) + + fireEvent.click(tab(/^Balance beam/)) + + await waitFor(() => + expect(within(routinesList()).getAllByRole('listitem')).toHaveLength(1), + ) + expect(within(routinesList()).getByText('Beam set')).toBeTruthy() + expect(within(elementsList()).getAllByRole('listitem')).toHaveLength(1) + }) + + it('can be used with the arrow keys, Home and End', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const ub = tab(/^Uneven bars/) + ub.focus() + fireEvent.keyDown(ub, { key: 'ArrowRight' }) + // The tab takes the focus, and its panel is shown with it (the tabs follow the focus). + await waitFor(() => + expect(document.activeElement).toBe(tab(/^Balance beam/)), + ) + await waitFor(() => + expect(tab(/^Balance beam/).getAttribute('aria-selected')).toBe('true'), + ) + + fireEvent.keyDown(document.activeElement!, { key: 'End' }) + await waitFor(() => + expect(document.activeElement).toBe(tab(/^Floor exercise/)), + ) + fireEvent.keyDown(document.activeElement!, { key: 'Home' }) + await waitFor(() => expect(document.activeElement).toBe(tab(/^Vault/))) + }) + + it('says that there is nothing yet, for an apparatus with no routines and no elements', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + fireEvent.click(tab(/^Floor exercise/)) + + expect( + await screen.findByText('No routines on Floor exercise yet.'), + ).toBeTruthy() + expect( + screen.getByText('No elements on Floor exercise in the list yet.'), + ).toBeTruthy() + }) +}) + +describe('GymnastPage: the routines', () => { + it('shows the name, the size, the date of the last change and the score', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const rows = within(routinesList()).getAllByRole('listitem') + expect(rows[0].textContent).toContain('Upgrade with Ricna') + expect(rows[0].textContent).toContain('9 elements · Updated 27 Sep') + expect(rows[0].textContent).toContain('3.4') + expect(rows[1].textContent).toContain('Competition 1') + expect(rows[2].textContent).toContain('Safe set') + }) + + it('marks the highest routine with text, and only that one', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const rows = within(routinesList()).getAllByRole('listitem') + expect(within(rows[0]).getByText('Highest')).toBeTruthy() + expect(within(rows[1]).queryByText('Highest')).toBeNull() + expect(within(rows[2]).queryByText('Highest')).toBeNull() + }) + + it('reads the score with a word before it, for a screen reader', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const first = within(routinesList()).getAllByRole('listitem')[0] + expect(first.textContent).toContain('D-score 3.4') + }) + + it('says that a routine without a score has an element that is gone, and does not show 0', async () => { + const gymnast: ServerGymnast = exampleGymnast() + gymnast.routines = [ + { + ...gymnast.routines[0], + dScore: null, + items: [{ ...gymnast.routines[0].items[0], removed: true }], + }, + ] + renderPage(() => gymnastAnswer(gymnast)) + await screen.findByRole('heading', { level: 1 }) + + const row = within(routinesList()).getAllByRole('listitem')[0] + expect(row.textContent).toMatch(/no d-score/i) + expect(row.textContent).toMatch(/no longer in the Code of Points/i) + expect(row.textContent).not.toContain('0.0') + }) +}) + +describe('GymnastPage: the list of elements', () => { + it('shows the code, the name or the description, the difficulty and a link to the element', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const rows = within(elementsList()).getAllByRole('listitem') + const first = within(rows[0]).getByRole('link', { + name: /^1\.302 Glide kip, cast to handstand with 1\/2 turn/, + }) + expect(first.getAttribute('href')).toBe('/elements/UB-1.302-a') + expect(rows[0].textContent).toContain('Difficulty C') + // An element with a name is shown by its name. + expect( + within(rows[1]).getByRole('link', { name: /^4\.504 Ricna/ }), + ).toBeTruthy() + }) + + it('warns about an element that is no longer in the Code of Points', async () => { + const gymnast = exampleGymnast() + gymnast.elements[0] = { ...gymnast.elements[0], removed: true } + renderPage(() => gymnastAnswer(gymnast)) + await screen.findByRole('heading', { level: 1 }) + + const first = within(elementsList()).getAllByRole('listitem')[0] + expect(first.textContent).toMatch(/no longer in the Code of Points/i) + }) +}) + +describe('GymnastPage: removing an element', () => { + const removeAnswer = (gymnast: ServerGymnast, itemId: string): Answer => + ok({ + removeGymnastElement: { + ...gymnast, + elements: gymnast.elements.filter((e) => e.id !== itemId), + }, + }) + + const removeButton = (name: RegExp | string) => + screen.getByRole('button', { name }) + + it('has a button for each element that says which element it removes', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + expect(removeButton(/^Remove 1\.302 Glide kip/)).toBeTruthy() + expect(removeButton(/^Remove 4\.504 Ricna/)).toBeTruthy() + }) + + it('asks before it removes, and changes nothing when the answer is no', async () => { + const page = renderPage() + await screen.findByRole('heading', { level: 1 }) + + fireEvent.click(removeButton(/^Remove 1\.302/)) + const dialog = await screen.findByRole('alertdialog') + expect(dialog.textContent).toContain('1.302') + expect(page.removed()).toHaveLength(0) + + fireEvent.click(screen.getByRole('button', { name: 'No, keep it' })) + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + expect(page.removed()).toHaveLength(0) + expect(within(elementsList()).getAllByRole('listitem')).toHaveLength(2) + }) + + it('gives the focus back to the button that was pressed when the answer is no', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + const button = removeButton(/^Remove 1.302/) + + fireEvent.click(button) + await screen.findByRole('alertdialog') + fireEvent.click(screen.getByRole('button', { name: 'No, keep it' })) + + await waitFor(() => expect(document.activeElement).toBe(button)) + }) + + it('removes the element, shows the new list, says so and moves the focus to the heading of the list', async () => { + const gymnast = exampleGymnast() + const page = renderPage( + () => gymnastAnswer(gymnast), + () => removeAnswer(gymnast, 'UB-1.302-a'), + ) + await screen.findByRole('heading', { level: 1 }) + + fireEvent.click(removeButton(/^Remove 1\.302/)) + await screen.findByRole('alertdialog') + fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })) + + await waitFor(() => + expect(within(elementsList()).getAllByRole('listitem')).toHaveLength(1), + ) + expect(page.removed()[0].variables).toEqual({ + gymnastId: 'gymnast-1', + itemId: 'UB-1.302-a', + }) + expect(screen.getByRole('status').textContent).toContain( + '1.302 removed from the list.', + ) + expect(screen.queryByRole('alertdialog')).toBeNull() + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole('heading', { level: 2, name: 'Element list' }), + ), + ) + }) + + it('sends one request even if it is confirmed twice quickly', async () => { + const gymnast = exampleGymnast() + const answer = deferred() + const page = renderPage( + () => gymnastAnswer(gymnast), + () => answer.promise, + ) + await screen.findByRole('heading', { level: 1 }) + fireEvent.click(removeButton(/^Remove 1\.302/)) + await screen.findByRole('alertdialog') + + const confirm = screen.getByRole('button', { name: 'Yes, remove' }) + fireEvent.click(confirm) + fireEvent.click(confirm) + + await waitFor(() => expect(page.removed()).toHaveLength(1)) + answer.resolve(removeAnswer(gymnast, 'UB-1.302-a')) + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + expect(page.removed()).toHaveLength(1) + }) + + it('keeps the element and tells why when the server says no', async () => { + renderPage( + () => gymnastAnswer(), + () => failure('INTERNAL_SERVER_ERROR', 'boom'), + ) + await screen.findByRole('heading', { level: 1 }) + + fireEvent.click(removeButton(/^Remove 1\.302/)) + await screen.findByRole('alertdialog') + fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })) + + const alert = await screen.findByRole('alert') + expect(alert.textContent).toContain('Something went wrong on our side') + expect(within(elementsList()).getAllByRole('listitem')).toHaveLength(2) + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + }) +}) + +describe('GymnastPage: when it cannot show the gymnast', () => { + it('says that the gymnast was not found, with a link to the gymnasts', async () => { + renderPage(() => failure('NOT_FOUND', 'No such gymnast')) + + expect( + await screen.findByRole('heading', { + level: 1, + name: 'Gymnast not found', + }), + ).toBeTruthy() + expect(screen.getByRole('alert').textContent).toContain( + 'No gymnast of yours', + ) + expect( + screen + .getByRole('link', { name: 'Back to your gymnasts' }) + .getAttribute('href'), + ).toBe('/gymnasts') + expect(document.title).toBe('Gymnast not found – WAG CoP Planner') + }) + + it('says that there is no gymnast for a gymnast that is null', async () => { + renderPage(() => ok({ gymnast: null })) + + expect( + await screen.findByRole('heading', { + level: 1, + name: 'Gymnast not found', + }), + ).toBeTruthy() + }) + + it('shows the error and a retry button for an error that can be tried again', async () => { + let attempt = 0 + renderPage(() => { + attempt += 1 + if (attempt === 1) throw new TypeError('Failed to fetch') + return gymnastAnswer() + }) + + expect((await screen.findByRole('alert')).textContent).toContain( + 'Could not reach the server', + ) + expect(document.title).toBe('Gymnast unavailable – WAG CoP Planner') + + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + expect( + await screen.findByRole('heading', { level: 1, name: 'Ingrid Nilsen' }), + ).toBeTruthy() + }) +}) + +describe('GymnastPage: structure for a screen reader', () => { + it('has the headings in order: h1, then h2 for the routines and the list, and no level skipped', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + const levels = screen + .getAllByRole('heading') + .map((h) => Number(h.tagName[1])) + expect(levels).toEqual([1, 2, 2]) + }) + + it('has one main landmark', async () => { + renderPage() + await screen.findByRole('heading', { level: 1 }) + + expect(screen.getAllByRole('main')).toHaveLength(1) + }) +}) diff --git a/src/tests/styles/gymnastTheme.test.ts b/src/tests/styles/gymnastTheme.test.ts index 983d389..3806438 100644 --- a/src/tests/styles/gymnastTheme.test.ts +++ b/src/tests/styles/gymnastTheme.test.ts @@ -206,6 +206,7 @@ describe('the components of the gymnasts and login pages', () => { // The two pages (src/pages/) and the components they use. const files = [ '../../pages/YourGymnastsPage.tsx', + '../../pages/GymnastPage.tsx', '../../pages/LoginPage.tsx', ...['../../components/gymnasts/', '../../components/auth/'].flatMap((dir) => readdirSync(here(dir)) From 798ad467d8d1d35b39ab3cd7611bfdd3b6b4126e Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 11:56:00 +0100 Subject: [PATCH 10/32] feat(gymnasts): link each gymnast card to the gymnast page The name is a link inside the heading, and its ::after covers the whole card. The delete button stays above it. Refs: #149 --- src/components/gymnasts/GymnastRow.tsx | 26 +++++++++++++----- .../components/gymnasts/GymnastList.test.tsx | 27 +++++++++++++++---- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/components/gymnasts/GymnastRow.tsx b/src/components/gymnasts/GymnastRow.tsx index 8b8b26f..e13a62d 100644 --- a/src/components/gymnasts/GymnastRow.tsx +++ b/src/components/gymnasts/GymnastRow.tsx @@ -1,3 +1,4 @@ +import { Link } from 'react-router-dom' import DeleteGymnastButton from '@/components/gymnasts/DeleteGymnastButton' import type { GymnastSummary } from '@/lib/gymnasts' import { getGymnastInitials } from '@/lib/gymnasts' @@ -12,7 +13,7 @@ export default function GymnastRow({ onFailed: (message: string) => void }) { return ( -
  • +
  • -

    {gymnast.name}

    - +

    + {/* The link covers the whole card (the ::after), as the pointer and the hover + suggest, so it can be pressed anywhere on it. The delete button is above it. */} + + {gymnast.name} + +

    +
    + +
  • ) diff --git a/src/tests/components/gymnasts/GymnastList.test.tsx b/src/tests/components/gymnasts/GymnastList.test.tsx index 1b59017..2975788 100644 --- a/src/tests/components/gymnasts/GymnastList.test.tsx +++ b/src/tests/components/gymnasts/GymnastList.test.tsx @@ -1,5 +1,6 @@ import { ApolloProvider } from '@apollo/client/react' import { renderToString } from 'react-dom/server' +import { MemoryRouter } from 'react-router-dom' import { describe, expect, it } from 'vitest' import { createTestClient } from '@/tests/apollo' import GymnastList from '@/components/gymnasts/GymnastList' @@ -9,11 +10,13 @@ const { client } = createTestClient(() => new Promise(() => {})) const renderList = (gymnasts: { id: string; name: string }[]) => renderToString( - {}} - onFailed={() => {}} - /> + + {}} + onFailed={() => {}} + /> + , ) @@ -30,6 +33,20 @@ describe('GymnastList', () => { expect(markup).toContain('EB') }) + it('links the name of each gymnast to the page of that gymnast, inside the heading of the card', () => { + const markup = renderList([ + { id: 'one', name: 'Ingrid Haugen' }, + { id: 'two', name: 'Emma Berg' }, + ]) + + expect(markup).toMatch( + /]*>]*href="\/gymnasts\/one"[^>]*>Ingrid Haugen<\/a><\/h2>/, + ) + expect(markup).toMatch( + /]*>]*href="\/gymnasts\/two"[^>]*>Emma Berg<\/a><\/h2>/, + ) + }) + it('has a heading for the list', () => { const markup = renderList([]) From 7d1ea4a154fb7b6d22cd2b7165eb365e74e2f39f Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 11:56:00 +0100 Subject: [PATCH 11/32] docs: document the AI use for the gymnast page --- docs/AI_USAGE.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index f278aa0..b8da276 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -532,3 +532,11 @@ - **Tool:** Claude Code - **What the team did:** Wrote issue #97: move the pages out of `src/components/`, so it is clear which files are routes and which are parts of pages, and describe the folders in the README. - **What Claude did:** Moved the four pages and their tests with `git mv` (so their history follows them), and moved the three placeholder pages out of `App.tsx` into their own files, so every route has a file in `src/pages/`. Updated the imports, the links in the README and the docs, and the "Project structure" table. Found that `src/styles/gymnastTheme.test.ts` reads the files of the gymnasts and login pages by folder, so after the move it would have stopped checking the two pages without failing; added them to the list. The shared components in `components/ui/` were left where they are (see the issue). After that, asked to move the frontend tests to `src/tests/` as well: moved all 29 with `git mv` into the same folders as the code they test, changed their relative imports to the `@/` alias (so a test does not depend on where it is), and updated the paths that `gymnastTheme.test.ts` reads files from. + +## The Gymnast page (`src/pages/GymnastPage.tsx`, `src/components/gymnasts/`, `src/components/ConfirmDialog.tsx`, `src/lib/gymnastDetail*.ts`) + +- **Tool:** Claude Code +- **What the team did:** Wrote issue #149 and the mockup (`docs/design/gymnastDetail.png`), and decided the scope: the apparatus tabs (only the name of the apparatus), the routines and the list of elements of one apparatus, and removing an element from the list. "New routine", "Add from search", editing the name and deleting a routine belong to other issues. Decided that no other page is changed and that no new colors are made. +- **What Claude did:** Wrote the plan and the tests first (the grouping logic, the page on the real Apollo Client with a stub for `fetch`, and the confirmation dialog), watched them fail, and then wrote the page. One query asks for the gymnast with all routines and elements (they are limited by the API), and the page groups them by apparatus itself, so changing apparatus costs no request. Reused `PageTitle`, `Button` with `PILL`, `DifficultyBadge`, the structure query of the filters, the Base UI tabs and alert dialog, and the existing color tokens. The tabs are the shadcn/ui tabs (added with the shadcn CLI). The question "are you sure?" is one shared component (`ConfirmDialog`, made from the dialog of `DeleteGymnastButton`), which the delete button and the removal of an element both use. +- **Accessibility check (WCAG 2.2 AA):** axe-core in light and dark mode, on every apparatus, with the dialog open, at 320 px and with text spacing; keyboard-only use and focus order in Chromium; target sizes; and Lighthouse on a production build. The checks found three problems that were fixed: the value under the difficulty badge could not be read in dark mode, the focus ring of the icon buttons was missing (the shared button sets `outline-none`), and the focus was lost after removing an element (a disabled button loses the focus, and one `ref` shared by the panels of all apparatus ended up empty). A screen reader was not used: that has to be done by a person. +- **What was not done by Claude:** the choices of scope, the wording and which findings to fix are the team's. Claude committed the work and opened the pull request as a draft when asked; it is not merged or approved. From eca6cf20f5c2f3d0cf4d9f5e0799df27752dd838 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 14:18:42 +0100 Subject: [PATCH 12/32] feat(about): add the About page Introduce the site and the sport for people who do not know gymnastics, link the official Code of Points PDF, say that the content is not ours, and explain that the site is made for trainers and why it has a login. Plain text with links, in light and dark mode. - add a glossary: apparatus, routine, element with the A-J values, vault, D-score, E-score, final score and Code of Points - say what the calculator does not support yet: composition requirements, connection values, bonuses and the E-score - add component tests for the PDF link, the rights text, the heading order and that the page has no buttons - add the About page to docs/AI_USAGE.md Closes #146 --- docs/AI_USAGE.md | 8 ++ src/components/about/AboutSection.tsx | 22 +++ src/lib/codeOfPoints.ts | 10 ++ src/pages/AboutPage.tsx | 134 ++++++++++++++++- src/styles/global.css | 8 +- src/tests/pages/AboutPage.test.tsx | 200 ++++++++++++++++++++++++++ 6 files changed, 376 insertions(+), 6 deletions(-) create mode 100644 src/components/about/AboutSection.tsx create mode 100644 src/lib/codeOfPoints.ts create mode 100644 src/tests/pages/AboutPage.test.tsx diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index f278aa0..4f1daf2 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -532,3 +532,11 @@ - **Tool:** Claude Code - **What the team did:** Wrote issue #97: move the pages out of `src/components/`, so it is clear which files are routes and which are parts of pages, and describe the folders in the README. - **What Claude did:** Moved the four pages and their tests with `git mv` (so their history follows them), and moved the three placeholder pages out of `App.tsx` into their own files, so every route has a file in `src/pages/`. Updated the imports, the links in the README and the docs, and the "Project structure" table. Found that `src/styles/gymnastTheme.test.ts` reads the files of the gymnasts and login pages by folder, so after the move it would have stopped checking the two pages without failing; added them to the list. The shared components in `components/ui/` were left where they are (see the issue). After that, asked to move the frontend tests to `src/tests/` as well: moved all 29 with `git mv` into the same folders as the code they test, changed their relative imports to the `@/` alias (so a test does not depend on where it is), and updated the paths that `gymnastTheme.test.ts` reads files from. + +## The About page (`src/pages/AboutPage.tsx`, `src/components/about/`, `src/lib/codeOfPoints.ts`) + +- **Tool:** Claude Code +- **What the team did:** Wrote issue #146 and decided what the page is for: an introduction for people who do not know gymnastics, the PDF it comes from with a link, a clear statement that the Code of Points is not ours, the mission (the knowledge of the PDF for search and for calculating scores), and that the site is made especially for trainers, which is why there is a login. Decided that the page must be very simple, work in light and dark mode, and have no buttons of its own. +- **What Claude did:** Found the official PDF on gymnastics.sport and checked that the link answers with a PDF (23.8 MB). Read the front pages of the PDF to check what the page says: that FIG owns the Code, that the final score is the D-score plus the E-score, and that the D-score has difficulty, composition requirements and connection value. Wrote the tests first (the link, the rights text, the glossary, the trainers and login text, the limits of the calculator, headings in order, no buttons), then the page as plain text with links, using only the theme colors. Checked it with axe in both themes and at 320 px. +- **What was found:** The PDF says "Translation and copying are prohibited without prior written approval by FIG". The page says that we do not own the Code of Points, but whether the team may show the elements and drawings is a question for the team and the course staff, and it is not answered here. +- **What was not done by Claude:** nothing was committed or posted. diff --git a/src/components/about/AboutSection.tsx b/src/components/about/AboutSection.tsx new file mode 100644 index 0000000..69a918f --- /dev/null +++ b/src/components/about/AboutSection.tsx @@ -0,0 +1,22 @@ +import { useId, type ReactNode } from 'react' + +/** + * One part of the About page: a heading (h2) and plain text under it. The `
    ` is named + * by its heading, so a screen reader can list the parts and jump between them. + */ +export default function AboutSection({ + title, + children, +}: { + title: string + children: ReactNode +}) { + const headingId = useId() + + return ( +
    +

    {title}

    + {children} +
    + ) +} diff --git a/src/lib/codeOfPoints.ts b/src/lib/codeOfPoints.ts new file mode 100644 index 0000000..f51ffd5 --- /dev/null +++ b/src/lib/codeOfPoints.ts @@ -0,0 +1,10 @@ +// Where the Code of Points that the catalog comes from can be found. The links were checked on +// 2026-11-06: the file answered 200 with `application/pdf` (23.8 MB), and the page of the +// rules lists it as "1.1 - WAG COP 2025-2028". + +/** The official PDF of FIG: Women's Artistic Gymnastics Code of Points 2025-2028 (English). */ +export const COP_PDF_URL = + 'https://www.gymnastics.sport/publicdir/rules/files/en_1.1%20-%20WAG%20COP%202025-2028.pdf' + +/** The page of FIG with all the rules documents, for the day the file is moved or renamed. */ +export const COP_RULES_URL = 'https://www.gymnastics.sport/rules/' diff --git a/src/pages/AboutPage.tsx b/src/pages/AboutPage.tsx index 6085613..8295447 100644 --- a/src/pages/AboutPage.tsx +++ b/src/pages/AboutPage.tsx @@ -1,11 +1,139 @@ +import { Link } from 'react-router-dom' +import AboutSection from '@/components/about/AboutSection' import PageTitle from '@/components/PageTitle' +import { COP_PDF_URL, COP_RULES_URL } from '@/lib/codeOfPoints' -// A placeholder until the page is built. It has its own
    , like the real pages. +// A link in the text, underlined so it is not told by its color alone (WCAG 1.4.1). The colors +// are the theme colors, so the page reads in light and in dark mode. +const LINK = + 'font-medium underline underline-offset-4 hover:no-underline focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--text-color)' + +/** The words of the site, in the order a newcomer meets them. */ +const GLOSSARY: { term: string; description: string }[] = [ + { + term: 'Apparatus', + description: + 'One of the four events: vault, uneven bars, balance beam and floor exercise.', + }, + { + term: 'Routine', + description: + 'The whole exercise a gymnast performs on one apparatus. On uneven bars, balance beam and floor it is a series of elements. On vault it is one vault (some competitions require two).', + }, + { + term: 'Element', + description: + 'One skill in a routine, such as a turn or a salto. Each has a code, like 2.305, and a drawing. The grade is a letter with a fixed value: A = 0.1, B = 0.2, C = 0.3, D = 0.4, E = 0.5, F = 0.6, G = 0.7, H = 0.8, I = 0.9, J = 1.0. The code tells the grade: in 2.305 the 3 after the dot means C.', + }, + { + term: 'Vault', + description: + 'A vault has no letter. A letter (0.1 to 1.0) is one part of a D-score, which on the other apparatus is built from eight elements. A vault stands for the whole exercise: its value, between 1.6 and 6.4, is the D-score. So one vault is worth about as much as a whole routine of eight elements, and a routine on vault can be just one vault.', + }, + { + term: 'D-score', + description: + 'The difficulty score. It is made of the values of the eight highest elements, plus composition requirements, connection values and bonuses. On vault it is just the value of the vault.', + }, + { + term: 'E-score', + description: + 'The execution score. It starts at 10.0, and the judges subtract points for faults. We do not support subtracting the E-score yet: the calculator only gives the D-score.', + }, + { + term: 'Final score', + description: 'The D-score plus the E-score, minus any penalties.', + }, + { + term: 'Code of Points', + description: + 'The rulebook of FIG, the international gymnastics federation. It lists all elements and what they are worth. A new one is made every four years.', + }, +] + +/** A plain introduction to the site, to the sport and to the Code of Points the data comes from. */ export default function AboutPage() { return ( -
    +
    About -

    About

    + +
    +

    About

    + + +

    + The knowledge about elements and their values is in a long PDF. Our + mission is to make it available for search and for calculating + scores, for trainers and their gymnasts. +

    +
    + + +

    + A gymnast performs an exercise on each of four apparatus, and judges + score it. These are the words you will meet here. +

    +
    + {GLOSSARY.map(({ term, description }) => ( +
    +
    {term}
    +
    {description}
    +
    + ))} +
    +
    + + +

    + All elements and values here come from the{' '} + + Women’s Artistic Gymnastics Code of Points 2025–2028 (PDF, about + 24 MB) + + , published by FIG. If the link stops working, see the{' '} + + rules page of FIG + + . +

    +

    + We do not own the Code of Points. It is the property of the FIG, and + all rights belong to FIG. We use it for education in a student + project that is not affiliated with FIG. The catalog may not have + the latest changes, so the official document always decides. +

    +
    + + +

    + The site is made especially for trainers. Anyone can{' '} + + search the elements + + . A trainer can{' '} + + log in + {' '} + to keep a profile for each gymnast, with routines and the elements + each can do. That is why there is a login. For now it is a test + login: anyone can log in as any coach, so do not enter real + information. +

    +

    + Our{' '} + + calculator + {' '} + adds the eight highest elements of a routine, each counted once. It + does not include composition requirements, connection values or + bonuses (for example the dismount bonus), so it is not an official + score. +

    +
    +
    ) } diff --git a/src/styles/global.css b/src/styles/global.css index 7feca8b..f6b6cc7 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -9,9 +9,11 @@ border-inline: 0; } -/* The titles of the Elements, gymnasts and login pages are the same: the size +/* The titles of the Elements, gymnasts, About and login pages are the same: the size of h1 comes from index.css (56px, 36px on a narrow window) for all of them. */ -#root :is(.elements-page, .your-gymnasts-page, .login-page) :is(h1, h2) { +#root + :is(.elements-page, .your-gymnasts-page, .about-page, .login-page) + :is(h1, h2) { color: var(--text-color); font-weight: 700; letter-spacing: -0.01em; @@ -83,7 +85,7 @@ margin-bottom: 0.75rem; } -#root .your-gymnasts-page h2 { +#root :is(.your-gymnasts-page, .about-page) h2 { font-size: clamp(1.375rem, 4vw, 1.75rem); line-height: 1.2; margin: 0; diff --git a/src/tests/pages/AboutPage.test.tsx b/src/tests/pages/AboutPage.test.tsx new file mode 100644 index 0000000..8c8f8c3 --- /dev/null +++ b/src/tests/pages/AboutPage.test.tsx @@ -0,0 +1,200 @@ +// @vitest-environment jsdom +// The About page is static text. These tests say what it must tell a visitor, so a rewrite +// of the wording cannot drop the link to the PDF, the notice that the Code of Points is not +// ours, or the limits of the calculator. + +import { cleanup, render, screen, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, describe, expect, it } from 'vitest' +import AboutPage from '@/pages/AboutPage' +import { COP_PDF_URL, COP_RULES_URL } from '@/lib/codeOfPoints' + +afterEach(cleanup) + +const renderPage = () => + render( + + + , + ) + +/** The section with this heading. */ +const section = (name: string) => + screen + .getByRole('heading', { level: 2, name }) + .closest('section') as HTMLElement + +describe('AboutPage: the page', () => { + it('has a title (WCAG 2.4.2) and one h1', () => { + renderPage() + + expect(document.title).toBe('About – WAG CoP Planner') + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1) + expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('About') + }) + + it('has one main landmark and the headings in order: h1, then h2 only', () => { + renderPage() + + expect(screen.getAllByRole('main')).toHaveLength(1) + const levels = screen + .getAllByRole('heading') + .map((heading) => Number(heading.tagName[1])) + expect(levels[0]).toBe(1) + expect(levels.slice(1).every((level) => level === 2)).toBe(true) + expect(levels.length).toBe(5) + }) + + it('has every section named by its heading, so a screen reader can list them', () => { + renderPage() + + for (const heading of screen.getAllByRole('heading', { level: 2 })) { + const region = heading.closest('section')! + expect(region.getAttribute('aria-labelledby')).toBe(heading.id) + } + }) +}) + +describe('AboutPage: what the site is for', () => { + it('says the mission: the knowledge of the PDF for search and for calculating scores, especially for trainers', () => { + renderPage() + + const mission = section('Our mission').textContent ?? '' + expect(mission).toMatch(/search/i) + expect(mission).toMatch(/calculat/i) + expect(mission).toMatch(/trainers/i) + expect(mission).toMatch(/gymnasts/i) + }) + + it('explains the domain for a visitor who does not know gymnastics', () => { + renderPage() + + const intro = section('Gymnastics in two minutes') + // The four apparatus are named in the description of the first word. + for (const apparatus of ['vault', 'uneven bars', 'balance beam', 'floor']) { + expect(intro.textContent?.toLowerCase()).toContain(apparatus) + } + // The words of the site, each explained. + const terms = within(intro) + .getAllByRole('term') + .map((term) => term.textContent) + for (const term of [ + 'Apparatus', + 'Element', + 'D-score', + 'E-score', + 'Final score', + 'Code of Points', + ]) { + expect(terms).toContain(term) + } + }) + + it('gives each term a description', () => { + renderPage() + + const intro = section('Gymnastics in two minutes') + expect(within(intro).getAllByRole('definition').length).toBe( + within(intro).getAllByRole('term').length, + ) + }) +}) + +describe('AboutPage: the PDF and the rights', () => { + it('links to the PDF of FIG, and tells what the link is and how big the file is', () => { + renderPage() + + const link = screen.getByRole('link', { + name: /Women.s Artistic Gymnastics Code of Points 2025–2028/, + }) + expect(link.getAttribute('href')).toBe(COP_PDF_URL) + expect(COP_PDF_URL).toMatch(/^https:\/\/www\.gymnastics\.sport\/.*\.pdf$/) + expect(link.textContent).toMatch(/PDF/) + expect(section('The Code of Points and our data').textContent).toMatch( + /24 MB/, + ) + }) + + it('links to the page of FIG with the rules, in case the file moves', () => { + renderPage() + + const link = screen.getByRole('link', { name: /rules page/i }) + expect(link.getAttribute('href')).toBe(COP_RULES_URL) + }) + + it('says that the Code of Points is not ours and belongs to FIG', () => { + renderPage() + + const text = section('The Code of Points and our data').textContent ?? '' + expect(text).toMatch(/do not own/i) + expect(text).toMatch(/property of the FIG/i) + expect(text).toMatch(/not affiliated/i) + }) + + it('says that the catalog can be behind the official document, and that the PDF decides', () => { + renderPage() + + const text = section('The Code of Points and our data').textContent ?? '' + expect(text).toMatch(/latest changes/i) + expect(text).toMatch(/official/i) + }) +}) + +describe('AboutPage: trainers and the login', () => { + it('says that the site is made for trainers, and why there is a login', () => { + renderPage() + + const trainers = section('For trainers') + expect(trainers.textContent).toMatch(/log in/i) + expect(trainers.textContent).toMatch(/gymnast/i) + expect(trainers.textContent).toMatch(/routines/i) + }) + + it('is honest that the login is a test login that anyone can use as anyone', () => { + renderPage() + + expect(section('For trainers').textContent).toMatch( + /anyone can log in as any coach/i, + ) + }) + + it('has a link to the login page', () => { + renderPage() + + const link = within(section('For trainers')).getByRole('link', { + name: /^log in$/i, + }) + expect(link.getAttribute('href')).toBe('/login') + }) +}) + +describe('AboutPage: the calculator and the links', () => { + it('says what the calculator counts and what not, so nobody takes the number for an official score', () => { + renderPage() + + const text = section('For trainers').textContent ?? '' + expect(text).toMatch(/eight highest/i) + expect(text).toMatch(/composition requirements/i) + expect(text).toMatch(/connection values/i) + expect(text).toMatch(/not an official score/i) + }) + + it('has links to the search and the calculator', () => { + renderPage() + + expect( + screen + .getByRole('link', { name: /search the elements/i }) + .getAttribute('href'), + ).toBe('/elements') + expect( + screen.getByRole('link', { name: /calculator/i }).getAttribute('href'), + ).toBe('/calculator') + }) + + it('has no buttons: it is text with links', () => { + renderPage() + + expect(screen.queryAllByRole('button')).toHaveLength(0) + }) +}) From bc446f2e9cffa9af0b940d8d3435584d707c0665 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 14:34:01 +0100 Subject: [PATCH 13/32] test(about): remove the tests of the wording of the page Keep the tests of the structure, the login text, the limits of the calculator and the links. The tests of the mission, the glossary and the PDF and rights text only checked that words were present, so they broke on any rewording. --- src/tests/pages/AboutPage.test.tsx | 92 +----------------------------- 1 file changed, 3 insertions(+), 89 deletions(-) diff --git a/src/tests/pages/AboutPage.test.tsx b/src/tests/pages/AboutPage.test.tsx index 8c8f8c3..3ddac85 100644 --- a/src/tests/pages/AboutPage.test.tsx +++ b/src/tests/pages/AboutPage.test.tsx @@ -1,13 +1,12 @@ // @vitest-environment jsdom -// The About page is static text. These tests say what it must tell a visitor, so a rewrite -// of the wording cannot drop the link to the PDF, the notice that the Code of Points is not -// ours, or the limits of the calculator. +// The About page is static text. These tests check its structure and accessibility, and the +// parts that must not be lost in a rewrite of the wording: the login text and the limits of +// the calculator. import { cleanup, render, screen, within } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import { afterEach, describe, expect, it } from 'vitest' import AboutPage from '@/pages/AboutPage' -import { COP_PDF_URL, COP_RULES_URL } from '@/lib/codeOfPoints' afterEach(cleanup) @@ -55,91 +54,6 @@ describe('AboutPage: the page', () => { }) }) -describe('AboutPage: what the site is for', () => { - it('says the mission: the knowledge of the PDF for search and for calculating scores, especially for trainers', () => { - renderPage() - - const mission = section('Our mission').textContent ?? '' - expect(mission).toMatch(/search/i) - expect(mission).toMatch(/calculat/i) - expect(mission).toMatch(/trainers/i) - expect(mission).toMatch(/gymnasts/i) - }) - - it('explains the domain for a visitor who does not know gymnastics', () => { - renderPage() - - const intro = section('Gymnastics in two minutes') - // The four apparatus are named in the description of the first word. - for (const apparatus of ['vault', 'uneven bars', 'balance beam', 'floor']) { - expect(intro.textContent?.toLowerCase()).toContain(apparatus) - } - // The words of the site, each explained. - const terms = within(intro) - .getAllByRole('term') - .map((term) => term.textContent) - for (const term of [ - 'Apparatus', - 'Element', - 'D-score', - 'E-score', - 'Final score', - 'Code of Points', - ]) { - expect(terms).toContain(term) - } - }) - - it('gives each term a description', () => { - renderPage() - - const intro = section('Gymnastics in two minutes') - expect(within(intro).getAllByRole('definition').length).toBe( - within(intro).getAllByRole('term').length, - ) - }) -}) - -describe('AboutPage: the PDF and the rights', () => { - it('links to the PDF of FIG, and tells what the link is and how big the file is', () => { - renderPage() - - const link = screen.getByRole('link', { - name: /Women.s Artistic Gymnastics Code of Points 2025–2028/, - }) - expect(link.getAttribute('href')).toBe(COP_PDF_URL) - expect(COP_PDF_URL).toMatch(/^https:\/\/www\.gymnastics\.sport\/.*\.pdf$/) - expect(link.textContent).toMatch(/PDF/) - expect(section('The Code of Points and our data').textContent).toMatch( - /24 MB/, - ) - }) - - it('links to the page of FIG with the rules, in case the file moves', () => { - renderPage() - - const link = screen.getByRole('link', { name: /rules page/i }) - expect(link.getAttribute('href')).toBe(COP_RULES_URL) - }) - - it('says that the Code of Points is not ours and belongs to FIG', () => { - renderPage() - - const text = section('The Code of Points and our data').textContent ?? '' - expect(text).toMatch(/do not own/i) - expect(text).toMatch(/property of the FIG/i) - expect(text).toMatch(/not affiliated/i) - }) - - it('says that the catalog can be behind the official document, and that the PDF decides', () => { - renderPage() - - const text = section('The Code of Points and our data').textContent ?? '' - expect(text).toMatch(/latest changes/i) - expect(text).toMatch(/official/i) - }) -}) - describe('AboutPage: trainers and the login', () => { it('says that the site is made for trainers, and why there is a login', () => { renderPage() From 555c8bc797ea079d0d7e0a46230e511f83c376c9 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 14:34:01 +0100 Subject: [PATCH 14/32] docs(ai-usage): note the changes the team asked for on the About page Replace the line that said nothing was committed or posted with what the team changed after the first version: the trainers and the login, the A-J mapping, routines and vaults, and what the calculator does not support yet. --- docs/AI_USAGE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 4f1daf2..1144ab1 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -539,4 +539,4 @@ - **What the team did:** Wrote issue #146 and decided what the page is for: an introduction for people who do not know gymnastics, the PDF it comes from with a link, a clear statement that the Code of Points is not ours, the mission (the knowledge of the PDF for search and for calculating scores), and that the site is made especially for trainers, which is why there is a login. Decided that the page must be very simple, work in light and dark mode, and have no buttons of its own. - **What Claude did:** Found the official PDF on gymnastics.sport and checked that the link answers with a PDF (23.8 MB). Read the front pages of the PDF to check what the page says: that FIG owns the Code, that the final score is the D-score plus the E-score, and that the D-score has difficulty, composition requirements and connection value. Wrote the tests first (the link, the rights text, the glossary, the trainers and login text, the limits of the calculator, headings in order, no buttons), then the page as plain text with links, using only the theme colors. Checked it with axe in both themes and at 320 px. - **What was found:** The PDF says "Translation and copying are prohibited without prior written approval by FIG". The page says that we do not own the Code of Points, but whether the team may show the elements and drawings is a question for the team and the course staff, and it is not answered here. -- **What was not done by Claude:** nothing was committed or posted. +- **What the team changed afterwards:** The team asked for changes in the text and for information that Claude had missed in the first version: that the site is especially for trainers and why it has a login, the full mapping from the letters A to J to their numbers, the difference between a routine and a vault and why a vault has no letter, and that the calculator does not support bonuses or subtracting the E-score yet. The team also asked for a very simple page without buttons, in light and dark mode, and checked what Claude wrote against the PDF. From 807a09efdb932119d32a790fd3baebc54b7bd95a Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 14:37:33 +0100 Subject: [PATCH 15/32] feat(a11y): add a go-to-top link to every page Add a link at the end of every page that takes a keyboard or screen reader user back to the start of the page. It is read by a screen reader and not seen until it has the focus, like the skip link. - scroll to the top and put the focus on the first thing that can be reached, the "Skip to main content" link - do not follow the link, so the address and the history do not change - put the component once in App.tsx, so every page has it - add tests of the component and a test that the link comes after the page on each route - add the change to docs/AI_USAGE.md Closes #165 --- docs/AI_USAGE.md | 7 +++ src/App.tsx | 3 ++ src/components/goToTop/GoToTop.tsx | 24 ++++++++++ src/tests/App.test.tsx | 16 +++++++ src/tests/components/goToTop/GoToTop.test.tsx | 46 +++++++++++++++++++ 5 files changed, 96 insertions(+) create mode 100644 src/components/goToTop/GoToTop.tsx create mode 100644 src/tests/components/goToTop/GoToTop.test.tsx diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index f278aa0..dfeec2f 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -532,3 +532,10 @@ - **Tool:** Claude Code - **What the team did:** Wrote issue #97: move the pages out of `src/components/`, so it is clear which files are routes and which are parts of pages, and describe the folders in the README. - **What Claude did:** Moved the four pages and their tests with `git mv` (so their history follows them), and moved the three placeholder pages out of `App.tsx` into their own files, so every route has a file in `src/pages/`. Updated the imports, the links in the README and the docs, and the "Project structure" table. Found that `src/styles/gymnastTheme.test.ts` reads the files of the gymnasts and login pages by folder, so after the move it would have stopped checking the two pages without failing; added them to the list. The shared components in `components/ui/` were left where they are (see the issue). After that, asked to move the frontend tests to `src/tests/` as well: moved all 29 with `git mv` into the same folders as the code they test, changed their relative imports to the `@/` alias (so a test does not depend on where it is), and updated the paths that `gymnastTheme.test.ts` reads files from. + +## The go-to-top link on every page (`src/components/goToTop/GoToTop.tsx`, `src/App.tsx`) + +- **Tool:** Claude Code +- **What the team did:** First asked for the link on the About page, then decided that it belongs at the bottom of every page and in its own issue (#165) and pull request. Decided that it is read by a screen reader and not seen until it has the focus. +- **What Claude did:** Wrote the tests first (the link is hidden until it has the focus, it scrolls to the top and puts the focus on the first thing that can be reached, and it does not change the address), then the component, and put it once in `App.tsx` after the page content. Checked in a real browser on several pages that the link is 1 by 1 pixels when it does not have the focus, that it shows with an outline when it has, that Enter goes to the top and puts the focus on "Skip to main content", and that axe finds no violations in light and dark mode. The first version still had padding when it was hidden, and it sat against the left edge when it was shown; both were fixed. +- **What the team changed:** asked for the link to be moved from the About page to every page. diff --git a/src/App.tsx b/src/App.tsx index b9bd1be..d83bb87 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { Navigate, Route, Routes } from 'react-router-dom' import { Navbar } from './components/navbar/Navbar' import { SkipLink } from './components/skipLink/SkipLink' +import { GoToTop } from './components/goToTop/GoToTop' import { ProtectedRoute } from './components/ProtectedRoute' import { AuthProvider } from './components/auth/AuthProvider' @@ -58,6 +59,8 @@ export function App() { } /> + {/* The last thing on every page: a link back to the start, for the keyboard and screen readers. */} + ) } diff --git a/src/components/goToTop/GoToTop.tsx b/src/components/goToTop/GoToTop.tsx new file mode 100644 index 0000000..87664dc --- /dev/null +++ b/src/components/goToTop/GoToTop.tsx @@ -0,0 +1,24 @@ +/** + * A link at the end of every page that takes a keyboard or screen reader user back to the + * start of the page, so they do not have to go back through everything. It is read by a + * screen reader and not seen, until it has the focus (like the skip link), and then it shows. + * + * It scrolls to the top and puts the focus on the first thing that can be reached, which is + * the "Skip to main content" link. A plain `#` link would only scroll, and the focus would + * stay at the bottom. It does not follow the link, so the address and the history do not change. + */ +export const GoToTop = () => { + return ( + { + event.preventDefault() + window.scrollTo({ top: 0 }) + document.querySelector('a[href], button')?.focus() + }} + className="sr-only rounded-sm font-text font-semibold text-(--text-color) underline underline-offset-4 focus:not-sr-only focus:block focus:w-fit focus:my-4 focus:ml-5 focus:px-md focus:py-sm sm:focus:ml-8 lg:focus:ml-12 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--text-color)" + > + Go to the top of the page + + ) +} diff --git a/src/tests/App.test.tsx b/src/tests/App.test.tsx index cd9fbd2..907c6c8 100644 --- a/src/tests/App.test.tsx +++ b/src/tests/App.test.tsx @@ -86,6 +86,22 @@ describe('App', () => { expect(screen.getAllByRole('main')).toHaveLength(1) }) + it('ends every page with the link back to the top, which comes after the page', () => { + renderApp() + + for (const name of ['Search', 'About', 'Calculator']) { + fireEvent.click(screen.getByRole('link', { name })) + + const top = screen.getByRole('link', { + name: 'Go to the top of the page', + }) + expect( + screen.getByRole('main').compareDocumentPosition(top) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + } + }) + it('gives every page its own title, so the tab and the history say where you are', () => { renderApp() expect(document.title).toBe('Elements – WAG CoP Planner') diff --git a/src/tests/components/goToTop/GoToTop.test.tsx b/src/tests/components/goToTop/GoToTop.test.tsx new file mode 100644 index 0000000..6937aa9 --- /dev/null +++ b/src/tests/components/goToTop/GoToTop.test.tsx @@ -0,0 +1,46 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { GoToTop } from '@/components/goToTop/GoToTop' + +afterEach(cleanup) + +const LINK = { name: 'Go to the top of the page' } + +describe('GoToTop', () => { + it('is a link that is read by a screen reader but not seen until it has the focus', () => { + render() + + const link = screen.getByRole('link', LINK) + expect(link.className).toContain('sr-only') + expect(link.className).toContain('focus:not-sr-only') + }) + + it('scrolls to the top and puts the focus on the first thing that can be reached', () => { + const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + render( + <> + Start of the page + + , + ) + + fireEvent.click(screen.getByRole('link', LINK)) + + expect(scrollTo).toHaveBeenCalledWith({ top: 0 }) + expect(document.activeElement).toBe( + screen.getByRole('link', { name: 'Start of the page' }), + ) + scrollTo.mockRestore() + }) + + it('does not follow the link, so the address and the history stay as they are', () => { + vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + render() + + const notCancelled = fireEvent.click(screen.getByRole('link', LINK)) + + // fireEvent gives false when something called preventDefault. + expect(notCancelled).toBe(false) + }) +}) From 86fbf0818f676d9550021aa60b8936e9ac88c552 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 14:45:35 +0100 Subject: [PATCH 16/32] docs(a11y): refer to WCAG 2.4.1 in the bypass block links Say in the comments of SkipLink and GoToTop that the skip link is what meets WCAG 2.4.1 Bypass Blocks (level A, technique G1), and that the go-to-top link is the way back, which WCAG does not require. --- src/components/goToTop/GoToTop.tsx | 4 ++++ src/components/skipLink/SkipLink.tsx | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/src/components/goToTop/GoToTop.tsx b/src/components/goToTop/GoToTop.tsx index 87664dc..94a9d0f 100644 --- a/src/components/goToTop/GoToTop.tsx +++ b/src/components/goToTop/GoToTop.tsx @@ -3,6 +3,10 @@ * start of the page, so they do not have to go back through everything. It is read by a * screen reader and not seen, until it has the focus (like the skip link), and then it shows. * + * It goes with the "Skip to main content" link (`SkipLink`), which is what meets WCAG 2.4.1 + * Bypass Blocks (level A, technique G1). This link is the way back: WCAG does not require it, + * but it saves the same users from going back through a long page. + * * It scrolls to the top and puts the focus on the first thing that can be reached, which is * the "Skip to main content" link. A plain `#` link would only scroll, and the focus would * stay at the bottom. It does not follow the link, so the address and the history do not change. diff --git a/src/components/skipLink/SkipLink.tsx b/src/components/skipLink/SkipLink.tsx index 89eebb5..f76ae2d 100644 --- a/src/components/skipLink/SkipLink.tsx +++ b/src/components/skipLink/SkipLink.tsx @@ -2,6 +2,11 @@ interface SkipLinkProps { mainId?: string } +/** + * The link that lets a keyboard or screen reader user skip the navigation and go to the main + * content. It is the first thing that can be reached, and it is seen when it has the focus. + * It meets WCAG 2.4.1 Bypass Blocks (level A, technique G1). `GoToTop` is the way back. + */ export const SkipLink = ({ mainId = 'main-content' }: SkipLinkProps) => { return ( Date: Fri, 6 Nov 2026 14:49:32 +0100 Subject: [PATCH 17/32] revert: docs(a11y): refer to WCAG 2.4.1 in the bypass block links This reverts commit 86fbf08. The skip link already existed, so the pull request only adds the go-to-top link and does not refer to WCAG 2.4.1. --- src/components/goToTop/GoToTop.tsx | 4 ---- src/components/skipLink/SkipLink.tsx | 5 ----- 2 files changed, 9 deletions(-) diff --git a/src/components/goToTop/GoToTop.tsx b/src/components/goToTop/GoToTop.tsx index 94a9d0f..87664dc 100644 --- a/src/components/goToTop/GoToTop.tsx +++ b/src/components/goToTop/GoToTop.tsx @@ -3,10 +3,6 @@ * start of the page, so they do not have to go back through everything. It is read by a * screen reader and not seen, until it has the focus (like the skip link), and then it shows. * - * It goes with the "Skip to main content" link (`SkipLink`), which is what meets WCAG 2.4.1 - * Bypass Blocks (level A, technique G1). This link is the way back: WCAG does not require it, - * but it saves the same users from going back through a long page. - * * It scrolls to the top and puts the focus on the first thing that can be reached, which is * the "Skip to main content" link. A plain `#` link would only scroll, and the focus would * stay at the bottom. It does not follow the link, so the address and the history do not change. diff --git a/src/components/skipLink/SkipLink.tsx b/src/components/skipLink/SkipLink.tsx index f76ae2d..89eebb5 100644 --- a/src/components/skipLink/SkipLink.tsx +++ b/src/components/skipLink/SkipLink.tsx @@ -2,11 +2,6 @@ interface SkipLinkProps { mainId?: string } -/** - * The link that lets a keyboard or screen reader user skip the navigation and go to the main - * content. It is the first thing that can be reached, and it is seen when it has the focus. - * It meets WCAG 2.4.1 Bypass Blocks (level A, technique G1). `GoToTop` is the way back. - */ export const SkipLink = ({ mainId = 'main-content' }: SkipLinkProps) => { return ( Date: Fri, 6 Nov 2026 17:11:17 +0100 Subject: [PATCH 18/32] feat(gymnasts): polish the gymnast page and share its buttons and errors Add New routine and Add from search buttons, a shared TrashButton, ListSection and StretchedLink, LinkButton (a real link with the pill look) and Button icon. Errors are in three layers: ErrorMessage, ErrorPanel and ErrorPage. Fix the layout on phones, the dark hover color and the Highest label. Refs: #149 --- src/components/ErrorMessage.tsx | 36 ++++++++ src/components/ErrorPage.tsx | 38 +++++++++ src/components/ErrorPanel.tsx | 49 +++++++++++ src/components/gymnasts/AddButton.tsx | 27 ++++++ src/components/gymnasts/ApparatusTabs.tsx | 2 +- .../gymnasts/DeleteGymnastButton.tsx | 20 ++--- .../gymnasts/GymnastElementList.tsx | 83 +++++++++---------- src/components/gymnasts/GymnastList.tsx | 2 +- src/components/gymnasts/GymnastPageStates.tsx | 70 +++++----------- src/components/gymnasts/GymnastRow.tsx | 18 ++-- src/components/gymnasts/ListSection.tsx | 56 +++++++++++++ src/components/gymnasts/RoutineList.tsx | 51 +++++++----- src/components/gymnasts/StretchedLink.tsx | 19 +++++ src/components/gymnasts/TrashButton.tsx | 41 +++++++++ src/components/gymnasts/listCard.ts | 12 +-- src/components/ui/LinkButton.tsx | 35 ++++++++ src/components/ui/button.tsx | 21 ++++- src/lib/gymnastDetail.ts | 19 +++++ src/lib/gymnastDetailOperations.ts | 21 +++++ src/pages/GymnastPage.tsx | 34 ++++---- src/pages/YourGymnastsPage.tsx | 2 +- src/styles/global.css | 26 +----- src/styles/variables.css | 11 +++ src/tests/components/errors.test.tsx | 76 +++++++++++++++++ .../components/gymnasts/AddButton.test.tsx | 24 ++++++ .../gymnasts/DeleteGymnastButton.test.tsx | 9 -- .../components/gymnasts/GymnastList.test.tsx | 3 - .../components/gymnasts/ListSection.test.tsx | 63 ++++++++++++++ .../components/gymnasts/TrashButton.test.tsx | 32 +++++++ src/tests/components/ui/button.test.tsx | 15 +++- src/tests/lib/gymnastDetail.test.ts | 22 +++++ src/tests/pages/GymnastPage.test.tsx | 24 +++++- src/tests/styles/gymnastTheme.test.ts | 36 ++------ 33 files changed, 758 insertions(+), 239 deletions(-) create mode 100644 src/components/ErrorMessage.tsx create mode 100644 src/components/ErrorPage.tsx create mode 100644 src/components/ErrorPanel.tsx create mode 100644 src/components/gymnasts/AddButton.tsx create mode 100644 src/components/gymnasts/ListSection.tsx create mode 100644 src/components/gymnasts/StretchedLink.tsx create mode 100644 src/components/gymnasts/TrashButton.tsx create mode 100644 src/components/ui/LinkButton.tsx create mode 100644 src/tests/components/errors.test.tsx create mode 100644 src/tests/components/gymnasts/AddButton.test.tsx create mode 100644 src/tests/components/gymnasts/ListSection.test.tsx create mode 100644 src/tests/components/gymnasts/TrashButton.test.tsx diff --git a/src/components/ErrorMessage.tsx b/src/components/ErrorMessage.tsx new file mode 100644 index 0000000..8d12675 --- /dev/null +++ b/src/components/ErrorMessage.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react' +import { TriangleAlert } from 'lucide-react' + +/** + * One error message, the first of the three layers (`ErrorMessage`, `ErrorPanel`, + * `ErrorPage`) that every error of the app is shown with, so that they look and are read the + * same. It is a `role="alert"`, so a screen reader reads it when it appears, and only the + * text is read: a button beside it is not part of the alert. The meaning is in the icon and + * the words and not in the color alone (WCAG 1.4.1); the icon is the red (3:1 on the card in + * both themes, checked in gymnastTheme.test.ts), and the text is the text color, which can be + * read on the page and on a card. Give it an `id` when a field points at it with + * `aria-describedby`. + */ +export default function ErrorMessage({ + children, + id, + className = '', +}: { + children: ReactNode + id?: string + className?: string +}) { + return ( + + ) +} diff --git a/src/components/ErrorPage.tsx b/src/components/ErrorPage.tsx new file mode 100644 index 0000000..915a988 --- /dev/null +++ b/src/components/ErrorPage.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from 'react' +import ErrorPanel from '@/components/ErrorPanel' +import PageTitle from '@/components/PageTitle' + +/** + * The third layer: a whole page that says what went wrong. It has the title of the page (the + * tab, the history, a screen reader), the `h1`, and an `ErrorPanel`. `className` is the + * classes of the `
    `, so the page keeps the look of its other states. + */ +export default function ErrorPage({ + title, + message, + retryable, + onRetry, + action, + className = '', +}: { + title: string + message: string + retryable?: boolean + onRetry?: () => void + action?: ReactNode + className?: string +}) { + return ( +
    + {title} +

    {title}

    + +
    + ) +} diff --git a/src/components/ErrorPanel.tsx b/src/components/ErrorPanel.tsx new file mode 100644 index 0000000..6cfa4a8 --- /dev/null +++ b/src/components/ErrorPanel.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from 'react' +import ErrorMessage from '@/components/ErrorMessage' +import { Button } from '@/components/ui/button' +import { PILL } from '@/components/ui/pill' + +/** + * The second layer: an error message in a card, with a way on. "Try again" is shown when the + * error can be tried again (`retryable`, from `describeError`) and the caller can do it + * (`onRetry`); `action` is anything else that helps, such as a link to log in. Use it for a + * part of a page that could not be loaded. The message is the only alert: the buttons are not + * read out as a part of it. + */ +export default function ErrorPanel({ + message, + retryable = false, + onRetry, + action, + className = '', +}: { + message: string + retryable?: boolean + onRetry?: () => void + action?: ReactNode + className?: string +}) { + const canRetry = retryable && onRetry !== undefined + return ( +
    + {message} + {(canRetry || action) && ( +
    + {canRetry && ( + + )} + {action} +
    + )} +
    + ) +} diff --git a/src/components/gymnasts/AddButton.tsx b/src/components/gymnasts/AddButton.tsx new file mode 100644 index 0000000..8eb58ce --- /dev/null +++ b/src/components/gymnasts/AddButton.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from 'react' +import { Plus, type LucideIcon } from 'lucide-react' +import LinkButton from '@/components/ui/LinkButton' + +/** + * The pill button that is a link, with an icon before the text: a plus to add something + * ("New routine", "Add from search"), or another icon for another meaning (a lock on "Add to + * gymnast" when the visitor must log in first). `state` is the state of the navigation, for + * the login page, which sends the visitor back to the page in `state.from`. + */ +export default function AddButton({ + to, + state, + icon = Plus, + children, +}: { + to: string + state?: unknown + icon?: LucideIcon + children: ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/src/components/gymnasts/ApparatusTabs.tsx b/src/components/gymnasts/ApparatusTabs.tsx index b323545..589d90c 100644 --- a/src/components/gymnasts/ApparatusTabs.tsx +++ b/src/components/gymnasts/ApparatusTabs.tsx @@ -32,7 +32,7 @@ export default function ApparatusTabs({ variant="line" aria-label="Apparatus" activateOnFocus - className="h-auto w-full flex-wrap group-data-[orientation=horizontal]/tabs:h-auto justify-start gap-1 border-b border-(--box-stroke) p-0 text-(--text-color)" + className="grid h-auto w-full grid-cols-2 sm:flex sm:flex-wrap group-data-[orientation=horizontal]/tabs:h-auto justify-start gap-1 border-b border-(--box-stroke) p-0 text-(--text-color)" > {apparatus.map((one) => ( - + className="ml-auto" + />
    - - {element.code}{' '} + {/* The code is above the name on a narrow screen: beside it, the name got about 60 px + at 320 px wide, and one word a line. */} + {element.code}{' '} {label} - + {element.removed && (

    - + className="relative z-10" + /> ) } /** * The elements the gymnast can do or is practicing on one apparatus, in the order they were - * added. The page can move the focus to the heading (`elementsHeadingId`): after an element - * has been removed the button that was pressed is gone, and this is where the visitor was. - * The id has the apparatus in it, because the panel of the apparatus that was left is still - * in the page for a moment, and two headings must not have the same id. + * added, and the "Add from search" button, which goes to the search with the gymnast in the + * address (`addFromSearchHref`). The page can move the focus to the heading + * (`elementsHeadingId`): after an element has been removed the button that was pressed is + * gone, and this is where the visitor was. The id has the apparatus in it, because the panel + * of the apparatus that was left is still in the page for a moment, and two headings must not + * have the same id. */ export default function GymnastElementList({ apparatusId, elements, apparatusName, + gymnastId, onAskRemove, }: { apparatusId: string elements: ElementView[] apparatusName: string + gymnastId: string /** The visitor pressed the remove button of an element. The page asks "are you sure?". */ onAskRemove: (element: ElementView, opener: HTMLElement) => void }) { - const headingId = elementsHeadingId(apparatusId) return ( -

    -

    - Element list -

    - {elements.length === 0 ? ( -

    No elements on {apparatusName} in the list yet.

    - ) : ( -
      - {elements.map((element) => ( - - ))} -
    + ( + )} -
    + action={ + Add from search + } + /> ) } diff --git a/src/components/gymnasts/GymnastList.tsx b/src/components/gymnasts/GymnastList.tsx index 86c95da..ba8bcbd 100644 --- a/src/components/gymnasts/GymnastList.tsx +++ b/src/components/gymnasts/GymnastList.tsx @@ -20,7 +20,7 @@ export default function GymnastList({ You haven’t added any gymnasts yet.

    ) : ( -
      +
        {gymnasts.map((gymnast) => ( - Gymnast not found -

        Gymnast not found

        -

        - No gymnast of yours matches this address. -

        - -
    + Back to your gymnasts} + /> ) } @@ -40,38 +31,23 @@ export function GymnastUnavailable({ onRetry: () => void }) { return ( -
    - Gymnast unavailable -

    Gymnast unavailable

    -

    - {problem.message} -

    - {problem.code === 'UNAUTHENTICATED' ? ( - - ) : ( - problem.retryable && ( - + Log in + ) - )} -
    + } + /> ) } diff --git a/src/components/gymnasts/GymnastRow.tsx b/src/components/gymnasts/GymnastRow.tsx index e13a62d..44d0f4a 100644 --- a/src/components/gymnasts/GymnastRow.tsx +++ b/src/components/gymnasts/GymnastRow.tsx @@ -1,5 +1,6 @@ -import { Link } from 'react-router-dom' import DeleteGymnastButton from '@/components/gymnasts/DeleteGymnastButton' +import { CARD_LIFT, LIST_CARD, ROW_HOVER } from '@/components/gymnasts/listCard' +import StretchedLink from '@/components/gymnasts/StretchedLink' import type { GymnastSummary } from '@/lib/gymnasts' import { getGymnastInitials } from '@/lib/gymnasts' @@ -13,7 +14,9 @@ export default function GymnastRow({ onFailed: (message: string) => void }) { return ( -
  • +
  • -

    - {/* The link covers the whole card (the ::after), as the pointer and the hover - suggest, so it can be pressed anywhere on it. The delete button is above it. */} - +

    + {gymnast.name} - +

    `; what is common is here, so the + * structure that a screen reader reads (a named section with a named list) is made in one + * place. + * + * The heading can get the focus from the code (`tabIndex={-1}`, so it is not in the tab order), + * which the page uses after an element has been removed and the button that was pressed is gone. + */ +export default function ListSection({ + headingId, + title, + items, + emptyText, + renderRow, + action, +}: { + /** The id of the heading. It names the section and the list, and it can be looked up to move the focus. */ + headingId: string + title: string + items: T[] + /** What is said when `items` is empty. */ + emptyText: string + /** One `
  • ` for one item. The key is set here. */ + renderRow: (item: T) => ReactNode + /** Shown to the right of the heading. */ + action?: ReactNode +}) { + return ( +
    +
    +

    + {title} +

    + {action} +
    + {items.length === 0 ? ( +

    {emptyText}

    + ) : ( +
      + {items.map((item) => ( + {renderRow(item)} + ))} +
    + )} +
    + ) +} diff --git a/src/components/gymnasts/RoutineList.tsx b/src/components/gymnasts/RoutineList.tsx index f459ce2..5796dd3 100644 --- a/src/components/gymnasts/RoutineList.tsx +++ b/src/components/gymnasts/RoutineList.tsx @@ -1,8 +1,19 @@ -import { LIST_CARD, ROW_HOVER } from '@/components/gymnasts/listCard' +import AddButton from '@/components/gymnasts/AddButton' +import ListSection from '@/components/gymnasts/ListSection' +import { ROW_HOVER } from '@/components/gymnasts/listCard' import { TriangleAlert } from 'lucide-react' -import { countText, formatUpdated, type RoutineView } from '@/lib/gymnastDetail' +import { + countText, + formatUpdated, + newRoutineHref, + type RoutineView, +} from '@/lib/gymnastDetail' -/** One routine: its name, how big it is, when it changed and its D-score. */ +/** + * One routine: its name, how big it is, when it changed and its D-score. The "Highest" label has + * the color of the buttons of the site (`--button-bg`, `--button-text`). In light mode that is the + * color of the card too, so the label has an edge (`--box-stroke`) to be seen against it. + */ function RoutineRow({ routine }: { routine: RoutineView }) { return (
  • {routine.isHighest && ( - + Highest )} @@ -39,7 +50,7 @@ function RoutineRow({ routine }: { routine: RoutineView }) {

    )} -

    +

    {routine.score === null ? ( ) : ( @@ -53,29 +64,27 @@ function RoutineRow({ routine }: { routine: RoutineView }) { ) } -/** The routines of one apparatus, highest score first. */ +/** + * The routines of one apparatus, highest score first, and the "New routine" button, which + * goes to the calculator with the gymnast in the address (`newRoutineHref`). + */ export default function RoutineList({ routines, apparatusName, + gymnastId, }: { routines: RoutineView[] apparatusName: string + gymnastId: string }) { return ( -

    -

    Routines

    - {routines.length === 0 ? ( -

    No routines on {apparatusName} yet.

    - ) : ( -
      - {routines.map((routine) => ( - - ))} -
    - )} -
    + } + action={New routine} + /> ) } diff --git a/src/components/gymnasts/StretchedLink.tsx b/src/components/gymnasts/StretchedLink.tsx new file mode 100644 index 0000000..9eb3ba4 --- /dev/null +++ b/src/components/gymnasts/StretchedLink.tsx @@ -0,0 +1,19 @@ +import { Link, type LinkProps } from 'react-router-dom' + +/** + * A link that covers the whole card or row it is in (its `::after` is stretched over the + * nearest positioned parent), so the card can be pressed anywhere, as the pointer and the + * hover suggest. The parent must be `relative`, and a button inside the card must be + * `relative z-10` to stay above the link. The focus ring is on the link itself. + */ +export default function StretchedLink({ + className = '', + ...props +}: Omit & { className?: string }) { + return ( + + ) +} diff --git a/src/components/gymnasts/TrashButton.tsx b/src/components/gymnasts/TrashButton.tsx new file mode 100644 index 0000000..d0e1cf7 --- /dev/null +++ b/src/components/gymnasts/TrashButton.tsx @@ -0,0 +1,41 @@ +import type { MouseEvent } from 'react' +import { Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' + +/** + * The trash icon button of the gymnasts pages: the one that deletes a gymnast on "Your + * gymnasts" and the one that removes an element on the Gymnast page. Both look and work the + * same, so they are this one component. It only asks (`onClick`); the dialog is the caller's. + * + * The icon is in the text color until the pointer is over it or it is pressed, and then it + * turns red with a tint of the red behind it. Red is the strongest color of the site, so it + * is kept for the moment it matters and a list of many rows is not a list of many red icons. + * `hover:` only applies to a device that can hover (Tailwind 4 wraps it in + * `@media (hover: hover)`), so a phone gets the red from `active:`, while the finger is down. The button is + * 48 by 48 CSS pixels (WCAG 2.5.8 asks for 24, 2.5.5 for 44), and the icon needs 3:1 on the + * card in both themes (checked in gymnastTheme.test.ts). `label` is what a screen reader + * says, so a list of them can be told apart. "Solid": the shared button sets `outline-none`, + * which a width and a color alone do not undo. + */ +export default function TrashButton({ + label, + onClick, + className = '', +}: { + label: string + onClick: (event: MouseEvent) => void + className?: string +}) { + return ( + + ) +} diff --git a/src/components/gymnasts/listCard.ts b/src/components/gymnasts/listCard.ts index dd8fd5c..7d6227b 100644 --- a/src/components/gymnasts/listCard.ts +++ b/src/components/gymnasts/listCard.ts @@ -3,11 +3,13 @@ * the cards on the Elements page and the gymnasts page (`--surface`) and their shadow. */ export const LIST_CARD = 'bg-(--surface) shadow-(--box-shadow)' - /** - * The hover of a row in such a card, as on the gymnasts page: the card gets a tint of the - * text color. It is the same mix as there (8 %, which keeps the text above 4.5:1 in both - * themes, see gymnastTheme.test.ts). The pointer is for the rows that can be pressed. + * The hover of a row or a card: the color is the theme value `--card-hover` (a tint of the text + * color in light mode, a deeper navy in dark mode, see variables.css), which keeps the text above + * 4.5:1 in both themes (gymnastTheme.test.ts). The pointer is for the rows that can be pressed. + * The shadow is in the transition too, for a card that also gets `CARD_LIFT`. */ export const ROW_HOVER = - 'transition-[background-color] duration-150 hover:bg-[color-mix(in_srgb,var(--surface),var(--text-color)_8%)] motion-reduce:transition-none' + 'transition-[background-color,box-shadow] duration-150 hover:bg-(--card-hover) motion-reduce:transition-none' +/** A card that stands alone (a gymnast on "Your gymnasts") also gets a deeper shadow on hover. */ +export const CARD_LIFT = 'hover:shadow-[0_6px_16px_rgba(3,2,2,0.22)]' diff --git a/src/components/ui/LinkButton.tsx b/src/components/ui/LinkButton.tsx new file mode 100644 index 0000000..593dc19 --- /dev/null +++ b/src/components/ui/LinkButton.tsx @@ -0,0 +1,35 @@ +import { cn } from 'cn' +import type { LucideIcon } from 'lucide-react' +import { Link, type LinkProps } from 'react-router-dom' +import { buttonVariants } from '@/components/ui/button' +import { PILL } from '@/components/ui/pill' + +/** + * A link that looks like the pill button of the site, with an optional icon before the text. + * It is a real `
    `, so a screen reader says "link" for something that goes to another page. + * The Base UI `Button` is not used for this: given a link in `render` it either warns that it is + * not a `) + + expect(screen.getByRole('button', { name: 'Add one' })).toBeTruthy() + }) + + it('has a heading that the code can focus but that is not in the tab order', () => { + renderSection(items) + + const heading = screen.getByRole('heading', { level: 2 }) + expect(heading.getAttribute('tabindex')).toBe('-1') + heading.focus() + expect(document.activeElement).toBe(heading) + }) +}) diff --git a/src/tests/components/gymnasts/TrashButton.test.tsx b/src/tests/components/gymnasts/TrashButton.test.tsx new file mode 100644 index 0000000..677bcf4 --- /dev/null +++ b/src/tests/components/gymnasts/TrashButton.test.tsx @@ -0,0 +1,32 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import TrashButton from '@/components/gymnasts/TrashButton' + +afterEach(cleanup) + +describe('TrashButton', () => { + it('is a button named by its label, with a trash icon that is hidden from a screen reader', () => { + render( {}} />) + + const button = screen.getByRole('button', { name: 'Delete Emma Berg' }) + const icon = button.querySelector('svg') + expect(icon).not.toBeNull() + expect(icon?.getAttribute('aria-hidden')).toBe('true') + }) + + it('calls onClick with the event, so the caller can keep the button that was pressed', () => { + let pressed: HTMLElement | null = null + const onClick = vi.fn((event: { currentTarget: HTMLElement }) => { + pressed = event.currentTarget + }) + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + expect(onClick).toHaveBeenCalledTimes(1) + expect(pressed).toBe(button) + }) +}) diff --git a/src/tests/components/ui/button.test.tsx b/src/tests/components/ui/button.test.tsx index ca49705..96ef7e1 100644 --- a/src/tests/components/ui/button.test.tsx +++ b/src/tests/components/ui/button.test.tsx @@ -19,9 +19,18 @@ describe('Button from shadcn/ui', () => { expect(html).toContain(' { - const html = renderToString() + it('shows the icon of the icon prop before the label, hidden from a screen reader', () => { + const html = renderToString() - expect(html).toContain('border-border') + expect(html.indexOf(' { + const html = renderToString() + + expect(html).not.toContain(' { expect(countText(8, 'element')).toBe('8 elements') }) }) + +describe('newRoutineHref', () => { + it('goes to the calculator with the gymnast in the address', () => { + expect(newRoutineHref('gymnast-1')).toBe('/calculator?gymnast=gymnast-1') + }) + + it('escapes an id that has characters of its own in an address', () => { + expect(newRoutineHref('a&b=c')).toBe('/calculator?gymnast=a%26b%3Dc') + }) +}) + +describe('addFromSearchHref', () => { + it('goes to the search of the elements with the gymnast in the address', () => { + expect(addFromSearchHref('gymnast-1')).toBe('/elements?gymnast=gymnast-1') + }) + + it('escapes an id that has characters of its own in an address', () => { + expect(addFromSearchHref('a&b=c')).toBe('/elements?gymnast=a%26b%3Dc') + }) +}) diff --git a/src/tests/pages/GymnastPage.test.tsx b/src/tests/pages/GymnastPage.test.tsx index f81362f..c8e5ab8 100644 --- a/src/tests/pages/GymnastPage.test.tsx +++ b/src/tests/pages/GymnastPage.test.tsx @@ -324,7 +324,9 @@ describe('GymnastPage: removing an element', () => { fireEvent.click(removeButton(/^Remove 1\.302/)) const dialog = await screen.findByRole('alertdialog') - expect(dialog.textContent).toContain('1.302') + expect(dialog.textContent).toContain( + '1.302 will be removed from Ingrid Nilsen', + ) expect(page.removed()).toHaveLength(0) fireEvent.click(screen.getByRole('button', { name: 'No, keep it' })) @@ -484,3 +486,23 @@ describe('GymnastPage: structure for a screen reader', () => { expect(screen.getAllByRole('main')).toHaveLength(1) }) }) + +describe('GymnastPage: New routine and Add from search', () => { + it('has a "New routine" button that goes to the calculator with the gymnast in the address', async () => { + renderPage() + + const link = await screen.findByRole('link', { name: 'New routine' }) + expect(link.getAttribute('href')).toBe('/calculator?gymnast=gymnast-1') + // A plus before the label, hidden from a screen reader: the name is still "New routine". + const plus = link.querySelector('svg.lucide-plus') + expect(plus?.getAttribute('aria-hidden')).toBe('true') + }) + + it('has an "Add from search" button that goes to the search with the gymnast in the address', async () => { + renderPage() + + const link = await screen.findByRole('link', { name: 'Add from search' }) + expect(link.getAttribute('href')).toBe('/elements?gymnast=gymnast-1') + expect(link.querySelector('svg.lucide-plus')).not.toBeNull() + }) +}) diff --git a/src/tests/styles/gymnastTheme.test.ts b/src/tests/styles/gymnastTheme.test.ts index 3806438..f6a729e 100644 --- a/src/tests/styles/gymnastTheme.test.ts +++ b/src/tests/styles/gymnastTheme.test.ts @@ -248,40 +248,16 @@ describe('the components of the gymnasts and login pages', () => { }) }) -/** The color that `color-mix(in srgb, a, b N%)` gives, as a hex color. */ -const mix = (a: string, b: string, percent: number) => { - const channel = (hex: string, i: number) => - parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) - const part = (i: number) => - Math.round( - channel(a, i) * (1 - percent / 100) + channel(b, i) * (percent / 100), - ) - return ( - '#' + [0, 1, 2].map((i) => part(i).toString(16).padStart(2, '0')).join('') - ) -} - -describe('the card of a gymnast on hover', () => { +describe('the "Highest" label of a routine', () => { it.each(themes)( - 'still has a name that can be read on the tinted card in the %s theme (at least 4.5:1)', + 'can be read in the colors of the buttons in the %s theme (at least 4.5:1)', (_name, block) => { - const hovered = mix( - valueOf(block, '--surface'), - valueOf(block, '--text-color'), - 8, - ) expect( - contrast(valueOf(block, '--text-color'), hovered), + contrast( + valueOf(block, '--button-text'), + valueOf(block, '--button-bg'), + ), ).toBeGreaterThanOrEqual(4.5) }, ) - - it.each(themes)( - 'is different from the card at rest in the %s theme, so the visitor can see it', - (_name, block) => { - const rest = valueOf(block, '--surface') - const hovered = mix(rest, valueOf(block, '--text-color'), 8) - expect(hovered).not.toBe(rest) - }, - ) }) From 61bb9d872f973cce3aad0fed212623fcd21fed18 Mon Sep 17 00:00:00 2001 From: Ronja Date: Fri, 6 Nov 2026 17:11:18 +0100 Subject: [PATCH 19/32] feat(elements): dark mode, working Add to gymnast and themed vault badge The element page follows the theme with readable contrast. Add to gymnast is a menu for a logged-in coach and a link with a lock otherwise. Try in a routine uses Button with an icon, and DifficultyBadge is used for vaults. Refs: #149 --- e2e/element-page.e2e.ts | 139 +++++++ e2e/gymnast-helpers.ts | 251 +++++++++++++ e2e/gymnast-page.e2e.ts | 346 ++++++++++++++++++ playwright.config.ts | 4 + src/components/elements/AddToGymnast.tsx | 164 +++++++++ src/components/elements/ElementActions.tsx | 37 +- .../elements/ElementDetailHeader.tsx | 6 +- src/components/elements/ElementDiagram.tsx | 3 +- .../elements/ElementMetadataCard.tsx | 18 +- src/components/ui/DifficultyBadge.tsx | 4 +- src/components/ui/ElementValueBadge.tsx | 16 - src/components/ui/ElementVariants.tsx | 23 +- src/components/ui/menuStyles.ts | 10 + src/pages/ElementDetailPage.tsx | 24 +- .../components/elements/AddToGymnast.test.tsx | 264 +++++++++++++ .../elements/ElementActions.test.tsx | 73 +++- src/tests/pages/ElementDetailPage.test.tsx | 25 +- 17 files changed, 1307 insertions(+), 100 deletions(-) create mode 100644 e2e/element-page.e2e.ts create mode 100644 e2e/gymnast-helpers.ts create mode 100644 e2e/gymnast-page.e2e.ts create mode 100644 src/components/elements/AddToGymnast.tsx delete mode 100644 src/components/ui/ElementValueBadge.tsx create mode 100644 src/components/ui/menuStyles.ts create mode 100644 src/tests/components/elements/AddToGymnast.test.tsx diff --git a/e2e/element-page.e2e.ts b/e2e/element-page.e2e.ts new file mode 100644 index 0000000..1e9175c --- /dev/null +++ b/e2e/element-page.e2e.ts @@ -0,0 +1,139 @@ +// The page of one element (and of one vault) in a real browser: axe in both modes and on a +// phone, sideways scrolling, and "Add to gymnast", which is a link to the login for a visitor and +// a menu of her gymnasts for a logged-in coach. + +import { expect, test, type Page } from '@playwright/test' +import { + axeViolations, + catalogExamples, + chooseTheme, + createGymnast, + deleteGymnasts, + scrollsSideways, + signIn, + type TestGymnast, + type Theme, +} from './gymnast-helpers.ts' + +const COACH = 'e2e-element-page@example.test' + +let element: { id: string; code: string } +let notInList: { id: string; code: string } +let vaultId: string +let gymnast: TestGymnast + +test.beforeAll(async () => { + const examples = await catalogExamples() + element = examples.inList[0]! + notInList = examples.notInList + vaultId = examples.vaultId + await deleteGymnasts(COACH) + gymnast = await createGymnast(COACH, 'Ingrid Nilsen') +}) + +test.afterAll(async () => { + await deleteGymnasts(COACH) +}) + +async function openPage(page: Page, id: string, theme: Theme = 'light') { + await chooseTheme(page, theme) + await page.goto(`elements/${id}`) + await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + await expect( + page.getByRole('region', { name: 'Element metadata' }), + ).toBeVisible() +} + +test.describe('accessibility (axe, WCAG 2.2 A and AA)', () => { + for (const theme of ['light', 'dark'] as const) { + test(`the page of an element in ${theme} mode`, async ({ page }) => { + await openPage(page, element.id, theme) + expect(await axeViolations(page)).toEqual([]) + }) + } + + test('the page of an element on a phone, 320 px wide, in dark mode', async ({ + page, + }) => { + await page.setViewportSize({ width: 320, height: 700 }) + await openPage(page, element.id, 'dark') + expect(await axeViolations(page)).toEqual([]) + expect(await scrollsSideways(page)).toBe(false) + }) + + // A vault has the difficulty badges, which an element does not. Dark mode on a phone is the + // hardest case for them, so one test is enough. + test('the page of a vault, with its badges, on a phone in dark mode', async ({ + page, + }) => { + await page.setViewportSize({ width: 320, height: 700 }) + await openPage(page, vaultId, 'dark') + expect(await axeViolations(page)).toEqual([]) + expect(await scrollsSideways(page)).toBe(false) + }) +}) + +test.describe('"Add to gymnast", not logged in', () => { + test('is a link with a lock to the login page, and not a disabled button', async ({ + page, + }) => { + await openPage(page, element.id) + const link = page.getByRole('link', { name: /^Add to gymnast/ }) + await expect(link).toBeVisible() + await expect(link).toBeEnabled() + + await link.click() + await expect(page).toHaveURL(/\/login$/) + await expect(page.getByRole('heading', { level: 1 })).toContainText( + /log in/i, + ) + }) +}) + +test.describe('"Add to gymnast", logged in', () => { + test('adds the element to the gymnast that is chosen, and it is in her list', async ({ + page, + }) => { + await signIn(page, COACH) + await page.goto(`elements/${notInList.id}`) + await expect( + page.getByRole('region', { name: 'Element metadata' }), + ).toBeVisible() + + await page.getByRole('button', { name: 'Add to gymnast' }).click() + await page.getByRole('menuitem', { name: gymnast.name }).click() + await expect( + page.getByText(`${notInList.code} added to ${gymnast.name}.`), + ).toBeVisible() + + await page.goto(`gymnasts/${gymnast.id}`) + await expect( + page + .getByRole('list', { name: 'Element list' }) + .getByText(notInList.code, { exact: true }), + ).toBeVisible() + }) + + test('opens the menu with the keyboard, and Escape closes it and gives the focus back', async ({ + page, + }) => { + await signIn(page, COACH) + await page.goto(`elements/${element.id}`) + await expect( + page.getByRole('region', { name: 'Element metadata' }), + ).toBeVisible() + + const button = page.getByRole('button', { name: 'Add to gymnast' }) + await expect(button).toBeEnabled() + await button.focus() + await page.keyboard.press('Enter') + await expect(page.getByRole('menu')).toBeVisible() + await expect( + page.getByRole('menuitem', { name: gymnast.name }), + ).toBeVisible() + + await page.keyboard.press('Escape') + await expect(page.getByRole('menu')).toBeHidden() + await expect(button).toBeFocused() + }) +}) diff --git a/e2e/gymnast-helpers.ts b/e2e/gymnast-helpers.ts new file mode 100644 index 0000000..44f2e72 --- /dev/null +++ b/e2e/gymnast-helpers.ts @@ -0,0 +1,251 @@ +// Helpers for the end-to-end tests of the Gymnast page and the element page: a login that +// does not go through the form, test data made through the API, axe, and the colors of the +// page as the browser works them out. + +import AxeBuilder from '@axe-core/playwright' +import type { Page } from '@playwright/test' +import { apiUrl } from './env.ts' + +export type Theme = 'light' | 'dark' + +/** The refresh token is kept under this name in localStorage (`REFRESH_TOKEN_KEY` in src/lib/apollo/session.ts). */ +const REFRESH_TOKEN_KEY = 'cop.refreshToken' + +async function graphql( + query: string, + variables: Record = {}, + token?: string, +): Promise { + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ query, variables }), + }) + const body = (await response.json()) as { data?: T; errors?: unknown } + if (body.errors || !body.data) { + throw new Error(`The API refused: ${JSON.stringify(body.errors)}`) + } + return body.data +} + +/** + * Logs in as the test coach with this e-mail address (`devLogin`: the server must be started + * with DEV_LOGIN=true and a SESSION_SECRET, as playwright.config.ts does). A refresh token can + * be used once, so a test that opens the page must have a login of its own. + */ +async function login(email: string) { + const { devLogin } = await graphql<{ + devLogin: { accessToken: string; refreshToken: string } + }>( + 'mutation($email: String!) { devLogin(email: $email) { accessToken refreshToken } }', + { email }, + ) + return devLogin +} + +/** + * Makes the page open as this coach, in this theme: the login is put in localStorage before the + * page starts, where the app looks for it. Call it before `page.goto`. + */ +export async function signIn( + page: Page, + email: string, + theme: Theme = 'light', +) { + const { refreshToken } = await login(email) + await page.addInitScript( + ([key, token, chosen]) => { + // Only when there is none: the app writes a new one after each refresh, and the old one + // has been used by then. + if (!localStorage.getItem(key as string)) { + localStorage.setItem(key as string, token as string) + } + localStorage.setItem('theme', chosen as string) + }, + [REFRESH_TOKEN_KEY, refreshToken, theme], + ) +} + +/** The theme for a page that nobody is logged in on. */ +export async function chooseTheme(page: Page, theme: Theme) { + await page.addInitScript( + (chosen) => localStorage.setItem('theme', chosen), + theme, + ) +} + +export type TestGymnast = { + id: string + name: string + /** The codes of the elements in her list, in the order they were added. */ + codes: string[] +} + +/** The first elements of the uneven bars and the first vault, which the tests use as examples. */ +export async function catalogExamples() { + const data = await graphql<{ + bars: { edges: { node: { id: string; code: string } }[] } + vault: { edges: { node: { id: string } }[] } + }>(` + { + bars: searchCatalog(first: 9, filter: { apparatusId: "UB" }) { + edges { + node { + ... on Element { + id + code + } + } + } + } + vault: searchCatalog(first: 1, filter: { apparatusId: "VT" }) { + edges { + node { + ... on Vault { + id + } + } + } + } + } + `) + const bars = data.bars.edges.map((edge) => edge.node) + return { + /** Eight that the test gymnast has in her list. */ + inList: bars.slice(0, 8), + /** One that she does not have. */ + notInList: bars[8]!, + vaultId: data.vault.edges[0]!.node.id, + } +} + +/** Deletes every gymnast of the coach, so a run starts from the same place. */ +export async function deleteGymnasts(email: string) { + const { accessToken } = await login(email) + const { me } = await graphql<{ me: { gymnasts: { id: string }[] } }>( + '{ me { gymnasts { id } } }', + {}, + accessToken, + ) + for (const { id } of me.gymnasts) { + await graphql( + 'mutation($id: ID!) { deleteGymnast(id: $id) }', + { id }, + accessToken, + ) + } +} + +/** + * Makes a gymnast with eight elements and the routines (three by default) on the uneven bars. The coach is made + * on the first login. Other gymnasts of the coach are not touched. + */ +export async function createGymnast( + email: string, + name: string, + routines: string[] = ['Upgrade with Ricna', 'Competition 1', 'Safe set'], +): Promise { + const { accessToken } = await login(email) + const { inList } = await catalogExamples() + const { createGymnast: gymnast } = await graphql<{ + createGymnast: { id: string } + }>( + 'mutation($name: String!) { createGymnast(name: $name) { id } }', + { name }, + accessToken, + ) + for (const item of inList) { + await graphql( + 'mutation($g: ID!, $i: ID!) { addGymnastElement(gymnastId: $g, itemId: $i) { id } }', + { g: gymnast.id, i: item.id }, + accessToken, + ) + } + for (const routine of routines) { + const { createRoutine } = await graphql<{ createRoutine: { id: string } }>( + 'mutation($input: CreateRoutineInput!) { createRoutine(input: $input) { id } }', + { input: { gymnastId: gymnast.id, apparatusId: 'UB', name: routine } }, + accessToken, + ) + await graphql( + 'mutation($r: ID!, $ids: [ID!]!) { setRoutineItems(routineId: $r, itemIds: $ids) { id } }', + { r: createRoutine.id, ids: inList.slice(0, 5).map((item) => item.id) }, + accessToken, + ) + } + return { id: gymnast.id, name, codes: inList.map((item) => item.code) } +} + +const AXE_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'] + +/** + * What axe finds on the page, as plain data (WCAG 2.0, 2.1 and 2.2, level A and AA). `region` + * is turned off for a state where a dialog or menu is open: they are in a portal at the end of + * the document (the Base UI default), which axe reports as best practice and not as a WCAG failure. + */ +export async function axeViolations( + page: Page, + options: { portalOpen?: boolean } = {}, +) { + const builder = new AxeBuilder({ page }).withTags(AXE_TAGS) + if (options.portalOpen) builder.disableRules(['region']) + const { violations } = await builder.analyze() + return violations.map((violation) => ({ + rule: violation.id, + impact: violation.impact, + nodes: violation.nodes.map((node) => node.target.join(' ')), + })) +} + +/** True when the page can be scrolled sideways, which it must not be on a narrow screen (WCAG 1.4.10). */ +export function scrollsSideways(page: Page) { + return page.evaluate( + () => + document.documentElement.scrollWidth > + document.documentElement.clientWidth, + ) +} + +/** The line spacing, letter spacing and word spacing that WCAG 1.4.12 says a page must survive. */ +export const TEXT_SPACING_CSS = + '* { line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important } p { margin-bottom: 2em !important }' + +// --- Colors. The browser answers `rgb(...)`, `rgba(...)` or `color(srgb r g b)` (for a +// color-mix), and a token is a hex color. All of them become [r, g, b] in 0-1. + +export type Rgb = [number, number, number] + +export function parseColor(value: string): Rgb { + const text = value.trim() + const srgb = /^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/.exec(text) + if (srgb) return [Number(srgb[1]), Number(srgb[2]), Number(srgb[3])] + const rgb = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/.exec(text) + if (rgb) + return [Number(rgb[1]) / 255, Number(rgb[2]) / 255, Number(rgb[3]) / 255] + const hex = /^#([0-9a-f]{6})$/i.exec(text) + if (hex) { + const n = parseInt(hex[1]!, 16) + return [(n >> 16) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255] + } + throw new Error(`Cannot read the color "${value}"`) +} + +const linear = (c: number) => + c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 +const luminance = ([r, g, b]: Rgb) => + 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) + +/** The contrast ratio of WCAG between two colors, 1 to 21. */ +export function contrast(a: string, b: string): number { + const [x, y] = [luminance(parseColor(a)), luminance(parseColor(b))] + return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) +} + +/** True when the two colors are the same one, to what the eye can tell (a rounding of the browser). */ +export function sameColor(a: string, b: string): boolean { + const [x, y] = [parseColor(a), parseColor(b)] + return x.every((channel, i) => Math.abs(channel - y[i]!) < 0.01) +} diff --git a/e2e/gymnast-page.e2e.ts b/e2e/gymnast-page.e2e.ts new file mode 100644 index 0000000..204f864 --- /dev/null +++ b/e2e/gymnast-page.e2e.ts @@ -0,0 +1,346 @@ +// The Gymnast page and "Your gymnasts" in a real browser, logged in as a test coach (see +// signIn in gymnast-helpers.ts). This is where what a component test cannot see is checked, +// because jsdom has no layout, no focus rules and no real colors: axe, sideways scrolling on a +// narrow screen, the size of what is pressed, the arrow keys on the tabs, where the focus goes +// around the dialog, and the colors of a row and of the trash icon under the pointer. +// +// The test data is made through the API and is the coach's own, so the tests can be run again. + +import { expect, test, type Page } from '@playwright/test' +import { + axeViolations, + contrast, + createGymnast, + deleteGymnasts, + scrollsSideways, + sameColor, + signIn, + TEXT_SPACING_CSS, + type Theme, + type TestGymnast, +} from './gymnast-helpers.ts' + +const COACH = 'e2e-gymnast-page@example.test' + +let gymnast: TestGymnast +let longName: TestGymnast + +test.beforeAll(async () => { + await deleteGymnasts(COACH) + gymnast = await createGymnast(COACH, 'Ingrid Marie Nilsen-Haugeland') + // A name of 40 letters and a routine of 56 without a space, which have to be broken to fit a + // phone. A long word is what makes a grid column wider than the window. + longName = await createGymnast( + COACH, + 'Wolfeschlegelsteinhausenbergerdorffsmith', + ['Supercalifragilisticexpialidocious-routine-without-spaces'], + ) +}) + +test.afterAll(async () => { + await deleteGymnasts(COACH) +}) + +/** Opens the page of a gymnast, logged in, and waits until the lists are there. */ +async function openGymnast( + page: Page, + who: TestGymnast, + theme: Theme = 'light', +) { + await signIn(page, COACH, theme) + await page.goto(`gymnasts/${who.id}`) + await expect( + page.getByRole('heading', { level: 2, name: 'Element list' }), + ).toBeVisible() +} + +const elementList = (page: Page) => + page.getByRole('list', { name: 'Element list' }) +const trashButtons = (page: Page) => + page.getByRole('button', { name: /^Remove / }) + +test.describe('accessibility (axe, WCAG 2.2 A and AA)', () => { + for (const theme of ['light', 'dark'] as const) { + test(`the Gymnast page in ${theme} mode`, async ({ page }) => { + await openGymnast(page, gymnast, theme) + expect(await axeViolations(page)).toEqual([]) + }) + } + + test('the Gymnast page on a phone, 320 px wide', async ({ page }) => { + await page.setViewportSize({ width: 320, height: 700 }) + await openGymnast(page, gymnast, 'dark') + expect(await axeViolations(page)).toEqual([]) + }) + + test('the Gymnast page with the question "are you sure?" open', async ({ + page, + }) => { + await openGymnast(page, gymnast) + await trashButtons(page).first().click() + await expect(page.getByRole('alertdialog')).toBeVisible() + expect(await axeViolations(page, { portalOpen: true })).toEqual([]) + }) + + test('"Your gymnasts" in light and dark mode', async ({ page }) => { + for (const theme of ['light', 'dark'] as const) { + await signIn(page, COACH, theme) + await page.goto('gymnasts') + await expect( + page.getByRole('heading', { level: 1, name: 'Your gymnasts' }), + ).toBeVisible() + await expect(page.getByRole('link', { name: gymnast.name })).toBeVisible() + expect(await axeViolations(page), `in ${theme} mode`).toEqual([]) + } + }) +}) + +test.describe('narrow screens (WCAG 1.4.10 reflow and 1.4.12 text spacing)', () => { + for (const width of [280, 320, 360, 390]) { + test(`no sideways scrolling at ${width} px, also with a long name and with the text spacing of 1.4.12`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 700 }) + await signIn(page, COACH) + const pages: [string, string][] = [ + ['the Gymnast page', `gymnasts/${gymnast.id}`], + ['the Gymnast page of the long name', `gymnasts/${longName.id}`], + ['"Your gymnasts"', 'gymnasts'], + ] + for (const [label, address] of pages) { + await page.goto(address) + await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + await page.waitForLoadState('networkidle') + expect(await scrollsSideways(page), label).toBe(false) + + await page.addStyleTag({ content: TEXT_SPACING_CSS }) + expect( + await scrollsSideways(page), + `${label} with the text spacing of 1.4.12`, + ).toBe(false) + } + }) + } +}) + +test.describe('the size of what is pressed, on a phone', () => { + test.use({ viewport: { width: 320, height: 700 }, hasTouch: true }) + + // 40 px is what the Elements page is tested for. WCAG 2.2 asks for 24 (2.5.8, level AA). + // The header is outside
    and is not part of this. + const MIN = 40 + + for (const [label, address] of [ + ['the Gymnast page', () => `gymnasts/${gymnast.id}`], + ['"Your gymnasts"', () => 'gymnasts'], + ] as const) { + test(`every button, tab and link in ${label} is at least ${MIN} px tall and wide`, async ({ + page, + }) => { + await signIn(page, COACH) + await page.goto(address()) + await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + await page.waitForLoadState('networkidle') + + const tooSmall = await page + .locator('main a, main button, main [role=tab]') + .evaluateAll((items, min) => { + const found: { name: string; width: number; height: number }[] = [] + for (const item of items) { + const box = item.getBoundingClientRect() + if (box.width === 0 || box.height === 0) continue // hidden + if (item.closest('.sr-only')) continue + if (Math.round(box.width) < min || Math.round(box.height) < min) { + found.push({ + name: ( + item.getAttribute('aria-label') ?? + item.textContent ?? + '' + ) + .trim() + .slice(0, 40), + width: Math.round(box.width), + height: Math.round(box.height), + }) + } + } + return found + }, MIN) + expect(tooSmall).toEqual([]) + }) + } +}) + +test.describe('the tabs, with the keyboard', () => { + test('the arrow keys, Home and End move between the apparatus, and the chosen one shows its own lists', async ({ + page, + }) => { + await openGymnast(page, gymnast) + const selected = () => page.getByRole('tab', { selected: true }) + + // She has routines on the uneven bars only, so that is where the page opens. + await expect(selected()).toHaveText('Uneven bars') + await selected().focus() + + await page.keyboard.press('ArrowRight') + await expect(selected()).toHaveText('Balance beam') + await expect(selected()).toBeFocused() + await expect( + page.getByText('No routines on Balance beam yet.'), + ).toBeVisible() + + await page.keyboard.press('End') + await expect(selected()).toHaveText('Floor exercise') + await expect( + page.getByText('No elements on Floor exercise in the list yet.'), + ).toBeVisible() + + await page.keyboard.press('Home') + await expect(selected()).toHaveText('Vault') + + await page.keyboard.press('ArrowRight') + await expect(selected()).toHaveText('Uneven bars') + await expect( + page.getByRole('heading', { level: 2, name: 'Routines' }), + ).toBeVisible() + await expect(elementList(page).getByRole('listitem')).toHaveCount( + gymnast.codes.length, + ) + }) +}) + +test.describe('the question "are you sure?" when an element is removed', () => { + test('closes with Escape and gives the focus back to the button', async ({ + page, + }) => { + await openGymnast(page, gymnast) + const trash = trashButtons(page).first() + await trash.focus() + await page.keyboard.press('Enter') + + const dialog = page.getByRole('alertdialog') + await expect(dialog).toBeVisible() + + await page.keyboard.press('Escape') + await expect(dialog).toBeHidden() + await expect(trash).toBeFocused() + await expect(elementList(page).getByRole('listitem')).toHaveCount( + gymnast.codes.length, + ) + }) + + test('removes the element when it is confirmed, and the focus goes to the heading of the list', async ({ + page, + }) => { + const own = await createGymnast(COACH, 'Removal Test') + await openGymnast(page, own) + const before = own.codes.length + await expect(elementList(page).getByRole('listitem')).toHaveCount(before) + + await trashButtons(page).first().click() + await page.getByRole('button', { name: 'Yes, remove' }).click() + + await expect(elementList(page).getByRole('listitem')).toHaveCount( + before - 1, + ) + await expect(page.getByRole('alertdialog')).toBeHidden() + await expect( + page.getByRole('heading', { level: 2, name: 'Element list' }), + ).toBeFocused() + await expect( + page + .getByRole('status') + .filter({ hasText: `${own.codes[0]} removed from the list.` }), + ).toBeAttached() + }) +}) + +test.describe('the colors under the pointer', () => { + test.use({ viewport: { width: 1100, height: 900 } }) + + const colorOf = + (page: Page, property: 'color' | 'backgroundColor') => (selector: string) => + page + .locator(selector) + .first() + .evaluate((el, p) => getComputedStyle(el)[p], property) + + /** The value of a token of the theme, as the browser has it. */ + const token = (page: Page, name: string) => + page.evaluate( + (n) => + getComputedStyle(document.documentElement).getPropertyValue(n).trim(), + name, + ) + + for (const theme of ['light', 'dark'] as const) { + test(`a row under the pointer can be told from the card, and its text can be read, in ${theme} mode`, async ({ + page, + }) => { + await openGymnast(page, gymnast, theme) + const list = elementList(page) + const row = list.getByRole('listitem').nth(1) + const card = await list.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + + await row.hover() + // The color changes over 150 ms, so wait until it has stopped changing. + await expect + .poll(() => row.evaluate((el) => getComputedStyle(el).backgroundColor)) + .not.toBe('rgba(0, 0, 0, 0)') + await page.waitForTimeout(300) + const hovered = await row.evaluate( + (el) => getComputedStyle(el).backgroundColor, + ) + const text = await row + .getByRole('link') + .evaluate((el) => getComputedStyle(el).color) + + expect( + sameColor(hovered, card), + 'the row is not the color of the card', + ).toBe(false) + expect(contrast(text, hovered)).toBeGreaterThanOrEqual(4.5) + + if (theme === 'dark') { + // It was 1.05:1: the row looked like a hole in the card. + const pageColor = await colorOf(page, 'backgroundColor')('main') + expect(contrast(hovered, pageColor)).toBeGreaterThanOrEqual(1.5) + } + }) + } + + test('the trash icon is in the text color, turns red under the pointer, and is red while it is pressed', async ({ + page, + }) => { + await openGymnast(page, gymnast, 'dark') + const trash = trashButtons(page).first() + const colorNow = () => trash.evaluate((el) => getComputedStyle(el).color) + const danger = await token(page, '--danger') + const text = await token(page, '--text-color') + + expect( + sameColor(await colorNow(), text), + 'at rest it is the text color', + ).toBe(true) + + await trash.hover() + await expect + .poll(async () => sameColor(await colorNow(), danger)) + .toBe(true) + + // Pressed, and then let go away from the button, so that nothing is clicked. + await page.mouse.move(0, 0) + await expect.poll(async () => sameColor(await colorNow(), text)).toBe(true) + await trash.hover() + await page.mouse.down() + // The color changes over 150 ms. + await expect + .poll(async () => sameColor(await colorNow(), danger)) + .toBe(true) + await page.mouse.move(0, 0) + await page.mouse.up() + await expect(page.getByRole('alertdialog')).toBeHidden() + }) +}) diff --git a/playwright.config.ts b/playwright.config.ts index 4e501d5..1309f71 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -49,6 +49,10 @@ export default defineConfig({ PORT: String(backendPort), CORS_ORIGIN: `http://localhost:${frontendPort}`, IMAGE_BASE_URL: `http://localhost:${backendPort}/images`, + // The tests of the gymnast pages log in through the API (`devLogin`) and make + // their own test coach and gymnasts. Only for a backend that the tests start. + DEV_LOGIN: 'true', + SESSION_SECRET: 'end-to-end-tests-only-secret-32-characters', }, reuseExistingServer: !process.env['CI'], timeout: 60_000, diff --git a/src/components/elements/AddToGymnast.tsx b/src/components/elements/AddToGymnast.tsx new file mode 100644 index 0000000..8fb2c2f --- /dev/null +++ b/src/components/elements/AddToGymnast.tsx @@ -0,0 +1,164 @@ +import { useMutation, useQuery } from '@apollo/client/react' +import { Menu } from '@base-ui/react/menu' +import { LockKeyhole, Plus } from 'lucide-react' +import { Link, useLocation } from 'react-router-dom' +import { useAuth } from '@/components/auth/useAuth' +import AddButton from '@/components/gymnasts/AddButton' +import { Button } from '@/components/ui/button' +import { MENU_ITEM, MENU_LABEL, MENU_POPUP } from '@/components/ui/menuStyles' +import { PILL } from '@/components/ui/pill' +import { describeError } from '@/lib/apollo/errors' +import type { ElementDetail } from '@/lib/catalog' +import { ADD_GYMNAST_ELEMENT_MUTATION } from '@/lib/gymnastDetailOperations' +import { GYMNASTS_QUERY } from '@/lib/gymnastOperations' + +/** The button when nothing can be added: a removed element, or the answer about the login is on its way. */ +function Unavailable({ describedBy }: { describedBy?: string }) { + return ( + + ) +} + +/** The gymnasts of the coach in a menu, one choice for each gymnast. */ +function GymnastMenu({ + element, + onAdded, + onFailed, +}: { + element: ElementDetail + onAdded: (message: string) => void + onFailed: (message: string) => void +}) { + // The gymnasts can have changed on the other page, so the cache is shown and the server is asked. + const gymnasts = useQuery(GYMNASTS_QUERY, { + fetchPolicy: 'cache-and-network', + }) + const [addElement, { loading: adding }] = useMutation( + ADD_GYMNAST_ELEMENT_MUTATION, + ) + const list = gymnasts.data?.me?.gymnasts + + async function add(gymnast: { id: string; name: string }) { + if (adding) return + try { + await addElement({ + variables: { gymnastId: gymnast.id, itemId: element.id }, + }) + } catch (problem) { + onFailed(describeError(problem).message) + return + } + onAdded(`${element.code} added to ${gymnast.name}.`) + } + + return ( + + + } + > + Add to gymnast + + + + + + + Add {element.code} to + + {!list && gymnasts.loading ? ( +

    + Loading your gymnasts… +

    + ) : !list ? ( +

    + {describeError(gymnasts.error).message} +

    + ) : list.length === 0 ? ( + <> +

    You have no gymnasts yet.

    + } + className={MENU_ITEM} + > + Go to your gymnasts + + + ) : ( + list.map((gymnast) => ( + void add(gymnast)} + className={MENU_ITEM} + > + {gymnast.name} + + )) + )} +
    +
    +
    +
    +
    + ) +} + +/** + * "Add to gymnast" on the page of an element. What it is depends on the login, because only a + * logged-in coach has gymnasts: + * - logged in: a menu with the gymnasts of the coach; the choice adds the element to that list; + * - not logged in: a link to the login page, with a lock, which sends the visitor back to this + * page after the login. It is not disabled: a disabled button cannot be reached by the + * keyboard, says nothing about why, and leaves the visitor without a way on; + * - a removed element (the API refuses it) or an answer about the login that is not here yet: + * a disabled button. `describedBy` is the reason, which a screen reader reads with it. + */ +export default function AddToGymnast({ + element, + describedBy, + onAdded, + onFailed, +}: { + element: ElementDetail + describedBy?: string + onAdded: (message: string) => void + onFailed: (message: string) => void +}) { + const auth = useAuth() + const location = useLocation() + + if (element.removed || auth.status === 'loading') { + return + } + if (auth.status === 'anonymous') { + return ( + + Add to gymnast + (log in first) + + ) + } + return +} diff --git a/src/components/elements/ElementActions.tsx b/src/components/elements/ElementActions.tsx index e094740..1002314 100644 --- a/src/components/elements/ElementActions.tsx +++ b/src/components/elements/ElementActions.tsx @@ -1,6 +1,9 @@ import { useId, useState } from 'react' -import { LockKeyhole, Plus } from 'lucide-react' +import { Plus } from 'lucide-react' +import AddToGymnast from '@/components/elements/AddToGymnast' +import ErrorMessage from '@/components/ErrorMessage' import { Button } from '@/components/ui/button' +import { PILL } from '@/components/ui/pill' import type { ElementDetail } from '@/lib/catalog' export default function ElementActions({ @@ -9,6 +12,7 @@ export default function ElementActions({ element: ElementDetail }) { const [status, setStatus] = useState('') + const [error, setError] = useState('') const reasonId = useId() // A screen reader reads the reason with each disabled button. const describedBy = element.removed ? reasonId : undefined @@ -20,28 +24,30 @@ export default function ElementActions({ > - + { + setError('') + setStatus(message) + }} + onFailed={(message) => { + setStatus('') + setError(message) + }} + /> {element.removed && ( -

    +

    A removed element cannot be added to a routine or a gymnast.

    )} @@ -49,10 +55,11 @@ export default function ElementActions({ role="status" aria-live="polite" aria-atomic="true" - className="text-xs text-(--text-color-dark)" + className="text-xs text-(--text-color)" > {status}

    + {error && {error}} ) } diff --git a/src/components/elements/ElementDetailHeader.tsx b/src/components/elements/ElementDetailHeader.tsx index 70eabdb..bac14d7 100644 --- a/src/components/elements/ElementDetailHeader.tsx +++ b/src/components/elements/ElementDetailHeader.tsx @@ -15,13 +15,13 @@ export default function ElementDetailHeader({ type="button" onClick={() => navigate(-1)} aria-label="Go back to previous page" - className="inline-flex items-center gap-2 rounded-md py-2 text-sm font-medium text-(--text-color-dark) underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" + className="inline-flex items-center gap-2 rounded-md py-2 text-sm font-medium text-(--text-color) underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--text-color)" >