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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ The code in `src/` is the Vite starter project with one example test. The toolin

## What to review

| Document | Describes |
| ---------------------------------------------- | ---------------------------------------------------------------------------------------- |
| [docs/UI_PLANS.md](docs/UI_PLANS.md) | The user groups, user stories and pages of the application |
| [docs/design/README.md](docs/design/README.md) | Mockups of five of the pages. Mock designs only, not the final design |
| [docs/DATA_MODEL.md](docs/DATA_MODEL.md) | The entities, how they relate, the constraints and the reasoning behind the main choices |
| [docs/examples/](docs/examples/) | Invented example data that shows how each entity in the data model is structured |
| [docs/AI_USAGE.md](docs/AI_USAGE.md) | How AI was used in the project |
| Document | Describes |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------- |
| [docs/UI_PLANS.md](docs/UI_PLANS.md) | The user groups, user stories and pages of the application |
| [docs/design/README.md](docs/design/README.md) | Mockups of five of the pages. Mock designs only, not the final design |
| [docs/DATA_MODEL.md](docs/DATA_MODEL.md) | The entities, how they relate, the constraints and the reasoning behind the main choices |
| [docs/API.md](docs/API.md) | The GraphQL API: why GraphQL, the schema, search and pagination, login and error handling |
| [docs/examples/](docs/examples/) | Invented example data that shows how each entity in the data model is structured |
| [docs/AI_USAGE.md](docs/AI_USAGE.md) | How AI was used in the project |

## The dataset

Expand All @@ -42,6 +43,19 @@ For coaches, who log in with Google:

The D-value of a routine is calculated from its elements and is not stored. Composition requirements and connection values are not included in this version.

## Why GraphQL and not REST

GraphQL is required by the assignment, but it also suits this application. The full description of the API is in [docs/API.md](docs/API.md), and the schema is in [`backend/src/schema.graphql`](backend/src/schema.graphql).

- **The data is nested.** A page often needs an apparatus with its groups and the items in them, or a gymnast with its routines and their elements. With REST that means several requests, or one endpoint for each page. With GraphQL the client asks for exactly the nested fields it needs in one request.
- **Less data over the network.** A list of search results only needs a code, a description and a value, while a detail page needs more. The client chooses the fields, so we do not send what is not shown. This is part of our sustainability choices.
- **One typed contract.** The schema describes every query, argument and field. We can generate TypeScript types from it, so a change in the API shows up as a type error in the client and not as a bug at runtime.
- **Search, filter, sort and pagination in one query.** `searchCatalog` takes all of them as typed arguments and runs on the server over the whole data set. Pages are fetched with cursors, which keeps every page fast whatever the size of the catalog.

The costs are that HTTP caching is harder than with REST, because all requests go to one endpoint, and that the server must guard against expensive queries. We handle the last one with an upper limit on the page size, and we avoid repeated database queries for nested fields with DataLoader.

How the API stays fast when the catalog grows (cursors, indexes, text search, limits and caching) is described in the scalability section of [docs/API.md](docs/API.md#scalability).

## Technology

In the repository now:
Expand Down
239 changes: 239 additions & 0 deletions backend/src/schema.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
# GraphQL schema draft: the catalog (apparatus, groups, elements, vaults, search)
# and the user data (coaches, gymnasts, routines, login).
#
# ERROR HANDLING
# 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.
# 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).
# 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,
# 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.
# LIMIT_REACHED The coach has reached a limit: 200 gymnasts, 50 routines
# per gymnast, or 50 items in a routine.
#
# 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.
# Every other protected operation throws UNAUTHENTICATED.
# - Codes are part of the contract, but the schema stays small. Typed result
# unions (Routine | NameTaken | ...) were considered, but they add a type
# and a fragment per error for a prototype with few failure cases.
# - 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."""
type Apparatus {
id: ID!
name: String!
groups: [ElementGroup!]!
}

"""A group of elements or vaults on one apparatus, e.g. "1.000 Mounts"."""
type ElementGroup {
id: ID!
number: Int!
name: String!
apparatus: Apparatus!
}

"""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."""
name: String
"""Full URL of the drawing, ready to use as the src of an <img>."""
imageUrl: String!
elementGroup: ElementGroup!
}

