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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 45 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ We are building a web application that helps gymnastics coaches plan their gymna

## Status

The first delivery was a specification. The application is not finished: the frontend is still the Vite starter page, but the catalog data has been extracted from the Code of Points and is in [`data/`](data/), and the database schema is in place: the Prisma schema and its migrations create all the tables of the data model in PostgreSQL. The seed script loads `data/` into the database, and a GraphQL server in `backend/` answers the catalog queries and the search (see [GraphQL server](#graphql-server)). The login and the user data (gymnasts, their lists of elements and routines) work through the API. The connection from the frontend is the next step.
The first delivery was a specification. The application is not finished. The catalog data has been extracted from the Code of Points and is in [`data/`](data/), the database schema and migrations are in place, the seed script loads `data/` into PostgreSQL, and a GraphQL server in `backend/` answers the catalog queries and the search (see [GraphQL server](#graphql-server)). The login and the user data (gymnasts, their lists of elements and routines) work through the API.

The code in `src/` is the Vite starter project with one example test. The tooling is set up: TypeScript, ESLint, Prettier and Vitest.
The frontend is connected to the API: the **Elements page** searches the catalog with filters, sorting and "Load more" (see [The Elements page](#the-elements-page-search-filters-and-sort)), each element has a detail page, and "Your gymnasts" lists and adds gymnasts. The calculator and the About page are still placeholders, and the Google login in the interface is not connected yet. The tooling is set up: TypeScript, ESLint, Prettier and Vitest.

## What to review

Expand Down Expand Up @@ -144,7 +144,7 @@ An icon without text needs a label for screen readers, for example `<Button aria

## Getting started

> The frontend in `src/` is still the Vite starter project, so `npm run dev` only shows the starter page. To work on the backend, also follow "Database (local setup)" below.
> `npm run dev` starts the frontend, which gets its data from the backend. To see the catalog, also set up the database and start the backend: follow "Database (local setup)" below, then run `npm run backend` in a second terminal.
You need Node 24 (see `.nvmrc`). The project uses npm; do not use pnpm or yarn, so that everyone gets the versions in `package-lock.json`.

Expand All @@ -154,7 +154,7 @@ npm ci
npm run dev
```

`npm run dev` starts the starter project at http://localhost:5173/project2/.
`npm run dev` starts the frontend at http://localhost:5173/project2/. Without the backend the pages show an error with "Try again".

Other commands:

Expand Down Expand Up @@ -208,6 +208,46 @@ fetchMore({ variables: { after: data.searchCatalog.pageInfo.endCursor } })
- **The login.** `login(googleToken)`, `logout()` and `restoreSession()` are in `src/lib/apollo/client.ts`. The access token is kept in memory, the refresh token in `localStorage`. A request that answers `UNAUTHENTICATED` because the access token has expired refreshes the session once and is repeated. Refreshing is done from one place at a time (`navigator.locks`), as the server requires. How it works and why is in "Identifying the user" in [docs/API.md](docs/API.md). The Google login button itself is not built yet.
- **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.

### What the user can do

- **Search** for words in the code, name or description (see [Catalog search](#catalog-search)). The request is sent when the user has stopped typing for 300 ms, or at once on Enter.
- **Filter** by apparatus, by element group of the chosen apparatus, and by difficulty. Several difficulty values can be chosen. "Clear filters" takes them all away.
- **Sort** by code, difficulty or name, in both directions.
- Everything can be combined, and a change of any of them starts again from the first page. Filtering, sorting and counting run on the server over the whole catalog, never only on what the client has loaded (a requirement of the assignment).

### Choices and why

- **Ready-made parts only.** The menus are the Base UI `Menu` and `Tooltip` that shadcn/ui is built on (already a dependency), so the arrow keys, Enter, Space, Escape, the type-ahead and "checked" are handled by a library that is tested for it. The address is `useSearchParams` from React Router. The Apollo cache already keeps one list per `search`, `filter` and `sort` (`keyArgs` in `src/lib/apollo/cache.ts`), so a new filter is a new list and "Load more" adds to the right one.
- **Scales with the catalog.** What the filters choose from is small and fixed: the 4 apparatus with their groups and difficulty values, fetched once (`CatalogStructure`, kept by the Apollo cache and by the server in memory for ten minutes) and the letters A-J. Nothing in the interface grows with the number of elements, and no filter or sort option is read from the results.
- **Difficulty is a letter for elements and a band for vault.** An element has a value from 0.1 to 1.0, which the coach knows as a letter (A = 0.1 ... J = 1.0). A vault has a D-value from 1.6 to 6.4 and no letter. There are 23 different D-values, which is too many to choose between one by one, so the menu offers bands of one whole number (2.0-2.9, 3.0-3.9 ...). A band is sent to the API as the D-values that exist inside it, which `Apparatus.difficultyValues` lists, so the API needs no ranges. Only bands that have a vault are offered, and the chosen difficulty is dropped when the apparatus changes between vault and the others, because the scales differ.
- **The sort is one list of outcomes.** "Code: low to high", "Difficulty: high to low", "Name: A to Z" ... instead of a field and a separate "Ascending"/"Descending". Practitioner guidance on sort menus (for example Vitaly Friedman's) is to name what the user gets and not the jargon, and the ARIA Authoring Practices put the sort state on the column header (`aria-sort`) for tables. Difficulty needs an apparatus, because element values and vault D-scores cannot be compared (see "Why difficulty is only sorted within an apparatus" in [docs/API.md](docs/API.md)), so its two choices are disabled until one is chosen, and the menu says why. On vault the difficulty sort is by the D-score.
- **"Element group" is a section of the table of elements** (for example "1.000 Mounts" on uneven bars; `ElementGroup` in the database), not one of the five composition requirements of the Code of Points that are worth 0.5 each. The first digit of an element's code is the number of its group. A vault group has a long title, so it is shown as "Group N", as in the results list.
- **The address is cleaned before it is sent.** A filter that the API would refuse (an apparatus or group that does not exist, a group of another apparatus, a difficulty that is not one of the options) is left out, so an old or edited link never gives `BAD_USER_INPUT`. An address with a filter waits for the structure; if the structure cannot be loaded the page says so and does not search, because searching without the filters would show results that do not match the address.
- **Each change is built on the last one.** Two quick choices in the difficulty menu both count. React Router's function form of `setSearchParams` only sees the address of the last render, so the page keeps the latest address in a ref.

### Accessibility

- **A filter that cannot be used is disabled, but still reachable.** The group filter before an apparatus is chosen uses `aria-disabled` and not the `disabled` attribute: a disabled button is skipped by the keyboard, and then nobody can find out why it is disabled. The reason is a tooltip (on hover, on keyboard focus, and when the button is pressed, because a tooltip does not open on a tap) and also its `aria-describedby`, so a screen reader says it. This is the pattern that PatternFly, Salt and Vivid recommend for a disabled control that has to explain itself.
- **Each button says its name and its choice** ("Apparatus: Uneven bars", "Difficulty: A, B"), the menus have names, a chosen item is `aria-checked`, and the focus goes back to the button when a menu closes. "Clear filters" removes itself, so it moves the focus to the first filter.
- **The number of results is announced** (`role="status"`, "Showing 20 of 178"), and the results section is `aria-busy` while it loads.
- **Every page has a title** that says what the page is (`PageTitle`, "Elements – WAG CoP Planner"; WCAG 2.4.2). React 19 moves a `<title>` to the head wherever it is rendered, so no library is needed. The element page names the element and the apparatus, also while it is loading or has failed.
- **Checked** with axe-core (no violations in light and dark mode, with the menus open and at phone width), Lighthouse (100), keyboard-only use, contrast (about 15.9:1), touch target sizes, reflow at 320 px, text spacing and reduced motion. **Not checked** with a screen reader such as NVDA or VoiceOver: that has to be done by a person.

### 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.
- 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.

### Known limits

- The tooltip and the focus behavior of the menus are Base UI's. Axe lists their internal focus guards and `aria-controls` as "needs review", and reports the portal of an open menu as "content not in a landmark" (best practice, not a WCAG failure).
- The backend has to be updated together with the frontend: the page asks for `Apparatus.difficultyValues`, which an older backend does not have, and then the filters cannot be loaded.

## Database (local setup)

The backend uses PostgreSQL 18. Everyone installs it directly on their own machine, without Docker, and everyone uses version 18 so that migrations behave the same for all of us.
Expand Down Expand Up @@ -481,7 +521,7 @@ Run `ANALYZE` after adding or removing many rows, or PostgreSQL plans from old s
| `docs/` | The specification: user interface plans, data model, example data and AI usage. [docs/EXTRACTION.md](docs/EXTRACTION.md) explains how `data/` was made |
| `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 frontend code. For now the Vite starter project and one example test |
| `src/` | The frontend code: the pages, the Apollo client, the components and their tests |

## Working in this repository

Expand Down
71 changes: 64 additions & 7 deletions backend/src/catalog/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
searchCatalog,
searchTerms,
} from './search.js'
import { catalogStructure } from './structure.js'

describe('searchTerms', () => {
it('uses the same rules as the stored words: lowercase, no accents, codes kept whole', () => {
Expand Down Expand Up @@ -218,22 +219,78 @@ describe.runIf(hasDatabase)('searchCatalog on the seeded catalog', () => {
)
})

it('rejects difficulty values that are not 0.1–1.0 in steps of 0.1, and vault', async () => {
it('rejects difficulty values that are not in steps of 0.1 or out of range', async () => {
for (const value of [0, 0.15, 1.1, -0.1, Number.NaN]) {
await expectBadInput(
{ filter: { difficultyValues: [value] } },
/Difficulty value .* is not valid/,
)
}
await expectBadInput(
{ filter: { apparatusId: 'VT', difficultyValues: [0.3] } },
/cannot be used with vault/,
// Vault D-values go higher, but not above 10.0, and also in steps of 0.1.
for (const value of [0, 2.45, 10.1, Number.NaN]) {
await expectBadInput(
{ filter: { apparatusId: 'VT', difficultyValues: [value] } },
/Difficulty value .* is not valid/,
)
}
})

it('filters vault by its D-value, and never mixes it with the element values', async () => {
const dValues = (
await prisma.catalogEntry.findMany({
where: { type: 'VAULT' },
select: { difficulty: true },
distinct: ['difficulty'],
orderBy: { difficulty: 'asc' },
})
).map((e) => Number(e.difficulty))
const chosen = [dValues[1], dValues[3]]
const found = await searchCatalog(prisma, {
filter: { apparatusId: 'VT', difficultyValues: chosen },
withTotalCount: true,
first: 100,
})
expect(found.edges.length).toBeGreaterThan(0)
expect(
found.edges.every(
(e) =>
e.node.type === 'VAULT' && chosen.includes(Number(e.node.difficulty)),
),
).toBe(true)
expect(found.totalCount).toBe(
await prisma.catalogEntry.count({
where: {
type: 'VAULT',
difficulty: { in: chosen.map((v) => v.toFixed(1)) },
},
}),
)
// A vault group without an apparatus filter is vault too, not 0 hits.
await expectBadInput(
{ filter: { elementGroupId: 'VT-1', difficultyValues: [0.3] } },
/cannot be used with vault/,
const byGroup = await searchCatalog(prisma, {
filter: { elementGroupId: 'VT-1', difficultyValues: dValues },
withTotalCount: true,
})
expect(byGroup.totalCount).toBe(
await prisma.catalogEntry.count({ where: { elementGroupId: 'VT-1' } }),
)
// The element scale 0.1–1.0 matches no vault, and without an apparatus it is elements.
const none = await searchCatalog(prisma, {
filter: { apparatusId: 'VT', difficultyValues: [0.3] },
withTotalCount: true,
})
expect(none.totalCount).toBe(0)
})

it('lists the difficulty values that exist, in order, in the structure', async () => {
const { difficultyValuesByApparatus } = await catalogStructure(prisma)
for (const [apparatusId, values] of difficultyValuesByApparatus) {
expect(values).toEqual([...values].sort((x, y) => x - y))
expect(new Set(values).size).toBe(values.length)
expect(
values.every((v) => (apparatusId === 'VT' ? v >= 1 : v <= 1)),
).toBe(true)
}
expect(difficultyValuesByApparatus.get('VT')!.length).toBeGreaterThan(5)
})

it('sorts by difficulty only within an apparatus', async () => {
Expand Down
36 changes: 24 additions & 12 deletions backend/src/catalog/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ export type { SortDirection, SortField } from './sorts.js'
export type CatalogFilter = {
apparatusId?: string | null
elementGroupId?: string | null
/** Difficulty values 0.1–1.0 in steps of 0.1. Only elements, never vaults. */
/**
* Difficulty values in steps of 0.1: 0.1–1.0 for elements, and the D-values (0.1–10.0)
* for vault, when the apparatus or the group is vault. Without an apparatus it only
* finds elements.
*/
difficultyValues?: number[] | null
namedOnly?: boolean | null
}
Expand Down Expand Up @@ -84,16 +88,21 @@ export function searchTerms(text: string | null | undefined): string[] {
}

/** Checks a difficulty value and returns it as a Decimal with one decimal. */
function difficultyValue(value: unknown): Prisma.Decimal {
/** The highest D-value that can be filtered on: element values are 0.1–1.0, vault D-values 0.1–10.0. */
const MAX_ELEMENT_TENTHS = 10
const MAX_VAULT_TENTHS = 100

function difficultyValue(value: unknown, isVault: boolean): Prisma.Decimal {
const maxTenths = isVault ? MAX_VAULT_TENTHS : MAX_ELEMENT_TENTHS
const tenths = typeof value === 'number' ? Math.round(value * 10) : NaN
if (
!Number.isFinite(tenths) ||
Math.abs((value as number) * 10 - tenths) > 1e-6 ||
tenths < 1 ||
tenths > 10
tenths > maxTenths
) {
throw new BadUserInputError(
`Difficulty value ${String(value)} is not valid. Use 0.1–1.0 in steps of 0.1.`,
`Difficulty value ${String(value)} is not valid. Use ${isVault ? '0.1–10.0 for vault' : '0.1–1.0'} in steps of 0.1.`,
)
}
return new Prisma.Decimal(tenths).dividedBy(10)
Expand Down Expand Up @@ -134,14 +143,17 @@ async function buildWhere(
groupApparatusId = group.apparatusId
}
if (filter.difficultyValues != null && filter.difficultyValues.length > 0) {
// A vault group without an apparatus filter is vault too.
if (filter.apparatusId === 'VT' || groupApparatusId === 'VT') {
throw new BadUserInputError(
'The difficulty filter cannot be used with vault. Vaults have D-values, not difficulty values.',
)
}
const values = filter.difficultyValues.map(difficultyValue)
and.push({ type: 'ELEMENT', difficulty: { in: values } })
// A vault group without an apparatus filter is vault too. Vault is filtered on its
// D-value (the same column), and elements on their value. The two scales are never
// mixed: without an apparatus only elements are filtered, as before.
const isVault = filter.apparatusId === 'VT' || groupApparatusId === 'VT'
const values = filter.difficultyValues.map((v) =>
difficultyValue(v, isVault),
)
and.push({
type: isVault ? 'VAULT' : 'ELEMENT',
difficulty: { in: values },
})
}
if (filter.namedOnly === true) and.push({ hasName: true })

Expand Down
20 changes: 19 additions & 1 deletion backend/src/catalog/structure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ export const CACHE_TTL_MS = 10 * 60 * 1000
export type CatalogStructure = {
apparatusIds: ReadonlySet<string>
groupsById: ReadonlyMap<string, ElementGroup>
/**
* The difficulty values that exist on each apparatus, ascending: element values
* (0.1–1.0) and vault D-values. Read with one `groupBy`, once per CACHE_TTL_MS, so the
* cost does not depend on how many requests there are.
*/
difficultyValuesByApparatus: ReadonlyMap<string, readonly number[]>
}

const cache = new WeakMap<
Expand All @@ -20,13 +26,25 @@ const cache = new WeakMap<
>()

async function load(prisma: PrismaClient): Promise<CatalogStructure> {
const [apparatus, groups] = await Promise.all([
const [apparatus, groups, difficulties] = await Promise.all([
prisma.apparatus.findMany({ select: { id: true } }),
prisma.elementGroup.findMany({ orderBy: { number: 'asc' } }),
// The index on (apparatus_id, difficulty, id) serves this.
prisma.catalogEntry.groupBy({
by: ['apparatusId', 'difficulty'],
orderBy: { difficulty: 'asc' },
}),
])
const difficultyValuesByApparatus = new Map<string, number[]>()
for (const { apparatusId, difficulty } of difficulties) {
const values = difficultyValuesByApparatus.get(apparatusId) ?? []
values.push(Number(difficulty))
difficultyValuesByApparatus.set(apparatusId, values)
}
return {
apparatusIds: new Set(apparatus.map((a) => a.id)),
groupsById: new Map(groups.map((g) => [g.id, g])),
difficultyValuesByApparatus,
}
}

Expand Down
Loading