diff --git a/.gitignore b/.gitignore index 7082aef..e38fcfd 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ dist dist-ssr *.local +# The Prisma client (`npm run db:generate`) and the GraphQL types (`npm run codegen`) +backend/src/generated + # Environment variables .env .env.* @@ -26,3 +29,9 @@ dist-ssr *.njsproj *.sln *.sw? + +# Code of Points extraction: the PDF must never be committed +scripts/extract-cop/en_1.1.-WAG-Code-of-Points-2025-2028.pdf +scripts/extract-cop/review.html +scripts/extract-cop/.venv +__pycache__ diff --git a/README.md b/README.md index b0a447f..e6f3044 100644 --- a/README.md +++ b/README.md @@ -6,23 +6,24 @@ We are building a web application that helps gymnastics coaches plan their gymna ## Status -The first delivery is a specification. There is no working application yet. +The first delivery was a specification. The application is not finished: the frontend is still the Vite starter page, but the catalog data has been extracted from the Code of Points and is in [`data/`](data/), and the database schema is in place: the Prisma schema and its migrations create all the tables of the data model in PostgreSQL. The seed script loads `data/` into the database, and a GraphQL server in `backend/` answers the catalog queries and the search (see [GraphQL server](#graphql-server)). The login and the user data (gymnasts, their lists of elements and routines) work through the API. The connection from the frontend is the next step. The code in `src/` is the Vite starter project with one example test. The tooling is set up: TypeScript, ESLint, Prettier and Vitest. ## 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 -The catalog data comes from the FIG Women's Artistic Gymnastics Code of Points 2025–2028, which is published as a PDF. We will extract the apparatus, element groups, elements and vaults from the PDF with a script, and load them into the database with a seed script. Neither script is written yet. +The catalog data comes from the FIG Women's Artistic Gymnastics Code of Points 2025–2028, which is published as a PDF. We have extracted the apparatus, element groups, elements and vaults from the PDF with a Python script in [`scripts/extract-cop/`](scripts/extract-cop/), and the result is in [`data/`](data/). [docs/EXTRACTION.md](docs/EXTRACTION.md) explains how it was done and what has been checked by hand. The seed script in `backend/src/seed/` loads the data into the database (see "Seeding" below). The data is used for educational purposes. All rights to the Code of Points belong to FIG (Fédération Internationale de Gymnastique). @@ -42,18 +43,36 @@ 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, so a deep page does not read the earlier pages again (measured on a large catalog in a later sub-issue). + +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: - React, TypeScript and Vite for the frontend -- ESLint and Prettier for linting and formatting +- A backend package in `backend/` with TypeScript and Prisma, set up as an npm workspace +- PostgreSQL 18, installed directly on our own machines (not in Docker), with the Prisma schema and migrations for all the tables of the data model +- A seed script that loads the catalog from `data/` into the database +- The GraphQL server (Apollo Server) with the catalog queries and the catalog search, with Prisma only and cursor pagination, see [GraphQL server](#graphql-server) +- ESLint and Prettier for linting and formatting, for both the frontend and the backend - Vitest for testing +- Tailwind CSS, shadcn/ui and Lucide icons for the interface, see [UI components](#ui-components-shadcnui-tailwind-css-and-lucide) Planned: - A GraphQL backend in Node and TypeScript -- PostgreSQL, installed directly on the VM (not in Docker), accessed with Prisma +- PostgreSQL 18 on the VM, set up the same way as on our own machines ### Why PostgreSQL @@ -61,7 +80,7 @@ The data model in [docs/DATA_MODEL.md](docs/DATA_MODEL.md) is relational: appara - **Relations and constraints.** Foreign keys stop a group from pointing to an apparatus that does not exist, or a routine from pointing to a gymnast that does not exist. A unique constraint on gymnast, apparatus and routine name gives constraint 6. Rules that compare several rows, such as "all elements in a routine belong to the routine's apparatus" (constraints 7 and 8), cannot be written as a simple constraint. They are checked in the backend and can be moved to triggers later if needed. - **Search, filter and sort over the whole data set.** This must happen on the server and not only on what the client has loaded. PostgreSQL does it with indexes and `ORDER BY`, and it has full text search for the search field. Cursor pagination uses an index on the sort columns, so a page is fast whatever the size of the catalog. -- **Prisma.** Prisma supports PostgreSQL well. It gives us migrations, a way to seed the catalog data, and TypeScript types for the queries. +- **Prisma.** Prisma supports PostgreSQL well. It gives us migrations, a way to seed the catalog data, and TypeScript types for the queries. We chose it over writing SQL by hand because the tables are described in one readable schema file, the migrations are generated from that file, and a query that does not match the database is caught as a type error before the code runs. - **Runs directly on the VM.** PostgreSQL is a standard package for the virtual machine, so it does not need Docker. We also considered MongoDB and SQLite: @@ -69,11 +88,61 @@ We also considered MongoDB and SQLite: - **MongoDB:** the list of element ids in a routine fits a document. But the catalog is joined to routines, groups and variants all the time, and the constraints would have to live in the backend code. - **SQLite:** simple to set up, but it is one file with limited concurrent writes, which is a poor fit for a server with several users. -Open: how the ordered list of elements in a routine is stored. A join table with a position column keeps the foreign keys, while an array of ids is simpler. We decide this when the Prisma schema is written. +The ordered list of elements in a routine is stored as a join table with a position column, which keeps the foreign keys. How each list in the data model is stored is described in "Storage in PostgreSQL" in [docs/DATA_MODEL.md](docs/DATA_MODEL.md#storage-in-postgresql). + +### UI components: shadcn/ui, Tailwind CSS and Lucide + +The interface is built from [shadcn/ui](https://ui.shadcn.com) components, styled with [Tailwind CSS](https://tailwindcss.com) 4, with [Lucide](https://lucide.dev) icons. + +**Why** + +- **Accessibility.** The components are built on [Base UI](https://base-ui.com) primitives, which handle keyboard use, focus and ARIA. We do not write that ourselves, and universal design is a requirement of the course. +- **The code is ours.** shadcn/ui copies the components into `src/components/ui/`. We can read and change them, and only the components we use end up in the bundle. Lucide icons are imported one by one, and unused icons are removed from the bundle. +- **One look.** The same buttons, inputs and selects everywhere. + +**How to add a component** + +```bash +npx shadcn@latest add dialog +``` + +The component is added to `src/components/ui/`. Import it with the `@/` alias (`@/` means `src/`): + +```tsx +import { SearchIcon } from 'lucide-react' +import { Button } from '@/components/ui/button' + +; +``` + +An icon without text needs a label for screen readers, for example ``. + +**Tailwind CSS** + +- **Set up.** `tailwindcss` and `@tailwindcss/vite` are in `devDependencies`, because they are only needed to build. The Vite plugin is in `vite.config.ts`, and `src/index.css` starts with `@import 'tailwindcss';`. There is no `tailwind.config.js`: Tailwind 4 is configured in CSS, with [`@theme`](https://tailwindcss.com/docs/theme). +- **Small CSS.** Only the classes that the code uses end up in the built CSS. We checked it: a class that is used is in the built file, and a class that is not used is not. +- **Browsers.** Tailwind 4 is made for Chrome 111, Safari 16.4 and Firefox 128 or newer. Older browsers are not supported. +- **Our own CSS wins over utility classes.** Tailwind puts its styles in CSS layers, and CSS that is not in a layer beats CSS that is, whatever the specificity. A rule in `src/index.css` or `src/App.css` therefore overrides a utility class on the same element. If a class seems to have no effect, look for a plain CSS rule for the same property. New styles should be written as utility classes, or inside `@layer`. +- **Preflight.** Tailwind's base styles reset margins, heading sizes and buttons. Our own CSS is not in a layer, so it still wins where it sets the same property. + +**Choices we made** + +- **Style.** `base-nova` on Base UI, which is the default of the shadcn/ui CLI. Radix UI is the older alternative. +- **Class names.** The `cn` helper from the `cn` package, as the CLI sets it up. +- **Fonts.** Faustina (headings) and Inter (text), loaded from Google Fonts in `index.html` and set with the `--font-family-*` tokens in `src/styles/variables.css`. The CLI wanted to add the Geist font (about 60 kB of font files), which we removed. +- **Dark mode.** It follows `data-theme="dark"` on the `` element (or any other element: the dark tokens apply to it and everything inside it), as in `src/styles/variables.css`, and not a `.dark` class. +- **`@/` alias.** Set with `paths` only. TypeScript 6 deprecates `baseUrl`, which the shadcn/ui guide still tells us to add. +- **Lint.** The Fast Refresh rule is off for `src/components/ui/`, because the components also export helpers such as `buttonVariants`. + +**Still to do** + +- **Colours.** shadcn/ui uses its own tokens (`--background`, `--foreground`, `--primary` …) in `src/index.css`. Our design tokens in `src/styles/variables.css` are separate, and are not connected to them yet. The two tokens `--border` and `--accent` also exist in the Vite starter styles in `src/index.css`, and shadcn/ui's values win. The starter styles go when the page is built. ## Getting started -> **Not relevant yet.** The first delivery is a specification, so you do not need to run anything to review it. These commands only start the starter project. +> The frontend in `src/` is still the Vite starter project, so `npm run dev` only shows the starter page. To work on the backend, also follow "Database (local setup)" below. You need Node 24 (see `.nvmrc`). The project uses npm; do not use pnpm or yarn, so that everyone gets the versions in `package-lock.json`. @@ -83,7 +152,7 @@ npm ci npm run dev ``` -`npm run dev` starts the starter project at http://localhost:5173. +`npm run dev` starts the starter project at http://localhost:5173/project2/. Other commands: @@ -95,6 +164,323 @@ Other commands: | `npm run format` | Formats all files with Prettier | | `npm run test` | Runs the tests with Vitest | +`npm ci` installs both the frontend and the backend, because the backend is an npm workspace. The database commands are described in the next section. + +### Calling the API from the frontend + +The frontend uses [Apollo Client](https://www.apollographql.com/docs/react) (`src/lib/apollo/`), and `src/main.tsx` wraps the app in `ApolloProvider`. Start the backend (`npm run backend`, port 3001) and the frontend (`npm run dev`). In development Vite sends `/graphql` and `/images` to the backend, so the browser sees one address and there is no CORS to set up. Set `VITE_API_URL` (for example in `.env.local`) to call another address; the default is `/graphql`. The app is built for the virtual machine with `VITE_API_URL=/project2/graphql npm run build`, because it is served under `/project2/` and Apache must send that address to the backend (see "Connect the frontend to the backend" in [docs/VM_SETUP.md](docs/VM_SETUP.md)). + +**Bundle size.** `npm run build` gives one JavaScript file of 665 kB (177 kB compressed), and Vite warns about files over 500 kB. About 160 kB of it is Apollo Client. We leave it for now: the app has two pages, so route-level splitting (`React.lazy`) would give little, and raising the warning limit only hides the number. Check it again when the pages grow. The options then are `React.lazy` for the larger pages and `rollup-plugin-visualizer` to see what is big. + +```tsx +import { gql } from '@apollo/client' +import { useQuery } from '@apollo/client/react' + +const SEARCH = gql` + query Search($search: String, $first: Int, $after: String) { + searchCatalog(search: $search, first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + description + } + } + } + } +` + +const { data, fetchMore } = useQuery(SEARCH, { + variables: { search, first: 20 }, +}) +// "Load more": the next page is added to the list in the cache. +fetchMore({ variables: { after: data.searchCatalog.pageInfo.endCursor } }) +``` + +- **Load more with `fetchMore`.** The cache keeps all the pages of one search in one list (the key is the search, filter and sort, not `first` or `after`). A query with `after` that is sent with `useQuery` or `client.query` is answered from the cache. `fetchMore` always asks the server, so use it for the next page. +- **`totalCount` only with the first page** (`totalCount @include(if: $firstPage)`): the server counts all matches only when the query asks for it, and the cache keeps the count when later pages do not have it. +- **The login.** `login(googleToken)`, `logout()` and `restoreSession()` are in `src/lib/apollo/client.ts`. The access token is kept in memory, the refresh token in `localStorage`. A request that answers `UNAUTHENTICATED` because the access token has expired refreshes the session once and is repeated. Refreshing is done from one place at a time (`navigator.locks`), as the server requires. How it works and why is in "Identifying the user" in [docs/API.md](docs/API.md). The Google login button itself is not built yet. +- **Errors.** `describeError(error)` (`src/lib/apollo/errors.ts`) turns an error from the API or the network into a message for the user, and says whether trying again can help. + +## Database (local setup) + +The backend uses PostgreSQL 18. Everyone installs it directly on their own machine, without Docker, and everyone uses version 18 so that migrations behave the same for all of us. + +### 1. Install PostgreSQL 18 + +**Mac**, with one of these: + +- **Postgres.app** (recommended): download it from [postgresapp.com](https://postgresapp.com/), move it to Applications and open it. Choose PostgreSQL 18 if you are asked, and click _Initialize_. To use `psql` in the terminal, run this once and open a new terminal: + + ```bash + sudo mkdir -p /etc/paths.d && echo /Applications/Postgres.app/Contents/Versions/latest/bin | sudo tee /etc/paths.d/postgresapp + ``` + +- **Homebrew:** + + ```bash + brew install postgresql@18 + brew services start postgresql@18 + ``` + +**Windows:** download the installer from EDB through [postgresql.org/download/windows](https://www.postgresql.org/download/windows/) and choose version 18. Keep the default port 5432, and write down the password you choose for the `postgres` user. `psql` is available as _SQL Shell (psql)_ in the Start menu. + +### 2. Create a user and a database + +Open `psql` as the administrator: + +- Mac: `psql postgres` +- Windows: _SQL Shell (psql)_, log in as `postgres` + +Then create a user and a database, both called `cop`. Choose your own password: + +```sql +CREATE USER cop WITH PASSWORD 'choose-a-password' CREATEDB; +CREATE DATABASE cop OWNER cop; +``` + +The user needs `CREATEDB` because `prisma migrate dev` creates a temporary _shadow database_ to check the migrations, and deletes it again afterwards. + +### 3. Connect the backend to the database + +Copy the example file and fill in your password: + +```bash +cp backend/.env.example backend/.env +``` + +On Windows: `copy backend\.env.example backend\.env`. + +Open `backend/.env` and replace `your-password` in `DATABASE_URL`. The format is `postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public`. The file is ignored by Git, so your password is never committed. + +### 4. Install and run + +From the repository root: + +```bash +npm ci +npm run generate +npm run db:migrate +npm run db:seed +``` + +`npm run db:migrate` creates the tables in your database by applying the migrations in `backend/prisma/migrations/`. Run it again whenever someone has added a new migration. + +| Command | What it does | +| ----------------------------- | ----------------------------------------------------------------------------------------------- | +| `npm run generate` | Runs both generators below. `typecheck` and `build` in `backend` run it first | +| `npm run db:generate` | Generates the Prisma client from `backend/prisma/schema.prisma` | +| `npm run codegen` | Generates the TypeScript types for the resolvers from `backend/src/schema.graphql` | +| `npm run db:migrate` | Creates a migration from changes in the schema and applies it to your database | +| `npm run db:deploy` | Applies existing migrations without creating new ones. Used on the server | +| `npm run db:seed` | Loads the catalog from `data/` into the database. See "Seeding" below | +| `npm run db:synthetic -- add` | Adds 100 000 fake items for scalability tests. `remove` deletes them. See "Synthetic test data" | +| `npm run db:studio` | Opens Prisma Studio in the browser, to look at the data in the database | + +### 5. Check the connection + +To check that the backend can reach your database: + +1. Make sure PostgreSQL is running. In Postgres.app the server should show as running. With Homebrew, run `brew services start postgresql@18`. On Windows the service starts automatically. +2. Open a terminal in the repository root and go to the backend folder: + + ```bash + cd backend + ``` + +3. Run: + + ```bash + npx prisma migrate status + ``` + +4. Look for these lines in the output. The first one means that the connection works, and the last one that your tables are up to date: + + ``` + Datasource "db": PostgreSQL database "cop", schema "public" at "localhost:5432" + ... + Database schema is up to date! + ``` + +5. Go back to the repository root: + + ```bash + cd .. + ``` + +If the output says "Following migration have not yet been applied", the connection works but your tables are missing or out of date. Go back to the repository root and run `npm run db:migrate`. If the connection fails, Prisma prints an error such as "Can't reach database server" or "Authentication failed" instead. + +### 6. Seeding + +The seed script loads the catalog (apparatus, element groups, variant groups, elements and vaults) from the JSON files in `data/` into your database, and rebuilds the search tables. Run it from the repository root: + +```bash +npm run db:seed +``` + +Run it again: + +- **after migrations**, when you have run `npm run db:migrate` or `npm run db:deploy` +- **whenever `data/` changes**, for example after a new run of the extraction script + +It is safe to run as often as you like: + +- **Everything happens in one transaction.** If something fails, nothing is changed. +- **A second run with the same data changes nothing.** It prints a table of what was inserted, updated, marked as removed and deleted. +- **It checks the data first.** If something in `data/` breaks a rule in `docs/DATA_MODEL.md`, for example a variant group with different codes or a missing image, it stops with a message that names the row and the rule, and changes nothing. +- **It never touches user data.** Coaches, gymnasts, routines and their lists are left alone. +- **Removed elements are kept while they are in use.** An element or vault that is no longer in `data/` is marked as removed. It is deleted only if no routine or gymnast list uses it. + +The code is in `backend/src/seed/`. The rules for search words, including the written-out abbreviations such as `handstand` for `hstd`, are in `backend/src/catalog/words.ts`. The search uses the same rules. + +### 7. Look at the data + +To see what is in your database, open Prisma Studio from the repository root: + +```bash +npm run db:studio +``` + +The terminal prints the address, for example "Prisma Studio is running at: http://localhost:51212". The port can change between runs. Open the address in your browser if it does not open by itself. The page has a list of all the tables. Click a table to see its rows, filter and sort them, and follow the relations, for example from an element to its variant group or from a routine to its items. Stop it with Ctrl+C in the terminal. + +Prisma Studio uses the connection in `backend/.env`, so there is nothing more to set up. You can also edit and delete rows there, so be careful: changes to the catalog are overwritten the next time you run `npm run db:seed`, and changes to user data are not undone. + +To write your own SQL queries, connect with `psql -h localhost -U cop -d cop` (on Windows, use _SQL Shell (psql)_) and enter the password from `backend/.env`. The tables have snake_case names in the database, such as `catalog_entries` and `element_group_id`. `\dt` lists the tables, `\d elements` shows the columns of a table, and `\q` quits. + +## GraphQL server + +The server is in `backend/src/server/` (Apollo Server 5 on Express 5). It answers the catalog queries and the search through GraphQL. Login and the user data (gymnasts, routines) are built (see "Login" below). How the server works is in [The server in docs/API.md](docs/API.md#the-server). + +**Start it.** The database must be migrated and seeded first (see "Database (local setup)" above): + +```bash +npm run backend # restarts when a file changes +npm run start -w backend # starts once +``` + +It listens on port 3001. Open http://localhost:3001/graphql in a browser to try queries in Apollo Sandbox, for example: + +```graphql +{ + searchCatalog(search: "hecht", first: 3) { + totalCount + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + description + imageUrl + ... on Element { + value + variants { + id + description + } + } + } + } + } +} +``` + +Other addresses: `/images//.webp` serves the drawings in `data/images/` (on the virtual machine Apache does this), and `/health` answers `{"status":"ok"}`. + +**Settings** (all optional, in `backend/.env`, see `backend/.env.example`): + +| Variable | Default | Used for | +| ---------------- | ------------------------------ | ------------------------------------------------------------------- | +| `PORT` | `3001` | The port of the server | +| `IMAGE_BASE_URL` | `http://localhost:3001/images` | The address the drawings are served from (`imageUrl` in the API) | +| `CORS_ORIGIN` | `http://localhost:5173` | The addresses that may call the API from a browser, comma separated | +| `SESSION_SECRET` | none | Signs the access tokens, at least 32 random characters | + +The tests of the server are in `backend/src/server/` and run with `npm test`, like the search tests. They need the seeded database. The session tests write a test coach and sessions (the Google id starts with `test-70-`) and delete them again. + +**The session.** A logged-in coach has an access token (15 minutes) and a refresh token (14 days). How it works and why is in "Identifying the user" in [docs/API.md](docs/API.md#identifying-the-user). To turn the tokens on, make a secret and put it in `backend/.env` as `SESSION_SECRET`: + +```bash +node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))" +``` + +The server does not start if the secret is shorter than 32 characters. Without it the catalog works and `me` is always `null`. The login with Google (`login`) is a separate pull request, because Sign in with Google needs https and the course server has only http; until it is merged, `login` answers `NOT_IMPLEMENTED`. Send `Authorization: Bearer ` with the queries, or call `refreshSession` with the refresh token. + +## Catalog search + +The catalog search is in `backend/src/catalog/search.ts`. It finds elements and vaults by the start of their words, with filters, sorting and cursor pagination, using Prisma only. How it works and why is described in "Search without raw SQL" in [docs/API.md](docs/API.md#search-without-raw-sql). + +The tests in `backend/src/catalog/` run with `npm test`. The search tests use your local database and need the seeded catalog, so run `npm run db:seed` first. They only read from the database. + +**Without `DATABASE_URL` in `backend/.env`, the database tests are skipped.** Vitest then reports them as skipped and the run still passes, so a passing run without a database does not mean the search was tested. Check that the output says 0 skipped. + +### Synthetic test data and measurements + +The real catalog has about 560 items, which is too small to see whether the search scales. `npm run db:synthetic` adds fake items for that: + +```bash +npm run db:synthetic -- add # 100 000 items (use --count for another number) +npm run db:synthetic -- remove # deletes all synthetic data again +``` + +The items copy descriptions from the real catalog, so the words are spread like in real data. They are spread over the four apparatus (vault items on VT, elements on the others), about 5 % have a name, and the elements have a difficulty value from 0.1 to 1.0. Every id starts with `SYN-` and every item is in a synthetic group of its own (`SYN-UB-1001` and so on), so the real catalog is never mixed up with them. The same input gives the same rows. `npm run db:seed` never adds synthetic data, and it leaves the `SYN-` rows alone. `add` stops if synthetic data already exists, and runs in one transaction, so it either adds everything or nothing. `remove` deletes only rows with a `SYN-` id. Use a local database, never the one on the virtual machine. + +**Measured on 2026-10-17** with 100 562 catalog entries and 1 415 215 words (562 entries are real), PostgreSQL 18 in a Docker container on a laptop, after `ANALYZE`. The time is the median of 7 calls to `searchCatalog` from Node, including Prisma, with 20 items per page and without `totalCount`. "Without indexes" means that all indexes on `catalog_entries` and `catalog_words` except the primary keys were dropped (and created again afterwards). + +| Query | With indexes (ms) | Without indexes (ms) | +| --------------------------------- | ----------------: | -------------------: | +| browse, sort CODE | 1.6 | 20.8 | +| browse, sort NAME | 1.2 | 20.2 | +| browse, sort DIFFICULTY (UB) | 1.1 | 12.6 | +| filter apparatus UB | 1.4 | 12.5 | +| filter difficulty 0.1/0.2 (BB) | 1.1 | 12.6 | +| search "a" (one letter) | 1.6 | 48.2 | +| search "t" (one letter) | 1.4 | 47.7 | +| search "forward" (common word) | 1.3 | 48.0 | +| search "kip" + UB | 10.1 | 35.1 | +| search "forward turn" (two words) | 1.8 | 52.1 | +| search "syn5003" (rare word) | 1.1 | 29.6 | +| search "1001.5" (code) | 4.6 | 48.7 | + +Pages far into a result (100 items per page; the cursor for page N comes from following the pages before it): + +| Query | Page | With indexes (ms) | Without indexes (ms) | +| ----------------- | ---: | ----------------: | -------------------: | +| browse, sort CODE | 1 | 1.5 | 18.9 | +| browse, sort CODE | 101 | 2.2 | 39.2 | +| browse, sort CODE | 501 | 1.9 | 34.5 | +| search "a" | 1 | 1.8 | 49.8 | +| search "a" | 101 | 4.0 | 75.7 | +| search "a" | 501 | 1.5 | 31.1 | + +`EXPLAIN (ANALYZE, BUFFERS)` on the queries Prisma sends shows that the indexes are used: + +- **Page 501 of the list sorted by code:** an index scan on `catalog_entries_code_sort_id_idx` reads 99 rows and takes 0.11 ms. A page far into the list costs the same as the first page, because the cursor is a condition on the index and nothing is skipped (no `OFFSET`). +- **A one-letter search ("a"):** an index scan on `catalog_entries_code_sort_id_idx` walks the entries in order and checks each one against `catalog_words` through its primary key (63 entries checked for 20 results). It stops as soon as the page is full, so the search does not read all 1.4 million words. +- **Without the indexes** the same queries read the whole table: 12–52 ms for a page, and 30–76 ms for a page far into a result, and the time grows with the number of items. + +The tables take 63 MB (`catalog_entries`) and 222 MB (`catalog_words`) with their indexes, 185 MB of it in indexes. That is the price of the speed, at this size. Adding the synthetic data takes about 70 seconds for 100 000 items. + +**Reading the numbers.** These are one machine and one run, so use them to compare, not as promises. The slowest case with indexes is a search for a common word together with a filter ("kip" on uneven bars, 10 ms), where PostgreSQL has to check many words. A one-letter search that is only a trailing "s" ("s") finds nothing on purpose, because the trailing "s" is removed from a search word. + +Run `ANALYZE` after adding or removing many rows, or PostgreSQL plans from old statistics (`npm run db:synthetic` does not run it). To repeat the measurement, call `searchCatalog` from a small script and use `EXPLAIN (ANALYZE, BUFFERS)` on the queries Prisma logs (`log: [{ emit: 'event', level: 'query' }]`). The application code never uses raw SQL, but `EXPLAIN` is typed by hand in `psql`. + +## Project structure + +| Folder | Contents | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `backend/` | The backend package: TypeScript, Prisma, the seed script, the catalog search, the GraphQL server and the GraphQL schema | +| `data/` | The catalog data extracted from the Code of Points: JSON files and one image per element | +| `docs/` | The specification: user interface plans, data model, example data and AI usage. [docs/EXTRACTION.md](docs/EXTRACTION.md) explains how `data/` was made | +| `public/` | Static files served as they are, such as the favicon | +| `scripts/extract-cop/` | The Python script that extracts `data/` from the Code of Points PDF | +| `src/` | The frontend code. For now the Vite starter project and one example test | + ## Working in this repository `main` is protected — you cannot push to it directly. Work on a branch and diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..1075243 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,22 @@ +# Copy this file to .env and fill in your own values. .env is not committed. +# +# Runtime server connection (wag_app - restricted CRUD permissions) +DATABASE_URL="postgresql://wag_app:YOUR_APP_PASSWORD@localhost:5432/wag_catalog" + +# Migration connection string (wag_migrator - schema owner for migrations & setup) +MIGRATION_DATABASE_URL="postgresql://wag_migrator:YOUR_MIGRATOR_PASSWORD@localhost:5432/wag_catalog" + +# The GraphQL server (npm run dev -w backend). All three are optional. +# The port of the server. The backend should be on 3001 on the virtual machine. +# PORT=3001 +# The address the drawings are served from, without a final slash. In development +# the server serves them itself. On the virtual machine it is the Apache address, +# for example http://it2810-XX.idi.ntnu.no/project2/images +# IMAGE_BASE_URL=http://localhost:3001/images +# The addresses that may call the API from a browser, separated by commas. +# CORS_ORIGIN=http://localhost:5173 + +# Signs the access tokens (optional). At least 32 random characters. Without it there +# are no tokens and `me` is always null. +# Make one with: node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))" +# SESSION_SECRET= diff --git a/backend/codegen.ts b/backend/codegen.ts new file mode 100644 index 0000000..8f320ed --- /dev/null +++ b/backend/codegen.ts @@ -0,0 +1,39 @@ +// GraphQL Code Generator: makes TypeScript types from src/schema.graphql, so a +// resolver that does not match the schema fails `tsc`. Run it with +// `npm run codegen`. The result (src/generated/graphql.ts) is not committed, +// like the Prisma client in the same folder. +import type { CodegenConfig } from '@graphql-codegen/cli' + +const config: CodegenConfig = { + schema: 'src/schema.graphql', + generates: { + 'src/generated/graphql.ts': { + plugins: ['typescript', 'typescript-resolvers'], + config: { + // What every resolver gets as its third argument. + contextType: '../server/context.js#Context', + useTypeImports: true, + enumsAsTypes: true, + // GraphQL types that resolvers receive as database rows. The fields of + // the row do not have to match the schema: a field resolver converts + // them (for example Decimal to number). + mappers: { + Apparatus: '../generated/prisma/client.js#Apparatus as ApparatusRow', + ElementGroup: + '../generated/prisma/client.js#ElementGroup as ElementGroupRow', + Coach: '../generated/prisma/client.js#Coach as CoachRow', + Gymnast: '../generated/prisma/client.js#Gymnast as GymnastRow', + Routine: '../generated/prisma/client.js#Routine as RoutineRow', + // An element or vault row with a __typename, see server/items.ts. + CatalogItem: '../server/items.js#Item', + CatalogConnection: '../server/items.js#ConnectionRow', + Element: '../server/items.js#ElementItem', + Vault: '../server/items.js#VaultItem', + }, + scalars: { DateTime: 'Date' }, + }, + }, + }, +} + +export default config diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..f445048 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,49 @@ +{ + "name": "backend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "tsx src/index.ts", + "prebuild": "npm run generate", + "build": "tsc", + "setup": "DATABASE_URL=${MIGRATION_DATABASE_URL:-$DATABASE_URL} prisma migrate deploy && DATABASE_URL=${MIGRATION_DATABASE_URL:-$DATABASE_URL} npm run prisma:seed", + "pretypecheck": "npm run generate", + "typecheck": "tsc --noEmit", + "generate": "npm run prisma:generate && npm run codegen", + "prisma:generate": "prisma generate", + "codegen": "graphql-codegen --config codegen.ts", + "prisma:migrate": "prisma migrate dev", + "prisma:deploy": "prisma migrate deploy", + "prisma:seed": "prisma db seed", + "synthetic": "tsx src/seed/synthetic.ts", + "prisma:studio": "prisma studio" + }, + "engines": { + "node": ">=24" + }, + "devDependencies": { + "@graphql-codegen/cli": "^7.4.6", + "@graphql-codegen/typescript": "^6.1.2", + "@graphql-codegen/typescript-resolvers": "^6.1.1", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/node": "^24.19.2", + "prisma": "^7.10.1", + "typescript": "~6.0.2" + }, + "dependencies": { + "@apollo/server": "^5.5.1", + "@as-integrations/express5": "^1.1.2", + "@prisma/adapter-pg": "^7.10.1", + "@prisma/client": "^7.10.1", + "cors": "^2.8.6", + "dataloader": "^2.2.3", + "dotenv": "^18.0.8", + "express": "^5.3.0", + "graphql": "^16.14.2", + "jose": "^6.2.12", + "tsx": "^4.23.15" + } +} diff --git a/backend/prisma.config.ts b/backend/prisma.config.ts new file mode 100644 index 0000000..ff3dce0 --- /dev/null +++ b/backend/prisma.config.ts @@ -0,0 +1,16 @@ +// Prisma configuration. Prisma 7 reads the database URL from here, not from +// schema.prisma. DATABASE_URL is loaded from backend/.env. +import 'dotenv/config' +import { defineConfig } from 'prisma/config' + +export default defineConfig({ + schema: 'prisma/schema.prisma', + migrations: { + path: 'prisma/migrations', + // `prisma db seed` (npm run db:seed) runs the seed script. + seed: 'tsx src/seed/seed.ts', + }, + datasource: { + url: process.env['DATABASE_URL'], + }, +}) diff --git a/backend/prisma/migrations/20261015072132_create_data_model/migration.sql b/backend/prisma/migrations/20261015072132_create_data_model/migration.sql new file mode 100644 index 0000000..8f7430d --- /dev/null +++ b/backend/prisma/migrations/20261015072132_create_data_model/migration.sql @@ -0,0 +1,272 @@ +-- CreateEnum +CREATE TYPE "catalog_entry_type" AS ENUM ('ELEMENT', 'VAULT'); + +-- CreateTable +CREATE TABLE "apparatus" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "position" INTEGER NOT NULL, + + CONSTRAINT "apparatus_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "element_groups" ( + "id" TEXT NOT NULL, + "apparatus_id" TEXT NOT NULL, + "number" INTEGER NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "element_groups_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "variant_groups" ( + "id" TEXT NOT NULL, + + CONSTRAINT "variant_groups_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "elements" ( + "id" TEXT NOT NULL, + "element_group_id" TEXT NOT NULL, + "variant_group_id" TEXT, + "code" TEXT NOT NULL, + "value" DECIMAL(3,1) NOT NULL, + "description" TEXT NOT NULL, + "name" TEXT, + "image" TEXT NOT NULL, + "removed_at" TIMESTAMPTZ(3), + + CONSTRAINT "elements_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "vaults" ( + "id" TEXT NOT NULL, + "element_group_id" TEXT NOT NULL, + "variant_group_id" TEXT, + "code" TEXT NOT NULL, + "d_value" DECIMAL(3,1) NOT NULL, + "description" TEXT NOT NULL, + "name" TEXT, + "image" TEXT NOT NULL, + "removed_at" TIMESTAMPTZ(3), + + CONSTRAINT "vaults_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "catalog_entries" ( + "id" TEXT NOT NULL, + "type" "catalog_entry_type" NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT, + "description" TEXT NOT NULL, + "difficulty" DECIMAL(3,1) NOT NULL, + "apparatus_id" TEXT NOT NULL, + "element_group_id" TEXT NOT NULL, + "has_name" BOOLEAN NOT NULL, + "name_sort" TEXT NOT NULL, + "code_sort" TEXT NOT NULL, + + CONSTRAINT "catalog_entries_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "catalog_words" ( + "entry_id" TEXT NOT NULL, + "word" TEXT NOT NULL, + + CONSTRAINT "catalog_words_pkey" PRIMARY KEY ("entry_id","word") +); + +-- CreateTable +CREATE TABLE "coaches" ( + "id" TEXT NOT NULL, + "google_id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "coaches_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "gymnasts" ( + "id" TEXT NOT NULL, + "coach_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "gymnasts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "gymnast_elements" ( + "id" SERIAL NOT NULL, + "gymnast_id" TEXT NOT NULL, + "element_id" TEXT, + "vault_id" TEXT, + + CONSTRAINT "gymnast_elements_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "routines" ( + "id" TEXT NOT NULL, + "gymnast_id" TEXT NOT NULL, + "apparatus_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "routines_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "routine_elements" ( + "routine_id" TEXT NOT NULL, + "position" INTEGER NOT NULL, + "element_id" TEXT, + "vault_id" TEXT, + + CONSTRAINT "routine_elements_pkey" PRIMARY KEY ("routine_id","position") +); + +-- CreateIndex +CREATE UNIQUE INDEX "apparatus_position_key" ON "apparatus"("position"); + +-- CreateIndex +CREATE UNIQUE INDEX "element_groups_apparatus_id_number_key" ON "element_groups"("apparatus_id", "number"); + +-- CreateIndex +CREATE INDEX "elements_element_group_id_idx" ON "elements"("element_group_id"); + +-- CreateIndex +CREATE INDEX "elements_variant_group_id_idx" ON "elements"("variant_group_id"); + +-- CreateIndex +CREATE INDEX "vaults_element_group_id_idx" ON "vaults"("element_group_id"); + +-- CreateIndex +CREATE INDEX "vaults_variant_group_id_idx" ON "vaults"("variant_group_id"); + +-- CreateIndex +CREATE INDEX "catalog_entries_code_sort_id_idx" ON "catalog_entries"("code_sort", "id"); + +-- CreateIndex +CREATE INDEX "catalog_entries_difficulty_id_idx" ON "catalog_entries"("difficulty", "id"); + +-- CreateIndex +CREATE INDEX "catalog_entries_has_name_name_sort_id_idx" ON "catalog_entries"("has_name", "name_sort", "id"); + +-- CreateIndex +CREATE INDEX "catalog_entries_apparatus_id_idx" ON "catalog_entries"("apparatus_id"); + +-- CreateIndex +CREATE INDEX "catalog_entries_element_group_id_idx" ON "catalog_entries"("element_group_id"); + +-- CreateIndex +CREATE INDEX "catalog_words_word_idx" ON "catalog_words"("word"); + +-- CreateIndex +CREATE UNIQUE INDEX "coaches_google_id_key" ON "coaches"("google_id"); + +-- CreateIndex +CREATE INDEX "gymnasts_coach_id_idx" ON "gymnasts"("coach_id"); + +-- CreateIndex +CREATE INDEX "gymnast_elements_element_id_idx" ON "gymnast_elements"("element_id"); + +-- CreateIndex +CREATE INDEX "gymnast_elements_vault_id_idx" ON "gymnast_elements"("vault_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "gymnast_elements_gymnast_id_element_id_key" ON "gymnast_elements"("gymnast_id", "element_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "gymnast_elements_gymnast_id_vault_id_key" ON "gymnast_elements"("gymnast_id", "vault_id"); + +-- CreateIndex +CREATE INDEX "routines_apparatus_id_idx" ON "routines"("apparatus_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "routines_gymnast_id_apparatus_id_name_key" ON "routines"("gymnast_id", "apparatus_id", "name"); + +-- CreateIndex +CREATE INDEX "routine_elements_element_id_idx" ON "routine_elements"("element_id"); + +-- CreateIndex +CREATE INDEX "routine_elements_vault_id_idx" ON "routine_elements"("vault_id"); + +-- AddForeignKey +ALTER TABLE "element_groups" ADD CONSTRAINT "element_groups_apparatus_id_fkey" FOREIGN KEY ("apparatus_id") REFERENCES "apparatus"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "elements" ADD CONSTRAINT "elements_element_group_id_fkey" FOREIGN KEY ("element_group_id") REFERENCES "element_groups"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "elements" ADD CONSTRAINT "elements_variant_group_id_fkey" FOREIGN KEY ("variant_group_id") REFERENCES "variant_groups"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "vaults" ADD CONSTRAINT "vaults_element_group_id_fkey" FOREIGN KEY ("element_group_id") REFERENCES "element_groups"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "vaults" ADD CONSTRAINT "vaults_variant_group_id_fkey" FOREIGN KEY ("variant_group_id") REFERENCES "variant_groups"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "catalog_words" ADD CONSTRAINT "catalog_words_entry_id_fkey" FOREIGN KEY ("entry_id") REFERENCES "catalog_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "gymnasts" ADD CONSTRAINT "gymnasts_coach_id_fkey" FOREIGN KEY ("coach_id") REFERENCES "coaches"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "gymnast_elements" ADD CONSTRAINT "gymnast_elements_gymnast_id_fkey" FOREIGN KEY ("gymnast_id") REFERENCES "gymnasts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "gymnast_elements" ADD CONSTRAINT "gymnast_elements_element_id_fkey" FOREIGN KEY ("element_id") REFERENCES "elements"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "gymnast_elements" ADD CONSTRAINT "gymnast_elements_vault_id_fkey" FOREIGN KEY ("vault_id") REFERENCES "vaults"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "routines" ADD CONSTRAINT "routines_gymnast_id_fkey" FOREIGN KEY ("gymnast_id") REFERENCES "gymnasts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "routines" ADD CONSTRAINT "routines_apparatus_id_fkey" FOREIGN KEY ("apparatus_id") REFERENCES "apparatus"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "routine_elements" ADD CONSTRAINT "routine_elements_routine_id_fkey" FOREIGN KEY ("routine_id") REFERENCES "routines"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "routine_elements" ADD CONSTRAINT "routine_elements_element_id_fkey" FOREIGN KEY ("element_id") REFERENCES "elements"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "routine_elements" ADD CONSTRAINT "routine_elements_vault_id_fkey" FOREIGN KEY ("vault_id") REFERENCES "vaults"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- --------------------------------------------------------------------------- +-- Added by hand: Prisma cannot express these in schema.prisma. +-- --------------------------------------------------------------------------- + +-- Each row points to exactly one catalog item: an element or a vault, never +-- both and never neither. +ALTER TABLE "gymnast_elements" + ADD CONSTRAINT "gymnast_elements_one_item_check" + CHECK (num_nonnulls("element_id", "vault_id") = 1); + +ALTER TABLE "routine_elements" + ADD CONSTRAINT "routine_elements_one_item_check" + CHECK (num_nonnulls("element_id", "vault_id") = 1); + +-- DO NOT REMOVE. The catalog search finds words with a prefix match +-- (Prisma startsWith, which becomes LIKE 'term%'). Our databases use the +-- en-US collation, and with it an ordinary B-tree index cannot serve a prefix +-- match, so every search would read the whole table. With the C collation on +-- this column only, the index on "word" is used. All other text keeps en-US +-- sorting, and no special database settings are needed on any machine. +-- (text_pattern_ops on the index was tried first, but Prisma cannot read it +-- back and wanted to recreate the index on every migrate dev.) +-- Changing the collation rebuilds catalog_words_word_idx automatically. +-- See "What the index needs" in docs/API.md. +ALTER TABLE "catalog_words" ALTER COLUMN "word" TYPE TEXT COLLATE "C"; diff --git a/backend/prisma/migrations/20261015124317_add_sort_indexes_and_updated_at_default/migration.sql b/backend/prisma/migrations/20261015124317_add_sort_indexes_and_updated_at_default/migration.sql new file mode 100644 index 0000000..941d45a --- /dev/null +++ b/backend/prisma/migrations/20261015124317_add_sort_indexes_and_updated_at_default/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "routines" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- CreateIndex +CREATE INDEX "catalog_entries_has_name_desc_name_sort_id_idx" ON "catalog_entries"("has_name" DESC, "name_sort", "id"); + +-- CreateIndex +CREATE INDEX "catalog_entries_apparatus_id_difficulty_id_idx" ON "catalog_entries"("apparatus_id", "difficulty", "id"); + +-- CreateIndex +CREATE INDEX "catalog_entries_apparatus_id_code_sort_id_idx" ON "catalog_entries"("apparatus_id", "code_sort", "id"); diff --git a/backend/prisma/migrations/20261015131631_add_value_and_position_checks/migration.sql b/backend/prisma/migrations/20261015131631_add_value_and_position_checks/migration.sql new file mode 100644 index 0000000..2a590ee --- /dev/null +++ b/backend/prisma/migrations/20261015131631_add_value_and_position_checks/migration.sql @@ -0,0 +1,18 @@ +-- Added by hand: Prisma cannot express CHECK constraints in schema.prisma. + +-- A difficulty value is 0.1-1.0 (A-J). The extraction script and the seed +-- check this too, but the database refuses anything outside the range. +ALTER TABLE "elements" + ADD CONSTRAINT "elements_value_range_check" + CHECK ("value" BETWEEN 0.1 AND 1.0); + +-- A vault D-value is always positive. +ALTER TABLE "vaults" + ADD CONSTRAINT "vaults_d_value_positive_check" + CHECK ("d_value" > 0); + +-- Positions in a routine start at 1. The backend writes them as 1, 2, 3 ... +-- without gaps when it replaces the items (setRoutineItems). +ALTER TABLE "routine_elements" + ADD CONSTRAINT "routine_elements_position_check" + CHECK ("position" >= 1); diff --git a/backend/prisma/migrations/20261017080228_add_sessions/migration.sql b/backend/prisma/migrations/20261017080228_add_sessions/migration.sql new file mode 100644 index 0000000..8bdaa32 --- /dev/null +++ b/backend/prisma/migrations/20261017080228_add_sessions/migration.sql @@ -0,0 +1,27 @@ +-- CreateTable +CREATE TABLE "sessions" ( + "id" TEXT NOT NULL, + "coach_id" TEXT NOT NULL, + "family_id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "revoked_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_token_hash_key" ON "sessions"("token_hash"); + +-- CreateIndex +CREATE INDEX "sessions_coach_id_idx" ON "sessions"("coach_id"); + +-- CreateIndex +CREATE INDEX "sessions_family_id_idx" ON "sessions"("family_id"); + +-- CreateIndex +CREATE INDEX "sessions_expires_at_idx" ON "sessions"("expires_at"); + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_coach_id_fkey" FOREIGN KEY ("coach_id") REFERENCES "coaches"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..c424bf6 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,293 @@ +// Prisma schema for the backend. It follows docs/DATA_MODEL.md, where the +// section "Storage in PostgreSQL" explains the choices. +// Docs: https://pris.ly/d/prisma-schema +// +// Names are camelCase here and snake_case in the database (@map, @@map). +// +// Not expressed here, but added in SQL in the migration: CHECK constraints on +// gymnast_elements and routine_elements (exactly one of element_id and +// vault_id is set). + +generator client { + provider = "prisma-client" + output = "../src/generated/prisma" +} + +datasource db { + provider = "postgresql" +} + +// --------------------------------------------------------------------------- +// Catalog. Read-only in the app, filled by the seed script. The ids are the +// readable ids from data/ ("UB", "UB-2", "UB-2.305", "UB-2.305-a"). +// --------------------------------------------------------------------------- + +model Apparatus { + id String @id + name String + /// Sort order: VT 1, UB 2, BB 3, FX 4. + position Int @unique + + elementGroups ElementGroup[] + routines Routine[] + + @@map("apparatus") +} + +model ElementGroup { + id String @id + apparatusId String @map("apparatus_id") + number Int + name String + + apparatus Apparatus @relation(fields: [apparatusId], references: [id], onDelete: Restrict) + elements Element[] + vaults Vault[] + + @@unique([apparatusId, number]) + @@map("element_groups") +} + +/// Links the variants of one code. The elements and vaults point to it. +model VariantGroup { + id String @id + + elements Element[] + vaults Vault[] + + @@map("variant_groups") +} + +/// An element on uneven bars, balance beam or floor exercise. +model Element { + id String @id + elementGroupId String @map("element_group_id") + variantGroupId String? @map("variant_group_id") + code String + /// Difficulty value 0.1-1.0. Decimal, so sums have no rounding errors. + value Decimal @db.Decimal(3, 1) + description String + name String? + image String + /// Set when the element is no longer in the Code of Points (soft delete). + removedAt DateTime? @map("removed_at") @db.Timestamptz(3) + + elementGroup ElementGroup @relation(fields: [elementGroupId], references: [id], onDelete: Restrict) + variantGroup VariantGroup? @relation(fields: [variantGroupId], references: [id], onDelete: Restrict) + gymnastElements GymnastElement[] + routineElements RoutineElement[] + + @@index([elementGroupId]) + @@index([variantGroupId]) + @@map("elements") +} + +/// A vault. Its own table, because it has a D-value instead of a difficulty value. +model Vault { + id String @id + elementGroupId String @map("element_group_id") + variantGroupId String? @map("variant_group_id") + code String + /// Fixed difficulty score, e.g. 2.6. + dValue Decimal @map("d_value") @db.Decimal(3, 1) + description String + name String? + image String + removedAt DateTime? @map("removed_at") @db.Timestamptz(3) + + elementGroup ElementGroup @relation(fields: [elementGroupId], references: [id], onDelete: Restrict) + variantGroup VariantGroup? @relation(fields: [variantGroupId], references: [id], onDelete: Restrict) + gymnastElements GymnastElement[] + routineElements RoutineElement[] + + @@index([elementGroupId]) + @@index([variantGroupId]) + @@map("vaults") +} + +// --------------------------------------------------------------------------- +// Search. Derived from the catalog by the seed script and read-only in the +// app. See "Search without raw SQL" in docs/API.md. +// --------------------------------------------------------------------------- + +enum CatalogEntryType { + ELEMENT + VAULT + + @@map("catalog_entry_type") +} + +/// One row per code for the catalog list: the "-a" variant of each element or +/// vault, with the columns they share and the sort keys. +model CatalogEntry { + /// The id of the "-a" variant, e.g. "UB-1.101-a". + id String @id + type CatalogEntryType + code String + name String? + description String + /// value for an element, dValue for a vault. + difficulty Decimal @db.Decimal(3, 1) + apparatusId String @map("apparatus_id") + elementGroupId String @map("element_group_id") + /// Whether the item is named after a gymnast. Used to sort named items first. + hasName Boolean @map("has_name") + /// The name in lowercase, or "" when there is no name. Never null, so the + /// cursor can sort on it. + nameSort String @map("name_sort") + /// The code with each part padded to the same length, so "2.1" sorts before "10.1". + codeSort String @map("code_sort") + + words CatalogWord[] + + // One index per sort, with id last so the order is stable for the cursor. + @@index([codeSort, id]) + @@index([difficulty, id]) + // Sort by name: named items first (hasName DESC) in both directions, so + // ascending and descending each need their own index. + @@index([hasName, nameSort, id]) + @@index([hasName(sort: Desc), nameSort, id], map: "catalog_entries_has_name_desc_name_sort_id_idx") + // Filter by apparatus combined with a sort, so a filtered page does not walk + // past the rows of the other apparatus first. + @@index([apparatusId, difficulty, id]) + @@index([apparatusId, codeSort, id]) + // Filters. + @@index([apparatusId]) + @@index([elementGroupId]) + @@map("catalog_entries") +} + +/// One row per distinct word of an entry, in lowercase. Holds the words of all +/// variants of the code, so a word that is only in variant "-b" finds the code. +model CatalogWord { + entryId String @map("entry_id") + word String + + entry CatalogEntry @relation(fields: [entryId], references: [id], onDelete: Cascade) + + @@id([entryId, word]) + // For the prefix search (startsWith). The word column has the C collation, + // set in SQL in the migration, so this index can serve LIKE 'term%'. See + // "What the index needs" in docs/API.md. + @@index([word]) + @@map("catalog_words") +} + +// --------------------------------------------------------------------------- +// User data. Generated ids (cuid). Deleting a coach deletes their gymnasts, +// and deleting a gymnast deletes their routines and element list. +// --------------------------------------------------------------------------- + +model Coach { + id String @id @default(cuid()) + /// Google's unique user id (the "sub" claim). Never exposed in the API. + googleId String @unique @map("google_id") + email String + name String + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + + gymnasts Gymnast[] + sessions Session[] + + @@map("coaches") +} + +/// One refresh token of a login. Only a hash of the token is stored. Every use +/// of a token replaces it with a new one in the same family (rotation). A +/// token that is used again after it was replaced means that it was copied, +/// and the whole family is revoked. See "Identifying the user" in docs/API.md. +model Session { + id String @id @default(cuid()) + coachId String @map("coach_id") + /// All the tokens that follow from one login share a family. + familyId String @map("family_id") + /// SHA-256 of the refresh token, as hex. The token itself is never stored. + tokenHash String @unique @map("token_hash") + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + /// Set when the token has been used, or the login was ended. + revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + + coach Coach @relation(fields: [coachId], references: [id], onDelete: Cascade) + + @@index([coachId]) + @@index([familyId]) + @@index([expiresAt]) + @@map("sessions") +} + +model Gymnast { + id String @id @default(cuid()) + coachId String @map("coach_id") + name String + + coach Coach @relation(fields: [coachId], references: [id], onDelete: Cascade) + elements GymnastElement[] + routines Routine[] + + @@index([coachId]) + @@map("gymnasts") +} + +/// An element or vault the gymnast can do or is practicing. Exactly one of +/// elementId and vaultId is set (CHECK constraint in the migration). +model GymnastElement { + /// A row id is needed because element_id and vault_id can be null, so they + /// cannot be part of a primary key. + id Int @id @default(autoincrement()) + gymnastId String @map("gymnast_id") + elementId String? @map("element_id") + vaultId String? @map("vault_id") + + gymnast Gymnast @relation(fields: [gymnastId], references: [id], onDelete: Cascade) + element Element? @relation(fields: [elementId], references: [id], onDelete: Restrict) + vault Vault? @relation(fields: [vaultId], references: [id], onDelete: Restrict) + + // No duplicates per gymnast. PostgreSQL treats null values as different, so + // each rule only applies to the rows where that column is set. + @@unique([gymnastId, elementId]) + @@unique([gymnastId, vaultId]) + @@index([elementId]) + @@index([vaultId]) + @@map("gymnast_elements") +} + +model Routine { + id String @id @default(cuid()) + gymnastId String @map("gymnast_id") + apparatusId String @map("apparatus_id") + name String + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + /// Set by Prisma on every update of the routine row. The items are separate + /// rows, so setRoutineItems must also update this in the same transaction. + /// The default lets inserts outside Prisma work. + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + gymnast Gymnast @relation(fields: [gymnastId], references: [id], onDelete: Cascade) + apparatus Apparatus @relation(fields: [apparatusId], references: [id], onDelete: Restrict) + items RoutineElement[] + + // Constraint 6: a routine name is unique per gymnast and apparatus. + @@unique([gymnastId, apparatusId, name]) + @@index([apparatusId]) + @@map("routines") +} + +/// One element or vault in a routine, at a position. Exactly one of elementId +/// and vaultId is set (CHECK constraint in the migration). +model RoutineElement { + routineId String @map("routine_id") + /// Order in the routine, starting at 1. + position Int + elementId String? @map("element_id") + vaultId String? @map("vault_id") + + routine Routine @relation(fields: [routineId], references: [id], onDelete: Cascade) + element Element? @relation(fields: [elementId], references: [id], onDelete: Restrict) + vault Vault? @relation(fields: [vaultId], references: [id], onDelete: Restrict) + + @@id([routineId, position]) + @@index([elementId]) + @@index([vaultId]) + @@map("routine_elements") +} diff --git a/backend/src/auth/accessToken.test.ts b/backend/src/auth/accessToken.test.ts new file mode 100644 index 0000000..e3100f6 --- /dev/null +++ b/backend/src/auth/accessToken.test.ts @@ -0,0 +1,84 @@ +// Tests for the access token. No database is needed. + +import { SignJWT } from 'jose' +import { describe, expect, it } from 'vitest' +import { + ACCESS_TOKEN_SECONDS, + signAccessToken, + verifyAccessToken, +} from './accessToken.js' + +const SECRET = 'a-secret-that-is-at-least-32-characters-long' +const NOW = new Date('2026-10-17T10:00:00Z') +const after = (seconds: number) => new Date(NOW.getTime() + seconds * 1000) + +describe('the access token', () => { + it('holds the coach id and gives it back', async () => { + const token = await signAccessToken('coach-1', SECRET, NOW) + expect(await verifyAccessToken(token, SECRET, NOW)).toBe('coach-1') + }) + + it('lives for 15 minutes', async () => { + expect(ACCESS_TOKEN_SECONDS).toBe(15 * 60) + const token = await signAccessToken('coach-1', SECRET, NOW) + expect(await verifyAccessToken(token, SECRET, after(14 * 60 + 59))).toBe( + 'coach-1', + ) + expect( + await verifyAccessToken(token, SECRET, after(15 * 60 + 1)), + ).toBeNull() + }) + + it('is refused when it is signed with another key', async () => { + const token = await signAccessToken('coach-1', 'x'.repeat(40), NOW) + expect(await verifyAccessToken(token, SECRET, NOW)).toBeNull() + }) + + it('is refused when the content has been changed', async () => { + const token = await signAccessToken('coach-1', SECRET, NOW) + const [header, , signature] = token.split('.') + const forged = Buffer.from( + JSON.stringify({ sub: 'coach-2', iss: 'cop-catalog', exp: 9999999999 }), + ).toString('base64url') + expect( + await verifyAccessToken(`${header}.${forged}.${signature}`, SECRET, NOW), + ).toBeNull() + }) + + it('is refused without a signature ("alg": "none")', async () => { + const part = (value: object) => + Buffer.from(JSON.stringify(value)).toString('base64url') + const unsigned = `${part({ alg: 'none' })}.${part({ + sub: 'coach-1', + iss: 'cop-catalog', + exp: 9999999999, + })}.` + expect(await verifyAccessToken(unsigned, SECRET, NOW)).toBeNull() + }) + + it('is refused when it comes from another issuer or has no coach id', async () => { + const key = new TextEncoder().encode(SECRET) + const issued = Math.floor(NOW.getTime() / 1000) + const other = await new SignJWT({}) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject('coach-1') + .setIssuer('someone-else') + .setIssuedAt(issued) + .setExpirationTime(issued + 60) + .sign(key) + const noSubject = await new SignJWT({}) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuer('cop-catalog') + .setIssuedAt(issued) + .setExpirationTime(issued + 60) + .sign(key) + expect(await verifyAccessToken(other, SECRET, NOW)).toBeNull() + expect(await verifyAccessToken(noSubject, SECRET, NOW)).toBeNull() + }) + + it('is refused when it is not a token at all, and never throws', async () => { + for (const text of ['', 'abc', 'a.b.c', '....', 'x'.repeat(5000)]) { + expect(await verifyAccessToken(text, SECRET, NOW)).toBeNull() + } + }) +}) diff --git a/backend/src/auth/accessToken.ts b/backend/src/auth/accessToken.ts new file mode 100644 index 0000000..692395b --- /dev/null +++ b/backend/src/auth/accessToken.ts @@ -0,0 +1,51 @@ +// The access token: a short-lived signed JWT that the client sends in the +// Authorization header. It holds only the coach id (`sub`). It is signed with +// SESSION_SECRET (HS256) and lives for 15 minutes. After that the client +// uses its refresh token (see sessions.ts) to get a new one. + +import { SignJWT, jwtVerify } from 'jose' + +export const ACCESS_TOKEN_SECONDS = 15 * 60 +const ISSUER = 'cop-catalog' +const ALGORITHM = 'HS256' + +const key = (secret: string) => new TextEncoder().encode(secret) + +export async function signAccessToken( + coachId: string, + secret: string, + now: Date, +): Promise { + const issuedAt = Math.floor(now.getTime() / 1000) + return new SignJWT({}) + .setProtectedHeader({ alg: ALGORITHM }) + .setSubject(coachId) + .setIssuer(ISSUER) + .setIssuedAt(issuedAt) + .setExpirationTime(issuedAt + ACCESS_TOKEN_SECONDS) + .sign(key(secret)) +} + +/** + * The coach id in a valid token, or null when the token is wrong, tampered + * with, signed with another key or expired. It never throws and never says + * why, because a missing login is a normal state (see `me` in docs/API.md). + */ +export async function verifyAccessToken( + token: string, + secret: string, + now: Date, +): Promise { + try { + const { payload } = await jwtVerify(token, key(secret), { + issuer: ISSUER, + algorithms: [ALGORITHM], // never "none", and no other key type + currentDate: now, + }) + return typeof payload.sub === 'string' && payload.sub !== '' + ? payload.sub + : null + } catch { + return null + } +} diff --git a/backend/src/auth/sessions.test.ts b/backend/src/auth/sessions.test.ts new file mode 100644 index 0000000..1316ef2 --- /dev/null +++ b/backend/src/auth/sessions.test.ts @@ -0,0 +1,165 @@ +// Tests for the refresh tokens. They run against the local database from +// backend/.env (run `npm run db:deploy` first) and write a test coach with +// sessions, which they delete again. Without DATABASE_URL they are skipped. + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createPrismaClient } from '../db.js' +import type { PrismaClient } from '../generated/prisma/client.js' +import { UnauthenticatedError } from '../errors.js' +import { + REFRESH_TOKEN_DAYS, + deleteExpiredSessions, + endSession, + hashToken, + rotateSession, + startSession, +} from './sessions.js' + +const hasDatabase = Boolean(process.env['DATABASE_URL']) +const NOW = new Date('2026-10-17T10:00:00Z') +const days = (n: number) => new Date(NOW.getTime() + n * 24 * 60 * 60 * 1000) +const TEST_ID = 'test-70-sessions' + +describe.runIf(hasDatabase)('the refresh tokens', () => { + let prisma: PrismaClient + let coachId: string + + const clean = () => + prisma.coach.deleteMany({ where: { googleId: { startsWith: TEST_ID } } }) + + beforeAll(async () => { + prisma = createPrismaClient() + await clean() + coachId = ( + await prisma.coach.create({ + data: { googleId: TEST_ID, email: 'test@example.com', name: 'Test' }, + }) + ).id + }) + + afterAll(async () => { + await clean() + await prisma.$disconnect() + }) + + it('stores a hash of the token and never the token', async () => { + const token = await startSession(prisma, coachId, NOW) + const row = await prisma.session.findUniqueOrThrow({ + where: { tokenHash: hashToken(token) }, + }) + expect(token.length).toBeGreaterThanOrEqual(40) + expect(JSON.stringify(row)).not.toContain(token) + expect(row.tokenHash).toMatch(/^[0-9a-f]{64}$/) + expect(row.expiresAt).toEqual(days(REFRESH_TOKEN_DAYS)) + expect(row.revokedAt).toBeNull() + }) + + it('gives a new token for a token, and the old token stops working', async () => { + const first = await startSession(prisma, coachId, NOW) + const second = await rotateSession(prisma, first, NOW) + expect(second.coachId).toBe(coachId) + expect(second.refreshToken).not.toBe(first) + + // The new token works and expires 14 days after it was made. + const later = days(1) + const third = await rotateSession(prisma, second.refreshToken, later) + const row = await prisma.session.findUniqueOrThrow({ + where: { tokenHash: hashToken(third.refreshToken) }, + }) + expect(row.expiresAt).toEqual( + new Date(later.getTime() + REFRESH_TOKEN_DAYS * 24 * 60 * 60 * 1000), + ) + + // The old one does not (and using it again ends the login, see below). + await expect(rotateSession(prisma, first, NOW)).rejects.toBeInstanceOf( + UnauthenticatedError, + ) + }) + + it('revokes the whole login when an old token is used again', async () => { + const first = await startSession(prisma, coachId, NOW) + const second = await rotateSession(prisma, first, NOW) + const third = await rotateSession(prisma, second.refreshToken, NOW) + + // Someone uses the first token again: it was copied. + await expect(rotateSession(prisma, first, NOW)).rejects.toBeInstanceOf( + UnauthenticatedError, + ) + // Now the newest token is dead too, so the thief and the coach both log in again. + await expect( + rotateSession(prisma, third.refreshToken, NOW), + ).rejects.toBeInstanceOf(UnauthenticatedError) + }) + + it('does not touch the other logins of the coach when one is revoked', async () => { + const phone = await startSession(prisma, coachId, NOW) + const laptop = await startSession(prisma, coachId, NOW) + await endSession(prisma, phone, NOW) + + await expect(rotateSession(prisma, phone, NOW)).rejects.toBeInstanceOf( + UnauthenticatedError, + ) + await expect(rotateSession(prisma, laptop, NOW)).resolves.toMatchObject({ + coachId, + }) + }) + + it('refuses an expired token, and a token that does not exist', async () => { + const token = await startSession(prisma, coachId, NOW) + await expect( + rotateSession(prisma, token, days(REFRESH_TOKEN_DAYS)), + ).rejects.toBeInstanceOf(UnauthenticatedError) + // One second before it expires it still works. + const fresh = await startSession(prisma, coachId, NOW) + await expect( + rotateSession( + prisma, + fresh, + new Date(days(REFRESH_TOKEN_DAYS).getTime() - 1000), + ), + ).resolves.toBeDefined() + + for (const text of ['', 'not-a-token', 'x'.repeat(5000)]) { + await expect(rotateSession(prisma, text, NOW)).rejects.toBeInstanceOf( + UnauthenticatedError, + ) + } + }) + + it('lets only one of two requests with the same token through', async () => { + const token = await startSession(prisma, coachId, NOW) + const results = await Promise.allSettled([ + rotateSession(prisma, token, NOW), + rotateSession(prisma, token, NOW), + ]) + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1) + expect(results.filter((r) => r.status === 'rejected')).toHaveLength(1) + }) + + it('ends a login on logout, and does nothing for an unknown token', async () => { + const first = await startSession(prisma, coachId, NOW) + const second = await rotateSession(prisma, first, NOW) + await endSession(prisma, second.refreshToken, NOW) + await expect( + rotateSession(prisma, second.refreshToken, NOW), + ).rejects.toBeInstanceOf(UnauthenticatedError) + + await expect(endSession(prisma, 'unknown', NOW)).resolves.toBeUndefined() + await expect(endSession(prisma, '', NOW)).resolves.toBeUndefined() + }) + + it('deletes only the tokens that have expired', async () => { + const old = await startSession(prisma, coachId, NOW) + const used = await startSession(prisma, coachId, days(10)) + await rotateSession(prisma, used, days(10)) // used, but not expired + const current = await startSession(prisma, coachId, days(10)) + + await deleteExpiredSessions(prisma, days(REFRESH_TOKEN_DAYS + 1)) + + const exists = (token: string) => + prisma.session.count({ where: { tokenHash: hashToken(token) } }) + expect(await exists(old)).toBe(0) + expect(await exists(used)).toBe(1) // kept for the reuse check + expect(await exists(current)).toBe(1) + }) +}) diff --git a/backend/src/auth/sessions.ts b/backend/src/auth/sessions.ts new file mode 100644 index 0000000..7da425c --- /dev/null +++ b/backend/src/auth/sessions.ts @@ -0,0 +1,139 @@ +// The refresh tokens. A refresh token is a long random value that lives for 14 +// days. Only its SHA-256 hash is stored (table `sessions`), so a copy of the +// database cannot be used to log in. +// +// Rotation: every use of a refresh token replaces it with a new one in the same +// family (all the tokens that follow from one login). The old token stops +// working. If a token that was already replaced is used again, someone has a +// copy of it, and we cannot tell who is the coach and who is the thief, so the +// whole family is revoked and the coach has to log in again with Google. +// Logging out revokes the family in the same way. +// +// Two tabs that refresh with the same token at the same moment look like a +// reuse, and end the login. The client must therefore refresh in one place at +// a time (see "Identifying the user" in docs/API.md). + +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import type { PrismaClient } from '../generated/prisma/client.js' +import { UnauthenticatedError } from '../errors.js' + +export const REFRESH_TOKEN_DAYS = 14 +/** Longer than any token we make, so a huge string is never hashed. */ +const MAX_TOKEN_LENGTH = 512 + +const DAY_MS = 24 * 60 * 60 * 1000 +const INVALID = 'The refresh token is not valid. Log in again.' + +export const hashToken = (token: string): string => + createHash('sha256').update(token).digest('hex') + +const newToken = (): string => randomBytes(32).toString('base64url') +const expiryFrom = (now: Date): Date => + new Date(now.getTime() + REFRESH_TOKEN_DAYS * DAY_MS) + +type Db = Pick + +/** Starts a new family for a login and returns the refresh token. */ +export async function startSession( + db: Db, + coachId: string, + now: Date, +): Promise { + const token = newToken() + await db.session.create({ + data: { + coachId, + familyId: randomUUID(), + tokenHash: hashToken(token), + expiresAt: expiryFrom(now), + }, + }) + return token +} + +type Rotation = + { ok: true; coachId: string; refreshToken: string } | { ok: false } + +/** + * Uses a refresh token: it stops working, and a new one in the same family is + * returned with the coach id. Throws UnauthenticatedError when the token is + * unknown, expired, already used or revoked. Using a token a second time + * revokes the whole family. + */ +export async function rotateSession( + prisma: PrismaClient, + token: string, + now: Date, +): Promise<{ coachId: string; refreshToken: string }> { + if (token === '' || token.length > MAX_TOKEN_LENGTH) { + throw new UnauthenticatedError(INVALID) + } + const tokenHash = hashToken(token) + + // The result is decided inside the transaction and the error is thrown after + // it, because an error inside would roll back the revoking of the family. + const result = await prisma.$transaction(async (tx): Promise => { + const row = await tx.session.findUnique({ where: { tokenHash } }) + if (!row) return { ok: false } + if (row.expiresAt <= now) return { ok: false } + + // Only one caller can change revokedAt from null. If this update changes + // nothing, the token was used before (or at the same moment). + const claimed = await tx.session.updateMany({ + where: { id: row.id, revokedAt: null }, + data: { revokedAt: now }, + }) + if (claimed.count === 0) { + await tx.session.updateMany({ + where: { familyId: row.familyId, revokedAt: null }, + data: { revokedAt: now }, + }) + return { ok: false } + } + + const refreshToken = newToken() + await tx.session.create({ + data: { + coachId: row.coachId, + familyId: row.familyId, + tokenHash: hashToken(refreshToken), + expiresAt: expiryFrom(now), + }, + }) + return { ok: true, coachId: row.coachId, refreshToken } + }) + + if (!result.ok) throw new UnauthenticatedError(INVALID) + return { coachId: result.coachId, refreshToken: result.refreshToken } +} + +/** Ends the login that the refresh token belongs to. An unknown token does nothing. */ +export async function endSession( + db: Db, + token: string, + now: Date, +): Promise { + if (token === '' || token.length > MAX_TOKEN_LENGTH) return + const row = await db.session.findUnique({ + where: { tokenHash: hashToken(token) }, + }) + if (!row) return + await db.session.updateMany({ + where: { familyId: row.familyId, revokedAt: null }, + data: { revokedAt: now }, + }) +} + +/** + * Deletes the tokens that have expired. Tokens that were used or revoked stay + * until they expire, because the reuse check needs them. + */ +export async function deleteExpiredSessions( + db: Db, + now: Date, +): Promise { + const result = await db.session.deleteMany({ + where: { expiresAt: { lt: now } }, + }) + return result.count +} diff --git a/backend/src/catalog/abbreviations.test.ts b/backend/src/catalog/abbreviations.test.ts new file mode 100644 index 0000000..241cfcb --- /dev/null +++ b/backend/src/catalog/abbreviations.test.ts @@ -0,0 +1,67 @@ +// Checks that every abbreviation in the catalog descriptions is either written +// out (ABBREVIATIONS) or left out on purpose (ABBREVIATIONS_LEFT_OUT). +// +// A word in a description is treated as an abbreviation when it has at least +// two letters and: +// - is all uppercase, like "HB" and "LB", or +// - has no vowels (a, e, i, o, u, y), like "fwd", "bwd" and "hstd", or +// - is followed by a dot that does not end a sentence, like "sec." in +// "(2 sec.)" and "min." in "min. ¾ twist". A dot ends a sentence when it is +// the last character, or when the next word starts with an uppercase letter. +// Single letters such as the "L" in "L grip" are shapes, not abbreviations. + +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { + ABBREVIATIONS, + ABBREVIATIONS_LEFT_OUT, + normalizeWords, +} from './words.js' + +type CatalogItem = { id: string; description: string } + +const dataDir = new URL('../../../data/', import.meta.url) +const read = (file: string): CatalogItem[] => + JSON.parse(readFileSync(new URL(file, dataDir), 'utf8')) +const items = [...read('elements.json'), ...read('vaults.json')] + +function findAbbreviations(description: string): string[] { + const found: string[] = [] + const text = description.trimEnd() + for (const match of text.matchAll(/[A-Za-z]+/g)) { + const word = match[0] + const end = match.index + word.length + const next = text.slice(end + 1).trimStart()[0] + const dotInside = + text[end] === '.' && next !== undefined && next === next.toLowerCase() + const isAbbreviation = + word.length >= 2 && + (word === word.toUpperCase() || !/[aeiouy]/i.test(word) || dotInside) + if (isAbbreviation) found.push(...normalizeWords(word)) + } + return found +} + +describe('abbreviations in the catalog descriptions', () => { + it('finds the abbreviations it is meant to find', () => { + expect(findAbbreviations('Swing fwd on HB to hstd (2 sec.)')).toEqual([ + 'fwd', + 'hb', + 'hstd', + 'sec', + ]) + expect(findAbbreviations('Salto in L hang. Then a tuck.')).toEqual([]) + }) + + it('are all written out or left out on purpose', () => { + const unknown = new Map() + for (const item of items) { + for (const word of findAbbreviations(item.description)) { + if (!(word in ABBREVIATIONS) && !(word in ABBREVIATIONS_LEFT_OUT)) + unknown.set(word, item.id) + } + } + // Shows each unknown abbreviation with an id where it is used. + expect(Object.fromEntries(unknown)).toEqual({}) + }) +}) diff --git a/backend/src/catalog/cursor.ts b/backend/src/catalog/cursor.ts new file mode 100644 index 0000000..8c246dd --- /dev/null +++ b/backend/src/catalog/cursor.ts @@ -0,0 +1,76 @@ +// The cursor of the catalog search: a text that says where the next page +// starts. It is base64url-encoded JSON with the sort field and direction, the +// sort values of the last item on the page, and its id. +// +// The client should treat it as opaque, but it is not secret: the catalog is +// public, so a changed or hand-made cursor can only lead to another page of +// public data. What matters is that it is checked strictly when it is decoded, +// before any value reaches Prisma. + +import { BadUserInputError } from '../errors.js' +import { + SORTS, + type SortDirection, + type SortField, + type SortValues, + isText, +} from './sorts.js' + +/** What a cursor points at: the sort values and the id of the last item. */ +export type CursorPosition = { values: SortValues; id: string } + +const INVALID = + 'The cursor is not valid. Use the endCursor from the previous page.' + +export function encodeCursor( + field: SortField, + direction: SortDirection, + position: CursorPosition, +): string { + const payload = { field, direction, id: position.id, ...position.values } + return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') +} + +/** + * Decodes and checks a cursor: it must be for the same sort field and + * direction as the current request, have exactly the expected keys, and every + * value must have the right type. Anything else gives BAD_USER_INPUT. + */ +export function decodeCursor( + cursor: string, + field: SortField, + direction: SortDirection, +): CursorPosition { + let payload: unknown + try { + payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) + } catch { + throw new BadUserInputError(INVALID) + } + if ( + typeof payload !== 'object' || + payload === null || + Array.isArray(payload) + ) { + throw new BadUserInputError(INVALID) + } + + const { + field: f, + direction: d, + id, + ...values + } = payload as Record + if (f !== field || d !== direction) { + throw new BadUserInputError( + `The cursor was made for another sort (${String(f)} ${String(d)}), not ${field} ${direction}. Start again without a cursor.`, + ) + } + const sort = SORTS[field] + const sameKeys = + Object.keys(values).sort().join() === [...sort.keys].sort().join() + if (!sameKeys || !isText(id) || id === '' || !sort.isValid(values)) { + throw new BadUserInputError(INVALID) + } + return { values: values as SortValues, id } +} diff --git a/backend/src/catalog/search.test.ts b/backend/src/catalog/search.test.ts new file mode 100644 index 0000000..71adfbb --- /dev/null +++ b/backend/src/catalog/search.test.ts @@ -0,0 +1,505 @@ +// Tests for the catalog search. The first part has no database. The rest runs +// against the local database from backend/.env and needs the seeded catalog +// (run `npm run db:seed` first). It only reads: no catalog rows or user data +// are changed. Without DATABASE_URL the database tests are skipped. + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { PrismaPg } from '@prisma/adapter-pg' +import { createPrismaClient } from '../db.js' +import { PrismaClient as LoggingClient } from '../generated/prisma/client.js' +import type { PrismaClient } from '../generated/prisma/client.js' +import { + BadUserInputError, + type CatalogSearchArgs, + listElementGroups, + searchCatalog, + searchTerms, +} from './search.js' + +describe('searchTerms', () => { + it('uses the same rules as the stored words: lowercase, no accents, codes kept whole', () => { + expect(searchTerms('SAL')).toEqual(['sal']) + expect(searchTerms('Fouetté')).toEqual(['fouette']) + expect(searchTerms('3.304')).toEqual(['3.304']) + }) + + it('splits "hop-grip" into two words, like the stored words', () => { + expect(searchTerms('hop-grip')).toEqual(['hop', 'grip']) + }) + + it('drops the dot after an abbreviation such as "sec."', () => { + expect(searchTerms('sec.')).toEqual(['sec']) + }) + + it('splits "1/1" into numbers', () => { + expect(searchTerms('1/1')).toEqual(['1']) + }) + + it('removes a trailing "s", but not from codes or numbers', () => { + expect(searchTerms('saltos circles')).toEqual(['salto', 'circle']) + expect(searchTerms('3.3 180s')).toEqual(['3.3', '180s']) + }) + + it('drops a word that is only "s", and empty words', () => { + expect(searchTerms('s')).toEqual([]) + expect(searchTerms(' ')).toEqual([]) + expect(searchTerms(null)).toEqual([]) + }) + + it('removes % and _, which are wildcards in LIKE', () => { + expect(searchTerms('%')).toEqual([]) + expect(searchTerms('sal%')).toEqual(['sal']) + expect(searchTerms('_alto')).toEqual(['alto']) + }) +}) + +const hasDatabase = Boolean(process.env['DATABASE_URL']) + +describe.runIf(hasDatabase)('searchCatalog on the seeded catalog', () => { + let prisma: PrismaClient + let totalEntries: number + let rowCounts: number[] + + const counts = () => + Promise.all([ + prisma.catalogEntry.count(), + prisma.catalogWord.count(), + prisma.element.count(), + prisma.vault.count(), + prisma.coach.count(), + prisma.gymnast.count(), + prisma.routine.count(), + prisma.routineElement.count(), + prisma.gymnastElement.count(), + ]) + + /** Follows the cursors to the end and returns all ids, in order. */ + async function all(args: CatalogSearchArgs, pageSize = 50) { + const firstPage = await searchCatalog(prisma, { + ...args, + first: pageSize, + withTotalCount: true, + }) + const nodes = firstPage.edges.map((e) => e.node) + let page = firstPage + while (page.pageInfo.hasNextPage) { + page = await searchCatalog(prisma, { + ...args, + first: pageSize, + after: page.pageInfo.endCursor, + }) + nodes.push(...page.edges.map((e) => e.node)) + } + return { + nodes, + ids: nodes.map((n) => n.id), + totalCount: firstPage.totalCount!, + } + } + + const ids = async (search: string, filter?: CatalogSearchArgs['filter']) => + (await all({ search, filter }, 100)).ids + + const expectBadInput = async (args: CatalogSearchArgs, message: RegExp) => { + const error = await searchCatalog(prisma, args).catch((e: unknown) => e) + expect(error).toBeInstanceOf(BadUserInputError) + expect((error as BadUserInputError).code).toBe('BAD_USER_INPUT') + expect((error as Error).message).toMatch(message) + } + + beforeAll(async () => { + prisma = createPrismaClient() + totalEntries = await prisma.catalogEntry.count() + if (totalEntries === 0) + throw new Error('The catalog is empty. Run `npm run db:seed` first.') + rowCounts = await counts() + }) + + afterAll(async () => { + // The search only reads: nothing in the catalog or the user data changed. + expect(await counts()).toEqual(rowCounts) + await prisma.$disconnect() + }) + + it('requires every search word to match', async () => { + const giant = await ids('giant') + const circle = await ids('circle') + const both = await ids('giant circle') + expect(both.length).toBeGreaterThan(0) + // Exactly the items that each word finds on its own. + expect(both).toEqual(giant.filter((id) => circle.includes(id))) + expect(both.length).toBeLessThan(circle.length) + }) + + it('removes a trailing "s" from each search word', async () => { + expect(await ids('circles')).toEqual(await ids('circle')) + expect(await ids('Saltos')).toEqual(await ids('salto')) + }) + + it('finds a code by a word that is only in its variant -b', async () => { + // UB-2.506-a is named Hindorff; Martins is the name of variant -b. + const entry = await prisma.catalogEntry.findUniqueOrThrow({ + where: { id: 'UB-2.506-a' }, + }) + expect(`${entry.name} ${entry.description}`.toLowerCase()).not.toContain( + 'martins', + ) + expect(await ids('martins')).toContain('UB-2.506-a') + }) + + it('finds an element by a written-out abbreviation', async () => { + const entry = await prisma.catalogEntry.findUniqueOrThrow({ + where: { id: 'UB-2.305-a' }, + }) + expect(entry.description).toBe('Clear hip circle to hstd') + expect(await ids('handstand')).toContain('UB-2.305-a') + }) + + it('treats "hop-grip", "sec." and "1/1" like the stored words', async () => { + expect(await ids('hop-grip')).toEqual(await ids('hop grip')) + expect((await ids('hop-grip')).length).toBeGreaterThan(0) + expect(await ids('sec.')).toEqual(await ids('sec')) + expect((await ids('sec.')).length).toBeGreaterThan(0) + expect(await ids('1/1 turn')).toEqual(await ids('1 turn')) + }) + + it('finds codes by a code prefix', async () => { + const found = await all({ search: '3.3' }, 100) + expect(found.totalCount).toBeGreaterThan(0) + expect(found.nodes.every((n) => n.code.startsWith('3.3'))).toBe(true) + }) + + it('is not fooled by % and _, and ignores case', async () => { + expect(await ids('SAL')).toEqual(await ids('sal')) + expect(await ids('sal%')).toEqual(await ids('sal')) + // As a LIKE pattern, "_alto%" would match "salto". Here it is just "alto". + expect(await ids('_alto')).toEqual(await ids('alto')) + expect(await ids('_alto')).not.toEqual(await ids('salto')) + // "%" alone is cleaned away and leaves no words, so it finds nothing. + expect((await all({ search: '%' }, 100)).totalCount).toBe(0) + }) + + it('finds nothing for text without searchable words, and everything for empty text', async () => { + for (const search of ['ø', 'ß', '日本語', '😀', '%_', 's']) { + const page = await searchCatalog(prisma, { search, withTotalCount: true }) + expect(page.totalCount).toBe(0) + expect(page.edges).toEqual([]) + expect(page.pageInfo).toEqual({ hasNextPage: false, endCursor: null }) + } + for (const search of [null, '', ' ']) { + const page = await searchCatalog(prisma, { search, withTotalCount: true }) + expect(page.totalCount).toBe(totalEntries) + } + }) + + it('filters by difficulty values, only elements', async () => { + const found = await all({ filter: { difficultyValues: [0.1, 0.2] } }, 100) + expect(found.nodes.every((n) => n.type === 'ELEMENT')).toBe(true) + expect( + found.nodes.every((n) => + ['0.1', '0.2'].includes(n.difficulty.toFixed(1)), + ), + ).toBe(true) + expect(found.totalCount).toBe( + await prisma.catalogEntry.count({ + where: { type: 'ELEMENT', difficulty: { in: ['0.1', '0.2'] } }, + }), + ) + // A float such as 0.30000000000000004 is rounded to 0.3. + const point3 = await searchCatalog(prisma, { + filter: { difficultyValues: [0.1 + 0.2] }, + withTotalCount: true, + }) + expect(point3.totalCount).toBe( + await prisma.catalogEntry.count({ + where: { type: 'ELEMENT', difficulty: '0.3' }, + }), + ) + }) + + it('rejects difficulty values that are not 0.1–1.0 in steps of 0.1, and vault', async () => { + for (const value of [0, 0.15, 1.1, -0.1, Number.NaN]) { + await expectBadInput( + { filter: { difficultyValues: [value] } }, + /Difficulty value .* is not valid/, + ) + } + await expectBadInput( + { filter: { apparatusId: 'VT', difficultyValues: [0.3] } }, + /cannot be used with vault/, + ) + // A vault group without an apparatus filter is vault too, not 0 hits. + await expectBadInput( + { filter: { elementGroupId: 'VT-1', difficultyValues: [0.3] } }, + /cannot be used with vault/, + ) + }) + + it('sorts by difficulty only within an apparatus', async () => { + await expectBadInput( + { sort: { field: 'DIFFICULTY' } }, + /needs an apparatus filter/, + ) + for (const direction of ['ASC', 'DESC'] as const) { + const found = await all( + { + filter: { apparatusId: 'BB' }, + sort: { field: 'DIFFICULTY', direction }, + }, + 25, + ) + const values = found.nodes.map((n) => Number(n.difficulty)) + const sorted = [...values].sort((a, b) => + direction === 'ASC' ? a - b : b - a, + ) + expect(values).toEqual(sorted) + expect(found.nodes.every((n) => n.apparatusId === 'BB')).toBe(true) + } + }) + + it('follows the cursors to the end without skipping or repeating items', async () => { + const cases: [CatalogSearchArgs, number][] = [ + [{ sort: { field: 'CODE', direction: 'ASC' } }, 37], + [{ sort: { field: 'CODE', direction: 'DESC' } }, 50], + // Most items have no name, so nameSort is "" for most rows. + [{ sort: { field: 'NAME', direction: 'ASC' } }, 50], + [{ sort: { field: 'NAME', direction: 'DESC' } }, 23], + // Many items share a value, so id decides the order. + [ + { + filter: { apparatusId: 'UB' }, + sort: { field: 'DIFFICULTY', direction: 'ASC' }, + }, + 20, + ], + [{ search: 'salto', sort: { field: 'NAME', direction: 'ASC' } }, 7], + ] + for (const [args, pageSize] of cases) { + const found = await all(args, pageSize) + const expected = await searchCatalog(prisma, { + ...args, + first: 1, + withTotalCount: true, + }) + expect(new Set(found.ids).size).toBe(found.ids.length) // nothing repeated + expect(found.ids.length).toBe(expected.totalCount) // nothing skipped + } + const all1 = await all({}, 100) + expect(all1.totalCount).toBe(totalEntries) + }) + + it('puts named items first in both directions of the name sort', async () => { + for (const direction of ['ASC', 'DESC'] as const) { + const { nodes } = await all({ sort: { field: 'NAME', direction } }, 100) + const firstUnnamed = nodes.findIndex((n) => !n.hasName) + expect(firstUnnamed).toBeGreaterThan(0) + expect(nodes.slice(firstUnnamed).every((n) => !n.hasName)).toBe(true) + } + }) + + it('filters over the whole catalog', async () => { + const bb = await all({ filter: { apparatusId: 'BB' } }, 100) + expect(bb.totalCount).toBe( + await prisma.catalogEntry.count({ where: { apparatusId: 'BB' } }), + ) + expect(bb.ids.length).toBe(bb.totalCount) + + const group = await all( + { filter: { apparatusId: 'UB', elementGroupId: 'UB-3' } }, + 100, + ) + expect(group.totalCount).toBe( + await prisma.catalogEntry.count({ where: { elementGroupId: 'UB-3' } }), + ) + + const named = await all({ filter: { namedOnly: true } }, 100) + expect(named.totalCount).toBe( + await prisma.catalogEntry.count({ where: { hasName: true } }), + ) + expect(named.nodes.every((n) => n.name !== null)).toBe(true) + }) + + it('only accepts groups from the chosen apparatus, and lists groups per apparatus', async () => { + await expectBadInput( + { filter: { apparatusId: 'BB', elementGroupId: 'UB-3' } }, + /belongs to UB/, + ) + await expectBadInput( + { filter: { elementGroupId: 'UB-99' } }, + /Unknown element group/, + ) + const groups = await listElementGroups(prisma, 'UB') + expect(groups.map((g) => g.number)).toEqual([1, 2, 3, 4, 5, 6]) + expect(groups.every((g) => g.apparatusId === 'UB')).toBe(true) + await expect(listElementGroups(prisma, 'XX')).rejects.toBeInstanceOf( + BadUserInputError, + ) + }) + + it('treats SQL in the search text, the filters and the cursor as plain values', async () => { + const found = await searchCatalog(prisma, { + search: "'; DROP TABLE catalog_entries; --", + withTotalCount: true, + }) + expect(found.totalCount).toBe(0) + expect(await prisma.catalogEntry.count()).toBe(totalEntries) + await expectBadInput( + { filter: { apparatusId: "UB' OR '1'='1" } }, + /Unknown apparatus/, + ) + await expectBadInput({ after: "' OR 1=1 --" }, /cursor is not valid/) + }) + + it('rejects a cursor made for another sort, or with the wrong shape', async () => { + const page = await searchCatalog(prisma, { + first: 2, + sort: { field: 'CODE' }, + }) + const cursor = page.pageInfo.endCursor! + await expectBadInput( + { after: cursor, sort: { field: 'NAME' } }, + /made for another sort/, + ) + await expectBadInput( + { after: cursor, sort: { field: 'CODE', direction: 'DESC' } }, + /made for another sort/, + ) + + const encode = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString('base64url') + const code = { field: 'CODE', direction: 'ASC', codeSort: '0001.0101' } + const wrong = [ + 'not base64 json', + encode('a string'), + encode([1, 2]), + encode(code), // no id + encode({ ...code, id: '' }), + encode({ ...code, id: 42 }), + encode({ ...code, id: 'UB-1.101-a', codeSort: 7 }), + encode({ ...code, id: 'UB-1.101-a', extra: 'x' }), + ] + for (const after of wrong) { + await expectBadInput( + { after, sort: { field: 'CODE' } }, + /cursor is not valid/, + ) + } + await expectBadInput( + { + after: encode({ + field: 'DIFFICULTY', + direction: 'ASC', + difficulty: '0.15', + id: 'x', + }), + filter: { apparatusId: 'UB' }, + sort: { field: 'DIFFICULTY' }, + }, + /cursor is not valid/, + ) + }) + + it('accepts a hand-made cursor with the right shape, which only leads to public data', async () => { + const cursor = Buffer.from( + JSON.stringify({ + field: 'CODE', + direction: 'ASC', + codeSort: '0003.0000', + id: 'x', + }), + ).toString('base64url') + const page = await searchCatalog(prisma, { + first: 3, + after: cursor, + sort: { field: 'CODE' }, + }) + expect(page.edges.every((e) => e.node.codeSort > '0003.0000')).toBe(true) + }) + + it('limits the search to 80 characters and 8 words', async () => { + await expectBadInput({ search: 'a'.repeat(81) }, /at most 80 characters/) + await expectBadInput( + { search: 'one two three four five six seven eight nine' }, + /at most 8 words, not 9/, + ) + // Exactly at the limits is fine, and repeated words count once. + await searchCatalog(prisma, { search: 'a'.repeat(80) }) + await searchCatalog(prisma, { + search: 'salto salto bwd fwd tucked piked stretched twist turn', + }) + }) + + it('checks first and the sort', async () => { + for (const first of [0, 101, 1.5]) + await expectBadInput({ first }, /first must be a whole number/) + await expectBadInput( + { sort: { field: 'POPULARITY' as never } }, + /Unknown sort field/, + ) + }) + + it('checks the apparatus and group filters without a database call', async () => { + // Records every use of the apparatus and element group tables. + const used: string[] = [] + const counting = new Proxy(prisma, { + get(target, key, receiver) { + if (key === 'apparatus' || key === 'elementGroup') + used.push(String(key)) + return Reflect.get(target, key, receiver) + }, + }) + // The first search reads the apparatus and groups once, into the cache. + await searchCatalog(counting, { filter: { apparatusId: 'UB' } }) + expect(used.length).toBeGreaterThan(0) + used.length = 0 + // After that, checking the filters and listing groups use only the cache. + await searchCatalog(counting, { + filter: { apparatusId: 'UB', elementGroupId: 'UB-3' }, + }) + await listElementGroups(counting, 'BB') + expect(used).toEqual([]) + }) + + it('continues after a cursor without OR, so the id is an index bound', async () => { + // With "value > v OR (value = v AND id > x)" the database can only use + // the id as a filter, and reads every row with the same value again on + // every page. Each query must be a plain condition instead. + const logging = new LoggingClient({ + adapter: new PrismaPg({ connectionString: process.env['DATABASE_URL']! }), + log: [{ emit: 'event', level: 'query' }], + }) + const sql: string[] = [] + logging.$on('query', (e) => sql.push(e.query)) + try { + for (const args of [ + { sort: { field: 'NAME' as const } }, + { + filter: { apparatusId: 'UB' }, + sort: { field: 'DIFFICULTY' as const }, + }, + { sort: { field: 'CODE' as const, direction: 'DESC' as const } }, + ]) { + const page1 = await searchCatalog(logging, { ...args, first: 30 }) + sql.length = 0 + await searchCatalog(logging, { + ...args, + first: 30, + after: page1.pageInfo.endCursor, + }) + const pageQueries = sql.filter((q) => q.includes('catalog_entries')) + expect(pageQueries.length).toBeGreaterThan(0) + expect(pageQueries.some((q) => /\bOR\b/.test(q))).toBe(false) + expect(pageQueries.some((q) => /\bOFFSET\b\s+[1-9]/.test(q))).toBe( + false, + ) + } + } finally { + await logging.$disconnect() + } + }) + + it('only counts when asked to', async () => { + const page = await searchCatalog(prisma, { first: 1 }) + expect(page.totalCount).toBeUndefined() + }) +}) diff --git a/backend/src/catalog/search.ts b/backend/src/catalog/search.ts new file mode 100644 index 0000000..6115d75 --- /dev/null +++ b/backend/src/catalog/search.ts @@ -0,0 +1,257 @@ +// The catalog search behind `searchCatalog` in backend/src/schema.graphql. +// The design is "Search without raw SQL" in docs/API.md: Prisma only, a prefix +// match on CatalogWord.word, and keyset pagination with an opaque cursor. + +import { + Prisma, + type CatalogEntry, + type PrismaClient, +} from '../generated/prisma/client.js' +import { BadUserInputError } from '../errors.js' +import { decodeCursor, encodeCursor } from './cursor.js' +import { + SORTS, + SORT_FIELDS, + type SortDirection, + type SortField, +} from './sorts.js' +import { catalogStructure } from './structure.js' +import { normalizeWords } from './words.js' + +export { BadUserInputError } from '../errors.js' +export type { SortDirection, SortField } from './sorts.js' + +export type CatalogFilter = { + apparatusId?: string | null + elementGroupId?: string | null + /** Difficulty values 0.1–1.0 in steps of 0.1. Only elements, never vaults. */ + difficultyValues?: number[] | null + namedOnly?: boolean | null +} + +export type CatalogSearchArgs = { + search?: string | null + filter?: CatalogFilter | null + sort?: { field?: SortField | null; direction?: SortDirection | null } | null + /** Page size, 1–100. */ + first?: number | null + after?: string | null + /** Count all matches. Only set it when the client asks for totalCount. */ + withTotalCount?: boolean +} + +export type CatalogSearchResult = { + edges: { cursor: string; node: CatalogEntry }[] + pageInfo: { hasNextPage: boolean; endCursor: string | null } + /** Only set when withTotalCount was true. */ + totalCount?: number +} + +const DIRECTIONS: readonly SortDirection[] = ['ASC', 'DESC'] +const MAX_PAGE_SIZE = 100 +/** Limits for the search text. Far more than anyone types, and each word adds a condition to the query. */ +export const MAX_SEARCH_LENGTH = 80 +export const MAX_SEARCH_WORDS = 8 +const DEFAULT_PAGE_SIZE = 20 + +/** + * The words to search for. The same rules as the stored words + * (normalizeWords), and a trailing "s" is removed, so "saltos" finds "salto". + * A word that is only "s" is dropped. Codes and numbers (words that start + * with a digit) are left exactly as they are. + */ +export function searchTerms(text: string | null | undefined): string[] { + const terms = new Set() + for (const word of normalizeWords(text ?? '')) { + const term = /^\d/.test(word) ? word : word.replace(/s$/, '') + if (term) terms.add(term) + } + return [...terms] +} + +/** Checks a difficulty value and returns it as a Decimal with one decimal. */ +function difficultyValue(value: unknown): Prisma.Decimal { + const tenths = typeof value === 'number' ? Math.round(value * 10) : NaN + if ( + !Number.isFinite(tenths) || + Math.abs((value as number) * 10 - tenths) > 1e-6 || + tenths < 1 || + tenths > 10 + ) { + throw new BadUserInputError( + `Difficulty value ${String(value)} is not valid. Use 0.1–1.0 in steps of 0.1.`, + ) + } + return new Prisma.Decimal(tenths).dividedBy(10) +} + +/** Checks the arguments and builds the Prisma where for the filters and the text. */ +async function buildWhere( + prisma: PrismaClient, + args: CatalogSearchArgs, + field: SortField, +): Promise { + const filter = args.filter ?? {} + const and: Prisma.CatalogEntryWhereInput[] = [] + let groupApparatusId: string | null = null + // From memory, so checking the filters costs no database call. + const structure = await catalogStructure(prisma) + + if (filter.apparatusId != null) { + if (!structure.apparatusIds.has(filter.apparatusId)) + throw new BadUserInputError(`Unknown apparatus "${filter.apparatusId}".`) + and.push({ apparatusId: filter.apparatusId }) + } + if (filter.elementGroupId != null) { + const group = structure.groupsById.get(filter.elementGroupId) + if (!group) + throw new BadUserInputError( + `Unknown element group "${filter.elementGroupId}".`, + ) + if ( + filter.apparatusId != null && + group.apparatusId !== filter.apparatusId + ) { + throw new BadUserInputError( + `Element group "${group.id}" belongs to ${group.apparatusId}, not to the chosen apparatus ${filter.apparatusId}.`, + ) + } + and.push({ elementGroupId: filter.elementGroupId }) + groupApparatusId = group.apparatusId + } + if (filter.difficultyValues != null && filter.difficultyValues.length > 0) { + // A vault group without an apparatus filter is vault too. + if (filter.apparatusId === 'VT' || groupApparatusId === 'VT') { + throw new BadUserInputError( + 'The difficulty filter cannot be used with vault. Vaults have D-values, not difficulty values.', + ) + } + const values = filter.difficultyValues.map(difficultyValue) + and.push({ type: 'ELEMENT', difficulty: { in: values } }) + } + if (filter.namedOnly === true) and.push({ hasName: true }) + + if (field === 'DIFFICULTY' && filter.apparatusId == null) { + throw new BadUserInputError( + 'Sorting by difficulty needs an apparatus filter. Element values and vault D-values cannot be compared.', + ) + } + + // Every search word must match a word of the entry. Never mode: 'insensitive': + // it becomes ILIKE, which cannot use the index on word. + for (const term of searchTerms(args.search)) { + and.push({ words: { some: { word: { startsWith: term } } } }) + } + return { AND: and } +} + +/** + * Searches, filters, sorts and pages the catalog. Throws BadUserInputError + * for input the client must fix. Returns CatalogEntry rows. The fields that + * only exist on elements or vaults are loaded separately by the resolver. + */ +export async function searchCatalog( + prisma: PrismaClient, + args: CatalogSearchArgs, +): Promise { + const field = args.sort?.field ?? 'CODE' + const direction = args.sort?.direction ?? 'ASC' + if (!SORT_FIELDS.includes(field)) { + throw new BadUserInputError( + `Unknown sort field "${String(field)}". Use ${SORT_FIELDS.join(', ')}.`, + ) + } + if (!DIRECTIONS.includes(direction)) { + throw new BadUserInputError( + `Unknown sort direction "${String(direction)}". Use ASC or DESC.`, + ) + } + const first = args.first ?? DEFAULT_PAGE_SIZE + if (!Number.isInteger(first) || first < 1 || first > MAX_PAGE_SIZE) { + throw new BadUserInputError( + `first must be a whole number from 1 to ${MAX_PAGE_SIZE}, not ${String(first)}.`, + ) + } + + const text = args.search?.trim() ?? '' + if (text.length > MAX_SEARCH_LENGTH) { + throw new BadUserInputError( + `The search can have at most ${MAX_SEARCH_LENGTH} characters, not ${text.length}.`, + ) + } + const termCount = searchTerms(text).length + if (termCount > MAX_SEARCH_WORDS) { + throw new BadUserInputError( + `The search can have at most ${MAX_SEARCH_WORDS} words, not ${termCount}.`, + ) + } + + const sort = SORTS[field] + const where = await buildWhere(prisma, args, field) + const cursor = args.after ? decodeCursor(args.after, field, direction) : null + + // Text that contains no searchable words (such as "ø", "😀" or "%") finds + // nothing. Only an empty text means no text filter. + if (args.search?.trim() && searchTerms(args.search).length === 0) { + return { + edges: [], + pageInfo: { hasNextPage: false, endCursor: null }, + ...(args.withTotalCount ? { totalCount: 0 } : {}), + } + } + const orderBy = sort.orderBy(direction === 'ASC' ? 'asc' : 'desc') + // Keyset pagination: the first page is one query. After a cursor, the parts + // that follow it are read in order until the page is full, so every query + // starts in the index exactly after the cursor. One extra row tells whether + // there is a next page. + const parts = cursor + ? sort.afterParts( + cursor.values, + cursor.id, + direction === 'ASC' ? 'gt' : 'lt', + ) + : [{}] + const rows: CatalogEntry[] = [] + for (const part of parts) { + if (rows.length > first) break + rows.push( + ...(await prisma.catalogEntry.findMany({ + where: { AND: [where, part] }, + orderBy, + take: first + 1 - rows.length, + })), + ) + } + const page = rows.slice(0, first) + const edges = page.map((node) => ({ + cursor: encodeCursor(field, direction, { + values: sort.values(node), + id: node.id, + }), + node, + })) + const result: CatalogSearchResult = { + edges, + pageInfo: { + hasNextPage: rows.length > first, + endCursor: edges.at(-1)?.cursor ?? null, + }, + } + // Counting reads every match, so it only runs when the client asks for it. + if (args.withTotalCount) + result.totalCount = await prisma.catalogEntry.count({ where }) + return result +} + +/** The element groups of one apparatus, in the order of the Code of Points. */ +export async function listElementGroups( + prisma: PrismaClient, + apparatusId: string, +) { + const structure = await catalogStructure(prisma) + if (!structure.apparatusIds.has(apparatusId)) + throw new BadUserInputError(`Unknown apparatus "${apparatusId}".`) + return [...structure.groupsById.values()] + .filter((g) => g.apparatusId === apparatusId) + .sort((a, b) => a.number - b.number) +} diff --git a/backend/src/catalog/sorts.ts b/backend/src/catalog/sorts.ts new file mode 100644 index 0000000..892051c --- /dev/null +++ b/backend/src/catalog/sorts.ts @@ -0,0 +1,91 @@ +// The sorts of the catalog search, one entry per sort. Everything a sort needs +// is here: the order, the values a cursor stores, how those values are +// checked when a cursor is decoded, and the condition for the rows after a +// cursor (keyset pagination). To add a sort, add an entry here and to +// CatalogSortField in backend/src/schema.graphql. + +import { Prisma, type CatalogEntry } from '../generated/prisma/client.js' + +export type SortField = 'CODE' | 'DIFFICULTY' | 'NAME' +export type SortDirection = 'ASC' | 'DESC' + +/** The sort values of one row, as stored in a cursor (without the id). */ +export type SortValues = Record + +type Where = Prisma.CatalogEntryWhereInput +type OrderBy = Prisma.CatalogEntryOrderByWithRelationInput[] + +/** "gt" for ascending and "lt" for descending. */ +export type After = 'gt' | 'lt' + +type Sort = { + /** The keys of the sort values in a cursor. */ + keys: string[] + /** The sort values of a row, for its cursor. */ + values(entry: CatalogEntry): SortValues + /** Whether sort values read from a cursor have the right types. */ + isValid(values: Record): boolean + /** The order of the rows. id is always last, so the order is stable. */ + orderBy(direction: 'asc' | 'desc'): OrderBy + /** + * The rows that come after the row with these values and this id, as parts + * in sort order. Each part is a plain condition the index can start from: + * first the rows with the same sort value and a greater id, then the rows + * with a greater sort value. One condition with OR would leave the id as a + * filter, so the database would read every row with the same value again + * on every page (see "Keyset pagination" in docs/API.md). + */ + afterParts(values: SortValues, id: string, after: After): Where[] +} + +export const isText = (v: unknown): v is string => + typeof v === 'string' && v.length <= 200 + +export const SORTS: Record = { + CODE: { + keys: ['codeSort'], + values: (entry) => ({ codeSort: entry.codeSort }), + isValid: (v) => isText(v['codeSort']), + orderBy: (dir) => [{ codeSort: dir }, { id: dir }], + afterParts: (v, id, after) => [ + { codeSort: v['codeSort'] as string, id: { [after]: id } }, + { codeSort: { [after]: v['codeSort'] } }, + ], + }, + + DIFFICULTY: { + keys: ['difficulty'], + values: (entry) => ({ difficulty: entry.difficulty.toFixed(1) }), + isValid: (v) => + isText(v['difficulty']) && /^\d+(\.\d)?$/.test(v['difficulty']), + orderBy: (dir) => [{ difficulty: dir }, { id: dir }], + afterParts: (v, id, after) => { + const d = new Prisma.Decimal(v['difficulty'] as string) + return [ + { difficulty: d, id: { [after]: id } }, + { difficulty: { [after]: d } }, + ] + }, + }, + + // Named items first in both directions, so hasName is always descending. + NAME: { + keys: ['hasName', 'nameSort'], + values: (entry) => ({ hasName: entry.hasName, nameSort: entry.nameSort }), + isValid: (v) => typeof v['hasName'] === 'boolean' && isText(v['nameSort']), + orderBy: (dir) => [{ hasName: 'desc' }, { nameSort: dir }, { id: dir }], + afterParts: (v, id, after) => { + const hasName = v['hasName'] as boolean + const nameSort = v['nameSort'] as string + const parts: Where[] = [ + { hasName, nameSort, id: { [after]: id } }, + { hasName, nameSort: { [after]: nameSort } }, + ] + // After the named items come the unnamed ones. + if (hasName) parts.push({ hasName: false }) + return parts + }, + }, +} + +export const SORT_FIELDS = Object.keys(SORTS) as SortField[] diff --git a/backend/src/catalog/structure.ts b/backend/src/catalog/structure.ts new file mode 100644 index 0000000..8937dee --- /dev/null +++ b/backend/src/catalog/structure.ts @@ -0,0 +1,51 @@ +// The apparatus and element groups, kept in memory. The search checks the +// apparatus and group filters against them on every request, and the catalog +// is read-only, so reading them from the database each time is wasted work. +// +// The list is read again after CACHE_TTL_MS, so a new seed shows up without a +// restart. Apparatus and groups only change with a new Code of Points. + +import type { ElementGroup, PrismaClient } from '../generated/prisma/client.js' + +export const CACHE_TTL_MS = 10 * 60 * 1000 + +export type CatalogStructure = { + apparatusIds: ReadonlySet + groupsById: ReadonlyMap +} + +const cache = new WeakMap< + PrismaClient, + { loadedAt: number; structure: Promise } +>() + +async function load(prisma: PrismaClient): Promise { + const [apparatus, groups] = await Promise.all([ + prisma.apparatus.findMany({ select: { id: true } }), + prisma.elementGroup.findMany({ orderBy: { number: 'asc' } }), + ]) + return { + apparatusIds: new Set(apparatus.map((a) => a.id)), + groupsById: new Map(groups.map((g) => [g.id, g])), + } +} + +/** The apparatus and groups, from memory when they were read recently. */ +export function catalogStructure( + prisma: PrismaClient, +): Promise { + const cached = cache.get(prisma) + if (cached && Date.now() - cached.loadedAt < CACHE_TTL_MS) { + return cached.structure + } + const structure = load(prisma) + cache.set(prisma, { loadedAt: Date.now(), structure }) + // Do not keep a failed read: try again on the next request. + structure.catch(() => cache.delete(prisma)) + return structure +} + +/** Forgets the cached apparatus and groups, for example after a seed in a test. */ +export function clearCatalogStructure(prisma: PrismaClient): void { + cache.delete(prisma) +} diff --git a/backend/src/catalog/words.test.ts b/backend/src/catalog/words.test.ts new file mode 100644 index 0000000..41ed336 --- /dev/null +++ b/backend/src/catalog/words.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { normalizeWords, searchWords } from './words.js' + +describe('normalizeWords', () => { + it('lowercases', () => { + expect(normalizeWords('Salto BWD Tucked')).toEqual([ + 'salto', + 'bwd', + 'tucked', + ]) + }) + + it('removes accents', () => { + expect(normalizeWords('Fouetté Röhm Šimić')).toEqual([ + 'fouette', + 'rohm', + 'simic', + ]) + }) + + it('keeps a code such as 3.304 as one word', () => { + expect(normalizeWords('3.304 and 1.22')).toEqual(['3.304', 'and', '1.22']) + }) + + it('removes punctuation', () => { + expect( + normalizeWords('Clear hip circle (Weiler-kip), hop-grip; “false-pop”.'), + ).toEqual([ + 'clear', + 'hip', + 'circle', + 'weiler', + 'kip', + 'hop', + 'grip', + 'false', + 'pop', + ]) + }) + + it('does not keep a dot at the end of a word', () => { + expect(normalizeWords('to hang on HB. (2 sec.)')).toEqual([ + 'to', + 'hang', + 'on', + 'hb', + '2', + 'sec', + ]) + }) + + it('splits fractions and degrees into numbers', () => { + expect(normalizeWords('½ turn (180°), 1/1 turn')).toEqual([ + '1', + '2', + 'turn', + '180', + '1', + '1', + 'turn', + ]) + }) + + it('returns no words for an empty text or only punctuation', () => { + expect(normalizeWords('')).toEqual([]) + expect(normalizeWords(' – (…) ')).toEqual([]) + }) + + it('never returns % or _, which are wildcards in LIKE', () => { + expect(normalizeWords('%sal_to%')).toEqual(['sal', 'to']) + }) +}) + +describe('searchWords', () => { + it('adds the written-out form of abbreviations', () => { + expect([...searchWords('Cast to hstd on LB')]).toEqual([ + 'cast', + 'to', + 'hstd', + 'handstand', + 'on', + 'lb', + 'low', + 'bar', + ]) + }) + + it('keeps each word once', () => { + expect([...searchWords('fwd and fwd')]).toEqual(['fwd', 'forward', 'and']) + }) +}) diff --git a/backend/src/catalog/words.ts b/backend/src/catalog/words.ts new file mode 100644 index 0000000..95e2c11 --- /dev/null +++ b/backend/src/catalog/words.ts @@ -0,0 +1,60 @@ +// The words the catalog search looks in. The seed script uses these functions to +// fill CatalogWord, and the search (issue #24) must use normalizeWords on the +// search text, so that both sides follow exactly the same rules. + +/** + * Splits a text into search words: lowercase, without accents, and without + * punctuation. A code such as "3.304" is kept as one word. + * + * normalizeWords('Clear hip circle to hstd (Weiler-kip), 1/1 turn') returns + * ['clear', 'hip', 'circle', 'to', 'hstd', 'weiler', 'kip', '1', '1', 'turn']. + */ +export function normalizeWords(text: string): string[] { + const plain = text + .normalize('NFKD') // "é" -> "e" + accent, "½" -> "1⁄2" + .replace(/\p{M}/gu, '') // remove the accents + .toLowerCase() + // A code: digits with dots between them. Otherwise: letters and digits. + return plain.match(/\d+(?:\.\d+)+|[a-z0-9]+/g) ?? [] +} + +/** + * Abbreviations used in the descriptions of the Code of Points, and the words + * they stand for. The key is the abbreviation as normalizeWords returns it. + * The seed adds the written-out words next to the abbreviation, so a search + * for "handstand" finds an element whose description says "hstd". + */ +export const ABBREVIATIONS: Readonly> = { + fwd: 'forward', + bwd: 'backward', + swd: 'sideward', + hstd: 'handstand', + hdst: 'handstand', // misspelling of hstd in two descriptions + hb: 'high bar', + lb: 'low bar', + sec: 'seconds', // "(2 sec.)" + min: 'minimum', // "min. ¾ twist" +} + +/** + * Abbreviations in the descriptions that are left out on purpose, with the + * reason. The abbreviation test fails on any abbreviation that is in neither + * list. + */ +export const ABBREVIATIONS_LEFT_OUT: Readonly> = { + etc: 'not something anyone searches for', +} + +/** + * The search words for a text: its own words, and the written-out form of + * every abbreviation in it. Each word appears once. + */ +export function searchWords(text: string): Set { + const words = new Set() + for (const word of normalizeWords(text)) { + words.add(word) + const writtenOut = ABBREVIATIONS[word] + if (writtenOut) normalizeWords(writtenOut).forEach((w) => words.add(w)) + } + return words +} diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..1ba7778 --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,21 @@ +import { config } from 'dotenv' +import { fileURLToPath } from 'node:url' +import { PrismaPg } from '@prisma/adapter-pg' +import { PrismaClient } from './generated/prisma/client.js' + +// backend/.env, also when the code runs from the repository root (for example +// in the tests). +config({ + path: fileURLToPath(new URL('../.env', import.meta.url)), + quiet: true, +}) + +/** A Prisma client connected to DATABASE_URL (from backend/.env). */ +export function createPrismaClient(): PrismaClient { + const connectionString = process.env['DATABASE_URL'] + if (!connectionString) + throw new Error( + 'DATABASE_URL is not set. See "Database (local setup)" in the README.', + ) + return new PrismaClient({ adapter: new PrismaPg({ connectionString }) }) +} diff --git a/backend/src/dscore.test.ts b/backend/src/dscore.test.ts new file mode 100644 index 0000000..5d94661 --- /dev/null +++ b/backend/src/dscore.test.ts @@ -0,0 +1,114 @@ +// Tests for the D-score function. No database is needed. + +import { describe, expect, it } from 'vitest' +import { calculateDScore, type ScoredItem } from './dscore.js' +import { InvalidItemsError } from './errors.js' + +const element = ( + code: string, + value: string, + apparatus = 'UB', +): ScoredItem => ({ + id: `${apparatus}-${code}-a`, + code, + value, + isVault: false, +}) +const vault = (code: string, value: string): ScoredItem => ({ + id: `VT-${code}-a`, + code, + value, + isVault: true, +}) + +describe('calculateDScore', () => { + it('gives 0 for an empty list', () => { + expect(calculateDScore([])).toBe(0) + }) + + it('sums all the elements when there are fewer than eight', () => { + expect( + calculateDScore([element('1.101', '0.1'), element('2.102', '0.5')]), + ).toBe(0.6) + }) + + it('sums exactly eight elements', () => { + const items = Array.from({ length: 8 }, (_, i) => + element(`3.${300 + i}`, '0.3'), + ) + expect(calculateDScore(items)).toBe(2.4) + }) + + it('counts only the eight highest of more than eight', () => { + const values = [ + '1.0', + '0.9', + '0.8', + '0.7', + '0.6', + '0.5', + '0.4', + '0.3', + '0.2', + '0.1', + ] + const items = values.map((v, i) => element(`4.${400 + i}`, v)) + // 1.0 + 0.9 + 0.8 + 0.7 + 0.6 + 0.5 + 0.4 + 0.3 = 5.2; 0.2 and 0.1 are left out + expect(calculateDScore(items)).toBe(5.2) + }) + + it('counts each code once, also when two variants are in the list', () => { + const a = element('1.101', '0.4') + const b = { ...element('1.101', '0.4'), id: 'UB-1.101-b' } + expect(calculateDScore([a, b])).toBe(0.4) + // The same code does not use up two of the eight places. + const others = Array.from({ length: 7 }, (_, i) => + element(`5.${500 + i}`, '0.1'), + ) + expect(calculateDScore([a, b, ...others])).toBe(1.1) + }) + + it('does not get rounding errors from decimals', () => { + // As floats 0.1 + 0.2 is 0.30000000000000004. + expect( + calculateDScore([element('1.101', '0.1'), element('1.102', '0.2')]), + ).toBe(0.3) + const items = Array.from({ length: 8 }, (_, i) => + element(`6.${600 + i}`, '0.7'), + ) + expect(calculateDScore(items)).toBe(5.6) + }) + + it('accepts values that are not strings, such as Prisma decimals', () => { + const decimal = { toString: () => '0.8' } + expect( + calculateDScore([{ ...element('1.101', '0'), value: decimal }]), + ).toBe(0.8) + }) + + it('gives the D-value of the vault on vault', () => { + expect(calculateDScore([vault('4.1', '2.6')])).toBe(2.6) + // Two variants of the same vault are one vault. + expect( + calculateDScore([ + vault('4.1', '2.6'), + { ...vault('4.1', '2.6'), id: 'VT-4.1-b' }, + ]), + ).toBe(2.6) + }) + + it('refuses more than one vault', () => { + expect(() => + calculateDScore([vault('4.1', '2.6'), vault('5.1', '3.0')]), + ).toThrow(InvalidItemsError) + }) + + it('refuses items from more than one apparatus, also a vault with elements', () => { + expect(() => + calculateDScore([element('1.101', '0.1'), element('1.102', '0.1', 'BB')]), + ).toThrow(InvalidItemsError) + expect(() => + calculateDScore([vault('4.1', '2.6'), element('1.101', '0.1')]), + ).toThrow(InvalidItemsError) + }) +}) diff --git a/backend/src/dscore.ts b/backend/src/dscore.ts new file mode 100644 index 0000000..797e502 --- /dev/null +++ b/backend/src/dscore.ts @@ -0,0 +1,66 @@ +// The D-score of a list of elements or vaults (constraint 13 in +// docs/DATA_MODEL.md). The calculator (`calculateDScore`) and, later, +// `Routine.dScore` use this function, so both follow the same rule. +// +// - Elements: the sum of the eight highest values, where each code counts once. +// Fewer than eight elements are all summed. An empty list gives 0. +// - Vault: the routine holds one vault and the score is that vault's D-value. +// +// The values have one decimal, so the sum is made in tenths (whole numbers) and +// divided at the end. Adding 0.1 + 0.2 as decimals gives 0.30000000000000004. + +import { InvalidItemsError } from './errors.js' +import { type Item, apparatusOfId } from './server/items.js' + +/** How many elements count in the score. */ +export const COUNTED_ELEMENTS = 8 + +export type ScoredItem = { + id: string + code: string + /** `value` of an element, `dValue` of a vault. A string or Prisma Decimal with one decimal. */ + value: { toString(): string } + isVault: boolean +} + +const toTenths = (value: { toString(): string }): number => + Math.round(Number(value.toString()) * 10) + +/** + * The D-score of the items. Throws InvalidItemsError when they do not fit + * together: items from more than one apparatus (this includes a vault with + * elements), or more than one vault. + */ +export function calculateDScore(items: ScoredItem[]): number { + if (new Set(items.map((item) => apparatusOfId(item.id))).size > 1) { + throw new InvalidItemsError( + 'The items must all be on the same apparatus, and a vault cannot be combined with elements.', + ) + } + + // One value for each code. Variants of a code have the same value, so the + // highest is the same as any. + const byCode = new Map() + for (const item of items) { + const value = toTenths(item.value) + byCode.set(item.code, Math.max(byCode.get(item.code) ?? 0, value)) + } + + if (items.some((item) => item.isVault)) { + if (byCode.size > 1) { + throw new InvalidItemsError('A routine on vault has only one vault.') + } + return [...byCode.values()][0]! / 10 + } + + const highest = [...byCode.values()] + .sort((a, b) => b - a) + .slice(0, COUNTED_ELEMENTS) + return highest.reduce((sum, tenths) => sum + tenths, 0) / 10 +} + +/** An element or vault from the database as the function wants it. */ +export const toScoredItem = (item: Item): ScoredItem => + item.__typename === 'Vault' + ? { id: item.id, code: item.code, value: item.dValue, isVault: true } + : { id: item.id, code: item.code, value: item.value, isVault: false } diff --git a/backend/src/errors.ts b/backend/src/errors.ts new file mode 100644 index 0000000..dd0191d --- /dev/null +++ b/backend/src/errors.ts @@ -0,0 +1,109 @@ +// Errors that the GraphQL layer turns into errors with `extensions.code` (see +// "Error handling" in docs/API.md). Resolvers and the search throw these; the +// server maps them in backend/src/server/formatError.ts. + +/** + * Wrong input from the client. The GraphQL layer turns it into an error with + * `extensions.code` "BAD_USER_INPUT" and this message (see "Error handling" + * in docs/API.md). + */ +export class BadUserInputError extends Error { + readonly code = 'BAD_USER_INPUT' + + constructor(message: string) { + super(message) + this.name = 'BadUserInputError' + } +} + +/** The operation needs a logged-in coach. Code "UNAUTHENTICATED". */ +export class UnauthenticatedError extends Error { + readonly code = 'UNAUTHENTICATED' + + constructor(message = 'You must be logged in.') { + super(message) + this.name = 'UnauthenticatedError' + } +} + +/** + * The operation is in the schema but not built yet. Code "NOT_IMPLEMENTED". + * This is temporary: it goes away when the login and the user data are done. + */ +export class NotImplementedError extends Error { + readonly code = 'NOT_IMPLEMENTED' + + constructor(operation: string) { + super(`${operation} is not available yet.`) + this.name = 'NotImplementedError' + } +} + +/** + * The items do not fit together: items from more than one apparatus, a vault + * with elements, more than one vault, or an item that has been removed from + * the Code of Points. Code "INVALID_ITEMS". + */ +export class InvalidItemsError extends Error { + readonly code = 'INVALID_ITEMS' + + constructor(message: string) { + super(message) + this.name = 'InvalidItemsError' + } +} + +/** + * The id does not exist, or it belongs to another coach. Both cases give the + * same answer, so that nobody can find out which ids exist. Code "NOT_FOUND". + */ +export class NotFoundError extends Error { + readonly code = 'NOT_FOUND' + + constructor(what = 'The item') { + super(`${what} was not found.`) + this.name = 'NotFoundError' + } +} + +/** The coach already has a routine with this name for this gymnast and apparatus. Code "NAME_TAKEN". */ +export class NameTakenError extends Error { + readonly code = 'NAME_TAKEN' + + constructor(message = 'A routine with this name already exists.') { + super(message) + this.name = 'NameTakenError' + } +} + +/** A list is full (gymnasts, routines, items in a routine or in a gymnast's list). Code "LIMIT_REACHED". */ +export class LimitReachedError extends Error { + readonly code = 'LIMIT_REACHED' + + constructor(message: string) { + super(message) + this.name = 'LimitReachedError' + } +} + +/** The errors above, which the server shows to the client with their code. */ +export type ClientError = + | BadUserInputError + | UnauthenticatedError + | NotImplementedError + | InvalidItemsError + | NotFoundError + | NameTakenError + | LimitReachedError + +export function isClientError(error: unknown): error is ClientError { + return ( + error instanceof BadUserInputError || + error instanceof UnauthenticatedError || + error instanceof NotImplementedError || + error instanceof InvalidItemsError || + error instanceof NotFoundError || + error instanceof NameTakenError || + error instanceof LimitReachedError + ) +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..32ca65f --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,31 @@ +// Starts the server: `npm run dev -w backend` (restarts on changes) or +// `npm run start -w backend`. The settings are in backend/.env. + +import { createPrismaClient } from './db.js' // reads backend/.env first +import { createApp } from './server/app.js' +import { readConfig } from './server/config.js' + +const config = readConfig() +if (!config.sessionSecret) { + console.warn( + 'SESSION_SECRET is not set: login is turned off and `me` is always null. ' + + 'Set it in backend/.env (see docs/API.md, Configuration).', + ) +} +const prisma = createPrismaClient() +const { app, server } = await createApp({ prisma, config }) + +const listener = app.listen(config.port, () => { + console.log(`GraphQL server on http://localhost:${config.port}/graphql`) + console.log(`Drawings on ${config.imageBaseUrl}`) +}) + +async function shutDown(signal: string) { + console.log(`${signal}: shutting down`) + listener.close() + await server.stop() + await prisma.$disconnect() + process.exit(0) +} +process.on('SIGINT', () => void shutDown('SIGINT')) +process.on('SIGTERM', () => void shutDown('SIGTERM')) diff --git a/backend/src/schema.graphql b/backend/src/schema.graphql new file mode 100644 index 0000000..8415cdf --- /dev/null +++ b/backend/src/schema.graphql @@ -0,0 +1,415 @@ +# 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, 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). +# 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). 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, 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. +# 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 . + """ + 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, 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. +""" +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 . + """ + 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 a routine that has it gets no D-score. + """ + removed: Boolean! + """ + Difficulty value, 0.1-1.0. The frontend shows it as a letter: 0.1 = A, + 0.2 = B ... 1.0 = J. + """ + value: Float! + """ + Other ways of performing the same element (same code, same value). + Removed variants are left out. + """ + 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 . + """ + 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 a routine that has it gets no D-score. + """ + removed: Boolean! + """ + Fixed difficulty score, e.g. 2.6. + """ + dValue: Float! + """ + Other ways of performing the same vault. Removed variants are left out. + """ + variants: [Vault!]! +} + +""" +What the user can sort the catalog by. +""" +enum CatalogSortField { + CODE + """ + value for elements and dValue for vaults. Only allowed together with + filter.apparatusId, because the two scales cannot be compared. Without it + the search throws BAD_USER_INPUT. + """ + DIFFICULTY + NAME +} + +enum SortDirection { + ASC + DESC +} + +input CatalogSort { + field: CatalogSortField! = CODE + direction: SortDirection! = ASC +} + +input CatalogFilter { + apparatusId: ID + elementGroupId: ID + """ + Only elements with one of these difficulty values (several can be chosen), + e.g. [0.1, 0.2]. The frontend shows the values as letters, so choosing A + and B sends [0.1, 0.2]. Each value must be 0.1-1.0 in steps of 0.1, + 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!] + """ + 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 + """ + 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. + """ + 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, or + when an item has been removed (see `removed`). At most 50 ids, otherwise + BAD_USER_INPUT. + """ + calculateDScore(itemIds: [ID!]!): Float! + + searchCatalog( + """ + 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. 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. + """ + 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!]! + """ + The elements and vaults the gymnast can do or is practicing, in the order + 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(apparatusId: ID): [CatalogItem!]! +} + +type Routine { + id: ID! + name: String! + gymnast: Gymnast! + apparatus: Apparatus! + """ + The elements, or the single vault, in the order of the routine. The same + element may appear more than once (it still counts once in dScore). + Removed items stay in the routine, with removed: true, so the UI can warn. + """ + items: [CatalogItem!]! + """ + 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. + """ + updatedAt: DateTime! +} + +type AuthPayload { + """ + Short-lived token (15 minutes). The client sends it in the header + "Authorization: Bearer " with every request. + """ + accessToken: String! + """ + Lives for 14 days and can be used once: refreshSession gives a new access + token and a new refresh token. Keep it safe, and never send it anywhere + else than to refreshSession and logout. + """ + refreshToken: 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! + """ + Gives a new access token and a new refresh token for a refresh token, which + then stops working. Throws UNAUTHENTICATED when the token is unknown, + expired or revoked. Using a token that has already been used ends the whole + login, because the token has been copied. + """ + refreshSession(refreshToken: String!): AuthPayload! + """ + Ends the login that the refresh token belongs to. Returns true also for an + unknown token. + """ + logout(refreshToken: String!): Boolean! + + """ + 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! + + """ + 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. 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/backend/src/seed/catalogData.ts b/backend/src/seed/catalogData.ts new file mode 100644 index 0000000..b8431d2 --- /dev/null +++ b/backend/src/seed/catalogData.ts @@ -0,0 +1,225 @@ +// Reads the catalog from data/*.json, checks it, and converts it to the shape +// of the database. Nothing here touches the database. + +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +/** Sort order of the apparatus. Not in data/apparatus.json. */ +export const APPARATUS_POSITION: Readonly> = { + VT: 1, + UB: 2, + BB: 3, + FX: 4, +} + +type ApparatusJson = { id: string; name: string } +type GroupJson = { + id: string + apparatusId: string + number: number + name: string +} +type VariantGroupJson = { id: string; variantIds: string[] } +type ItemJson = { + id: string + elementGroupId: string + code: string + description: string + name: string | null + image: string +} +type ElementJson = ItemJson & { value: number } +type VaultJson = ItemJson & { dValue: number } + +/** One element or vault, converted: the value is kept as text with one decimal. */ +export type CatalogItem = { + kind: 'element' | 'vault' + id: string + elementGroupId: string + variantGroupId: string | null + code: string + value: string + description: string + name: string | null + image: string +} + +export type CatalogData = { + apparatus: { id: string; name: string; position: number }[] + groups: GroupJson[] + variantGroups: { id: string }[] + items: CatalogItem[] +} + +/** The data does not follow a rule. The message names the row and the rule. */ +export class CatalogDataError extends Error {} + +function readJson(dataDir: string, file: string): T { + return JSON.parse(readFileSync(join(dataDir, file), 'utf8')) as T +} + +/** A number with at most one decimal, as text ("0.1", "2.6"). */ +function oneDecimal(value: unknown, row: string, field: string): string { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new CatalogDataError(`${row}: ${field} is not a number`) + } + const text = String(value) + if (!/^-?\d+(\.\d)?$/.test(text)) { + throw new CatalogDataError( + `${row}: ${field} ${text} has more than one decimal. The database stores one decimal and would round it.`, + ) + } + return text.includes('.') ? text : `${text}.0` +} + +/** + * Reads data/ and checks it against catalog constraints 1–5 in + * docs/DATA_MODEL.md and the shape the database needs. Throws a + * CatalogDataError at the first problem. + */ +export function loadCatalogData(dataDir: string): CatalogData { + const apparatusJson = readJson(dataDir, 'apparatus.json') + const groups = readJson(dataDir, 'elementGroups.json') + const variantGroupsJson = readJson( + dataDir, + 'variantGroups.json', + ) + const elements = readJson(dataDir, 'elements.json') + const vaults = readJson(dataDir, 'vaults.json') + + const apparatus = apparatusJson.map((a) => { + const position = APPARATUS_POSITION[a.id] + if (position === undefined) + throw new CatalogDataError( + `apparatus ${a.id}: unknown apparatus, no sort order`, + ) + return { id: a.id, name: a.name, position } + }) + const apparatusIds = new Set(apparatus.map((a) => a.id)) + const groupById = new Map(groups.map((g) => [g.id, g])) + for (const g of groups) { + if (!apparatusIds.has(g.apparatusId)) { + throw new CatalogDataError( + `element group ${g.id}: apparatus ${g.apparatusId} does not exist`, + ) + } + } + + // Variant groups: constraints 1 (at least two ids that exist) and 3 (at + // most one group per item) are checked here, 2 and 4 below. + const itemIds = new Set([...elements, ...vaults].map((i) => i.id)) + const variantGroupOf = new Map() + for (const vg of variantGroupsJson) { + if (vg.variantIds.length < 2) { + throw new CatalogDataError( + `variant group ${vg.id}: has fewer than two variants (constraint 1)`, + ) + } + for (const id of vg.variantIds) { + if (!itemIds.has(id)) + throw new CatalogDataError( + `variant group ${vg.id}: ${id} does not exist (constraint 1)`, + ) + const other = variantGroupOf.get(id) + if (other) + throw new CatalogDataError( + `${id}: is in variant groups ${other} and ${vg.id} (constraint 3)`, + ) + variantGroupOf.set(id, vg.id) + } + } + + const items: CatalogItem[] = [] + const seen = new Set() + const convert = ( + kind: CatalogItem['kind'], + raw: ElementJson | VaultJson, + ): CatalogItem => { + const row = `${kind} ${raw.id}` + if (seen.has(raw.id)) + throw new CatalogDataError(`${row}: the id is used more than once`) + seen.add(raw.id) + const group = groupById.get(raw.elementGroupId) + if (!group) + throw new CatalogDataError( + `${row}: element group ${raw.elementGroupId} does not exist`, + ) + if ((group.apparatusId === 'VT') !== (kind === 'vault')) { + throw new CatalogDataError( + `${row}: is in group ${group.id}, which is for ${kind === 'vault' ? 'elements' : 'vaults'}`, + ) + } + const field = kind === 'element' ? 'value' : 'dValue' + const value = oneDecimal( + kind === 'element' + ? (raw as ElementJson).value + : (raw as VaultJson).dValue, + row, + field, + ) + const number = Number(value) + if (kind === 'element' && (number < 0.1 || number > 1)) { + throw new CatalogDataError(`${row}: value ${value} is outside 0.1–1.0`) + } + if (kind === 'vault' && number <= 0) + throw new CatalogDataError( + `${row}: dValue ${value} is not greater than 0`, + ) + if (!raw.description?.trim()) + throw new CatalogDataError(`${row}: has no description`) + // Constraint 5: every element and vault has an image. + if (!raw.image) + throw new CatalogDataError(`${row}: has no image (constraint 5)`) + if (!existsSync(join(dataDir, 'images', group.apparatusId, raw.image))) { + throw new CatalogDataError( + `${row}: image ${group.apparatusId}/${raw.image} does not exist (constraint 5)`, + ) + } + return { + kind, + id: raw.id, + elementGroupId: raw.elementGroupId, + variantGroupId: variantGroupOf.get(raw.id) ?? null, + code: raw.code, + value, + description: raw.description, + name: raw.name, + image: raw.image, + } + } + elements.forEach((e) => items.push(convert('element', e))) + vaults.forEach((v) => items.push(convert('vault', v))) + + // Constraints 2 (one type per group) and 4 (same code and value). + const itemById = new Map(items.map((i) => [i.id, i])) + for (const vg of variantGroupsJson) { + const members = vg.variantIds.map((id) => itemById.get(id)!) + const first = members[0]! + for (const m of members) { + if (m.kind !== first.kind) + throw new CatalogDataError( + `variant group ${vg.id}: mixes elements and vaults (constraint 2)`, + ) + if (m.code !== first.code || m.value !== first.value) { + throw new CatalogDataError( + `variant group ${vg.id}: ${m.id} has code ${m.code} and value ${m.value}, but ${first.id} has code ${first.code} and value ${first.value} (constraint 4)`, + ) + } + } + } + + return { + apparatus, + groups, + variantGroups: variantGroupsJson.map((vg) => ({ id: vg.id })), + items, + } +} + +/** The code with each part padded to four digits, so "2.1" sorts before "10.1". */ +export function codeSort(code: string): string { + return code + .split('.') + .map((part) => part.padStart(4, '0')) + .join('.') +} diff --git a/backend/src/seed/seed.ts b/backend/src/seed/seed.ts new file mode 100644 index 0000000..c1b3c1e --- /dev/null +++ b/backend/src/seed/seed.ts @@ -0,0 +1,324 @@ +// Seeds the catalog from data/*.json and rebuilds the search tables. +// +// Run with `npm run db:seed` from the repository root. It is safe to run more +// than once: everything happens in one transaction, and a second run with the +// same data changes nothing in the catalog. User data (coaches, gymnasts, +// routines and their lists) is never touched. Rows whose id starts with "SYN-" +// are synthetic test data from another script and are left alone. +// +// Options: --data reads the JSON files from another folder than data/. + +import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' +import { createPrismaClient } from '../db.js' +import { searchWords } from '../catalog/words.js' +import { Prisma } from '../generated/prisma/client.js' +import { + type CatalogData, + type CatalogItem, + CatalogDataError, + codeSort, + loadCatalogData, +} from './catalogData.js' + +type Tx = Prisma.TransactionClient +type Counts = { + inserted: number + updated: number + removed: number + deleted: number +} + +const NOT_SYNTHETIC = { NOT: { id: { startsWith: 'SYN-' } } } +const DEFAULT_DATA_DIR = fileURLToPath( + new URL('../../../data/', import.meta.url), +) + +const counts = (): Counts => ({ + inserted: 0, + updated: 0, + removed: 0, + deleted: 0, +}) + +async function seedApparatusAndGroups( + tx: Tx, + data: CatalogData, + summary: Record, +) { + const apparatus = new Map( + (await tx.apparatus.findMany({ where: NOT_SYNTHETIC })).map((a) => [ + a.id, + a, + ]), + ) + for (const a of data.apparatus) { + const old = apparatus.get(a.id) + if (!old) { + await tx.apparatus.create({ data: a }) + summary.apparatus!.inserted++ + } else if (old.name !== a.name || old.position !== a.position) { + await tx.apparatus.update({ where: { id: a.id }, data: a }) + summary.apparatus!.updated++ + } + } + + const groups = new Map( + (await tx.elementGroup.findMany({ where: NOT_SYNTHETIC })).map((g) => [ + g.id, + g, + ]), + ) + for (const g of data.groups) { + const old = groups.get(g.id) + if (!old) { + await tx.elementGroup.create({ data: g }) + summary.elementGroups!.inserted++ + } else if ( + old.apparatusId !== g.apparatusId || + old.number !== g.number || + old.name !== g.name + ) { + await tx.elementGroup.update({ where: { id: g.id }, data: g }) + summary.elementGroups!.updated++ + } + } + + const variantGroups = new Set( + (await tx.variantGroup.findMany({ where: NOT_SYNTHETIC })).map( + (vg) => vg.id, + ), + ) + const newGroups = data.variantGroups.filter((vg) => !variantGroups.has(vg.id)) + if (newGroups.length) await tx.variantGroup.createMany({ data: newGroups }) + summary.variantGroups!.inserted += newGroups.length +} + +/** Upserts the elements or the vaults, and marks the ones missing from the data as removed. */ +async function seedItems( + tx: Tx, + kind: CatalogItem['kind'], + items: CatalogItem[], + summary: Counts, + now: Date, +) { + const table = kind === 'element' ? tx.element : tx.vault + type Row = { + id: string + elementGroupId: string + variantGroupId: string | null + code: string + description: string + name: string | null + image: string + removedAt: Date | null + value?: Prisma.Decimal + dValue?: Prisma.Decimal + } + const existing = new Map( + ( + (await (table as typeof tx.element).findMany({ + where: NOT_SYNTHETIC, + })) as Row[] + ).map((r) => [r.id, r]), + ) + const toRow = (i: CatalogItem) => ({ + id: i.id, + elementGroupId: i.elementGroupId, + variantGroupId: i.variantGroupId, + code: i.code, + description: i.description, + name: i.name, + image: i.image, + removedAt: null, + ...(kind === 'element' ? { value: i.value } : { dValue: i.value }), + }) + + const inserts = [] + for (const item of items) { + const old = existing.get(item.id) + if (!old) { + inserts.push(toRow(item)) + continue + } + const oldValue = kind === 'element' ? old.value : old.dValue + const changed = + old.elementGroupId !== item.elementGroupId || + old.variantGroupId !== item.variantGroupId || + old.code !== item.code || + old.description !== item.description || + old.name !== item.name || + old.image !== item.image || + old.removedAt !== null || // it was removed, and is back in the data + !oldValue?.equals(item.value) + if (changed) { + if (kind === 'element') + await tx.element.update({ + where: { id: item.id }, + data: toRow(item) as Prisma.ElementUncheckedUpdateInput, + }) + else + await tx.vault.update({ + where: { id: item.id }, + data: toRow(item) as Prisma.VaultUncheckedUpdateInput, + }) + summary.updated++ + } + } + if (inserts.length) { + if (kind === 'element') + await tx.element.createMany({ + data: inserts as Prisma.ElementCreateManyInput[], + }) + else + await tx.vault.createMany({ + data: inserts as Prisma.VaultCreateManyInput[], + }) + } + summary.inserted += inserts.length + + // Soft delete: in the database, but no longer in the data. + const inData = items.map((i) => i.id) + const where = { ...NOT_SYNTHETIC, id: { notIn: inData }, removedAt: null } + const removed = + kind === 'element' + ? await tx.element.updateMany({ where, data: { removedAt: now } }) + : await tx.vault.updateMany({ where, data: { removedAt: now } }) + summary.removed += removed.count + + // Clean-up: removed items that no routine and no gymnast list uses. + const unused = { + ...NOT_SYNTHETIC, + removedAt: { not: null }, + routineElements: { none: {} }, + gymnastElements: { none: {} }, + } + const deleted = + kind === 'element' + ? await tx.element.deleteMany({ where: unused }) + : await tx.vault.deleteMany({ where: unused }) + summary.deleted += deleted.count +} + +/** Deletes variant groups with fewer than two members, and unlinks their last member. */ +async function cleanVariantGroups(tx: Tx, summary: Counts) { + const groups = await tx.variantGroup.findMany({ + where: NOT_SYNTHETIC, + include: { _count: { select: { elements: true, vaults: true } } }, + }) + for (const group of groups) { + if (group._count.elements + group._count.vaults >= 2) continue + await tx.element.updateMany({ + where: { variantGroupId: group.id }, + data: { variantGroupId: null }, + }) + await tx.vault.updateMany({ + where: { variantGroupId: group.id }, + data: { variantGroupId: null }, + }) + await tx.variantGroup.delete({ where: { id: group.id } }) + summary.deleted++ + } +} + +/** + * Rebuilds CatalogEntry and CatalogWord from the catalog. One entry per code, + * from the first variant that is not removed. The words come from the code, + * name and description of all variants of the code that are not removed. + */ +async function rebuildSearchTables(tx: Tx, data: CatalogData) { + const deleted = await tx.catalogEntry.deleteMany({ where: NOT_SYNTHETIC }) // the words are deleted with them + + const apparatusOfGroup = new Map( + data.groups.map((g) => [g.id, g.apparatusId]), + ) + // Everything in the data is in the catalog and not removed after the seed. + const byCode = new Map() + for (const item of data.items) { + const key = `${apparatusOfGroup.get(item.elementGroupId)}-${item.code}` + byCode.set(key, [...(byCode.get(key) ?? []), item]) + } + + const entries: Prisma.CatalogEntryCreateManyInput[] = [] + const words: Prisma.CatalogWordCreateManyInput[] = [] + for (const variants of byCode.values()) { + variants.sort((a, b) => a.id.localeCompare(b.id)) + const first = variants[0]! + entries.push({ + id: first.id, + type: first.kind === 'element' ? 'ELEMENT' : 'VAULT', + code: first.code, + name: first.name, + description: first.description, + difficulty: first.value, + apparatusId: apparatusOfGroup.get(first.elementGroupId)!, + elementGroupId: first.elementGroupId, + hasName: first.name !== null, + nameSort: first.name?.toLowerCase() ?? '', + codeSort: codeSort(first.code), + }) + const entryWords = new Set() + for (const v of variants) { + for (const text of [v.code, v.name ?? '', v.description]) + searchWords(text).forEach((w) => entryWords.add(w)) + } + entryWords.forEach((word) => words.push({ entryId: first.id, word })) + } + await tx.catalogEntry.createMany({ data: entries }) + await tx.catalogWord.createMany({ data: words }) + return { + deletedEntries: deleted.count, + entries: entries.length, + words: words.length, + } +} + +export async function seed(dataDir: string) { + const data = loadCatalogData(dataDir) // throws before anything is written + const prisma = createPrismaClient() + const summary: Record = { + apparatus: counts(), + elementGroups: counts(), + variantGroups: counts(), + elements: counts(), + vaults: counts(), + } + try { + const now = new Date() + const search = await prisma.$transaction( + async (tx) => { + await seedApparatusAndGroups(tx, data, summary) + const elements = data.items.filter((i) => i.kind === 'element') + const vaults = data.items.filter((i) => i.kind === 'vault') + await seedItems(tx, 'element', elements, summary.elements!, now) + await seedItems(tx, 'vault', vaults, summary.vaults!, now) + await cleanVariantGroups(tx, summary.variantGroups!) + return rebuildSearchTables(tx, data) + }, + { timeout: 120_000, maxWait: 10_000 }, + ) + + console.log(`Seeded the catalog from ${dataDir}`) + console.table(summary) + console.log( + `Search tables rebuilt: ${search.entries} catalog entries and ${search.words} words (${search.deletedEntries} old entries replaced).`, + ) + } finally { + await prisma.$disconnect() + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const { values } = parseArgs({ options: { data: { type: 'string' } } }) + seed(values.data ?? DEFAULT_DATA_DIR).catch((error: unknown) => { + if (error instanceof CatalogDataError) { + console.error(`The seed stopped. Nothing was changed.\n${error.message}`) + } else { + console.error( + 'The seed failed. Nothing was changed, because everything runs in one transaction.', + ) + console.error(error) + } + process.exit(1) + }) +} diff --git a/backend/src/seed/synthetic.ts b/backend/src/seed/synthetic.ts new file mode 100644 index 0000000..79e72c9 --- /dev/null +++ b/backend/src/seed/synthetic.ts @@ -0,0 +1,122 @@ +// Adds or removes synthetic catalog rows for scalability tests (issue #25). +// +// npm run db:synthetic -- add [--count 100000] +// npm run db:synthetic -- remove +// +// The rows have ids that start with "SYN-" and groups of their own. The normal +// seed never runs this script and never touches these rows. `add` writes +// everything in one transaction, and refuses to run when synthetic rows +// already exist (run `remove` first). `remove` deletes only rows with a +// "SYN-" id, so the real catalog and user data are left alone. + +import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' +import { createPrismaClient } from '../db.js' +import type { Prisma } from '../generated/prisma/client.js' +import { loadCatalogData } from './catalogData.js' +import { SYNTHETIC_PREFIX, generateSynthetic } from './syntheticData.js' + +const DEFAULT_COUNT = 100_000 +const DEFAULT_DATA_DIR = fileURLToPath( + new URL('../../../data/', import.meta.url), +) +const IS_SYNTHETIC = { id: { startsWith: SYNTHETIC_PREFIX } } +/** Rows per createMany. Postgres allows at most 65535 parameters per query. */ +const BATCH = 5000 + +async function insertInBatches( + rows: T[], + insert: (batch: T[]) => Promise, +) { + for (let i = 0; i < rows.length; i += BATCH) + await insert(rows.slice(i, i + BATCH)) +} + +export async function addSynthetic(count: number, dataDir = DEFAULT_DATA_DIR) { + const rows = generateSynthetic(loadCatalogData(dataDir), count) + const prisma = createPrismaClient() + try { + const existing = await prisma.catalogEntry.count({ where: IS_SYNTHETIC }) + if (existing > 0) + throw new Error( + `${existing} synthetic entries already exist. Run "npm run db:synthetic -- remove" first.`, + ) + const started = Date.now() + await prisma.$transaction( + async (tx) => { + await tx.elementGroup.createMany({ data: rows.groups }) + await insertInBatches(rows.elements, (data) => + tx.element.createMany({ data }), + ) + await insertInBatches(rows.vaults, (data) => + tx.vault.createMany({ data }), + ) + await insertInBatches(rows.entries, (data) => + tx.catalogEntry.createMany({ + data: data as Prisma.CatalogEntryCreateManyInput[], + }), + ) + await insertInBatches(rows.words, (data) => + tx.catalogWord.createMany({ data }), + ) + }, + { timeout: 30 * 60_000, maxWait: 10_000 }, + ) + console.log( + `Added ${rows.entries.length} synthetic items (${rows.elements.length} elements, ${rows.vaults.length} vaults) in ${rows.groups.length} groups, with ${rows.words.length} search words, in ${((Date.now() - started) / 1000).toFixed(1)} s.`, + ) + console.log( + 'Run "npm run db:synthetic -- remove" to delete them. Run ANALYZE (see the README) before measuring.', + ) + } finally { + await prisma.$disconnect() + } +} + +export async function removeSynthetic() { + const prisma = createPrismaClient() + try { + const result = await prisma.$transaction( + async (tx) => { + // The words are deleted with their entries (cascade). + const entries = await tx.catalogEntry.deleteMany({ + where: IS_SYNTHETIC, + }) + const elements = await tx.element.deleteMany({ where: IS_SYNTHETIC }) + const vaults = await tx.vault.deleteMany({ where: IS_SYNTHETIC }) + const groups = await tx.elementGroup.deleteMany({ + where: IS_SYNTHETIC, + }) + return { entries, elements, vaults, groups } + }, + { timeout: 30 * 60_000, maxWait: 10_000 }, + ) + console.log( + `Removed ${result.entries.count} entries, ${result.elements.count} elements, ${result.vaults.count} vaults and ${result.groups.count} groups.`, + ) + } finally { + await prisma.$disconnect() + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const { values, positionals } = parseArgs({ + options: { count: { type: 'string' }, data: { type: 'string' } }, + allowPositionals: true, + }) + const command = positionals[0] + const run = + command === 'add' + ? addSynthetic(Number(values.count ?? DEFAULT_COUNT), values.data) + : command === 'remove' + ? removeSynthetic() + : Promise.reject( + new Error( + 'Usage: npm run db:synthetic -- add [--count 100000] or npm run db:synthetic -- remove', + ), + ) + run.catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exit(1) + }) +} diff --git a/backend/src/seed/syntheticData.test.ts b/backend/src/seed/syntheticData.test.ts new file mode 100644 index 0000000..2add6e9 --- /dev/null +++ b/backend/src/seed/syntheticData.test.ts @@ -0,0 +1,78 @@ +// Tests for the synthetic data generator. No database is needed. + +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { loadCatalogData } from './catalogData.js' +import { SYNTHETIC_PREFIX, generateSynthetic } from './syntheticData.js' + +const data = loadCatalogData( + fileURLToPath(new URL('../../../data/', import.meta.url)), +) + +describe('generateSynthetic', () => { + const rows = generateSynthetic(data, 5000) + + it('makes the requested number of items, spread over the apparatus', () => { + expect(rows.entries).toHaveLength(5000) + expect(rows.elements.length + rows.vaults.length).toBe(5000) + expect(rows.vaults.length).toBe(1250) // one in four is on vault + expect(new Set(rows.entries.map((e) => e.apparatusId))).toEqual( + new Set(['VT', 'UB', 'BB', 'FX']), + ) + }) + + it('starts every id with SYN- and gives the items groups of their own', () => { + const ids = [ + ...rows.groups.map((g) => g.id), + ...rows.elements.map((e) => e.id), + ...rows.vaults.map((v) => v.id), + ...rows.entries.map((e) => e.id), + ...rows.entries.map((e) => e.elementGroupId), + ] + expect(ids.every((id) => id.startsWith(SYNTHETIC_PREFIX))).toBe(true) + const realGroups = new Set(data.groups.map((g) => g.id)) + expect(rows.groups.some((g) => realGroups.has(g.id))).toBe(false) + }) + + it('has unique ids, and group numbers that do not clash with real groups', () => { + expect(new Set(rows.entries.map((e) => e.id)).size).toBe(5000) + expect(new Set(rows.groups.map((g) => g.id)).size).toBe(rows.groups.length) + const realNumbers = new Set( + data.groups.map((g) => `${g.apparatusId}-${g.number}`), + ) + for (const g of rows.groups) + expect(realNumbers.has(`${g.apparatusId}-${g.number}`)).toBe(false) + }) + + it('has valid values: 0.1-1.0 for elements, the real D-value for vaults', () => { + for (const e of rows.elements) { + expect(Number(e.value)).toBeGreaterThanOrEqual(0.1) + expect(Number(e.value)).toBeLessThanOrEqual(1) + } + const realVaultValues = new Set( + data.items.filter((i) => i.kind === 'vault').map((i) => i.value), + ) + for (const v of rows.vaults) + expect(realVaultValues.has(v.dValue)).toBe(true) + }) + + it('gives every entry search words, all pointing at an existing entry', () => { + const entryIds = new Set(rows.entries.map((e) => e.id)) + expect(rows.words.every((w) => entryIds.has(w.entryId))).toBe(true) + const withWords = new Set(rows.words.map((w) => w.entryId)) + expect(withWords.size).toBe(5000) + const keys = rows.words.map((w) => `${w.entryId}|${w.word}`) + expect(new Set(keys).size).toBe(keys.length) // the primary key (entry, word) + }) + + it('gives the same rows every time', () => { + expect(generateSynthetic(data, 100).entries).toEqual( + generateSynthetic(data, 100).entries, + ) + }) + + it('rejects a count that is not a positive whole number', () => { + expect(() => generateSynthetic(data, 0)).toThrow() + expect(() => generateSynthetic(data, 1.5)).toThrow() + }) +}) diff --git a/backend/src/seed/syntheticData.ts b/backend/src/seed/syntheticData.ts new file mode 100644 index 0000000..7a690fa --- /dev/null +++ b/backend/src/seed/syntheticData.ts @@ -0,0 +1,164 @@ +// Generates synthetic catalog rows for scalability tests (issue #25). Nothing +// here touches the database: synthetic.ts writes the rows. The rows look like +// the real catalog (descriptions are copied from real items, so the words are +// spread the same way), but every id starts with "SYN-" and every group is a +// group of its own, so the real catalog is never mixed up with them. + +import { searchWords } from '../catalog/words.js' +import { type CatalogData, codeSort } from './catalogData.js' + +export const SYNTHETIC_PREFIX = 'SYN-' + +/** Items per synthetic group. Keeps the sequence number in a code below 10000. */ +const GROUP_SIZE = 1000 +/** First group number. The real groups are numbered 1, 2, 3, ... */ +const FIRST_GROUP_NUMBER = 1001 +const APPARATUS_ORDER = ['VT', 'UB', 'BB', 'FX'] as const +/** Share of the items that are named after a gymnast. */ +const NAMED_SHARE = 0.05 + +export type SyntheticRows = { + groups: { + id: string + apparatusId: string + number: number + name: string + }[] + elements: { + id: string + elementGroupId: string + code: string + value: string + description: string + name: string | null + image: string + }[] + vaults: { + id: string + elementGroupId: string + code: string + dValue: string + description: string + name: string | null + image: string + }[] + entries: { + id: string + type: 'ELEMENT' | 'VAULT' + code: string + name: string | null + description: string + difficulty: string + apparatusId: string + elementGroupId: string + hasName: boolean + nameSort: string + codeSort: string + }[] + words: { entryId: string; word: string }[] +} + +/** A small, fast random number generator, so the same count gives the same rows. */ +function mulberry32(seed: number): () => number { + let a = seed + return () => { + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +/** + * `count` synthetic items spread over the four apparatus (vault on VT, the + * others elements). The real catalog in `data` is only used as a source of + * descriptions and vault values. + */ +export function generateSynthetic( + data: CatalogData, + count: number, +): SyntheticRows { + if (!Number.isInteger(count) || count < 1) + throw new Error(`The count must be a whole number of at least 1: ${count}`) + + const apparatusOfGroup = new Map( + data.groups.map((g) => [g.id, g.apparatusId]), + ) + const templates = new Map() + for (const item of data.items) { + const apparatusId = apparatusOfGroup.get(item.elementGroupId)! + templates.set(apparatusId, [...(templates.get(apparatusId) ?? []), item]) + } + for (const apparatusId of APPARATUS_ORDER) + if (!templates.get(apparatusId)?.length) + throw new Error(`The catalog has no items on ${apparatusId} to copy`) + + const random = mulberry32(25) + const rows: SyntheticRows = { + groups: [], + elements: [], + vaults: [], + entries: [], + words: [], + } + const groupIds = new Set() + + for (let i = 0; i < count; i++) { + const apparatusId = APPARATUS_ORDER[i % APPARATUS_ORDER.length]! + const indexOnApparatus = Math.floor(i / APPARATUS_ORDER.length) + const number = + FIRST_GROUP_NUMBER + Math.floor(indexOnApparatus / GROUP_SIZE) + const code = `${number}.${(indexOnApparatus % GROUP_SIZE) + 1}` + const groupId = `${SYNTHETIC_PREFIX}${apparatusId}-${number}` + const id = `${SYNTHETIC_PREFIX}${apparatusId}-${code}-a` + + if (!groupIds.has(groupId)) { + groupIds.add(groupId) + rows.groups.push({ + id: groupId, + apparatusId, + number, + name: `Synthetic group ${number}`, + }) + } + + const options = templates.get(apparatusId)! + const template = options[Math.floor(random() * options.length)]! + const name = random() < NAMED_SHARE ? `Syn${i}` : null + const isVault = apparatusId === 'VT' + const difficulty = isVault + ? template.value + : ((1 + Math.floor(random() * 10)) / 10).toFixed(1) // 0.1 ... 1.0 + + const common = { + id, + elementGroupId: groupId, + code, + description: template.description, + name, + image: 'synthetic.png', + } + if (isVault) rows.vaults.push({ ...common, dValue: difficulty }) + else rows.elements.push({ ...common, value: difficulty }) + + rows.entries.push({ + id, + type: isVault ? 'VAULT' : 'ELEMENT', + code, + name, + description: template.description, + difficulty, + apparatusId, + elementGroupId: groupId, + hasName: name !== null, + nameSort: name?.toLowerCase() ?? '', + codeSort: codeSort(code), + }) + + const words = new Set() + for (const text of [code, name ?? '', template.description]) + searchWords(text).forEach((w) => words.add(w)) + words.forEach((word) => rows.words.push({ entryId: id, word })) + } + return rows +} diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts new file mode 100644 index 0000000..8c52cea --- /dev/null +++ b/backend/src/server/app.ts @@ -0,0 +1,71 @@ +// The HTTP app: the GraphQL endpoint, the drawings and a health check. It is +// built from a Prisma client and the settings, so the tests can make one +// against any database and call it in the same way as the real server. + +import { ApolloServer } from '@apollo/server' +import { expressMiddleware } from '@as-integrations/express5' +import cors from 'cors' +import express, { type Express } from 'express' +import { fileURLToPath } from 'node:url' +import type { PrismaClient } from '../generated/prisma/client.js' +import type { Config } from './config.js' +import { type Context, createContext } from './context.js' +import { MAX_QUERY_DEPTH, depthLimit } from './depthLimit.js' +import { formatError } from './formatError.js' +import { resolvers } from './resolvers/index.js' +import { loadTypeDefs } from './schema.js' + +/** The drawings (data/images//.webp). Apache serves them on the virtual machine. */ +const IMAGES_DIR = new URL('../../../data/images/', import.meta.url) + +/** The GraphQL server. `executeOperation` works on it without HTTP, for tests. */ +export function createApolloServer(): ApolloServer { + return new ApolloServer({ + typeDefs: loadTypeDefs(), + resolvers, + validationRules: [depthLimit(MAX_QUERY_DEPTH)], + formatError, + includeStacktraceInErrorResponses: false, + }) +} + +export async function createApp(options: { + prisma: PrismaClient + config: Config +}): Promise<{ app: Express; server: ApolloServer }> { + const { prisma, config } = options + const server = createApolloServer() + await server.start() + + const app = express() + app.disable('x-powered-by') + + // For the process manager and the proxy: the server is up. It does not touch the database. + app.get('/health', (_req, res) => { + res.json({ status: 'ok' }) + }) + + // The drawings never change under the same name, so the browser may keep them. + app.use( + '/images', + express.static(fileURLToPath(IMAGES_DIR), { + maxAge: '30d', + immutable: true, + index: false, + }), + ) + + app.use( + '/graphql', + cors({ origin: config.corsOrigins }), + express.json({ limit: '100kb' }), + expressMiddleware(server, { + context: async ({ req }) => + createContext(prisma, config, { + authorization: req.headers.authorization, + }), + }), + ) + + return { app, server } +} diff --git a/backend/src/server/auth.test.ts b/backend/src/server/auth.test.ts new file mode 100644 index 0000000..880fd31 --- /dev/null +++ b/backend/src/server/auth.test.ts @@ -0,0 +1,327 @@ +// Tests for the access token and the refresh token through GraphQL (me, +// refreshSession, logout). The coach and the first tokens are made directly, +// because the login with Google is another pull request. The time is set by +// the tests. They run against the local database from backend/.env and write +// test coaches and sessions, which they delete again. Without DATABASE_URL they +// are skipped. + +import type { ApolloServer } from '@apollo/server' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { signAccessToken } from '../auth/accessToken.js' +import { hashToken, startSession } from '../auth/sessions.js' +import { createPrismaClient } from '../db.js' +import type { PrismaClient } from '../generated/prisma/client.js' +import { createApolloServer, createApp } from './app.js' +import type { Config } from './config.js' +import { type Context, createContext } from './context.js' + +const hasDatabase = Boolean(process.env['DATABASE_URL']) +const TEST_ID = 'test-70-session' +const START = new Date('2026-10-17T10:00:00Z') +const minutes = (n: number) => new Date(START.getTime() + n * 60 * 1000) +const days = (n: number) => minutes(n * 24 * 60) + +const config: Config = { + port: 3001, + imageBaseUrl: 'http://images.test/images', + corsOrigins: ['http://allowed.test'], + sessionSecret: 'a-secret-that-is-at-least-32-characters-long', +} + +type GqlError = { message: string; extensions?: Record } +type Result = { + data: T | null | undefined + errors: readonly GqlError[] | undefined +} +type Payload = { + accessToken: string + refreshToken: string + coach: { id: string; name: string; email: string } +} + +const REFRESH = `mutation ($t: String!) { refreshSession(refreshToken: $t) { accessToken refreshToken coach { id } } }` +const LOGOUT = `mutation ($t: String!) { logout(refreshToken: $t) }` +const ME = `{ me { id name email } }` + +describe.runIf(hasDatabase)('the session', () => { + let prisma: PrismaClient + let server: ApolloServer + + const clean = () => + prisma.coach.deleteMany({ where: { googleId: { startsWith: TEST_ID } } }) + + async function run( + query: string, + variables?: Record, + options: { + accessToken?: string + now?: Date + config?: Config + } = {}, + ): Promise> { + const now = options.now ?? START + const response = await server.executeOperation( + { query, variables }, + { + contextValue: createContext(prisma, options.config ?? config, { + authorization: options.accessToken + ? `Bearer ${options.accessToken}` + : undefined, + now: () => now, + }), + }, + ) + if (response.body.kind !== 'single') throw new Error('expected one result') + const { data, errors } = response.body.singleResult + return { data: data as T | null | undefined, errors } + } + + const codeOf = (r: Result) => r.errors?.[0]?.extensions?.['code'] + + /** A coach with a first access token and refresh token, made without the login. */ + async function signIn(now = START): Promise { + const coach = await prisma.coach.upsert({ + where: { googleId: TEST_ID }, + create: { googleId: TEST_ID, email: 'anna@example.com', name: 'Anna' }, + update: {}, + }) + return { + accessToken: await signAccessToken(coach.id, config.sessionSecret!, now), + refreshToken: await startSession(prisma, coach.id, now), + coach: { id: coach.id, name: coach.name, email: coach.email }, + } + } + + beforeAll(async () => { + prisma = createPrismaClient() + server = createApolloServer() + await server.start() + await clean() + }) + + afterAll(async () => { + await clean() + await server.stop() + await prisma.$disconnect() + }) + + describe('me and the protected operations', () => { + it('returns the coach for a valid access token', async () => { + const { accessToken, coach } = await signIn() + const result = await run<{ me: { id: string; name: string } }>( + ME, + undefined, + { + accessToken, + }, + ) + expect(result.errors).toBeUndefined() + expect(result.data!.me).toMatchObject({ id: coach.id, name: 'Anna' }) + }) + + it('returns null without a token, with a bad token and after 15 minutes', async () => { + const { accessToken } = await signIn() + const tampered = accessToken.slice(0, -3) + 'abc' + const cases: [string, Parameters[2]][] = [ + ['no token', {}], + ['a bad token', { accessToken: 'not-a-token' }], + ['a changed token', { accessToken: tampered }], + ['an expired token', { accessToken, now: minutes(16) }], + [ + 'a token signed with another key', + { + accessToken, + config: { ...config, sessionSecret: 'x'.repeat(40) }, + }, + ], + ] + for (const [label, options] of cases) { + const result = await run<{ me: null }>(ME, undefined, options) + expect(result.errors, label).toBeUndefined() + expect(result.data!.me, label).toBeNull() + } + // Still valid just before the 15 minutes are over. + const stillValid = await run<{ me: { id: string } | null }>( + ME, + undefined, + { + accessToken, + now: minutes(14), + }, + ) + expect(stillValid.data!.me).not.toBeNull() + }) + + it('returns null when the coach no longer exists', async () => { + const { accessToken } = await signIn() + await clean() + const result = await run<{ me: null }>(ME, undefined, { accessToken }) + expect(result.errors).toBeUndefined() + expect(result.data!.me).toBeNull() + }) + + it('throws UNAUTHENTICATED for a protected operation without a valid token', async () => { + const { accessToken } = await signIn() + const query = 'mutation { createGymnast(name: "A") { id } }' + expect(codeOf(await run(query))).toBe('UNAUTHENTICATED') + expect(codeOf(await run(query, undefined, { accessToken: 'nope' }))).toBe( + 'UNAUTHENTICATED', + ) + expect( + codeOf(await run(query, undefined, { accessToken, now: minutes(20) })), + ).toBe('UNAUTHENTICATED') + // With a valid token the login check passes. + expect(codeOf(await run(query, undefined, { accessToken }))).not.toBe( + 'UNAUTHENTICATED', + ) + }) + }) + + describe('refreshSession', () => { + it('gives new tokens, and the old refresh token stops working', async () => { + const first = await signIn() + const refreshed = await run<{ refreshSession: Payload }>( + REFRESH, + { t: first.refreshToken }, + { now: minutes(20) }, + ) + expect(refreshed.errors).toBeUndefined() + const next = refreshed.data!.refreshSession + expect(next.coach.id).toBe(first.coach.id) + expect(next.refreshToken).not.toBe(first.refreshToken) + expect(next.accessToken).not.toBe(first.accessToken) + + // The new access token works when the first one has expired. + const me = await run<{ me: { id: string } }>(ME, undefined, { + accessToken: next.accessToken, + now: minutes(21), + }) + expect(me.data!.me.id).toBe(first.coach.id) + + const again = await run(REFRESH, { t: first.refreshToken }) + expect(codeOf(again)).toBe('UNAUTHENTICATED') + }) + + it('ends the whole login when an old refresh token is used again', async () => { + const first = await signIn() + const second = await run<{ refreshSession: Payload }>(REFRESH, { + t: first.refreshToken, + }) + const newest = second.data!.refreshSession.refreshToken + + expect(codeOf(await run(REFRESH, { t: first.refreshToken }))).toBe( + 'UNAUTHENTICATED', + ) + expect(codeOf(await run(REFRESH, { t: newest }))).toBe('UNAUTHENTICATED') + }) + + it('refuses an expired, unknown or empty refresh token', async () => { + const { refreshToken } = await signIn() + const expired = await run(REFRESH, { t: refreshToken }, { now: days(15) }) + expect(codeOf(expired)).toBe('UNAUTHENTICATED') + for (const t of ['', 'unknown', 'x'.repeat(5000)]) { + expect(codeOf(await run(REFRESH, { t }))).toBe('UNAUTHENTICATED') + } + }) + + it('removes the sessions that have expired', async () => { + const { refreshToken, coach } = await signIn() + const old = await prisma.session.create({ + data: { + coachId: coach.id, + familyId: 'old-family', + tokenHash: hashToken('an-old-token'), + expiresAt: new Date(START.getTime() - 1000), + }, + }) + await run(REFRESH, { t: refreshToken }) + expect(await prisma.session.findUnique({ where: { id: old.id } })).toBe( + null, + ) + }) + + it('does not accept the access token as a refresh token', async () => { + const { accessToken } = await signIn() + expect(codeOf(await run(REFRESH, { t: accessToken }))).toBe( + 'UNAUTHENTICATED', + ) + }) + }) + + describe('logout', () => { + it('ends the login: the refresh token stops working', async () => { + const { refreshToken } = await signIn() + const result = await run<{ logout: boolean }>(LOGOUT, { t: refreshToken }) + expect(result.errors).toBeUndefined() + expect(result.data!.logout).toBe(true) + expect(codeOf(await run(REFRESH, { t: refreshToken }))).toBe( + 'UNAUTHENTICATED', + ) + }) + + it('is true also for an unknown token and when it is done twice', async () => { + const { refreshToken } = await signIn() + for (const t of [refreshToken, refreshToken, 'unknown', '']) { + const result = await run<{ logout: boolean }>(LOGOUT, { t }) + expect(result.errors).toBeUndefined() + expect(result.data!.logout).toBe(true) + } + }) + }) + + describe('over HTTP', () => { + let base: string + let closeServer: () => Promise + + beforeAll(async () => { + const { app, server: apollo } = await createApp({ + prisma, + config, + }) + const listener = app.listen(0) + await new Promise((resolve) => listener.once('listening', resolve)) + base = `http://127.0.0.1:${(listener.address() as AddressInfo).port}` + closeServer = async () => { + await new Promise((resolve) => listener.close(resolve)) + await apollo.stop() + } + }) + + afterAll(async () => { + await closeServer() + }) + + const post = async ( + query: string, + variables?: object, + authorization?: string, + ) => { + const response = await fetch(`${base}/graphql`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(authorization ? { authorization } : {}), + }, + body: JSON.stringify({ query, variables }), + }) + return (await response.json()) as { + data?: Record + errors?: GqlError[] + } + } + + it('reads me with the Authorization header', async () => { + // The HTTP server checks the token against the real clock, not START. + const { accessToken, coach } = await signIn(new Date()) + + const me = await post(ME, undefined, `Bearer ${accessToken}`) + expect((me.data!['me'] as { id: string }).id).toBe(coach.id) + + const anonymous = await post(ME) + expect(anonymous.data!['me']).toBeNull() + const wrongScheme = await post(ME, undefined, `Basic ${accessToken}`) + expect(wrongScheme.data!['me']).toBeNull() + }) + }) +}) diff --git a/backend/src/server/calculator.test.ts b/backend/src/server/calculator.test.ts new file mode 100644 index 0000000..e221325 --- /dev/null +++ b/backend/src/server/calculator.test.ts @@ -0,0 +1,175 @@ +// Tests for the calculator query. They run against the local database from +// backend/.env and need the seeded catalog (run `npm run db:seed` first). They +// only read. Without DATABASE_URL they are skipped. + +import type { ApolloServer } from '@apollo/server' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { calculateDScore } from '../dscore.js' +import { createPrismaClient } from '../db.js' +import type { PrismaClient } from '../generated/prisma/client.js' +import { createApolloServer } from './app.js' +import type { Config } from './config.js' +import { type Context, createContext } from './context.js' +import { MAX_CALCULATOR_IDS } from './resolvers/calculator.js' + +const hasDatabase = Boolean(process.env['DATABASE_URL']) +const config: Config = { + port: 3001, + imageBaseUrl: 'http://images.test/images', + corsOrigins: [], +} + +type GqlError = { message: string; extensions?: Record } +type Result = { + data: { calculateDScore: number } | null | undefined + errors: readonly GqlError[] | undefined +} + +const QUERY = 'query ($ids: [ID!]!) { calculateDScore(itemIds: $ids) }' + +describe.runIf(hasDatabase)('calculateDScore', () => { + let prisma: PrismaClient + let server: ApolloServer + + const run = async ( + ids: string[], + client: PrismaClient = prisma, + ): Promise => { + const response = await server.executeOperation( + { query: QUERY, variables: { ids } }, + { contextValue: createContext(client, config) }, + ) + if (response.body.kind !== 'single') throw new Error('expected one result') + const { data, errors } = response.body.singleResult + return { data: data as Result['data'], errors } + } + const codeOf = (r: Result) => r.errors?.[0]?.extensions?.['code'] + + beforeAll(async () => { + prisma = createPrismaClient() + server = createApolloServer() + await server.start() + }) + + afterAll(async () => { + await server.stop() + await prisma.$disconnect() + }) + + /** The values of the first n elements on an apparatus, from the database. */ + async function elements(apparatusId: string, n: number) { + const rows = await prisma.element.findMany({ + where: { id: { startsWith: `${apparatusId}-` }, removedAt: null }, + orderBy: { id: 'asc' }, + }) + const seen = new Set() + return rows.filter((r) => !seen.has(r.code) && seen.add(r.code)).slice(0, n) + } + + it('is open to everyone: no login is needed', async () => { + const result = await run([]) + expect(result.errors).toBeUndefined() + expect(result.data!.calculateDScore).toBe(0) + }) + + it('gives the same score as the function for elements of the catalog', async () => { + const rows = await elements('UB', 10) + const expected = calculateDScore( + rows.map((r) => ({ + id: r.id, + code: r.code, + value: r.value, + isVault: false, + })), + ) + const result = await run(rows.map((r) => r.id)) + expect(result.errors).toBeUndefined() + expect(result.data!.calculateDScore).toBe(expected) + expect(expected).toBeGreaterThan(0) + }) + + it('gives the D-value of a vault', async () => { + const vault = await prisma.vault.findFirstOrThrow({ + orderBy: { id: 'asc' }, + }) + const result = await run([vault.id]) + expect(result.data!.calculateDScore).toBe(Number(vault.dValue.toString())) + }) + + it('counts the same id twice as once, and a variant of the same code once', async () => { + const [first] = await elements('BB', 1) + const once = await run([first!.id]) + const twice = await run([first!.id, first!.id]) + expect(twice.data!.calculateDScore).toBe(once.data!.calculateDScore) + }) + + it('refuses items that do not fit together with INVALID_ITEMS', async () => { + const [ub] = await elements('UB', 1) + const [bb] = await elements('BB', 1) + const vault = await prisma.vault.findFirstOrThrow({ + orderBy: { id: 'asc' }, + }) + const second = await prisma.vault.findFirstOrThrow({ + where: { code: { not: vault.code } }, + orderBy: { id: 'asc' }, + }) + expect(codeOf(await run([ub!.id, bb!.id]))).toBe('INVALID_ITEMS') + expect(codeOf(await run([ub!.id, vault.id]))).toBe('INVALID_ITEMS') + expect(codeOf(await run([vault.id, second.id]))).toBe('INVALID_ITEMS') + }) + + it('refuses a removed item with INVALID_ITEMS', async () => { + const [row] = await elements('UB', 1) + const fake = { + element: { + findMany: () => Promise.resolve([{ ...row!, removedAt: new Date() }]), + }, + vault: { findMany: () => Promise.resolve([]) }, + } as unknown as PrismaClient + const result = await run([row!.id], fake) + expect(codeOf(result)).toBe('INVALID_ITEMS') + expect(result.errors![0]!.message).toContain( + 'no longer in the Code of Points', + ) + }) + + it('refuses too many ids, an unknown id and a badly formed id with BAD_USER_INPUT', async () => { + const many = Array.from( + { length: MAX_CALCULATOR_IDS + 1 }, + (_, i) => `UB-1.${i}-a`, + ) + expect(codeOf(await run(many))).toBe('BAD_USER_INPUT') + expect(codeOf(await run(['UB-99.999-a']))).toBe('BAD_USER_INPUT') + for (const bad of [ + '', + "UB-1.101-a'; DROP TABLE elements; --", + 'x'.repeat(100), + '%', + ]) { + expect(codeOf(await run([bad])), bad).toBe('BAD_USER_INPUT') + } + // Exactly 50 ids is fine. + const fifty = (await elements('FX', MAX_CALCULATOR_IDS)).map((r) => r.id) + expect(fifty.length).toBeGreaterThan(8) + expect((await run(fifty)).errors).toBeUndefined() + }) + + it('uses a fixed number of queries, not one per id', async () => { + const queries: string[] = [] + const { PrismaClient: Logging } = + await import('../generated/prisma/client.js') + const { PrismaPg } = await import('@prisma/adapter-pg') + const logging = new Logging({ + adapter: new PrismaPg({ connectionString: process.env['DATABASE_URL']! }), + log: [{ emit: 'event', level: 'query' }], + }) + logging.$on('query', (e) => queries.push(e.query)) + try { + const ids = (await elements('UB', 30)).map((r) => r.id) + await run(ids, logging as unknown as PrismaClient) + expect(queries.length).toBeLessThanOrEqual(2) + } finally { + await logging.$disconnect() + } + }) +}) diff --git a/backend/src/server/config.test.ts b/backend/src/server/config.test.ts new file mode 100644 index 0000000..4bc02b3 --- /dev/null +++ b/backend/src/server/config.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { DEFAULT_CORS_ORIGIN, DEFAULT_PORT, readConfig } from './config.js' + +describe('readConfig', () => { + it('has defaults for development', () => { + expect(readConfig({})).toEqual({ + port: DEFAULT_PORT, + imageBaseUrl: `http://localhost:${DEFAULT_PORT}/images`, + corsOrigins: [DEFAULT_CORS_ORIGIN], + }) + }) + + it('takes the image address from the port when it is not set', () => { + expect(readConfig({ PORT: '4000' }).imageBaseUrl).toBe( + 'http://localhost:4000/images', + ) + expect(readConfig({ PORT: '4000', IMAGE_BASE_URL: '' }).imageBaseUrl).toBe( + 'http://localhost:4000/images', + ) + }) + + it('removes the final slash of the image address', () => { + expect( + readConfig({ + IMAGE_BASE_URL: 'http://it2810-00.idi.ntnu.no/project2/images///', + }).imageBaseUrl, + ).toBe('http://it2810-00.idi.ntnu.no/project2/images') + }) + + it('reads several allowed origins, separated by commas', () => { + expect( + readConfig({ CORS_ORIGIN: 'http://a.example, http://b.example ,' }) + .corsOrigins, + ).toEqual(['http://a.example', 'http://b.example']) + }) + + it('stops with a clear message when the port is not a port', () => { + for (const port of ['abc', '0', '70000', '30.5', '-1']) { + expect(() => readConfig({ PORT: port })).toThrow( + /PORT must be a whole number from 1 to 65535/, + ) + } + }) + + describe('the session secret', () => { + const SECRET = 'a-secret-that-is-at-least-32-characters-long' + + it('reads the session secret', () => { + expect(readConfig({ SESSION_SECRET: SECRET }).sessionSecret).toBe(SECRET) + }) + + it('has no session secret when it is not set', () => { + const config = readConfig({ SESSION_SECRET: ' ' }) + expect(config).not.toHaveProperty('sessionSecret') + }) + + it('stops in production when the secret is missing', () => { + expect(() => readConfig({ NODE_ENV: 'production' })).toThrow( + /SESSION_SECRET is not set/, + ) + expect(() => + readConfig({ NODE_ENV: 'production', SESSION_SECRET: ' ' }), + ).toThrow(/SESSION_SECRET is not set/) + }) + + it('starts in production when the secret is set', () => { + expect( + readConfig({ NODE_ENV: 'production', SESSION_SECRET: SECRET }) + .sessionSecret, + ).toBe(SECRET) + }) + + it('stops when the secret is too short, and does not print it', () => { + const weak = 'password-1234' + try { + readConfig({ SESSION_SECRET: weak }) + expect.unreachable() + } catch (error) { + expect((error as Error).message).toMatch(/at least 32 characters/) + expect((error as Error).message).not.toContain(weak) + } + }) + }) +}) diff --git a/backend/src/server/config.ts b/backend/src/server/config.ts new file mode 100644 index 0000000..cbfeaf8 --- /dev/null +++ b/backend/src/server/config.ts @@ -0,0 +1,67 @@ +// The settings of the server, read from environment variables (backend/.env, +// see backend/.env.example). They are read once at start-up. + +export type Config = { + /** The port the server listens on. */ + port: number + /** + * The address the drawings are served from, without a final slash. The API + * gives `${imageBaseUrl}/${apparatus}/${file}` as `imageUrl`. + */ + imageBaseUrl: string + /** The addresses (origins) that may call the API from a browser. */ + corsOrigins: string[] + /** + * The key that signs the access tokens. At least 32 characters. Without it + * there are no tokens, and `me` is always null. + */ + sessionSecret?: string +} + +export const MIN_SESSION_SECRET_LENGTH = 32 + +export const DEFAULT_PORT = 3001 +export const DEFAULT_CORS_ORIGIN = 'http://localhost:5173' // the Vite dev server + +/** Reads and checks the settings. A wrong value stops the start-up with a clear message. */ +export function readConfig(env: NodeJS.ProcessEnv = process.env): Config { + const rawPort = env['PORT'] + const port = + rawPort === undefined || rawPort === '' ? DEFAULT_PORT : Number(rawPort) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error( + `PORT must be a whole number from 1 to 65535, not "${String(rawPort)}".`, + ) + } + + const rawBase = env['IMAGE_BASE_URL'] + const imageBaseUrl = ( + rawBase === undefined || rawBase === '' + ? `http://localhost:${port}/images` + : rawBase + ).replace(/\/+$/, '') + + const corsOrigins = (env['CORS_ORIGIN'] ?? DEFAULT_CORS_ORIGIN) + .split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin !== '') + + const sessionSecret = env['SESSION_SECRET']?.trim() || undefined + if (sessionSecret && sessionSecret.length < MIN_SESSION_SECRET_LENGTH) { + throw new Error( + `SESSION_SECRET must be at least ${MIN_SESSION_SECRET_LENGTH} characters. Make one with: node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))"`, + ) + } + + if (!sessionSecret && env['NODE_ENV'] === 'production') { + throw new Error( + 'SESSION_SECRET is not set. In production the server does not start without it, so that a forgotten secret is found at once and not when a coach cannot log in.', + ) + } + return { + port, + imageBaseUrl, + corsOrigins, + ...(sessionSecret ? { sessionSecret } : {}), + } +} diff --git a/backend/src/server/context.ts b/backend/src/server/context.ts new file mode 100644 index 0000000..11d0c3e --- /dev/null +++ b/backend/src/server/context.ts @@ -0,0 +1,66 @@ +// What every resolver can use. A new context is made for each request. + +import { verifyAccessToken } from '../auth/accessToken.js' +import { UnauthenticatedError } from '../errors.js' +import type { PrismaClient } from '../generated/prisma/client.js' +import type { Config } from './config.js' +import { type Loaders, createLoaders } from './loaders.js' + +export type Context = { + prisma: PrismaClient + config: Config + loaders: Loaders + /** + * The id of the logged-in coach, or null when the request has no valid + * access token. The token is checked once per request, the first time + * something asks. + */ + getCoachId: () => Promise + /** The time now. The tests set it to test expiry. */ + now: () => Date +} + +export type ContextOptions = { + /** The value of the Authorization header, if there is one. */ + authorization?: string | undefined + now?: () => Date +} + +/** The token in "Bearer ", or null. */ +export function bearerToken(header: string | undefined): string | null { + const match = /^Bearer ([^\s]+)$/i.exec(header ?? '') + return match?.[1] ?? null +} + +export function createContext( + prisma: PrismaClient, + config: Config, + options: ContextOptions = {}, +): Context { + const now = options.now ?? (() => new Date()) + const token = bearerToken(options.authorization) + + let coachId: Promise | undefined + const getCoachId = () => { + coachId ??= + token && config.sessionSecret + ? verifyAccessToken(token, config.sessionSecret, now()) + : Promise.resolve(null) + return coachId + } + + return { + prisma, + config, + loaders: createLoaders(prisma), + getCoachId, + now, + } +} + +/** The id of the logged-in coach. Throws UNAUTHENTICATED when nobody is logged in. */ +export async function requireCoachId(context: Context): Promise { + const coachId = await context.getCoachId() + if (!coachId) throw new UnauthenticatedError() + return coachId +} diff --git a/backend/src/server/depthLimit.test.ts b/backend/src/server/depthLimit.test.ts new file mode 100644 index 0000000..ff1571f --- /dev/null +++ b/backend/src/server/depthLimit.test.ts @@ -0,0 +1,64 @@ +import { buildSchema, parse, validate } from 'graphql' +import { describe, expect, it } from 'vitest' +import { depthLimit } from './depthLimit.js' + +// A small schema with a cycle, like Apparatus → groups → apparatus in the real one. +const schema = buildSchema(` + type Node { name: String, next: Node } + type Query { node: Node } +`) + +/** The messages from the depth rule for a query. */ +const errorsFor = (query: string, max: number) => + validate(schema, parse(query), [depthLimit(max)]).map((e) => e.message) + +describe('depthLimit', () => { + it('accepts a query at the limit and rejects one level deeper', () => { + // node { next { next { name } } } is 4 levels deep. + const query = '{ node { next { next { name } } } }' + expect(errorsFor(query, 4)).toEqual([]) + expect(errorsFor(query, 3)).toEqual([ + 'The query is 4 levels deep. The limit is 3.', + ]) + }) + + it('counts through inline fragments and named fragments', () => { + const inline = + '{ node { ... on Node { next { ... on Node { next { name } } } } } }' + expect(errorsFor(inline, 4)).toEqual([]) + expect(errorsFor(inline, 3)).toHaveLength(1) + + const named = ` + query { node { ...Level1 } } + fragment Level1 on Node { next { ...Level2 } } + fragment Level2 on Node { next { name } } + ` + expect(errorsFor(named, 4)).toEqual([]) + expect(errorsFor(named, 3)).toHaveLength(1) + }) + + it('does not count the introspection fields', () => { + const introspection = + '{ __schema { types { fields { type { ofType { ofType { name } } } } } } }' + expect(errorsFor(introspection, 2)).toEqual([]) + }) + + it('does not loop on a fragment that uses itself', () => { + const cyclic = ` + query { node { ...A } } + fragment A on Node { next { ...A } } + ` + // Another rule reports the cycle. This rule only has to finish. + expect(() => errorsFor(cyclic, 8)).not.toThrow() + }) + + it('checks every operation of the document', () => { + const query = ` + query Shallow { node { name } } + query Deep { node { next { next { next { name } } } } } + ` + expect(errorsFor(query, 3)).toEqual([ + 'The query is 5 levels deep. The limit is 3.', + ]) + }) +}) diff --git a/backend/src/server/depthLimit.ts b/backend/src/server/depthLimit.ts new file mode 100644 index 0000000..d7fafd7 --- /dev/null +++ b/backend/src/server/depthLimit.ts @@ -0,0 +1,75 @@ +// A limit on how deep a query can be. The schema has cycles (Apparatus → +// groups → apparatus → …), so without a limit a client could ask for a query of +// any depth. The check is a validation rule: a query that is too deep is +// rejected before anything runs, with the code GRAPHQL_VALIDATION_FAILED. +// +// The introspection fields (__schema, __type) are not counted, because the +// tools that read the schema (Apollo Sandbox) ask a deep question on purpose. + +import { + GraphQLError, + Kind, + type ASTVisitor, + type FragmentDefinitionNode, + type SelectionSetNode, + type ValidationContext, +} from 'graphql' + +/** The deepest query the server accepts. `searchCatalog { edges { node { … } } }` is 3 levels before its fields. */ +export const MAX_QUERY_DEPTH = 8 + +function depthOf( + set: SelectionSetNode, + fragments: ReadonlyMap, + visiting: ReadonlySet, +): number { + let deepest = 0 + for (const selection of set.selections) { + if (selection.kind === Kind.FIELD) { + if (selection.name.value.startsWith('__')) continue + const below = selection.selectionSet + ? depthOf(selection.selectionSet, fragments, visiting) + : 0 + deepest = Math.max(deepest, 1 + below) + } else if (selection.kind === Kind.INLINE_FRAGMENT) { + deepest = Math.max( + deepest, + depthOf(selection.selectionSet, fragments, visiting), + ) + } else { + const name = selection.name.value + const fragment = fragments.get(name) + // A fragment that uses itself is reported by another rule; do not loop here. + if (!fragment || visiting.has(name)) continue + deepest = Math.max( + deepest, + depthOf(fragment.selectionSet, fragments, new Set([...visiting, name])), + ) + } + } + return deepest +} + +export function depthLimit(maxDepth: number) { + return (context: ValidationContext): ASTVisitor => { + const fragments = new Map() + for (const definition of context.getDocument().definitions) { + if (definition.kind === Kind.FRAGMENT_DEFINITION) { + fragments.set(definition.name.value, definition) + } + } + return { + OperationDefinition(operation) { + const depth = depthOf(operation.selectionSet, fragments, new Set()) + if (depth > maxDepth) { + context.reportError( + new GraphQLError( + `The query is ${depth} levels deep. The limit is ${maxDepth}.`, + { nodes: [operation] }, + ), + ) + } + }, + } + } +} diff --git a/backend/src/server/formatError.test.ts b/backend/src/server/formatError.test.ts new file mode 100644 index 0000000..42d8ca7 --- /dev/null +++ b/backend/src/server/formatError.test.ts @@ -0,0 +1,70 @@ +import { GraphQLError } from 'graphql' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + BadUserInputError, + NotImplementedError, + UnauthenticatedError, +} from '../errors.js' +import { formatError } from './formatError.js' + +/** What Apollo hands to formatError for an error thrown in a resolver. */ +const thrownInResolver = (error: Error) => + new GraphQLError(error.message, { + path: ['somewhere'], + originalError: error, + extensions: { code: 'INTERNAL_SERVER_ERROR' }, + }) + +describe('formatError', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it.each([ + [new BadUserInputError('first must be 1–100'), 'BAD_USER_INPUT'], + [new UnauthenticatedError(), 'UNAUTHENTICATED'], + [new NotImplementedError('login'), 'NOT_IMPLEMENTED'], + ])('gives %s its own code and keeps the message', (error, code) => { + const error2 = thrownInResolver(error) + const result = formatError(error2.toJSON(), error2) + expect(result.extensions).toEqual({ code }) + expect(result.message).toBe(error.message) + expect(result.path).toEqual(['somewhere']) + }) + + it('hides the details of an unexpected error and logs the real one', () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const error = thrownInResolver( + new Error('password=secret at db.internal:5432'), + ) + const result = formatError(error.toJSON(), error) + + expect(result.message).toBe('Internal server error') + expect(result.extensions).toEqual({ code: 'INTERNAL_SERVER_ERROR' }) + expect(JSON.stringify(result)).not.toContain('secret') + expect(log).toHaveBeenCalledOnce() + }) + + it('hides an unexpected error even when it carries another code or none', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const withOtherCode = new GraphQLError('db.internal:5432 refused', { + originalError: new Error('db.internal:5432 refused'), + extensions: { code: 'SOMETHING_ELSE' }, + }) + const withoutCode = new GraphQLError('stack at /srv/app.js', { + originalError: new Error('stack at /srv/app.js'), + }) + for (const error of [withOtherCode, withoutCode]) { + const result = formatError(error.toJSON(), error) + expect(result.message).toBe('Internal server error') + expect(result.extensions).toEqual({ code: 'INTERNAL_SERVER_ERROR' }) + } + }) + + it('leaves the errors from GraphQL itself as they are', () => { + const error = new GraphQLError('The query is 9 levels deep.', { + extensions: { code: 'GRAPHQL_VALIDATION_FAILED' }, + }) + expect(formatError(error.toJSON(), error)).toEqual(error.toJSON()) + }) +}) diff --git a/backend/src/server/formatError.ts b/backend/src/server/formatError.ts new file mode 100644 index 0000000..0be3fde --- /dev/null +++ b/backend/src/server/formatError.ts @@ -0,0 +1,48 @@ +// How errors look to the client (see "Error handling" in docs/API.md). +// - The errors we throw on purpose (BadUserInputError and the others in +// src/errors.ts) get their code in `extensions.code`. +// - Errors from GraphQL itself (an invalid query, a query that is too deep) +// keep their own code, for example GRAPHQL_VALIDATION_FAILED. +// - Anything else is a bug or a failure on our side: the client gets a +// general message and no details, and the real error goes to the log. + +import type { GraphQLFormattedError } from 'graphql' +import { isClientError } from '../errors.js' + +/** + * The error that a resolver threw. GraphQL wraps it in its own error and keeps + * it as `originalError`. This reads that field directly and does not use + * `instanceof GraphQLError` (as Apollo's `unwrapResolverError` does), because + * that check fails when two copies of the `graphql` package are loaded. + */ +function unwrapResolverError(error: unknown): unknown { + const original = (error as { originalError?: unknown } | null)?.originalError + return original ?? error +} + +export function formatError( + formatted: GraphQLFormattedError, + error: unknown, +): GraphQLFormattedError { + const original = unwrapResolverError(error) + if (isClientError(original)) { + return { ...formatted, extensions: { code: original.code } } + } + // An error that wraps something we did not throw on purpose is always masked, + // whatever code it carries. A bare error without a code is masked too. + const wrapsUnknownError = original !== error + const code = formatted.extensions?.['code'] + if ( + wrapsUnknownError || + code === undefined || + code === 'INTERNAL_SERVER_ERROR' + ) { + console.error('Internal server error:', original) + return { + message: 'Internal server error', + extensions: { code: 'INTERNAL_SERVER_ERROR' }, + ...(formatted.path ? { path: formatted.path } : {}), + } + } + return formatted +} diff --git a/backend/src/server/items.ts b/backend/src/server/items.ts new file mode 100644 index 0000000..bc67332 --- /dev/null +++ b/backend/src/server/items.ts @@ -0,0 +1,46 @@ +// The catalog items as the resolvers see them. The database has elements and +// vaults in two tables. GraphQL has one interface, CatalogItem, with the types +// Element and Vault. Each row gets a `__typename`, which is how the interface is +// resolved to one of the two types. + +import type { Element, Vault } from '../generated/prisma/client.js' + +export type ElementItem = Element & { __typename: 'Element' } +export type VaultItem = Vault & { __typename: 'Vault' } +export type Item = ElementItem | VaultItem + +/** + * Vault ids and the ids of vault variant groups start with "VT-" (see + * "Vault is its own entity" in docs/DATA_MODEL.md). + */ +export const isVaultId = (id: string): boolean => id.startsWith('VT-') + +/** Ids look like "UB-2.305-a". This keeps odd text out of the queries and the messages. */ +export const ITEM_ID_PATTERN = /^[A-Za-z0-9.-]{1,40}$/ + +/** The apparatus an item belongs to: the first part of its id ("UB" in "UB-2.305-a"). */ +export const apparatusOfId = (id: string): string => id.split('-')[0] ?? id + +export const asElement = (row: Element): ElementItem => ({ + ...row, + __typename: 'Element', +}) +export const asVault = (row: Vault): VaultItem => ({ + ...row, + __typename: 'Vault', +}) + +export const isElementItem = (item: Item): item is ElementItem => + item.__typename === 'Element' +export const isVaultItem = (item: Item): item is VaultItem => + item.__typename === 'Vault' + +/** + * What the search resolver returns for CatalogConnection. `totalCount` is only + * there when the query asked for it (it costs a count over all the matches). + */ +export type ConnectionRow = { + edges: { cursor: string; node: Item }[] + pageInfo: { hasNextPage: boolean; endCursor: string | null } + totalCount?: number +} diff --git a/backend/src/server/loaders.ts b/backend/src/server/loaders.ts new file mode 100644 index 0000000..52369fe --- /dev/null +++ b/backend/src/server/loaders.ts @@ -0,0 +1,137 @@ +// DataLoaders: they collect the lookups that the resolvers make during one +// request and send one query for each kind, instead of one query for each +// search result. A new set is made for every request, so nothing is shared +// between requests (see "N+1 queries" in docs/API.md). + +import DataLoader from 'dataloader' +import type { + Apparatus, + Element, + Gymnast, + PrismaClient, + Routine, + Vault, +} from '../generated/prisma/client.js' +import { type Item, asElement, asVault, isVaultId } from './items.js' + +/** The element or the vault of a list row (exactly one of them is set). */ +const itemOf = (row: { + element: Element | null + vault: Vault | null +}): Item[] => + row.element ? [asElement(row.element)] : row.vault ? [asVault(row.vault)] : [] + +export function createLoaders(prisma: PrismaClient) { + /** An element or vault by its id, also when it is removed. `null` when it does not exist. */ + const itemById = new DataLoader(async (ids) => { + const elementIds = ids.filter((id) => !isVaultId(id)) + const vaultIds = ids.filter(isVaultId) + const [elements, vaults] = await Promise.all([ + elementIds.length > 0 + ? prisma.element.findMany({ where: { id: { in: elementIds } } }) + : [], + vaultIds.length > 0 + ? prisma.vault.findMany({ where: { id: { in: vaultIds } } }) + : [], + ]) + const byId = new Map() + for (const row of elements) byId.set(row.id, asElement(row)) + for (const row of vaults) byId.set(row.id, asVault(row)) + return ids.map((id) => byId.get(id) ?? null) + }) + + /** The members of a variant group that are still in the Code of Points, in id order. */ + const variantsOfGroup = new DataLoader(async (groupIds) => { + const elementGroups = groupIds.filter((id) => !isVaultId(id)) + const vaultGroups = groupIds.filter(isVaultId) + const [elements, vaults] = await Promise.all([ + elementGroups.length > 0 + ? prisma.element.findMany({ + where: { variantGroupId: { in: elementGroups }, removedAt: null }, + orderBy: { id: 'asc' }, + }) + : [], + vaultGroups.length > 0 + ? prisma.vault.findMany({ + where: { variantGroupId: { in: vaultGroups }, removedAt: null }, + orderBy: { id: 'asc' }, + }) + : [], + ]) + const byGroup = new Map() + const add = (groupId: string | null, item: Item) => { + if (groupId === null) return + byGroup.set(groupId, [...(byGroup.get(groupId) ?? []), item]) + } + for (const row of elements) add(row.variantGroupId, asElement(row)) + for (const row of vaults) add(row.variantGroupId, asVault(row)) + return groupIds.map((id) => byGroup.get(id) ?? []) + }) + + const apparatusById = new DataLoader( + async (ids) => { + const rows = await prisma.apparatus.findMany({ + where: { id: { in: [...ids] } }, + }) + const byId = new Map(rows.map((row) => [row.id, row])) + return ids.map((id) => byId.get(id) ?? null) + }, + ) + + const gymnastById = new DataLoader(async (ids) => { + const rows = await prisma.gymnast.findMany({ + where: { id: { in: [...ids] } }, + }) + const byId = new Map(rows.map((row) => [row.id, row])) + return ids.map((id) => byId.get(id) ?? null) + }) + + /** The routines of each gymnast: by apparatus, then by name. At most 50 each. */ + const routinesOfGymnast = new DataLoader(async (ids) => { + const rows = await prisma.routine.findMany({ + where: { gymnastId: { in: [...ids] } }, + orderBy: [ + { apparatus: { position: 'asc' } }, + { name: 'asc' }, + { id: 'asc' }, + ], + }) + return ids.map((id) => rows.filter((row) => row.gymnastId === id)) + }) + + /** The list of each gymnast, in the order the items were added. At most 100 each. */ + const elementsOfGymnast = new DataLoader(async (ids) => { + const rows = await prisma.gymnastElement.findMany({ + where: { gymnastId: { in: [...ids] } }, + orderBy: { id: 'asc' }, + include: { element: true, vault: true }, + }) + return ids.map((id) => + rows.filter((row) => row.gymnastId === id).flatMap(itemOf), + ) + }) + + /** The items of each routine, in the order of the routine. The same item can be there twice. */ + const itemsOfRoutine = new DataLoader(async (ids) => { + const rows = await prisma.routineElement.findMany({ + where: { routineId: { in: [...ids] } }, + orderBy: [{ routineId: 'asc' }, { position: 'asc' }], + include: { element: true, vault: true }, + }) + return ids.map((id) => + rows.filter((row) => row.routineId === id).flatMap(itemOf), + ) + }) + + return { + itemById, + variantsOfGroup, + apparatusById, + gymnastById, + routinesOfGymnast, + elementsOfGymnast, + itemsOfRoutine, + } +} + +export type Loaders = ReturnType diff --git a/backend/src/server/resolvers/auth.ts b/backend/src/server/resolvers/auth.ts new file mode 100644 index 0000000..ecf811e --- /dev/null +++ b/backend/src/server/resolvers/auth.ts @@ -0,0 +1,92 @@ +// Login, the session and the logged-in coach (issue #70). How it works is in +// "Identifying the user" in docs/API.md. + +import { signAccessToken } from '../../auth/accessToken.js' +import { + deleteExpiredSessions, + endSession, + rotateSession, +} from '../../auth/sessions.js' +import { NotImplementedError, UnauthenticatedError } from '../../errors.js' +import type { Resolvers } from '../../generated/graphql.js' +import type { Coach } from '../../generated/prisma/client.js' +import type { Context } from '../context.js' + +/** Longer than any token we make. */ +const MAX_TOKEN_LENGTH = 4096 + +const notImplemented = (operation: string) => (): never => { + throw new NotImplementedError(operation) +} + +const tokenFrom = (value: string, message: string): string => { + if (value === '' || value.length > MAX_TOKEN_LENGTH) { + throw new UnauthenticatedError(message) + } + return value +} + +/** The result of login and refreshSession: new tokens and the coach. */ +async function authPayload( + context: Context, + coach: Coach, + refreshToken: string, +) { + const accessToken = await signAccessToken( + coach.id, + context.config.sessionSecret!, // checked by the caller + context.now(), + ) + return { accessToken, refreshToken, coach } +} + +export const authResolvers = { + Query: { + /** The logged-in coach, or null. Not being logged in is a normal state, so this never throws. */ + me: async (_parent: unknown, _args: unknown, context: Context) => { + const coachId = await context.getCoachId() + if (!coachId) return null + return context.prisma.coach.findUnique({ where: { id: coachId } }) + }, + }, + + Mutation: { + /** The Google login is another pull request (issue #70, part 2). */ + login: notImplemented('login'), + + /** Gives a new access token and a new refresh token for a refresh token. */ + refreshSession: async ( + _parent: unknown, + args: { refreshToken: string }, + context: Context, + ) => { + if (!context.config.sessionSecret) { + throw new NotImplementedError('refreshSession') + } + await deleteExpiredSessions(context.prisma, context.now()) + const rotated = await rotateSession( + context.prisma, + tokenFrom( + args.refreshToken, + 'The refresh token is not valid. Log in again.', + ), + context.now(), + ) + const coach = await context.prisma.coach.findUnique({ + where: { id: rotated.coachId }, + }) + if (!coach) throw new UnauthenticatedError('Log in again.') + return authPayload(context, coach, rotated.refreshToken) + }, + + /** Ends the login. It is true also for an unknown token, so tokens cannot be probed. */ + logout: async ( + _parent: unknown, + args: { refreshToken: string }, + context: Context, + ) => { + await endSession(context.prisma, args.refreshToken, context.now()) + return true + }, + }, +} satisfies Resolvers diff --git a/backend/src/server/resolvers/calculator.ts b/backend/src/server/resolvers/calculator.ts new file mode 100644 index 0000000..3136d68 --- /dev/null +++ b/backend/src/server/resolvers/calculator.ts @@ -0,0 +1,63 @@ +// The calculator: the D-score of a list of items, for everyone. Nothing is saved. + +import { calculateDScore, type ScoredItem } from '../../dscore.js' +import { BadUserInputError, InvalidItemsError } from '../../errors.js' +import type { Resolvers } from '../../generated/graphql.js' +import type { Context } from '../context.js' +import { ITEM_ID_PATTERN } from '../items.js' + +/** The most ids one call may have (also in docs/API.md). */ +export const MAX_CALCULATOR_IDS = 50 + +export const calculatorResolvers = { + Query: { + calculateDScore: async ( + _parent: unknown, + args: { itemIds: string[] }, + context: Context, + ): Promise => { + if (args.itemIds.length > MAX_CALCULATOR_IDS) { + throw new BadUserInputError( + `At most ${MAX_CALCULATOR_IDS} ids can be calculated at once.`, + ) + } + if (args.itemIds.some((id) => !ITEM_ID_PATTERN.test(id))) { + throw new BadUserInputError('An item id is not valid.') + } + const ids = [...new Set(args.itemIds)] // the same id twice counts once + + const [elements, vaults] = await Promise.all([ + context.prisma.element.findMany({ where: { id: { in: ids } } }), + context.prisma.vault.findMany({ where: { id: { in: ids } } }), + ]) + + const found = new Set([...elements, ...vaults].map((item) => item.id)) + const unknown = ids.find((id) => !found.has(id)) + if (unknown !== undefined) { + throw new BadUserInputError(`There is no item with id ${unknown}.`) + } + const removed = [...elements, ...vaults].find((item) => item.removedAt) + if (removed) { + throw new InvalidItemsError( + `${removed.id} is no longer in the Code of Points. Remove it to get a score.`, + ) + } + + const items: ScoredItem[] = [ + ...elements.map((e) => ({ + id: e.id, + code: e.code, + value: e.value, + isVault: false, + })), + ...vaults.map((v) => ({ + id: v.id, + code: v.code, + value: v.dValue, + isVault: true, + })), + ] + return calculateDScore(items) + }, + }, +} satisfies Resolvers diff --git a/backend/src/server/resolvers/catalog.ts b/backend/src/server/resolvers/catalog.ts new file mode 100644 index 0000000..c3d8bfb --- /dev/null +++ b/backend/src/server/resolvers/catalog.ts @@ -0,0 +1,174 @@ +// The resolvers of the catalog: apparatus, groups, elements, vaults and the +// search. They are thin: the search itself is in backend/src/catalog/search.ts, +// and the loaders collect the database lookups (see loaders.ts). + +import type { GraphQLResolveInfo } from 'graphql' +import type { Resolvers } from '../../generated/graphql.js' +import type { Apparatus, ElementGroup } from '../../generated/prisma/client.js' +import { + type CatalogSearchArgs, + listElementGroups, + searchCatalog, +} from '../../catalog/search.js' +import { catalogStructure } from '../../catalog/structure.js' +import type { Context } from '../context.js' +import { + type ConnectionRow, + type ElementItem, + type Item, + type VaultItem, + apparatusOfId, + isElementItem, + isVaultItem, +} from '../items.js' +import { selectsField } from '../selectsField.js' + +type SearchArgs = Omit + +/** The fields that Element and Vault share (the CatalogItem interface). */ +const itemFields = { + /** The address of the drawing: the base address, the apparatus folder and the file name. */ + imageUrl: (item: Item, _args: unknown, ctx: Context): string => + `${ctx.config.imageBaseUrl}/${apparatusOfId(item.id)}/${item.image}`, + + elementGroup: async ( + item: Item, + _args: unknown, + ctx: Context, + ): Promise => { + const group = (await catalogStructure(ctx.prisma)).groupsById.get( + item.elementGroupId, + ) + if (!group) + throw new Error( + `The element group ${item.elementGroupId} does not exist.`, + ) + return group + }, + + removed: (item: Item): boolean => item.removedAt !== null, +} + +/** The other ways of performing the item that are still in the Code of Points. */ +async function otherVariants(item: Item, ctx: Context): Promise { + if (item.variantGroupId === null) return [] + const members = await ctx.loaders.variantsOfGroup.load(item.variantGroupId) + return members.filter((member) => member.id !== item.id) +} + +export const catalogResolvers = { + Query: { + apparatus: ( + _parent: unknown, + _args: unknown, + ctx: Context, + ): Promise => + ctx.prisma.apparatus.findMany({ orderBy: { position: 'asc' } }), + + elementGroup: async ( + _parent: unknown, + args: { id: string }, + ctx: Context, + ): Promise => + (await catalogStructure(ctx.prisma)).groupsById.get(args.id) ?? null, + + element: async (_parent: unknown, args: { id: string }, ctx: Context) => { + const item = await ctx.loaders.itemById.load(args.id) + return item?.__typename === 'Element' ? item : null + }, + + vault: async (_parent: unknown, args: { id: string }, ctx: Context) => { + const item = await ctx.loaders.itemById.load(args.id) + return item?.__typename === 'Vault' ? item : null + }, + + catalogItem: (_parent: unknown, args: { id: string }, ctx: Context) => + ctx.loaders.itemById.load(args.id), + + /** + * The search returns one catalog entry per code. The element or vault + * behind each entry is loaded in one batch, so a page costs a fixed number + * of queries. The total count is only calculated when the query asks for it. + */ + searchCatalog: async ( + _parent: unknown, + args: SearchArgs, + ctx: Context, + info: GraphQLResolveInfo, + ) => { + const result = await searchCatalog(ctx.prisma, { + ...args, + withTotalCount: selectsField(info, 'totalCount'), + }) + const items = await ctx.loaders.itemById.loadMany( + result.edges.map((edge) => edge.node.id), + ) + const edges = result.edges.map((edge, index) => { + const item = items[index] + if (!item || item instanceof Error) { + throw new Error( + `The catalog entry ${edge.node.id} has no element or vault. Run the seed again.`, + ) + } + return { cursor: edge.cursor, node: item } + }) + return { + edges, + pageInfo: result.pageInfo, + ...(result.totalCount !== undefined + ? { totalCount: result.totalCount } + : {}), + } + }, + }, + + Apparatus: { + groups: ( + apparatus: Apparatus, + _args: unknown, + ctx: Context, + ): Promise => listElementGroups(ctx.prisma, apparatus.id), + }, + + ElementGroup: { + apparatus: async ( + group: ElementGroup, + _args: unknown, + ctx: Context, + ): Promise => { + const apparatus = await ctx.loaders.apparatusById.load(group.apparatusId) + // The database guarantees it (foreign key), and the schema says it is never null. + if (!apparatus) + throw new Error(`The apparatus ${group.apparatusId} does not exist.`) + return apparatus + }, + }, + + CatalogConnection: { + /** Only calculated when the query asks for it, see selectsField. */ + totalCount: (connection: ConnectionRow): number => { + if (connection.totalCount === undefined) + throw new Error('totalCount was not calculated for this query.') + return connection.totalCount + }, + }, + + /** Tells GraphQL which type an item is: the rows carry a `__typename`. */ + CatalogItem: { + __resolveType: (item: Item): 'Element' | 'Vault' => item.__typename, + }, + + Element: { + ...itemFields, + value: (item: ElementItem): number => item.value.toNumber(), + variants: async (item: ElementItem, _args: unknown, ctx: Context) => + (await otherVariants(item, ctx)).filter(isElementItem), + }, + + Vault: { + ...itemFields, + dValue: (item: VaultItem): number => item.dValue.toNumber(), + variants: async (item: VaultItem, _args: unknown, ctx: Context) => + (await otherVariants(item, ctx)).filter(isVaultItem), + }, +} satisfies Resolvers diff --git a/backend/src/server/resolvers/index.ts b/backend/src/server/resolvers/index.ts new file mode 100644 index 0000000..41f68de --- /dev/null +++ b/backend/src/server/resolvers/index.ts @@ -0,0 +1,36 @@ +import { GraphQLScalarType } from 'graphql' +import { authResolvers } from './auth.js' +import { calculatorResolvers } from './calculator.js' +import { catalogResolvers } from './catalog.js' +import { userDataResolvers } from './userData.js' + +/** DateTime: an ISO 8601 date and time in the answer, a Date in the resolvers. */ +const DateTime = new GraphQLScalarType({ + name: 'DateTime', + serialize: (value) => { + if (!(value instanceof Date)) throw new TypeError('Expected a Date.') + return value.toISOString() + }, + parseValue: (value) => { + const date = new Date(String(value)) + if (Number.isNaN(date.getTime())) throw new TypeError('Not a date.') + return date + }, +}) + +/** All the resolvers, merged by type. Add a file per area and merge it here. */ +export const resolvers = { + DateTime, + ...catalogResolvers, + ...userDataResolvers, + Query: { + ...catalogResolvers.Query, + ...calculatorResolvers.Query, + ...authResolvers.Query, + ...userDataResolvers.Query, + }, + Mutation: { + ...authResolvers.Mutation, + ...userDataResolvers.Mutation, + }, +} diff --git a/backend/src/server/resolvers/userData.ts b/backend/src/server/resolvers/userData.ts new file mode 100644 index 0000000..4bb2619 --- /dev/null +++ b/backend/src/server/resolvers/userData.ts @@ -0,0 +1,357 @@ +// The user data: gymnasts, their lists of elements, and routines (issue #73). +// The rules are in "User data" and "Limits" in docs/API.md: +// - every operation needs a login, and a coach only reaches their own data. +// Another coach's id gives NOT_FOUND, the same as an id that does not exist, +// so nobody can find out which ids exist +// - names are trimmed and 1-70 characters, the lists have limits +// - adding an item that is already in the gymnast's list, or removing one that +// is not, changes nothing and gives no error + +import { calculateDScore, toScoredItem } from '../../dscore.js' +import { + BadUserInputError, + InvalidItemsError, + LimitReachedError, + NameTakenError, + NotFoundError, +} from '../../errors.js' +import type { Resolvers } from '../../generated/graphql.js' +import { + type Gymnast, + Prisma, + type Routine, +} from '../../generated/prisma/client.js' +import { + MAX_ELEMENTS_PER_GYMNAST, + MAX_GYMNASTS_PER_COACH, + MAX_ITEMS_PER_ROUTINE, + MAX_ROUTINES_PER_GYMNAST, + cleanName, +} from '../../userData/rules.js' +import { type Context, requireCoachId } from '../context.js' +import { ITEM_ID_PATTERN, apparatusOfId, isVaultId } from '../items.js' + +/** A unique index was broken: two requests made the same thing at the same moment, or the name exists. */ +const isUniqueViolation = (error: unknown): boolean => + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + +/** The coach's own gymnast. Someone else's gymnast is NOT_FOUND, like one that does not exist. */ +async function ownGymnast( + context: Context, + coachId: string, + id: string, +): Promise { + const gymnast = await context.prisma.gymnast.findFirst({ + where: { id, coachId }, + }) + if (!gymnast) throw new NotFoundError('The gymnast') + return gymnast +} + +/** The coach's own routine (through its gymnast). */ +async function ownRoutine( + context: Context, + coachId: string, + id: string, +): Promise { + const routine = await context.prisma.routine.findFirst({ + where: { id, gymnast: { coachId } }, + }) + if (!routine) throw new NotFoundError('The routine') + return routine +} + +/** The loaders keep what they have loaded during one request. A change must make them forget it. */ +const forgetGymnast = (context: Context, gymnastId: string) => { + context.loaders.gymnastById.clear(gymnastId) + context.loaders.elementsOfGymnast.clear(gymnastId) + context.loaders.routinesOfGymnast.clear(gymnastId) +} + +const NAME_TAKEN = + 'The gymnast already has a routine with this name on this apparatus.' + +export const userDataResolvers = { + Query: { + gymnast: async (_parent, args, context) => + ownGymnast(context, await requireCoachId(context), args.id), + + routine: async (_parent, args, context) => + ownRoutine(context, await requireCoachId(context), args.id), + }, + + Coach: { + /** At most 200, see MAX_GYMNASTS_PER_COACH. */ + gymnasts: (coach, _args, context) => + context.prisma.gymnast.findMany({ + where: { coachId: coach.id }, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + take: MAX_GYMNASTS_PER_COACH, + }), + }, + + Gymnast: { + routines: async (gymnast, args, context) => { + const routines = await context.loaders.routinesOfGymnast.load(gymnast.id) + return args.apparatusId + ? routines.filter((r) => r.apparatusId === args.apparatusId) + : routines + }, + + elements: async (gymnast, args, context) => { + const items = await context.loaders.elementsOfGymnast.load(gymnast.id) + return args.apparatusId + ? items.filter((item) => apparatusOfId(item.id) === args.apparatusId) + : items + }, + }, + + Routine: { + gymnast: async (routine, _args, context) => { + const gymnast = await context.loaders.gymnastById.load(routine.gymnastId) + if (!gymnast) throw new NotFoundError('The gymnast') // the foreign key makes this impossible + return gymnast + }, + + apparatus: async (routine, _args, context) => { + const apparatus = await context.loaders.apparatusById.load( + routine.apparatusId, + ) + if (!apparatus) throw new Error(`No apparatus ${routine.apparatusId}.`) + return apparatus + }, + + items: (routine, _args, context) => + context.loaders.itemsOfRoutine.load(routine.id), + + /** null while the routine has a removed item, like the calculator refuses one. */ + dScore: async (routine, _args, context) => { + const items = await context.loaders.itemsOfRoutine.load(routine.id) + if (items.some((item) => item.removedAt !== null)) return null + return calculateDScore(items.map(toScoredItem)) + }, + }, + + Mutation: { + createGymnast: async (_parent, args, context) => { + const coachId = await requireCoachId(context) + const name = cleanName(args.name, 'gymnast') + const count = await context.prisma.gymnast.count({ where: { coachId } }) + if (count >= MAX_GYMNASTS_PER_COACH) { + throw new LimitReachedError( + `A coach can have at most ${MAX_GYMNASTS_PER_COACH} gymnasts.`, + ) + } + return context.prisma.gymnast.create({ data: { coachId, name } }) + }, + + renameGymnast: async (_parent, args, context) => { + const coachId = await requireCoachId(context) + await ownGymnast(context, coachId, args.id) + const name = cleanName(args.name, 'gymnast') + const gymnast = await context.prisma.gymnast.update({ + where: { id: args.id }, + data: { name }, + }) + forgetGymnast(context, gymnast.id) + return gymnast + }, + + /** Also deletes the routines and the list (the database does it). Returns the id. */ + deleteGymnast: async (_parent, args, context) => { + const coachId = await requireCoachId(context) + await ownGymnast(context, coachId, args.id) + await context.prisma.gymnast.delete({ where: { id: args.id } }) + forgetGymnast(context, args.id) + return args.id + }, + + addGymnastElement: async (_parent, args, context) => { + const coachId = await requireCoachId(context) + const gymnast = await ownGymnast(context, coachId, args.gymnastId) + if (!ITEM_ID_PATTERN.test(args.itemId)) { + throw new NotFoundError('The item') + } + const item = await context.loaders.itemById.load(args.itemId) + if (!item) throw new NotFoundError('The item') + + const row = + item.__typename === 'Vault' + ? { gymnastId: gymnast.id, vaultId: item.id } + : { gymnastId: gymnast.id, elementId: item.id } + // Already in the list: nothing to do, and no error. + const existing = await context.prisma.gymnastElement.findFirst({ + where: row, + }) + if (!existing) { + if (item.removedAt !== null) { + throw new InvalidItemsError( + `${item.id} is no longer in the Code of Points and cannot be added.`, + ) + } + const count = await context.prisma.gymnastElement.count({ + where: { gymnastId: gymnast.id }, + }) + if (count >= MAX_ELEMENTS_PER_GYMNAST) { + throw new LimitReachedError( + `A gymnast can have at most ${MAX_ELEMENTS_PER_GYMNAST} items in the list.`, + ) + } + try { + await context.prisma.gymnastElement.create({ data: row }) + } catch (error) { + // Two requests added the same item at the same moment: it is in the list. + if (!isUniqueViolation(error)) throw error + } + } + forgetGymnast(context, gymnast.id) + return gymnast + }, + + removeGymnastElement: async (_parent, args, context) => { + const coachId = await requireCoachId(context) + const gymnast = await ownGymnast(context, coachId, args.gymnastId) + // Also an item that has been removed from the Code of Points. An item that is not in the list does nothing. + await context.prisma.gymnastElement.deleteMany({ + where: isVaultId(args.itemId) + ? { gymnastId: gymnast.id, vaultId: args.itemId } + : { gymnastId: gymnast.id, elementId: args.itemId }, + }) + forgetGymnast(context, gymnast.id) + return gymnast + }, + + createRoutine: async (_parent, args, context) => { + const coachId = await requireCoachId(context) + const { gymnastId, apparatusId } = args.input + const gymnast = await ownGymnast(context, coachId, gymnastId) + if (!(await context.loaders.apparatusById.load(apparatusId))) { + throw new BadUserInputError('There is no apparatus with that id.') + } + const name = cleanName(args.input.name, 'routine') + const count = await context.prisma.routine.count({ + where: { gymnastId: gymnast.id }, + }) + if (count >= MAX_ROUTINES_PER_GYMNAST) { + throw new LimitReachedError( + `A gymnast can have at most ${MAX_ROUTINES_PER_GYMNAST} routines.`, + ) + } + try { + const routine = await context.prisma.routine.create({ + data: { gymnastId: gymnast.id, apparatusId, name }, + }) + forgetGymnast(context, gymnast.id) + return routine + } catch (error) { + if (isUniqueViolation(error)) throw new NameTakenError(NAME_TAKEN) + throw error + } + }, + + renameRoutine: async (_parent, args, context) => { + const coachId = await requireCoachId(context) + const routine = await ownRoutine(context, coachId, args.id) + const name = cleanName(args.name, 'routine') + try { + const renamed = await context.prisma.routine.update({ + where: { id: routine.id }, + data: { name }, + }) + forgetGymnast(context, routine.gymnastId) + return renamed + } catch (error) { + if (isUniqueViolation(error)) throw new NameTakenError(NAME_TAKEN) + throw error + } + }, + + deleteRoutine: async (_parent, args, context) => { + const coachId = await requireCoachId(context) + const routine = await ownRoutine(context, coachId, args.id) + await context.prisma.routine.delete({ where: { id: routine.id } }) + forgetGymnast(context, routine.gymnastId) + context.loaders.itemsOfRoutine.clear(routine.id) + return routine.id + }, + + /** + * Replaces the whole list of the routine, in the order of `itemIds`. The + * last save wins. A removed item may stay in the routine but cannot be added. + */ + setRoutineItems: async (_parent, args, context) => { + const coachId = await requireCoachId(context) + const routine = await ownRoutine(context, coachId, args.routineId) + const { itemIds } = args + if (itemIds.length > MAX_ITEMS_PER_ROUTINE) { + throw new LimitReachedError( + `A routine can have at most ${MAX_ITEMS_PER_ROUTINE} items.`, + ) + } + if (itemIds.some((id) => !ITEM_ID_PATTERN.test(id))) { + throw new BadUserInputError('An item id is not valid.') + } + + // Constraints 7 and 8: the items are on the routine's apparatus, and + // there is one vault on vault (and no vault elsewhere, since a vault is + // always on VT). + const unique = [...new Set(itemIds)] + const loaded = await context.loaders.itemById.loadMany(unique) + const found = new Map() + loaded.forEach((item, index) => { + if (!item || item instanceof Error) { + throw new BadUserInputError( + `There is no item with id ${unique[index]}.`, + ) + } + found.set(item.id, item) + }) + for (const id of unique) { + if (apparatusOfId(id) !== routine.apparatusId) { + throw new InvalidItemsError( + `${id} is not on the apparatus of this routine (${routine.apparatusId}).`, + ) + } + } + if (routine.apparatusId === 'VT' && itemIds.length > 1) { + throw new InvalidItemsError('A routine on vault has only one vault.') + } + + // A removed item can stay if it is already in the routine, but not be added. + const current = new Set( + (await context.loaders.itemsOfRoutine.load(routine.id)).map( + (item) => item.id, + ), + ) + for (const item of found.values()) { + if (item.removedAt !== null && !current.has(item.id)) { + throw new InvalidItemsError( + `${item.id} is no longer in the Code of Points and cannot be added.`, + ) + } + } + + const updated = await context.prisma.$transaction(async (tx) => { + // Changing the routine first takes a lock on its row, so two saves at + // the same moment follow each other. It also sets updatedAt, which the + // items (separate rows) would not do. + const updated = await tx.routine.update({ + where: { id: routine.id }, + data: { updatedAt: context.now() }, + }) + await tx.routineElement.deleteMany({ where: { routineId: routine.id } }) + await tx.routineElement.createMany({ + data: itemIds.map((id, index) => ({ + routineId: routine.id, + position: index + 1, + ...(isVaultId(id) ? { vaultId: id } : { elementId: id }), + })), + }) + return updated + }) + context.loaders.itemsOfRoutine.clear(routine.id) + return updated + }, + }, +} satisfies Resolvers diff --git a/backend/src/server/schema.ts b/backend/src/server/schema.ts new file mode 100644 index 0000000..d17830f --- /dev/null +++ b/backend/src/server/schema.ts @@ -0,0 +1,8 @@ +// The schema is written first, in backend/src/schema.graphql, and the server +// reads it from there (schema first, see "Server and types" in docs/API.md). + +import { readFileSync } from 'node:fs' + +export function loadTypeDefs(): string { + return readFileSync(new URL('../schema.graphql', import.meta.url), 'utf8') +} diff --git a/backend/src/server/selectsField.test.ts b/backend/src/server/selectsField.test.ts new file mode 100644 index 0000000..7dbaf4e --- /dev/null +++ b/backend/src/server/selectsField.test.ts @@ -0,0 +1,89 @@ +import { + buildSchema, + execute, + graphql, + parse, + type GraphQLResolveInfo, +} from 'graphql' +import { describe, expect, it } from 'vitest' +import { selectsField } from './selectsField.js' + +const schema = buildSchema(` + type Page { total: Int, items: [String] } + type Query { page: Page } +`) + +/** Runs a query and tells whether the resolver of `page` sees `total` in it. */ +async function sees( + source: string, + variables?: Record, +): Promise { + let seen: boolean | undefined + await graphql({ + schema, + source, + variableValues: variables, + rootValue: { + page: (_args: unknown, _ctx: unknown, info: GraphQLResolveInfo) => { + seen = selectsField(info, 'total') + return { total: 1, items: [] } + }, + }, + }) + if (seen === undefined) throw new Error('The resolver did not run.') + return seen +} + +describe('selectsField', () => { + it('sees a field that is asked for, also with an alias', async () => { + expect(await sees('{ page { total } }')).toBe(true) + expect(await sees('{ page { count: total } }')).toBe(true) + }) + + it('does not see a field that is not asked for', async () => { + expect(await sees('{ page { items } }')).toBe(false) + }) + + it('sees it through inline fragments and named fragments', async () => { + expect(await sees('{ page { ... on Page { total } } }')).toBe(true) + expect(await sees('{ page { ...P } } fragment P on Page { total }')).toBe( + true, + ) + expect(await sees('{ page { ...P } } fragment P on Page { items }')).toBe( + false, + ) + }) + + it('does not loop on a fragment that uses itself', async () => { + // Validation rejects such a query before it runs, so run it without validation. + let seen: boolean | undefined + await execute({ + schema, + document: parse('{ page { ...P } } fragment P on Page { ...P }'), + rootValue: { + page: (_args: unknown, _ctx: unknown, info: GraphQLResolveInfo) => { + seen = selectsField(info, 'total') + return { total: 1 } + }, + }, + }) + expect(seen).toBe(false) + }) + + it('follows @include and @skip, with a variable or a value', async () => { + const query = 'query($on: Boolean!) { page { total @include(if: $on) } }' + expect(await sees(query, { on: true })).toBe(true) + expect(await sees(query, { on: false })).toBe(false) + + expect(await sees('{ page { total @include(if: false) } }')).toBe(false) + expect(await sees('{ page { total @skip(if: true) } }')).toBe(false) + expect(await sees('{ page { total @skip(if: false) } }')).toBe(true) + }) + + it('follows @include on a fragment', async () => { + const query = + 'query($on: Boolean!) { page { ...P @include(if: $on) } } fragment P on Page { total }' + expect(await sees(query, { on: true })).toBe(true) + expect(await sees(query, { on: false })).toBe(false) + }) +}) diff --git a/backend/src/server/selectsField.ts b/backend/src/server/selectsField.ts new file mode 100644 index 0000000..17ce638 --- /dev/null +++ b/backend/src/server/selectsField.ts @@ -0,0 +1,63 @@ +// Whether a query asks for a field directly under the field being resolved, +// also through fragments, and not when `@skip` or `@include` leaves it out. +// The search uses it so that the total count, which reads every match, is only +// calculated when the client asks for it. A client asks only on the first page +// with `totalCount @include(if: $firstPage)`. + +import { + GraphQLIncludeDirective, + GraphQLSkipDirective, + Kind, + getDirectiveValues, + type FieldNode, + type FragmentSpreadNode, + type GraphQLResolveInfo, + type InlineFragmentNode, + type SelectionSetNode, +} from 'graphql' + +/** False when `@skip(if: true)` or `@include(if: false)` leaves the node out. */ +function isIncluded( + node: FieldNode | FragmentSpreadNode | InlineFragmentNode, + info: GraphQLResolveInfo, +): boolean { + if ( + getDirectiveValues(GraphQLSkipDirective, node, info.variableValues)?.[ + 'if' + ] === true + ) { + return false + } + if ( + getDirectiveValues(GraphQLIncludeDirective, node, info.variableValues)?.[ + 'if' + ] === false + ) { + return false + } + return true +} + +export function selectsField( + info: GraphQLResolveInfo, + fieldName: string, +): boolean { + const visit = ( + set: SelectionSetNode | undefined, + seenFragments: Set, + ): boolean => { + if (!set) return false + return set.selections.some((selection) => { + if (!isIncluded(selection, info)) return false + if (selection.kind === Kind.FIELD) + return selection.name.value === fieldName + if (selection.kind === Kind.INLINE_FRAGMENT) + return visit(selection.selectionSet, seenFragments) + const name = selection.name.value + if (seenFragments.has(name)) return false + seenFragments.add(name) + return visit(info.fragments[name]?.selectionSet, seenFragments) + }) + } + return info.fieldNodes.some((node) => visit(node.selectionSet, new Set())) +} diff --git a/backend/src/server/server.test.ts b/backend/src/server/server.test.ts new file mode 100644 index 0000000..715f6e9 --- /dev/null +++ b/backend/src/server/server.test.ts @@ -0,0 +1,663 @@ +// Tests for the GraphQL server. They run against the local database from +// backend/.env and need the seeded catalog (run `npm run db:seed` first). They +// only read: no catalog rows or user data are changed. Without DATABASE_URL +// the tests are skipped. + +import type { ApolloServer } from '@apollo/server' +import { PrismaPg } from '@prisma/adapter-pg' +import { buildSchema } from 'graphql' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { searchCatalog } from '../catalog/search.js' +import { createPrismaClient } from '../db.js' +import { PrismaClient as LoggingClient } from '../generated/prisma/client.js' +import type { PrismaClient } from '../generated/prisma/client.js' +import { createApp, createApolloServer } from './app.js' +import type { Config } from './config.js' +import { type Context, createContext } from './context.js' +import { MAX_QUERY_DEPTH } from './depthLimit.js' +import { resolvers } from './resolvers/index.js' +import { loadTypeDefs } from './schema.js' + +const hasDatabase = Boolean(process.env['DATABASE_URL']) + +const config: Config = { + port: 3001, + imageBaseUrl: 'http://images.test/images', + corsOrigins: ['http://allowed.test'], +} + +type GqlError = { message: string; extensions?: Record } +type Result = { + data: T | null | undefined + errors: readonly GqlError[] | undefined +} + +describe.runIf(hasDatabase)('the GraphQL server on the seeded catalog', () => { + let prisma: PrismaClient + let server: ApolloServer + let rowCounts: number[] + + const counts = () => + Promise.all([ + prisma.catalogEntry.count(), + prisma.catalogWord.count(), + prisma.element.count(), + prisma.vault.count(), + prisma.coach.count(), + prisma.gymnast.count(), + prisma.routine.count(), + ]) + + /** Runs a query on the server without HTTP. */ + async function run( + query: string, + variables?: Record, + client: PrismaClient = prisma, + ): Promise> { + const response = await server.executeOperation( + { query, variables }, + { contextValue: createContext(client, config) }, + ) + if (response.body.kind !== 'single') + throw new Error('Expected a single result.') + const { data, errors } = response.body.singleResult + return { data: data as T | null | undefined, errors } + } + + const codeOf = (result: Result) => + result.errors?.[0]?.extensions?.['code'] + + beforeAll(async () => { + prisma = createPrismaClient() + if ((await prisma.catalogEntry.count()) === 0) { + throw new Error('The catalog is empty. Run `npm run db:seed` first.') + } + server = createApolloServer() + await server.start() + rowCounts = await counts() + }) + + afterAll(async () => { + // The server only reads: nothing in the catalog or the user data changed. + expect(await counts()).toEqual(rowCounts) + await server.stop() + await prisma.$disconnect() + }) + + describe('the catalog', () => { + it('lists the apparatus in the order of the Code of Points, with their groups', async () => { + const { data, errors } = await run<{ + apparatus: { + id: string + groups: { id: string; number: number; apparatus: { id: string } }[] + }[] + }>('{ apparatus { id groups { id number apparatus { id } } } }') + + expect(errors).toBeUndefined() + const expected = await prisma.apparatus.findMany({ + orderBy: { position: 'asc' }, + }) + expect(data!.apparatus.map((a) => a.id)).toEqual( + expected.map((a) => a.id), + ) + for (const apparatus of data!.apparatus) { + expect(apparatus.groups.length).toBeGreaterThan(0) + const numbers = apparatus.groups.map((g) => g.number) + expect(numbers).toEqual([...numbers].sort((a, b) => a - b)) + expect( + apparatus.groups.every((g) => g.apparatus.id === apparatus.id), + ).toBe(true) + } + }) + + it('gives an element with its value, drawing, group and removed flag', async () => { + const row = await prisma.element.findFirstOrThrow({ + orderBy: { id: 'asc' }, + }) + const { data, errors } = await run<{ + element: { + id: string + code: string + description: string + name: string | null + imageUrl: string + removed: boolean + value: number + elementGroup: { id: string; name: string } + } + }>( + `query($id: ID!) { element(id: $id) { id code description name imageUrl removed value + elementGroup { id name } } }`, + { id: row.id }, + ) + + expect(errors).toBeUndefined() + expect(data!.element).toMatchObject({ + id: row.id, + code: row.code, + description: row.description, + name: row.name, + removed: false, + value: row.value.toNumber(), + elementGroup: { id: row.elementGroupId }, + imageUrl: `http://images.test/images/${row.id.split('-')[0]}/${row.image}`, + }) + expect(typeof data!.element.value).toBe('number') + }) + + it('gives a vault with its D-value', async () => { + const row = await prisma.vault.findFirstOrThrow({ + orderBy: { id: 'asc' }, + }) + const { data, errors } = await run<{ + vault: { id: string; dValue: number; imageUrl: string } + }>('query($id: ID!) { vault(id: $id) { id dValue imageUrl } }', { + id: row.id, + }) + + expect(errors).toBeUndefined() + expect(data!.vault.dValue).toBe(row.dValue.toNumber()) + expect(data!.vault.imageUrl).toBe( + `http://images.test/images/VT/${row.image}`, + ) + }) + + it('resolves catalogItem to Element or Vault, and does not mix the two', async () => { + const element = await prisma.element.findFirstOrThrow() + const vault = await prisma.vault.findFirstOrThrow() + const { data } = await run<{ + a: { __typename: string; id: string } + b: { __typename: string; id: string } + elementWithVaultId: unknown + vaultWithElementId: unknown + unknown: unknown + odd: unknown + }>( + `query($e: ID!, $v: ID!) { + a: catalogItem(id: $e) { __typename id } + b: catalogItem(id: $v) { __typename id } + elementWithVaultId: element(id: $v) { id } + vaultWithElementId: vault(id: $e) { id } + unknown: catalogItem(id: "XX-9.9-a") { id } + odd: catalogItem(id: "'; DROP TABLE elements;--") { id } + }`, + { e: element.id, v: vault.id }, + ) + + expect(data!.a).toEqual({ __typename: 'Element', id: element.id }) + expect(data!.b).toEqual({ __typename: 'Vault', id: vault.id }) + expect(data!.elementWithVaultId).toBeNull() + expect(data!.vaultWithElementId).toBeNull() + expect(data!.unknown).toBeNull() + expect(data!.odd).toBeNull() + }) + + it('lists the other variants of a code, and none for a code with one variant', async () => { + const group = await prisma.variantGroup.findFirstOrThrow({ + where: { elements: { some: {} } }, + include: { elements: { orderBy: { id: 'asc' } } }, + }) + const [first, second] = group.elements + const single = await prisma.element.findFirstOrThrow({ + where: { variantGroupId: null }, + }) + + const { data, errors } = await run<{ + first: { variants: { id: string }[] } + second: { variants: { id: string }[] } + single: { variants: { id: string }[] } + }>( + `query($a: ID!, $b: ID!, $c: ID!) { + first: element(id: $a) { variants { id } } + second: element(id: $b) { variants { id } } + single: element(id: $c) { variants { id } } + }`, + { a: first!.id, b: second!.id, c: single.id }, + ) + + expect(errors).toBeUndefined() + const others = (id: string) => + group.elements + .filter((e) => e.id !== id && e.removedAt === null) + .map((e) => e.id) + expect(data!.first.variants.map((v) => v.id)).toEqual(others(first!.id)) + expect(data!.second.variants.map((v) => v.id)).toEqual(others(second!.id)) + expect(data!.single.variants).toEqual([]) + }) + + it('finds an element group by id and gives its apparatus', async () => { + const { data } = await run<{ + group: { id: string; apparatus: { id: string } } + none: unknown + }>( + '{ group: elementGroup(id: "UB-1") { id apparatus { id } } none: elementGroup(id: "nope") { id } }', + ) + expect(data!.group).toEqual({ id: 'UB-1', apparatus: { id: 'UB' } }) + expect(data!.none).toBeNull() + }) + }) + + describe('searchCatalog', () => { + const SEARCH = `query($search: String, $filter: CatalogFilter, $sort: CatalogSort, $first: Int!, $after: String) { + searchCatalog(search: $search, filter: $filter, sort: $sort, first: $first, after: $after) { + totalCount + pageInfo { hasNextPage endCursor } + edges { cursor node { __typename id code } } + } + }` + type Connection = { + totalCount: number + pageInfo: { hasNextPage: boolean; endCursor: string | null } + edges: { + cursor: string + node: { __typename: string; id: string; code: string } + }[] + } + + it('gives the same page, cursors and count as the search function', async () => { + const variables = { + search: 'hecht', + sort: { field: 'NAME', direction: 'DESC' }, + first: 5, + } + const { data, errors } = await run<{ searchCatalog: Connection }>( + SEARCH, + variables, + ) + const direct = await searchCatalog(prisma, { + search: 'hecht', + sort: { field: 'NAME', direction: 'DESC' }, + first: 5, + withTotalCount: true, + }) + + expect(errors).toBeUndefined() + expect(data!.searchCatalog.edges.map((e) => e.node.id)).toEqual( + direct.edges.map((e) => e.node.id), + ) + expect(data!.searchCatalog.edges.map((e) => e.cursor)).toEqual( + direct.edges.map((e) => e.cursor), + ) + expect(data!.searchCatalog.pageInfo).toEqual(direct.pageInfo) + expect(data!.searchCatalog.totalCount).toBe(direct.totalCount) + expect( + data!.searchCatalog.edges.every((e) => e.node.__typename === 'Element'), + ).toBe(true) + }) + + it('finds both elements and vaults, each as its own type', async () => { + const { data } = await run<{ searchCatalog: Connection }>(SEARCH, { + first: 100, + }) + const types = new Set( + data!.searchCatalog.edges.map((e) => e.node.__typename), + ) + expect(types).toEqual(new Set(['Element', 'Vault'])) + }) + + it('follows the cursors to the end without skipping or repeating a result', async () => { + const ids: string[] = [] + let after: string | null = null + let pages = 0 + do { + const { data, errors }: Result<{ searchCatalog: Connection }> = + await run(SEARCH, { + search: 'turn', + first: 7, + after, + }) + expect(errors).toBeUndefined() + ids.push(...data!.searchCatalog.edges.map((e) => e.node.id)) + after = data!.searchCatalog.pageInfo.hasNextPage + ? data!.searchCatalog.pageInfo.endCursor + : null + pages++ + } while (after !== null && pages < 200) + + const all = await searchCatalog(prisma, { + search: 'turn', + first: 100, + withTotalCount: true, + }) + expect(all.totalCount).toBeGreaterThan(14) // more than two pages + expect(new Set(ids).size).toBe(ids.length) + expect(ids).toHaveLength(all.totalCount!) + }) + + it('filters and sorts over the whole catalog', async () => { + const { data, errors } = await run<{ searchCatalog: Connection }>( + SEARCH, + { + filter: { apparatusId: 'BB', difficultyValues: [0.4, 0.5] }, + sort: { field: 'DIFFICULTY', direction: 'DESC' }, + first: 100, + }, + ) + expect(errors).toBeUndefined() + const direct = await searchCatalog(prisma, { + filter: { apparatusId: 'BB', difficultyValues: [0.4, 0.5] }, + sort: { field: 'DIFFICULTY', direction: 'DESC' }, + first: 100, + }) + expect(data!.searchCatalog.edges.map((e) => e.node.id)).toEqual( + direct.edges.map((e) => e.node.id), + ) + expect(data!.searchCatalog.edges.length).toBeGreaterThan(0) + }) + }) + + describe('database calls', () => { + const PAGE = `query($first: Int!, $withCount: Boolean!) { + searchCatalog(search: "turn", first: $first) { + totalCount @include(if: $withCount) + edges { node { + id description imageUrl removed + ... on Element { value variants { id } elementGroup { name apparatus { name } } } + ... on Vault { dValue variants { id } elementGroup { name apparatus { name } } } + } } + } + }` + + /** A client that records the SQL it sends. */ + function loggingClient() { + const sql: string[] = [] + const client = new LoggingClient({ + adapter: new PrismaPg({ + connectionString: process.env['DATABASE_URL']!, + }), + log: [{ emit: 'event', level: 'query' }], + }) + client.$on('query', (e) => sql.push(e.query)) + return { client, sql } + } + + it('counts the matches only when the query asks for totalCount', async () => { + const { client, sql } = loggingClient() + try { + await run(PAGE, { first: 5, withCount: false }, client) // also fills the structure cache + sql.length = 0 + await run(PAGE, { first: 5, withCount: false }, client) + expect(sql.filter((q) => /COUNT\(\*\)/i.test(q))).toHaveLength(0) + + sql.length = 0 + await run(PAGE, { first: 5, withCount: true }, client) + expect(sql.filter((q) => /COUNT\(\*\)/i.test(q))).toHaveLength(1) + } finally { + await client.$disconnect() + } + }) + + it('sends a fixed number of queries, not one for each result', async () => { + const { client, sql } = loggingClient() + try { + await run(PAGE, { first: 5, withCount: false }, client) // fills the structure cache + const queriesFor = async (first: number) => { + sql.length = 0 + const { data, errors } = await run<{ + searchCatalog: { edges: unknown[] } + }>(PAGE, { first, withCount: false }, client) + expect(errors).toBeUndefined() + expect(data!.searchCatalog.edges).toHaveLength(first) + return sql.length + } + // The loaders send one batch for each kind: the elements and their + // variants, the vaults and their variants, and the apparatus. The exact + // number varies by one or two, because the batches are made in the + // order the resolvers finish. It must stay small however many results + // there are: one query for each of 100 results would be over 100. + const counts = [ + await queriesFor(5), + await queriesFor(50), + await queriesFor(100), + ] + + expect(Math.max(...counts)).toBeLessThanOrEqual(10) + } finally { + await client.$disconnect() + } + }) + }) + + describe('errors', () => { + it.each([ + [ + 'first is 0', + '{ searchCatalog(first: 0) { totalCount } }', + /first must be/, + ], + [ + 'first is above 100', + '{ searchCatalog(first: 101) { totalCount } }', + /first must be/, + ], + [ + '9 search words', + '{ searchCatalog(search: "a b c d e f g h i") { totalCount } }', + /at most 8 words/, + ], + [ + 'a search text over 80 characters', + `{ searchCatalog(search: "${'a'.repeat(81)}") { totalCount } }`, + /at most 80 characters/, + ], + [ + 'a cursor that is not valid', + '{ searchCatalog(after: "nonsense") { totalCount } }', + /cursor is not valid/, + ], + [ + 'an unknown apparatus', + '{ searchCatalog(filter: { apparatusId: "XX" }) { totalCount } }', + /Unknown apparatus/, + ], + [ + 'difficulty together with vault', + '{ searchCatalog(filter: { apparatusId: "VT", difficultyValues: [0.1] }) { totalCount } }', + /cannot be used with vault/, + ], + [ + 'a difficulty value that does not exist', + '{ searchCatalog(filter: { difficultyValues: [0.15] }) { totalCount } }', + /not valid/, + ], + [ + 'difficulty sort without an apparatus', + '{ searchCatalog(sort: { field: DIFFICULTY }) { totalCount } }', + /needs an apparatus filter/, + ], + ])('gives BAD_USER_INPUT for %s', async (_label, query, message) => { + const result = await run(query) + expect(codeOf(result)).toBe('BAD_USER_INPUT') + expect(result.errors![0]!.message).toMatch(message) + }) + + it('rejects a query that is too deep, and accepts one at the limit', async () => { + // apparatus → groups → apparatus → … : each pair adds two levels. + const nest = (levels: number): string => { + let query = 'name' + for (let i = 0; i < levels; i++) + query = i % 2 === 0 ? `apparatus { ${query} }` : `groups { ${query} }` + return `{ apparatus { ${query} } }` + } + const atLimit = nest(MAX_QUERY_DEPTH - 2) + const tooDeep = nest(MAX_QUERY_DEPTH - 1) + + expect((await run(atLimit)).errors).toBeUndefined() + const rejected = await run(tooDeep) + expect(codeOf(rejected)).toBe('GRAPHQL_VALIDATION_FAILED') + expect(rejected.errors![0]!.message).toMatch( + /levels deep. The limit is 8/, + ) + }) + + it('still answers the introspection query that the tools use', async () => { + const { data, errors } = await run<{ + __schema: { types: { name: string }[] } + }>( + '{ __schema { types { name fields { name type { ofType { ofType { ofType { name } } } } } } } }', + ) + expect(errors).toBeUndefined() + expect(data!.__schema.types.some((t) => t.name === 'CatalogItem')).toBe( + true, + ) + }) + + it('hides the details of an unexpected error', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const broken = { + apparatus: { + findMany: () => { + throw new Error('password=secret at db.internal:5432') + }, + }, + } as unknown as PrismaClient + const result = await run('{ apparatus { id } }', undefined, broken) + + expect(codeOf(result)).toBe('INTERNAL_SERVER_ERROR') + expect(result.errors![0]!.message).toBe('Internal server error') + expect(JSON.stringify(result)).not.toContain('secret') + expect(JSON.stringify(result)).not.toContain('stacktrace') + expect(log).toHaveBeenCalled() + } finally { + log.mockRestore() + } + }) + }) + + describe('without a login', () => { + it('answers null for me when nobody is logged in', async () => { + const { data, errors } = await run<{ me: null }>('{ me { id } }') + expect(errors).toBeUndefined() + expect(data!.me).toBeNull() + }) + + it.each([ + ['gymnast', '{ gymnast(id: "x") { id } }', 'UNAUTHENTICATED'], + ['routine', '{ routine(id: "x") { id } }', 'UNAUTHENTICATED'], + [ + 'createGymnast', + 'mutation { createGymnast(name: "A") { id } }', + 'UNAUTHENTICATED', + ], + [ + 'addGymnastElement', + 'mutation { addGymnastElement(gymnastId: "x", itemId: "y") { id } }', + 'UNAUTHENTICATED', + ], + [ + 'login', + 'mutation { login(googleToken: "x") { accessToken } }', + 'NOT_IMPLEMENTED', + ], + ])('answers a clear error for %s', async (_name, query, code) => { + const result = await run(query) + expect(codeOf(result)).toBe(code) + expect(result.errors![0]!.message).toMatch(/not available yet|logged in/) + }) + + it('has a resolver for every mutation in the schema, so none returns a confusing null', () => { + const mutations = Object.keys( + buildSchema(loadTypeDefs()).getMutationType()!.getFields(), + ) + expect(Object.keys(resolvers.Mutation).sort()).toEqual(mutations.sort()) + }) + }) + + describe('over HTTP', () => { + let base: string + let closeServer: () => Promise + + beforeAll(async () => { + const { app, server: httpApollo } = await createApp({ prisma, config }) + const listener = app.listen(0, '127.0.0.1') + await new Promise((resolve) => + listener.once('listening', () => resolve()), + ) + base = `http://127.0.0.1:${(listener.address() as AddressInfo).port}` + closeServer = async () => { + await new Promise((resolve) => listener.close(() => resolve())) + await httpApollo.stop() + } + }) + afterAll(async () => { + await closeServer() + }) + + const post = (body: string, headers: Record = {}) => + fetch(`${base}/graphql`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body, + }) + + it('answers a health check and does not say which software it is', async () => { + const response = await fetch(`${base}/health`) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ status: 'ok' }) + expect(response.headers.get('x-powered-by')).toBeNull() + }) + + it('answers a query on /graphql', async () => { + const response = await post( + JSON.stringify({ + query: '{ searchCatalog(search: "hecht", first: 1) { totalCount } }', + }), + ) + expect(response.status).toBe(200) + const body = (await response.json()) as { + data: { searchCatalog: { totalCount: number } } + } + expect(body.data.searchCatalog.totalCount).toBeGreaterThan(0) + }) + + it('serves a drawing that imageUrl points to, and lets the browser keep it', async () => { + const row = await prisma.element.findFirstOrThrow({ + orderBy: { id: 'asc' }, + }) + const url = `${base}/images/${row.id.split('-')[0]}/${row.image}` + const response = await fetch(url) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('image/webp') + expect(response.headers.get('cache-control')).toMatch(/immutable/) + }) + + it('gives 404 for a drawing that does not exist, and does not leave the images folder', async () => { + expect((await fetch(`${base}/images/UB/nope.webp`)).status).toBe(404) + const outside = await fetch(`${base}/images/%2e%2e/%2e%2e/package.json`) + expect(outside.status).not.toBe(200) + }) + + it('lets the allowed origin call the API from a browser, and no other', async () => { + const preflight = (origin: string) => + fetch(`${base}/graphql`, { + method: 'OPTIONS', + headers: { origin, 'access-control-request-method': 'POST' }, + }) + expect( + (await preflight('http://allowed.test')).headers.get( + 'access-control-allow-origin', + ), + ).toBe('http://allowed.test') + expect( + (await preflight('http://other.test')).headers.get( + 'access-control-allow-origin', + ), + ).toBeNull() + }) + + it('refuses a request body above 100 kB', async () => { + const response = await post( + JSON.stringify({ query: `{ __typename } # ${'x'.repeat(200_000)}` }), + ) + expect(response.status).toBe(413) + }) + + it('refuses a query that is not JSON', async () => { + const response = await post('this is not json') + expect(response.status).toBeGreaterThanOrEqual(400) + expect(response.status).toBeLessThan(500) + }) + }) +}) diff --git a/backend/src/server/userData.test.ts b/backend/src/server/userData.test.ts new file mode 100644 index 0000000..b18babc --- /dev/null +++ b/backend/src/server/userData.test.ts @@ -0,0 +1,1022 @@ +// Tests for the gymnasts, their lists and the routines (issue #73). They run +// against the local database from backend/.env and need the seeded catalog. +// They write two test coaches with their data, and delete them again (the +// database deletes the gymnasts, routines and lists with the coach). One +// element is marked as removed for the tests of removed items, and restored +// afterwards. Without DATABASE_URL they are skipped. + +import type { ApolloServer } from '@apollo/server' +import { PrismaPg } from '@prisma/adapter-pg' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { signAccessToken } from '../auth/accessToken.js' +import { createPrismaClient } from '../db.js' +import { calculateDScore, toScoredItem } from '../dscore.js' +import { PrismaClient as LoggingClient } from '../generated/prisma/client.js' +import type { PrismaClient } from '../generated/prisma/client.js' +import { createApolloServer } from './app.js' +import type { Config } from './config.js' +import { type Context, createContext } from './context.js' +import { asElement, asVault } from './items.js' + +const hasDatabase = Boolean(process.env['DATABASE_URL']) +const PREFIX = 'test-73-' +const START = new Date('2026-10-17T10:00:00Z') + +const config: Config = { + port: 3001, + imageBaseUrl: 'http://images.test/images', + corsOrigins: [], + sessionSecret: 'a-secret-that-is-at-least-32-characters-long', +} + +type GqlError = { message: string; extensions?: Record } +type Result = { + data: T | null | undefined + errors: readonly GqlError[] | undefined +} + +describe.runIf(hasDatabase)('gymnasts, lists and routines', () => { + let prisma: PrismaClient + let server: ApolloServer + let anna: { id: string; token: string } + let bo: { id: string; token: string } + /** Elements of the seeded catalog, one for each code, and the first vault. */ + let ub: string[] + let bb: string[] + let vaults: string[] + + const clean = () => + prisma.coach.deleteMany({ where: { googleId: { startsWith: PREFIX } } }) + + /** Runs a query as a coach (or as nobody) on the server without HTTP. */ + async function run>( + who: { token: string } | null, + query: string, + variables?: Record, + options: { now?: Date; client?: PrismaClient } = {}, + ): Promise> { + const response = await server.executeOperation( + { query, variables }, + { + contextValue: createContext(options.client ?? prisma, config, { + authorization: who ? `Bearer ${who.token}` : undefined, + now: () => options.now ?? START, + }), + }, + ) + if (response.body.kind !== 'single') throw new Error('expected one result') + const { data, errors } = response.body.singleResult + return { data: data as T | null | undefined, errors } + } + const codeOf = (r: Result) => r.errors?.[0]?.extensions?.['code'] + + /** Runs a query that must work, and returns its data. */ + async function ok( + who: { token: string }, + query: string, + variables?: Record, + options?: { now?: Date }, + ): Promise { + const result = await run(who, query, variables, options) + expect(result.errors).toBeUndefined() + return result.data! + } + + const createGymnast = async (who: { token: string }, name: string) => + ( + await ok<{ createGymnast: { id: string; name: string } }>( + who, + 'mutation ($n: String!) { createGymnast(name: $n) { id name } }', + { n: name }, + ) + ).createGymnast + + const createRoutine = async ( + who: { token: string }, + gymnastId: string, + apparatusId: string, + name: string, + ) => + ( + await ok<{ createRoutine: { id: string; name: string } }>( + who, + 'mutation ($i: CreateRoutineInput!) { createRoutine(input: $i) { id name } }', + { i: { gymnastId, apparatusId, name } }, + ) + ).createRoutine + + const SET_ITEMS = `mutation ($r: ID!, $ids: [ID!]!) { setRoutineItems(routineId: $r, itemIds: $ids) { id updatedAt dScore items { id removed } } }` + const ADD = `mutation ($g: ID!, $i: ID!) { addGymnastElement(gymnastId: $g, itemId: $i) { id elements { id } } }` + const REMOVE = `mutation ($g: ID!, $i: ID!) { removeGymnastElement(gymnastId: $g, itemId: $i) { id elements { id } } }` + const LIST = `query ($g: ID!, $a: ID) { gymnast(id: $g) { elements(apparatusId: $a) { id } } }` + + const listOf = async (who: { token: string }, gymnastId: string) => + ( + await ok<{ gymnast: { elements: { id: string }[] } }>(who, LIST, { + g: gymnastId, + }) + ).gymnast.elements.map((e) => e.id) + + const itemsOf = async (who: { token: string }, routineId: string) => + ( + await ok<{ routine: { items: { id: string }[] } }>( + who, + 'query ($r: ID!) { routine(id: $r) { items { id } } }', + { r: routineId }, + ) + ).routine.items.map((i) => i.id) + + /** The first element of each of the first `n` codes on an apparatus. */ + async function elementsOn(apparatus: string, n: number): Promise { + const rows = await prisma.element.findMany({ + where: { id: { startsWith: `${apparatus}-` }, removedAt: null }, + orderBy: { id: 'asc' }, + }) + const seen = new Set() + return rows + .filter((r) => !seen.has(r.code) && seen.add(r.code)) + .slice(0, n) + .map((r) => r.id) + } + + async function makeCoach(name: string) { + const coach = await prisma.coach.create({ + data: { + googleId: `${PREFIX}${name}`, + email: `${name}@example.com`, + name, + }, + }) + return { + id: coach.id, + token: await signAccessToken(coach.id, config.sessionSecret!, START), + } + } + + beforeAll(async () => { + prisma = createPrismaClient() + server = createApolloServer() + await server.start() + await clean() + anna = await makeCoach('anna') + bo = await makeCoach('bo') + ;[ub, bb] = await Promise.all([elementsOn('UB', 60), elementsOn('BB', 12)]) + vaults = ( + await prisma.vault.findMany({ orderBy: { id: 'asc' }, take: 5 }) + ).map((v) => v.id) + }) + + afterAll(async () => { + await clean() + await server.stop() + await prisma.$disconnect() + }) + + describe('login and ownership', () => { + const operations: [string, string, Record][] = [ + ['gymnast', '{ gymnast(id: "x") { id } }', {}], + ['routine', '{ routine(id: "x") { id } }', {}], + ['createGymnast', 'mutation { createGymnast(name: "A") { id } }', {}], + [ + 'renameGymnast', + 'mutation { renameGymnast(id: "x", name: "A") { id } }', + {}, + ], + ['deleteGymnast', 'mutation { deleteGymnast(id: "x") }', {}], + [ + 'addGymnastElement', + 'mutation { addGymnastElement(gymnastId: "x", itemId: "UB-1.101-a") { id } }', + {}, + ], + [ + 'removeGymnastElement', + 'mutation { removeGymnastElement(gymnastId: "x", itemId: "UB-1.101-a") { id } }', + {}, + ], + [ + 'createRoutine', + 'mutation { createRoutine(input: {gymnastId: "x", apparatusId: "UB", name: "A"}) { id } }', + {}, + ], + [ + 'renameRoutine', + 'mutation { renameRoutine(id: "x", name: "A") { id } }', + {}, + ], + ['deleteRoutine', 'mutation { deleteRoutine(id: "x") }', {}], + [ + 'setRoutineItems', + 'mutation { setRoutineItems(routineId: "x", itemIds: []) { id } }', + {}, + ], + ] + + it.each(operations)('%s needs a login', async (_name, query) => { + expect(codeOf(await run(null, query))).toBe('UNAUTHENTICATED') + expect(codeOf(await run({ token: 'not-a-token' }, query))).toBe( + 'UNAUTHENTICATED', + ) + expect( + codeOf( + await run({ token: anna.token }, query, undefined, { + now: new Date(START.getTime() + 20 * 60 * 1000), + }), + ), + ).toBe('UNAUTHENTICATED') // the access token has expired + }) + + it("never lets a coach reach another coach's data: NOT_FOUND, and nothing changes", async () => { + const gymnast = await createGymnast(anna, 'Annas gymnast') + const routine = await createRoutine( + anna, + gymnast.id, + 'UB', + 'Annas routine', + ) + await ok(anna, ADD, { g: gymnast.id, i: ub[0] }) + await ok(anna, SET_ITEMS, { r: routine.id, ids: [ub[0], ub[1]] }) + + const attacks: [string, string, Record][] = [ + ['gymnast', '{ gymnast(id: "G") { id } }', {}], + ['routine', '{ routine(id: "R") { id } }', {}], + [ + 'renameGymnast', + 'mutation { renameGymnast(id: "G", name: "Hacked") { id } }', + {}, + ], + ['deleteGymnast', 'mutation { deleteGymnast(id: "G") }', {}], + [ + 'addGymnastElement', + `mutation { addGymnastElement(gymnastId: "G", itemId: "${ub[2]}") { id } }`, + {}, + ], + [ + 'removeGymnastElement', + `mutation { removeGymnastElement(gymnastId: "G", itemId: "${ub[0]}") { id } }`, + {}, + ], + [ + 'createRoutine', + 'mutation { createRoutine(input: {gymnastId: "G", apparatusId: "UB", name: "Hacked"}) { id } }', + {}, + ], + [ + 'renameRoutine', + 'mutation { renameRoutine(id: "R", name: "Hacked") { id } }', + {}, + ], + ['deleteRoutine', 'mutation { deleteRoutine(id: "R") }', {}], + [ + 'setRoutineItems', + `mutation { setRoutineItems(routineId: "R", itemIds: ["${ub[5]}"]) { id } }`, + {}, + ], + ] + const messages = new Set() + for (const [name, template] of attacks) { + const query = template + .replace('"G"', `"${gymnast.id}"`) + .replace('"R"', `"${routine.id}"`) + const result = await run(bo, query) + expect(codeOf(result), name).toBe('NOT_FOUND') + messages.add( + `${name}:${result.errors![0]!.message.replace(/gymnast|routine/i, 'X')}`, + ) + } + + // The same answer as for an id that does not exist, so ids cannot be probed. + const unknown = await run(bo, '{ gymnast(id: "does-not-exist") { id } }') + const other = await run(bo, `{ gymnast(id: "${gymnast.id}") { id } }`) + expect(unknown.errors![0]!.message).toBe(other.errors![0]!.message) + + // Nothing was changed. + const back = await ok<{ + gymnast: { name: string; routines: { name: string }[] } + }>(anna, `{ gymnast(id: "${gymnast.id}") { name routines { name } } }`) + expect(back.gymnast.name).toBe('Annas gymnast') + expect(back.gymnast.routines.map((r) => r.name)).toEqual([ + 'Annas routine', + ]) + expect(await listOf(anna, gymnast.id)).toEqual([ub[0]]) + expect(await itemsOf(anna, routine.id)).toEqual([ub[0], ub[1]]) + }) + + it("does not show another coach's gymnasts in me", async () => { + await createGymnast(bo, 'Bos gymnast') + const mine = await ok<{ me: { gymnasts: { name: string }[] } }>( + bo, + '{ me { gymnasts { name } } }', + ) + expect(mine.me.gymnasts.map((g) => g.name)).toEqual(['Bos gymnast']) + }) + }) + + describe('gymnasts', () => { + it('creates, renames and deletes a gymnast', async () => { + const created = await createGymnast(anna, ' Kari Nilsen ') + expect(created.name).toBe('Kari Nilsen') // trimmed + const renamed = await ok<{ renameGymnast: { name: string } }>( + anna, + `mutation { renameGymnast(id: "${created.id}", name: " Kari N ") { name } }`, + ) + expect(renamed.renameGymnast.name).toBe('Kari N') + + const deleted = await ok<{ deleteGymnast: string }>( + anna, + `mutation { deleteGymnast(id: "${created.id}") }`, + ) + expect(deleted.deleteGymnast).toBe(created.id) + const gone = await run(anna, `{ gymnast(id: "${created.id}") { id } }`) + expect(codeOf(gone)).toBe('NOT_FOUND') + }) + + it('deletes the routines and the list with the gymnast', async () => { + const gymnast = await createGymnast(anna, 'To delete') + const routine = await createRoutine(anna, gymnast.id, 'BB', 'R1') + await ok(anna, ADD, { g: gymnast.id, i: bb[0] }) + await ok(anna, SET_ITEMS, { r: routine.id, ids: [bb[0]] }) + + await ok(anna, `mutation { deleteGymnast(id: "${gymnast.id}") }`) + expect( + await prisma.routine.count({ where: { gymnastId: gymnast.id } }), + ).toBe(0) + expect( + await prisma.routineElement.count({ where: { routineId: routine.id } }), + ).toBe(0) + expect( + await prisma.gymnastElement.count({ where: { gymnastId: gymnast.id } }), + ).toBe(0) + }) + + it('trims the name and allows 1-70 characters, counted as characters', async () => { + const mutation = + 'mutation ($n: String!) { createGymnast(name: $n) { name } }' + for (const bad of ['', ' ', 'x'.repeat(71), '😀'.repeat(71)]) { + expect( + codeOf(await run(anna, mutation, { n: bad })), + JSON.stringify(bad.slice(0, 5)), + ).toBe('BAD_USER_INPUT') + } + for (const good of [ + 'x', + 'x'.repeat(70), + '😀'.repeat(70), + 'æøå'.repeat(23), + ]) { + const result = await run<{ createGymnast: { name: string } }>( + anna, + mutation, + { n: good }, + ) + expect(result.errors, good.slice(0, 5)).toBeUndefined() + expect(result.data!.createGymnast.name).toBe(good) + } + // 70 characters plus spaces around them is fine, since the spaces are removed. + const padded = await run<{ createGymnast: { name: string } }>( + anna, + mutation, + { n: ` ${'y'.repeat(70)} ` }, + ) + expect(padded.data!.createGymnast.name).toBe('y'.repeat(70)) + }) + + it('stops at 200 gymnasts for a coach, and does not count other coaches', async () => { + const coach = await makeCoach('many') + await prisma.gymnast.createMany({ + data: Array.from({ length: 200 }, (_, i) => ({ + coachId: coach.id, + name: `G${i}`, + })), + }) + const mutation = 'mutation { createGymnast(name: "One more") { id } }' + expect(codeOf(await run(coach, mutation))).toBe('LIMIT_REACHED') + // Another coach is not affected. + expect((await run(bo, mutation)).errors).toBeUndefined() + // Deleting one makes room. + const first = await prisma.gymnast.findFirstOrThrow({ + where: { coachId: coach.id }, + }) + await ok(coach, `mutation { deleteGymnast(id: "${first.id}") }`) + expect((await run(coach, mutation)).errors).toBeUndefined() + }) + + it('lists the gymnasts of the coach by name', async () => { + const coach = await makeCoach('sorted') + for (const name of ['Cecilie', 'Anna', 'Berit']) + await createGymnast(coach, name) + const me = await ok<{ me: { gymnasts: { name: string }[] } }>( + coach, + '{ me { gymnasts { name } } }', + ) + expect(me.me.gymnasts.map((g) => g.name)).toEqual([ + 'Anna', + 'Berit', + 'Cecilie', + ]) + }) + }) + + describe("a gymnast's list", () => { + it('adds items in order, and can show only one apparatus', async () => { + const gymnast = await createGymnast(anna, 'List') + await ok(anna, ADD, { g: gymnast.id, i: ub[3] }) + await ok(anna, ADD, { g: gymnast.id, i: bb[1] }) + await ok(anna, ADD, { g: gymnast.id, i: vaults[0] }) + await ok(anna, ADD, { g: gymnast.id, i: ub[1] }) + + expect(await listOf(anna, gymnast.id)).toEqual([ + ub[3], + bb[1], + vaults[0], + ub[1], + ]) + const onUb = await ok<{ gymnast: { elements: { id: string }[] } }>( + anna, + LIST, + { g: gymnast.id, a: 'UB' }, + ) + expect(onUb.gymnast.elements.map((e) => e.id)).toEqual([ub[3], ub[1]]) + const onVault = await ok<{ gymnast: { elements: { id: string }[] } }>( + anna, + LIST, + { g: gymnast.id, a: 'VT' }, + ) + expect(onVault.gymnast.elements.map((e) => e.id)).toEqual([vaults[0]]) + }) + + it('does nothing, without an error, when an item is added twice or removed when it is not there', async () => { + const gymnast = await createGymnast(anna, 'No-op') + await ok(anna, ADD, { g: gymnast.id, i: ub[0] }) + + const again = await ok<{ + addGymnastElement: { elements: { id: string }[] } + }>(anna, ADD, { g: gymnast.id, i: ub[0] }) + expect(again.addGymnastElement.elements.map((e) => e.id)).toEqual([ub[0]]) + expect( + await prisma.gymnastElement.count({ where: { gymnastId: gymnast.id } }), + ).toBe(1) + + const notThere = await ok<{ + removeGymnastElement: { elements: { id: string }[] } + }>(anna, REMOVE, { g: gymnast.id, i: ub[9] }) + expect(notThere.removeGymnastElement.elements.map((e) => e.id)).toEqual([ + ub[0], + ]) + const unknown = await run(anna, REMOVE, { + g: gymnast.id, + i: 'UB-99.99-a', + }) + expect(unknown.errors).toBeUndefined() + + const removed = await ok<{ + removeGymnastElement: { elements: { id: string }[] } + }>(anna, REMOVE, { g: gymnast.id, i: ub[0] }) + expect(removed.removeGymnastElement.elements).toEqual([]) + }) + + it('gives the updated list when several changes are in one request', async () => { + const gymnast = await createGymnast(anna, 'Several') + const result = await ok<{ + a: { elements: { id: string }[] } + b: { elements: { id: string }[] } + c: { elements: { id: string }[] } + }>( + anna, + `mutation ($g: ID!, $x: ID!, $y: ID!) { + a: addGymnastElement(gymnastId: $g, itemId: $x) { elements { id } } + b: addGymnastElement(gymnastId: $g, itemId: $y) { elements { id } } + c: removeGymnastElement(gymnastId: $g, itemId: $x) { elements { id } } + }`, + { g: gymnast.id, x: ub[0], y: ub[1] }, + ) + expect(result.a.elements.map((e) => e.id)).toEqual([ub[0]]) + expect(result.b.elements.map((e) => e.id)).toEqual([ub[0], ub[1]]) + expect(result.c.elements.map((e) => e.id)).toEqual([ub[1]]) + }) + + it('answers NOT_FOUND for an item that does not exist', async () => { + const gymnast = await createGymnast(anna, 'Unknown item') + for (const itemId of ['UB-99.99-a', "x'; DROP TABLE elements; --", '']) { + expect( + codeOf(await run(anna, ADD, { g: gymnast.id, i: itemId })), + itemId, + ).toBe('NOT_FOUND') + } + }) + + it('stops at 100 items, but an item that is already there is still fine', async () => { + const gymnast = await createGymnast(anna, 'Full list') + const rows = ( + await prisma.element.findMany({ + where: { removedAt: null }, + orderBy: { id: 'asc' }, + take: 101, + }) + ).map((e) => e.id) + await prisma.gymnastElement.createMany({ + data: rows + .slice(0, 100) + .map((id) => ({ gymnastId: gymnast.id, elementId: id })), + }) + expect( + codeOf(await run(anna, ADD, { g: gymnast.id, i: rows[100] })), + ).toBe('LIMIT_REACHED') + expect( + (await run(anna, ADD, { g: gymnast.id, i: rows[0] })).errors, + ).toBeUndefined() + await ok(anna, REMOVE, { g: gymnast.id, i: rows[0] }) + expect( + (await run(anna, ADD, { g: gymnast.id, i: rows[100] })).errors, + ).toBeUndefined() + }) + + it('puts one item in the list once when two requests add it at the same moment', async () => { + const gymnast = await createGymnast(anna, 'Race') + const results = await Promise.all( + Array.from({ length: 4 }, () => + run(anna, ADD, { g: gymnast.id, i: ub[7] }), + ), + ) + for (const result of results) expect(result.errors).toBeUndefined() + expect( + await prisma.gymnastElement.count({ where: { gymnastId: gymnast.id } }), + ).toBe(1) + }) + }) + + describe('routines', () => { + it('creates, renames and deletes a routine', async () => { + const gymnast = await createGymnast(anna, 'Routines') + const routine = await createRoutine(anna, gymnast.id, 'FX', ' Floor 1 ') + expect(routine.name).toBe('Floor 1') + + const renamed = await ok<{ renameRoutine: { name: string } }>( + anna, + `mutation { renameRoutine(id: "${routine.id}", name: "Floor 2") { name } }`, + ) + expect(renamed.renameRoutine.name).toBe('Floor 2') + // Renaming to its own name is fine. + expect( + ( + await run( + anna, + `mutation { renameRoutine(id: "${routine.id}", name: "Floor 2") { id } }`, + ) + ).errors, + ).toBeUndefined() + + const deleted = await ok<{ deleteRoutine: string }>( + anna, + `mutation { deleteRoutine(id: "${routine.id}") }`, + ) + expect(deleted.deleteRoutine).toBe(routine.id) + expect( + codeOf(await run(anna, `{ routine(id: "${routine.id}") { id } }`)), + ).toBe('NOT_FOUND') + }) + + it('shows the fields of a routine: apparatus, gymnast and ISO dates', async () => { + const gymnast = await createGymnast(anna, 'Fields') + const routine = await createRoutine(anna, gymnast.id, 'BB', 'Beam') + const data = await ok<{ + routine: { + name: string + createdAt: string + updatedAt: string + apparatus: { id: string; name: string } + gymnast: { id: string } + items: unknown[] + dScore: number + } + }>( + anna, + `{ routine(id: "${routine.id}") { name createdAt updatedAt apparatus { id name } gymnast { id } items { id } dScore } }`, + ) + expect(data.routine.apparatus.id).toBe('BB') + expect(data.routine.gymnast.id).toBe(gymnast.id) + expect(data.routine.items).toEqual([]) + expect(data.routine.dScore).toBe(0) + expect(data.routine.createdAt).toMatch( + /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/, + ) + expect(data.routine.updatedAt).toMatch(/Z$/) + }) + + it('trims the name and allows 1-70 characters', async () => { + const gymnast = await createGymnast(anna, 'Names') + const mutation = + 'mutation ($i: CreateRoutineInput!) { createRoutine(input: $i) { name } }' + for (const bad of ['', ' ', 'x'.repeat(71)]) { + expect( + codeOf( + await run(anna, mutation, { + i: { gymnastId: gymnast.id, apparatusId: 'UB', name: bad }, + }), + ), + ).toBe('BAD_USER_INPUT') + } + const good = await run(anna, mutation, { + i: { gymnastId: gymnast.id, apparatusId: 'UB', name: 'z'.repeat(70) }, + }) + expect(good.errors).toBeUndefined() + }) + + it('refuses a name that exists for the gymnast and apparatus, but not on another apparatus or gymnast', async () => { + const first = await createGymnast(anna, 'Names 1') + const second = await createGymnast(anna, 'Names 2') + const routine = await createRoutine(anna, first.id, 'UB', 'Same') + + const dup = await run( + anna, + 'mutation ($i: CreateRoutineInput!) { createRoutine(input: $i) { id } }', + { i: { gymnastId: first.id, apparatusId: 'UB', name: ' Same ' } }, + ) + expect(codeOf(dup)).toBe('NAME_TAKEN') + await createRoutine(anna, first.id, 'BB', 'Same') // another apparatus + await createRoutine(anna, second.id, 'UB', 'Same') // another gymnast + + const other = await createRoutine(anna, first.id, 'UB', 'Other') + expect( + codeOf( + await run( + anna, + `mutation { renameRoutine(id: "${other.id}", name: "Same") { id } }`, + ), + ), + ).toBe('NAME_TAKEN') + expect(routine.name).toBe('Same') + }) + + it('refuses an apparatus that does not exist, and stops at 50 routines', async () => { + const gymnast = await createGymnast(anna, 'Many routines') + const mutation = + 'mutation ($i: CreateRoutineInput!) { createRoutine(input: $i) { id } }' + expect( + codeOf( + await run(anna, mutation, { + i: { gymnastId: gymnast.id, apparatusId: 'XX', name: 'A' }, + }), + ), + ).toBe('BAD_USER_INPUT') + + await prisma.routine.createMany({ + data: Array.from({ length: 50 }, (_, i) => ({ + gymnastId: gymnast.id, + apparatusId: 'FX', + name: `R${i}`, + })), + }) + expect( + codeOf( + await run(anna, mutation, { + i: { gymnastId: gymnast.id, apparatusId: 'UB', name: 'One more' }, + }), + ), + ).toBe('LIMIT_REACHED') + }) + + it('lists the routines of a gymnast by apparatus and name, or only one apparatus', async () => { + const gymnast = await createGymnast(anna, 'Order') + await createRoutine(anna, gymnast.id, 'FX', 'B') + await createRoutine(anna, gymnast.id, 'UB', 'Z') + await createRoutine(anna, gymnast.id, 'UB', 'A') + await createRoutine(anna, gymnast.id, 'VT', 'V') + + const all = await ok<{ + gymnast: { routines: { name: string; apparatus: { id: string } }[] } + }>( + anna, + `{ gymnast(id: "${gymnast.id}") { routines { name apparatus { id } } } }`, + ) + expect( + all.gymnast.routines.map((r) => `${r.apparatus.id}:${r.name}`), + ).toEqual(['VT:V', 'UB:A', 'UB:Z', 'FX:B']) + const onUb = await ok<{ gymnast: { routines: { name: string }[] } }>( + anna, + `{ gymnast(id: "${gymnast.id}") { routines(apparatusId: "UB") { name } } }`, + ) + expect(onUb.gymnast.routines.map((r) => r.name)).toEqual(['A', 'Z']) + }) + }) + + describe('the items of a routine', () => { + let gymnastId: string + let n = 0 + const newRoutine = async (apparatus: string) => { + n += 1 + return (await createRoutine(anna, gymnastId, apparatus, `Items ${n}`)).id + } + + beforeAll(async () => { + gymnastId = (await createGymnast(anna, 'Items')).id + }) + + it('replaces the whole list, in order, and the same element may be there twice', async () => { + const routine = await newRoutine('UB') + const first = await ok<{ setRoutineItems: { items: { id: string }[] } }>( + anna, + SET_ITEMS, + { r: routine, ids: [ub[2], ub[0], ub[2]] }, + ) + expect(first.setRoutineItems.items.map((i) => i.id)).toEqual([ + ub[2], + ub[0], + ub[2], + ]) + + await ok(anna, SET_ITEMS, { r: routine, ids: [ub[4], ub[3]] }) + expect(await itemsOf(anna, routine)).toEqual([ub[4], ub[3]]) + const rows = await prisma.routineElement.findMany({ + where: { routineId: routine }, + orderBy: { position: 'asc' }, + }) + expect(rows.map((r) => r.position)).toEqual([1, 2]) // 1, 2, 3 ... without gaps + + await ok(anna, SET_ITEMS, { r: routine, ids: [] }) + expect(await itemsOf(anna, routine)).toEqual([]) + }) + + it('sets updatedAt, and keeps createdAt', async () => { + const routine = await newRoutine('UB') + const before = await ok<{ + routine: { createdAt: string; updatedAt: string } + }>(anna, `{ routine(id: "${routine}") { createdAt updatedAt } }`) + const later = new Date(START.getTime() + 10 * 60 * 1000) + const result = await ok<{ setRoutineItems: { updatedAt: string } }>( + anna, + SET_ITEMS, + { r: routine, ids: [ub[0]] }, + { now: later }, + ) + expect(result.setRoutineItems.updatedAt).toBe(later.toISOString()) + const after = await ok<{ + routine: { createdAt: string; updatedAt: string } + }>(anna, `{ routine(id: "${routine}") { createdAt updatedAt } }`) + expect(after.routine.createdAt).toBe(before.routine.createdAt) + expect(after.routine.updatedAt).toBe(later.toISOString()) + }) + + it('calculates dScore with the same rule as the calculator', async () => { + const routine = await newRoutine('UB') + const ids = ub.slice(0, 12) + const result = await ok<{ setRoutineItems: { dScore: number } }>( + anna, + SET_ITEMS, + { r: routine, ids }, + ) + const rows = await prisma.element.findMany({ where: { id: { in: ids } } }) + const expected = calculateDScore( + rows.map((r) => toScoredItem(asElement(r))), + ) + expect(result.setRoutineItems.dScore).toBe(expected) + expect(expected).toBeGreaterThan(0) + + const calc = await ok<{ calculateDScore: number }>( + anna, + '{ calculateDScore(itemIds: $ids) }'.replace( + '$ids', + JSON.stringify(ids), + ), + ) + expect(calc.calculateDScore).toBe(expected) + }) + + it('has the D-value of the vault on vault', async () => { + const routine = await newRoutine('VT') + const result = await ok<{ + setRoutineItems: { dScore: number; items: { id: string }[] } + }>(anna, SET_ITEMS, { r: routine, ids: [vaults[1]] }) + const vault = await prisma.vault.findUniqueOrThrow({ + where: { id: vaults[1]! }, + }) + expect(result.setRoutineItems.dScore).toBe( + calculateDScore([toScoredItem(asVault(vault))]), + ) + }) + + it('refuses items that do not fit the routine with INVALID_ITEMS, and then changes nothing', async () => { + const routine = await newRoutine('UB') + const vaultRoutine = await newRoutine('VT') + await ok(anna, SET_ITEMS, { r: routine, ids: [ub[0], ub[1]] }) + + const bad: [string, string, string[]][] = [ + ['an element from another apparatus', routine, [ub[2]!, bb[0]!]], + ['a vault on bars', routine, [vaults[0]!]], + ['an element on vault', vaultRoutine, [ub[0]!]], + ['two vaults', vaultRoutine, [vaults[0]!, vaults[1]!]], + ['the same vault twice', vaultRoutine, [vaults[0]!, vaults[0]!]], + ] + for (const [label, id, ids] of bad) { + expect(codeOf(await run(anna, SET_ITEMS, { r: id, ids })), label).toBe( + 'INVALID_ITEMS', + ) + } + expect(await itemsOf(anna, routine)).toEqual([ub[0], ub[1]]) // unchanged + expect(await itemsOf(anna, vaultRoutine)).toEqual([]) + // One vault is fine. + expect( + (await run(anna, SET_ITEMS, { r: vaultRoutine, ids: [vaults[0]] })) + .errors, + ).toBeUndefined() + }) + + it('stops at 50 items, and refuses unknown or odd ids', async () => { + const routine = await newRoutine('UB') + const fifty = Array.from({ length: 50 }, (_, i) => ub[i % ub.length]!) + expect( + (await run(anna, SET_ITEMS, { r: routine, ids: fifty })).errors, + ).toBeUndefined() + expect( + codeOf( + await run(anna, SET_ITEMS, { r: routine, ids: [...fifty, ub[0]] }), + ), + ).toBe('LIMIT_REACHED') + expect( + codeOf(await run(anna, SET_ITEMS, { r: routine, ids: ['UB-99.99-a'] })), + ).toBe('BAD_USER_INPUT') + expect( + codeOf( + await run(anna, SET_ITEMS, { + r: routine, + ids: ["x'; DROP TABLE routines; --"], + }), + ), + ).toBe('BAD_USER_INPUT') + expect((await itemsOf(anna, routine)).length).toBe(50) // unchanged + }) + + it('lets two saves at the same moment follow each other: the last one wins', async () => { + const routine = await newRoutine('UB') + const one = [ub[0]!, ub[1]!, ub[2]!] + const two = [ub[5]!, ub[4]!] + const results = await Promise.all([ + run(anna, SET_ITEMS, { r: routine, ids: one }), + run(anna, SET_ITEMS, { r: routine, ids: two }), + ]) + for (const result of results) expect(result.errors).toBeUndefined() + const final = await itemsOf(anna, routine) + expect([one, two]).toContainEqual(final) + }) + }) + + describe('removed items', () => { + let removedId: string + let gymnastId: string + + beforeAll(async () => { + removedId = ub[20]! + gymnastId = (await createGymnast(anna, 'Removed')).id + }) + + const setRemoved = (value: Date | null) => + prisma.element.update({ + where: { id: removedId }, + data: { removedAt: value }, + }) + + afterAll(async () => { + await setRemoved(null) + }) + + it('can stay in a routine and a list, shows removed: true, and makes dScore null', async () => { + const routine = (await createRoutine(anna, gymnastId, 'UB', 'Removed 1')) + .id + await ok(anna, SET_ITEMS, { r: routine, ids: [ub[0], removedId, ub[1]] }) + await ok(anna, ADD, { g: gymnastId, i: removedId }) + const before = await ok<{ routine: { dScore: number | null } }>( + anna, + `{ routine(id: "${routine}") { dScore } }`, + ) + expect(before.routine.dScore).toBeGreaterThan(0) + + await setRemoved(new Date()) + try { + const shown = await ok<{ + routine: { + dScore: number | null + items: { id: string; removed: boolean }[] + } + }>( + anna, + `{ routine(id: "${routine}") { dScore items { id removed } } }`, + ) + expect(shown.routine.items.map((i) => [i.id, i.removed])).toEqual([ + [ub[0], false], + [removedId, true], + [ub[1], false], + ]) + expect(shown.routine.dScore).toBeNull() + expect(await listOf(anna, gymnastId)).toContain(removedId) + + // It may stay when the routine is saved again, but not be added to another routine. + expect( + (await run(anna, SET_ITEMS, { r: routine, ids: [removedId, ub[1]] })) + .errors, + ).toBeUndefined() + const other = (await createRoutine(anna, gymnastId, 'UB', 'Removed 2')) + .id + expect( + codeOf(await run(anna, SET_ITEMS, { r: other, ids: [removedId] })), + ).toBe('INVALID_ITEMS') + + // Taking it out gives the score back. + const fixed = await ok<{ setRoutineItems: { dScore: number | null } }>( + anna, + SET_ITEMS, + { r: routine, ids: [ub[1]] }, + ) + expect(fixed.setRoutineItems.dScore).toBeGreaterThan(0) + } finally { + await setRemoved(null) + } + }) + + it('cannot be added to a list, but can be taken out of it', async () => { + const gymnast = (await createGymnast(anna, 'Removed list')).id + await setRemoved(new Date()) + try { + expect(codeOf(await run(anna, ADD, { g: gymnast, i: removedId }))).toBe( + 'INVALID_ITEMS', + ) + // Already in the list (added before it was removed): adding again is a no-op, and it can be removed. + await prisma.gymnastElement.create({ + data: { gymnastId: gymnast, elementId: removedId }, + }) + expect( + (await run(anna, ADD, { g: gymnast, i: removedId })).errors, + ).toBeUndefined() + await ok(anna, REMOVE, { g: gymnast, i: removedId }) + expect(await listOf(anna, gymnast)).toEqual([]) + } finally { + await setRemoved(null) + } + }) + }) + + describe('queries', () => { + it('uses a fixed number of queries, whatever the number of gymnasts', async () => { + /** A coach with `n` gymnasts, each with a list, two routines and items in them. */ + async function coachWith(name: string, n: number) { + const coach = await makeCoach(name) + for (let i = 0; i < n; i++) { + const g = await prisma.gymnast.create({ + data: { coachId: coach.id, name: `Q${i}` }, + }) + await prisma.gymnastElement.createMany({ + data: [ + { gymnastId: g.id, elementId: ub[0]! }, + { gymnastId: g.id, elementId: ub[1]! }, + ], + }) + const r1 = await prisma.routine.create({ + data: { gymnastId: g.id, apparatusId: 'UB', name: 'One' }, + }) + const r2 = await prisma.routine.create({ + data: { gymnastId: g.id, apparatusId: 'BB', name: 'Two' }, + }) + await prisma.routineElement.createMany({ + data: [ + { routineId: r1.id, position: 1, elementId: ub[0]! }, + { routineId: r2.id, position: 1, elementId: bb[0]! }, + ], + }) + } + return coach + } + + /** The number of queries the server sends for the whole overview. */ + async function queriesFor(coach: { token: string }, gymnasts: number) { + const queries: string[] = [] + const logging = new LoggingClient({ + adapter: new PrismaPg({ + connectionString: process.env['DATABASE_URL']!, + }), + log: [{ emit: 'event', level: 'query' }], + }) + logging.$on('query', (e) => queries.push(e.query)) + try { + const result = await run<{ + me: { gymnasts: { routines: unknown[]; elements: unknown[] }[] } + }>( + coach, + '{ me { gymnasts { name routines { name dScore apparatus { id } items { id } } elements { id } } } }', + undefined, + { client: logging as unknown as PrismaClient }, + ) + expect(result.errors).toBeUndefined() + expect(result.data!.me.gymnasts).toHaveLength(gymnasts) + expect(result.data!.me.gymnasts[0]!.routines).toHaveLength(2) + expect(result.data!.me.gymnasts[0]!.elements).toHaveLength(2) + } finally { + await logging.$disconnect() + } + return queries.length + } + + const few = await queriesFor(await coachWith('queries-few', 2), 2) + const many = await queriesFor(await coachWith('queries-many', 25), 25) + expect(many).toBe(few) // the same for 2 and for 25 gymnasts + expect(many).toBeLessThanOrEqual(12) + }) + }) +}) diff --git a/backend/src/userData/rules.ts b/backend/src/userData/rules.ts new file mode 100644 index 0000000..b147066 --- /dev/null +++ b/backend/src/userData/rules.ts @@ -0,0 +1,28 @@ +// The limits and the rules for names in the user data (see "Limits" and +// "Names" in docs/API.md). The database does not check them (only the value +// ranges are checked there), so the backend does. + +import { BadUserInputError } from '../errors.js' + +export const MAX_GYMNASTS_PER_COACH = 200 +export const MAX_ROUTINES_PER_GYMNAST = 50 +export const MAX_ITEMS_PER_ROUTINE = 50 +export const MAX_ELEMENTS_PER_GYMNAST = 100 +/** Characters in the name of a gymnast or a routine. */ +export const MAX_NAME_LENGTH = 70 + +/** + * The name without spaces at the start and the end. It must be 1-70 + * characters. The length is counted in characters (code points), not in bytes + * or UTF-16 units, so a name with accents or emoji gets the same room as any other. + */ +export function cleanName(name: string, what: string): string { + const trimmed = name.trim() + const length = [...trimmed].length + if (length < 1 || length > MAX_NAME_LENGTH) { + throw new BadUserInputError( + `The name of a ${what} must be 1-${MAX_NAME_LENGTH} characters.`, + ) + } + return trimmed +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..440662b --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "module": "nodenext", + "moduleResolution": "nodenext", + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "prisma.config.ts", "codegen.ts"] +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..15addee --- /dev/null +++ b/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/data/README.md b/data/README.md new file mode 100644 index 0000000..339ce8c --- /dev/null +++ b/data/README.md @@ -0,0 +1,14 @@ +# Catalog data + +These files are generated by [`scripts/extract-cop/`](../scripts/extract-cop/) from the FIG Women's Artistic Gymnastics Code of Points 2025–2028. Do not edit them by hand: fix the script and run it again instead. All rights to the content belong to FIG (Fédération Internationale de Gymnastique), and the data is used here for educational purposes. + +The structure of the data is described in [docs/DATA_MODEL.md](../docs/DATA_MODEL.md). How the data was made, and what has been checked by hand, is explained in [docs/EXTRACTION.md](../docs/EXTRACTION.md). + +| File | Contents | +| -------------------- | ---------------------------------------------------------------------------------------------- | +| `apparatus.json` | The four apparatus: vault, uneven bars, balance beam and floor exercise | +| `elementGroups.json` | The element groups on each apparatus, with their number and name | +| `elements.json` | The elements on uneven bars, balance beam and floor exercise, with code, value and description | +| `vaults.json` | The vaults, with code, D-value and description | +| `variantGroups.json` | For each code with more than one variant, the ids of its variants | +| `images/` | One drawing per element and vault, in a folder per apparatus: `images/UB/UB-2.305-a.webp` | diff --git a/data/apparatus.json b/data/apparatus.json new file mode 100644 index 0000000..7cf1a27 --- /dev/null +++ b/data/apparatus.json @@ -0,0 +1,18 @@ +[ + { + "id": "VT", + "name": "Vault" + }, + { + "id": "UB", + "name": "Uneven bars" + }, + { + "id": "BB", + "name": "Balance beam" + }, + { + "id": "FX", + "name": "Floor exercise" + } +] diff --git a/data/elementGroups.json b/data/elementGroups.json new file mode 100644 index 0000000..84003c4 --- /dev/null +++ b/data/elementGroups.json @@ -0,0 +1,134 @@ +[ + { + "id": "VT-1", + "apparatusId": "VT", + "number": 1, + "name": "Handspring, Yamashita, round-off with or without turn in 1st and/or 2nd flight phase" + }, + { + "id": "VT-2", + "apparatusId": "VT", + "number": 2, + "name": "Handspring fwd with/without 1/1 turn (360°) in 1st flight phase – salto fwd/bwd with/without twist in 2nd flight phase" + }, + { + "id": "VT-3", + "apparatusId": "VT", + "number": 3, + "name": "Handspring with ¼ - ½ turn (90°-180°) in 1st flight phase (Tsukahara) – salto bwd with/without twist in 2nd flight phase" + }, + { + "id": "VT-4", + "apparatusId": "VT", + "number": 4, + "name": "Round-off (Yurchenko) with/wo ¾ turn (270°) in 1st flight phase – salto bwd with/without twist in 2nd flight phase" + }, + { + "id": "VT-5", + "apparatusId": "VT", + "number": 5, + "name": "Round-off with ½ turn (180°) in 1st flight phase – salto fwd/bwd with/without twist in 2nd flight phase" + }, + { + "id": "UB-1", + "apparatusId": "UB", + "number": 1, + "name": "Mounts" + }, + { + "id": "UB-2", + "apparatusId": "UB", + "number": 2, + "name": "Casts and clear hip circles" + }, + { + "id": "UB-3", + "apparatusId": "UB", + "number": 3, + "name": "Giant circles" + }, + { + "id": "UB-4", + "apparatusId": "UB", + "number": 4, + "name": "Stalder circles" + }, + { + "id": "UB-5", + "apparatusId": "UB", + "number": 5, + "name": "Pike circles" + }, + { + "id": "UB-6", + "apparatusId": "UB", + "number": 6, + "name": "Dismounts" + }, + { + "id": "BB-1", + "apparatusId": "BB", + "number": 1, + "name": "Mounts" + }, + { + "id": "BB-2", + "apparatusId": "BB", + "number": 2, + "name": "Gymnastic leaps, jumps and hops" + }, + { + "id": "BB-3", + "apparatusId": "BB", + "number": 3, + "name": "Gymnastic turns" + }, + { + "id": "BB-4", + "apparatusId": "BB", + "number": 4, + "name": "Holds and acrobatic non-flight" + }, + { + "id": "BB-5", + "apparatusId": "BB", + "number": 5, + "name": "Acrobatic flight" + }, + { + "id": "BB-6", + "apparatusId": "BB", + "number": 6, + "name": "Dismounts" + }, + { + "id": "FX-1", + "apparatusId": "FX", + "number": 1, + "name": "Gymnastic leaps, jumps and hops" + }, + { + "id": "FX-2", + "apparatusId": "FX", + "number": 2, + "name": "Gymnastic turns" + }, + { + "id": "FX-3", + "apparatusId": "FX", + "number": 3, + "name": "Hand support elements" + }, + { + "id": "FX-4", + "apparatusId": "FX", + "number": 4, + "name": "Saltos forward & sideward" + }, + { + "id": "FX-5", + "apparatusId": "FX", + "number": 5, + "name": "Saltos backward" + } +] diff --git a/data/elements.json b/data/elements.json new file mode 100644 index 0000000..0af5ac5 --- /dev/null +++ b/data/elements.json @@ -0,0 +1,6743 @@ +[ + { + "id": "UB-1.101-a", + "elementGroupId": "UB-1", + "code": "1.101", + "value": 0.1, + "description": "Glide kip to support on LB", + "name": null, + "image": "UB-1.101-a.webp" + }, + { + "id": "UB-1.101-b", + "elementGroupId": "UB-1", + "code": "1.101", + "value": 0.1, + "description": "Glide kip with ½ turn (180°) kip to support on LB", + "name": null, + "image": "UB-1.101-b.webp" + }, + { + "id": "UB-1.102-a", + "elementGroupId": "UB-1", + "code": "1.102", + "value": 0.1, + "description": "Jump with ½ turn (180°) kip to support on LB", + "name": null, + "image": "UB-1.102-a.webp" + }, + { + "id": "UB-1.102-b", + "elementGroupId": "UB-1", + "code": "1.102", + "value": 0.1, + "description": "Jump with ½ turn (180°) stoop through to rear support on LB (back kip)", + "name": null, + "image": "UB-1.102-b.webp" + }, + { + "id": "UB-1.103-a", + "elementGroupId": "UB-1", + "code": "1.103", + "value": 0.1, + "description": "Glide on LB (or swing fwd on HB) and stoop through to rear support (back kip)", + "name": null, + "image": "UB-1.103-a.webp" + }, + { + "id": "UB-1.103-b", + "elementGroupId": "UB-1", + "code": "1.103", + "value": 0.1, + "description": "Glide on LB (or swing fwd on HB) and stoop through to rear support (back kip), straddle cut bwd to hang on same bar", + "name": null, + "image": "UB-1.103-b.webp" + }, + { + "id": "UB-1.104-a", + "elementGroupId": "UB-1", + "code": "1.104", + "value": 0.1, + "description": "Jump to hang on HB – also with reverse grip – kip to support", + "name": null, + "image": "UB-1.104-a.webp" + }, + { + "id": "UB-1.105-a", + "elementGroupId": "UB-1", + "code": "1.105", + "value": 0.1, + "description": "Facing HB – Jump with 1/1 turn (360°) to hang on HB", + "name": null, + "image": "UB-1.105-a.webp" + }, + { + "id": "UB-1.106-a", + "elementGroupId": "UB-1", + "code": "1.106", + "value": 0.1, + "description": "Straddle vault with hand repulsion over LB to catch HB", + "name": null, + "image": "UB-1.106-a.webp" + }, + { + "id": "UB-1.202-a", + "elementGroupId": "UB-1", + "code": "1.202", + "value": 0.2, + "description": "Jump with 1/1 turn (360°) and Glide kip to support on LB", + "name": null, + "image": "UB-1.202-a.webp" + }, + { + "id": "UB-1.203-a", + "elementGroupId": "UB-1", + "code": "1.203", + "value": 0.2, + "description": "Reverse kip from: – glide fwd on LB – swing fwd on HB – stoop through to kip hang, back kip swing, seat (pike) circle bwd to rear support", + "name": null, + "image": "UB-1.203-a.webp" + }, + { + "id": "UB-1.203-b", + "elementGroupId": "UB-1", + "code": "1.203", + "value": 0.2, + "description": "Reverse kip from: – glide fwd on LB – swing fwd on HB – stoop through to kip hang, back kip swing, seat (pike) circle bwd to rear support", + "name": null, + "image": "UB-1.203-b.webp" + }, + { + "id": "UB-1.204-a", + "elementGroupId": "UB-1", + "code": "1.204", + "value": 0.2, + "description": "Facing HB – Jump with ½ turn (180°) – kip to support on HB", + "name": null, + "image": "UB-1.204-a.webp" + }, + { + "id": "UB-1.204-b", + "elementGroupId": "UB-1", + "code": "1.204", + "value": 0.2, + "description": "Free jump with ½ turn (180°) over LB to hang on HB", + "name": null, + "image": "UB-1.204-b.webp" + }, + { + "id": "UB-1.206-a", + "elementGroupId": "UB-1", + "code": "1.206", + "value": 0.2, + "description": "Free straddle jump over LB to hang on HB", + "name": null, + "image": "UB-1.206-a.webp" + }, + { + "id": "UB-1.206-b", + "elementGroupId": "UB-1", + "code": "1.206", + "value": 0.2, + "description": "Hecht jump (legs together) with hand repulsion over LB to hang on HB", + "name": null, + "image": "UB-1.206-b.webp" + }, + { + "id": "UB-1.207-a", + "elementGroupId": "UB-1", + "code": "1.207", + "value": 0.2, + "description": "Salto fwd tucked, piked or straddled over LB into L hang on LB", + "name": null, + "image": "UB-1.207-a.webp" + }, + { + "id": "UB-1.208-a", + "elementGroupId": "UB-1", + "code": "1.208", + "value": 0.2, + "description": "Round-off in front of LB – flight bwd (straddled) through clear straddle support on LB", + "name": null, + "image": "UB-1.208-a.webp" + }, + { + "id": "UB-1.210-a", + "elementGroupId": "UB-1", + "code": "1.210", + "value": 0.2, + "description": "Jump to hstd on LB with hips bent, then extended", + "name": null, + "image": "UB-1.210-a.webp" + }, + { + "id": "UB-1.210-b", + "elementGroupId": "UB-1", + "code": "1.210", + "value": 0.2, + "description": "Jump to hstd on LB with hips bent, then extended, with ½ turn (180°) in hstd phase, legs together or straddle", + "name": null, + "image": "UB-1.210-b.webp" + }, + { + "id": "UB-1.304-a", + "elementGroupId": "UB-1", + "code": "1.304", + "value": 0.3, + "description": "Jump with ½ turn (180°) over LB – kip to support on HB", + "name": null, + "image": "UB-1.304-a.webp" + }, + { + "id": "UB-1.306-a", + "elementGroupId": "UB-1", + "code": "1.306", + "value": 0.3, + "description": "Free stretch jump over LB with legs together to hang on HB", + "name": "Makhautsova", + "image": "UB-1.306-a.webp" + }, + { + "id": "UB-1.307-a", + "elementGroupId": "UB-1", + "code": "1.307", + "value": 0.3, + "description": "Facing HB – salto fwd to hang on HB", + "name": null, + "image": "UB-1.307-a.webp" + }, + { + "id": "UB-1.307-b", + "elementGroupId": "UB-1", + "code": "1.307", + "value": 0.3, + "description": "Roll fwd piked with hand repulsion over LB with flight to hang on HB", + "name": null, + "image": "UB-1.307-b.webp" + }, + { + "id": "UB-1.308-a", + "elementGroupId": "UB-1", + "code": "1.308", + "value": 0.3, + "description": "Round-off in front of LB – flight bwd over LB with legs together or straddled to hang on HB", + "name": null, + "image": "UB-1.308-a.webp" + }, + { + "id": "UB-1.310-a", + "elementGroupId": "UB-1", + "code": "1.310", + "value": 0.3, + "description": "Jump to hstd on LB with hips bent, then extended and 1/1 turn (360°) in hstd phase, legs together or straddle", + "name": null, + "image": "UB-1.310-a.webp" + }, + { + "id": "UB-1.310-b", + "elementGroupId": "UB-1", + "code": "1.310", + "value": 0.3, + "description": "Jump with extended body to hdst on LB also with ½ turn (180°)", + "name": null, + "image": "UB-1.310-b.webp" + }, + { + "id": "UB-1.406-a", + "elementGroupId": "UB-1", + "code": "1.406", + "value": 0.4, + "description": "Hecht jump (legs together) with hand repulsion and 1/1 turn (360°) over LB to hang on HB", + "name": "Gebeshian", + "image": "UB-1.406-a.webp" + }, + { + "id": "UB-1.406-b", + "elementGroupId": "UB-1", + "code": "1.406", + "value": 0.4, + "description": "Free stretch jump (legs together) with 1/1 turn (360°) over LB to hang on HB", + "name": "Petrova", + "image": "UB-1.406-b.webp" + }, + { + "id": "UB-1.407-a", + "elementGroupId": "UB-1", + "code": "1.407", + "value": 0.4, + "description": "Salto fwd tucked over LB to hang on HB without touching LB", + "name": null, + "image": "UB-1.407-a.webp" + }, + { + "id": "UB-1.408-a", + "elementGroupId": "UB-1", + "code": "1.408", + "value": 0.4, + "description": "Round-off in front of LB – flight bwd over LB with 1/1 turn (360°) to hang on HB", + "name": null, + "image": "UB-1.408-a.webp" + }, + { + "id": "UB-1.409-a", + "elementGroupId": "UB-1", + "code": "1.409", + "value": 0.4, + "description": "Round-off in front of LB – tucked salto bwd over LB to hang on LB", + "name": "Jentsch", + "image": "UB-1.409-a.webp" + }, + { + "id": "UB-1.409-b", + "elementGroupId": "UB-1", + "code": "1.409", + "value": 0.4, + "description": "Round-off in front of LB, flic-flac through hstd phase on LB", + "name": "González", + "image": "UB-1.409-b.webp" + }, + { + "id": "UB-1.410-a", + "elementGroupId": "UB-1", + "code": "1.410", + "value": 0.4, + "description": "Jump with extended body to hstd on LB with 1/1 turn (360°) in hstd phase", + "name": "Maaranen", + "image": "UB-1.410-a.webp" + }, + { + "id": "UB-1.411-a", + "elementGroupId": "UB-1", + "code": "1.411", + "value": 0.4, + "description": "Jump to clear support on HB – clear hip circle to hstd on HB", + "name": "McNamara", + "image": "UB-1.411-a.webp" + }, + { + "id": "UB-1.411-b", + "elementGroupId": "UB-1", + "code": "1.411", + "value": 0.4, + "description": "Jump to clear support on HB – clear hip circle to hstd on HB, with ½ turn (180°) in hstd phase on HB", + "name": null, + "image": "UB-1.411-b.webp" + }, + { + "id": "UB-1.509-a", + "elementGroupId": "UB-1", + "code": "1.509", + "value": 0.5, + "description": "Round-off in front of LB, flic-flac with 1/1 turn (360°) to clear support or through hstd phase on LB", + "name": "Gurova", + "image": "UB-1.509-a.webp" + }, + { + "id": "UB-1.609-a", + "elementGroupId": "UB-1", + "code": "1.609", + "value": 0.6, + "description": "Round-off in front of LB – tucked arabian salto over LB to hang on HB without touching the LB", + "name": null, + "image": "UB-1.609-a.webp" + }, + { + "id": "UB-1.709-a", + "elementGroupId": "UB-1", + "code": "1.709", + "value": 0.7, + "description": "Round-off in front of LB – piked arabian salto over LB to hang on HB without touching the LB", + "name": null, + "image": "UB-1.709-a.webp" + }, + { + "id": "UB-2.101-a", + "elementGroupId": "UB-2", + "code": "2.101", + "value": 0.1, + "description": "Cast to hstd with legs straddled or with hips bent", + "name": null, + "image": "UB-2.101-a.webp" + }, + { + "id": "UB-2.101-b", + "elementGroupId": "UB-2", + "code": "2.101", + "value": 0.1, + "description": "Cast to hstd with legs straddled or with hips bent, with hop-grip change", + "name": null, + "image": "UB-2.101-b.webp" + }, + { + "id": "UB-2.104-a", + "elementGroupId": "UB-2", + "code": "2.104", + "value": 0.1, + "description": "Hip circle fwd (hips touching bar)", + "name": null, + "image": "UB-2.104-a.webp" + }, + { + "id": "UB-2.105-a", + "elementGroupId": "UB-2", + "code": "2.105", + "value": 0.1, + "description": "Hip circle bwd (hips touching bar)", + "name": null, + "image": "UB-2.105-a.webp" + }, + { + "id": "UB-2.105-b", + "elementGroupId": "UB-2", + "code": "2.105", + "value": 0.1, + "description": "Clear hip circle bwd or hip circle bwd on LB – hip repulsion (“false-pop”) – with regrasp on LB", + "name": null, + "image": "UB-2.105-b.webp" + }, + { + "id": "UB-2.201-a", + "elementGroupId": "UB-2", + "code": "2.201", + "value": 0.2, + "description": "Cast to hstd with legs together and hips extended", + "name": null, + "image": "UB-2.201-a.webp" + }, + { + "id": "UB-2.201-b", + "elementGroupId": "UB-2", + "code": "2.201", + "value": 0.2, + "description": "Cast to hstd with legs together and hips extended, with hop-grip change", + "name": null, + "image": "UB-2.201-b.webp" + }, + { + "id": "UB-2.201-c", + "elementGroupId": "UB-2", + "code": "2.201", + "value": 0.2, + "description": "Cast to hstd with legs together and hips extended, with ½ turn (180°) legs together or straddled", + "name": null, + "image": "UB-2.201-c.webp" + }, + { + "id": "UB-2.202-a", + "elementGroupId": "UB-2", + "code": "2.202", + "value": 0.2, + "description": "Front support on HB – stoop with flight or free straddle fwd over HB and ½ turn (180°) to hang on HB", + "name": null, + "image": "UB-2.202-a.webp" + }, + { + "id": "UB-2.202-b", + "elementGroupId": "UB-2", + "code": "2.202", + "value": 0.2, + "description": "Front support on HB – stoop with flight or free straddle fwd over HB and ½ turn (180°) to hang on HB", + "name": null, + "image": "UB-2.202-b.webp" + }, + { + "id": "UB-2.206-a", + "elementGroupId": "UB-2", + "code": "2.206", + "value": 0.2, + "description": "Clear underswing on LB, release and counter movement fwd in flight to hang on HB.", + "name": null, + "image": "UB-2.206-a.webp" + }, + { + "id": "UB-2.301-a", + "elementGroupId": "UB-2", + "code": "2.301", + "value": 0.3, + "description": "Cast with 1/1 turn (360°) to hstd", + "name": null, + "image": "UB-2.301-a.webp" + }, + { + "id": "UB-2.302-a", + "elementGroupId": "UB-2", + "code": "2.302", + "value": 0.3, + "description": "Hip circle bwd hecht with flight and ½ turn (180°) passing over bar to hang on same bar", + "name": null, + "image": "UB-2.302-a.webp" + }, + { + "id": "UB-2.302-b", + "elementGroupId": "UB-2", + "code": "2.302", + "value": 0.3, + "description": "Clear hip circle bwd hecht with flight and ½ turn (180°) passing over bar to hang on same bar", + "name": null, + "image": "UB-2.302-b.webp" + }, + { + "id": "UB-2.303-a", + "elementGroupId": "UB-2", + "code": "2.303", + "value": 0.3, + "description": "From front support on HB – cast with release and 1/1 turn (360°) to hang on HB", + "name": "Caslavska", + "image": "UB-2.303-a.webp" + }, + { + "id": "UB-2.305-a", + "elementGroupId": "UB-2", + "code": "2.305", + "value": 0.3, + "description": "Clear hip circle to hstd", + "name": null, + "image": "UB-2.305-a.webp" + }, + { + "id": "UB-2.305-b", + "elementGroupId": "UB-2", + "code": "2.305", + "value": 0.3, + "description": "Clear hip circle to hstd, with hop-grip change in hstd phase", + "name": null, + "image": "UB-2.305-b.webp" + }, + { + "id": "UB-2.305-c", + "elementGroupId": "UB-2", + "code": "2.305", + "value": 0.3, + "description": "Clear hip circle to hstd, with ½ turn (180°) to hstd", + "name": null, + "image": "UB-2.305-c.webp" + }, + { + "id": "UB-2.306-a", + "elementGroupId": "UB-2", + "code": "2.306", + "value": 0.3, + "description": "Clear hip circle bwd on HB with hecht to clear support on LB", + "name": "Pedrick", + "image": "UB-2.306-a.webp" + }, + { + "id": "UB-2.307-a", + "elementGroupId": "UB-2", + "code": "2.307", + "value": 0.3, + "description": "Outer front support – clear hip circle bwd on LB with hecht to hang on HB", + "name": "Yarotska", + "image": "UB-2.307-a.webp" + }, + { + "id": "UB-2.401-a", + "elementGroupId": "UB-2", + "code": "2.401", + "value": 0.4, + "description": "Cast with 1½ turn (540°) to hstd", + "name": "Reeder", + "image": "UB-2.401-a.webp" + }, + { + "id": "UB-2.403-a", + "elementGroupId": "UB-2", + "code": "2.403", + "value": 0.4, + "description": "From inner front support on LB – cast with salto roll fwd to hang on HB (Radochla roll)", + "name": "Radochla", + "image": "UB-2.403-a.webp" + }, + { + "id": "UB-2.404-a", + "elementGroupId": "UB-2", + "code": "2.404", + "value": 0.4, + "description": "Clear hip circle fwd to hstd (Weiler-kip)", + "name": null, + "image": "UB-2.404-a.webp" + }, + { + "id": "UB-2.404-b", + "elementGroupId": "UB-2", + "code": "2.404", + "value": 0.4, + "description": "Clear hip circle fwd to hstd, with ½ turn (180°) in hstd phase", + "name": null, + "image": "UB-2.404-b.webp" + }, + { + "id": "UB-2.405-a", + "elementGroupId": "UB-2", + "code": "2.405", + "value": 0.4, + "description": "Clear hip circle with 1/1 turn (360°) to hstd", + "name": null, + "image": "UB-2.405-a.webp" + }, + { + "id": "UB-2.406-a", + "elementGroupId": "UB-2", + "code": "2.406", + "value": 0.4, + "description": "Inner front support on LB – clear hip circle through hstd with flight to hang on HB", + "name": "Shaposhnikova", + "image": "UB-2.406-a.webp" + }, + { + "id": "UB-2.407-a", + "elementGroupId": "UB-2", + "code": "2.407", + "value": 0.4, + "description": "Outer front support – clear hip circle bwd on LB with hecht with ½ turn (180°) to hang on HB (legs together or straddled)", + "name": null, + "image": "UB-2.407-a.webp" + }, + { + "id": "UB-2.503-a", + "elementGroupId": "UB-2", + "code": "2.503", + "value": 0.5, + "description": "Front support on HB – cast with salto fwd straddled to hang on HB (Comaneci salto)", + "name": "Comaneci", + "image": "UB-2.503-a.webp" + }, + { + "id": "UB-2.504-a", + "elementGroupId": "UB-2", + "code": "2.504", + "value": 0.5, + "description": "Clear hip circle fwd to hstd with 1/1 turn (360°) in hstd phase", + "name": "Godwin", + "image": "UB-2.504-a.webp" + }, + { + "id": "UB-2.504-b", + "elementGroupId": "UB-2", + "code": "2.504", + "value": 0.5, + "description": "Clear hip circle forward (Weiler) into salto fwd straddled to hang on HB (Comaneci salto)", + "name": "Adalsteinsdottir", + "image": "UB-2.504-b.webp" + }, + { + "id": "UB-2.505-a", + "elementGroupId": "UB-2", + "code": "2.505", + "value": 0.5, + "description": "Clear hip circle with 1½ turn (540°) to hstd", + "name": null, + "image": "UB-2.505-a.webp" + }, + { + "id": "UB-2.506-a", + "elementGroupId": "UB-2", + "code": "2.506", + "value": 0.5, + "description": "Clear hip circle on HB, counter straddle over HB to hang on HB", + "name": "Hindorff", + "image": "UB-2.506-a.webp" + }, + { + "id": "UB-2.506-b", + "elementGroupId": "UB-2", + "code": "2.506", + "value": 0.5, + "description": "Clear hip circle on HB with counter straddle over the HB with ½ turn (180°) to hang in mixed L grip", + "name": "Martins", + "image": "UB-2.506-b.webp" + }, + { + "id": "UB-2.506-c", + "elementGroupId": "UB-2", + "code": "2.506", + "value": 0.5, + "description": "Clear hip circle through hstd with ½ turn (180°) in flight to hang on HB", + "name": "Khorkina", + "image": "UB-2.506-c.webp" + }, + { + "id": "UB-2.606-a", + "elementGroupId": "UB-2", + "code": "2.606", + "value": 0.6, + "description": "Clear hip circle on HB, counter pike over HB to hang on HB", + "name": "Shang", + "image": "UB-2.606-a.webp" + }, + { + "id": "UB-2.606-b", + "elementGroupId": "UB-2", + "code": "2.606", + "value": 0.6, + "description": "Clear hip circle on HB, counter pike over HB with ½ turn (180°) to hang in mixed L grip", + "name": "Black", + "image": "UB-2.606-b.webp" + }, + { + "id": "UB-2.706-a", + "elementGroupId": "UB-2", + "code": "2.706", + "value": 0.7, + "description": "Clear hip circle on HB, with counter stretched -reverse in layout position over HB to hang", + "name": "Zhang", + "image": "UB-2.706-a.webp" + }, + { + "id": "UB-3.201-a", + "elementGroupId": "UB-3", + "code": "3.201", + "value": 0.2, + "description": "Giant circle bwd in regular grip, or on one arm", + "name": "Liu", + "image": "UB-3.201-a.webp" + }, + { + "id": "UB-3.201-b", + "elementGroupId": "UB-3", + "code": "3.201", + "value": 0.2, + "description": "Giant circle bwd in regular grip, or on one arm, with ½ turn (180°) hstd", + "name": null, + "image": "UB-3.201-b.webp" + }, + { + "id": "UB-3.206-a", + "elementGroupId": "UB-3", + "code": "3.206", + "value": 0.2, + "description": "Giant circle fwd in reverse, regular or mix grip", + "name": null, + "image": "UB-3.206-a.webp" + }, + { + "id": "UB-3.206-b", + "elementGroupId": "UB-3", + "code": "3.206", + "value": 0.2, + "description": "Giant circle fwd in reverse, regular or mix grip, with legs straddled or hips bent in upswing phase", + "name": null, + "image": "UB-3.206-b.webp" + }, + { + "id": "UB-3.206-c", + "elementGroupId": "UB-3", + "code": "3.206", + "value": 0.2, + "description": "Giant circle fwd in reverse, regular or mix grip, with ½ turn (180°) to hstd", + "name": null, + "image": "UB-3.206-c.webp" + }, + { + "id": "UB-3.301-a", + "elementGroupId": "UB-3", + "code": "3.301", + "value": 0.3, + "description": "Giant circle bwd with 1/1 turn (360°) to hstd", + "name": null, + "image": "UB-3.301-a.webp" + }, + { + "id": "UB-3.304-a", + "elementGroupId": "UB-3", + "code": "3.304", + "value": 0.3, + "description": "Hang on HB, facing LB – swing fwd and roll bwd (legs straddled) to clear support on LB", + "name": null, + "image": "UB-3.304-a.webp" + }, + { + "id": "UB-3.306-a", + "elementGroupId": "UB-3", + "code": "3.306", + "value": 0.3, + "description": "Giant circle fwd with 1/1 turn (360°) to hstd", + "name": null, + "image": "UB-3.306-a.webp" + }, + { + "id": "UB-3.307-a", + "elementGroupId": "UB-3", + "code": "3.307", + "value": 0.3, + "description": "Swing bwd – straddle flight bwd over LB to hstd on LB", + "name": null, + "image": "UB-3.307-a.webp" + }, + { + "id": "UB-3.308-a", + "elementGroupId": "UB-3", + "code": "3.308", + "value": 0.3, + "description": "Swing bwd salto fwd tucked to hang on HB (Jaeger-Salto)", + "name": null, + "image": "UB-3.308-a.webp" + }, + { + "id": "UB-3.309-a", + "elementGroupId": "UB-3", + "code": "3.309", + "value": 0.3, + "description": "Swing bwd with free stoop or straddle vault and ½ turn (180°) over HB to hang", + "name": "Zhang/Volpi", + "image": "UB-3.309-a.webp" + }, + { + "id": "UB-3.309-b", + "elementGroupId": "UB-3", + "code": "3.309", + "value": 0.3, + "description": "Swing bwd with free stoop or straddle vault and ½ turn (180°) over HB to hang", + "name": "Zhang/Volpi", + "image": "UB-3.309-b.webp" + }, + { + "id": "UB-3.310-a", + "elementGroupId": "UB-3", + "code": "3.310", + "value": 0.3, + "description": "Giant circle fwd in L grip with piked or stretched body (L grip giant)", + "name": "Zaytseva", + "image": "UB-3.310-a.webp" + }, + { + "id": "UB-3.310-b", + "elementGroupId": "UB-3", + "code": "3.310", + "value": 0.3, + "description": "Giant circle fwd in L grip with piked or stretched body (L grip giant), with ½ turn (180°) to hstd", + "name": null, + "image": "UB-3.310-b.webp" + }, + { + "id": "UB-3.401-a", + "elementGroupId": "UB-3", + "code": "3.401", + "value": 0.4, + "description": "Giant circle bwd with 1½ or 2/1 turn (540° or 720°) to hstd, also with hop 1/1 turn (360°) to hstd", + "name": "Chusovitina", + "image": "UB-3.401-a.webp" + }, + { + "id": "UB-3.401-b", + "elementGroupId": "UB-3", + "code": "3.401", + "value": 0.4, + "description": "Giant circle bwd with 1½ or 2/1 turn (540° or 720°) to hstd, also with hop 1/1 turn (360°) to hstd", + "name": "Chusovitina", + "image": "UB-3.401-b.webp" + }, + { + "id": "UB-3.401-c", + "elementGroupId": "UB-3", + "code": "3.401", + "value": 0.4, + "description": "Giant circle bwd with 1½ or 2/1 turn (540° or 720°) to hstd, also with hop 1/1 turn (360°) to hstd", + "name": "Chusovitina", + "image": "UB-3.401-c.webp" + }, + { + "id": "UB-3.402-a", + "elementGroupId": "UB-3", + "code": "3.402", + "value": 0.4, + "description": "Hang on HB – Swing fwd with ½ turn (180°) and flight to hstd on LB", + "name": null, + "image": "UB-3.402-a.webp" + }, + { + "id": "UB-3.403-a", + "elementGroupId": "UB-3", + "code": "3.403", + "value": 0.4, + "description": "Long swing fwd, counter straddle-reverse hecht over HB to hang (Tkatchev)", + "name": "Davydova", + "image": "UB-3.403-a.webp" + }, + { + "id": "UB-3.403-b", + "elementGroupId": "UB-3", + "code": "3.403", + "value": 0.4, + "description": "Tkatchev with ½ turn (180°)", + "name": "Kononenko", + "image": "UB-3.403-b.webp" + }, + { + "id": "UB-3.403-c", + "elementGroupId": "UB-3", + "code": "3.403", + "value": 0.4, + "description": "Swing fwd with ½ turn (180°), pike vault over HB to hang", + "name": "Monckton", + "image": "UB-3.403-c.webp" + }, + { + "id": "UB-3.404-a", + "elementGroupId": "UB-3", + "code": "3.404", + "value": 0.4, + "description": "Hang on HB, facing LB – swing fwd, salto bwd stretched between bars to clear support on LB", + "name": "Pak", + "image": "UB-3.404-a.webp" + }, + { + "id": "UB-3.405-a", + "elementGroupId": "UB-3", + "code": "3.405", + "value": 0.4, + "description": "Swing fwd with ½ turn (180°) and salto fwd straddled to hang on HB (Deltchev Salto)", + "name": null, + "image": "UB-3.405-a.webp" + }, + { + "id": "UB-3.405-b", + "elementGroupId": "UB-3", + "code": "3.405", + "value": 0.4, + "description": "Swing fwd with ½ turn (180°) and salto fwd straddled to hang on HB (Deltchev Salto), piked", + "name": "Moreno/Nakamura", + "image": "UB-3.405-b.webp" + }, + { + "id": "UB-3.405-c", + "elementGroupId": "UB-3", + "code": "3.405", + "value": 0.4, + "description": "Swing fwd and salto bwd piked with ½ turn (180°) to hang on HB (Gienger Salto)", + "name": null, + "image": "UB-3.405-c.webp" + }, + { + "id": "UB-3.407-a", + "elementGroupId": "UB-3", + "code": "3.407", + "value": 0.4, + "description": "Swing bwd release and ½ turn (180°) in flight between the bars to clear support on LB", + "name": "Ejova", + "image": "UB-3.407-a.webp" + }, + { + "id": "UB-3.408-a", + "elementGroupId": "UB-3", + "code": "3.408", + "value": 0.4, + "description": "Jaeger Salto straddled to hang on HB", + "name": null, + "image": "UB-3.408-a.webp" + }, + { + "id": "UB-3.408-b", + "elementGroupId": "UB-3", + "code": "3.408", + "value": 0.4, + "description": "Jaeger Salto straddled with ½ turn (180°) to hang on HB", + "name": "Li Ya", + "image": "UB-3.408-b.webp" + }, + { + "id": "UB-3.408-c", + "elementGroupId": "UB-3", + "code": "3.408", + "value": 0.4, + "description": "Jaeger Salto piked to hang on HB", + "name": null, + "image": "UB-3.408-c.webp" + }, + { + "id": "UB-3.409-a", + "elementGroupId": "UB-3", + "code": "3.409", + "value": 0.4, + "description": "Swing bwd with ½ turn (180°) and straddle flight bwd over HB to hang", + "name": "Khorkina", + "image": "UB-3.409-a.webp" + }, + { + "id": "UB-3.410-a", + "elementGroupId": "UB-3", + "code": "3.410", + "value": 0.4, + "description": "Giant circle fwd in L grip with 1/1 turn (360°) to hstd", + "name": null, + "image": "UB-3.410-a.webp" + }, + { + "id": "UB-3.502-a", + "elementGroupId": "UB-3", + "code": "3.502", + "value": 0.5, + "description": "Hang on HB – Swing fwd with 1½ turn (540°) and flight over LB to hang on LB", + "name": "Strong", + "image": "UB-3.502-a.webp" + }, + { + "id": "UB-3.503-a", + "elementGroupId": "UB-3", + "code": "3.503", + "value": 0.5, + "description": "Tkatchev piked", + "name": null, + "image": "UB-3.503-a.webp" + }, + { + "id": "UB-3.503-b", + "elementGroupId": "UB-3", + "code": "3.503", + "value": 0.5, + "description": "Long swing fwd with ½ turn (180°) - ½ turn (180°) to counter straddle in flight over HB to hang", + "name": "Shushunova", + "image": "UB-3.503-b.webp" + }, + { + "id": "UB-3.504-a", + "elementGroupId": "UB-3", + "code": "3.504", + "value": 0.5, + "description": "Pak Salto with 1/1 turn (360°)", + "name": "Bhardwaj", + "image": "UB-3.504-a.webp" + }, + { + "id": "UB-3.505-a", + "elementGroupId": "UB-3", + "code": "3.505", + "value": 0.5, + "description": "Swing fwd and salto bwd with ½ turn (180°) stretched to hang on HB", + "name": null, + "image": "UB-3.505-a.webp" + }, + { + "id": "UB-3.506-a", + "elementGroupId": "UB-3", + "code": "3.506", + "value": 0.5, + "description": "Giant circle fwd in reverse grip to hstd with initiation of 1/1 turn (360°) on one arm before hstd phase", + "name": null, + "image": "UB-3.506-a.webp" + }, + { + "id": "UB-3.506-b", + "elementGroupId": "UB-3", + "code": "3.506", + "value": 0.5, + "description": "Giant circle fwd in reverse grip with 1½ turn (540°) to hstd", + "name": null, + "image": "UB-3.506-b.webp" + }, + { + "id": "UB-3.510-a", + "elementGroupId": "UB-3", + "code": "3.510", + "value": 0.5, + "description": "Giant circle fwd in L grip with initiation of 1/1 turn (360°) on 1 arm before hstd phase, completed to hstd", + "name": null, + "image": "UB-3.510-a.webp" + }, + { + "id": "UB-3.510-b", + "elementGroupId": "UB-3", + "code": "3.510", + "value": 0.5, + "description": "Giant circle fwd in L grip with 1½ turn (540°) to hstd (any technique accepted)", + "name": null, + "image": "UB-3.510-b.webp" + }, + { + "id": "UB-3.604-a", + "elementGroupId": "UB-3", + "code": "3.604", + "value": 0.6, + "description": "Facing outward on HB - swing fwd and counter salto fwd with legs straddled to hang on HB in reverse grip", + "name": "Kim", + "image": "UB-3.604-a.webp" + }, + { + "id": "UB-3.608-a", + "elementGroupId": "UB-3", + "code": "3.608", + "value": 0.6, + "description": "Jaeger Salto stretched to hang on HB", + "name": "Capuccitti", + "image": "UB-3.608-a.webp" + }, + { + "id": "UB-3.705-a", + "elementGroupId": "UB-3", + "code": "3.705", + "value": 0.7, + "description": "Swing fwd and salto bwd stretched with 1½ turn (540°) to hang on HB", + "name": "Hristakieva", + "image": "UB-3.705-a.webp" + }, + { + "id": "UB-3.708-a", + "elementGroupId": "UB-3", + "code": "3.708", + "value": 0.7, + "description": "Swing bwd with salto fwd tucked over HB to hang on HB", + "name": "Mo", + "image": "UB-3.708-a.webp" + }, + { + "id": "UB-3.708-b", + "elementGroupId": "UB-3", + "code": "3.708", + "value": 0.7, + "description": "Jaeger Salto stretched with 1/1turn (360°), to hang on HB", + "name": "Yang", + "image": "UB-3.708-b.webp" + }, + { + "id": "UB-4.101-a", + "elementGroupId": "UB-4", + "code": "4.101", + "value": 0.1, + "description": "Clear straddle circle fwd to clear support", + "name": null, + "image": "UB-4.101-a.webp" + }, + { + "id": "UB-4.103-a", + "elementGroupId": "UB-4", + "code": "4.103", + "value": 0.1, + "description": "Clear straddle circle fwd in L grip to clear support", + "name": null, + "image": "UB-4.103-a.webp" + }, + { + "id": "UB-4.104-a", + "elementGroupId": "UB-4", + "code": "4.104", + "value": 0.1, + "description": "Clear straddle circle bwd to clear support", + "name": null, + "image": "UB-4.104-a.webp" + }, + { + "id": "UB-4.106-a", + "elementGroupId": "UB-4", + "code": "4.106", + "value": 0.1, + "description": "Rear support – seat (pike) circle fwd to rear support", + "name": null, + "image": "UB-4.106-a.webp" + }, + { + "id": "UB-4.107-a", + "elementGroupId": "UB-4", + "code": "4.107", + "value": 0.1, + "description": "Rear support – seat (pike) circle bwd to rear support", + "name": null, + "image": "UB-4.107-a.webp" + }, + { + "id": "UB-4.201-a", + "elementGroupId": "UB-4", + "code": "4.201", + "value": 0.2, + "description": "Kip on HB, passing through clear straddle support – swing/press to hstd and ½ turn (180°) in hstd phase", + "name": null, + "image": "UB-4.201-a.webp" + }, + { + "id": "UB-4.205-a", + "elementGroupId": "UB-4", + "code": "4.205", + "value": 0.2, + "description": "Facing inward – Stalder bwd with release and counter movement fwd in flight to hang on HB", + "name": null, + "image": "UB-4.205-a.webp" + }, + { + "id": "UB-4.208-a", + "elementGroupId": "UB-4", + "code": "4.208", + "value": 0.2, + "description": "Clear pike circle bwd on LB with counter flight to hang on HB", + "name": null, + "image": "UB-4.208-a.webp" + }, + { + "id": "UB-4.302-a", + "elementGroupId": "UB-4", + "code": "4.302", + "value": 0.3, + "description": "Clear straddle circle bwd on HB with flight to clear support on LB", + "name": null, + "image": "UB-4.302-a.webp" + }, + { + "id": "UB-4.304-a", + "elementGroupId": "UB-4", + "code": "4.304", + "value": 0.3, + "description": "Stalder bwd to hstd, also with hop-grip change in hstd phase or with ½ turn (180°) to hstd", + "name": null, + "image": "UB-4.304-a.webp" + }, + { + "id": "UB-4.304-b", + "elementGroupId": "UB-4", + "code": "4.304", + "value": 0.3, + "description": "Stalder bwd to hstd, also with hop-grip change in hstd phase or with ½ turn (180°) to hstd", + "name": null, + "image": "UB-4.304-b.webp" + }, + { + "id": "UB-4.304-c", + "elementGroupId": "UB-4", + "code": "4.304", + "value": 0.3, + "description": "Stalder bwd to hstd, also with hop-grip change in hstd phase or with ½ turn (180°) to hstd", + "name": null, + "image": "UB-4.304-c.webp" + }, + { + "id": "UB-4.305-a", + "elementGroupId": "UB-4", + "code": "4.305", + "value": 0.3, + "description": "Facing inward – Stalder bwd with hecht flight to hang on HB", + "name": null, + "image": "UB-4.305-a.webp" + }, + { + "id": "UB-4.308-a", + "elementGroupId": "UB-4", + "code": "4.308", + "value": 0.3, + "description": "Clear pike circle bwd on LB with hecht flight to hang on HB", + "name": "Zgoba", + "image": "UB-4.308-a.webp" + }, + { + "id": "UB-4.308-b", + "elementGroupId": "UB-4", + "code": "4.308", + "value": 0.3, + "description": "Clear pike circle bwd on HB with flight to clear support on LB", + "name": "Sayer", + "image": "UB-4.308-b.webp" + }, + { + "id": "UB-4.401-a", + "elementGroupId": "UB-4", + "code": "4.401", + "value": 0.4, + "description": "Stalder fwd to hstd", + "name": null, + "image": "UB-4.401-a.webp" + }, + { + "id": "UB-4.401-b", + "elementGroupId": "UB-4", + "code": "4.401", + "value": 0.4, + "description": "Stalder fwd to hstd, with ½ turn (180°) to hstd", + "name": null, + "image": "UB-4.401-b.webp" + }, + { + "id": "UB-4.402-a", + "elementGroupId": "UB-4", + "code": "4.402", + "value": 0.4, + "description": "Clear straddle circle bwd on HB with flight fwd to hstd on LB", + "name": null, + "image": "UB-4.402-a.webp" + }, + { + "id": "UB-4.403-a", + "elementGroupId": "UB-4", + "code": "4.403", + "value": 0.4, + "description": "Stalder fwd in L grip to hstd", + "name": "White", + "image": "UB-4.403-a.webp" + }, + { + "id": "UB-4.403-b", + "elementGroupId": "UB-4", + "code": "4.403", + "value": 0.4, + "description": "Stalder fwd in L grip to hstd, with ½ turn (180°) to hstd", + "name": "White", + "image": "UB-4.403-b.webp" + }, + { + "id": "UB-4.404-a", + "elementGroupId": "UB-4", + "code": "4.404", + "value": 0.4, + "description": "Stalder bwd with 1/1 turn (360°) to hstd", + "name": "Frederick", + "image": "UB-4.404-a.webp" + }, + { + "id": "UB-4.405-a", + "elementGroupId": "UB-4", + "code": "4.405", + "value": 0.4, + "description": "Facing outward – Stalder bwd through hstd with flight to hang on HB", + "name": null, + "image": "UB-4.405-a.webp" + }, + { + "id": "UB-4.406-a", + "elementGroupId": "UB-4", + "code": "4.406", + "value": 0.4, + "description": "Clear pike circle fwd to hstd", + "name": null, + "image": "UB-4.406-a.webp" + }, + { + "id": "UB-4.406-b", + "elementGroupId": "UB-4", + "code": "4.406", + "value": 0.4, + "description": "Clear pike circle fwd to hstd, with ½ turn (180°) to hstd", + "name": null, + "image": "UB-4.406-b.webp" + }, + { + "id": "UB-4.407-a", + "elementGroupId": "UB-4", + "code": "4.407", + "value": 0.4, + "description": "Clear pike circle bwd to hstd", + "name": null, + "image": "UB-4.407-a.webp" + }, + { + "id": "UB-4.407-b", + "elementGroupId": "UB-4", + "code": "4.407", + "value": 0.4, + "description": "Clear pike circle bwd to hstd, with ½ turn (180°) to hstd", + "name": null, + "image": "UB-4.407-b.webp" + }, + { + "id": "UB-4.501-a", + "elementGroupId": "UB-4", + "code": "4.501", + "value": 0.5, + "description": "Stalder fwd with 1/1 turn (360°) to hstd", + "name": null, + "image": "UB-4.501-a.webp" + }, + { + "id": "UB-4.502-a", + "elementGroupId": "UB-4", + "code": "4.502", + "value": 0.5, + "description": "Stalder bwd on HB with counter straddle - reverse hecht over HB to hang", + "name": "Ricna", + "image": "UB-4.502-a.webp" + }, + { + "id": "UB-4.502-b", + "elementGroupId": "UB-4", + "code": "4.502", + "value": 0.5, + "description": "Stalder bwd on HB with counter straddle reverse hecht over HB with ½ (180°) turn to hang in mixed L grip", + "name": "Derwael / Fenton", + "image": "UB-4.502-b.webp" + }, + { + "id": "UB-4.503-a", + "elementGroupId": "UB-4", + "code": "4.503", + "value": 0.5, + "description": "Stalder fwd in L grip with 1/1 turn (360°) to hstd (any technique accepted)", + "name": null, + "image": "UB-4.503-a.webp" + }, + { + "id": "UB-4.503-b", + "elementGroupId": "UB-4", + "code": "4.503", + "value": 0.5, + "description": "Stalder fwd in L grip with 1/1 turn (360°) to hstd (any technique accepted)", + "name": null, + "image": "UB-4.503-b.webp" + }, + { + "id": "UB-4.504-a", + "elementGroupId": "UB-4", + "code": "4.504", + "value": 0.5, + "description": "Stalder bwd with 1½ turn (540°) to hstd", + "name": null, + "image": "UB-4.504-a.webp" + }, + { + "id": "UB-4.505-a", + "elementGroupId": "UB-4", + "code": "4.505", + "value": 0.5, + "description": "Stalder bwd through hstd with flight and ½ turn (180°), to hang on HB", + "name": null, + "image": "UB-4.505-a.webp" + }, + { + "id": "UB-4.506-a", + "elementGroupId": "UB-4", + "code": "4.506", + "value": 0.5, + "description": "Clear pike circle fwd with 1/1 turn (360°) to hstd", + "name": null, + "image": "UB-4.506-a.webp" + }, + { + "id": "UB-4.507-a", + "elementGroupId": "UB-4", + "code": "4.507", + "value": 0.5, + "description": "Clear pike circle bwd with 1/1 turn (360°) to hstd.", + "name": null, + "image": "UB-4.507-a.webp" + }, + { + "id": "UB-4.508-a", + "elementGroupId": "UB-4", + "code": "4.508", + "value": 0.5, + "description": "Clear pike circle bwd through hstd with flight to hang on HB", + "name": "Komova", + "image": "UB-4.508-a.webp" + }, + { + "id": "UB-4.508-b", + "elementGroupId": "UB-4", + "code": "4.508", + "value": 0.5, + "description": "Clear pike circle bwd through hstd with flight to hang on HB, with ½ turn (180°)", + "name": null, + "image": "UB-4.508-b.webp" + }, + { + "id": "UB-4.508-c", + "elementGroupId": "UB-4", + "code": "4.508", + "value": 0.5, + "description": "Clear pike circle bwd with counter straddle (open hip before flight) – reverse hecht over HB to hang", + "name": "Galante", + "image": "UB-4.508-c.webp" + }, + { + "id": "UB-4.602-a", + "elementGroupId": "UB-4", + "code": "4.602", + "value": 0.6, + "description": "Stalder bwd on HB with counter pike - reverse hecht over HB to hang", + "name": "Downie", + "image": "UB-4.602-a.webp" + }, + { + "id": "UB-4.608-a", + "elementGroupId": "UB-4", + "code": "4.608", + "value": 0.6, + "description": "Clear pike circle bwd with counter pike – reverse hecht over HB to hang", + "name": null, + "image": "UB-4.608-a.webp" + }, + { + "id": "UB-4.708-a", + "elementGroupId": "UB-4", + "code": "4.708", + "value": 0.7, + "description": "Clear pike circle bwd with counter stretched - reverse hecht in layout position over HB to hang", + "name": "Nemour", + "image": "UB-4.708-a.webp" + }, + { + "id": "UB-5.101-a", + "elementGroupId": "UB-5", + "code": "5.101", + "value": 0.1, + "description": "Rear support on LB or HB – seat (pike) circle fwd with straddle cut bwd to hang on same bar", + "name": null, + "image": "UB-5.101-a.webp" + }, + { + "id": "UB-5.104-a", + "elementGroupId": "UB-5", + "code": "5.104", + "value": 0.1, + "description": "Underswing bwd (inverted pike swing), dislocate (Schleudern) to hang on HB", + "name": null, + "image": "UB-5.104-a.webp" + }, + { + "id": "UB-5.105-a", + "elementGroupId": "UB-5", + "code": "5.105", + "value": 0.1, + "description": "Sole circle forward (piked or straddle)", + "name": null, + "image": "UB-5.105-a.webp" + }, + { + "id": "UB-5.106-a", + "elementGroupId": "UB-5", + "code": "5.106", + "value": 0.1, + "description": "Sole circle forward in L grip (piked or straddle)", + "name": null, + "image": "UB-5.106-a.webp" + }, + { + "id": "UB-5.108-a", + "elementGroupId": "UB-5", + "code": "5.108", + "value": 0.1, + "description": "Sole circle bwd (piked or straddle)", + "name": null, + "image": "UB-5.108-a.webp" + }, + { + "id": "UB-5.202-a", + "elementGroupId": "UB-5", + "code": "5.202", + "value": 0.2, + "description": "Rear support on HB – seat circle bwd with release to hang on LB", + "name": null, + "image": "UB-5.202-a.webp" + }, + { + "id": "UB-5.207-a", + "elementGroupId": "UB-5", + "code": "5.207", + "value": 0.2, + "description": "Underswing on LB (support of feet) with counter movement fwd in flight to hang on HB", + "name": null, + "image": "UB-5.207-a.webp" + }, + { + "id": "UB-5.301-a", + "elementGroupId": "UB-5", + "code": "5.301", + "value": 0.3, + "description": "Outer rear support on HB – fall bwd to inverted pike swing or seat (pike) circle fwd – straddle cut bwd with flight over LB to hang", + "name": null, + "image": "UB-5.301-a.webp" + }, + { + "id": "UB-5.301-b", + "elementGroupId": "UB-5", + "code": "5.301", + "value": 0.3, + "description": "Outer rear support on HB – fall bwd to inverted pike swing or seat (pike) circle fwd – straddle cut bwd with flight over LB to hang", + "name": null, + "image": "UB-5.301-b.webp" + }, + { + "id": "UB-5.301-c", + "elementGroupId": "UB-5", + "code": "5.301", + "value": 0.3, + "description": "Rear support on LB - seat (pike) circle fwd with straddle cut bwd and grip change to hang on HB.", + "name": null, + "image": "UB-5.301-c.webp" + }, + { + "id": "UB-5.302-a", + "elementGroupId": "UB-5", + "code": "5.302", + "value": 0.3, + "description": "Clear rear pike support on HB (legs together) – full circle swing bwd to finish in clear rear support on HB", + "name": "Steinemann circle", + "image": "UB-5.302-a.webp" + }, + { + "id": "UB-5.303-a", + "elementGroupId": "UB-5", + "code": "5.303", + "value": 0.3, + "description": "Clear rear pike support (legs together) on HB – full circle swing bwd with stoop out bwd to hang on HB", + "name": "Li Li", + "image": "UB-5.303-a.webp" + }, + { + "id": "UB-5.304-a", + "elementGroupId": "UB-5", + "code": "5.304", + "value": 0.3, + "description": "Schleudern to near hstd with hop-change to regular grip on HB", + "name": null, + "image": "UB-5.304-a.webp" + }, + { + "id": "UB-5.304-b", + "elementGroupId": "UB-5", + "code": "5.304", + "value": 0.3, + "description": "Stoop trough on HB, dislocate and release with ½ (180°) turn in flight between the bars to catch LB in hang", + "name": "Alt", + "image": "UB-5.304-b.webp" + }, + { + "id": "UB-5.305-a", + "elementGroupId": "UB-5", + "code": "5.305", + "value": 0.3, + "description": "Pike sole circle fwd in reverse grip to hstd", + "name": null, + "image": "UB-5.305-a.webp" + }, + { + "id": "UB-5.305-b", + "elementGroupId": "UB-5", + "code": "5.305", + "value": 0.3, + "description": "Pike sole circle fwd in reverse grip to hstd, with ½ turn (180°) to hstd", + "name": null, + "image": "UB-5.305-b.webp" + }, + { + "id": "UB-5.306-a", + "elementGroupId": "UB-5", + "code": "5.306", + "value": 0.3, + "description": "Pike sole circle fwd in L grip to hstd", + "name": null, + "image": "UB-5.306-a.webp" + }, + { + "id": "UB-5.306-b", + "elementGroupId": "UB-5", + "code": "5.306", + "value": 0.3, + "description": "Pike sole circle fwd in L grip to hstd, with ½ turn (180°)", + "name": null, + "image": "UB-5.306-b.webp" + }, + { + "id": "UB-5.307-a", + "elementGroupId": "UB-5", + "code": "5.307", + "value": 0.3, + "description": "Underswing on HB or LB with hand release and 1½ turn (540°) to hang", + "name": "Burda", + "image": "UB-5.307-a.webp" + }, + { + "id": "UB-5.308-a", + "elementGroupId": "UB-5", + "code": "5.308", + "value": 0.3, + "description": "Pike sole circle bwd to hstd", + "name": null, + "image": "UB-5.308-a.webp" + }, + { + "id": "UB-5.308-b", + "elementGroupId": "UB-5", + "code": "5.308", + "value": 0.3, + "description": "Pike sole circle bwd to hstd, with hop-grip change to reverse grip in hstd phase", + "name": null, + "image": "UB-5.308-b.webp" + }, + { + "id": "UB-5.308-c", + "elementGroupId": "UB-5", + "code": "5.308", + "value": 0.3, + "description": "Pike sole circle bwd to hstd, with ½ turn (180°)", + "name": null, + "image": "UB-5.308-c.webp" + }, + { + "id": "UB-5.402-a", + "elementGroupId": "UB-5", + "code": "5.402", + "value": 0.4, + "description": "Clear rear pike support on HB (legs together) – full circle swing bwd – continuing through clear rear pike support bwd over HB into hang", + "name": "Mirgoradskaja", + "image": "UB-5.402-a.webp" + }, + { + "id": "UB-5.402-b", + "elementGroupId": "UB-5", + "code": "5.402", + "value": 0.4, + "description": "From hstd clear pike circle bwd to rear inverted pike support", + "name": "Krasnyanska", + "image": "UB-5.402-b.webp" + }, + { + "id": "UB-5.403-a", + "elementGroupId": "UB-5", + "code": "5.403", + "value": 0.4, + "description": "Clear rear pike support (legs together) on HB – full circle swing bwd with counter flight bwd straddled", + "name": "Li Li", + "image": "UB-5.403-a.webp" + }, + { + "id": "UB-5.404-a", + "elementGroupId": "UB-5", + "code": "5.404", + "value": 0.4, + "description": "Underswing bwd (inverted pike swing) on HB – dislocate with flight to hstd on LB", + "name": "Zuchold-Schleudern", + "image": "UB-5.404-a.webp" + }, + { + "id": "UB-5.404-b", + "elementGroupId": "UB-5", + "code": "5.404", + "value": 0.4, + "description": "Underswing bwd (inverted pike swing) on HB – dislocate with flight to hstd on LB, from stoop through etc...", + "name": null, + "image": "UB-5.404-b.webp" + }, + { + "id": "UB-5.405-a", + "elementGroupId": "UB-5", + "code": "5.405", + "value": 0.4, + "description": "Pike sole circle fwd in reverse grip with 1/1 turn (360°) to hstd", + "name": "Hoefnagel", + "image": "UB-5.405-a.webp" + }, + { + "id": "UB-5.406-a", + "elementGroupId": "UB-5", + "code": "5.406", + "value": 0.4, + "description": "Pike sole circle fwd in L grip with 1/1 turn (360°) to hstd", + "name": null, + "image": "UB-5.406-a.webp" + }, + { + "id": "UB-5.408-a", + "elementGroupId": "UB-5", + "code": "5.408", + "value": 0.4, + "description": "Pike sole circle bwd with 1/1 turn (360°) to hstd", + "name": null, + "image": "UB-5.408-a.webp" + }, + { + "id": "UB-5.408-b", + "elementGroupId": "UB-5", + "code": "5.408", + "value": 0.4, + "description": "From L grip, swing bwd ½ turn (180°), to double salto bwd piked", + "name": "Okamura", + "image": "UB-5.408-b.webp" + }, + { + "id": "UB-5.409-a", + "elementGroupId": "UB-5", + "code": "5.409", + "value": 0.4, + "description": "Inner front support on LB – pike sole circle bwd through hstd with flight to hang on HB", + "name": "Maloney", + "image": "UB-5.409-a.webp" + }, + { + "id": "UB-5.410-a", + "elementGroupId": "UB-5", + "code": "5.410", + "value": 0.4, + "description": "Pike sole circle bwd counter straddle-reverse hecht over HB to hang", + "name": "Ray", + "image": "UB-5.410-a.webp" + }, + { + "id": "UB-5.410-b", + "elementGroupId": "UB-5", + "code": "5.410", + "value": 0.4, + "description": "Pike sole circle bwd with counter straddle hecht over HB with ½ turn (180°) to hang in mixed L grip", + "name": "Tweddle", + "image": "UB-5.410-b.webp" + }, + { + "id": "UB-5.501-a", + "elementGroupId": "UB-5", + "code": "5.501", + "value": 0.5, + "description": "Stoop in to Adler-seat (pike) circle fwd through clear extended support to finish in L grip (deviation up to 30° allowed)", + "name": "Luo", + "image": "UB-5.501-a.webp" + }, + { + "id": "UB-5.501-b", + "elementGroupId": "UB-5", + "code": "5.501", + "value": 0.5, + "description": "Stoop in to Adler-seat (pike) circle fwd through clear extended support to finish in L grip (deviation up to 30° allowed), with ½ turn (180°)", + "name": null, + "image": "UB-5.501-b.webp" + }, + { + "id": "UB-5.502-a", + "elementGroupId": "UB-5", + "code": "5.502", + "value": 0.5, + "description": "Clear rear pike support on HB (legs together) circle swing bwd and continue to salto bwd stretched between bars to clear support on LB circle swing bwd and ½ turn (180°) with flight to hstd on LB", + "name": "Teza", + "image": "UB-5.502-a.webp" + }, + { + "id": "UB-5.502-b", + "elementGroupId": "UB-5", + "code": "5.502", + "value": 0.5, + "description": "Clear rear pike support on HB (legs together) circle swing bwd and continue to salto bwd stretched between bars to clear support on LB circle swing bwd and ½ turn (180°) with flight to hstd on LB", + "name": "Teza", + "image": "UB-5.502-b.webp" + }, + { + "id": "UB-5.508-a", + "elementGroupId": "UB-5", + "code": "5.508", + "value": 0.5, + "description": "Pike sole circle bwd with 1½ turn (540°) to hstd", + "name": "Lucke", + "image": "UB-5.508-a.webp" + }, + { + "id": "UB-5.509-a", + "elementGroupId": "UB-5", + "code": "5.509", + "value": 0.5, + "description": "Facing outward on LB – pike sole circle bwd through hstd with flight and ½ turn (180°) or 1/1 turn (360°) to hang on HB", + "name": "Van Leeuwen / Seitz", + "image": "UB-5.509-a.webp" + }, + { + "id": "UB-5.509-b", + "elementGroupId": "UB-5", + "code": "5.509", + "value": 0.5, + "description": "Facing outward on LB – pike sole circle bwd through hstd with flight and ½ turn (180°) or 1/1 turn (360°) to hang on HB", + "name": "Van Leeuwen / Seitz", + "image": "UB-5.509-b.webp" + }, + { + "id": "UB-5.510-a", + "elementGroupId": "UB-5", + "code": "5.510", + "value": 0.5, + "description": "Pike sole circle bwd counter pike hecht over HB to hang", + "name": "Church", + "image": "UB-5.510-a.webp" + }, + { + "id": "UB-5.510-b", + "elementGroupId": "UB-5", + "code": "5.510", + "value": 0.5, + "description": "Pike sole circle bwd with counter pike hecht over HB with ½ turn (180°) to hang in mixed L grip", + "name": "Fenton", + "image": "UB-5.510-b.webp" + }, + { + "id": "UB-5.607-a", + "elementGroupId": "UB-5", + "code": "5.607", + "value": 0.6, + "description": "Facing outward on HB – underswing with support of feet-counter salto fwd straddled to hang on HB in reverse grip", + "name": null, + "image": "UB-5.607-a.webp" + }, + { + "id": "UB-5.610-a", + "elementGroupId": "UB-5", + "code": "5.610", + "value": 0.6, + "description": "Pike sole circle bwd with counter stretched – reverse hecht in layout position over HB to hang", + "name": "Nabieva", + "image": "UB-5.610-a.webp" + }, + { + "id": "UB-5.610-b", + "elementGroupId": "UB-5", + "code": "5.610", + "value": 0.6, + "description": "Pike sole circle bwd with counter stretched – reverse hecht in layout position over HB, with ½ turn (180°) to hang in mixed L grip", + "name": "Derwael", + "image": "UB-5.610-b.webp" + }, + { + "id": "UB-6.101-a", + "elementGroupId": "UB-6", + "code": "6.101", + "value": 0.1, + "description": "From HB – underswing with ½ turn (180°) or 1/1 turn (360°) to stand", + "name": null, + "image": "UB-6.101-a.webp" + }, + { + "id": "UB-6.101-b", + "elementGroupId": "UB-6", + "code": "6.101", + "value": 0.1, + "description": "From HB – underswing with ½ turn (180°) or 1/1 turn (360°) to stand", + "name": null, + "image": "UB-6.101-b.webp" + }, + { + "id": "UB-6.102-a", + "elementGroupId": "UB-6", + "code": "6.102", + "value": 0.1, + "description": "From HB – clear underswing with ½ turn (180°) or 1/1 turn (360°) to stand", + "name": null, + "image": "UB-6.102-a.webp" + }, + { + "id": "UB-6.102-b", + "elementGroupId": "UB-6", + "code": "6.102", + "value": 0.1, + "description": "From HB – clear underswing with ½ turn (180°) or 1/1 turn (360°) to stand", + "name": null, + "image": "UB-6.102-b.webp" + }, + { + "id": "UB-6.104-a", + "elementGroupId": "UB-6", + "code": "6.104", + "value": 0.1, + "description": "Swing fwd to salto bwd tucked, piked or stretched (flyaway)", + "name": null, + "image": "UB-6.104-a.webp" + }, + { + "id": "UB-6.104-b", + "elementGroupId": "UB-6", + "code": "6.104", + "value": 0.1, + "description": "Swing fwd to salto bwd tucked, piked or stretched (flyaway)", + "name": null, + "image": "UB-6.104-b.webp" + }, + { + "id": "UB-6.104-c", + "elementGroupId": "UB-6", + "code": "6.104", + "value": 0.1, + "description": "Swing fwd to salto bwd tucked, piked or stretched (flyaway)", + "name": null, + "image": "UB-6.104-c.webp" + }, + { + "id": "UB-6.108-a", + "elementGroupId": "UB-6", + "code": "6.108", + "value": 0.1, + "description": "Swing bwd to salto fwd tucked, piked or stretched or clear straddle circle fwd on HB to salto fwd tucked", + "name": null, + "image": "UB-6.108-a.webp" + }, + { + "id": "UB-6.108-b", + "elementGroupId": "UB-6", + "code": "6.108", + "value": 0.1, + "description": "Swing bwd to salto fwd tucked, piked or stretched or clear straddle circle fwd on HB to salto fwd tucked", + "name": null, + "image": "UB-6.108-b.webp" + }, + { + "id": "UB-6.108-c", + "elementGroupId": "UB-6", + "code": "6.108", + "value": 0.1, + "description": "Swing bwd to salto fwd tucked, piked or stretched or clear straddle circle fwd on HB to salto fwd tucked", + "name": null, + "image": "UB-6.108-c.webp" + }, + { + "id": "UB-6.108-d", + "elementGroupId": "UB-6", + "code": "6.108", + "value": 0.1, + "description": "Swing bwd to salto fwd tucked, piked or stretched or clear straddle circle fwd on HB to salto fwd tucked", + "name": null, + "image": "UB-6.108-d.webp" + }, + { + "id": "UB-6.201-a", + "elementGroupId": "UB-6", + "code": "6.201", + "value": 0.2, + "description": "From HB – underswing with salto fwd tucked or piked", + "name": null, + "image": "UB-6.201-a.webp" + }, + { + "id": "UB-6.201-b", + "elementGroupId": "UB-6", + "code": "6.201", + "value": 0.2, + "description": "From HB – underswing with salto fwd tucked or piked", + "name": null, + "image": "UB-6.201-b.webp" + }, + { + "id": "UB-6.204-a", + "elementGroupId": "UB-6", + "code": "6.204", + "value": 0.2, + "description": "Swing fwd to salto bwd tucked, or stretched with ½ turn (180°) or 1/1 turn (360°) (flyaway)", + "name": null, + "image": "UB-6.204-a.webp" + }, + { + "id": "UB-6.204-b", + "elementGroupId": "UB-6", + "code": "6.204", + "value": 0.2, + "description": "Swing fwd to salto bwd tucked, or stretched with ½ turn (180°) or 1/1 turn (360°) (flyaway)", + "name": null, + "image": "UB-6.204-b.webp" + }, + { + "id": "UB-6.204-c", + "elementGroupId": "UB-6", + "code": "6.204", + "value": 0.2, + "description": "Swing fwd to salto bwd tucked, or stretched with ½ turn (180°) or 1/1 turn (360°) (flyaway)", + "name": null, + "image": "UB-6.204-c.webp" + }, + { + "id": "UB-6.204-d", + "elementGroupId": "UB-6", + "code": "6.204", + "value": 0.2, + "description": "Swing fwd to salto bwd tucked, or stretched with ½ turn (180°) or 1/1 turn (360°) (flyaway)", + "name": null, + "image": "UB-6.204-d.webp" + }, + { + "id": "UB-6.205-a", + "elementGroupId": "UB-6", + "code": "6.205", + "value": 0.2, + "description": "Swing fwd to double salto bwd tucked", + "name": null, + "image": "UB-6.205-a.webp" + }, + { + "id": "UB-6.208-a", + "elementGroupId": "UB-6", + "code": "6.208", + "value": 0.2, + "description": "Swing bwd to salto fwd tucked or stretched with ½ turn (180°) or 1/1 turn (360°)", + "name": null, + "image": "UB-6.208-a.webp" + }, + { + "id": "UB-6.208-b", + "elementGroupId": "UB-6", + "code": "6.208", + "value": 0.2, + "description": "Swing bwd to salto fwd tucked or stretched with ½ turn (180°) or 1/1 turn (360°)", + "name": null, + "image": "UB-6.208-b.webp" + }, + { + "id": "UB-6.208-c", + "elementGroupId": "UB-6", + "code": "6.208", + "value": 0.2, + "description": "Swing bwd to salto fwd tucked or stretched with ½ turn (180°) or 1/1 turn (360°)", + "name": null, + "image": "UB-6.208-c.webp" + }, + { + "id": "UB-6.208-d", + "elementGroupId": "UB-6", + "code": "6.208", + "value": 0.2, + "description": "Swing bwd to salto fwd tucked or stretched with ½ turn (180°) or 1/1 turn (360°)", + "name": null, + "image": "UB-6.208-d.webp" + }, + { + "id": "UB-6.209-a", + "elementGroupId": "UB-6", + "code": "6.209", + "value": 0.2, + "description": "Hip circle bwd (also clear) on LB or HB – hecht", + "name": null, + "image": "UB-6.209-a.webp" + }, + { + "id": "UB-6.209-b", + "elementGroupId": "UB-6", + "code": "6.209", + "value": 0.2, + "description": "Hip circle bwd (also clear) on LB or HB – hecht", + "name": null, + "image": "UB-6.209-b.webp" + }, + { + "id": "UB-6.210-a", + "elementGroupId": "UB-6", + "code": "6.210", + "value": 0.2, + "description": "On HB - salto fwd tucked", + "name": null, + "image": "UB-6.210-a.webp" + }, + { + "id": "UB-6.301-a", + "elementGroupId": "UB-6", + "code": "6.301", + "value": 0.3, + "description": "From HB – underswing with salto fwd tucked or piked with ½ turn (180°) or 1/1 turn (360°)", + "name": null, + "image": "UB-6.301-a.webp" + }, + { + "id": "UB-6.301-b", + "elementGroupId": "UB-6", + "code": "6.301", + "value": 0.3, + "description": "From HB – underswing with salto fwd tucked or piked with ½ turn (180°) or 1/1 turn (360°)", + "name": null, + "image": "UB-6.301-b.webp" + }, + { + "id": "UB-6.302-a", + "elementGroupId": "UB-6", + "code": "6.302", + "value": 0.3, + "description": "From HB – clear underswing with salto fwd tucked or piked; also with ½ turn (180°)", + "name": null, + "image": "UB-6.302-a.webp" + }, + { + "id": "UB-6.302-b", + "elementGroupId": "UB-6", + "code": "6.302", + "value": 0.3, + "description": "Clear straddle circle with salto fwd tucked also with ½ turn (180°)", + "name": "Plichta / Alt", + "image": "UB-6.302-b.webp" + }, + { + "id": "UB-6.303-a", + "elementGroupId": "UB-6", + "code": "6.303", + "value": 0.3, + "description": "From HB – underswing with ½ or clear underswing with ½ turn (180°) to salto bwd tucked or piked", + "name": "Comaneci", + "image": "UB-6.303-a.webp" + }, + { + "id": "UB-6.303-b", + "elementGroupId": "UB-6", + "code": "6.303", + "value": 0.3, + "description": "From HB – underswing with ½ or clear underswing with ½ turn (180°) to salto bwd tucked or piked", + "name": "Comaneci", + "image": "UB-6.303-b.webp" + }, + { + "id": "UB-6.303-c", + "elementGroupId": "UB-6", + "code": "6.303", + "value": 0.3, + "description": "From HB – underswing with ½ or clear underswing with ½ turn (180°) to salto bwd tucked or piked", + "name": "Comaneci", + "image": "UB-6.303-c.webp" + }, + { + "id": "UB-6.303-d", + "elementGroupId": "UB-6", + "code": "6.303", + "value": 0.3, + "description": "From HB – underswing with ½ or clear underswing with ½ turn (180°) to salto bwd tucked or piked", + "name": "Comaneci", + "image": "UB-6.303-d.webp" + }, + { + "id": "UB-6.304-a", + "elementGroupId": "UB-6", + "code": "6.304", + "value": 0.3, + "description": "Swing fwd to salto bwd stretched with 1½ turn (540°) or 2/1 turn (720°)", + "name": null, + "image": "UB-6.304-a.webp" + }, + { + "id": "UB-6.304-b", + "elementGroupId": "UB-6", + "code": "6.304", + "value": 0.3, + "description": "Swing fwd to salto bwd stretched with 1½ turn (540°) or 2/1 turn (720°)", + "name": null, + "image": "UB-6.304-b.webp" + }, + { + "id": "UB-6.305-a", + "elementGroupId": "UB-6", + "code": "6.305", + "value": 0.3, + "description": "Swing fwd to double salto bwd piked", + "name": null, + "image": "UB-6.305-a.webp" + }, + { + "id": "UB-6.308-a", + "elementGroupId": "UB-6", + "code": "6.308", + "value": 0.3, + "description": "Swing bwd to salto fwd stretched with 1½ turn (540°) or 2/1 turn (720°)", + "name": "Pechstein", + "image": "UB-6.308-a.webp" + }, + { + "id": "UB-6.308-b", + "elementGroupId": "UB-6", + "code": "6.308", + "value": 0.3, + "description": "Swing bwd to salto fwd stretched with 1½ turn (540°) or 2/1 turn (720°)", + "name": "Pechstein", + "image": "UB-6.308-b.webp" + }, + { + "id": "UB-6.308-c", + "elementGroupId": "UB-6", + "code": "6.308", + "value": 0.3, + "description": "From L grip, swing bwd, ½ (180°) turn, to double salto bwd tucked", + "name": "Fan", + "image": "UB-6.308-c.webp" + }, + { + "id": "UB-6.309-a", + "elementGroupId": "UB-6", + "code": "6.309", + "value": 0.3, + "description": "Hip circle bwd (also clear) on LB or HB – hecht with 1/1 turn (360°)", + "name": null, + "image": "UB-6.309-a.webp" + }, + { + "id": "UB-6.309-b", + "elementGroupId": "UB-6", + "code": "6.309", + "value": 0.3, + "description": "Hip circle bwd (also clear) on LB or HB – hecht with 1/1 turn (360°)", + "name": null, + "image": "UB-6.309-b.webp" + }, + { + "id": "UB-6.310-a", + "elementGroupId": "UB-6", + "code": "6.310", + "value": 0.3, + "description": "On HB – salto fwd piked or salto bwd tucked or piked", + "name": "Delladio / Parolari", + "image": "UB-6.310-a.webp" + }, + { + "id": "UB-6.310-b", + "elementGroupId": "UB-6", + "code": "6.310", + "value": 0.3, + "description": "On HB – salto fwd piked or salto bwd tucked or piked", + "name": "Delladio / Parolari", + "image": "UB-6.310-b.webp" + }, + { + "id": "UB-6.310-c", + "elementGroupId": "UB-6", + "code": "6.310", + "value": 0.3, + "description": "On HB – salto fwd piked or salto bwd tucked or piked", + "name": "Delladio / Parolari", + "image": "UB-6.310-c.webp" + }, + { + "id": "UB-6.401-a", + "elementGroupId": "UB-6", + "code": "6.401", + "value": 0.4, + "description": "From HB – underswing with salto fwd tucked with 1½ turn (540°)", + "name": null, + "image": "UB-6.401-a.webp" + }, + { + "id": "UB-6.401-b", + "elementGroupId": "UB-6", + "code": "6.401", + "value": 0.4, + "description": "From HB – underswing with salto fwd stretched with ½ turn (180°)", + "name": "Moors", + "image": "UB-6.401-b.webp" + }, + { + "id": "UB-6.402-a", + "elementGroupId": "UB-6", + "code": "6.402", + "value": 0.4, + "description": "From HB – clear underswing with salto fwd tucked with 1/1 turn (360°)", + "name": null, + "image": "UB-6.402-a.webp" + }, + { + "id": "UB-6.402-b", + "elementGroupId": "UB-6", + "code": "6.402", + "value": 0.4, + "description": "Clear pike underswing to salto forward stretched with ½ turn (180°)", + "name": "Brunner", + "image": "UB-6.402-b.webp" + }, + { + "id": "UB-6.402-c", + "elementGroupId": "UB-6", + "code": "6.402", + "value": 0.4, + "description": "From HB – clear straddle circle with salto fwd tucked with 1/1 turn (360°)", + "name": "Petz", + "image": "UB-6.402-c.webp" + }, + { + "id": "UB-6.403-a", + "elementGroupId": "UB-6", + "code": "6.403", + "value": 0.4, + "description": "From HB – underswing with ½ turn (180°) to salto bwd stretched", + "name": null, + "image": "UB-6.403-a.webp" + }, + { + "id": "UB-6.403-b", + "elementGroupId": "UB-6", + "code": "6.403", + "value": 0.4, + "description": "From HB – clear underswing with ½ turn (180°) to salto bwd stretched", + "name": "Okino", + "image": "UB-6.403-b.webp" + }, + { + "id": "UB-6.403-c", + "elementGroupId": "UB-6", + "code": "6.403", + "value": 0.4, + "description": "From HB – underswing with ½ turn (180°) to salto bwd tucked with 1/1 turn (360°)", + "name": "Kraeker", + "image": "UB-6.403-c.webp" + }, + { + "id": "UB-6.404-a", + "elementGroupId": "UB-6", + "code": "6.404", + "value": 0.4, + "description": "Swing fwd to salto bwd stretched with 2½ turn (900°)", + "name": "Ji", + "image": "UB-6.404-a.webp" + }, + { + "id": "UB-6.405-a", + "elementGroupId": "UB-6", + "code": "6.405", + "value": 0.4, + "description": "Swing fwd to double salto bwd tucked or piked with 1/1 turn (360°) in first or second salto", + "name": "Morio / Chusovitina", + "image": "UB-6.405-a.webp" + }, + { + "id": "UB-6.405-b", + "elementGroupId": "UB-6", + "code": "6.405", + "value": 0.4, + "description": "Swing fwd to double salto bwd tucked or piked with 1/1 turn (360°) in first or second salto", + "name": "Morio / Chusovitina", + "image": "UB-6.405-b.webp" + }, + { + "id": "UB-6.406-a", + "elementGroupId": "UB-6", + "code": "6.406", + "value": 0.4, + "description": "Swing fwd to double salto bwd stretched", + "name": null, + "image": "UB-6.406-a.webp" + }, + { + "id": "UB-6.407-a", + "elementGroupId": "UB-6", + "code": "6.407", + "value": 0.4, + "description": "Swing fwd to salto bwd tucked with ½ turn (180°) – into salto fwd tucked", + "name": "Fontaine", + "image": "UB-6.407-a.webp" + }, + { + "id": "UB-6.407-b", + "elementGroupId": "UB-6", + "code": "6.407", + "value": 0.4, + "description": "Swing fwd with ½ turn (180°) to double salto fwd tucked", + "name": null, + "image": "UB-6.407-b.webp" + }, + { + "id": "UB-6.408-a", + "elementGroupId": "UB-6", + "code": "6.408", + "value": 0.4, + "description": "Swing bwd to double salto fwd tucked", + "name": null, + "image": "UB-6.408-a.webp" + }, + { + "id": "UB-6.408-b", + "elementGroupId": "UB-6", + "code": "6.408", + "value": 0.4, + "description": "From L grip, swing bwd ½ turn (180°), to double salto bwd piked", + "name": "Okamura", + "image": "UB-6.408-b.webp" + }, + { + "id": "UB-6.409-a", + "elementGroupId": "UB-6", + "code": "6.409", + "value": 0.4, + "description": "Hip circle bwd (also clear) on LB or HB – hecht with salto bwd tucked", + "name": "Mukhina", + "image": "UB-6.409-a.webp" + }, + { + "id": "UB-6.409-b", + "elementGroupId": "UB-6", + "code": "6.409", + "value": 0.4, + "description": "Hip circle bwd (also clear) on LB or HB – hecht with salto bwd tucked", + "name": "Mukhina", + "image": "UB-6.409-b.webp" + }, + { + "id": "UB-6.502-a", + "elementGroupId": "UB-6", + "code": "6.502", + "value": 0.5, + "description": "From HB – clear underswing with salto fwd tucked with 1½ turn (540°)", + "name": null, + "image": "UB-6.502-a.webp" + }, + { + "id": "UB-6.504-a", + "elementGroupId": "UB-6", + "code": "6.504", + "value": 0.5, + "description": "Swing fwd to salto bwd stretched with 3/1 turn (1080°)", + "name": "Bar", + "image": "UB-6.504-a.webp" + }, + { + "id": "UB-6.505-a", + "elementGroupId": "UB-6", + "code": "6.505", + "value": 0.5, + "description": "Swing fwd to double salto bwd tucked with 1½ turn (540°)", + "name": "Mustafina", + "image": "UB-6.505-a.webp" + }, + { + "id": "UB-6.507-a", + "elementGroupId": "UB-6", + "code": "6.507", + "value": 0.5, + "description": "Swing fwd to salto bwd stretched with ½ turn (180°) – into salto fwd stretched", + "name": "Blanco", + "image": "UB-6.507-a.webp" + }, + { + "id": "UB-6.507-b", + "elementGroupId": "UB-6", + "code": "6.507", + "value": 0.5, + "description": "Swing fwd with ½ turn (180°) to double salto fwd piked", + "name": "Giovannini / Li Ya", + "image": "UB-6.507-b.webp" + }, + { + "id": "UB-6.508-a", + "elementGroupId": "UB-6", + "code": "6.508", + "value": 0.5, + "description": "Swing bwd to double salto fwd tucked with ½ turn (180°)", + "name": "Arai", + "image": "UB-6.508-a.webp" + }, + { + "id": "UB-6.508-b", + "elementGroupId": "UB-6", + "code": "6.508", + "value": 0.5, + "description": "Swing bwd to salto fwd with ½ turn (180°) into salto bwd tucked", + "name": null, + "image": "UB-6.508-b.webp" + }, + { + "id": "UB-6.508-c", + "elementGroupId": "UB-6", + "code": "6.508", + "value": 0.5, + "description": "Swing bwd to double salto fwd piked", + "name": "Stewart", + "image": "UB-6.508-c.webp" + }, + { + "id": "UB-6.509-a", + "elementGroupId": "UB-6", + "code": "6.509", + "value": 0.5, + "description": "Hip circle bwd (also clear) on LB or HB – hecht with ½ turn (180°) to salto fwd tucked", + "name": null, + "image": "UB-6.509-a.webp" + }, + { + "id": "UB-6.509-b", + "elementGroupId": "UB-6", + "code": "6.509", + "value": 0.5, + "description": "Hip circle bwd (also clear) on LB or HB – hecht with ½ turn (180°) to salto fwd tucked", + "name": null, + "image": "UB-6.509-b.webp" + }, + { + "id": "UB-6.605-a", + "elementGroupId": "UB-6", + "code": "6.605", + "value": 0.6, + "description": "Swing fwd to double salto bwd tucked with 2/1 turn (720°)", + "name": "Fabrichnova", + "image": "UB-6.605-a.webp" + }, + { + "id": "UB-6.606-a", + "elementGroupId": "UB-6", + "code": "6.606", + "value": 0.6, + "description": "Swing fwd to double salto bwd stretched with 1/1 turn (360°) in first or second salto", + "name": null, + "image": "UB-6.606-a.webp" + }, + { + "id": "UB-6.608-a", + "elementGroupId": "UB-6", + "code": "6.608", + "value": 0.6, + "description": "Swing bwd to double salto fwd piked with ½ turn (180°)", + "name": "Pentek", + "image": "UB-6.608-a.webp" + }, + { + "id": "UB-6.609-a", + "elementGroupId": "UB-6", + "code": "6.609", + "value": 0.6, + "description": "Hip circle bwd (also clear) on LB or HB – hecht with 1/1 turn (360°) to salto bwd tucked", + "name": "Ma", + "image": "UB-6.609-a.webp" + }, + { + "id": "UB-6.609-b", + "elementGroupId": "UB-6", + "code": "6.609", + "value": 0.6, + "description": "Hip circle bwd (also clear) on LB or HB – hecht with 1/1 turn (360°) to salto bwd tucked", + "name": "Ma", + "image": "UB-6.609-b.webp" + }, + { + "id": "UB-6.706-a", + "elementGroupId": "UB-6", + "code": "6.706", + "value": 0.7, + "description": "Swing fwd to double salto bwd stretched with 2/1 turn (720°)", + "name": "Ray", + "image": "UB-6.706-a.webp" + }, + { + "id": "UB-6.707-a", + "elementGroupId": "UB-6", + "code": "6.707", + "value": 0.7, + "description": "Swing fwd to triple salto bwd tucked", + "name": "Magaña", + "image": "UB-6.707-a.webp" + }, + { + "id": "BB-1.101-a", + "elementGroupId": "BB-1", + "code": "1.101", + "value": 0.1, + "description": "Leap - on landing must show arabesque position (leg min. at horizontal)", + "name": null, + "image": "BB-1.101-a.webp" + }, + { + "id": "BB-1.102-a", + "elementGroupId": "BB-1", + "code": "1.102", + "value": 0.1, + "description": "Thief vault – take-off from one leg – free leap over beam, one leg after another to rear support – 90° approach to beam, or Scissor leap over beam to cross sit on thigh – diagonal approach to beam", + "name": null, + "image": "BB-1.102-a.webp" + }, + { + "id": "BB-1.103-a", + "elementGroupId": "BB-1", + "code": "1.103", + "value": 0.1, + "description": "Flank to rear support", + "name": null, + "image": "BB-1.103-a.webp" + }, + { + "id": "BB-1.103-b", + "elementGroupId": "BB-1", + "code": "1.103", + "value": 0.1, + "description": "Flank to rear support, with ½ turn (180°)", + "name": null, + "image": "BB-1.103-b.webp" + }, + { + "id": "BB-1.103-c", + "elementGroupId": "BB-1", + "code": "1.103", + "value": 0.1, + "description": "Jump with hand support, ¾ turn (270˚) in support to cross sit on thigh", + "name": null, + "image": "BB-1.103-c.webp" + }, + { + "id": "BB-1.105-a", + "elementGroupId": "BB-1", + "code": "1.105", + "value": 0.1, + "description": "Jump with hand support to side split sit or straddle position (must show split without hands touching the beam as final position; in straddle position the trunk must touch the beam)", + "name": null, + "image": "BB-1.105-a.webp" + }, + { + "id": "BB-1.105-b", + "elementGroupId": "BB-1", + "code": "1.105", + "value": 0.1, + "description": "Jump with hand support to side split sit or straddle position (must show split without hands touching the beam as final position; in straddle position the trunk must touch the beam)", + "name": null, + "image": "BB-1.105-b.webp" + }, + { + "id": "BB-1.105-c", + "elementGroupId": "BB-1", + "code": "1.105", + "value": 0.1, + "description": "Jump or leap to cross split sit – diagonal approach to beam (with hand support)", + "name": null, + "image": "BB-1.105-c.webp" + }, + { + "id": "BB-1.106-a", + "elementGroupId": "BB-1", + "code": "1.106", + "value": 0.1, + "description": "From side stand - squat or stoop through to rear support", + "name": null, + "image": "BB-1.106-a.webp" + }, + { + "id": "BB-1.106-b", + "elementGroupId": "BB-1", + "code": "1.106", + "value": 0.1, + "description": "From side stand - squat or stoop through to rear support", + "name": null, + "image": "BB-1.106-b.webp" + }, + { + "id": "BB-1.108-a", + "elementGroupId": "BB-1", + "code": "1.108", + "value": 0.1, + "description": "Cartwheel with bending of both arms through chest stand to swing down", + "name": null, + "image": "BB-1.108-a.webp" + }, + { + "id": "BB-1.108-b", + "elementGroupId": "BB-1", + "code": "1.108", + "value": 0.1, + "description": "Stand at side of beam and jump to chest stand with both arms bent", + "name": null, + "image": "BB-1.108-b.webp" + }, + { + "id": "BB-1.111-a", + "elementGroupId": "BB-1", + "code": "1.111", + "value": 0.1, + "description": "Planche with support on one or both bent arms (2 sec.)", + "name": null, + "image": "BB-1.111-a.webp" + }, + { + "id": "BB-1.111-b", + "elementGroupId": "BB-1", + "code": "1.111", + "value": 0.1, + "description": "Planche with support on one or both bent arms (2 sec.), legs in cross split position", + "name": null, + "image": "BB-1.111-b.webp" + }, + { + "id": "BB-1.114-a", + "elementGroupId": "BB-1", + "code": "1.114", + "value": 0.1, + "description": "Jump to hstd with bent or straight legs – lower to optional end position", + "name": null, + "image": "BB-1.114-a.webp" + }, + { + "id": "BB-1.114-b", + "elementGroupId": "BB-1", + "code": "1.114", + "value": 0.1, + "description": "Jump to hstd with bent or straight legs – lower to optional end position", + "name": null, + "image": "BB-1.114-b.webp" + }, + { + "id": "BB-1.201-a", + "elementGroupId": "BB-1", + "code": "1.201", + "value": 0.2, + "description": "Split leap (180°)", + "name": null, + "image": "BB-1.201-a.webp" + }, + { + "id": "BB-1.204-a", + "elementGroupId": "BB-1", + "code": "1.204", + "value": 0.2, + "description": "Jump with ½ turn (180°) to clear straddle support, or jump bwd through straddle position over the beam to front support, or to side split – 90° approach to beam", + "name": null, + "image": "BB-1.204-a.webp" + }, + { + "id": "BB-1.204-b", + "elementGroupId": "BB-1", + "code": "1.204", + "value": 0.2, + "description": "Jump with ½ turn (180°) to clear straddle support, or jump bwd through straddle position over the beam to front support, or to side split – 90° approach to beam", + "name": null, + "image": "BB-1.204-b.webp" + }, + { + "id": "BB-1.207-a", + "elementGroupId": "BB-1", + "code": "1.207", + "value": 0.2, + "description": "Jump to roll fwd at end or middle of beam to sit position or tuck stand", + "name": null, + "image": "BB-1.207-a.webp" + }, + { + "id": "BB-1.207-b", + "elementGroupId": "BB-1", + "code": "1.207", + "value": 0.2, + "description": "Jump to clear straddle support on end of beam – swing bwd to roll fwd to sit position or tuck stand", + "name": null, + "image": "BB-1.207-b.webp" + }, + { + "id": "BB-1.208-a", + "elementGroupId": "BB-1", + "code": "1.208", + "value": 0.2, + "description": "Jump with ½ turn (180°) over shoulder to neck stand, ½ turn (180°) to chest stand", + "name": null, + "image": "BB-1.208-a.webp" + }, + { + "id": "BB-1.210-a", + "elementGroupId": "BB-1", + "code": "1.210", + "value": 0.2, + "description": "Jump, press, or swing to hstd (2 sec.) – lower to end position touching beam, also with ½ turn (180°) in hstd", + "name": null, + "image": "BB-1.210-a.webp" + }, + { + "id": "BB-1.210-b", + "elementGroupId": "BB-1", + "code": "1.210", + "value": 0.2, + "description": "Jump, press, or swing to hstd (2 sec.) – lower to end position touching beam, also with ½ turn (180°) in hstd", + "name": null, + "image": "BB-1.210-b.webp" + }, + { + "id": "BB-1.210-c", + "elementGroupId": "BB-1", + "code": "1.210", + "value": 0.2, + "description": "Jump, press, or swing to hstd (2 sec.) – lower to end position touching beam, also with ½ turn (180°) in hstd", + "name": null, + "image": "BB-1.210-c.webp" + }, + { + "id": "BB-1.211-a", + "elementGroupId": "BB-1", + "code": "1.211", + "value": 0.2, + "description": "Jump with bent hips to clear front support above horizontal minimum at 45° (planche) (2 sec.) - lower to optional end position", + "name": null, + "image": "BB-1.211-a.webp" + }, + { + "id": "BB-1.213-a", + "elementGroupId": "BB-1", + "code": "1.213", + "value": 0.2, + "description": "Press to side hstd, with bending - stretching of legs – hop with ¼ turn (90°) to cross hstd - lower to optional end position", + "name": null, + "image": "BB-1.213-a.webp" + }, + { + "id": "BB-1.214-a", + "elementGroupId": "BB-1", + "code": "1.214", + "value": 0.2, + "description": "Cartwheel on one or both arms", + "name": null, + "image": "BB-1.214-a.webp" + }, + { + "id": "BB-1.214-b", + "elementGroupId": "BB-1", + "code": "1.214", + "value": 0.2, + "description": "Cartwheel on one or both arms", + "name": null, + "image": "BB-1.214-b.webp" + }, + { + "id": "BB-1.215-a", + "elementGroupId": "BB-1", + "code": "1.215", + "value": 0.2, + "description": "Handspring fwd with hand repulsion from springboard to rear support – or with ¼ turn (90°) to cross sit on thigh – 90° approach to beam", + "name": null, + "image": "BB-1.215-a.webp" + }, + { + "id": "BB-1.215-b", + "elementGroupId": "BB-1", + "code": "1.215", + "value": 0.2, + "description": "Handspring fwd with hand repulsion from springboard to rear support – or with ¼ turn (90°) to cross sit on thigh – 90° approach to beam", + "name": null, + "image": "BB-1.215-b.webp" + }, + { + "id": "BB-1.215-c", + "elementGroupId": "BB-1", + "code": "1.215", + "value": 0.2, + "description": "Cartwheel, grasping the beam, to front support – 90° approach to beam", + "name": null, + "image": "BB-1.215-c.webp" + }, + { + "id": "BB-1.302-a", + "elementGroupId": "BB-1", + "code": "1.302", + "value": 0.3, + "description": "Straight Jump with ½ turn (180°) in flight phase to stand", + "name": null, + "image": "BB-1.302-a.webp" + }, + { + "id": "BB-1.303-a", + "elementGroupId": "BB-1", + "code": "1.303", + "value": 0.3, + "description": "Two flying flairs", + "name": null, + "image": "BB-1.303-a.webp" + }, + { + "id": "BB-1.303-b", + "elementGroupId": "BB-1", + "code": "1.303", + "value": 0.3, + "description": "Two flying flairs", + "name": null, + "image": "BB-1.303-b.webp" + }, + { + "id": "BB-1.304-a", + "elementGroupId": "BB-1", + "code": "1.304", + "value": 0.3, + "description": "Straddle pike jump bwd over beam from round-off into immediate hip circle bwd – 90° approach to beam", + "name": null, + "image": "BB-1.304-a.webp" + }, + { + "id": "BB-1.305-a", + "elementGroupId": "BB-1", + "code": "1.305", + "value": 0.3, + "description": "Free jump to cross split sit take-off from two feet – diagonal approach to beam", + "name": null, + "image": "BB-1.305-a.webp" + }, + { + "id": "BB-1.305-b", + "elementGroupId": "BB-1", + "code": "1.305", + "value": 0.3, + "description": "Change leg leap to free cross split sit – diagonal approach to beam", + "name": "Dick", + "image": "BB-1.305-b.webp" + }, + { + "id": "BB-1.308-a", + "elementGroupId": "BB-1", + "code": "1.308", + "value": 0.3, + "description": "Jump with ½ turn (180°) over shoulder to neck stand, 1/1 or 1½ turn (360° - 540˚) to neck stand", + "name": null, + "image": "BB-1.308-a.webp" + }, + { + "id": "BB-1.308-b", + "elementGroupId": "BB-1", + "code": "1.308", + "value": 0.3, + "description": "Jump with ½ turn (180°) over shoulder to neck stand, 1/1 or 1½ turn (360° - 540˚) to neck stand", + "name": null, + "image": "BB-1.308-b.webp" + }, + { + "id": "BB-1.309-a", + "elementGroupId": "BB-1", + "code": "1.309", + "value": 0.3, + "description": "From cross stand facing end of beam – head kip", + "name": null, + "image": "BB-1.309-a.webp" + }, + { + "id": "BB-1.309-b", + "elementGroupId": "BB-1", + "code": "1.309", + "value": 0.3, + "description": "From cross stand facing end of beam – jump to hstd with hip angle (pike) to front walkover", + "name": null, + "image": "BB-1.309-b.webp" + }, + { + "id": "BB-1.309-c", + "elementGroupId": "BB-1", + "code": "1.309", + "value": 0.3, + "description": "From cross stand facing end of beam – jump to hstd with hip angle (pike) to handspring fwd with step-out or land on 2 feet", + "name": null, + "image": "BB-1.309-c.webp" + }, + { + "id": "BB-1.309-d", + "elementGroupId": "BB-1", + "code": "1.309", + "value": 0.3, + "description": "From cross stand facing end of beam – jump to hstd with hip angle (pike) to handspring fwd with step-out or land on 2 feet", + "name": null, + "image": "BB-1.309-d.webp" + }, + { + "id": "BB-1.310-a", + "elementGroupId": "BB-1", + "code": "1.310", + "value": 0.3, + "description": "Jump, press, or swing to cross or side hstd with 1/1 - 1½ turn (360°- 540°) – lower to end position touching beam", + "name": null, + "image": "BB-1.310-a.webp" + }, + { + "id": "BB-1.310-b", + "elementGroupId": "BB-1", + "code": "1.310", + "value": 0.3, + "description": "Jump, press, or swing to cross or side hstd with 1/1 - 1½ turn (360°- 540°) – lower to end position touching beam", + "name": null, + "image": "BB-1.310-b.webp" + }, + { + "id": "BB-1.310-c", + "elementGroupId": "BB-1", + "code": "1.310", + "value": 0.3, + "description": "Jump, press, or swing to cross or side hstd with 1/1 - 1½ turn (360°- 540°) – lower to end position touching beam", + "name": null, + "image": "BB-1.310-c.webp" + }, + { + "id": "BB-1.311-a", + "elementGroupId": "BB-1", + "code": "1.311", + "value": 0.3, + "description": "Jump with stretched hips to planche (2 sec.)", + "name": "Shushunova", + "image": "BB-1.311-a.webp" + }, + { + "id": "BB-1.311-b", + "elementGroupId": "BB-1", + "code": "1.311", + "value": 0.3, + "description": "Jump, press, or swing to hstd – lower to planche (2 sec.), also with ½ turn (180°) in hstd", + "name": null, + "image": "BB-1.311-b.webp" + }, + { + "id": "BB-1.311-c", + "elementGroupId": "BB-1", + "code": "1.311", + "value": 0.3, + "description": "Jump, press, or swing to hstd – lower to clear pike support with legs together (2 sec.), also with ½ turn (180°) in hstd", + "name": null, + "image": "BB-1.311-c.webp" + }, + { + "id": "BB-1.311-d", + "elementGroupId": "BB-1", + "code": "1.311", + "value": 0.3, + "description": "From hstd (2 sec.) release one hand with swing down swd to rear support; also with ½ turn (180°) in hstd", + "name": "Li Yifang", + "image": "BB-1.311-d.webp" + }, + { + "id": "BB-1.312-a", + "elementGroupId": "BB-1", + "code": "1.312", + "value": 0.3, + "description": "Hstd in cross position with large arch span, also piked with one leg vertical, other leg bent (2 sec.) - lower to optional end position", + "name": null, + "image": "BB-1.312-a.webp" + }, + { + "id": "BB-1.312-b", + "elementGroupId": "BB-1", + "code": "1.312", + "value": 0.3, + "description": "Hstd with horizontal leg hold (2 sec.) – reverse planche in different variations - lower to optional end position", + "name": null, + "image": "BB-1.312-b.webp" + }, + { + "id": "BB-1.313-a", + "elementGroupId": "BB-1", + "code": "1.313", + "value": 0.3, + "description": "Jump, press, or swing to hstd shift weight to one arm hstd (2 sec.) – lower to optional end position", + "name": null, + "image": "BB-1.313-a.webp" + }, + { + "id": "BB-1.313-b", + "elementGroupId": "BB-1", + "code": "1.313", + "value": 0.3, + "description": "Jump, press, or swing to hstd shift weight to one arm hstd (2 sec.) – lower to optional end position", + "name": null, + "image": "BB-1.313-b.webp" + }, + { + "id": "BB-1.313-c", + "elementGroupId": "BB-1", + "code": "1.313", + "value": 0.3, + "description": "Jump, press, or swing to hstd shift weight to one arm hstd (2 sec.) – lower to optional end position", + "name": null, + "image": "BB-1.313-c.webp" + }, + { + "id": "BB-1.314-a", + "elementGroupId": "BB-1", + "code": "1.314", + "value": 0.3, + "description": "Jump with ¼ turn (90°) and extended hips through a momentary hstd on one arm with immediate ¼ turn (90°) and support on second arm to side hstd – lower to optional end position - 90° approach to beam", + "name": null, + "image": "BB-1.314-a.webp" + }, + { + "id": "BB-1.314-b", + "elementGroupId": "BB-1", + "code": "1.314", + "value": 0.3, + "description": "From rear stand (back towards beam), flic flac over beam to candle position, ending in front support", + "name": "Beukes", + "image": "BB-1.314-b.webp" + }, + { + "id": "BB-1.314-c", + "elementGroupId": "BB-1", + "code": "1.314", + "value": 0.3, + "description": "From rear stand (back towards beam), flic flac with ½ turn (180°) to candle position, ending in front support (grasping under the beam with both hands)", + "name": "Soares", + "image": "BB-1.314-c.webp" + }, + { + "id": "BB-1.315-a", + "elementGroupId": "BB-1", + "code": "1.315", + "value": 0.3, + "description": "Free (aerial) walkover fwd to rear support – or with ¼ turn (90°) to cross sit on thigh – 90° approach to beam", + "name": null, + "image": "BB-1.315-a.webp" + }, + { + "id": "BB-1.315-b", + "elementGroupId": "BB-1", + "code": "1.315", + "value": 0.3, + "description": "Free (aerial) walkover fwd to rear support – or with ¼ turn (90°) to cross sit on thigh – 90° approach to beam", + "name": null, + "image": "BB-1.315-b.webp" + }, + { + "id": "BB-1.317-a", + "elementGroupId": "BB-1", + "code": "1.317", + "value": 0.3, + "description": "Round-off at end of beam – flic-flac through hstd with swing down to cross straddle sit", + "name": null, + "image": "BB-1.317-a.webp" + }, + { + "id": "BB-1.401-a", + "elementGroupId": "BB-1", + "code": "1.401", + "value": 0.4, + "description": "Leap fwd with leg change (free leg swing to 45°) to cross split", + "name": null, + "image": "BB-1.401-a.webp" + }, + { + "id": "BB-1.402-a", + "elementGroupId": "BB-1", + "code": "1.402", + "value": 0.4, + "description": "Jump with 1/1 turn (360°) in flight phase to stand – approach from end or diagonal to beam", + "name": null, + "image": "BB-1.402-a.webp" + }, + { + "id": "BB-1.403-a", + "elementGroupId": "BB-1", + "code": "1.403", + "value": 0.4, + "description": "3 flying flairs", + "name": "Homma", + "image": "BB-1.403-a.webp" + }, + { + "id": "BB-1.403-b", + "elementGroupId": "BB-1", + "code": "1.403", + "value": 0.4, + "description": "Two flank circles followed by leg “Flair”", + "name": "Baitova", + "image": "BB-1.403-b.webp" + }, + { + "id": "BB-1.405-a", + "elementGroupId": "BB-1", + "code": "1.405", + "value": 0.4, + "description": "Leap with leg change and ½ turn (180)˚ away from beam to free cross split sit – diagonal approach to beam", + "name": "Dick", + "image": "BB-1.405-a.webp" + }, + { + "id": "BB-1.411-a", + "elementGroupId": "BB-1", + "code": "1.411", + "value": 0.4, + "description": "Jump, press or swing to hstd – 1/1 turn (360°) in hstd – lower to planche (2 sec.)", + "name": null, + "image": "BB-1.411-a.webp" + }, + { + "id": "BB-1.411-b", + "elementGroupId": "BB-1", + "code": "1.411", + "value": 0.4, + "description": "Jump, press or swing to hstd – 1/1 turn (360°) in hstd – lower to clear pike support with legs together (2 sec.)", + "name": null, + "image": "BB-1.411-b.webp" + }, + { + "id": "BB-1.411-c", + "elementGroupId": "BB-1", + "code": "1.411", + "value": 0.4, + "description": "Jump, press, or swing to hstd – 1/1 turn (360°) in hstd – release one hand with swing down swd to rear support", + "name": null, + "image": "BB-1.411-c.webp" + }, + { + "id": "BB-1.412-a", + "elementGroupId": "BB-1", + "code": "1.412", + "value": 0.4, + "description": "Jump or press to side hstd – walkover fwd to side stand on both legs", + "name": "Phillips", + "image": "BB-1.412-a.webp" + }, + { + "id": "BB-1.412-b", + "elementGroupId": "BB-1", + "code": "1.412", + "value": 0.4, + "description": "Jump or press to side hstd – walkover fwd to side stand on both legs", + "name": "Phillips", + "image": "BB-1.412-b.webp" + }, + { + "id": "BB-1.413-a", + "elementGroupId": "BB-1", + "code": "1.413", + "value": 0.4, + "description": "Jump or press on one arm to hstd lower to optional end position, also jump, press, or swing to hstd – shift weight to side hstd on one arm (2 sec.), lowering to clear straddle support on one arm, also with ¼ turn (90°)", + "name": "Rankin", + "image": "BB-1.413-a.webp" + }, + { + "id": "BB-1.414-a", + "elementGroupId": "BB-1", + "code": "1.414", + "value": 0.4, + "description": "Round-off in front of beam – jump with ½ twist (180°) to near side hstd – lower to optional position", + "name": "Gurova", + "image": "BB-1.414-a.webp" + }, + { + "id": "BB-1.416-a", + "elementGroupId": "BB-1", + "code": "1.416", + "value": 0.4, + "description": "Salto fwd tucked to stand – approach from end of beam", + "name": null, + "image": "BB-1.416-a.webp" + }, + { + "id": "BB-1.417-a", + "elementGroupId": "BB-1", + "code": "1.417", + "value": 0.4, + "description": "Round-off at end of beam – flic-flac through hstd – support on one or both arms – to stand", + "name": null, + "image": "BB-1.417-a.webp" + }, + { + "id": "BB-1.417-b", + "elementGroupId": "BB-1", + "code": "1.417", + "value": 0.4, + "description": "Round-off at end of beam – flic-flac with 1/1 twist (360°) into swing down to cross straddle sit", + "name": "Tsavdaridou", + "image": "BB-1.417-b.webp" + }, + { + "id": "BB-1.514-a", + "elementGroupId": "BB-1", + "code": "1.514", + "value": 0.5, + "description": "Round-off in front of beam – flic-flac with1/1 twist (360°) to hip circle bwd", + "name": "Zamolodchikova", + "image": "BB-1.514-a.webp" + }, + { + "id": "BB-1.516-a", + "elementGroupId": "BB-1", + "code": "1.516", + "value": 0.5, + "description": "Salto fwd piked to stand – approach from end of beam", + "name": null, + "image": "BB-1.516-a.webp" + }, + { + "id": "BB-1.517-a", + "elementGroupId": "BB-1", + "code": "1.517", + "value": 0.5, + "description": "Round-off at end of beam – flic-flac with ¾ twist (270˚) to stand", + "name": null, + "image": "BB-1.517-a.webp" + }, + { + "id": "BB-1.518-a", + "elementGroupId": "BB-1", + "code": "1.518", + "value": 0.5, + "description": "Round-off at end of beam – salto bwd tucked, piked or stretched with step-out to stand", + "name": null, + "image": "BB-1.518-a.webp" + }, + { + "id": "BB-1.518-b", + "elementGroupId": "BB-1", + "code": "1.518", + "value": 0.5, + "description": "Round-off at end of beam – salto bwd tucked, piked or stretched with step-out to stand", + "name": null, + "image": "BB-1.518-b.webp" + }, + { + "id": "BB-1.518-c", + "elementGroupId": "BB-1", + "code": "1.518", + "value": 0.5, + "description": "Round-off at end of beam – salto bwd tucked, piked or stretched with step-out to stand", + "name": null, + "image": "BB-1.518-c.webp" + }, + { + "id": "BB-1.609-a", + "elementGroupId": "BB-1", + "code": "1.609", + "value": 0.6, + "description": "Round-off at end of beam – flic-flac with ½ turn (180°) and walkover fwd", + "name": "Dunn", + "image": "BB-1.609-a.webp" + }, + { + "id": "BB-1.615-a", + "elementGroupId": "BB-1", + "code": "1.615", + "value": 0.6, + "description": "Aerial walkover fwd to cross stand – approach from end of beam, take off from both feet", + "name": null, + "image": "BB-1.615-a.webp" + }, + { + "id": "BB-1.616-a", + "elementGroupId": "BB-1", + "code": "1.616", + "value": 0.6, + "description": "Salto fwd tucked with ½ twist (180°)", + "name": "Wong Hiu Ying Angel", + "image": "BB-1.616-a.webp" + }, + { + "id": "BB-1.618-a", + "elementGroupId": "BB-1", + "code": "1.618", + "value": 0.6, + "description": "Round-off at end of beam – salto bwd tucked with 1/1 twist (360°) to stand", + "name": "Garrison", + "image": "BB-1.618-a.webp" + }, + { + "id": "BB-1.618-b", + "elementGroupId": "BB-1", + "code": "1.618", + "value": 0.6, + "description": "Round-off at end of beam – salto bwd stretched to stand", + "name": null, + "image": "BB-1.618-b.webp" + }, + { + "id": "BB-1.619-a", + "elementGroupId": "BB-1", + "code": "1.619", + "value": 0.6, + "description": "Jump fwd with ½ twist (180°) – salto bwd piked to stand", + "name": null, + "image": "BB-1.619-a.webp" + }, + { + "id": "BB-1.716-a", + "elementGroupId": "BB-1", + "code": "1.716", + "value": 0.7, + "description": "Round-off at end of beam – take off bwd with ½ turn (180°) – tucked salto fwd to stand", + "name": "Erceg", + "image": "BB-1.716-a.webp" + }, + { + "id": "BB-1.718-a", + "elementGroupId": "BB-1", + "code": "1.718", + "value": 0.7, + "description": "Round-off at end of beam – salto bwd stretched with 1/1 twist (360°) to stand", + "name": null, + "image": "BB-1.718-a.webp" + }, + { + "id": "BB-2.101-a", + "elementGroupId": "BB-2", + "code": "2.101", + "value": 0.1, + "description": "Split leap fwd (leg separation 180°)", + "name": null, + "image": "BB-2.101-a.webp" + }, + { + "id": "BB-2.101-b", + "elementGroupId": "BB-2", + "code": "2.101", + "value": 0.1, + "description": "Split leap fwd (leg separation 180°)", + "name": null, + "image": "BB-2.101-b.webp" + }, + { + "id": "BB-2.105-a", + "elementGroupId": "BB-2", + "code": "2.105", + "value": 0.1, + "description": "Stride leap fwd with change of legs to wolf position (hip angle at 45°)", + "name": null, + "image": "BB-2.105-a.webp" + }, + { + "id": "BB-2.107-a", + "elementGroupId": "BB-2", + "code": "2.107", + "value": 0.1, + "description": "Pike jump from cross position (legs above horizontal)", + "name": null, + "image": "BB-2.107-a.webp" + }, + { + "id": "BB-2.108-a", + "elementGroupId": "BB-2", + "code": "2.108", + "value": 0.1, + "description": "Sissone (leg separation 180° on the diagonal / 45° to the floor) take off from both feet, land on one foot", + "name": null, + "image": "BB-2.108-a.webp" + }, + { + "id": "BB-2.108-b", + "elementGroupId": "BB-2", + "code": "2.108", + "value": 0.1, + "description": "Stag jump (leg separation 180°, back leg straight or bent)", + "name": null, + "image": "BB-2.108-b.webp" + }, + { + "id": "BB-2.110-a", + "elementGroupId": "BB-2", + "code": "2.110", + "value": 0.1, + "description": "Cat leap (knees above horizontal alternately)", + "name": null, + "image": "BB-2.110-a.webp" + }, + { + "id": "BB-2.110-b", + "elementGroupId": "BB-2", + "code": "2.110", + "value": 0.1, + "description": "Scissors leap fwd", + "name": null, + "image": "BB-2.110-b.webp" + }, + { + "id": "BB-2.112-a", + "elementGroupId": "BB-2", + "code": "2.112", + "value": 0.1, + "description": "Wolf hop or jump from cross position (hip angle at 45°, knees together)", + "name": null, + "image": "BB-2.112-a.webp" + }, + { + "id": "BB-2.112-b", + "elementGroupId": "BB-2", + "code": "2.112", + "value": 0.1, + "description": "Wolf hop or jump from cross position (hip angle at 45°, knees together)", + "name": null, + "image": "BB-2.112-b.webp" + }, + { + "id": "BB-2.202-a", + "elementGroupId": "BB-2", + "code": "2.202", + "value": 0.2, + "description": "Split jump (leg separation 180°) from cross position", + "name": null, + "image": "BB-2.202-a.webp" + }, + { + "id": "BB-2.203-a", + "elementGroupId": "BB-2", + "code": "2.203", + "value": 0.2, + "description": "Straddle pike jump (both legs above horizontal)", + "name": null, + "image": "BB-2.203-a.webp" + }, + { + "id": "BB-2.203-b", + "elementGroupId": "BB-2", + "code": "2.203", + "value": 0.2, + "description": "Straddle pike jump (both legs above horizontal), side split jump from cross position", + "name": null, + "image": "BB-2.203-b.webp" + }, + { + "id": "BB-2.204-a", + "elementGroupId": "BB-2", + "code": "2.204", + "value": 0.2, + "description": "Fouetté hop with ½ turn (180°) to land in arabesque (free leg above horizontal)", + "name": null, + "image": "BB-2.204-a.webp" + }, + { + "id": "BB-2.206-a", + "elementGroupId": "BB-2", + "code": "2.206", + "value": 0.2, + "description": "Leap with ¼ turn (90°) into straddle pike position (both legs above horizontal), to land on one or both feet", + "name": null, + "image": "BB-2.206-a.webp" + }, + { + "id": "BB-2.207-a", + "elementGroupId": "BB-2", + "code": "2.207", + "value": 0.2, + "description": "Pike jump with ½ turn (180°) from cross position", + "name": null, + "image": "BB-2.207-a.webp" + }, + { + "id": "BB-2.208-a", + "elementGroupId": "BB-2", + "code": "2.208", + "value": 0.2, + "description": "Sissone to ring position (rear foot at head height, body arched and head dropped bwd, leg separation 180°) to land on one foot", + "name": null, + "image": "BB-2.208-a.webp" + }, + { + "id": "BB-2.208-b", + "elementGroupId": "BB-2", + "code": "2.208", + "value": 0.2, + "description": "Sissone to ring position (rear foot at head height, body arched and head dropped bwd, leg separation 180°) to land on one foot, stag-ring jump", + "name": null, + "image": "BB-2.208-b.webp" + }, + { + "id": "BB-2.209-a", + "elementGroupId": "BB-2", + "code": "2.209", + "value": 0.2, + "description": "Hop with ½ turn (180°) free leg extended fwd at horizontal throughout", + "name": null, + "image": "BB-2.209-a.webp" + }, + { + "id": "BB-2.209-b", + "elementGroupId": "BB-2", + "code": "2.209", + "value": 0.2, + "description": "Stretched jump or hop with 1/1turn (360°) from cross position", + "name": null, + "image": "BB-2.209-b.webp" + }, + { + "id": "BB-2.210-a", + "elementGroupId": "BB-2", + "code": "2.210", + "value": 0.2, + "description": "Cat leap with ½ turn (180°)", + "name": null, + "image": "BB-2.210-a.webp" + }, + { + "id": "BB-2.211-a", + "elementGroupId": "BB-2", + "code": "2.211", + "value": 0.2, + "description": "Tuck hop or jump with ½ turn (180°) from cross position (hip & knee angle at 45°)", + "name": null, + "image": "BB-2.211-a.webp" + }, + { + "id": "BB-2.212-a", + "elementGroupId": "BB-2", + "code": "2.212", + "value": 0.2, + "description": "Wolf hop or jump with ½ turn (180°) from cross position", + "name": null, + "image": "BB-2.212-a.webp" + }, + { + "id": "BB-2.301-a", + "elementGroupId": "BB-2", + "code": "2.301", + "value": 0.3, + "description": "Split leap fwd with ½ turn (180°)", + "name": null, + "image": "BB-2.301-a.webp" + }, + { + "id": "BB-2.302-a", + "elementGroupId": "BB-2", + "code": "2.302", + "value": 0.3, + "description": "Split jump with ½ turn (180°) from cross position", + "name": null, + "image": "BB-2.302-a.webp" + }, + { + "id": "BB-2.303-a", + "elementGroupId": "BB-2", + "code": "2.303", + "value": 0.3, + "description": "Straddle pike jump with ½ turn (180°) from cross position", + "name": null, + "image": "BB-2.303-a.webp" + }, + { + "id": "BB-2.305-a", + "elementGroupId": "BB-2", + "code": "2.305", + "value": 0.3, + "description": "Leap fwd with leg change (free leg swing to 45˚) to cross split (180° separation after leg change)", + "name": "Switch leap", + "image": "BB-2.305-a.webp" + }, + { + "id": "BB-2.306-a", + "elementGroupId": "BB-2", + "code": "2.306", + "value": 0.3, + "description": "Leap fwd with leg change and ¼ turn (90°) to side split (180°) or straddle pike position", + "name": "Johnson", + "image": "BB-2.306-a.webp" + }, + { + "id": "BB-2.306-b", + "elementGroupId": "BB-2", + "code": "2.306", + "value": 0.3, + "description": "Leap fwd with leg change and ¼ turn (90°) to side split (180°) or straddle pike position", + "name": "Johnson", + "image": "BB-2.306-b.webp" + }, + { + "id": "BB-2.308-a", + "elementGroupId": "BB-2", + "code": "2.308", + "value": 0.3, + "description": "Jump with upper back arch and head release with feet to head height/closed ring (Sheep jump)", + "name": null, + "image": "BB-2.308-a.webp" + }, + { + "id": "BB-2.310-a", + "elementGroupId": "BB-2", + "code": "2.310", + "value": 0.3, + "description": "Cat leap with 1/1 turn (360°)", + "name": null, + "image": "BB-2.310-a.webp" + }, + { + "id": "BB-2.311-a", + "elementGroupId": "BB-2", + "code": "2.311", + "value": 0.3, + "description": "Tuck hop or jump with 1/1 turn (360°) from cross position", + "name": null, + "image": "BB-2.311-a.webp" + }, + { + "id": "BB-2.402-a", + "elementGroupId": "BB-2", + "code": "2.402", + "value": 0.4, + "description": "Split jump with 1/1 turn (360°) from cross position", + "name": null, + "image": "BB-2.402-a.webp" + }, + { + "id": "BB-2.403-a", + "elementGroupId": "BB-2", + "code": "2.403", + "value": 0.4, + "description": "Straddle pike jump with 1/1 turn (360°) from cross position", + "name": null, + "image": "BB-2.403-a.webp" + }, + { + "id": "BB-2.404-a", + "elementGroupId": "BB-2", + "code": "2.404", + "value": 0.4, + "description": "Fouetté hop with leg change to cross split (leg separation 180°) (tour jeté)", + "name": null, + "image": "BB-2.404-a.webp" + }, + { + "id": "BB-2.405-a", + "elementGroupId": "BB-2", + "code": "2.405", + "value": 0.4, + "description": "Switch leap with ½ turn (180°)", + "name": null, + "image": "BB-2.405-a.webp" + }, + { + "id": "BB-2.407-a", + "elementGroupId": "BB-2", + "code": "2.407", + "value": 0.4, + "description": "Pike jump with 1/1 turn (360°) from cross position", + "name": null, + "image": "BB-2.407-a.webp" + }, + { + "id": "BB-2.408-a", + "elementGroupId": "BB-2", + "code": "2.408", + "value": 0.4, + "description": "Split jump to ring position (leg separation 180°) to land on two feet", + "name": null, + "image": "BB-2.408-a.webp" + }, + { + "id": "BB-2.408-b", + "elementGroupId": "BB-2", + "code": "2.408", + "value": 0.4, + "description": "Split ring leap (leg separation 180°)", + "name": null, + "image": "BB-2.408-b.webp" + }, + { + "id": "BB-2.408-c", + "elementGroupId": "BB-2", + "code": "2.408", + "value": 0.4, + "description": "Jump to cross over split with body arched and head dropped bwd", + "name": "Yang Bo", + "image": "BB-2.408-c.webp" + }, + { + "id": "BB-2.409-a", + "elementGroupId": "BB-2", + "code": "2.409", + "value": 0.4, + "description": "Stretched jump or hop with 1½ turn (540°) from cross position", + "name": null, + "image": "BB-2.409-a.webp" + }, + { + "id": "BB-2.411-a", + "elementGroupId": "BB-2", + "code": "2.411", + "value": 0.4, + "description": "Tuck hop or jump with 1½ turn (540°) from cross position", + "name": null, + "image": "BB-2.411-a.webp" + }, + { + "id": "BB-2.412-a", + "elementGroupId": "BB-2", + "code": "2.412", + "value": 0.4, + "description": "Wolf hop or jump with 1/1 turn (360°) from cross position", + "name": null, + "image": "BB-2.412-a.webp" + }, + { + "id": "BB-2.504-a", + "elementGroupId": "BB-2", + "code": "2.504", + "value": 0.5, + "description": "Tour jeté with additional ½ turn (180°)", + "name": null, + "image": "BB-2.504-a.webp" + }, + { + "id": "BB-2.505-a", + "elementGroupId": "BB-2", + "code": "2.505", + "value": 0.5, + "description": "Switch leap to ring position (leg separation 180°)", + "name": null, + "image": "BB-2.505-a.webp" + }, + { + "id": "BB-2.506-a", + "elementGroupId": "BB-2", + "code": "2.506", + "value": 0.5, + "description": "Johnson with additional ½ turn (180°)", + "name": null, + "image": "BB-2.506-a.webp" + }, + { + "id": "BB-2.512-a", + "elementGroupId": "BB-2", + "code": "2.512", + "value": 0.5, + "description": "Wolf hop or jump with 1½ turn (540°) from cross position", + "name": null, + "image": "BB-2.512-a.webp" + }, + { + "id": "BB-3.101-a", + "elementGroupId": "BB-3", + "code": "3.101", + "value": 0.1, + "description": "1/1 turn (360°) on one leg – free leg optional below horizontal", + "name": null, + "image": "BB-3.101-a.webp" + }, + { + "id": "BB-3.201-a", + "elementGroupId": "BB-3", + "code": "3.201", + "value": 0.2, + "description": "1½ turn (540°) on one leg – free leg optional below horizontal", + "name": null, + "image": "BB-3.201-a.webp" + }, + { + "id": "BB-3.206-a", + "elementGroupId": "BB-3", + "code": "3.206", + "value": 0.2, + "description": "½ illusion turn (180°) through standing split (180° legs separation) with/without brief touching of beam with one hand", + "name": null, + "image": "BB-3.206-a.webp" + }, + { + "id": "BB-3.207-a", + "elementGroupId": "BB-3", + "code": "3.207", + "value": 0.2, + "description": "1/1 turn (360°) in tuck stand on one leg – free leg straight throughout turn", + "name": null, + "image": "BB-3.207-a.webp" + }, + { + "id": "BB-3.208-a", + "elementGroupId": "BB-3", + "code": "3.208", + "value": 0.2, + "description": "1/1 turn or 1½ turn (360° or 540°) in prone position – alternate support of hands permitted", + "name": null, + "image": "BB-3.208-a.webp" + }, + { + "id": "BB-3.208-b", + "elementGroupId": "BB-3", + "code": "3.208", + "value": 0.2, + "description": "1/1 turn or 1½ turn (360° or 540°) in prone position – alternate support of hands permitted", + "name": null, + "image": "BB-3.208-b.webp" + }, + { + "id": "BB-3.302-a", + "elementGroupId": "BB-3", + "code": "3.302", + "value": 0.3, + "description": "1/1 turn (360°) on one leg in back attitude (thigh of free leg at horizontal, throughout turn)", + "name": null, + "image": "BB-3.302-a.webp" + }, + { + "id": "BB-3.304-a", + "elementGroupId": "BB-3", + "code": "3.304", + "value": 0.3, + "description": "1/1 turn (360°) with heel of extended free leg fwd at horizontal throughout turn (support leg may be straight or bent)", + "name": null, + "image": "BB-3.304-a.webp" + }, + { + "id": "BB-3.305-a", + "elementGroupId": "BB-3", + "code": "3.305", + "value": 0.3, + "description": "1/1 turn (360°) with free leg held upward in 180° split position throughout turn", + "name": null, + "image": "BB-3.305-a.webp" + }, + { + "id": "BB-3.307-a", + "elementGroupId": "BB-3", + "code": "3.307", + "value": 0.3, + "description": "1½ turn (540°) in tuck stand on one leg – free leg straight throughout turn", + "name": null, + "image": "BB-3.307-a.webp" + }, + { + "id": "BB-3.401-a", + "elementGroupId": "BB-3", + "code": "3.401", + "value": 0.4, + "description": "2/1 turn (720°) on one leg – free leg optional below horizontal", + "name": null, + "image": "BB-3.401-a.webp" + }, + { + "id": "BB-3.402-a", + "elementGroupId": "BB-3", + "code": "3.402", + "value": 0.4, + "description": "1½ turn (540°) on one leg in back attitude (thigh of free leg at horizontal, throughout turn)", + "name": null, + "image": "BB-3.402-a.webp" + }, + { + "id": "BB-3.403-a", + "elementGroupId": "BB-3", + "code": "3.403", + "value": 0.4, + "description": "1/1 turn (360°) with free leg held bwd with both hands", + "name": "Preziosa", + "image": "BB-3.403-a.webp" + }, + { + "id": "BB-3.404-a", + "elementGroupId": "BB-3", + "code": "3.404", + "value": 0.4, + "description": "1½ turn (540°) with heel of extended free leg fwd at horizontal throughout turn (support leg may be straight or bent)", + "name": null, + "image": "BB-3.404-a.webp" + }, + { + "id": "BB-3.405-a", + "elementGroupId": "BB-3", + "code": "3.405", + "value": 0.4, + "description": "1½ turn (540°) with free leg held upward in 180° split position throughout turn", + "name": "Galante", + "image": "BB-3.405-a.webp" + }, + { + "id": "BB-3.406-a", + "elementGroupId": "BB-3", + "code": "3.406", + "value": 0.4, + "description": "1/1 illusion turn (360°) through standing split (180° legs separation) with/without brief touching of beam with one hand", + "name": null, + "image": "BB-3.406-a.webp" + }, + { + "id": "BB-3.407-a", + "elementGroupId": "BB-3", + "code": "3.407", + "value": 0.4, + "description": "2/1 turn (720°) or 2½ turn (900°) in tuck stand on one leg – free leg straight throughout turn", + "name": "Humphrey", + "image": "BB-3.407-a.webp" + }, + { + "id": "BB-3.407-b", + "elementGroupId": "BB-3", + "code": "3.407", + "value": 0.4, + "description": "2/1 turn (720°) or 2½ turn (900°) in tuck stand on one leg – free leg straight throughout turn", + "name": "Humphrey", + "image": "BB-3.407-b.webp" + }, + { + "id": "BB-3.408-a", + "elementGroupId": "BB-3", + "code": "3.408", + "value": 0.4, + "description": "1¼ turn (450°) on back in kip position (hip-leg angle closed)", + "name": "Li Li", + "image": "BB-3.408-a.webp" + }, + { + "id": "BB-3.501-a", + "elementGroupId": "BB-3", + "code": "3.501", + "value": 0.5, + "description": "3/1 turn (1080°) on one leg – free leg optional below horizontal", + "name": "Okino", + "image": "BB-3.501-a.webp" + }, + { + "id": "BB-3.504-a", + "elementGroupId": "BB-3", + "code": "3.504", + "value": 0.5, + "description": "2/1 turn (720°) with heel of extended free leg fwd at horizontal throughout turn (support leg may be straight or bent)", + "name": "Wevers", + "image": "BB-3.504-a.webp" + }, + { + "id": "BB-3.505-a", + "elementGroupId": "BB-3", + "code": "3.505", + "value": 0.5, + "description": "2/1 turn (720°) with free leg held upward in 180° split position throughout turn", + "name": "Sugihara", + "image": "BB-3.505-a.webp" + }, + { + "id": "BB-3.507-a", + "elementGroupId": "BB-3", + "code": "3.507", + "value": 0.5, + "description": "3/1 turn (1080°) in tuck stand on one leg – free leg straight throughout turn", + "name": "Mitchell", + "image": "BB-3.507-a.webp" + }, + { + "id": "BB-4.101-a", + "elementGroupId": "BB-4", + "code": "4.101", + "value": 0.1, + "description": "From kneeling sit position, rise upward with body wave through toe-balance stand", + "name": null, + "image": "BB-4.101-a.webp" + }, + { + "id": "BB-4.102-a", + "elementGroupId": "BB-4", + "code": "4.102", + "value": 0.1, + "description": "Standing split fwd with hand support in front of support leg (leg separation 180°) – Needle scale (2 sec.) or Stand on one leg with foot of free leg in fwd hold above head (2 sec.)", + "name": null, + "image": "BB-4.102-a.webp" + }, + { + "id": "BB-4.102-b", + "elementGroupId": "BB-4", + "code": "4.102", + "value": 0.1, + "description": "Scale fwd, support leg extended (leg separation 180°) (2 sec.)", + "name": null, + "image": "BB-4.102-b.webp" + }, + { + "id": "BB-4.103-a", + "elementGroupId": "BB-4", + "code": "4.103", + "value": 0.1, + "description": "Kick to side or cross hstd (2 sec.), lower to end position touching beam", + "name": null, + "image": "BB-4.103-a.webp" + }, + { + "id": "BB-4.103-b", + "elementGroupId": "BB-4", + "code": "4.103", + "value": 0.1, + "description": "Kick to side or cross hstd (2 sec.), lower to end position touching beam", + "name": null, + "image": "BB-4.103-b.webp" + }, + { + "id": "BB-4.105-a", + "elementGroupId": "BB-4", + "code": "4.105", + "value": 0.1, + "description": "Roll bwd with hand support on top of the beam – landing on one or both feet", + "name": null, + "image": "BB-4.105-a.webp" + }, + { + "id": "BB-4.107-a", + "elementGroupId": "BB-4", + "code": "4.107", + "value": 0.1, + "description": "Cartwheel, also with support on one arm, or Cartwheel with flight phase before or after hand support", + "name": null, + "image": "BB-4.107-a.webp" + }, + { + "id": "BB-4.107-b", + "elementGroupId": "BB-4", + "code": "4.107", + "value": 0.1, + "description": "Cartwheel, also with support on one arm, or Cartwheel with flight phase before or after hand support", + "name": null, + "image": "BB-4.107-b.webp" + }, + { + "id": "BB-4.107-c", + "elementGroupId": "BB-4", + "code": "4.107", + "value": 0.1, + "description": "Cartwheel, also with support on one arm, or Cartwheel with flight phase before or after hand support", + "name": null, + "image": "BB-4.107-c.webp" + }, + { + "id": "BB-4.108-a", + "elementGroupId": "BB-4", + "code": "4.108", + "value": 0.1, + "description": "Walkover fwd, with/without alternate hand support (Tinsica)", + "name": null, + "image": "BB-4.108-a.webp" + }, + { + "id": "BB-4.108-b", + "elementGroupId": "BB-4", + "code": "4.108", + "value": 0.1, + "description": "Walkover fwd, with/without alternate hand support (Tinsica)", + "name": null, + "image": "BB-4.108-b.webp" + }, + { + "id": "BB-4.108-c", + "elementGroupId": "BB-4", + "code": "4.108", + "value": 0.1, + "description": "Walkover fwd, bwd (Tic-Toc)", + "name": null, + "image": "BB-4.108-c.webp" + }, + { + "id": "BB-4.109-a", + "elementGroupId": "BB-4", + "code": "4.109", + "value": 0.1, + "description": "Walkover bwd, with/without alternate hand support, also with swing down to cross sit", + "name": null, + "image": "BB-4.109-a.webp" + }, + { + "id": "BB-4.109-b", + "elementGroupId": "BB-4", + "code": "4.109", + "value": 0.1, + "description": "Walkover bwd, with/without alternate hand support, also with swing down to cross sit", + "name": null, + "image": "BB-4.109-b.webp" + }, + { + "id": "BB-4.109-c", + "elementGroupId": "BB-4", + "code": "4.109", + "value": 0.1, + "description": "Walkover bwd, with/without alternate hand support, also with swing down to cross sit", + "name": null, + "image": "BB-4.109-c.webp" + }, + { + "id": "BB-4.202-a", + "elementGroupId": "BB-4", + "code": "4.202", + "value": 0.2, + "description": "Clear pike support with legs together (2 sec.)", + "name": null, + "image": "BB-4.202-a.webp" + }, + { + "id": "BB-4.203-a", + "elementGroupId": "BB-4", + "code": "4.203", + "value": 0.2, + "description": "Kick to cross hstd – roll fwd with or without hand support to sit position or to tuck stand", + "name": null, + "image": "BB-4.203-a.webp" + }, + { + "id": "BB-4.204-a", + "elementGroupId": "BB-4", + "code": "4.204", + "value": 0.2, + "description": "Roll fwd without hand support to sit position or to tuck stand", + "name": null, + "image": "BB-4.204-a.webp" + }, + { + "id": "BB-4.204-b", + "elementGroupId": "BB-4", + "code": "4.204", + "value": 0.2, + "description": "From cross sit, swing bwd to shoulder roll fwd with hip extension and hand support to sit position or to tuck stand", + "name": null, + "image": "BB-4.204-b.webp" + }, + { + "id": "BB-4.206-a", + "elementGroupId": "BB-4", + "code": "4.206", + "value": 0.2, + "description": "Roll swd, body tucked, straddle or stretched through neck stand", + "name": null, + "image": "BB-4.206-a.webp" + }, + { + "id": "BB-4.206-b", + "elementGroupId": "BB-4", + "code": "4.206", + "value": 0.2, + "description": "Roll swd, body tucked, straddle or stretched through neck stand, with ½ turn (180°) over shoulder", + "name": null, + "image": "BB-4.206-b.webp" + }, + { + "id": "BB-4.207-a", + "elementGroupId": "BB-4", + "code": "4.207", + "value": 0.2, + "description": "Roll swd, piked straddle with hand support – end position optional", + "name": null, + "image": "BB-4.207-a.webp" + }, + { + "id": "BB-4.208-a", + "elementGroupId": "BB-4", + "code": "4.208", + "value": 0.2, + "description": "Walkover fwd, with support on one arm", + "name": null, + "image": "BB-4.208-a.webp" + }, + { + "id": "BB-4.209-a", + "elementGroupId": "BB-4", + "code": "4.209", + "value": 0.2, + "description": "Walkover bwd, with support on one arm", + "name": null, + "image": "BB-4.209-a.webp" + }, + { + "id": "BB-4.210-a", + "elementGroupId": "BB-4", + "code": "4.210", + "value": 0.2, + "description": "From extended tuck sit – walkover bwd (Valdez)", + "name": null, + "image": "BB-4.210-a.webp" + }, + { + "id": "BB-4.210-b", + "elementGroupId": "BB-4", + "code": "4.210", + "value": 0.2, + "description": "Kick over bwd through horizontal plane with support on one arm", + "name": "Garrison", + "image": "BB-4.210-b.webp" + }, + { + "id": "BB-4.304-a", + "elementGroupId": "BB-4", + "code": "4.304", + "value": 0.3, + "description": "Free shoulder roll fwd with hip extension and without hand support to sit position or tuck stand", + "name": null, + "image": "BB-4.304-a.webp" + }, + { + "id": "BB-4.305-a", + "elementGroupId": "BB-4", + "code": "4.305", + "value": 0.3, + "description": "Roll bwd to hstd - lower to end position touching beam", + "name": null, + "image": "BB-4.305-a.webp" + }, + { + "id": "BB-4.306-a", + "elementGroupId": "BB-4", + "code": "4.306", + "value": 0.3, + "description": "Roll swd, body stretched without hand support", + "name": null, + "image": "BB-4.306-a.webp" + }, + { + "id": "BB-4.307-a", + "elementGroupId": "BB-4", + "code": "4.307", + "value": 0.3, + "description": "From cross sit or side split – roll swd straddle or stretched without hand support – end position optional", + "name": null, + "image": "BB-4.307-a.webp" + }, + { + "id": "BB-4.308-a", + "elementGroupId": "BB-4", + "code": "4.308", + "value": 0.3, + "description": "Kick to cross hdst with ½ turn (180°) to walkover fwd", + "name": null, + "image": "BB-4.308-a.webp" + }, + { + "id": "BB-4.308-b", + "elementGroupId": "BB-4", + "code": "4.308", + "value": 0.3, + "description": "Walkover fwd in side position to side stand", + "name": null, + "image": "BB-4.308-b.webp" + }, + { + "id": "BB-4.309-a", + "elementGroupId": "BB-4", + "code": "4.309", + "value": 0.3, + "description": "Walkover bwd with ½ turn (180°) to walkover fwd", + "name": null, + "image": "BB-4.309-a.webp" + }, + { + "id": "BB-4.309-b", + "elementGroupId": "BB-4", + "code": "4.309", + "value": 0.3, + "description": "Walkover bwd in side position to side stand", + "name": null, + "image": "BB-4.309-b.webp" + }, + { + "id": "BB-4.309-c", + "elementGroupId": "BB-4", + "code": "4.309", + "value": 0.3, + "description": "Walkover bwd with stoop through of one leg to cross split sit", + "name": null, + "image": "BB-4.309-c.webp" + }, + { + "id": "BB-4.310-a", + "elementGroupId": "BB-4", + "code": "4.310", + "value": 0.3, + "description": "Valdez with 1/1 turn (360°) lower to end position touching beam", + "name": null, + "image": "BB-4.310-a.webp" + }, + { + "id": "BB-5.201-a", + "elementGroupId": "BB-5", + "code": "5.201", + "value": 0.2, + "description": "Handspring fwd with flight to land on one or both feet (same element)", + "name": null, + "image": "BB-5.201-a.webp" + }, + { + "id": "BB-5.201-b", + "elementGroupId": "BB-5", + "code": "5.201", + "value": 0.2, + "description": "Handspring fwd with flight to land on one or both feet (same element), with support on one arm", + "name": null, + "image": "BB-5.201-b.webp" + }, + { + "id": "BB-5.202-a", + "elementGroupId": "BB-5", + "code": "5.202", + "value": 0.2, + "description": "Flic-flac to land on both feet", + "name": null, + "image": "BB-5.202-a.webp" + }, + { + "id": "BB-5.204-a", + "elementGroupId": "BB-5", + "code": "5.204", + "value": 0.2, + "description": "Flic-flac with step out", + "name": null, + "image": "BB-5.204-a.webp" + }, + { + "id": "BB-5.204-b", + "elementGroupId": "BB-5", + "code": "5.204", + "value": 0.2, + "description": "Flic-flac with step out, with support on one arm", + "name": null, + "image": "BB-5.204-b.webp" + }, + { + "id": "BB-5.206-a", + "elementGroupId": "BB-5", + "code": "5.206", + "value": 0.2, + "description": "Gainer flic-flac also with support on one arm", + "name": null, + "image": "BB-5.206-a.webp" + }, + { + "id": "BB-5.206-b", + "elementGroupId": "BB-5", + "code": "5.206", + "value": 0.2, + "description": "Gainer flic-flac also with support on one arm", + "name": null, + "image": "BB-5.206-b.webp" + }, + { + "id": "BB-5.207-a", + "elementGroupId": "BB-5", + "code": "5.207", + "value": 0.2, + "description": "Flic-flac or Gainer flic-flac – with high flight phase, and swing down to cross straddle sit", + "name": null, + "image": "BB-5.207-a.webp" + }, + { + "id": "BB-5.207-b", + "elementGroupId": "BB-5", + "code": "5.207", + "value": 0.2, + "description": "Flic-flac or Gainer flic-flac – with high flight phase, and swing down to cross straddle sit", + "name": null, + "image": "BB-5.207-b.webp" + }, + { + "id": "BB-5.208-a", + "elementGroupId": "BB-5", + "code": "5.208", + "value": 0.2, + "description": "Round-off", + "name": null, + "image": "BB-5.208-a.webp" + }, + { + "id": "BB-5.301-a", + "elementGroupId": "BB-5", + "code": "5.301", + "value": 0.3, + "description": "Handspring fwd with leg change in flight phase", + "name": null, + "image": "BB-5.301-a.webp" + }, + { + "id": "BB-5.302-a", + "elementGroupId": "BB-5", + "code": "5.302", + "value": 0.3, + "description": "Flic-flac with ½ twist (180°) to hstd (2 sec.) – lower to optional end position", + "name": null, + "image": "BB-5.302-a.webp" + }, + { + "id": "BB-5.303-a", + "elementGroupId": "BB-5", + "code": "5.303", + "value": 0.3, + "description": "Flic-flac with ¼ twist (90°) to hstd (2 sec.) – lower to optional end position", + "name": null, + "image": "BB-5.303-a.webp" + }, + { + "id": "BB-5.304-a", + "elementGroupId": "BB-5", + "code": "5.304", + "value": 0.3, + "description": "Flic-flac with ½ twist (180°) after hand support", + "name": null, + "image": "BB-5.304-a.webp" + }, + { + "id": "BB-5.306-a", + "elementGroupId": "BB-5", + "code": "5.306", + "value": 0.3, + "description": "Gainer flic-flac with ¼ twist (90°) to hstd (2 sec.) – lower to optional end position", + "name": "Kitti", + "image": "BB-5.306-a.webp" + }, + { + "id": "BB-5.307-a", + "elementGroupId": "BB-5", + "code": "5.307", + "value": 0.3, + "description": "Flic-flac or Gainer flic-flac – with piking and stretching of hips in flight phase and swing down to cross straddle sit", + "name": "Rueda", + "image": "BB-5.307-a.webp" + }, + { + "id": "BB-5.307-b", + "elementGroupId": "BB-5", + "code": "5.307", + "value": 0.3, + "description": "Flic-flac or Gainer flic-flac – with piking and stretching of hips in flight phase and swing down to cross straddle sit", + "name": "Rueda", + "image": "BB-5.307-b.webp" + }, + { + "id": "BB-5.310-a", + "elementGroupId": "BB-5", + "code": "5.310", + "value": 0.3, + "description": "Salto fwd tucked, take-off from one leg to stand on one or both feet", + "name": "Liukin", + "image": "BB-5.310-a.webp" + }, + { + "id": "BB-5.312-a", + "elementGroupId": "BB-5", + "code": "5.312", + "value": 0.3, + "description": "Salto bwd tucked, piked or stretched (step out)", + "name": null, + "image": "BB-5.312-a.webp" + }, + { + "id": "BB-5.312-b", + "elementGroupId": "BB-5", + "code": "5.312", + "value": 0.3, + "description": "Salto bwd tucked, piked or stretched (step out)", + "name": null, + "image": "BB-5.312-b.webp" + }, + { + "id": "BB-5.312-c", + "elementGroupId": "BB-5", + "code": "5.312", + "value": 0.3, + "description": "Salto bwd tucked, piked or stretched (step out)", + "name": null, + "image": "BB-5.312-c.webp" + }, + { + "id": "BB-5.313-a", + "elementGroupId": "BB-5", + "code": "5.313", + "value": 0.3, + "description": "Gainer salto bwd tucked, piked or stretched step out", + "name": null, + "image": "BB-5.313-a.webp" + }, + { + "id": "BB-5.313-b", + "elementGroupId": "BB-5", + "code": "5.313", + "value": 0.3, + "description": "Gainer salto bwd tucked, piked or stretched step out", + "name": null, + "image": "BB-5.313-b.webp" + }, + { + "id": "BB-5.313-c", + "elementGroupId": "BB-5", + "code": "5.313", + "value": 0.3, + "description": "Gainer salto bwd tucked, piked or stretched step out", + "name": null, + "image": "BB-5.313-c.webp" + }, + { + "id": "BB-5.401-a", + "elementGroupId": "BB-5", + "code": "5.401", + "value": 0.4, + "description": "Jump bwd (flic-flac take-off) with ½ twist (180°) through hstd to walkover fwd", + "name": "Onodi", + "image": "BB-5.401-a.webp" + }, + { + "id": "BB-5.401-b", + "elementGroupId": "BB-5", + "code": "5.401", + "value": 0.4, + "description": "Jump bwd (flic-flac take-off) with ½ twist (180°) through hstd to walkover fwd, with support on one arm, or to tic-toc", + "name": null, + "image": "BB-5.401-b.webp" + }, + { + "id": "BB-5.402-a", + "elementGroupId": "BB-5", + "code": "5.402", + "value": 0.4, + "description": "Flic-flac from side position with ½ twist (180°) to side hstd lower to optional end position", + "name": "Kolesnikova", + "image": "BB-5.402-a.webp" + }, + { + "id": "BB-5.403-a", + "elementGroupId": "BB-5", + "code": "5.403", + "value": 0.4, + "description": "Flic-flac with twist (270°) to side hstd (2 sec.) – lower to optional end position", + "name": "Omelianchik", + "image": "BB-5.403-a.webp" + }, + { + "id": "BB-5.404-a", + "elementGroupId": "BB-5", + "code": "5.404", + "value": 0.4, + "description": "Flic-flac with min. ¾ twist (270°) before hand support", + "name": "Kochetkova", + "image": "BB-5.404-a.webp" + }, + { + "id": "BB-5.405-a", + "elementGroupId": "BB-5", + "code": "5.405", + "value": 0.4, + "description": "Flic-flac with step out from side position", + "name": "Tousek", + "image": "BB-5.405-a.webp" + }, + { + "id": "BB-5.405-b", + "elementGroupId": "BB-5", + "code": "5.405", + "value": 0.4, + "description": "Flic-flac from side position to front support or with hip circle bwd", + "name": null, + "image": "BB-5.405-b.webp" + }, + { + "id": "BB-5.405-c", + "elementGroupId": "BB-5", + "code": "5.405", + "value": 0.4, + "description": "Flic-flac from side position to front support or with hip circle bwd", + "name": null, + "image": "BB-5.405-c.webp" + }, + { + "id": "BB-5.406-a", + "elementGroupId": "BB-5", + "code": "5.406", + "value": 0.4, + "description": "Gainer flic-flac with min. ¾ twist (270°) before hand support", + "name": "Khorkina", + "image": "BB-5.406-a.webp" + }, + { + "id": "BB-5.407-a", + "elementGroupId": "BB-5", + "code": "5.407", + "value": 0.4, + "description": "Flic-flac with 1/1 twist (360°) – swing down to cross straddle sit", + "name": "Rulfova", + "image": "BB-5.407-a.webp" + }, + { + "id": "BB-5.408-a", + "elementGroupId": "BB-5", + "code": "5.408", + "value": 0.4, + "description": "Free (aerial) cartwheel – landing on one or both feet, in cross or side position", + "name": null, + "image": "BB-5.408-a.webp" + }, + { + "id": "BB-5.408-b", + "elementGroupId": "BB-5", + "code": "5.408", + "value": 0.4, + "description": "Free (aerial) cartwheel – landing on one or both feet, in cross or side position, with leg change", + "name": null, + "image": "BB-5.408-b.webp" + }, + { + "id": "BB-5.409-a", + "elementGroupId": "BB-5", + "code": "5.409", + "value": 0.4, + "description": "Free (aerial) walkover fwd, landing on one or both feet", + "name": null, + "image": "BB-5.409-a.webp" + }, + { + "id": "BB-5.409-b", + "elementGroupId": "BB-5", + "code": "5.409", + "value": 0.4, + "description": "Free (aerial) walkover fwd, landing on one or both feet", + "name": null, + "image": "BB-5.409-b.webp" + }, + { + "id": "BB-5.410-a", + "elementGroupId": "BB-5", + "code": "5.410", + "value": 0.4, + "description": "Salto fwd tucked to cross stand", + "name": null, + "image": "BB-5.410-a.webp" + }, + { + "id": "BB-5.411-a", + "elementGroupId": "BB-5", + "code": "5.411", + "value": 0.4, + "description": "Salto swd tucked take off from one leg to side stand", + "name": null, + "image": "BB-5.411-a.webp" + }, + { + "id": "BB-5.501-a", + "elementGroupId": "BB-5", + "code": "5.501", + "value": 0.5, + "description": "Jump bwd (flic-flac take-off) with ½ twist (180°) to handspring fwd to land on both feet", + "name": "Worley", + "image": "BB-5.501-a.webp" + }, + { + "id": "BB-5.505-a", + "elementGroupId": "BB-5", + "code": "5.505", + "value": 0.5, + "description": "Flic-flac from side position with 1/1 twist (360°) to hip circle bwd", + "name": "Teza", + "image": "BB-5.505-a.webp" + }, + { + "id": "BB-5.508-a", + "elementGroupId": "BB-5", + "code": "5.508", + "value": 0.5, + "description": "Free (aerial) round-off tucked or piked – take off from both feet", + "name": null, + "image": "BB-5.508-a.webp" + }, + { + "id": "BB-5.508-b", + "elementGroupId": "BB-5", + "code": "5.508", + "value": 0.5, + "description": "Free (aerial) round-off tucked or piked – take off from both feet", + "name": null, + "image": "BB-5.508-b.webp" + }, + { + "id": "BB-5.508-c", + "elementGroupId": "BB-5", + "code": "5.508", + "value": 0.5, + "description": "Free (aerial) cartwheel in side position", + "name": "Colussi", + "image": "BB-5.508-c.webp" + }, + { + "id": "BB-5.510-a", + "elementGroupId": "BB-5", + "code": "5.510", + "value": 0.5, + "description": "Salto fwd piked to cross stand", + "name": null, + "image": "BB-5.510-a.webp" + }, + { + "id": "BB-5.511-a", + "elementGroupId": "BB-5", + "code": "5.511", + "value": 0.5, + "description": "Salto swd tucked with ½ twist (180°) take off from one leg to side stand", + "name": "Schaefer", + "image": "BB-5.511-a.webp" + }, + { + "id": "BB-5.512-a", + "elementGroupId": "BB-5", + "code": "5.512", + "value": 0.5, + "description": "Salto bwd stretched with legs together", + "name": null, + "image": "BB-5.512-a.webp" + }, + { + "id": "BB-5.611-a", + "elementGroupId": "BB-5", + "code": "5.611", + "value": 0.6, + "description": "Arabian salto tucked (take-off bwd with ½ twist [180°], salto fwd)", + "name": "Lobaznyuk", + "image": "BB-5.611-a.webp" + }, + { + "id": "BB-5.612-a", + "elementGroupId": "BB-5", + "code": "5.612", + "value": 0.6, + "description": "Salto bwd tucked with 1/1 twist (360°)", + "name": "Shishova", + "image": "BB-5.612-a.webp" + }, + { + "id": "BB-5.613-a", + "elementGroupId": "BB-5", + "code": "5.613", + "value": 0.6, + "description": "Jump fwd with ½ twist (180°) – salto bwd tucked", + "name": null, + "image": "BB-5.613-a.webp" + }, + { + "id": "BB-5.613-b", + "elementGroupId": "BB-5", + "code": "5.613", + "value": 0.6, + "description": "Jump fwd with ½ twist (180°) – piked", + "name": "Produnova", + "image": "BB-5.613-b.webp" + }, + { + "id": "BB-5.712-a", + "elementGroupId": "BB-5", + "code": "5.712", + "value": 0.7, + "description": "Salto bwd stretched with 1/1 twist (360°)", + "name": "Shishova", + "image": "BB-5.712-a.webp" + }, + { + "id": "BB-6.101-a", + "elementGroupId": "BB-6", + "code": "6.101", + "value": 0.1, + "description": "Free (aerial) walkover fwd with ½ twist (180°)", + "name": null, + "image": "BB-6.101-a.webp" + }, + { + "id": "BB-6.102-a", + "elementGroupId": "BB-6", + "code": "6.102", + "value": 0.1, + "description": "Salto fwd tucked or piked", + "name": null, + "image": "BB-6.102-a.webp" + }, + { + "id": "BB-6.102-b", + "elementGroupId": "BB-6", + "code": "6.102", + "value": 0.1, + "description": "Salto fwd tucked or piked, with ½ twist (180°)", + "name": null, + "image": "BB-6.102-b.webp" + }, + { + "id": "BB-6.104-a", + "elementGroupId": "BB-6", + "code": "6.104", + "value": 0.1, + "description": "Salto bwd tucked, piked, or stretched", + "name": null, + "image": "BB-6.104-a.webp" + }, + { + "id": "BB-6.104-b", + "elementGroupId": "BB-6", + "code": "6.104", + "value": 0.1, + "description": "Salto bwd tucked, piked, or stretched, with ½ twist (180°) (tucked or stretched)", + "name": null, + "image": "BB-6.104-b.webp" + }, + { + "id": "BB-6.106-a", + "elementGroupId": "BB-6", + "code": "6.106", + "value": 0.1, + "description": "Gainer salto tucked, piked, or stretched to side of beam", + "name": null, + "image": "BB-6.106-a.webp" + }, + { + "id": "BB-6.106-b", + "elementGroupId": "BB-6", + "code": "6.106", + "value": 0.1, + "description": "Gainer salto tucked, piked, or stretched to side of beam, with ½ twist (180°) (tucked or stretched)", + "name": null, + "image": "BB-6.106-b.webp" + }, + { + "id": "BB-6.201-a", + "elementGroupId": "BB-6", + "code": "6.201", + "value": 0.2, + "description": "Free (aerial) walkover fwd with 1/1 twist (360°)", + "name": null, + "image": "BB-6.201-a.webp" + }, + { + "id": "BB-6.201-b", + "elementGroupId": "BB-6", + "code": "6.201", + "value": 0.2, + "description": "Free (aerial) cartwheel with ½ twist (180°)", + "name": null, + "image": "BB-6.201-b.webp" + }, + { + "id": "BB-6.202-a", + "elementGroupId": "BB-6", + "code": "6.202", + "value": 0.2, + "description": "Salto fwd stretched, also with ½ twist (180°)", + "name": null, + "image": "BB-6.202-a.webp" + }, + { + "id": "BB-6.202-b", + "elementGroupId": "BB-6", + "code": "6.202", + "value": 0.2, + "description": "Salto fwd tucked with 1/1 twist (360°)", + "name": null, + "image": "BB-6.202-b.webp" + }, + { + "id": "BB-6.203-a", + "elementGroupId": "BB-6", + "code": "6.203", + "value": 0.2, + "description": "Jump bwd, with ½ twist (180°), salto fwd tucked or piked", + "name": "Arabian salto", + "image": "BB-6.203-a.webp" + }, + { + "id": "BB-6.204-a", + "elementGroupId": "BB-6", + "code": "6.204", + "value": 0.2, + "description": "Salto bwd tucked or stretched with 1/1 twist (360°)", + "name": null, + "image": "BB-6.204-a.webp" + }, + { + "id": "BB-6.204-b", + "elementGroupId": "BB-6", + "code": "6.204", + "value": 0.2, + "description": "Salto bwd tucked or stretched with 1/1 twist (360°)", + "name": null, + "image": "BB-6.204-b.webp" + }, + { + "id": "BB-6.206-a", + "elementGroupId": "BB-6", + "code": "6.206", + "value": 0.2, + "description": "Gainer salto tucked or stretched with 1/1 twist (360°) to side of beam", + "name": null, + "image": "BB-6.206-a.webp" + }, + { + "id": "BB-6.206-b", + "elementGroupId": "BB-6", + "code": "6.206", + "value": 0.2, + "description": "Gainer salto tucked or stretched with 1/1 twist (360°) to side of beam", + "name": null, + "image": "BB-6.206-b.webp" + }, + { + "id": "BB-6.207-a", + "elementGroupId": "BB-6", + "code": "6.207", + "value": 0.2, + "description": "Gainer salto tucked or piked at end of beam", + "name": null, + "image": "BB-6.207-a.webp" + }, + { + "id": "BB-6.207-b", + "elementGroupId": "BB-6", + "code": "6.207", + "value": 0.2, + "description": "Gainer salto tucked or piked at end of beam", + "name": null, + "image": "BB-6.207-b.webp" + }, + { + "id": "BB-6.301-a", + "elementGroupId": "BB-6", + "code": "6.301", + "value": 0.3, + "description": "Free (aerial) walkover fwd with 1½ twist (540°)", + "name": null, + "image": "BB-6.301-a.webp" + }, + { + "id": "BB-6.302-a", + "elementGroupId": "BB-6", + "code": "6.302", + "value": 0.3, + "description": "Salto fwd stretched with 1/1 twist (360°) or 1½ twist (540°)", + "name": null, + "image": "BB-6.302-a.webp" + }, + { + "id": "BB-6.302-b", + "elementGroupId": "BB-6", + "code": "6.302", + "value": 0.3, + "description": "Salto fwd stretched with 1/1 twist (360°) or 1½ twist (540°)", + "name": null, + "image": "BB-6.302-b.webp" + }, + { + "id": "BB-6.303-a", + "elementGroupId": "BB-6", + "code": "6.303", + "value": 0.3, + "description": "Stretched jump fwd with 1/1 twist (360°) and salto fwd tucked or piked", + "name": null, + "image": "BB-6.303-a.webp" + }, + { + "id": "BB-6.304-a", + "elementGroupId": "BB-6", + "code": "6.304", + "value": 0.3, + "description": "Salto bwd tucked or stretched with 1½ twist (540°)", + "name": "Domingues", + "image": "BB-6.304-a.webp" + }, + { + "id": "BB-6.304-b", + "elementGroupId": "BB-6", + "code": "6.304", + "value": 0.3, + "description": "Salto bwd stretched with 2/1 twist (720°)", + "name": null, + "image": "BB-6.304-b.webp" + }, + { + "id": "BB-6.306-a", + "elementGroupId": "BB-6", + "code": "6.306", + "value": 0.3, + "description": "Gainer salto bwd tucked or stretched with 1½ twist (540˚) or 2/1 twist (720°) to side of beam", + "name": "Bohmerova", + "image": "BB-6.306-a.webp" + }, + { + "id": "BB-6.306-b", + "elementGroupId": "BB-6", + "code": "6.306", + "value": 0.3, + "description": "Gainer salto bwd tucked or stretched with 1½ twist (540˚) or 2/1 twist (720°) to side of beam", + "name": "Bohmerova", + "image": "BB-6.306-b.webp" + }, + { + "id": "BB-6.307-a", + "elementGroupId": "BB-6", + "code": "6.307", + "value": 0.3, + "description": "Gainer salto stretched with legs together at end of beam", + "name": null, + "image": "BB-6.307-a.webp" + }, + { + "id": "BB-6.307-b", + "elementGroupId": "BB-6", + "code": "6.307", + "value": 0.3, + "description": "Gainer salto tucked with 1/1 twist (360°) at end of beam", + "name": "Kim", + "image": "BB-6.307-b.webp" + }, + { + "id": "BB-6.402-a", + "elementGroupId": "BB-6", + "code": "6.402", + "value": 0.4, + "description": "Salto fwd stretched with 2/1 twist (720°)", + "name": "Araujo", + "image": "BB-6.402-a.webp" + }, + { + "id": "BB-6.403-a", + "elementGroupId": "BB-6", + "code": "6.403", + "value": 0.4, + "description": "Stretched jump fwd with 1/1 twist (360°) and salto fwd stretched", + "name": null, + "image": "BB-6.403-a.webp" + }, + { + "id": "BB-6.404-a", + "elementGroupId": "BB-6", + "code": "6.404", + "value": 0.4, + "description": "Salto bwd stretched with 2½ twist (900°)", + "name": null, + "image": "BB-6.404-a.webp" + }, + { + "id": "BB-6.405-a", + "elementGroupId": "BB-6", + "code": "6.405", + "value": 0.4, + "description": "Double salto bwd tucked", + "name": null, + "image": "BB-6.405-a.webp" + }, + { + "id": "BB-6.406-a", + "elementGroupId": "BB-6", + "code": "6.406", + "value": 0.4, + "description": "Gainer salto bwd stretched with 2½ twist (900°)", + "name": "Khorkina", + "image": "BB-6.406-a.webp" + }, + { + "id": "BB-6.407-a", + "elementGroupId": "BB-6", + "code": "6.407", + "value": 0.4, + "description": "Gainer salto stretched with 1/1 twist (360°) at end of beam", + "name": "Steingruber", + "image": "BB-6.407-a.webp" + }, + { + "id": "BB-6.407-b", + "elementGroupId": "BB-6", + "code": "6.407", + "value": 0.4, + "description": "Gainer salto tucked with 1½ twist (540°) at end of beam", + "name": "Olafsdottir", + "image": "BB-6.407-b.webp" + }, + { + "id": "BB-6.501-a", + "elementGroupId": "BB-6", + "code": "6.501", + "value": 0.5, + "description": "Free (aerial) cartwheel into salto bwd tucked", + "name": "Kim", + "image": "BB-6.501-a.webp" + }, + { + "id": "BB-6.505-a", + "elementGroupId": "BB-6", + "code": "6.505", + "value": 0.5, + "description": "Double salto bwd piked", + "name": null, + "image": "BB-6.505-a.webp" + }, + { + "id": "BB-6.507-a", + "elementGroupId": "BB-6", + "code": "6.507", + "value": 0.5, + "description": "Gainer salto stretched with 2/1 twist (720°) at end of beam", + "name": "Jurkowska-Kowalska", + "image": "BB-6.507-a.webp" + }, + { + "id": "BB-6.602-a", + "elementGroupId": "BB-6", + "code": "6.602", + "value": 0.6, + "description": "Double salto fwd tucked", + "name": null, + "image": "BB-6.602-a.webp" + }, + { + "id": "BB-6.604-a", + "elementGroupId": "BB-6", + "code": "6.604", + "value": 0.6, + "description": "Salto bwd stretched with 3/1 twist (1080°)", + "name": null, + "image": "BB-6.604-a.webp" + }, + { + "id": "BB-6.606-a", + "elementGroupId": "BB-6", + "code": "6.606", + "value": 0.6, + "description": "Gainer salto bwd stretched with 3/1 twist (1080°) to side of beam", + "name": null, + "image": "BB-6.606-a.webp" + }, + { + "id": "BB-6.703-a", + "elementGroupId": "BB-6", + "code": "6.703", + "value": 0.7, + "description": "Arabian double salto fwd tucked", + "name": "Patterson", + "image": "BB-6.703-a.webp" + }, + { + "id": "BB-6.705-a", + "elementGroupId": "BB-6", + "code": "6.705", + "value": 0.7, + "description": "Double salto bwd tucked or piked with 1/1 twist (360°)", + "name": null, + "image": "BB-6.705-a.webp" + }, + { + "id": "BB-6.705-b", + "elementGroupId": "BB-6", + "code": "6.705", + "value": 0.7, + "description": "Double salto bwd tucked or piked with 1/1 twist (360°)", + "name": null, + "image": "BB-6.705-b.webp" + }, + { + "id": "BB-6.805-a", + "elementGroupId": "BB-6", + "code": "6.805", + "value": 0.8, + "description": "Double salto bwd tucked with 2/1 twist (720°)", + "name": "Biles", + "image": "BB-6.805-a.webp" + }, + { + "id": "FX-1.101-a", + "elementGroupId": "FX-1", + "code": "1.101", + "value": 0.1, + "description": "Split leap fwd (leg separation 180°)", + "name": null, + "image": "FX-1.101-a.webp" + }, + { + "id": "FX-1.103-a", + "elementGroupId": "FX-1", + "code": "1.103", + "value": 0.1, + "description": "Tuck jump with separation of legs to cross split (180°) during flight phase", + "name": null, + "image": "FX-1.103-a.webp" + }, + { + "id": "FX-1.104-a", + "elementGroupId": "FX-1", + "code": "1.104", + "value": 0.1, + "description": "Leap fwd with ¼ turn (90°) into straddle pike position (both legs above horizontal) or side split to land on one or both feet", + "name": null, + "image": "FX-1.104-a.webp" + }, + { + "id": "FX-1.104-b", + "elementGroupId": "FX-1", + "code": "1.104", + "value": 0.1, + "description": "Leap fwd with ¼ turn (90°) into straddle pike position (both legs above horizontal) or side split to land on one or both feet", + "name": null, + "image": "FX-1.104-b.webp" + }, + { + "id": "FX-1.105-a", + "elementGroupId": "FX-1", + "code": "1.105", + "value": 0.1, + "description": "Stride leap fwd with change of legs to wolf position", + "name": null, + "image": "FX-1.105-a.webp" + }, + { + "id": "FX-1.106-a", + "elementGroupId": "FX-1", + "code": "1.106", + "value": 0.1, + "description": "Pike jump (legs above horizontal)", + "name": null, + "image": "FX-1.106-a.webp" + }, + { + "id": "FX-1.107-a", + "elementGroupId": "FX-1", + "code": "1.107", + "value": 0.1, + "description": "Straddle pike jump (both legs above horizontal)", + "name": null, + "image": "FX-1.107-a.webp" + }, + { + "id": "FX-1.107-b", + "elementGroupId": "FX-1", + "code": "1.107", + "value": 0.1, + "description": "Straddle pike jump (both legs above horizontal), side split jump (180° separation of legs)", + "name": null, + "image": "FX-1.107-b.webp" + }, + { + "id": "FX-1.108-a", + "elementGroupId": "FX-1", + "code": "1.108", + "value": 0.1, + "description": "Straddle pike (both legs above horizontal), or side split jump landing in front lying support", + "name": null, + "image": "FX-1.108-a.webp" + }, + { + "id": "FX-1.108-b", + "elementGroupId": "FX-1", + "code": "1.108", + "value": 0.1, + "description": "Straddle pike (both legs above horizontal), or side split jump landing in front lying support, with ½ turn (180°)", + "name": null, + "image": "FX-1.108-b.webp" + }, + { + "id": "FX-1.108-c", + "elementGroupId": "FX-1", + "code": "1.108", + "value": 0.1, + "description": "Hop with 1/1 turn (360°) to straddle and land in front lying support", + "name": null, + "image": "FX-1.108-c.webp" + }, + { + "id": "FX-1.109-a", + "elementGroupId": "FX-1", + "code": "1.109", + "value": 0.1, + "description": "Split jump (180° separation of legs), back leg straight or bent)", + "name": null, + "image": "FX-1.109-a.webp" + }, + { + "id": "FX-1.109-b", + "elementGroupId": "FX-1", + "code": "1.109", + "value": 0.1, + "description": "Stag jump (180° separation of legs), back leg straight or bent)", + "name": null, + "image": "FX-1.109-b.webp" + }, + { + "id": "FX-1.109-c", + "elementGroupId": "FX-1", + "code": "1.109", + "value": 0.1, + "description": "Stag jump with ½ turn (180°)", + "name": null, + "image": "FX-1.109-c.webp" + }, + { + "id": "FX-1.109-d", + "elementGroupId": "FX-1", + "code": "1.109", + "value": 0.1, + "description": "Sissone (180° separation of legs) on the diagonal/45°to the floor) take off two feet, to land on one foot", + "name": null, + "image": "FX-1.109-d.webp" + }, + { + "id": "FX-1.110-a", + "elementGroupId": "FX-1", + "code": "1.110", + "value": 0.1, + "description": "Stretched hop or jump with 1/1 turn (360°)", + "name": null, + "image": "FX-1.110-a.webp" + }, + { + "id": "FX-1.111-a", + "elementGroupId": "FX-1", + "code": "1.111", + "value": 0.1, + "description": "Leap with alternate leg change (knees above horizontal)", + "name": "Cat leap", + "image": "FX-1.111-a.webp" + }, + { + "id": "FX-1.111-b", + "elementGroupId": "FX-1", + "code": "1.111", + "value": 0.1, + "description": "Scissors leap fwd", + "name": null, + "image": "FX-1.111-b.webp" + }, + { + "id": "FX-1.112-a", + "elementGroupId": "FX-1", + "code": "1.112", + "value": 0.1, + "description": "Hop with ½ turn (180˚) to land in arabesque with free leg above horizontal (Fouetté hop)", + "name": null, + "image": "FX-1.112-a.webp" + }, + { + "id": "FX-1.112-b", + "elementGroupId": "FX-1", + "code": "1.112", + "value": 0.1, + "description": "Hop with ½ turn (180°) free leg extended fwd at horizontal throughout", + "name": null, + "image": "FX-1.112-b.webp" + }, + { + "id": "FX-1.114-a", + "elementGroupId": "FX-1", + "code": "1.114", + "value": 0.1, + "description": "Hop or Jump with one leg bent and the other – extended straight, fwd above horizontal with knees together", + "name": "Wolf hop or jump", + "image": "FX-1.114-a.webp" + }, + { + "id": "FX-1.201-a", + "elementGroupId": "FX-1", + "code": "1.201", + "value": 0.2, + "description": "Split leap with ½ turn (180°)", + "name": null, + "image": "FX-1.201-a.webp" + }, + { + "id": "FX-1.202-a", + "elementGroupId": "FX-1", + "code": "1.202", + "value": 0.2, + "description": "Fouetté hop with leg change to cross split (leg separation 180°)", + "name": null, + "image": "FX-1.202-a.webp" + }, + { + "id": "FX-1.202-b", + "elementGroupId": "FX-1", + "code": "1.202", + "value": 0.2, + "description": "Fouetté hop with leg change to cross split (leg separation 180°), to ring position (tour jeté)", + "name": null, + "image": "FX-1.202-b.webp" + }, + { + "id": "FX-1.203-a", + "elementGroupId": "FX-1", + "code": "1.203", + "value": 0.2, + "description": "Butterfly fwd torso parallel to floor, slightly arched, legs straddled and feet above hip height during flight", + "name": null, + "image": "FX-1.203-a.webp" + }, + { + "id": "FX-1.203-b", + "elementGroupId": "FX-1", + "code": "1.203", + "value": 0.2, + "description": "Butterfly bwd torso parallel to floor, slightly arched, legs straddled and feet at or slightly below hip height during flight", + "name": null, + "image": "FX-1.203-b.webp" + }, + { + "id": "FX-1.203-c", + "elementGroupId": "FX-1", + "code": "1.203", + "value": 0.2, + "description": "Butterfly bwd torso parallel to floor, slightly arched, legs straddled and feet at or slightly below hip height during flight, landing in front lying support", + "name": null, + "image": "FX-1.203-c.webp" + }, + { + "id": "FX-1.204-a", + "elementGroupId": "FX-1", + "code": "1.204", + "value": 0.2, + "description": "Switch leap with ¼ turn (90°) to side split or to straddle pike position (both legs above horizontal)", + "name": "Johnson", + "image": "FX-1.204-a.webp" + }, + { + "id": "FX-1.204-b", + "elementGroupId": "FX-1", + "code": "1.204", + "value": 0.2, + "description": "Switch leap with ¼ turn (90°) to side split or to straddle pike position (both legs above horizontal)", + "name": "Johnson", + "image": "FX-1.204-b.webp" + }, + { + "id": "FX-1.205-a", + "elementGroupId": "FX-1", + "code": "1.205", + "value": 0.2, + "description": "Leap fwd with leg change (free leg swing to 45°) to cross split (180° separation after leg change) (Switch leap)", + "name": null, + "image": "FX-1.205-a.webp" + }, + { + "id": "FX-1.206-a", + "elementGroupId": "FX-1", + "code": "1.206", + "value": 0.2, + "description": "Jump with upper back arch and head release with feet almost touching head (Sheep jump)", + "name": null, + "image": "FX-1.206-a.webp" + }, + { + "id": "FX-1.207-a", + "elementGroupId": "FX-1", + "code": "1.207", + "value": 0.2, + "description": "Straddle pike or side split jump with ½ turn (180°)", + "name": null, + "image": "FX-1.207-a.webp" + }, + { + "id": "FX-1.207-b", + "elementGroupId": "FX-1", + "code": "1.207", + "value": 0.2, + "description": "Straddle pike or side split jump with ½ turn (180°)", + "name": null, + "image": "FX-1.207-b.webp" + }, + { + "id": "FX-1.207-c", + "elementGroupId": "FX-1", + "code": "1.207", + "value": 0.2, + "description": "Split Jump with ½ turn (180°)", + "name": null, + "image": "FX-1.207-c.webp" + }, + { + "id": "FX-1.208-a", + "elementGroupId": "FX-1", + "code": "1.208", + "value": 0.2, + "description": "Straddle pike (both legs above horizontal), or side split jump with 1/1 turn (360°) landing in front lying support", + "name": null, + "image": "FX-1.208-a.webp" + }, + { + "id": "FX-1.208-b", + "elementGroupId": "FX-1", + "code": "1.208", + "value": 0.2, + "description": "Hop with 1½ turn (540°) in horizontal plane to land in front lying support", + "name": null, + "image": "FX-1.208-b.webp" + }, + { + "id": "FX-1.209-a", + "elementGroupId": "FX-1", + "code": "1.209", + "value": 0.2, + "description": "Sissone to ring position (rear foot at head height, body arched and head dropped bwd, 180° separation of legs), to land on one foot", + "name": null, + "image": "FX-1.209-a.webp" + }, + { + "id": "FX-1.209-b", + "elementGroupId": "FX-1", + "code": "1.209", + "value": 0.2, + "description": "Stag ring jump (rear foot at head height, body arched and head dropped bwd,), to land on both feet", + "name": null, + "image": "FX-1.209-b.webp" + }, + { + "id": "FX-1.209-c", + "elementGroupId": "FX-1", + "code": "1.209", + "value": 0.2, + "description": "Split jump to ring position (180° separation of legs) to land on both feet", + "name": null, + "image": "FX-1.209-c.webp" + }, + { + "id": "FX-1.209-d", + "elementGroupId": "FX-1", + "code": "1.209", + "value": 0.2, + "description": "Split jump to ring position with ½ turn (180°) to land on both feet", + "name": null, + "image": "FX-1.209-d.webp" + }, + { + "id": "FX-1.211-a", + "elementGroupId": "FX-1", + "code": "1.211", + "value": 0.2, + "description": "Cat leap with 1/1 turn (360º)", + "name": null, + "image": "FX-1.211-a.webp" + }, + { + "id": "FX-1.212-a", + "elementGroupId": "FX-1", + "code": "1.212", + "value": 0.2, + "description": "Hop with 1/1 turn (360°), free leg extended fwd at horizontal throughout", + "name": null, + "image": "FX-1.212-a.webp" + }, + { + "id": "FX-1.213-a", + "elementGroupId": "FX-1", + "code": "1.213", + "value": 0.2, + "description": "Tuck hop or jump with 1/1 turn (360º)", + "name": null, + "image": "FX-1.213-a.webp" + }, + { + "id": "FX-1.214-a", + "elementGroupId": "FX-1", + "code": "1.214", + "value": 0.2, + "description": "Wolf hop or jump with 1/1 turn (360°)", + "name": null, + "image": "FX-1.214-a.webp" + }, + { + "id": "FX-1.214-b", + "elementGroupId": "FX-1", + "code": "1.214", + "value": 0.2, + "description": "Wolf hop or jump with 1/1 turn (360°), landing in front lying support", + "name": null, + "image": "FX-1.214-b.webp" + }, + { + "id": "FX-1.301-a", + "elementGroupId": "FX-1", + "code": "1.301", + "value": 0.3, + "description": "Split leap with 1/1 turn (360°)", + "name": null, + "image": "FX-1.301-a.webp" + }, + { + "id": "FX-1.302-a", + "elementGroupId": "FX-1", + "code": "1.302", + "value": 0.3, + "description": "Tour jeté with additional ½ turn (180°), landing on one or both feet", + "name": "Produnova", + "image": "FX-1.302-a.webp" + }, + { + "id": "FX-1.302-b", + "elementGroupId": "FX-1", + "code": "1.302", + "value": 0.3, + "description": "Tour jeté with additional ½ turn (180°), landing on one or both feet, in split sit position", + "name": "Produnova", + "image": "FX-1.302-b.webp" + }, + { + "id": "FX-1.302-c", + "elementGroupId": "FX-1", + "code": "1.302", + "value": 0.3, + "description": "Leap fwd, through tour jeté technique, with ¾ turn (270°) into straddle pike position with additional ¼ turn (90°), landing on one or both feet", + "name": "Csillag", + "image": "FX-1.302-c.webp" + }, + { + "id": "FX-1.304-a", + "elementGroupId": "FX-1", + "code": "1.304", + "value": 0.3, + "description": "Switch leap with ½ turn (180°) in flight phase", + "name": "Frolova", + "image": "FX-1.304-a.webp" + }, + { + "id": "FX-1.304-b", + "elementGroupId": "FX-1", + "code": "1.304", + "value": 0.3, + "description": "Johnson with additional ½ turn (180°)", + "name": null, + "image": "FX-1.304-b.webp" + }, + { + "id": "FX-1.305-a", + "elementGroupId": "FX-1", + "code": "1.305", + "value": 0.3, + "description": "Switch leap to ring position (180° separation of legs)", + "name": null, + "image": "FX-1.305-a.webp" + }, + { + "id": "FX-1.306-a", + "elementGroupId": "FX-1", + "code": "1.306", + "value": 0.3, + "description": "Pike jump (legs above horizontal) with 1/1 turn (360°)", + "name": "Moerz", + "image": "FX-1.306-a.webp" + }, + { + "id": "FX-1.306-b", + "elementGroupId": "FX-1", + "code": "1.306", + "value": 0.3, + "description": "Pike jump (legs above horizontal) with 1/1 turn (360°), landing in front lying support", + "name": "Moerz", + "image": "FX-1.306-b.webp" + }, + { + "id": "FX-1.307-a", + "elementGroupId": "FX-1", + "code": "1.307", + "value": 0.3, + "description": "Straddle pike or side split jump with 1/1 turn (360°)", + "name": "Popa", + "image": "FX-1.307-a.webp" + }, + { + "id": "FX-1.307-b", + "elementGroupId": "FX-1", + "code": "1.307", + "value": 0.3, + "description": "Straddle pike or side split jump with 1/1 turn (360°)", + "name": "Popa", + "image": "FX-1.307-b.webp" + }, + { + "id": "FX-1.307-c", + "elementGroupId": "FX-1", + "code": "1.307", + "value": 0.3, + "description": "Split Jump with 1/1 turn (360°)", + "name": null, + "image": "FX-1.307-c.webp" + }, + { + "id": "FX-1.309-a", + "elementGroupId": "FX-1", + "code": "1.309", + "value": 0.3, + "description": "Split leap to ring ring leap (180° separation of legs)", + "name": null, + "image": "FX-1.309-a.webp" + }, + { + "id": "FX-1.309-b", + "elementGroupId": "FX-1", + "code": "1.309", + "value": 0.3, + "description": "Split jump to ring position with 1/1 turn (360°)", + "name": "Jurkowska-Kowalska", + "image": "FX-1.309-b.webp" + }, + { + "id": "FX-1.310-a", + "elementGroupId": "FX-1", + "code": "1.310", + "value": 0.3, + "description": "Stretched hop or jump with 2/1 turn (720°)", + "name": null, + "image": "FX-1.310-a.webp" + }, + { + "id": "FX-1.311-a", + "elementGroupId": "FX-1", + "code": "1.311", + "value": 0.3, + "description": "Cat leap with 2/1 turn (720°)", + "name": null, + "image": "FX-1.311-a.webp" + }, + { + "id": "FX-1.313-a", + "elementGroupId": "FX-1", + "code": "1.313", + "value": 0.3, + "description": "Tuck hop or jump with 2/1 turn (720°)", + "name": null, + "image": "FX-1.313-a.webp" + }, + { + "id": "FX-1.313-b", + "elementGroupId": "FX-1", + "code": "1.313", + "value": 0.3, + "description": "Tuck hop or jump with 2/1 turn (720°), landing in front lying support", + "name": null, + "image": "FX-1.313-b.webp" + }, + { + "id": "FX-1.402-a", + "elementGroupId": "FX-1", + "code": "1.402", + "value": 0.4, + "description": "Tour jeté with additional 1/1 turn (360°), landing on one or both feet", + "name": "Gogean", + "image": "FX-1.402-a.webp" + }, + { + "id": "FX-1.404-a", + "elementGroupId": "FX-1", + "code": "1.404", + "value": 0.4, + "description": "Switch leap with 1/1 turn (360°) in flight phase", + "name": null, + "image": "FX-1.404-a.webp" + }, + { + "id": "FX-1.404-b", + "elementGroupId": "FX-1", + "code": "1.404", + "value": 0.4, + "description": "Johnson with additional 1/1 turn (360°)", + "name": "Bulimar", + "image": "FX-1.404-b.webp" + }, + { + "id": "FX-1.405-a", + "elementGroupId": "FX-1", + "code": "1.405", + "value": 0.4, + "description": "Switch leap to ring position with ½ turn (180°)", + "name": "Sankova", + "image": "FX-1.405-a.webp" + }, + { + "id": "FX-1.407-a", + "elementGroupId": "FX-1", + "code": "1.407", + "value": 0.4, + "description": "Straddle pike or side split jump with 1½ turn (540°)", + "name": null, + "image": "FX-1.407-a.webp" + }, + { + "id": "FX-1.407-b", + "elementGroupId": "FX-1", + "code": "1.407", + "value": 0.4, + "description": "Straddle pike or side split jump with 1½ turn (540°)", + "name": null, + "image": "FX-1.407-b.webp" + }, + { + "id": "FX-1.407-c", + "elementGroupId": "FX-1", + "code": "1.407", + "value": 0.4, + "description": "Split Jump with 1½ turn (540°)", + "name": null, + "image": "FX-1.407-c.webp" + }, + { + "id": "FX-1.409-a", + "elementGroupId": "FX-1", + "code": "1.409", + "value": 0.4, + "description": "Tour jeté, to ring position with additional ½ turn (180°)", + "name": "Ferrari", + "image": "FX-1.409-a.webp" + }, + { + "id": "FX-1.409-b", + "elementGroupId": "FX-1", + "code": "1.409", + "value": 0.4, + "description": "Split leap to ring position with ½ turn (180°)", + "name": "Ting", + "image": "FX-1.409-b.webp" + }, + { + "id": "FX-1.514-a", + "elementGroupId": "FX-1", + "code": "1.514", + "value": 0.5, + "description": "Wolf hop or jump with 2/1 turn (720°)", + "name": null, + "image": "FX-1.514-a.webp" + }, + { + "id": "FX-2.101-a", + "elementGroupId": "FX-2", + "code": "2.101", + "value": 0.1, + "description": "1/1 turn (360°) on one leg – free leg optional below horizontal", + "name": null, + "image": "FX-2.101-a.webp" + }, + { + "id": "FX-2.201-a", + "elementGroupId": "FX-2", + "code": "2.201", + "value": 0.2, + "description": "2/1 turn (720°) on one leg – free leg optional below horizontal", + "name": null, + "image": "FX-2.201-a.webp" + }, + { + "id": "FX-2.202-a", + "elementGroupId": "FX-2", + "code": "2.202", + "value": 0.2, + "description": "1/1 turn (360°) with heel of extended free leg fwd at horizontal throughout turn (support leg may be straight or bent)", + "name": null, + "image": "FX-2.202-a.webp" + }, + { + "id": "FX-2.203-a", + "elementGroupId": "FX-2", + "code": "2.203", + "value": 0.2, + "description": "1/1 turn (360°) with free leg held upward in 180° split position throughout turn", + "name": null, + "image": "FX-2.203-a.webp" + }, + { + "id": "FX-2.204-a", + "elementGroupId": "FX-2", + "code": "2.204", + "value": 0.2, + "description": "1/1 turn (360°) in back attitude (thigh of free leg at horizontal throughout turn)", + "name": null, + "image": "FX-2.204-a.webp" + }, + { + "id": "FX-2.205-a", + "elementGroupId": "FX-2", + "code": "2.205", + "value": 0.2, + "description": "1/1 turn (360°) in scale fwd with free leg above horizontal throughout turn", + "name": null, + "image": "FX-2.205-a.webp" + }, + { + "id": "FX-2.206-a", + "elementGroupId": "FX-2", + "code": "2.206", + "value": 0.2, + "description": "1/1 illusion turn (360°) through standing split without touching floor with hand", + "name": null, + "image": "FX-2.206-a.webp" + }, + { + "id": "FX-2.207-a", + "elementGroupId": "FX-2", + "code": "2.207", + "value": 0.2, + "description": "1/1 turn (360°) in tuck stand on one leg – free leg straight throughout turn", + "name": null, + "image": "FX-2.207-a.webp" + }, + { + "id": "FX-2.208-a", + "elementGroupId": "FX-2", + "code": "2.208", + "value": 0.2, + "description": "2/1 turn (720°) or more on back in kip position (hip-leg angle closed)", + "name": null, + "image": "FX-2.208-a.webp" + }, + { + "id": "FX-2.208-b", + "elementGroupId": "FX-2", + "code": "2.208", + "value": 0.2, + "description": "2/1 turn (720°) or more on back in kip position (hip-leg angle closed)", + "name": null, + "image": "FX-2.208-b.webp" + }, + { + "id": "FX-2.301-a", + "elementGroupId": "FX-2", + "code": "2.301", + "value": 0.3, + "description": "3/1 turn (1080°) on one leg – free leg optional below horizontal", + "name": null, + "image": "FX-2.301-a.webp" + }, + { + "id": "FX-2.307-a", + "elementGroupId": "FX-2", + "code": "2.307", + "value": 0.3, + "description": "2/1 turn (720°) starting with free leg at horizontal, lowering to complete the turn in wolf position", + "name": "Nguyen", + "image": "FX-2.307-a.webp" + }, + { + "id": "FX-2.402-a", + "elementGroupId": "FX-2", + "code": "2.402", + "value": 0.4, + "description": "2/1 turn (720°) with heel of extended free leg fwd at horizontal throughout turn (support leg may be straight or bent)", + "name": null, + "image": "FX-2.402-a.webp" + }, + { + "id": "FX-2.403-a", + "elementGroupId": "FX-2", + "code": "2.403", + "value": 0.4, + "description": "2/1 turn (720°) with free leg held upward in 180° split position throughout turn", + "name": "Memmel", + "image": "FX-2.403-a.webp" + }, + { + "id": "FX-2.404-a", + "elementGroupId": "FX-2", + "code": "2.404", + "value": 0.4, + "description": "2/1 turn (720°) in back attitude (thigh of free leg at horizontal throughout turn)", + "name": "Semenova", + "image": "FX-2.404-a.webp" + }, + { + "id": "FX-2.404-b", + "elementGroupId": "FX-2", + "code": "2.404", + "value": 0.4, + "description": "2/1 turn (720°) with free leg held with both hands bwd/upward throughout turn", + "name": "Berar", + "image": "FX-2.404-b.webp" + }, + { + "id": "FX-2.407-a", + "elementGroupId": "FX-2", + "code": "2.407", + "value": 0.4, + "description": "2/1 turn (720°) in tuck stand on one leg – free leg straight throughout turn (no turn initiation with a push from hands on floor)", + "name": null, + "image": "FX-2.407-a.webp" + }, + { + "id": "FX-2.501-a", + "elementGroupId": "FX-2", + "code": "2.501", + "value": 0.5, + "description": "4/1 turn (1440°) on one leg – free leg optional below horizontal", + "name": "Gomez", + "image": "FX-2.501-a.webp" + }, + { + "id": "FX-2.503-a", + "elementGroupId": "FX-2", + "code": "2.503", + "value": 0.5, + "description": "3/1 turn (1080°) with free leg held upward in 180° split position throughout turn", + "name": "Mustafina", + "image": "FX-2.503-a.webp" + }, + { + "id": "FX-2.507-a", + "elementGroupId": "FX-2", + "code": "2.507", + "value": 0.5, + "description": "3/1 turn (1080°) in tuck stand on one leg – free leg straight throughout turn (no turn initiation with a push from hands on floor)", + "name": "Mitchell", + "image": "FX-2.507-a.webp" + }, + { + "id": "FX-3.101-a", + "elementGroupId": "FX-3", + "code": "3.101", + "value": 0.1, + "description": "Jump kick or press to hstd – return movement optional, also with ½ or 1/1 turn (180° or 360°) in hstd", + "name": null, + "image": "FX-3.101-a.webp" + }, + { + "id": "FX-3.101-b", + "elementGroupId": "FX-3", + "code": "3.101", + "value": 0.1, + "description": "Jump kick or press to hstd – return movement optional, also with ½ or 1/1 turn (180° or 360°) in hstd", + "name": null, + "image": "FX-3.101-b.webp" + }, + { + "id": "FX-3.101-c", + "elementGroupId": "FX-3", + "code": "3.101", + "value": 0.1, + "description": "Jump kick or press to hstd – return movement optional, also with ½ or 1/1 turn (180° or 360°) in hstd", + "name": null, + "image": "FX-3.101-c.webp" + }, + { + "id": "FX-3.102-a", + "elementGroupId": "FX-3", + "code": "3.102", + "value": 0.1, + "description": "Hecht roll", + "name": null, + "image": "FX-3.102-a.webp" + }, + { + "id": "FX-3.103-a", + "elementGroupId": "FX-3", + "code": "3.103", + "value": 0.1, + "description": "Roll bwd to hstd with ½ or 1/1 turn (180° or 360°) in hstd", + "name": null, + "image": "FX-3.103-a.webp" + }, + { + "id": "FX-3.103-b", + "elementGroupId": "FX-3", + "code": "3.103", + "value": 0.1, + "description": "Roll bwd to hstd with ½ or 1/1 turn (180° or 360°) in hstd", + "name": null, + "image": "FX-3.103-b.webp" + }, + { + "id": "FX-3.104-a", + "elementGroupId": "FX-3", + "code": "3.104", + "value": 0.1, + "description": "Walkover bwd from stand or extended tuck-sit to hstd with 1/1 turn (360°) in hstd – return movement optional", + "name": null, + "image": "FX-3.104-a.webp" + }, + { + "id": "FX-3.104-b", + "elementGroupId": "FX-3", + "code": "3.104", + "value": 0.1, + "description": "Walkover bwd from stand or extended tuck-sit to hstd with 1/1 turn (360°) in hstd – return movement optional", + "name": null, + "image": "FX-3.104-b.webp" + }, + { + "id": "FX-3.105-a", + "elementGroupId": "FX-3", + "code": "3.105", + "value": 0.1, + "description": "Handspring fwd, take-off from one leg or Flyspring fwd, take-off from both legs – with or without hecht phase before hand support – landing optional", + "name": null, + "image": "FX-3.105-a.webp" + }, + { + "id": "FX-3.105-b", + "elementGroupId": "FX-3", + "code": "3.105", + "value": 0.1, + "description": "Handspring fwd, take-off from one leg or Flyspring fwd, take-off from both legs – with or without hecht phase before hand support – landing optional", + "name": null, + "image": "FX-3.105-b.webp" + }, + { + "id": "FX-3.105-c", + "elementGroupId": "FX-3", + "code": "3.105", + "value": 0.1, + "description": "Jump bwd with ½ twist (180°) to handspring fwd – landing optional", + "name": null, + "image": "FX-3.105-c.webp" + }, + { + "id": "FX-3.106-a", + "elementGroupId": "FX-3", + "code": "3.106", + "value": 0.1, + "description": "Round-off", + "name": null, + "image": "FX-3.106-a.webp" + }, + { + "id": "FX-3.107-a", + "elementGroupId": "FX-3", + "code": "3.107", + "value": 0.1, + "description": "All flic-flac and gainer flic-flac variations, also with support of one arm", + "name": null, + "image": "FX-3.107-a.webp" + }, + { + "id": "FX-3.107-b", + "elementGroupId": "FX-3", + "code": "3.107", + "value": 0.1, + "description": "All flic-flac and gainer flic-flac variations, also with support of one arm", + "name": null, + "image": "FX-3.107-b.webp" + }, + { + "id": "FX-3.107-c", + "elementGroupId": "FX-3", + "code": "3.107", + "value": 0.1, + "description": "All flic-flac and gainer flic-flac variations, also with support of one arm", + "name": null, + "image": "FX-3.107-c.webp" + }, + { + "id": "FX-3.107-d", + "elementGroupId": "FX-3", + "code": "3.107", + "value": 0.1, + "description": "Arabian (bwd take-off) with ¼ twist (90°) – free (aerial) cartwheel – continuing with ¼ twist (90°) to front lying support", + "name": "Tsavdaridou", + "image": "FX-3.107-d.webp" + }, + { + "id": "FX-3.201-a", + "elementGroupId": "FX-3", + "code": "3.201", + "value": 0.2, + "description": "Jump kick or press to hstd with 1½ or 2/1 turn (540° or 720°) in hstd – return movement optional", + "name": null, + "image": "FX-3.201-a.webp" + }, + { + "id": "FX-3.201-b", + "elementGroupId": "FX-3", + "code": "3.201", + "value": 0.2, + "description": "Jump kick or press to hstd with 1½ or 2/1 turn (540° or 720°) in hstd – return movement optional", + "name": null, + "image": "FX-3.201-b.webp" + }, + { + "id": "FX-3.201-c", + "elementGroupId": "FX-3", + "code": "3.201", + "value": 0.2, + "description": "Jump kick or press to hstd with 1½ or 2/1 turn (540° or 720°) in hstd – return movement optional", + "name": null, + "image": "FX-3.201-c.webp" + }, + { + "id": "FX-3.203-a", + "elementGroupId": "FX-3", + "code": "3.203", + "value": 0.2, + "description": "Roll bwd to hstd with 1½ or 2/1 turn (540° or 720°) in hstd", + "name": null, + "image": "FX-3.203-a.webp" + }, + { + "id": "FX-3.203-b", + "elementGroupId": "FX-3", + "code": "3.203", + "value": 0.2, + "description": "Roll bwd to hstd with 1½ or 2/1 turn (540° or 720°) in hstd", + "name": null, + "image": "FX-3.203-b.webp" + }, + { + "id": "FX-3.207-a", + "elementGroupId": "FX-3", + "code": "3.207", + "value": 0.2, + "description": "Flic-flac with 1/1 twist (360°) before hand support", + "name": null, + "image": "FX-3.207-a.webp" + }, + { + "id": "FX-3.305-a", + "elementGroupId": "FX-3", + "code": "3.305", + "value": 0.3, + "description": "Handspring fwd with 1/1 twist (360°) after hand support or before", + "name": "Mostepanova", + "image": "FX-3.305-a.webp" + }, + { + "id": "FX-3.305-b", + "elementGroupId": "FX-3", + "code": "3.305", + "value": 0.3, + "description": "Handspring fwd with 1/1 twist (360°) after hand support or before", + "name": "Mostepanova", + "image": "FX-3.305-b.webp" + }, + { + "id": "FX-4.101-a", + "elementGroupId": "FX-4", + "code": "4.101", + "value": 0.1, + "description": "Salto fwd tucked or piked", + "name": null, + "image": "FX-4.101-a.webp" + }, + { + "id": "FX-4.101-b", + "elementGroupId": "FX-4", + "code": "4.101", + "value": 0.1, + "description": "Salto fwd tucked or piked", + "name": null, + "image": "FX-4.101-b.webp" + }, + { + "id": "FX-4.103-a", + "elementGroupId": "FX-4", + "code": "4.103", + "value": 0.1, + "description": "Free (aerial) walkover fwd", + "name": null, + "image": "FX-4.103-a.webp" + }, + { + "id": "FX-4.104-a", + "elementGroupId": "FX-4", + "code": "4.104", + "value": 0.1, + "description": "Free (aerial) cartwheel or free (aerial) round-off", + "name": null, + "image": "FX-4.104-a.webp" + }, + { + "id": "FX-4.104-b", + "elementGroupId": "FX-4", + "code": "4.104", + "value": 0.1, + "description": "Free (aerial) cartwheel or free (aerial) round-off", + "name": null, + "image": "FX-4.104-b.webp" + }, + { + "id": "FX-4.105-a", + "elementGroupId": "FX-4", + "code": "4.105", + "value": 0.1, + "description": "From take-off fwd from one or both legs – salto swd tucked or piked", + "name": null, + "image": "FX-4.105-a.webp" + }, + { + "id": "FX-4.105-b", + "elementGroupId": "FX-4", + "code": "4.105", + "value": 0.1, + "description": "From take-off fwd from one or both legs – salto swd tucked or piked", + "name": null, + "image": "FX-4.105-b.webp" + }, + { + "id": "FX-4.201-a", + "elementGroupId": "FX-4", + "code": "4.201", + "value": 0.2, + "description": "Salto fwd tucked with ½ or 1/1 twist (180° or 360°), also Salto fwd piked with ½ twist (180°)", + "name": null, + "image": "FX-4.201-a.webp" + }, + { + "id": "FX-4.201-b", + "elementGroupId": "FX-4", + "code": "4.201", + "value": 0.2, + "description": "Salto fwd tucked with ½ or 1/1 twist (180° or 360°), also Salto fwd piked with ½ twist (180°)", + "name": null, + "image": "FX-4.201-b.webp" + }, + { + "id": "FX-4.201-c", + "elementGroupId": "FX-4", + "code": "4.201", + "value": 0.2, + "description": "Salto fwd tucked with ½ or 1/1 twist (180° or 360°), also Salto fwd piked with ½ twist (180°)", + "name": null, + "image": "FX-4.201-c.webp" + }, + { + "id": "FX-4.202-a", + "elementGroupId": "FX-4", + "code": "4.202", + "value": 0.2, + "description": "Salto fwd stretched, also with ½ twist (180°)", + "name": null, + "image": "FX-4.202-a.webp" + }, + { + "id": "FX-4.202-b", + "elementGroupId": "FX-4", + "code": "4.202", + "value": 0.2, + "description": "Salto fwd stretched, also with ½ twist (180°)", + "name": null, + "image": "FX-4.202-b.webp" + }, + { + "id": "FX-4.202-c", + "elementGroupId": "FX-4", + "code": "4.202", + "value": 0.2, + "description": "Salto fwd stretched, also with ½ twist (180°)", + "name": null, + "image": "FX-4.202-c.webp" + }, + { + "id": "FX-4.205-a", + "elementGroupId": "FX-4", + "code": "4.205", + "value": 0.2, + "description": "Arabian salto tucked or piked, (take-off bwd with ½ twist [180°], salto fwd) – landing optional", + "name": null, + "image": "FX-4.205-a.webp" + }, + { + "id": "FX-4.205-b", + "elementGroupId": "FX-4", + "code": "4.205", + "value": 0.2, + "description": "Arabian salto tucked or piked, (take-off bwd with ½ twist [180°], salto fwd) – landing optional", + "name": null, + "image": "FX-4.205-b.webp" + }, + { + "id": "FX-4.302-a", + "elementGroupId": "FX-4", + "code": "4.302", + "value": 0.3, + "description": "Salto fwd stretched with 1/1 or 1½ twist (360° or 540°)", + "name": null, + "image": "FX-4.302-a.webp" + }, + { + "id": "FX-4.302-b", + "elementGroupId": "FX-4", + "code": "4.302", + "value": 0.3, + "description": "Salto fwd stretched with 1/1 or 1½ twist (360° or 540°)", + "name": null, + "image": "FX-4.302-b.webp" + }, + { + "id": "FX-4.402-a", + "elementGroupId": "FX-4", + "code": "4.402", + "value": 0.4, + "description": "Salto fwd stretched with 2/1 twist (720°)", + "name": "Tarasevich", + "image": "FX-4.402-a.webp" + }, + { + "id": "FX-4.501-a", + "elementGroupId": "FX-4", + "code": "4.501", + "value": 0.5, + "description": "Double salto fwd tucked", + "name": "Podkopayeva", + "image": "FX-4.501-a.webp" + }, + { + "id": "FX-4.502-a", + "elementGroupId": "FX-4", + "code": "4.502", + "value": 0.5, + "description": "Salto fwd stretched with 2½ twist (900°)", + "name": "Cojocar", + "image": "FX-4.502-a.webp" + }, + { + "id": "FX-4.505-a", + "elementGroupId": "FX-4", + "code": "4.505", + "value": 0.5, + "description": "Arabian double salto tucked", + "name": "Andreasen/Jentsch", + "image": "FX-4.505-a.webp" + }, + { + "id": "FX-4.601-a", + "elementGroupId": "FX-4", + "code": "4.601", + "value": 0.6, + "description": "Double salto fwd tucked with ½ twist (180°)", + "name": "Podkopayeva", + "image": "FX-4.601-a.webp" + }, + { + "id": "FX-4.601-b", + "elementGroupId": "FX-4", + "code": "4.601", + "value": 0.6, + "description": "Double salto fwd piked", + "name": "Dowell", + "image": "FX-4.601-b.webp" + }, + { + "id": "FX-4.602-a", + "elementGroupId": "FX-4", + "code": "4.602", + "value": 0.6, + "description": "Salto fwd stretched with 3/1 twist (1080°)", + "name": "Maldonado", + "image": "FX-4.602-a.webp" + }, + { + "id": "FX-4.605-a", + "elementGroupId": "FX-4", + "code": "4.605", + "value": 0.6, + "description": "Arabian double salto piked", + "name": "Dos Santos", + "image": "FX-4.605-a.webp" + }, + { + "id": "FX-4.805-a", + "elementGroupId": "FX-4", + "code": "4.805", + "value": 0.8, + "description": "Arabian double salto stretched", + "name": "Dos Santos", + "image": "FX-4.805-a.webp" + }, + { + "id": "FX-5.101-a", + "elementGroupId": "FX-5", + "code": "5.101", + "value": 0.1, + "description": "Salto bwd tucked, piked, or stretched", + "name": null, + "image": "FX-5.101-a.webp" + }, + { + "id": "FX-5.101-b", + "elementGroupId": "FX-5", + "code": "5.101", + "value": 0.1, + "description": "Salto bwd tucked, piked, or stretched", + "name": null, + "image": "FX-5.101-b.webp" + }, + { + "id": "FX-5.101-c", + "elementGroupId": "FX-5", + "code": "5.101", + "value": 0.1, + "description": "Salto bwd tucked, piked, or stretched", + "name": null, + "image": "FX-5.101-c.webp" + }, + { + "id": "FX-5.104-a", + "elementGroupId": "FX-5", + "code": "5.104", + "value": 0.1, + "description": "Whip salto bwd", + "name": null, + "image": "FX-5.104-a.webp" + }, + { + "id": "FX-5.201-a", + "elementGroupId": "FX-5", + "code": "5.201", + "value": 0.2, + "description": "Salto bwd stretched with ½", + "name": null, + "image": "FX-5.201-a.webp" + }, + { + "id": "FX-5.201-b", + "elementGroupId": "FX-5", + "code": "5.201", + "value": 0.2, + "description": "Salto bwd stretched with ½, salto bwd tucked or stretched with 1/1 twist (180° or 360°)", + "name": null, + "image": "FX-5.201-b.webp" + }, + { + "id": "FX-5.204-a", + "elementGroupId": "FX-5", + "code": "5.204", + "value": 0.2, + "description": "Whip salto bwd with ½ twist (180°)", + "name": null, + "image": "FX-5.204-a.webp" + }, + { + "id": "FX-5.301-a", + "elementGroupId": "FX-5", + "code": "5.301", + "value": 0.3, + "description": "Salto bwd stretched with 1½ or 2/1 twist (540° or 720°)", + "name": null, + "image": "FX-5.301-a.webp" + }, + { + "id": "FX-5.301-b", + "elementGroupId": "FX-5", + "code": "5.301", + "value": 0.3, + "description": "Salto bwd stretched with 1½ or 2/1 twist (540° or 720°)", + "name": null, + "image": "FX-5.301-b.webp" + }, + { + "id": "FX-5.304-a", + "elementGroupId": "FX-5", + "code": "5.304", + "value": 0.3, + "description": "Whip salto bwd with 1/1 twist (360°)", + "name": null, + "image": "FX-5.304-a.webp" + }, + { + "id": "FX-5.401-a", + "elementGroupId": "FX-5", + "code": "5.401", + "value": 0.4, + "description": "Salto bwd stretched with 2½ twist (900°)", + "name": null, + "image": "FX-5.401-a.webp" + }, + { + "id": "FX-5.402-a", + "elementGroupId": "FX-5", + "code": "5.402", + "value": 0.4, + "description": "Double salto bwd tucked", + "name": "Kim", + "image": "FX-5.402-a.webp" + }, + { + "id": "FX-5.402-b", + "elementGroupId": "FX-5", + "code": "5.402", + "value": 0.4, + "description": "Double salto bwd piked", + "name": null, + "image": "FX-5.402-b.webp" + }, + { + "id": "FX-5.501-a", + "elementGroupId": "FX-5", + "code": "5.501", + "value": 0.5, + "description": "Salto bwd stretched with 3/1 twist (1080°)", + "name": null, + "image": "FX-5.501-a.webp" + }, + { + "id": "FX-5.502-a", + "elementGroupId": "FX-5", + "code": "5.502", + "value": 0.5, + "description": "Double salto bwd tucked or piked with 1/1 twist (360°) (any technique)", + "name": "Mukhina / Oliveira", + "image": "FX-5.502-a.webp" + }, + { + "id": "FX-5.502-b", + "elementGroupId": "FX-5", + "code": "5.502", + "value": 0.5, + "description": "Double salto bwd tucked or piked with 1/1 twist (360°) (any technique)", + "name": "Mukhina / Oliveira", + "image": "FX-5.502-b.webp" + }, + { + "id": "FX-5.601-a", + "elementGroupId": "FX-5", + "code": "5.601", + "value": 0.6, + "description": "Salto bwd stretched with 3½ twist (1260°)", + "name": null, + "image": "FX-5.601-a.webp" + }, + { + "id": "FX-5.602-a", + "elementGroupId": "FX-5", + "code": "5.602", + "value": 0.6, + "description": "Double salto bwd tucked with 1½ twist (540°)", + "name": "Heron", + "image": "FX-5.602-a.webp" + }, + { + "id": "FX-5.603-a", + "elementGroupId": "FX-5", + "code": "5.603", + "value": 0.6, + "description": "Double salto bwd stretched", + "name": null, + "image": "FX-5.603-a.webp" + }, + { + "id": "FX-5.703-a", + "elementGroupId": "FX-5", + "code": "5.703", + "value": 0.7, + "description": "Double salto bwd stretched with ½ twist (180°)", + "name": "Biles", + "image": "FX-5.703-a.webp" + }, + { + "id": "FX-5.802-a", + "elementGroupId": "FX-5", + "code": "5.802", + "value": 0.8, + "description": "Double salto bwd tucked with 2/1 twist (720°)", + "name": "Silivas", + "image": "FX-5.802-a.webp" + }, + { + "id": "FX-5.802-b", + "elementGroupId": "FX-5", + "code": "5.802", + "value": 0.8, + "description": "Double salto bwd tucked with 3/1 twist (1080°)", + "name": "Biles", + "image": "FX-5.802-b.webp" + }, + { + "id": "FX-5.803-a", + "elementGroupId": "FX-5", + "code": "5.803", + "value": 0.8, + "description": "Double salto bwd stretched with 1/1 twist (360°)", + "name": "Chusovitina/Touzhikova", + "image": "FX-5.803-a.webp" + }, + { + "id": "FX-5.903-a", + "elementGroupId": "FX-5", + "code": "5.903", + "value": 0.9, + "description": "Double Salto bwd stretched with 2/1 twist (720°)", + "name": "Moors", + "image": "FX-5.903-a.webp" + } +] diff --git a/data/images/BB/BB-1.101-a.webp b/data/images/BB/BB-1.101-a.webp new file mode 100644 index 0000000..ae1614b Binary files /dev/null and b/data/images/BB/BB-1.101-a.webp differ diff --git a/data/images/BB/BB-1.102-a.webp b/data/images/BB/BB-1.102-a.webp new file mode 100644 index 0000000..83540a8 Binary files /dev/null and b/data/images/BB/BB-1.102-a.webp differ diff --git a/data/images/BB/BB-1.103-a.webp b/data/images/BB/BB-1.103-a.webp new file mode 100644 index 0000000..6a7395f Binary files /dev/null and b/data/images/BB/BB-1.103-a.webp differ diff --git a/data/images/BB/BB-1.103-b.webp b/data/images/BB/BB-1.103-b.webp new file mode 100644 index 0000000..dbb97b0 Binary files /dev/null and b/data/images/BB/BB-1.103-b.webp differ diff --git a/data/images/BB/BB-1.103-c.webp b/data/images/BB/BB-1.103-c.webp new file mode 100644 index 0000000..df1d58c Binary files /dev/null and b/data/images/BB/BB-1.103-c.webp differ diff --git a/data/images/BB/BB-1.105-a.webp b/data/images/BB/BB-1.105-a.webp new file mode 100644 index 0000000..472bed2 Binary files /dev/null and b/data/images/BB/BB-1.105-a.webp differ diff --git a/data/images/BB/BB-1.105-b.webp b/data/images/BB/BB-1.105-b.webp new file mode 100644 index 0000000..9afec54 Binary files /dev/null and b/data/images/BB/BB-1.105-b.webp differ diff --git a/data/images/BB/BB-1.105-c.webp b/data/images/BB/BB-1.105-c.webp new file mode 100644 index 0000000..3b7bf8b Binary files /dev/null and b/data/images/BB/BB-1.105-c.webp differ diff --git a/data/images/BB/BB-1.106-a.webp b/data/images/BB/BB-1.106-a.webp new file mode 100644 index 0000000..9ab2690 Binary files /dev/null and b/data/images/BB/BB-1.106-a.webp differ diff --git a/data/images/BB/BB-1.106-b.webp b/data/images/BB/BB-1.106-b.webp new file mode 100644 index 0000000..f4acd22 Binary files /dev/null and b/data/images/BB/BB-1.106-b.webp differ diff --git a/data/images/BB/BB-1.108-a.webp b/data/images/BB/BB-1.108-a.webp new file mode 100644 index 0000000..889bdf5 Binary files /dev/null and b/data/images/BB/BB-1.108-a.webp differ diff --git a/data/images/BB/BB-1.108-b.webp b/data/images/BB/BB-1.108-b.webp new file mode 100644 index 0000000..542db42 Binary files /dev/null and b/data/images/BB/BB-1.108-b.webp differ diff --git a/data/images/BB/BB-1.111-a.webp b/data/images/BB/BB-1.111-a.webp new file mode 100644 index 0000000..b286e69 Binary files /dev/null and b/data/images/BB/BB-1.111-a.webp differ diff --git a/data/images/BB/BB-1.111-b.webp b/data/images/BB/BB-1.111-b.webp new file mode 100644 index 0000000..a9f8981 Binary files /dev/null and b/data/images/BB/BB-1.111-b.webp differ diff --git a/data/images/BB/BB-1.114-a.webp b/data/images/BB/BB-1.114-a.webp new file mode 100644 index 0000000..ab43cb8 Binary files /dev/null and b/data/images/BB/BB-1.114-a.webp differ diff --git a/data/images/BB/BB-1.114-b.webp b/data/images/BB/BB-1.114-b.webp new file mode 100644 index 0000000..08e9312 Binary files /dev/null and b/data/images/BB/BB-1.114-b.webp differ diff --git a/data/images/BB/BB-1.201-a.webp b/data/images/BB/BB-1.201-a.webp new file mode 100644 index 0000000..13f21a8 Binary files /dev/null and b/data/images/BB/BB-1.201-a.webp differ diff --git a/data/images/BB/BB-1.204-a.webp b/data/images/BB/BB-1.204-a.webp new file mode 100644 index 0000000..8cf2565 Binary files /dev/null and b/data/images/BB/BB-1.204-a.webp differ diff --git a/data/images/BB/BB-1.204-b.webp b/data/images/BB/BB-1.204-b.webp new file mode 100644 index 0000000..182ff61 Binary files /dev/null and b/data/images/BB/BB-1.204-b.webp differ diff --git a/data/images/BB/BB-1.207-a.webp b/data/images/BB/BB-1.207-a.webp new file mode 100644 index 0000000..6cf0cac Binary files /dev/null and b/data/images/BB/BB-1.207-a.webp differ diff --git a/data/images/BB/BB-1.207-b.webp b/data/images/BB/BB-1.207-b.webp new file mode 100644 index 0000000..d8960ee Binary files /dev/null and b/data/images/BB/BB-1.207-b.webp differ diff --git a/data/images/BB/BB-1.208-a.webp b/data/images/BB/BB-1.208-a.webp new file mode 100644 index 0000000..0e0c705 Binary files /dev/null and b/data/images/BB/BB-1.208-a.webp differ diff --git a/data/images/BB/BB-1.210-a.webp b/data/images/BB/BB-1.210-a.webp new file mode 100644 index 0000000..909f590 Binary files /dev/null and b/data/images/BB/BB-1.210-a.webp differ diff --git a/data/images/BB/BB-1.210-b.webp b/data/images/BB/BB-1.210-b.webp new file mode 100644 index 0000000..c56a9cd Binary files /dev/null and b/data/images/BB/BB-1.210-b.webp differ diff --git a/data/images/BB/BB-1.210-c.webp b/data/images/BB/BB-1.210-c.webp new file mode 100644 index 0000000..3917f47 Binary files /dev/null and b/data/images/BB/BB-1.210-c.webp differ diff --git a/data/images/BB/BB-1.211-a.webp b/data/images/BB/BB-1.211-a.webp new file mode 100644 index 0000000..ddd0d8f Binary files /dev/null and b/data/images/BB/BB-1.211-a.webp differ diff --git a/data/images/BB/BB-1.213-a.webp b/data/images/BB/BB-1.213-a.webp new file mode 100644 index 0000000..af77aab Binary files /dev/null and b/data/images/BB/BB-1.213-a.webp differ diff --git a/data/images/BB/BB-1.214-a.webp b/data/images/BB/BB-1.214-a.webp new file mode 100644 index 0000000..72438dc Binary files /dev/null and b/data/images/BB/BB-1.214-a.webp differ diff --git a/data/images/BB/BB-1.214-b.webp b/data/images/BB/BB-1.214-b.webp new file mode 100644 index 0000000..cb51a85 Binary files /dev/null and b/data/images/BB/BB-1.214-b.webp differ diff --git a/data/images/BB/BB-1.215-a.webp b/data/images/BB/BB-1.215-a.webp new file mode 100644 index 0000000..2f6fdc2 Binary files /dev/null and b/data/images/BB/BB-1.215-a.webp differ diff --git a/data/images/BB/BB-1.215-b.webp b/data/images/BB/BB-1.215-b.webp new file mode 100644 index 0000000..51a41d4 Binary files /dev/null and b/data/images/BB/BB-1.215-b.webp differ diff --git a/data/images/BB/BB-1.215-c.webp b/data/images/BB/BB-1.215-c.webp new file mode 100644 index 0000000..d5a68e4 Binary files /dev/null and b/data/images/BB/BB-1.215-c.webp differ diff --git a/data/images/BB/BB-1.302-a.webp b/data/images/BB/BB-1.302-a.webp new file mode 100644 index 0000000..d3a051e Binary files /dev/null and b/data/images/BB/BB-1.302-a.webp differ diff --git a/data/images/BB/BB-1.303-a.webp b/data/images/BB/BB-1.303-a.webp new file mode 100644 index 0000000..3ce337b Binary files /dev/null and b/data/images/BB/BB-1.303-a.webp differ diff --git a/data/images/BB/BB-1.303-b.webp b/data/images/BB/BB-1.303-b.webp new file mode 100644 index 0000000..a56dc0d Binary files /dev/null and b/data/images/BB/BB-1.303-b.webp differ diff --git a/data/images/BB/BB-1.304-a.webp b/data/images/BB/BB-1.304-a.webp new file mode 100644 index 0000000..6978d4c Binary files /dev/null and b/data/images/BB/BB-1.304-a.webp differ diff --git a/data/images/BB/BB-1.305-a.webp b/data/images/BB/BB-1.305-a.webp new file mode 100644 index 0000000..e8fe34b Binary files /dev/null and b/data/images/BB/BB-1.305-a.webp differ diff --git a/data/images/BB/BB-1.305-b.webp b/data/images/BB/BB-1.305-b.webp new file mode 100644 index 0000000..bdddc7d Binary files /dev/null and b/data/images/BB/BB-1.305-b.webp differ diff --git a/data/images/BB/BB-1.308-a.webp b/data/images/BB/BB-1.308-a.webp new file mode 100644 index 0000000..8fc8f04 Binary files /dev/null and b/data/images/BB/BB-1.308-a.webp differ diff --git a/data/images/BB/BB-1.308-b.webp b/data/images/BB/BB-1.308-b.webp new file mode 100644 index 0000000..3dad9f6 Binary files /dev/null and b/data/images/BB/BB-1.308-b.webp differ diff --git a/data/images/BB/BB-1.309-a.webp b/data/images/BB/BB-1.309-a.webp new file mode 100644 index 0000000..140b55a Binary files /dev/null and b/data/images/BB/BB-1.309-a.webp differ diff --git a/data/images/BB/BB-1.309-b.webp b/data/images/BB/BB-1.309-b.webp new file mode 100644 index 0000000..aba002a Binary files /dev/null and b/data/images/BB/BB-1.309-b.webp differ diff --git a/data/images/BB/BB-1.309-c.webp b/data/images/BB/BB-1.309-c.webp new file mode 100644 index 0000000..cdf698b Binary files /dev/null and b/data/images/BB/BB-1.309-c.webp differ diff --git a/data/images/BB/BB-1.309-d.webp b/data/images/BB/BB-1.309-d.webp new file mode 100644 index 0000000..69ab183 Binary files /dev/null and b/data/images/BB/BB-1.309-d.webp differ diff --git a/data/images/BB/BB-1.310-a.webp b/data/images/BB/BB-1.310-a.webp new file mode 100644 index 0000000..f2e360a Binary files /dev/null and b/data/images/BB/BB-1.310-a.webp differ diff --git a/data/images/BB/BB-1.310-b.webp b/data/images/BB/BB-1.310-b.webp new file mode 100644 index 0000000..6e171e2 Binary files /dev/null and b/data/images/BB/BB-1.310-b.webp differ diff --git a/data/images/BB/BB-1.310-c.webp b/data/images/BB/BB-1.310-c.webp new file mode 100644 index 0000000..37ec8ad Binary files /dev/null and b/data/images/BB/BB-1.310-c.webp differ diff --git a/data/images/BB/BB-1.311-a.webp b/data/images/BB/BB-1.311-a.webp new file mode 100644 index 0000000..3334993 Binary files /dev/null and b/data/images/BB/BB-1.311-a.webp differ diff --git a/data/images/BB/BB-1.311-b.webp b/data/images/BB/BB-1.311-b.webp new file mode 100644 index 0000000..7b4a114 Binary files /dev/null and b/data/images/BB/BB-1.311-b.webp differ diff --git a/data/images/BB/BB-1.311-c.webp b/data/images/BB/BB-1.311-c.webp new file mode 100644 index 0000000..df68156 Binary files /dev/null and b/data/images/BB/BB-1.311-c.webp differ diff --git a/data/images/BB/BB-1.311-d.webp b/data/images/BB/BB-1.311-d.webp new file mode 100644 index 0000000..e622cdc Binary files /dev/null and b/data/images/BB/BB-1.311-d.webp differ diff --git a/data/images/BB/BB-1.312-a.webp b/data/images/BB/BB-1.312-a.webp new file mode 100644 index 0000000..02b2a67 Binary files /dev/null and b/data/images/BB/BB-1.312-a.webp differ diff --git a/data/images/BB/BB-1.312-b.webp b/data/images/BB/BB-1.312-b.webp new file mode 100644 index 0000000..3f5b3e1 Binary files /dev/null and b/data/images/BB/BB-1.312-b.webp differ diff --git a/data/images/BB/BB-1.313-a.webp b/data/images/BB/BB-1.313-a.webp new file mode 100644 index 0000000..f47c2b1 Binary files /dev/null and b/data/images/BB/BB-1.313-a.webp differ diff --git a/data/images/BB/BB-1.313-b.webp b/data/images/BB/BB-1.313-b.webp new file mode 100644 index 0000000..bf65fb7 Binary files /dev/null and b/data/images/BB/BB-1.313-b.webp differ diff --git a/data/images/BB/BB-1.313-c.webp b/data/images/BB/BB-1.313-c.webp new file mode 100644 index 0000000..3debfa2 Binary files /dev/null and b/data/images/BB/BB-1.313-c.webp differ diff --git a/data/images/BB/BB-1.314-a.webp b/data/images/BB/BB-1.314-a.webp new file mode 100644 index 0000000..58bf5f0 Binary files /dev/null and b/data/images/BB/BB-1.314-a.webp differ diff --git a/data/images/BB/BB-1.314-b.webp b/data/images/BB/BB-1.314-b.webp new file mode 100644 index 0000000..73b8975 Binary files /dev/null and b/data/images/BB/BB-1.314-b.webp differ diff --git a/data/images/BB/BB-1.314-c.webp b/data/images/BB/BB-1.314-c.webp new file mode 100644 index 0000000..4042204 Binary files /dev/null and b/data/images/BB/BB-1.314-c.webp differ diff --git a/data/images/BB/BB-1.315-a.webp b/data/images/BB/BB-1.315-a.webp new file mode 100644 index 0000000..7e20496 Binary files /dev/null and b/data/images/BB/BB-1.315-a.webp differ diff --git a/data/images/BB/BB-1.315-b.webp b/data/images/BB/BB-1.315-b.webp new file mode 100644 index 0000000..1edc984 Binary files /dev/null and b/data/images/BB/BB-1.315-b.webp differ diff --git a/data/images/BB/BB-1.317-a.webp b/data/images/BB/BB-1.317-a.webp new file mode 100644 index 0000000..fbb9f9f Binary files /dev/null and b/data/images/BB/BB-1.317-a.webp differ diff --git a/data/images/BB/BB-1.401-a.webp b/data/images/BB/BB-1.401-a.webp new file mode 100644 index 0000000..8f1f9b4 Binary files /dev/null and b/data/images/BB/BB-1.401-a.webp differ diff --git a/data/images/BB/BB-1.402-a.webp b/data/images/BB/BB-1.402-a.webp new file mode 100644 index 0000000..849d597 Binary files /dev/null and b/data/images/BB/BB-1.402-a.webp differ diff --git a/data/images/BB/BB-1.403-a.webp b/data/images/BB/BB-1.403-a.webp new file mode 100644 index 0000000..fcefa32 Binary files /dev/null and b/data/images/BB/BB-1.403-a.webp differ diff --git a/data/images/BB/BB-1.403-b.webp b/data/images/BB/BB-1.403-b.webp new file mode 100644 index 0000000..0e36f64 Binary files /dev/null and b/data/images/BB/BB-1.403-b.webp differ diff --git a/data/images/BB/BB-1.405-a.webp b/data/images/BB/BB-1.405-a.webp new file mode 100644 index 0000000..b03a3e8 Binary files /dev/null and b/data/images/BB/BB-1.405-a.webp differ diff --git a/data/images/BB/BB-1.411-a.webp b/data/images/BB/BB-1.411-a.webp new file mode 100644 index 0000000..1c470e6 Binary files /dev/null and b/data/images/BB/BB-1.411-a.webp differ diff --git a/data/images/BB/BB-1.411-b.webp b/data/images/BB/BB-1.411-b.webp new file mode 100644 index 0000000..f66785a Binary files /dev/null and b/data/images/BB/BB-1.411-b.webp differ diff --git a/data/images/BB/BB-1.411-c.webp b/data/images/BB/BB-1.411-c.webp new file mode 100644 index 0000000..677522b Binary files /dev/null and b/data/images/BB/BB-1.411-c.webp differ diff --git a/data/images/BB/BB-1.412-a.webp b/data/images/BB/BB-1.412-a.webp new file mode 100644 index 0000000..8b52056 Binary files /dev/null and b/data/images/BB/BB-1.412-a.webp differ diff --git a/data/images/BB/BB-1.412-b.webp b/data/images/BB/BB-1.412-b.webp new file mode 100644 index 0000000..531b8ea Binary files /dev/null and b/data/images/BB/BB-1.412-b.webp differ diff --git a/data/images/BB/BB-1.413-a.webp b/data/images/BB/BB-1.413-a.webp new file mode 100644 index 0000000..2766782 Binary files /dev/null and b/data/images/BB/BB-1.413-a.webp differ diff --git a/data/images/BB/BB-1.414-a.webp b/data/images/BB/BB-1.414-a.webp new file mode 100644 index 0000000..985768e Binary files /dev/null and b/data/images/BB/BB-1.414-a.webp differ diff --git a/data/images/BB/BB-1.416-a.webp b/data/images/BB/BB-1.416-a.webp new file mode 100644 index 0000000..ef6843f Binary files /dev/null and b/data/images/BB/BB-1.416-a.webp differ diff --git a/data/images/BB/BB-1.417-a.webp b/data/images/BB/BB-1.417-a.webp new file mode 100644 index 0000000..b7c2937 Binary files /dev/null and b/data/images/BB/BB-1.417-a.webp differ diff --git a/data/images/BB/BB-1.417-b.webp b/data/images/BB/BB-1.417-b.webp new file mode 100644 index 0000000..73cbb1d Binary files /dev/null and b/data/images/BB/BB-1.417-b.webp differ diff --git a/data/images/BB/BB-1.514-a.webp b/data/images/BB/BB-1.514-a.webp new file mode 100644 index 0000000..483492b Binary files /dev/null and b/data/images/BB/BB-1.514-a.webp differ diff --git a/data/images/BB/BB-1.516-a.webp b/data/images/BB/BB-1.516-a.webp new file mode 100644 index 0000000..24334f5 Binary files /dev/null and b/data/images/BB/BB-1.516-a.webp differ diff --git a/data/images/BB/BB-1.517-a.webp b/data/images/BB/BB-1.517-a.webp new file mode 100644 index 0000000..39c10b8 Binary files /dev/null and b/data/images/BB/BB-1.517-a.webp differ diff --git a/data/images/BB/BB-1.518-a.webp b/data/images/BB/BB-1.518-a.webp new file mode 100644 index 0000000..4d2a3bd Binary files /dev/null and b/data/images/BB/BB-1.518-a.webp differ diff --git a/data/images/BB/BB-1.518-b.webp b/data/images/BB/BB-1.518-b.webp new file mode 100644 index 0000000..5345d36 Binary files /dev/null and b/data/images/BB/BB-1.518-b.webp differ diff --git a/data/images/BB/BB-1.518-c.webp b/data/images/BB/BB-1.518-c.webp new file mode 100644 index 0000000..a7e272f Binary files /dev/null and b/data/images/BB/BB-1.518-c.webp differ diff --git a/data/images/BB/BB-1.609-a.webp b/data/images/BB/BB-1.609-a.webp new file mode 100644 index 0000000..ba166b9 Binary files /dev/null and b/data/images/BB/BB-1.609-a.webp differ diff --git a/data/images/BB/BB-1.615-a.webp b/data/images/BB/BB-1.615-a.webp new file mode 100644 index 0000000..bca8e9f Binary files /dev/null and b/data/images/BB/BB-1.615-a.webp differ diff --git a/data/images/BB/BB-1.616-a.webp b/data/images/BB/BB-1.616-a.webp new file mode 100644 index 0000000..5f26e6e Binary files /dev/null and b/data/images/BB/BB-1.616-a.webp differ diff --git a/data/images/BB/BB-1.618-a.webp b/data/images/BB/BB-1.618-a.webp new file mode 100644 index 0000000..feb3000 Binary files /dev/null and b/data/images/BB/BB-1.618-a.webp differ diff --git a/data/images/BB/BB-1.618-b.webp b/data/images/BB/BB-1.618-b.webp new file mode 100644 index 0000000..74079ea Binary files /dev/null and b/data/images/BB/BB-1.618-b.webp differ diff --git a/data/images/BB/BB-1.619-a.webp b/data/images/BB/BB-1.619-a.webp new file mode 100644 index 0000000..65526ad Binary files /dev/null and b/data/images/BB/BB-1.619-a.webp differ diff --git a/data/images/BB/BB-1.716-a.webp b/data/images/BB/BB-1.716-a.webp new file mode 100644 index 0000000..39a7a1d Binary files /dev/null and b/data/images/BB/BB-1.716-a.webp differ diff --git a/data/images/BB/BB-1.718-a.webp b/data/images/BB/BB-1.718-a.webp new file mode 100644 index 0000000..b340c4d Binary files /dev/null and b/data/images/BB/BB-1.718-a.webp differ diff --git a/data/images/BB/BB-2.101-a.webp b/data/images/BB/BB-2.101-a.webp new file mode 100644 index 0000000..a36c8dc Binary files /dev/null and b/data/images/BB/BB-2.101-a.webp differ diff --git a/data/images/BB/BB-2.101-b.webp b/data/images/BB/BB-2.101-b.webp new file mode 100644 index 0000000..4c1c102 Binary files /dev/null and b/data/images/BB/BB-2.101-b.webp differ diff --git a/data/images/BB/BB-2.105-a.webp b/data/images/BB/BB-2.105-a.webp new file mode 100644 index 0000000..ed07b0c Binary files /dev/null and b/data/images/BB/BB-2.105-a.webp differ diff --git a/data/images/BB/BB-2.107-a.webp b/data/images/BB/BB-2.107-a.webp new file mode 100644 index 0000000..784964a Binary files /dev/null and b/data/images/BB/BB-2.107-a.webp differ diff --git a/data/images/BB/BB-2.108-a.webp b/data/images/BB/BB-2.108-a.webp new file mode 100644 index 0000000..faffa5c Binary files /dev/null and b/data/images/BB/BB-2.108-a.webp differ diff --git a/data/images/BB/BB-2.108-b.webp b/data/images/BB/BB-2.108-b.webp new file mode 100644 index 0000000..689a219 Binary files /dev/null and b/data/images/BB/BB-2.108-b.webp differ diff --git a/data/images/BB/BB-2.110-a.webp b/data/images/BB/BB-2.110-a.webp new file mode 100644 index 0000000..f359273 Binary files /dev/null and b/data/images/BB/BB-2.110-a.webp differ diff --git a/data/images/BB/BB-2.110-b.webp b/data/images/BB/BB-2.110-b.webp new file mode 100644 index 0000000..e890fe7 Binary files /dev/null and b/data/images/BB/BB-2.110-b.webp differ diff --git a/data/images/BB/BB-2.112-a.webp b/data/images/BB/BB-2.112-a.webp new file mode 100644 index 0000000..66601c7 Binary files /dev/null and b/data/images/BB/BB-2.112-a.webp differ diff --git a/data/images/BB/BB-2.112-b.webp b/data/images/BB/BB-2.112-b.webp new file mode 100644 index 0000000..b81ec06 Binary files /dev/null and b/data/images/BB/BB-2.112-b.webp differ diff --git a/data/images/BB/BB-2.202-a.webp b/data/images/BB/BB-2.202-a.webp new file mode 100644 index 0000000..cd42ca7 Binary files /dev/null and b/data/images/BB/BB-2.202-a.webp differ diff --git a/data/images/BB/BB-2.203-a.webp b/data/images/BB/BB-2.203-a.webp new file mode 100644 index 0000000..58ce39e Binary files /dev/null and b/data/images/BB/BB-2.203-a.webp differ diff --git a/data/images/BB/BB-2.203-b.webp b/data/images/BB/BB-2.203-b.webp new file mode 100644 index 0000000..ac78139 Binary files /dev/null and b/data/images/BB/BB-2.203-b.webp differ diff --git a/data/images/BB/BB-2.204-a.webp b/data/images/BB/BB-2.204-a.webp new file mode 100644 index 0000000..0b7eace Binary files /dev/null and b/data/images/BB/BB-2.204-a.webp differ diff --git a/data/images/BB/BB-2.206-a.webp b/data/images/BB/BB-2.206-a.webp new file mode 100644 index 0000000..6e68abc Binary files /dev/null and b/data/images/BB/BB-2.206-a.webp differ diff --git a/data/images/BB/BB-2.207-a.webp b/data/images/BB/BB-2.207-a.webp new file mode 100644 index 0000000..f12f9f7 Binary files /dev/null and b/data/images/BB/BB-2.207-a.webp differ diff --git a/data/images/BB/BB-2.208-a.webp b/data/images/BB/BB-2.208-a.webp new file mode 100644 index 0000000..0c437e5 Binary files /dev/null and b/data/images/BB/BB-2.208-a.webp differ diff --git a/data/images/BB/BB-2.208-b.webp b/data/images/BB/BB-2.208-b.webp new file mode 100644 index 0000000..5f923c3 Binary files /dev/null and b/data/images/BB/BB-2.208-b.webp differ diff --git a/data/images/BB/BB-2.209-a.webp b/data/images/BB/BB-2.209-a.webp new file mode 100644 index 0000000..77d1c09 Binary files /dev/null and b/data/images/BB/BB-2.209-a.webp differ diff --git a/data/images/BB/BB-2.209-b.webp b/data/images/BB/BB-2.209-b.webp new file mode 100644 index 0000000..b103a1d Binary files /dev/null and b/data/images/BB/BB-2.209-b.webp differ diff --git a/data/images/BB/BB-2.210-a.webp b/data/images/BB/BB-2.210-a.webp new file mode 100644 index 0000000..fe96f83 Binary files /dev/null and b/data/images/BB/BB-2.210-a.webp differ diff --git a/data/images/BB/BB-2.211-a.webp b/data/images/BB/BB-2.211-a.webp new file mode 100644 index 0000000..7600f23 Binary files /dev/null and b/data/images/BB/BB-2.211-a.webp differ diff --git a/data/images/BB/BB-2.212-a.webp b/data/images/BB/BB-2.212-a.webp new file mode 100644 index 0000000..f2df492 Binary files /dev/null and b/data/images/BB/BB-2.212-a.webp differ diff --git a/data/images/BB/BB-2.301-a.webp b/data/images/BB/BB-2.301-a.webp new file mode 100644 index 0000000..928dcbd Binary files /dev/null and b/data/images/BB/BB-2.301-a.webp differ diff --git a/data/images/BB/BB-2.302-a.webp b/data/images/BB/BB-2.302-a.webp new file mode 100644 index 0000000..edc995b Binary files /dev/null and b/data/images/BB/BB-2.302-a.webp differ diff --git a/data/images/BB/BB-2.303-a.webp b/data/images/BB/BB-2.303-a.webp new file mode 100644 index 0000000..e6c87f0 Binary files /dev/null and b/data/images/BB/BB-2.303-a.webp differ diff --git a/data/images/BB/BB-2.305-a.webp b/data/images/BB/BB-2.305-a.webp new file mode 100644 index 0000000..cd3e347 Binary files /dev/null and b/data/images/BB/BB-2.305-a.webp differ diff --git a/data/images/BB/BB-2.306-a.webp b/data/images/BB/BB-2.306-a.webp new file mode 100644 index 0000000..39b856e Binary files /dev/null and b/data/images/BB/BB-2.306-a.webp differ diff --git a/data/images/BB/BB-2.306-b.webp b/data/images/BB/BB-2.306-b.webp new file mode 100644 index 0000000..4948664 Binary files /dev/null and b/data/images/BB/BB-2.306-b.webp differ diff --git a/data/images/BB/BB-2.308-a.webp b/data/images/BB/BB-2.308-a.webp new file mode 100644 index 0000000..6ed1a37 Binary files /dev/null and b/data/images/BB/BB-2.308-a.webp differ diff --git a/data/images/BB/BB-2.310-a.webp b/data/images/BB/BB-2.310-a.webp new file mode 100644 index 0000000..515d4fb Binary files /dev/null and b/data/images/BB/BB-2.310-a.webp differ diff --git a/data/images/BB/BB-2.311-a.webp b/data/images/BB/BB-2.311-a.webp new file mode 100644 index 0000000..a917ecb Binary files /dev/null and b/data/images/BB/BB-2.311-a.webp differ diff --git a/data/images/BB/BB-2.402-a.webp b/data/images/BB/BB-2.402-a.webp new file mode 100644 index 0000000..8062c0b Binary files /dev/null and b/data/images/BB/BB-2.402-a.webp differ diff --git a/data/images/BB/BB-2.403-a.webp b/data/images/BB/BB-2.403-a.webp new file mode 100644 index 0000000..a5346dd Binary files /dev/null and b/data/images/BB/BB-2.403-a.webp differ diff --git a/data/images/BB/BB-2.404-a.webp b/data/images/BB/BB-2.404-a.webp new file mode 100644 index 0000000..831b785 Binary files /dev/null and b/data/images/BB/BB-2.404-a.webp differ diff --git a/data/images/BB/BB-2.405-a.webp b/data/images/BB/BB-2.405-a.webp new file mode 100644 index 0000000..78e6f95 Binary files /dev/null and b/data/images/BB/BB-2.405-a.webp differ diff --git a/data/images/BB/BB-2.407-a.webp b/data/images/BB/BB-2.407-a.webp new file mode 100644 index 0000000..7dfc23b Binary files /dev/null and b/data/images/BB/BB-2.407-a.webp differ diff --git a/data/images/BB/BB-2.408-a.webp b/data/images/BB/BB-2.408-a.webp new file mode 100644 index 0000000..9aca343 Binary files /dev/null and b/data/images/BB/BB-2.408-a.webp differ diff --git a/data/images/BB/BB-2.408-b.webp b/data/images/BB/BB-2.408-b.webp new file mode 100644 index 0000000..dba8bff Binary files /dev/null and b/data/images/BB/BB-2.408-b.webp differ diff --git a/data/images/BB/BB-2.408-c.webp b/data/images/BB/BB-2.408-c.webp new file mode 100644 index 0000000..dfd99c8 Binary files /dev/null and b/data/images/BB/BB-2.408-c.webp differ diff --git a/data/images/BB/BB-2.409-a.webp b/data/images/BB/BB-2.409-a.webp new file mode 100644 index 0000000..7182cc8 Binary files /dev/null and b/data/images/BB/BB-2.409-a.webp differ diff --git a/data/images/BB/BB-2.411-a.webp b/data/images/BB/BB-2.411-a.webp new file mode 100644 index 0000000..f748dd3 Binary files /dev/null and b/data/images/BB/BB-2.411-a.webp differ diff --git a/data/images/BB/BB-2.412-a.webp b/data/images/BB/BB-2.412-a.webp new file mode 100644 index 0000000..9e16cd4 Binary files /dev/null and b/data/images/BB/BB-2.412-a.webp differ diff --git a/data/images/BB/BB-2.504-a.webp b/data/images/BB/BB-2.504-a.webp new file mode 100644 index 0000000..2eba03c Binary files /dev/null and b/data/images/BB/BB-2.504-a.webp differ diff --git a/data/images/BB/BB-2.505-a.webp b/data/images/BB/BB-2.505-a.webp new file mode 100644 index 0000000..74424a2 Binary files /dev/null and b/data/images/BB/BB-2.505-a.webp differ diff --git a/data/images/BB/BB-2.506-a.webp b/data/images/BB/BB-2.506-a.webp new file mode 100644 index 0000000..72a118e Binary files /dev/null and b/data/images/BB/BB-2.506-a.webp differ diff --git a/data/images/BB/BB-2.512-a.webp b/data/images/BB/BB-2.512-a.webp new file mode 100644 index 0000000..b0583ec Binary files /dev/null and b/data/images/BB/BB-2.512-a.webp differ diff --git a/data/images/BB/BB-3.101-a.webp b/data/images/BB/BB-3.101-a.webp new file mode 100644 index 0000000..50d9958 Binary files /dev/null and b/data/images/BB/BB-3.101-a.webp differ diff --git a/data/images/BB/BB-3.201-a.webp b/data/images/BB/BB-3.201-a.webp new file mode 100644 index 0000000..e64cf8e Binary files /dev/null and b/data/images/BB/BB-3.201-a.webp differ diff --git a/data/images/BB/BB-3.206-a.webp b/data/images/BB/BB-3.206-a.webp new file mode 100644 index 0000000..4e48a33 Binary files /dev/null and b/data/images/BB/BB-3.206-a.webp differ diff --git a/data/images/BB/BB-3.207-a.webp b/data/images/BB/BB-3.207-a.webp new file mode 100644 index 0000000..8e1ab6e Binary files /dev/null and b/data/images/BB/BB-3.207-a.webp differ diff --git a/data/images/BB/BB-3.208-a.webp b/data/images/BB/BB-3.208-a.webp new file mode 100644 index 0000000..d960a3d Binary files /dev/null and b/data/images/BB/BB-3.208-a.webp differ diff --git a/data/images/BB/BB-3.208-b.webp b/data/images/BB/BB-3.208-b.webp new file mode 100644 index 0000000..9da52d3 Binary files /dev/null and b/data/images/BB/BB-3.208-b.webp differ diff --git a/data/images/BB/BB-3.302-a.webp b/data/images/BB/BB-3.302-a.webp new file mode 100644 index 0000000..1b83071 Binary files /dev/null and b/data/images/BB/BB-3.302-a.webp differ diff --git a/data/images/BB/BB-3.304-a.webp b/data/images/BB/BB-3.304-a.webp new file mode 100644 index 0000000..4b4ca78 Binary files /dev/null and b/data/images/BB/BB-3.304-a.webp differ diff --git a/data/images/BB/BB-3.305-a.webp b/data/images/BB/BB-3.305-a.webp new file mode 100644 index 0000000..e69f37b Binary files /dev/null and b/data/images/BB/BB-3.305-a.webp differ diff --git a/data/images/BB/BB-3.307-a.webp b/data/images/BB/BB-3.307-a.webp new file mode 100644 index 0000000..9e5690f Binary files /dev/null and b/data/images/BB/BB-3.307-a.webp differ diff --git a/data/images/BB/BB-3.401-a.webp b/data/images/BB/BB-3.401-a.webp new file mode 100644 index 0000000..b5d7fe5 Binary files /dev/null and b/data/images/BB/BB-3.401-a.webp differ diff --git a/data/images/BB/BB-3.402-a.webp b/data/images/BB/BB-3.402-a.webp new file mode 100644 index 0000000..33836cf Binary files /dev/null and b/data/images/BB/BB-3.402-a.webp differ diff --git a/data/images/BB/BB-3.403-a.webp b/data/images/BB/BB-3.403-a.webp new file mode 100644 index 0000000..2024206 Binary files /dev/null and b/data/images/BB/BB-3.403-a.webp differ diff --git a/data/images/BB/BB-3.404-a.webp b/data/images/BB/BB-3.404-a.webp new file mode 100644 index 0000000..485662d Binary files /dev/null and b/data/images/BB/BB-3.404-a.webp differ diff --git a/data/images/BB/BB-3.405-a.webp b/data/images/BB/BB-3.405-a.webp new file mode 100644 index 0000000..41cd7cf Binary files /dev/null and b/data/images/BB/BB-3.405-a.webp differ diff --git a/data/images/BB/BB-3.406-a.webp b/data/images/BB/BB-3.406-a.webp new file mode 100644 index 0000000..c0a27a9 Binary files /dev/null and b/data/images/BB/BB-3.406-a.webp differ diff --git a/data/images/BB/BB-3.407-a.webp b/data/images/BB/BB-3.407-a.webp new file mode 100644 index 0000000..4850ce6 Binary files /dev/null and b/data/images/BB/BB-3.407-a.webp differ diff --git a/data/images/BB/BB-3.407-b.webp b/data/images/BB/BB-3.407-b.webp new file mode 100644 index 0000000..ddf0a31 Binary files /dev/null and b/data/images/BB/BB-3.407-b.webp differ diff --git a/data/images/BB/BB-3.408-a.webp b/data/images/BB/BB-3.408-a.webp new file mode 100644 index 0000000..476c872 Binary files /dev/null and b/data/images/BB/BB-3.408-a.webp differ diff --git a/data/images/BB/BB-3.501-a.webp b/data/images/BB/BB-3.501-a.webp new file mode 100644 index 0000000..71049d0 Binary files /dev/null and b/data/images/BB/BB-3.501-a.webp differ diff --git a/data/images/BB/BB-3.504-a.webp b/data/images/BB/BB-3.504-a.webp new file mode 100644 index 0000000..36f3c17 Binary files /dev/null and b/data/images/BB/BB-3.504-a.webp differ diff --git a/data/images/BB/BB-3.505-a.webp b/data/images/BB/BB-3.505-a.webp new file mode 100644 index 0000000..4d9e875 Binary files /dev/null and b/data/images/BB/BB-3.505-a.webp differ diff --git a/data/images/BB/BB-3.507-a.webp b/data/images/BB/BB-3.507-a.webp new file mode 100644 index 0000000..6c24707 Binary files /dev/null and b/data/images/BB/BB-3.507-a.webp differ diff --git a/data/images/BB/BB-4.101-a.webp b/data/images/BB/BB-4.101-a.webp new file mode 100644 index 0000000..ed2ebe6 Binary files /dev/null and b/data/images/BB/BB-4.101-a.webp differ diff --git a/data/images/BB/BB-4.102-a.webp b/data/images/BB/BB-4.102-a.webp new file mode 100644 index 0000000..688797b Binary files /dev/null and b/data/images/BB/BB-4.102-a.webp differ diff --git a/data/images/BB/BB-4.102-b.webp b/data/images/BB/BB-4.102-b.webp new file mode 100644 index 0000000..c103295 Binary files /dev/null and b/data/images/BB/BB-4.102-b.webp differ diff --git a/data/images/BB/BB-4.103-a.webp b/data/images/BB/BB-4.103-a.webp new file mode 100644 index 0000000..ddfb4d1 Binary files /dev/null and b/data/images/BB/BB-4.103-a.webp differ diff --git a/data/images/BB/BB-4.103-b.webp b/data/images/BB/BB-4.103-b.webp new file mode 100644 index 0000000..c345e32 Binary files /dev/null and b/data/images/BB/BB-4.103-b.webp differ diff --git a/data/images/BB/BB-4.105-a.webp b/data/images/BB/BB-4.105-a.webp new file mode 100644 index 0000000..637ee26 Binary files /dev/null and b/data/images/BB/BB-4.105-a.webp differ diff --git a/data/images/BB/BB-4.107-a.webp b/data/images/BB/BB-4.107-a.webp new file mode 100644 index 0000000..f7ab48d Binary files /dev/null and b/data/images/BB/BB-4.107-a.webp differ diff --git a/data/images/BB/BB-4.107-b.webp b/data/images/BB/BB-4.107-b.webp new file mode 100644 index 0000000..fac1616 Binary files /dev/null and b/data/images/BB/BB-4.107-b.webp differ diff --git a/data/images/BB/BB-4.107-c.webp b/data/images/BB/BB-4.107-c.webp new file mode 100644 index 0000000..99e2c86 Binary files /dev/null and b/data/images/BB/BB-4.107-c.webp differ diff --git a/data/images/BB/BB-4.108-a.webp b/data/images/BB/BB-4.108-a.webp new file mode 100644 index 0000000..42880df Binary files /dev/null and b/data/images/BB/BB-4.108-a.webp differ diff --git a/data/images/BB/BB-4.108-b.webp b/data/images/BB/BB-4.108-b.webp new file mode 100644 index 0000000..51370b1 Binary files /dev/null and b/data/images/BB/BB-4.108-b.webp differ diff --git a/data/images/BB/BB-4.108-c.webp b/data/images/BB/BB-4.108-c.webp new file mode 100644 index 0000000..9bee3d3 Binary files /dev/null and b/data/images/BB/BB-4.108-c.webp differ diff --git a/data/images/BB/BB-4.109-a.webp b/data/images/BB/BB-4.109-a.webp new file mode 100644 index 0000000..c7ad5c8 Binary files /dev/null and b/data/images/BB/BB-4.109-a.webp differ diff --git a/data/images/BB/BB-4.109-b.webp b/data/images/BB/BB-4.109-b.webp new file mode 100644 index 0000000..2ed6a17 Binary files /dev/null and b/data/images/BB/BB-4.109-b.webp differ diff --git a/data/images/BB/BB-4.109-c.webp b/data/images/BB/BB-4.109-c.webp new file mode 100644 index 0000000..3682576 Binary files /dev/null and b/data/images/BB/BB-4.109-c.webp differ diff --git a/data/images/BB/BB-4.202-a.webp b/data/images/BB/BB-4.202-a.webp new file mode 100644 index 0000000..a812d7b Binary files /dev/null and b/data/images/BB/BB-4.202-a.webp differ diff --git a/data/images/BB/BB-4.203-a.webp b/data/images/BB/BB-4.203-a.webp new file mode 100644 index 0000000..7fb1a7f Binary files /dev/null and b/data/images/BB/BB-4.203-a.webp differ diff --git a/data/images/BB/BB-4.204-a.webp b/data/images/BB/BB-4.204-a.webp new file mode 100644 index 0000000..ae91edc Binary files /dev/null and b/data/images/BB/BB-4.204-a.webp differ diff --git a/data/images/BB/BB-4.204-b.webp b/data/images/BB/BB-4.204-b.webp new file mode 100644 index 0000000..30ae178 Binary files /dev/null and b/data/images/BB/BB-4.204-b.webp differ diff --git a/data/images/BB/BB-4.206-a.webp b/data/images/BB/BB-4.206-a.webp new file mode 100644 index 0000000..48346e2 Binary files /dev/null and b/data/images/BB/BB-4.206-a.webp differ diff --git a/data/images/BB/BB-4.206-b.webp b/data/images/BB/BB-4.206-b.webp new file mode 100644 index 0000000..6551497 Binary files /dev/null and b/data/images/BB/BB-4.206-b.webp differ diff --git a/data/images/BB/BB-4.207-a.webp b/data/images/BB/BB-4.207-a.webp new file mode 100644 index 0000000..682365f Binary files /dev/null and b/data/images/BB/BB-4.207-a.webp differ diff --git a/data/images/BB/BB-4.208-a.webp b/data/images/BB/BB-4.208-a.webp new file mode 100644 index 0000000..09ba208 Binary files /dev/null and b/data/images/BB/BB-4.208-a.webp differ diff --git a/data/images/BB/BB-4.209-a.webp b/data/images/BB/BB-4.209-a.webp new file mode 100644 index 0000000..f7379cd Binary files /dev/null and b/data/images/BB/BB-4.209-a.webp differ diff --git a/data/images/BB/BB-4.210-a.webp b/data/images/BB/BB-4.210-a.webp new file mode 100644 index 0000000..4532592 Binary files /dev/null and b/data/images/BB/BB-4.210-a.webp differ diff --git a/data/images/BB/BB-4.210-b.webp b/data/images/BB/BB-4.210-b.webp new file mode 100644 index 0000000..5a2405b Binary files /dev/null and b/data/images/BB/BB-4.210-b.webp differ diff --git a/data/images/BB/BB-4.304-a.webp b/data/images/BB/BB-4.304-a.webp new file mode 100644 index 0000000..2509e4e Binary files /dev/null and b/data/images/BB/BB-4.304-a.webp differ diff --git a/data/images/BB/BB-4.305-a.webp b/data/images/BB/BB-4.305-a.webp new file mode 100644 index 0000000..7011e8e Binary files /dev/null and b/data/images/BB/BB-4.305-a.webp differ diff --git a/data/images/BB/BB-4.306-a.webp b/data/images/BB/BB-4.306-a.webp new file mode 100644 index 0000000..5826ecd Binary files /dev/null and b/data/images/BB/BB-4.306-a.webp differ diff --git a/data/images/BB/BB-4.307-a.webp b/data/images/BB/BB-4.307-a.webp new file mode 100644 index 0000000..b092e8e Binary files /dev/null and b/data/images/BB/BB-4.307-a.webp differ diff --git a/data/images/BB/BB-4.308-a.webp b/data/images/BB/BB-4.308-a.webp new file mode 100644 index 0000000..9a49974 Binary files /dev/null and b/data/images/BB/BB-4.308-a.webp differ diff --git a/data/images/BB/BB-4.308-b.webp b/data/images/BB/BB-4.308-b.webp new file mode 100644 index 0000000..84a810e Binary files /dev/null and b/data/images/BB/BB-4.308-b.webp differ diff --git a/data/images/BB/BB-4.309-a.webp b/data/images/BB/BB-4.309-a.webp new file mode 100644 index 0000000..58d5b05 Binary files /dev/null and b/data/images/BB/BB-4.309-a.webp differ diff --git a/data/images/BB/BB-4.309-b.webp b/data/images/BB/BB-4.309-b.webp new file mode 100644 index 0000000..70c8193 Binary files /dev/null and b/data/images/BB/BB-4.309-b.webp differ diff --git a/data/images/BB/BB-4.309-c.webp b/data/images/BB/BB-4.309-c.webp new file mode 100644 index 0000000..e60f6f4 Binary files /dev/null and b/data/images/BB/BB-4.309-c.webp differ diff --git a/data/images/BB/BB-4.310-a.webp b/data/images/BB/BB-4.310-a.webp new file mode 100644 index 0000000..ed0509a Binary files /dev/null and b/data/images/BB/BB-4.310-a.webp differ diff --git a/data/images/BB/BB-5.201-a.webp b/data/images/BB/BB-5.201-a.webp new file mode 100644 index 0000000..9740be0 Binary files /dev/null and b/data/images/BB/BB-5.201-a.webp differ diff --git a/data/images/BB/BB-5.201-b.webp b/data/images/BB/BB-5.201-b.webp new file mode 100644 index 0000000..9f0e49f Binary files /dev/null and b/data/images/BB/BB-5.201-b.webp differ diff --git a/data/images/BB/BB-5.202-a.webp b/data/images/BB/BB-5.202-a.webp new file mode 100644 index 0000000..be68dca Binary files /dev/null and b/data/images/BB/BB-5.202-a.webp differ diff --git a/data/images/BB/BB-5.204-a.webp b/data/images/BB/BB-5.204-a.webp new file mode 100644 index 0000000..870364b Binary files /dev/null and b/data/images/BB/BB-5.204-a.webp differ diff --git a/data/images/BB/BB-5.204-b.webp b/data/images/BB/BB-5.204-b.webp new file mode 100644 index 0000000..52390bc Binary files /dev/null and b/data/images/BB/BB-5.204-b.webp differ diff --git a/data/images/BB/BB-5.206-a.webp b/data/images/BB/BB-5.206-a.webp new file mode 100644 index 0000000..0e1fde0 Binary files /dev/null and b/data/images/BB/BB-5.206-a.webp differ diff --git a/data/images/BB/BB-5.206-b.webp b/data/images/BB/BB-5.206-b.webp new file mode 100644 index 0000000..f5c4fb7 Binary files /dev/null and b/data/images/BB/BB-5.206-b.webp differ diff --git a/data/images/BB/BB-5.207-a.webp b/data/images/BB/BB-5.207-a.webp new file mode 100644 index 0000000..783fa12 Binary files /dev/null and b/data/images/BB/BB-5.207-a.webp differ diff --git a/data/images/BB/BB-5.207-b.webp b/data/images/BB/BB-5.207-b.webp new file mode 100644 index 0000000..d662050 Binary files /dev/null and b/data/images/BB/BB-5.207-b.webp differ diff --git a/data/images/BB/BB-5.208-a.webp b/data/images/BB/BB-5.208-a.webp new file mode 100644 index 0000000..d295db4 Binary files /dev/null and b/data/images/BB/BB-5.208-a.webp differ diff --git a/data/images/BB/BB-5.301-a.webp b/data/images/BB/BB-5.301-a.webp new file mode 100644 index 0000000..31c667d Binary files /dev/null and b/data/images/BB/BB-5.301-a.webp differ diff --git a/data/images/BB/BB-5.302-a.webp b/data/images/BB/BB-5.302-a.webp new file mode 100644 index 0000000..bc030e1 Binary files /dev/null and b/data/images/BB/BB-5.302-a.webp differ diff --git a/data/images/BB/BB-5.303-a.webp b/data/images/BB/BB-5.303-a.webp new file mode 100644 index 0000000..d81c38c Binary files /dev/null and b/data/images/BB/BB-5.303-a.webp differ diff --git a/data/images/BB/BB-5.304-a.webp b/data/images/BB/BB-5.304-a.webp new file mode 100644 index 0000000..91bc3aa Binary files /dev/null and b/data/images/BB/BB-5.304-a.webp differ diff --git a/data/images/BB/BB-5.306-a.webp b/data/images/BB/BB-5.306-a.webp new file mode 100644 index 0000000..68eaa48 Binary files /dev/null and b/data/images/BB/BB-5.306-a.webp differ diff --git a/data/images/BB/BB-5.307-a.webp b/data/images/BB/BB-5.307-a.webp new file mode 100644 index 0000000..ef9fa33 Binary files /dev/null and b/data/images/BB/BB-5.307-a.webp differ diff --git a/data/images/BB/BB-5.307-b.webp b/data/images/BB/BB-5.307-b.webp new file mode 100644 index 0000000..92f60bc Binary files /dev/null and b/data/images/BB/BB-5.307-b.webp differ diff --git a/data/images/BB/BB-5.310-a.webp b/data/images/BB/BB-5.310-a.webp new file mode 100644 index 0000000..dddd392 Binary files /dev/null and b/data/images/BB/BB-5.310-a.webp differ diff --git a/data/images/BB/BB-5.312-a.webp b/data/images/BB/BB-5.312-a.webp new file mode 100644 index 0000000..57e7edc Binary files /dev/null and b/data/images/BB/BB-5.312-a.webp differ diff --git a/data/images/BB/BB-5.312-b.webp b/data/images/BB/BB-5.312-b.webp new file mode 100644 index 0000000..c1e8842 Binary files /dev/null and b/data/images/BB/BB-5.312-b.webp differ diff --git a/data/images/BB/BB-5.312-c.webp b/data/images/BB/BB-5.312-c.webp new file mode 100644 index 0000000..7ecbbbd Binary files /dev/null and b/data/images/BB/BB-5.312-c.webp differ diff --git a/data/images/BB/BB-5.313-a.webp b/data/images/BB/BB-5.313-a.webp new file mode 100644 index 0000000..0987f40 Binary files /dev/null and b/data/images/BB/BB-5.313-a.webp differ diff --git a/data/images/BB/BB-5.313-b.webp b/data/images/BB/BB-5.313-b.webp new file mode 100644 index 0000000..5a60470 Binary files /dev/null and b/data/images/BB/BB-5.313-b.webp differ diff --git a/data/images/BB/BB-5.313-c.webp b/data/images/BB/BB-5.313-c.webp new file mode 100644 index 0000000..6f41f9f Binary files /dev/null and b/data/images/BB/BB-5.313-c.webp differ diff --git a/data/images/BB/BB-5.401-a.webp b/data/images/BB/BB-5.401-a.webp new file mode 100644 index 0000000..068f10d Binary files /dev/null and b/data/images/BB/BB-5.401-a.webp differ diff --git a/data/images/BB/BB-5.401-b.webp b/data/images/BB/BB-5.401-b.webp new file mode 100644 index 0000000..51f241e Binary files /dev/null and b/data/images/BB/BB-5.401-b.webp differ diff --git a/data/images/BB/BB-5.402-a.webp b/data/images/BB/BB-5.402-a.webp new file mode 100644 index 0000000..9ed66ab Binary files /dev/null and b/data/images/BB/BB-5.402-a.webp differ diff --git a/data/images/BB/BB-5.403-a.webp b/data/images/BB/BB-5.403-a.webp new file mode 100644 index 0000000..dc82e5f Binary files /dev/null and b/data/images/BB/BB-5.403-a.webp differ diff --git a/data/images/BB/BB-5.404-a.webp b/data/images/BB/BB-5.404-a.webp new file mode 100644 index 0000000..dd49e28 Binary files /dev/null and b/data/images/BB/BB-5.404-a.webp differ diff --git a/data/images/BB/BB-5.405-a.webp b/data/images/BB/BB-5.405-a.webp new file mode 100644 index 0000000..b247448 Binary files /dev/null and b/data/images/BB/BB-5.405-a.webp differ diff --git a/data/images/BB/BB-5.405-b.webp b/data/images/BB/BB-5.405-b.webp new file mode 100644 index 0000000..0ca3045 Binary files /dev/null and b/data/images/BB/BB-5.405-b.webp differ diff --git a/data/images/BB/BB-5.405-c.webp b/data/images/BB/BB-5.405-c.webp new file mode 100644 index 0000000..0936281 Binary files /dev/null and b/data/images/BB/BB-5.405-c.webp differ diff --git a/data/images/BB/BB-5.406-a.webp b/data/images/BB/BB-5.406-a.webp new file mode 100644 index 0000000..d1b6248 Binary files /dev/null and b/data/images/BB/BB-5.406-a.webp differ diff --git a/data/images/BB/BB-5.407-a.webp b/data/images/BB/BB-5.407-a.webp new file mode 100644 index 0000000..2f23421 Binary files /dev/null and b/data/images/BB/BB-5.407-a.webp differ diff --git a/data/images/BB/BB-5.408-a.webp b/data/images/BB/BB-5.408-a.webp new file mode 100644 index 0000000..078dd2d Binary files /dev/null and b/data/images/BB/BB-5.408-a.webp differ diff --git a/data/images/BB/BB-5.408-b.webp b/data/images/BB/BB-5.408-b.webp new file mode 100644 index 0000000..352d796 Binary files /dev/null and b/data/images/BB/BB-5.408-b.webp differ diff --git a/data/images/BB/BB-5.409-a.webp b/data/images/BB/BB-5.409-a.webp new file mode 100644 index 0000000..4433c78 Binary files /dev/null and b/data/images/BB/BB-5.409-a.webp differ diff --git a/data/images/BB/BB-5.409-b.webp b/data/images/BB/BB-5.409-b.webp new file mode 100644 index 0000000..7dfdd30 Binary files /dev/null and b/data/images/BB/BB-5.409-b.webp differ diff --git a/data/images/BB/BB-5.410-a.webp b/data/images/BB/BB-5.410-a.webp new file mode 100644 index 0000000..2d8329e Binary files /dev/null and b/data/images/BB/BB-5.410-a.webp differ diff --git a/data/images/BB/BB-5.411-a.webp b/data/images/BB/BB-5.411-a.webp new file mode 100644 index 0000000..2b9395a Binary files /dev/null and b/data/images/BB/BB-5.411-a.webp differ diff --git a/data/images/BB/BB-5.501-a.webp b/data/images/BB/BB-5.501-a.webp new file mode 100644 index 0000000..1ffbede Binary files /dev/null and b/data/images/BB/BB-5.501-a.webp differ diff --git a/data/images/BB/BB-5.505-a.webp b/data/images/BB/BB-5.505-a.webp new file mode 100644 index 0000000..6ef3f45 Binary files /dev/null and b/data/images/BB/BB-5.505-a.webp differ diff --git a/data/images/BB/BB-5.508-a.webp b/data/images/BB/BB-5.508-a.webp new file mode 100644 index 0000000..fe4f2ae Binary files /dev/null and b/data/images/BB/BB-5.508-a.webp differ diff --git a/data/images/BB/BB-5.508-b.webp b/data/images/BB/BB-5.508-b.webp new file mode 100644 index 0000000..c8f2b3e Binary files /dev/null and b/data/images/BB/BB-5.508-b.webp differ diff --git a/data/images/BB/BB-5.508-c.webp b/data/images/BB/BB-5.508-c.webp new file mode 100644 index 0000000..04f810e Binary files /dev/null and b/data/images/BB/BB-5.508-c.webp differ diff --git a/data/images/BB/BB-5.510-a.webp b/data/images/BB/BB-5.510-a.webp new file mode 100644 index 0000000..0cb283b Binary files /dev/null and b/data/images/BB/BB-5.510-a.webp differ diff --git a/data/images/BB/BB-5.511-a.webp b/data/images/BB/BB-5.511-a.webp new file mode 100644 index 0000000..c478d27 Binary files /dev/null and b/data/images/BB/BB-5.511-a.webp differ diff --git a/data/images/BB/BB-5.512-a.webp b/data/images/BB/BB-5.512-a.webp new file mode 100644 index 0000000..12e1cd1 Binary files /dev/null and b/data/images/BB/BB-5.512-a.webp differ diff --git a/data/images/BB/BB-5.611-a.webp b/data/images/BB/BB-5.611-a.webp new file mode 100644 index 0000000..00b24e0 Binary files /dev/null and b/data/images/BB/BB-5.611-a.webp differ diff --git a/data/images/BB/BB-5.612-a.webp b/data/images/BB/BB-5.612-a.webp new file mode 100644 index 0000000..a9303fd Binary files /dev/null and b/data/images/BB/BB-5.612-a.webp differ diff --git a/data/images/BB/BB-5.613-a.webp b/data/images/BB/BB-5.613-a.webp new file mode 100644 index 0000000..1e2b96f Binary files /dev/null and b/data/images/BB/BB-5.613-a.webp differ diff --git a/data/images/BB/BB-5.613-b.webp b/data/images/BB/BB-5.613-b.webp new file mode 100644 index 0000000..d5181ce Binary files /dev/null and b/data/images/BB/BB-5.613-b.webp differ diff --git a/data/images/BB/BB-5.712-a.webp b/data/images/BB/BB-5.712-a.webp new file mode 100644 index 0000000..430ea55 Binary files /dev/null and b/data/images/BB/BB-5.712-a.webp differ diff --git a/data/images/BB/BB-6.101-a.webp b/data/images/BB/BB-6.101-a.webp new file mode 100644 index 0000000..e988ba3 Binary files /dev/null and b/data/images/BB/BB-6.101-a.webp differ diff --git a/data/images/BB/BB-6.102-a.webp b/data/images/BB/BB-6.102-a.webp new file mode 100644 index 0000000..fc8f1d1 Binary files /dev/null and b/data/images/BB/BB-6.102-a.webp differ diff --git a/data/images/BB/BB-6.102-b.webp b/data/images/BB/BB-6.102-b.webp new file mode 100644 index 0000000..e783b29 Binary files /dev/null and b/data/images/BB/BB-6.102-b.webp differ diff --git a/data/images/BB/BB-6.104-a.webp b/data/images/BB/BB-6.104-a.webp new file mode 100644 index 0000000..0a3427e Binary files /dev/null and b/data/images/BB/BB-6.104-a.webp differ diff --git a/data/images/BB/BB-6.104-b.webp b/data/images/BB/BB-6.104-b.webp new file mode 100644 index 0000000..be502c6 Binary files /dev/null and b/data/images/BB/BB-6.104-b.webp differ diff --git a/data/images/BB/BB-6.106-a.webp b/data/images/BB/BB-6.106-a.webp new file mode 100644 index 0000000..642b057 Binary files /dev/null and b/data/images/BB/BB-6.106-a.webp differ diff --git a/data/images/BB/BB-6.106-b.webp b/data/images/BB/BB-6.106-b.webp new file mode 100644 index 0000000..64b64d3 Binary files /dev/null and b/data/images/BB/BB-6.106-b.webp differ diff --git a/data/images/BB/BB-6.201-a.webp b/data/images/BB/BB-6.201-a.webp new file mode 100644 index 0000000..8aad21f Binary files /dev/null and b/data/images/BB/BB-6.201-a.webp differ diff --git a/data/images/BB/BB-6.201-b.webp b/data/images/BB/BB-6.201-b.webp new file mode 100644 index 0000000..4746acc Binary files /dev/null and b/data/images/BB/BB-6.201-b.webp differ diff --git a/data/images/BB/BB-6.202-a.webp b/data/images/BB/BB-6.202-a.webp new file mode 100644 index 0000000..c9e9e6b Binary files /dev/null and b/data/images/BB/BB-6.202-a.webp differ diff --git a/data/images/BB/BB-6.202-b.webp b/data/images/BB/BB-6.202-b.webp new file mode 100644 index 0000000..1d0df28 Binary files /dev/null and b/data/images/BB/BB-6.202-b.webp differ diff --git a/data/images/BB/BB-6.203-a.webp b/data/images/BB/BB-6.203-a.webp new file mode 100644 index 0000000..471cef2 Binary files /dev/null and b/data/images/BB/BB-6.203-a.webp differ diff --git a/data/images/BB/BB-6.204-a.webp b/data/images/BB/BB-6.204-a.webp new file mode 100644 index 0000000..7467bdb Binary files /dev/null and b/data/images/BB/BB-6.204-a.webp differ diff --git a/data/images/BB/BB-6.204-b.webp b/data/images/BB/BB-6.204-b.webp new file mode 100644 index 0000000..0bc458a Binary files /dev/null and b/data/images/BB/BB-6.204-b.webp differ diff --git a/data/images/BB/BB-6.206-a.webp b/data/images/BB/BB-6.206-a.webp new file mode 100644 index 0000000..b6c91b2 Binary files /dev/null and b/data/images/BB/BB-6.206-a.webp differ diff --git a/data/images/BB/BB-6.206-b.webp b/data/images/BB/BB-6.206-b.webp new file mode 100644 index 0000000..25bbdac Binary files /dev/null and b/data/images/BB/BB-6.206-b.webp differ diff --git a/data/images/BB/BB-6.207-a.webp b/data/images/BB/BB-6.207-a.webp new file mode 100644 index 0000000..bf3bb19 Binary files /dev/null and b/data/images/BB/BB-6.207-a.webp differ diff --git a/data/images/BB/BB-6.207-b.webp b/data/images/BB/BB-6.207-b.webp new file mode 100644 index 0000000..cd96f97 Binary files /dev/null and b/data/images/BB/BB-6.207-b.webp differ diff --git a/data/images/BB/BB-6.301-a.webp b/data/images/BB/BB-6.301-a.webp new file mode 100644 index 0000000..6f88214 Binary files /dev/null and b/data/images/BB/BB-6.301-a.webp differ diff --git a/data/images/BB/BB-6.302-a.webp b/data/images/BB/BB-6.302-a.webp new file mode 100644 index 0000000..dc1a871 Binary files /dev/null and b/data/images/BB/BB-6.302-a.webp differ diff --git a/data/images/BB/BB-6.302-b.webp b/data/images/BB/BB-6.302-b.webp new file mode 100644 index 0000000..93ca6ad Binary files /dev/null and b/data/images/BB/BB-6.302-b.webp differ diff --git a/data/images/BB/BB-6.303-a.webp b/data/images/BB/BB-6.303-a.webp new file mode 100644 index 0000000..db12b51 Binary files /dev/null and b/data/images/BB/BB-6.303-a.webp differ diff --git a/data/images/BB/BB-6.304-a.webp b/data/images/BB/BB-6.304-a.webp new file mode 100644 index 0000000..1acc155 Binary files /dev/null and b/data/images/BB/BB-6.304-a.webp differ diff --git a/data/images/BB/BB-6.304-b.webp b/data/images/BB/BB-6.304-b.webp new file mode 100644 index 0000000..ff6ab55 Binary files /dev/null and b/data/images/BB/BB-6.304-b.webp differ diff --git a/data/images/BB/BB-6.306-a.webp b/data/images/BB/BB-6.306-a.webp new file mode 100644 index 0000000..3b6a3ba Binary files /dev/null and b/data/images/BB/BB-6.306-a.webp differ diff --git a/data/images/BB/BB-6.306-b.webp b/data/images/BB/BB-6.306-b.webp new file mode 100644 index 0000000..f438e5d Binary files /dev/null and b/data/images/BB/BB-6.306-b.webp differ diff --git a/data/images/BB/BB-6.307-a.webp b/data/images/BB/BB-6.307-a.webp new file mode 100644 index 0000000..04c5504 Binary files /dev/null and b/data/images/BB/BB-6.307-a.webp differ diff --git a/data/images/BB/BB-6.307-b.webp b/data/images/BB/BB-6.307-b.webp new file mode 100644 index 0000000..7545a38 Binary files /dev/null and b/data/images/BB/BB-6.307-b.webp differ diff --git a/data/images/BB/BB-6.402-a.webp b/data/images/BB/BB-6.402-a.webp new file mode 100644 index 0000000..18da772 Binary files /dev/null and b/data/images/BB/BB-6.402-a.webp differ diff --git a/data/images/BB/BB-6.403-a.webp b/data/images/BB/BB-6.403-a.webp new file mode 100644 index 0000000..acf4155 Binary files /dev/null and b/data/images/BB/BB-6.403-a.webp differ diff --git a/data/images/BB/BB-6.404-a.webp b/data/images/BB/BB-6.404-a.webp new file mode 100644 index 0000000..cc2ee00 Binary files /dev/null and b/data/images/BB/BB-6.404-a.webp differ diff --git a/data/images/BB/BB-6.405-a.webp b/data/images/BB/BB-6.405-a.webp new file mode 100644 index 0000000..04a7035 Binary files /dev/null and b/data/images/BB/BB-6.405-a.webp differ diff --git a/data/images/BB/BB-6.406-a.webp b/data/images/BB/BB-6.406-a.webp new file mode 100644 index 0000000..3382570 Binary files /dev/null and b/data/images/BB/BB-6.406-a.webp differ diff --git a/data/images/BB/BB-6.407-a.webp b/data/images/BB/BB-6.407-a.webp new file mode 100644 index 0000000..bac7ad2 Binary files /dev/null and b/data/images/BB/BB-6.407-a.webp differ diff --git a/data/images/BB/BB-6.407-b.webp b/data/images/BB/BB-6.407-b.webp new file mode 100644 index 0000000..a8f7cab Binary files /dev/null and b/data/images/BB/BB-6.407-b.webp differ diff --git a/data/images/BB/BB-6.501-a.webp b/data/images/BB/BB-6.501-a.webp new file mode 100644 index 0000000..d28a218 Binary files /dev/null and b/data/images/BB/BB-6.501-a.webp differ diff --git a/data/images/BB/BB-6.505-a.webp b/data/images/BB/BB-6.505-a.webp new file mode 100644 index 0000000..23e49f6 Binary files /dev/null and b/data/images/BB/BB-6.505-a.webp differ diff --git a/data/images/BB/BB-6.507-a.webp b/data/images/BB/BB-6.507-a.webp new file mode 100644 index 0000000..8a62df6 Binary files /dev/null and b/data/images/BB/BB-6.507-a.webp differ diff --git a/data/images/BB/BB-6.602-a.webp b/data/images/BB/BB-6.602-a.webp new file mode 100644 index 0000000..c6efd9a Binary files /dev/null and b/data/images/BB/BB-6.602-a.webp differ diff --git a/data/images/BB/BB-6.604-a.webp b/data/images/BB/BB-6.604-a.webp new file mode 100644 index 0000000..04989d0 Binary files /dev/null and b/data/images/BB/BB-6.604-a.webp differ diff --git a/data/images/BB/BB-6.606-a.webp b/data/images/BB/BB-6.606-a.webp new file mode 100644 index 0000000..ebf2a47 Binary files /dev/null and b/data/images/BB/BB-6.606-a.webp differ diff --git a/data/images/BB/BB-6.703-a.webp b/data/images/BB/BB-6.703-a.webp new file mode 100644 index 0000000..4d8a767 Binary files /dev/null and b/data/images/BB/BB-6.703-a.webp differ diff --git a/data/images/BB/BB-6.705-a.webp b/data/images/BB/BB-6.705-a.webp new file mode 100644 index 0000000..53db7b9 Binary files /dev/null and b/data/images/BB/BB-6.705-a.webp differ diff --git a/data/images/BB/BB-6.705-b.webp b/data/images/BB/BB-6.705-b.webp new file mode 100644 index 0000000..b2ea8fe Binary files /dev/null and b/data/images/BB/BB-6.705-b.webp differ diff --git a/data/images/BB/BB-6.805-a.webp b/data/images/BB/BB-6.805-a.webp new file mode 100644 index 0000000..272e2da Binary files /dev/null and b/data/images/BB/BB-6.805-a.webp differ diff --git a/data/images/FX/FX-1.101-a.webp b/data/images/FX/FX-1.101-a.webp new file mode 100644 index 0000000..18048bd Binary files /dev/null and b/data/images/FX/FX-1.101-a.webp differ diff --git a/data/images/FX/FX-1.103-a.webp b/data/images/FX/FX-1.103-a.webp new file mode 100644 index 0000000..f4055bd Binary files /dev/null and b/data/images/FX/FX-1.103-a.webp differ diff --git a/data/images/FX/FX-1.104-a.webp b/data/images/FX/FX-1.104-a.webp new file mode 100644 index 0000000..0244bc4 Binary files /dev/null and b/data/images/FX/FX-1.104-a.webp differ diff --git a/data/images/FX/FX-1.104-b.webp b/data/images/FX/FX-1.104-b.webp new file mode 100644 index 0000000..2fceda3 Binary files /dev/null and b/data/images/FX/FX-1.104-b.webp differ diff --git a/data/images/FX/FX-1.105-a.webp b/data/images/FX/FX-1.105-a.webp new file mode 100644 index 0000000..590282e Binary files /dev/null and b/data/images/FX/FX-1.105-a.webp differ diff --git a/data/images/FX/FX-1.106-a.webp b/data/images/FX/FX-1.106-a.webp new file mode 100644 index 0000000..8fc98c9 Binary files /dev/null and b/data/images/FX/FX-1.106-a.webp differ diff --git a/data/images/FX/FX-1.107-a.webp b/data/images/FX/FX-1.107-a.webp new file mode 100644 index 0000000..f7a91ce Binary files /dev/null and b/data/images/FX/FX-1.107-a.webp differ diff --git a/data/images/FX/FX-1.107-b.webp b/data/images/FX/FX-1.107-b.webp new file mode 100644 index 0000000..664484d Binary files /dev/null and b/data/images/FX/FX-1.107-b.webp differ diff --git a/data/images/FX/FX-1.108-a.webp b/data/images/FX/FX-1.108-a.webp new file mode 100644 index 0000000..accb028 Binary files /dev/null and b/data/images/FX/FX-1.108-a.webp differ diff --git a/data/images/FX/FX-1.108-b.webp b/data/images/FX/FX-1.108-b.webp new file mode 100644 index 0000000..31609fd Binary files /dev/null and b/data/images/FX/FX-1.108-b.webp differ diff --git a/data/images/FX/FX-1.108-c.webp b/data/images/FX/FX-1.108-c.webp new file mode 100644 index 0000000..58e1f91 Binary files /dev/null and b/data/images/FX/FX-1.108-c.webp differ diff --git a/data/images/FX/FX-1.109-a.webp b/data/images/FX/FX-1.109-a.webp new file mode 100644 index 0000000..8c8047b Binary files /dev/null and b/data/images/FX/FX-1.109-a.webp differ diff --git a/data/images/FX/FX-1.109-b.webp b/data/images/FX/FX-1.109-b.webp new file mode 100644 index 0000000..508369a Binary files /dev/null and b/data/images/FX/FX-1.109-b.webp differ diff --git a/data/images/FX/FX-1.109-c.webp b/data/images/FX/FX-1.109-c.webp new file mode 100644 index 0000000..8f70a77 Binary files /dev/null and b/data/images/FX/FX-1.109-c.webp differ diff --git a/data/images/FX/FX-1.109-d.webp b/data/images/FX/FX-1.109-d.webp new file mode 100644 index 0000000..ea472c5 Binary files /dev/null and b/data/images/FX/FX-1.109-d.webp differ diff --git a/data/images/FX/FX-1.110-a.webp b/data/images/FX/FX-1.110-a.webp new file mode 100644 index 0000000..e923c52 Binary files /dev/null and b/data/images/FX/FX-1.110-a.webp differ diff --git a/data/images/FX/FX-1.111-a.webp b/data/images/FX/FX-1.111-a.webp new file mode 100644 index 0000000..d79c92a Binary files /dev/null and b/data/images/FX/FX-1.111-a.webp differ diff --git a/data/images/FX/FX-1.111-b.webp b/data/images/FX/FX-1.111-b.webp new file mode 100644 index 0000000..f8b4fc6 Binary files /dev/null and b/data/images/FX/FX-1.111-b.webp differ diff --git a/data/images/FX/FX-1.112-a.webp b/data/images/FX/FX-1.112-a.webp new file mode 100644 index 0000000..1950faa Binary files /dev/null and b/data/images/FX/FX-1.112-a.webp differ diff --git a/data/images/FX/FX-1.112-b.webp b/data/images/FX/FX-1.112-b.webp new file mode 100644 index 0000000..74412d3 Binary files /dev/null and b/data/images/FX/FX-1.112-b.webp differ diff --git a/data/images/FX/FX-1.114-a.webp b/data/images/FX/FX-1.114-a.webp new file mode 100644 index 0000000..f626ead Binary files /dev/null and b/data/images/FX/FX-1.114-a.webp differ diff --git a/data/images/FX/FX-1.201-a.webp b/data/images/FX/FX-1.201-a.webp new file mode 100644 index 0000000..26ab71d Binary files /dev/null and b/data/images/FX/FX-1.201-a.webp differ diff --git a/data/images/FX/FX-1.202-a.webp b/data/images/FX/FX-1.202-a.webp new file mode 100644 index 0000000..60694ae Binary files /dev/null and b/data/images/FX/FX-1.202-a.webp differ diff --git a/data/images/FX/FX-1.202-b.webp b/data/images/FX/FX-1.202-b.webp new file mode 100644 index 0000000..9d30607 Binary files /dev/null and b/data/images/FX/FX-1.202-b.webp differ diff --git a/data/images/FX/FX-1.203-a.webp b/data/images/FX/FX-1.203-a.webp new file mode 100644 index 0000000..27852c2 Binary files /dev/null and b/data/images/FX/FX-1.203-a.webp differ diff --git a/data/images/FX/FX-1.203-b.webp b/data/images/FX/FX-1.203-b.webp new file mode 100644 index 0000000..6d849e2 Binary files /dev/null and b/data/images/FX/FX-1.203-b.webp differ diff --git a/data/images/FX/FX-1.203-c.webp b/data/images/FX/FX-1.203-c.webp new file mode 100644 index 0000000..4d1c114 Binary files /dev/null and b/data/images/FX/FX-1.203-c.webp differ diff --git a/data/images/FX/FX-1.204-a.webp b/data/images/FX/FX-1.204-a.webp new file mode 100644 index 0000000..16f2da5 Binary files /dev/null and b/data/images/FX/FX-1.204-a.webp differ diff --git a/data/images/FX/FX-1.204-b.webp b/data/images/FX/FX-1.204-b.webp new file mode 100644 index 0000000..301f305 Binary files /dev/null and b/data/images/FX/FX-1.204-b.webp differ diff --git a/data/images/FX/FX-1.205-a.webp b/data/images/FX/FX-1.205-a.webp new file mode 100644 index 0000000..d405e9a Binary files /dev/null and b/data/images/FX/FX-1.205-a.webp differ diff --git a/data/images/FX/FX-1.206-a.webp b/data/images/FX/FX-1.206-a.webp new file mode 100644 index 0000000..538e94c Binary files /dev/null and b/data/images/FX/FX-1.206-a.webp differ diff --git a/data/images/FX/FX-1.207-a.webp b/data/images/FX/FX-1.207-a.webp new file mode 100644 index 0000000..432306c Binary files /dev/null and b/data/images/FX/FX-1.207-a.webp differ diff --git a/data/images/FX/FX-1.207-b.webp b/data/images/FX/FX-1.207-b.webp new file mode 100644 index 0000000..a0fe15d Binary files /dev/null and b/data/images/FX/FX-1.207-b.webp differ diff --git a/data/images/FX/FX-1.207-c.webp b/data/images/FX/FX-1.207-c.webp new file mode 100644 index 0000000..332b32a Binary files /dev/null and b/data/images/FX/FX-1.207-c.webp differ diff --git a/data/images/FX/FX-1.208-a.webp b/data/images/FX/FX-1.208-a.webp new file mode 100644 index 0000000..bf8ef3d Binary files /dev/null and b/data/images/FX/FX-1.208-a.webp differ diff --git a/data/images/FX/FX-1.208-b.webp b/data/images/FX/FX-1.208-b.webp new file mode 100644 index 0000000..632d683 Binary files /dev/null and b/data/images/FX/FX-1.208-b.webp differ diff --git a/data/images/FX/FX-1.209-a.webp b/data/images/FX/FX-1.209-a.webp new file mode 100644 index 0000000..a926b89 Binary files /dev/null and b/data/images/FX/FX-1.209-a.webp differ diff --git a/data/images/FX/FX-1.209-b.webp b/data/images/FX/FX-1.209-b.webp new file mode 100644 index 0000000..8ac95f5 Binary files /dev/null and b/data/images/FX/FX-1.209-b.webp differ diff --git a/data/images/FX/FX-1.209-c.webp b/data/images/FX/FX-1.209-c.webp new file mode 100644 index 0000000..b3e53b7 Binary files /dev/null and b/data/images/FX/FX-1.209-c.webp differ diff --git a/data/images/FX/FX-1.209-d.webp b/data/images/FX/FX-1.209-d.webp new file mode 100644 index 0000000..475704e Binary files /dev/null and b/data/images/FX/FX-1.209-d.webp differ diff --git a/data/images/FX/FX-1.211-a.webp b/data/images/FX/FX-1.211-a.webp new file mode 100644 index 0000000..7709da4 Binary files /dev/null and b/data/images/FX/FX-1.211-a.webp differ diff --git a/data/images/FX/FX-1.212-a.webp b/data/images/FX/FX-1.212-a.webp new file mode 100644 index 0000000..5891f65 Binary files /dev/null and b/data/images/FX/FX-1.212-a.webp differ diff --git a/data/images/FX/FX-1.213-a.webp b/data/images/FX/FX-1.213-a.webp new file mode 100644 index 0000000..fafc68e Binary files /dev/null and b/data/images/FX/FX-1.213-a.webp differ diff --git a/data/images/FX/FX-1.214-a.webp b/data/images/FX/FX-1.214-a.webp new file mode 100644 index 0000000..66b6c62 Binary files /dev/null and b/data/images/FX/FX-1.214-a.webp differ diff --git a/data/images/FX/FX-1.214-b.webp b/data/images/FX/FX-1.214-b.webp new file mode 100644 index 0000000..2720cfd Binary files /dev/null and b/data/images/FX/FX-1.214-b.webp differ diff --git a/data/images/FX/FX-1.301-a.webp b/data/images/FX/FX-1.301-a.webp new file mode 100644 index 0000000..4493c3a Binary files /dev/null and b/data/images/FX/FX-1.301-a.webp differ diff --git a/data/images/FX/FX-1.302-a.webp b/data/images/FX/FX-1.302-a.webp new file mode 100644 index 0000000..eb88c5d Binary files /dev/null and b/data/images/FX/FX-1.302-a.webp differ diff --git a/data/images/FX/FX-1.302-b.webp b/data/images/FX/FX-1.302-b.webp new file mode 100644 index 0000000..1602d03 Binary files /dev/null and b/data/images/FX/FX-1.302-b.webp differ diff --git a/data/images/FX/FX-1.302-c.webp b/data/images/FX/FX-1.302-c.webp new file mode 100644 index 0000000..8a3c1a1 Binary files /dev/null and b/data/images/FX/FX-1.302-c.webp differ diff --git a/data/images/FX/FX-1.304-a.webp b/data/images/FX/FX-1.304-a.webp new file mode 100644 index 0000000..0204836 Binary files /dev/null and b/data/images/FX/FX-1.304-a.webp differ diff --git a/data/images/FX/FX-1.304-b.webp b/data/images/FX/FX-1.304-b.webp new file mode 100644 index 0000000..df2ba13 Binary files /dev/null and b/data/images/FX/FX-1.304-b.webp differ diff --git a/data/images/FX/FX-1.305-a.webp b/data/images/FX/FX-1.305-a.webp new file mode 100644 index 0000000..c5aa978 Binary files /dev/null and b/data/images/FX/FX-1.305-a.webp differ diff --git a/data/images/FX/FX-1.306-a.webp b/data/images/FX/FX-1.306-a.webp new file mode 100644 index 0000000..43781b4 Binary files /dev/null and b/data/images/FX/FX-1.306-a.webp differ diff --git a/data/images/FX/FX-1.306-b.webp b/data/images/FX/FX-1.306-b.webp new file mode 100644 index 0000000..044e9e4 Binary files /dev/null and b/data/images/FX/FX-1.306-b.webp differ diff --git a/data/images/FX/FX-1.307-a.webp b/data/images/FX/FX-1.307-a.webp new file mode 100644 index 0000000..1a1051e Binary files /dev/null and b/data/images/FX/FX-1.307-a.webp differ diff --git a/data/images/FX/FX-1.307-b.webp b/data/images/FX/FX-1.307-b.webp new file mode 100644 index 0000000..d4ed2de Binary files /dev/null and b/data/images/FX/FX-1.307-b.webp differ diff --git a/data/images/FX/FX-1.307-c.webp b/data/images/FX/FX-1.307-c.webp new file mode 100644 index 0000000..af9effc Binary files /dev/null and b/data/images/FX/FX-1.307-c.webp differ diff --git a/data/images/FX/FX-1.309-a.webp b/data/images/FX/FX-1.309-a.webp new file mode 100644 index 0000000..5db3877 Binary files /dev/null and b/data/images/FX/FX-1.309-a.webp differ diff --git a/data/images/FX/FX-1.309-b.webp b/data/images/FX/FX-1.309-b.webp new file mode 100644 index 0000000..3529fba Binary files /dev/null and b/data/images/FX/FX-1.309-b.webp differ diff --git a/data/images/FX/FX-1.310-a.webp b/data/images/FX/FX-1.310-a.webp new file mode 100644 index 0000000..1219d21 Binary files /dev/null and b/data/images/FX/FX-1.310-a.webp differ diff --git a/data/images/FX/FX-1.311-a.webp b/data/images/FX/FX-1.311-a.webp new file mode 100644 index 0000000..68dab0a Binary files /dev/null and b/data/images/FX/FX-1.311-a.webp differ diff --git a/data/images/FX/FX-1.313-a.webp b/data/images/FX/FX-1.313-a.webp new file mode 100644 index 0000000..17b606b Binary files /dev/null and b/data/images/FX/FX-1.313-a.webp differ diff --git a/data/images/FX/FX-1.313-b.webp b/data/images/FX/FX-1.313-b.webp new file mode 100644 index 0000000..d63178f Binary files /dev/null and b/data/images/FX/FX-1.313-b.webp differ diff --git a/data/images/FX/FX-1.402-a.webp b/data/images/FX/FX-1.402-a.webp new file mode 100644 index 0000000..df150fd Binary files /dev/null and b/data/images/FX/FX-1.402-a.webp differ diff --git a/data/images/FX/FX-1.404-a.webp b/data/images/FX/FX-1.404-a.webp new file mode 100644 index 0000000..ce9ff73 Binary files /dev/null and b/data/images/FX/FX-1.404-a.webp differ diff --git a/data/images/FX/FX-1.404-b.webp b/data/images/FX/FX-1.404-b.webp new file mode 100644 index 0000000..3e8490e Binary files /dev/null and b/data/images/FX/FX-1.404-b.webp differ diff --git a/data/images/FX/FX-1.405-a.webp b/data/images/FX/FX-1.405-a.webp new file mode 100644 index 0000000..35a96b1 Binary files /dev/null and b/data/images/FX/FX-1.405-a.webp differ diff --git a/data/images/FX/FX-1.407-a.webp b/data/images/FX/FX-1.407-a.webp new file mode 100644 index 0000000..9216102 Binary files /dev/null and b/data/images/FX/FX-1.407-a.webp differ diff --git a/data/images/FX/FX-1.407-b.webp b/data/images/FX/FX-1.407-b.webp new file mode 100644 index 0000000..554674e Binary files /dev/null and b/data/images/FX/FX-1.407-b.webp differ diff --git a/data/images/FX/FX-1.407-c.webp b/data/images/FX/FX-1.407-c.webp new file mode 100644 index 0000000..af86d9c Binary files /dev/null and b/data/images/FX/FX-1.407-c.webp differ diff --git a/data/images/FX/FX-1.409-a.webp b/data/images/FX/FX-1.409-a.webp new file mode 100644 index 0000000..9996e5d Binary files /dev/null and b/data/images/FX/FX-1.409-a.webp differ diff --git a/data/images/FX/FX-1.409-b.webp b/data/images/FX/FX-1.409-b.webp new file mode 100644 index 0000000..1d1ba12 Binary files /dev/null and b/data/images/FX/FX-1.409-b.webp differ diff --git a/data/images/FX/FX-1.514-a.webp b/data/images/FX/FX-1.514-a.webp new file mode 100644 index 0000000..c26bb3e Binary files /dev/null and b/data/images/FX/FX-1.514-a.webp differ diff --git a/data/images/FX/FX-2.101-a.webp b/data/images/FX/FX-2.101-a.webp new file mode 100644 index 0000000..ebc9841 Binary files /dev/null and b/data/images/FX/FX-2.101-a.webp differ diff --git a/data/images/FX/FX-2.201-a.webp b/data/images/FX/FX-2.201-a.webp new file mode 100644 index 0000000..77bbbc1 Binary files /dev/null and b/data/images/FX/FX-2.201-a.webp differ diff --git a/data/images/FX/FX-2.202-a.webp b/data/images/FX/FX-2.202-a.webp new file mode 100644 index 0000000..1c04f33 Binary files /dev/null and b/data/images/FX/FX-2.202-a.webp differ diff --git a/data/images/FX/FX-2.203-a.webp b/data/images/FX/FX-2.203-a.webp new file mode 100644 index 0000000..3c94b5b Binary files /dev/null and b/data/images/FX/FX-2.203-a.webp differ diff --git a/data/images/FX/FX-2.204-a.webp b/data/images/FX/FX-2.204-a.webp new file mode 100644 index 0000000..d23349b Binary files /dev/null and b/data/images/FX/FX-2.204-a.webp differ diff --git a/data/images/FX/FX-2.205-a.webp b/data/images/FX/FX-2.205-a.webp new file mode 100644 index 0000000..2adb3ef Binary files /dev/null and b/data/images/FX/FX-2.205-a.webp differ diff --git a/data/images/FX/FX-2.206-a.webp b/data/images/FX/FX-2.206-a.webp new file mode 100644 index 0000000..a9457b5 Binary files /dev/null and b/data/images/FX/FX-2.206-a.webp differ diff --git a/data/images/FX/FX-2.207-a.webp b/data/images/FX/FX-2.207-a.webp new file mode 100644 index 0000000..05fa2e8 Binary files /dev/null and b/data/images/FX/FX-2.207-a.webp differ diff --git a/data/images/FX/FX-2.208-a.webp b/data/images/FX/FX-2.208-a.webp new file mode 100644 index 0000000..2f7ed35 Binary files /dev/null and b/data/images/FX/FX-2.208-a.webp differ diff --git a/data/images/FX/FX-2.208-b.webp b/data/images/FX/FX-2.208-b.webp new file mode 100644 index 0000000..051dce1 Binary files /dev/null and b/data/images/FX/FX-2.208-b.webp differ diff --git a/data/images/FX/FX-2.301-a.webp b/data/images/FX/FX-2.301-a.webp new file mode 100644 index 0000000..2898ec0 Binary files /dev/null and b/data/images/FX/FX-2.301-a.webp differ diff --git a/data/images/FX/FX-2.307-a.webp b/data/images/FX/FX-2.307-a.webp new file mode 100644 index 0000000..ea5b52b Binary files /dev/null and b/data/images/FX/FX-2.307-a.webp differ diff --git a/data/images/FX/FX-2.402-a.webp b/data/images/FX/FX-2.402-a.webp new file mode 100644 index 0000000..80ab8a5 Binary files /dev/null and b/data/images/FX/FX-2.402-a.webp differ diff --git a/data/images/FX/FX-2.403-a.webp b/data/images/FX/FX-2.403-a.webp new file mode 100644 index 0000000..da256e3 Binary files /dev/null and b/data/images/FX/FX-2.403-a.webp differ diff --git a/data/images/FX/FX-2.404-a.webp b/data/images/FX/FX-2.404-a.webp new file mode 100644 index 0000000..fcc26c5 Binary files /dev/null and b/data/images/FX/FX-2.404-a.webp differ diff --git a/data/images/FX/FX-2.404-b.webp b/data/images/FX/FX-2.404-b.webp new file mode 100644 index 0000000..4daeb90 Binary files /dev/null and b/data/images/FX/FX-2.404-b.webp differ diff --git a/data/images/FX/FX-2.407-a.webp b/data/images/FX/FX-2.407-a.webp new file mode 100644 index 0000000..302a97e Binary files /dev/null and b/data/images/FX/FX-2.407-a.webp differ diff --git a/data/images/FX/FX-2.501-a.webp b/data/images/FX/FX-2.501-a.webp new file mode 100644 index 0000000..54504d9 Binary files /dev/null and b/data/images/FX/FX-2.501-a.webp differ diff --git a/data/images/FX/FX-2.503-a.webp b/data/images/FX/FX-2.503-a.webp new file mode 100644 index 0000000..34a8e44 Binary files /dev/null and b/data/images/FX/FX-2.503-a.webp differ diff --git a/data/images/FX/FX-2.507-a.webp b/data/images/FX/FX-2.507-a.webp new file mode 100644 index 0000000..da9077a Binary files /dev/null and b/data/images/FX/FX-2.507-a.webp differ diff --git a/data/images/FX/FX-3.101-a.webp b/data/images/FX/FX-3.101-a.webp new file mode 100644 index 0000000..43f9afd Binary files /dev/null and b/data/images/FX/FX-3.101-a.webp differ diff --git a/data/images/FX/FX-3.101-b.webp b/data/images/FX/FX-3.101-b.webp new file mode 100644 index 0000000..099ead7 Binary files /dev/null and b/data/images/FX/FX-3.101-b.webp differ diff --git a/data/images/FX/FX-3.101-c.webp b/data/images/FX/FX-3.101-c.webp new file mode 100644 index 0000000..443b235 Binary files /dev/null and b/data/images/FX/FX-3.101-c.webp differ diff --git a/data/images/FX/FX-3.102-a.webp b/data/images/FX/FX-3.102-a.webp new file mode 100644 index 0000000..83ac7f6 Binary files /dev/null and b/data/images/FX/FX-3.102-a.webp differ diff --git a/data/images/FX/FX-3.103-a.webp b/data/images/FX/FX-3.103-a.webp new file mode 100644 index 0000000..b5a9983 Binary files /dev/null and b/data/images/FX/FX-3.103-a.webp differ diff --git a/data/images/FX/FX-3.103-b.webp b/data/images/FX/FX-3.103-b.webp new file mode 100644 index 0000000..6a0d5de Binary files /dev/null and b/data/images/FX/FX-3.103-b.webp differ diff --git a/data/images/FX/FX-3.104-a.webp b/data/images/FX/FX-3.104-a.webp new file mode 100644 index 0000000..3e17e95 Binary files /dev/null and b/data/images/FX/FX-3.104-a.webp differ diff --git a/data/images/FX/FX-3.104-b.webp b/data/images/FX/FX-3.104-b.webp new file mode 100644 index 0000000..923196d Binary files /dev/null and b/data/images/FX/FX-3.104-b.webp differ diff --git a/data/images/FX/FX-3.105-a.webp b/data/images/FX/FX-3.105-a.webp new file mode 100644 index 0000000..0b1586f Binary files /dev/null and b/data/images/FX/FX-3.105-a.webp differ diff --git a/data/images/FX/FX-3.105-b.webp b/data/images/FX/FX-3.105-b.webp new file mode 100644 index 0000000..e83e1f5 Binary files /dev/null and b/data/images/FX/FX-3.105-b.webp differ diff --git a/data/images/FX/FX-3.105-c.webp b/data/images/FX/FX-3.105-c.webp new file mode 100644 index 0000000..e20447e Binary files /dev/null and b/data/images/FX/FX-3.105-c.webp differ diff --git a/data/images/FX/FX-3.106-a.webp b/data/images/FX/FX-3.106-a.webp new file mode 100644 index 0000000..12ffb4e Binary files /dev/null and b/data/images/FX/FX-3.106-a.webp differ diff --git a/data/images/FX/FX-3.107-a.webp b/data/images/FX/FX-3.107-a.webp new file mode 100644 index 0000000..47907bf Binary files /dev/null and b/data/images/FX/FX-3.107-a.webp differ diff --git a/data/images/FX/FX-3.107-b.webp b/data/images/FX/FX-3.107-b.webp new file mode 100644 index 0000000..7a53089 Binary files /dev/null and b/data/images/FX/FX-3.107-b.webp differ diff --git a/data/images/FX/FX-3.107-c.webp b/data/images/FX/FX-3.107-c.webp new file mode 100644 index 0000000..19ad87b Binary files /dev/null and b/data/images/FX/FX-3.107-c.webp differ diff --git a/data/images/FX/FX-3.107-d.webp b/data/images/FX/FX-3.107-d.webp new file mode 100644 index 0000000..fe829cd Binary files /dev/null and b/data/images/FX/FX-3.107-d.webp differ diff --git a/data/images/FX/FX-3.201-a.webp b/data/images/FX/FX-3.201-a.webp new file mode 100644 index 0000000..ed1901a Binary files /dev/null and b/data/images/FX/FX-3.201-a.webp differ diff --git a/data/images/FX/FX-3.201-b.webp b/data/images/FX/FX-3.201-b.webp new file mode 100644 index 0000000..77bfc4e Binary files /dev/null and b/data/images/FX/FX-3.201-b.webp differ diff --git a/data/images/FX/FX-3.201-c.webp b/data/images/FX/FX-3.201-c.webp new file mode 100644 index 0000000..12e91db Binary files /dev/null and b/data/images/FX/FX-3.201-c.webp differ diff --git a/data/images/FX/FX-3.203-a.webp b/data/images/FX/FX-3.203-a.webp new file mode 100644 index 0000000..d8feeab Binary files /dev/null and b/data/images/FX/FX-3.203-a.webp differ diff --git a/data/images/FX/FX-3.203-b.webp b/data/images/FX/FX-3.203-b.webp new file mode 100644 index 0000000..3dc6160 Binary files /dev/null and b/data/images/FX/FX-3.203-b.webp differ diff --git a/data/images/FX/FX-3.207-a.webp b/data/images/FX/FX-3.207-a.webp new file mode 100644 index 0000000..fae7d22 Binary files /dev/null and b/data/images/FX/FX-3.207-a.webp differ diff --git a/data/images/FX/FX-3.305-a.webp b/data/images/FX/FX-3.305-a.webp new file mode 100644 index 0000000..2c5d55d Binary files /dev/null and b/data/images/FX/FX-3.305-a.webp differ diff --git a/data/images/FX/FX-3.305-b.webp b/data/images/FX/FX-3.305-b.webp new file mode 100644 index 0000000..2210fe9 Binary files /dev/null and b/data/images/FX/FX-3.305-b.webp differ diff --git a/data/images/FX/FX-4.101-a.webp b/data/images/FX/FX-4.101-a.webp new file mode 100644 index 0000000..344acd0 Binary files /dev/null and b/data/images/FX/FX-4.101-a.webp differ diff --git a/data/images/FX/FX-4.101-b.webp b/data/images/FX/FX-4.101-b.webp new file mode 100644 index 0000000..91b8ed5 Binary files /dev/null and b/data/images/FX/FX-4.101-b.webp differ diff --git a/data/images/FX/FX-4.103-a.webp b/data/images/FX/FX-4.103-a.webp new file mode 100644 index 0000000..a4a3c4b Binary files /dev/null and b/data/images/FX/FX-4.103-a.webp differ diff --git a/data/images/FX/FX-4.104-a.webp b/data/images/FX/FX-4.104-a.webp new file mode 100644 index 0000000..529eafe Binary files /dev/null and b/data/images/FX/FX-4.104-a.webp differ diff --git a/data/images/FX/FX-4.104-b.webp b/data/images/FX/FX-4.104-b.webp new file mode 100644 index 0000000..193f760 Binary files /dev/null and b/data/images/FX/FX-4.104-b.webp differ diff --git a/data/images/FX/FX-4.105-a.webp b/data/images/FX/FX-4.105-a.webp new file mode 100644 index 0000000..a5cbfb0 Binary files /dev/null and b/data/images/FX/FX-4.105-a.webp differ diff --git a/data/images/FX/FX-4.105-b.webp b/data/images/FX/FX-4.105-b.webp new file mode 100644 index 0000000..e4f3fab Binary files /dev/null and b/data/images/FX/FX-4.105-b.webp differ diff --git a/data/images/FX/FX-4.201-a.webp b/data/images/FX/FX-4.201-a.webp new file mode 100644 index 0000000..a97fc2e Binary files /dev/null and b/data/images/FX/FX-4.201-a.webp differ diff --git a/data/images/FX/FX-4.201-b.webp b/data/images/FX/FX-4.201-b.webp new file mode 100644 index 0000000..deac2a7 Binary files /dev/null and b/data/images/FX/FX-4.201-b.webp differ diff --git a/data/images/FX/FX-4.201-c.webp b/data/images/FX/FX-4.201-c.webp new file mode 100644 index 0000000..01ac72e Binary files /dev/null and b/data/images/FX/FX-4.201-c.webp differ diff --git a/data/images/FX/FX-4.202-a.webp b/data/images/FX/FX-4.202-a.webp new file mode 100644 index 0000000..a773fe4 Binary files /dev/null and b/data/images/FX/FX-4.202-a.webp differ diff --git a/data/images/FX/FX-4.202-b.webp b/data/images/FX/FX-4.202-b.webp new file mode 100644 index 0000000..fa13aea Binary files /dev/null and b/data/images/FX/FX-4.202-b.webp differ diff --git a/data/images/FX/FX-4.202-c.webp b/data/images/FX/FX-4.202-c.webp new file mode 100644 index 0000000..2af5922 Binary files /dev/null and b/data/images/FX/FX-4.202-c.webp differ diff --git a/data/images/FX/FX-4.205-a.webp b/data/images/FX/FX-4.205-a.webp new file mode 100644 index 0000000..10c8a07 Binary files /dev/null and b/data/images/FX/FX-4.205-a.webp differ diff --git a/data/images/FX/FX-4.205-b.webp b/data/images/FX/FX-4.205-b.webp new file mode 100644 index 0000000..ce50f35 Binary files /dev/null and b/data/images/FX/FX-4.205-b.webp differ diff --git a/data/images/FX/FX-4.302-a.webp b/data/images/FX/FX-4.302-a.webp new file mode 100644 index 0000000..2c62198 Binary files /dev/null and b/data/images/FX/FX-4.302-a.webp differ diff --git a/data/images/FX/FX-4.302-b.webp b/data/images/FX/FX-4.302-b.webp new file mode 100644 index 0000000..813158f Binary files /dev/null and b/data/images/FX/FX-4.302-b.webp differ diff --git a/data/images/FX/FX-4.402-a.webp b/data/images/FX/FX-4.402-a.webp new file mode 100644 index 0000000..9062592 Binary files /dev/null and b/data/images/FX/FX-4.402-a.webp differ diff --git a/data/images/FX/FX-4.501-a.webp b/data/images/FX/FX-4.501-a.webp new file mode 100644 index 0000000..362bf9b Binary files /dev/null and b/data/images/FX/FX-4.501-a.webp differ diff --git a/data/images/FX/FX-4.502-a.webp b/data/images/FX/FX-4.502-a.webp new file mode 100644 index 0000000..ad61401 Binary files /dev/null and b/data/images/FX/FX-4.502-a.webp differ diff --git a/data/images/FX/FX-4.505-a.webp b/data/images/FX/FX-4.505-a.webp new file mode 100644 index 0000000..36fd10c Binary files /dev/null and b/data/images/FX/FX-4.505-a.webp differ diff --git a/data/images/FX/FX-4.601-a.webp b/data/images/FX/FX-4.601-a.webp new file mode 100644 index 0000000..fc6e5b8 Binary files /dev/null and b/data/images/FX/FX-4.601-a.webp differ diff --git a/data/images/FX/FX-4.601-b.webp b/data/images/FX/FX-4.601-b.webp new file mode 100644 index 0000000..51fbe46 Binary files /dev/null and b/data/images/FX/FX-4.601-b.webp differ diff --git a/data/images/FX/FX-4.602-a.webp b/data/images/FX/FX-4.602-a.webp new file mode 100644 index 0000000..bef1cdc Binary files /dev/null and b/data/images/FX/FX-4.602-a.webp differ diff --git a/data/images/FX/FX-4.605-a.webp b/data/images/FX/FX-4.605-a.webp new file mode 100644 index 0000000..2bb4925 Binary files /dev/null and b/data/images/FX/FX-4.605-a.webp differ diff --git a/data/images/FX/FX-4.805-a.webp b/data/images/FX/FX-4.805-a.webp new file mode 100644 index 0000000..33ee5cb Binary files /dev/null and b/data/images/FX/FX-4.805-a.webp differ diff --git a/data/images/FX/FX-5.101-a.webp b/data/images/FX/FX-5.101-a.webp new file mode 100644 index 0000000..c4d5f05 Binary files /dev/null and b/data/images/FX/FX-5.101-a.webp differ diff --git a/data/images/FX/FX-5.101-b.webp b/data/images/FX/FX-5.101-b.webp new file mode 100644 index 0000000..158eaa1 Binary files /dev/null and b/data/images/FX/FX-5.101-b.webp differ diff --git a/data/images/FX/FX-5.101-c.webp b/data/images/FX/FX-5.101-c.webp new file mode 100644 index 0000000..d80290f Binary files /dev/null and b/data/images/FX/FX-5.101-c.webp differ diff --git a/data/images/FX/FX-5.104-a.webp b/data/images/FX/FX-5.104-a.webp new file mode 100644 index 0000000..20ccd6c Binary files /dev/null and b/data/images/FX/FX-5.104-a.webp differ diff --git a/data/images/FX/FX-5.201-a.webp b/data/images/FX/FX-5.201-a.webp new file mode 100644 index 0000000..916ca86 Binary files /dev/null and b/data/images/FX/FX-5.201-a.webp differ diff --git a/data/images/FX/FX-5.201-b.webp b/data/images/FX/FX-5.201-b.webp new file mode 100644 index 0000000..c0bb6b8 Binary files /dev/null and b/data/images/FX/FX-5.201-b.webp differ diff --git a/data/images/FX/FX-5.204-a.webp b/data/images/FX/FX-5.204-a.webp new file mode 100644 index 0000000..b60105b Binary files /dev/null and b/data/images/FX/FX-5.204-a.webp differ diff --git a/data/images/FX/FX-5.301-a.webp b/data/images/FX/FX-5.301-a.webp new file mode 100644 index 0000000..87b1bfa Binary files /dev/null and b/data/images/FX/FX-5.301-a.webp differ diff --git a/data/images/FX/FX-5.301-b.webp b/data/images/FX/FX-5.301-b.webp new file mode 100644 index 0000000..ae7c8ba Binary files /dev/null and b/data/images/FX/FX-5.301-b.webp differ diff --git a/data/images/FX/FX-5.304-a.webp b/data/images/FX/FX-5.304-a.webp new file mode 100644 index 0000000..7949d03 Binary files /dev/null and b/data/images/FX/FX-5.304-a.webp differ diff --git a/data/images/FX/FX-5.401-a.webp b/data/images/FX/FX-5.401-a.webp new file mode 100644 index 0000000..1f4400e Binary files /dev/null and b/data/images/FX/FX-5.401-a.webp differ diff --git a/data/images/FX/FX-5.402-a.webp b/data/images/FX/FX-5.402-a.webp new file mode 100644 index 0000000..38d7b66 Binary files /dev/null and b/data/images/FX/FX-5.402-a.webp differ diff --git a/data/images/FX/FX-5.402-b.webp b/data/images/FX/FX-5.402-b.webp new file mode 100644 index 0000000..e340874 Binary files /dev/null and b/data/images/FX/FX-5.402-b.webp differ diff --git a/data/images/FX/FX-5.501-a.webp b/data/images/FX/FX-5.501-a.webp new file mode 100644 index 0000000..a4ab764 Binary files /dev/null and b/data/images/FX/FX-5.501-a.webp differ diff --git a/data/images/FX/FX-5.502-a.webp b/data/images/FX/FX-5.502-a.webp new file mode 100644 index 0000000..e9ba100 Binary files /dev/null and b/data/images/FX/FX-5.502-a.webp differ diff --git a/data/images/FX/FX-5.502-b.webp b/data/images/FX/FX-5.502-b.webp new file mode 100644 index 0000000..941aad7 Binary files /dev/null and b/data/images/FX/FX-5.502-b.webp differ diff --git a/data/images/FX/FX-5.601-a.webp b/data/images/FX/FX-5.601-a.webp new file mode 100644 index 0000000..1fca3d2 Binary files /dev/null and b/data/images/FX/FX-5.601-a.webp differ diff --git a/data/images/FX/FX-5.602-a.webp b/data/images/FX/FX-5.602-a.webp new file mode 100644 index 0000000..2036a08 Binary files /dev/null and b/data/images/FX/FX-5.602-a.webp differ diff --git a/data/images/FX/FX-5.603-a.webp b/data/images/FX/FX-5.603-a.webp new file mode 100644 index 0000000..6f53142 Binary files /dev/null and b/data/images/FX/FX-5.603-a.webp differ diff --git a/data/images/FX/FX-5.703-a.webp b/data/images/FX/FX-5.703-a.webp new file mode 100644 index 0000000..fade8f0 Binary files /dev/null and b/data/images/FX/FX-5.703-a.webp differ diff --git a/data/images/FX/FX-5.802-a.webp b/data/images/FX/FX-5.802-a.webp new file mode 100644 index 0000000..07e2289 Binary files /dev/null and b/data/images/FX/FX-5.802-a.webp differ diff --git a/data/images/FX/FX-5.802-b.webp b/data/images/FX/FX-5.802-b.webp new file mode 100644 index 0000000..7cf1e5b Binary files /dev/null and b/data/images/FX/FX-5.802-b.webp differ diff --git a/data/images/FX/FX-5.803-a.webp b/data/images/FX/FX-5.803-a.webp new file mode 100644 index 0000000..7ce97ee Binary files /dev/null and b/data/images/FX/FX-5.803-a.webp differ diff --git a/data/images/FX/FX-5.903-a.webp b/data/images/FX/FX-5.903-a.webp new file mode 100644 index 0000000..4e6bf2e Binary files /dev/null and b/data/images/FX/FX-5.903-a.webp differ diff --git a/data/images/UB/UB-1.101-a.webp b/data/images/UB/UB-1.101-a.webp new file mode 100644 index 0000000..f55bf55 Binary files /dev/null and b/data/images/UB/UB-1.101-a.webp differ diff --git a/data/images/UB/UB-1.101-b.webp b/data/images/UB/UB-1.101-b.webp new file mode 100644 index 0000000..9e98a71 Binary files /dev/null and b/data/images/UB/UB-1.101-b.webp differ diff --git a/data/images/UB/UB-1.102-a.webp b/data/images/UB/UB-1.102-a.webp new file mode 100644 index 0000000..60c7746 Binary files /dev/null and b/data/images/UB/UB-1.102-a.webp differ diff --git a/data/images/UB/UB-1.102-b.webp b/data/images/UB/UB-1.102-b.webp new file mode 100644 index 0000000..a5a7d58 Binary files /dev/null and b/data/images/UB/UB-1.102-b.webp differ diff --git a/data/images/UB/UB-1.103-a.webp b/data/images/UB/UB-1.103-a.webp new file mode 100644 index 0000000..564ce87 Binary files /dev/null and b/data/images/UB/UB-1.103-a.webp differ diff --git a/data/images/UB/UB-1.103-b.webp b/data/images/UB/UB-1.103-b.webp new file mode 100644 index 0000000..03e24dd Binary files /dev/null and b/data/images/UB/UB-1.103-b.webp differ diff --git a/data/images/UB/UB-1.104-a.webp b/data/images/UB/UB-1.104-a.webp new file mode 100644 index 0000000..ccbeee7 Binary files /dev/null and b/data/images/UB/UB-1.104-a.webp differ diff --git a/data/images/UB/UB-1.105-a.webp b/data/images/UB/UB-1.105-a.webp new file mode 100644 index 0000000..59bbdf7 Binary files /dev/null and b/data/images/UB/UB-1.105-a.webp differ diff --git a/data/images/UB/UB-1.106-a.webp b/data/images/UB/UB-1.106-a.webp new file mode 100644 index 0000000..ead998f Binary files /dev/null and b/data/images/UB/UB-1.106-a.webp differ diff --git a/data/images/UB/UB-1.202-a.webp b/data/images/UB/UB-1.202-a.webp new file mode 100644 index 0000000..d329adb Binary files /dev/null and b/data/images/UB/UB-1.202-a.webp differ diff --git a/data/images/UB/UB-1.203-a.webp b/data/images/UB/UB-1.203-a.webp new file mode 100644 index 0000000..52fe25c Binary files /dev/null and b/data/images/UB/UB-1.203-a.webp differ diff --git a/data/images/UB/UB-1.203-b.webp b/data/images/UB/UB-1.203-b.webp new file mode 100644 index 0000000..3df419c Binary files /dev/null and b/data/images/UB/UB-1.203-b.webp differ diff --git a/data/images/UB/UB-1.204-a.webp b/data/images/UB/UB-1.204-a.webp new file mode 100644 index 0000000..5aa208d Binary files /dev/null and b/data/images/UB/UB-1.204-a.webp differ diff --git a/data/images/UB/UB-1.204-b.webp b/data/images/UB/UB-1.204-b.webp new file mode 100644 index 0000000..a66800c Binary files /dev/null and b/data/images/UB/UB-1.204-b.webp differ diff --git a/data/images/UB/UB-1.206-a.webp b/data/images/UB/UB-1.206-a.webp new file mode 100644 index 0000000..c4ad437 Binary files /dev/null and b/data/images/UB/UB-1.206-a.webp differ diff --git a/data/images/UB/UB-1.206-b.webp b/data/images/UB/UB-1.206-b.webp new file mode 100644 index 0000000..b96aa04 Binary files /dev/null and b/data/images/UB/UB-1.206-b.webp differ diff --git a/data/images/UB/UB-1.207-a.webp b/data/images/UB/UB-1.207-a.webp new file mode 100644 index 0000000..56a1e3c Binary files /dev/null and b/data/images/UB/UB-1.207-a.webp differ diff --git a/data/images/UB/UB-1.208-a.webp b/data/images/UB/UB-1.208-a.webp new file mode 100644 index 0000000..271fa38 Binary files /dev/null and b/data/images/UB/UB-1.208-a.webp differ diff --git a/data/images/UB/UB-1.210-a.webp b/data/images/UB/UB-1.210-a.webp new file mode 100644 index 0000000..68258a1 Binary files /dev/null and b/data/images/UB/UB-1.210-a.webp differ diff --git a/data/images/UB/UB-1.210-b.webp b/data/images/UB/UB-1.210-b.webp new file mode 100644 index 0000000..cdee2ea Binary files /dev/null and b/data/images/UB/UB-1.210-b.webp differ diff --git a/data/images/UB/UB-1.304-a.webp b/data/images/UB/UB-1.304-a.webp new file mode 100644 index 0000000..9c682c9 Binary files /dev/null and b/data/images/UB/UB-1.304-a.webp differ diff --git a/data/images/UB/UB-1.306-a.webp b/data/images/UB/UB-1.306-a.webp new file mode 100644 index 0000000..3bfc83b Binary files /dev/null and b/data/images/UB/UB-1.306-a.webp differ diff --git a/data/images/UB/UB-1.307-a.webp b/data/images/UB/UB-1.307-a.webp new file mode 100644 index 0000000..e9e5756 Binary files /dev/null and b/data/images/UB/UB-1.307-a.webp differ diff --git a/data/images/UB/UB-1.307-b.webp b/data/images/UB/UB-1.307-b.webp new file mode 100644 index 0000000..fc1c547 Binary files /dev/null and b/data/images/UB/UB-1.307-b.webp differ diff --git a/data/images/UB/UB-1.308-a.webp b/data/images/UB/UB-1.308-a.webp new file mode 100644 index 0000000..4a72e10 Binary files /dev/null and b/data/images/UB/UB-1.308-a.webp differ diff --git a/data/images/UB/UB-1.310-a.webp b/data/images/UB/UB-1.310-a.webp new file mode 100644 index 0000000..c5ab5d8 Binary files /dev/null and b/data/images/UB/UB-1.310-a.webp differ diff --git a/data/images/UB/UB-1.310-b.webp b/data/images/UB/UB-1.310-b.webp new file mode 100644 index 0000000..ef7ddb0 Binary files /dev/null and b/data/images/UB/UB-1.310-b.webp differ diff --git a/data/images/UB/UB-1.406-a.webp b/data/images/UB/UB-1.406-a.webp new file mode 100644 index 0000000..114b561 Binary files /dev/null and b/data/images/UB/UB-1.406-a.webp differ diff --git a/data/images/UB/UB-1.406-b.webp b/data/images/UB/UB-1.406-b.webp new file mode 100644 index 0000000..0ca3edf Binary files /dev/null and b/data/images/UB/UB-1.406-b.webp differ diff --git a/data/images/UB/UB-1.407-a.webp b/data/images/UB/UB-1.407-a.webp new file mode 100644 index 0000000..676ffd1 Binary files /dev/null and b/data/images/UB/UB-1.407-a.webp differ diff --git a/data/images/UB/UB-1.408-a.webp b/data/images/UB/UB-1.408-a.webp new file mode 100644 index 0000000..f7542ff Binary files /dev/null and b/data/images/UB/UB-1.408-a.webp differ diff --git a/data/images/UB/UB-1.409-a.webp b/data/images/UB/UB-1.409-a.webp new file mode 100644 index 0000000..797ffb3 Binary files /dev/null and b/data/images/UB/UB-1.409-a.webp differ diff --git a/data/images/UB/UB-1.409-b.webp b/data/images/UB/UB-1.409-b.webp new file mode 100644 index 0000000..6c11f3a Binary files /dev/null and b/data/images/UB/UB-1.409-b.webp differ diff --git a/data/images/UB/UB-1.410-a.webp b/data/images/UB/UB-1.410-a.webp new file mode 100644 index 0000000..b0853be Binary files /dev/null and b/data/images/UB/UB-1.410-a.webp differ diff --git a/data/images/UB/UB-1.411-a.webp b/data/images/UB/UB-1.411-a.webp new file mode 100644 index 0000000..763e84c Binary files /dev/null and b/data/images/UB/UB-1.411-a.webp differ diff --git a/data/images/UB/UB-1.411-b.webp b/data/images/UB/UB-1.411-b.webp new file mode 100644 index 0000000..59484c3 Binary files /dev/null and b/data/images/UB/UB-1.411-b.webp differ diff --git a/data/images/UB/UB-1.509-a.webp b/data/images/UB/UB-1.509-a.webp new file mode 100644 index 0000000..4b1c761 Binary files /dev/null and b/data/images/UB/UB-1.509-a.webp differ diff --git a/data/images/UB/UB-1.609-a.webp b/data/images/UB/UB-1.609-a.webp new file mode 100644 index 0000000..8feaa36 Binary files /dev/null and b/data/images/UB/UB-1.609-a.webp differ diff --git a/data/images/UB/UB-1.709-a.webp b/data/images/UB/UB-1.709-a.webp new file mode 100644 index 0000000..b3976e6 Binary files /dev/null and b/data/images/UB/UB-1.709-a.webp differ diff --git a/data/images/UB/UB-2.101-a.webp b/data/images/UB/UB-2.101-a.webp new file mode 100644 index 0000000..6d57f38 Binary files /dev/null and b/data/images/UB/UB-2.101-a.webp differ diff --git a/data/images/UB/UB-2.101-b.webp b/data/images/UB/UB-2.101-b.webp new file mode 100644 index 0000000..b515915 Binary files /dev/null and b/data/images/UB/UB-2.101-b.webp differ diff --git a/data/images/UB/UB-2.104-a.webp b/data/images/UB/UB-2.104-a.webp new file mode 100644 index 0000000..8d7ac52 Binary files /dev/null and b/data/images/UB/UB-2.104-a.webp differ diff --git a/data/images/UB/UB-2.105-a.webp b/data/images/UB/UB-2.105-a.webp new file mode 100644 index 0000000..27d7c12 Binary files /dev/null and b/data/images/UB/UB-2.105-a.webp differ diff --git a/data/images/UB/UB-2.105-b.webp b/data/images/UB/UB-2.105-b.webp new file mode 100644 index 0000000..c34e3c5 Binary files /dev/null and b/data/images/UB/UB-2.105-b.webp differ diff --git a/data/images/UB/UB-2.201-a.webp b/data/images/UB/UB-2.201-a.webp new file mode 100644 index 0000000..cb04436 Binary files /dev/null and b/data/images/UB/UB-2.201-a.webp differ diff --git a/data/images/UB/UB-2.201-b.webp b/data/images/UB/UB-2.201-b.webp new file mode 100644 index 0000000..e6bdb97 Binary files /dev/null and b/data/images/UB/UB-2.201-b.webp differ diff --git a/data/images/UB/UB-2.201-c.webp b/data/images/UB/UB-2.201-c.webp new file mode 100644 index 0000000..e7577a5 Binary files /dev/null and b/data/images/UB/UB-2.201-c.webp differ diff --git a/data/images/UB/UB-2.202-a.webp b/data/images/UB/UB-2.202-a.webp new file mode 100644 index 0000000..a22627c Binary files /dev/null and b/data/images/UB/UB-2.202-a.webp differ diff --git a/data/images/UB/UB-2.202-b.webp b/data/images/UB/UB-2.202-b.webp new file mode 100644 index 0000000..56ef339 Binary files /dev/null and b/data/images/UB/UB-2.202-b.webp differ diff --git a/data/images/UB/UB-2.206-a.webp b/data/images/UB/UB-2.206-a.webp new file mode 100644 index 0000000..09dd598 Binary files /dev/null and b/data/images/UB/UB-2.206-a.webp differ diff --git a/data/images/UB/UB-2.301-a.webp b/data/images/UB/UB-2.301-a.webp new file mode 100644 index 0000000..2aee193 Binary files /dev/null and b/data/images/UB/UB-2.301-a.webp differ diff --git a/data/images/UB/UB-2.302-a.webp b/data/images/UB/UB-2.302-a.webp new file mode 100644 index 0000000..ca21642 Binary files /dev/null and b/data/images/UB/UB-2.302-a.webp differ diff --git a/data/images/UB/UB-2.302-b.webp b/data/images/UB/UB-2.302-b.webp new file mode 100644 index 0000000..b0af18c Binary files /dev/null and b/data/images/UB/UB-2.302-b.webp differ diff --git a/data/images/UB/UB-2.303-a.webp b/data/images/UB/UB-2.303-a.webp new file mode 100644 index 0000000..704b389 Binary files /dev/null and b/data/images/UB/UB-2.303-a.webp differ diff --git a/data/images/UB/UB-2.305-a.webp b/data/images/UB/UB-2.305-a.webp new file mode 100644 index 0000000..a2242fe Binary files /dev/null and b/data/images/UB/UB-2.305-a.webp differ diff --git a/data/images/UB/UB-2.305-b.webp b/data/images/UB/UB-2.305-b.webp new file mode 100644 index 0000000..f5d42dd Binary files /dev/null and b/data/images/UB/UB-2.305-b.webp differ diff --git a/data/images/UB/UB-2.305-c.webp b/data/images/UB/UB-2.305-c.webp new file mode 100644 index 0000000..614628a Binary files /dev/null and b/data/images/UB/UB-2.305-c.webp differ diff --git a/data/images/UB/UB-2.306-a.webp b/data/images/UB/UB-2.306-a.webp new file mode 100644 index 0000000..d6223a8 Binary files /dev/null and b/data/images/UB/UB-2.306-a.webp differ diff --git a/data/images/UB/UB-2.307-a.webp b/data/images/UB/UB-2.307-a.webp new file mode 100644 index 0000000..b2219d5 Binary files /dev/null and b/data/images/UB/UB-2.307-a.webp differ diff --git a/data/images/UB/UB-2.401-a.webp b/data/images/UB/UB-2.401-a.webp new file mode 100644 index 0000000..69be6c5 Binary files /dev/null and b/data/images/UB/UB-2.401-a.webp differ diff --git a/data/images/UB/UB-2.403-a.webp b/data/images/UB/UB-2.403-a.webp new file mode 100644 index 0000000..082b302 Binary files /dev/null and b/data/images/UB/UB-2.403-a.webp differ diff --git a/data/images/UB/UB-2.404-a.webp b/data/images/UB/UB-2.404-a.webp new file mode 100644 index 0000000..cd107d6 Binary files /dev/null and b/data/images/UB/UB-2.404-a.webp differ diff --git a/data/images/UB/UB-2.404-b.webp b/data/images/UB/UB-2.404-b.webp new file mode 100644 index 0000000..ce5b26d Binary files /dev/null and b/data/images/UB/UB-2.404-b.webp differ diff --git a/data/images/UB/UB-2.405-a.webp b/data/images/UB/UB-2.405-a.webp new file mode 100644 index 0000000..f6e9f38 Binary files /dev/null and b/data/images/UB/UB-2.405-a.webp differ diff --git a/data/images/UB/UB-2.406-a.webp b/data/images/UB/UB-2.406-a.webp new file mode 100644 index 0000000..1a65c13 Binary files /dev/null and b/data/images/UB/UB-2.406-a.webp differ diff --git a/data/images/UB/UB-2.407-a.webp b/data/images/UB/UB-2.407-a.webp new file mode 100644 index 0000000..79228d2 Binary files /dev/null and b/data/images/UB/UB-2.407-a.webp differ diff --git a/data/images/UB/UB-2.503-a.webp b/data/images/UB/UB-2.503-a.webp new file mode 100644 index 0000000..6fc4089 Binary files /dev/null and b/data/images/UB/UB-2.503-a.webp differ diff --git a/data/images/UB/UB-2.504-a.webp b/data/images/UB/UB-2.504-a.webp new file mode 100644 index 0000000..e119718 Binary files /dev/null and b/data/images/UB/UB-2.504-a.webp differ diff --git a/data/images/UB/UB-2.504-b.webp b/data/images/UB/UB-2.504-b.webp new file mode 100644 index 0000000..b638525 Binary files /dev/null and b/data/images/UB/UB-2.504-b.webp differ diff --git a/data/images/UB/UB-2.505-a.webp b/data/images/UB/UB-2.505-a.webp new file mode 100644 index 0000000..9a46406 Binary files /dev/null and b/data/images/UB/UB-2.505-a.webp differ diff --git a/data/images/UB/UB-2.506-a.webp b/data/images/UB/UB-2.506-a.webp new file mode 100644 index 0000000..16fb732 Binary files /dev/null and b/data/images/UB/UB-2.506-a.webp differ diff --git a/data/images/UB/UB-2.506-b.webp b/data/images/UB/UB-2.506-b.webp new file mode 100644 index 0000000..2bf837d Binary files /dev/null and b/data/images/UB/UB-2.506-b.webp differ diff --git a/data/images/UB/UB-2.506-c.webp b/data/images/UB/UB-2.506-c.webp new file mode 100644 index 0000000..ab8129c Binary files /dev/null and b/data/images/UB/UB-2.506-c.webp differ diff --git a/data/images/UB/UB-2.606-a.webp b/data/images/UB/UB-2.606-a.webp new file mode 100644 index 0000000..fa7fb78 Binary files /dev/null and b/data/images/UB/UB-2.606-a.webp differ diff --git a/data/images/UB/UB-2.606-b.webp b/data/images/UB/UB-2.606-b.webp new file mode 100644 index 0000000..bded0b5 Binary files /dev/null and b/data/images/UB/UB-2.606-b.webp differ diff --git a/data/images/UB/UB-2.706-a.webp b/data/images/UB/UB-2.706-a.webp new file mode 100644 index 0000000..56bd14e Binary files /dev/null and b/data/images/UB/UB-2.706-a.webp differ diff --git a/data/images/UB/UB-3.201-a.webp b/data/images/UB/UB-3.201-a.webp new file mode 100644 index 0000000..ad68ca6 Binary files /dev/null and b/data/images/UB/UB-3.201-a.webp differ diff --git a/data/images/UB/UB-3.201-b.webp b/data/images/UB/UB-3.201-b.webp new file mode 100644 index 0000000..6c98d7d Binary files /dev/null and b/data/images/UB/UB-3.201-b.webp differ diff --git a/data/images/UB/UB-3.206-a.webp b/data/images/UB/UB-3.206-a.webp new file mode 100644 index 0000000..543e137 Binary files /dev/null and b/data/images/UB/UB-3.206-a.webp differ diff --git a/data/images/UB/UB-3.206-b.webp b/data/images/UB/UB-3.206-b.webp new file mode 100644 index 0000000..2ec05bc Binary files /dev/null and b/data/images/UB/UB-3.206-b.webp differ diff --git a/data/images/UB/UB-3.206-c.webp b/data/images/UB/UB-3.206-c.webp new file mode 100644 index 0000000..8940216 Binary files /dev/null and b/data/images/UB/UB-3.206-c.webp differ diff --git a/data/images/UB/UB-3.301-a.webp b/data/images/UB/UB-3.301-a.webp new file mode 100644 index 0000000..be79428 Binary files /dev/null and b/data/images/UB/UB-3.301-a.webp differ diff --git a/data/images/UB/UB-3.304-a.webp b/data/images/UB/UB-3.304-a.webp new file mode 100644 index 0000000..1b51672 Binary files /dev/null and b/data/images/UB/UB-3.304-a.webp differ diff --git a/data/images/UB/UB-3.306-a.webp b/data/images/UB/UB-3.306-a.webp new file mode 100644 index 0000000..de6fb25 Binary files /dev/null and b/data/images/UB/UB-3.306-a.webp differ diff --git a/data/images/UB/UB-3.307-a.webp b/data/images/UB/UB-3.307-a.webp new file mode 100644 index 0000000..d455e22 Binary files /dev/null and b/data/images/UB/UB-3.307-a.webp differ diff --git a/data/images/UB/UB-3.308-a.webp b/data/images/UB/UB-3.308-a.webp new file mode 100644 index 0000000..f638035 Binary files /dev/null and b/data/images/UB/UB-3.308-a.webp differ diff --git a/data/images/UB/UB-3.309-a.webp b/data/images/UB/UB-3.309-a.webp new file mode 100644 index 0000000..c8898e5 Binary files /dev/null and b/data/images/UB/UB-3.309-a.webp differ diff --git a/data/images/UB/UB-3.309-b.webp b/data/images/UB/UB-3.309-b.webp new file mode 100644 index 0000000..8db3cfa Binary files /dev/null and b/data/images/UB/UB-3.309-b.webp differ diff --git a/data/images/UB/UB-3.310-a.webp b/data/images/UB/UB-3.310-a.webp new file mode 100644 index 0000000..4bfc81b Binary files /dev/null and b/data/images/UB/UB-3.310-a.webp differ diff --git a/data/images/UB/UB-3.310-b.webp b/data/images/UB/UB-3.310-b.webp new file mode 100644 index 0000000..21ca2a2 Binary files /dev/null and b/data/images/UB/UB-3.310-b.webp differ diff --git a/data/images/UB/UB-3.401-a.webp b/data/images/UB/UB-3.401-a.webp new file mode 100644 index 0000000..7d9c940 Binary files /dev/null and b/data/images/UB/UB-3.401-a.webp differ diff --git a/data/images/UB/UB-3.401-b.webp b/data/images/UB/UB-3.401-b.webp new file mode 100644 index 0000000..d5b6de1 Binary files /dev/null and b/data/images/UB/UB-3.401-b.webp differ diff --git a/data/images/UB/UB-3.401-c.webp b/data/images/UB/UB-3.401-c.webp new file mode 100644 index 0000000..9e9d987 Binary files /dev/null and b/data/images/UB/UB-3.401-c.webp differ diff --git a/data/images/UB/UB-3.402-a.webp b/data/images/UB/UB-3.402-a.webp new file mode 100644 index 0000000..12ea3c6 Binary files /dev/null and b/data/images/UB/UB-3.402-a.webp differ diff --git a/data/images/UB/UB-3.403-a.webp b/data/images/UB/UB-3.403-a.webp new file mode 100644 index 0000000..4d5af27 Binary files /dev/null and b/data/images/UB/UB-3.403-a.webp differ diff --git a/data/images/UB/UB-3.403-b.webp b/data/images/UB/UB-3.403-b.webp new file mode 100644 index 0000000..eb6f22e Binary files /dev/null and b/data/images/UB/UB-3.403-b.webp differ diff --git a/data/images/UB/UB-3.403-c.webp b/data/images/UB/UB-3.403-c.webp new file mode 100644 index 0000000..41e2c58 Binary files /dev/null and b/data/images/UB/UB-3.403-c.webp differ diff --git a/data/images/UB/UB-3.404-a.webp b/data/images/UB/UB-3.404-a.webp new file mode 100644 index 0000000..684ba59 Binary files /dev/null and b/data/images/UB/UB-3.404-a.webp differ diff --git a/data/images/UB/UB-3.405-a.webp b/data/images/UB/UB-3.405-a.webp new file mode 100644 index 0000000..53eda09 Binary files /dev/null and b/data/images/UB/UB-3.405-a.webp differ diff --git a/data/images/UB/UB-3.405-b.webp b/data/images/UB/UB-3.405-b.webp new file mode 100644 index 0000000..646ed26 Binary files /dev/null and b/data/images/UB/UB-3.405-b.webp differ diff --git a/data/images/UB/UB-3.405-c.webp b/data/images/UB/UB-3.405-c.webp new file mode 100644 index 0000000..28c6b9c Binary files /dev/null and b/data/images/UB/UB-3.405-c.webp differ diff --git a/data/images/UB/UB-3.407-a.webp b/data/images/UB/UB-3.407-a.webp new file mode 100644 index 0000000..cbe1843 Binary files /dev/null and b/data/images/UB/UB-3.407-a.webp differ diff --git a/data/images/UB/UB-3.408-a.webp b/data/images/UB/UB-3.408-a.webp new file mode 100644 index 0000000..bc6ad18 Binary files /dev/null and b/data/images/UB/UB-3.408-a.webp differ diff --git a/data/images/UB/UB-3.408-b.webp b/data/images/UB/UB-3.408-b.webp new file mode 100644 index 0000000..bcebea4 Binary files /dev/null and b/data/images/UB/UB-3.408-b.webp differ diff --git a/data/images/UB/UB-3.408-c.webp b/data/images/UB/UB-3.408-c.webp new file mode 100644 index 0000000..ada262d Binary files /dev/null and b/data/images/UB/UB-3.408-c.webp differ diff --git a/data/images/UB/UB-3.409-a.webp b/data/images/UB/UB-3.409-a.webp new file mode 100644 index 0000000..296f2fe Binary files /dev/null and b/data/images/UB/UB-3.409-a.webp differ diff --git a/data/images/UB/UB-3.410-a.webp b/data/images/UB/UB-3.410-a.webp new file mode 100644 index 0000000..dd5fc70 Binary files /dev/null and b/data/images/UB/UB-3.410-a.webp differ diff --git a/data/images/UB/UB-3.502-a.webp b/data/images/UB/UB-3.502-a.webp new file mode 100644 index 0000000..0c88786 Binary files /dev/null and b/data/images/UB/UB-3.502-a.webp differ diff --git a/data/images/UB/UB-3.503-a.webp b/data/images/UB/UB-3.503-a.webp new file mode 100644 index 0000000..3622c1d Binary files /dev/null and b/data/images/UB/UB-3.503-a.webp differ diff --git a/data/images/UB/UB-3.503-b.webp b/data/images/UB/UB-3.503-b.webp new file mode 100644 index 0000000..200551c Binary files /dev/null and b/data/images/UB/UB-3.503-b.webp differ diff --git a/data/images/UB/UB-3.504-a.webp b/data/images/UB/UB-3.504-a.webp new file mode 100644 index 0000000..03da6cd Binary files /dev/null and b/data/images/UB/UB-3.504-a.webp differ diff --git a/data/images/UB/UB-3.505-a.webp b/data/images/UB/UB-3.505-a.webp new file mode 100644 index 0000000..da8145d Binary files /dev/null and b/data/images/UB/UB-3.505-a.webp differ diff --git a/data/images/UB/UB-3.506-a.webp b/data/images/UB/UB-3.506-a.webp new file mode 100644 index 0000000..b3ef7ca Binary files /dev/null and b/data/images/UB/UB-3.506-a.webp differ diff --git a/data/images/UB/UB-3.506-b.webp b/data/images/UB/UB-3.506-b.webp new file mode 100644 index 0000000..f4173f2 Binary files /dev/null and b/data/images/UB/UB-3.506-b.webp differ diff --git a/data/images/UB/UB-3.510-a.webp b/data/images/UB/UB-3.510-a.webp new file mode 100644 index 0000000..fdaf8f7 Binary files /dev/null and b/data/images/UB/UB-3.510-a.webp differ diff --git a/data/images/UB/UB-3.510-b.webp b/data/images/UB/UB-3.510-b.webp new file mode 100644 index 0000000..9235f25 Binary files /dev/null and b/data/images/UB/UB-3.510-b.webp differ diff --git a/data/images/UB/UB-3.604-a.webp b/data/images/UB/UB-3.604-a.webp new file mode 100644 index 0000000..8fd8d19 Binary files /dev/null and b/data/images/UB/UB-3.604-a.webp differ diff --git a/data/images/UB/UB-3.608-a.webp b/data/images/UB/UB-3.608-a.webp new file mode 100644 index 0000000..e5846df Binary files /dev/null and b/data/images/UB/UB-3.608-a.webp differ diff --git a/data/images/UB/UB-3.705-a.webp b/data/images/UB/UB-3.705-a.webp new file mode 100644 index 0000000..92da948 Binary files /dev/null and b/data/images/UB/UB-3.705-a.webp differ diff --git a/data/images/UB/UB-3.708-a.webp b/data/images/UB/UB-3.708-a.webp new file mode 100644 index 0000000..cd8c4a9 Binary files /dev/null and b/data/images/UB/UB-3.708-a.webp differ diff --git a/data/images/UB/UB-3.708-b.webp b/data/images/UB/UB-3.708-b.webp new file mode 100644 index 0000000..5a8f59d Binary files /dev/null and b/data/images/UB/UB-3.708-b.webp differ diff --git a/data/images/UB/UB-4.101-a.webp b/data/images/UB/UB-4.101-a.webp new file mode 100644 index 0000000..cb21c12 Binary files /dev/null and b/data/images/UB/UB-4.101-a.webp differ diff --git a/data/images/UB/UB-4.103-a.webp b/data/images/UB/UB-4.103-a.webp new file mode 100644 index 0000000..9f719b8 Binary files /dev/null and b/data/images/UB/UB-4.103-a.webp differ diff --git a/data/images/UB/UB-4.104-a.webp b/data/images/UB/UB-4.104-a.webp new file mode 100644 index 0000000..a1429be Binary files /dev/null and b/data/images/UB/UB-4.104-a.webp differ diff --git a/data/images/UB/UB-4.106-a.webp b/data/images/UB/UB-4.106-a.webp new file mode 100644 index 0000000..eb8949b Binary files /dev/null and b/data/images/UB/UB-4.106-a.webp differ diff --git a/data/images/UB/UB-4.107-a.webp b/data/images/UB/UB-4.107-a.webp new file mode 100644 index 0000000..ea5a64c Binary files /dev/null and b/data/images/UB/UB-4.107-a.webp differ diff --git a/data/images/UB/UB-4.201-a.webp b/data/images/UB/UB-4.201-a.webp new file mode 100644 index 0000000..6d8ad0f Binary files /dev/null and b/data/images/UB/UB-4.201-a.webp differ diff --git a/data/images/UB/UB-4.205-a.webp b/data/images/UB/UB-4.205-a.webp new file mode 100644 index 0000000..60d95ac Binary files /dev/null and b/data/images/UB/UB-4.205-a.webp differ diff --git a/data/images/UB/UB-4.208-a.webp b/data/images/UB/UB-4.208-a.webp new file mode 100644 index 0000000..d16824a Binary files /dev/null and b/data/images/UB/UB-4.208-a.webp differ diff --git a/data/images/UB/UB-4.302-a.webp b/data/images/UB/UB-4.302-a.webp new file mode 100644 index 0000000..d8061dd Binary files /dev/null and b/data/images/UB/UB-4.302-a.webp differ diff --git a/data/images/UB/UB-4.304-a.webp b/data/images/UB/UB-4.304-a.webp new file mode 100644 index 0000000..4f11d49 Binary files /dev/null and b/data/images/UB/UB-4.304-a.webp differ diff --git a/data/images/UB/UB-4.304-b.webp b/data/images/UB/UB-4.304-b.webp new file mode 100644 index 0000000..da946f3 Binary files /dev/null and b/data/images/UB/UB-4.304-b.webp differ diff --git a/data/images/UB/UB-4.304-c.webp b/data/images/UB/UB-4.304-c.webp new file mode 100644 index 0000000..1c928d2 Binary files /dev/null and b/data/images/UB/UB-4.304-c.webp differ diff --git a/data/images/UB/UB-4.305-a.webp b/data/images/UB/UB-4.305-a.webp new file mode 100644 index 0000000..9a723f1 Binary files /dev/null and b/data/images/UB/UB-4.305-a.webp differ diff --git a/data/images/UB/UB-4.308-a.webp b/data/images/UB/UB-4.308-a.webp new file mode 100644 index 0000000..fb014b7 Binary files /dev/null and b/data/images/UB/UB-4.308-a.webp differ diff --git a/data/images/UB/UB-4.308-b.webp b/data/images/UB/UB-4.308-b.webp new file mode 100644 index 0000000..8e51669 Binary files /dev/null and b/data/images/UB/UB-4.308-b.webp differ diff --git a/data/images/UB/UB-4.401-a.webp b/data/images/UB/UB-4.401-a.webp new file mode 100644 index 0000000..6183f7c Binary files /dev/null and b/data/images/UB/UB-4.401-a.webp differ diff --git a/data/images/UB/UB-4.401-b.webp b/data/images/UB/UB-4.401-b.webp new file mode 100644 index 0000000..23afecb Binary files /dev/null and b/data/images/UB/UB-4.401-b.webp differ diff --git a/data/images/UB/UB-4.402-a.webp b/data/images/UB/UB-4.402-a.webp new file mode 100644 index 0000000..bd171d8 Binary files /dev/null and b/data/images/UB/UB-4.402-a.webp differ diff --git a/data/images/UB/UB-4.403-a.webp b/data/images/UB/UB-4.403-a.webp new file mode 100644 index 0000000..b2bf6d3 Binary files /dev/null and b/data/images/UB/UB-4.403-a.webp differ diff --git a/data/images/UB/UB-4.403-b.webp b/data/images/UB/UB-4.403-b.webp new file mode 100644 index 0000000..4e624b1 Binary files /dev/null and b/data/images/UB/UB-4.403-b.webp differ diff --git a/data/images/UB/UB-4.404-a.webp b/data/images/UB/UB-4.404-a.webp new file mode 100644 index 0000000..2ff51e1 Binary files /dev/null and b/data/images/UB/UB-4.404-a.webp differ diff --git a/data/images/UB/UB-4.405-a.webp b/data/images/UB/UB-4.405-a.webp new file mode 100644 index 0000000..3c8568c Binary files /dev/null and b/data/images/UB/UB-4.405-a.webp differ diff --git a/data/images/UB/UB-4.406-a.webp b/data/images/UB/UB-4.406-a.webp new file mode 100644 index 0000000..153f2d3 Binary files /dev/null and b/data/images/UB/UB-4.406-a.webp differ diff --git a/data/images/UB/UB-4.406-b.webp b/data/images/UB/UB-4.406-b.webp new file mode 100644 index 0000000..6e80ed8 Binary files /dev/null and b/data/images/UB/UB-4.406-b.webp differ diff --git a/data/images/UB/UB-4.407-a.webp b/data/images/UB/UB-4.407-a.webp new file mode 100644 index 0000000..a737e80 Binary files /dev/null and b/data/images/UB/UB-4.407-a.webp differ diff --git a/data/images/UB/UB-4.407-b.webp b/data/images/UB/UB-4.407-b.webp new file mode 100644 index 0000000..1bc5e16 Binary files /dev/null and b/data/images/UB/UB-4.407-b.webp differ diff --git a/data/images/UB/UB-4.501-a.webp b/data/images/UB/UB-4.501-a.webp new file mode 100644 index 0000000..cae4927 Binary files /dev/null and b/data/images/UB/UB-4.501-a.webp differ diff --git a/data/images/UB/UB-4.502-a.webp b/data/images/UB/UB-4.502-a.webp new file mode 100644 index 0000000..f16dacf Binary files /dev/null and b/data/images/UB/UB-4.502-a.webp differ diff --git a/data/images/UB/UB-4.502-b.webp b/data/images/UB/UB-4.502-b.webp new file mode 100644 index 0000000..ea12796 Binary files /dev/null and b/data/images/UB/UB-4.502-b.webp differ diff --git a/data/images/UB/UB-4.503-a.webp b/data/images/UB/UB-4.503-a.webp new file mode 100644 index 0000000..f791392 Binary files /dev/null and b/data/images/UB/UB-4.503-a.webp differ diff --git a/data/images/UB/UB-4.503-b.webp b/data/images/UB/UB-4.503-b.webp new file mode 100644 index 0000000..e19bdd6 Binary files /dev/null and b/data/images/UB/UB-4.503-b.webp differ diff --git a/data/images/UB/UB-4.504-a.webp b/data/images/UB/UB-4.504-a.webp new file mode 100644 index 0000000..6b902fe Binary files /dev/null and b/data/images/UB/UB-4.504-a.webp differ diff --git a/data/images/UB/UB-4.505-a.webp b/data/images/UB/UB-4.505-a.webp new file mode 100644 index 0000000..08923f3 Binary files /dev/null and b/data/images/UB/UB-4.505-a.webp differ diff --git a/data/images/UB/UB-4.506-a.webp b/data/images/UB/UB-4.506-a.webp new file mode 100644 index 0000000..c74b189 Binary files /dev/null and b/data/images/UB/UB-4.506-a.webp differ diff --git a/data/images/UB/UB-4.507-a.webp b/data/images/UB/UB-4.507-a.webp new file mode 100644 index 0000000..21b767c Binary files /dev/null and b/data/images/UB/UB-4.507-a.webp differ diff --git a/data/images/UB/UB-4.508-a.webp b/data/images/UB/UB-4.508-a.webp new file mode 100644 index 0000000..e071871 Binary files /dev/null and b/data/images/UB/UB-4.508-a.webp differ diff --git a/data/images/UB/UB-4.508-b.webp b/data/images/UB/UB-4.508-b.webp new file mode 100644 index 0000000..8b5b410 Binary files /dev/null and b/data/images/UB/UB-4.508-b.webp differ diff --git a/data/images/UB/UB-4.508-c.webp b/data/images/UB/UB-4.508-c.webp new file mode 100644 index 0000000..8878867 Binary files /dev/null and b/data/images/UB/UB-4.508-c.webp differ diff --git a/data/images/UB/UB-4.602-a.webp b/data/images/UB/UB-4.602-a.webp new file mode 100644 index 0000000..9d55a4d Binary files /dev/null and b/data/images/UB/UB-4.602-a.webp differ diff --git a/data/images/UB/UB-4.608-a.webp b/data/images/UB/UB-4.608-a.webp new file mode 100644 index 0000000..252aaa6 Binary files /dev/null and b/data/images/UB/UB-4.608-a.webp differ diff --git a/data/images/UB/UB-4.708-a.webp b/data/images/UB/UB-4.708-a.webp new file mode 100644 index 0000000..2017e64 Binary files /dev/null and b/data/images/UB/UB-4.708-a.webp differ diff --git a/data/images/UB/UB-5.101-a.webp b/data/images/UB/UB-5.101-a.webp new file mode 100644 index 0000000..a8b50b5 Binary files /dev/null and b/data/images/UB/UB-5.101-a.webp differ diff --git a/data/images/UB/UB-5.104-a.webp b/data/images/UB/UB-5.104-a.webp new file mode 100644 index 0000000..5459284 Binary files /dev/null and b/data/images/UB/UB-5.104-a.webp differ diff --git a/data/images/UB/UB-5.105-a.webp b/data/images/UB/UB-5.105-a.webp new file mode 100644 index 0000000..a63fb87 Binary files /dev/null and b/data/images/UB/UB-5.105-a.webp differ diff --git a/data/images/UB/UB-5.106-a.webp b/data/images/UB/UB-5.106-a.webp new file mode 100644 index 0000000..c24b033 Binary files /dev/null and b/data/images/UB/UB-5.106-a.webp differ diff --git a/data/images/UB/UB-5.108-a.webp b/data/images/UB/UB-5.108-a.webp new file mode 100644 index 0000000..86d3d52 Binary files /dev/null and b/data/images/UB/UB-5.108-a.webp differ diff --git a/data/images/UB/UB-5.202-a.webp b/data/images/UB/UB-5.202-a.webp new file mode 100644 index 0000000..44b8299 Binary files /dev/null and b/data/images/UB/UB-5.202-a.webp differ diff --git a/data/images/UB/UB-5.207-a.webp b/data/images/UB/UB-5.207-a.webp new file mode 100644 index 0000000..59d4f34 Binary files /dev/null and b/data/images/UB/UB-5.207-a.webp differ diff --git a/data/images/UB/UB-5.301-a.webp b/data/images/UB/UB-5.301-a.webp new file mode 100644 index 0000000..f7751e2 Binary files /dev/null and b/data/images/UB/UB-5.301-a.webp differ diff --git a/data/images/UB/UB-5.301-b.webp b/data/images/UB/UB-5.301-b.webp new file mode 100644 index 0000000..9d98854 Binary files /dev/null and b/data/images/UB/UB-5.301-b.webp differ diff --git a/data/images/UB/UB-5.301-c.webp b/data/images/UB/UB-5.301-c.webp new file mode 100644 index 0000000..af8ca32 Binary files /dev/null and b/data/images/UB/UB-5.301-c.webp differ diff --git a/data/images/UB/UB-5.302-a.webp b/data/images/UB/UB-5.302-a.webp new file mode 100644 index 0000000..a356ac8 Binary files /dev/null and b/data/images/UB/UB-5.302-a.webp differ diff --git a/data/images/UB/UB-5.303-a.webp b/data/images/UB/UB-5.303-a.webp new file mode 100644 index 0000000..3da01db Binary files /dev/null and b/data/images/UB/UB-5.303-a.webp differ diff --git a/data/images/UB/UB-5.304-a.webp b/data/images/UB/UB-5.304-a.webp new file mode 100644 index 0000000..345bcef Binary files /dev/null and b/data/images/UB/UB-5.304-a.webp differ diff --git a/data/images/UB/UB-5.304-b.webp b/data/images/UB/UB-5.304-b.webp new file mode 100644 index 0000000..07b4812 Binary files /dev/null and b/data/images/UB/UB-5.304-b.webp differ diff --git a/data/images/UB/UB-5.305-a.webp b/data/images/UB/UB-5.305-a.webp new file mode 100644 index 0000000..8eebd95 Binary files /dev/null and b/data/images/UB/UB-5.305-a.webp differ diff --git a/data/images/UB/UB-5.305-b.webp b/data/images/UB/UB-5.305-b.webp new file mode 100644 index 0000000..c8a58cb Binary files /dev/null and b/data/images/UB/UB-5.305-b.webp differ diff --git a/data/images/UB/UB-5.306-a.webp b/data/images/UB/UB-5.306-a.webp new file mode 100644 index 0000000..ae2d0d8 Binary files /dev/null and b/data/images/UB/UB-5.306-a.webp differ diff --git a/data/images/UB/UB-5.306-b.webp b/data/images/UB/UB-5.306-b.webp new file mode 100644 index 0000000..28df1ec Binary files /dev/null and b/data/images/UB/UB-5.306-b.webp differ diff --git a/data/images/UB/UB-5.307-a.webp b/data/images/UB/UB-5.307-a.webp new file mode 100644 index 0000000..63bed2b Binary files /dev/null and b/data/images/UB/UB-5.307-a.webp differ diff --git a/data/images/UB/UB-5.308-a.webp b/data/images/UB/UB-5.308-a.webp new file mode 100644 index 0000000..c8862d0 Binary files /dev/null and b/data/images/UB/UB-5.308-a.webp differ diff --git a/data/images/UB/UB-5.308-b.webp b/data/images/UB/UB-5.308-b.webp new file mode 100644 index 0000000..51e9bcd Binary files /dev/null and b/data/images/UB/UB-5.308-b.webp differ diff --git a/data/images/UB/UB-5.308-c.webp b/data/images/UB/UB-5.308-c.webp new file mode 100644 index 0000000..ea7e62a Binary files /dev/null and b/data/images/UB/UB-5.308-c.webp differ diff --git a/data/images/UB/UB-5.402-a.webp b/data/images/UB/UB-5.402-a.webp new file mode 100644 index 0000000..f4a887b Binary files /dev/null and b/data/images/UB/UB-5.402-a.webp differ diff --git a/data/images/UB/UB-5.402-b.webp b/data/images/UB/UB-5.402-b.webp new file mode 100644 index 0000000..e8aca81 Binary files /dev/null and b/data/images/UB/UB-5.402-b.webp differ diff --git a/data/images/UB/UB-5.403-a.webp b/data/images/UB/UB-5.403-a.webp new file mode 100644 index 0000000..bce4590 Binary files /dev/null and b/data/images/UB/UB-5.403-a.webp differ diff --git a/data/images/UB/UB-5.404-a.webp b/data/images/UB/UB-5.404-a.webp new file mode 100644 index 0000000..0cc853a Binary files /dev/null and b/data/images/UB/UB-5.404-a.webp differ diff --git a/data/images/UB/UB-5.404-b.webp b/data/images/UB/UB-5.404-b.webp new file mode 100644 index 0000000..b1abe45 Binary files /dev/null and b/data/images/UB/UB-5.404-b.webp differ diff --git a/data/images/UB/UB-5.405-a.webp b/data/images/UB/UB-5.405-a.webp new file mode 100644 index 0000000..0b09061 Binary files /dev/null and b/data/images/UB/UB-5.405-a.webp differ diff --git a/data/images/UB/UB-5.406-a.webp b/data/images/UB/UB-5.406-a.webp new file mode 100644 index 0000000..f8ed1d5 Binary files /dev/null and b/data/images/UB/UB-5.406-a.webp differ diff --git a/data/images/UB/UB-5.408-a.webp b/data/images/UB/UB-5.408-a.webp new file mode 100644 index 0000000..7eff915 Binary files /dev/null and b/data/images/UB/UB-5.408-a.webp differ diff --git a/data/images/UB/UB-5.408-b.webp b/data/images/UB/UB-5.408-b.webp new file mode 100644 index 0000000..4765abd Binary files /dev/null and b/data/images/UB/UB-5.408-b.webp differ diff --git a/data/images/UB/UB-5.409-a.webp b/data/images/UB/UB-5.409-a.webp new file mode 100644 index 0000000..9615aa8 Binary files /dev/null and b/data/images/UB/UB-5.409-a.webp differ diff --git a/data/images/UB/UB-5.410-a.webp b/data/images/UB/UB-5.410-a.webp new file mode 100644 index 0000000..fbe4f7d Binary files /dev/null and b/data/images/UB/UB-5.410-a.webp differ diff --git a/data/images/UB/UB-5.410-b.webp b/data/images/UB/UB-5.410-b.webp new file mode 100644 index 0000000..d39245b Binary files /dev/null and b/data/images/UB/UB-5.410-b.webp differ diff --git a/data/images/UB/UB-5.501-a.webp b/data/images/UB/UB-5.501-a.webp new file mode 100644 index 0000000..0f264b4 Binary files /dev/null and b/data/images/UB/UB-5.501-a.webp differ diff --git a/data/images/UB/UB-5.501-b.webp b/data/images/UB/UB-5.501-b.webp new file mode 100644 index 0000000..98373cf Binary files /dev/null and b/data/images/UB/UB-5.501-b.webp differ diff --git a/data/images/UB/UB-5.502-a.webp b/data/images/UB/UB-5.502-a.webp new file mode 100644 index 0000000..e87cf1f Binary files /dev/null and b/data/images/UB/UB-5.502-a.webp differ diff --git a/data/images/UB/UB-5.502-b.webp b/data/images/UB/UB-5.502-b.webp new file mode 100644 index 0000000..cf1675c Binary files /dev/null and b/data/images/UB/UB-5.502-b.webp differ diff --git a/data/images/UB/UB-5.508-a.webp b/data/images/UB/UB-5.508-a.webp new file mode 100644 index 0000000..4a1999a Binary files /dev/null and b/data/images/UB/UB-5.508-a.webp differ diff --git a/data/images/UB/UB-5.509-a.webp b/data/images/UB/UB-5.509-a.webp new file mode 100644 index 0000000..3785b95 Binary files /dev/null and b/data/images/UB/UB-5.509-a.webp differ diff --git a/data/images/UB/UB-5.509-b.webp b/data/images/UB/UB-5.509-b.webp new file mode 100644 index 0000000..2d97567 Binary files /dev/null and b/data/images/UB/UB-5.509-b.webp differ diff --git a/data/images/UB/UB-5.510-a.webp b/data/images/UB/UB-5.510-a.webp new file mode 100644 index 0000000..799c7b7 Binary files /dev/null and b/data/images/UB/UB-5.510-a.webp differ diff --git a/data/images/UB/UB-5.510-b.webp b/data/images/UB/UB-5.510-b.webp new file mode 100644 index 0000000..f1d4527 Binary files /dev/null and b/data/images/UB/UB-5.510-b.webp differ diff --git a/data/images/UB/UB-5.607-a.webp b/data/images/UB/UB-5.607-a.webp new file mode 100644 index 0000000..14f6df3 Binary files /dev/null and b/data/images/UB/UB-5.607-a.webp differ diff --git a/data/images/UB/UB-5.610-a.webp b/data/images/UB/UB-5.610-a.webp new file mode 100644 index 0000000..5751f81 Binary files /dev/null and b/data/images/UB/UB-5.610-a.webp differ diff --git a/data/images/UB/UB-5.610-b.webp b/data/images/UB/UB-5.610-b.webp new file mode 100644 index 0000000..5308730 Binary files /dev/null and b/data/images/UB/UB-5.610-b.webp differ diff --git a/data/images/UB/UB-6.101-a.webp b/data/images/UB/UB-6.101-a.webp new file mode 100644 index 0000000..76cc302 Binary files /dev/null and b/data/images/UB/UB-6.101-a.webp differ diff --git a/data/images/UB/UB-6.101-b.webp b/data/images/UB/UB-6.101-b.webp new file mode 100644 index 0000000..af187a7 Binary files /dev/null and b/data/images/UB/UB-6.101-b.webp differ diff --git a/data/images/UB/UB-6.102-a.webp b/data/images/UB/UB-6.102-a.webp new file mode 100644 index 0000000..d133320 Binary files /dev/null and b/data/images/UB/UB-6.102-a.webp differ diff --git a/data/images/UB/UB-6.102-b.webp b/data/images/UB/UB-6.102-b.webp new file mode 100644 index 0000000..19111cf Binary files /dev/null and b/data/images/UB/UB-6.102-b.webp differ diff --git a/data/images/UB/UB-6.104-a.webp b/data/images/UB/UB-6.104-a.webp new file mode 100644 index 0000000..0b5ee6a Binary files /dev/null and b/data/images/UB/UB-6.104-a.webp differ diff --git a/data/images/UB/UB-6.104-b.webp b/data/images/UB/UB-6.104-b.webp new file mode 100644 index 0000000..65e7c4d Binary files /dev/null and b/data/images/UB/UB-6.104-b.webp differ diff --git a/data/images/UB/UB-6.104-c.webp b/data/images/UB/UB-6.104-c.webp new file mode 100644 index 0000000..7e1e866 Binary files /dev/null and b/data/images/UB/UB-6.104-c.webp differ diff --git a/data/images/UB/UB-6.108-a.webp b/data/images/UB/UB-6.108-a.webp new file mode 100644 index 0000000..f9f2d7f Binary files /dev/null and b/data/images/UB/UB-6.108-a.webp differ diff --git a/data/images/UB/UB-6.108-b.webp b/data/images/UB/UB-6.108-b.webp new file mode 100644 index 0000000..fdd64f4 Binary files /dev/null and b/data/images/UB/UB-6.108-b.webp differ diff --git a/data/images/UB/UB-6.108-c.webp b/data/images/UB/UB-6.108-c.webp new file mode 100644 index 0000000..f2d41dd Binary files /dev/null and b/data/images/UB/UB-6.108-c.webp differ diff --git a/data/images/UB/UB-6.108-d.webp b/data/images/UB/UB-6.108-d.webp new file mode 100644 index 0000000..8bd714e Binary files /dev/null and b/data/images/UB/UB-6.108-d.webp differ diff --git a/data/images/UB/UB-6.201-a.webp b/data/images/UB/UB-6.201-a.webp new file mode 100644 index 0000000..481c189 Binary files /dev/null and b/data/images/UB/UB-6.201-a.webp differ diff --git a/data/images/UB/UB-6.201-b.webp b/data/images/UB/UB-6.201-b.webp new file mode 100644 index 0000000..597e90b Binary files /dev/null and b/data/images/UB/UB-6.201-b.webp differ diff --git a/data/images/UB/UB-6.204-a.webp b/data/images/UB/UB-6.204-a.webp new file mode 100644 index 0000000..7cd4902 Binary files /dev/null and b/data/images/UB/UB-6.204-a.webp differ diff --git a/data/images/UB/UB-6.204-b.webp b/data/images/UB/UB-6.204-b.webp new file mode 100644 index 0000000..76e893e Binary files /dev/null and b/data/images/UB/UB-6.204-b.webp differ diff --git a/data/images/UB/UB-6.204-c.webp b/data/images/UB/UB-6.204-c.webp new file mode 100644 index 0000000..71edf7a Binary files /dev/null and b/data/images/UB/UB-6.204-c.webp differ diff --git a/data/images/UB/UB-6.204-d.webp b/data/images/UB/UB-6.204-d.webp new file mode 100644 index 0000000..822db73 Binary files /dev/null and b/data/images/UB/UB-6.204-d.webp differ diff --git a/data/images/UB/UB-6.205-a.webp b/data/images/UB/UB-6.205-a.webp new file mode 100644 index 0000000..4d5c649 Binary files /dev/null and b/data/images/UB/UB-6.205-a.webp differ diff --git a/data/images/UB/UB-6.208-a.webp b/data/images/UB/UB-6.208-a.webp new file mode 100644 index 0000000..f12694c Binary files /dev/null and b/data/images/UB/UB-6.208-a.webp differ diff --git a/data/images/UB/UB-6.208-b.webp b/data/images/UB/UB-6.208-b.webp new file mode 100644 index 0000000..1787d65 Binary files /dev/null and b/data/images/UB/UB-6.208-b.webp differ diff --git a/data/images/UB/UB-6.208-c.webp b/data/images/UB/UB-6.208-c.webp new file mode 100644 index 0000000..5d1b3bd Binary files /dev/null and b/data/images/UB/UB-6.208-c.webp differ diff --git a/data/images/UB/UB-6.208-d.webp b/data/images/UB/UB-6.208-d.webp new file mode 100644 index 0000000..1517e17 Binary files /dev/null and b/data/images/UB/UB-6.208-d.webp differ diff --git a/data/images/UB/UB-6.209-a.webp b/data/images/UB/UB-6.209-a.webp new file mode 100644 index 0000000..0614699 Binary files /dev/null and b/data/images/UB/UB-6.209-a.webp differ diff --git a/data/images/UB/UB-6.209-b.webp b/data/images/UB/UB-6.209-b.webp new file mode 100644 index 0000000..241b63a Binary files /dev/null and b/data/images/UB/UB-6.209-b.webp differ diff --git a/data/images/UB/UB-6.210-a.webp b/data/images/UB/UB-6.210-a.webp new file mode 100644 index 0000000..98f3cf6 Binary files /dev/null and b/data/images/UB/UB-6.210-a.webp differ diff --git a/data/images/UB/UB-6.301-a.webp b/data/images/UB/UB-6.301-a.webp new file mode 100644 index 0000000..3938ef3 Binary files /dev/null and b/data/images/UB/UB-6.301-a.webp differ diff --git a/data/images/UB/UB-6.301-b.webp b/data/images/UB/UB-6.301-b.webp new file mode 100644 index 0000000..f56a031 Binary files /dev/null and b/data/images/UB/UB-6.301-b.webp differ diff --git a/data/images/UB/UB-6.302-a.webp b/data/images/UB/UB-6.302-a.webp new file mode 100644 index 0000000..092da60 Binary files /dev/null and b/data/images/UB/UB-6.302-a.webp differ diff --git a/data/images/UB/UB-6.302-b.webp b/data/images/UB/UB-6.302-b.webp new file mode 100644 index 0000000..adcf634 Binary files /dev/null and b/data/images/UB/UB-6.302-b.webp differ diff --git a/data/images/UB/UB-6.303-a.webp b/data/images/UB/UB-6.303-a.webp new file mode 100644 index 0000000..3817942 Binary files /dev/null and b/data/images/UB/UB-6.303-a.webp differ diff --git a/data/images/UB/UB-6.303-b.webp b/data/images/UB/UB-6.303-b.webp new file mode 100644 index 0000000..561c929 Binary files /dev/null and b/data/images/UB/UB-6.303-b.webp differ diff --git a/data/images/UB/UB-6.303-c.webp b/data/images/UB/UB-6.303-c.webp new file mode 100644 index 0000000..2399388 Binary files /dev/null and b/data/images/UB/UB-6.303-c.webp differ diff --git a/data/images/UB/UB-6.303-d.webp b/data/images/UB/UB-6.303-d.webp new file mode 100644 index 0000000..d2d678f Binary files /dev/null and b/data/images/UB/UB-6.303-d.webp differ diff --git a/data/images/UB/UB-6.304-a.webp b/data/images/UB/UB-6.304-a.webp new file mode 100644 index 0000000..9e5c8a7 Binary files /dev/null and b/data/images/UB/UB-6.304-a.webp differ diff --git a/data/images/UB/UB-6.304-b.webp b/data/images/UB/UB-6.304-b.webp new file mode 100644 index 0000000..75fe56c Binary files /dev/null and b/data/images/UB/UB-6.304-b.webp differ diff --git a/data/images/UB/UB-6.305-a.webp b/data/images/UB/UB-6.305-a.webp new file mode 100644 index 0000000..f1ee29d Binary files /dev/null and b/data/images/UB/UB-6.305-a.webp differ diff --git a/data/images/UB/UB-6.308-a.webp b/data/images/UB/UB-6.308-a.webp new file mode 100644 index 0000000..93f1a17 Binary files /dev/null and b/data/images/UB/UB-6.308-a.webp differ diff --git a/data/images/UB/UB-6.308-b.webp b/data/images/UB/UB-6.308-b.webp new file mode 100644 index 0000000..597a47d Binary files /dev/null and b/data/images/UB/UB-6.308-b.webp differ diff --git a/data/images/UB/UB-6.308-c.webp b/data/images/UB/UB-6.308-c.webp new file mode 100644 index 0000000..1c0bb54 Binary files /dev/null and b/data/images/UB/UB-6.308-c.webp differ diff --git a/data/images/UB/UB-6.309-a.webp b/data/images/UB/UB-6.309-a.webp new file mode 100644 index 0000000..cbc669c Binary files /dev/null and b/data/images/UB/UB-6.309-a.webp differ diff --git a/data/images/UB/UB-6.309-b.webp b/data/images/UB/UB-6.309-b.webp new file mode 100644 index 0000000..221304c Binary files /dev/null and b/data/images/UB/UB-6.309-b.webp differ diff --git a/data/images/UB/UB-6.310-a.webp b/data/images/UB/UB-6.310-a.webp new file mode 100644 index 0000000..4aab68c Binary files /dev/null and b/data/images/UB/UB-6.310-a.webp differ diff --git a/data/images/UB/UB-6.310-b.webp b/data/images/UB/UB-6.310-b.webp new file mode 100644 index 0000000..c69f8cc Binary files /dev/null and b/data/images/UB/UB-6.310-b.webp differ diff --git a/data/images/UB/UB-6.310-c.webp b/data/images/UB/UB-6.310-c.webp new file mode 100644 index 0000000..ae2a89f Binary files /dev/null and b/data/images/UB/UB-6.310-c.webp differ diff --git a/data/images/UB/UB-6.401-a.webp b/data/images/UB/UB-6.401-a.webp new file mode 100644 index 0000000..25f00e1 Binary files /dev/null and b/data/images/UB/UB-6.401-a.webp differ diff --git a/data/images/UB/UB-6.401-b.webp b/data/images/UB/UB-6.401-b.webp new file mode 100644 index 0000000..25f00d3 Binary files /dev/null and b/data/images/UB/UB-6.401-b.webp differ diff --git a/data/images/UB/UB-6.402-a.webp b/data/images/UB/UB-6.402-a.webp new file mode 100644 index 0000000..8441d6f Binary files /dev/null and b/data/images/UB/UB-6.402-a.webp differ diff --git a/data/images/UB/UB-6.402-b.webp b/data/images/UB/UB-6.402-b.webp new file mode 100644 index 0000000..706a31e Binary files /dev/null and b/data/images/UB/UB-6.402-b.webp differ diff --git a/data/images/UB/UB-6.402-c.webp b/data/images/UB/UB-6.402-c.webp new file mode 100644 index 0000000..46019d4 Binary files /dev/null and b/data/images/UB/UB-6.402-c.webp differ diff --git a/data/images/UB/UB-6.403-a.webp b/data/images/UB/UB-6.403-a.webp new file mode 100644 index 0000000..90ba48b Binary files /dev/null and b/data/images/UB/UB-6.403-a.webp differ diff --git a/data/images/UB/UB-6.403-b.webp b/data/images/UB/UB-6.403-b.webp new file mode 100644 index 0000000..02ca548 Binary files /dev/null and b/data/images/UB/UB-6.403-b.webp differ diff --git a/data/images/UB/UB-6.403-c.webp b/data/images/UB/UB-6.403-c.webp new file mode 100644 index 0000000..0310ab7 Binary files /dev/null and b/data/images/UB/UB-6.403-c.webp differ diff --git a/data/images/UB/UB-6.404-a.webp b/data/images/UB/UB-6.404-a.webp new file mode 100644 index 0000000..d9938df Binary files /dev/null and b/data/images/UB/UB-6.404-a.webp differ diff --git a/data/images/UB/UB-6.405-a.webp b/data/images/UB/UB-6.405-a.webp new file mode 100644 index 0000000..26b297b Binary files /dev/null and b/data/images/UB/UB-6.405-a.webp differ diff --git a/data/images/UB/UB-6.405-b.webp b/data/images/UB/UB-6.405-b.webp new file mode 100644 index 0000000..af289f4 Binary files /dev/null and b/data/images/UB/UB-6.405-b.webp differ diff --git a/data/images/UB/UB-6.406-a.webp b/data/images/UB/UB-6.406-a.webp new file mode 100644 index 0000000..8a1afc4 Binary files /dev/null and b/data/images/UB/UB-6.406-a.webp differ diff --git a/data/images/UB/UB-6.407-a.webp b/data/images/UB/UB-6.407-a.webp new file mode 100644 index 0000000..eb78ef8 Binary files /dev/null and b/data/images/UB/UB-6.407-a.webp differ diff --git a/data/images/UB/UB-6.407-b.webp b/data/images/UB/UB-6.407-b.webp new file mode 100644 index 0000000..91441c5 Binary files /dev/null and b/data/images/UB/UB-6.407-b.webp differ diff --git a/data/images/UB/UB-6.408-a.webp b/data/images/UB/UB-6.408-a.webp new file mode 100644 index 0000000..ce3de6e Binary files /dev/null and b/data/images/UB/UB-6.408-a.webp differ diff --git a/data/images/UB/UB-6.408-b.webp b/data/images/UB/UB-6.408-b.webp new file mode 100644 index 0000000..e9cd38b Binary files /dev/null and b/data/images/UB/UB-6.408-b.webp differ diff --git a/data/images/UB/UB-6.409-a.webp b/data/images/UB/UB-6.409-a.webp new file mode 100644 index 0000000..887f2b3 Binary files /dev/null and b/data/images/UB/UB-6.409-a.webp differ diff --git a/data/images/UB/UB-6.409-b.webp b/data/images/UB/UB-6.409-b.webp new file mode 100644 index 0000000..69327bb Binary files /dev/null and b/data/images/UB/UB-6.409-b.webp differ diff --git a/data/images/UB/UB-6.502-a.webp b/data/images/UB/UB-6.502-a.webp new file mode 100644 index 0000000..c236c6a Binary files /dev/null and b/data/images/UB/UB-6.502-a.webp differ diff --git a/data/images/UB/UB-6.504-a.webp b/data/images/UB/UB-6.504-a.webp new file mode 100644 index 0000000..1eb41c2 Binary files /dev/null and b/data/images/UB/UB-6.504-a.webp differ diff --git a/data/images/UB/UB-6.505-a.webp b/data/images/UB/UB-6.505-a.webp new file mode 100644 index 0000000..e7fabfd Binary files /dev/null and b/data/images/UB/UB-6.505-a.webp differ diff --git a/data/images/UB/UB-6.507-a.webp b/data/images/UB/UB-6.507-a.webp new file mode 100644 index 0000000..7d72fd8 Binary files /dev/null and b/data/images/UB/UB-6.507-a.webp differ diff --git a/data/images/UB/UB-6.507-b.webp b/data/images/UB/UB-6.507-b.webp new file mode 100644 index 0000000..486a1db Binary files /dev/null and b/data/images/UB/UB-6.507-b.webp differ diff --git a/data/images/UB/UB-6.508-a.webp b/data/images/UB/UB-6.508-a.webp new file mode 100644 index 0000000..32d753d Binary files /dev/null and b/data/images/UB/UB-6.508-a.webp differ diff --git a/data/images/UB/UB-6.508-b.webp b/data/images/UB/UB-6.508-b.webp new file mode 100644 index 0000000..120e4db Binary files /dev/null and b/data/images/UB/UB-6.508-b.webp differ diff --git a/data/images/UB/UB-6.508-c.webp b/data/images/UB/UB-6.508-c.webp new file mode 100644 index 0000000..a3be60b Binary files /dev/null and b/data/images/UB/UB-6.508-c.webp differ diff --git a/data/images/UB/UB-6.509-a.webp b/data/images/UB/UB-6.509-a.webp new file mode 100644 index 0000000..2730885 Binary files /dev/null and b/data/images/UB/UB-6.509-a.webp differ diff --git a/data/images/UB/UB-6.509-b.webp b/data/images/UB/UB-6.509-b.webp new file mode 100644 index 0000000..e6fb1ae Binary files /dev/null and b/data/images/UB/UB-6.509-b.webp differ diff --git a/data/images/UB/UB-6.605-a.webp b/data/images/UB/UB-6.605-a.webp new file mode 100644 index 0000000..b5ede3a Binary files /dev/null and b/data/images/UB/UB-6.605-a.webp differ diff --git a/data/images/UB/UB-6.606-a.webp b/data/images/UB/UB-6.606-a.webp new file mode 100644 index 0000000..d2aa1f7 Binary files /dev/null and b/data/images/UB/UB-6.606-a.webp differ diff --git a/data/images/UB/UB-6.608-a.webp b/data/images/UB/UB-6.608-a.webp new file mode 100644 index 0000000..da3f914 Binary files /dev/null and b/data/images/UB/UB-6.608-a.webp differ diff --git a/data/images/UB/UB-6.609-a.webp b/data/images/UB/UB-6.609-a.webp new file mode 100644 index 0000000..a850c5c Binary files /dev/null and b/data/images/UB/UB-6.609-a.webp differ diff --git a/data/images/UB/UB-6.609-b.webp b/data/images/UB/UB-6.609-b.webp new file mode 100644 index 0000000..f0c48fe Binary files /dev/null and b/data/images/UB/UB-6.609-b.webp differ diff --git a/data/images/UB/UB-6.706-a.webp b/data/images/UB/UB-6.706-a.webp new file mode 100644 index 0000000..0e26d62 Binary files /dev/null and b/data/images/UB/UB-6.706-a.webp differ diff --git a/data/images/UB/UB-6.707-a.webp b/data/images/UB/UB-6.707-a.webp new file mode 100644 index 0000000..be7d90d Binary files /dev/null and b/data/images/UB/UB-6.707-a.webp differ diff --git a/data/images/VT/VT-1.00-a.webp b/data/images/VT/VT-1.00-a.webp new file mode 100644 index 0000000..fd40805 Binary files /dev/null and b/data/images/VT/VT-1.00-a.webp differ diff --git a/data/images/VT/VT-1.01-a.webp b/data/images/VT/VT-1.01-a.webp new file mode 100644 index 0000000..e248705 Binary files /dev/null and b/data/images/VT/VT-1.01-a.webp differ diff --git a/data/images/VT/VT-1.02-a.webp b/data/images/VT/VT-1.02-a.webp new file mode 100644 index 0000000..000b0ea Binary files /dev/null and b/data/images/VT/VT-1.02-a.webp differ diff --git a/data/images/VT/VT-1.03-a.webp b/data/images/VT/VT-1.03-a.webp new file mode 100644 index 0000000..9051002 Binary files /dev/null and b/data/images/VT/VT-1.03-a.webp differ diff --git a/data/images/VT/VT-1.04-a.webp b/data/images/VT/VT-1.04-a.webp new file mode 100644 index 0000000..aa66c0b Binary files /dev/null and b/data/images/VT/VT-1.04-a.webp differ diff --git a/data/images/VT/VT-1.05-a.webp b/data/images/VT/VT-1.05-a.webp new file mode 100644 index 0000000..6fb84f0 Binary files /dev/null and b/data/images/VT/VT-1.05-a.webp differ diff --git a/data/images/VT/VT-1.10-a.webp b/data/images/VT/VT-1.10-a.webp new file mode 100644 index 0000000..5e4aae0 Binary files /dev/null and b/data/images/VT/VT-1.10-a.webp differ diff --git a/data/images/VT/VT-1.11-a.webp b/data/images/VT/VT-1.11-a.webp new file mode 100644 index 0000000..985d6fa Binary files /dev/null and b/data/images/VT/VT-1.11-a.webp differ diff --git a/data/images/VT/VT-1.12-a.webp b/data/images/VT/VT-1.12-a.webp new file mode 100644 index 0000000..588a4b4 Binary files /dev/null and b/data/images/VT/VT-1.12-a.webp differ diff --git a/data/images/VT/VT-1.20-a.webp b/data/images/VT/VT-1.20-a.webp new file mode 100644 index 0000000..d2e66f4 Binary files /dev/null and b/data/images/VT/VT-1.20-a.webp differ diff --git a/data/images/VT/VT-1.21-a.webp b/data/images/VT/VT-1.21-a.webp new file mode 100644 index 0000000..148ec15 Binary files /dev/null and b/data/images/VT/VT-1.21-a.webp differ diff --git a/data/images/VT/VT-1.22-a.webp b/data/images/VT/VT-1.22-a.webp new file mode 100644 index 0000000..79bc783 Binary files /dev/null and b/data/images/VT/VT-1.22-a.webp differ diff --git a/data/images/VT/VT-1.23-a.webp b/data/images/VT/VT-1.23-a.webp new file mode 100644 index 0000000..f410cc9 Binary files /dev/null and b/data/images/VT/VT-1.23-a.webp differ diff --git a/data/images/VT/VT-1.24-a.webp b/data/images/VT/VT-1.24-a.webp new file mode 100644 index 0000000..9babd20 Binary files /dev/null and b/data/images/VT/VT-1.24-a.webp differ diff --git a/data/images/VT/VT-1.30-a.webp b/data/images/VT/VT-1.30-a.webp new file mode 100644 index 0000000..0e7f574 Binary files /dev/null and b/data/images/VT/VT-1.30-a.webp differ diff --git a/data/images/VT/VT-1.31-a.webp b/data/images/VT/VT-1.31-a.webp new file mode 100644 index 0000000..280ba40 Binary files /dev/null and b/data/images/VT/VT-1.31-a.webp differ diff --git a/data/images/VT/VT-1.40-a.webp b/data/images/VT/VT-1.40-a.webp new file mode 100644 index 0000000..65ad9fb Binary files /dev/null and b/data/images/VT/VT-1.40-a.webp differ diff --git a/data/images/VT/VT-1.50-a.webp b/data/images/VT/VT-1.50-a.webp new file mode 100644 index 0000000..3af1640 Binary files /dev/null and b/data/images/VT/VT-1.50-a.webp differ diff --git a/data/images/VT/VT-1.51-a.webp b/data/images/VT/VT-1.51-a.webp new file mode 100644 index 0000000..df076c2 Binary files /dev/null and b/data/images/VT/VT-1.51-a.webp differ diff --git a/data/images/VT/VT-1.52-a.webp b/data/images/VT/VT-1.52-a.webp new file mode 100644 index 0000000..8ab7f33 Binary files /dev/null and b/data/images/VT/VT-1.52-a.webp differ diff --git a/data/images/VT/VT-1.53-a.webp b/data/images/VT/VT-1.53-a.webp new file mode 100644 index 0000000..1104304 Binary files /dev/null and b/data/images/VT/VT-1.53-a.webp differ diff --git a/data/images/VT/VT-1.60-a.webp b/data/images/VT/VT-1.60-a.webp new file mode 100644 index 0000000..574173b Binary files /dev/null and b/data/images/VT/VT-1.60-a.webp differ diff --git a/data/images/VT/VT-1.61-a.webp b/data/images/VT/VT-1.61-a.webp new file mode 100644 index 0000000..a9b183c Binary files /dev/null and b/data/images/VT/VT-1.61-a.webp differ diff --git a/data/images/VT/VT-1.62-a.webp b/data/images/VT/VT-1.62-a.webp new file mode 100644 index 0000000..b028ead Binary files /dev/null and b/data/images/VT/VT-1.62-a.webp differ diff --git a/data/images/VT/VT-2.10-a.webp b/data/images/VT/VT-2.10-a.webp new file mode 100644 index 0000000..5c2965f Binary files /dev/null and b/data/images/VT/VT-2.10-a.webp differ diff --git a/data/images/VT/VT-2.11-a.webp b/data/images/VT/VT-2.11-a.webp new file mode 100644 index 0000000..81f6954 Binary files /dev/null and b/data/images/VT/VT-2.11-a.webp differ diff --git a/data/images/VT/VT-2.11-b.webp b/data/images/VT/VT-2.11-b.webp new file mode 100644 index 0000000..c6f6684 Binary files /dev/null and b/data/images/VT/VT-2.11-b.webp differ diff --git a/data/images/VT/VT-2.12-a.webp b/data/images/VT/VT-2.12-a.webp new file mode 100644 index 0000000..3720fbb Binary files /dev/null and b/data/images/VT/VT-2.12-a.webp differ diff --git a/data/images/VT/VT-2.13-a.webp b/data/images/VT/VT-2.13-a.webp new file mode 100644 index 0000000..d7054c5 Binary files /dev/null and b/data/images/VT/VT-2.13-a.webp differ diff --git a/data/images/VT/VT-2.20-a.webp b/data/images/VT/VT-2.20-a.webp new file mode 100644 index 0000000..680c564 Binary files /dev/null and b/data/images/VT/VT-2.20-a.webp differ diff --git a/data/images/VT/VT-2.21-a.webp b/data/images/VT/VT-2.21-a.webp new file mode 100644 index 0000000..3ffc72f Binary files /dev/null and b/data/images/VT/VT-2.21-a.webp differ diff --git a/data/images/VT/VT-2.21-b.webp b/data/images/VT/VT-2.21-b.webp new file mode 100644 index 0000000..2fe55df Binary files /dev/null and b/data/images/VT/VT-2.21-b.webp differ diff --git a/data/images/VT/VT-2.22-a.webp b/data/images/VT/VT-2.22-a.webp new file mode 100644 index 0000000..a7bc8be Binary files /dev/null and b/data/images/VT/VT-2.22-a.webp differ diff --git a/data/images/VT/VT-2.30-a.webp b/data/images/VT/VT-2.30-a.webp new file mode 100644 index 0000000..9f935ef Binary files /dev/null and b/data/images/VT/VT-2.30-a.webp differ diff --git a/data/images/VT/VT-2.31-a.webp b/data/images/VT/VT-2.31-a.webp new file mode 100644 index 0000000..b1f3e4c Binary files /dev/null and b/data/images/VT/VT-2.31-a.webp differ diff --git a/data/images/VT/VT-2.32-a.webp b/data/images/VT/VT-2.32-a.webp new file mode 100644 index 0000000..b7ebcbd Binary files /dev/null and b/data/images/VT/VT-2.32-a.webp differ diff --git a/data/images/VT/VT-2.33-a.webp b/data/images/VT/VT-2.33-a.webp new file mode 100644 index 0000000..32267c3 Binary files /dev/null and b/data/images/VT/VT-2.33-a.webp differ diff --git a/data/images/VT/VT-2.34-a.webp b/data/images/VT/VT-2.34-a.webp new file mode 100644 index 0000000..9e1688c Binary files /dev/null and b/data/images/VT/VT-2.34-a.webp differ diff --git a/data/images/VT/VT-2.40-a.webp b/data/images/VT/VT-2.40-a.webp new file mode 100644 index 0000000..c26e825 Binary files /dev/null and b/data/images/VT/VT-2.40-a.webp differ diff --git a/data/images/VT/VT-2.41-a.webp b/data/images/VT/VT-2.41-a.webp new file mode 100644 index 0000000..650f4d0 Binary files /dev/null and b/data/images/VT/VT-2.41-a.webp differ diff --git a/data/images/VT/VT-2.50-a.webp b/data/images/VT/VT-2.50-a.webp new file mode 100644 index 0000000..9d1a867 Binary files /dev/null and b/data/images/VT/VT-2.50-a.webp differ diff --git a/data/images/VT/VT-3.10-a.webp b/data/images/VT/VT-3.10-a.webp new file mode 100644 index 0000000..dd5cb43 Binary files /dev/null and b/data/images/VT/VT-3.10-a.webp differ diff --git a/data/images/VT/VT-3.11-a.webp b/data/images/VT/VT-3.11-a.webp new file mode 100644 index 0000000..25d167f Binary files /dev/null and b/data/images/VT/VT-3.11-a.webp differ diff --git a/data/images/VT/VT-3.12-a.webp b/data/images/VT/VT-3.12-a.webp new file mode 100644 index 0000000..cd70486 Binary files /dev/null and b/data/images/VT/VT-3.12-a.webp differ diff --git a/data/images/VT/VT-3.13-a.webp b/data/images/VT/VT-3.13-a.webp new file mode 100644 index 0000000..b10d0bd Binary files /dev/null and b/data/images/VT/VT-3.13-a.webp differ diff --git a/data/images/VT/VT-3.14-a.webp b/data/images/VT/VT-3.14-a.webp new file mode 100644 index 0000000..545a5b9 Binary files /dev/null and b/data/images/VT/VT-3.14-a.webp differ diff --git a/data/images/VT/VT-3.20-a.webp b/data/images/VT/VT-3.20-a.webp new file mode 100644 index 0000000..2a6425d Binary files /dev/null and b/data/images/VT/VT-3.20-a.webp differ diff --git a/data/images/VT/VT-3.30-a.webp b/data/images/VT/VT-3.30-a.webp new file mode 100644 index 0000000..171ca13 Binary files /dev/null and b/data/images/VT/VT-3.30-a.webp differ diff --git a/data/images/VT/VT-3.31-a.webp b/data/images/VT/VT-3.31-a.webp new file mode 100644 index 0000000..65e677b Binary files /dev/null and b/data/images/VT/VT-3.31-a.webp differ diff --git a/data/images/VT/VT-3.32-a.webp b/data/images/VT/VT-3.32-a.webp new file mode 100644 index 0000000..e3a934c Binary files /dev/null and b/data/images/VT/VT-3.32-a.webp differ diff --git a/data/images/VT/VT-3.33-a.webp b/data/images/VT/VT-3.33-a.webp new file mode 100644 index 0000000..25fb6bb Binary files /dev/null and b/data/images/VT/VT-3.33-a.webp differ diff --git a/data/images/VT/VT-3.34-a.webp b/data/images/VT/VT-3.34-a.webp new file mode 100644 index 0000000..70da8f1 Binary files /dev/null and b/data/images/VT/VT-3.34-a.webp differ diff --git a/data/images/VT/VT-3.35-a.webp b/data/images/VT/VT-3.35-a.webp new file mode 100644 index 0000000..6d6eeb5 Binary files /dev/null and b/data/images/VT/VT-3.35-a.webp differ diff --git a/data/images/VT/VT-4.10-a.webp b/data/images/VT/VT-4.10-a.webp new file mode 100644 index 0000000..fcd24c2 Binary files /dev/null and b/data/images/VT/VT-4.10-a.webp differ diff --git a/data/images/VT/VT-4.11-a.webp b/data/images/VT/VT-4.11-a.webp new file mode 100644 index 0000000..430a1db Binary files /dev/null and b/data/images/VT/VT-4.11-a.webp differ diff --git a/data/images/VT/VT-4.12-a.webp b/data/images/VT/VT-4.12-a.webp new file mode 100644 index 0000000..8741927 Binary files /dev/null and b/data/images/VT/VT-4.12-a.webp differ diff --git a/data/images/VT/VT-4.13-a.webp b/data/images/VT/VT-4.13-a.webp new file mode 100644 index 0000000..8dd1041 Binary files /dev/null and b/data/images/VT/VT-4.13-a.webp differ diff --git a/data/images/VT/VT-4.14-a.webp b/data/images/VT/VT-4.14-a.webp new file mode 100644 index 0000000..83b9bcd Binary files /dev/null and b/data/images/VT/VT-4.14-a.webp differ diff --git a/data/images/VT/VT-4.20-a.webp b/data/images/VT/VT-4.20-a.webp new file mode 100644 index 0000000..32f79d2 Binary files /dev/null and b/data/images/VT/VT-4.20-a.webp differ diff --git a/data/images/VT/VT-4.30-a.webp b/data/images/VT/VT-4.30-a.webp new file mode 100644 index 0000000..7745f5f Binary files /dev/null and b/data/images/VT/VT-4.30-a.webp differ diff --git a/data/images/VT/VT-4.31-a.webp b/data/images/VT/VT-4.31-a.webp new file mode 100644 index 0000000..0b12c07 Binary files /dev/null and b/data/images/VT/VT-4.31-a.webp differ diff --git a/data/images/VT/VT-4.32-a.webp b/data/images/VT/VT-4.32-a.webp new file mode 100644 index 0000000..97fb518 Binary files /dev/null and b/data/images/VT/VT-4.32-a.webp differ diff --git a/data/images/VT/VT-4.33-a.webp b/data/images/VT/VT-4.33-a.webp new file mode 100644 index 0000000..955fccd Binary files /dev/null and b/data/images/VT/VT-4.33-a.webp differ diff --git a/data/images/VT/VT-4.34-a.webp b/data/images/VT/VT-4.34-a.webp new file mode 100644 index 0000000..15ca4f2 Binary files /dev/null and b/data/images/VT/VT-4.34-a.webp differ diff --git a/data/images/VT/VT-4.35-a.webp b/data/images/VT/VT-4.35-a.webp new file mode 100644 index 0000000..e2d9716 Binary files /dev/null and b/data/images/VT/VT-4.35-a.webp differ diff --git a/data/images/VT/VT-4.40-a.webp b/data/images/VT/VT-4.40-a.webp new file mode 100644 index 0000000..e2189da Binary files /dev/null and b/data/images/VT/VT-4.40-a.webp differ diff --git a/data/images/VT/VT-4.41-a.webp b/data/images/VT/VT-4.41-a.webp new file mode 100644 index 0000000..7a154ee Binary files /dev/null and b/data/images/VT/VT-4.41-a.webp differ diff --git a/data/images/VT/VT-4.42-a.webp b/data/images/VT/VT-4.42-a.webp new file mode 100644 index 0000000..5056f82 Binary files /dev/null and b/data/images/VT/VT-4.42-a.webp differ diff --git a/data/images/VT/VT-4.50-a.webp b/data/images/VT/VT-4.50-a.webp new file mode 100644 index 0000000..47de777 Binary files /dev/null and b/data/images/VT/VT-4.50-a.webp differ diff --git a/data/images/VT/VT-4.51-a.webp b/data/images/VT/VT-4.51-a.webp new file mode 100644 index 0000000..cdaa50f Binary files /dev/null and b/data/images/VT/VT-4.51-a.webp differ diff --git a/data/images/VT/VT-4.52-a.webp b/data/images/VT/VT-4.52-a.webp new file mode 100644 index 0000000..34889b8 Binary files /dev/null and b/data/images/VT/VT-4.52-a.webp differ diff --git a/data/images/VT/VT-4.53-a.webp b/data/images/VT/VT-4.53-a.webp new file mode 100644 index 0000000..1e08ff4 Binary files /dev/null and b/data/images/VT/VT-4.53-a.webp differ diff --git a/data/images/VT/VT-4.62-a.webp b/data/images/VT/VT-4.62-a.webp new file mode 100644 index 0000000..19d9b17 Binary files /dev/null and b/data/images/VT/VT-4.62-a.webp differ diff --git a/data/images/VT/VT-5.10-a.webp b/data/images/VT/VT-5.10-a.webp new file mode 100644 index 0000000..c05bab3 Binary files /dev/null and b/data/images/VT/VT-5.10-a.webp differ diff --git a/data/images/VT/VT-5.11-a.webp b/data/images/VT/VT-5.11-a.webp new file mode 100644 index 0000000..56d2fed Binary files /dev/null and b/data/images/VT/VT-5.11-a.webp differ diff --git a/data/images/VT/VT-5.11-b.webp b/data/images/VT/VT-5.11-b.webp new file mode 100644 index 0000000..b073201 Binary files /dev/null and b/data/images/VT/VT-5.11-b.webp differ diff --git a/data/images/VT/VT-5.12-a.webp b/data/images/VT/VT-5.12-a.webp new file mode 100644 index 0000000..7707d7f Binary files /dev/null and b/data/images/VT/VT-5.12-a.webp differ diff --git a/data/images/VT/VT-5.13-a.webp b/data/images/VT/VT-5.13-a.webp new file mode 100644 index 0000000..bd8de15 Binary files /dev/null and b/data/images/VT/VT-5.13-a.webp differ diff --git a/data/images/VT/VT-5.20-a.webp b/data/images/VT/VT-5.20-a.webp new file mode 100644 index 0000000..f9a18d9 Binary files /dev/null and b/data/images/VT/VT-5.20-a.webp differ diff --git a/data/images/VT/VT-5.21-a.webp b/data/images/VT/VT-5.21-a.webp new file mode 100644 index 0000000..0d096db Binary files /dev/null and b/data/images/VT/VT-5.21-a.webp differ diff --git a/data/images/VT/VT-5.21-b.webp b/data/images/VT/VT-5.21-b.webp new file mode 100644 index 0000000..5e270f4 Binary files /dev/null and b/data/images/VT/VT-5.21-b.webp differ diff --git a/data/images/VT/VT-5.22-a.webp b/data/images/VT/VT-5.22-a.webp new file mode 100644 index 0000000..1259573 Binary files /dev/null and b/data/images/VT/VT-5.22-a.webp differ diff --git a/data/images/VT/VT-5.30-a.webp b/data/images/VT/VT-5.30-a.webp new file mode 100644 index 0000000..a51ba38 Binary files /dev/null and b/data/images/VT/VT-5.30-a.webp differ diff --git a/data/images/VT/VT-5.31-a.webp b/data/images/VT/VT-5.31-a.webp new file mode 100644 index 0000000..02c1dff Binary files /dev/null and b/data/images/VT/VT-5.31-a.webp differ diff --git a/data/images/VT/VT-5.32-a.webp b/data/images/VT/VT-5.32-a.webp new file mode 100644 index 0000000..0f3a9cd Binary files /dev/null and b/data/images/VT/VT-5.32-a.webp differ diff --git a/data/images/VT/VT-5.33-a.webp b/data/images/VT/VT-5.33-a.webp new file mode 100644 index 0000000..29d93e5 Binary files /dev/null and b/data/images/VT/VT-5.33-a.webp differ diff --git a/data/images/VT/VT-5.34-a.webp b/data/images/VT/VT-5.34-a.webp new file mode 100644 index 0000000..3721c8a Binary files /dev/null and b/data/images/VT/VT-5.34-a.webp differ diff --git a/data/variantGroups.json b/data/variantGroups.json new file mode 100644 index 0000000..69de57b --- /dev/null +++ b/data/variantGroups.json @@ -0,0 +1,834 @@ +[ + { + "id": "UB-1.101", + "variantIds": ["UB-1.101-a", "UB-1.101-b"] + }, + { + "id": "UB-1.102", + "variantIds": ["UB-1.102-a", "UB-1.102-b"] + }, + { + "id": "UB-1.103", + "variantIds": ["UB-1.103-a", "UB-1.103-b"] + }, + { + "id": "UB-1.203", + "variantIds": ["UB-1.203-a", "UB-1.203-b"] + }, + { + "id": "UB-1.204", + "variantIds": ["UB-1.204-a", "UB-1.204-b"] + }, + { + "id": "UB-1.206", + "variantIds": ["UB-1.206-a", "UB-1.206-b"] + }, + { + "id": "UB-1.210", + "variantIds": ["UB-1.210-a", "UB-1.210-b"] + }, + { + "id": "UB-1.307", + "variantIds": ["UB-1.307-a", "UB-1.307-b"] + }, + { + "id": "UB-1.310", + "variantIds": ["UB-1.310-a", "UB-1.310-b"] + }, + { + "id": "UB-1.406", + "variantIds": ["UB-1.406-a", "UB-1.406-b"] + }, + { + "id": "UB-1.409", + "variantIds": ["UB-1.409-a", "UB-1.409-b"] + }, + { + "id": "UB-1.411", + "variantIds": ["UB-1.411-a", "UB-1.411-b"] + }, + { + "id": "UB-2.101", + "variantIds": ["UB-2.101-a", "UB-2.101-b"] + }, + { + "id": "UB-2.105", + "variantIds": ["UB-2.105-a", "UB-2.105-b"] + }, + { + "id": "UB-2.201", + "variantIds": ["UB-2.201-a", "UB-2.201-b", "UB-2.201-c"] + }, + { + "id": "UB-2.202", + "variantIds": ["UB-2.202-a", "UB-2.202-b"] + }, + { + "id": "UB-2.302", + "variantIds": ["UB-2.302-a", "UB-2.302-b"] + }, + { + "id": "UB-2.305", + "variantIds": ["UB-2.305-a", "UB-2.305-b", "UB-2.305-c"] + }, + { + "id": "UB-2.404", + "variantIds": ["UB-2.404-a", "UB-2.404-b"] + }, + { + "id": "UB-2.504", + "variantIds": ["UB-2.504-a", "UB-2.504-b"] + }, + { + "id": "UB-2.506", + "variantIds": ["UB-2.506-a", "UB-2.506-b", "UB-2.506-c"] + }, + { + "id": "UB-2.606", + "variantIds": ["UB-2.606-a", "UB-2.606-b"] + }, + { + "id": "UB-3.201", + "variantIds": ["UB-3.201-a", "UB-3.201-b"] + }, + { + "id": "UB-3.206", + "variantIds": ["UB-3.206-a", "UB-3.206-b", "UB-3.206-c"] + }, + { + "id": "UB-3.309", + "variantIds": ["UB-3.309-a", "UB-3.309-b"] + }, + { + "id": "UB-3.310", + "variantIds": ["UB-3.310-a", "UB-3.310-b"] + }, + { + "id": "UB-3.401", + "variantIds": ["UB-3.401-a", "UB-3.401-b", "UB-3.401-c"] + }, + { + "id": "UB-3.403", + "variantIds": ["UB-3.403-a", "UB-3.403-b", "UB-3.403-c"] + }, + { + "id": "UB-3.405", + "variantIds": ["UB-3.405-a", "UB-3.405-b", "UB-3.405-c"] + }, + { + "id": "UB-3.408", + "variantIds": ["UB-3.408-a", "UB-3.408-b", "UB-3.408-c"] + }, + { + "id": "UB-3.503", + "variantIds": ["UB-3.503-a", "UB-3.503-b"] + }, + { + "id": "UB-3.506", + "variantIds": ["UB-3.506-a", "UB-3.506-b"] + }, + { + "id": "UB-3.510", + "variantIds": ["UB-3.510-a", "UB-3.510-b"] + }, + { + "id": "UB-3.708", + "variantIds": ["UB-3.708-a", "UB-3.708-b"] + }, + { + "id": "UB-4.304", + "variantIds": ["UB-4.304-a", "UB-4.304-b", "UB-4.304-c"] + }, + { + "id": "UB-4.308", + "variantIds": ["UB-4.308-a", "UB-4.308-b"] + }, + { + "id": "UB-4.401", + "variantIds": ["UB-4.401-a", "UB-4.401-b"] + }, + { + "id": "UB-4.403", + "variantIds": ["UB-4.403-a", "UB-4.403-b"] + }, + { + "id": "UB-4.406", + "variantIds": ["UB-4.406-a", "UB-4.406-b"] + }, + { + "id": "UB-4.407", + "variantIds": ["UB-4.407-a", "UB-4.407-b"] + }, + { + "id": "UB-4.502", + "variantIds": ["UB-4.502-a", "UB-4.502-b"] + }, + { + "id": "UB-4.503", + "variantIds": ["UB-4.503-a", "UB-4.503-b"] + }, + { + "id": "UB-4.508", + "variantIds": ["UB-4.508-a", "UB-4.508-b", "UB-4.508-c"] + }, + { + "id": "UB-5.301", + "variantIds": ["UB-5.301-a", "UB-5.301-b", "UB-5.301-c"] + }, + { + "id": "UB-5.304", + "variantIds": ["UB-5.304-a", "UB-5.304-b"] + }, + { + "id": "UB-5.305", + "variantIds": ["UB-5.305-a", "UB-5.305-b"] + }, + { + "id": "UB-5.306", + "variantIds": ["UB-5.306-a", "UB-5.306-b"] + }, + { + "id": "UB-5.308", + "variantIds": ["UB-5.308-a", "UB-5.308-b", "UB-5.308-c"] + }, + { + "id": "UB-5.402", + "variantIds": ["UB-5.402-a", "UB-5.402-b"] + }, + { + "id": "UB-5.404", + "variantIds": ["UB-5.404-a", "UB-5.404-b"] + }, + { + "id": "UB-5.408", + "variantIds": ["UB-5.408-a", "UB-5.408-b"] + }, + { + "id": "UB-5.410", + "variantIds": ["UB-5.410-a", "UB-5.410-b"] + }, + { + "id": "UB-5.501", + "variantIds": ["UB-5.501-a", "UB-5.501-b"] + }, + { + "id": "UB-5.502", + "variantIds": ["UB-5.502-a", "UB-5.502-b"] + }, + { + "id": "UB-5.509", + "variantIds": ["UB-5.509-a", "UB-5.509-b"] + }, + { + "id": "UB-5.510", + "variantIds": ["UB-5.510-a", "UB-5.510-b"] + }, + { + "id": "UB-5.610", + "variantIds": ["UB-5.610-a", "UB-5.610-b"] + }, + { + "id": "UB-6.101", + "variantIds": ["UB-6.101-a", "UB-6.101-b"] + }, + { + "id": "UB-6.102", + "variantIds": ["UB-6.102-a", "UB-6.102-b"] + }, + { + "id": "UB-6.104", + "variantIds": ["UB-6.104-a", "UB-6.104-b", "UB-6.104-c"] + }, + { + "id": "UB-6.108", + "variantIds": ["UB-6.108-a", "UB-6.108-b", "UB-6.108-c", "UB-6.108-d"] + }, + { + "id": "UB-6.201", + "variantIds": ["UB-6.201-a", "UB-6.201-b"] + }, + { + "id": "UB-6.204", + "variantIds": ["UB-6.204-a", "UB-6.204-b", "UB-6.204-c", "UB-6.204-d"] + }, + { + "id": "UB-6.208", + "variantIds": ["UB-6.208-a", "UB-6.208-b", "UB-6.208-c", "UB-6.208-d"] + }, + { + "id": "UB-6.209", + "variantIds": ["UB-6.209-a", "UB-6.209-b"] + }, + { + "id": "UB-6.301", + "variantIds": ["UB-6.301-a", "UB-6.301-b"] + }, + { + "id": "UB-6.302", + "variantIds": ["UB-6.302-a", "UB-6.302-b"] + }, + { + "id": "UB-6.303", + "variantIds": ["UB-6.303-a", "UB-6.303-b", "UB-6.303-c", "UB-6.303-d"] + }, + { + "id": "UB-6.304", + "variantIds": ["UB-6.304-a", "UB-6.304-b"] + }, + { + "id": "UB-6.308", + "variantIds": ["UB-6.308-a", "UB-6.308-b", "UB-6.308-c"] + }, + { + "id": "UB-6.309", + "variantIds": ["UB-6.309-a", "UB-6.309-b"] + }, + { + "id": "UB-6.310", + "variantIds": ["UB-6.310-a", "UB-6.310-b", "UB-6.310-c"] + }, + { + "id": "UB-6.401", + "variantIds": ["UB-6.401-a", "UB-6.401-b"] + }, + { + "id": "UB-6.402", + "variantIds": ["UB-6.402-a", "UB-6.402-b", "UB-6.402-c"] + }, + { + "id": "UB-6.403", + "variantIds": ["UB-6.403-a", "UB-6.403-b", "UB-6.403-c"] + }, + { + "id": "UB-6.405", + "variantIds": ["UB-6.405-a", "UB-6.405-b"] + }, + { + "id": "UB-6.407", + "variantIds": ["UB-6.407-a", "UB-6.407-b"] + }, + { + "id": "UB-6.408", + "variantIds": ["UB-6.408-a", "UB-6.408-b"] + }, + { + "id": "UB-6.409", + "variantIds": ["UB-6.409-a", "UB-6.409-b"] + }, + { + "id": "UB-6.507", + "variantIds": ["UB-6.507-a", "UB-6.507-b"] + }, + { + "id": "UB-6.508", + "variantIds": ["UB-6.508-a", "UB-6.508-b", "UB-6.508-c"] + }, + { + "id": "UB-6.509", + "variantIds": ["UB-6.509-a", "UB-6.509-b"] + }, + { + "id": "UB-6.609", + "variantIds": ["UB-6.609-a", "UB-6.609-b"] + }, + { + "id": "BB-1.103", + "variantIds": ["BB-1.103-a", "BB-1.103-b", "BB-1.103-c"] + }, + { + "id": "BB-1.105", + "variantIds": ["BB-1.105-a", "BB-1.105-b", "BB-1.105-c"] + }, + { + "id": "BB-1.106", + "variantIds": ["BB-1.106-a", "BB-1.106-b"] + }, + { + "id": "BB-1.108", + "variantIds": ["BB-1.108-a", "BB-1.108-b"] + }, + { + "id": "BB-1.111", + "variantIds": ["BB-1.111-a", "BB-1.111-b"] + }, + { + "id": "BB-1.114", + "variantIds": ["BB-1.114-a", "BB-1.114-b"] + }, + { + "id": "BB-1.204", + "variantIds": ["BB-1.204-a", "BB-1.204-b"] + }, + { + "id": "BB-1.207", + "variantIds": ["BB-1.207-a", "BB-1.207-b"] + }, + { + "id": "BB-1.210", + "variantIds": ["BB-1.210-a", "BB-1.210-b", "BB-1.210-c"] + }, + { + "id": "BB-1.214", + "variantIds": ["BB-1.214-a", "BB-1.214-b"] + }, + { + "id": "BB-1.215", + "variantIds": ["BB-1.215-a", "BB-1.215-b", "BB-1.215-c"] + }, + { + "id": "BB-1.303", + "variantIds": ["BB-1.303-a", "BB-1.303-b"] + }, + { + "id": "BB-1.305", + "variantIds": ["BB-1.305-a", "BB-1.305-b"] + }, + { + "id": "BB-1.308", + "variantIds": ["BB-1.308-a", "BB-1.308-b"] + }, + { + "id": "BB-1.309", + "variantIds": ["BB-1.309-a", "BB-1.309-b", "BB-1.309-c", "BB-1.309-d"] + }, + { + "id": "BB-1.310", + "variantIds": ["BB-1.310-a", "BB-1.310-b", "BB-1.310-c"] + }, + { + "id": "BB-1.311", + "variantIds": ["BB-1.311-a", "BB-1.311-b", "BB-1.311-c", "BB-1.311-d"] + }, + { + "id": "BB-1.312", + "variantIds": ["BB-1.312-a", "BB-1.312-b"] + }, + { + "id": "BB-1.313", + "variantIds": ["BB-1.313-a", "BB-1.313-b", "BB-1.313-c"] + }, + { + "id": "BB-1.314", + "variantIds": ["BB-1.314-a", "BB-1.314-b", "BB-1.314-c"] + }, + { + "id": "BB-1.315", + "variantIds": ["BB-1.315-a", "BB-1.315-b"] + }, + { + "id": "BB-1.403", + "variantIds": ["BB-1.403-a", "BB-1.403-b"] + }, + { + "id": "BB-1.411", + "variantIds": ["BB-1.411-a", "BB-1.411-b", "BB-1.411-c"] + }, + { + "id": "BB-1.412", + "variantIds": ["BB-1.412-a", "BB-1.412-b"] + }, + { + "id": "BB-1.417", + "variantIds": ["BB-1.417-a", "BB-1.417-b"] + }, + { + "id": "BB-1.518", + "variantIds": ["BB-1.518-a", "BB-1.518-b", "BB-1.518-c"] + }, + { + "id": "BB-1.618", + "variantIds": ["BB-1.618-a", "BB-1.618-b"] + }, + { + "id": "BB-2.101", + "variantIds": ["BB-2.101-a", "BB-2.101-b"] + }, + { + "id": "BB-2.108", + "variantIds": ["BB-2.108-a", "BB-2.108-b"] + }, + { + "id": "BB-2.110", + "variantIds": ["BB-2.110-a", "BB-2.110-b"] + }, + { + "id": "BB-2.112", + "variantIds": ["BB-2.112-a", "BB-2.112-b"] + }, + { + "id": "BB-2.203", + "variantIds": ["BB-2.203-a", "BB-2.203-b"] + }, + { + "id": "BB-2.208", + "variantIds": ["BB-2.208-a", "BB-2.208-b"] + }, + { + "id": "BB-2.209", + "variantIds": ["BB-2.209-a", "BB-2.209-b"] + }, + { + "id": "BB-2.306", + "variantIds": ["BB-2.306-a", "BB-2.306-b"] + }, + { + "id": "BB-2.408", + "variantIds": ["BB-2.408-a", "BB-2.408-b", "BB-2.408-c"] + }, + { + "id": "BB-3.208", + "variantIds": ["BB-3.208-a", "BB-3.208-b"] + }, + { + "id": "BB-3.407", + "variantIds": ["BB-3.407-a", "BB-3.407-b"] + }, + { + "id": "BB-4.102", + "variantIds": ["BB-4.102-a", "BB-4.102-b"] + }, + { + "id": "BB-4.103", + "variantIds": ["BB-4.103-a", "BB-4.103-b"] + }, + { + "id": "BB-4.107", + "variantIds": ["BB-4.107-a", "BB-4.107-b", "BB-4.107-c"] + }, + { + "id": "BB-4.108", + "variantIds": ["BB-4.108-a", "BB-4.108-b", "BB-4.108-c"] + }, + { + "id": "BB-4.109", + "variantIds": ["BB-4.109-a", "BB-4.109-b", "BB-4.109-c"] + }, + { + "id": "BB-4.204", + "variantIds": ["BB-4.204-a", "BB-4.204-b"] + }, + { + "id": "BB-4.206", + "variantIds": ["BB-4.206-a", "BB-4.206-b"] + }, + { + "id": "BB-4.210", + "variantIds": ["BB-4.210-a", "BB-4.210-b"] + }, + { + "id": "BB-4.308", + "variantIds": ["BB-4.308-a", "BB-4.308-b"] + }, + { + "id": "BB-4.309", + "variantIds": ["BB-4.309-a", "BB-4.309-b", "BB-4.309-c"] + }, + { + "id": "BB-5.201", + "variantIds": ["BB-5.201-a", "BB-5.201-b"] + }, + { + "id": "BB-5.204", + "variantIds": ["BB-5.204-a", "BB-5.204-b"] + }, + { + "id": "BB-5.206", + "variantIds": ["BB-5.206-a", "BB-5.206-b"] + }, + { + "id": "BB-5.207", + "variantIds": ["BB-5.207-a", "BB-5.207-b"] + }, + { + "id": "BB-5.307", + "variantIds": ["BB-5.307-a", "BB-5.307-b"] + }, + { + "id": "BB-5.312", + "variantIds": ["BB-5.312-a", "BB-5.312-b", "BB-5.312-c"] + }, + { + "id": "BB-5.313", + "variantIds": ["BB-5.313-a", "BB-5.313-b", "BB-5.313-c"] + }, + { + "id": "BB-5.401", + "variantIds": ["BB-5.401-a", "BB-5.401-b"] + }, + { + "id": "BB-5.405", + "variantIds": ["BB-5.405-a", "BB-5.405-b", "BB-5.405-c"] + }, + { + "id": "BB-5.408", + "variantIds": ["BB-5.408-a", "BB-5.408-b"] + }, + { + "id": "BB-5.409", + "variantIds": ["BB-5.409-a", "BB-5.409-b"] + }, + { + "id": "BB-5.508", + "variantIds": ["BB-5.508-a", "BB-5.508-b", "BB-5.508-c"] + }, + { + "id": "BB-5.613", + "variantIds": ["BB-5.613-a", "BB-5.613-b"] + }, + { + "id": "BB-6.102", + "variantIds": ["BB-6.102-a", "BB-6.102-b"] + }, + { + "id": "BB-6.104", + "variantIds": ["BB-6.104-a", "BB-6.104-b"] + }, + { + "id": "BB-6.106", + "variantIds": ["BB-6.106-a", "BB-6.106-b"] + }, + { + "id": "BB-6.201", + "variantIds": ["BB-6.201-a", "BB-6.201-b"] + }, + { + "id": "BB-6.202", + "variantIds": ["BB-6.202-a", "BB-6.202-b"] + }, + { + "id": "BB-6.204", + "variantIds": ["BB-6.204-a", "BB-6.204-b"] + }, + { + "id": "BB-6.206", + "variantIds": ["BB-6.206-a", "BB-6.206-b"] + }, + { + "id": "BB-6.207", + "variantIds": ["BB-6.207-a", "BB-6.207-b"] + }, + { + "id": "BB-6.302", + "variantIds": ["BB-6.302-a", "BB-6.302-b"] + }, + { + "id": "BB-6.304", + "variantIds": ["BB-6.304-a", "BB-6.304-b"] + }, + { + "id": "BB-6.306", + "variantIds": ["BB-6.306-a", "BB-6.306-b"] + }, + { + "id": "BB-6.307", + "variantIds": ["BB-6.307-a", "BB-6.307-b"] + }, + { + "id": "BB-6.407", + "variantIds": ["BB-6.407-a", "BB-6.407-b"] + }, + { + "id": "BB-6.705", + "variantIds": ["BB-6.705-a", "BB-6.705-b"] + }, + { + "id": "FX-1.104", + "variantIds": ["FX-1.104-a", "FX-1.104-b"] + }, + { + "id": "FX-1.107", + "variantIds": ["FX-1.107-a", "FX-1.107-b"] + }, + { + "id": "FX-1.108", + "variantIds": ["FX-1.108-a", "FX-1.108-b", "FX-1.108-c"] + }, + { + "id": "FX-1.109", + "variantIds": ["FX-1.109-a", "FX-1.109-b", "FX-1.109-c", "FX-1.109-d"] + }, + { + "id": "FX-1.111", + "variantIds": ["FX-1.111-a", "FX-1.111-b"] + }, + { + "id": "FX-1.112", + "variantIds": ["FX-1.112-a", "FX-1.112-b"] + }, + { + "id": "FX-1.202", + "variantIds": ["FX-1.202-a", "FX-1.202-b"] + }, + { + "id": "FX-1.203", + "variantIds": ["FX-1.203-a", "FX-1.203-b", "FX-1.203-c"] + }, + { + "id": "FX-1.204", + "variantIds": ["FX-1.204-a", "FX-1.204-b"] + }, + { + "id": "FX-1.207", + "variantIds": ["FX-1.207-a", "FX-1.207-b", "FX-1.207-c"] + }, + { + "id": "FX-1.208", + "variantIds": ["FX-1.208-a", "FX-1.208-b"] + }, + { + "id": "FX-1.209", + "variantIds": ["FX-1.209-a", "FX-1.209-b", "FX-1.209-c", "FX-1.209-d"] + }, + { + "id": "FX-1.214", + "variantIds": ["FX-1.214-a", "FX-1.214-b"] + }, + { + "id": "FX-1.302", + "variantIds": ["FX-1.302-a", "FX-1.302-b", "FX-1.302-c"] + }, + { + "id": "FX-1.304", + "variantIds": ["FX-1.304-a", "FX-1.304-b"] + }, + { + "id": "FX-1.306", + "variantIds": ["FX-1.306-a", "FX-1.306-b"] + }, + { + "id": "FX-1.307", + "variantIds": ["FX-1.307-a", "FX-1.307-b", "FX-1.307-c"] + }, + { + "id": "FX-1.309", + "variantIds": ["FX-1.309-a", "FX-1.309-b"] + }, + { + "id": "FX-1.313", + "variantIds": ["FX-1.313-a", "FX-1.313-b"] + }, + { + "id": "FX-1.404", + "variantIds": ["FX-1.404-a", "FX-1.404-b"] + }, + { + "id": "FX-1.407", + "variantIds": ["FX-1.407-a", "FX-1.407-b", "FX-1.407-c"] + }, + { + "id": "FX-1.409", + "variantIds": ["FX-1.409-a", "FX-1.409-b"] + }, + { + "id": "FX-2.208", + "variantIds": ["FX-2.208-a", "FX-2.208-b"] + }, + { + "id": "FX-2.404", + "variantIds": ["FX-2.404-a", "FX-2.404-b"] + }, + { + "id": "FX-3.101", + "variantIds": ["FX-3.101-a", "FX-3.101-b", "FX-3.101-c"] + }, + { + "id": "FX-3.103", + "variantIds": ["FX-3.103-a", "FX-3.103-b"] + }, + { + "id": "FX-3.104", + "variantIds": ["FX-3.104-a", "FX-3.104-b"] + }, + { + "id": "FX-3.105", + "variantIds": ["FX-3.105-a", "FX-3.105-b", "FX-3.105-c"] + }, + { + "id": "FX-3.107", + "variantIds": ["FX-3.107-a", "FX-3.107-b", "FX-3.107-c", "FX-3.107-d"] + }, + { + "id": "FX-3.201", + "variantIds": ["FX-3.201-a", "FX-3.201-b", "FX-3.201-c"] + }, + { + "id": "FX-3.203", + "variantIds": ["FX-3.203-a", "FX-3.203-b"] + }, + { + "id": "FX-3.305", + "variantIds": ["FX-3.305-a", "FX-3.305-b"] + }, + { + "id": "FX-4.101", + "variantIds": ["FX-4.101-a", "FX-4.101-b"] + }, + { + "id": "FX-4.104", + "variantIds": ["FX-4.104-a", "FX-4.104-b"] + }, + { + "id": "FX-4.105", + "variantIds": ["FX-4.105-a", "FX-4.105-b"] + }, + { + "id": "FX-4.201", + "variantIds": ["FX-4.201-a", "FX-4.201-b", "FX-4.201-c"] + }, + { + "id": "FX-4.202", + "variantIds": ["FX-4.202-a", "FX-4.202-b", "FX-4.202-c"] + }, + { + "id": "FX-4.205", + "variantIds": ["FX-4.205-a", "FX-4.205-b"] + }, + { + "id": "FX-4.302", + "variantIds": ["FX-4.302-a", "FX-4.302-b"] + }, + { + "id": "FX-4.601", + "variantIds": ["FX-4.601-a", "FX-4.601-b"] + }, + { + "id": "FX-5.101", + "variantIds": ["FX-5.101-a", "FX-5.101-b", "FX-5.101-c"] + }, + { + "id": "FX-5.201", + "variantIds": ["FX-5.201-a", "FX-5.201-b"] + }, + { + "id": "FX-5.301", + "variantIds": ["FX-5.301-a", "FX-5.301-b"] + }, + { + "id": "FX-5.402", + "variantIds": ["FX-5.402-a", "FX-5.402-b"] + }, + { + "id": "FX-5.502", + "variantIds": ["FX-5.502-a", "FX-5.502-b"] + }, + { + "id": "FX-5.802", + "variantIds": ["FX-5.802-a", "FX-5.802-b"] + }, + { + "id": "VT-2.11", + "variantIds": ["VT-2.11-a", "VT-2.11-b"] + }, + { + "id": "VT-2.21", + "variantIds": ["VT-2.21-a", "VT-2.21-b"] + }, + { + "id": "VT-5.11", + "variantIds": ["VT-5.11-a", "VT-5.11-b"] + }, + { + "id": "VT-5.21", + "variantIds": ["VT-5.21-a", "VT-5.21-b"] + } +] diff --git a/data/vaults.json b/data/vaults.json new file mode 100644 index 0000000..9835c3b --- /dev/null +++ b/data/vaults.json @@ -0,0 +1,785 @@ +[ + { + "id": "VT-1.00-a", + "elementGroupId": "VT-1", + "code": "1.00", + "dValue": 1.6, + "description": "Handspring fwd", + "name": null, + "image": "VT-1.00-a.webp" + }, + { + "id": "VT-1.01-a", + "elementGroupId": "VT-1", + "code": "1.01", + "dValue": 2.0, + "description": "Handspring fwd on – ½ turn (180°) off", + "name": null, + "image": "VT-1.01-a.webp" + }, + { + "id": "VT-1.02-a", + "elementGroupId": "VT-1", + "code": "1.02", + "dValue": 2.6, + "description": "Handspring fwd on – 1/1 turn (360°) off", + "name": null, + "image": "VT-1.02-a.webp" + }, + { + "id": "VT-1.03-a", + "elementGroupId": "VT-1", + "code": "1.03", + "dValue": 3.2, + "description": "Handspring fwd on – 1½ turn (540°) off", + "name": "Kim", + "image": "VT-1.03-a.webp" + }, + { + "id": "VT-1.04-a", + "elementGroupId": "VT-1", + "code": "1.04", + "dValue": 3.6, + "description": "Handspring fwd on – 2/1 turn (720°) off", + "name": null, + "image": "VT-1.04-a.webp" + }, + { + "id": "VT-1.05-a", + "elementGroupId": "VT-1", + "code": "1.05", + "dValue": 4.0, + "description": "Handspring fwd on – 2½ turn (900°) off", + "name": null, + "image": "VT-1.05-a.webp" + }, + { + "id": "VT-1.10-a", + "elementGroupId": "VT-1", + "code": "1.10", + "dValue": 2.0, + "description": "Yamashita", + "name": null, + "image": "VT-1.10-a.webp" + }, + { + "id": "VT-1.11-a", + "elementGroupId": "VT-1", + "code": "1.11", + "dValue": 2.4, + "description": "Yamashita with ½ turn (180°) off", + "name": null, + "image": "VT-1.11-a.webp" + }, + { + "id": "VT-1.12-a", + "elementGroupId": "VT-1", + "code": "1.12", + "dValue": 2.8, + "description": "Yamashita with 1/1 turn (360°) off", + "name": null, + "image": "VT-1.12-a.webp" + }, + { + "id": "VT-1.20-a", + "elementGroupId": "VT-1", + "code": "1.20", + "dValue": 1.6, + "description": "Handspring fwd with ½ turn (180°) on – repulsion off", + "name": null, + "image": "VT-1.20-a.webp" + }, + { + "id": "VT-1.21-a", + "elementGroupId": "VT-1", + "code": "1.21", + "dValue": 2.4, + "description": "Handspring fwd with ½ turn (180°) on – ½ turn (180°) off (in either direction)", + "name": null, + "image": "VT-1.21-a.webp" + }, + { + "id": "VT-1.22-a", + "elementGroupId": "VT-1", + "code": "1.22", + "dValue": 2.6, + "description": "Handspring fwd with ½ turn (180°) on – 1/1 turn (360°) off", + "name": null, + "image": "VT-1.22-a.webp" + }, + { + "id": "VT-1.23-a", + "elementGroupId": "VT-1", + "code": "1.23", + "dValue": 3.2, + "description": "Handspring fwd with ½ turn (180°) on – 1½ turn (540°) off", + "name": null, + "image": "VT-1.23-a.webp" + }, + { + "id": "VT-1.24-a", + "elementGroupId": "VT-1", + "code": "1.24", + "dValue": 3.6, + "description": "Handspring fwd with ½ turn (180°) on – 2/1 turn (720°) off", + "name": null, + "image": "VT-1.24-a.webp" + }, + { + "id": "VT-1.30-a", + "elementGroupId": "VT-1", + "code": "1.30", + "dValue": 3.2, + "description": "Handspring fwd with 1/1 turn (360°) on – Handspring fwd off", + "name": null, + "image": "VT-1.30-a.webp" + }, + { + "id": "VT-1.31-a", + "elementGroupId": "VT-1", + "code": "1.31", + "dValue": 3.6, + "description": "Handspring fwd with 1/1 turn (360°) on – 1/1 turn (360°) off", + "name": "Korbut", + "image": "VT-1.31-a.webp" + }, + { + "id": "VT-1.40-a", + "elementGroupId": "VT-1", + "code": "1.40", + "dValue": 2.0, + "description": "Round-off, flic-flac on – repulsion off", + "name": null, + "image": "VT-1.40-a.webp" + }, + { + "id": "VT-1.50-a", + "elementGroupId": "VT-1", + "code": "1.50", + "dValue": 2.2, + "description": "Round-off, flic-flac with ½ turn (180°) on – Handspring fwd off", + "name": null, + "image": "VT-1.50-a.webp" + }, + { + "id": "VT-1.51-a", + "elementGroupId": "VT-1", + "code": "1.51", + "dValue": 2.6, + "description": "Round-off, flic-flac with ½ turn (180°) on –½ turn (180°) off", + "name": null, + "image": "VT-1.51-a.webp" + }, + { + "id": "VT-1.52-a", + "elementGroupId": "VT-1", + "code": "1.52", + "dValue": 3.0, + "description": "Round-off, flic-flac with ½ turn (180°) on – 1/1 turn (360°) off", + "name": null, + "image": "VT-1.52-a.webp" + }, + { + "id": "VT-1.53-a", + "elementGroupId": "VT-1", + "code": "1.53", + "dValue": 3.4, + "description": "Round-off, flic-flac with ½ turn (180°) on – 1½ turn (540°) off", + "name": null, + "image": "VT-1.53-a.webp" + }, + { + "id": "VT-1.60-a", + "elementGroupId": "VT-1", + "code": "1.60", + "dValue": 2.4, + "description": "Round-off, flic-flac with 1/1 turn (360°) on – repulsion off", + "name": null, + "image": "VT-1.60-a.webp" + }, + { + "id": "VT-1.61-a", + "elementGroupId": "VT-1", + "code": "1.61", + "dValue": 2.8, + "description": "Round-off, flic-flac with 1/1 turn (360°) on – ½ turn (180°) off", + "name": null, + "image": "VT-1.61-a.webp" + }, + { + "id": "VT-1.62-a", + "elementGroupId": "VT-1", + "code": "1.62", + "dValue": 3.2, + "description": "Round-off, flic-flac with 1/1 turn (360°) on –1/1 turn (360°) off", + "name": null, + "image": "VT-1.62-a.webp" + }, + { + "id": "VT-2.10-a", + "elementGroupId": "VT-2", + "code": "2.10", + "dValue": 3.6, + "description": "Handspring fwd on – tucked salto fwd off", + "name": null, + "image": "VT-2.10-a.webp" + }, + { + "id": "VT-2.11-a", + "elementGroupId": "VT-2", + "code": "2.11", + "dValue": 3.8, + "description": "Handspring fwd on – tucked salto fwd with ½ twist (180°) off", + "name": null, + "image": "VT-2.11-a.webp" + }, + { + "id": "VT-2.11-b", + "elementGroupId": "VT-2", + "code": "2.11", + "dValue": 3.8, + "description": "Handspring fwd on – ½ turn (180°) and tucked salto bwd off", + "name": null, + "image": "VT-2.11-b.webp" + }, + { + "id": "VT-2.12-a", + "elementGroupId": "VT-2", + "code": "2.12", + "dValue": 4.2, + "description": "Handspring fwd on – tucked salto fwd with 1/1 twist (360°) off", + "name": null, + "image": "VT-2.12-a.webp" + }, + { + "id": "VT-2.13-a", + "elementGroupId": "VT-2", + "code": "2.13", + "dValue": 4.6, + "description": "Handspring fwd on – tucked salto fwd with 1½ twist (540°) off", + "name": null, + "image": "VT-2.13-a.webp" + }, + { + "id": "VT-2.20-a", + "elementGroupId": "VT-2", + "code": "2.20", + "dValue": 3.8, + "description": "Handspring fwd on – piked salto fwd off", + "name": null, + "image": "VT-2.20-a.webp" + }, + { + "id": "VT-2.21-a", + "elementGroupId": "VT-2", + "code": "2.21", + "dValue": 4.0, + "description": "Handspring fwd on – piked salto fwd with ½ twist (180°) off", + "name": null, + "image": "VT-2.21-a.webp" + }, + { + "id": "VT-2.21-b", + "elementGroupId": "VT-2", + "code": "2.21", + "dValue": 4.0, + "description": "Handspring fwd on – ½ turn (180°) and piked salto bwd off", + "name": null, + "image": "VT-2.21-b.webp" + }, + { + "id": "VT-2.22-a", + "elementGroupId": "VT-2", + "code": "2.22", + "dValue": 4.4, + "description": "Handspring fwd on – piked salto fwd with 1/1 twist (360°) off", + "name": "Chusovitina", + "image": "VT-2.22-a.webp" + }, + { + "id": "VT-2.30-a", + "elementGroupId": "VT-2", + "code": "2.30", + "dValue": 4.4, + "description": "Handspring fwd on – stretched salto fwd off", + "name": "Evdokimova", + "image": "VT-2.30-a.webp" + }, + { + "id": "VT-2.31-a", + "elementGroupId": "VT-2", + "code": "2.31", + "dValue": 4.6, + "description": "Handspring fwd on – stretched salto fwd with ½ twist (180°) off", + "name": "Wang", + "image": "VT-2.31-a.webp" + }, + { + "id": "VT-2.32-a", + "elementGroupId": "VT-2", + "code": "2.32", + "dValue": 5.0, + "description": "Handspring fwd on – stretched salto fwd with 1/1 twist (360°) off", + "name": null, + "image": "VT-2.32-a.webp" + }, + { + "id": "VT-2.33-a", + "elementGroupId": "VT-2", + "code": "2.33", + "dValue": 5.4, + "description": "Handspring fwd on – stretched salto fwd with 1½ twist (540°) off", + "name": "Chusovitina", + "image": "VT-2.33-a.webp" + }, + { + "id": "VT-2.34-a", + "elementGroupId": "VT-2", + "code": "2.34", + "dValue": 5.8, + "description": "Handspring fwd on – stretched salto fwd with 2/1 twist (720°) off", + "name": "Yeo", + "image": "VT-2.34-a.webp" + }, + { + "id": "VT-2.40-a", + "elementGroupId": "VT-2", + "code": "2.40", + "dValue": 4.8, + "description": "Handspring fwd with 1/1 turn (360°) on – tucked salto fwd off", + "name": "Davydova", + "image": "VT-2.40-a.webp" + }, + { + "id": "VT-2.41-a", + "elementGroupId": "VT-2", + "code": "2.41", + "dValue": 5.2, + "description": "Handspring fwd with 1/1 turn (360°) on – piked salto fwd off", + "name": null, + "image": "VT-2.41-a.webp" + }, + { + "id": "VT-2.50-a", + "elementGroupId": "VT-2", + "code": "2.50", + "dValue": 6.0, + "description": "Handspring fwd on – tucked double salto fwd off", + "name": "Produnova", + "image": "VT-2.50-a.webp" + }, + { + "id": "VT-3.10-a", + "elementGroupId": "VT-3", + "code": "3.10", + "dValue": 3.2, + "description": "Tsukahara tucked", + "name": "Tourischeva", + "image": "VT-3.10-a.webp" + }, + { + "id": "VT-3.11-a", + "elementGroupId": "VT-3", + "code": "3.11", + "dValue": 3.4, + "description": "Tsukahara tucked with ½ twist (180°) off", + "name": null, + "image": "VT-3.11-a.webp" + }, + { + "id": "VT-3.12-a", + "elementGroupId": "VT-3", + "code": "3.12", + "dValue": 3.8, + "description": "Tsukahara tucked with 1/1 twist (360°) off", + "name": "Kim", + "image": "VT-3.12-a.webp" + }, + { + "id": "VT-3.13-a", + "elementGroupId": "VT-3", + "code": "3.13", + "dValue": 4.2, + "description": "Tsukahara tucked with 1½ twist (540°) off", + "name": null, + "image": "VT-3.13-a.webp" + }, + { + "id": "VT-3.14-a", + "elementGroupId": "VT-3", + "code": "3.14", + "dValue": 4.6, + "description": "Tsukahara tucked with 2/1 twist (720°) off", + "name": null, + "image": "VT-3.14-a.webp" + }, + { + "id": "VT-3.20-a", + "elementGroupId": "VT-3", + "code": "3.20", + "dValue": 3.4, + "description": "Tsukahara piked", + "name": null, + "image": "VT-3.20-a.webp" + }, + { + "id": "VT-3.30-a", + "elementGroupId": "VT-3", + "code": "3.30", + "dValue": 3.8, + "description": "Tsukahara stretched", + "name": null, + "image": "VT-3.30-a.webp" + }, + { + "id": "VT-3.31-a", + "elementGroupId": "VT-3", + "code": "3.31", + "dValue": 4.0, + "description": "Tsukahara stretched with ½ twist (180°) off", + "name": null, + "image": "VT-3.31-a.webp" + }, + { + "id": "VT-3.32-a", + "elementGroupId": "VT-3", + "code": "3.32", + "dValue": 4.4, + "description": "Tsukahara stretched with 1/1 twist (360°) off", + "name": "Kim", + "image": "VT-3.32-a.webp" + }, + { + "id": "VT-3.33-a", + "elementGroupId": "VT-3", + "code": "3.33", + "dValue": 4.8, + "description": "Tsukahara stretched with 1½ twist (540°) off", + "name": null, + "image": "VT-3.33-a.webp" + }, + { + "id": "VT-3.34-a", + "elementGroupId": "VT-3", + "code": "3.34", + "dValue": 5.2, + "description": "Tsukahara stretched with 2/1 twist (720°) off", + "name": "Zamolodchikova", + "image": "VT-3.34-a.webp" + }, + { + "id": "VT-3.35-a", + "elementGroupId": "VT-3", + "code": "3.35", + "dValue": 5.6, + "description": "Tsukahara stretched with 2½ twist (900°) off", + "name": null, + "image": "VT-3.35-a.webp" + }, + { + "id": "VT-4.10-a", + "elementGroupId": "VT-4", + "code": "4.10", + "dValue": 3.0, + "description": "Round-off, flic-flac on – tucked salto bwd off", + "name": "Yurchenko", + "image": "VT-4.10-a.webp" + }, + { + "id": "VT-4.11-a", + "elementGroupId": "VT-4", + "code": "4.11", + "dValue": 3.2, + "description": "Round-off, flic-flac on – tucked salto bwd with ½ twist (180°) off", + "name": null, + "image": "VT-4.11-a.webp" + }, + { + "id": "VT-4.12-a", + "elementGroupId": "VT-4", + "code": "4.12", + "dValue": 3.6, + "description": "Round-off, flic-flac on - tucked salto bwd with 1/1 twist (360°) off", + "name": null, + "image": "VT-4.12-a.webp" + }, + { + "id": "VT-4.13-a", + "elementGroupId": "VT-4", + "code": "4.13", + "dValue": 4.0, + "description": "Round-off, flic-flac on – tucked salto bwd with 1½ twist (540°) off", + "name": null, + "image": "VT-4.13-a.webp" + }, + { + "id": "VT-4.14-a", + "elementGroupId": "VT-4", + "code": "4.14", + "dValue": 4.4, + "description": "Round-off, flic-flac on – tucked salto bwd with 2/1 twist (720°) off", + "name": "Dungelova", + "image": "VT-4.14-a.webp" + }, + { + "id": "VT-4.20-a", + "elementGroupId": "VT-4", + "code": "4.20", + "dValue": 3.2, + "description": "Round-off, flic-flac on – piked salto bwd off", + "name": null, + "image": "VT-4.20-a.webp" + }, + { + "id": "VT-4.30-a", + "elementGroupId": "VT-4", + "code": "4.30", + "dValue": 3.6, + "description": "Round-off, flic-flac on – stretched salto bwd off", + "name": null, + "image": "VT-4.30-a.webp" + }, + { + "id": "VT-4.31-a", + "elementGroupId": "VT-4", + "code": "4.31", + "dValue": 3.8, + "description": "Round-off, flic-flac on – stretched salto bwd with ½ twist (180°) off", + "name": null, + "image": "VT-4.31-a.webp" + }, + { + "id": "VT-4.32-a", + "elementGroupId": "VT-4", + "code": "4.32", + "dValue": 4.2, + "description": "Round-off, flic-flac on – stretched salto bwd with 1/1 twist (360°) off", + "name": null, + "image": "VT-4.32-a.webp" + }, + { + "id": "VT-4.33-a", + "elementGroupId": "VT-4", + "code": "4.33", + "dValue": 4.6, + "description": "Round-off, flic-flac on – stretched salto bwd with 1½ twist (540°) off", + "name": null, + "image": "VT-4.33-a.webp" + }, + { + "id": "VT-4.34-a", + "elementGroupId": "VT-4", + "code": "4.34", + "dValue": 5.0, + "description": "Round-off, flic-flac on – stretched salto bwd with 2/1 twist (720°) off", + "name": "Baitova", + "image": "VT-4.34-a.webp" + }, + { + "id": "VT-4.35-a", + "elementGroupId": "VT-4", + "code": "4.35", + "dValue": 5.4, + "description": "Round-off, flic-flac on –stretched salto bwd with 2½ twist (900°) off", + "name": "Amanar", + "image": "VT-4.35-a.webp" + }, + { + "id": "VT-4.40-a", + "elementGroupId": "VT-4", + "code": "4.40", + "dValue": 3.6, + "description": "Round-off, flic-flac with ¾ turn (270°) on – tucked salto bwd off", + "name": "Luconi", + "image": "VT-4.40-a.webp" + }, + { + "id": "VT-4.41-a", + "elementGroupId": "VT-4", + "code": "4.41", + "dValue": 4.0, + "description": "Round-off, flic-flac with ¾ turn (270°) on – tucked salto bwd with ½ twist (180°) off", + "name": null, + "image": "VT-4.41-a.webp" + }, + { + "id": "VT-4.42-a", + "elementGroupId": "VT-4", + "code": "4.42", + "dValue": 4.2, + "description": "Round-off, flic-flac with ¾ turn (270°) on – tucked salto bwd with 1/1 twist (360°) off", + "name": null, + "image": "VT-4.42-a.webp" + }, + { + "id": "VT-4.50-a", + "elementGroupId": "VT-4", + "code": "4.50", + "dValue": 3.8, + "description": "Round-off, flic-flac with ¾ turn (270°) on – piked salto bwd off", + "name": null, + "image": "VT-4.50-a.webp" + }, + { + "id": "VT-4.51-a", + "elementGroupId": "VT-4", + "code": "4.51", + "dValue": 4.2, + "description": "Round-off, flic-flac with ¾ turn (270°) on – stretched salto bwd off", + "name": null, + "image": "VT-4.51-a.webp" + }, + { + "id": "VT-4.52-a", + "elementGroupId": "VT-4", + "code": "4.52", + "dValue": 4.6, + "description": "Round-off, flic-flac with ¾ turn (270°) on – stretched salto bwd with ½ twist (180°) off", + "name": null, + "image": "VT-4.52-a.webp" + }, + { + "id": "VT-4.53-a", + "elementGroupId": "VT-4", + "code": "4.53", + "dValue": 5.0, + "description": "Round-off, flic-flac with ¾ turn (270°) on – stretched salto bwd with 1/1 twist (360°) off", + "name": null, + "image": "VT-4.53-a.webp" + }, + { + "id": "VT-4.62-a", + "elementGroupId": "VT-4", + "code": "4.62", + "dValue": 6.4, + "description": "Round-off, flic-flac on –double piked salto bwd off", + "name": "Biles", + "image": "VT-4.62-a.webp" + }, + { + "id": "VT-5.10-a", + "elementGroupId": "VT-5", + "code": "5.10", + "dValue": 3.8, + "description": "Round-off, flic-flac with ½ turn (180°) on – tucked salto fwd off", + "name": "Ivantcheva", + "image": "VT-5.10-a.webp" + }, + { + "id": "VT-5.11-a", + "elementGroupId": "VT-5", + "code": "5.11", + "dValue": 4.0, + "description": "Round-off, flic-flac with ½ turn (180°) on – tucked salto fwd with ½ twist (180°) off", + "name": "Servente", + "image": "VT-5.11-a.webp" + }, + { + "id": "VT-5.11-b", + "elementGroupId": "VT-5", + "code": "5.11", + "dValue": 4.0, + "description": "Round-off, flic-flac with ½ turn (180°) on – ½ turn (180°) and tucked salto bwd off", + "name": null, + "image": "VT-5.11-b.webp" + }, + { + "id": "VT-5.12-a", + "elementGroupId": "VT-5", + "code": "5.12", + "dValue": 4.4, + "description": "Round-off, flic-flac with ½ turn (180°) on – tucked salto fwd with 1/1 twist (360°) off", + "name": null, + "image": "VT-5.12-a.webp" + }, + { + "id": "VT-5.13-a", + "elementGroupId": "VT-5", + "code": "5.13", + "dValue": 4.8, + "description": "Round-off flic-flac with ½ turn (180°) on - tucked salto fwd with 1½ twist (540°) off", + "name": "Khorkina", + "image": "VT-5.13-a.webp" + }, + { + "id": "VT-5.20-a", + "elementGroupId": "VT-5", + "code": "5.20", + "dValue": 4.0, + "description": "Round-off, flic-flac with ½ turn (180°) on – piked salto fwd off", + "name": "Omelianchik", + "image": "VT-5.20-a.webp" + }, + { + "id": "VT-5.21-a", + "elementGroupId": "VT-5", + "code": "5.21", + "dValue": 4.2, + "description": "Round-off, flic-flac with ½ turn (180°) on – piked salto fwd with ½ twist (180°) off", + "name": "Podkopayeva", + "image": "VT-5.21-a.webp" + }, + { + "id": "VT-5.21-b", + "elementGroupId": "VT-5", + "code": "5.21", + "dValue": 4.2, + "description": "Round-off, flic-flac with ½ turn (180°) on – ½ turn (180°) and piked salto bwd off", + "name": null, + "image": "VT-5.21-b.webp" + }, + { + "id": "VT-5.22-a", + "elementGroupId": "VT-5", + "code": "5.22", + "dValue": 4.6, + "description": "Round-off, flic-flac with ½ turn (180°) on – piked salto fwd with 1/1 twist (360°) off", + "name": null, + "image": "VT-5.22-a.webp" + }, + { + "id": "VT-5.30-a", + "elementGroupId": "VT-5", + "code": "5.30", + "dValue": 4.6, + "description": "Round-off, flic-flac with ½ turn (180°) on – stretched salto fwd off", + "name": null, + "image": "VT-5.30-a.webp" + }, + { + "id": "VT-5.31-a", + "elementGroupId": "VT-5", + "code": "5.31", + "dValue": 4.8, + "description": "Round-off, flic-flac with ½ turn (180°) on – salto fwd stretched with ½ twist (180°) off", + "name": null, + "image": "VT-5.31-a.webp" + }, + { + "id": "VT-5.32-a", + "elementGroupId": "VT-5", + "code": "5.32", + "dValue": 5.2, + "description": "Round-off flic-flac with ½ turn (180°) on – stretched salto fwd with 1/1 twist (360°) off", + "name": null, + "image": "VT-5.32-a.webp" + }, + { + "id": "VT-5.33-a", + "elementGroupId": "VT-5", + "code": "5.33", + "dValue": 5.6, + "description": "Round-off flic-flac with ½ turn (180°) on – stretched salto fwd with 1½ twist (540°) off", + "name": "Cheng", + "image": "VT-5.33-a.webp" + }, + { + "id": "VT-5.34-a", + "elementGroupId": "VT-5", + "code": "5.34", + "dValue": 6.0, + "description": "Round off flic-flac with ½ turn (180°) on – stretched salto fwd with 2/1 twist (720°) off", + "name": "Biles", + "image": "VT-5.34-a.webp" + } +] diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 9541e73..bc5eb73 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -16,9 +16,12 @@ - **What Claude did:** Generated all the data in the JSON files and wrote the README. The elements, vaults, descriptions, names, coach, gymnasts and routines are invented by Claude. The apparatus and element group names were - written by Claude from its knowledge of the Code of Points, not copied from - the document. Claude also checked with a script that all referenced ids - exist and that the data follows the constraints. + first written by Claude from its knowledge of the Code of Points, and later + replaced with the real names extracted from the document (`data/`). Claude + also checked with a script that all referenced ids exist and that the data + follows the constraints. When the database schema was written, Claude Code + converted the files to the new storage shape (lists as rows, variant groups + as a column on each element and vault). - **Prompt:** ```text @@ -42,3 +45,329 @@ Add a short README in docs/examples/ saying the data is invented, and link to the folder from DATA_MODEL.md. ``` + +## Extraction of the catalog data (`scripts/extract-cop/`, `data/`, `docs/EXTRACTION.md`) + +- **Tool:** Claude Code +- **What the team did:** Wrote the task description: which files to produce, + how the tables in the Code of Points are built, how variants and images + should be handled, and which checks to run. Had the work done step by step + (one page of uneven bars, a page with harder cases, a vault page, then the + whole document) and checked the result of each step against the PDF. + Decided that every id keeps the variant letter (`-a`) and that images are + stored in one folder per apparatus. Went through every code the script + flagged as uncertain, and decided the corrections: 13 new descriptions, + names moved to the right variant, three descriptions written out in full, + and two codes where several drawings were one element. +- **What Claude did:** Wrote the extraction script, the review page and the + checks, found the layout rules of the PDF (drawings as vector graphics or + bitmaps, notes inside the table, drawings in several parts), and suggested + texts for many of the corrections, which the team accepted or changed. Also + wrote `docs/EXTRACTION.md`, the READMEs in `data/` and `scripts/extract-cop/`, + and the commit messages and pull request descriptions. +- **Review:** The team compared the flagged codes with the PDF and checked a + sample of the images. Codes the script did not flag were only spot-checked. + +## 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. + +## Database choice and backend setup (`backend/`, README) + +- **Tools:** Claude for the discussion and the decisions, Claude Code for the + setup. +- **Background:** The database was not chosen before the first delivery. The + reviews of the first delivery suggested a database, and the team discussed + that feedback. +- **What the team did:** Discussed the options with Claude, using the + suggestion from the reviews as a starting point. Answered a questionnaire + that Claude made, so that every decision was made before any setup started: + PostgreSQL with Prisma, npm as the only package manager, the backend as its + own npm workspace in `backend/`, PostgreSQL installed directly on each + machine without Docker, and everyone on PostgreSQL 18. The team then wrote + the task description for Claude Code based on those decisions. +- **What Claude did:** In the discussion, Claude explained the options and + their trade-offs and made the questionnaire. Claude Code then set up the + 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. + +## Database schema and migrations (`backend/prisma/`, `docs/DATA_MODEL.md`, `docs/API.md`) + +- **Tool:** Claude Code +- **What the team did:** Made the design decisions before the work started and + wrote them in the task description: readable ids for the catalog and + generated ids for user data, lists stored as join tables with a CHECK rule, + `removed_at`, timestamps, `Decimal` values, and what happens on delete. + During the work the team changed the search design back to the one in + `docs/API.md` (`CatalogEntry` and `CatalogWord` instead of a database view), + chose how the prefix search uses the index (the `C` collation on the word + column), and decided the open questions from three rounds of peer review: + difficulty is only sorted within an apparatus and filtered by value, letters + are only shown in the frontend, removed elements stay but do not count, the + same element may appear twice in a routine, value ranges are checked in the + database, and the gymnast's element list is part of the API. The team + approved each reset of the local database, and the team members reviewed + the pull request. +- **What Claude did:** Wrote `schema.prisma`, the three migrations (including + the CHECK constraints and the collation in SQL), and the changes to + `docs/DATA_MODEL.md`, `docs/API.md`, `schema.graphql` and the README. Tested + the constraints, the indexes (with `EXPLAIN` on large test data) and the + search query through Prisma, and checked that `prisma migrate dev` reports + no drift. Found that `text_pattern_ops` causes drift in Prisma and proposed + the column collation instead, and proposed most of the wording for the + answers to the review, which the team accepted or changed. +- **Prompts:** The work was done in one long conversation. It started with a + task description that listed the tables, the decisions above and the + checks to run, and continued with the team's decisions and the review + comments as they came. + +## Seed script (`backend/src/seed/`, `backend/src/catalog/`, README) + +- **Tool:** Claude Code +- **What the team did:** Wrote the task description with the rules the seed + must follow: convert the old shape of `data/`, check the data before + writing, upsert in one transaction, soft delete and clean-up, leave user data + and synthetic test rows alone, and rebuild the search tables. Asked to see + the list of abbreviations before it was finalized, and approved it. +- **What Claude did:** Went through the descriptions to find the + abbreviations and proposed the list, wrote the seed script, the word + normalization and the tests, and the "Seeding" section in the README. Ran + the checks on a local database: two runs in a row with no changes, the row + counts against the JSON files, broken copies of the data, the soft delete + and clean-up, and that user data is untouched. + +## shadcn/ui, Tailwind CSS and Lucide (`components.json`, `src/components/ui/`, README) + +- **Tool:** Claude Code +- **What the team did:** Decided to use Tailwind CSS, shadcn/ui and Lucide for + the interface, and wrote issues #51 and #54 with the decisions that had to + be made. The team reviews the choices in the README. +- **What Claude did:** Checked that the shadcn/ui CLI works with our versions + of Vite, TypeScript and React in a scratch copy, then ran `shadcn init` in + the repository. Claude removed the Geist font, made dark mode follow + `data-theme="dark"`, set the `@/` alias with `paths` only, switched off the + Fast Refresh lint rule for the generated folder, wrote a test that renders a + Button with an icon, and wrote the README section. For Tailwind, Claude + checked in a build that a used class ends up in the CSS and an unused class + does not, and checked the browser support and the cascade layers in the + Tailwind documentation. Claude checked lint, build and tests. + +## Element variant card (`src/components/ui/`, `src/lib/difficulty.ts`) + +- **Tool:** GitHub Copilot +- **What the team did:** Specified a reusable element card and difficulty + badge, including the letter-to-value display, the existing navy palette, + accessible labeling, and a sample catalog image. +- **What GitHub Copilot did:** Added the card primitives, a difficulty badge, + the shared A–J value mapping, and a sample element-variant card. The title + uses an explicit native heading level and the description uses a paragraph. +- **Review:** The team refined the layout, colors, difficulty display, and + accessibility semantics during review. + +## Element detail page (`src/components/elements/`, `src/lib/catalog.ts`) + +- **Tool:** GitHub Copilot +- **What the team did:** Provided the detail-page design and specified modular + sections, reuse of the existing variant and difficulty components, existing + CSS colors only, and accessibility requirements. +- **What GitHub Copilot did:** Added a route-backed page, a local catalog data + adapter, reusable header, metadata, diagram and action sections, and tests + for catalog lookup and variant navigation. The page composes the existing + `ElementVariants` and `DifficultyBadge` components. +- **Review:** The team checked the page in the browser, corrected theme-token + use and image sizing, and reviewed keyboard navigation and accessible names. + +## Your gymnasts overview (`src/components/gymnasts/`, `src/lib/gymnasts.ts`) + +- **Tool:** GitHub Copilot +- **What the team did:** Provided the page mockup and required existing theme + colors, accessible rows, and an add-gymnast flow. +- **What GitHub Copilot did:** Added the `/gymnasts` route, gymnast rows and + initials, name normalization, and an accessible inline add form with local + state. It used the project's existing CSS variables rather than the mockup's + placeholder colors. +- **Review:** The team exercised the form in the browser at desktop and mobile + widths. The list remains local until the GraphQL API is available. + +## 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 +- **What the team did:** Wrote the task from the issue and the search design + in `docs/API.md`, with the rules: Prisma only, the same word rules as the + seed, never `mode: 'insensitive'`, keyset pagination, and tests against the + seeded catalog. When Claude found that the seed splits words differently + from what the issue said, the team decided to keep the seed's rules for the + search too, and asked for tests that lock this in. Decided to use a plain + base64url cursor instead of an encrypted one, since the catalog is public. +- **What Claude did:** Wrote the search function, the cursor, the group list, + the lint rule against raw SQL and the tests, and updated the README and + `docs/API.md`. Ran the example searches, followed the cursors through whole + result lists, and checked the SQL Prisma sends (prefix `LIKE`, no `ILIKE`, + keyset condition). The tests found a bug in the check of difficulty values, + which Claude fixed. +- **After the review:** The team chose the answers to each review point: limits + of 80 characters and 8 words, an empty result for text without words, a + prefix match for numbers, a cache for apparatus and groups, and splitting the + keyset condition into separate queries. They also moved the variants hint to + its own issue. Claude made the changes, checked the query plans with + `EXPLAIN ANALYZE`, added tests for each point and updated the docs. + +## Apollo Client in the frontend (`src/lib/apollo/`) + +- **Tool:** Claude Code +- **What the team did:** Wrote issue #75: set up Apollo Client so the pages can use the + API, with a cache for the search, the token handling from the login issue and one + place for error messages, without changing the pages. The team decided that the + refresh token is returned in the response and kept in `localStorage`. +- **What Claude did:** Wrote the client, the cache policy for `searchCatalog`, the + authorization link (one refresh and one repeat on an expired token, one refresh at a + time between tabs), the session, the error messages, the Vite proxy and the tests. + Checked it against the running backend: the first page, "load more" and the errors. + That check showed that a plain query with `after` is answered from the cache because + `after` is not part of the key, so the pages must be loaded with `fetchMore`. This is + now in the README and `docs/API.md`. + +## Synthetic test data (`backend/src/seed/synthetic.ts`) + +- **Tool:** Claude Code +- **What the team did:** Wrote issue #25: a separate script that adds 50 000 to + 100 000 fake elements with `SYN-` ids in groups of their own, one command that + removes them, and measurements with and without indexes in the README. +- **What Claude did:** Wrote the generator (descriptions are copied from the real + catalog, so the words are spread like in real data), the `add` and `remove` + commands, and tests for the generator. Ran them on a throwaway PostgreSQL 18 + database with 100 000 items: checked that `remove` leaves the real catalog and + that the normal seed leaves the `SYN-` rows alone, measured the search with + and without indexes, read `EXPLAIN (ANALYZE, BUFFERS)` for three queries and + wrote the results in the README. + +## GraphQL server (`backend/src/server/`, README, `docs/API.md`) + +- **Tool:** Claude Code +- **What the team did:** Asked for the next backend step so that the search + works through GraphQL, and for it to be started as an issue (#28). The + design came from `docs/API.md`: Apollo Server, schema first, DataLoader, no + raw SQL, the error codes and the limits. The team reviews the server and the + choices below. +- **What Claude did:** Rewrote issue #28, set up the server on Express, wrote + the catalog resolvers, the loaders, the depth limit, the error mapping, the + settings and the tests, and updated the README and `docs/API.md`. The tests + found two defects that were fixed: `totalCount` was calculated even when the + query left it out with `@include(if: false)`, and Apollo's + `unwrapResolverError` did not recognise errors when two copies of the + `graphql` package are loaded. Claude also started the server and tried + queries, images, CORS and the error cases over HTTP. +## The calculator (`backend/src/dscore.ts`, `backend/src/server/resolvers/calculator.ts`) + +- **Tool:** Claude Code +- **What the team did:** Wrote issue #72 and the rule for the D-score earlier (the + eight highest values, each code once, the single vault on vault), and decided + that a removed item is refused by the calculator. +- **What Claude did:** Wrote the D-score function (the sum is made in tenths to + avoid rounding errors), the `calculateDScore` query with its errors, the + `INVALID_ITEMS` error class, the tests and the documentation, and checked + that the number of queries does not grow with the number of ids. + +## GraphQL Code Generator (`backend/codegen.ts`) + +- **Tool:** Claude Code +- **What the team did:** Wrote issue #74: types made from the schema, so a change in + the schema shows up as a type error in the resolvers. +- **What Claude did:** Set up `@graphql-codegen/cli` with the `typescript` and + `typescript-resolvers` plugins, mapped the database rows to the GraphQL types, + typed the resolvers with `satisfies Resolvers`, and showed that renaming + a field in the schema fails `tsc`. The types found two mismatches in the + catalog resolvers (a possible `null` for `ElementGroup.apparatus` and a missing + `totalCount`), which Claude fixed. `npm audit` reports a `braces` problem in + the generator's own dependencies (a build tool, not part of the server). + +## Access tokens and sessions (`backend/src/auth/`, `backend/src/server/`, `backend/prisma/schema.prisma`) + +- **Tool:** Claude Code +- **What the team did:** Wrote issue #70 and decided how the session works: a + 15-minute access token and a 14-day refresh token that is replaced at every use + (rotation), `jose` instead of a full auth framework, and writing the other parts by + hand. The team asked what the conventional lifetimes are and chose the two-token + setup over one long-lived token. Because the course server has no https, which + Google sign-in needs, the team asked for the Google check to be a separate pull + request, so that the session does not wait for it. +- **What Claude did:** Researched the usual token lifetimes and the options for + libraries (Better Auth, Auth.js, hosted services) and explained why they were + rejected. Wrote `refreshSession`, `logout` and `me`, the `sessions` table and its + migration, the reuse check that ends a login when an old refresh token is used + again, the settings, the tests (time set by the tests, two requests at once with + the same token) and the documentation, and split the work into this part and the + Google login. Ran the tests against a throwaway PostgreSQL database. One test of + Claude's own was wrong (it used an old token and expected the new one to keep + working), which showed the reuse check doing its job; the test was fixed. + +## Gymnasts, lists and routines (`backend/src/server/resolvers/userData.ts`) + +- **Tool:** Claude Code +- **What the team did:** Wrote issue #73 and decided the rules earlier: a coach only + reaches their own data (`NOT_FOUND` for others, the same as for an unknown id), + names of 1-70 characters, the limits of 200, 50, 50 and 100, no error when an item + is added to a list twice or removed when it is not there, a removed item stays but + cannot be added, and `Routine.dScore` is `null` with a removed item. +- **What Claude did:** Wrote the resolvers, the loaders (so the number of queries does + not grow with the number of gymnasts), the error classes, the `DateTime` scalar, and + 40 tests with two test coaches. Decided to lock the routine row at the start of + `setRoutineItems`, so two saves at the same moment follow each other, and tested it. + A first version of the test for updatedAt used an access token that had expired, + which showed that the tokens are checked against the time set in the test. + + +## Database Infrastructure & Setup Completion (`backend/package.json`, `backend/.env.example`, `docs/VM_SETUP.md`, `docs/AI_USAGE.md`, `src/App.tsx`) + +- **Tool:** ChatGPT & Gemini +- **What the team did:** Defined the PostgreSQL 18 dual-user security model for the VM (separating `wag_migrator` schema management from `wag_app` runtime CRUD operations per `docs/API.md`), updated `backend/.env.example` and `package.json` to handle dynamic migration connection fallbacks, and resolved Vite `base: '/project2/'` asset resolution breaking SVG icons in `src/App.tsx`. +- **What the AI did:** Generated the complete VM setup guide (`docs/VM_SETUP.md`) detailing PostgreSQL 18 installation, SQL privilege configurations, and seeding verification steps. Drafted the fallback script for `backend/package.json`, updated environment templates, and refactored hardcoded SVG `/icons.svg#` links in `src/App.tsx` to dynamically import and resolve relative to Vite's base path. +- **After the review:** Verified that PostgreSQL 18, database creation, user roles, and catalog seeding were fully executed and tested on the VM. Confirmed that local dev server instructions in `README.md` correctly reflected the `/project2/` subpath. + +## Tailwind design tokens (`index.html`, `src/index.css`) + +- **Tool:** GitHub Copilot +- **What the team did:** Chose to reuse the existing design tokens, load + Faustina and Inter, and expose the tokens as Tailwind utilities for the UI. +- **What GitHub Copilot did:** Added the font links, connected the font tokens, + mapped the existing colors, spacing, font sizes, radii and shadows into the + Tailwind theme, and changed global heading text to the existing navy token. +- **Review:** The team reviewed the theme changes and confirmed the colors + should come from the existing CSS variables rather than new palette values. + diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..180a2c9 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,479 @@ +# GraphQL API + +This document describes the API between the React client and the backend: why we chose GraphQL, what the schema contains, how search and pagination work, how users are identified, and how errors are reported. + +The schema itself is in [`backend/src/schema.graphql`](../backend/src/schema.graphql). It is a draft: the backend is not implemented yet. The data behind it is described in [DATA_MODEL.md](DATA_MODEL.md). + +## Why GraphQL and not REST + +GraphQL is required by the assignment, but it also suits this application: + +- **The data is nested.** A page often needs an apparatus, its groups and the items in them, or a gymnast with its routines and their elements. With REST this is either several requests or one endpoint per 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. A detail page needs more. The client chooses the fields, so we do not send fields that are not shown. This is also part of our sustainability choices. +- **One typed contract.** The schema describes every query, argument and field. We can generate TypeScript types from it for the client, so a change in the API shows up as a type error and not as a bug at runtime. + +## The schema and the database are different + +The GraphQL schema is not a copy of the database tables. It describes what the client needs: + +| Database / data model | GraphQL | +| ----------------------------------------- | --------------------------------------------------- | +| `routine_elements` (rows with a position) | `Routine.items` (the elements themselves, in order) | +| `gymnast_elements` (rows) | `Gymnast.elements` (the elements themselves) | +| `removedAt` (a timestamp) | `removed` (true or false) on every catalog item | +| `VariantGroup` | `Element.variants` and `Vault.variants` | +| `Coach.googleId` | not exposed | +| D-score | calculated field: `Routine.dScore` | + +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. + +## Overview of the schema + +### 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`, `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. + +Fields that only exist on one type are fetched with a fragment: + +```graphql +{ + catalogItem(id: "VT-1.22-a") { + id + name + ... on Vault { + dValue + } + ... on Element { + value + } + } +} +``` + +#### 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`. A routine also has `createdAt` and `updatedAt`. +- `login(googleToken)` verifies the Google token and returns an access token, a refresh token and the coach. `refreshSession(refreshToken)` gives new tokens, and `logout(refreshToken)` ends the login. The Google check and `login` are a separate pull request (issue #70, part 2); until it is merged `login` answers `NOT_IMPLEMENTED`. +- 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. The rule is the same as for a saved routine (constraint 13 in [DATA_MODEL.md](DATA_MODEL.md)): the sum of the eight highest values, each code counted once, and on vault the D-value of the single vault. The sum is made in tenths (whole numbers), so there are no rounding errors. An empty list gives `0`, and the same id twice counts once. It throws `INVALID_ITEMS` when the items do not fit together (items from more than one apparatus, a vault with elements, or more than one vault) or when an item has been removed from the Code of Points, and `BAD_USER_INPUT` for more than 50 ids, an id that does not exist or an id with odd characters. The function is in `backend/src/dscore.ts` and `Routine.dScore` will use the same one. +- 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 + +All of it happens in one query, `searchCatalog`, on the server and in the database. The client never filters or sorts only the part of the result it has loaded. + +```graphql +query { + searchCatalog( + search: "salto" + filter: { apparatusId: "BB", difficultyValues: [0.4, 0.5] } + sort: { field: DIFFICULTY, direction: DESC } + first: 20 + after: "" + ) { + totalCount + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + code + description + ... on Element { + value + } + } + } + } +} +``` + +- **Search** is one or more words and is not case sensitive. It finds items that have, for every search word, a word that starts with it. An empty search means no text filter, while a search with no searchable words (such as `%` or `ø`) finds nothing. At most 80 characters and 8 words, otherwise `BAD_USER_INPUT` (see Search without raw SQL). The client should wait until the user stops typing (debounce) before it sends the query. +- **Filter** by apparatus, group, difficulty values, and whether the item is named after a gymnast. The difficulty filter is a multiselect of values, for example `[0.4, 0.5]`. The interface shows the values as letters, so choosing D and E sends `[0.4, 0.5]`. Each value must be 0.1–1.0 in steps of 0.1. The backend rounds each value to one decimal and compares it as a `Decimal`, because a GraphQL `Float` is a binary floating-point number: 0.1 + 0.2 arrives as 0.30000000000000004, which would otherwise match nothing. The filter only applies to elements, because vaults have D-values and not difficulty values, so vaults are left out when it is set. Together with vault it gives `BAD_USER_INPUT`, also when vault is given only through a vault group (`VT-1`) and not as the apparatus. +- **The apparatus and groups are checked from memory.** The search checks the apparatus and group filters against a list that is read from the database once and kept for ten minutes (`backend/src/catalog/structure.ts`), so a search page needs no extra database calls for it. The catalog is read-only, and the apparatus and groups only change with a new Code of Points. `listElementGroups` uses the same list. +- **Unknown values in the filter are errors, not empty results.** An apparatus or element group that does not exist gives `BAD_USER_INPUT`, and so does a group from another apparatus than the chosen one. An empty list would hide a typo or an old id in the client, so the client is told what is wrong instead. The element group filter can be used without an apparatus, because a group belongs to exactly one apparatus. +- **Sort** by code, difficulty or name, ascending or descending. `DIFFICULTY` means `value` for elements and `dValue` for vaults, and is only sorted within one apparatus: it is only allowed together with an `apparatusId` filter, and gives `BAD_USER_INPUT` without it. Element values (0.1–1.0) and vault D-values (about 2.0–6.4) are different scales, so one list sorted by both would always put the vaults at one end. +- **Pagination** uses cursors (the Relay connection pattern: `edges`, `node`, `cursor`, `pageInfo`). `totalCount` is the number of matches in the whole result set. + +### Why difficulty is only sorted within an apparatus + +Sorting by difficulty is only allowed when an apparatus is selected. Comparing difficulty across apparatus gives the user nothing of value: element values go from 0.1 to 1.0, and vault D-values from about 2.0 to 6.4, so a list sorted by both would only put all the vaults at one end. Within one apparatus the order is meaningful, so that is the only place the sort is offered. Without an apparatus filter the API gives `BAD_USER_INPUT`, and the interface should only offer the difficulty sort once an apparatus is chosen. + +For the same reason, the difficulty filter is a list of chosen values and not a range. A coach thinks in letters (A–J), so the interface shows the values as letters, and the API works with the values. Choosing A and B sends `[0.1, 0.2]`. The letters only exist for elements on bars, beam and floor, so the filter leaves out vaults, and cannot be combined with vault. The letter is never stored or sent by the API: the frontend calculates it from the value. + +### Difficulty letters are only handled in the frontend + +The difficulty letter (A–J) and the difficulty value (0.1–1.0) always match one to one, so only the value is stored and sent through the API. All logic in the backend and the database uses the value: filtering, sorting and the D-value of a routine. The letter is only something the user sees, so the frontend handles it in both directions: + +| Letter | A | B | C | D | E | F | G | H | I | J | +| ------ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Value | 0.1 | 0.2 | 0.3 | 0.4 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 | 1.0 | + +- **Showing an element:** the frontend gets `value` and shows the letter for it, for example `0.4` as D. +- **Filtering:** the user chooses letters in a multiselect, and the frontend sends the matching values in `difficultyValues`. Choosing A and B sends `[0.1, 0.2]`, and the API returns the elements with value 0.1 or 0.2. +- **Sorting:** the user sorts by difficulty, and the API sorts by value. Letters and values have the same order, so the result is the same. +- **Vaults** have no letter. The frontend shows their D-value, for example 2.6, and does not offer the letter filter when vault is chosen. + +The mapping lives in one place in the frontend (a small function and the list of letters), so the API and the database never need to know about letters. + +### Why cursors and not page numbers + +The requirement is that the solution works with any amount of data. With offset pagination the database has to count past all earlier rows for every page, and rows can be skipped or shown twice if data changes between two requests. A cursor says "continue after this row", so the cost does not grow with the page number, and it fits infinite scroll. The cursor is opaque to the client, which only passes the `endCursor` back as `after`. + +## Scalability + +The requirement is that the solution works with any amount of data. The catalog is the part that can grow, so that is where the design must hold. Below is what the API does about it, and what the backend must do. + +### The catalog + +- **Every list is paged with cursors**, and the page size has an upper limit of 100 in the schema documentation (`first`: 1–100). A client cannot ask for the whole catalog in one request. +- **A deep page must not cost more than the first page.** The cursor holds the sort values and the `id` of the last row, and the database continues after it with an index. Offset pagination would read and throw away all earlier rows. After a cursor the search runs up to three small queries, each starting in the index right after the cursor (see Keyset pagination under Search without raw SQL). We checked the plans with `EXPLAIN ANALYZE` on the real catalog. Measuring this on a large catalog is sub-issue 5. +- **Sorting must be stable.** Many items have the same difficulty, so `id` must always be the last sort key. Without it rows can be skipped or repeated between two pages. +- **Filter and sort need indexes.** `schema.prisma` declares an index for each sort field combined with `id` (`codeSort`, `difficulty`, and `hasName` with `nameSort`), and for the filter columns (apparatus, group). Without them the database sorts the whole catalog for every request. The name sort puts named items first in both directions (`hasName` descending), so ascending and descending each have their own index. The apparatus filter is often combined with a sort, so there are also indexes on apparatus with `difficulty` and with `codeSort`. The difficulty sort always has an apparatus filter, so it uses the index on apparatus and `difficulty`. The difficulty filter without an apparatus uses the index on `difficulty`. Without them, a filtered page walks past the rows of the other apparatus first, which is slow in one direction (for example vault sorted by ascending difficulty). +- **Sort by name needs a rule for items without a name.** Most items have no name. A cursor does not accept `nulls` placement, so the sort uses a plain `hasName` field and a `nameSort` field that is never `null` (see Search without raw SQL). +- **The text search must not scan the table.** A search for `%word%` cannot use an ordinary index. The search uses a word table with a B-tree index and a prefix match, which finds the rows that have a word starting with the search term without reading every row. A search that is correct but reads every row works on 500 rows and not on 5 million. +- **`totalCount` is only calculated when the client asks for it.** Counting a large result takes time: about 30 ms for the whole catalog and about 100 ms for a search in a test with 225 000 entries, and the cost grows with the catalog. The resolver for the field runs only if `totalCount` is in the query, so a client that scrolls without showing a total does not pay for it. The client should therefore ask for `totalCount` only with the first page of a new search or filter, and not with the pages that are loaded while scrolling. Together with the debounce on the search field, this keeps the count from running on every keystroke. If the count is still too slow, it can be capped (for example "1000+"), which needs a change in the schema. +- **Elements and vaults are different tables, but one list.** A query cannot combine two tables into one sorted and paged result. The data model keeps them apart because they work differently, so the list is read from `CatalogEntry`, a read-only table with the shared columns that the seed script fills. It never gets out of date because the catalog never changes between seed runs. +- **Variants are few.** A variant group has two or three members, so `variants` does not need paging. + +### Protecting the server + +- **No N+1 queries.** A list of 20 items that each asks for `elementGroup` and `variants` would give 41 database queries. DataLoader collects the lookups of one request into one query for each kind. This is done for the catalog (the element or vault behind a search result, the variants of a code and the apparatus of a group, in `backend/src/server/loaders.ts`). It is still to do for `Gymnast.routines`, `Gymnast.elements`, `Routine.items` and `Routine.apparatus` when the user data is built. +- **Limit the depth and cost of a query.** The schema has cycles (`Apparatus` → `groups` → `apparatus` → …), so a client could ask for a very deep query. The server rejects a query deeper than 8 levels (`MAX_QUERY_DEPTH` in `backend/src/server/depthLimit.ts`) with `GRAPHQL_VALIDATION_FAILED`, before anything runs. The introspection fields are not counted, because the tools that read the schema ask a deep question on purpose. A request body above 100 kB is refused. A cost limit is not set. +- **Limit the size of the arguments.** `calculateDScore` and `setRoutineItems` take at most 50 ids. + +### 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, 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 + +- **On the client**, Apollo Client stores the result by type and `id`. The ids of elements and vaults are unique across both types (`UB-1.101-a`, `VT-1.22-a`), so an item that is loaded in a list is reused in the detail view without a new request. +- **On the server**, the catalog is read-only, so search results and items can be cached in memory and cleared when the seed script runs. User data must not be shared between coaches in a cache. +- **Images** are static files with long cache headers (see Images). + +### What this does not cover + +These choices keep the API and the queries cheap. A single Node process and a single PostgreSQL server on the virtual machine is the limit of what we run. For more traffic the same API can run on several Node processes, since the server keeps no state between requests (the session is a token). + +## Images + +The database stores only the file name of a drawing (the `image` field in the data model). The API gives a full URL in `imageUrl`, ready to use as the `src` of an ``. The backend builds it from a base URL in an environment variable and the file name. + +- The data does not depend on where the images are. If they move to another folder or server, we change one variable and no data. +- The client does not need to know where the images are. +- The name `imageUrl` makes it clear that this is a URL. For GraphQL, a file name and a URL are both just a string, so the name is the only thing that tells them apart. +- The images are static files served by Apache and cached by the browser. They are not sent through GraphQL, which keeps the responses small. + +The file names are not exposed in the API. The to-do list for images, the virtual machine and the frontend is in IMAGES.md in the docs folder. + +## Identifying the user + +Coaches log in with Google, and the server gives them two tokens of its own (issue #70). The code is in `backend/src/auth/`. + +**Status:** the tokens, `refreshSession`, `logout` and `me` are built. The Google check and `login` (steps 1–3 below) are a separate pull request, because Sign in with Google needs https (except on localhost), and the course server has only http. Until that is merged, `login` answers `NOT_IMPLEMENTED`, and the tokens can only be made by the code (the tests do). + +1. The browser gets a Google ID token (Sign in with Google) and the client sends it to `login`. +2. The server checks it with Google (`google-auth-library`): the signature, the expiry, the issuer, and that it was made for our application (`aud` is `GOOGLE_CLIENT_ID`). It creates the coach on the first login from `sub` (stored as `googleId`), `email` and `name`, and keeps the name and email up to date on later logins. The Google token itself is not stored (see "Google login and `googleId`" in [DATA_MODEL.md](DATA_MODEL.md)). +3. `login` returns the coach with an access token and a refresh token. + +| Token | What it is | Lives for | Used for | +| ------------- | -------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------- | +| Access token | A JWT (`jose`, HS256, signed with `SESSION_SECRET`) that only holds the coach id | 15 minutes | Sent in `Authorization: Bearer ` with every request | +| Refresh token | 32 random bytes (base64url), not a JWT. Only its SHA-256 hash is stored | 14 days | Only sent to `refreshSession` and `logout`, to get new tokens or to end the login | + +The access token is short, because the client keeps it and a token that leaks can be used until it expires. The refresh token lives longer, so a coach does not log in with Google again every 15 minutes. These are the usual lifetimes for this setup (see OAuth 2.0 and the OWASP session guidance). + +**Rotation.** `refreshSession(refreshToken)` returns a new access token and a **new** refresh token, and the old refresh token stops working. All the refresh tokens that follow from one login form a family (table `sessions`, see [DATA_MODEL.md](DATA_MODEL.md)). If a token that has already been replaced is used again, someone has a copy of it, and the server cannot tell who is the coach and who is the thief. It then revokes the whole family, and the coach has to log in with Google again. `logout(refreshToken)` revokes the family in the same way, and returns `true` also for an unknown token, so tokens cannot be probed. + +**What the client does:** + +- It keeps the access token in memory. It keeps the refresh token where it survives a reload, and sends it nowhere except to `refreshSession` and `logout`. +- **It refreshes before the request, not only after an error.** If the client has a refresh token and the access token is missing (a reload, a new tab) or expires within 30 seconds, it calls `refreshSession` first. It reads `exp` from the token only to plan this; the server still decides what is valid. The reason is that an expired access token does not make the server fail the request: `me` answers `null`, so a page that asks for `me` would show the coach as logged out after 15 minutes, although the refresh token still works. Waiting for an `UNAUTHENTICATED` error cannot catch that. Checking the expiry before the request and retrying after an error is the usual way to build an Apollo auth link, and the retry stays as a fallback for the cases the check misses (a clock that is wrong, a login that was ended on the server). When a request answers `UNAUTHENTICATED`, the client refreshes once and repeats the request. If that fails, the coach logs in with Google again. +- **The login, refresh and logout requests time out after 5 seconds** (`REQUEST_TIMEOUT_MS` in `src/lib/apollo/api.ts`, with `AbortSignal.timeout`). `main.tsx` waits for the refresh before it shows the app, so a slow server would otherwise leave a white page. A timeout is not an answer from the server, so the client keeps the refresh token and the next request tries again. Showing the app first with a loading state would also work, but it needs a state in every page that reads `me`, and the timeout is one line in one place. +- **It must refresh from one place at a time.** Two tabs that use the same refresh token at the same moment look like a stolen token, and the login ends. Use one refresh at a time per browser (for example `navigator.locks`), and store the new refresh token before the next request. + +**Decision for now: the tokens are returned in the response body, not in a cookie.** The refresh token is therefore readable by scripts on the page, so a cross-site scripting hole would leak it. Rotation and the reuse check limit what a stolen token is worth, but they do not remove the risk. An httpOnly, Secure, SameSite cookie for the refresh token is safer, and works when the app and the API are on the same origin (Apache proxy on the virtual machine, the Vite proxy in development). It needs `credentials` in CORS and a change in `login`, `refreshSession` and `logout`, and can be added later without changing the rest. + +**What the server guarantees:** + +- A missing, wrong, tampered or expired access token is the same as not being logged in: `me` returns `null` and every protected operation throws `UNAUTHENTICATED`. The token is checked once per request, and only `Authorization: Bearer` is read. Tokens signed with `alg: none` or another key are refused. +- An invalid Google token (when the login is merged), and an unknown, expired, used or revoked refresh token, all give `UNAUTHENTICATED` with a general message. The reason stays on the server. +- Expired refresh tokens are deleted whenever `refreshSession` runs (and at login, when the Google login is merged). The clean-up has no timer of its own, because the server is one process on one machine and the table is small; a rarely used server simply keeps a few extra rows until the next refresh. Tokens that were used or revoked stay until they expire, because the reuse check needs them. +- **An access token keeps working for up to 15 minutes after `logout`.** `logout` ends the refresh-token family, so no new tokens can be made, but the access token is only checked against its signature and expiry and not against the database. That is what makes it cheap, and it is the usual trade-off for short-lived JWTs. Checking the database on every request would remove the gap, but it would also remove the reason for having a stateless access token, and 15 minutes is short enough for this app, whose data is a coach's own gymnasts and routines. +- Changing `SESSION_SECRET` logs everyone out of their access tokens (the refresh tokens still work and give new ones). +- Not covered yet: limiting how often `login` can be called. + +The catalog, `searchCatalog` and `calculateDScore` do not need a login. Everything about gymnasts and routines does, and a coach can only reach their own data. + +## Error handling + +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, 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: + +```json +{ + "data": null, + "errors": [ + { + "message": "A routine with this name already exists", + "extensions": { "code": "NAME_TAKEN" } + } + ] +} +``` + +### Why we chose this + +- **`me` returns `null` when nobody is logged in.** The app asks `me` on every page load, and "not logged in" is a normal state for everyone who only uses the catalog. If it were an error, normal use would trigger the error handling and logging. Every other protected operation throws `UNAUTHENTICATED`, because there a missing login is a real problem that the UI must react to, for example by sending the user to the login page. +- **`NOT_FOUND` also for other coaches' data.** A separate "forbidden" answer would tell a user which ids exist. The same answer in both cases follows constraint 9 (a coach can only read and change their own gymnasts and routines) without leaking anything. +- **Plain GraphQL errors and not typed result unions.** A union such as `Routine | NameTaken | InvalidItems` makes the compiler force the client to handle each error, but it adds a type and a fragment per error to every mutation. For a prototype with a few failure cases this costs more than it gives. If the number of error cases grows, the unions can be introduced for single mutations without changing the rest of the schema. +- **Readable messages for errors the user can fix.** `NAME_TAKEN` and `INVALID_ITEMS` have messages that the UI can show as they are. + +The price of this choice is that the types do not force the client to handle the errors. We handle that with tests of the error cases (see the testing section in the README when it exists). + +## The server + +The server is built (issue #28) in `backend/src/server/`. It answers the catalog queries and the search. The login is built (issue #70), and so are the gymnasts, their lists and the routines (issue #73). + +**Run it:** `npm run backend` (restarts on changes) or `npm run start -w backend`, after the database is migrated and seeded. The settings are in `backend/.env` (see Configuration). The code is split so each part can be read and tested on its own: + +| File | What it does | +| ------------------------ | ---------------------------------------------------------------------------------------------------- | +| `index.ts` | Starts the server and shuts it down cleanly on `SIGINT` and `SIGTERM` | +| `server/app.ts` | Builds the Express app and the Apollo server: `/graphql`, `/images`, `/health` | +| `server/config.ts` | Reads and checks the settings; a wrong value stops the start with a clear message | +| `server/resolvers/` | `catalog.ts`, `calculator.ts`, `auth.ts` (login, `me`) and `userData.ts` (gymnasts, lists, routines) | +| `server/loaders.ts` | The DataLoaders, one set for each request | +| `server/depthLimit.ts` | The depth limit as a validation rule | +| `server/formatError.ts` | How errors look to the client | +| `server/selectsField.ts` | Whether the query asks for a field, so `totalCount` is only calculated when it is asked for | +| `server/context.ts` | What every resolver can use: the database, the settings, the loaders and the logged-in coach | +| `auth/accessToken.ts` | Signs and checks the access token (`jose`) | +| `auth/sessions.ts` | The refresh tokens: start, rotate, end, delete the expired | + +The scripts are in `backend/package.json`: `dev` and `start` run `src/index.ts` with `tsx`. The server runs from the source, because `tsconfig.json` has `noEmit` and there is no `dist/` to run. `setup` runs the migrations and then the seed (`prisma migrate deploy` and `prisma db seed`), which is what a new database needs, such as the one on the virtual machine (issue #26). `build` only checks the types. + +### Addresses + +| Address | What it gives | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `POST /graphql` | The API. In a browser, `GET /graphql` opens Apollo Sandbox when the server is not in production | +| `GET /images//` | The drawings in `data/images/`, kept by the browser for 30 days. On the virtual machine Apache serves them | +| `GET /health` | `{"status":"ok"}`. It does not use the database, so a process manager or a proxy can check that the server is up | + +### Decisions + +- **`variants` lists the other variants** of a code, without the item itself, and without variants that are removed. The number of variants of a code is therefore `variants.length + 1`. +- **`imageUrl`** is `IMAGE_BASE_URL`, the apparatus (the first part of the id) and the file name. Moving the drawings only changes `IMAGE_BASE_URL`. +- **`value` and `dValue`** are `Decimal` in the database and are converted to numbers at the end, where they are sent. +- **A search result is a catalog entry** (one per code). The element or vault behind each result is loaded in one batch by DataLoader, so a page costs a fixed number of queries (4–6 in the tests) whether it has 5 or 100 results. +- **`totalCount` is only calculated when the query asks for it**, also through fragments, and not when `@skip` or `@include` leaves it out. A client that wants the count only on the first page writes `totalCount @include(if: $firstPage)`. +- **Errors.** `BadUserInputError` and the other errors in `backend/src/errors.ts` get their code in `extensions.code`. Errors from GraphQL itself (an invalid query) keep their code. Anything else gives `INTERNAL_SERVER_ERROR` with the message "Internal server error": the details go to the log and never to the client. `formatError` reads `originalError` directly instead of using Apollo's `unwrapResolverError`, because that function fails when two copies of the `graphql` package are loaded. +- **A request body above 100 kB is refused** (413). The CORS allow-list is `CORS_ORIGIN`; a browser on another address cannot call the API. +- **`graphql` is pinned to version 16.** Apollo Server 5 needs `^16`, and the latest release is 17. +- **`tsx` and `dotenv` are regular dependencies**, because the server runs with `tsx` and loads `backend/.env`. Install with `npm ci`, not with `--omit=dev`. + +### The user data + +The resolvers for gymnasts, their lists and routines are in `server/resolvers/userData.ts` (issue #73). The rules are the ones in "User data" above and in the table of error codes: + +- **Ownership.** Every operation needs a login (`UNAUTHENTICATED`) and only reaches the logged-in coach's own data. A gymnast or routine of another coach gives `NOT_FOUND`, with the same message as an id that does not exist. The routine is checked through its gymnast. +- **Names** are trimmed and 1-70 characters, counted as characters (an emoji is one). **Limits:** 200 gymnasts per coach, 50 routines per gymnast, 50 items in a routine (`LIMIT_REACHED`), 100 items in a gymnast's list. A routine name is unique per gymnast and apparatus (`NAME_TAKEN`). The limits are checked before the row is written, so two requests at the same moment can pass the check together and go over a limit by the number of requests. The unique name is guarded by the database. +- **`setRoutineItems`** replaces the whole list in one transaction and sets `updatedAt`. It checks that every item is on the routine's apparatus, that there is at most one vault on vault, and that a removed item is only kept if it was already in the routine (`INVALID_ITEMS`); an unknown id is `BAD_USER_INPUT`. The first thing the transaction does is to update the routine row, which locks it, so two saves at the same moment follow each other and the last one wins. A save that fails changes nothing. +- **The gymnast's list:** adding an item that is already there, or removing one that is not, changes nothing and gives no error. A removed item cannot be added (`INVALID_ITEMS`) but can be taken out. An unknown item is `NOT_FOUND`. +- **`Routine.dScore`** is `calculateDScore` of the items, and `null` while the routine has a removed item. +- **No N+1 queries.** `Gymnast.routines`, `Gymnast.elements`, `Routine.items` and the gymnast and apparatus of a routine are DataLoaders, so a coach with 25 gymnasts costs the same number of queries as one with 2 (a test checks it). A mutation makes the loaders forget what it changed, so several changes in one request show the right list. +- `DateTime` is an ISO 8601 string in the answer. + +## Tools + +These are the choices for the implementation. The server is built with them (issue #28), except where it says that something is still to do. The team can change them. + +### Server and types + +- **Apollo Server 5** on Express 5 for the GraphQL server (`graphql` is pinned to version 16, which Apollo needs). It has the most documentation, it works with Apollo Client (which we use for the cache and for the local state in the frontend), and it has Apollo Sandbox for trying queries in the browser. +- **Schema first.** We write `schema.graphql` by hand and the resolvers follow it. The file is easy to read for someone who reviews the project, and the schema is the contract between the frontend and the backend. The alternative, generating the schema from TypeScript code (code first), hides the contract in the code. +- **GraphQL Code Generator** makes the TypeScript types for the resolvers from the same schema file (issue #74), and will make the types for the queries in the frontend. A change in the schema then shows up as a type error in both places. Run `npm run codegen` (in `backend/`, config in `backend/codegen.ts`) after `npm ci` and after every change in `schema.graphql`. It writes `backend/src/generated/graphql.ts`, which is not committed, like the Prisma client next to it, so `tsc` and `npm run lint` need it. The resolver files end with `satisfies Resolvers`: a resolver for a field that is not in the schema, with the wrong arguments or with the wrong return type fails `tsc`. The database rows that resolvers return are mapped in `codegen.ts` (`Apparatus`, `ElementGroup`, `Coach`, `Gymnast`, `Routine`, and `Element`, `Vault`, `CatalogItem` and `CatalogConnection` from `server/items.ts`). The first run found two mismatches that are now fixed: `ElementGroup.apparatus` could be `null` although the schema says it never is, and `totalCount` could be missing when the query did not ask for it (it now has its own resolver that fails clearly). + +### The client in the frontend + +Apollo Client is set up in `src/lib/apollo/` (issue #75). See "Calling the API from the frontend" in the README for how to use it. + +- **`cache.ts`.** `searchCatalog` is a Relay connection. The key of the field is the search, filter and sort, so all the pages of one search are one list. The merge function puts a page with `after` after the pages already there, skips a page that arrives twice (by cursor), starts over when the first page (no `after`) arrives again, and keeps `totalCount` when a later page does not have it. `possibleTypes` lets a fragment on `CatalogItem` match `Element` and `Vault`. Elements and vaults are stored once by `__typename` and `id`, also when they are in several lists. Because `after` is not in the key, a plain query with `after` is a cache hit; `fetchMore` is the way to load the next page. +- **`authLink.ts`, `session.ts`.** Adds `Authorization: Bearer `, and on `UNAUTHENTICATED` refreshes the session once and repeats the request once. The login operations (`Login`, `RefreshSession`, `Logout`) never start a refresh. Parts of the page that refresh at the same moment share one request, and tabs follow each other through `navigator.locks`, with the refresh token read inside the lock. A refresh token that the server refuses ends the login; a network error keeps it for a later try. The access token is kept in memory, and the refresh token in `localStorage` (see the decision in "Identifying the user"). +- **`api.ts`.** The `login`, `refreshSession` and `logout` calls are plain `fetch` requests, not Apollo operations, because the refresh is called by the link itself. +- **`errors.ts`.** One place for the messages of the error codes. + +### Search without raw SQL + +**The backend never uses raw SQL.** No `$queryRaw`, `$executeRaw`, their `Unsafe` versions or `$queryRawTyped` (Prisma's TypedSQL). Every query goes through Prisma Client, which sends all values as parameters. This removes SQL injection by the way the code is built, and not by careful escaping. A lint rule (`no-restricted-properties` on those names, in `eslint.config.js`) makes the build fail if someone adds a raw query anywhere in the backend, including the tests. Indexes are declared in `schema.prisma` with `@@index`, and the migrations are generated by Prisma, with a few additions written by hand in SQL: CHECK constraints and the collation of `catalog_words.word`, which Prisma cannot express. These are migrations, not queries in the backend, so they do not break the rule. + +The requirement is search on words that is not case sensitive, together with filter, sort and cursors over the whole catalog. Prisma can do this if the data is shaped for it. Two things stop Prisma from doing it on the element and vault tables directly: a query cannot combine two tables into one sorted list, and a cursor only works when every sort field is a plain field of the model (a sort with `nulls` placement, or on a related record, makes it throw an error). + +**The catalog entry model.** `CatalogEntry` is a read-only table, filled by the seed script, with one row per code: the first variant that is not removed, normally the `-a` variant of each element and each vault. The catalog list shows one row per code, and the other variants are shown on the detail page: + +| Field | Content | +| ------------------------------- | ------------------------------------------------------------------------------- | +| `id` | The id of the element or vault, for example `UB-1.101-a` | +| `type` | `ELEMENT` or `VAULT` | +| `code`, `name`, `description` | Copied from the element or vault | +| `difficulty` | `value` for an element and `dValue` for a vault | +| `apparatusId`, `elementGroupId` | For the filters | +| `hasName`, `nameSort` | Whether the item is named after a gymnast, and the name in lowercase (or empty) | +| `codeSort` | The code with each part padded to the same length, so `2.1` sorts before `10.1` | + +The element and vault tables stay as the data model describes them and are the source for the detail view. The entry table is for the list. `searchCatalog` reads the entries, and the fields that only exist on one type (`value`, `dValue`, `imageUrl`, `variants`) are loaded from the element and vault tables with DataLoader. + +**Sort without `null`.** Most items have no name, so sorting by name cannot put `null` last, because a cursor does not accept `nulls` placement. The sort is `hasName` descending, then `nameSort`, then `id`. All three are plain fields and no value is `null`, so named items come first in both directions and the cursor works. + +**The word index.** A second table, `CatalogWord`, has one row for each distinct word of each entry: the entry id and the word in lowercase. The seed script fills it by splitting the code, the name and the description of **all the variants** of the code into words (letters and digits, without accents; a code like `3.304` is kept as one word). A word that is only in variant `-b` therefore still finds the code. The seed script also adds abbreviations from the Code of Points written out as words, for example `handstand` for `hstd` and `low bar` for `LB`. The list is `ABBREVIATIONS` in `backend/src/catalog/words.ts`, and a test fails if the data contains an abbreviation that is neither in the list nor left out on purpose. It has a B-tree index on `word`. The search is a Prisma filter with one condition per search word: + +```ts +where: { + AND: terms.map((term) => ({ + words: { + some: { + word: { + startsWith: term, + }, + }, + }, + })) +} +``` + +- The search text is split into words the same way as the stored words: lowercase, without accents, letters and digits, with codes like `3.304` kept whole. Both sides use `normalizeWords` in `backend/src/catalog/words.ts`, so the search words and the stored words always follow the same rules. Any other character, including `%` and `_`, is always removed. So "hop-grip" becomes `hop` and `grip`, "sec." becomes `sec`, and "1/1" becomes `1`. Empty words are dropped. +- **The search text has limits: at most 80 characters and 8 words** (after cleaning, and a word that is repeated counts once). More gives `BAD_USER_INPUT` with a message that says which limit was passed, so the interface can tell the user. A catalog description has 12 words and 65 characters at the median, so the limits are far more than anyone types, while each word adds a condition to the query. +- **Text without searchable words finds nothing.** If the search text is not empty but gives no words, for example "ø", "日本語", "😀" or "%", the result is empty. The user typed something, so showing the whole catalog would be surprising. Only an empty search, or one with only spaces, means no text filter. +- **Numbers are matched by prefix, like words.** "36" also finds "360", so the results follow the user while they type. A whole number is not matched exactly. +- **The cleaning is the only protection against wildcards.** Prisma does not escape `%` and `_` in `startsWith`: a search for `%` becomes `LIKE '%%'` and matches every word (in a test, all 225 000 entries in about 460 ms). The cleaning removes them, so it must always run before the query, and it must have tests. +- **The cleaning is also what makes the search case-insensitive.** The words are stored in lowercase, so an uppercase term such as "SAL" finds nothing unless it is lowercased first. +- **All words must match.** An item is found only if it has, for every search word, a word that starts with it. "giant turn" finds giant circles with a turn, and not every giant circle and every turn. +- **A trailing `s` is removed from each search word**, so "saltos" finds "salto" and "hips" finds "hip". Because the match is on the start of a word, the shorter word still finds the longer one: "salto" also finds "saltos". A search word that is only `s` is dropped. Codes and numbers (words that start with a digit) are left as they are, because they have no plural and a code must be matched exactly. This is the only rule that the search side adds to `normalizeWords` (`searchTerms` in `backend/src/catalog/search.ts`). +- Both the stored words and the term are lowercase, so the search is not case sensitive without `mode: 'insensitive'`. **The search query must never use `mode: 'insensitive'`, and a lint rule (`no-restricted-syntax` in `eslint.config.js`) fails the build if it is used anywhere in the backend.** It turns `LIKE` into `ILIKE`, and then the index is no longer used. The seed script stores the words in lowercase, and the backend lowercases the search term before the query. +- `startsWith` finds the rows that have a word starting with the search word, so "sal" finds "salto". It works from the first letter. +- The filter, sort and cursor use the same `findMany` query, and `totalCount` uses `count` with the same `where`, without the cursor. The count only runs when the caller asks for it (`withTotalCount`), which the resolver does only when the query contains `totalCount`. +- **Keyset pagination.** The page after a cursor is selected with conditions on the sort values in the `WHERE`, not with Prisma's `cursor` and `skip` options. The rows after a cursor are split into parts in sort order, and each part is its own query: for the code sort, first `codeSort = v` and `id > id`, then `codeSort > v`. The name sort has a third part, the items without a name, when the cursor is on a named item. The parts are read in order until the page is full, so most pages need one or two queries. **Why not one condition with `OR`:** with `codeSort > v OR (codeSort = v AND id > id)` PostgreSQL can only use the sort value as an index bound and checks `id` as a filter. Rows with the same sort value are then read again on every page. That is many rows here: about 69 % of the items have no name (same `nameSort`), and the difficulty sort has only ten values. On the seeded catalog `EXPLAIN ANALYZE` showed `Rows Removed by Filter: 223` for one page with `OR`, and `id` in the `Index Cond` with the split. The parts are defined per sort in `afterParts` in `backend/src/catalog/sorts.ts`, and a test checks that the queries after a cursor have no `OR`. Prisma's `cursor` takes an `id` and looks up that row to find its sort values, so it needs the row to still exist and to be found again with the same filters. Our cursor carries the sort values itself, so the next page works without that lookup, and the values are checked when the cursor is decoded. Prisma still adds `OFFSET 0` to every query with `take`. It skips no rows, so it costs nothing. + +The search is `searchCatalog` in `backend/src/catalog/search.ts`. It returns `CatalogEntry` rows, the page info and, when asked, the total count. The fields that only exist on elements or vaults are loaded by the resolver. `listElementGroups` in the same file lists the groups of one apparatus, for `Apparatus.groups`. + +**What the index needs.** A B-tree index can serve `LIKE 'sal%'` only if the column uses the `C` collation, or the index uses `text_pattern_ops`. Our databases use the `en-US` collation (the default for PostgreSQL 18 on our machines), so an ordinary index on `word` is not used for a prefix search, and every search would read the whole table. We checked this with `EXPLAIN`. + +We therefore set the `C` collation on the `word` column of `CatalogWord` only, in the migration (`ALTER TABLE "catalog_words" ALTER COLUMN "word" TYPE TEXT COLLATE "C"`), and keep an ordinary `@@index([word])` in `schema.prisma`. The reasons: + +- **It only affects this column.** All other text keeps the `en-US` sorting, including `nameSort` for names with accents. Creating the whole database with the `C` collation would change the sorting of all text. +- **It works the same everywhere.** The setting is part of the migration, so it is the same on every machine and on the virtual machine, without any special database settings. With a database-wide `C` collation, anyone who created the database with the default settings would silently get a slow search. + +We tried `text_pattern_ops` on the index first. Prisma accepts it in `schema.prisma`, but it cannot read it back from the database, so it wanted to drop and recreate the index on every `prisma migrate dev`. + +**What it costs.** + +- It does not find the middle of a word ("alto" does not find "salto"), and it does not forgive spelling mistakes, apart from a trailing `s`. A search engine could add more later. +- There is no ordering by relevance. The user sorts by code, difficulty or name, which all have indexes and give a stable cursor. +- The tables hold the same words again, so the database is larger. The catalog is read-only and small compared to the cost of a slow search, and the seed script keeps the tables in step with the catalog. If the catalog changes, the seed script runs again. + +**To verify before we rely on it.** Fill a test database with a catalog many times larger than the real one (for example a million entries made from the real words), and run `EXPLAIN ANALYZE` on the queries that Prisma sends for the search, the filter and the sort, with and without a cursor. Check that the indexes are used, that a page far into the result is as fast as the first, and that a one-letter term still returns in a reasonable time. If the prefix search is too slow, the fallbacks that do not need raw SQL are to require two letters in the interface, or to limit how many words a one-letter term can match. We do not fall back to raw SQL. + +**Why not Prisma's `search` filter.** Full text search for PostgreSQL in Prisma is a preview feature (`fullTextSearchPostgres`). The documentation does not say if prefix matching is supported, and the index for it cannot be declared in `schema.prisma`, which would mean raw SQL in a migration. We do not use it. + +**The cursor.** The cursor given to the client is base64url-encoded JSON that holds the sort field and direction, the values of the sort fields for the last row, and its `id`. The client should treat it as opaque and only pass the `endCursor` back as `after`. It is not encrypted or signed, on purpose: the catalog is public, so a changed or hand-made cursor can only lead to another page of public data, and a secret key would make every cursor stop working when the key changes or the server restarts without it. What protects the query is the check when the cursor is decoded: the sort field and direction must match the request, the cursor must have exactly the expected keys, the `id` must be a non-empty text, and every sort value must have the right type (a difficulty must look like `0.4`). Anything else gives `BAD_USER_INPUT`, and only checked values reach Prisma, which sends them as parameters. + +**Query plans.** Prisma sends the search as `word::text LIKE ($1 || '%')`. PostgreSQL normally plans each query with the actual value, sees the prefix, and uses the index (`word >= 'sal' AND word < 'sam'`, about 0.03 ms). With a generic plan, made without knowing the value, it cannot use the prefix and reads the whole table (86 ms on 3 million words in a test). Do not set `plan_cache_mode = force_generic_plan`. Be careful with a connection pooler that uses named prepared statements, because PostgreSQL can switch a prepared statement to a generic plan after it has run a few times. Check with `EXPLAIN` that the index is still used if the connection setup changes. + +**The database user is limited.** On the virtual machine, the backend connects with a user that can read and write the tables of the application, and cannot create users or databases. Migrations are run with a different user. On our own machines we use one user, `cop`, for everything, because it needs to create the shadow database for `prisma migrate dev` (see "Database (local setup)" in the README). + +The alternative for much larger systems is a search engine such as Meilisearch or Elasticsearch. It would be a second service on the virtual machine to install, start and keep in step with the database, and the assignment asks for the database on the virtual machine. For a catalog of this size it adds work and no benefit, so we do not use it. + +### N+1 queries + +We use **DataLoader**, which collects the lookups of one request and sends one query. Prisma already batches `findUnique` calls made at the same time, so the loaders matter most for lists of related rows, which Prisma does not combine: `Gymnast.routines`, `Element.variants` and `Routine.items`. A loader must return the rows in the same order as the ids it got (for `Routine.items`, in the order of the routine). Create the loaders for each request, so that data does not leak between users. This is how `backend/src/server/context.ts` does it: a new set of loaders for every request. + +## Configuration + +The settings are read from environment variables, and an example file with all names (but without secrets) is `backend/.env.example`. `SESSION_SECRET` is only needed for the tokens: without it the catalog works and `me` is always `null`. **Decision: a missing secret gives a warning in development, and stops the server when `NODE_ENV=production`.** In development the catalog and the calculator need no login, so a developer who has not set a secret can still work, and the warning in the log says why `me` is `null`. On the virtual machine a quiet fallback is the dangerous case: the site would start and look fine, and the login would be dead until someone noticed. Stopping at start-up (in `readConfig`, covered by tests) makes a forgotten secret impossible to miss. The price is that the catalog is also down until the secret is set, which is what we want from a failed deploy. The server must be started with `NODE_ENV=production` on the virtual machine for the check to apply; a copy of the placeholder in `docs/VM_SETUP.md` is already refused, because it is shorter than 32 characters. `GOOGLE_CLIENT_ID` comes with the Google login (a separate pull request). + +| Variable | Used for | +| ---------------- | -------------------------------------------------------------------------------------------------------------------- | +| `PORT` | The port of the server, `3001` | +| `DATABASE_URL` | The PostgreSQL connection for Prisma | +| `IMAGE_BASE_URL` | The address where the drawings are served (see Images) | +| `CORS_ORIGIN` | The addresses that may call the API from a browser | +| `SESSION_SECRET` | The key that signs the access tokens. At least 32 random characters. The server does not start with a shorter secret, or without one when `NODE_ENV=production` | + +The course staff must be able to start the backend, so the README (or a file readable with `sudo` on the virtual machine) must list how to set these. + +## Testing the API + +The search function already has its tests, in `backend/src/catalog/search.test.ts`. They run against the local database with the seeded catalog and only read from it. They are skipped when `DATABASE_URL` is not set, so that `npm test` also works for someone who only works on the frontend. A run with skipped tests has therefore not tested the search (see the README). + +Vitest runs the API tests against a test database, with queries sent to the server. The cases that matter most for this API: + +- **Search:** not case sensitive, no text gives all items, and elements and vaults appear in the same list. +- **Pagination:** every item is returned once when we follow the cursors to the end. This must also hold when many items have the same sort value, and for items without a name when sorting by name. `hasNextPage` is `false` on the last page. +- **Filter and sort:** done over the whole catalog, not only the loaded page. +- **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`). +- **The calculator** (`backend/src/dscore.test.ts`, `backend/src/server/calculator.test.ts`): fewer than, exactly and more than eight elements, the same code twice, a vault, an empty list, sums that would give `0.30000000000000004` as floats; the query needs no login, gives the same score as the function for catalog items, refuses items from two apparatus, a vault with elements, two vaults and a removed item with `INVALID_ITEMS`, refuses 51 ids, an unknown id and an id with SQL in it with `BAD_USER_INPUT`, and uses a fixed number of queries. +- **The session** (`backend/src/server/auth.test.ts`, `backend/src/auth/`; the time is set by the tests): `me` returns the coach for a valid access token and `null` without a token, with a changed token, with a token signed with another key and after 15 minutes; a protected operation without a valid token gives `UNAUTHENTICATED`; a refresh token works once and gives new tokens; using an old one again ends the whole login; an expired, unknown or logged-out refresh token gives `UNAUTHENTICATED`; two requests with the same refresh token let only one through; the database holds a hash and not the token; the `Authorization` header over HTTP. +- **Gymnasts, lists and routines** (`backend/src/server/userData.test.ts`, with two test coaches): every operation needs a login; a coach never reaches another coach's gymnast, list or routine (`NOT_FOUND`, nothing changes, same message as an unknown id); names (trimmed, 1-70 characters, emoji count as one); 200 gymnasts, 100 list items, 50 routines and 50 items in a routine; `NAME_TAKEN`; a list in order and filtered by apparatus; adding twice and removing a missing item are no-ops, also several changes in one request and four requests at once; `setRoutineItems` order, positions without gaps, duplicates, `updatedAt`, wrong apparatus, two vaults, unknown and odd ids, a failed save changes nothing, two saves at once; removed items (stay, `removed: true`, `dScore` null, cannot be added); `dScore` equals the calculator; the number of queries is the same for 2 and 25 gymnasts. +- **Wildcards and case:** a search for `%`, `_` or a word containing them does not match everything, and an uppercase search such as "SAL" finds the same items as "sal". +- **Search text limits:** 81 characters or 9 words give `BAD_USER_INPUT`, and a text with no searchable words gives no results, while an empty text or only spaces gives all items. +- **Deep pages:** the queries after a cursor contain no `OR` and no `OFFSET` above 0, so each one starts in the index right after the cursor. +- **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. +- **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 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. + +**Decision: the test files run one at a time (`fileParallelism: false` in `vite.config.ts`).** The database tests share one local database, and some of them write to it (the login tests) while others check that the row counts do not change, so two files at the same time would break each other. The price is that the frontend tests also run one file at a time. We keep it because the suite is still small and fast (the frontend and non-database tests finish in about a second), so the loss is small for now. Running only the database tests one at a time (for example as a separate Vitest project, or with a database per test file) would remove the price, but it is more setup than the gain is worth now. Revisit it when the suite gets slow. + +The end-to-end tests (a long sequence of actions in the browser) are described separately in the README when they exist. + +## Open questions + +- Should the lists of gymnasts and routines become cursor connections? Decided against for now, with the triggers for changing, in [USER_LIST_PAGINATION.md](USER_LIST_PAGINATION.md). +- What is the `alt` text of the drawings? See [IMAGES.md](IMAGES.md) when it is merged. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 252cb66..434b2d1 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -2,10 +2,14 @@ This document describes the data model for the Code of Points catalog: what each entity holds, how the entities relate to each other, the constraints we enforce, and the reasoning behind the main design choices. -The catalog data (apparatus, element groups, elements, vaults and variant groups) is extracted from the FIG Women's Artistic Gymnastics Code of Points 2025–2028 and is read-only in the app. The user data (coaches, gymnasts and routines) is created by users through the website. +The catalog data (apparatus, element groups, elements, vaults and variant groups) is extracted from the FIG Women's Artistic Gymnastics Code of Points 2025–2028 and is read-only in the app. The user data (coaches, gymnasts and routines) is created by users through the website. The app is built on the Code of Points for women's artistic gymnastics, so all gymnasts are women, and the documentation refers to a gymnast as "she". Example data that shows how each entity is structured is in [examples/](examples/). The entries are invented and are for documentation only. +The extracted catalog data is in [data/](../data/). [EXTRACTION.md](EXTRACTION.md) explains how it was made. + +The model is stored in PostgreSQL through Prisma. The schema is in [`backend/prisma/schema.prisma`](../backend/prisma/schema.prisma), and the section [Storage in PostgreSQL](#storage-in-postgresql) explains how the model is turned into tables. + ## Diagram ```mermaid @@ -16,21 +20,27 @@ erDiagram VARIANT_GROUP |o--o{ ELEMENT : "has variants" VARIANT_GROUP |o--o{ VAULT : "has variants" COACH ||--o{ GYMNAST : coaches + COACH ||--o{ SESSION : "logs in with" GYMNAST ||--o{ ROUTINE : has APPARATUS ||--o{ ROUTINE : "performed on" - ROUTINE }o--o{ ELEMENT : includes - ROUTINE }o--o{ VAULT : includes - GYMNAST }o--o{ ELEMENT : "can do or practices" - GYMNAST }o--o{ VAULT : "can do or practices" - - APPARATUS { string id string name } + GYMNAST ||--o{ GYMNAST_ELEMENT : "can do or practices" + ELEMENT |o--o{ GYMNAST_ELEMENT : "is in" + VAULT |o--o{ GYMNAST_ELEMENT : "is in" + ROUTINE ||--o{ ROUTINE_ELEMENT : "consists of" + ELEMENT |o--o{ ROUTINE_ELEMENT : "is in" + VAULT |o--o{ ROUTINE_ELEMENT : "is in" + + APPARATUS { string id string name int position } ELEMENT_GROUP { string id string apparatusId int number string name } - ELEMENT { string id string elementGroupId string code float value string description string name string image } - VAULT { string id string elementGroupId string code float dValue string description string name string image } - VARIANT_GROUP { string id string_array variantIds } + VARIANT_GROUP { string id } + ELEMENT { string id string elementGroupId string variantGroupId decimal value string code string description string name string image datetime removedAt } + VAULT { string id string elementGroupId string variantGroupId decimal dValue string code string description string name string image datetime removedAt } COACH { string id string googleId string email string name datetime createdAt } - GYMNAST { string id string coachId string name string_array elementIds } - ROUTINE { string id string gymnastId string name string apparatusId string_array elementIds } + SESSION { string id string coachId string familyId string tokenHash datetime expiresAt datetime revokedAt datetime createdAt } + GYMNAST { string id string coachId string name } + GYMNAST_ELEMENT { int id string gymnastId string elementId string vaultId } + ROUTINE { string id string gymnastId string apparatusId string name datetime createdAt datetime updatedAt } + ROUTINE_ELEMENT { string routineId int position string elementId string vaultId } ``` ## Reading the diagram @@ -39,7 +49,7 @@ The model has two parts. **The catalog** Each of the four apparatus (vault, uneven bars, balance beam, floor) is divided into element groups, matching the groups in the Code of Points, for example "1.000 Mounts" on uneven bars. Each group contains either elements (uneven bars, beam, floor) or vaults. Some elements and vaults can be performed in more than one way. These variants are linked together by a variant group. -**The user data** A coach logs in and creates gymnasts. Each gymnast has a list of elements she can do or is practicing, and can have several routines on each apparatus, so the coach can compare different versions and their D-values. A routine is an ordered list of ids: elements on uneven bars, beam and floor, and a single vault on vault. +**The user data** A coach logs in and creates gymnasts. Each gymnast has a list of elements she can do or is practicing, and can have several routines on each apparatus, so the coach can compare different versions and their D-values. A routine is an ordered list of items: elements on uneven bars, beam and floor, and a single vault on vault. Both lists are stored as rows in their own tables (`GymnastElement` and `RoutineElement`), where each row points to either an element or a vault. ## Entities @@ -47,10 +57,11 @@ The model has two parts. The four apparatus in women's artistic gymnastics. -| Field | Example | Description | -| ------ | --------------- | ---------------------------------- | -| `id` | `"UB"` | `VT`, `UB`, `BB` or `FX` | -| `name` | `"Uneven bars"` | Name as used in the Code of Points | +| Field | Example | Description | +| ---------- | --------------- | ------------------------------------------ | +| `id` | `"UB"` | `VT`, `UB`, `BB` or `FX` | +| `name` | `"Uneven bars"` | Name as used in the Code of Points | +| `position` | `2` | Sort order: VT 1, UB 2, BB 3, FX 4. Unique | ### ElementGroup @@ -67,38 +78,41 @@ A group of elements or vaults on one apparatus, as listed in the Code of Points. One element on uneven bars, balance beam or floor. -| Field | Example | Description | -| ---------------- | ------------------------------ | ---------------------------------------------- | -| `id` | `"UB-1.101-a"` | Apparatus + code + variant letter | -| `elementGroupId` | `"UB-1"` | The group the element belongs to | -| `code` | `"1.101"` | Element number in the Code of Points | -| `value` | `0.1` | Difficulty value (0.1–1.0) | -| `description` | `"Glide kip to support on LB"` | Description from the Code of Points | -| `name` | `"Biles"` or `null` | The gymnast the element is named after, if any | -| `image` | `"UB-1.101-a.webp"` | Drawing of the element | +| Field | Example | Description | +| ---------------- | ------------------------------ | -------------------------------------------------------------------------------------------------- | +| `id` | `"UB-1.101-a"` | Apparatus + code + variant letter. The letter is always there, also when there is only one variant | +| `elementGroupId` | `"UB-1"` | The group the element belongs to | +| `variantGroupId` | `"UB-1.101"` or `null` | The variant group, if the code has more than one variant | +| `code` | `"1.101"` | Element number in the Code of Points | +| `value` | `0.1` | Difficulty value (0.1–1.0), stored as a decimal number | +| `description` | `"Glide kip to support on LB"` | Description from the Code of Points | +| `name` | `"Biles"` or `null` | The gymnast the element is named after, if any | +| `image` | `"UB-1.101-a.webp"` | File name of the drawing. The file is in a folder per apparatus: `data/images/UB/UB-1.101-a.webp` | +| `removedAt` | `null` | When the element was removed from the Code of Points, or `null` if it is still there | ### Vault One vault from the vault table. -| Field | Example | Description | -| ---------------- | ------------------ | -------------------------------------------- | -| `id` | `"VT-1.22-a"` | `VT` + code + variant letter | -| `elementGroupId` | `"VT-1"` | The vault group the vault belongs to | -| `code` | `"1.22"` | Vault number in the Code of Points | -| `dValue` | `2.6` | Difficulty score for the vault | -| `description` | `"..."` | Description from the Code of Points | -| `name` | `"Kim"` or `null` | The gymnast the vault is named after, if any | -| `image` | `"VT-1.22-a.webp"` | Drawing of the vault | +| Field | Example | Description | +| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------ | +| `id` | `"VT-1.22-a"` | `VT` + code + variant letter. The letter is always there, also when there is only one variant | +| `elementGroupId` | `"VT-1"` | The vault group the vault belongs to | +| `variantGroupId` | `"VT-2.11"` or `null` | The variant group, if the code has more than one variant | +| `code` | `"1.22"` | Vault number in the Code of Points | +| `dValue` | `2.6` | Difficulty score for the vault, stored as a decimal number | +| `description` | `"..."` | Description from the Code of Points | +| `name` | `"Kim"` or `null` | The gymnast the vault is named after, if any | +| `image` | `"VT-1.22-a.webp"` | File name of the drawing. The file is in a folder per apparatus: `data/images/VT/VT-1.22-a.webp` | +| `removedAt` | `null` | When the vault was removed from the Code of Points, or `null` if it is still there | ### VariantGroup -Links elements or vaults that are different ways of performing the same element. Only elements and vaults with more than one variant belong to a variant group. +Links elements or vaults that are different ways of performing the same element. Only codes with more than one variant have a variant group. The group itself only has an id: the elements or vaults point to it with their `variantGroupId`. -| Field | Example | Description | -| ------------ | ------------------------------ | ---------------------------------------------------------------------------------- | -| `id` | `"UB-1.101"` | Apparatus + code. An id starting with `VT-` holds vaults, all others hold elements | -| `variantIds` | `["UB-1.101-a", "UB-1.101-b"]` | The elements or vaults in the group | +| Field | Example | Description | +| ----- | ------------ | ---------------------------------------------------------------------------------- | +| `id` | `"UB-1.101"` | Apparatus + code. An id starting with `VT-` holds vaults, all others hold elements | ### Coach @@ -106,74 +120,224 @@ A user of the website. Coaches log in with Google. | Field | Example | Description | | ----------- | ------------------------- | ------------------------------------------------------------- | -| `id` | | Internal id | +| `id` | `"cmg1x2…"` | Generated id (cuid) | | `googleId` | `"109876543210…"` | Google's unique user id (the `sub` claim in the Google token) | | `email` | `"kari.nilsen@gmail.com"` | Email from the Google account | | `name` | `"Kari Nilsen"` | Name from the Google account | | `createdAt` | `"2026-10-01T12:00:00Z"` | When the coach first logged in | +### Session + +One refresh token of a login (see "Identifying the user" in [API.md](API.md)). Only a hash of the token is stored. + +| Field | Example | Description | +| ----------- | ------------------------ | ------------------------------------------------------------------------------ | +| `id` | `"cmg2a1…"` | Generated id (cuid) | +| `coachId` | `"cmg1x2…"` | The coach who logged in | +| `familyId` | `"6f1c…"` | Shared by all the tokens that follow from one login | +| `tokenHash` | `"9b74c9…"` | SHA-256 of the refresh token, as hex. Unique. The token itself is never stored | +| `expiresAt` | `"2026-10-31T12:00:00Z"` | 14 days after the token was made | +| `revokedAt` | `null` | Set when the token has been used, or the login was ended | +| `createdAt` | `"2026-10-17T12:00:00Z"` | When the token was made | + +Using a token replaces it with a new row in the same family. A token that is used when `revokedAt` is already set revokes the whole family. Rows are deleted when they have expired. + ### Gymnast A gymnast created by a coach. -| Field | Example | Description | -| ------------ | ------------------------------------------- | ------------------------------------------------------- | -| `id` | | Internal id | -| `coachId` | | The coach who created the gymnast | -| `name` | `"Ingrid Haugen"` | Name of the gymnast | -| `elementIds` | `["UB-1.101-a", "UB-3.304-a", "VT-1.22-a"]` | Elements and vaults the gymnast can do or is practicing | +| Field | Example | Description | +| --------- | ----------------- | --------------------------------- | +| `id` | `"cmg1x3…"` | Generated id (cuid) | +| `coachId` | | The coach who created the gymnast | +| `name` | `"Ingrid Haugen"` | Name of the gymnast | + +### GymnastElement + +One element or vault that a gymnast can do or is practicing. A gymnast's list is all the rows with her `gymnastId`. Exactly one of `elementId` and `vaultId` is set. + +| Field | Example | Description | +| ----------- | ------------------------ | ------------------------------------- | +| `id` | `1` | Row number, generated by the database | +| `gymnastId` | | The gymnast the row belongs to | +| `elementId` | `"UB-1.101-a"` or `null` | The element, if the row is an element | +| `vaultId` | `"VT-1.22-a"` or `null` | The vault, if the row is a vault | ### Routine A gymnast's routine on one apparatus. A gymnast can have several routines on the same apparatus. -| Field | Example | Description | -| ------------- | ------------------------------------------------- | ------------------------------------------------------------------ | -| `id` | | Internal id | -| `gymnastId` | | The gymnast the routine belongs to | -| `name` | `"Competition routine"` | Name chosen by the coach, to tell routines apart | -| `apparatusId` | `"UB"` | The apparatus the routine is performed on | -| `elementIds` | `["UB-1.101-a", "UB-3.304-a"]` or `["VT-1.22-a"]` | The elements in the routine, in order. On vault: exactly one vault | +| Field | Example | Description | +| ------------- | ------------------------ | ------------------------------------------------ | +| `id` | `"cmg1x4…"` | Generated id (cuid) | +| `gymnastId` | | The gymnast the routine belongs to | +| `apparatusId` | `"UB"` | The apparatus the routine is performed on | +| `name` | `"Competition routine"` | Name chosen by the coach, to tell routines apart | +| `createdAt` | `"2026-10-01T12:00:00Z"` | When the routine was created | +| `updatedAt` | `"2026-10-03T09:30:00Z"` | When the routine or its name was last changed | + +### RoutineElement + +One element or vault in a routine. The routine's items are all the rows with its `routineId`, ordered by `position`. Exactly one of `elementId` and `vaultId` is set. On vault, a routine has exactly one row, with a vault. + +| Field | Example | Description | +| ----------- | ------------------------ | ---------------------------------------------------- | +| `routineId` | | The routine the row belongs to | +| `position` | `1` | The place in the routine, from 1. Unique per routine | +| `elementId` | `"UB-1.101-a"` or `null` | The element, if the row is an element | +| `vaultId` | `"VT-1.22-a"` or `null` | The vault, if the row is a vault | ### Calculator routine (not stored) -The calculator lets anyone put together a routine and see its D-value, without logging in. It has the same shape as a routine, but without an id or a gymnast, and it is never stored in the database. It lives in the client's state while the user is on the page. +The calculator lets anyone put together a routine and see its D-value, without logging in. It has the same shape as a routine, but without an id, a gymnast or a name, and it is never stored in the database. It lives in the client's state while the user is on the page. -| Field | Example | Description | -| ------------- | ------------------------------ | ------------------------------------------------------------------ | -| `apparatusId` | `"UB"` | The apparatus the routine is for | -| `elementIds` | `["UB-1.101-a", "UB-3.304-a"]` | The elements in the routine, in order. On vault: exactly one vault | +| Field | Example | Description | +| ------------- | ------------------------------ | ---------------------------------------------------------------------------- | +| `apparatusId` | `"UB"` | The apparatus the user has chosen in the calculator. Only kept in the client | +| `itemIds` | `["UB-1.101-a", "UB-3.304-a"]` | The elements in the routine, in order. On vault: exactly one vault | -The D-value is calculated by the backend through a query that takes `apparatusId` and `elementIds` and returns the D-value without saving anything. That way the calculator and saved routines use the same calculation. +The D-value is calculated by the backend through the query `calculateDScore(itemIds)`, which returns the D-value without saving anything. It only takes the ids: the apparatus follows from the items (vault ids start with `VT-`, and every other id starts with its apparatus), and items that do not fit together give `INVALID_ITEMS`. That way the calculator and saved routines use the same calculation and the same checks. A logged-in coach can save a calculator routine as a routine for one of their gymnasts. It then gets an `id`, a `gymnastId` and a `name`, and is stored like any other routine. +### Search tables + +The two search tables are not in the diagram at the top, because they hold no new information. Together they look like this: + +```mermaid +erDiagram + CATALOG_ENTRY ||--o{ CATALOG_WORD : "is found by" + + CATALOG_ENTRY { string id string type string code string name string description decimal difficulty string apparatusId string elementGroupId boolean hasName string nameSort string codeSort } + CATALOG_WORD { string entryId string word } +``` + +### CatalogEntry + +A read-only table for the catalog search, filled by the seed script from the elements and vaults. It is not in the diagram, because it holds no new information. There is one row per code: the first variant of each element or vault that is not removed, normally the `-a` variant. If `-a` is removed but `-b` is still in the Code of Points, the row uses `-b`, so the code does not disappear from the catalog. A code where every variant is removed is left out. The other variants are shown on the detail page. + +`CatalogEntry` and `CatalogWord` have no foreign keys to the elements, vaults or groups, so the database does not stop a row that points to nothing. The seed script therefore rebuilds both tables from the catalog in one transaction every time it runs, so they always match the catalog. + +| Field | Example | Description | +| ---------------- | ------------------------------ | ------------------------------------------------------------------------------- | +| `id` | `"UB-1.101-a"` | The id of the `-a` variant | +| `type` | `ELEMENT` | `ELEMENT` or `VAULT` | +| `code` | `"1.101"` | Copied from the element or vault | +| `name` | `"Biles"` or `null` | Copied from the element or vault | +| `description` | `"Glide kip to support on LB"` | Copied from the element or vault | +| `difficulty` | `0.1` | `value` for an element and `dValue` for a vault | +| `apparatusId` | `"UB"` | For the apparatus filter | +| `elementGroupId` | `"UB-1"` | For the group filter | +| `hasName` | `true` | Whether the item is named after a gymnast | +| `nameSort` | `"biles"` or `""` | The name in lowercase, or empty. Never `null`, so it can be used with a cursor | +| `codeSort` | `"0001.0101"` | The code with each part padded to the same length, so `2.1` sorts before `10.1` | + +### CatalogWord + +The words the catalog search looks in, one row per distinct word of an entry. Read-only and filled by the seed script. + +| Field | Example | Description | +| --------- | -------------- | ----------------------------------------------------------------------------------------------- | +| `entryId` | `"UB-1.101-a"` | The catalog entry the word finds | +| `word` | `"glide"` | One word in lowercase. The column has the `C` collation, so the index can serve a prefix search | + +The words come from the code, the name and the description of **all** variants of the code, so a word that is only in variant `-b` still finds the code. They are split with `normalizeWords` in `backend/src/catalog/words.ts`, the same function the search uses on the search text, so both sides follow the same rules. The seed script also adds abbreviations from the Code of Points written out as words, for example `handstand` for `hstd`, so that a search for the full word finds elements whose description uses the abbreviation. + ## Constraints ### Catalog data These are checked by the extraction script when the data is generated. -1. Every variant group has at least two ids, and every id exists. -2. All ids in a variant group are of the same type: only elements or only vaults. -3. An element or vault belongs to at most one variant group. +1. Every variant group has at least two members, and every element or vault that points to a variant group exists. +2. The members of a variant group are of the same type: only elements or only vaults. +3. An element or vault belongs to at most one variant group. The database guarantees this, because `variantGroupId` holds one value. 4. Variants in the same group have the same `code` and the same value (`value` or `dValue`). 5. Every element and vault has an image. Any that are missing are flagged for manual review. ### User data -These are enforced by the backend. +Some of these are enforced by the database and some by the backend, as noted for each. -6. A gymnast can have any number of routines on each apparatus. Routine names should be unique per gymnast and apparatus, so the coach can tell them apart. -7. A routine on vault has exactly one id in `elementIds`, and it is a vault. A routine on any other apparatus contains only elements. -8. All elements in a routine must belong to the same apparatus as the routine. -9. An element appears at most once in a gymnast's `elementIds`. -10. The catalog is open to everyone. Gymnasts and routines can only be read and changed by the coach they belong to. +6. A gymnast can have any number of routines on each apparatus, but routine names are unique per gymnast and apparatus, so the coach can tell them apart. _Database: unique constraint._ +7. A routine on vault has exactly one item, and it is a vault. A routine on any other apparatus contains only elements. _Backend._ +8. All elements in a routine must belong to the same apparatus as the routine. _Backend._ +9. The catalog is open to everyone. Gymnasts and routines can only be read and changed by the coach they belong to. _Backend._ +10. An element or vault appears at most once in a gymnast's list. _Database: unique constraints._ +11. Every row in a gymnast's list or in a routine points to exactly one catalog item: an element or a vault, never both and never neither. _Database: CHECK constraint._ + +Constraints 7 and 8 compare several rows, or a row with its routine, so they cannot be written as a simple constraint in the database. ### Calculated values -11. The difficulty letter (A–J) is not stored. It is calculated from `value`: 0.1 = A, 0.2 = B … 1.0 = J. -12. 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`. Composition requirements and connection values are not included yet. +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`. 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 + +The schema is in [`backend/prisma/schema.prisma`](../backend/prisma/schema.prisma), and the tables are created by the migrations in `backend/prisma/migrations/`. The names are camelCase in Prisma and in this document, and snake_case in the database: the field `elementGroupId` is the column `element_group_id`, and the model `RoutineElement` is the table `routine_elements`. + +### Lists become rows + +The data model has three lists: the members of a variant group, a gymnast's elements and the items of a routine. A relational database does not store lists in a column, so each list is stored as rows that point to their owner: + +- **Variant groups:** each element and vault has a `variantGroupId`, which is `null` when the code has only one variant. The members of a group are the rows that point to it. +- **A gymnast's list:** the table `gymnast_elements`, with one row per element or vault. +- **A routine:** the table `routine_elements`, with one row per item and a `position` that gives the order. The position starts at 1 and is unique per routine. The same element may appear more than once in a routine, at different positions. It still counts only once in the D-value, but connection bonuses, which are planned for later, can involve the same element twice (for example C + C). + +A gymnast's list and a routine can hold both elements and vaults, which are in two tables. Each row therefore has two columns, `elementId` and `vaultId`, and a CHECK constraint makes sure exactly one of them is set (constraint 11). Both columns are foreign keys, so a row can never point to an element or vault that does not exist. Prisma cannot express CHECK constraints, so they are added in SQL in the migration. + +Rows in `gymnast_elements` have their own generated number as id, because `elementId` and `vaultId` can be `null` and can therefore not be part of a primary key. The routine rows use the routine and the position as their key. + +### Ids + +The catalog keeps the readable ids from the data, such as `UB`, `UB-2`, `UB-2.305` and `UB-2.305-a`. They are stable, and they are the same ids that the API and the image file names use. + +The user data (coaches, gymnasts and routines) gets ids generated by Prisma (`cuid`). They say nothing about the data and cannot be guessed. + +### Removed elements + +Elements and vaults have a `removedAt` timestamp, which is `null` while the element is in the Code of Points. If a new Code of Points drops an element, it is marked as removed and not deleted, so routines and gymnast lists that use it still work. The seed script leaves removed items out of the search tables, so they are not found in the catalog search. + +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. 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 + +`Coach.createdAt` is when the coach first logged in. A routine has `createdAt` and `updatedAt`, so the app can show when a routine was last changed. Both get the current time when the row is created, also when it is inserted outside Prisma. Prisma sets `updatedAt` again every time the routine row is updated. The items of a routine are separate rows, so changing them does not touch the routine row: `setRoutineItems` must therefore also update `updatedAt`, in the same transaction as the items. + +### Sort order of the apparatus + +`Apparatus.position` gives the order the apparatus are shown in: vault, uneven bars, balance beam, floor exercise. This is the order of the Code of Points, and not the alphabetical order of the ids. + +### Numbers + +`value` and `dValue` are stored as decimal numbers (`Decimal(3, 1)`), not as floating-point numbers. A sum of D-values is then exact: 0.1 + 0.2 is 0.3, and not 0.30000000000000004. + +Prisma returns these columns as `Decimal` objects, not as JavaScript numbers. The backend must add them up as `Decimal` and convert the result to a number only at the end, when it is sent in the API. Converting each value to a number first brings the rounding errors back. + +### Checks in the database + +Besides the constraints above, the database checks the ranges of the catalog values and the positions in a routine, so that wrong data cannot be inserted outside the app either: + +- `elements.value` is between 0.1 and 1.0. +- `vaults.d_value` is greater than 0. +- `routine_elements.position` is 1 or more. The backend writes the positions as 1, 2, 3 … without gaps when it replaces the items of a routine. + +The extraction script and the seed script check the values too. These CHECK constraints are written in SQL in the migrations, because Prisma cannot express them. + +### Limits + +The limits on user data (200 gymnasts per coach, 50 routines per gymnast and 50 items in a routine) are checked only in the backend, before it writes. Two requests from the same coach at exactly the same time can therefore go slightly over a limit. We accept this, because the limits are there to stop runaway use and not to enforce an exact number. + +### What happens on delete + +- **User data cascades.** Deleting a coach deletes their gymnasts and their sessions. Deleting a gymnast deletes her routines and her element list. Deleting a routine deletes its items. +- **Catalog rows that are in use cannot be deleted.** The database refuses to delete an element, vault, group or apparatus that something points to. Elements that leave the Code of Points get `removedAt` instead. ## Design decisions @@ -183,15 +347,31 @@ Vaults are stored separately from elements because they work differently in the If vaults were stored as elements, they would have a difficulty letter that is always empty. With vault as its own entity, the catalog describes each vault by its D-value, as the Code of Points does. -In a routine, a vault is treated the same way as an element: it is an id in the routine's `elementIds`. A routine on vault holds exactly one vault. This keeps one rule for the D-value on every apparatus (the sum of the eight highest values), and with a single vault in the list, that sum is simply the vault's `dValue`. Vault ids always start with `VT-`, so the backend can tell a vault from an element by its id. +In gymnastics a vault is an element like any other. It has its own table only because its difficulty is stored differently: one table would need a column whose meaning depends on the apparatus (a value from 0.1 to 1.0 for most rows, a D-value such as 2.6 for vaults), and the letter would be empty for every vault. + +In a routine, a vault is treated the same way as an element: it is one item in the routine, stored in `routine_elements` with `vaultId` set instead of `elementId`. A routine on vault holds exactly one vault. This keeps one rule for the D-value on every apparatus (the sum of the eight highest values), and with a single vault in the routine, that sum is simply the vault's `dValue`. Vault ids always start with `VT-`, so the API can also tell a vault from an element by its id. ### Variants are linked through a variant group -Some cells in the Code of Points show two or three ways of performing the same element, each with its own drawing and description, but with the same code and the same value. We store each variant as its own element (`UB-1.101-a`, `UB-1.101-b`) so that each one has its own image and description and can be searched and added to a routine on its own. +Some cells in the Code of Points show two or three ways of performing the same element, each with its own drawing and description, but with the same code and the same value. We store each variant as its own element (`UB-1.101-a`, `UB-1.101-b`) so that each one has its own image and description and can be added to a routine on its own. The catalog list shows one row per code, and the search finds the code by a word from any of its variants. The user then chooses the variant on the detail page, where all the variants are shown. + +The variants are linked through a variant group: each variant has a `variantGroupId` that points to the group. Adding or removing a variant only changes that variant, not the others. Because variants share the same code, the D-value counts each code only once (constraint 13). + +A variant group never contains both elements and vaults. The type is given by the id: vault ids and vault group ids always start with `VT-`. -The variants are linked through a variant group that holds a list of their ids. Adding or removing a variant only changes the variant group, not the other elements. Because variants share the same code, the D-value counts each code only once (constraint 12). +### Every id has a variant letter -We use one list (`variantIds`) instead of separate lists for elements and vaults, because a variant group never contains both. The type is given by the id: vault ids always start with `VT-`. +The id of an element or vault always ends with a variant letter, also when there is only one variant: `UB-2.405-a`, not `UB-2.405`. We considered dropping the letter for elements without variants, but kept it for three reasons: + +- The id does not change if a later version of the Code of Points adds a variant. A routine or a gymnast's element list that refers to `UB-2.405-a` keeps working. +- An element id can never be mistaken for a variant group id. A variant group id has no letter (`UB-2.305`). +- There is one id format to validate, and the image file name always matches the id. + +In the user interface, the letter does not have to be shown for elements without variants. + +### Images are stored in one folder per apparatus + +There is one image per element and vault, more than 800 in total. They are stored in one folder per apparatus, for example `data/images/UB/UB-2.305-a.webp`, so the folders stay easy to browse. The `image` field holds only the file name. The folder is the first part of the id, so the path can always be built from the id. ### Google login and `googleId` @@ -199,9 +379,13 @@ Coaches log in with their Google account, so we do not store usernames or passwo We store `googleId` and not the token itself. The token expires after a short time and is only used to prove who the user is when they log in. The `googleId` never changes, even if the user changes their name or email. +### Two tokens after the login + +After the Google login the server gives the coach a short-lived access token (15 minutes) and a refresh token (14 days). The access token is a signed JWT and is not stored. The refresh token is stored as a hash in `sessions`, so that it can be replaced at every use and revoked, which a JWT alone cannot do. The reasons and the rules are in "Identifying the user" in [API.md](API.md). + ### A gymnast has a list of elements -Besides routines, a coach wants an overview of which elements each gymnast can do or is working on. This is stored as a list of element and vault ids on the gymnast. We do not distinguish between elements the gymnast has mastered and elements she is practicing, since the list is meant as a planning overview for the coach. +Besides routines, a coach wants an overview of which elements each gymnast can do or is working on. This is stored as rows in `gymnast_elements`, one per element or vault. We do not distinguish between elements the gymnast has mastered and elements she is practicing, since the list is meant as a planning overview for the coach. The list is independent of the routines: a gymnast can practice an element that is not in any routine yet, and a routine can contain an element the gymnast has not mastered. The list can later be used to suggest elements when the coach builds a routine. @@ -209,9 +393,9 @@ The list is independent of the routines: a gymnast can practice an element that A coach often tries out different versions of a routine, for example with or without a new element, to see how much each version is worth. A gymnast can therefore have any number of routines on the same apparatus, each with its own name. Because the D-value is calculated for each routine, the versions can be compared side by side. -### Elements in a routine are a list of ids +### Elements in a routine are rows with a position -A routine stores its elements as an ordered list of element ids. The order in the list is the order in the routine, so no separate position field is needed. If we later need more information per element in a routine (for example connections between elements or coach notes), the list can be changed to a list of objects without affecting the rest of the model. +A routine stores its items as rows in `routine_elements`, each with a `position`. The order of the positions is the order in the routine. When the coach changes a routine, the API replaces all its rows in one go (`setRoutineItems`), so the backend never has to move single rows around. If we later need more information per item in a routine (for example connections between elements or coach notes), it can be added as columns on these rows without affecting the rest of the model. ### The D-value is calculated, not stored @@ -223,4 +407,49 @@ Visitors who are not logged in should also be able to try out routines, but they ### The difficulty letter is calculated -The difficulty letter (A–J) and the value (0.1–1.0) always match one to one, so we only store `value`. The letter is calculated when it is shown, and filtering by difficulty is done on `value`. +The difficulty letter (A–J) and the value (0.1–1.0) always match one to one, so we only store `value`, and all the logic uses the value. The letter is only shown in the frontend, which calculates it from the value (see [Difficulty letters are only handled in the frontend](API.md#difficulty-letters-are-only-handled-in-the-frontend) in API.md). The catalog filter takes a list of values (several can be chosen): when the user chooses A and B in the interface, the frontend sends `[0.1, 0.2]`. Sorting by difficulty is only offered within one apparatus, because comparing element values with vault D-values gives no useful order (see [API.md](API.md#why-difficulty-is-only-sorted-within-an-apparatus)). + +### The image is stored as a file name + +The `image` field holds only the file name, for example `UB-1.101-a.webp`, and not a full URL. The GraphQL API turns it into a URL (`imageUrl`) by adding the address where the images are served. If the images move, no data needs to change. + +### The search uses two derived tables + +The catalog is searched through two extra tables that are not part of the diagram above, because they hold no new information: `CatalogEntry` (one row per code, with the shared columns and the sort keys) and `CatalogWord` (the lowercase words of all variants of each code, for the word search). The seed script fills both from the catalog, and they are read-only. They exist because a database query cannot combine the element and vault tables into one sorted list with a cursor, and because the backend does not use raw SQL. The fields and the reasoning are in [API.md](API.md#search-without-raw-sql). + +The catalog list shows one row per code and not one per variant, so `CatalogEntry` only has one variant of each code: the first one that is not removed, normally `-a`. `CatalogWord` holds the words of all the variants, so the search still finds a code by a word that is only in one of its other variants. + +### Difficulty is sorted within an apparatus and filtered by value + +Sorting by difficulty is only allowed when an apparatus is selected, because comparing difficulty across apparatus gives the user nothing of value: element values (0.1–1.0) and vault D-values (about 2.0–6.4) are different scales. The difficulty filter is a list of chosen values, not a range, and only the frontend shows them as letters. The reasoning is in [API.md](API.md#why-difficulty-is-only-sorted-within-an-apparatus). + +### The gymnast's list is changed one item at a time + +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 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 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 +- 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 + +We considered letting removed elements still count in the D-value of saved routines. We chose not to, because the D-value would then show a value the routine no longer has under the current Code of Points. + +The catalog list uses the first variant of a code that is not removed. If only the `-a` variant is removed, the code is still found through its `-b` variant. + +### The same element can appear twice in a routine + +A routine may contain the same element more than once, at different positions. It still counts only once in the D-value, as in the Code of Points. We allow it because connection bonuses, which we plan to add later, can involve the same element twice (for example C + C). Forbidding duplicates now would have to be undone then. + +### Value ranges are checked in the database, limits only in the backend + +The database refuses an element value outside 0.1–1.0, a vault D-value of 0 or less, and a routine position below 1. These checks are cheap, and they stop wrong data even if it is inserted outside the app, for example by a script. + +The limits on user data (200 gymnasts, 50 routines per gymnast, 50 items per routine) are only checked in the backend. Two requests at exactly the same time can go slightly over a limit. We accept this, because the limits are there to stop runaway use and not to guarantee an exact number, and enforcing them in the database would need locking or triggers. diff --git a/docs/EXTRACTION.md b/docs/EXTRACTION.md new file mode 100644 index 0000000..9c77e9e --- /dev/null +++ b/docs/EXTRACTION.md @@ -0,0 +1,76 @@ +# How the catalog data was made + +This document explains, in plain language, how we got the elements and vaults out of the Code of Points and into the repository. Read it if you wonder where the files in `data/` come from, what has been checked, and how to fix something that is wrong. + +## In short + +The FIG Women's Artistic Gymnastics Code of Points 2025–2028 is a PDF. Pages 60–169 hold the Table of Elements: one table cell per element, with a code, a description, sometimes a name, and a drawing. + +We wrote a Python script that reads those pages and produces the catalog: 749 elements, 87 vaults and one image for each of them. The script does most of the work, and we checked and corrected the uncertain cases by hand. + +## The parts, and which ones matter + +There are many files, but only three things to know about. + +| Part | What it is | Do you need to touch it? | +| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------- | +| [`data/`](../data/) | The result: five JSON files and a folder of images. This is what the application will use. | No. It is generated, so never edit it by hand. | +| [`scripts/extract-cop/extract.py`](../scripts/extract-cop/) | The tool that makes `data/` from the PDF. It also holds the lists of corrections we made by hand. | Only to add a correction, or to run the extraction again. | +| The PDF | The source. It is not in the repository and must never be committed, because the rights belong to FIG. | Only if you run the script. | + +Most of the files are the images in `data/images/`, one per element and vault. They are why the folder looks large. + +How to install and run the script is described in [scripts/extract-cop/README.md](../scripts/extract-cop/README.md). What each file in `data/` contains is listed in [data/README.md](../data/README.md). + +## What the script does + +For each page in the Table of Elements, the script does this: + +1. **Finds the group.** It reads the heading at the top of the page, such as "2.000 — CASTS AND CLEAR HIP CIRCLES". That gives the apparatus and the element group. +2. **Finds the codes.** Every element starts with a coloured label with its code, such as `2.305`. Each label marks one cell in the table. Empty cells are skipped. +3. **Reads the text in the cell.** Text in italics is the description. Text in bold in parentheses is the name of the gymnast the element is named after. +4. **Finds the drawings in the cell.** It separates the drawing of the gymnast from the small judging symbol below it, which we do not use. +5. **Makes one element per drawing.** If a cell has several drawings, each becomes its own variant: `UB-2.305-a`, `UB-2.305-b` and `UB-2.305-c`. +6. **Saves each drawing as an image.** The image shows only the drawing, without the symbol, the code or the description. +7. **Checks the result.** At the end it checks that all references are valid and prints a list of everything it was unsure about. + +The value of an element is not read from the table. It comes from the code: the first digit after the dot is the difficulty, so `2.305` is a C element worth 0.3. Vaults are different. Their D-value is printed in the cell, for example "2.60 P.". + +The script also writes a review page, `review.html`, that shows every element with its image and marks the uncertain ones. We used it to check the result against the PDF. + +## Decisions we made + +- **Every id ends with a letter, also when there is only one variant.** An element without variants is `UB-2.405-a`, not `UB-2.405`. We considered dropping the letter, but kept it for three reasons. The id does not change if a variant is added in a later version of the Code of Points, so saved routines keep working. An element id can never be mistaken for a variant group id, which has no letter (`UB-2.305`). And there is one id format to check instead of two. +- **Images are in one folder per apparatus.** For example `data/images/UB/UB-2.305-a.webp`. The `image` field in the data holds only the file name. The folder is the first part of the id. +- **One description for several drawings.** Sometimes the PDF has one sentence for two or three drawings, such as "Clear hip circle to hstd, also with hop-grip change in hstd phase, or with ½ turn (180°) to hstd". Where the sentence could be split safely, each variant gets the first part plus its own part. Where it could not, every variant has the whole sentence. +- **Drawings in several parts are one image.** Some drawings on beam show the start of an element on the left and two ways of finishing it on the right. They are kept as one image and one element. +- **Two markers are not extracted.** Some codes on beam and floor are printed with "(D)" for dance elements, or "(\*)" for elements that only receive credit once. The data model has no fields for them. + +## What we checked by hand + +The script flagged about 160 codes as uncertain. We went through all of them: + +- **Descriptions the script had split** (52 codes). We read each variant's text against its drawing, corrected 13 texts, and removed the name from variants it did not belong to. +- **Codes with more drawings than judging symbols** (7 codes). Six were correct. One, `BB-1.403`, had four variants that were really two, each drawn in two rows. +- **Descriptions that only continued the one above** (3 codes), such as "– piked". We wrote them out in full. +- **`BB-1.312`**, which had four variants that were really two. +- **Codes with one drawing but several judging symbols** (9 codes). The image could have shown two variants that should have been separate elements. All nine were correct as they were. +- **Codes where every variant has the same full description** (about 90). These are correct, and we left them as they are. The text does not say what separates the variants, because the PDF has one description for several drawings. + +The corrections are stored in three lists at the top of `extract.py`, so they are applied again every time the script runs. + +The script still prints these codes as flagged on every run. It does not record that they have been checked. + +## What has not been checked + +Everything the script did not flag. We looked at about 90 of the images and around 15 pages. The rest is unchecked, so there may be errors we have not found. + +## If you find an error + +Do not correct the files in `data/`. The next run of the script would replace them and the correction would be lost. + +Instead, add the correction to the lists at the top of `scripts/extract-cop/extract.py`, run the script, and commit both the script and the new `data/`. The lists are explained in [scripts/extract-cop/README.md](../scripts/extract-cop/README.md). + +## How AI was used + +The script was written by Claude Code from a task description written by the team. It was built step by step: first one page of uneven bars, then a page with harder cases, then a vault page, and then the rest of the document, with the team checking the result of each step. The team made the decisions described above and decided every correction by hand. For many of the corrected descriptions, Claude suggested a text that the team accepted or changed. This document was also drafted by Claude Code. diff --git a/docs/UI_PLANS.md b/docs/UI_PLANS.md index 05691a3..6035dc2 100644 --- a/docs/UI_PLANS.md +++ b/docs/UI_PLANS.md @@ -14,7 +14,7 @@ Anyone who uses the website without logging in. This can be gymnasts, coaches, j - As a visitor, I want to search for an element by name or description, so that I can quickly find its value. - As a visitor, I want to filter elements by apparatus and group, so that I only see the elements that are relevant to me. -- As a visitor, I want to see only the elements of one value, so that I can find a new element to practice. +- As a visitor, I want to see only the elements of one or more difficulty letters (for example D and E), so that I can find a new element to practice. - As a visitor, I want to look up an element's code and value, so that I can check it during or after a competition. - As a visitor, I want to try out a routine in the calculator, so that I can see what it would be worth. diff --git a/docs/USER_LIST_PAGINATION.md b/docs/USER_LIST_PAGINATION.md new file mode 100644 index 0000000..794c2ab --- /dev/null +++ b/docs/USER_LIST_PAGINATION.md @@ -0,0 +1,64 @@ +# Should the lists of user data use cursors? + +This document records a decision about the GraphQL schema in [`backend/src/schema.graphql`](../backend/src/schema.graphql): whether the lists of gymnasts and routines should be cursor connections, like the search in the catalog, or stay plain lists. + +## The question + +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. + +## Decision + +**No. The lists stay plain lists with limits, for now.** We revisit it if one of the triggers at the end of this document happens. + +## Why not cursors + +1. **The size is bounded by the domain, not by the amount of data in the system.** A coach has some tens of gymnasts, a gymnast has a few routines on each apparatus, and a routine has about eight counted elements. The system grows by getting more coaches, and that does not make any single list longer. Every query for user data starts with the coach's id, which has an index, so the cost depends on that coach's own rows and not on the size of the tables. The catalog is different: it is the same for everyone, and its size is only limited by the Code of Points. +2. **The user interface needs the whole list.** The gymnast overview, the choice of gymnast when an element is added to a routine, and the order of the elements in a routine all work on the full list. A routine is ordered and edited as a whole (`setRoutineItems` replaces the list), so paging it would make editing harder, not easier. With a cursor connection, the dropdown would have to load more pages on scroll, or the client would have to fetch all the pages anyway. +3. **Less code and fewer places to make mistakes.** A connection needs edge and page-info types, cursor handling in the backend, a cache merge rule in Apollo Client for each paged field, a "load more" interaction in the interface, and tests of all of it. For a field that holds 20 rows this is cost without a benefit. It matters more here because the team is new to GraphQL. +4. **Limits give a safe upper bound.** With a limit, a list cannot grow so large that it slows the server or the client. Without paging and without a limit it could, so the limit is what makes the plain list acceptable. + +## Why cursors could be better + +These are the real arguments for changing, and they are the reason this is a decision and not a given: + +1. **The limit is a restriction on the user.** A coach who reaches 200 gymnasts cannot add more. For one coach in a gymnastics club this is very unlikely, but it is a rule that the assignment's "any amount of data" does not mention. +2. **Changing later breaks the clients.** Changing `gymnasts: [Gymnast!]!` to `gymnasts(first, after): GymnastConnection!` changes the shape of the answer. All queries that use the field must be rewritten. This is cheap while the schema is a draft and nobody has built on it, and it gets more expensive with every component that uses the field. With generated TypeScript types the compiler shows every place that must change, which limits the damage. +3. **Someone who reads the schema may expect it.** A reviewer who sees cursors on the catalog and plain lists on the user data may ask why. That is why this document exists. + +## Triggers for changing + +Change `Coach.gymnasts` (and then `Gymnast.routines` if needed) to a cursor connection if: + +- we add accounts that share gymnasts between several coaches, for example a club that sees all its gymnasts, because then the list is no longer bounded by one coach, or +- real use or the staff's testing shows coaches who need more than the limit, or +- the interface gets a search or a sort over the gymnasts that must run on the server. + +`Routine.items` stays a plain list in any case, because it is ordered and edited as a whole. + +How it would look: + +```graphql +type Coach { + gymnasts(first: Int! = 50, after: String): GymnastConnection! +} +``` + +It would use the same `edges`, `node`, `cursor` and `pageInfo` as the catalog, with `id` as the last sort key. + +## What the backend must do + +- 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 100th item in a gymnast's list works and the 101st gives `LIMIT_REACHED`, and a coach cannot reach another coach's lists. diff --git a/docs/VM_SETUP.md b/docs/VM_SETUP.md new file mode 100644 index 0000000..7bb18de --- /dev/null +++ b/docs/VM_SETUP.md @@ -0,0 +1,147 @@ +# VM Database Setup (PostgreSQL 18) + +This document describes how to set up PostgreSQL 18 on the virtual machine (VM) and configure the two database users as required by [`docs/API.md`](API.md). + +--- + +## 1. Install PostgreSQL 18 + +Install PostgreSQL 18 on Ubuntu/Debian using the official PostgreSQL APT repository: + +```bash +# Add official PostgreSQL APT repository +sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' +wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - + +# Update packages and install PostgreSQL 18 +sudo apt-get update +sudo apt-get -y install postgresql-18 +``` + +Verify that PostgreSQL is active: + +```bash +sudo systemctl status postgresql +``` + +--- + +## 2. Configure Database & Users + +Log into PostgreSQL as the superuser: + +```bash +sudo -u postgres psql +``` + +Run the following SQL statements to create the `wag_catalog` database and set up the dual user roles (`wag_migrator` and `wag_app`): + +```sql +-- Create the project database +CREATE DATABASE wag_catalog; + +-- 1. Migration User (Schema owner: can create/alter tables and run migrations) +CREATE USER wag_migrator WITH PASSWORD 'YOUR_SECURE_MIGRATOR_PASSWORD'; +GRANT ALL PRIVILEGES ON DATABASE wag_catalog TO wag_migrator; + +-- 2. Application User (Restricted runtime access: CRUD on tables only) +CREATE USER wag_app WITH PASSWORD 'YOUR_SECURE_APP_PASSWORD'; +GRANT CONNECT ON DATABASE wag_catalog TO wag_app; + +-- Connect to wag_catalog to configure schema permissions +\c wag_catalog + +-- Grant schema and table permissions to wag_app +GRANT USAGE ON SCHEMA public TO wag_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO wag_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO wag_app; +``` + +Type `\q` to exit `psql`. + +--- + +## 3. Configure Environment Variables + +Create a `backend/.env` file on the server (copied from `backend/.env.example`): + +```env +# Runtime backend server connection (wag_app) +DATABASE_URL="postgresql://wag_app:YOUR_SECURE_APP_PASSWORD@localhost:5432/wag_catalog" + +# Migration script connection (wag_migrator) +MIGRATION_DATABASE_URL="postgresql://wag_migrator:YOUR_SECURE_MIGRATOR_PASSWORD@localhost:5432/wag_catalog" + +PORT=3001 +NODE_ENV=production +SESSION_SECRET="your_production_session_secret" +``` + +Replace the value of `SESSION_SECRET` with a random secret of at least 32 characters. Make one on the server with: + +```bash +node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))" +``` + +`NODE_ENV=production` makes the backend refuse to start when `SESSION_SECRET` is missing or shorter than 32 characters. Without it the server starts anyway, and login quietly does not work (`me` is always `null`). A forgotten secret is then found when the server is started, in the log, and not when a coach cannot log in. If the server stops with "SESSION_SECRET is not set", the line is missing or empty in `backend/.env`. The file must be in `backend/`, next to `package.json`, and readable by the user that starts the server. + +--- + +## 4. Run Migrations & Initial Seed + +Run the database setup using the migration connection string: + +```bash +DATABASE_URL="postgresql://wag_migrator:YOUR_SECURE_MIGRATOR_PASSWORD@localhost:5432/wag_catalog" npm run setup +``` + +This command executes Prisma migrations to create all database tables and populates the database with the initial gymnastics catalog data. + +_Note: The initial setup and database seeding have been successfully executed on the production VM._ + +--- + +## 5. Connect the frontend to the backend + +The frontend is served by Apache under `/project2/`, and the backend listens on port 3001. The browser must reach the backend on the same address as the frontend, so Apache sends `/project2/graphql` and `/project2/images` to the backend. This also means there is no CORS to set up. + +_Status: this configuration follows the Apache documentation for `mod_proxy`, but it has **not been tried on the VM yet**. Check it with the `curl` command below._ + +1. Enable the proxy modules and restart Apache: + + ```bash + sudo a2enmod proxy proxy_http + sudo systemctl restart apache2 + ``` + +2. In the `VirtualHost` that serves `/project2/`, add (the path and the address in each pair either both end in a slash or both do not): + + ```apache + ProxyRequests Off + ProxyPass /project2/graphql http://localhost:3001/graphql + ProxyPassReverse /project2/graphql http://localhost:3001/graphql + ProxyPass /project2/images http://localhost:3001/images + ProxyPassReverse /project2/images http://localhost:3001/images + ``` + + `ProxyRequests Off` is the default and must stay off: with it on, Apache would work as an open proxy for anyone. + +3. Build the frontend with the API address, and put `IMAGE_BASE_URL` in `backend/.env` (the drawings are then fetched through the same address): + + ```bash + VITE_API_URL=/project2/graphql npm run build + ``` + + ```env + IMAGE_BASE_URL=http://it2810-XX.idi.ntnu.no/project2/images + ``` + +4. Check that the backend answers through Apache: + + ```bash + curl -s -X POST -H 'content-type: application/json' -d '{"query":"{ __typename }"}' http://it2810-XX.idi.ntnu.no/project2/graphql + ``` + + The answer should be `{"data":{"__typename":"Query"}}`. + +The VM serves plain http, so Sign in with Google does not work there (Google only accepts http for `localhost`), and the tokens would travel without encryption. See "Login" in the README. diff --git a/docs/examples/README.md b/docs/examples/README.md index 048725f..26e164b 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -6,15 +6,21 @@ These JSON files show how the data described in [DATA_MODEL.md](../DATA_MODEL.md **The data is generated by AI.** All the JSON files were generated by Claude Code from a task description written by the team. See [AI_USAGE.md](../AI_USAGE.md). -The ids of coaches, gymnasts and routines (`coach-1`, `gymnast-1`, `routine-1`) are placeholders for the internal ids the database will generate. +The ids of coaches, gymnasts and routines (`coach-1`, `gymnast-1`, `routine-1`) are placeholders for the ids Prisma generates (cuid). The rows in `gymnastElements.json` are numbered by the database. -| File | Entity | Contents | -| -------------------- | ------------ | --------------------------------------------------------------------- | -| `apparatus.json` | Apparatus | All 4 apparatus | -| `elementGroups.json` | ElementGroup | All 22 element groups | -| `elements.json` | Element | 9 elements on uneven bars, beam and floor, including two variants | -| `vaults.json` | Vault | 2 vaults from different groups | -| `variantGroups.json` | VariantGroup | 1 variant group, linking the two variants of `UB-1.101` | -| `coaches.json` | Coach | 1 coach | -| `gymnasts.json` | Gymnast | 2 gymnasts with their lists of elements | -| `routines.json` | Routine | 5 routines, including two on the same apparatus and one vault routine | +The files follow the tables in [DATA_MODEL.md](../DATA_MODEL.md#storage-in-postgresql): lists are stored as rows, and elements and vaults point to their variant group. + +| File | Entity | Contents | +| ---------------------- | -------------- | ----------------------------------------------------------------------------- | +| `apparatus.json` | Apparatus | All 4 apparatus, with their sort order | +| `elementGroups.json` | ElementGroup | All 22 element groups | +| `elements.json` | Element | 9 elements on uneven bars, beam and floor, including two variants of one code | +| `vaults.json` | Vault | 2 vaults from different groups | +| `variantGroups.json` | VariantGroup | 1 variant group. The two variants of `UB-1.101` point to it | +| `coaches.json` | Coach | 1 coach | +| `gymnasts.json` | Gymnast | 2 gymnasts | +| `gymnastElements.json` | GymnastElement | The two gymnasts' lists of elements and vaults, one row per item | +| `routines.json` | Routine | 5 routines, including two on the same apparatus and one vault routine | +| `routineElements.json` | RoutineElement | The items of the routines, one row per item with its position | + +The search tables `CatalogEntry` and `CatalogWord` are not included, because the seed script derives them from the catalog. diff --git a/docs/examples/apparatus.json b/docs/examples/apparatus.json index 391287d..d16e6e0 100644 --- a/docs/examples/apparatus.json +++ b/docs/examples/apparatus.json @@ -1,6 +1,22 @@ [ - { "id": "VT", "name": "Vault" }, - { "id": "UB", "name": "Uneven bars" }, - { "id": "BB", "name": "Balance beam" }, - { "id": "FX", "name": "Floor exercise" } + { + "id": "VT", + "name": "Vault", + "position": 1 + }, + { + "id": "UB", + "name": "Uneven bars", + "position": 2 + }, + { + "id": "BB", + "name": "Balance beam", + "position": 3 + }, + { + "id": "FX", + "name": "Floor exercise", + "position": 4 + } ] diff --git a/docs/examples/elementGroups.json b/docs/examples/elementGroups.json index be0d045..84003c4 100644 --- a/docs/examples/elementGroups.json +++ b/docs/examples/elementGroups.json @@ -3,51 +3,86 @@ "id": "VT-1", "apparatusId": "VT", "number": 1, - "name": "Vaults without salto (handspring, Yamashita, round-off) with or without LA turn in 1st and/or 2nd flight phase" + "name": "Handspring, Yamashita, round-off with or without turn in 1st and/or 2nd flight phase" }, { "id": "VT-2", "apparatusId": "VT", "number": 2, - "name": "Handspring fwd with or without 1/1 turn (360°) in 1st flight phase – salto fwd or bwd with or without LA turn in 2nd flight phase" + "name": "Handspring fwd with/without 1/1 turn (360°) in 1st flight phase – salto fwd/bwd with/without twist in 2nd flight phase" }, { "id": "VT-3", "apparatusId": "VT", "number": 3, - "name": "Handspring with ¼–½ turn (90°–180°) in 1st flight phase (Tsukahara) – salto bwd with or without LA turn in 2nd flight phase" + "name": "Handspring with ¼ - ½ turn (90°-180°) in 1st flight phase (Tsukahara) – salto bwd with/without twist in 2nd flight phase" }, { "id": "VT-4", "apparatusId": "VT", "number": 4, - "name": "Round-off (Yurchenko) with or without ¾ turn (270°) in 1st flight phase – salto bwd with or without LA turn in 2nd flight phase" + "name": "Round-off (Yurchenko) with/wo ¾ turn (270°) in 1st flight phase – salto bwd with/without twist in 2nd flight phase" }, { "id": "VT-5", "apparatusId": "VT", "number": 5, - "name": "Round-off with ½ turn (180°) in 1st flight phase – salto fwd or bwd with or without LA turn in 2nd flight phase" + "name": "Round-off with ½ turn (180°) in 1st flight phase – salto fwd/bwd with/without twist in 2nd flight phase" + }, + { + "id": "UB-1", + "apparatusId": "UB", + "number": 1, + "name": "Mounts" }, - { "id": "UB-1", "apparatusId": "UB", "number": 1, "name": "Mounts" }, { "id": "UB-2", "apparatusId": "UB", "number": 2, "name": "Casts and clear hip circles" }, - { "id": "UB-3", "apparatusId": "UB", "number": 3, "name": "Giant circles" }, - { "id": "UB-4", "apparatusId": "UB", "number": 4, "name": "Stalder circles" }, - { "id": "UB-5", "apparatusId": "UB", "number": 5, "name": "Pike circles" }, - { "id": "UB-6", "apparatusId": "UB", "number": 6, "name": "Dismounts" }, - { "id": "BB-1", "apparatusId": "BB", "number": 1, "name": "Mounts" }, + { + "id": "UB-3", + "apparatusId": "UB", + "number": 3, + "name": "Giant circles" + }, + { + "id": "UB-4", + "apparatusId": "UB", + "number": 4, + "name": "Stalder circles" + }, + { + "id": "UB-5", + "apparatusId": "UB", + "number": 5, + "name": "Pike circles" + }, + { + "id": "UB-6", + "apparatusId": "UB", + "number": 6, + "name": "Dismounts" + }, + { + "id": "BB-1", + "apparatusId": "BB", + "number": 1, + "name": "Mounts" + }, { "id": "BB-2", "apparatusId": "BB", "number": 2, "name": "Gymnastic leaps, jumps and hops" }, - { "id": "BB-3", "apparatusId": "BB", "number": 3, "name": "Gymnastic turns" }, + { + "id": "BB-3", + "apparatusId": "BB", + "number": 3, + "name": "Gymnastic turns" + }, { "id": "BB-4", "apparatusId": "BB", @@ -60,14 +95,24 @@ "number": 5, "name": "Acrobatic flight" }, - { "id": "BB-6", "apparatusId": "BB", "number": 6, "name": "Dismounts" }, + { + "id": "BB-6", + "apparatusId": "BB", + "number": 6, + "name": "Dismounts" + }, { "id": "FX-1", "apparatusId": "FX", "number": 1, "name": "Gymnastic leaps, jumps and hops" }, - { "id": "FX-2", "apparatusId": "FX", "number": 2, "name": "Gymnastic turns" }, + { + "id": "FX-2", + "apparatusId": "FX", + "number": 2, + "name": "Gymnastic turns" + }, { "id": "FX-3", "apparatusId": "FX", @@ -78,7 +123,12 @@ "id": "FX-4", "apparatusId": "FX", "number": 4, - "name": "Saltos forward and sideward" + "name": "Saltos forward & sideward" }, - { "id": "FX-5", "apparatusId": "FX", "number": 5, "name": "Saltos backward" } + { + "id": "FX-5", + "apparatusId": "FX", + "number": 5, + "name": "Saltos backward" + } ] diff --git a/docs/examples/elements.json b/docs/examples/elements.json index 71a36c9..3dd3c13 100644 --- a/docs/examples/elements.json +++ b/docs/examples/elements.json @@ -2,82 +2,100 @@ { "id": "UB-1.101-a", "elementGroupId": "UB-1", + "variantGroupId": "UB-1.101", "code": "1.101", "value": 0.1, "description": "Jump with straight body to hang on HB – swing fwd with ½ turn (180°)", "name": null, - "image": "UB-1.101-a.webp" + "image": "UB-1.101-a.webp", + "removedAt": null }, { "id": "UB-1.101-b", "elementGroupId": "UB-1", + "variantGroupId": "UB-1.101", "code": "1.101", "value": 0.1, "description": "Jump with legs straddled to hang on HB – swing fwd with ½ turn (180°)", "name": null, - "image": "UB-1.101-b.webp" + "image": "UB-1.101-b.webp", + "removedAt": null }, { "id": "UB-3.304-a", "elementGroupId": "UB-3", + "variantGroupId": null, "code": "3.304", "value": 0.3, "description": "Giant circle bwd in mixed grip with ½ turn (180°) through handstand", "name": null, - "image": "UB-3.304-a.webp" + "image": "UB-3.304-a.webp", + "removedAt": null }, { "id": "BB-2.201-a", "elementGroupId": "BB-2", + "variantGroupId": null, "code": "2.201", "value": 0.2, "description": "Leap fwd with ½ turn (180°), landing on one leg in cross position", "name": null, - "image": "BB-2.201-a.webp" + "image": "BB-2.201-a.webp", + "removedAt": null }, { "id": "BB-5.403-a", "elementGroupId": "BB-5", + "variantGroupId": null, "code": "5.403", "value": 0.4, "description": "Salto swd tucked from a two-step approach, landing in side position", "name": "Lindqvist", - "image": "BB-5.403-a.webp" + "image": "BB-5.403-a.webp", + "removedAt": null }, { "id": "BB-6.305-a", "elementGroupId": "BB-6", + "variantGroupId": null, "code": "6.305", "value": 0.3, "description": "Round-off at end of beam – salto bwd piked with ½ turn (180°)", "name": null, - "image": "BB-6.305-a.webp" + "image": "BB-6.305-a.webp", + "removedAt": null }, { "id": "FX-1.203-a", "elementGroupId": "FX-1", + "variantGroupId": null, "code": "1.203", "value": 0.2, "description": "Hop with free leg at horizontal and 1/1 turn (360°)", "name": null, - "image": "FX-1.203-a.webp" + "image": "FX-1.203-a.webp", + "removedAt": null }, { "id": "FX-2.302-a", "elementGroupId": "FX-2", + "variantGroupId": null, "code": "2.302", "value": 0.3, "description": "2/1 turn (720°) on one leg with free leg held bent in front", "name": null, - "image": "FX-2.302-a.webp" + "image": "FX-2.302-a.webp", + "removedAt": null }, { "id": "FX-5.506-a", "elementGroupId": "FX-5", + "variantGroupId": null, "code": "5.506", "value": 0.5, "description": "Double salto bwd with first salto tucked and second salto piked, with 1/1 turn (360°)", "name": "Aasen", - "image": "FX-5.506-a.webp" + "image": "FX-5.506-a.webp", + "removedAt": null } ] diff --git a/docs/examples/gymnastElements.json b/docs/examples/gymnastElements.json new file mode 100644 index 0000000..65c2d24 --- /dev/null +++ b/docs/examples/gymnastElements.json @@ -0,0 +1,62 @@ +[ + { + "id": 1, + "gymnastId": "gymnast-1", + "elementId": "UB-1.101-a", + "vaultId": null + }, + { + "id": 2, + "gymnastId": "gymnast-1", + "elementId": "UB-1.101-b", + "vaultId": null + }, + { + "id": 3, + "gymnastId": "gymnast-1", + "elementId": "UB-3.304-a", + "vaultId": null + }, + { + "id": 4, + "gymnastId": "gymnast-1", + "elementId": "BB-2.201-a", + "vaultId": null + }, + { + "id": 5, + "gymnastId": "gymnast-1", + "elementId": null, + "vaultId": "VT-1.12-a" + }, + { + "id": 6, + "gymnastId": "gymnast-1", + "elementId": null, + "vaultId": "VT-4.23-a" + }, + { + "id": 7, + "gymnastId": "gymnast-2", + "elementId": "BB-2.201-a", + "vaultId": null + }, + { + "id": 8, + "gymnastId": "gymnast-2", + "elementId": "BB-5.403-a", + "vaultId": null + }, + { + "id": 9, + "gymnastId": "gymnast-2", + "elementId": "FX-1.203-a", + "vaultId": null + }, + { + "id": 10, + "gymnastId": "gymnast-2", + "elementId": "FX-2.302-a", + "vaultId": null + } +] diff --git a/docs/examples/gymnasts.json b/docs/examples/gymnasts.json index 81c2cd4..3aa0acf 100644 --- a/docs/examples/gymnasts.json +++ b/docs/examples/gymnasts.json @@ -2,20 +2,11 @@ { "id": "gymnast-1", "coachId": "coach-1", - "name": "Ingrid Haugen", - "elementIds": [ - "UB-1.101-a", - "UB-1.101-b", - "UB-3.304-a", - "BB-2.201-a", - "VT-1.12-a", - "VT-4.23-a" - ] + "name": "Ingrid Haugen" }, { "id": "gymnast-2", "coachId": "coach-1", - "name": "Emma Berg", - "elementIds": ["BB-2.201-a", "BB-5.403-a", "FX-1.203-a", "FX-2.302-a"] + "name": "Emma Berg" } ] diff --git a/docs/examples/routineElements.json b/docs/examples/routineElements.json new file mode 100644 index 0000000..0657a05 --- /dev/null +++ b/docs/examples/routineElements.json @@ -0,0 +1,68 @@ +[ + { + "routineId": "routine-1", + "position": 1, + "elementId": "UB-1.101-a", + "vaultId": null + }, + { + "routineId": "routine-1", + "position": 2, + "elementId": "UB-3.304-a", + "vaultId": null + }, + { + "routineId": "routine-2", + "position": 1, + "elementId": "UB-1.101-b", + "vaultId": null + }, + { + "routineId": "routine-2", + "position": 2, + "elementId": "UB-3.304-a", + "vaultId": null + }, + { + "routineId": "routine-3", + "position": 1, + "elementId": null, + "vaultId": "VT-1.12-a" + }, + { + "routineId": "routine-4", + "position": 1, + "elementId": "BB-2.201-a", + "vaultId": null + }, + { + "routineId": "routine-4", + "position": 2, + "elementId": "BB-5.403-a", + "vaultId": null + }, + { + "routineId": "routine-4", + "position": 3, + "elementId": "BB-6.305-a", + "vaultId": null + }, + { + "routineId": "routine-5", + "position": 1, + "elementId": "FX-1.203-a", + "vaultId": null + }, + { + "routineId": "routine-5", + "position": 2, + "elementId": "FX-2.302-a", + "vaultId": null + }, + { + "routineId": "routine-5", + "position": 3, + "elementId": "FX-5.506-a", + "vaultId": null + } +] diff --git a/docs/examples/routines.json b/docs/examples/routines.json index 4531d46..e3bd223 100644 --- a/docs/examples/routines.json +++ b/docs/examples/routines.json @@ -4,34 +4,39 @@ "gymnastId": "gymnast-1", "name": "Competition routine", "apparatusId": "UB", - "elementIds": ["UB-1.101-a", "UB-3.304-a"] + "createdAt": "2026-10-01T12:00:00Z", + "updatedAt": "2026-10-01T18:30:00Z" }, { "id": "routine-2", "gymnastId": "gymnast-1", "name": "With straddled mount", "apparatusId": "UB", - "elementIds": ["UB-1.101-b", "UB-3.304-a"] + "createdAt": "2026-10-02T12:00:00Z", + "updatedAt": "2026-10-02T18:30:00Z" }, { "id": "routine-3", "gymnastId": "gymnast-1", "name": "Competition vault", "apparatusId": "VT", - "elementIds": ["VT-1.12-a"] + "createdAt": "2026-10-03T12:00:00Z", + "updatedAt": "2026-10-03T18:30:00Z" }, { "id": "routine-4", "gymnastId": "gymnast-2", "name": "Competition routine", "apparatusId": "BB", - "elementIds": ["BB-2.201-a", "BB-5.403-a", "BB-6.305-a"] + "createdAt": "2026-10-04T12:00:00Z", + "updatedAt": "2026-10-04T18:30:00Z" }, { "id": "routine-5", "gymnastId": "gymnast-2", "name": "Competition routine", "apparatusId": "FX", - "elementIds": ["FX-1.203-a", "FX-2.302-a", "FX-5.506-a"] + "createdAt": "2026-10-05T12:00:00Z", + "updatedAt": "2026-10-05T18:30:00Z" } ] diff --git a/docs/examples/variantGroups.json b/docs/examples/variantGroups.json index 1f5e194..8c5ee8d 100644 --- a/docs/examples/variantGroups.json +++ b/docs/examples/variantGroups.json @@ -1,6 +1,5 @@ [ { - "id": "UB-1.101", - "variantIds": ["UB-1.101-a", "UB-1.101-b"] + "id": "UB-1.101" } ] diff --git a/docs/examples/vaults.json b/docs/examples/vaults.json index 65108de..27fb107 100644 --- a/docs/examples/vaults.json +++ b/docs/examples/vaults.json @@ -2,19 +2,23 @@ { "id": "VT-1.12-a", "elementGroupId": "VT-1", + "variantGroupId": null, "code": "1.12", "dValue": 2.4, "description": "Handspring fwd on – ¾ turn (270°) off", "name": null, - "image": "VT-1.12-a.webp" + "image": "VT-1.12-a.webp", + "removedAt": null }, { "id": "VT-4.23-a", "elementGroupId": "VT-4", + "variantGroupId": null, "code": "4.23", "dValue": 4.4, "description": "Round-off, flic-flac on – salto bwd piked with 1¼ turn (450°) off", "name": "Dahl", - "image": "VT-4.23-a.webp" + "image": "VT-4.23-a.webp", + "removedAt": null } ] diff --git a/eslint.config.js b/eslint.config.js index 490ec23..2043a57 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,9 +7,10 @@ import eslintConfigPrettier from 'eslint-config-prettier/flat' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', 'backend/src/generated']), { files: ['**/*.{ts,tsx}'], + ignores: ['backend/**'], extends: [ js.configs.recommended, tseslint.configs.recommended, @@ -20,5 +21,47 @@ export default defineConfig([ globals: globals.browser, }, }, + { + files: ['backend/**/*.ts'], + extends: [js.configs.recommended, tseslint.configs.recommended], + languageOptions: { + globals: globals.node, + }, + rules: { + // The backend never uses raw SQL: every query goes through Prisma, which + // sends all values as parameters. See "Search without raw SQL" in + // docs/API.md. + 'no-restricted-properties': [ + 'error', + ...[ + '$queryRaw', + '$executeRaw', + '$queryRawUnsafe', + '$executeRawUnsafe', + '$queryRawTyped', + ].map((property) => ({ + property, + message: + 'Raw SQL is not allowed. Use the Prisma Client API (see docs/API.md).', + })), + ], + // mode: 'insensitive' turns LIKE into ILIKE, which cannot use the index + // on CatalogWord.word. The words are stored in lowercase instead. + 'no-restricted-syntax': [ + 'error', + { + selector: "Property[key.name='mode'][value.value='insensitive']", + message: + "mode: 'insensitive' is not allowed: it becomes ILIKE, which cannot use the index. Lowercase the search words instead (see docs/API.md).", + }, + ], + }, + }, + { + // The shadcn/ui CLI copies components into this folder. They also export + // helpers such as buttonVariants, which the Fast Refresh rule does not allow. + files: ['src/components/ui/**'], + rules: { 'react-refresh/only-export-components': 'off' }, + }, eslintConfigPrettier, ]) diff --git a/index.html b/index.html index 27a7eef..8cd354a 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,12 @@ + + + t29-project-2 diff --git a/package-lock.json b/package-lock.json index e73e7ee..397526c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,16 +7,34 @@ "": { "name": "t29-project-2", "version": "0.0.0", + "workspaces": [ + "backend" + ], "dependencies": { + "@apollo/client": "^4.3.4", + "@apollo/server": "^5.5.1", + "@base-ui/react": "^1.9.0", + "@prisma/client": "^7.10.1", + "class-variance-authority": "^0.7.1", + "cn": "^0.4.0", + "dotenv": "^18.0.8", + "graphql": "^16.14.2", + "lucide-react": "^1.58.0", + "pg": "^8.23.1", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4", + "rxjs": "^7.8.2" }, "devDependencies": { "@babel/core": "^7.29.7", "@eslint/js": "^10.0.1", "@rolldown/plugin-babel": "^0.2.3", + "@tailwindcss/vite": "^4.3.3", "@types/babel__core": "^7.20.5", - "@types/node": "^24.13.3", + "@types/cors": "^2.8.19", + "@types/node": "^24.19.2", + "@types/pg": "^8.23.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", "@vitejs/plugin-react": "^6.1.1", @@ -27,10 +45,367 @@ "eslint-plugin-react-refresh": "^0.5.6", "globals": "^17.12.0", "prettier": "^3.9.9", + "prisma": "^7.10.1", + "shadcn": "^1.0.0", + "tailwindcss": "^4.3.3", + "tsx": "^4.23.15", + "tw-animate-css": "^1.4.0", "typescript": "~6.0.2", "typescript-eslint": "^8.69.0", "vite": "^8.3.0", "vitest": "^5.0.2" + }, + "engines": { + "node": ">=24" + } + }, + "backend": { + "version": "0.0.0", + "dependencies": { + "@apollo/server": "^5.5.1", + "@as-integrations/express5": "^1.1.2", + "@prisma/adapter-pg": "^7.10.1", + "@prisma/client": "^7.10.1", + "cors": "^2.8.6", + "dataloader": "^2.2.3", + "dotenv": "^18.0.8", + "express": "^5.3.0", + "graphql": "^16.14.2", + "jose": "^6.2.12", + "tsx": "^4.23.15" + }, + "devDependencies": { + "@graphql-codegen/cli": "^7.4.6", + "@graphql-codegen/typescript": "^6.1.2", + "@graphql-codegen/typescript-resolvers": "^6.1.1", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/node": "^24.19.2", + "prisma": "^7.10.1", + "typescript": "~6.0.2" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@apollo/cache-control-types": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@apollo/cache-control-types/-/cache-control-types-1.0.3.tgz", + "integrity": "sha512-F17/vCp7QVwom9eG7ToauIKdAxpSoadsJnqIfyryLFSkLSOEqu+eC5Z3N8OXcUVStuOMcNHlyraRsA6rRICu4g==", + "license": "MIT", + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/client": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.3.4.tgz", + "integrity": "sha512-knXw5+GtT5zjrpqxG2Pnor34Xmjd5qcpI34slI8GKV88r1vc5LPCve6ImfcpRsBIy1aHBjTRFTHIt2YRDcxfHA==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@wry/caches": "^1.0.0", + "@wry/equality": "^0.5.6", + "@wry/trie": "^0.5.0", + "graphql-tag": "^2.12.6", + "optimism": "^0.18.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "graphql": "^16.0.0 || ^17.0.0", + "graphql-ws": "^5.5.5 || ^6.0.3", + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "react-dom": "^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "rxjs": "^7.3.0", + "subscriptions-transport-ws": "^0.9.0 || ^0.11.0" + }, + "peerDependenciesMeta": { + "graphql-ws": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "subscriptions-transport-ws": { + "optional": true + } + } + }, + "node_modules/@apollo/protobufjs": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.2.8.tgz", + "integrity": "sha512-r7xNeUqZX+eBBEmyvaPw0/cSz6zgf5jdH8mjUz8ynKpNs/GU7vi2T7sNcZINk2ZID7wwjG91FCgdpCrQuJ8rzA==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.0", + "long": "^4.0.0" + }, + "bin": { + "apollo-pbjs": "bin/pbjs", + "apollo-pbts": "bin/pbts" + } + }, + "node_modules/@apollo/server": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@apollo/server/-/server-5.5.1.tgz", + "integrity": "sha512-Rn3g5TJQsMSUY23CWZTghWdBWyjX7dP1eaEBPkvmM2RHi82cDcpgTIkSCbGvtTUEGjwopLv1AAooU/n7iIZ20A==", + "license": "MIT", + "dependencies": { + "@apollo/cache-control-types": "^1.0.3", + "@apollo/server-gateway-interface": "^2.0.0", + "@apollo/usage-reporting-protobuf": "^4.1.1", + "@apollo/utils.createhash": "^3.0.0", + "@apollo/utils.fetcher": "^3.0.0", + "@apollo/utils.isnodelike": "^3.0.0", + "@apollo/utils.keyvaluecache": "^4.0.0", + "@apollo/utils.logger": "^3.0.0", + "@apollo/utils.usagereporting": "^2.1.0", + "@apollo/utils.withrequired": "^3.0.0", + "@graphql-tools/schema": "^10.0.0", + "async-retry": "^1.2.1", + "body-parser": "^2.2.2", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "finalhandler": "^2.1.0", + "loglevel": "^1.6.8", + "lru-cache": "^11.1.0", + "negotiator": "^1.0.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "graphql": "^16.11.0" + } + }, + "node_modules/@apollo/server-gateway-interface": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@apollo/server-gateway-interface/-/server-gateway-interface-2.0.0.tgz", + "integrity": "sha512-3HEMD6fSantG2My3jWkb9dvfkF9vJ4BDLRjMgsnD790VINtuPaEp+h3Hg9HOHiWkML6QsOhnaRqZ+gvhp3y8Nw==", + "license": "MIT", + "dependencies": { + "@apollo/usage-reporting-protobuf": "^4.1.1", + "@apollo/utils.fetcher": "^3.0.0", + "@apollo/utils.keyvaluecache": "^4.0.0", + "@apollo/utils.logger": "^3.0.0" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/server/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@apollo/usage-reporting-protobuf": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@apollo/usage-reporting-protobuf/-/usage-reporting-protobuf-4.1.2.tgz", + "integrity": "sha512-aTnAD41RYz0d5dawlyR5Iclkgzx0Xb0njUJmEfvZ6pS4f4HU8wCYyctPpWat/HWp2PmRwDfX5R1k4uVcDKZ4xA==", + "license": "MIT", + "dependencies": { + "@apollo/protobufjs": "1.2.8" + } + }, + "node_modules/@apollo/utils.createhash": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.createhash/-/utils.createhash-3.0.1.tgz", + "integrity": "sha512-CKrlySj4eQYftBE5MJ8IzKwIibQnftDT7yGfsJy5KSEEnLlPASX0UTpbKqkjlVEwPPd4mEwI7WOM7XNxEuO05A==", + "license": "MIT", + "dependencies": { + "@apollo/utils.isnodelike": "^3.0.0", + "sha.js": "^2.4.11" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@apollo/utils.dropunuseddefinitions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.dropunuseddefinitions/-/utils.dropunuseddefinitions-2.0.1.tgz", + "integrity": "sha512-EsPIBqsSt2BwDsv8Wu76LK5R1KtsVkNoO4b0M5aK0hx+dGg9xJXuqlr7Fo34Dl+y83jmzn+UvEW+t1/GP2melA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.fetcher": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.fetcher/-/utils.fetcher-3.1.0.tgz", + "integrity": "sha512-Z3QAyrsQkvrdTuHAFwWDNd+0l50guwoQUoaDQssLOjkmnmVuvXlJykqlEJolio+4rFwBnWdoY1ByFdKaQEcm7A==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@apollo/utils.isnodelike": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.isnodelike/-/utils.isnodelike-3.0.0.tgz", + "integrity": "sha512-xrjyjfkzunZ0DeF6xkHaK5IKR8F1FBq6qV+uZ+h9worIF/2YSzA0uoBxGv6tbTeo9QoIQnRW4PVFzGix5E7n/g==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@apollo/utils.keyvaluecache": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-4.0.0.tgz", + "integrity": "sha512-mKw1myRUkQsGPNB+9bglAuhviodJ2L2MRYLTafCMw5BIo7nbvCPNCkLnIHjZ1NOzH7SnMAr5c9LmXiqsgYqLZw==", + "license": "MIT", + "dependencies": { + "@apollo/utils.logger": "^3.0.0", + "lru-cache": "^11.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@apollo/utils.keyvaluecache/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@apollo/utils.logger": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-3.0.0.tgz", + "integrity": "sha512-M8V8JOTH0F2qEi+ktPfw4RL7MvUycDfKp7aEap2eWXfL5SqWHN6jTLbj5f5fj1cceHpyaUSOZlvlaaryaxZAmg==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@apollo/utils.printwithreducedwhitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.printwithreducedwhitespace/-/utils.printwithreducedwhitespace-2.0.1.tgz", + "integrity": "sha512-9M4LUXV/fQBh8vZWlLvb/HyyhjJ77/I5ZKu+NBWV/BmYGyRmoEP9EVAy7LCVoY3t8BDcyCAGfxJaLFCSuQkPUg==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.removealiases": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.removealiases/-/utils.removealiases-2.0.1.tgz", + "integrity": "sha512-0joRc2HBO4u594Op1nev+mUF6yRnxoUH64xw8x3bX7n8QBDYdeYgY4tF0vJReTy+zdn2xv6fMsquATSgC722FA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.sortast": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.sortast/-/utils.sortast-2.0.1.tgz", + "integrity": "sha512-eciIavsWpJ09za1pn37wpsCGrQNXUhM0TktnZmHwO+Zy9O4fu/WdB4+5BvVhFiZYOXvfjzJUcc+hsIV8RUOtMw==", + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.stripsensitiveliterals": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.stripsensitiveliterals/-/utils.stripsensitiveliterals-2.0.1.tgz", + "integrity": "sha512-QJs7HtzXS/JIPMKWimFnUMK7VjkGQTzqD9bKD1h3iuPAqLsxd0mUNVbkYOPTsDhUKgcvUOfOqOJWYohAKMvcSA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.usagereporting": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.usagereporting/-/utils.usagereporting-2.1.0.tgz", + "integrity": "sha512-LPSlBrn+S17oBy5eWkrRSGb98sWmnEzo3DPTZgp8IQc8sJe0prDgDuppGq4NeQlpoqEHz0hQeYHAOA0Z3aQsxQ==", + "license": "MIT", + "dependencies": { + "@apollo/usage-reporting-protobuf": "^4.1.0", + "@apollo/utils.dropunuseddefinitions": "^2.0.1", + "@apollo/utils.printwithreducedwhitespace": "^2.0.1", + "@apollo/utils.removealiases": "2.0.1", + "@apollo/utils.sortast": "^2.0.1", + "@apollo/utils.stripsensitiveliterals": "^2.0.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.withrequired": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.withrequired/-/utils.withrequired-3.0.0.tgz", + "integrity": "sha512-aaxeavfJ+RHboh7c2ofO5HHtQobGX4AgUujXP4CXpREHp9fQ9jPi6K9T1jrAKe7HIipoP0OJ1gd6JamSkFIpvA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@ardatan/relay-compiler": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/@ardatan/relay-compiler/-/relay-compiler-13.0.2.tgz", + "integrity": "sha512-VFpv9UP820SiwDUPYtq7PmD3jifzZlevkQ26bhbSzFeruSTys0eHzQCZyKg+IhgmZzwPI9AFjPe26ABNjGeIKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^8.0.0", + "immutable": "^5.1.9", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "graphql": "*" + } + }, + "node_modules/@as-integrations/express5": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@as-integrations/express5/-/express5-1.1.2.tgz", + "integrity": "sha512-BxfwtcWNf2CELDkuPQxi5Zl3WqY/dQVJYafeCBOGoFQjv5M0fjhxmAFZ9vKx/5YKKNeok4UY6PkFbHzmQrdxIA==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@apollo/server": "^4.0.0 || ^5.0.0", + "express": "^5.0.0" } }, "node_modules/@babel/code-frame": { @@ -165,6 +540,16 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -225,6 +610,12 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.7.tgz", + "integrity": "sha512-k0hagDDq/6w4v+0Sp4wFvIz/yswH5O7Va6aRTnD7N62Tnborp4X+YB+H3XUcWVSR4aE47WPEXiIIZchBs0GmYg==", + "license": "MIT" + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -273,6 +664,66 @@ "node": ">=6.9.0" } }, + "node_modules/@base-ui/react": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.9.0.tgz", + "integrity": "sha512-S8CSYosASBKtpL8SWUfS78rFElfLijiCgA7oHOK22jCJ95FULsYyKwBNFOgudt0RA+/7yNc7F81bOvQnRgbBiQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7 || ^8.0.0", + "@base-ui/utils": "0.5.0", + "@floating-ui/react-dom": "^2.1.9", + "@floating-ui/utils": "^0.2.12", + "use-sync-external-store": "^1.7.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.5.0.tgz", + "integrity": "sha512-dYGGcEttjkYiMoa5nHm8vLaJ2UcuTWxo3BFAwOYXeRg/q7UVVvA6cacEIXEs3FgBfK1CGtW1ptElqs7wsx+t9A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7 || ^8.0.0", + "@floating-ui/utils": "^0.2.12", + "reselect": "^5.3.0", + "use-sync-external-store": "^1.7.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@cacheable/memory": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", @@ -297,396 +748,285 @@ "keyv": "^5.6.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", - "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node_modules/@electric-sql/pglite": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", + "integrity": "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.3.tgz", + "integrity": "sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "@electric-sql/pglite": "0.4.3" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.3.tgz", + "integrity": "sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==", + "devOptional": true, "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "peerDependencies": { + "@electric-sql/pglite": "0.4.3" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "node_modules/@envelop/core": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.6.1.tgz", + "integrity": "sha512-KEbJ7yrAA+W7IY9MZzpZJ77w/72ndaV/16+hhPapd+0Ohjqffl0mmF3QWy62/m3nRP4qidD+1eiEjJhS/O38Tg==", "dev": true, "license": "MIT", + "dependencies": { + "@envelop/instrumentation": "^1.0.1", + "@envelop/types": "^5.2.2", + "@whatwg-node/promise-helpers": "^1.2.4", + "tslib": "^2.5.0" + }, "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=18.0.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "node_modules/@envelop/core/node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" + "tslib": "^2.6.3" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=16.0.0" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", - "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "node_modules/@envelop/instrumentation": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@envelop/instrumentation/-/instrumentation-1.0.1.tgz", + "integrity": "sha512-FuexE2qd3Yc1Kh1DjTliwGxb9kyyv4ySBNnfJOxnQasVmGdcqX6AkkO5sIMxLWFRftp8PErOcIn2dQW/1RwytQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.15" + "@whatwg-node/promise-helpers": "^1.2.1", + "tslib": "^2.5.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18.0.0" } }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "node_modules/@envelop/instrumentation/node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", "dev": true, "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", - "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", - "dev": true, - "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.2.1", - "levn": "^0.4.1" + "tslib": "^2.6.3" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=16.0.0" } }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "node_modules/@envelop/types": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@envelop/types/-/types-5.2.2.tgz", + "integrity": "sha512-nl9cmUh8F+2/NNzpcLjzNqtaVn0m8XSzzxrPU2ByHhlz4Hqlc5QBfRsemZkoJMKrMviJXyJYdKZiUP1o70F3Kw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@humanfs/types": "^0.15.0" + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.5.0" }, "engines": { - "node": ">=18.18.0" + "node": ">=18.0.0" } }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "node_modules/@envelop/types/node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" + "tslib": "^2.6.3" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "node": ">=16.0.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "node": ">=18" } }, - "node_modules/@keyv/bigmap": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", - "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", - "dev": true, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], "license": "MIT", - "dependencies": { - "hashery": "^1.4.0", - "hookified": "^1.15.0" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "keyv": "^5.6.0" - } - }, - "node_modules/@keyv/serialize": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", - "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@oxc-project/types": { - "version": "0.151.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", - "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/oxc-project" + "node": ">=18" } }, - "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", - "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ - "arm" + "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", - "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ - "arm64" + "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", - "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", - "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", - "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ - "x64" + "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", - "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ - "arm" + "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", - "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], - "dev": true, - "libc": [ - "glibc" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" ], "license": "MIT", "optional": true, @@ -694,19 +1034,31 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", - "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ - "arm64" + "loong64" ], - "dev": true, - "libc": [ - "musl" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" ], "license": "MIT", "optional": true, @@ -714,19 +1066,31 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", - "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], - "dev": true, - "libc": [ - "glibc" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" ], "license": "MIT", "optional": true, @@ -734,2041 +1098,9363 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", - "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], - "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", - "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], - "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", - "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], - "dev": true, - "libc": [ - "musl" + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "openbsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", - "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openharmony" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", - "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", - "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@rolldown/plugin-babel": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/plugin-babel/-/plugin-babel-0.2.4.tgz", - "integrity": "sha512-ygmm2j/mN+aCvstHo0x3IOu4uc6LqXT9SJ3q4kpfG/+Yhfz0B+E47FITcC0FljZADwUFARP6E2kiE+Lc26D8wg==", + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { - "picomatch": "^4.0.7" + "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": ">=22.12.0 || ^24.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, - "peerDependencies": { - "@babel/core": "^7.29.0 || ^8.0.0-rc.1", - "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", - "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", - "rolldown": "^1.0.0-rc.5", - "vite": "^8.0.0" + "funding": { + "url": "https://opencollective.com/eslint" }, - "peerDependenciesMeta": { - "@babel/plugin-transform-runtime": { - "optional": true - }, - "@babel/runtime": { - "optional": true - }, - "vite": { - "optional": true - } + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@babel/types": "^7.28.2" + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "node_modules/@fastify/busboy": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.3.tgz", + "integrity": "sha512-fmo/IKgkenPzz3qTKG+sIUXdF/Kv83AkvvOXJHPTHJeKJbat8SsL1ihUaFaDotqoEl4UZ1Lf8Eb/3vK6eaWX2A==", "dev": true, "license": "MIT" }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } }, - "node_modules/@types/node": { - "version": "24.19.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", - "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", - "dev": true, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" } }, - "node_modules/@types/react": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", - "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dev": true, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", "license": "MIT", "dependencies": { - "csstype": "^3.2.2" + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" } }, - "node_modules/@types/react-dom": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", - "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@graphql-codegen/add": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-7.1.1.tgz", + "integrity": "sha512-xN4za/V9e1NentxtKQYKKWzSoWNs3x72vqUZ4YGIzFLLGOJOCNVrR9lXDDQHDPrtcPZaj06zksdrfuJ7qutNCA==", "dev": true, "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^7.4.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=16" + }, "peerDependencies": { - "@types/react": "^19.3.0" + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", - "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", + "node_modules/@graphql-codegen/cli": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-7.4.6.tgz", + "integrity": "sha512-OLCyXIJvfTcNl56RWr7XF+RQnfb+dnj4BnA39krPha+wpzNfGqe89nXeEfVCUHx7J3Bt0Um3eSPhcQuNRXXG5w==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.71.0", - "@typescript-eslint/type-utils": "8.71.0", - "@typescript-eslint/utils": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" + "@babel/generator": "^7.18.13", + "@babel/template": "^7.18.10", + "@babel/types": "^7.18.13", + "@graphql-codegen/client-preset": "^6.2.3", + "@graphql-codegen/core": "^6.2.2", + "@graphql-codegen/plugin-helpers": "^7.4.2", + "@graphql-tools/apollo-engine-loader": "^8.0.38", + "@graphql-tools/code-file-loader": "^8.1.41", + "@graphql-tools/git-loader": "^8.0.44", + "@graphql-tools/github-loader": "^9.1.11", + "@graphql-tools/graphql-file-loader": "^8.1.22", + "@graphql-tools/json-file-loader": "^8.0.36", + "@graphql-tools/load": "^8.1.19", + "@graphql-tools/merge": "^9.2.6", + "@graphql-tools/url-loader": "^9.1.12", + "@graphql-tools/utils": "^12.0.3", + "@inquirer/prompts": "^8.3.2", + "@whatwg-node/fetch": "^0.10.0", + "chalk": "^5.6.0", + "cosmiconfig": "^9.0.0", + "debounce": "^3.0.0", + "detect-indent": "^7.0.0", + "graphql-config": "^5.1.8", + "is-glob": "^4.0.1", + "jiti": "^2.3.0", + "json-to-pretty-yaml": "^1.2.2", + "listr2": "^10.2.1", + "log-symbols": "^7.0.0", + "micromatch": "^4.0.5", + "shell-quote": "^1.7.3", + "string-env-interpolation": "^1.0.1", + "ts-log": "^3.0.0", + "tslib": "^2.4.0", + "yaml": "^2.3.1", + "yargs": "^18.0.0" }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "bin": { + "gql-gen": "esm/bin.js", + "graphql-code-generator": "esm/bin.js", + "graphql-codegen": "esm/bin.js", + "graphql-codegen-cjs": "cjs/bin.js", + "graphql-codegen-esm": "esm/bin.js" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "engines": { + "node": ">=16" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.71.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "@parcel/watcher": "^2.1.0", + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + }, + "peerDependenciesMeta": { + "@parcel/watcher": { + "optional": true + } } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", - "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", + "node_modules/@graphql-codegen/client-preset": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-6.2.3.tgz", + "integrity": "sha512-Ma0aOoFC3ULMLGPMQpYnEoI9SdFWydEG7RvQIUfLJBWr92Rk2oYeapvE+5ZsSanErc2XxnJiPROp/e3Wql4H/g==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 4" + "dependencies": { + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/template": "^7.20.7", + "@graphql-codegen/add": "^7.1.1", + "@graphql-codegen/gql-tag-operations": "^6.1.1", + "@graphql-codegen/plugin-helpers": "^7.4.2", + "@graphql-codegen/typed-document-node": "^7.1.1", + "@graphql-codegen/typescript": "^6.1.2", + "@graphql-codegen/typescript-operations": "^6.1.10", + "@graphql-codegen/visitor-plugin-common": "^7.2.10", + "@graphql-tools/documents": "^1.0.0", + "@graphql-tools/utils": "^12.0.3", + "@graphql-typed-document-node/core": "3.2.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0", + "graphql-sock": "^1.0.0" + }, + "peerDependenciesMeta": { + "graphql-sock": { + "optional": true + } } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", - "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", + "node_modules/@graphql-codegen/core": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/core/-/core-6.2.2.tgz", + "integrity": "sha512-bClqhA2ZITWKiEYd5A87KaU93YBC8o1++B3Y0FpYIZPZfGEWEwbhyvUkrSKrXqUagwM7c0h77f7DB4oR6vBgpQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.71.0", - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/typescript-estree": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0", - "debug": "^4.4.3" + "@graphql-codegen/plugin-helpers": "^7.4.2", + "@graphql-tools/schema": "^10.1.3", + "@graphql-tools/utils": "^12.0.3", + "tslib": "^2.8.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=16" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", - "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "node_modules/@graphql-codegen/gql-tag-operations": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-6.1.1.tgz", + "integrity": "sha512-TJ8yK31ZExBdslw8VG5P8mclrYvhMR7a/UnQvYp87pfekNJqfJignALxdZUkwjWWZNiO8hZEBYQiIrCwSJBHKg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.71.0", - "@typescript-eslint/types": "^8.71.0", - "debug": "^4.4.3" + "@graphql-codegen/plugin-helpers": "^7.4.2", + "@graphql-codegen/visitor-plugin-common": "^7.2.9", + "@graphql-tools/utils": "^12.0.3", + "auto-bind": "^5.0.0", + "tslib": "^2.8.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=16" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", - "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "node_modules/@graphql-codegen/plugin-helpers": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-7.4.2.tgz", + "integrity": "sha512-5SmkQvoNg3orbCNWBIv6lD1Zk1/mJbgdqv5oUy2Pw8Q70sO1Y3CGOkdwnPUp5l/pm0GKApQUyqPJkqFaJKewpQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0" + "@graphql-tools/utils": "^12.0.3", + "change-case-all": "^2.1.0", + "common-tags": "1.8.2", + "import-from": "4.0.0", + "tslib": "^2.8.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=16" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", - "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "node_modules/@graphql-codegen/schema-ast": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/schema-ast/-/schema-ast-6.1.1.tgz", + "integrity": "sha512-9THb+iRVIDdRBVBL/FIAXIVXx++XkGxynqVCco7F5lKY4NYy07fB4oRcLGIpKuLGNeGNcRkTaQ3x7DNyojbC+A==", "dev": true, "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "dependencies": { + "@graphql-codegen/plugin-helpers": "^7.4.2", + "@graphql-tools/utils": "^12.0.3", + "tslib": "^2.8.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "engines": { + "node": ">=16" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", - "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", + "node_modules/@graphql-codegen/typed-document-node": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-7.2.0.tgz", + "integrity": "sha512-+QS4FeRsUE0WwqdGpBf8CuYUCYtenfnEJ5KBfElv1eRdmooD8MH8Txy5Okk6E8hK75QZ+rAYBf1at2h/4CF1mg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/typescript-estree": "8.71.0", - "@typescript-eslint/utils": "8.71.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" + "@graphql-codegen/plugin-helpers": "^7.4.2", + "@graphql-codegen/visitor-plugin-common": "^7.3.0", + "auto-bind": "^5.0.0", + "change-case-all": "^2.1.0", + "tslib": "^2.8.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=16" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", - "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "node_modules/@graphql-codegen/typescript": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-6.1.2.tgz", + "integrity": "sha512-tO9lJu0Yzy5Ipx+IMSBLZlAIQwGDTgqH3L8quGtU8u+BMZCAtGME82utpz16nmKLXueuWUuVzDarkYc12/otgw==", "dev": true, "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^7.4.2", + "@graphql-codegen/schema-ast": "^6.1.1", + "@graphql-codegen/visitor-plugin-common": "^7.2.10", + "auto-bind": "^5.0.0", + "tslib": "~2.8.0" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=16" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "peerDependencies": { + "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", - "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "node_modules/@graphql-codegen/typescript-operations": { + "version": "6.1.10", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-6.1.10.tgz", + "integrity": "sha512-hTnwAwQI+/lV0mAlWV0Jg7jBo2XCC++8+njr0meDHQbeAJSH6Pew1ZIcmLVvQUsQNSQwNoR4SKftHbDvq6KoNA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.71.0", - "@typescript-eslint/tsconfig-utils": "8.71.0", - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/visitor-keys": "8.71.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" + "@graphql-codegen/plugin-helpers": "^7.4.1", + "@graphql-codegen/schema-ast": "^6.1.0", + "@graphql-codegen/visitor-plugin-common": "^7.2.8", + "auto-bind": "^5.0.0", + "tslib": "^2.8.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=16" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0", + "graphql-sock": "^1.0.0" + }, + "peerDependenciesMeta": { + "graphql-sock": { + "optional": true + } } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "node_modules/@graphql-codegen/typescript-resolvers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-resolvers/-/typescript-resolvers-6.1.1.tgz", + "integrity": "sha512-extVXCrMfglK0SYZsr+9i1sZzVsf3JwV0GLisoUHrYo4G9Rq+qXE9o592t32w6VfpeD9DCma8GAr+IAlU79F2A==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "dependencies": { + "@graphql-codegen/plugin-helpers": "^7.4.2", + "@graphql-codegen/typescript": "^6.1.1", + "@graphql-codegen/visitor-plugin-common": "^7.2.9", + "@graphql-tools/utils": "^12.0.3", + "auto-bind": "^5.0.0", + "tslib": "^2.8.0" }, "engines": { - "node": ">=10" + "node": ">=16" + }, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0", + "graphql-sock": "^1.0.0" + }, + "peerDependenciesMeta": { + "graphql-sock": { + "optional": true + } } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", - "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "node_modules/@graphql-codegen/visitor-plugin-common": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-7.3.0.tgz", + "integrity": "sha512-XS+VizbHubOXwk6TKK24FLiaicm5p210vufhhpQK78+0ETLhOiMGPBnkz6ieT3OOPJkakKXQoit948ekxE4ohg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.71.0", - "@typescript-eslint/types": "8.71.0", - "@typescript-eslint/typescript-estree": "8.71.0" + "@graphql-codegen/plugin-helpers": "^7.4.2", + "@graphql-tools/optimize": "^2.0.0", + "@graphql-tools/relay-operation-optimizer": "^7.1.12", + "@graphql-tools/utils": "^12.0.3", + "auto-bind": "^5.0.0", + "change-case-all": "^2.1.0", + "dependency-graph": "^1.0.0", + "graphql-tag": "^2.11.0", + "parse-filepath": "^1.0.2", + "tslib": "^2.8.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=16" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.71.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", - "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "node_modules/@graphql-hive/signal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@graphql-hive/signal/-/signal-2.0.0.tgz", + "integrity": "sha512-Pz8wB3K0iU6ae9S1fWfsmJX24CcGeTo6hE7T44ucmV/ALKRj+bxClmqrYcDT7v3f0d12Rh4FAXBb6gon+WkDpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@graphql-tools/apollo-engine-loader": { + "version": "8.0.40", + "resolved": "https://registry.npmjs.org/@graphql-tools/apollo-engine-loader/-/apollo-engine-loader-8.0.40.tgz", + "integrity": "sha512-diVagkRBLhyEyZTGxBIyWMJ+wlc/hnQygOCFzg9nZOz48PbpDrn9X4+pQhPydbfpTmNJHaAjdRj0Yr5InG8AYg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.0", - "eslint-visitor-keys": "^5.0.0" + "@graphql-tools/utils": "^12.1.0", + "@whatwg-node/fetch": "^0.12.0", + "sync-fetch": "0.7.1", + "tslib": "^2.4.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=16.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@vitejs/plugin-react": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", - "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "node_modules/@graphql-tools/apollo-engine-loader/node_modules/@whatwg-node/disposablestack": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.1.0.tgz", + "integrity": "sha512-ok0A9+D+mdeb3w1GNCpVr+2gVVLRWKo9Ywm/whNsoWSNfNg3jNxS0/wUF6NSUYbfOtFGu2wZgJnLjasbaQEfvA==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "^1.0.1" + "@whatwg-node/promise-helpers": "^2.0.0", + "tslib": "^2.6.3" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=22.15.0" + } + }, + "node_modules/@graphql-tools/apollo-engine-loader/node_modules/@whatwg-node/fetch": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.12.1.tgz", + "integrity": "sha512-MjSXerFVxhWwPXZMNp4b4Q3WZMEIqzinHVWuH5tF2LNGy4hHypqPJCNpaR6bEZbaGdlH+r5dRAmQ8bxowcetjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/node-fetch": "^0.9.1", + "urlpattern-polyfill": "^10.0.0" }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "oxc-transform-react": "^0.145.0", - "vite": "^8.0.0" + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/@graphql-tools/apollo-engine-loader/node_modules/@whatwg-node/node-fetch": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.9.1.tgz", + "integrity": "sha512-LX/1X2QEHupCz6YXRjefZRoOsycCGOIJdXtQPVVxhzPQ/CpWpWFaXl53ShqLystvLZSvtUDKLhCA/yLveHA/LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.1.1", + "@whatwg-node/disposablestack": "^0.1.0", + "@whatwg-node/promise-helpers": "^2.0.0", + "tslib": "^2.6.3" }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "oxc-transform-react": { - "optional": true - } + "engines": { + "node": ">=22.15.0" } }, - "node_modules/@vitest/mocker": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz", - "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", + "node_modules/@graphql-tools/batch-execute": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-10.2.0.tgz", + "integrity": "sha512-lXz9fGum2shGKMMU7zmt0mXHUFo/KbPzHpv/Qzb2/MrJUYDNfWCKPPpiJi0e4uS09xmuafub02VPH2IZfFlGJw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.2", - "estree-walker": "^3.0.3", - "magic-string": "^1.2.3" + "@graphql-tools/utils": "^12.0.1", + "@whatwg-node/promise-helpers": "^1.3.2", + "dataloader": "^2.2.3", + "tslib": "^2.8.1" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=20.0.0" }, "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/@vitest/spy": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz", - "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", + "node_modules/@graphql-tools/batch-execute/node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", "dev": true, "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "node_modules/@graphql-tools/code-file-loader": { + "version": "8.1.43", + "resolved": "https://registry.npmjs.org/@graphql-tools/code-file-loader/-/code-file-loader-8.1.43.tgz", + "integrity": "sha512-ScuOr9o0y2TZSj12qC+Q4OMibnoQZTH4VfMB7h7fyYXpy1Ww/Bas08qQML5ifZNos/mvPbZFI3jobTkjohst9Q==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "@graphql-tools/graphql-tag-pluck": "8.3.41", + "@graphql-tools/utils": "^12.1.0", + "globby": "^11.0.3", + "tslib": "^2.4.0", + "unixify": "^1.0.0" }, "engines": { - "node": ">=0.4.0" + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "node_modules/@graphql-tools/delegate": { + "version": "12.2.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-12.2.1.tgz", + "integrity": "sha512-kGCrmSM2VMwLqrYtSWVYJC9AiwWxk4zZCLWmS0rln1uzHbhTuZ359UnQyGrz+P9hemUEBp4DGw3lmZPhtuAL3w==", "dev": true, "license": "MIT", + "dependencies": { + "@graphql-tools/batch-execute": "^10.2.0", + "@graphql-tools/executor": "^2.0.1", + "@graphql-tools/schema": "^10.1.1", + "@graphql-tools/utils": "^12.0.1", + "@repeaterjs/repeater": "^3.0.6", + "@whatwg-node/promise-helpers": "^1.3.2", + "dataloader": "^2.2.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "node_modules/@graphql-tools/delegate/node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "tslib": "^2.6.3" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "engines": { + "node": ">=16.0.0" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "node_modules/@graphql-tools/documents": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/documents/-/documents-1.0.2.tgz", + "integrity": "sha512-l/0r+E0857uSa0zr70qFxzV7SiqGVWbR8KwxLyynENmIeHw6JpChUpYmp2cSUjrUZ9aRMlZ8X01utcsZFeiBFg==", "dev": true, "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, "engines": { - "node": ">=12" + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/babel-plugin-react-compiler": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", - "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "node_modules/@graphql-tools/executor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-2.1.0.tgz", + "integrity": "sha512-RR41E8r1FKrHhZS5YhdcUg04GZ5VP6VGaO2MQKkmEEazXx6qY4kuzZYLs4kq+DBzPdNmsRdVsf7K/uTfi5fhAA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.26.0" + "@graphql-tools/utils": "^12.1.0", + "@graphql-typed-document-node/core": "^3.2.0", + "@repeaterjs/repeater": "^3.1.0", + "@whatwg-node/disposablestack": "^0.1.0", + "@whatwg-node/promise-helpers": "^2.0.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/@graphql-tools/executor-common": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-common/-/executor-common-1.1.0.tgz", + "integrity": "sha512-It8E6ZqeLTZnkdMP4GciWQB8l04Rl0OzDHUo9QYNyxClrgAEqaPbsPuvo2avfkIE5ngE0S1dkA7dtF/7DD/BdA==", "dev": true, "license": "MIT", + "dependencies": { + "@envelop/core": "^5.4.0", + "@graphql-tools/utils": "^12.0.1" + }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.26", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz", - "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==", + "node_modules/@graphql-tools/executor-graphql-ws": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-graphql-ws/-/executor-graphql-ws-3.2.0.tgz", + "integrity": "sha512-Yi84qS3C77BIy0YJw1+nHJ+GAI4bTzHaCWLW1j/9PpHUqmlp32iMxPQOdI7QrXke+pBkIl03aMxyZpmZ/jDidA==", "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" + "license": "MIT", + "dependencies": { + "@graphql-tools/executor-common": "^1.1.0", + "@graphql-tools/utils": "^12.0.1", + "@whatwg-node/disposablestack": "^0.0.6", + "graphql-ws": "^6.0.6", + "isows": "^1.0.7", + "tslib": "^2.8.1", + "ws": "^8.21.0" }, "engines": { - "node": ">=6.0.0" + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "node_modules/@graphql-tools/executor-http": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-http/-/executor-http-3.4.1.tgz", + "integrity": "sha512-OcIfgG/F8ysubB20PCf9SiCcxkwMOd8n9GmnpwpYhU7s/DDF13Ady969dPzRzAsgjNWjSS5DqzvVN8e2OphtiA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" + "@graphql-hive/signal": "^2.0.0", + "@graphql-tools/executor-common": "^1.1.0", + "@graphql-tools/utils": "^12.0.1", + "@repeaterjs/repeater": "^3.0.4", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/fetch": "^0.10.13", + "@whatwg-node/promise-helpers": "^1.3.2", + "meros": "^1.3.2", + "tslib": "^2.8.1" }, "engines": { - "node": "20 || >=22" + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/browserslist": { - "version": "4.29.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", - "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "node_modules/@graphql-tools/executor-http/node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.26", - "caniuse-lite": "^1.0.30001813", - "electron-to-chromium": "^1.5.439", - "node-releases": "^2.0.57", - "update-browserslist-db": "^1.3.3" - }, - "bin": { - "browserslist": "cli.js" + "tslib": "^2.6.3" }, "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": ">=16.0.0" } }, - "node_modules/cacheable": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", - "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "node_modules/@graphql-tools/executor-legacy-ws": { + "version": "1.1.39", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor-legacy-ws/-/executor-legacy-ws-1.1.39.tgz", + "integrity": "sha512-bwGaqKlcjk9GurbMl5XkegWmbDWna+kFbnExyPwEkxDtKijDIZSZ0LC10ya99sBYXu4RQNjQaEjfBzvGMbuKWA==", "dev": true, "license": "MIT", "dependencies": { - "@cacheable/memory": "^2.2.0", - "@cacheable/utils": "^2.5.0", - "hookified": "^1.15.0", - "keyv": "^5.6.0", - "qified": "^0.10.1" + "@graphql-tools/utils": "^12.1.0", + "@types/ws": "^8.18.2", + "isomorphic-ws": "^5.0.0", + "tslib": "^2.4.0", + "ws": "^8.21.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001813", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001813.tgz", - "integrity": "sha512-zfjJo4rM0+fUomGDBW/xcDjhIwz/210DGvip2MAMDZ8KHcRPnOHmEgHPZP0UHlZoxr8fYKVJOqcORhYQcG4FKQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "node_modules/@graphql-tools/executor/node_modules/@whatwg-node/disposablestack": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.1.0.tgz", + "integrity": "sha512-ok0A9+D+mdeb3w1GNCpVr+2gVVLRWKo9Ywm/whNsoWSNfNg3jNxS0/wUF6NSUYbfOtFGu2wZgJnLjasbaQEfvA==", "dev": true, "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^2.0.0", + "tslib": "^2.6.3" + }, "engines": { - "node": ">=18" + "node": ">=22.15.0" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/@graphql-tools/git-loader": { + "version": "8.0.46", + "resolved": "https://registry.npmjs.org/@graphql-tools/git-loader/-/git-loader-8.0.46.tgz", + "integrity": "sha512-T5XtJY6D3hQIXEYu2b1w731YPyPpe4L/61kGK2aoFlmB403fbSykpiIejwpRN/iSdBvkff5fLwMAV6WzCEJGRQ==", "dev": true, "license": "MIT", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "@graphql-tools/graphql-tag-pluck": "8.3.41", + "@graphql-tools/utils": "^12.1.0", + "is-glob": "4.0.3", + "micromatch": "^4.0.8", + "tslib": "^2.4.0", + "unixify": "^1.0.0" }, "engines": { - "node": ">= 8" + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/@graphql-tools/github-loader": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@graphql-tools/github-loader/-/github-loader-9.1.13.tgz", + "integrity": "sha512-KA/u/bKTGSj8JinzAcBFqXXh7ZGLcYUClaTMOBnAO4Gmsoq4HNOjUfPk/XyfNCSdj0yi1T//pVuJBGGaBmooNg==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "@graphql-tools/executor-http": "^3.4.0", + "@graphql-tools/graphql-tag-pluck": "^8.3.41", + "@graphql-tools/utils": "^12.1.0", + "@whatwg-node/fetch": "^0.12.0", + "@whatwg-node/promise-helpers": "^2.0.0", + "sync-fetch": "0.7.1", + "tslib": "^2.4.0" }, "engines": { - "node": ">=6.0" + "node": ">=20.0.0" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "node_modules/@graphql-tools/github-loader/node_modules/@whatwg-node/disposablestack": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.1.0.tgz", + "integrity": "sha512-ok0A9+D+mdeb3w1GNCpVr+2gVVLRWKo9Ywm/whNsoWSNfNg3jNxS0/wUF6NSUYbfOtFGu2wZgJnLjasbaQEfvA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^2.0.0", + "tslib": "^2.6.3" + }, "engines": { - "node": ">=8" + "node": ">=22.15.0" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.442", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.442.tgz", - "integrity": "sha512-najZYZ3+ZpjN1z3VOsrBiv5ej18vQgfV2lvEmxWzfmKrk4bdm3Owseyud4yGU6DfFn9pyunoAHDDyM4KmP78Ew==", - "dev": true, - "license": "ISC" - }, - "node_modules/es-module-lexer": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", - "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "node_modules/@graphql-tools/github-loader/node_modules/@whatwg-node/fetch": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.12.1.tgz", + "integrity": "sha512-MjSXerFVxhWwPXZMNp4b4Q3WZMEIqzinHVWuH5tF2LNGy4hHypqPJCNpaR6bEZbaGdlH+r5dRAmQ8bxowcetjQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@whatwg-node/node-fetch": "^0.9.1", + "urlpattern-polyfill": "^10.0.0" + }, + "engines": { + "node": ">=22.15.0" + } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/@graphql-tools/github-loader/node_modules/@whatwg-node/node-fetch": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.9.1.tgz", + "integrity": "sha512-LX/1X2QEHupCz6YXRjefZRoOsycCGOIJdXtQPVVxhzPQ/CpWpWFaXl53ShqLystvLZSvtUDKLhCA/yLveHA/LA==", "dev": true, "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.1.1", + "@whatwg-node/disposablestack": "^0.1.0", + "@whatwg-node/promise-helpers": "^2.0.0", + "tslib": "^2.6.3" + }, "engines": { - "node": ">=6" + "node": ">=22.15.0" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/@graphql-tools/graphql-file-loader": { + "version": "8.1.24", + "resolved": "https://registry.npmjs.org/@graphql-tools/graphql-file-loader/-/graphql-file-loader-8.1.24.tgz", + "integrity": "sha512-GA52nn3BLliE7TETVuOq0RmDN+2RmVR4g9btP4FV1qu2Fg9XAT+VrZeKsB6m0My9M80l0bmgfv7c2pRKtA69Tw==", "dev": true, "license": "MIT", + "dependencies": { + "@graphql-tools/import": "^7.2.4", + "@graphql-tools/utils": "^12.1.0", + "globby": "^11.0.3", + "tslib": "^2.4.0", + "unixify": "^1.0.0" + }, "engines": { - "node": ">=10" + "node": ">=16.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/eslint": { - "version": "10.11.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", - "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", + "node_modules/@graphql-tools/graphql-tag-pluck": { + "version": "8.3.41", + "resolved": "https://registry.npmjs.org/@graphql-tools/graphql-tag-pluck/-/graphql-tag-pluck-8.3.41.tgz", + "integrity": "sha512-XCka1z/hkUqkAzfxrckte8lkHObvpHuqgoTXxutZc5GFnwLXcpMbDwTuACsPmS9azAl+S5WiQGfyt0USPeSMzw==", "dev": true, "license": "MIT", - "workspaces": [ - "packages/*" - ], "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.7.0", - "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.3", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "11.1.5 || >11.1.6 <12", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" + "@babel/core": "^7.29.7", + "@babel/parser": "^7.29.3", + "@babel/traverse": "^7.26.10", + "@babel/types": "^7.26.10", + "@graphql-tools/utils": "^12.1.0", + "tslib": "^2.4.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": ">=16.0.0" }, "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/eslint-config-prettier": { - "version": "10.1.8", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "node_modules/@graphql-tools/import": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@graphql-tools/import/-/import-7.2.4.tgz", + "integrity": "sha512-QmAfaw+5yGsnVyIYR3GmbMir6/oN64Ac+5792s6IM9yc6oVgALjYmk42K3GBDKcY8QfAE3B8Xl8l9Mtr+qxMCw==", "dev": true, "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" + "dependencies": { + "@graphql-tools/utils": "^12.1.0", + "resolve-from": "5.0.0", + "tslib": "^2.4.0" }, - "funding": { - "url": "https://opencollective.com/eslint-config-prettier" + "engines": { + "node": ">=16.0.0" }, "peerDependencies": { - "eslint": ">=7.0.0" + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/eslint-plugin-react-hooks": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", - "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "node_modules/@graphql-tools/json-file-loader": { + "version": "8.0.38", + "resolved": "https://registry.npmjs.org/@graphql-tools/json-file-loader/-/json-file-loader-8.0.38.tgz", + "integrity": "sha512-P2LnQFA9XuI5C4HalOJAq3lRZ28/uXAXMBoRJ8FdfoA3+V8nSh6KrWcevQEL9M9e6t8nITGNvNiSKgdzarhQZQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" + "@graphql-tools/utils": "^12.1.0", + "globby": "^11.0.3", + "tslib": "^2.4.0", + "unixify": "^1.0.0" }, "engines": { - "node": ">=18" + "node": ">=16.0.0" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz", - "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", + "node_modules/@graphql-tools/load": { + "version": "8.1.21", + "resolved": "https://registry.npmjs.org/@graphql-tools/load/-/load-8.1.21.tgz", + "integrity": "sha512-OiaAP3PqRpmWBexUxHnGMB9PuW5OY7GfTLWj+f0BbSeXckkJmikA8ZrfR0G973AHUz6y9t058ItZEi4F9+Cy9A==", "dev": true, "license": "MIT", + "dependencies": { + "@graphql-tools/schema": "^10.1.5", + "@graphql-tools/utils": "^12.1.0", + "p-limit": "3.1.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, "peerDependencies": { - "eslint": "^9 || ^10" + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", - "dev": true, - "license": "BSD-2-Clause", + "node_modules/@graphql-tools/merge": { + "version": "9.2.8", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.2.8.tgz", + "integrity": "sha512-OsCEmJ9RIMT4LB/1jBbyKHe7pMIlfGhLAQ0FVNaZqteydDUC+rTI9wZ+yaKqxQpPOFFycD+UtMhovg19lPq92Q==", + "license": "MIT", "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" + "@graphql-tools/utils": "^12.1.0", + "tslib": "^2.4.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=16.0.0" }, - "funding": { - "url": "https://opencollective.com/eslint" + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "node_modules/@graphql-tools/optimize": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/optimize/-/optimize-2.0.1.tgz", + "integrity": "sha512-I63H75wg7SD/+iPj+QdvZgvul0dxxf/el18Yiry5zmwKeqRvzcm6l2KUYRNgMR/ydytp0VuHw3XeyGhPWGigAg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=16.0.0" }, - "funding": { - "url": "https://opencollective.com/eslint" + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "node_modules/@graphql-tools/relay-operation-optimizer": { + "version": "7.1.14", + "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.1.14.tgz", + "integrity": "sha512-dpU5rev9s7rl1tj2xXLunp/bjGr57fgw4FxlIj7uA397ka93tLJsXANlH/Paix5I0qbtsqyrgt3xtapJBgmJ4w==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" + "@ardatan/relay-compiler": "^13.0.2", + "@graphql-tools/utils": "^12.1.0", + "tslib": "^2.4.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=16.0.0" }, - "funding": { - "url": "https://opencollective.com/eslint" + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/@graphql-tools/schema": { + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.1.5.tgz", + "integrity": "sha512-F0Onw0ZBREakKGqcgsKhdSCjOXgmntzQWL0QA+/WrwKmnJ7nJRyGKUJavwuVZL2TrXblaIUvQxm4t1h3FuA3cA==", + "license": "MIT", "dependencies": { - "estraverse": "^5.1.0" + "@graphql-tools/merge": "^9.2.8", + "@graphql-tools/utils": "^12.1.0", + "tslib": "^2.4.0" }, "engines": { - "node": ">=0.10" + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "node_modules/@graphql-tools/url-loader": { + "version": "9.1.14", + "resolved": "https://registry.npmjs.org/@graphql-tools/url-loader/-/url-loader-9.1.14.tgz", + "integrity": "sha512-4GYvqG80NJDZ7ZgJiNWb1Kb2SlVB1XC8GeIYm5BiEdezJ/fRHioUN3dMGbryN12mhSIMEdsLLKpK/0Ry73LinQ==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "estraverse": "^5.2.0" + "@graphql-tools/executor-graphql-ws": "^3.2.0", + "@graphql-tools/executor-http": "^3.4.0", + "@graphql-tools/executor-legacy-ws": "^1.1.39", + "@graphql-tools/utils": "^12.1.0", + "@graphql-tools/wrap": "^11.1.1", + "@types/ws": "^8.18.2", + "@whatwg-node/fetch": "^0.12.0", + "@whatwg-node/promise-helpers": "^2.0.0", + "isomorphic-ws": "^5.0.0", + "sync-fetch": "0.7.1", + "tslib": "^2.4.0", + "ws": "^8.21.3" }, "engines": { - "node": ">=4.0" + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/@graphql-tools/url-loader/node_modules/@whatwg-node/disposablestack": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.1.0.tgz", + "integrity": "sha512-ok0A9+D+mdeb3w1GNCpVr+2gVVLRWKo9Ywm/whNsoWSNfNg3jNxS0/wUF6NSUYbfOtFGu2wZgJnLjasbaQEfvA==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^2.0.0", + "tslib": "^2.6.3" + }, "engines": { - "node": ">=4.0" + "node": ">=22.15.0" } }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "node_modules/@graphql-tools/url-loader/node_modules/@whatwg-node/fetch": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.12.1.tgz", + "integrity": "sha512-MjSXerFVxhWwPXZMNp4b4Q3WZMEIqzinHVWuH5tF2LNGy4hHypqPJCNpaR6bEZbaGdlH+r5dRAmQ8bxowcetjQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", + "@whatwg-node/node-fetch": "^0.9.1", + "urlpattern-polyfill": "^10.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=22.15.0" } }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "node_modules/@graphql-tools/url-loader/node_modules/@whatwg-node/node-fetch": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.9.1.tgz", + "integrity": "sha512-LX/1X2QEHupCz6YXRjefZRoOsycCGOIJdXtQPVVxhzPQ/CpWpWFaXl53ShqLystvLZSvtUDKLhCA/yLveHA/LA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.1.1", + "@whatwg-node/disposablestack": "^0.1.0", + "@whatwg-node/promise-helpers": "^2.0.0", + "tslib": "^2.6.3" + }, "engines": { - "node": ">=12.0.0" + "node": ">=22.15.0" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "node_modules/@graphql-tools/utils": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.1.0.tgz", + "integrity": "sha512-+mOuIX7u83/BHG0L9JviMzKid1j35cxOWhLoox47TXHAJ6Tnn30RgrmEQPmk1FhmaQbhJEcwYBDVyieUvZopJw==", "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^2.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, "engines": { - "node": ">=12.0.0" + "node": ">=16.0.0" }, "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/file-entry-cache": { - "version": "11.1.5", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", - "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "node_modules/@graphql-tools/wrap": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-11.2.1.tgz", + "integrity": "sha512-N/3Z4SD9MKhfycX8RgkoBAx2uNBIOCfY2LlrVY5hNNqLEBndgYQ29Lluhye5PO+NeTR5lN5ScCDfGEfTVk58FA==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^6.1.23" + "@graphql-tools/delegate": "^12.2.1", + "@graphql-tools/schema": "^10.1.1", + "@graphql-tools/utils": "^12.0.1", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "node_modules/@graphql-tools/wrap/node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "tslib": "^2.6.3" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=16.0.0" } }, - "node_modules/flat-cache": { - "version": "6.1.23", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", - "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", - "dev": true, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", "license": "MIT", - "dependencies": { - "cacheable": "^2.5.0", - "flatted": "^3.4.2", - "hookified": "^1.15.0" + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, - "node_modules/flatted": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", - "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, - "license": "ISC" + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=18.18.0" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": ">=6.9.0" + "node": ">=18.18.0" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, + "license": "Apache-2.0", "engines": { - "node": ">=10.13.0" + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/globals": { - "version": "17.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", - "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=18.18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/hashery": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", - "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "node_modules/@inquirer/ansi": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.9.tgz", + "integrity": "sha512-0Ywt3S8tqM6TWUcsRlBFQ1x+X3/o8kktX0ZiKAnByYb9GFDknnOhse1PVtfiD2BXPI8ar+BWx9PV9R0lQIcygw==", "dev": true, "license": "MIT", - "dependencies": { - "hookified": "^1.15.0" - }, "engines": { - "node": ">=20" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", - "dev": true, - "license": "MIT" - }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "node_modules/@inquirer/checkbox": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.7.tgz", + "integrity": "sha512-/+QmGE/9bvE/EobaZDnzYe+T+QWoJrnmxwBQNtoi2OVSUR9d1znYsNyEoUrOZvFSxDYttrhzRIpXpaLkN+iIsg==", "dev": true, "license": "MIT", "dependencies": { - "hermes-estree": "0.25.1" + "@inquirer/ansi": "^2.0.9", + "@inquirer/core": "^12.0.5", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/hookified": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", - "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/@inquirer/confirm": { + "version": "6.3.4", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.4.tgz", + "integrity": "sha512-ZwnaZZDa1zxsyVrN32s447ydMnKcw7e8WWNT+R+Qsu0zAn8Rs6yi9d1B80Kob1Ul2cfUi7t8JD2t4/K7uEZpqA==", "dev": true, "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.5", + "@inquirer/type": "4.2.0" + }, "engines": { - "node": ">= 4" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "node_modules/@inquirer/core": { + "version": "12.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.5.tgz", + "integrity": "sha512-c0ZSnO9oVk/IlEGoZK3u8b3IZQeu9I6jUkXJSQnQEVRXsjKvtAq3AGqFL2LgfJhlzMZne08pZHWRWasIcI3Rew==", "dev": true, "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.9", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.2.0", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, "engines": { - "node": ">=0.8.19" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/@inquirer/editor": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.5.tgz", + "integrity": "sha512-6lU3/pPx5z3lHWejFW061MV4qZooBQYv/+EeGhNOPiP/i+hrYsHfxxBnnDGsAfg8YdLUd69YckoeOjtr3QuAUg==", "dev": true, "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.5", + "@inquirer/external-editor": "^3.0.6", + "@inquirer/type": "4.2.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/@inquirer/expand": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.7.tgz", + "integrity": "sha512-eiJ31YFCaB6fLoJCqLIn8S6eqUyME6vwr8KcP5h8cxmA+bV9RRVPO+kywoxb7G63HduDXV6sN/SM7o2R+ahEcQ==", "dev": true, "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "@inquirer/core": "^12.0.5", + "@inquirer/type": "4.2.0" }, "engines": { - "node": ">=0.10.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/@inquirer/external-editor": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.6.tgz", + "integrity": "sha512-rWmw6WG8JZ7LMn/srXEylYGhL6EXXJeROE7/4x4GREh6NgzqJBDbIgpNGsYoLPfobeb4VZtEYzPGnT5EFNYhiQ==", "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" }, "engines": { - "node": ">=6" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/@inquirer/figures": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", + "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", "dev": true, "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, "engines": { - "node": ">=6" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, - "node_modules/keyv": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "node_modules/@inquirer/input": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.8.tgz", + "integrity": "sha512-psOSTgkL8y/OtgJJYbqJ0oISr2QBE0U1Ro/qQdA/EKjeJiwiUTQgQmq8o4ESNzVOWthIeh3YLD7BCUR9dKnR5g==", "dev": true, "license": "MIT", "dependencies": { - "@keyv/serialize": "^1.1.1" + "@inquirer/core": "^12.0.5", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/@inquirer/number": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.5.tgz", + "integrity": "sha512-lEy7LWsdF5qHx5QjHZqabUqM9P7i5uTMCt8GRzqxmwXXKbapKJDA+ucVflVVPAyj4snD5DTAD4L+sct85XOlOQ==", "dev": true, "license": "MIT", "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "@inquirer/core": "^12.0.5", + "@inquirer/type": "4.2.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "node_modules/@inquirer/password": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.2.4.tgz", + "integrity": "sha512-T7ir3fflSSVCYEVqNyMBq+pIMsilQRtbZcD5gxn6Ewsin5TJWBxrGBdXveWtmeXVAeoInfhS+BhBUZaL4sFSDg==", "dev": true, - "license": "MPL-2.0", + "license": "MIT", "dependencies": { - "detect-libc": "^2.0.3" + "@inquirer/ansi": "^2.0.9", + "@inquirer/core": "^12.0.5", + "@inquirer/type": "4.2.0" }, "engines": { - "node": ">= 12.0.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@types/node": ">=18" }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], + "node_modules/@inquirer/prompts": { + "version": "8.7.4", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.4.tgz", + "integrity": "sha512-SAJt+QhY5rOJAIgZBw9TrHWfgrp6wsb9cqRCyqax+Hl6EiFfRXmD9EsIWkST8yq/dQ8H46Is2Sr/JG7Ls/LQ+Q==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.7", + "@inquirer/confirm": "^6.3.4", + "@inquirer/editor": "^5.3.5", + "@inquirer/expand": "^5.1.7", + "@inquirer/input": "^5.1.8", + "@inquirer/number": "^4.2.5", + "@inquirer/password": "^5.2.4", + "@inquirer/rawlist": "^5.3.7", + "@inquirer/search": "^4.3.5", + "@inquirer/select": "^5.2.7" + }, "engines": { - "node": ">= 12.0.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], + "node_modules/@inquirer/rawlist": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.7.tgz", + "integrity": "sha512-XveEe8v3BHj9ec00n+9i8FHjWhXjS6+jCTizYvXCqKUKtgkrq9xid8S5j3mYGW96qfxG3mwNoud3m3v8GDk9Pw==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.5", + "@inquirer/type": "4.2.0" + }, "engines": { - "node": ">= 12.0.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], + "node_modules/@inquirer/search": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.5.tgz", + "integrity": "sha512-kIUecMugEN2YTvTmff+uYz0nAE0f9RFNDVZknfrh264vAQM9P8tnsXiUp7z8XbGuNIBEtFMJhvc9QquawpC46A==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.5", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.2.0" + }, "engines": { - "node": ">= 12.0.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], + "node_modules/@inquirer/select": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.7.tgz", + "integrity": "sha512-pRDWO4dTqSAvFQdO8yclKMtXNDm9K9HmvWB5UZmT3clw2OlOBNXveHzhyFmO/0d0yJMqlPcNITus1Jtpm6t8VQ==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.9", + "@inquirer/core": "^12.0.5", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.2.0" + }, "engines": { - "node": ">= 12.0.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], + "node_modules/@inquirer/type": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.2.0.tgz", + "integrity": "sha512-2rnHX8RPo4glJYqnFc/v3nQeIhDcjyRRyaT7MGcfL/8xv/8K03SXYoA2tX06WVSKywWOE/ncbG3dtuzLjR5TCQ==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", "engines": { - "node": ">= 12.0.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@prisma/adapter-pg": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.1.tgz", + "integrity": "sha512-EP9ruPYHjKP80WZlb5qVaEXKgZjJieBFjAxcBtQnDAHD6+16UN4keVmBh6L/RUpuDQT2FQRbvKNgq5d4Ix+IkQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.10.1", + "@types/pg": "^8.16.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, + "node_modules/@prisma/client": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.1.tgz", + "integrity": "sha512-5tgAbFGh//zVOC6WPNVgziOErJHYkUE+wmqCwbIyq6IrTXUYtv9y2iBFGh/Owo99Mx0+cr0j1FOrxkYBMuyhaQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.10.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.10.1.tgz", + "integrity": "sha512-AzoATVIgmO3lcZfM443+9SI7+cyLMW2+Psw4op8WQHpHvHCX//CKxv8fwhiL0yGWF8DjdQrFewxrZo5McBdB8g==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/config": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.10.1.tgz", + "integrity": "sha512-+0swF5ELUa3P/9cIR26+gpYjjCbGE0qvEBZhV4zMDkYsrtHjZ884MQbOnWFWXj600N0sSJNnHbtBqe12yDngWg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.3.4", + "deepmerge-ts": "8.0.2", + "effect": "3.20.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.10.1.tgz", + "integrity": "sha512-eFCAkGZ13D/qPg48JBlG5IWi9cSBBOC7oG3abDPcAkYioDWlIjh0YJJLK2J7qo5S+xWWD8I6egAN/8KbCaiMCA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/dev": { + "version": "0.24.17", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.17.tgz", + "integrity": "sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.4.3", + "@electric-sql/pglite-socket": "0.1.3", + "@electric-sql/pglite-tools": "0.3.3", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "@prisma/streams-local": "0.1.11", + "find-my-way": "9.7.0", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.4.2", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/dev/node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.10.1.tgz", + "integrity": "sha512-DJ4c6xvVnm3s9ZUyql9nCUicADSqyvH+CjXRCM9L6m5zJxCNmebCElG6X4irICq3rosBwlX+S19Dxq/4IrZk3A==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.1" + } + }, + "node_modules/@prisma/engines": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.1.tgz", + "integrity": "sha512-R6/ghv1Bu36FhCdQLFof1pNtyoWoeDzJLYeTKjgqb4eGtOAJx7ZqJtcm4pzkahLfZ9rYz8AkmsMDwkyiYbXPCQ==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.1", + "@prisma/engines-version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", + "@prisma/fetch-engine": "7.10.1", + "@prisma/get-platform": "7.10.1" + } + }, + "node_modules/@prisma/engines-version": { + "version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8.tgz", + "integrity": "sha512-XjXmwjN2NE340RpXoXv8ZF8aNAvr0FE+7gNfkKeW6vyjXs7F8uxvEbWQoVrhioXDQoIsnNmv7r+wxvzfdEZaWA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.1.tgz", + "integrity": "sha512-iVnghHne5vcaobBjPVCMEjh5r/1UCj394EIGTUclasFRatpR/MQwcM2veLv5kgoFSyYP54nch/lNFCQRYcDxvg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.1" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.10.1.tgz", + "integrity": "sha512-qZezeaExLWG2ptbHOrMxdiWluKHgqEclTdJbvY6pk6+Wg7EiVkeq5U3cXz9ti8nd0bngNvsDgledLHVRg5LF1w==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.1", + "@prisma/engines-version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", + "@prisma/get-platform": "7.10.1" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.1.tgz", + "integrity": "sha512-iVnghHne5vcaobBjPVCMEjh5r/1UCj394EIGTUclasFRatpR/MQwcM2veLv5kgoFSyYP54nch/lNFCQRYcDxvg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.1" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/streams-local": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.11.tgz", + "integrity": "sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.12.0", + "better-result": "^2.7.0", + "env-paths": "^3.0.0", + "proper-lockfile": "^4.1.2" + }, + "engines": { + "bun": ">=1.2.0", + "node": ">=22.0.0" + } + }, + "node_modules/@prisma/streams-local/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@prisma/streams-local/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@prisma/studio-core": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.33.0.tgz", + "integrity": "sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@radix-ui/react-toggle": "1.1.10", + "@visx/curve": "4.0.1-alpha.0", + "@visx/event": "4.0.1-alpha.0", + "@visx/grid": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/responsive": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", + "d3-array": "3.2.4", + "d3-shape": "3.2.0", + "elkjs": "0.11.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0", + "pnpm": "8" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@repeaterjs/repeater": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.1.0.tgz", + "integrity": "sha512-TaoVksZRSx2KWYYpyLQtMQXXeS98VsgZImzW65xmiVgbYhXLk+aEsmzPLirqVuE4/XuUapH2iMtxUzaBNDzdSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/plugin-babel": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/plugin-babel/-/plugin-babel-0.2.4.tgz", + "integrity": "sha512-ygmm2j/mN+aCvstHo0x3IOu4uc6LqXT9SJ3q4kpfG/+Yhfz0B+E47FITcC0FljZADwUFARP6E2kiE+Lc26D8wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^4.0.7" + }, + "engines": { + "node": ">=22.12.0 || ^24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.29.0 || ^8.0.0-rc.1", + "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", + "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", + "rolldown": "^1.0.0-rc.5", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@babel/plugin-transform-runtime": { + "optional": true + }, + "@babel/runtime": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", + "integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz", + "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", + "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz", + "integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.26", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.26.tgz", + "integrity": "sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", + "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@visx/curve": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/curve/-/curve-4.0.1-alpha.0.tgz", + "integrity": "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@visx/vendor": "4.0.0-alpha.0" + } + }, + "node_modules/@visx/event": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/event/-/event-4.0.1-alpha.0.tgz", + "integrity": "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/point": "4.0.1-alpha.0" + } + }, + "node_modules/@visx/grid": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/grid/-/grid-4.0.1-alpha.0.tgz", + "integrity": "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/curve": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/point": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", + "classnames": "^2.3.1" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/group": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/group/-/group-4.0.1-alpha.0.tgz", + "integrity": "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "classnames": "^2.3.1" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/point": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/point/-/point-4.0.1-alpha.0.tgz", + "integrity": "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@visx/responsive": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-4.0.1-alpha.0.tgz", + "integrity": "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.17.13", + "@types/react": "*", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/scale": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-4.0.1-alpha.0.tgz", + "integrity": "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@visx/vendor": "4.0.0-alpha.0" + } + }, + "node_modules/@visx/shape": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/shape/-/shape-4.0.1-alpha.0.tgz", + "integrity": "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.17.13", + "@types/react": "*", + "@visx/curve": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/vendor": "4.0.0-alpha.0", + "classnames": "^2.3.1", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/vendor": { + "version": "4.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-4.0.0-alpha.0.tgz", + "integrity": "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==", + "devOptional": true, + "license": "MIT and ISC", + "dependencies": { + "@types/d3-array": "3.0.3", + "@types/d3-color": "3.1.0", + "@types/d3-delaunay": "6.0.1", + "@types/d3-format": "3.0.1", + "@types/d3-geo": "3.1.0", + "@types/d3-interpolate": "3.0.1", + "@types/d3-path": "3.1.1", + "@types/d3-scale": "4.0.2", + "@types/d3-shape": "3.1.7", + "@types/d3-time": "3.0.0", + "@types/d3-time-format": "2.1.0", + "d3-array": "3.2.1", + "d3-color": "3.1.0", + "d3-delaunay": "6.0.2", + "d3-format": "3.1.0", + "d3-geo": "3.1.0", + "d3-interpolate": "3.0.1", + "d3-path": "3.1.0", + "d3-scale": "4.0.2", + "d3-shape": "3.2.0", + "d3-time": "3.1.0", + "d3-time-format": "4.1.0", + "internmap": "2.0.3" + } + }, + "node_modules/@visx/vendor/node_modules/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz", + "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.2", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz", + "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@whatwg-node/disposablestack": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", + "integrity": "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/disposablestack/node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@whatwg-node/fetch": { + "version": "0.10.13", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.13.tgz", + "integrity": "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/node-fetch": "^0.8.3", + "urlpattern-polyfill": "^10.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/node-fetch": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.8.6.tgz", + "integrity": "sha512-BDMdYFcerLQkwA2RTldxOqRCs6ZQD1S7UgP3pUdGUkcbgTrP/V5ko77ZkCww9DHmC4lpoYuwigGfQYj285gMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.1.1", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/node-fetch/node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, + "node_modules/@wry/caches": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", + "integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/context": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz", + "integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/equality": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz", + "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/trie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz", + "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/async-retry/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, + "node_modules/backend": { + "resolved": "backend", + "link": true + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.26", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz", + "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/better-result": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz", + "integrity": "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001813", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001813.tgz", + "integrity": "sha512-zfjJo4rM0+fUomGDBW/xcDjhIwz/210DGvip2MAMDZ8KHcRPnOHmEgHPZP0UHlZoxr8fYKVJOqcORhYQcG4FKQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/change-case-all": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/change-case-all/-/change-case-all-2.1.0.tgz", + "integrity": "sha512-v6b0WWWkZUMHVuYk82l+WROgkUm4qEN2w5hKRNWtEOYwWqUGoi8C6xH0l1RLF1EoWqDFK6MFclmN3od6ws3/uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "change-case": "^5.2.0", + "sponge-case": "^2.0.2", + "swap-case": "^3.0.2", + "title-case": "^3.0.3" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cn": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cn/-/cn-0.4.0.tgz", + "integrity": "sha512-qOAeUhPwPzBxx6jaeQdo646XIKliKHc2j1ZF7Ngb1o0pJZKH5gq8FkA0aVdUdPFUDXNXyUCXpCtoJISo6dhizg==", + "license": "MIT", + "bin": { + "cn": "bin/cn.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cross-inspect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", + "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz", + "integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/dataloader": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz", + "integrity": "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==", + "license": "MIT" + }, + "node_modules/debounce": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-3.0.0.tgz", + "integrity": "sha512-64byRbF0/AirwbuHqB3/ZpMG9/nckDa6ZA0yd6UnaQNwbbemCOwvz2sL5sjXLHhZHADyiwLm0M5qMhltUUx+TA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge-ts": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz", + "integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==", + "devOptional": true, + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" + } + ], + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dependency-graph": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz", + "integrity": "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-indent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-7.0.2.tgz", + "integrity": "sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "18.0.8", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.8.tgz", + "integrity": "sha512-2KDeoFoK/zRtcnbunCDlLG5VWVMcyqeQc6iK65o231nfNAcuGrKoqUQfJFUtBI+QlShk5HKIHxwebBtUl3rEfA==", + "license": "BSD-2-Clause", + "bin": { + "dotenv": "dist/index.cjs" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/effect": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", + "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.442", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.442.tgz", + "integrity": "sha512-najZYZ3+ZpjN1z3VOsrBiv5ej18vQgfV2lvEmxWzfmKrk4bdm3Owseyud4yGU6DfFn9pyunoAHDDyM4KmP78Ew==", + "dev": true, + "license": "ISC" + }, + "node_modules/elkjs": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz", + "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", + "devOptional": true, + "license": "EPL-2.0" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz", + "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/exsolve": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz", + "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-my-way": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/giget": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.1.tgz", + "integrity": "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==", + "devOptional": true, + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/grammex": { + "version": "3.1.13", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.13.tgz", + "integrity": "sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-config": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/graphql-config/-/graphql-config-5.1.8.tgz", + "integrity": "sha512-7DyT4ctUiNRMAU19IFy8o1zoQCJPjik1/vBwWIZlILGI6O1mHZXd59rUzF81oglNyHtPrNNCWonqfwu58BG66A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/graphql-file-loader": "^8.1.22", + "@graphql-tools/json-file-loader": "^8.0.36", + "@graphql-tools/load": "^8.1.19", + "@graphql-tools/merge": "^9.2.6", + "@graphql-tools/url-loader": "^9.1.12", + "@graphql-tools/utils": "^12.0.3", + "cosmiconfig": "^8.1.0", + "jiti": "^2.0.0", + "minimatch": "^10.0.0", + "string-env-interpolation": "^1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "cosmiconfig-toml-loader": "^1.0.0", + "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + }, + "peerDependenciesMeta": { + "cosmiconfig-toml-loader": { + "optional": true + } + } + }, + "node_modules/graphql-config/node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/graphql-tag": { + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.7.tgz", + "integrity": "sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/graphql-ws": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.4.1.tgz", + "integrity": "sha512-a3MzY5u+tCHHwuyimzxMAHZQ6TY7bOpkQURtKXE5kKIkBdsZ2axlb7sLzMpzGFO4guhws9FyMVB1onyX58pO4w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@fastify/websocket": "^10 || ^11", + "crossws": "~0.3 || ~0.4", + "graphql": "^15.10.1 || ^16 || ^17", + "ws": "^8" + }, + "peerDependenciesMeta": { + "@fastify/websocket": { + "optional": true + }, + "crossws": { + "optional": true + }, + "ws": { + "optional": true + } + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immutable": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-from/-/import-from-4.0.0.tgz", + "integrity": "sha512-P9J71vT5nLlDeV8FHs5nNxaLbrpfAV5cF5srvbZfpwpcJoM/xZR3hiv+q+SAnuSmuGbXMWud063iIMx/V/EWZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-absolute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", + "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-relative": "^1.0.0", + "is-windows": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/is-relative": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", + "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unc-path": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unc-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", + "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "unc-path-regex": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.13", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.13.tgz", + "integrity": "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-to-pretty-yaml": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/json-to-pretty-yaml/-/json-to-pretty-yaml-1.2.2.tgz", + "integrity": "sha512-rvm6hunfCcqegwYaG5T4yKJWxc9FXFgBVrcTZ4XfSVRwa5HA/Xs+vB/Eo9treYYHCeNM0nrSUr82V/M31Urc7A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "remedial": "^1.0.7", + "remove-trailing-spaces": "^1.0.6" + }, + "engines": { + "node": ">= 0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.2.tgz", + "integrity": "sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.2.0", + "eventemitter3": "^5.0.4", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^10.0.0" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lru.min": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", + "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", + "devOptional": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/lucide-react": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.58.0.tgz", + "integrity": "sha512-hXaFNm6mk6QVtBnDGBn1YAs8j2tGUk0A4s/CpclIcczfxeXiW1rdOLVo6zCsoIih9XXsGvDODQerl749a2n7bA==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/meros": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/meros/-/meros-1.3.2.tgz", + "integrity": "sha512-Q3mobPbvEx7XbwhnC1J1r60+5H6EZyNccdzSz0eGexJRwouUtTZxPVRGdqKtxlpD84ScK4+tIGldkqDtCKdI0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=13" + }, + "peerDependencies": { + "@types/node": ">=13" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/mysql2": { + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz", + "integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "remove-trailing-separator": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optimism": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz", + "integrity": "sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==", + "license": "MIT", + "dependencies": { + "@wry/caches": "^1.0.0", + "@wry/context": "^0.7.0", + "@wry/trie": "^0.5.0", + "tslib": "^2.3.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-filepath": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", + "integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-absolute": "^1.0.0", + "map-cache": "^0.2.0", + "path-root": "^0.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-root": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", + "integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-root-regex": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-root-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", + "integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.4.0.tgz", + "integrity": "sha512-07Fys7iVx0X6TcviMOgogM1Lk33ZyqOezMnU79S+kffWqwolG/5W9V8EgQSeYCIfFTYVgssRbnH630Oa2+soBA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.3.1", + "exsolve": "^1.1.3", + "pathe": "^2.0.3" + } + }, + "node_modules/pkg-types/node_modules/confbox": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", + "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, "engines": { - "node": ">= 12.0.0" + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "devOptional": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prisma": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.10.1.tgz", + "integrity": "sha512-GoVeErHGGtsSjsflQ1r2BA3RKTXtxNraQJz8OLkLhoLr7OgCscdx4qw4WMM4HnERYc+a1cq2VnMNbep+vOjNcQ==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "7.10.1", + "@prisma/dev": "0.24.17", + "@prisma/engines": "7.10.1", + "@prisma/studio-core": "0.33.0", + "mysql2": "3.24.4", + "postgres": "3.4.7" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://opencollective.com/express" } }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, - "libc": [ - "musl" + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" + "license": "MIT" + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc9": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.1.0.tgz", + "integrity": "sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.7", + "destr": "^2.0.5" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", "engines": { - "node": ">= 12.0.0" + "node": ">=18" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://opencollective.com/express" } }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "devOptional": true, + "license": "MIT", "engines": { - "node": ">= 12.0.0" + "node": ">= 20.19.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, + "node_modules/remedial": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/remedial/-/remedial-1.0.8.tgz", + "integrity": "sha512-/62tYiOe6DzS5BqVsNpH/nkGlX45C/Sp6V+NtiN6JQNS1Viay7cWkazmRkrQrdFj2eshDe96SIQNIoMxqhzBOg==", "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "(MIT OR Apache-2.0)", "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "*" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], + "node_modules/remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "ISC" + }, + "node_modules/remove-trailing-spaces": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/remove-trailing-spaces/-/remove-trailing-spaces-1.0.9.tgz", + "integrity": "sha512-xzG7w5IRijvIkHIjDk65URsJJ7k4J95wmcArY5PRcmjldIOl7oTvG8+X2Ag690R7SfwiOcHrWZKVc1Pp5WIOzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "devOptional": true, + "license": "MIT", "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=0.10.0" } }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], + "node_modules/reselect": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz", + "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=8" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^5.0.0" + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" } }, - "node_modules/magic-string": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", - "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.6.0" + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" } }, - "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT" + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "devOptional": true, + "license": "Unlicense" + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "dev": true, + "license": "MIT", "dependencies": { - "brace-expansion": "^5.0.8" + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" }, "engines": { - "node": "18 || 20 || >=22" + "node": "^20.19.0 || >=22.12.0" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT" }, - "node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", - "dev": true, + "node_modules/safe-regex2": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.2.tgz", + "integrity": "sha512-tAtCGFPPP02VroDVzp4daGi6bC7jWG+41D47GKf18FeH/ubvKQHT9VmhAkvtjNOmJwqtApzWeUpEhkyEYULvmw==", + "devOptional": true, "funding": [ { "type": "github", - "url": "https://github.com/sponsors/ai" + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" } ], "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" + "dependencies": { + "ret": "~0.5.0" }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "bin": { + "safe-regex2": "bin/safe-regex2.js" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/node-releases": { - "version": "2.0.57", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", - "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, "engines": { - "node": ">=18" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/obug": { + "node_modules/serve-static": { "version": "2.2.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", - "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, "engines": { - "node": ">=12.20.0" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "license": "MIT", "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 0.4" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", "dependencies": { - "yocto-queue": "^0.1.0" + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" }, "engines": { - "node": ">=10" + "node": ">= 0.10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "node_modules/shadcn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-1.0.0.tgz", + "integrity": "sha512-kCxBIBiPS83WxrWkOQHamWpr9XlLtOtOlJM6QX90h9A5xZCBMhxu4ibcNT2ZnzZLdexkYbQrnijfPKdOsZxOpA==", "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "devOptional": true, "license": "MIT", "dependencies": { - "p-limit": "^3.0.2" + "shebang-regex": "^3.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/shell-quote": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/picocolors": { + "node_modules/side-channel": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, "engines": { - "node": ">=12" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { - "nanoid": "^3.3.18", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, "engines": { - "node": ">= 0.8.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/prettier": { - "version": "3.9.9", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", - "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", - "dev": true, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "devOptional": true, + "license": "ISC", "engines": { "node": ">=14" }, "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/qified": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", - "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", "dev": true, "license": "MIT", "dependencies": { - "hookified": "^2.1.1" + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" }, "engines": { "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/qified/node_modules/hookified": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", - "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sponge-case": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/sponge-case/-/sponge-case-2.0.3.tgz", + "integrity": "sha512-i4h9ZGRfxV6Xw3mpZSFOfbXjf0cQcYmssGWutgNIfFZ2VM+YIWfD71N/kjjwK6X/AAHzBr+rciEcn/L34S8TGw==", "dev": true, "license": "MIT" }, - "node_modules/react": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", - "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "node_modules/sql-escaper": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz", + "integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==", + "devOptional": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" } }, - "node_modules/react-dom": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", - "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", - "dependencies": { - "scheduler": "^0.28.0" - }, - "peerDependencies": { - "react": "^19.3.0" + "engines": { + "node": ">= 0.8" } }, - "node_modules/rolldown": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", - "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-env-interpolation": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/string-env-interpolation/-/string-env-interpolation-1.0.1.tgz", + "integrity": "sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.151.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20" }, - "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.11", - "@rolldown/binding-android-arm64": "1.2.11", - "@rolldown/binding-darwin-arm64": "1.2.11", - "@rolldown/binding-darwin-x64": "1.2.11", - "@rolldown/binding-freebsd-x64": "1.2.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", - "@rolldown/binding-linux-arm64-gnu": "1.2.11", - "@rolldown/binding-linux-arm64-musl": "1.2.11", - "@rolldown/binding-linux-ppc64-gnu": "1.2.11", - "@rolldown/binding-linux-s390x-gnu": "1.2.11", - "@rolldown/binding-linux-x64-gnu": "1.2.11", - "@rolldown/binding-linux-x64-musl": "1.2.11", - "@rolldown/binding-openharmony-arm64": "1.2.11", - "@rolldown/binding-win32-arm64-msvc": "1.2.11", - "@rolldown/binding-win32-x64-msvc": "1.2.11" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/scheduler": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", - "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/swap-case": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/swap-case/-/swap-case-3.0.3.tgz", + "integrity": "sha512-6p4op8wE9CQv7uDFzulI6YXUw4lD9n4oQierdbFThEKVWVQcbQcUjdP27W8XE7V4QnWmnq9jueSHceyyQnqQVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/sync-fetch": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/sync-fetch/-/sync-fetch-0.7.1.tgz", + "integrity": "sha512-tTw2sPVgJb/ZO+Uz5Cs1haWg7aL0cw+DvD8mXos+ws5YaelM3IBZ50M717T1+RD5Qq0TdeZ/EI/wVMnAdJA0Eg==", "dev": true, "license": "MIT", "dependencies": { - "shebang-regex": "^3.0.0" + "node-fetch": "^3.3.2", + "timeout-signal": "^2.0.0", + "whatwg-mimetype": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=22.12.0" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/sync-fetch/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=20" } }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/std-env": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", - "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "node_modules/timeout-signal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/timeout-signal/-/timeout-signal-2.0.0.tgz", + "integrity": "sha512-YBGpG4bWsHoPvofT6y/5iqulfXIiIErl5B0LdtHT1mGXDFTAhhRrbUpTvBgYbovr+3cKblya2WAOcpoy90XguA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=16" + } }, "node_modules/tinybench": { "version": "6.2.0", @@ -2807,6 +10493,52 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/title-case": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", + "integrity": "sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -2820,6 +10552,51 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-log": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/ts-log/-/ts-log-3.0.3.tgz", + "integrity": "sha512-4uxVlS/cbNFeSmZbXgBCq+MRkbck1iW7B2Vqd+gziasf//uZP/HFI2IcoHlyYWFgFRl+5G5iqHe4RvY/h/qUxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20", + "npm": ">=10" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -2833,11 +10610,56 @@ "node": ">= 0.8.0" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -2871,13 +10693,44 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/unc-path-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", + "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "dev": true, "license": "MIT" }, + "node_modules/unixify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unixify/-/unixify-1.0.0.tgz", + "integrity": "sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "normalize-path": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", @@ -2919,6 +10772,46 @@ "punycode": "^2.1.0" } }, + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/valibot": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", @@ -3080,11 +10973,30 @@ } } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -3096,6 +11008,27 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz", @@ -3119,6 +11052,70 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.2.tgz", + "integrity": "sha512-6OLRNZqRntVGBm26ghZ5eZhCuWWyOkRCSn8XsOIuq5stpCE/tjLbdozd93naILZig0lmD8ANfRwnhn0DLeXGlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -3126,6 +11123,50 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", + "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -3139,6 +11180,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + }, "node_modules/zod": { "version": "4.6.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", diff --git a/package.json b/package.json index eb11af5..7ddd469 100644 --- a/package.json +++ b/package.json @@ -3,24 +3,51 @@ "private": true, "version": "0.0.0", "type": "module", + "workspaces": [ + "backend" + ], "scripts": { "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", "format": "prettier --write .", "test": "vitest", - "preview": "vite preview" + "preview": "vite preview", + "backend": "npm run dev -w backend", + "db:generate": "npm run prisma:generate -w backend", + "codegen": "npm run codegen -w backend", + "generate": "npm run generate -w backend", + "db:migrate": "npm run prisma:migrate -w backend", + "db:deploy": "npm run prisma:deploy -w backend", + "db:seed": "npm run prisma:seed -w backend", + "db:synthetic": "npm run synthetic -w backend --", + "db:studio": "npm run prisma:studio -w backend" }, "dependencies": { + "@apollo/client": "^4.3.4", + "@apollo/server": "^5.5.1", + "@base-ui/react": "^1.9.0", + "@prisma/client": "^7.10.1", + "class-variance-authority": "^0.7.1", + "cn": "^0.4.0", + "dotenv": "^18.0.8", + "graphql": "^16.14.2", + "lucide-react": "^1.58.0", + "pg": "^8.23.1", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4", + "rxjs": "^7.8.2" }, "devDependencies": { "@babel/core": "^7.29.7", "@eslint/js": "^10.0.1", "@rolldown/plugin-babel": "^0.2.3", + "@tailwindcss/vite": "^4.3.3", "@types/babel__core": "^7.20.5", - "@types/node": "^24.13.3", + "@types/cors": "^2.8.19", + "@types/node": "^24.19.2", + "@types/pg": "^8.23.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", "@vitejs/plugin-react": "^6.1.1", @@ -31,6 +58,11 @@ "eslint-plugin-react-refresh": "^0.5.6", "globals": "^17.12.0", "prettier": "^3.9.9", + "prisma": "^7.10.1", + "shadcn": "^1.0.0", + "tailwindcss": "^4.3.3", + "tsx": "^4.23.15", + "tw-animate-css": "^1.4.0", "typescript": "~6.0.2", "typescript-eslint": "^8.69.0", "vite": "^8.3.0", diff --git a/scripts/extract-cop/README.md b/scripts/extract-cop/README.md new file mode 100644 index 0000000..cc8b8af --- /dev/null +++ b/scripts/extract-cop/README.md @@ -0,0 +1,66 @@ +# Code of Points extraction + +`extract.py` reads the Table of Elements in the FIG Women's Artistic Gymnastics Code of Points 2025–2028 and writes the catalog data to [`data/`](../../data/): the JSON files described in [docs/DATA_MODEL.md](../../docs/DATA_MODEL.md) and one drawing per element and vault. + +For a plain explanation of how the extraction works and what has been checked by hand, see [docs/EXTRACTION.md](../../docs/EXTRACTION.md). + +## The PDF + +The PDF is not in the repository, and it must never be committed. Download it from FIG and save it in this folder with exactly this name: + +``` +scripts/extract-cop/en_1.1.-WAG-Code-of-Points-2025-2028.pdf +``` + +That path is in `.gitignore`. Check with `git status` that the PDF is not listed before you commit. + +## Install + +You need Python 3.9 or newer. The script is tested with Python 3.9 and 3.12. + +```bash +cd scripts/extract-cop +python3 -m venv .venv +source .venv/bin/activate +pip install --upgrade pip +pip install -r requirements.txt +``` + +On Windows, activate the environment with `.venv\Scripts\activate` instead. + +If `python3 --version` shows an older version, create the environment with a newer one, for example `python3.12 -m venv .venv`. + +## Run + +```bash +python extract.py +``` + +A run takes a few minutes. It replaces the JSON files and `images/` in `data/`, writes `review.html` in this folder, and prints the result of the checks: missing references, the number of elements and vaults per group, and everything flagged as uncertain. + +To try the script on a few pages, give their printed page numbers: + +```bash +python extract.py --pages 80 81 +``` + +This also replaces everything in `data/`, so run the script without `--pages` again before you commit. + +## Review page + +Open `review.html` in a browser, for example with `open review.html` on macOS or `start review.html` on Windows. It shows every element and vault with its image, id, code, value, description and name, grouped by apparatus and group. + +- A red border means the entry is flagged, with the reason below it. +- A blue background means the code has several variants. They are shown next to each other. + +The page loads the images from `data/images/`, so leave it in this folder. It is in `.gitignore` and is not committed. + +## Corrections by hand + +Some cells in the PDF cannot be read correctly by rule. Fixes found by checking the review page against the PDF are kept in three lists at the top of `extract.py`, so they are applied on every run: + +- `ONE_DRAWING_PER_DESCRIPTION`: codes where the drawings under one description are a single drawing in several rows, not several variants. +- `VARIANTS_CONFIRMED`: codes that were flagged for having more drawings than judging symbols, and are confirmed to be correct. +- `FIELD_CORRECTIONS`: descriptions and names corrected by hand, per id. Used where a description in the PDF only continues the one above it, or a name is printed between two drawings. + +Do not correct the files in `data/` directly. The next run of the script replaces them. diff --git a/scripts/extract-cop/extract.py b/scripts/extract-cop/extract.py new file mode 100644 index 0000000..54cebb2 --- /dev/null +++ b/scripts/extract-cop/extract.py @@ -0,0 +1,981 @@ +#!/usr/bin/env python3 +"""Extract the Table of Elements from the FIG WAG Code of Points 2025-2028. + +Reads the PDF in this folder and writes the catalog data described in +docs/DATA_MODEL.md to data/*.json, with one drawing per element or vault in +data/images//. It also writes review.html in this folder for checking the +result in a browser. + +Usage: + python extract.py # the whole Table of Elements + python extract.py --pages 80 # only some printed page numbers (for testing) +""" + +import argparse +import html +import json +import re +import shutil +from collections import Counter, defaultdict +from pathlib import Path + +import pdfplumber +from PIL import Image, ImageDraw + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent +PDF_PATH = HERE / "en_1.1.-WAG-Code-of-Points-2025-2028.pdf" +DATA_DIR = ROOT / "data" +IMAGE_DIR = DATA_DIR / "images" +REVIEW_PATH = HERE / "review.html" + +APPARATUS = [ + {"id": "VT", "name": "Vault"}, + {"id": "UB", "name": "Uneven bars"}, + {"id": "BB", "name": "Balance beam"}, + {"id": "FX", "name": "Floor exercise"}, +] +APPARATUS_ORDER = [a["id"] for a in APPARATUS] +SECTION_TITLES = [ + ("VAULT TABLE", "VT"), + ("UNEVEN BARS", "UB"), + ("BALANCE BEAM", "BB"), + ("FLOOR EXERCISE", "FX"), +] + +RENDER_DPI = 288 # 4 px per PDF point +PX_PER_PT = RENDER_DPI / 72 +MAX_IMAGE_WIDTH = 400 +ELEMENT_CODE = re.compile(r"^\d\.\d{3}$") # uneven bars, beam, floor +VAULT_CODE = re.compile(r"^\d\.\d{2}$") +D_VALUE = re.compile(r"^(\d\.\d{2})P?\.?$") # printed as "2.60 P." in the vault tables +LETTERS = "ABCDEFGHIJ" + +# Corrections from checking the result by hand against the PDF. +# Codes where the drawings under one description are a single drawing in +# several rows, not several variants. +ONE_DRAWING_PER_DESCRIPTION = {"BB-1.312", "BB-1.403"} +# Codes with more drawings than judging symbols that are confirmed correct. +VARIANTS_CONFIRMED = {"BB-1.303", "BB-2.101", "BB-2.306", "BB-4.206", "FX-1.204", "FX-2.208"} +# Fields corrected by hand, per id. Used where a description in the PDF only +# continues the one above it ("– piked"), or a name is printed between drawings. +FIELD_CORRECTIONS = { + "UB-3.405-a": {"name": None}, + "UB-3.405-c": { + "description": "Swing fwd and salto bwd piked with ½ turn (180°) to hang on HB (Gienger Salto)", + "name": None, + }, + "BB-1.411-a": { + "description": "Jump, press or swing to hstd – 1/1 turn (360°) in hstd – lower to planche (2 sec.)", + }, + "BB-1.411-b": { + "description": "Jump, press or swing to hstd – 1/1 turn (360°) in hstd – lower to clear pike support with legs together (2 sec.)", + }, + "BB-5.613-b": {"description": "Jump fwd with ½ twist (180°) – piked"}, + # Split descriptions checked by hand. + "VT-2.11-b": {"description": "Handspring fwd on – ½ turn (180°) and tucked salto bwd off"}, + "VT-2.21-b": {"description": "Handspring fwd on – ½ turn (180°) and piked salto bwd off"}, + "VT-5.11-b": { + "description": "Round-off, flic-flac with ½ turn (180°) on – ½ turn (180°) and tucked salto bwd off", + "name": None, + }, + "VT-5.21-b": { + "description": "Round-off, flic-flac with ½ turn (180°) on – ½ turn (180°) and piked salto bwd off", + "name": None, + }, + "UB-1.101-b": {"description": "Glide kip with ½ turn (180°) kip to support on LB"}, + "UB-1.411-b": {"name": None}, + "UB-2.302-a": { + "description": "Hip circle bwd hecht with flight and ½ turn (180°) passing over bar to hang on same bar", + }, + "UB-2.302-b": { + "description": "Clear hip circle bwd hecht with flight and ½ turn (180°) passing over bar to hang on same bar", + }, + "UB-2.404-a": {"description": "Clear hip circle fwd to hstd (Weiler-kip)"}, + "UB-2.404-b": {"description": "Clear hip circle fwd to hstd, with ½ turn (180°) in hstd phase"}, + "UB-3.201-b": {"name": None}, + "UB-3.206-c": { + "description": "Giant circle fwd in reverse, regular or mix grip, with ½ turn (180°) to hstd", + }, + "UB-3.310-b": {"name": None}, + "UB-3.506-b": {"description": "Giant circle fwd in reverse grip with 1½ turn (540°) to hstd"}, + "UB-3.510-b": { + "description": "Giant circle fwd in L grip with 1½ turn (540°) to hstd (any technique accepted)", + }, + "UB-4.508-b": {"name": None}, + "UB-5.404-b": {"name": None}, + "UB-5.501-b": {"name": None}, + "UB-6.508-b": { + "description": "Swing bwd to salto fwd with ½ turn (180°) into salto bwd tucked", + "name": None, + }, + "BB-5.401-b": {"name": None}, +} + +WHITE = (None, 1, 1.0, (1,), (1.0,), (1, 1, 1), (1.0, 1.0, 1.0), (0, 0, 0, 0)) + + +# -------------------------------------------------------------------------- +# Small geometry helpers. A box is (x0, top, x1, bottom) in PDF points. +# -------------------------------------------------------------------------- + + +def box_of(obj): + return (obj["x0"], obj["top"], obj["x1"], obj["bottom"]) + + +def center(box): + return ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2) + + +def inside(point, box): + return box[0] <= point[0] <= box[2] and box[1] <= point[1] <= box[3] + + +def union(boxes): + boxes = list(boxes) + return ( + min(b[0] for b in boxes), + min(b[1] for b in boxes), + max(b[2] for b in boxes), + max(b[3] for b in boxes), + ) + + +def intersects(a, b): + return a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3] + + +def near(a, b, gap_x, gap_y): + return ( + a[0] - gap_x < b[2] + and b[0] - gap_x < a[2] + and a[1] - gap_y < b[3] + and b[1] - gap_y < a[3] + ) + + +def cluster_boxes(boxes, gap_x, gap_y): + """Group boxes that touch or lie within the given gaps of each other.""" + parent = list(range(len(boxes))) + + def find(i): + while parent[i] != i: + parent[i] = parent[parent[i]] + i = parent[i] + return i + + order = sorted(range(len(boxes)), key=lambda i: boxes[i][0]) + for a_pos, a in enumerate(order): + for b in order[a_pos + 1 :]: + if boxes[b][0] - gap_x >= boxes[a][2]: + # Sorted by x0, but a later box can still be wide enough to + # reach, so only stop when even x0 is out of range. + if boxes[b][0] - gap_x >= max(boxes[a][2], boxes[a][0] + 200): + break + continue + if near(boxes[a], boxes[b], gap_x, gap_y): + parent[find(a)] = find(b) + groups = defaultdict(list) + for i in range(len(boxes)): + groups[find(i)].append(boxes[i]) + return [union(g) for g in groups.values()] + + +# -------------------------------------------------------------------------- +# Reading a page +# -------------------------------------------------------------------------- + + +def read_words(page): + """Words with flags for bold and italic, taken from the font of the letters.""" + words = page.extract_words(return_chars=True, x_tolerance=2, y_tolerance=2) + for word in words: + fonts = [c["fontname"] for c in word["chars"]] + word["bold"] = sum("Bold" in f for f in fonts) * 2 > len(fonts) + word["italic"] = sum("Italic" in f for f in fonts) * 2 > len(fonts) + word["size"] = max(c["size"] for c in word["chars"]) + word["box"] = box_of(word) + del word["chars"] + return words + + +def group_lines(words): + """Group words into text lines, top to bottom and left to right.""" + lines = [] + for word in sorted(words, key=lambda w: (w["top"], w["x0"])): + if lines and abs(lines[-1][0]["top"] - word["top"]) < 3: + lines[-1].append(word) + else: + lines.append([word]) + return [sorted(line, key=lambda w: w["x0"]) for line in lines] + + +def join_lines(lines): + """Join text lines into one string, keeping hyphenated words together.""" + text = "" + for line in lines: + part = " ".join(w["text"] for w in line) + if text.endswith("-") and not text.endswith(" -"): + text += part + else: + text = (text + " " + part).strip() + return re.sub(r"\s+", " ", text).strip() + + +def find_sections(pdf): + """Map printed page number -> apparatus id for the Table of Elements. + + Each apparatus starts with a portrait title page ("14.2 UNEVEN BARS – + Elements"), followed by landscape pages with the tables. + """ + pages = {} + current = None + for index, page in enumerate(pdf.pages): + if page.width > page.height: + if current: + pages[index + 1] = current + continue + text = page.extract_text() or "" + if current and "APPENDICES" in text: + break + match = re.match(r"\s*14\.\d\s+(.+)", text) + if match and len(text.split()) < 40: + for title, apparatus in SECTION_TITLES: + if title in match.group(1).upper(): + current = apparatus + return pages + + +def page_heading(words, first_label_top): + """The group heading above the table: (group number, name). + + Elements: '2.000 — CASTS AND CLEAR HIP CIRCLES'. Vault: 'GROUP 1 ― HANDSPRING, ...'. + """ + above = [w for w in words if center(w["box"])[1] < first_label_top and w["size"] >= 9.5 and w["bold"]] + text = " ".join(" ".join(w["text"] for w in line) for line in group_lines(above)) + text = re.sub(r"\b(\d) (ST|ND|RD|TH)\b", r"\1\2", text) # superscript read as its own word + match = re.match(r"^(\d)\.000\s*[—―–-]\s*(.+)$", text) or re.match(r"^GROUP (\d)\s*[—―–-]\s*(.+)$", text) + if not match: + return None + name = match.group(2) + # Some pages print the heading twice, and the column letters follow it. + name = re.split(r"\s+\d\.000\s*[—―–-]", name)[0] + name = re.sub(r"(\s+[A-J](/[A-J])*)+$", "", name) + return int(match.group(1)), name.strip() + + +KEEP_CASE = ["Yamashita", "Yurchenko", "Tsukahara", "LA"] + + +def sentence_case(text): + text = text.strip().lower() + text = text[:1].upper() + text[1:] + for word in KEEP_CASE: + text = re.sub(rf"\b{word}\b", word, text, flags=re.IGNORECASE) + return text + + +def table_bottom(page, words): + """Y position of the bottom border of the table.""" + footer = [w["top"] for w in words if w["text"] == "Page" and w["top"] > page.height - 80] + limit = (min(footer) if footer else page.height - 40) - 5 + borders = [ + r["bottom"] + for r in page.rects + page.lines + if r["x1"] - r["x0"] > 100 and r["bottom"] - r["top"] < 2 and r["bottom"] < limit + ] + return max(borders) if borders else limit - 10 + + +def graphic_objects(page): + """Boxes of everything drawn on the page: vector shapes and bitmaps. + + Table borders are included here. They lie on the cell edges, and + read_cell() only looks just inside the edges. + """ + vectors = [] + for obj in page.curves + page.lines + page.rects: + box = box_of(obj) + width, height = box[2] - box[0], box[3] - box[1] + if obj["object_type"] == "rect" and obj.get("fill") and width > 30 and height > 6: + continue # coloured label, header cell or cell background + vectors.append(box) + bitmaps = [(box_of(im), "Indexed" in str(im.get("colorspace"))) for im in page.images] + return vectors, bitmaps + + +def find_notes(words): + """Boxes around explanatory notes printed inside the table. + + Notes are upright text of several words, such as "(*) Elements in the same + box with an asterisk receive credit only once". Descriptions are italic, + and labels inside drawings are short ("180°"). + """ + upright = [ + w + for w in words + if not w["italic"] and not w["bold"] and not re.fullmatch(r"[\d½¼¾/.,°()–-]+", w["text"]) + ] + boxes = [w["box"] for w in upright] + notes = [] + for region in cluster_boxes(boxes, gap_x=12, gap_y=8): + if sum(inside(center(b), region) for b in boxes) >= 4: + notes.append((region[0] - 14, region[1] - 14, region[2] + 14, region[3] + 14)) + return notes + + +# -------------------------------------------------------------------------- +# Elements on uneven bars, balance beam and floor exercise +# -------------------------------------------------------------------------- + + +def find_code_labels(page, words, pattern): + """The coloured code labels ('2.305') with the x range of their column.""" + labels = [] + for word in words: + if not pattern.match(word["text"]) or word["italic"] or word["size"] > 9.6: + continue + if word["text"].endswith("000"): + continue + mid = center(word["box"]) + bars = [ + box_of(r) + for r in page.rects + if r.get("fill") + and r.get("non_stroking_color") not in WHITE + and inside(mid, box_of(r)) + and r["bottom"] - r["top"] < 25 + and r["x1"] - r["x0"] > 60 + ] + if not bars: + continue # a code mentioned inside a description, not a label + bar = max(bars, key=lambda b: b[2] - b[0]) + labels.append({"code": word["text"], "box": word["box"], "x0": bar[0], "x1": bar[2], "bar": bar}) + return labels + + +def column_letters(words, labels, state): + """Map each label to the letters in its column header (e.g. 'F/G'). + + A few pages have no header row. They use the columns of the page before. + """ + if not labels: + return {} + first_label_top = min(l["box"][1] for l in labels) + headers = [ + (w["x0"], w["x1"], w["text"]) + for w in words + if w["top"] < first_label_top and re.fullmatch(r"[A-J](/[A-J])*", w["text"]) and w["bold"] + ] + if headers: + state["columns"] = headers + result = {} + for label in labels: + letters = [] + for x0, x1, text in state.get("columns", []): + if label["x0"] - 1 <= (x0 + x1) / 2 <= label["x1"] + 1: + letters += text.split("/") + result[label["code"]] = letters + return result + + +def split_description(description, count): + """Split one description over several drawings. + + Returns a list with one description per drawing, or None if it cannot be + split with confidence. + """ + patterns = [ + r"\s*[;,]\s*also\s+|\s+also\s+", + r"\s*[;,]\s*also\s+|\s+also\s+|\s*[;,]\s*or\s+", + ] + for pattern in patterns: + parts = [p.strip(" ;,") for p in re.split(pattern, description) if p.strip(" ;,")] + if len(parts) == count and len(parts[0].split()) >= 3: + base = parts[0] + return [base] + [f"{base}, {part}" for part in parts[1:]] + return None + + +def is_symbol(width, height, indexed): + """Judging symbols are small bitmaps with a two-colour palette. + + Drawings stored as bitmaps are larger (at least 57 pt wide) and in RGB. + """ + if indexed: + return width < 75 and height < 45 + return width < 52 and height < 34 + + +def merge_rows(boxes, dividers): + """Merge the parts of a drawing that are separated by a vertical line. + + Some drawings show the start of an element on the left and two ways of + finishing it on the right, with a thin vertical line between them. Two + drawings side by side without such a line are separate variants. + """ + boxes = list(boxes) + merged = True + while merged: + merged = False + for i in range(len(boxes)): + for j in range(i + 1, len(boxes)): + a, b = boxes[i], boxes[j] + overlap = min(a[3], b[3]) - max(a[1], b[1]) + left, right = min(a[2], b[2]), max(a[0], b[0]) + between = any( + left - 4 <= center(d)[0] <= right + 4 and d[1] < min(a[3], b[3]) and d[3] > max(a[1], b[1]) + for d in dividers + ) + if overlap > 0 and between: + boxes[i] = union([a, b]) + del boxes[j] + merged = True + break + if merged: + break + return boxes + + +def parse_blocks(text_words): + """Group the text of a cell into descriptions, each with an optional name.""" + blocks = [] + previous_bottom = None + for line in group_lines(text_words): + top = min(w["top"] for w in line) + bottom = max(w["bottom"] for w in line) + if previous_bottom is None or top - previous_bottom > 6: + blocks.append({"lines": [], "top": top}) + blocks[-1]["lines"].append(line) + blocks[-1]["bottom"] = bottom + previous_bottom = bottom + + parsed = [] + for block in blocks: + description_lines, name_words = [], [] + for line in block["lines"]: + text = " ".join(w["text"] for w in line) + if all(w["bold"] for w in line) or re.fullmatch(r"\([A-Z][^()]*\)", text): + name_words += line + else: + name_words += [w for w in line if w["bold"]] + description_lines.append([w for w in line if not w["bold"]]) + name = " ".join(w["text"] for w in name_words).strip() + name = re.sub(r"\)\s*,?\s*\(", " / ", name) # "(Plichta) (Alt)" -> "Plichta / Alt" + name = name.strip(" (),") or None + description = join_lines(description_lines) + if not description and name and parsed: + parsed[-1]["name"] = parsed[-1]["name"] or name # a name on its own + parsed[-1]["bottom"] = block["bottom"] + continue + parsed.append({"description": description, "name": name, "top": block["top"], "bottom": block["bottom"]}) + return parsed + + +def read_cell(words, vectors, bitmaps, notes, cell, apparatus): + """Read the text of one table cell and find its drawings and symbols.""" + in_note = lambda box: any(inside(center(box), note) for note in notes) + cell_words = [w for w in words if inside(center(w["box"]), cell) and not in_note(w["box"])] + text_words = [w for w in cell_words if w["italic"]] + label_words = [w for w in cell_words if not w["italic"]] # e.g. '180°' in a drawing + + d_value = None + if apparatus == "VT": + points = [w for w in label_words if D_VALUE.match(w["text"]) or w["text"] in ("P.", "P")] + numbers = [w for w in points if D_VALUE.match(w["text"])] + if len(numbers) == 1: + d_value = float(D_VALUE.match(numbers[0]["text"]).group(1)) + label_words = [w for w in label_words if w not in points] + + # The cell borders are drawn on the cell edges, so look just inside them. + inner = (cell[0] + 1.5, cell[1] + 1.5, cell[2] - 1.5, cell[3] - 1.5) + + def in_cell(box): + if not inside(center(box), inner): + return False + thin = min(box[2] - box[0], box[3] - box[1]) < 2 + return not any(inside(center(box), n) or (thin and intersects(box, n)) for n in notes) + + symbols, shapes = [], [] + for box, indexed in bitmaps: + if not in_cell(box): + continue + width, height = box[2] - box[0], box[3] - box[1] + (symbols if is_symbol(width, height, indexed) else shapes).append(box) + shapes += [box for box in vectors if in_cell(box)] + dividers = [b for b in shapes if b[2] - b[0] < 2 and b[3] - b[1] > 40] + shapes += [w["box"] for w in label_words] + + drawings, fragments = [], [] + for box in cluster_boxes(shapes, gap_x=12, gap_y=4): + width, height = box[2] - box[0], box[3] - box[1] + if width < 14 or height < 14: + continue # stray mark + # A frame around a note: only lines along its edges, with text inside. + members = [m for m in shapes if inside(center(m), box)] + framed = [w for w in text_words if inside(center(w["box"]), box)] + is_edge = lambda m: ( + min(m[2] - m[0], m[3] - m[1]) < 2.5 + or (m[2] - m[0] > 0.8 * width and m[3] - m[1] > 0.8 * height) + ) + if len(framed) >= 2 and all(is_edge(m) for m in members): + text_words = [w for w in text_words if w not in framed] + continue + (fragments if width < 32 and height < 32 else drawings).append(box) + + # A small loose part, such as an arrow with a degree label, belongs to + # the nearest drawing. + for box in fragments: + distance = lambda d: max(box[0] - d[2], d[0] - box[2], 0) + max(box[1] - d[3], d[1] - box[3], 0) + if drawings: + index = min(range(len(drawings)), key=lambda i: distance(drawings[i])) + if distance(drawings[index]) < 30: + drawings[index] = union([drawings[index], box]) + + # A degree label printed just below or above a drawing belongs to it. + for word in label_words: + box = word["box"] + if any(intersects(box, d) for d in drawings): + continue + for index, d in enumerate(drawings): + gap = max(box[1] - d[3], d[1] - box[3]) + if gap < 14 and box[0] < d[2] and d[0] < box[2]: + drawings[index] = union([d, box]) + break + + return { + "cell": cell, + "blocks": parse_blocks(text_words), + "drawings": merge_rows(drawings, dividers), + "symbols": symbols, + "text_boxes": [w["box"] for w in text_words], + "d_value": d_value, + } + + +def move_overflow(cells): + """Give parts of a drawing that stick out into the next cell back to it.""" + for content in cells: + cell = content["cell"] + for part in list(content["drawings"]): + at_edge = part[0] - cell[0] < 5 or cell[2] - part[2] < 5 + if content["blocks"] and not at_edge: + continue + for other in cells: + if other is content: + continue + for index, host in enumerate(other["drawings"]): + overlap = min(part[3], host[3]) - max(part[1], host[1]) + gap = max(part[0], host[0]) - min(part[2], host[2]) + smaller = part[2] - part[0] < 0.5 * (host[2] - host[0]) + if overlap > 0.5 * (part[3] - part[1]) and gap < 15 and smaller: + other["drawings"][index] = union([part, host]) + content["drawings"].remove(part) + break + else: + continue + break + + +def build_variants(content, context): + """Link each drawing in a cell to its description. One variant per drawing.""" + blocks = [{**b, "drawings": []} for b in content["blocks"]] + drawings = sorted(content["drawings"], key=lambda b: (b[1], b[0])) + if not blocks and not drawings: + return [] + if blocks and not drawings: + text = " / ".join(b["description"] for b in blocks) + context["notes"].append((context["code"], text)) + return [] # text without any drawing: a note, not an element + if not blocks: + context["flags"].append((context["code"], "drawing without a description")) + blocks.append({"description": "", "name": None, "top": 0, "bottom": 0, "drawings": []}) + for drawing in drawings: + middle = center(drawing)[1] + above = [b for b in blocks if b["top"] <= middle] + (above[-1] if above else blocks[0])["drawings"].append(drawing) + if context["code"] in ONE_DRAWING_PER_DESCRIPTION: + for block in blocks: + if block["drawings"]: + block["drawings"] = [union(block["drawings"])] + drawings = [d for block in blocks for d in block["drawings"]] + + if len(drawings) > len(content["symbols"]) and context["code"] not in VARIANTS_CONFIRMED: + context["flags"].append((context["code"], "more drawings than judging symbols, check the variants")) + if len(drawings) == 1 and len(content["symbols"]) > 1: + context["flags"].append((context["code"], "one drawing but several judging symbols, may hold several variants")) + + variants = [] + for block in blocks: + boxes = sorted(block["drawings"], key=lambda b: (round(b[1] / 15), b[0])) + if not boxes: + context["flags"].append((context["code"], f"no drawing found for: {block['description'][:60]}")) + variants.append({**block, "drawing": None}) + elif len(boxes) == 1: + variants.append({**block, "drawing": boxes[0]}) + else: + parts = split_description(block["description"], len(boxes)) + if parts: + context["flags"].append((context["code"], "description split over several drawings")) + else: + parts = [block["description"]] * len(boxes) + context["flags"].append( + (context["code"], "could not split the description, all variants have the full text") + ) + for part, box in zip(parts, boxes): + variants.append({**block, "description": part, "drawing": box}) + return variants + + +def extract_page(page, page_number, apparatus, state): + """Read all elements (or vaults) on one page of the tables.""" + is_vault = apparatus == "VT" + words = read_words(page) + labels = find_code_labels(page, words, VAULT_CODE if is_vault else ELEMENT_CODE) + first_label_top = min((l["bar"][1] for l in labels), default=60) + heading = page_heading(words, first_label_top) + if heading: + state["group"] = heading + elif not state["group"]: + state["flags"].append((f"{apparatus} page {page_number}", "no group heading found")) + return [] + number, group_name = state["group"] + group_id = f"{apparatus}-{number}" + state["groups"].setdefault(group_id, group_name) + + letters = {} if is_vault else column_letters(words, labels, state) + bottom = table_bottom(page, words) + vectors, bitmaps = graphic_objects(page) + notes = find_notes(words) + + cells = [] + for label in sorted(labels, key=lambda l: (l["box"][1], l["x0"])): + below = [ + other["bar"][1] + for other in labels + if other is not label + and other["bar"][1] > label["bar"][1] + 2 + and other["x0"] < label["x1"] - 5 + and label["x0"] < other["x1"] - 5 + ] + cell = (label["x0"], label["bar"][3], label["x1"], min(below) if below else bottom) + content = read_cell(words, vectors, bitmaps, notes, cell, apparatus) + content["code"] = label["code"] + cells.append(content) + move_overflow(cells) + + masks = [box for c in cells for box in c["symbols"] + c["text_boxes"]] + image = None + found = [] + for content in cells: + code, cell = content["code"], content["cell"] + context = {"code": f"{apparatus}-{code}", "flags": state["flags"], "notes": state["notes"]} + variants = build_variants(content, context) + if not variants: + continue + + if code[0] != str(number): + state["flags"].append((context["code"], f"code is not in group {number}")) + if is_vault: + value_field = {"dValue": content["d_value"]} + if content["d_value"] is None: + state["flags"].append((context["code"], "no D-value found")) + else: + digit = int(code[2]) + value_field = {"value": digit / 10} + letter = LETTERS[digit - 1] if digit else "?" + allowed = letters.get(code, []) + last_column = any("F" in header for header in allowed) + if not allowed: + state["flags"].append((context["code"], "no column header found above the code")) + elif not (letter in allowed or (last_column and letter >= "F")): + state["flags"].append( + (context["code"], f"value {digit / 10} ({letter}) does not match column {'/'.join(allowed)}") + ) + if cell[3] >= bottom - 1 and any(v["drawing"] is None for v in variants): + state["flags"].append((context["code"], "cell may continue on the next page")) + + for index, variant in enumerate(variants): + item_id = f"{apparatus}-{code}-{chr(ord('a') + index)}" + image_name = None + if variant["drawing"]: + if image is None: + image = page.to_image(resolution=RENDER_DPI).original.convert("L") + image_name = save_drawing(image, variant["drawing"], cell, masks, item_id) + found.append( + { + "id": item_id, + "elementGroupId": group_id, + "code": code, + **value_field, + "description": variant["description"], + "name": variant["name"], + "image": image_name, + "_page": page_number, + } + ) + return found + + +# -------------------------------------------------------------------------- +# Images +# -------------------------------------------------------------------------- + + +def save_drawing(page_image, drawing, cell, masks, element_id): + """Crop the drawing out of the rendered page and save it as WebP.""" + pad = 3 + limit = union([cell, drawing]) # a drawing can be wider than its cell + crop = ( + max(drawing[0] - pad, limit[0] + 1), + max(drawing[1] - pad, limit[1] + 1), + min(drawing[2] + pad, limit[2] - 1), + min(drawing[3] + pad, limit[3] - 1), + ) + to_px = lambda box: tuple(int(round(v * PX_PER_PT)) for v in box) + picture = page_image.crop(to_px(crop)).copy() + + # Paint over judging symbols and text that fall inside the crop. + draw = ImageDraw.Draw(picture) + for box in masks: + if intersects(box, crop): + shifted = (box[0] - crop[0], box[1] - crop[1], box[2] - crop[0], box[3] - crop[1]) + draw.rectangle(to_px(shifted), fill=255) + + # Trim empty margins, then leave a small white border. + ink = picture.point(lambda p: 255 if p < 200 else 0).getbbox() + if ink: + picture = picture.crop(ink) + border = 8 + framed = Image.new("L", (picture.width + 2 * border, picture.height + 2 * border), 255) + framed.paste(picture, (border, border)) + if framed.width > MAX_IMAGE_WIDTH: + height = round(framed.height * MAX_IMAGE_WIDTH / framed.width) + framed = framed.resize((MAX_IMAGE_WIDTH, height), Image.LANCZOS) + + name = f"{element_id}.webp" + framed.convert("RGB").save(image_path(name), "WEBP", quality=75, method=6) + return name + + +def image_path(name): + """Images are stored in one folder per apparatus: data/images/UB/UB-2.305-a.webp.""" + folder = IMAGE_DIR / name.split("-")[0] + folder.mkdir(parents=True, exist_ok=True) + return folder / name + + +# -------------------------------------------------------------------------- +# Output, checks and review page +# -------------------------------------------------------------------------- + + +def sort_key(item): + apparatus, code, letter = item["id"].split("-") + return (APPARATUS_ORDER.index(apparatus), float(code), code, letter) + + +def build_variant_groups(items): + by_code = defaultdict(list) + for item in items: + by_code[item["id"].rsplit("-", 1)[0]].append(item["id"]) + return [{"id": key, "variantIds": ids} for key, ids in by_code.items() if len(ids) > 1] + + +def write_json(name, data): + clean = [{k: v for k, v in row.items() if not k.startswith("_")} for row in data] + path = DATA_DIR / name + text = json.dumps(clean, indent=2, ensure_ascii=False) + + # Write short lists on one line, the way Prettier formats JSON. + def collapse(match): + indent = match.group(1) + items = [item.strip().rstrip(",") for item in match.group(2).strip().split("\n")] + line = f"{indent}{match.group(0).strip().split('[')[0]}[{', '.join(items)}]" + return line if len(line) + 1 <= 80 else match.group(0) + + text = re.sub(r"^( *)\"\w+\": \[\n((?: +\"[^\n]*\n)+) *\]", collapse, text, flags=re.M) + path.write_text(text + "\n", encoding="utf-8") + + +def run_checks(groups, elements, vaults, variant_groups, flags): + print("\n== Checks ==") + problems = [] + apparatus_ids = {a["id"] for a in APPARATUS} + group_ids = {g["id"] for g in groups} + items = {i["id"]: i for i in elements + vaults} + + for group in groups: + if group["apparatusId"] not in apparatus_ids: + problems.append(f"group {group['id']}: unknown apparatus") + for item in items.values(): + if item["elementGroupId"] not in group_ids: + problems.append(f"{item['id']}: unknown element group") + seen = set() + for vg in variant_groups: + if len(vg["variantIds"]) < 2: + problems.append(f"variant group {vg['id']}: fewer than two ids") + members = [items.get(i) for i in vg["variantIds"]] + if not all(members): + problems.append(f"variant group {vg['id']}: unknown id") + continue + if len({m["code"] for m in members}) > 1: + problems.append(f"variant group {vg['id']}: codes differ") + if len({m.get("value", m.get("dValue")) for m in members}) > 1: + problems.append(f"variant group {vg['id']}: values differ") + for variant_id in vg["variantIds"]: + if variant_id in seen: + problems.append(f"{variant_id}: in more than one variant group") + seen.add(variant_id) + if len(items) != len(elements) + len(vaults): + problems.append("duplicate ids") + for item in items.values(): + if not item["image"] or not image_path(item["image"]).exists(): + flags.append((item["id"], "missing image")) + if not item["description"]: + flags.append((item["id"], "missing description")) + elif not re.match(r"[A-Z0-9“\"(½¼¾]", item["description"]): + flags.append((item["id"], "description may be incomplete, it continues the one above")) + + print("References and variant groups:", "OK" if not problems else "") + for problem in problems: + print(" PROBLEM:", problem) + + print("\nElements and vaults per group:") + counts = Counter(i["elementGroupId"] for i in items.values()) + for apparatus in APPARATUS_ORDER: + total = sum(n for g, n in counts.items() if g.startswith(apparatus)) + if total: + detail = ", ".join(f"{g}: {n}" for g, n in sorted(counts.items()) if g.startswith(apparatus)) + print(f" {apparatus}: {total} ({detail})") + print(f" Variant groups: {len(variant_groups)}") + + sizes = [image_path(i["image"]).stat().st_size for i in items.values() if i["image"]] + if sizes: + over = sum(s > 20_000 for s in sizes) + print(f"\nImages: {len(sizes)}, average {sum(sizes) / len(sizes) / 1000:.1f} kB, " + f"largest {max(sizes) / 1000:.1f} kB, {over} over 20 kB") + + print(f"\nFlagged as uncertain: {len(flags)}") + for target, reason in sorted(set(flags)): + print(f" {target}: {reason}") + return problems + + +def write_review(groups, elements, vaults, flags): + flagged = defaultdict(list) + for target, reason in flags: + flagged[target].append(reason) + esc = html.escape + parts = [ + "", + "Code of Points extraction review

Code of Points extraction review

", + f"

{len(elements)} elements, {len(vaults)} vaults, {len(set(flags))} flags. " + "Red border = flagged. Blue background = has variants.

", + ] + items = sorted(elements + vaults, key=sort_key) + codes = Counter(i["id"].rsplit("-", 1)[0] for i in items) + for apparatus in APPARATUS: + own = [i for i in items if i["id"].startswith(apparatus["id"])] + if not own: + continue + parts.append(f"

{esc(apparatus['name'])} ({len(own)})

") + for group in [g for g in groups if g["apparatusId"] == apparatus["id"]]: + members = [i for i in own if i["elementGroupId"] == group["id"]] + parts.append(f"

{esc(group['id'])} – {esc(group['name'])} ({len(members)})

") + for item in members: + code_key = item["id"].rsplit("-", 1)[0] + reasons = flagged.get(item["id"], []) + flagged.get(code_key, []) + classes = "card" + (" variant" if codes[code_key] > 1 else "") + (" flag" if reasons else "") + value = f"value {item['value']}" if "value" in item else f"D-value {item['dValue']}" + picture = ( + f"" + if item["image"] + else "

NO IMAGE

" + ) + parts.append( + f"
{picture}
{esc(item['id'])}
" + f"
code {esc(item['code'])} · {value} · page {item['_page']}
" + f"
{esc(item['description'])}
" + + (f"
({esc(item['name'])})
" if item["name"] else "") + + "".join(f"
⚠ {esc(r)}
" for r in sorted(set(reasons))) + + "
" + ) + parts.append("
") + parts.append("") + REVIEW_PATH.write_text("\n".join(parts), encoding="utf-8") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("--pages", type=int, nargs="*", help="printed page numbers to extract (default: all)") + args = parser.parse_args() + + if IMAGE_DIR.exists(): + shutil.rmtree(IMAGE_DIR) + IMAGE_DIR.mkdir(parents=True) + + elements, vaults, flags = [], [], [] + notes = [] + states = {a: {"group": None, "groups": {}, "flags": flags, "notes": notes} for a in APPARATUS_ORDER} + with pdfplumber.open(PDF_PATH) as pdf: + sections = find_sections(pdf) + print(f"Table of Elements: printed pages {min(sections)}-{max(sections)}") + for page_number, apparatus in sorted(sections.items()): + page = pdf.pages[page_number - 1] + state = states[apparatus] + if args.pages and page_number not in args.pages: + continue + found = extract_page(page, page_number, apparatus, state) + (vaults if apparatus == "VT" else elements).extend(found) + print(f" page {page_number} ({apparatus}): {len(found)}") + + groups = [] + for apparatus in APPARATUS_ORDER: + for group_id, name in sorted(states[apparatus]["groups"].items()): + groups.append( + { + "id": group_id, + "apparatusId": apparatus, + "number": int(group_id.split("-")[1]), + "name": sentence_case(name), + } + ) + elements.sort(key=sort_key) + vaults.sort(key=sort_key) + unused = set(FIELD_CORRECTIONS) + for item in elements + vaults: + if item["id"] in FIELD_CORRECTIONS: + item.update(FIELD_CORRECTIONS[item["id"]]) + unused.discard(item["id"]) + for item_id in sorted(unused): + flags.append((item_id, "has a hand correction, but the id was not found")) + variant_groups = build_variant_groups(elements + vaults) + + write_json("apparatus.json", APPARATUS) + write_json("elementGroups.json", groups) + write_json("elements.json", elements) + write_json("vaults.json", vaults) + write_json("variantGroups.json", variant_groups) + + problems = run_checks(groups, elements, vaults, variant_groups, flags) + print(f"\nText without a drawing, treated as notes and skipped: {len(notes)}") + for code, text in notes: + print(f" {code}: {text[:90]}") + write_review(groups, elements, vaults, flags) + print(f"\nWrote {DATA_DIR.relative_to(ROOT)}/ and {REVIEW_PATH.relative_to(ROOT)}") + return 1 if problems else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/extract-cop/requirements.txt b/scripts/extract-cop/requirements.txt new file mode 100644 index 0000000..34cef38 --- /dev/null +++ b/scripts/extract-cop/requirements.txt @@ -0,0 +1,2 @@ +pdfplumber==0.11.* +Pillow>=10 diff --git a/src/App.tsx b/src/App.tsx index 9ca8e8d..c474b73 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,121 +1,21 @@ -import { useState } from 'react' -import heroImg from './assets/hero.png' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import './App.css' +import { Navigate, Route, Routes } from 'react-router-dom' +import './index.css' +import './styles/variables.css' +import './styles/global.css' +import ElementDetailPage from './components/elements/ElementDetailPage' +import YourGymnastsPage from './components/gymnasts/YourGymnastsPage' function App() { - const [count, setCount] = useState(0) - return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- + + } + /> + } /> + } /> + } /> + ) } diff --git a/src/components/elements/ElementActions.tsx b/src/components/elements/ElementActions.tsx new file mode 100644 index 0000000..0d1d8ac --- /dev/null +++ b/src/components/elements/ElementActions.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react' +import { LockKeyhole, Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import type { ElementDetail } from '@/lib/catalog' + +export default function ElementActions({ + element, +}: { + element: ElementDetail +}) { + const [status, setStatus] = useState('') + + return ( +
+ + +

+ {status} +

+
+ ) +} diff --git a/src/components/elements/ElementDetailHeader.tsx b/src/components/elements/ElementDetailHeader.tsx new file mode 100644 index 0000000..7c35d3d --- /dev/null +++ b/src/components/elements/ElementDetailHeader.tsx @@ -0,0 +1,41 @@ +import { ArrowLeft } from 'lucide-react' +import { useNavigate } from 'react-router-dom' +import type { ElementDetail } from '@/lib/catalog' + +export default function ElementDetailHeader({ + element, +}: { + element: ElementDetail +}) { + const navigate = useNavigate() + + return ( +
+ +
+

+ {element.code} + {element.name && ( + <> + + {element.name} + + )} +

+ {element.description && ( +

+ {element.description} +

+ )} +
+
+ ) +} diff --git a/src/components/elements/ElementDetailPage.tsx b/src/components/elements/ElementDetailPage.tsx new file mode 100644 index 0000000..e1ec464 --- /dev/null +++ b/src/components/elements/ElementDetailPage.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import ElementActions from '@/components/elements/ElementActions' +import ElementDetailHeader from '@/components/elements/ElementDetailHeader' +import ElementDiagram from '@/components/elements/ElementDiagram' +import ElementMetadataCard from '@/components/elements/ElementMetadataCard' +import ElementVariants from '@/components/ui/ElementVariants' +import { getElementDetail, type ElementDetail } from '@/lib/catalog' + +type DetailState = + | { status: 'loading' } + | { status: 'not-found' } + | { status: 'error' } + | { status: 'success'; element: ElementDetail } + +export default function ElementDetailPage() { + const { elementId } = useParams() + const [retryCount, setRetryCount] = useState(0) + const requestKey = `${elementId ?? ''}:${retryCount}` + const [result, setResult] = useState<{ + requestKey: string + state: DetailState + } | null>(null) + + useEffect(() => { + let isCurrent = true + + getElementDetail(elementId ?? '') + .then((element) => { + if (!isCurrent) return + setResult({ + requestKey, + state: element + ? { status: 'success', element } + : { status: 'not-found' }, + }) + }) + .catch(() => { + if (isCurrent) setResult({ requestKey, state: { status: 'error' } }) + }) + + return () => { + isCurrent = false + } + }, [elementId, requestKey]) + + const state = + result?.requestKey === requestKey + ? result.state + : ({ status: 'loading' } as const) + + if (state.status === 'loading') { + return ( +
+

+ Loading element details +

+
+
+
+
+
+
+ ) + } + + if (state.status === 'not-found') { + return ( +
+
+

Element not found

+

+ No catalog item matches this address. +

+ + View a catalog element + +
+
+ ) + } + + if (state.status === 'error') { + return ( +
+
+

Element details unavailable

+

+ The catalog item could not be loaded. Please try again. +

+ +
+
+ ) + } + + const { element } = state + + return ( +
+
+ + + + + +
+
+ ) +} diff --git a/src/components/elements/ElementDiagram.tsx b/src/components/elements/ElementDiagram.tsx new file mode 100644 index 0000000..1f0ca8a --- /dev/null +++ b/src/components/elements/ElementDiagram.tsx @@ -0,0 +1,31 @@ +import { Card, CardContent } from '@/components/ui/card' +import type { ElementDetail } from '@/lib/catalog' + +export default function ElementDiagram({ + element, +}: { + element: ElementDetail +}) { + return ( +
+ + + {element.imageUrl ? ( + {`Illustration + ) : ( +

+ No illustration is available for this element. +

+ )} +
+
+
+ ) +} diff --git a/src/components/elements/ElementMetadataCard.tsx b/src/components/elements/ElementMetadataCard.tsx new file mode 100644 index 0000000..da87a2c --- /dev/null +++ b/src/components/elements/ElementMetadataCard.tsx @@ -0,0 +1,62 @@ +import type { ReactNode } from 'react' +import { Card, CardContent } from '@/components/ui/card' +import DifficultyBadge from '@/components/ui/DifficultyBadge' +import ElementValueBadge from '@/components/ui/ElementValueBadge' +import type { ElementDetail } from '@/lib/catalog' +import { getDifficultyLetter } from '@/lib/difficulty' + +function MetadataItem({ + label, + children, +}: { + label: string + children: ReactNode +}) { + return ( +
+
+ {label} +
+
+ {children} +
+
+ ) +} + +export default function ElementMetadataCard({ + element, +}: { + element: ElementDetail +}) { + const difficulty = element.isVault + ? undefined + : getDifficultyLetter(element.value) + + return ( +
+ + +
+ + {element.isVault || !difficulty ? ( + + ) : ( + + )} + + + {element.apparatusName} + + + {element.elementGroupName} + +
+
+
+
+ ) +} diff --git a/src/components/gymnasts/GymnastList.test.tsx b/src/components/gymnasts/GymnastList.test.tsx new file mode 100644 index 0000000..e3b933b --- /dev/null +++ b/src/components/gymnasts/GymnastList.test.tsx @@ -0,0 +1,34 @@ +import { renderToString } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import GymnastList from "./GymnastList"; + +describe("GymnastList", () => { + it("renders names with initials", () => { + const markup = renderToString( + , + ); + + expect(markup).toContain("Ingrid Haugen"); + expect(markup).toContain("IH"); + expect(markup).toContain("Emma Berg"); + expect(markup).toContain("EB"); + }); + + it("has a heading for the list", () => { + const markup = renderToString(); + + expect(markup).toMatch(/]*>Gymnast list<\/h2>/); + }); + + it("announces the empty list state", () => { + const markup = renderToString(); + + expect(markup).toContain('role="status"'); + expect(markup).toContain("You haven’t added any gymnasts yet."); + }); +}); \ No newline at end of file diff --git a/src/components/gymnasts/GymnastList.tsx b/src/components/gymnasts/GymnastList.tsx new file mode 100644 index 0000000..39f56d0 --- /dev/null +++ b/src/components/gymnasts/GymnastList.tsx @@ -0,0 +1,30 @@ +import GymnastRow from "@/components/gymnasts/GymnastRow"; +import type { GymnastSummary } from "@/lib/gymnasts"; + +export default function GymnastList({ + gymnasts, +}: { + gymnasts: GymnastSummary[]; +}) { + return ( +
+

+ Gymnast list +

+ {gymnasts.length === 0 ? ( +

+ You haven’t added any gymnasts yet. +

+ ) : ( +
    + {gymnasts.map((gymnast) => ( + + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/gymnasts/GymnastRow.tsx b/src/components/gymnasts/GymnastRow.tsx new file mode 100644 index 0000000..2a955f8 --- /dev/null +++ b/src/components/gymnasts/GymnastRow.tsx @@ -0,0 +1,18 @@ +import type { GymnastSummary } from "@/lib/gymnasts"; +import { getGymnastInitials } from "@/lib/gymnasts"; + +export default function GymnastRow({ gymnast }: { gymnast: GymnastSummary }) { + return ( +
  • +
    + +

    {gymnast.name}

    +
    +
  • + ); +} \ No newline at end of file diff --git a/src/components/gymnasts/YourGymnastsPage.tsx b/src/components/gymnasts/YourGymnastsPage.tsx new file mode 100644 index 0000000..f2700a7 --- /dev/null +++ b/src/components/gymnasts/YourGymnastsPage.tsx @@ -0,0 +1,150 @@ +import { useEffect, useRef, useState } from "react"; +import { Plus, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import GymnastList from "@/components/gymnasts/GymnastList"; +import { + exampleGymnasts, + getGymnastNameError, + normalizeGymnastName, + type GymnastSummary, +} from "@/lib/gymnasts"; + +export default function YourGymnastsPage() { + const [gymnasts, setGymnasts] = useState(exampleGymnasts); + const [isFormOpen, setIsFormOpen] = useState(false); + const [name, setName] = useState(""); + const [error, setError] = useState(""); + const [announcement, setAnnouncement] = useState(""); + const nameInput = useRef(null); + const addButton = useRef(null); + const wasFormOpen = useRef(false); + + useEffect(() => { + if (isFormOpen) { + nameInput.current?.focus(); + wasFormOpen.current = true; + } else if (wasFormOpen.current) { + addButton.current?.focus(); + wasFormOpen.current = false; + } + }, [isFormOpen]); + + function closeForm() { + setIsFormOpen(false); + setName(""); + setError(""); + } + + function addGymnast(event: React.FormEvent) { + event.preventDefault(); + const normalizedName = normalizeGymnastName(name); + const nameError = getGymnastNameError(name); + + if (nameError) { + setError(nameError); + nameInput.current?.focus(); + return; + } + + const gymnast: GymnastSummary = { + id: crypto.randomUUID(), + name: normalizedName, + }; + + setGymnasts((currentGymnasts) => [...currentGymnasts, gymnast]); + setAnnouncement(`${normalizedName} added to your gymnasts.`); + closeForm(); + } + + return ( +
    +
    +

    + {announcement} +

    +
    +

    Your gymnasts

    + {!isFormOpen && ( + + )} +
    + + {isFormOpen && ( +
    +
    +

    Add gymnast

    + +
    +
    + + { + setName(event.target.value); + setError(""); + }} + aria-invalid={Boolean(error)} + aria-describedby={error ? "gymnast-name-error" : undefined} + className="h-11 w-full rounded-md border border-(--box-stroke) bg-(--background-color-element) px-3 text-(--text-color-dark) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--text-color-dark)" + /> + {error && ( + + )} +
    +
    + + +
    +
    + )} + + +
    +
    + ); +} \ No newline at end of file diff --git a/src/components/ui/DifficultyBadge.tsx b/src/components/ui/DifficultyBadge.tsx new file mode 100644 index 0000000..d49d82a --- /dev/null +++ b/src/components/ui/DifficultyBadge.tsx @@ -0,0 +1,37 @@ +import { cn } from 'cn' +import { getDifficultyValue } from '@/lib/difficulty' + +export default function DifficultyBadge({ + difficulty, +}: { + difficulty: string | number +}) { + const letter = + typeof difficulty === 'string' ? difficulty.toUpperCase() : undefined + const value = letter ? getDifficultyValue(letter) : undefined + const displayDifficulty = value !== undefined ? letter : difficulty + + return ( + + {/* The hidden labels sit inside the same span as their value, so a + screen reader reads "Difficulty B, Value 0.2" once. No title: it + would be read a second time, and a tooltip only works with a mouse. */} + + Difficulty + {displayDifficulty} + {value !== undefined && ,} + + {value !== undefined && ( + + Value + {value.toFixed(1)} + + )} + + ) +} diff --git a/src/components/ui/ElementValueBadge.tsx b/src/components/ui/ElementValueBadge.tsx new file mode 100644 index 0000000..049834e --- /dev/null +++ b/src/components/ui/ElementValueBadge.tsx @@ -0,0 +1,16 @@ +export default function ElementValueBadge({ + label, + value, +}: { + label: string + value: number +}) { + const formattedValue = value.toFixed(1) + + return ( + + {label} + {formattedValue} + + ) +} diff --git a/src/components/ui/ElementVariants.test.tsx b/src/components/ui/ElementVariants.test.tsx new file mode 100644 index 0000000..2db38d7 --- /dev/null +++ b/src/components/ui/ElementVariants.test.tsx @@ -0,0 +1,42 @@ +import { renderToString } from 'react-dom/server' +import { MemoryRouter } from 'react-router-dom' +import { describe, expect, it } from 'vitest' +import ElementVariants from './ElementVariants' + +describe('ElementVariants', () => { + it('renders navigable variant cards with their values', () => { + const markup = renderToString( + + + , + ) + + expect(markup).toMatch(/Variants of this element.*1.*<\/h2>/) + expect(markup).toContain('href="/elements/UB-1.101-b"') + expect(markup).toContain('Glide kip with half turn') + expect(markup).toContain('Difficulty ') + expect(markup).toContain('Value ') + expect(markup).toContain('0.1') + }) + + it('announces when no other variants exist', () => { + const markup = renderToString( + + + , + ) + + expect(markup).toContain('This element has no other variants.') + }) +}) diff --git a/src/components/ui/ElementVariants.tsx b/src/components/ui/ElementVariants.tsx new file mode 100644 index 0000000..7cb74bf --- /dev/null +++ b/src/components/ui/ElementVariants.tsx @@ -0,0 +1,67 @@ +import { Link } from 'react-router-dom' +import { Card, CardContent, CardTitle } from '@/components/ui/card' +import DifficultyBadge from '@/components/ui/DifficultyBadge' +import ElementValueBadge from '@/components/ui/ElementValueBadge' +import type { CatalogVariant } from '@/lib/catalog' +import { getDifficultyLetter } from '@/lib/difficulty' + +export default function ElementVariants({ + variants, +}: { + variants: CatalogVariant[] +}) { + return ( +
    +

    + Variants of this element ({variants.length}) +

    + {variants.length === 0 ? ( +

    + This element has no other variants. +

    + ) : ( +
      + {variants.map((variant) => { + const difficulty = variant.isVault + ? undefined + : getDifficultyLetter(variant.value) + + return ( +
    • + + + +
      + + {variant.code} + {variant.name && ` · ${variant.name}`} + +

      + {variant.description} +

      +
      + {difficulty ? ( + + ) : ( + + )} +
      +
      + +
    • + ) + })} +
    + )} +
    + ) +} diff --git a/src/components/ui/button.test.tsx b/src/components/ui/button.test.tsx new file mode 100644 index 0000000..ca49705 --- /dev/null +++ b/src/components/ui/button.test.tsx @@ -0,0 +1,27 @@ +import { SearchIcon } from 'lucide-react' +import { renderToString } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { Button } from '@/components/ui/button' + +// Shows that the setup works: the "@/" alias, the shadcn/ui Button and a +// Lucide icon. It renders to a string, so it needs no browser environment. +describe('Button from shadcn/ui', () => { + it('renders a button with the text and a Lucide icon', () => { + const html = renderToString( + , + ) + + expect(html).toContain(' { + const html = renderToString() + + expect(html).toContain('border-border') + }) +}) diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..34b315f --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,57 @@ +import { Button as ButtonPrimitive } from '@base-ui/react/button' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from 'cn' + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/80', + outline: + 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', + ghost: + 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50', + destructive: + 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: + 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', + icon: 'size-8', + 'icon-xs': + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + 'icon-sm': + 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg', + 'icon-lg': 'size-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +function Button({ + className, + variant = 'default', + size = 'default', + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..2674a95 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,106 @@ +import * as React from 'react' +import { cn } from 'cn' + +function Card({ + className, + size = 'default', + ...props +}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) { + return ( +
    img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl', + className, + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
    + ) +} + +type CardTitleProps = React.ComponentProps<'h2'> & { + as: 'h2' | 'h3' | 'h4' +} + +function CardTitle({ as: Heading, className, ...props }: CardTitleProps) { + return ( + + ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<'p'>) { + return ( +

    + ) +} + +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { + return ( +

    + ) +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
    + ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
    + ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/index.css b/src/index.css index 5fb3313..4330872 100644 --- a/src/index.css +++ b/src/index.css @@ -1,18 +1,36 @@ +/* + * Tailwind CSS 4. Tailwind puts its styles in CSS layers, so the unlayered CSS + * below wins over Tailwind's utility classes. See "Tailwind CSS" in the README. + */ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +/* + * shadcn/ui setup. The CLI added the imports above, the tokens in :root, the + * @theme block, the dark tokens and the base layer. We changed three things: + * - Dark mode follows our design: the attribute data-theme="dark" on the + * page, as in styles/variables.css, and not a .dark class. + * - Typography uses the existing Faustina/Inter tokens from variables.css. + * - The tokens --border and --accent are shared with the Vite starter styles + * below. shadcn's values win. The starter styles go when the page is built. + */ +@custom-variant dark (&:is([data-theme='dark'] *)); + :root { --text: #6b6375; --text-h: #08060d; --bg: #fff; - --border: #e5e4e7; + --border: oklch(0.922 0 0); --code-bg: #f4f3ec; - --accent: #aa3bff; + --accent: oklch(0.97 0 0); --accent-bg: rgba(170, 59, 255, 0.1); --accent-border: rgba(170, 59, 255, 0.5); --social-bg: rgba(244, 243, 236, 0.5); --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + --sans: var(--font-family-text), sans-serif; + --heading: var(--font-family-title); --mono: ui-monospace, Consolas, monospace; font: 18px/145% var(--sans); @@ -28,6 +46,66 @@ @media (max-width: 1024px) { font-size: 16px; } + + --background: oklch(1 0 0); + + --foreground: oklch(0.145 0 0); + + --card: oklch(1 0 0); + + --card-foreground: oklch(0.145 0 0); + + --popover: oklch(1 0 0); + + --popover-foreground: oklch(0.145 0 0); + + --primary: oklch(0.205 0 0); + + --primary-foreground: oklch(0.985 0 0); + + --secondary: oklch(0.97 0 0); + + --secondary-foreground: oklch(0.205 0 0); + + --muted: oklch(0.97 0 0); + + --muted-foreground: oklch(0.556 0 0); + + --accent-foreground: oklch(0.205 0 0); + + --destructive: oklch(0.577 0.245 27.325); + + --input: oklch(0.922 0 0); + + --ring: oklch(0.708 0 0); + + --chart-1: oklch(0.87 0 0); + + --chart-2: oklch(0.556 0 0); + + --chart-3: oklch(0.439 0 0); + + --chart-4: oklch(0.371 0 0); + + --chart-5: oklch(0.269 0 0); + + --radius: 0.625rem; + + --sidebar: oklch(0.985 0 0); + + --sidebar-foreground: oklch(0.145 0 0); + + --sidebar-primary: oklch(0.205 0 0); + + --sidebar-primary-foreground: oklch(0.985 0 0); + + --sidebar-accent: oklch(0.97 0 0); + + --sidebar-accent-foreground: oklch(0.205 0 0); + + --sidebar-border: oklch(0.922 0 0); + + --sidebar-ring: oklch(0.708 0 0); } @media (prefers-color-scheme: dark) { @@ -70,7 +148,7 @@ h1, h2 { font-family: var(--heading); font-weight: 500; - color: var(--text-h); + color: var(--text-color); } h1 { @@ -109,3 +187,124 @@ code { padding: 4px 8px; background: var(--code-bg); } + +@theme inline { + --font-heading: var(--font-family-title); + --font-sans: var(--font-family-text), sans-serif; + --font-title: var(--font-family-title); + --font-text: var(--font-family-text), sans-serif; + --text-page-title: var(--font-size-page-title); + --text-element-title: var(--font-size-element-title); + --text-row-title: var(--font-size-row-title); + --radius-corner: var(--corner-radius); + --shadow-box: var(--box-shadow); + --spacing-xs: var(--space-xs); + --spacing-sm: var(--space-sm); + --spacing-md: var(--space-md); + --spacing-lg: var(--space-lg); + --spacing-xl: var(--space-xl); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-primary-color: var(--primary-color); + --color-secondary-color: var(--secondary-color); + --color-accent-color: var(--accent-color); + --color-background-color: var(--background-color); + --color-background-color-element: var(--background-color-element); + --color-text-color: var(--text-color); + --color-text-color-dark: var(--text-color-dark); + --color-text-color-light: var(--text-color-light); + --color-box-color: var(--box-color); + --color-box-stroke: var(--box-stroke); + --color-border-color: var(--border-color); + --color-header-background: var(--header-background); + --color-element-background: var(--element-background); + --color-surface-selected: var(--surface-selected); + --color-text-color-primary: var(--text-color-primary); + --color-text-color-secondary: var(--text-color-secondary); + --color-text-color-on-dark: var(--text-color-on-dark); + --color-border-subtle: var(--border-subtle); + --color-badge-d-value-bg: var(--badge-d-value-bg); + --color-badge-d-value-text: var(--badge-d-value-text); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +[data-theme='dark'] { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} diff --git a/src/lib/apollo/api.test.ts b/src/lib/apollo/api.test.ts new file mode 100644 index 0000000..06ecdcf --- /dev/null +++ b/src/lib/apollo/api.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' +import { ApiError, REQUEST_TIMEOUT_MS, refreshRequest } from './api' + +const answer = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status }) + +describe('the requests to the API', () => { + it('give every request a timeout, so a slow server does not leave a white page', async () => { + const fetchFn = vi + .fn() + .mockResolvedValue( + answer({ data: { refreshSession: { accessToken: 'a' } } }), + ) + const timeout = vi.spyOn(AbortSignal, 'timeout') + + await refreshRequest('/graphql', 'refresh-1', fetchFn) + + expect(timeout).toHaveBeenCalledWith(REQUEST_TIMEOUT_MS) + expect(fetchFn.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal) + timeout.mockRestore() + }) + + it('pass on the code of an error from the server', async () => { + const fetchFn = vi.fn().mockImplementation(async () => + answer({ + errors: [ + { message: 'Log in again.', extensions: { code: 'UNAUTHENTICATED' } }, + ], + }), + ) + await expect( + refreshRequest('/graphql', 'refresh-1', fetchFn), + ).rejects.toMatchObject({ code: 'UNAUTHENTICATED' }) + await expect( + refreshRequest('/graphql', 'refresh-1', fetchFn), + ).rejects.toBeInstanceOf(ApiError) + }) +}) diff --git a/src/lib/apollo/api.ts b/src/lib/apollo/api.ts new file mode 100644 index 0000000..fb0620e --- /dev/null +++ b/src/lib/apollo/api.ts @@ -0,0 +1,102 @@ +// Plain requests to the API for the login, the refresh and the logout. They do +// not go through Apollo Client: the authorization link calls the refresh, so +// the refresh itself must not run through the link. + +export type AuthPayload = { + accessToken: string + refreshToken: string + coach: { id: string; name: string; email: string } +} + +/** The server answered with an error. `code` is `extensions.code` (see docs/API.md). */ +export class ApiError extends Error { + readonly code: string + + constructor(code: string, message: string) { + super(message) + this.name = 'ApiError' + this.code = code + } +} + +type Fetch = typeof fetch + +/** + * How long the login, refresh and logout requests may take. Without a limit a + * slow server leaves a white page, because the app waits for the refresh + * before it is shown (see main.tsx). A timeout is not an answer from the + * server, so the session keeps the refresh token and a later try can work. + */ +export const REQUEST_TIMEOUT_MS = 5000 + +async function call( + url: string, + query: string, + variables: Record, + fetchFn: Fetch, +): Promise { + const response = await fetchFn(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query, variables }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + if (!response.ok) { + throw new ApiError( + 'NETWORK_ERROR', + `The server answered ${response.status}.`, + ) + } + const body = (await response.json()) as { + data?: T + errors?: { message: string; extensions?: { code?: string } }[] + } + const first = body.errors?.[0] + if (first) { + throw new ApiError(first.extensions?.code ?? 'UNKNOWN', first.message) + } + return body.data as T +} + +const PAYLOAD = 'accessToken refreshToken coach { id name email }' + +export async function loginRequest( + url: string, + googleToken: string, + fetchFn: Fetch = fetch, +): Promise { + const data = await call<{ login: AuthPayload }>( + url, + `mutation Login($googleToken: String!) { login(googleToken: $googleToken) { ${PAYLOAD} } }`, + { googleToken }, + fetchFn, + ) + return data.login +} + +export async function refreshRequest( + url: string, + refreshToken: string, + fetchFn: Fetch = fetch, +): Promise { + const data = await call<{ refreshSession: AuthPayload }>( + url, + `mutation RefreshSession($refreshToken: String!) { refreshSession(refreshToken: $refreshToken) { ${PAYLOAD} } }`, + { refreshToken }, + fetchFn, + ) + return data.refreshSession +} + +export async function logoutRequest( + url: string, + refreshToken: string, + fetchFn: Fetch = fetch, +): Promise { + await call<{ logout: boolean }>( + url, + 'mutation Logout($refreshToken: String!) { logout(refreshToken: $refreshToken) }', + { refreshToken }, + fetchFn, + ) +} diff --git a/src/lib/apollo/authLink.test.ts b/src/lib/apollo/authLink.test.ts new file mode 100644 index 0000000..276871a --- /dev/null +++ b/src/lib/apollo/authLink.test.ts @@ -0,0 +1,192 @@ +import { ApolloLink } from '@apollo/client/link' +import { gql } from '@apollo/client' +import { firstValueFrom, of } from 'rxjs' +import { type Mock, describe, expect, it, vi } from 'vitest' +import type { AuthPayload } from './api' +import { createAuthLink } from './authLink' +import { createSession, type TokenStorage } from './session' + +const ME = gql` + query Me { + me { + id + } + } +` +const LOGIN = gql` + mutation Login($googleToken: String!) { + login(googleToken: $googleToken) { + accessToken + } + } +` + +const unauthenticated = { + data: null, + errors: [ + { + message: 'You must be logged in.', + extensions: { code: 'UNAUTHENTICATED' }, + }, + ], +} +const ok = { data: { me: { id: 'c1' } } } + +const payload = (n: number): AuthPayload => ({ + accessToken: `access-${n}`, + refreshToken: `refresh-${n}`, + coach: { id: 'c1', name: 'Anna', email: 'anna@example.com' }, +}) + +function memoryStorage(): TokenStorage { + const values = new Map() + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => void values.set(key, value), + removeItem: (key) => void values.delete(key), + } +} + +/** + * A server that only accepts the access token "access-2", and records the + * Authorization header of every request. + */ +type RefreshRequest = Mock<(refreshToken: string) => Promise> + +function setup(options: { refreshRequest?: RefreshRequest } = {}) { + const seen: (string | undefined)[] = [] + const server = new ApolloLink((operation) => { + const headers = operation.getContext()['headers'] as + Record | undefined + seen.push(headers?.['authorization']) + return of( + headers?.['authorization'] === 'Bearer access-2' ? ok : unauthenticated, + ) + }) + const refreshRequest = + options.refreshRequest ?? + vi + .fn<(refreshToken: string) => Promise>() + .mockResolvedValue(payload(2)) + const session = createSession({ storage: memoryStorage(), refreshRequest }) + const link = ApolloLink.from([createAuthLink(session), server]) + const run = (query = ME) => + firstValueFrom(ApolloLink.execute(link, { query }, { client: {} as never })) + return { session, refreshRequest, seen, run } +} + +describe('the authorization link', () => { + it('adds the access token to the request, and nothing when there is none', async () => { + const { session, seen, run } = setup() + await run() + session.setTokens(payload(2)) + await run() + expect(seen).toEqual([undefined, 'Bearer access-2']) + }) + + it('refreshes before the first request when there is a refresh token but no access token', async () => { + const storage = memoryStorage() + storage.setItem('cop.refreshToken', 'refresh-1') + const refreshRequest = vi + .fn<(refreshToken: string) => Promise>() + .mockResolvedValue(payload(2)) + const seen: (string | undefined)[] = [] + const server = new ApolloLink((operation) => { + const headers = operation.getContext()['headers'] as + Record | undefined + seen.push(headers?.['authorization']) + return of(ok) + }) + const session = createSession({ storage, refreshRequest }) + const link = ApolloLink.from([createAuthLink(session), server]) + + await firstValueFrom( + ApolloLink.execute(link, { query: ME }, { client: {} as never }), + ) + expect(refreshRequest).toHaveBeenCalledTimes(1) + expect(seen).toEqual(['Bearer access-2']) // no failed first try + }) + + it('refreshes once and repeats the request when the access token has expired', async () => { + const { session, refreshRequest, seen, run } = setup() + session.setTokens(payload(1)) // access-1 is expired for this server + + const result = await run() + expect(result).toEqual(ok) + expect(refreshRequest).toHaveBeenCalledTimes(1) + expect(refreshRequest).toHaveBeenCalledWith('refresh-1') + expect(seen).toEqual(['Bearer access-1', 'Bearer access-2']) + }) + + it('refreshes once when several requests expire at the same moment', async () => { + const { session, refreshRequest, run } = setup() + session.setTokens(payload(1)) + + const results = await Promise.all([run(), run(), run()]) + expect(results).toEqual([ok, ok, ok]) + expect(refreshRequest).toHaveBeenCalledTimes(1) + }) + + it('passes on the error, and forgets the login, when the refresh token is refused', async () => { + const { ApiError } = await import('./api') + const refreshRequest = vi + .fn<(refreshToken: string) => Promise>() + .mockRejectedValue(new ApiError('UNAUTHENTICATED', 'Log in again.')) + const { session, seen, run } = setup({ refreshRequest }) + session.setTokens(payload(1)) + + expect(await run()).toEqual(unauthenticated) + expect(seen).toHaveLength(1) // not repeated + expect(session.hasRefreshToken()).toBe(false) + }) + + it('does not try more than once', async () => { + // The refresh gives a token that the server also refuses. + const { session, refreshRequest, seen, run } = setup({ + refreshRequest: vi + .fn<(refreshToken: string) => Promise>() + .mockResolvedValue(payload(3)), + }) + session.setTokens(payload(1)) + + expect(await run()).toEqual(unauthenticated) + expect(refreshRequest).toHaveBeenCalledTimes(1) + expect(seen).toEqual(['Bearer access-1', 'Bearer access-3']) + }) + + it('does not refresh without a refresh token', async () => { + const { refreshRequest, seen, run } = setup() + expect(await run()).toEqual(unauthenticated) + expect(refreshRequest).not.toHaveBeenCalled() + expect(seen).toHaveLength(1) + }) + + it('does not refresh for the login operations themselves', async () => { + const { session, refreshRequest, run } = setup() + session.setTokens(payload(1)) + await run(LOGIN) + expect(refreshRequest).not.toHaveBeenCalled() + }) + + it('leaves other errors alone', async () => { + const error = { + data: null, + errors: [ + { message: 'Bad input', extensions: { code: 'BAD_USER_INPUT' } }, + ], + } + const session = createSession({ + storage: memoryStorage(), + refreshRequest: vi.fn(), + }) + session.setTokens(payload(1)) + const link = ApolloLink.from([ + createAuthLink(session), + new ApolloLink(() => of(error)), + ]) + const result = await firstValueFrom( + ApolloLink.execute(link, { query: ME }, { client: {} as never }), + ) + expect(result).toEqual(error) + }) +}) diff --git a/src/lib/apollo/authLink.ts b/src/lib/apollo/authLink.ts new file mode 100644 index 0000000..92eb02e --- /dev/null +++ b/src/lib/apollo/authLink.ts @@ -0,0 +1,60 @@ +// The authorization link: it adds `Authorization: Bearer ` to +// every request, and renews the session first when the access token has +// expired or is about to (an expired token makes `me` answer null and not an +// error, so waiting for an error would show the coach as logged out). When the +// server answers UNAUTHENTICATED because the access +// token has expired, it refreshes the session once and repeats the request. +// If the refresh fails, the answer of the first try is passed on, and the page +// shows that the coach must log in. + +import { ApolloLink } from '@apollo/client/link' +import { from, mergeMap, of } from 'rxjs' +import type { Session } from './session' + +/** The operations of the login itself must never start a refresh. */ +const AUTH_OPERATIONS = new Set(['Login', 'RefreshSession', 'Logout']) + +const isUnauthenticated = (result: ApolloLink.Result): boolean => + 'errors' in result && + (result.errors ?? []).some( + (error) => error.extensions?.['code'] === 'UNAUTHENTICATED', + ) + +export function createAuthLink(session: Session): ApolloLink { + return new ApolloLink((operation, forward) => { + const isAuthOperation = AUTH_OPERATIONS.has(operation.operationName ?? '') + const addToken = () => { + const token = session.getAccessToken() + if (!token) return + operation.setContext( + ({ headers }: { headers?: Record }) => ({ + headers: { ...headers, authorization: `Bearer ${token}` }, + }), + ) + } + const send = () => { + addToken() + return forward(operation).pipe( + mergeMap((result) => { + if ( + !isUnauthenticated(result) || + isAuthOperation || + !session.hasRefreshToken() + ) { + return of(result) + } + return from(session.refresh()).pipe( + mergeMap((refreshed) => { + if (!refreshed) return of(result) + addToken() + return forward(operation) // the second try is not checked again + }), + ) + }), + ) + } + + if (isAuthOperation) return send() + return from(session.ensureFresh()).pipe(mergeMap(send)) + }) +} diff --git a/src/lib/apollo/cache.test.ts b/src/lib/apollo/cache.test.ts new file mode 100644 index 0000000..b979b79 --- /dev/null +++ b/src/lib/apollo/cache.test.ts @@ -0,0 +1,269 @@ +import { gql } from '@apollo/client' +import { describe, expect, it } from 'vitest' +import { createCache } from './cache' + +const SEARCH = gql` + query Search( + $search: String + $filter: CatalogFilter + $first: Int + $after: String + ) { + searchCatalog( + search: $search + filter: $filter + first: $first + after: $after + ) { + totalCount + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + __typename + id + description + } + } + } + } +` +// The same query without totalCount: it is only asked for with the first page. +const SEARCH_NEXT = gql` + query Search( + $search: String + $filter: CatalogFilter + $first: Int + $after: String + ) { + searchCatalog( + search: $search + filter: $filter + first: $first + after: $after + ) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + __typename + id + description + } + } + } + } +` + +const edge = (n: number, type: 'Element' | 'Vault' = 'Element') => ({ + __typename: 'CatalogEdge', + cursor: `c${n}`, + node: { __typename: type, id: `UB-1.${n}-a`, description: `Element ${n}` }, +}) +const connection = ( + edges: ReturnType[], + hasNextPage: boolean, + totalCount?: number, +) => ({ + searchCatalog: { + __typename: 'CatalogConnection', + ...(totalCount === undefined ? {} : { totalCount }), + pageInfo: { + __typename: 'PageInfo', + hasNextPage, + endCursor: edges.at(-1)!.cursor, + }, + edges, + }, +}) + +type Read = { + searchCatalog: { + totalCount: number + edges: { cursor: string }[] + pageInfo: { hasNextPage: boolean; endCursor: string } + } +} | null + +describe('the cache for searchCatalog', () => { + it('puts the next page after the first, so "load more" gives one list', () => { + const cache = createCache() + const variables = { search: 'kip', first: 2 } + cache.writeQuery({ + query: SEARCH, + variables, + data: connection([edge(1), edge(2)], true, 5), + }) + cache.writeQuery({ + query: SEARCH_NEXT, + variables: { ...variables, after: 'c2' }, + data: connection([edge(3), edge(4)], true), + }) + cache.writeQuery({ + query: SEARCH_NEXT, + variables: { ...variables, after: 'c4' }, + data: connection([edge(5)], false), + }) + + const read = cache.readQuery({ query: SEARCH, variables })! + expect(read.searchCatalog.edges.map((e) => e.cursor)).toEqual([ + 'c1', + 'c2', + 'c3', + 'c4', + 'c5', + ]) + expect(read.searchCatalog.pageInfo).toEqual({ + __typename: 'PageInfo', + hasNextPage: false, + endCursor: 'c5', + }) + expect(read.searchCatalog.totalCount).toBe(5) // from the first page + }) + + it('adds nothing when the same page arrives twice', () => { + const cache = createCache() + const variables = { search: 'kip', first: 2 } + cache.writeQuery({ + query: SEARCH, + variables, + data: connection([edge(1), edge(2)], true, 4), + }) + const next = { + query: SEARCH_NEXT, + variables: { ...variables, after: 'c2' }, + data: connection([edge(3), edge(4)], false), + } + cache.writeQuery(next) + cache.writeQuery(next) + + const read = cache.readQuery({ query: SEARCH, variables })! + expect(read.searchCatalog.edges.map((e) => e.cursor)).toEqual([ + 'c1', + 'c2', + 'c3', + 'c4', + ]) + }) + + it('starts over when the first page of the same search arrives again', () => { + const cache = createCache() + const variables = { search: 'kip', first: 2 } + cache.writeQuery({ + query: SEARCH, + variables, + data: connection([edge(1), edge(2)], true, 4), + }) + cache.writeQuery({ + query: SEARCH_NEXT, + variables: { ...variables, after: 'c2' }, + data: connection([edge(3), edge(4)], false), + }) + // A reload of the first page (the data may have changed). + cache.writeQuery({ + query: SEARCH, + variables, + data: connection([edge(1), edge(2)], true, 9), + }) + + const read = cache.readQuery({ query: SEARCH, variables })! + expect(read.searchCatalog.edges.map((e) => e.cursor)).toEqual(['c1', 'c2']) + expect(read.searchCatalog.totalCount).toBe(9) + }) + + it('keeps a list for each search, filter and sort', () => { + const cache = createCache() + cache.writeQuery({ + query: SEARCH, + variables: { search: 'kip', first: 2 }, + data: connection([edge(1), edge(2)], false, 2), + }) + cache.writeQuery({ + query: SEARCH, + variables: { search: 'hang', first: 2 }, + data: connection([edge(7)], false, 1), + }) + cache.writeQuery({ + query: SEARCH, + variables: { search: 'kip', filter: { apparatusId: 'UB' }, first: 2 }, + data: connection([edge(9)], false, 1), + }) + + const cursors = (variables: object) => + cache + .readQuery({ query: SEARCH, variables })! + .searchCatalog.edges.map((e) => e.cursor) + expect(cursors({ search: 'kip', first: 2 })).toEqual(['c1', 'c2']) + expect(cursors({ search: 'hang', first: 2 })).toEqual(['c7']) + expect( + cursors({ search: 'kip', filter: { apparatusId: 'UB' }, first: 2 }), + ).toEqual(['c9']) + // The page size is not part of the search. + expect(cursors({ search: 'kip', first: 50 })).toEqual(['c1', 'c2']) + }) + + it('stores an element once, also when it is in two lists', () => { + const cache = createCache() + cache.writeQuery({ + query: SEARCH, + variables: { search: 'a', first: 2 }, + data: connection([edge(1), edge(2)], false, 2), + }) + cache.writeQuery({ + query: SEARCH, + variables: { search: 'b', first: 2 }, + data: connection([edge(2)], false, 1), + }) + + const elements = Object.keys(cache.extract()).filter((key) => + key.startsWith('Element:'), + ) + expect(elements.sort()).toEqual(['Element:UB-1.1-a', 'Element:UB-1.2-a']) + }) + + it('lets a fragment on the CatalogItem interface match an element and a vault', () => { + const cache = createCache() + const query = gql` + query Items { + catalogItems @client { + ...ItemFields + } + } + fragment ItemFields on CatalogItem { + id + description + } + ` + void query + cache.writeFragment({ + id: 'Vault:VT-4.1-a', + fragment: gql` + fragment V on Vault { + id + description + } + `, + data: { __typename: 'Vault', id: 'VT-4.1-a', description: 'Handspring' }, + }) + const read = cache.readFragment({ + id: 'Vault:VT-4.1-a', + fragment: gql` + fragment I on CatalogItem { + id + description + } + `, + }) + expect(read).toEqual({ + __typename: 'Vault', + id: 'VT-4.1-a', + description: 'Handspring', + }) + }) +}) diff --git a/src/lib/apollo/cache.ts b/src/lib/apollo/cache.ts new file mode 100644 index 0000000..3e98823 --- /dev/null +++ b/src/lib/apollo/cache.ts @@ -0,0 +1,49 @@ +// The cache of Apollo Client. `searchCatalog` is a Relay connection +// (edges, cursor, pageInfo). The cache keeps all the pages of one search in one +// list: "load more" (the same query with `after`) adds the next page to it, and +// a new search, filter or sort starts a new list. + +import { InMemoryCache, type TypePolicies } from '@apollo/client' + +type Edge = { cursor?: string } +type Connection = { + edges?: readonly Edge[] + totalCount?: number | null + pageInfo?: unknown + [key: string]: unknown +} + +export const typePolicies: TypePolicies = { + Query: { + fields: { + searchCatalog: { + // `first` and `after` are left out on purpose: they are the page, not the search. + keyArgs: ['search', 'filter', 'sort'], + merge( + existing: Connection | undefined, + incoming: Connection, + { args }, + ): Connection { + // The first page of a search (no cursor) replaces the list. + if (!existing || !args?.['after']) return incoming + const known = new Set(existing.edges?.map((edge) => edge.cursor)) + const added = (incoming.edges ?? []).filter( + (edge) => !known.has(edge.cursor), // the same page twice adds nothing + ) + return { + ...incoming, + edges: [...(existing.edges ?? []), ...added], + // totalCount is only asked for with the first page. + totalCount: incoming.totalCount ?? existing.totalCount, + } + }, + }, + }, + }, +} + +/** CatalogItem is an interface: a fragment on it also matches Element and Vault. */ +export const possibleTypes = { CatalogItem: ['Element', 'Vault'] } + +export const createCache = () => + new InMemoryCache({ typePolicies, possibleTypes }) diff --git a/src/lib/apollo/client.ts b/src/lib/apollo/client.ts new file mode 100644 index 0000000..bbbb352 --- /dev/null +++ b/src/lib/apollo/client.ts @@ -0,0 +1,87 @@ +// The Apollo Client of the app, and the functions for the session. The address +// of the API is VITE_API_URL (default "/graphql"). In development Vite sends +// "/graphql" to the backend on port 3001 (vite.config.ts), so the browser sees +// one address and there is no CORS to set up. + +import { ApolloClient } from '@apollo/client' +import { HttpLink } from '@apollo/client/link/http' +import { ApolloLink } from '@apollo/client/link' +import { + type AuthPayload, + loginRequest, + logoutRequest, + refreshRequest, +} from './api' +import { createAuthLink } from './authLink' +import { createCache } from './cache' +import { type Session, type TokenStorage, createSession } from './session' + +export const API_URL: string = import.meta.env.VITE_API_URL ?? '/graphql' + +export function createApolloClient(options: { + uri: string + session: Session + fetch?: typeof fetch +}) { + return new ApolloClient({ + cache: createCache(), + link: ApolloLink.from([ + createAuthLink(options.session), + new HttpLink({ + uri: options.uri, + ...(options.fetch ? { fetch: options.fetch } : {}), + }), + ]), + }) +} + +function browserStorage(): TokenStorage | null { + try { + return window.localStorage + } catch { + return null // blocked by the browser settings + } +} + +export const session: Session = createSession({ + storage: browserStorage(), + refreshRequest: (refreshToken) => refreshRequest(API_URL, refreshToken), + locks: typeof navigator === 'undefined' ? null : (navigator.locks ?? null), +}) + +export const apolloClient = createApolloClient({ uri: API_URL, session }) + +/** + * Logs in with the token from Sign in with Google. Everything the client has + * cached is cleared, so nothing from before the login is shown. + */ +export async function login( + googleToken: string, +): Promise { + const payload = await loginRequest(API_URL, googleToken) + session.setTokens(payload) + await apolloClient.clearStore() + return payload.coach +} + +/** Ends the login on the server and forgets the tokens and everything cached. */ +export async function logout(): Promise { + const refreshToken = session.getRefreshToken() + session.clear() + await apolloClient.clearStore() + if (refreshToken) { + try { + await logoutRequest(API_URL, refreshToken) + } catch { + // The tokens are gone here either way. The refresh token on the server + // expires by itself after 14 days. + } + } +} + +/** + * On page load: if the coach logged in earlier, get an access token with the + * refresh token. Returns whether the coach is logged in. + */ +export const restoreSession = (): Promise => + session.hasRefreshToken() ? session.refresh() : Promise.resolve(false) diff --git a/src/lib/apollo/errors.test.ts b/src/lib/apollo/errors.test.ts new file mode 100644 index 0000000..8f234af --- /dev/null +++ b/src/lib/apollo/errors.test.ts @@ -0,0 +1,89 @@ +import { CombinedGraphQLErrors } from '@apollo/client/errors' +import { describe, expect, it } from 'vitest' +import { ApiError } from './api' +import { describeError } from './errors' + +const fromServer = (code: string, message: string) => + new CombinedGraphQLErrors({ + data: null, + errors: [{ message, extensions: { code } }], + }) + +describe('describeError', () => { + it('shows the message of the server when it says what to change', () => { + for (const code of [ + 'BAD_USER_INPUT', + 'NAME_TAKEN', + 'LIMIT_REACHED', + 'INVALID_ITEMS', + ]) { + const described = describeError( + fromServer(code, 'The name must be 1-70 characters.'), + ) + expect(described).toEqual({ + code, + message: 'The name must be 1-70 characters.', + retryable: false, + }) + } + }) + + it('uses its own message for the other codes, and never shows server details', () => { + expect( + describeError(fromServer('UNAUTHENTICATED', 'You must be logged in.')), + ).toEqual({ + code: 'UNAUTHENTICATED', + message: 'Log in to continue.', + retryable: false, + }) + expect( + describeError(fromServer('NOT_FOUND', 'The gymnast was not found.')) + .message, + ).toBe('We could not find that.') + const internal = describeError( + fromServer('INTERNAL_SERVER_ERROR', 'password=secret'), + ) + expect(internal.retryable).toBe(true) + expect(JSON.stringify(internal)).not.toContain('secret') + expect(describeError(fromServer('SOMETHING_NEW', 'x')).retryable).toBe(true) + }) + + it('understands an ApiError from the login functions', () => { + expect( + describeError( + new ApiError('UNAUTHENTICATED', 'The Google token is not valid.'), + ).code, + ).toBe('UNAUTHENTICATED') + expect( + describeError(new ApiError('NETWORK_ERROR', 'The server answered 502.')), + ).toMatchObject({ + code: 'NETWORK_ERROR', + retryable: true, + }) + }) + + it('says that the server cannot be reached when fetch fails', () => { + const described = describeError(new TypeError('Failed to fetch')) + expect(described.code).toBe('NETWORK_ERROR') + expect(described.retryable).toBe(true) + }) + + it('handles a server error page (for example 502 from a proxy)', () => { + const error = Object.assign(new Error('Response not successful'), { + statusCode: 502, + }) + expect(describeError(error)).toMatchObject({ + code: 'SERVER_ERROR', + retryable: true, + }) + }) + + it('handles anything else without throwing', () => { + for (const value of [null, undefined, 'text', 42, {}, new Error('x')]) { + expect(describeError(value)).toMatchObject({ + code: 'UNKNOWN', + retryable: true, + }) + } + }) +}) diff --git a/src/lib/apollo/errors.ts b/src/lib/apollo/errors.ts new file mode 100644 index 0000000..82e729e --- /dev/null +++ b/src/lib/apollo/errors.ts @@ -0,0 +1,94 @@ +// One place that turns an error from the API or the network into a message that +// a page can show. The codes are the ones in "Error handling" in docs/API.md. + +export type DescribedError = { + /** The `extensions.code` from the server, or NETWORK_ERROR / UNKNOWN. */ + code: string + /** A message for the user. */ + message: string + /** Whether trying again can help. */ + retryable: boolean +} + +type GraphQLErrorLike = { + message?: string + extensions?: Record +} + +/** The GraphQL errors in an Apollo error (CombinedGraphQLErrors), an ApiError or a plain object. */ +function graphQLErrors(error: unknown): GraphQLErrorLike[] { + const errors = (error as { errors?: unknown } | null)?.errors + return Array.isArray(errors) ? (errors as GraphQLErrorLike[]) : [] +} + +/** + * Codes where the message from the server is meant for the user, because it + * says what to change ("The name of a gymnast must be 1-70 characters."). + */ +const SERVER_MESSAGE = new Set([ + 'BAD_USER_INPUT', + 'NAME_TAKEN', + 'LIMIT_REACHED', + 'INVALID_ITEMS', +]) + +const FIXED: Record = { + UNAUTHENTICATED: { message: 'Log in to continue.', retryable: false }, + NOT_FOUND: { message: 'We could not find that.', retryable: false }, + NOT_IMPLEMENTED: { + message: 'This is not available yet.', + retryable: false, + }, + GRAPHQL_VALIDATION_FAILED: { + message: 'The app sent a request that the server does not understand.', + retryable: false, + }, + INTERNAL_SERVER_ERROR: { + message: 'Something went wrong on our side. Try again.', + retryable: true, + }, +} + +const NETWORK = { + code: 'NETWORK_ERROR', + message: 'Could not reach the server. Check your connection and try again.', + retryable: true, +} + +export function describeError(error: unknown): DescribedError { + const first = graphQLErrors(error)[0] + const code = + first?.extensions?.['code'] ?? (error as { code?: unknown } | null)?.code + if (typeof code === 'string' && code !== 'NETWORK_ERROR') { + if (SERVER_MESSAGE.has(code)) { + const message = + first?.message ?? (error as { message?: string }).message ?? '' + return { code, message, retryable: false } + } + const fixed = FIXED[code] + if (fixed) return { code, ...fixed } + return { + code, + message: 'Something went wrong. Try again.', + retryable: true, + } + } + // No code from the server: the request did not get an answer (a TypeError + // from fetch, a broken connection), or the server answered with a page that + // is not GraphQL (a proxy error, 502). + if (error instanceof TypeError || code === 'NETWORK_ERROR') return NETWORK + if ( + typeof (error as { statusCode?: unknown } | null)?.statusCode === 'number' + ) { + return { + code: 'SERVER_ERROR', + message: 'The server could not answer. Try again.', + retryable: true, + } + } + return { + code: 'UNKNOWN', + message: 'Something went wrong. Try again.', + retryable: true, + } +} diff --git a/src/lib/apollo/session.test.ts b/src/lib/apollo/session.test.ts new file mode 100644 index 0000000..4b75b84 --- /dev/null +++ b/src/lib/apollo/session.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it, vi } from 'vitest' +import { ApiError, type AuthPayload } from './api' +import { REFRESH_TOKEN_KEY, createSession, type TokenStorage } from './session' + +const payload = (n: number): AuthPayload => ({ + accessToken: `access-${n}`, + refreshToken: `refresh-${n}`, + coach: { id: 'c1', name: 'Anna', email: 'anna@example.com' }, +}) + +function memoryStorage(): TokenStorage & { values: Map } { + const values = new Map() + return { + values, + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => void values.set(key, value), + removeItem: (key) => void values.delete(key), + } +} + +const base64url = (value: object) => + btoa(JSON.stringify(value)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + +/** A token that looks like the server's: three parts, with `exp` in seconds. */ +const jwt = (expSeconds: number) => + `${base64url({ alg: 'HS256' })}.${base64url({ exp: expSeconds })}.signature` + +describe('the session', () => { + it('keeps the access token in memory and the refresh token in the storage', () => { + const storage = memoryStorage() + const session = createSession({ storage, refreshRequest: vi.fn() }) + expect(session.getAccessToken()).toBeNull() + expect(session.hasRefreshToken()).toBe(false) + + session.setTokens(payload(1)) + expect(session.getAccessToken()).toBe('access-1') + expect(storage.values.get(REFRESH_TOKEN_KEY)).toBe('refresh-1') + expect([...storage.values.values()]).not.toContain('access-1') + + // A new tab has the refresh token but no access token. + const other = createSession({ storage, refreshRequest: vi.fn() }) + expect(other.getAccessToken()).toBeNull() + expect(other.hasRefreshToken()).toBe(true) + + session.clear() + expect(session.getAccessToken()).toBeNull() + expect(storage.values.size).toBe(0) + }) + + it('still works when the storage is missing or throws', () => { + const broken: TokenStorage = { + getItem: () => { + throw new Error('blocked') + }, + setItem: () => { + throw new Error('blocked') + }, + removeItem: () => { + throw new Error('blocked') + }, + } + for (const storage of [null, broken]) { + const session = createSession({ storage, refreshRequest: vi.fn() }) + session.setTokens(payload(1)) + expect(session.getAccessToken()).toBe('access-1') + expect(session.getRefreshToken()).toBe('refresh-1') + session.clear() + expect(session.hasRefreshToken()).toBe(false) + } + }) + + describe('refresh', () => { + it('uses the stored refresh token and keeps the new tokens', async () => { + const storage = memoryStorage() + storage.setItem(REFRESH_TOKEN_KEY, 'refresh-1') + const refreshRequest = vi.fn().mockResolvedValue(payload(2)) + const session = createSession({ storage, refreshRequest }) + + expect(await session.refresh()).toBe(true) + expect(refreshRequest).toHaveBeenCalledWith('refresh-1') + expect(session.getAccessToken()).toBe('access-2') + // The new refresh token replaces the old one, which no longer works. + expect(storage.values.get(REFRESH_TOKEN_KEY)).toBe('refresh-2') + }) + + it('does nothing without a refresh token', async () => { + const refreshRequest = vi.fn() + const session = createSession({ + storage: memoryStorage(), + refreshRequest, + }) + expect(await session.refresh()).toBe(false) + expect(refreshRequest).not.toHaveBeenCalled() + }) + + it('forgets the login when the server refuses the refresh token', async () => { + const storage = memoryStorage() + const session = createSession({ + storage, + refreshRequest: vi + .fn() + .mockRejectedValue(new ApiError('UNAUTHENTICATED', 'Log in again.')), + }) + session.setTokens(payload(1)) + + expect(await session.refresh()).toBe(false) + expect(session.getAccessToken()).toBeNull() + expect(storage.values.size).toBe(0) + }) + + it('keeps the refresh token when the server cannot be reached, so a later try can work', async () => { + const storage = memoryStorage() + const refreshRequest = vi + .fn() + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockRejectedValueOnce(new ApiError('INTERNAL_SERVER_ERROR', 'Oops')) + .mockResolvedValueOnce(payload(2)) + const session = createSession({ storage, refreshRequest }) + session.setTokens(payload(1)) + + expect(await session.refresh()).toBe(false) + expect(await session.refresh()).toBe(false) + expect(storage.values.get(REFRESH_TOKEN_KEY)).toBe('refresh-1') + expect(await session.refresh()).toBe(true) + expect(storage.values.get(REFRESH_TOKEN_KEY)).toBe('refresh-2') + }) + + it('sends one request when several parts of the page refresh at the same moment', async () => { + let resolve!: (value: AuthPayload) => void + const refreshRequest = vi.fn( + () => new Promise((r) => (resolve = r)), + ) + const session = createSession({ + storage: memoryStorage(), + refreshRequest, + }) + session.setTokens(payload(1)) + + const all = Promise.all([ + session.refresh(), + session.refresh(), + session.refresh(), + ]) + resolve(payload(2)) + expect(await all).toEqual([true, true, true]) + expect(refreshRequest).toHaveBeenCalledTimes(1) + + // A later refresh is a new request. + refreshRequest.mockResolvedValueOnce(payload(3) as never) + expect(await session.refresh()).toBe(true) + expect(refreshRequest).toHaveBeenCalledTimes(2) + }) + + it('goes through the lock between tabs, and reads the token inside it', async () => { + const storage = memoryStorage() + const names: string[] = [] + // Another tab refreshes while this tab waits for the lock. + const locks = { + request: ((name: string, callback: () => Promise) => { + names.push(name) + storage.setItem(REFRESH_TOKEN_KEY, 'refresh-from-other-tab') + return callback() + }) as unknown as LockManager['request'], + } + const refreshRequest = vi.fn().mockResolvedValue(payload(5)) + const session = createSession({ storage, refreshRequest, locks }) + session.setTokens(payload(1)) + + expect(await session.refresh()).toBe(true) + expect(names).toEqual(['cop-refresh-session']) + expect(refreshRequest).toHaveBeenCalledWith('refresh-from-other-tab') + }) + }) +}) + +describe('ensureFresh', () => { + const NOW = Date.parse('2026-10-21T12:00:00Z') + const inSeconds = (n: number) => NOW / 1000 + n + + function setup(accessExpiresInSeconds: number | null) { + const refreshed = { ...payload(2), accessToken: jwt(inSeconds(900)) } + const refreshRequest = vi + .fn<(refreshToken: string) => Promise>() + .mockResolvedValue(refreshed) + const session = createSession({ + storage: memoryStorage(), + refreshRequest, + now: () => NOW, + }) + if (accessExpiresInSeconds !== null) { + session.setTokens({ + accessToken: jwt(inSeconds(accessExpiresInSeconds)), + refreshToken: 'refresh-1', + }) + } + return { session, refreshRequest } + } + + it('does nothing without a refresh token', async () => { + const { session, refreshRequest } = setup(null) + await session.ensureFresh() + expect(refreshRequest).not.toHaveBeenCalled() + }) + + it('refreshes when there is a refresh token but no access token (a new tab)', async () => { + const storage = memoryStorage() + createSession({ storage, refreshRequest: vi.fn() }).setTokens({ + accessToken: jwt(inSeconds(900)), + refreshToken: 'refresh-1', + }) + const refreshRequest = vi + .fn<(refreshToken: string) => Promise>() + .mockResolvedValue(payload(2)) + const newTab = createSession({ storage, refreshRequest, now: () => NOW }) + expect(newTab.getAccessToken()).toBeNull() + + await newTab.ensureFresh() + expect(refreshRequest).toHaveBeenCalledWith('refresh-1') + expect(newTab.getAccessToken()).toBe('access-2') + }) + + it('does not refresh while the access token is valid for longer than the margin', async () => { + const { session, refreshRequest } = setup(600) + await session.ensureFresh() + expect(refreshRequest).not.toHaveBeenCalled() + }) + + it('refreshes when the access token has expired or is about to', async () => { + for (const seconds of [-60, 0, 20]) { + const { session, refreshRequest } = setup(seconds) + await session.ensureFresh() + expect(refreshRequest).toHaveBeenCalledTimes(1) + expect(refreshRequest).toHaveBeenCalledWith('refresh-1') + expect(session.getRefreshToken()).toBe('refresh-2') + } + }) + + it('keeps the refresh token when the request times out', async () => { + const timeout = new DOMException('The operation timed out.', 'TimeoutError') + const refreshRequest = vi + .fn<(refreshToken: string) => Promise>() + .mockRejectedValue(timeout) + const session = createSession({ + storage: memoryStorage(), + refreshRequest, + now: () => NOW, + }) + session.setTokens({ + accessToken: jwt(inSeconds(-1)), + refreshToken: 'refresh-1', + }) + + await expect(session.refresh()).resolves.toBe(false) + expect(session.getRefreshToken()).toBe('refresh-1') + }) +}) diff --git a/src/lib/apollo/session.ts b/src/lib/apollo/session.ts new file mode 100644 index 0000000..749ba39 --- /dev/null +++ b/src/lib/apollo/session.ts @@ -0,0 +1,154 @@ +// The tokens of the logged-in coach (see "Identifying the user" in docs/API.md). +// - The access token (15 minutes) is kept in memory only. +// - The refresh token (14 days) is kept in localStorage, so a reload does not +// log the coach out. The server replaces it at every use, so the new one is +// stored before anything else happens. +// - Refreshing is done from one place at a time. Two tabs that use the same +// refresh token at the same moment look like a stolen token to the server, +// and it ends the login. Within a tab the calls share one request, and +// between tabs the Web Locks API (`navigator.locks`) makes them follow each +// other. The refresh token is read inside the lock, so a tab that waited +// uses the newest one. + +import { ApiError, type AuthPayload } from './api' + +export const REFRESH_TOKEN_KEY = 'cop.refreshToken' +const LOCK_NAME = 'cop-refresh-session' + +/** A token that expires within this time is renewed before the request. */ +export const EXPIRY_MARGIN_MS = 30 * 1000 + +/** + * When the access token expires, in milliseconds, read from its `exp` claim. + * Only the client's own planning uses this; the server decides if the token + * is valid. Null for a token that cannot be read. + */ +function expiryOf(token: string): number | null { + try { + const part = token.split('.')[1] + if (!part) return null + const claims = JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/'))) + return typeof claims.exp === 'number' ? claims.exp * 1000 : null + } catch { + return null + } +} + +export type TokenStorage = Pick +type Locks = Pick + +export type Session = { + getAccessToken: () => string | null + hasRefreshToken: () => boolean + getRefreshToken: () => string | null + /** Stores the tokens of a login or a refresh. */ + setTokens: ( + payload: Pick, + ) => void + /** Forgets both tokens (logout, or a refresh token that the server refuses). */ + clear: () => void + /** + * Gets new tokens with the stored refresh token. True when the coach is + * logged in afterwards. False when there is no refresh token, when the server + * refuses it (the tokens are cleared), or when the server cannot be reached + * (the refresh token is kept, so a later try can work). + */ + refresh: () => Promise + /** + * Refreshes before a request when the coach has a refresh token and the + * access token is missing or about to expire. An expired access token does + * not make the server fail the request: `me` answers null, so the page would + * show the coach as logged out. Does nothing without a refresh token. + */ + ensureFresh: () => Promise +} + +export function createSession(options: { + storage?: TokenStorage | null + refreshRequest: (refreshToken: string) => Promise + locks?: Locks | null + /** The time in milliseconds. The tests set it. */ + now?: () => number +}): Session { + const { storage = null, locks = null, refreshRequest } = options + const now = options.now ?? Date.now + let accessToken: string | null = null + let accessExpiresAt: number | null = null + let inFlight: Promise | null = null + + // Storage can be missing or throw (private windows, blocked site data). + const read = (): string | null => { + try { + return storage?.getItem(REFRESH_TOKEN_KEY) ?? null + } catch { + return null + } + } + let memoryRefreshToken: string | null = null // when the storage does not work + const getRefreshToken = () => read() ?? memoryRefreshToken + + const setTokens: Session['setTokens'] = (payload) => { + accessToken = payload.accessToken + accessExpiresAt = expiryOf(payload.accessToken) + memoryRefreshToken = payload.refreshToken + try { + storage?.setItem(REFRESH_TOKEN_KEY, payload.refreshToken) + } catch { + // The memory copy keeps this tab logged in. + } + } + + const clear = () => { + accessToken = null + accessExpiresAt = null + memoryRefreshToken = null + try { + storage?.removeItem(REFRESH_TOKEN_KEY) + } catch { + // Nothing more to do. + } + } + + const refreshOnce = async (): Promise => { + const token = getRefreshToken() // read inside the lock + if (!token) return false + try { + setTokens(await refreshRequest(token)) + return true + } catch (error) { + // The server says the login is over: forget it. Any other error (no + // network, a server error) keeps the token for a later try. + if (error instanceof ApiError && error.code === 'UNAUTHENTICATED') { + clear() + } + return false + } + } + + const refresh = () => { + inFlight ??= ( + locks ? locks.request(LOCK_NAME, refreshOnce) : refreshOnce() + ).finally(() => { + inFlight = null + }) + return inFlight + } + + const ensureFresh = async () => { + if (getRefreshToken() === null) return + const expired = + accessToken === null || + (accessExpiresAt !== null && now() >= accessExpiresAt - EXPIRY_MARGIN_MS) + if (expired) await refresh() + } + + return { + getAccessToken: () => accessToken, + hasRefreshToken: () => getRefreshToken() !== null, + getRefreshToken, + setTokens, + clear, + refresh, + ensureFresh, + } +} diff --git a/src/lib/catalog.test.ts b/src/lib/catalog.test.ts new file mode 100644 index 0000000..13b7d6e --- /dev/null +++ b/src/lib/catalog.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { buildImageUrl, getElementDetail } from './catalog' + +describe('getElementDetail', () => { + it('returns catalog metadata, other variants, and the diagram URL', async () => { + const detail = await getElementDetail('UB-1.101-a') + + expect(detail).toMatchObject({ + id: 'UB-1.101-a', + apparatusName: 'Uneven bars', + elementGroupName: 'Mounts', + variants: [{ id: 'UB-1.101-b' }], + }) + expect(detail?.imageUrl).toContain('UB-1.101-a.webp') + }) + + it('returns null when the catalog id does not exist', async () => { + await expect(getElementDetail('BB-0.000-a')).resolves.toBeNull() + }) +}) + +describe('buildImageUrl', () => { + it('has no URL when the record has no image', () => { + expect(buildImageUrl('/', 'UB', undefined)).toBeNull() + expect(buildImageUrl('/', 'UB', null)).toBeNull() + }) + + it('does not double the slash after the base URL', () => { + expect(buildImageUrl('/project2/', 'UB', 'a.webp')).toBe( + '/project2/data/images/UB/a.webp', + ) + expect(buildImageUrl('/', 'UB', 'a.webp')).toBe('/data/images/UB/a.webp') + }) +}) diff --git a/src/lib/catalog.ts b/src/lib/catalog.ts new file mode 100644 index 0000000..e6091e2 --- /dev/null +++ b/src/lib/catalog.ts @@ -0,0 +1,95 @@ +import apparatusRecords from '../../data/apparatus.json' +import elementGroupRecords from '../../data/elementGroups.json' +import elementRecords from '../../data/elements.json' +import variantGroupRecords from '../../data/variantGroups.json' +import vaultRecords from '../../data/vaults.json' + +type ElementRecord = (typeof elementRecords)[number] +type VaultRecord = (typeof vaultRecords)[number] +type CatalogRecord = ElementRecord | VaultRecord + +export type CatalogVariant = { + id: string + code: string + name: string | null + description: string + value: number + isVault: boolean +} + +export type ElementDetail = CatalogVariant & { + apparatusId: string + apparatusName: string + elementGroupName: string + imageUrl: string | null + variants: CatalogVariant[] +} + +const catalogById = new Map( + [...elementRecords, ...vaultRecords].map((record) => [record.id, record]), +) + +const apparatusById = new Map( + apparatusRecords.map((apparatus) => [apparatus.id, apparatus]), +) + +const elementGroupById = new Map( + elementGroupRecords.map((group) => [group.id, group]), +) + +const variantGroupById = new Map( + variantGroupRecords.map((group) => [group.id, group]), +) + +export function buildImageUrl( + baseUrl: string, + apparatusId: string, + image: string | null | undefined, +): string | null { + if (!image) return null + return `${baseUrl.replace(/\/+$/, '')}/data/images/${apparatusId}/${image}` +} + +function toCatalogVariant(record: CatalogRecord): CatalogVariant { + const isVault = 'dValue' in record + + return { + id: record.id, + code: record.code, + name: record.name, + description: record.description, + value: isVault ? record.dValue : record.value, + isVault, + } +} + +export async function getElementDetail( + id: string, +): Promise { + const record = catalogById.get(id) + + if (!record) return null + + const apparatusId = id.slice(0, 2) + const variantGroupId = `${apparatusId}-${record.code}` + const relatedVariantIds = + variantGroupById.get(variantGroupId)?.variantIds ?? [] + const variants = relatedVariantIds + .filter((variantId) => variantId !== id) + .map((variantId) => catalogById.get(variantId)) + .filter((variant): variant is CatalogRecord => variant !== undefined) + .map(toCatalogVariant) + return { + ...toCatalogVariant(record), + apparatusId, + apparatusName: apparatusById.get(apparatusId)?.name ?? apparatusId, + elementGroupName: + elementGroupById.get(record.elementGroupId)?.name ?? 'Element group', + imageUrl: buildImageUrl( + import.meta.env.BASE_URL, + apparatusId, + record.image, + ), + variants, + } +} diff --git a/src/lib/difficulty.ts b/src/lib/difficulty.ts new file mode 100644 index 0000000..3938460 --- /dev/null +++ b/src/lib/difficulty.ts @@ -0,0 +1,37 @@ +export const DIFFICULTY_VALUE_BY_LETTER = { + A: 0.1, + B: 0.2, + C: 0.3, + D: 0.4, + E: 0.5, + F: 0.6, + G: 0.7, + H: 0.8, + I: 0.9, + J: 1.0, +} as const + +export type DifficultyLetter = keyof typeof DIFFICULTY_VALUE_BY_LETTER + +// Keyed by the value in tenths, so that 0.1 and 0.10000000000000002 both find A. +const letterByTenths = new Map( + ( + Object.entries(DIFFICULTY_VALUE_BY_LETTER) as [DifficultyLetter, number][] + ).map(([letter, value]) => [Math.round(value * 10), letter]), +) + +export function getDifficultyValue(letter: string): number | undefined { + const normalizedLetter = letter.toUpperCase() + + if (!Object.hasOwn(DIFFICULTY_VALUE_BY_LETTER, normalizedLetter)) { + return undefined + } + + return DIFFICULTY_VALUE_BY_LETTER[normalizedLetter as DifficultyLetter] +} + +export function getDifficultyLetter( + value: number, +): DifficultyLetter | undefined { + return letterByTenths.get(Math.round(value * 10)) +} diff --git a/src/lib/gymnasts.test.ts b/src/lib/gymnasts.test.ts new file mode 100644 index 0000000..4104be1 --- /dev/null +++ b/src/lib/gymnasts.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + getGymnastInitials, + getGymnastNameError, + normalizeGymnastName, +} from "./gymnasts"; + +describe("gymnast helpers", () => { + it("creates initials from the first and last name", () => { + expect(getGymnastInitials("Ingrid Haugen")).toBe("IH"); + }); + + it("handles a single name and empty input", () => { + expect(getGymnastInitials("Madonna")).toBe("MA"); + expect(getGymnastInitials(" ")).toBe("?"); + }); + + it("trims and collapses whitespace in names", () => { + expect(normalizeGymnastName(" Emma Berg ")).toBe("Emma Berg"); + }); + + it("rejects blank and overlong names", () => { + expect(getGymnastNameError(" ")).toBe("Enter a gymnast name."); + expect(getGymnastNameError("A".repeat(71))).toBe( + "Names must be 70 characters or fewer.", + ); + expect(getGymnastNameError("Sofia Hansen")).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/src/lib/gymnasts.ts b/src/lib/gymnasts.ts new file mode 100644 index 0000000..ef27b6e --- /dev/null +++ b/src/lib/gymnasts.ts @@ -0,0 +1,33 @@ +export type GymnastSummary = { + id: string; + name: string; +}; + +export const exampleGymnasts: GymnastSummary[] = [ + { id: "gymnast-1", name: "Ingrid Haugen" }, + { id: "gymnast-2", name: "Emma Berg" }, +]; + +export function getGymnastInitials(name: string): string { + const words = name.trim().split(/\s+/).filter(Boolean); + + if (words.length === 0) return "?"; + if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); + + return `${words[0][0]}${words.at(-1)?.[0] ?? ""}`.toUpperCase(); +} + +export function normalizeGymnastName(name: string): string { + return name.trim().replace(/\s+/g, " "); +} + +export function getGymnastNameError(name: string): string | undefined { + const normalizedName = normalizeGymnastName(name); + + if (!normalizedName) return "Enter a gymnast name."; + if (normalizedName.length > 70) { + return "Names must be 70 characters or fewer."; + } + + return undefined; +} \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..ae9e5a7 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1 @@ +export { cn } from 'cn' diff --git a/src/main.tsx b/src/main.tsx index 17d5695..9ba3546 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,12 +1,20 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import './index.css' -import './styles/variables.css' -import './styles/global.css' +import { ApolloProvider } from '@apollo/client/react' +import { BrowserRouter } from 'react-router-dom' import App from './App.tsx' +import { apolloClient, restoreSession } from './lib/apollo/client' -createRoot(document.getElementById('root')!).render( - - - , -) +// A coach who logged in earlier gets a new access token before the first +// request. Without a saved login this answers at once. +void restoreSession().finally(() => { + createRoot(document.getElementById('root')!).render( + + + + + + + , + ) +}) diff --git a/src/styles/global.css b/src/styles/global.css index e69de29..4db4f32 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -0,0 +1,19 @@ +#root .element-detail-page :is(h1, h2) { + color: var(--text-color-dark); +} + +#root .your-gymnasts-page :is(h1, h2) { + color: var(--text-color-dark); +} + +#root .your-gymnasts-page h1 { + font-size: 42px; + line-height: 1.1; + margin: 0; +} + +#root .your-gymnasts-page h2 { + font-size: 19px; + line-height: 1.2; + margin: 0; +} diff --git a/src/styles/variables.css b/src/styles/variables.css index 94f575e..f6cac9a 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -24,8 +24,9 @@ --box-stroke: #888394; --box-shadow: 0 2px 5px rgba(3, 2, 2, 0.1); - /* D-value */ - --background-color: #BDE981; + /* D-value badge */ + --badge-d-value-bg: #BDE981; + --badge-d-value-text: var(--text-color-dark); --border-color: var(--text-color); /* This part was generate with the help of Gemini 2.5 Flash using the prompt: @@ -37,25 +38,25 @@ --space-md: 1rem; /* 16px - default padding (inputs, row heights) */ --space-lg: 1.5rem; /* 24px - component gaps (between search & filters) */ --space-xl: 2.5rem; /* 40px - page section margins & outer padding */ - - /* Dark mode colors */ - [data-theme="dark"] { - /* Main Layout Colors */ - --background-color: #090F2F; /* Dark blue background */ - --header-background: #101844; /* Slightly lighter blue for nav */ - --element-background: #18225C; /* Inputs, dropdowns, table background */ - --surface-selected: #253788; /* Row selection tint */ - - /* Text Colors */ - --text-color-primary: #EAE0CF; /* Warm light text */ - --text-color-secondary: #A0AEC0; /* Muted text for labels */ - --text-color-on-dark: #090F2F; /* Inverted text for light buttons */ - - /* UI Components & Borders */ - --border-color: #3B426B; - --border-subtle: #2A3363; - --badge-d-value-bg: #82C43C; /* Slightly deeper green for contrast */ - --badge-d-value-text: #090F2F; - - } -} \ No newline at end of file +} + +/* Dark mode colors */ +[data-theme='dark'] { + /* Main Layout Colors */ + --background-color: #090F2F; /* Dark blue background */ + --header-background: #101844; /* Slightly lighter blue for nav */ + --element-background: #18225C; /* Inputs, dropdowns, table background */ + --surface-selected: #253788; /* Row selection tint */ + + /* Text Colors */ + --text-color: #EAE0CF; /* Headings and body text */ + --text-color-primary: #EAE0CF; /* Warm light text */ + --text-color-secondary: #A0AEC0; /* Muted text for labels */ + --text-color-on-dark: #090F2F; /* Inverted text for light buttons */ + + /* UI Components & Borders */ + --border-color: #3B426B; + --border-subtle: #2A3363; + --badge-d-value-bg: #82C43C; /* Slightly deeper green for contrast */ + --badge-d-value-text: #090F2F; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..d4e233d --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,6 @@ +/// + +interface ImportMetaEnv { + /** The address of the GraphQL API. Default "/graphql" (Vite sends it to the backend in development). */ + readonly VITE_API_URL?: string +} diff --git a/tsconfig.app.json b/tsconfig.app.json index 6830b6f..099956c 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -7,6 +7,7 @@ "types": ["vite/client"], "allowArbitraryExtensions": true, "skipLibCheck": true, + "resolveJsonModule": true, /* Bundler mode */ "moduleResolution": "bundler", @@ -20,7 +21,10 @@ "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + + /* Path alias used by shadcn/ui: @/ means src/. No baseUrl, TypeScript 6 deprecates it. */ + "paths": { "@/*": ["./src/*"] } }, "include": ["src"] } diff --git a/tsconfig.json b/tsconfig.json index 1ffef60..9e335f7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,16 @@ { "files": [], "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } + ], + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + } } diff --git a/vite.config.ts b/vite.config.ts index c7c1ed4..57a10f1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,51 @@ +import tailwindcss from '@tailwindcss/vite' import react, { reactCompilerPreset } from '@vitejs/plugin-react' import babel from '@rolldown/plugin-babel' -import { defineConfig } from 'vite' +import { cpSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' + +// The drawings in data/images are served by Vite in `npm run dev`, but are not +// in public/, so a build would leave them out of dist/. Copy them there until +// the images come from the server (IMAGE_BASE_URL). +const copyCatalogImages = { + name: 'copy-catalog-images', + apply: 'build' as const, + closeBundle() { + cpSync( + fileURLToPath(new URL('./data/images', import.meta.url)), + fileURLToPath(new URL('./dist/data/images', import.meta.url)), + { recursive: true }, + ) + }, +} // https://vite.dev/config/ export default defineConfig({ - plugins: [react(), babel({ presets: [reactCompilerPreset()] })], + base: '/project2/', + plugins: [ + react(), + babel({ presets: [reactCompilerPreset()] }), + tailwindcss(), + copyCatalogImages, + ], + server: { + // In development the browser calls the backend through Vite, so the app + // and the API have the same address and CORS is not needed. The backend + // listens on port 3001 (npm run backend). + proxy: { + '/graphql': 'http://localhost:3001', + '/images': 'http://localhost:3001', + }, + }, + resolve: { + // "@/components/ui/button" means "src/components/ui/button". + alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, + }, + test: { + // The backend tests share one local database, and some of them write to it + // (the login tests), while others check that the row counts do not change. + // One test file at a time keeps them apart. + fileParallelism: false, + }, })