"""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 <img>."""
imageUrl: String!
elementGroup: ElementGroup!
"""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)."""
variants: [Element!]!
}

"""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 <img>."""
imageUrl: String!
elementGroup: ElementGroup!
"""Fixed difficulty score, e.g. 2.6."""
dValue: Float!
variants: [Vault!]!
}

"""What the user can sort the catalog by."""
enum CatalogSortField {
CODE
DIFFICULTY
NAME
}

enum SortDirection {
ASC
DESC
}

input CatalogSort {
field: CatalogSortField! = CODE
direction: SortDirection! = ASC
}

input CatalogFilter {
apparatusId: ID
elementGroupId: ID
"""Applies to value for elements and dValue for vaults."""
minDifficulty: Float
maxDifficulty: Float
"""Only items named after a gymnast."""
namedOnly: Boolean
}

type CatalogEdge {
"""Opaque position marker. Pass it as `after` to get the next page."""
cursor: String!
node: CatalogItem!
}

type PageInfo {
hasNextPage: Boolean!
endCursor: String
}

type CatalogConnection {
edges: [CatalogEdge!]!
pageInfo: PageInfo!
"""
Number of matches in the whole result set, not only this page. Counting is
expensive on a large result, so the server only counts when the client asks
for this field.
"""
totalCount: Int!
}

type Query {
apparatus: [Apparatus!]!
elementGroup(id: ID!): ElementGroup
element(id: ID!): Element
vault(id: ID!): Vault
catalogItem(id: ID!): CatalogItem

"""The logged-in coach, or null when nobody is logged in."""
me: Coach
"""Throws UNAUTHENTICATED or NOT_FOUND."""
gymnast(id: ID!): Gymnast
"""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.
At most 50 ids, otherwise BAD_USER_INPUT.
"""
calculateDScore(itemIds: [ID!]!): Float!

searchCatalog(
"""
One word, case-insensitive. Finds items with a word that starts with the
term, in the code, name or description. Empty or missing means no text
filter.
"""
search: String
filter: CatalogFilter
sort: CatalogSort
"""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."""
scalar DateTime

"""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."""
gymnasts: [Gymnast!]!
}

type Gymnast {
id: ID!
name: String!
"""
Optionally only the routines on one apparatus. Limited to 50 per gymnast
by the backend.
"""
routines(apparatusId: ID): [Routine!]!
}

type Routine {
id: ID!
name: String!
gymnast: Gymnast!
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."""
dScore: Float!
}

type AuthPayload {
"""Session token the client sends in the Authorization header."""
token: String!
coach: Coach!
}

input CreateRoutineInput {
gymnastId: ID!
apparatusId: ID!
name: String!
}

type Mutation {
"""Verifies the Google token and creates the coach on the first login."""
login(googleToken: String!): AuthPayload!

"""Throws LIMIT_REACHED."""
createGymnast(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!

"""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.
"""
setRoutineItems(routineId: ID!, itemIds: [ID!]!): Routine!
}
13 changes: 13 additions & 0 deletions docs/AI_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,16 @@
Add a short README in docs/examples/ saying the data is invented, and link to the folder from
DATA_MODEL.md.
```

## GraphQL schema (`backend/src/schema.graphql` and `docs/API.md`)

- **Tool:** Claude Code
- **What the team did:** Had never used GraphQL before, so the schema was built
step by step in a conversation: catalog types first, then search and
pagination, then user data and errors. The team decided the direction,
for example schema-first, cursor pagination and how errors are reported, and
asked for the reasoning behind each choice.
- **What Claude did:** Wrote the draft schema and `docs/API.md`, explained each
GraphQL concept as it was introduced, and checked that the schema parses
with the `graphql` library. Part of the reasoning in the error handling
section comes from Claude's suggestions, which the team accepted.
Loading