diff --git a/backend/src/schema.graphql b/backend/src/schema.graphql
index 2134a1d..9264682 100644
--- a/backend/src/schema.graphql
+++ b/backend/src/schema.graphql
@@ -5,7 +5,8 @@
# 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).
@@ -13,14 +14,21 @@
# 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). Also a removed
-# item added to a gymnast's list.
-# BAD_USER_INPUT Empty name, malformed id, `first` out of range, a
-# DIFFICULTY sort without an apparatus filter, or a
-# difficulty filter together with vault, or a difficulty
-# value that is not 0.1-1.0 in steps of 0.1.
+# 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, name longer than 70 characters, malformed id,
+# `first` out of range, a DIFFICULTY sort without an
+# apparatus filter, a difficulty filter together with vault,
+# or a difficulty value that is not 0.1-1.0 in steps of 0.1.
# 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 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
@@ -32,14 +40,18 @@
# - 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!
groups: [ElementGroup!]!
}
-"""A group of elements or vaults on one apparatus, e.g. "1.000 Mounts"."""
+"""
+A group of elements or vaults on one apparatus, e.g. "1.000 Mounts".
+"""
type ElementGroup {
id: ID!
number: Int!
@@ -47,37 +59,48 @@ 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!
"""
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.
+ 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, a routine that has it
+ gets no D-score (null), and it cannot be added to a routine or a list.
"""
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!
"""
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!
"""
@@ -92,28 +115,38 @@ 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!
"""
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!
- """Fixed difficulty score, e.g. 2.6."""
+ """
+ Fixed difficulty score, e.g. 2.6.
+ """
dValue: Float!
- """Other ways of performing the same vault. Removed variants are left out."""
+ """
+ Other ways of performing the same vault. Removed variants are left out.
+ """
variants: [Vault!]!
}
-"""What the user can sort the catalog by."""
+"""
+What the user can sort the catalog by.
+"""
enum CatalogSortField {
CODE
"""
@@ -147,12 +180,16 @@ input CatalogFilter {
it throws BAD_USER_INPUT. Empty or missing means no difficulty filter.
"""
difficultyValues: [Float!]
- """Only items named after a gymnast."""
+ """
+ Only items named after a gymnast.
+ """
namedOnly: Boolean
}
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!
}
@@ -176,20 +213,36 @@ 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."""
+ """
+ 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
- nothing is saved. Throws INVALID_ITEMS when the items do not fit together.
- Removed items do not count. 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!
@@ -198,27 +251,38 @@ 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
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!]!
}
@@ -232,9 +296,11 @@ type Gymnast {
routines(apparatusId: ID): [Routine!]!
"""
The elements and vaults the gymnast can do or is practicing, in the order
- they were added. Removed items stay in the list, with removed: true.
+ they were added. Optionally only the elements on one apparatus. Removed
+ items stay in the list, with removed: true. Limited to 100 per gymnast by
+ the backend.
"""
- elements: [CatalogItem!]!
+ elements(apparatusId: ID): [CatalogItem!]!
}
type Routine {
@@ -249,14 +315,23 @@ type Routine {
"""
items: [CatalogItem!]!
"""
- Sum of the eight highest values, each code counted once. Removed items do
- not count. Calculated.
+ 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
+ createdAt: DateTime!
+ """
+ When the routine, its name or its items were last changed.
"""
- dScore: Float!
+ 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!
}
@@ -268,35 +343,54 @@ 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!
- """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!
-
- """Throws NAME_TAKEN or LIMIT_REACHED."""
- createRoutine(input: CreateRoutineInput!): Routine!
- """Throws NAME_TAKEN."""
- renameRoutine(id: ID!, name: String!): Routine!
- 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.
+ Also deletes the gymnast's routines. Returns the id of the deleted gymnast.
"""
- setRoutineItems(routineId: ID!, itemIds: [ID!]!): Routine!
+ deleteGymnast(id: ID!): ID!
"""
Adds an element or vault to the gymnast's list. Adding an item that is
- already in the list does nothing. Throws NOT_FOUND for an unknown gymnast
- or item, and INVALID_ITEMS for a removed item.
+ 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 element or vault from the gymnast's list. Removing an item that
- is not in the list does nothing. Throws NOT_FOUND for an unknown 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 does nothing.
+ Throws NOT_FOUND when the gymnast does not exist.
"""
removeGymnastElement(gymnastId: ID!, itemId: ID!): Gymnast!
+
+ """
+ The name is trimmed and must be 1-70 characters (BAD_USER_INPUT otherwise).
+ Throws NAME_TAKEN or LIMIT_REACHED.
+ """
+ createRoutine(input: CreateRoutineInput!): Routine!
+ """
+ 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!
+ """
+ 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. 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 49e8d28..76e7f2e 100644
--- a/docs/AI_USAGE.md
+++ b/docs/AI_USAGE.md
@@ -162,6 +162,32 @@
does not, and checked the browser support and the cascade layers in the
Tailwind documentation. Claude checked lint, build and tests.
+## Missing fields in the GraphQL schema (`backend/src/schema.graphql`, `docs/API.md`)
+
+- **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 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.
+- **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`)
- **Tool:** Claude Code
diff --git a/docs/API.md b/docs/API.md
index 3013d85..c9f4674 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -32,7 +32,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`. A vault has `dValue`. Both have `variants`. The difficulty letter (A–J) is not part of the API: the frontend shows it from `value`.
- `searchCatalog` searches, filters, sorts and pages over elements and vaults together.
@@ -53,14 +53,34 @@ 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 `removeGymnastElement` removes it.
+- **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.
+
### 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.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 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, 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.
-- `Gymnast.elements` is the gymnast's list of elements and vaults she can do or is practicing. `addGymnastElement` and `removeGymnastElement` change it one item at a time, because the coach adds items from the search one by one. Adding an item that is already in the list, or removing one that is not, does 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
@@ -150,7 +170,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, 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
@@ -183,14 +203,16 @@ 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, `first` out of range, a search text over 80 characters or 8 words, an unknown apparatus or element group in the filter, an element group from another apparatus, a `DIFFICULTY` sort without an apparatus filter, a difficulty filter together with vault, a difficulty value that is not 0.1–1.0 in steps of 0.1, or a cursor that is not valid or was made for another sort. |
-| `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, 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, 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. |
+| `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 when the query is invalid or exceeds the depth limit. |
+| `INTERNAL_SERVER_ERROR` | Added by Apollo Server for an unexpected error. The client shows a general message without details. |
An error response looks like this:
@@ -339,13 +361,14 @@ Vitest runs the API tests against a test database, with queries sent to the serv
- **Cache:** a second search does not read the apparatus and groups from the database again.
- **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.
-- **The gymnast's list:** adding the same item twice keeps one row, removing an item that is not there does nothing, and adding a removed item gives `INVALID_ITEMS`.
-- **Removed elements:** a removed item is still returned by id with `removed: true`, is left out of `variants` and the search, and does not count in `dScore`. A code whose `-a` is removed is still found through its `-b`.
-- **Duplicates in a routine:** the same element twice is accepted and counts once in `dScore`.
- **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, and a query that is too deep.
-- **The D-score:** the eight highest values, each code counted once, and the single vault on vault.
+- **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 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.
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/DATA_MODEL.md b/docs/DATA_MODEL.md
index 5c4d990..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, or removing one that is not, does nothing instead of giving an error. Clicking twice, or a slow network that sends the same request twice, then gives the same result as clicking once.
+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
diff --git a/docs/USER_LIST_PAGINATION.md b/docs/USER_LIST_PAGINATION.md
index 233a7d7..794c2ab 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.elements` | `[CatalogItem!]!` | 100 per gymnast |
+
+`Gymnast.elements` 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.
@@ -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 100th item in a gymnast's list works and the 101st gives `LIMIT_REACHED`, and a coach cannot reach another coach's lists.