diff --git a/README.md b/README.md index a92133c..334f629 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/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 @@ -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). @@ -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/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,15 +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 | +| 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 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..f278aa0 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). 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 64e514d..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 @@ -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/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/App.tsx b/src/App.tsx index fdc7b72..b9bd1be 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,54 +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 LoginPage from './components/auth/LoginPage'; -import PageTitle from './components/PageTitle'; +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 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

-
-); +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 ( @@ -77,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.test.tsx b/src/components/gymnasts/DevLoginButton.test.tsx deleted file mode 100644 index 8a45cb3..0000000 --- a/src/components/gymnasts/DevLoginButton.test.tsx +++ /dev/null @@ -1,134 +0,0 @@ -// @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 "./DevLoginButton"; - -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 }; - -afterEach(() => { - cleanup(); - vi.resetAllMocks(); - vi.unstubAllEnvs(); -}); - -function typeEmail(address: string) { - 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", () => { - for (const [value, expected] of [ - ["true", true], - ["", false], - ["false", false], - ["1", false], - ] as const) { - vi.stubEnv("VITE_DEV_LOGIN", value); - expect(isDevLoginEnabled()).toBe(expected); - } - }); -}); - -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.", - ), - ).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(); - - 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(); - }); - - it("starts with no address, so nobody logs in as someone else's coach by default", () => { - render( Promise.resolve()} />); - - expect( - 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; - logIn.mockReturnValue( - new Promise((done) => { - 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); - }); - - 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(); - expect( - screen.getByLabelText("Your e-mail address").value, - ).toBe("emma@x"); - }); - - 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(); - expect( - screen.getByRole("button", { name: BUTTON }).disabled, - ).toBe(false); - }); -}); 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.test.tsx b/src/components/gymnasts/GymnastList.test.tsx deleted file mode 100644 index 6d59d1d..0000000 --- a/src/components/gymnasts/GymnastList.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -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"; - -// Each row has a delete button, which uses the client for its mutation. -const { client } = createTestClient(() => new Promise(() => {})); -const renderList = (gymnasts: { id: string; name: string }[]) => - renderToString( - - {}} onFailed={() => {}} /> - , - ); - -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"); - }); -}); 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.test.tsx b/src/components/skipLink/SkipLink.test.tsx deleted file mode 100644 index 6fa0275..0000000 --- a/src/components/skipLink/SkipLink.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -// @vitest-environment jsdom -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { SkipLink } from './SkipLink'; - -describe('SkipLink', () => { - it('links to the main content using the default id', () => { - render(); - - expect(screen.getByRole('link', { name: 'Skip to main content' }).getAttribute('href')).toBe( - '#main-content', - ); - }); - - it('supports a custom main content id', () => { - render(); - - 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(); - - 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/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/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/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.test.ts b/src/lib/gymnasts.test.ts deleted file mode 100644 index 2dacd40..0000000 --- a/src/lib/gymnasts.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - getGymnastInitials, - getGymnastNameError, - limitGymnastNameInput, - normalizeGymnastName, -} from "./gymnasts"; - -describe("gymnast helpers", () => { - it("creates initials from the first and last name", () => { - expect(getGymnastInitials("Ingrid Haugen")).toBe("IH"); - }); - - it("handles a single name and empty input", () => { - expect(getGymnastInitials("Madonna")).toBe("MA"); - expect(getGymnastInitials(" ")).toBe("?"); - }); - - it("trims and collapses whitespace in names", () => { - expect(normalizeGymnastName(" Emma Berg ")).toBe("Emma Berg"); - }); - - it("rejects blank and overlong names", () => { - expect(getGymnastNameError(" ")).toBe("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("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 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/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.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.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.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.tsx b/src/pages/YourGymnastsPage.tsx similarity index 76% rename from src/components/gymnasts/YourGymnastsPage.tsx rename to src/pages/YourGymnastsPage.tsx index c84213c..8f9168a 100644 --- a/src/components/gymnasts/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/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 65% rename from src/App.test.tsx rename to src/tests/App.test.tsx index 74e8b3a..cd9fbd2 100644 --- a/src/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/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/tests/components/gymnasts/DevLoginButton.test.tsx b/src/tests/components/gymnasts/DevLoginButton.test.tsx new file mode 100644 index 0000000..6675057 --- /dev/null +++ b/src/tests/components/gymnasts/DevLoginButton.test.tsx @@ -0,0 +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' + +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 } + +afterEach(() => { + cleanup() + vi.resetAllMocks() + vi.unstubAllEnvs() +}) + +function typeEmail(address: string) { + 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', () => { + for (const [value, expected] of [ + ['true', true], + ['', false], + ['false', false], + ['1', false], + ] as const) { + vi.stubEnv('VITE_DEV_LOGIN', value) + expect(isDevLoginEnabled()).toBe(expected) + } + }) +}) + +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.', + ), + ).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() + + 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() + }) + + it("starts with no address, so nobody logs in as someone else's coach by default", () => { + render( Promise.resolve()} />) + + expect( + 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 + logIn.mockReturnValue( + new Promise((done) => { + 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) + }) + + 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() + expect( + screen.getByLabelText('Your e-mail address').value, + ).toBe('emma@x') + }) + + 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() + expect( + 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 new file mode 100644 index 0000000..1b59017 --- /dev/null +++ b/src/tests/components/gymnasts/GymnastList.test.tsx @@ -0,0 +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' + +// Each row has a delete button, which uses the client for its mutation. +const { client } = createTestClient(() => new Promise(() => {})) +const renderList = (gymnasts: { id: string; name: string }[]) => + renderToString( + + {}} + onFailed={() => {}} + /> + , + ) + +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') + }) +}) diff --git a/src/components/navbar/Navbar.test.tsx b/src/tests/components/navbar/Navbar.test.tsx similarity index 59% rename from src/components/navbar/Navbar.test.tsx rename to src/tests/components/navbar/Navbar.test.tsx index 977c92a..c111fcf 100644 --- a/src/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 './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 new file mode 100644 index 0000000..7604ad3 --- /dev/null +++ b/src/tests/components/skipLink/SkipLink.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment jsdom +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() + + expect( + screen + .getByRole('link', { name: 'Skip to main content' }) + .getAttribute('href'), + ).toBe('#main-content') + }) + + it('supports a custom main content id', () => { + render() + + 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() + + 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/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/tests/lib/gymnasts.test.ts b/src/tests/lib/gymnasts.test.ts new file mode 100644 index 0000000..df44e5f --- /dev/null +++ b/src/tests/lib/gymnasts.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { + getGymnastInitials, + getGymnastNameError, + limitGymnastNameInput, + normalizeGymnastName, +} from '@/lib/gymnasts' + +describe('gymnast helpers', () => { + it('creates initials from the first and last name', () => { + expect(getGymnastInitials('Ingrid Haugen')).toBe('IH') + }) + + it('handles a single name and empty input', () => { + expect(getGymnastInitials('Madonna')).toBe('MA') + expect(getGymnastInitials(' ')).toBe('?') + }) + + it('trims and collapses whitespace in names', () => { + expect(normalizeGymnastName(' Emma Berg ')).toBe('Emma Berg') + }) + + it('rejects blank and overlong names', () => { + expect(getGymnastNameError(' ')).toBe('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('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/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/components/elements/ElementDetailPage.test.tsx b/src/tests/pages/ElementDetailPage.test.tsx similarity index 99% rename from src/components/elements/ElementDetailPage.test.tsx rename to src/tests/pages/ElementDetailPage.test.tsx index f2c48ca..4a02b38 100644 --- a/src/components/elements/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/components/elements/ElementsPage.test.tsx b/src/tests/pages/ElementsPage.test.tsx similarity index 99% rename from src/components/elements/ElementsPage.test.tsx rename to src/tests/pages/ElementsPage.test.tsx index 15ee94f..c75b6df 100644 --- a/src/components/elements/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/components/auth/LoginPage.test.tsx b/src/tests/pages/LoginPage.test.tsx similarity index 99% rename from src/components/auth/LoginPage.test.tsx rename to src/tests/pages/LoginPage.test.tsx index 31d07da..eb066ae 100644 --- a/src/components/auth/LoginPage.test.tsx +++ b/src/tests/pages/LoginPage.test.tsx @@ -12,7 +12,7 @@ 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 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/components/gymnasts/YourGymnastsPage.test.tsx b/src/tests/pages/YourGymnastsPage.test.tsx similarity index 99% rename from src/components/gymnasts/YourGymnastsPage.test.tsx rename to src/tests/pages/YourGymnastsPage.test.tsx index 76f0ef7..ff19938 100644 --- a/src/components/gymnasts/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/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/src/styles/gymnastTheme.test.ts b/src/tests/styles/gymnastTheme.test.ts similarity index 96% rename from src/styles/gymnastTheme.test.ts rename to src/tests/styles/gymnastTheme.test.ts index c0ad01e..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']"), @@ -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) }) 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..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', }, }, @@ -28,4 +32,4 @@ export default defineConfig({ setupFiles: ['./src/tests/setupTests.ts'], fileParallelism: false, }, -}); +})