From c770b2240e3af1c3241d9c96be18c9c95021ce27 Mon Sep 17 00:00:00 2001 From: Ronja Date: Thu, 15 Oct 2026 14:48:57 +0200 Subject: [PATCH 1/6] feat: add gymnast item list, routine timestamps and removed flag to the GraphQL schema Gymnast.items with addGymnastItem and removeGymnastItem, Routine.createdAt and updatedAt, and CatalogItem.removed. API.md describes the rules, errors and limits. --- backend/src/schema.graphql | 56 ++++++++++++++++++++++++++++++++---- docs/AI_USAGE.md | 15 ++++++++++ docs/API.md | 44 ++++++++++++++++++++-------- docs/USER_LIST_PAGINATION.md | 9 ++++-- 4 files changed, 104 insertions(+), 20 deletions(-) diff --git a/backend/src/schema.graphql b/backend/src/schema.graphql index 13ad6d7..6c441fa 100644 --- a/backend/src/schema.graphql +++ b/backend/src/schema.graphql @@ -13,10 +13,13 @@ # gymnast and apparatus (constraint 6). # INVALID_ITEMS The items do not fit the routine: a vault outside vault, # an element on vault, more than one vault, or an item from -# another apparatus (constraints 7 and 8). +# another apparatus (constraints 7 and 8). Also an item that +# has been removed from the Code of Points (`removed`) and +# is not already in the routine or the gymnast's list. # BAD_USER_INPUT Empty name, malformed id, `first` out of range. # LIMIT_REACHED The coach has reached a limit: 200 gymnasts, 50 routines -# per gymnast, or 50 items in a routine. +# per gymnast, 50 items in a routine, or 200 items in a +# gymnast's list. # # Why this way: # - `me` is the only operation where "not logged in" is a normal answer. The @@ -53,6 +56,12 @@ interface CatalogItem { """Full URL of the drawing, ready to use as the src of an .""" imageUrl: String! elementGroup: ElementGroup! + """ + True when the item has been removed from the Code of Points. It is not found + by the catalog search, but it can still be in routines and gymnast lists, so + the UI should warn about it there. It cannot be added to a routine or a list. + """ + removed: Boolean! } """One element on uneven bars, balance beam or floor.""" @@ -64,11 +73,16 @@ type Element implements CatalogItem { """Full URL of the drawing, ready to use as the src of an .""" imageUrl: String! elementGroup: ElementGroup! + """True when the element has been removed from the Code of Points.""" + removed: Boolean! """Difficulty value, 0.1-1.0.""" value: Float! """Difficulty letter A-J, calculated from value.""" difficultyLetter: String! - """Other ways of performing the same element (same code, same value).""" + """ + Other ways of performing the same element (same code, same value) that are + still in the Code of Points. + """ variants: [Element!]! } @@ -81,8 +95,11 @@ type Vault implements CatalogItem { """Full URL of the drawing, ready to use as the src of an .""" imageUrl: String! elementGroup: ElementGroup! + """True when the vault has been removed from the Code of Points.""" + removed: Boolean! """Fixed difficulty score, e.g. 2.6.""" dValue: Float! + """Other ways of performing the same vault that are still in the Code of Points.""" variants: [Vault!]! } @@ -138,8 +155,11 @@ type CatalogConnection { type Query { apparatus: [Apparatus!]! elementGroup(id: ID!): ElementGroup + """Also finds removed elements (see `removed`), so routines can show them.""" element(id: ID!): Element + """Also finds removed vaults (see `removed`), so routines can show them.""" vault(id: ID!): Vault + """Also finds removed items (see `removed`), so routines can show them.""" catalogItem(id: ID!): CatalogItem """The logged-in coach, or null when nobody is logged in.""" @@ -191,6 +211,12 @@ type Gymnast { by the backend. """ routines(apparatusId: ID): [Routine!]! + """ + The elements and vaults the gymnast can do or is practicing, in the order + they were added. Optionally only the items on one apparatus. Can contain + removed items (see `removed`). Limited to 200 per gymnast by the backend. + """ + items(apparatusId: ID): [CatalogItem!]! } type Routine { @@ -200,8 +226,14 @@ type Routine { apparatus: Apparatus! """The elements, or the single vault, in the order of the routine.""" items: [CatalogItem!]! - """Sum of the eight highest values, each code counted once. Calculated.""" + """ + Sum of the eight highest values, each code counted once. Calculated. Removed + items count as before, so the score does not change silently. + """ dScore: Float! + createdAt: DateTime! + """When the routine, its name or its items were last changed.""" + updatedAt: DateTime! } type AuthPayload { @@ -226,6 +258,19 @@ type Mutation { """Also deletes the gymnast's routines. Returns the id of the deleted gymnast.""" deleteGymnast(id: ID!): ID! + """ + Adds an element or vault to the gymnast's list. Adding an item that is already + in the list changes nothing. Throws NOT_FOUND, INVALID_ITEMS (a removed item) + or LIMIT_REACHED. + """ + addGymnastItem(gymnastId: ID!, itemId: ID!): Gymnast! + """ + Removes an item from the gymnast's list, also when it has been removed from + the Code of Points. Removing an item that is not in the list changes nothing. + Throws NOT_FOUND. + """ + removeGymnastItem(gymnastId: ID!, itemId: ID!): Gymnast! + """Throws NAME_TAKEN or LIMIT_REACHED.""" createRoutine(input: CreateRoutineInput!): Routine! """Throws NAME_TAKEN.""" @@ -233,7 +278,8 @@ type Mutation { deleteRoutine(id: ID!): ID! """ Replaces the whole list. The client sends the ids in the wanted order. - More than 50 ids throws LIMIT_REACHED. Throws INVALID_ITEMS. + More than 50 ids throws LIMIT_REACHED. Throws INVALID_ITEMS. A removed item + can stay in the list, but it cannot be added to it. """ setRoutineItems(routineId: ID!, itemIds: [ID!]!): Routine! } diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 86aaea0..3113f47 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -75,3 +75,18 @@ workspace, TypeScript, Prisma and the ESLint configuration for the backend, wrote the "Database (local setup)" section in the README, and checked that the install, lint, build and the connection to a local database work. + +## Missing fields in the GraphQL schema (`backend/src/schema.graphql`, `docs/API.md`) + +- **Tool:** Claude Code +- **What the team did:** Wrote issue #37, which lists the fields that the + database has and the schema does not expose: the gymnast's list of elements + and vaults with mutations, `createdAt` and `updatedAt` on a routine, and a + flag for removed items. The team reviews the proposed rules before they are + used. +- **What Claude did:** Added the fields and mutations to the schema, and + described them in `docs/API.md`, including the error codes and limits. + Claude proposed the rules that the issue does not decide: adding and + removing an item is idempotent, a removed item cannot be added but can stay + where it is, and a removed item still counts in the D-score. Claude also + checked that the schema parses with the `graphql` library. diff --git a/docs/API.md b/docs/API.md index 67a81bc..99e8a07 100644 --- a/docs/API.md +++ b/docs/API.md @@ -21,6 +21,7 @@ The GraphQL schema is not a copy of the database tables. It describes what the c | `Routine.elementIds` (list of ids) | `Routine.items` (the elements themselves, in order) | | `VariantGroup` | `Element.variants` and `Vault.variants` | | `Coach.googleId` | not exposed | +| `gymnast_elements` (rows) | `Gymnast.items` (the elements and vaults themselves) | | D-score, difficulty letter | calculated fields: `Routine.dScore`, `Element.difficultyLetter` | Resolvers on the server translate between the two. Prisma is used inside the resolvers to read from PostgreSQL. The client never talks to Prisma or the database. @@ -30,7 +31,7 @@ Resolvers on the server translate between the two. Prisma is used inside the res ### Catalog (open to everyone) - `Apparatus` and `ElementGroup` describe the structure of the Code of Points. -- `CatalogItem` is an interface with the fields that elements and vaults share (`id`, `code`, `description`, `name`, `imageUrl`, `elementGroup`). +- `CatalogItem` is an interface with the fields that elements and vaults share (`id`, `code`, `description`, `name`, `imageUrl`, `elementGroup`, `removed`). - `Element` and `Vault` implement it. An element has `value` and `difficultyLetter`. A vault has `dValue`. Both have `variants`. - `searchCatalog` searches, filters, sorts and pages over elements and vaults together. @@ -51,11 +52,27 @@ Fields that only exist on one type are fetched with a fragment: } ``` +#### Removed items + +When a new Code of Points drops an element or vault, it is marked as removed in the database and not deleted, so routines and gymnast lists that use it keep working (see "Removed elements" in [DATA_MODEL.md](DATA_MODEL.md)). The API shows this with `removed: Boolean!` on every `CatalogItem`, so the UI can warn about it in a routine or a list. + +- **Search:** removed items are not found by `searchCatalog`. +- **By id:** `element(id)`, `vault(id)` and `catalogItem(id)` still return them, because a routine needs to show them. They have `removed: true`. +- **Variants:** `variants` only lists variants that are still in the Code of Points. +- **Adding:** a removed item cannot be added to a routine or to a gymnast's list. The mutation throws `INVALID_ITEMS`. An item that is already there can stay: `setRoutineItems` accepts a removed item that is already in the routine, and `removeGymnastItem` removes it. +- **The D-score:** a removed item still counts in `Routine.dScore`, so the score does not change without the coach noticing. The UI shows the warning. + ### User data (coaches only) -- `Coach`, `Gymnast` and `Routine`, with `Routine.items` in the order of the routine and a calculated `Routine.dScore`. +- `Coach`, `Gymnast` and `Routine`, with `Routine.items` in the order of the routine and a calculated `Routine.dScore`. A routine also has `createdAt` and `updatedAt`. - `login(googleToken)` verifies the Google token and returns a session token and the coach. - Mutations create, rename and delete gymnasts and routines. `setRoutineItems` replaces the whole list of items in a routine, so the client sends the ids in the wanted order and the server does not need separate add, remove and move operations. +- **A gymnast's list.** `Gymnast.items` is the list of elements and vaults the gymnast can do or is practicing, in the order they were added. It is the same list as the table `gymnast_elements` in the database. `items(apparatusId)` gives only the items on one apparatus, which is what the UI needs when it builds a routine. + - `addGymnastItem(gymnastId, itemId)` adds an element or vault, and `removeGymnastItem(gymnastId, itemId)` removes one. Both return the whole `Gymnast`, so the client can update its cache with the new list. + - Both are idempotent: adding an item that is already in the list, or removing one that is not, changes nothing and gives no error. The UI therefore does not need to check first, and a double click is harmless. + - The list is independent of the routines. A gymnast can practice an item that is in no routine, and a routine can contain an item that is not in the list. + - Errors: `UNAUTHENTICATED`, `NOT_FOUND` (the gymnast or the item does not exist, or the gymnast belongs to another coach), `INVALID_ITEMS` (a removed item cannot be added) and `LIMIT_REACHED` (200 items per gymnast). +- **`updatedAt` on a routine** changes when the routine is renamed and when its items change. The items are stored in a separate table, so `setRoutineItems` must update `updatedAt` explicitly, in the same transaction as the items. - `calculateDScore(itemIds)` is for the calculator, which works without logging in. It calculates the D-score and saves nothing. ## Search, filter, sort and pagination @@ -123,7 +140,7 @@ The requirement is that the solution works with any amount of data. The catalog ### User data -The lists of user data are not paged. They are small by their nature: a coach has some tens of gymnasts, and a gymnast has a few routines on each apparatus. The backend enforces a limit (200 gymnasts per coach, 50 routines per gymnast) so they stay small, and the limits are written in the schema. We considered cursor connections for them as well and decided against it for now. The reasons, and when to change, are in [USER_LIST_PAGINATION.md](USER_LIST_PAGINATION.md). +The lists of user data are not paged. They are small by their nature: a coach has some tens of gymnasts, and a gymnast has a few routines on each apparatus. The backend enforces a limit (200 gymnasts per coach, 50 routines per gymnast, 200 items in a gymnast's list) so they stay small, and the limits are written in the schema. We considered cursor connections for them as well and decided against it for now. The reasons, and when to change, are in [USER_LIST_PAGINATION.md](USER_LIST_PAGINATION.md). ### Caching @@ -156,14 +173,14 @@ The catalog, `searchCatalog` and `calculateDScore` do not need a login. Everythi Errors are thrown from the resolvers as GraphQL errors with a machine-readable code in `extensions.code`. The client reacts to the code and never to the text of the message. -| Code | When | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `UNAUTHENTICATED` | The operation needs a logged-in coach. | -| `NOT_FOUND` | The id does not exist, or belongs to another coach. | -| `NAME_TAKEN` | The gymnast already has a routine with this name on this apparatus (constraint 6). | -| `INVALID_ITEMS` | The items do not fit the routine: a vault outside vault, an element on vault, more than one vault, or an item from another apparatus (constraints 7 and 8). | -| `BAD_USER_INPUT` | Empty name, malformed id, or `first` out of range. | -| `LIMIT_REACHED` | The coach has reached a limit: 200 gymnasts, 50 routines per gymnast, or 50 items in a routine. See [USER_LIST_PAGINATION.md](USER_LIST_PAGINATION.md). | +| Code | When | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `UNAUTHENTICATED` | The operation needs a logged-in coach. | +| `NOT_FOUND` | The id does not exist, or belongs to another coach. | +| `NAME_TAKEN` | The gymnast already has a routine with this name on this apparatus (constraint 6). | +| `INVALID_ITEMS` | The items do not fit the routine: a vault outside vault, an element on vault, more than one vault, or an item from another apparatus (constraints 7 and 8). Also an item that has been removed from the Code of Points, when it is not already in the routine or the gymnast's list. | +| `BAD_USER_INPUT` | Empty name, malformed id, or `first` out of range. | +| `LIMIT_REACHED` | The coach has reached a limit: 200 gymnasts, 50 routines per gymnast, 50 items in a routine, or 200 items in a gymnast's list. See [USER_LIST_PAGINATION.md](USER_LIST_PAGINATION.md). | An error response looks like this: @@ -285,8 +302,11 @@ Vitest runs the API tests against a test database, with queries sent to the serv - **Errors:** each code in the table is returned for the right case. A coach that asks for another coach's gymnast gets `NOT_FOUND`, and a request without a login gets `UNAUTHENTICATED` (but `me` gives `null`). - **Injection:** a search term, an `apparatusId` and a cursor that contain SQL (for example a quote followed by DROP TABLE) are handled as plain values and give no results or `BAD_USER_INPUT`, and the tables are still there afterwards. A cursor made for another sort field is rejected. The lint rule against raw queries passes. - **Constraints 6–8:** a routine name that exists, a vault in a beam routine, more than one vault, and an item from the wrong apparatus. -- **Limits:** `first` of 0 and 101, more than 50 ids, the 201st gymnast, and a query that is too deep. +- **Limits:** `first` of 0 and 101, more than 50 ids, the 201st gymnast, the 201st item in a gymnast's list, and a query that is too deep. - **The D-score:** the eight highest values, each code counted once, and the single vault on vault. +- **The gymnast's list:** adding an item twice and removing an item that is not there change nothing and give no error. `removeGymnastItem` also removes a removed item. `addGymnastItem` of a removed item gives `INVALID_ITEMS`. Another coach's gymnast gives `NOT_FOUND`. +- **Removed items:** they are not found by `searchCatalog`, but `catalogItem(id)` returns them with `removed: true`. `setRoutineItems` accepts a removed item that is already in the routine and rejects one that is not. `dScore` counts them. +- **`updatedAt`:** it changes when a routine is renamed and when `setRoutineItems` changes the items. The end-to-end tests (a long sequence of actions in the browser) are described separately in the README when they exist. diff --git a/docs/USER_LIST_PAGINATION.md b/docs/USER_LIST_PAGINATION.md index 233a7d7..e19cad5 100644 --- a/docs/USER_LIST_PAGINATION.md +++ b/docs/USER_LIST_PAGINATION.md @@ -4,13 +4,16 @@ This document records a decision about the GraphQL schema in [`backend/src/schem ## The question -The catalog is paged with cursors (`searchCatalog`), because it can grow without limit. Three lists of user data are plain lists: +The catalog is paged with cursors (`searchCatalog`), because it can grow without limit. Four lists of user data are plain lists: | Field | Type | Limit set by the backend | | ------------------ | ----------------- | ------------------------ | | `Coach.gymnasts` | `[Gymnast!]!` | 200 per coach | | `Gymnast.routines` | `[Routine!]!` | 50 per gymnast | | `Routine.items` | `[CatalogItem!]!` | 50 per routine | +| `Gymnast.items` | `[CatalogItem!]!` | 200 per gymnast | + +`Gymnast.items` is the list of elements and vaults a gymnast can do or is practicing. It is also bounded by the domain: the whole catalog has fewer than 900 items, a gymnast does not practice most of them, and an item can be in the list only once. The limit of 200 is well above what a gymnast realistically has. The requirement is that the solution works with any amount of data, so we need to know whether these limits are a weakness. @@ -55,7 +58,7 @@ It would use the same `edges`, `node`, `cursor` and `pageInfo` as the catalog, w ## What the backend must do -- Enforce the limits when a gymnast or routine is created, and when items are set on a routine. The error code is `LIMIT_REACHED`, with a message the interface can show. +- Enforce the limits when a gymnast or routine is created, when items are set on a routine, and when an item is added to a gymnast's list. The error code is `LIMIT_REACHED`, with a message the interface can show. - Put an index on `Gymnast.coachId` and `Routine.gymnastId`, and on `Routine.apparatusId` together with `gymnastId`, so the lists are fast. - Load the lists with DataLoader (see [API.md](API.md)) so a page that shows several gymnasts does not send one query for each. -- Test the limits: the 200th gymnast works, the 201st gives `LIMIT_REACHED`, and a coach cannot reach another coach's lists. +- Test the limits: the 200th gymnast works, the 201st gives `LIMIT_REACHED`, the 200th item in a gymnast's list works and the 201st gives `LIMIT_REACHED`, and a coach cannot reach another coach's lists. From 3b61315485e285f6e9348f0c154ec68d7f8aed63 Mon Sep 17 00:00:00 2001 From: Ronja Date: Thu, 15 Oct 2026 15:10:00 +0200 Subject: [PATCH 2/6] feat: error on duplicate and missing list items, limit of 100, removed items do not count addGymnastItem throws ALREADY_EXISTS and removeGymnastItem throws NOT_FOUND when nothing would change. A gymnast's list is limited to 100 items. Removed items do not count in the D-score, and calculateDScore rejects them. API.md describes the warning in the UI, the seed report and the other error codes. --- backend/src/schema.graphql | 39 ++++++++++++++++++++++++------------ docs/AI_USAGE.md | 22 +++++++++++--------- docs/API.md | 39 +++++++++++++++++++++--------------- docs/USER_LIST_PAGINATION.md | 6 +++--- 4 files changed, 65 insertions(+), 41 deletions(-) diff --git a/backend/src/schema.graphql b/backend/src/schema.graphql index 6c441fa..e5fccc0 100644 --- a/backend/src/schema.graphql +++ b/backend/src/schema.graphql @@ -5,10 +5,15 @@ # Errors are thrown as GraphQL errors with a machine-readable # `extensions.code`. The client switches on the code, never on the message. # -# UNAUTHENTICATED The operation needs a logged-in coach. +# UNAUTHENTICATED The operation needs a logged-in coach, or the session token +# or the Google token is missing, invalid or expired. # NOT_FOUND The id does not exist, or belongs to another coach. # Both cases give the same answer, so that nobody can find # out which ids exist (constraint 9 in docs/DATA_MODEL.md). +# Also removeGymnastItem for an item that is not in the +# gymnast's list. +# ALREADY_EXISTS addGymnastItem for an item that is already in the +# gymnast's list. # NAME_TAKEN The coach already has a routine with this name for this # gymnast and apparatus (constraint 6). # INVALID_ITEMS The items do not fit the routine: a vault outside vault, @@ -18,9 +23,14 @@ # is not already in the routine or the gymnast's list. # BAD_USER_INPUT Empty name, malformed id, `first` out of range. # LIMIT_REACHED The coach has reached a limit: 200 gymnasts, 50 routines -# per gymnast, 50 items in a routine, or 200 items in a +# per gymnast, 50 items in a routine, or 100 items in a # gymnast's list. # +# Apollo Server also adds codes of its own. The ones that matter here: +# GRAPHQL_VALIDATION_FAILED The query is invalid, or deeper than the limit. +# INTERNAL_SERVER_ERROR Unexpected error. The client shows a general +# message and no details. +# # Why this way: # - `me` is the only operation where "not logged in" is a normal answer. The # app asks on every page load, so it returns null instead of an error. @@ -59,7 +69,8 @@ interface CatalogItem { """ True when the item has been removed from the Code of Points. It is not found by the catalog search, but it can still be in routines and gymnast lists, so - the UI should warn about it there. It cannot be added to a routine or a list. + the UI should warn about it there. It does not count in the D-score, and it + cannot be added to a routine or a list. """ removed: Boolean! } @@ -170,8 +181,9 @@ type Query { routine(id: ID!): Routine """ The D-score of a list of items, for the calculator. Open to everyone and - nothing is saved. Throws INVALID_ITEMS when the items do not fit together. - At most 50 ids, otherwise BAD_USER_INPUT. + nothing is saved. Throws INVALID_ITEMS when the items do not fit together, or + when an item has been removed (see `removed`). At most 50 ids, otherwise + BAD_USER_INPUT. """ calculateDScore(itemIds: [ID!]!): Float! @@ -214,7 +226,7 @@ type Gymnast { """ The elements and vaults the gymnast can do or is practicing, in the order they were added. Optionally only the items on one apparatus. Can contain - removed items (see `removed`). Limited to 200 per gymnast by the backend. + removed items (see `removed`). Limited to 100 per gymnast by the backend. """ items(apparatusId: ID): [CatalogItem!]! } @@ -228,7 +240,7 @@ type Routine { items: [CatalogItem!]! """ Sum of the eight highest values, each code counted once. Calculated. Removed - items count as before, so the score does not change silently. + items (see `removed`) do not count. """ dScore: Float! createdAt: DateTime! @@ -259,15 +271,15 @@ type Mutation { deleteGymnast(id: ID!): ID! """ - Adds an element or vault to the gymnast's list. Adding an item that is already - in the list changes nothing. Throws NOT_FOUND, INVALID_ITEMS (a removed item) - or LIMIT_REACHED. + Adds an element or vault to the gymnast's list. Throws NOT_FOUND, + ALREADY_EXISTS (the item is already in the list), INVALID_ITEMS (a removed + item) or LIMIT_REACHED. """ addGymnastItem(gymnastId: ID!, itemId: ID!): Gymnast! """ Removes an item from the gymnast's list, also when it has been removed from - the Code of Points. Removing an item that is not in the list changes nothing. - Throws NOT_FOUND. + the Code of Points. Throws NOT_FOUND when the gymnast does not exist or the + item is not in the list. """ removeGymnastItem(gymnastId: ID!, itemId: ID!): Gymnast! @@ -279,7 +291,8 @@ type Mutation { """ Replaces the whole list. The client sends the ids in the wanted order. More than 50 ids throws LIMIT_REACHED. Throws INVALID_ITEMS. A removed item - can stay in the list, but it cannot be added to it. + can stay in the list, but it cannot be added to it. The last save wins: the + mutation does not check if the routine was changed since the client loaded it. """ setRoutineItems(routineId: ID!, itemIds: [ID!]!): Routine! } diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 3113f47..4b45180 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -78,15 +78,19 @@ ## Missing fields in the GraphQL schema (`backend/src/schema.graphql`, `docs/API.md`) -- **Tool:** Claude Code +- **Tool:** Claude Code, and Claude for the research. - **What the team did:** Wrote issue #37, which lists the fields that the database has and the schema does not expose: the gymnast's list of elements and vaults with mutations, `createdAt` and `updatedAt` on a routine, and a - flag for removed items. The team reviews the proposed rules before they are - used. -- **What Claude did:** Added the fields and mutations to the schema, and - described them in `docs/API.md`, including the error codes and limits. - Claude proposed the rules that the issue does not decide: adding and - removing an item is idempotent, a removed item cannot be added but can stay - where it is, and a removed item still counts in the D-score. Claude also - checked that the schema parses with the `graphql` library. + flag for removed items. The team made the decisions: adding an item that is + already in the list, or removing one that is not, gives an error and is + never silent. A gymnast's list is limited to 100 items. A removed item does + not count in the D-score. The team also defined what "removed" means (an + item that is no longer in the Code of Points edition the app uses). +- **What Claude did:** Researched how others handle this (idempotency in RFC + 9110, errors as data in Apollo's guide, the gRPC error names, and soft + delete and warnings in the UI) and proposed the error names `ALREADY_EXISTS` + and `NOT_FOUND`. Added the fields, mutations and error codes to the schema, + and described them in `docs/API.md`, including the limits, the warning in + the UI and the tests. Checked that the schema parses with the `graphql` + library. diff --git a/docs/API.md b/docs/API.md index 99e8a07..3204b20 100644 --- a/docs/API.md +++ b/docs/API.md @@ -60,7 +60,10 @@ When a new Code of Points drops an element or vault, it is marked as removed in - **By id:** `element(id)`, `vault(id)` and `catalogItem(id)` still return them, because a routine needs to show them. They have `removed: true`. - **Variants:** `variants` only lists variants that are still in the Code of Points. - **Adding:** a removed item cannot be added to a routine or to a gymnast's list. The mutation throws `INVALID_ITEMS`. An item that is already there can stay: `setRoutineItems` accepts a removed item that is already in the routine, and `removeGymnastItem` removes it. -- **The D-score:** a removed item still counts in `Routine.dScore`, so the score does not change without the coach noticing. The UI shows the warning. +- **The D-score:** a removed item does not count in `Routine.dScore`. Only items that are still in the Code of Points are summed, with the same rule as before (the eight highest values, each code once), so if a removed item was one of the eight highest, the next one takes its place. The score of a saved routine can therefore go down when an item is removed, and the UI must tell the coach. `calculateDScore` throws `INVALID_ITEMS` for a removed item: the calculator has nothing saved to protect, and removed items cannot be found in the search. +- **What the UI shows:** a persistent inline warning on the routine or the list, and not a toast that disappears. The coach has to act on it, and several design systems advise against toasts for this kind of message. The warning says how many items are no longer in the Code of Points and do not count, marks each of them, and has a button to remove it. The UI counts `removed` in `Routine.items` or `Gymnast.items`, so no extra field is needed. +- **The seed script:** it marks an item as removed and never deletes it. It never changes routines or lists. It prints how many routines and lists use each removed item, so the team can see what is affected. +- **Added and changed items:** the FIG also publishes new elements and changed values during a cycle, which is more likely than a removal. The seed script adds new items. A changed value changes the D-score of saved routines, because the score is calculated and never stored. ### User data (coaches only) @@ -69,10 +72,11 @@ When a new Code of Points drops an element or vault, it is marked as removed in - Mutations create, rename and delete gymnasts and routines. `setRoutineItems` replaces the whole list of items in a routine, so the client sends the ids in the wanted order and the server does not need separate add, remove and move operations. - **A gymnast's list.** `Gymnast.items` is the list of elements and vaults the gymnast can do or is practicing, in the order they were added. It is the same list as the table `gymnast_elements` in the database. `items(apparatusId)` gives only the items on one apparatus, which is what the UI needs when it builds a routine. - `addGymnastItem(gymnastId, itemId)` adds an element or vault, and `removeGymnastItem(gymnastId, itemId)` removes one. Both return the whole `Gymnast`, so the client can update its cache with the new list. - - Both are idempotent: adding an item that is already in the list, or removing one that is not, changes nothing and gives no error. The UI therefore does not need to check first, and a double click is harmless. + - Neither is silent. Adding an item that is already in the list throws `ALREADY_EXISTS`, and removing one that is not in the list throws `NOT_FOUND`. The state is the same as before the call, so repeating a call does no harm, but the client is told that nothing changed. The UI disables the button while the call runs, and treats `ALREADY_EXISTS` on add and `NOT_FOUND` on remove as a sign that its list is out of date: it reloads the list and does not show an error. - The list is independent of the routines. A gymnast can practice an item that is in no routine, and a routine can contain an item that is not in the list. - - Errors: `UNAUTHENTICATED`, `NOT_FOUND` (the gymnast or the item does not exist, or the gymnast belongs to another coach), `INVALID_ITEMS` (a removed item cannot be added) and `LIMIT_REACHED` (200 items per gymnast). + - Errors: `UNAUTHENTICATED`, `NOT_FOUND` (the gymnast or the item does not exist, the gymnast belongs to another coach, or, when removing, the item is not in the list), `ALREADY_EXISTS` (when adding: the item is already in the list), `INVALID_ITEMS` (a removed item cannot be added) and `LIMIT_REACHED` (100 items per gymnast). - **`updatedAt` on a routine** changes when the routine is renamed and when its items change. The items are stored in a separate table, so `setRoutineItems` must update `updatedAt` explicitly, in the same transaction as the items. +- **Two tabs on the same routine.** `setRoutineItems` replaces the whole list, and the last save wins. It does not check if the routine was changed since the client loaded it, so a change made in another tab can be overwritten. This is a choice for the prototype. If it becomes a problem, add a `CONFLICT` error and let the client send the `updatedAt` it loaded. - `calculateDScore(itemIds)` is for the calculator, which works without logging in. It calculates the D-score and saves nothing. ## Search, filter, sort and pagination @@ -140,7 +144,7 @@ The requirement is that the solution works with any amount of data. The catalog ### User data -The lists of user data are not paged. They are small by their nature: a coach has some tens of gymnasts, and a gymnast has a few routines on each apparatus. The backend enforces a limit (200 gymnasts per coach, 50 routines per gymnast, 200 items in a gymnast's list) so they stay small, and the limits are written in the schema. We considered cursor connections for them as well and decided against it for now. The reasons, and when to change, are in [USER_LIST_PAGINATION.md](USER_LIST_PAGINATION.md). +The lists of user data are not paged. They are small by their nature: a coach has some tens of gymnasts, and a gymnast has a few routines on each apparatus. The backend enforces a limit (200 gymnasts per coach, 50 routines per gymnast, 100 items in a gymnast's list) so they stay small, and the limits are written in the schema. We considered cursor connections for them as well and decided against it for now. The reasons, and when to change, are in [USER_LIST_PAGINATION.md](USER_LIST_PAGINATION.md). ### Caching @@ -173,14 +177,17 @@ The catalog, `searchCatalog` and `calculateDScore` do not need a login. Everythi Errors are thrown from the resolvers as GraphQL errors with a machine-readable code in `extensions.code`. The client reacts to the code and never to the text of the message. -| Code | When | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `UNAUTHENTICATED` | The operation needs a logged-in coach. | -| `NOT_FOUND` | The id does not exist, or belongs to another coach. | -| `NAME_TAKEN` | The gymnast already has a routine with this name on this apparatus (constraint 6). | -| `INVALID_ITEMS` | The items do not fit the routine: a vault outside vault, an element on vault, more than one vault, or an item from another apparatus (constraints 7 and 8). Also an item that has been removed from the Code of Points, when it is not already in the routine or the gymnast's list. | -| `BAD_USER_INPUT` | Empty name, malformed id, or `first` out of range. | -| `LIMIT_REACHED` | The coach has reached a limit: 200 gymnasts, 50 routines per gymnast, 50 items in a routine, or 200 items in a gymnast's list. See [USER_LIST_PAGINATION.md](USER_LIST_PAGINATION.md). | +| Code | When | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `UNAUTHENTICATED` | The operation needs a logged-in coach, or the session token or the Google token is missing, invalid or expired. The client sends the coach to the login page. | +| `NOT_FOUND` | The id does not exist, or belongs to another coach. Also `removeGymnastItem` for an item that is not in the gymnast's list. | +| `ALREADY_EXISTS` | `addGymnastItem` for an item that is already in the gymnast's list. | +| `NAME_TAKEN` | The gymnast already has a routine with this name on this apparatus (constraint 6). | +| `INVALID_ITEMS` | The items do not fit the routine: a vault outside vault, an element on vault, more than one vault, or an item from another apparatus (constraints 7 and 8). Also an item that has been removed from the Code of Points, when it is not already in the routine or the gymnast's list. | +| `BAD_USER_INPUT` | Empty name, malformed id, or `first` out of range. | +| `LIMIT_REACHED` | The coach has reached a limit: 200 gymnasts, 50 routines per gymnast, 50 items in a routine, or 100 items in a gymnast's list. See [USER_LIST_PAGINATION.md](USER_LIST_PAGINATION.md). | +| `GRAPHQL_VALIDATION_FAILED` | Added by Apollo Server: the query is invalid, or deeper than the depth limit. | +| `INTERNAL_SERVER_ERROR` | Added by Apollo Server: an unexpected error. The client shows a general message and no details. | An error response looks like this: @@ -302,10 +309,10 @@ Vitest runs the API tests against a test database, with queries sent to the serv - **Errors:** each code in the table is returned for the right case. A coach that asks for another coach's gymnast gets `NOT_FOUND`, and a request without a login gets `UNAUTHENTICATED` (but `me` gives `null`). - **Injection:** a search term, an `apparatusId` and a cursor that contain SQL (for example a quote followed by DROP TABLE) are handled as plain values and give no results or `BAD_USER_INPUT`, and the tables are still there afterwards. A cursor made for another sort field is rejected. The lint rule against raw queries passes. - **Constraints 6–8:** a routine name that exists, a vault in a beam routine, more than one vault, and an item from the wrong apparatus. -- **Limits:** `first` of 0 and 101, more than 50 ids, the 201st gymnast, the 201st item in a gymnast's list, and a query that is too deep. -- **The D-score:** the eight highest values, each code counted once, and the single vault on vault. -- **The gymnast's list:** adding an item twice and removing an item that is not there change nothing and give no error. `removeGymnastItem` also removes a removed item. `addGymnastItem` of a removed item gives `INVALID_ITEMS`. Another coach's gymnast gives `NOT_FOUND`. -- **Removed items:** they are not found by `searchCatalog`, but `catalogItem(id)` returns them with `removed: true`. `setRoutineItems` accepts a removed item that is already in the routine and rejects one that is not. `dScore` counts them. +- **Limits:** `first` of 0 and 101, more than 50 ids, the 201st gymnast, the 101st item in a gymnast's list, and a query that is too deep. +- **The D-score:** the eight highest values, each code counted once, and the single vault on vault. A removed item does not count, and the next highest value takes its place. +- **The gymnast's list:** adding an item twice gives `ALREADY_EXISTS` and removing an item that is not there gives `NOT_FOUND`, and the list is unchanged in both cases. `removeGymnastItem` also removes a removed item. `addGymnastItem` of a removed item gives `INVALID_ITEMS`. Another coach's gymnast gives `NOT_FOUND`. +- **Removed items:** they are not found by `searchCatalog`, but `catalogItem(id)` returns them with `removed: true`. `setRoutineItems` accepts a removed item that is already in the routine and rejects one that is not. `calculateDScore` rejects them. - **`updatedAt`:** it changes when a routine is renamed and when `setRoutineItems` changes the items. The end-to-end tests (a long sequence of actions in the browser) are described separately in the README when they exist. diff --git a/docs/USER_LIST_PAGINATION.md b/docs/USER_LIST_PAGINATION.md index e19cad5..62f868e 100644 --- a/docs/USER_LIST_PAGINATION.md +++ b/docs/USER_LIST_PAGINATION.md @@ -11,9 +11,9 @@ The catalog is paged with cursors (`searchCatalog`), because it can grow without | `Coach.gymnasts` | `[Gymnast!]!` | 200 per coach | | `Gymnast.routines` | `[Routine!]!` | 50 per gymnast | | `Routine.items` | `[CatalogItem!]!` | 50 per routine | -| `Gymnast.items` | `[CatalogItem!]!` | 200 per gymnast | +| `Gymnast.items` | `[CatalogItem!]!` | 100 per gymnast | -`Gymnast.items` is the list of elements and vaults a gymnast can do or is practicing. It is also bounded by the domain: the whole catalog has fewer than 900 items, a gymnast does not practice most of them, and an item can be in the list only once. The limit of 200 is well above what a gymnast realistically has. +`Gymnast.items` is the list of elements and vaults a gymnast can do or is practicing. It is also bounded by the domain: the whole catalog has fewer than 900 items, a gymnast does not practice most of them, and an item can be in the list only once. The limit of 100 is well above what a gymnast realistically has. The requirement is that the solution works with any amount of data, so we need to know whether these limits are a weakness. @@ -61,4 +61,4 @@ It would use the same `edges`, `node`, `cursor` and `pageInfo` as the catalog, w - Enforce the limits when a gymnast or routine is created, when items are set on a routine, and when an item is added to a gymnast's list. The error code is `LIMIT_REACHED`, with a message the interface can show. - Put an index on `Gymnast.coachId` and `Routine.gymnastId`, and on `Routine.apparatusId` together with `gymnastId`, so the lists are fast. - Load the lists with DataLoader (see [API.md](API.md)) so a page that shows several gymnasts does not send one query for each. -- Test the limits: the 200th gymnast works, the 201st gives `LIMIT_REACHED`, the 200th item in a gymnast's list works and the 201st gives `LIMIT_REACHED`, and a coach cannot reach another coach's lists. +- Test the limits: the 200th gymnast works, the 201st gives `LIMIT_REACHED`, the 100th item in a gymnast's list works and the 101st gives `LIMIT_REACHED`, and a coach cannot reach another coach's lists. From bd01f3bc8a166eb3fe8964e40a027627b1cd56f9 Mon Sep 17 00:00:00 2001 From: Ronja Date: Thu, 15 Oct 2026 15:16:09 +0200 Subject: [PATCH 3/6] docs: limit gymnast and routine names to 70 characters Names are trimmed and checked in the backend, BAD_USER_INPUT otherwise. --- backend/src/schema.graphql | 19 +++++++++++++++---- docs/API.md | 5 +++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/backend/src/schema.graphql b/backend/src/schema.graphql index e5fccc0..5e14d7c 100644 --- a/backend/src/schema.graphql +++ b/backend/src/schema.graphql @@ -21,7 +21,8 @@ # another apparatus (constraints 7 and 8). Also an item that # has been removed from the Code of Points (`removed`) and # is not already in the routine or the gymnast's list. -# BAD_USER_INPUT Empty name, malformed id, `first` out of range. +# BAD_USER_INPUT Empty name, name longer than 70 characters, malformed id, +# `first` out of range. # LIMIT_REACHED The coach has reached a limit: 200 gymnasts, 50 routines # per gymnast, 50 items in a routine, or 100 items in a # gymnast's list. @@ -264,8 +265,12 @@ type Mutation { """Verifies the Google token and creates the coach on the first login.""" login(googleToken: String!): AuthPayload! - """Throws LIMIT_REACHED.""" + """ + The name is trimmed and must be 1-70 characters (BAD_USER_INPUT otherwise). + Throws LIMIT_REACHED. + """ createGymnast(name: String!): Gymnast! + """The name is trimmed and must be 1-70 characters (BAD_USER_INPUT otherwise).""" renameGymnast(id: ID!, name: String!): Gymnast! """Also deletes the gymnast's routines. Returns the id of the deleted gymnast.""" deleteGymnast(id: ID!): ID! @@ -283,9 +288,15 @@ type Mutation { """ removeGymnastItem(gymnastId: ID!, itemId: ID!): Gymnast! - """Throws NAME_TAKEN or LIMIT_REACHED.""" + """ + The name is trimmed and must be 1-70 characters (BAD_USER_INPUT otherwise). + Throws NAME_TAKEN or LIMIT_REACHED. + """ createRoutine(input: CreateRoutineInput!): Routine! - """Throws NAME_TAKEN.""" + """ + The name is trimmed and must be 1-70 characters (BAD_USER_INPUT otherwise). + Throws NAME_TAKEN. + """ renameRoutine(id: ID!, name: String!): Routine! deleteRoutine(id: ID!): ID! """ diff --git a/docs/API.md b/docs/API.md index 3204b20..595598f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -76,6 +76,7 @@ When a new Code of Points drops an element or vault, it is marked as removed in - The list is independent of the routines. A gymnast can practice an item that is in no routine, and a routine can contain an item that is not in the list. - Errors: `UNAUTHENTICATED`, `NOT_FOUND` (the gymnast or the item does not exist, the gymnast belongs to another coach, or, when removing, the item is not in the list), `ALREADY_EXISTS` (when adding: the item is already in the list), `INVALID_ITEMS` (a removed item cannot be added) and `LIMIT_REACHED` (100 items per gymnast). - **`updatedAt` on a routine** changes when the routine is renamed and when its items change. The items are stored in a separate table, so `setRoutineItems` must update `updatedAt` explicitly, in the same transaction as the items. +- **Names.** The name of a gymnast and the name of a routine are trimmed (spaces at the start and the end are removed) and must be 1–70 characters. A longer name, or a name with only spaces, throws `BAD_USER_INPUT`. The length is counted in characters and not in bytes, so a name with accents or emoji gets the same room as any other. 70 characters is the length that the UK Government Data Standards give for a full name in one field. Both limits are checked in the backend, and the database does not check them yet. `NAME_TAKEN` is checked on the trimmed name. - **Two tabs on the same routine.** `setRoutineItems` replaces the whole list, and the last save wins. It does not check if the routine was changed since the client loaded it, so a change made in another tab can be overwritten. This is a choice for the prototype. If it becomes a problem, add a `CONFLICT` error and let the client send the `updatedAt` it loaded. - `calculateDScore(itemIds)` is for the calculator, which works without logging in. It calculates the D-score and saves nothing. @@ -184,7 +185,7 @@ Errors are thrown from the resolvers as GraphQL errors with a machine-readable c | `ALREADY_EXISTS` | `addGymnastItem` for an item that is already in the gymnast's list. | | `NAME_TAKEN` | The gymnast already has a routine with this name on this apparatus (constraint 6). | | `INVALID_ITEMS` | The items do not fit the routine: a vault outside vault, an element on vault, more than one vault, or an item from another apparatus (constraints 7 and 8). Also an item that has been removed from the Code of Points, when it is not already in the routine or the gymnast's list. | -| `BAD_USER_INPUT` | Empty name, malformed id, or `first` out of range. | +| `BAD_USER_INPUT` | Empty name, name longer than 70 characters, malformed id, or `first` out of range. | | `LIMIT_REACHED` | The coach has reached a limit: 200 gymnasts, 50 routines per gymnast, 50 items in a routine, or 100 items in a gymnast's list. See [USER_LIST_PAGINATION.md](USER_LIST_PAGINATION.md). | | `GRAPHQL_VALIDATION_FAILED` | Added by Apollo Server: the query is invalid, or deeper than the depth limit. | | `INTERNAL_SERVER_ERROR` | Added by Apollo Server: an unexpected error. The client shows a general message and no details. | @@ -309,7 +310,7 @@ Vitest runs the API tests against a test database, with queries sent to the serv - **Errors:** each code in the table is returned for the right case. A coach that asks for another coach's gymnast gets `NOT_FOUND`, and a request without a login gets `UNAUTHENTICATED` (but `me` gives `null`). - **Injection:** a search term, an `apparatusId` and a cursor that contain SQL (for example a quote followed by DROP TABLE) are handled as plain values and give no results or `BAD_USER_INPUT`, and the tables are still there afterwards. A cursor made for another sort field is rejected. The lint rule against raw queries passes. - **Constraints 6–8:** a routine name that exists, a vault in a beam routine, more than one vault, and an item from the wrong apparatus. -- **Limits:** `first` of 0 and 101, more than 50 ids, the 201st gymnast, the 101st item in a gymnast's list, and a query that is too deep. +- **Limits:** `first` of 0 and 101, more than 50 ids, the 201st gymnast, the 101st item in a gymnast's list, a name of 71 characters (gymnast and routine), a name with only spaces, and a query that is too deep. - **The D-score:** the eight highest values, each code counted once, and the single vault on vault. A removed item does not count, and the next highest value takes its place. - **The gymnast's list:** adding an item twice gives `ALREADY_EXISTS` and removing an item that is not there gives `NOT_FOUND`, and the list is unchanged in both cases. `removeGymnastItem` also removes a removed item. `addGymnastItem` of a removed item gives `INVALID_ITEMS`. Another coach's gymnast gives `NOT_FOUND`. - **Removed items:** they are not found by `searchCatalog`, but `catalogItem(id)` returns them with `removed: true`. `setRoutineItems` accepts a removed item that is already in the routine and rejects one that is not. `calculateDScore` rejects them. From 5091f20f5602bc9b05ddfee2164beeedbd2fc1fb Mon Sep 17 00:00:00 2001 From: Ronja Date: Sat, 17 Oct 2026 09:00:58 +0200 Subject: [PATCH 4/6] fix: repair schema fields swallowed by doc comments in the dev merge Vault.dValue, CatalogFilter.difficultyValues, createGymnast, renameGymnast and createRoutine had ended up inside doc comments and were missing from the schema. Also restores the searchCatalog search description from dev (80 characters, 8 words, text without searchable words finds nothing). --- backend/src/schema.graphql | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/backend/src/schema.graphql b/backend/src/schema.graphql index 216461f..4aa32dc 100644 --- a/backend/src/schema.graphql +++ b/backend/src/schema.graphql @@ -124,7 +124,9 @@ type Vault implements CatalogItem { removed: Boolean! """ Fixed difficulty score, e.g. 2.6. - """dValue: Float!""" + """ + dValue: Float! + """ Other ways of performing the same vault. Removed variants are left out. """ variants: [Vault!]! @@ -162,7 +164,9 @@ input CatalogFilter { otherwise BAD_USER_INPUT. Vaults have D-values and not difficulty values, so they are left out when this is set, and together with apparatusId "VT" it throws BAD_USER_INPUT. Empty or missing means no difficulty filter. - """difficultyValues: [Float!]""" + """ + difficultyValues: [Float!] + """ Only items named after a gymnast. """ namedOnly: Boolean @@ -219,7 +223,10 @@ type Query { One or more words, case-insensitive. All the words must match: an item is found if, for every search word, it has a word that starts with it, in the code, name or description. A trailing "s" is removed from each search - word. Empty or missing means no text filter. + word. At most 80 characters and 8 words, otherwise BAD_USER_INPUT. Text + that has no searchable words (such as "ø" or "%") finds nothing. Empty or + only spaces means no text filter. A client should limit its input field to + 80 characters. """ search: String filter: CatalogFilter @@ -300,9 +307,13 @@ type Mutation { """ The name is trimmed and must be 1-70 characters (BAD_USER_INPUT otherwise). Throws LIMIT_REACHED. - """createGymnast(name: String!): Gymnast!""" + """ + createGymnast(name: String!): Gymnast! + """ The name is trimmed and must be 1-70 characters (BAD_USER_INPUT otherwise). - """renameGymnast(id: ID!, name: String!): Gymnast!""" + """ + renameGymnast(id: ID!, name: String!): Gymnast! + """ Also deletes the gymnast's routines. Returns the id of the deleted gymnast. """ deleteGymnast(id: ID!): ID! @@ -323,7 +334,9 @@ type Mutation { """ The name is trimmed and must be 1-70 characters (BAD_USER_INPUT otherwise). Throws NAME_TAKEN or LIMIT_REACHED. - """createRoutine(input: CreateRoutineInput!): Routine!""" + """ + createRoutine(input: CreateRoutineInput!): Routine! + """ The name is trimmed and must be 1-70 characters (BAD_USER_INPUT otherwise). Throws NAME_TAKEN. """ From 74b3c1a31b05653ccba18047e4fea7753a60946d Mon Sep 17 00:00:00 2001 From: Ronja Date: Sat, 17 Oct 2026 09:26:30 +0200 Subject: [PATCH 5/6] feat: make gymnast list changes no-ops and Routine.dScore nullable Adding an item that is already in a gymnast's list, or removing one that is not, now changes nothing and returns the gymnast, so ALREADY_EXISTS is gone and NOT_FOUND is only for unknown ids. Routine.dScore is null while the routine has a removed item, the same rule as calculateDScore. calculateDScore keeps taking only item ids. --- backend/src/schema.graphql | 30 ++++++++++++++---------------- docs/AI_USAGE.md | 7 +++++++ docs/API.md | 15 +++++++-------- docs/DATA_MODEL.md | 10 +++++----- 4 files changed, 33 insertions(+), 29 deletions(-) diff --git a/backend/src/schema.graphql b/backend/src/schema.graphql index 4aa32dc..f9f7b4a 100644 --- a/backend/src/schema.graphql +++ b/backend/src/schema.graphql @@ -10,10 +10,6 @@ # NOT_FOUND The id does not exist, or belongs to another coach. # Both cases give the same answer, so that nobody can find # out which ids exist (constraint 9 in docs/DATA_MODEL.md). -# Also removeGymnastElement for an item that is not in the -# gymnast's list. -# ALREADY_EXISTS addGymnastElement for an item that is already in the -# gymnast's list. # NAME_TAKEN The coach already has a routine with this name for this # gymnast and apparatus (constraint 6). # INVALID_ITEMS The items do not fit the routine: a vault outside vault, @@ -74,8 +70,8 @@ interface CatalogItem { """ True when the item is no longer in the Code of Points. It can still be fetched by id, and it stays in routines and gymnast lists, so the UI should - warn about it there. It is not found by the search, it does not count in the - D-score, and it cannot be added to a routine or a list. + warn about it there. It is not found by the search, a routine that has it + gets no D-score (null), and it cannot be added to a routine or a list. """ removed: Boolean! } @@ -92,7 +88,7 @@ type Element implements CatalogItem { """ True when the item is no longer in the Code of Points. It can still be fetched by id, and it stays in routines and gymnast lists, but it is not - found by the search and does not count in a D-value. + found by the search, and a routine that has it gets no D-score. """ removed: Boolean! """ @@ -119,7 +115,7 @@ type Vault implements CatalogItem { """ True when the item is no longer in the Code of Points. It can still be fetched by id, and it stays in routines and gymnast lists, but it is not - found by the search and does not count in a D-value. + found by the search, and a routine that has it gets no D-score. """ removed: Boolean! """ @@ -279,10 +275,12 @@ type Routine { """ items: [CatalogItem!]! """ - Sum of the eight highest values, each code counted once. Calculated. Removed - items (see `removed`) do not count. + Sum of the eight highest values, each code counted once. Calculated. null + when the routine has a removed item (see `removed`), the same rule as + `calculateDScore`, which rejects removed items. Remove the item to get a + score again. """ - dScore: Float! + dScore: Float createdAt: DateTime! """When the routine, its name or its items were last changed.""" updatedAt: DateTime! @@ -319,15 +317,15 @@ type Mutation { deleteGymnast(id: ID!): ID! """ - Adds an element or vault to the gymnast's list. Throws NOT_FOUND, - ALREADY_EXISTS (the item is already in the list), INVALID_ITEMS (a removed - item) or LIMIT_REACHED. + Adds an element or vault to the gymnast's list. Adding an item that is + already in the list does nothing. Throws NOT_FOUND (unknown gymnast or + item), INVALID_ITEMS (a removed item) or LIMIT_REACHED. """ addGymnastElement(gymnastId: ID!, itemId: ID!): Gymnast! """ Removes an item from the gymnast's list, also when it has been removed from - the Code of Points. Throws NOT_FOUND when the gymnast does not exist or the - item is not in the list. + the Code of Points. Removing an item that is not in the list does nothing. + Throws NOT_FOUND when the gymnast does not exist. """ removeGymnastElement(gymnastId: ID!, itemId: ID!): Gymnast! diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 961325f..76e7f2e 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -180,6 +180,13 @@ and described them in `docs/API.md`, including the limits, the warning in the UI and the tests. Checked that the schema parses with the `graphql` library. +- **After the review:** The team agreed with a reviewer on two changes: + adding an item that is already in the gymnast's list, or removing one that + is not, is a no-op without an error (the mutations return the gymnast), and + `Routine.dScore` is `null` while the routine has a removed item, the same + rule as `calculateDScore`. The team also decided not to add `apparatusId` to + `calculateDScore`. Claude made the changes in the schema and the docs, and + repaired schema fields that a merge had put inside doc comments. ## Catalog search (`backend/src/catalog/search.ts`, `eslint.config.js`, `docs/API.md`) diff --git a/docs/API.md b/docs/API.md index 32c69ea..c9f4674 100644 --- a/docs/API.md +++ b/docs/API.md @@ -61,7 +61,7 @@ When a new Code of Points drops an element or vault, it is marked as removed in - **By id:** `element(id)`, `vault(id)` and `catalogItem(id)` still return them, because a routine needs to show them. They have `removed: true`. - **Variants:** `variants` only lists variants that are still in the Code of Points. - **Adding:** a removed item cannot be added to a routine or to a gymnast's list. The mutation throws `INVALID_ITEMS`. An item that is already there can stay: `setRoutineItems` accepts a removed item that is already in the routine, and `removeGymnastElement` removes it. -- **The D-score:** a removed item does not count in `Routine.dScore`. Only items that are still in the Code of Points are summed, with the same rule as before (the eight highest values, each code once), so if a removed item was one of the eight highest, the next one takes its place. The score of a saved routine can therefore go down when an item is removed, and the UI must tell the coach. `calculateDScore` throws `INVALID_ITEMS` for a removed item: the calculator has nothing saved to protect, and removed items cannot be found in the search. +- **The D-score:** a routine that has a removed item gets no D-score: `Routine.dScore` is `null`, and `calculateDScore` throws `INVALID_ITEMS` for a removed item. The rule is the same everywhere, so a saved routine never shows a score that the calculator would refuse. The coach removes the item to get the score again, and the UI says so in the warning. Without a removed item the score is calculated as before (the eight highest values, each code once). A saved routine can therefore lose its score when an item is removed, and the UI must tell the coach. - **What the UI shows:** a persistent inline warning on the routine or the list, and not a toast that disappears. The coach has to act on it, and several design systems advise against toasts for this kind of message. The warning says how many items are no longer in the Code of Points and do not count, marks each of them, and has a button to remove it. The UI counts `removed` in `Routine.items` or `Gymnast.elements`, so no extra field is needed. - **The seed script:** it marks an item as removed and never deletes it. It never changes routines or lists. It prints how many routines and lists use each removed item, so the team can see what is affected. - **Added and changed items:** the FIG also publishes new elements and changed values during a cycle, which is more likely than a removal. The seed script adds new items. A changed value changes the D-score of saved routines, because the score is calculated and never stored. @@ -73,14 +73,14 @@ When a new Code of Points drops an element or vault, it is marked as removed in - Mutations create, rename and delete gymnasts and routines. `setRoutineItems` replaces the whole list of items in a routine, so the client sends the ids in the wanted order and the server does not need separate add, remove and move operations. - **A gymnast's list.** `Gymnast.elements` is the list of elements and vaults the gymnast can do or is practicing, in the order they were added. It is the same list as the table `gymnast_elements` in the database. `elements(apparatusId)` gives only the items on one apparatus, which is what the UI needs when it builds a routine. - `addGymnastElement(gymnastId, itemId)` adds an element or vault, and `removeGymnastElement(gymnastId, itemId)` removes one. Both return the whole `Gymnast`, so the client can update its cache with the new list. - - Neither is silent. Adding an item that is already in the list throws `ALREADY_EXISTS`, and removing one that is not in the list throws `NOT_FOUND`. The state is the same as before the call, so repeating a call does no harm, but the client is told that nothing changed. The UI disables the button while the call runs, and treats `ALREADY_EXISTS` on add and `NOT_FOUND` on remove as a sign that its list is out of date: it reloads the list and does not show an error. + - Neither gives an error when there is nothing to do. Adding an item that is already in the list, or removing one that is not in the list, changes nothing and returns the `Gymnast` as it is. The result is the state the client asked for, and a double click or a retry is harmless. `NOT_FOUND` is only used when the gymnast or the item does not exist. - The list is independent of the routines. A gymnast can practice an item that is in no routine, and a routine can contain an item that is not in the list. - - Errors: `UNAUTHENTICATED`, `NOT_FOUND` (the gymnast or the item does not exist, the gymnast belongs to another coach, or, when removing, the item is not in the list), `ALREADY_EXISTS` (when adding: the item is already in the list), `INVALID_ITEMS` (a removed item cannot be added) and `LIMIT_REACHED` (100 items per gymnast). + - Errors: `UNAUTHENTICATED`, `NOT_FOUND` (the gymnast or the item does not exist, or the gymnast belongs to another coach), `INVALID_ITEMS` (a removed item cannot be added) and `LIMIT_REACHED` (100 items per gymnast). - **`updatedAt` on a routine** changes when the routine is renamed and when its items change. The items are stored in a separate table, so `setRoutineItems` must update `updatedAt` explicitly, in the same transaction as the items. - **Names.** The name of a gymnast and the name of a routine are trimmed (spaces at the start and the end are removed) and must be 1–70 characters. A longer name, or a name with only spaces, throws `BAD_USER_INPUT`. The length is counted in characters and not in bytes, so a name with accents or emoji gets the same room as any other. 70 characters is the length that the UK Government Data Standards give for a full name in one field. Both limits are checked in the backend, and the database does not check them yet. `NAME_TAKEN` is checked on the trimmed name. - **Two tabs on the same routine.** `setRoutineItems` replaces the whole list, and the last save wins. It does not check if the routine was changed since the client loaded it, so a change made in another tab can be overwritten. This is a choice for the prototype. If it becomes a problem, add a `CONFLICT` error and let the client send the `updatedAt` it loaded. - `calculateDScore(itemIds)` is for the calculator, which works without logging in. It calculates the D-score and saves nothing. -- The same element may appear more than once in a routine. Removed elements stay in routines and lists with `removed: true`, and do not count in the D-score (see "Removed elements" in [DATA_MODEL.md](DATA_MODEL.md)). +- The same element may appear more than once in a routine. Removed elements stay in routines and lists with `removed: true`, and make the routine's D-score null (see "Removed elements" in [DATA_MODEL.md](DATA_MODEL.md)). ## Search, filter, sort and pagination @@ -206,8 +206,7 @@ Errors are thrown from the resolvers as GraphQL errors with a machine-readable c | Code | When | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `UNAUTHENTICATED` | The operation needs a logged-in coach, or the session token or Google token is missing, invalid, or expired. The client sends the coach to the login page. | -| `NOT_FOUND` | The id does not exist, belongs to another coach, or `removeGymnastElement` is called for an item that is not in the gymnast's list. | -| `ALREADY_EXISTS` | `addGymnastElement` is called for an item already in the gymnast's list. | +| `NOT_FOUND` | The id does not exist, or belongs to another coach. | | `NAME_TAKEN` | The gymnast already has a routine with this name on this apparatus (constraint 6). | | `INVALID_ITEMS` | The items do not fit the routine: a vault outside vault, an element on vault, more than one vault, or an item from another apparatus (constraints 7 and 8). Also applies to a removed item unless it is already in the routine or gymnast's list. | | `BAD_USER_INPUT` | Empty or overlong name, malformed id, `first` out of range, search text over 80 characters or 8 words, unknown apparatus/group, a group from another apparatus, invalid cursor or sort, difficulty sort without an apparatus, difficulty filter with vault, or difficulty value outside 0.1–1.0 steps. | @@ -365,8 +364,8 @@ Vitest runs the API tests against a test database, with queries sent to the serv - **Difficulty sort:** without an apparatus filter it gives `BAD_USER_INPUT`, and with one it only returns items from that apparatus. - **Difficulty values:** choosing `[0.1, 0.2]` returns elements with value 0.1 or 0.2, vaults are left out, a value such as 0.15 or 1.1 gives `BAD_USER_INPUT`, and the filter together with vault gives `BAD_USER_INPUT`. - **Limits:** `first` of 0 and 101, more than 50 ids, the 201st gymnast, the 101st element in a gymnast's list, a name of 71 characters (gymnast and routine), a name with only spaces, and a query that is too deep. -- **The D-score:** the eight highest values, each code counted once, and the single vault on vault. A removed item does not count, and the next highest value takes its place. -- **The gymnast's list:** adding an item twice gives `ALREADY_EXISTS` and removing an item that is not there gives `NOT_FOUND`, and the list is unchanged in both cases. `removeGymnastElement` also removes a removed item. `addGymnastElement` of a removed item gives `INVALID_ITEMS`. Another coach's gymnast gives `NOT_FOUND`. +- **The D-score:** the eight highest values, each code counted once, and the single vault on vault. A routine with a removed item has `dScore` `null`. +- **The gymnast's list:** adding an item twice and removing an item that is not there both leave the list unchanged and return the gymnast, without an error. `removeGymnastElement` also removes a removed item. `addGymnastElement` of a removed item gives `INVALID_ITEMS`. Another coach's gymnast gives `NOT_FOUND`. - **Duplicates in a routine:** the same element twice is accepted and counts once in `dScore`. - **Removed items:** they are not found by `searchCatalog`, but `catalogItem(id)` returns them with `removed: true`. They are left out of `variants`, and a code whose `-a` is removed is still found through its `-b`. `setRoutineItems` accepts a removed item that is already in the routine and rejects one that is not. `calculateDScore` rejects them. - **`updatedAt`:** it changes when a routine is renamed and when `setRoutineItems` changes the items. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 077afe2..12c4fb9 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -253,7 +253,7 @@ Constraints 7 and 8 compare several rows, or a row with its routine, so they can ### Calculated values 12. The difficulty letter (A–J) is not stored, and not part of the API. The frontend calculates it from `value`: 0.1 = A, 0.2 = B … 1.0 = J. -13. The D-value of a routine is calculated, not stored. It is the sum of the eight highest values in the routine (`value` for elements, `dValue` for vaults), where each `code` is counted only once. Two variants of the same element therefore only count once, as in the Code of Points. On vault the routine holds one vault, so the D-value is that vault's `dValue`. Removed elements and vaults do not count. Composition requirements and connection values are not included yet. +13. The D-value of a routine is calculated, not stored. It is the sum of the eight highest values in the routine (`value` for elements, `dValue` for vaults), where each `code` is counted only once. Two variants of the same element therefore only count once, as in the Code of Points. On vault the routine holds one vault, so the D-value is that vault's `dValue`. A routine with a removed element or vault has no D-value (the API returns `null`). Composition requirements and connection values are not included yet. ## Storage in PostgreSQL @@ -285,7 +285,7 @@ How a removed item behaves in the API: - **By id:** `element(id)`, `vault(id)` and `catalogItem(id)` still return it, with `removed: true`, so links and saved data keep working. - **Variants:** `variants` leaves out removed variants. -- **In a routine or a gymnast's list:** it stays, with `removed: true`, and the interface shows a warning next to it. It does not count in the D-value of the routine, so the coach sees that the routine is worth less until the element is replaced. +- **In a routine or a gymnast's list:** it stays, with `removed: true`, and the interface shows a warning next to it. The routine gets no D-value while it has the element, so the coach sees that something must be replaced to get a score. - **Adding it again:** a removed item cannot be added to a gymnast's list. The catalog search never offers it, so the interface cannot add it to a routine either. ### Timestamps @@ -405,16 +405,16 @@ Sorting by difficulty is only allowed when an apparatus is selected, because com The gymnast's list of elements is part of the API (`Gymnast.elements`), with one mutation to add an item and one to remove it. A coach adds elements to the list one by one from the search, so this matches how the list is used. A routine is different: its order matters and the coach rearranges it, so `setRoutineItems` replaces the whole routine at once. -Adding an item that is already in the list gives `ALREADY_EXISTS`, and removing one that is not in the list gives `NOT_FOUND`. The list is unchanged in both cases, so repeating a call does no harm, but the client is told that nothing changed and the failure is never silent. The interface disables the button while the call runs, and treats these two errors as a sign that its list is out of date. A gymnast's list has at most 100 items. +Adding an item that is already in the list, or removing one that is not in the list, changes nothing and gives no error. Both mutations return the whole gymnast, so the client gets the up-to-date list, and a double click or a retry is harmless. `NOT_FOUND` is only used when the gymnast or the item does not exist. A gymnast's list has at most 100 items. -### Removed elements stay, but do not count +### Removed elements stay, but give no D-value When an element is no longer in the Code of Points, it is marked with `removedAt` and kept, instead of deleted. We decided this because coaches have saved routines and lists that use it, and deleting it would break them or change them without warning. A removed element: - stays in routines and gymnast lists, marked as removed, and the interface shows a warning next to it -- does not count in the D-value, so the coach can see that the routine is worth less until the element is replaced +- makes the D-value of the routine `null`, the same as in the calculator, so the coach must replace the element to get a score - can still be fetched by id, so saved links work - is not found in the search and cannot be added to a gymnast's list From 630b938539d4ada2071ca4bd04dc8c01efdf2e40 Mon Sep 17 00:00:00 2001 From: Ronja Date: Sat, 17 Oct 2026 09:26:38 +0200 Subject: [PATCH 6/6] style: format schema.graphql with Prettier Only the layout of the doc comments changes. printSchema gives the same result before and after. --- backend/src/schema.graphql | 92 ++++++++++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 23 deletions(-) diff --git a/backend/src/schema.graphql b/backend/src/schema.graphql index f9f7b4a..9264682 100644 --- a/backend/src/schema.graphql +++ b/backend/src/schema.graphql @@ -40,7 +40,9 @@ # - Failures that the user can fix (NAME_TAKEN, INVALID_ITEMS) carry a # readable message that the UI can show as it is. -"""One of the four apparatus: VT, UB, BB or FX.""" +""" +One of the four apparatus: VT, UB, BB or FX. +""" type Apparatus { id: ID! name: String! @@ -57,14 +59,20 @@ type ElementGroup { apparatus: Apparatus! } -"""Fields shared by everything in the catalog: elements and vaults.""" +""" +Fields shared by everything in the catalog: elements and vaults. +""" interface CatalogItem { id: ID! code: String! description: String! - """The gymnast the item is named after, if any.""" + """ + The gymnast the item is named after, if any. + """ name: String - """Full URL of the drawing, ready to use as the src of an .""" + """ + Full URL of the drawing, ready to use as the src of an . + """ imageUrl: String! elementGroup: ElementGroup! """ @@ -76,13 +84,17 @@ interface CatalogItem { removed: Boolean! } -"""One element on uneven bars, balance beam or floor.""" +""" +One element on uneven bars, balance beam or floor. +""" type Element implements CatalogItem { id: ID! code: String! description: String! name: String - """Full URL of the drawing, ready to use as the src of an .""" + """ + Full URL of the drawing, ready to use as the src of an . + """ imageUrl: String! elementGroup: ElementGroup! """ @@ -103,13 +115,17 @@ type Element implements CatalogItem { variants: [Element!]! } -"""One vault from the vault table.""" +""" +One vault from the vault table. +""" type Vault implements CatalogItem { id: ID! code: String! description: String! name: String - """Full URL of the drawing, ready to use as the src of an .""" + """ + Full URL of the drawing, ready to use as the src of an . + """ imageUrl: String! elementGroup: ElementGroup! """ @@ -128,7 +144,9 @@ type Vault implements CatalogItem { variants: [Vault!]! } -"""What the user can sort the catalog by.""" +""" +What the user can sort the catalog by. +""" enum CatalogSortField { CODE """ @@ -169,7 +187,9 @@ input CatalogFilter { } type CatalogEdge { - """Opaque position marker. Pass it as `after` to get the next page.""" + """ + Opaque position marker. Pass it as `after` to get the next page. + """ cursor: String! node: CatalogItem! } @@ -193,18 +213,30 @@ type CatalogConnection { type Query { apparatus: [Apparatus!]! elementGroup(id: ID!): ElementGroup - """Also finds removed elements (see `removed`), so routines can show them.""" + """ + Also finds removed elements (see `removed`), so routines can show them. + """ element(id: ID!): Element - """Also finds removed vaults (see `removed`), so routines can show them.""" + """ + Also finds removed vaults (see `removed`), so routines can show them. + """ vault(id: ID!): Vault - """Also finds removed items (see `removed`), so routines can show them.""" + """ + Also finds removed items (see `removed`), so routines can show them. + """ catalogItem(id: ID!): CatalogItem - """The logged-in coach, or null when nobody is logged in.""" + """ + The logged-in coach, or null when nobody is logged in. + """ me: Coach - """Throws UNAUTHENTICATED or NOT_FOUND.""" + """ + Throws UNAUTHENTICATED or NOT_FOUND. + """ gymnast(id: ID!): Gymnast - """Throws UNAUTHENTICATED or NOT_FOUND.""" + """ + Throws UNAUTHENTICATED or NOT_FOUND. + """ routine(id: ID!): Routine """ The D-score of a list of items, for the calculator. Open to everyone and @@ -227,22 +259,30 @@ type Query { search: String filter: CatalogFilter sort: CatalogSort - """Page size, 1-100. A value outside the range gives BAD_USER_INPUT.""" + """ + Page size, 1-100. A value outside the range gives BAD_USER_INPUT. + """ first: Int! = 20 after: String ): CatalogConnection! } -"""An ISO 8601 date and time, e.g. 2026-10-01T12:00:00Z.""" +""" +An ISO 8601 date and time, e.g. 2026-10-01T12:00:00Z. +""" scalar DateTime -"""A logged-in user. Google's own id is never exposed.""" +""" +A logged-in user. Google's own id is never exposed. +""" type Coach { id: ID! name: String! email: String! createdAt: DateTime! - """All the coach's gymnasts. Limited to 200 per coach by the backend.""" + """ + All the coach's gymnasts. Limited to 200 per coach by the backend. + """ gymnasts: [Gymnast!]! } @@ -282,12 +322,16 @@ type Routine { """ dScore: Float createdAt: DateTime! - """When the routine, its name or its items were last changed.""" + """ + When the routine, its name or its items were last changed. + """ updatedAt: DateTime! } type AuthPayload { - """Session token the client sends in the Authorization header.""" + """ + Session token the client sends in the Authorization header. + """ token: String! coach: Coach! } @@ -299,7 +343,9 @@ input CreateRoutineInput { } type Mutation { - """Verifies the Google token and creates the coach on the first login.""" + """ + Verifies the Google token and creates the coach on the first login. + """ login(googleToken: String!): AuthPayload! """