From ee44b0122b11a116da88dbd4890bac3e05a125b0 Mon Sep 17 00:00:00 2001 From: Anettkva Date: Wed, 11 Nov 2026 10:42:54 +0100 Subject: [PATCH 01/14] feat(api): say which items of a routine count with scoreRoutine --- backend/src/catalog/structure.ts | 6 ++ backend/src/dscore.test.ts | 105 ++++++++++++++++++- backend/src/dscore.ts | 88 ++++++++++++---- backend/src/schema.graphql | 28 ++++- backend/src/server/calculator.test.ts | 82 +++++++++++++++ backend/src/server/resolvers/calculator.ts | 114 +++++++++++++-------- backend/src/server/resolvers/userData.ts | 6 +- backend/src/server/userData.test.ts | 8 +- docs/API.md | 9 +- docs/DATA_MODEL.md | 2 +- 10 files changed, 371 insertions(+), 77 deletions(-) diff --git a/backend/src/catalog/structure.ts b/backend/src/catalog/structure.ts index ef902b0..61489cb 100644 --- a/backend/src/catalog/structure.ts +++ b/backend/src/catalog/structure.ts @@ -5,6 +5,7 @@ // 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 { DISMOUNT_GROUP } from '../dscore.js' import type { ElementGroup, PrismaClient } from '../generated/prisma/client.js' export const CACHE_TTL_MS = 10 * 60 * 1000 @@ -18,6 +19,8 @@ export type CatalogStructure = { * cost does not depend on how many requests there are. */ difficultyValuesByApparatus: ReadonlyMap + /** The groups of the dismounts (uneven bars and balance beam), for the D-score. */ + dismountGroupIds: ReadonlySet } const cache = new WeakMap< @@ -45,6 +48,9 @@ async function load(prisma: PrismaClient): Promise { apparatusIds: new Set(apparatus.map((a) => a.id)), groupsById: new Map(groups.map((g) => [g.id, g])), difficultyValuesByApparatus, + dismountGroupIds: new Set( + groups.filter((g) => g.name === DISMOUNT_GROUP).map((g) => g.id), + ), } } diff --git a/backend/src/dscore.test.ts b/backend/src/dscore.test.ts index 5d94661..67fce7c 100644 --- a/backend/src/dscore.test.ts +++ b/backend/src/dscore.test.ts @@ -1,7 +1,7 @@ // Tests for the D-score function. No database is needed. import { describe, expect, it } from 'vitest' -import { calculateDScore, type ScoredItem } from './dscore.js' +import { calculateDScore, scoreItems, type ScoredItem } from './dscore.js' import { InvalidItemsError } from './errors.js' const element = ( @@ -112,3 +112,106 @@ describe('calculateDScore', () => { ).toThrow(InvalidItemsError) }) }) + +describe('scoreItems', () => { + it('marks every item as counted when all of them count', () => { + expect( + scoreItems([element('1.101', '0.1'), element('2.102', '0.5')]), + ).toEqual({ dScore: 0.6, counted: [true, true] }) + }) + + it('does not count the items outside the eight highest, wherever they are in the list', () => { + const values = [ + '0.1', + '1.0', + '0.9', + '0.8', + '0.7', + '0.6', + '0.5', + '0.4', + '0.3', + '0.2', + ] + const items = values.map((v, i) => element(`4.${400 + i}`, v)) + const { dScore, counted } = scoreItems(items) + expect(dScore).toBe(calculateDScore(items)) + // 0.1 (first) and 0.2 (last) are the two lowest. + expect(counted).toEqual([ + false, + true, + true, + true, + true, + true, + true, + true, + true, + false, + ]) + }) + + it('counts the first of two items with the same value at the edge of the eight', () => { + const items = Array.from({ length: 9 }, (_, i) => + element(`3.${300 + i}`, '0.3'), + ) + expect(scoreItems(items).counted).toEqual([...Array(8).fill(true), false]) + }) + + it('counts a code once: a second variant or the same id again does not count', () => { + const a = element('1.101', '0.4') + const b = { ...element('1.101', '0.4'), id: 'UB-1.101-b' } + expect(scoreItems([a, b, a])).toEqual({ + dScore: 0.4, + counted: [true, false, false], + }) + }) + + it('counts the vault of a routine on vault', () => { + expect(scoreItems([vault('4.1', '2.6')])).toEqual({ + dScore: 2.6, + counted: [true], + }) + }) + + it('gives an empty list for an empty routine', () => { + expect(scoreItems([])).toEqual({ dScore: 0, counted: [] }) + }) + + it('refuses the same items as calculateDScore', () => { + expect(() => + scoreItems([vault('4.1', '2.6'), vault('5.1', '3.0')]), + ).toThrow(InvalidItemsError) + expect(() => + scoreItems([element('1.101', '0.1'), element('1.102', '0.1', 'BB')]), + ).toThrow(InvalidItemsError) + }) + + it('always counts the dismount, in place of the lowest of the eight', () => { + const others = ['0.9', '0.8', '0.7', '0.6', '0.5', '0.4', '0.3', '0.2'].map( + (v, i) => element(`2.${200 + i}`, v), + ) + const dismount = { ...element('6.101', '0.1'), isDismount: true } + const { dScore, counted } = scoreItems([...others, dismount]) + // 0.9 + 0.8 + ... + 0.3 (seven) + the dismount 0.1, and not 0.2. + expect(dScore).toBe(4.3) + expect(counted).toEqual([ + true, + true, + true, + true, + true, + true, + true, + false, + true, + ]) + expect(calculateDScore([...others, dismount])).toBe(4.3) + }) + + it('does not change anything when the dismount is among the eight highest', () => { + const dismount = { ...element('6.501', '0.5'), isDismount: true } + const items = [element('1.101', '0.1'), dismount] + expect(scoreItems(items)).toEqual({ dScore: 0.6, counted: [true, true] }) + }) +}) diff --git a/backend/src/dscore.ts b/backend/src/dscore.ts index 797e502..feecbef 100644 --- a/backend/src/dscore.ts +++ b/backend/src/dscore.ts @@ -3,7 +3,9 @@ // `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. +// Fewer than eight elements are all summed. An empty list gives 0. A dismount +// always counts: when it is not among the eight highest, it takes the place of +// the lowest of them (the Code of Points counts the dismount in the eight). // - 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 @@ -21,46 +23,88 @@ export type ScoredItem = { /** `value` of an element, `dValue` of a vault. A string or Prisma Decimal with one decimal. */ value: { toString(): string } isVault: boolean + /** True for an element of the "Dismounts" group (uneven bars and balance beam). */ + isDismount?: boolean } +/** The name of the element group of the dismounts in the catalog (data/elementGroups.json). */ +export const DISMOUNT_GROUP = 'Dismounts' + const toTenths = (value: { toString(): string }): number => Math.round(Number(value.toString()) * 10) +export type RoutineScore = { + dScore: number + /** One for each item, in the same order: true when the item counts in the score. */ + counted: boolean[] +} + /** - * 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. + * The D-score of the items, and which of them count. 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. + * + * Each code counts once: the first item with the highest value of its code + * stands for it, and the others with that code do not count. Of those, the + * eight highest count; when two have the same value, the one that comes first + * in the list counts. The calculator shows the others as "Not counted". */ -export function calculateDScore(items: ScoredItem[]): number { +export function scoreItems(items: ScoredItem[]): RoutineScore { 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 tenths = items.map((item) => toTenths(item.value)) + // The index of the item that stands for each code. Variants of a code have the + // same value, so the first one normally stands for it. 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.') + items.forEach((item, index) => { + const best = byCode.get(item.code) + if (best === undefined || tenths[index]! > tenths[best]!) { + byCode.set(item.code, index) } - return [...byCode.values()][0]! / 10 + }) + + if (items.some((item) => item.isVault) && byCode.size > 1) { + throw new InvalidItemsError('A routine on vault has only one vault.') } - const highest = [...byCode.values()] - .sort((a, b) => b - a) - .slice(0, COUNTED_ELEMENTS) - return highest.reduce((sum, tenths) => sum + tenths, 0) / 10 + // On vault there is only one code, so this is the D-value of the vault. + const ranked = [...byCode.values()].sort( + (a, b) => tenths[b]! - tenths[a]! || a - b, + ) + const chosen = ranked.slice(0, COUNTED_ELEMENTS) + // The dismount always counts: the highest dismount outside the eight takes the place + // of the lowest element in them that is not a dismount. + const dismount = ranked.find((index) => items[index]!.isDismount) + if (dismount !== undefined && !chosen.includes(dismount)) { + const lowest = chosen.findLastIndex((index) => !items[index]!.isDismount) + if (lowest !== -1) chosen[lowest] = dismount + } + const counts = new Set(chosen) + return { + dScore: chosen.reduce((sum, index) => sum + tenths[index]!, 0) / 10, + counted: items.map((_, index) => counts.has(index)), + } } +/** The D-score of the items, with the same rule as `scoreItems`. */ +export const calculateDScore = (items: ScoredItem[]): number => + scoreItems(items).dScore + /** An element or vault from the database as the function wants it. */ -export const toScoredItem = (item: Item): ScoredItem => +export const toScoredItem = ( + item: Item, + dismountGroupIds: ReadonlySet, +): 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 } + : { + id: item.id, + code: item.code, + value: item.value, + isVault: false, + isDismount: dismountGroupIds.has(item.elementGroupId), + } diff --git a/backend/src/schema.graphql b/backend/src/schema.graphql index 1a7ce8f..37679b3 100644 --- a/backend/src/schema.graphql +++ b/backend/src/schema.graphql @@ -256,6 +256,13 @@ type Query { BAD_USER_INPUT. """ calculateDScore(itemIds: [ID!]!): Float! + """ + The D-score of a routine in the calculator, and which of its items count. + The same rule and the same errors as `calculateDScore`. `counted` has one + entry for each id in `itemIds`, in the same order, so the calculator can mark + the items that do not count ("Not counted"). + """ + scoreRoutine(itemIds: [ID!]!): RoutineScore! searchCatalog( """ @@ -326,7 +333,8 @@ type Routine { """ items: [CatalogItem!]! """ - Sum of the eight highest values, each code counted once. Calculated. null + Sum of the eight highest values, each code counted once; a dismount always + counts, in place of the lowest of the eight. 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. @@ -339,6 +347,24 @@ type Routine { updatedAt: DateTime! } +""" +The D-score of a list of items, and which of them count (see `scoreRoutine`). +""" +type RoutineScore { + """ + The D-score: the same number as `calculateDScore` gives. + """ + dScore: Float! + """ + One for each id in `itemIds`, in the same order: true when the item counts. + An item does not count when it is outside the eight highest values, or when + an item with the same code counts already (the first of them counts). A + dismount always counts, in place of the lowest of the eight. When + two items have the same value at the edge of the eight, the first counts. + """ + counted: [Boolean!]! +} + type AuthPayload { """ Short-lived token (15 minutes). The client sends it in the header diff --git a/backend/src/server/calculator.test.ts b/backend/src/server/calculator.test.ts index e221325..fd13cb6 100644 --- a/backend/src/server/calculator.test.ts +++ b/backend/src/server/calculator.test.ts @@ -5,6 +5,7 @@ import type { ApolloServer } from '@apollo/server' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { calculateDScore } from '../dscore.js' +import { catalogStructure } from '../catalog/structure.js' import { createPrismaClient } from '../db.js' import type { PrismaClient } from '../generated/prisma/client.js' import { createApolloServer } from './app.js' @@ -163,6 +164,9 @@ describe.runIf(hasDatabase)('calculateDScore', () => { adapter: new PrismaPg({ connectionString: process.env['DATABASE_URL']! }), log: [{ emit: 'event', level: 'query' }], }) + // The apparatus and groups are read once and kept (catalog/structure.ts), so they are + // read before counting: the count is about the queries of this request. + await catalogStructure(logging as unknown as PrismaClient) logging.$on('query', (e) => queries.push(e.query)) try { const ids = (await elements('UB', 30)).map((r) => r.id) @@ -172,4 +176,82 @@ describe.runIf(hasDatabase)('calculateDScore', () => { await logging.$disconnect() } }) + + /** `scoreRoutine` for the ids: the score and which of them count. */ + const score = async (ids: string[]) => { + const response = await server.executeOperation( + { + query: + 'query ($ids: [ID!]!) { scoreRoutine(itemIds: $ids) { dScore counted } }', + variables: { ids }, + }, + { contextValue: createContext(prisma, config) }, + ) + if (response.body.kind !== 'single') throw new Error('expected one result') + const { data, errors } = response.body.singleResult + return { + score: ( + data as { scoreRoutine: { dScore: number; counted: boolean[] } } | null + )?.scoreRoutine, + code: errors?.[0]?.extensions?.['code'], + } + } + + it('scoreRoutine gives the same score as calculateDScore, and says which items count', async () => { + const ids = (await elements('UB', 10)).map((r) => r.id) + const { score: result } = await score(ids) + expect(result!.dScore).toBe((await run(ids)).data!.calculateDScore) + expect(result!.counted).toHaveLength(ids.length) + // Ten different codes: eight count and two do not. + expect(result!.counted.filter(Boolean)).toHaveLength(8) + }) + + it('scoreRoutine does not count the same id twice', async () => { + const [first] = await elements('BB', 1) + expect((await score([first!.id, first!.id])).score!.counted).toEqual([ + true, + false, + ]) + }) + + it('scoreRoutine refuses the same input as calculateDScore', async () => { + expect((await score(['UB-9.999-z'])).code).toBe('BAD_USER_INPUT') + const [ub] = await elements('UB', 1) + const [bb] = await elements('BB', 1) + expect((await score([ub!.id, bb!.id])).code).toBe('INVALID_ITEMS') + }) + + it('scoreRoutine always counts the dismount, also when it is not among the eight highest', async () => { + const groups = await prisma.elementGroup.findMany({ + where: { apparatusId: 'UB' }, + }) + const dismounts = groups.find((g) => g.name === 'Dismounts')!.id + const [dismount] = await prisma.element.findMany({ + where: { elementGroupId: dismounts, removedAt: null }, + orderBy: { value: 'asc' }, + take: 1, + }) + // Nine elements with a higher value than the dismount, each with its own code. + const higher = ( + await prisma.element.findMany({ + where: { + id: { startsWith: 'UB-' }, + elementGroupId: { not: dismounts }, + removedAt: null, + value: { gt: dismount!.value }, + }, + orderBy: { value: 'desc' }, + }) + ) + .filter((r, i, all) => all.findIndex((o) => o.code === r.code) === i) + .slice(0, 9) + expect(higher).toHaveLength(9) + + const { score: result } = await score([ + ...higher.map((r) => r.id), + dismount!.id, + ]) + expect(result!.counted.at(-1)).toBe(true) + expect(result!.counted.filter(Boolean)).toHaveLength(8) + }) }) diff --git a/backend/src/server/resolvers/calculator.ts b/backend/src/server/resolvers/calculator.ts index 3136d68..b7d4e53 100644 --- a/backend/src/server/resolvers/calculator.ts +++ b/backend/src/server/resolvers/calculator.ts @@ -1,6 +1,7 @@ // The calculator: the D-score of a list of items, for everyone. Nothing is saved. -import { calculateDScore, type ScoredItem } from '../../dscore.js' +import { catalogStructure } from '../../catalog/structure.js' +import { scoreItems, type RoutineScore, type ScoredItem } from '../../dscore.js' import { BadUserInputError, InvalidItemsError } from '../../errors.js' import type { Resolvers } from '../../generated/graphql.js' import type { Context } from '../context.js' @@ -9,55 +10,78 @@ 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 +/** + * The items of the ids, in the order of the ids (the same id twice gives the + * item twice). Throws BAD_USER_INPUT for too many ids, a badly formed id or an + * id that does not exist, and INVALID_ITEMS for a removed item. + */ +async function loadItems( + itemIds: string[], + context: Context, +): Promise { + if (itemIds.length > MAX_CALCULATOR_IDS) { + throw new BadUserInputError( + `At most ${MAX_CALCULATOR_IDS} ids can be calculated at once.`, + ) + } + if (itemIds.some((id) => !ITEM_ID_PATTERN.test(id))) { + throw new BadUserInputError('An item id is not valid.') + } + const ids = [...new Set(itemIds)] + + 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.`, + ) + } + + // Which groups are dismounts (a dismount always counts). Read once and kept in memory. + const { dismountGroupIds } = + elements.length > 0 + ? await catalogStructure(context.prisma) + : { dismountGroupIds: new Set() } + + const byId = new Map() + for (const e of elements) { + byId.set(e.id, { + id: e.id, + code: e.code, + value: e.value, + isVault: false, + isDismount: dismountGroupIds.has(e.elementGroupId), + }) + } + for (const v of vaults) { + byId.set(v.id, { id: v.id, code: v.code, value: v.dValue, isVault: true }) + } + return itemIds.map((id) => byId.get(id)!) +} + export const calculatorResolvers = { Query: { + // The same id twice counts once: it has the same code (see scoreItems). 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) - }, + ): Promise => + scoreItems(await loadItems(args.itemIds, context)).dScore, + + scoreRoutine: async ( + _parent: unknown, + args: { itemIds: string[] }, + context: Context, + ): Promise => + scoreItems(await loadItems(args.itemIds, context)), }, } satisfies Resolvers diff --git a/backend/src/server/resolvers/userData.ts b/backend/src/server/resolvers/userData.ts index 4bb2619..3846373 100644 --- a/backend/src/server/resolvers/userData.ts +++ b/backend/src/server/resolvers/userData.ts @@ -7,6 +7,7 @@ // - adding an item that is already in the gymnast's list, or removing one that // is not, changes nothing and gives no error +import { catalogStructure } from '../../catalog/structure.js' import { calculateDScore, toScoredItem } from '../../dscore.js' import { BadUserInputError, @@ -129,7 +130,10 @@ export const userDataResolvers = { 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)) + const { dismountGroupIds } = await catalogStructure(context.prisma) + return calculateDScore( + items.map((item) => toScoredItem(item, dismountGroupIds)), + ) }, }, diff --git a/backend/src/server/userData.test.ts b/backend/src/server/userData.test.ts index b18babc..5e71f2b 100644 --- a/backend/src/server/userData.test.ts +++ b/backend/src/server/userData.test.ts @@ -9,6 +9,7 @@ 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 { catalogStructure } from '../catalog/structure.js' import { createPrismaClient } from '../db.js' import { calculateDScore, toScoredItem } from '../dscore.js' import { PrismaClient as LoggingClient } from '../generated/prisma/client.js' @@ -766,7 +767,7 @@ describe.runIf(hasDatabase)('gymnasts, lists and routines', () => { ) const rows = await prisma.element.findMany({ where: { id: { in: ids } } }) const expected = calculateDScore( - rows.map((r) => toScoredItem(asElement(r))), + rows.map((r) => toScoredItem(asElement(r), new Set())), ) expect(result.setRoutineItems.dScore).toBe(expected) expect(expected).toBeGreaterThan(0) @@ -790,7 +791,7 @@ describe.runIf(hasDatabase)('gymnasts, lists and routines', () => { where: { id: vaults[1]! }, }) expect(result.setRoutineItems.dScore).toBe( - calculateDScore([toScoredItem(asVault(vault))]), + calculateDScore([toScoredItem(asVault(vault), new Set())]), ) }) @@ -993,6 +994,9 @@ describe.runIf(hasDatabase)('gymnasts, lists and routines', () => { }), log: [{ emit: 'event', level: 'query' }], }) + // The apparatus and groups are read once and kept (catalog/structure.ts), so they are + // read before counting: the count is about the queries of this request. + await catalogStructure(logging as unknown as PrismaClient) logging.$on('query', (e) => queries.push(e.query)) try { const result = await run<{ diff --git a/docs/API.md b/docs/API.md index a736208..b7ca8df 100644 --- a/docs/API.md +++ b/docs/API.md @@ -79,7 +79,8 @@ When a new Code of Points drops an element or vault, it is marked as removed in - **`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 normalized: written in Unicode normal form C (so an accented letter typed as one character, or as a letter and an accent, is the same name), with the spaces at the start and the end removed and every run of white space inside the name made into one space. The result must be 1–70 characters. A longer name, or a name with only spaces, throws `BAD_USER_INPUT`. The length is counted in Unicode characters (code points), not in bytes or UTF-16 units, so a name with accents or a simple emoji gets the same room as any other. A flag or a family of emoji is made of several characters and counts as several. 70 characters is the length that the UK Government Data Standards give for a full name in one field. The rule is one file, `shared/names.ts`, which the backend and the form in the frontend both import, so they cannot disagree about what a valid name is. The database does not check the limits yet. `NAME_TAKEN` is checked on the normalized name. The form for a gymnast has the same rule: it does not let more than 70 characters be typed or pasted, and answers "Name is required" when there is no 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. +- `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, where a dismount always counts (it takes the place of the lowest of the eight when it is not among them), 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. +- `scoreRoutine(itemIds)` is what the calculator page uses. It has the same rule, the same limit and the same errors as `calculateDScore`, and gives `{ dScore, counted }`: the score, and one `true` or `false` for each id in `itemIds`, in the same order, so the page can mark the items that do not count ("Not counted"). An item does not count when it is outside the eight highest values, or when an item with the same code counts already. Of items with the same code, or the same value at the edge of the eight, the first in the list counts. The rule is in one place (`scoreItems` in `backend/src/dscore.ts`), and `calculateDScore` gives its `dScore`. - 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 @@ -166,7 +167,7 @@ The requirement is that the solution works with any amount of data. The catalog - **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. +- **Limit the size of the arguments.** `calculateDScore`, `scoreRoutine` and `setRoutineItems` take at most 50 ids. ### User data @@ -230,7 +231,7 @@ The access token is short, because the client keeps it and a token that leaks ca - 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. +The catalog, `searchCatalog`, `calculateDScore` and `scoreRoutine` do not need a login. Everything about gymnasts and routines does, and a coach can only reach their own data. ### Login for development @@ -487,7 +488,7 @@ Vitest runs the API tests against a test database, with queries sent to the serv - **Difficulty sort:** without an apparatus filter it gives `BAD_USER_INPUT`, and with one it only returns items from that apparatus. - **Difficulty values:** choosing `[0.1, 0.2]` returns elements with value 0.1 or 0.2, vaults are left out, a value such as 0.15 or 1.1 gives `BAD_USER_INPUT`, and together with vault the values are D-values: `[2.4]` returns the vaults with D-value 2.4, and 10.1 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 D-score:** the eight highest values, each code counted once and the dismount always counted, 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. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 434b2d1..442bcfe 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -271,7 +271,7 @@ Constraints 7 and 8 compare several rows, or a row with its routine, so they can ### Calculated values 12. The difficulty letter (A–J) is not stored, and not part of the API. The frontend calculates it from `value`: 0.1 = A, 0.2 = B … 1.0 = J. -13. The D-value of a routine is calculated, not stored. It is the sum of the eight highest values in the routine (`value` for elements, `dValue` for vaults), where each `code` is counted only once. Two variants of the same element therefore only count once, as in the Code of Points. On vault the routine holds one vault, so the D-value is that vault's `dValue`. 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. +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. A dismount (an element of the "Dismounts" group on uneven bars and balance beam) always counts: when it is not among the eight highest, it takes the place of the lowest of them. The calculator also lets a routine have at most one mount and one dismount. 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 From 3dce6112b9aaf23fd6c8c12ec12fd467035f58f4 Mon Sep 17 00:00:00 2001 From: Anettkva Date: Wed, 11 Nov 2026 10:43:38 +0100 Subject: [PATCH 02/14] test: move the search answer fixtures to elementRows --- src/tests/elementRows.ts | 44 +++++++++++++++++++++++ src/tests/pages/ElementsPage.test.tsx | 50 +++------------------------ 2 files changed, 49 insertions(+), 45 deletions(-) diff --git a/src/tests/elementRows.ts b/src/tests/elementRows.ts index 93b4398..8e6bbae 100644 --- a/src/tests/elementRows.ts +++ b/src/tests/elementRows.ts @@ -2,6 +2,7 @@ // page. The ids, texts, values and group numbers exist in the database. import type { ElementListRow } from '@/lib/elementList' +import { ok, type Answer } from '@/tests/apollo' const rowsWithoutImage: Omit[] = [ { @@ -108,3 +109,46 @@ export const exampleElementRows: ElementListRow[] = rowsWithoutImage.map( /** How many results the example server says there are in all. */ export const exampleTotalCount = 32 + +/** What the server says for one row (an Element has `value`, a Vault has `dValue`). */ +export const nodeOf = (row: ElementListRow) => ({ + __typename: row.isVault ? 'Vault' : 'Element', + id: row.id, + code: row.code, + name: row.name, + description: row.description, + imageUrl: row.imageUrl, + elementGroup: { + __typename: 'ElementGroup', + name: row.elementGroupName, + number: row.elementGroupNumber, + apparatus: { __typename: 'Apparatus', name: row.apparatusName }, + }, + ...(row.isVault ? { dValue: row.value } : { value: row.value }), + variants: row.hasVariants + ? [{ __typename: 'Element', id: `${row.id}-x` }] + : [], +}) + +/** What the server says to `SearchCatalog`. */ +export const searchAnswer = ( + rows: ElementListRow[], + totalCount = rows.length, + hasNextPage = false, +): Answer => + ok({ + searchCatalog: { + __typename: 'CatalogConnection', + totalCount, + pageInfo: { + __typename: 'PageInfo', + hasNextPage, + endCursor: rows.length ? rows[rows.length - 1].id : null, + }, + edges: rows.map((row) => ({ + __typename: 'CatalogEdge', + cursor: row.id, + node: nodeOf(row), + })), + }, + }) diff --git a/src/tests/pages/ElementsPage.test.tsx b/src/tests/pages/ElementsPage.test.tsx index c75b6df..7aad85d 100644 --- a/src/tests/pages/ElementsPage.test.tsx +++ b/src/tests/pages/ElementsPage.test.tsx @@ -15,7 +15,6 @@ import { import { print } from 'graphql' import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom' import { afterEach, describe, expect, it } from 'vitest' -import type { ElementListRow } from '@/lib/elementList' import { MAX_SEARCH_LENGTH, PAGE_SIZE, @@ -31,54 +30,15 @@ import { type Handler, type Operation, } from '@/tests/apollo' -import { exampleElementRows, exampleTotalCount } from '@/tests/elementRows' +import { + exampleElementRows, + exampleTotalCount, + searchAnswer, +} from '@/tests/elementRows' import ElementsPage from '@/pages/ElementsPage' afterEach(cleanup) -/** What the server says for one row (an Element has `value`, a Vault has `dValue`). */ -const nodeOf = (row: ElementListRow) => ({ - __typename: row.isVault ? 'Vault' : 'Element', - id: row.id, - code: row.code, - name: row.name, - description: row.description, - imageUrl: row.imageUrl, - elementGroup: { - __typename: 'ElementGroup', - name: row.elementGroupName, - number: row.elementGroupNumber, - apparatus: { __typename: 'Apparatus', name: row.apparatusName }, - }, - ...(row.isVault ? { dValue: row.value } : { value: row.value }), - variants: row.hasVariants - ? [{ __typename: 'Element', id: `${row.id}-x` }] - : [], -}) - -/** What the server says to `SearchCatalog`. */ -const searchAnswer = ( - rows: ElementListRow[], - totalCount = rows.length, - hasNextPage = false, -): Answer => - ok({ - searchCatalog: { - __typename: 'CatalogConnection', - totalCount, - pageInfo: { - __typename: 'PageInfo', - hasNextPage, - endCursor: rows.length ? rows[rows.length - 1].id : null, - }, - edges: rows.map((row) => ({ - __typename: 'CatalogEdge', - cursor: row.id, - node: nodeOf(row), - })), - }, - }) - /** The server has seven rows, and finds only those with the search text in them. */ const serverWithRows: Handler = ({ variables }) => { const text = String(variables['search'] ?? '').toLowerCase() From 4e49055404441227fb1372511ef58dffee2a8020 Mon Sep 17 00:00:00 2001 From: Anettkva Date: Wed, 11 Nov 2026 10:43:52 +0100 Subject: [PATCH 03/14] fix(ui): let long button text wrap inside ConfirmDialog --- src/components/ConfirmDialog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index ede7333..1857c69 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -89,7 +89,7 @@ export default function ConfirmDialog({ render={ From 4d279e0bfc6f1a2fbd312c4167d5fca28a57e0db Mon Sep 17 00:00:00 2001 From: Anettkva Date: Wed, 11 Nov 2026 10:44:09 +0100 Subject: [PATCH 04/14] feat(ui): add a pill look to ApparatusTabs --- src/components/gymnasts/ApparatusTabs.tsx | 34 +++++++++++++++++------ 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/src/components/gymnasts/ApparatusTabs.tsx b/src/components/gymnasts/ApparatusTabs.tsx index 589d90c..df49ed6 100644 --- a/src/components/gymnasts/ApparatusTabs.tsx +++ b/src/components/gymnasts/ApparatusTabs.tsx @@ -1,13 +1,12 @@ import type { ReactNode } from 'react' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import type { ApparatusView } from '@/lib/gymnastDetail' /** * One tab for each apparatus, with only its name. It is the shadcn/ui * tabs (`components/ui/tabs.tsx`, on the Base UI tabs, so the WAI-ARIA tabs pattern: arrow * keys, Home and End, a panel for each tab) in its "line" look. The panel of the chosen * apparatus is made by `renderPanel`. The tabs follow the focus, because a panel shows at - * once and costs nothing to open. + * once and costs nothing to open. The gymnast page and the calculator use it. * * The classes only set what the shadcn/ui defaults cannot do on this site: the colors of the * site (the defaults are 60 % gray text and a faint focus ring, which are not enough, WCAG @@ -15,30 +14,47 @@ import type { ApparatusView } from '@/lib/gymnastDetail' * has the line of the component under it and a bold name, so it is not told by a tone alone * (WCAG 1.4.1), and `aria-selected` tells a screen reader. */ -export default function ApparatusTabs({ +export default function ApparatusTabs({ apparatus, value, onValueChange, renderPanel, + look = 'line', }: { - apparatus: ApparatusView[] + apparatus: T[] value: string onValueChange: (id: string) => void - renderPanel: (apparatus: ApparatusView) => ReactNode + renderPanel: (apparatus: T) => ReactNode + /** + * "line" for the gymnast page, "pills" (rounded buttons) for the calculator. In the + * calculator choosing a tab can ask a question (the routine would be cleared), so the + * arrow keys only move the focus there and Enter or Space chooses the tab (the WAI-ARIA + * tabs pattern with manual activation). + */ + look?: 'line' | 'pills' }) { + const pills = look === 'pills' return ( onValueChange(String(id))}> {apparatus.map((one) => ( {one.name} From df66879320c8a167bc0325ee419637e956a75b7d Mon Sep 17 00:00:00 2001 From: Anettkva Date: Wed, 11 Nov 2026 10:44:40 +0100 Subject: [PATCH 05/14] feat(calculator): add the rules of a calculator routine --- src/lib/calculatorRoutine.ts | 247 ++++++++++++++++++++++++ src/tests/lib/calculatorRoutine.test.ts | 193 ++++++++++++++++++ 2 files changed, 440 insertions(+) create mode 100644 src/lib/calculatorRoutine.ts create mode 100644 src/tests/lib/calculatorRoutine.test.ts diff --git a/src/lib/calculatorRoutine.ts b/src/lib/calculatorRoutine.ts new file mode 100644 index 0000000..6ed7926 --- /dev/null +++ b/src/lib/calculatorRoutine.ts @@ -0,0 +1,247 @@ +// The routine of the calculator: what the visitor has put together on one apparatus. It +// is kept in the client only, while the page is open (nothing is saved), and the element +// page ("Try in a routine") and the calculator page share it (see +// src/components/calculator/useCalculatorRoutine.ts). The D-score comes from the API +// (`scoreRoutine`), so the rule of the score is not repeated here. + +import type { ElementDetail } from '@/lib/catalog' +import { getDifficultyLetter } from '@/lib/difficulty' +import type { ElementListRow } from '@/lib/elementList' +import { MAX_NAME_LENGTH, nameProblem, normalizeName } from '../../shared/names' + +/** The apparatus of the calculator, in the order of the tabs (as in the design). */ +export const CALCULATOR_APPARATUS = [ + { id: 'VT', name: 'Vault' }, + { id: 'UB', name: 'Uneven bars' }, + { id: 'BB', name: 'Balance beam' }, + { id: 'FX', name: 'Floor exercise' }, +] + +/** The name a new routine on the apparatus starts with. The visitor can change it (the pencil). */ +export const defaultRoutineName = (apparatusId: string): string => + `${apparatusName(apparatusId)} routine` + +/** The most items the API scores at once (`MAX_CALCULATOR_IDS` in the backend). */ +export const MAX_ROUTINE_ITEMS = 50 + +/** One element or vault in the routine: what the routine panel shows. */ +export type RoutineItem = { + id: string + code: string + /** The gymnast the element is named after, if any. */ + name: string | null + description: string + /** The value of an element, or the difficulty (D-value) of a vault. */ + value: number + isVault: boolean + apparatusId: string + /** A mount or a dismount (uneven bars and balance beam): a routine has at most one of each. */ + kind: ItemKind +} + +export type ItemKind = 'mount' | 'dismount' | null + +/** Mount, dismount or neither, from the name of the element group (data/elementGroups.json). */ +export const kindOfGroup = (groupName: string): ItemKind => + groupName === 'Mounts' + ? 'mount' + : groupName === 'Dismounts' + ? 'dismount' + : null + +export type CalculatorRoutine = { + apparatusId: string + /** The name of the routine, with the same rule as a saved routine (shared/names.ts). */ + name: string + /** The order of the routine: where an item is added, or where it was moved to. */ + items: RoutineItem[] +} + +/** A new, empty routine on the apparatus. */ +export const newRoutine = (apparatusId: string): CalculatorRoutine => ({ + apparatusId, + name: defaultRoutineName(apparatusId), + items: [], +}) + +/** The apparatus of an id: "UB-1.101-a" is on "UB". */ +export const apparatusOfId = (id: string): string => id.split('-')[0]! + +export const apparatusName = (apparatusId: string): string => + CALCULATOR_APPARATUS.find((one) => one.id === apparatusId)?.name ?? + apparatusId + +/** The routine the calculator starts with. */ +export const EMPTY_ROUTINE: CalculatorRoutine = newRoutine('UB') + +/** + * Why an item was not added: + * - `other-apparatus`: the routine has items on another apparatus. The page asks the + * visitor if a new routine should be started (a routine is on one apparatus). + * - `one-vault`: a routine on vault is one vault. + * - `full`: the routine has as many items as the API scores at once. + * - `mount`, `dismount`: a routine has at most one mount and one dismount. + */ +export type AddRefusal = + 'already' | 'other-apparatus' | 'one-vault' | 'full' | 'mount' | 'dismount' + +export type AddResult = + | { added: true; routine: CalculatorRoutine } + | { added: false; reason: AddRefusal } + +/** + * The routine with the item added at the end. An empty routine follows the apparatus of + * the item, so the first element can come from any apparatus. + */ +export function addItem( + routine: CalculatorRoutine, + item: RoutineItem, +): AddResult { + if (routine.items.some((one) => one.id === item.id)) { + return { added: false, reason: 'already' } + } + if (routine.items.length === 0) { + // A name that is still the start name follows the apparatus. + const name = + routine.name === defaultRoutineName(routine.apparatusId) + ? defaultRoutineName(item.apparatusId) + : routine.name + return { + added: true, + routine: { apparatusId: item.apparatusId, name, items: [item] }, + } + } + if (item.apparatusId !== routine.apparatusId) { + return { added: false, reason: 'other-apparatus' } + } + if (item.isVault) return { added: false, reason: 'one-vault' } + if (item.kind && routine.items.some((one) => one.kind === item.kind)) { + return { added: false, reason: item.kind } + } + if (routine.items.length >= MAX_ROUTINE_ITEMS) { + return { added: false, reason: 'full' } + } + return { + added: true, + routine: { ...routine, items: [...routine.items, item] }, + } +} + +export const removeItem = ( + routine: CalculatorRoutine, + id: string, +): CalculatorRoutine => ({ + ...routine, + items: routine.items.filter((one) => one.id !== id), +}) + +/** The routine with the item moved to `index` (0 is first). Other items keep their order. */ +export function moveItem( + routine: CalculatorRoutine, + id: string, + index: number, +): CalculatorRoutine { + const from = routine.items.findIndex((one) => one.id === id) + if (from === -1) return routine + const to = Math.max(0, Math.min(index, routine.items.length - 1)) + const items = [...routine.items] + const [item] = items.splice(from, 1) + items.splice(to, 0, item!) + return { ...routine, items } +} + +/** + * What is wrong with a new name for the routine, or null when it can be used. It is the + * rule of a saved routine (1–70 characters after the spaces are tidied), so #152 can + * save the name as it is. + */ +export function routineNameProblem(name: string): string | null { + switch (nameProblem(name)) { + case 'empty': + return 'Enter a name for the routine.' + case 'too-long': + return `The name can be at most ${MAX_NAME_LENGTH} characters.` + default: + return null + } +} + +export const tidyRoutineName = normalizeName + +/** + * The most the name field lets the visitor type: twice the rule, because `maxLength` counts + * UTF-16 units and an emoji is two. The exact check is `routineNameProblem` (as in gymnasts.ts). + */ +export const MAX_ROUTINE_NAME_INPUT = MAX_NAME_LENGTH * 2 + +/** + * What the "Counted" column says about an item, from `counted` of `scoreRoutine`. An + * item that does not count is either outside the eight highest values, or has the code of + * an item that counts already (a variant, or the same element twice). + */ +export function countedText( + items: Pick[], + counted: boolean[], + index: number, +): string { + if (counted[index]) return 'Included' + const sameCodeCounts = items.some( + (other, i) => + i !== index && counted[i] && other.code === items[index]!.code, + ) + return sameCodeCounts ? 'Same code counted' : 'Not in top 8' +} + +/** What the status message says when an item could not be added. */ +export function refusalMessage( + reason: AddRefusal, + item: RoutineItem, + routine: CalculatorRoutine, +): string { + switch (reason) { + case 'already': + return `${item.code} is already in the routine.` + case 'other-apparatus': + return `${item.code} is on ${apparatusName(item.apparatusId)}, and the routine is on ${apparatusName(routine.apparatusId)}.` + case 'one-vault': + return `A routine on vault is one vault. Remove ${routine.items[0]!.code} first.` + case 'full': + return `A routine can have at most ${MAX_ROUTINE_ITEMS} elements.` + case 'mount': + case 'dismount': + return `A routine has at most one ${reason}. Remove ${routine.items.find((one) => one.kind === reason)!.code} first.` + } +} + +/** "C 0.3" for an element, "2.6" for a vault. */ +export function valueText( + item: Pick, +): string { + const value = item.value.toFixed(1) + const letter = item.isVault ? undefined : getDifficultyLetter(item.value) + return letter ? `${letter} ${value}` : value +} + +/** A row of the search (the calculator's list) as an item of the routine. */ +export const rowToRoutineItem = (row: ElementListRow): RoutineItem => ({ + id: row.id, + code: row.code, + name: row.name, + description: row.description, + value: row.value, + isVault: row.isVault, + apparatusId: apparatusOfId(row.id), + kind: kindOfGroup(row.elementGroupName), +}) + +/** The element of the element page as an item of the routine ("Try in a routine"). */ +export const detailToRoutineItem = (element: ElementDetail): RoutineItem => ({ + id: element.id, + code: element.code, + name: element.name, + description: element.description, + value: element.value, + isVault: element.isVault, + apparatusId: element.apparatusId, + kind: kindOfGroup(element.elementGroupName), +}) diff --git a/src/tests/lib/calculatorRoutine.test.ts b/src/tests/lib/calculatorRoutine.test.ts new file mode 100644 index 0000000..3ccfd9a --- /dev/null +++ b/src/tests/lib/calculatorRoutine.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest' +import { + addItem, + countedText, + moveItem, + routineNameProblem, + EMPTY_ROUTINE, + MAX_ROUTINE_ITEMS, + refusalMessage, + removeItem, + valueText, + type CalculatorRoutine, + type RoutineItem, +} from '@/lib/calculatorRoutine' + +const item = ( + id: string, + value = 0.3, + isVault = false, + kind: RoutineItem['kind'] = null, +): RoutineItem => ({ + id, + code: id.split('-')[1]!, + name: null, + description: `Description of ${id}`, + value, + isVault, + apparatusId: id.split('-')[0]!, + kind, +}) + +const added = (routine: CalculatorRoutine, one: RoutineItem) => { + const result = addItem(routine, one) + if (!result.added) throw new Error(`not added: ${result.reason}`) + return result.routine +} + +describe('addItem', () => { + it('adds at the end, so the order is the order of the routine', () => { + const routine = added( + added(EMPTY_ROUTINE, item('UB-1.101-a')), + item('UB-2.305-a'), + ) + expect(routine.items.map((one) => one.id)).toEqual([ + 'UB-1.101-a', + 'UB-2.305-a', + ]) + }) + + it('lets an empty routine follow the apparatus of its first item', () => { + expect(added(EMPTY_ROUTINE, item('BB-1.101-a')).apparatusId).toBe('BB') + }) + + it('refuses an item that is in the routine already', () => { + const routine = added(EMPTY_ROUTINE, item('UB-1.101-a')) + expect(addItem(routine, item('UB-1.101-a'))).toEqual({ + added: false, + reason: 'already', + }) + }) + + it('refuses an item of another apparatus when the routine has items', () => { + const routine = added(EMPTY_ROUTINE, item('UB-1.101-a')) + expect(addItem(routine, item('BB-1.101-a'))).toEqual({ + added: false, + reason: 'other-apparatus', + }) + }) + + it('keeps a routine on vault to one vault', () => { + const routine = added(EMPTY_ROUTINE, item('VT-1.22-a', 2.6, true)) + expect(addItem(routine, item('VT-2.31-a', 4.6, true))).toEqual({ + added: false, + reason: 'one-vault', + }) + }) + + it('refuses more items than the API scores at once', () => { + const full: CalculatorRoutine = { + apparatusId: 'FX', + name: 'New routine', + items: Array.from({ length: MAX_ROUTINE_ITEMS }, (_, i) => + item(`FX-${i}.101-a`), + ), + } + expect(addItem(full, item('FX-9.999-a'))).toEqual({ + added: false, + reason: 'full', + }) + }) +}) + +describe('the rest of the routine', () => { + it('removes an item and keeps the apparatus', () => { + const routine = added(EMPTY_ROUTINE, item('BB-1.101-a')) + expect(removeItem(routine, 'BB-1.101-a')).toEqual({ + apparatusId: 'BB', + name: 'Balance beam routine', + items: [], + }) + }) + + it('says why an item was not added', () => { + const routine = added(EMPTY_ROUTINE, item('VT-1.22-a', 2.6, true)) + expect( + refusalMessage('one-vault', item('VT-2.31-a', 4.6, true), routine), + ).toBe('A routine on vault is one vault. Remove 1.22 first.') + expect(refusalMessage('other-apparatus', item('BB-1.101-a'), routine)).toBe( + '1.101 is on Balance beam, and the routine is on Vault.', + ) + }) + + it('shows the letter and the value of an element, and only the value of a vault', () => { + expect(valueText(item('UB-2.305-a', 0.3))).toBe('C 0.3') + expect(valueText(item('VT-1.22-a', 2.6, true))).toBe('2.6') + }) +}) + +describe('the order, the counted column and the name', () => { + const three = [ + item('UB-1.101-a'), + item('UB-2.305-a'), + item('UB-3.402-a'), + ].reduce(added, EMPTY_ROUTINE) + const order = (routine: CalculatorRoutine) => + routine.items.map((one) => one.code) + + it('moves an item, and keeps the others in their order', () => { + expect(order(moveItem(three, 'UB-3.402-a', 0))).toEqual([ + '3.402', + '1.101', + '2.305', + ]) + expect(order(moveItem(three, 'UB-1.101-a', 1))).toEqual([ + '2.305', + '1.101', + '3.402', + ]) + // A place outside the list is the first or the last. + expect(order(moveItem(three, 'UB-1.101-a', 9))).toEqual([ + '2.305', + '3.402', + '1.101', + ]) + }) + + it('says why an item does not count', () => { + const items = [{ code: '1.101' }, { code: '1.101' }, { code: '2.305' }] + const counted = [true, false, false] + expect(countedText(items, counted, 0)).toBe('Included') + expect(countedText(items, counted, 1)).toBe('Same code counted') + expect(countedText(items, counted, 2)).toBe('Not in top 8') + }) + + it('takes a name with the rule of a saved routine', () => { + expect(routineNameProblem('October draft')).toBeNull() + expect(routineNameProblem(' ')).toBe('Enter a name for the routine.') + expect(routineNameProblem('x'.repeat(71))).toBe( + 'The name can be at most 70 characters.', + ) + }) + + it('has at most one mount and one dismount', () => { + const routine = [ + item('UB-1.101-a', 0.1, false, 'mount'), + item('UB-6.101-a', 0.2, false, 'dismount'), + ].reduce(added, EMPTY_ROUTINE) + expect(addItem(routine, item('UB-1.102-a', 0.1, false, 'mount'))).toEqual({ + added: false, + reason: 'mount', + }) + expect( + addItem(routine, item('UB-6.102-a', 0.2, false, 'dismount')), + ).toEqual({ added: false, reason: 'dismount' }) + expect(addItem(routine, item('UB-2.305-a')).added).toBe(true) + expect( + refusalMessage( + 'dismount', + item('UB-6.102-a', 0.2, false, 'dismount'), + routine, + ), + ).toBe('A routine has at most one dismount. Remove 6.101 first.') + }) + + it('names a new routine after its apparatus, also when the first item is from another one', () => { + expect(EMPTY_ROUTINE.name).toBe('Uneven bars routine') + expect(added(EMPTY_ROUTINE, item('BB-1.101-a')).name).toBe( + 'Balance beam routine', + ) + const renamed = { ...EMPTY_ROUTINE, name: 'October draft' } + expect(added(renamed, item('BB-1.101-a')).name).toBe('October draft') + }) +}) From 0431bc647b962a9481746998fe251ec45109990f Mon Sep 17 00:00:00 2001 From: Anettkva Date: Wed, 11 Nov 2026 10:46:16 +0100 Subject: [PATCH 06/14] feat(calculator): share the routine between pages and connect "Try in a routine" --- src/App.tsx | 55 +++++++------ .../calculator/CalculatorRoutineProvider.tsx | 59 ++++++++++++++ .../calculator/useCalculatorRoutine.ts | 41 ++++++++++ src/components/elements/ElementActions.tsx | 65 +++++++++++++++- .../elements/ElementActions.test.tsx | 77 +++++++++++++++++-- src/tests/pages/ElementDetailPage.test.tsx | 19 +++-- 6 files changed, 277 insertions(+), 39 deletions(-) create mode 100644 src/components/calculator/CalculatorRoutineProvider.tsx create mode 100644 src/components/calculator/useCalculatorRoutine.ts diff --git a/src/App.tsx b/src/App.tsx index dd8dd65..56d913b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { SkipLink } from './components/skipLink/SkipLink' import { GoToTop } from './components/goToTop/GoToTop' import { ProtectedRoute } from './components/ProtectedRoute' import { AuthProvider } from './components/auth/AuthProvider' +import { CalculatorRoutineProvider } from './components/calculator/CalculatorRoutineProvider' // One page for each route (src/pages/). The parts of the pages are in src/components/. import AboutPage from '@/pages/AboutPage' @@ -36,33 +37,39 @@ export function App() { return ( - - setIsDarkMode((current) => !current)} - /> - {/* The pages have their own
, so this is only the target of the skip link. */} -
- - } /> - } /> - } /> + {/* The routine of the calculator, shared with "Try in a routine" on the element page. */} + + + setIsDarkMode((current) => !current)} + /> + {/* The pages have their own
, so this is only the target of the skip link. */} +
+ + } /> + } /> + } + /> - } /> - } /> - } /> + } /> + } /> + } /> - {/* Protected routes requiring login */} - }> - } /> - } /> - + {/* Protected routes requiring login */} + }> + } /> + } /> + - } /> - -
- {/* The last thing on every page: a link back to the start, for the keyboard and screen readers. */} - + } /> + +
+ {/* The last thing on every page: a link back to the start, for the keyboard and screen readers. */} + + ) } diff --git a/src/components/calculator/CalculatorRoutineProvider.tsx b/src/components/calculator/CalculatorRoutineProvider.tsx new file mode 100644 index 0000000..5fdc1e6 --- /dev/null +++ b/src/components/calculator/CalculatorRoutineProvider.tsx @@ -0,0 +1,59 @@ +import { useMemo, useRef, useState, type ReactNode } from 'react' +import { + addItem, + EMPTY_ROUTINE, + moveItem, + newRoutine, + removeItem, + tidyRoutineName, + type CalculatorRoutine, +} from '@/lib/calculatorRoutine' +import { + CalculatorRoutineContext, + type CalculatorRoutineState, +} from './useCalculatorRoutine' + +/** Holds the routine of the calculator for the whole site (see `useCalculatorRoutine`). */ +export function CalculatorRoutineProvider({ + children, + initial = EMPTY_ROUTINE, +}: { + children: ReactNode + /** Only for the tests. */ + initial?: CalculatorRoutine +}) { + const [routine, setRoutine] = useState(initial) + // `add` says at once whether the item was added, so it reads the routine as it is now, + // also after another change in the same event. + const current = useRef(routine) + const update = (next: CalculatorRoutine) => { + current.current = next + setRoutine(next) + } + + const state = useMemo( + () => ({ + routine, + add: (item) => { + const result = addItem(current.current, item) + if (result.added) update(result.routine) + return result + }, + remove: (id) => update(removeItem(current.current, id)), + clear: () => update({ ...current.current, items: [] }), + startOn: (apparatusId) => update(newRoutine(apparatusId)), + startWith: (item) => + update({ ...newRoutine(item.apparatusId), items: [item] }), + move: (id, index) => update(moveItem(current.current, id, index)), + rename: (name) => + update({ ...current.current, name: tidyRoutineName(name) }), + }), + [routine], + ) + + return ( + + {children} + + ) +} diff --git a/src/components/calculator/useCalculatorRoutine.ts b/src/components/calculator/useCalculatorRoutine.ts new file mode 100644 index 0000000..cefd853 --- /dev/null +++ b/src/components/calculator/useCalculatorRoutine.ts @@ -0,0 +1,41 @@ +import { createContext, useContext } from 'react' +import type { + AddResult, + CalculatorRoutine, + RoutineItem, +} from '@/lib/calculatorRoutine' + +/** + * The routine of the calculator, shared by the calculator page and "Try in a routine" on + * the element page, so both show the same routine. It lives while the site is open, and + * is gone after a reload: nothing is saved (see docs/UI_PLANS.md). + */ +export type CalculatorRoutineState = { + routine: CalculatorRoutine + /** Adds the item at the end, or says why it was not added (see `addItem`). */ + add: (item: RoutineItem) => AddResult + remove: (id: string) => void + /** Empties the routine and keeps the apparatus. */ + clear: () => void + /** Starts an empty routine on another apparatus. */ + startOn: (apparatusId: string) => void + /** Starts a new routine with only this item, on its apparatus. */ + startWith: (item: RoutineItem) => void + /** Moves the item to `index` in the order of the routine (0 is first). */ + move: (id: string, index: number) => void + /** Gives the routine a new name. Check it first with `routineNameProblem`. */ + rename: (name: string) => void +} + +export const CalculatorRoutineContext = + createContext(null) + +export function useCalculatorRoutine(): CalculatorRoutineState { + const state = useContext(CalculatorRoutineContext) + if (!state) { + throw new Error( + 'useCalculatorRoutine must be used inside a CalculatorRoutineProvider.', + ) + } + return state +} diff --git a/src/components/elements/ElementActions.tsx b/src/components/elements/ElementActions.tsx index 1002314..cbec783 100644 --- a/src/components/elements/ElementActions.tsx +++ b/src/components/elements/ElementActions.tsx @@ -1,9 +1,17 @@ import { useId, useState } from 'react' -import { Plus } from 'lucide-react' +import { Check, Plus } from 'lucide-react' +import { Link } from 'react-router-dom' +import { useCalculatorRoutine } from '@/components/calculator/useCalculatorRoutine' +import ConfirmDialog from '@/components/ConfirmDialog' import AddToGymnast from '@/components/elements/AddToGymnast' import ErrorMessage from '@/components/ErrorMessage' import { Button } from '@/components/ui/button' import { PILL } from '@/components/ui/pill' +import { + apparatusName, + detailToRoutineItem, + refusalMessage, +} from '@/lib/calculatorRoutine' import type { ElementDetail } from '@/lib/catalog' export default function ElementActions({ @@ -16,6 +24,33 @@ export default function ElementActions({ const reasonId = useId() // A screen reader reads the reason with each disabled button. const describedBy = element.removed ? reasonId : undefined + // "Try in a routine" adds the element to the calculator's routine, which the calculator + // page shows. It is a toggle: pressed while the element is in the routine. + const calculator = useCalculatorRoutine() + const item = detailToRoutineItem(element) + const inRoutine = calculator.routine.items.some((one) => one.id === item.id) + // The routine is on another apparatus: ask before starting a new one. + const [askNewRoutine, setAskNewRoutine] = useState(false) + const [linkToCalculator, setLinkToCalculator] = useState(false) + + function tryInRoutine() { + setError('') + if (inRoutine) { + calculator.remove(item.id) + setLinkToCalculator(false) + setStatus(`${item.code} removed from the routine in the calculator.`) + return + } + const result = calculator.add(item) + if (result.added) { + setLinkToCalculator(true) + setStatus(`${item.code} added to the routine in the calculator.`) + } else if (result.reason === 'other-apparatus') { + setAskNewRoutine(true) + } else { + setStatus(refusalMessage(result.reason, item, calculator.routine)) + } + } return (
setStatus('The routine builder is not connected yet.')} + aria-pressed={inRoutine} + onClick={tryInRoutine} > + {inRoutine &&
) diff --git a/src/tests/components/elements/ElementActions.test.tsx b/src/tests/components/elements/ElementActions.test.tsx index 33b9039..5234549 100644 --- a/src/tests/components/elements/ElementActions.test.tsx +++ b/src/tests/components/elements/ElementActions.test.tsx @@ -1,11 +1,19 @@ // @vitest-environment jsdom import { ApolloProvider } from '@apollo/client/react' -import { cleanup, render, screen } from '@testing-library/react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import { afterEach, describe, expect, it } from 'vitest' import { AuthProvider } from '@/components/auth/AuthProvider' +import { CalculatorRoutineProvider } from '@/components/calculator/CalculatorRoutineProvider' import ElementActions from '@/components/elements/ElementActions' +import type { CalculatorRoutine } from '@/lib/calculatorRoutine' import type { ElementDetail } from '@/lib/catalog' import { createTestClient, ok } from '@/tests/apollo' @@ -28,14 +36,16 @@ const element: ElementDetail = { } /** Nobody is logged in (`Me` answers null). */ -function renderActions(detail: ElementDetail) { +function renderActions(detail: ElementDetail, routine?: CalculatorRoutine) { const { client } = createTestClient(() => ok({ me: null })) return render( - - - + + + + + , ) @@ -75,4 +85,61 @@ describe('ElementActions', () => { expect(button.getAttribute('aria-describedby')).toBe(reason.id) } }) + + it('adds the element to the calculator with "Try in a routine", and removes it again', () => { + renderActions(element) + const button = screen.getByRole('button', { name: 'Try in a routine' }) + + fireEvent.click(button) + expect(button.getAttribute('aria-pressed')).toBe('true') + expect( + screen.getByText('2.305 added to the routine in the calculator.'), + ).toBeTruthy() + expect( + screen + .getByRole('link', { name: 'Open the calculator' }) + .getAttribute('href'), + ).toBe('/calculator') + + fireEvent.click(button) + expect(button.getAttribute('aria-pressed')).toBe('false') + expect( + screen.queryByRole('link', { name: 'Open the calculator' }), + ).toBeNull() + }) + + it('asks before it starts a new routine when the routine is on another apparatus', async () => { + renderActions(element, { + apparatusId: 'BB', + name: 'New routine', + items: [ + { + id: 'BB-1.101-a', + code: '1.101', + name: null, + description: 'Leap on', + value: 0.1, + isVault: false, + apparatusId: 'BB', + kind: 'mount', + }, + ], + }) + fireEvent.click(screen.getByRole('button', { name: 'Try in a routine' })) + expect((await screen.findByRole('alertdialog')).textContent).toContain( + 'is on Balance beam', + ) + + fireEvent.click(screen.getByRole('button', { name: 'Start a new routine' })) + await waitFor(() => + expect( + screen + .getByRole('button', { name: 'Try in a routine' }) + .getAttribute('aria-pressed'), + ).toBe('true'), + ) + expect( + screen.getByText('A new routine on Uneven bars was started with 2.305.'), + ).toBeTruthy() + }) }) diff --git a/src/tests/pages/ElementDetailPage.test.tsx b/src/tests/pages/ElementDetailPage.test.tsx index 7432d55..36b1e5b 100644 --- a/src/tests/pages/ElementDetailPage.test.tsx +++ b/src/tests/pages/ElementDetailPage.test.tsx @@ -22,6 +22,7 @@ import { type Operation, } from '@/tests/apollo' import { AuthProvider } from '@/components/auth/AuthProvider' +import { CalculatorRoutineProvider } from '@/components/calculator/CalculatorRoutineProvider' import ElementDetailPage from '@/pages/ElementDetailPage' const variant = (id: string) => ({ @@ -86,14 +87,16 @@ function renderPage(id: string, handler: Handler) { render( - - - } - /> - - + + + + } + /> + + + , ) From bfff94783a1cb080c3dde8c47e7e5f8ac7546e7b Mon Sep 17 00:00:00 2001 From: Anettkva Date: Wed, 11 Nov 2026 10:47:40 +0100 Subject: [PATCH 07/14] feat(calculator): build the calculator page --- .../calculator/AddElementDialog.tsx | 119 +++++ src/components/calculator/AddElementsList.tsx | 203 ++++++++ src/components/calculator/RoutineName.tsx | 143 ++++++ src/components/calculator/RoutinePanel.tsx | 290 +++++++++++ src/components/gymnasts/TrashButton.tsx | 6 +- src/lib/calculatorOperations.ts | 26 + src/pages/CalculatorPage.tsx | 140 ++++- src/tests/App.test.tsx | 2 +- src/tests/pages/CalculatorPage.test.tsx | 482 ++++++++++++++++++ 9 files changed, 1403 insertions(+), 8 deletions(-) create mode 100644 src/components/calculator/AddElementDialog.tsx create mode 100644 src/components/calculator/AddElementsList.tsx create mode 100644 src/components/calculator/RoutineName.tsx create mode 100644 src/components/calculator/RoutinePanel.tsx create mode 100644 src/lib/calculatorOperations.ts create mode 100644 src/tests/pages/CalculatorPage.test.tsx diff --git a/src/components/calculator/AddElementDialog.tsx b/src/components/calculator/AddElementDialog.tsx new file mode 100644 index 0000000..6251b6f --- /dev/null +++ b/src/components/calculator/AddElementDialog.tsx @@ -0,0 +1,119 @@ +import { Dialog } from '@base-ui/react/dialog' +import { Plus, TriangleAlert, X } from 'lucide-react' +import AddElementsList from '@/components/calculator/AddElementsList' +import { Button } from '@/components/ui/button' +import { PILL } from '@/components/ui/pill' +import type { RoutineItem } from '@/lib/calculatorRoutine' + +const FOCUS = + 'focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-(--text-color)' + +/** + * "Add element": the last row of the routine opens a popup with the search and the elements + * of the apparatus. It is the Base UI dialog, so the focus stays in it, Escape closes it, + * and the focus goes back to the row. Each "+" says with `aria-pressed` whether its + * element is in the routine, and the popup says why an element could not be added. + */ +export default function AddElementDialog({ + apparatusName, + apparatusId, + inRoutine, + onToggle, + refusal, + onClose, +}: { + apparatusName: string + apparatusId: string + inRoutine: (id: string) => boolean + onToggle: (item: RoutineItem) => void + /** Why the last element was not added, or empty. */ + refusal: string + /** When the popup closes, so an old warning is not shown the next time it opens. */ + onClose: () => void +}) { + return ( + { + if (!open) onClose() + }} + > + + } + > + + + + document.getElementById('calculator-search')} + className="fixed top-1/2 left-1/2 z-50 flex max-h-[90svh] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto rounded-lg border border-(--box-stroke) bg-(--page-bg) p-5 text-left text-(--text-color) shadow-(--box-shadow) sm:p-6" + > +
+
+ + Add elements + + + {`Search the elements on ${apparatusName}. “+” adds an element to the routine, and the check mark removes it again.`} + +
+ + } + > + +
+ {/* Only when an element could not be added (a second mount, for example). The page + behind is hidden from a screen reader while the popup is open, so the reason is + said here, as an alert. It looks like the other warnings of the site: the red + triangle and the words, not the color alone (WCAG 1.4.1). */} + {refusal && ( +
+
+ )} + + + } + > + Done + +
+
+
+ ) +} diff --git a/src/components/calculator/AddElementsList.tsx b/src/components/calculator/AddElementsList.tsx new file mode 100644 index 0000000..58c90a3 --- /dev/null +++ b/src/components/calculator/AddElementsList.tsx @@ -0,0 +1,203 @@ +import { useEffect, useState } from 'react' +import { useQuery } from '@apollo/client/react' +import { Check, Plus, Search } from 'lucide-react' +import ErrorPanel from '@/components/ErrorPanel' +import { Button } from '@/components/ui/button' +import DifficultyBadge from '@/components/ui/DifficultyBadge' +import { PILL } from '@/components/ui/pill' +import { describeError } from '@/lib/apollo/errors' +import { rowToRoutineItem, type RoutineItem } from '@/lib/calculatorRoutine' +import { getDifficultyLetter } from '@/lib/difficulty' +import { groupLabel } from '@/lib/elementList' +import { + MAX_SEARCH_LENGTH, + PAGE_SIZE, + SEARCH_CATALOG_MORE_QUERY, + SEARCH_CATALOG_QUERY, + SEARCH_DELAY_MS, + toListRow, +} from '@/lib/searchOperations' + +/** + * The elements of one apparatus, with a search field and "Load more" like the Elements + * page (searchCatalog filtered on the apparatus). + * + * TODO(#162): the Elements page is moving from "Load more" to pagination (#162). When that + * is merged, use the same pagination here, so the two lists work the same way. Each row has a toggle button: "+" adds + * the element to the routine, and a check mark shows that it is in it (press again to + * remove). The name of the button stays the same and `aria-pressed` says whether it is in + * the routine, as a toggle button should (WAI-ARIA button pattern). + */ +export default function AddElementsList({ + apparatusId, + inRoutine, + onToggle, +}: { + apparatusId: string + inRoutine: (id: string) => boolean + onToggle: (item: RoutineItem) => void +}) { + const [text, setText] = useState('') + const [search, setSearch] = useState('') + // Sent when the visitor has stopped typing, like the search on the Elements page. + useEffect(() => { + const timer = setTimeout(() => setSearch(text.trim()), SEARCH_DELAY_MS) + return () => clearTimeout(timer) + }, [text]) + + const variables = { search: search || null, filter: { apparatusId } } + const { data, previousData, loading, error, refetch, fetchMore } = useQuery( + SEARCH_CATALOG_QUERY, + { variables: { ...variables, first: PAGE_SIZE } }, + ) + const shown = (data ?? previousData)?.searchCatalog + const rows = shown?.edges.map((edge) => toListRow(edge.node)) ?? [] + const [loadingMore, setLoadingMore] = useState(false) + const [moreError, setMoreError] = useState('') + + async function loadMore() { + const after = shown?.pageInfo.endCursor + if (!after || loadingMore) return + setLoadingMore(true) + setMoreError('') + try { + await fetchMore({ + query: SEARCH_CATALOG_MORE_QUERY, + variables: { ...variables, first: PAGE_SIZE, after }, + }) + } catch (problem) { + setMoreError(describeError(problem).message) + } finally { + setLoadingMore(false) + } + } + + return ( +
+
{ + event.preventDefault() + setSearch(text.trim()) + }} + className="relative" + > + +
+ ) +} diff --git a/src/components/calculator/RoutineName.tsx b/src/components/calculator/RoutineName.tsx new file mode 100644 index 0000000..b6fc229 --- /dev/null +++ b/src/components/calculator/RoutineName.tsx @@ -0,0 +1,143 @@ +import { useEffect, useRef, useState, type FormEvent } from 'react' +import { Check, Pencil, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + MAX_ROUTINE_NAME_INPUT, + routineNameProblem, +} from '@/lib/calculatorRoutine' + +const ICON_BUTTON = + 'size-11 shrink-0 rounded-full text-(--text-color) focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-(--text-color)' + +/** + * The heading of the routine is its name ("Uneven bars routine" to begin with), with a pencil + * that turns it into a field. The field has the whole name in it, so any part can be changed, + * and the visitor erases what they do not want; the cursor is put at the end. It looks like the + * search field and has a fixed size on the line of the heading, so nothing moves when it + * opens. Enter or the check mark saves, Escape or the cross keeps the old name, and the focus + * goes back to the pencil. An error is shown next to the field (WCAG 3.3.1). + */ +export default function RoutineName({ + name, + onRename, +}: { + name: string + onRename: (name: string) => void +}) { + const [editing, setEditing] = useState(false) + const [text, setText] = useState(name) + const [error, setError] = useState('') + const input = useRef(null) + const pencil = useRef(null) + const wasEditing = useRef(false) + + useEffect(() => { + if (editing) { + const field = input.current + field?.focus() + // At the end of the name, and nothing selected: the visitor erases what they want. + field?.setSelectionRange(field.value.length, field.value.length) + } else if (wasEditing.current) pencil.current?.focus() + wasEditing.current = editing + }, [editing]) + + function start() { + setText(name) + setError('') + setEditing(true) + } + + function save(event: FormEvent) { + event.preventDefault() + const problem = routineNameProblem(text) + if (problem) { + setError(problem) + input.current?.focus() + return + } + onRename(text) + setEditing(false) + } + + if (!editing) { + return ( +
+

+ {name} +

+ +
+ ) + } + + return ( +
+

+ {name} +

+ +
+
+ + + {error && ( +

+ {error} +

+ )} +
+ ) +} diff --git a/src/components/calculator/RoutinePanel.tsx b/src/components/calculator/RoutinePanel.tsx new file mode 100644 index 0000000..c9a65de --- /dev/null +++ b/src/components/calculator/RoutinePanel.tsx @@ -0,0 +1,290 @@ +import { useState, type DragEvent } from 'react' +import { useQuery } from '@apollo/client/react' +import { ChevronDown, ChevronUp, GripVertical, Lock, Save } from 'lucide-react' +import { Link } from 'react-router-dom' +import { useAuth } from '@/components/auth/useAuth' +import AddElementDialog from '@/components/calculator/AddElementDialog' +import RoutineName from '@/components/calculator/RoutineName' +import ErrorPanel from '@/components/ErrorPanel' +import TrashButton from '@/components/gymnasts/TrashButton' +import { Button } from '@/components/ui/button' +import { PILL } from '@/components/ui/pill' +import { describeError } from '@/lib/apollo/errors' +import { SCORE_ROUTINE_QUERY } from '@/lib/calculatorOperations' +import { + apparatusName, + countedText, + type CalculatorRoutine, + type RoutineItem, +} from '@/lib/calculatorRoutine' +import { getDifficultyLetter } from '@/lib/difficulty' + +const FOCUS = + 'focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-(--text-color)' +const SMALL_ICON_BUTTON = `size-8 shrink-0 rounded-full text-(--text-color) disabled:opacity-30 ${FOCUS}` +const HEAD = 'px-4 py-3 text-left text-xs font-bold tracking-wide uppercase' + +/** + * The routine: its name, the items in their order with their value and whether they count, + * a row to add elements, and the D-value. The score and which items count come from the API + * (`scoreRoutine`), one request for each change of the routine. Apollo only shows the answer + * for the routine as it is now, so an answer that arrives after a newer request is not shown. + * While a new answer is on its way the last D-value stays, and an error is shown without + * losing the routine. + * + * The order can be changed by dragging a row by its handle, and with "Move up" and "Move + * down", so it can also be done without dragging (WCAG 2.5.7) and with the keyboard. + */ +export default function RoutinePanel({ + routine, + inRoutine, + refusal, + saveNote, + onClosePopup, + onToggle, + onRemove, + onMove, + onRename, + onClear, + onSave, +}: { + routine: CalculatorRoutine + inRoutine: (id: string) => boolean + /** Why the last element could not be added, shown in the popup. Empty when it was added. */ + refusal: string + /** Said beside "Save to a gymnast" after it is pressed (saving comes with #152). */ + saveNote: string + onClosePopup: () => void + onToggle: (item: RoutineItem) => void + onRemove: (id: string) => void + onMove: (id: string, index: number) => void + onRename: (name: string) => void + onClear: () => void + onSave: () => void +}) { + const auth = useAuth() + const ids = routine.items.map((item) => item.id) + const { data, previousData, loading, error, refetch } = useQuery( + SCORE_ROUTINE_QUERY, + { variables: { itemIds: ids }, skip: ids.length === 0 }, + ) + const empty = ids.length === 0 + // "Included" and "Not in top 8" are only shown for the routine they were calculated for. + const counted = data?.scoreRoutine.counted + const dScore = empty ? 0 : (data ?? previousData)?.scoreRoutine.dScore + const name = apparatusName(routine.apparatusId) + const isVault = routine.apparatusId === 'VT' + // A routine on vault is one vault, so there is nothing more to add when it has one. + const canAdd = !(isVault && routine.items.length > 0) + const problem = error ? describeError(error) : null + + // The row being dragged, and the row it is over (shown with a line). + const [dragged, setDragged] = useState(null) + const [over, setOver] = useState(null) + const dropOn = (index: number) => (event: DragEvent) => { + event.preventDefault() + if (dragged) onMove(dragged, index) + setDragged(null) + setOver(null) + } + + return ( +
+
+ + +
+ + {problem && ( + void refetch().catch(() => undefined)} + /> + )} + +
+ + + + + + + + + + + + + {routine.items.map((item, index) => { + const letter = item.isVault + ? undefined + : getDifficultyLetter(item.value) + const notCounted = counted?.[index] === false + return ( + { + if (!dragged) return + event.preventDefault() + setOver(index) + }} + onDrop={dropOn(index)} + className={`border-t border-(--box-stroke) ${over === index && dragged !== item.id ? 'shadow-[inset_0_2px_0_0_var(--text-color)]' : ''} ${dragged === item.id ? 'opacity-50' : ''}`} + > + + + + + + + ) + })} + + + + +
+ {`The ${name} routine, in its order`} +
+ Order + + Element + + Value + + Counted + + Remove +
+
+ {/* Only the handle starts a drag, so the text of a row can still be selected. */} + { + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', item.id) + setDragged(item.id) + }} + onDragEnd={() => { + setDragged(null) + setOver(null) + }} + title="Drag to change the order" + className="cursor-grab p-1 active:cursor-grabbing" + > + + {index + 1} + + +
+
+ {item.code} + {item.name ?? item.description} + + {letter && ( + + {letter} + + )} + {item.value.toFixed(1)} + + {counted ? countedText(routine.items, counted, index) : ''} + + {/* The same trash button as on the gymnast pages, so removing looks the same everywhere. */} + onRemove(item.id)} + /> +
+ {canAdd ? ( + + ) : ( +

+ A routine on vault is one vault. Remove it to choose + another. +

+ )} +
+
+ +
+
+ {auth.status === 'authenticated' ? ( +
+ + {/* Always there, so a screen reader hears the text when it comes. */} +

+ {saveNote} +

+
+ ) : ( + +
+

+ D-value = + + {dScore === undefined ? '–' : dScore.toFixed(1)} + +

+
+
+ ) +} diff --git a/src/components/gymnasts/TrashButton.tsx b/src/components/gymnasts/TrashButton.tsx index d0e1cf7..55f14dc 100644 --- a/src/components/gymnasts/TrashButton.tsx +++ b/src/components/gymnasts/TrashButton.tsx @@ -3,9 +3,9 @@ import { Trash2 } from 'lucide-react' import { Button } from '@/components/ui/button' /** - * The trash icon button of the gymnasts pages: the one that deletes a gymnast on "Your - * gymnasts" and the one that removes an element on the Gymnast page. Both look and work the - * same, so they are this one component. It only asks (`onClick`); the dialog is the caller's. + * The trash icon button of the site: the one that deletes a gymnast on "Your gymnasts", the + * one that removes an element on the Gymnast page, and the one that removes an element from + * the routine in the calculator. They look and work the same, so they are this one component. It only asks (`onClick`); the dialog is the caller's. * * The icon is in the text color until the pointer is over it or it is pressed, and then it * turns red with a tint of the red behind it. Red is the strongest color of the site, so it diff --git a/src/lib/calculatorOperations.ts b/src/lib/calculatorOperations.ts new file mode 100644 index 0000000..5401a33 --- /dev/null +++ b/src/lib/calculatorOperations.ts @@ -0,0 +1,26 @@ +// The query of the calculator page. The type is written by hand (one query does not need +// code generation); the fields are the ones in docs/API.md ("scoreRoutine"). + +import { gql, type TypedDocumentNode } from '@apollo/client' + +export type RoutineScore = { + dScore: number + /** One for each id, in the same order: false for an item that does not count. */ + counted: boolean[] +} + +/** + * The D-score of the routine and which items count. The rule is in the backend + * (`scoreItems` in backend/src/dscore.ts), so the calculator and saved routines agree. + */ +export const SCORE_ROUTINE_QUERY: TypedDocumentNode< + { scoreRoutine: RoutineScore }, + { itemIds: string[] } +> = gql` + query ScoreRoutine($itemIds: [ID!]!) { + scoreRoutine(itemIds: $itemIds) { + dScore + counted + } + } +` diff --git a/src/pages/CalculatorPage.tsx b/src/pages/CalculatorPage.tsx index 61585e3..9731dd7 100644 --- a/src/pages/CalculatorPage.tsx +++ b/src/pages/CalculatorPage.tsx @@ -1,11 +1,143 @@ +import { useState } from 'react' +import RoutinePanel from '@/components/calculator/RoutinePanel' +import { useCalculatorRoutine } from '@/components/calculator/useCalculatorRoutine' +import ConfirmDialog from '@/components/ConfirmDialog' +import ApparatusTabs from '@/components/gymnasts/ApparatusTabs' import PageTitle from '@/components/PageTitle' +import { + apparatusName, + CALCULATOR_APPARATUS, + refusalMessage, + type RoutineItem, +} from '@/lib/calculatorRoutine' -// A placeholder until the page is built. It has its own
, like the real pages. +/** + * The calculator: a routine on one apparatus and its D-value, for everyone. The routine is + * shared with "Try in a routine" on the element page and is kept while the site is open; + * nothing is saved. Changing the apparatus of a routine that has items asks first, because + * a routine is on one apparatus and the items would be lost (see docs/UI_PLANS.md). + */ export default function CalculatorPage() { + const { routine, add, remove, clear, startOn, move, rename } = + useCalculatorRoutine() + // What happened, for a screen reader and for everyone (WCAG 4.1.3). + const [status, setStatus] = useState('') + // Said beside "Save to a gymnast" when it is pressed, not in the line under the description. + const [saveNote, setSaveNote] = useState('') + // Why the last element could not be added, for the popup (see AddElementDialog). + const [refusal, setRefusal] = useState('') + // The apparatus the visitor wants to change to, while the dialog asks. + const [changeTo, setChangeTo] = useState(null) + + const inRoutine = (id: string) => routine.items.some((one) => one.id === id) + + // In the popup. Nothing is said about an element that was added or removed: the "+" turns + // into a check mark and the row shows up in the table (and a screen reader hears "pressed"). + // Only a refusal is shown, as the warning in the popup. + function toggle(item: RoutineItem) { + if (inRoutine(item.id)) { + remove(item.id) + setRefusal('') + return + } + const result = add(item) + setRefusal(result.added ? '' : refusalMessage(result.reason, item, routine)) + } + + function chooseApparatus(apparatusId: string) { + if (apparatusId === routine.apparatusId) return + if (routine.items.length === 0) startOn(apparatusId) + else setChangeTo(apparatusId) + } + return ( -
- Calculator -

Calculator

+
+ Routine calculator +

Routine calculator

+

+ Build a routine on one apparatus and see its D-value. The eight highest + element values count; on vault, the routine is one vault. +

+ {/* For a screen reader only: what changed after moving, renaming or clearing. On the + screen the change itself is the feedback. */} +

+ {status} +

+ +
+ ( + setRefusal('')} + onToggle={toggle} + onMove={(id, index) => { + const item = routine.items.find((one) => one.id === id) + move(id, index) + if (item) { + setStatus( + `${item.code} moved to place ${Math.min(index, routine.items.length - 1) + 1} of ${routine.items.length}.`, + ) + } + }} + onRename={(name) => { + rename(name) + setStatus('The routine has a new name.') + }} + onRemove={(id) => { + const item = routine.items.find((one) => one.id === id) + remove(id) + if (item) setStatus(`${item.code} removed from the routine.`) + }} + // TODO(#152): choose the gymnast and a name, then createRoutine and + // setRoutineItems. Until then the button says beside it that saving is not ready yet. + // When it is connected: warn before saving a routine on uneven bars or balance + // beam that has no dismount (a routine needs one; see docs/UI_PLANS.md). + onSave={() => + setSaveNote('Saving to a gymnast is not ready yet.') + } + onClear={() => { + clear() + setSaveNote('') + setStatus('The routine is cleared.') + }} + /> + )} + /> +
+ + { + if (!open) setChangeTo(null) + }} + title="Change the apparatus?" + description={`A routine is on one apparatus, so your routine on ${apparatusName(routine.apparatusId)} will be cleared.`} + confirmLabel={`Change to ${apparatusName(changeTo ?? '')}`} + busyLabel="Changing…" + // Back to the tab that is chosen. The tab that was pressed would otherwise get the + // focus, and choosing it again would open this question again. + finalFocus={() => + document.querySelector( + '[role="tab"][aria-selected="true"]', + ) + } + onConfirm={async () => { + if (!changeTo) return + startOn(changeTo) + setStatus( + `The routine is cleared. You are now on ${apparatusName(changeTo)}.`, + ) + setChangeTo(null) + }} + />
) } diff --git a/src/tests/App.test.tsx b/src/tests/App.test.tsx index eaa3906..1dc16dd 100644 --- a/src/tests/App.test.tsx +++ b/src/tests/App.test.tsx @@ -110,7 +110,7 @@ describe('App', () => { expect(document.title).toBe('About – WAG CoP Planner') fireEvent.click(screen.getByRole('link', { name: 'Calculator' })) - expect(document.title).toBe('Calculator – WAG CoP Planner') + expect(document.title).toBe('Routine calculator – WAG CoP Planner') window.history.pushState({}, '', `${import.meta.env.BASE_URL}no-such-page`) cleanup() diff --git a/src/tests/pages/CalculatorPage.test.tsx b/src/tests/pages/CalculatorPage.test.tsx new file mode 100644 index 0000000..2f2e7e0 --- /dev/null +++ b/src/tests/pages/CalculatorPage.test.tsx @@ -0,0 +1,482 @@ +// @vitest-environment jsdom +// The calculator on the real Apollo Client, with a stub for `fetch` (see src/tests/apollo.ts): +// the search of each apparatus and `scoreRoutine` answer like the server. + +import { ApolloProvider } from '@apollo/client/react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, describe, expect, it } from 'vitest' +import { AuthProvider } from '@/components/auth/AuthProvider' +import { CalculatorRoutineProvider } from '@/components/calculator/CalculatorRoutineProvider' +import type { ElementListRow } from '@/lib/elementList' +import CalculatorPage from '@/pages/CalculatorPage' +import { + createTestClient, + deferred, + failure, + ok, + type Answer, + type Operation, +} from '@/tests/apollo' +import { searchAnswer } from '@/tests/elementRows' + +const row = ( + id: string, + value: number, + description: string, +): ElementListRow => ({ + id, + code: id.split('-')[1]!, + name: null, + description, + apparatusName: id.startsWith('UB') ? 'Uneven bars' : 'Balance beam', + // Group 1 is the mounts and group 6 the dismounts, as in the catalog. + elementGroupName: id.split('-')[1]!.startsWith('1.') + ? 'Mounts' + : id.split('-')[1]!.startsWith('6.') + ? 'Dismounts' + : 'Casts and clear hip circles', + elementGroupNumber: 1, + value, + isVault: false, + hasVariants: false, + imageUrl: `http://images.test/${id}.webp`, +}) + +const ROWS = [ + row('UB-1.101-a', 0.1, 'Glide kip to front support on low bar'), + row('UB-2.305-a', 0.3, 'Clear hip circle to handstand'), + row('UB-1.102-a', 0.1, 'Jump to hang on high bar'), + row('UB-6.101-a', 0.2, 'Underswing dismount'), + row('UB-6.102-a', 0.2, 'Flyaway dismount'), + row('BB-1.101-a', 0.1, 'Leap on'), +] + +type Score = (ids: string[]) => Answer | Promise + +/** The sum of the values, and every item counts. */ +const sumAll: Score = (ids) => + ok({ + scoreRoutine: { + __typename: 'RoutineScore', + dScore: + Math.round( + ids.reduce( + (sum, id) => sum + ROWS.find((r) => r.id === id)!.value, + 0, + ) * 10, + ) / 10, + counted: ids.map(() => true), + }, + }) + +function renderPage(score: Score = sumAll, loggedIn = false) { + const { client, calls } = createTestClient((operation: Operation) => { + if (operation.operationName === 'Me') { + return ok({ + me: loggedIn + ? { + __typename: 'Coach', + id: 'coach-1', + name: 'Demo Coach', + email: 'demo@wag-planner.example', + } + : null, + }) + } + if (operation.operationName === 'ScoreRoutine') { + return score(operation.variables['itemIds'] as string[]) + } + const filter = operation.variables['filter'] as { apparatusId: string } + return searchAnswer(ROWS.filter((r) => r.id.startsWith(filter.apparatusId))) + }) + render( + + + + + + + + + , + ) + return { calls } +} + +/** Opens "Add element" and gives the popup. */ +async function openAdd() { + fireEvent.click(await screen.findByRole('button', { name: 'Add element' })) + return within(await screen.findByRole('dialog')) +} +const closeAdd = () => + fireEvent.click(screen.getByRole('button', { name: 'Done' })) +const toggle = async (code: string) => + within(await screen.findByRole('dialog')).findByRole('button', { + name: `Add ${code} to the routine`, + }) +/** Adds the elements in the popup, and closes it. */ +async function addElements(...codes: string[]) { + await openAdd() + for (const code of codes) fireEvent.click(await toggle(code)) + closeAdd() + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) +} +/** The rows of the routine, without the heading row and the "Add element" row. */ +const rows = () => + within(screen.getByRole('table')).getAllByRole('row').slice(1, -1) +const codesInOrder = () => + rows().map((r) => within(r).getAllByRole('cell')[1]!.textContent) +const dValue = () => + screen.getByText('D-value =').nextElementSibling!.textContent +const tab = (name: string) => screen.getByRole('tab', { name }) +const said = (text: string) => screen.getAllByText(text).length > 0 + +afterEach(cleanup) + +describe('CalculatorPage', () => { + it('adds and removes an element with its toggle button in the popup, without a message', async () => { + renderPage() + await openAdd() + const add = await toggle('1.101') + expect(add.getAttribute('aria-pressed')).toBe('false') + + fireEvent.click(add) + expect(add.getAttribute('aria-pressed')).toBe('true') + // The check mark and the new row are the feedback, so there is no text about it. + expect(screen.queryByText(/1\.101 added/)).toBeNull() + + fireEvent.click(add) + expect(add.getAttribute('aria-pressed')).toBe('false') + expect(screen.queryByText(/1\.101 removed/)).toBeNull() + + fireEvent.click(add) + closeAdd() + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + expect(codesInOrder()).toEqual([ + '1.101Glide kip to front support on low bar', + ]) + await waitFor(() => expect(dValue()).toBe('0.1')) + }) + + it('removes an element from the table, and clears the routine', async () => { + renderPage() + await addElements('1.101', '2.305') + await waitFor(() => expect(dValue()).toBe('0.4')) + + fireEvent.click( + screen.getByRole('button', { name: 'Remove 1.101 from the routine' }), + ) + expect(rows()).toHaveLength(1) + await waitFor(() => expect(dValue()).toBe('0.3')) + + fireEvent.click(screen.getByRole('button', { name: 'Clear routine' })) + expect(rows()).toHaveLength(0) + expect(said('The routine is cleared.')).toBe(true) + expect(dValue()).toBe('0.0') + }) + + it('says for each element whether it counts, from the answer of the API', async () => { + renderPage((ids) => + ok({ + scoreRoutine: { + __typename: 'RoutineScore', + dScore: 0.3, + counted: ids.map((id) => id !== 'UB-1.101-a'), + }, + }), + ) + await addElements('1.101', '2.305') + + await waitFor(() => expect(dValue()).toBe('0.3')) + const counted = rows().map( + (r) => within(r).getAllByRole('cell')[3]!.textContent, + ) + expect(counted).toEqual(['Not in top 8', 'Included']) + }) + + it('changes the order with "Move up" and "Move down", and says so', async () => { + renderPage() + await addElements('1.101', '2.305') + expect( + ( + screen.getByRole('button', { + name: 'Move 1.101 up', + }) as HTMLButtonElement + ).disabled, + ).toBe(true) + + fireEvent.click(screen.getByRole('button', { name: 'Move 1.101 down' })) + expect(codesInOrder().map((c) => c!.slice(0, 5))).toEqual([ + '2.305', + '1.101', + ]) + expect(said('1.101 moved to place 2 of 2.')).toBe(true) + + fireEvent.click(screen.getByRole('button', { name: 'Move 1.101 up' })) + expect(codesInOrder().map((c) => c!.slice(0, 5))).toEqual([ + '1.101', + '2.305', + ]) + }) + + it('changes the order by dragging a row by its handle', async () => { + renderPage() + await addElements('1.101', '2.305') + const handle = within(rows()[1]!).getByTitle('Drag to change the order') + const dataTransfer = { setData: () => undefined, effectAllowed: '' } + + fireEvent.dragStart(handle, { dataTransfer }) + fireEvent.dragOver(rows()[0]!, { dataTransfer }) + fireEvent.drop(rows()[0]!, { dataTransfer }) + expect(codesInOrder().map((c) => c!.slice(0, 5))).toEqual([ + '2.305', + '1.101', + ]) + }) + + it('renames the routine with the pencil: the whole name is in the field, to change as the visitor wants', async () => { + renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Rename the routine' })) + const field = screen.getByLabelText( + 'Name of the routine', + ) as HTMLInputElement + expect(field.value).toBe('Uneven bars routine') + // Nothing is selected and the cursor is at the end: the visitor erases what they want. + expect(field.selectionStart).toBe(field.value.length) + expect(field.selectionEnd).toBe(field.value.length) + + fireEvent.change(field, { target: { value: ' October draft ' } }) + fireEvent.submit(field.closest('form')!) + expect(screen.getByRole('heading', { level: 2 }).textContent).toBe( + 'October draft', + ) + // The focus goes back to the pencil. + expect(document.activeElement?.textContent).toBe('Rename the routine') + }) + + it('refuses an empty name and a name that is too long', async () => { + renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Rename the routine' })) + const field = screen.getByLabelText('Name of the routine') + fireEvent.change(field, { target: { value: ' ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save the name' })) + expect(screen.getByText('Enter a name for the routine.')).toBeTruthy() + expect(field.getAttribute('aria-invalid')).toBe('true') + + fireEvent.change(field, { target: { value: 'x'.repeat(71) } }) + fireEvent.click(screen.getByRole('button', { name: 'Save the name' })) + expect( + screen.getByText('The name can be at most 70 characters.'), + ).toBeTruthy() + }) + + it('has at most one mount and one dismount, and says why in the popup', async () => { + renderPage() + const popup = await openAdd() + fireEvent.click(await toggle('1.101')) + fireEvent.click(await toggle('1.102')) + // A warning with the red triangle, read out at once (an alert). + const warning = popup.getByRole('alert') + expect(warning.textContent).toBe( + 'Not addedA routine has at most one mount. Remove 1.101 first.', + ) + expect(warning.querySelector('svg')).not.toBeNull() + expect((await toggle('1.102')).getAttribute('aria-pressed')).toBe('false') + + fireEvent.click(await toggle('6.101')) + fireEvent.click(await toggle('6.102')) + expect( + popup.getByText( + 'A routine has at most one dismount. Remove 6.101 first.', + ), + ).toBeTruthy() + + // A normal addition says nothing in the popup. + fireEvent.click(await toggle('2.305')) + expect(popup.queryByText(/at most one/)).toBeNull() + expect(screen.queryByText(/2\.305 added/)).toBeNull() + + // An old warning is gone when the popup is opened again. + fireEvent.click(await toggle('6.102')) + expect(popup.getByRole('alert')).toBeTruthy() + closeAdd() + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + const again = await openAdd() + expect(again.queryByRole('alert')).toBeNull() + }) + + it('keeps the old name when the rename is cancelled with Escape', async () => { + renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Rename the routine' })) + const field = screen.getByLabelText('Name of the routine') + fireEvent.change(field, { target: { value: 'Something else' } }) + fireEvent.keyDown(field, { key: 'Escape' }) + expect(screen.queryByLabelText('Name of the routine')).toBeNull() + expect(screen.getByRole('heading', { level: 2 }).textContent).toBe( + 'Uneven bars routine', + ) + }) + + it('sends one request for each change, and ignores an answer that comes after a newer one', async () => { + const slow = deferred() + const { calls } = renderPage((ids) => + ids.length === 1 ? slow.promise : sumAll(ids), + ) + await addElements('1.101', '2.305') + await waitFor(() => expect(dValue()).toBe('0.4')) + + // The answer for the routine with one element comes last, and is not shown. + slow.resolve(sumAll(['UB-1.101-a']) as Answer) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(dValue()).toBe('0.4') + expect( + calls.filter((call) => call.operationName === 'ScoreRoutine'), + ).toHaveLength(2) + }) + + it('shows an error from the API and keeps the routine', async () => { + renderPage(() => + failure( + 'INVALID_ITEMS', + 'UB-1.101-a is no longer in the Code of Points. Remove it to get a score.', + ), + ) + await addElements('1.101') + + expect((await screen.findByRole('alert')).textContent).toContain( + 'no longer in the Code of Points', + ) + expect(rows()).toHaveLength(1) + }) + + it('has the apparatus as tabs: the arrow keys move the focus, and Enter chooses', async () => { + renderPage() + expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual([ + 'Vault', + 'Uneven bars', + 'Balance beam', + 'Floor exercise', + ]) + const ub = tab('Uneven bars') + ub.focus() + fireEvent.keyDown(ub, { key: 'ArrowRight' }) + await waitFor(() => + expect(document.activeElement).toBe(tab('Balance beam')), + ) + // Only the focus moved: choosing can ask a question, so it waits for Enter. + expect(tab('Uneven bars').getAttribute('aria-selected')).toBe('true') + + fireEvent.click(tab('Balance beam')) + await waitFor(() => + expect(tab('Balance beam').getAttribute('aria-selected')).toBe('true'), + ) + expect(screen.getByRole('heading', { level: 2 }).textContent).toBe( + 'Balance beam routine', + ) + }) + + it('closes the question for good with "No, keep it" or Escape, and gives the focus to the chosen tab', async () => { + renderPage() + await addElements('1.101') + + for (const close of [ + () => + fireEvent.click(screen.getByRole('button', { name: 'No, keep it' })), + () => + fireEvent.keyDown(screen.getByRole('alertdialog'), { key: 'Escape' }), + ]) { + fireEvent.click(tab('Balance beam')) + await screen.findByRole('alertdialog') + close() + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + // It does not open again when the focus comes back. + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(screen.queryByRole('alertdialog')).toBeNull() + expect(document.activeElement).toBe(tab('Uneven bars')) + expect(rows()).toHaveLength(1) + } + }) + + it('asks before it changes the apparatus of a routine with items', async () => { + renderPage() + await addElements('1.101') + + fireEvent.click(tab('Balance beam')) + expect((await screen.findByRole('alertdialog')).textContent).toContain( + 'your routine on Uneven bars will be cleared', + ) + fireEvent.click(screen.getByRole('button', { name: 'No, keep it' })) + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()) + expect(tab('Uneven bars').getAttribute('aria-selected')).toBe('true') + expect(rows()).toHaveLength(1) + + fireEvent.click(tab('Balance beam')) + fireEvent.click( + await screen.findByRole('button', { name: 'Change to Balance beam' }), + ) + await waitFor(() => + expect(tab('Balance beam').getAttribute('aria-selected')).toBe('true'), + ) + expect(rows()).toHaveLength(0) + expect(said('The routine is cleared. You are now on Balance beam.')).toBe( + true, + ) + }) + + it('has its headings in order, and a name on every button', async () => { + renderPage() + await addElements('1.101') + + expect(screen.getAllByRole('heading').map((h) => h.tagName)).toEqual([ + 'H1', + 'H2', + ]) + expect(screen.getByRole('heading', { level: 1 }).textContent).toBe( + 'Routine calculator', + ) + for (const button of screen.getAllByRole('button')) { + expect(button.textContent?.trim()).toBeTruthy() + } + }) + + it('has "Save to a gymnast" for a coach, which says beside it that saving is not ready yet', async () => { + renderPage(sumAll, true) + await addElements('1.101') + + const save = await screen.findByRole('button', { + name: 'Save to a gymnast', + }) + fireEvent.click(save) + // Beside the button, not in the status line under the description. + expect(save.nextElementSibling?.getAttribute('role')).toBe('status') + expect(save.nextElementSibling?.textContent).toBe( + 'Saving to a gymnast is not ready yet.', + ) + expect( + screen.getAllByText('Saving to a gymnast is not ready yet.'), + ).toHaveLength(1) + expect( + screen.queryByRole('link', { name: 'Log in to save routine' }), + ).toBeNull() + }) + + it('has "Log in to save routine" for a visitor, which leads to the login', async () => { + renderPage() + expect( + ( + await screen.findByRole('link', { name: 'Log in to save routine' }) + ).getAttribute('href'), + ).toBe('/login') + expect( + screen.queryByRole('button', { name: 'Save to a gymnast' }), + ).toBeNull() + }) +}) From 7ec3f7f908b0508c28895df6f9185178cee397b3 Mon Sep 17 00:00:00 2001 From: Anettkva Date: Wed, 11 Nov 2026 10:48:03 +0100 Subject: [PATCH 08/14] docs: describe the calculator page and how AI was used for it --- README.md | 6 +++--- docs/AI_USAGE.md | 8 +++++++- docs/UI_PLANS.md | 17 ++++++++++++++++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 417aa2f..3d1d3cd 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The beta now has a working React frontend connected to the GraphQL API and Postg - **Your gymnasts:** view and create gymnast profiles using the development test login, which accepts any email address. - **Backend:** the GraphQL API and seeded PostgreSQL catalog run on the VM. -The calculator and About pages are placeholders. Adding items from the catalog to gymnast lists or routines and building and saving routines in the frontend are not implemented yet. The Gymnasts screen covers profile listing and creation, not the full planned gymnast/routine workflows. Google sign-in is not planned; the development test login is the only login flow, and it accepts any email address. +The calculator builds a routine on one apparatus and shows its D-value, and "Try in a routine" on the element page adds to it; saving a routine for a gymnast is not implemented yet. The About page is a placeholder. The Gymnasts screen covers profile listing and creation, not the full planned gymnast/routine workflows. Google sign-in is not planned; the development test login is the only login flow, and it accepts any email address. ## What to review @@ -53,7 +53,7 @@ For everyone, without logging in: - **Done:** Search the catalog and load more results. - **Done:** View details for an element or vault, including its code, difficulty, description, image, and variants. - **Done:** Filter and sort results; the controls are currently visual placeholders. -- **Not done:** Build a routine in the calculator UI and see its D-value. The calculator page is currently a placeholder. +- **Done:** Build a routine in the calculator and see its D-value, with the elements that do not count marked (see "The calculator" in [docs/UI_PLANS.md](docs/UI_PLANS.md)). For coaches (the development test login is the only login flow): @@ -89,7 +89,7 @@ In the repository now: - Vitest for testing - Tailwind CSS, shadcn/ui and Lucide icons for the interface, see [UI components](#ui-components-shadcnui-tailwind-css-and-lucide) -The GraphQL backend and PostgreSQL database are implemented and deployed on the VM. The calculator UI and complete gymnast routine workflows remain planned. Google sign-in is not planned; the development test login is the only login flow. +The GraphQL backend and PostgreSQL database are implemented and deployed on the VM. Saving routines for gymnasts remains planned. Google sign-in is not planned; the development test login is the only login flow. ### Why PostgreSQL diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 05eb3fe..7e0e31a 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -580,4 +580,10 @@ - The result did not make the code shorter: the files under `src/components/gymnasts/` have more lines than before, because of the new files and their comments. What changed is that each decision now has one place. - Changed outside the Gymnast page: the element page (colors, the badge, "Try in a routine", "Add to gymnast"), "Your gymnasts" (the wrapping of the title and the list), `variables.css` (`--card-hover`), and two shared parts that other pages use too: `Button` (the `icon` prop, which changes nothing when it is not given) and `DifficultyBadge` (its colors now follow the theme, also in the results of the Elements page). - Not done on purpose: moving the error messages of the other pages (the elements results, the element page, the login, "Your gymnasts") to the three layers, because those files belong to other pull requests and the change would conflict with them. The menus of the Elements page still have their own copies of the menu classes that `menuStyles.ts` now holds. -- **Limits:** The end-to-end tests need the database and the backend, and are run by hand: the course git server has no runners. A screen reader was not used: that has to be done by a person. Lighthouse was started on a production build in the last round, but the run did not finish (the computer was low on memory), so there are no Lighthouse scores from that round. "Your gymnasts" was fixed after the last check in a browser and has not been measured again, and the results table of the Elements page, which uses the changed badge, has not been looked at in dark mode. The test gymnast was made with the test login in the local database and deleted again. \ No newline at end of file +- **Limits:** The end-to-end tests need the database and the backend, and are run by hand: the course git server has no runners. A screen reader was not used: that has to be done by a person. Lighthouse was started on a production build in the last round, but the run did not finish (the computer was low on memory), so there are no Lighthouse scores from that round. "Your gymnasts" was fixed after the last check in a browser and has not been measured again, and the results table of the Elements page, which uses the changed badge, has not been looked at in dark mode. The test gymnast was made with the test login in the local database and deleted again. + +## The calculator page (`src/pages/CalculatorPage.tsx`, `src/components/calculator/`, `backend/src/dscore.ts`) + +- **Tool:** Claude Code +- **What the team did:** Ronja wrote issue #95 from the design. Anette made the three decisions the issue left open: "Not counted" comes from a new API field (and not from the rule repeated in the frontend), changing the apparatus of a routine with items asks first, and the shared routine of #91 and "Try in a routine" on the element page are part of this work. Anette was in dialogue with Claude through the whole work, looked at the page in the browser after each round, and suggested the changes, from the new design direction to details such as the spacing, the warning in the popup, the trash button and which messages to show. +- **What Claude did:** Moved the rule of the D-score into `scoreItems`, which also says which items count, and added `scoreRoutine` next to `calculateDScore` (which is unchanged), with tests of the rule and of the API against a database. Built the page: the apparatus tabs of the gymnast page, the list with search and "Load more", the routine panel, the status messages and the confirmation dialog, and connected "Try in a routine". Wrote tests on the real Apollo Client, and removed nine behaviours one at a time (the toggle state, the "Not counted" mark, ignoring a late answer, the error, the confirmation, "Clear routine", the status message, adding from the element page and asking there) to check that a test fails each time. After that, Anette asked for a way to save the routine and for the list to follow the pagination of #162: added a "Save to a gymnast" button for a logged-in coach, which says it is not connected yet (saving is #152), and marked the list to move to the pagination of #162 when that is merged. Then Anette gave a new design direction (one table with the order, the element, the value and whether it counts, an "Add element" row, an editable name and the D-value below) and chose dragging plus "Move up"/"Move down" for the order, a popup for adding, and a Lucide pencil for the name. Built that with the colours of the site, and checked it with axe-core in light and dark mode and at 320 px: this found that the chosen pill tab had no background (the "line" variant of the tabs cleared it) and that the hidden labels of the table stuck out of its scroll area, and both were fixed. Last, Anette asked for less space at the top of the popup, for at most one mount and one dismount with the dismount always counted, and for the whole name to be editable: the rule that the dismount counts went into `scoreItems` in the backend (so saved routines get it too), and the limit of one mount and one dismount into the calculator's routine; the popup only says something when an element is refused. After that, Anette found that the question about changing the apparatus could not be closed: when it closed, the focus went back to the tab that was pressed, and because the tabs chose a tab when it got the focus, the question opened again. Fixed by choosing a tab only with Enter, Space or a click in the calculator, and by giving the focus to the chosen tab, with a test of both; the long button text now wraps inside its button, and the "+" in the popup is on one line with the circle of the badge. Then, at Anette's request: a refused element (a second mount, for example) is shown as a warning with an icon in the popup and is gone when the popup closes; the note that saving is not ready yet comes beside "Save to a gymnast" instead of under the page description; removing an element from the routine uses the trash button of the gymnast pages (`TrashButton`); and the messages about an added or removed element were removed, because the check mark and the row in the table already show it (the other status messages are kept for a screen reader only). Anette also asked that saving a routine without a dismount should give a warning: this is noted for #152 in the code and in `docs/UI_PLANS.md`. diff --git a/docs/UI_PLANS.md b/docs/UI_PLANS.md index 08048a3..2b84571 100644 --- a/docs/UI_PLANS.md +++ b/docs/UI_PLANS.md @@ -46,6 +46,21 @@ Mockups of some of the pages are in [design/README.md](design/README.md). | **Gymnast** | | ✓ | One gymnast's routines on each apparatus with their D-values, and the elements she can do or is practicing. | | **Your profile** | | ✓ | Account details from Google (name, email) and the date you joined. Log out. | +## The calculator + +The calculator (`/calculator`, `src/pages/CalculatorPage.tsx`) puts together a routine on one apparatus and shows its D-value. The routine is kept in the browser only while the site is open, and the element page ("Try in a routine") and the calculator share it, so both show the same routine. A reload starts with an empty routine, because nothing is saved. + +- **One table, in the order of the routine.** The routine is a table with the order, the element, its value and whether it counts ("Included", "Not in top 8", or "Same code counted" for a variant of a code that counts already). The last row, "Add element", opens a popup with the search and the elements of the apparatus. The D-value is under the table. +- **The order can be changed** by dragging a row by its handle, and with "Move up" and "Move down" on each row, so it can be done without dragging (WCAG 2.5.7) and with the keyboard. The order is the order of the routine when it is saved; the D-value does not depend on it, except which of two equal values at the edge of the eight counts. +- **The routine has a name** ("Uneven bars routine" to begin with, after the apparatus), changed with the pencil. The field has the whole name in it, so any part can be changed and the visitor erases what they do not want; it looks like the search field and has a fixed size on the line of the heading, so nothing on the page moves. It follows the rule of a saved routine (1–70 characters), so #152 can save it as it is. +- **Changing the apparatus asks first.** A routine is on one apparatus, so changing the tab of a routine that has items would clear it. The page asks ("Change the apparatus?") instead of clearing it at once, because a routine can take a while to put together and one mis-click on a tab should not lose it. An empty routine changes at once, since there is nothing to lose. Because choosing a tab can ask this question, the arrow keys only move the focus between the tabs, and Enter, Space or a click chooses one (manual activation in the WAI-ARIA tabs pattern). When the question closes, the focus goes to the chosen tab. "Try in a routine" on the page of an element from another apparatus asks the same way before it starts a new routine. +- **"Not counted" comes from the API.** `scoreRoutine` gives the D-score and which items count, from the same rule as `calculateDScore` and `Routine.dScore` (`scoreItems` in `backend/src/dscore.ts`). The rule is not repeated in the frontend, so the marks and the score cannot disagree. +- **One request for each change.** The D-value is asked for after every change of the routine. An answer that arrives after a newer request is not shown, and while a new answer is on its way the last D-value stays. An error is shown in the panel, and the routine is kept. +- **At most one mount and one dismount** (uneven bars and balance beam), as in the Code of Points. The popup says why when one is not added. The dismount always counts in the D-value (see constraint 13 in [DATA_MODEL.md](DATA_MODEL.md)). +- **A routine on vault is one vault,** and at most 50 items can be scored at once (the limit of the API). The page says so when it does not add an item. +- **Saving comes with #152.** A logged-in coach sees "Save to a gymnast", which for now says beside the button that saving is not ready yet; #152 adds choosing the gymnast and a name, and saving. A visitor gets a link to the login instead. When saving is built, a routine on uneven bars or balance beam without a dismount should give a warning before it is saved. +- **The list in the popup uses "Load more" for now.** The Elements page is moving to pagination (#162). When that is merged, the calculator's list should use the same pagination, so the two lists work the same way (marked `TODO(#162)` in `AddElementsList.tsx`). + ## Scope -The D-value of a routine is calculated as the sum of the eight highest element values, with each element counted only once. On vault it is the D-value of the chosen vault. Composition requirements and connection values are not included in this version. See [DATA_MODEL.md](DATA_MODEL.md) for details. +The D-value of a routine is calculated as the sum of the eight highest element values, with each element counted only once and the dismount always counted. On vault it is the D-value of the chosen vault. Composition requirements and connection values are not included in this version. See [DATA_MODEL.md](DATA_MODEL.md) for details. From a6baa9901cc2f9287bbb2c576f063aa4c07e1e8f Mon Sep 17 00:00:00 2001 From: Anettkva Date: Wed, 11 Nov 2026 11:14:15 +0100 Subject: [PATCH 09/14] fix(calculator): fix the drop line and the D-value, ask to replace a vault, share routine rules --- backend/src/dscore.ts | 4 +- backend/src/server/items.ts | 4 +- backend/src/server/resolvers/calculator.ts | 5 +- shared/routines.ts | 19 +++++++ src/components/calculator/RoutinePanel.tsx | 35 ++++++++++--- src/components/elements/ElementActions.tsx | 25 +++++++++ src/lib/calculatorRoutine.ts | 16 +++--- .../elements/ElementActions.test.tsx | 42 +++++++++++++++ src/tests/lib/calculatorRoutine.test.ts | 2 +- src/tests/pages/CalculatorPage.test.tsx | 51 +++++++++++++++++++ 10 files changed, 180 insertions(+), 23 deletions(-) create mode 100644 shared/routines.ts diff --git a/backend/src/dscore.ts b/backend/src/dscore.ts index feecbef..971df63 100644 --- a/backend/src/dscore.ts +++ b/backend/src/dscore.ts @@ -27,8 +27,8 @@ export type ScoredItem = { isDismount?: boolean } -/** The name of the element group of the dismounts in the catalog (data/elementGroups.json). */ -export const DISMOUNT_GROUP = 'Dismounts' +// The name of the dismount group is shared with the frontend. +export { DISMOUNT_GROUP } from '../../shared/routines.js' const toTenths = (value: { toString(): string }): number => Math.round(Number(value.toString()) * 10) diff --git a/backend/src/server/items.ts b/backend/src/server/items.ts index bc67332..c4364c2 100644 --- a/backend/src/server/items.ts +++ b/backend/src/server/items.ts @@ -18,8 +18,8 @@ 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 +// The frontend uses the same function (shared/routines.ts). +export { apparatusOfId } from '../../../shared/routines.js' export const asElement = (row: Element): ElementItem => ({ ...row, diff --git a/backend/src/server/resolvers/calculator.ts b/backend/src/server/resolvers/calculator.ts index b7d4e53..151d6e9 100644 --- a/backend/src/server/resolvers/calculator.ts +++ b/backend/src/server/resolvers/calculator.ts @@ -1,5 +1,6 @@ // The calculator: the D-score of a list of items, for everyone. Nothing is saved. +import { MAX_ROUTINE_ITEMS } from '../../../../shared/routines.js' import { catalogStructure } from '../../catalog/structure.js' import { scoreItems, type RoutineScore, type ScoredItem } from '../../dscore.js' import { BadUserInputError, InvalidItemsError } from '../../errors.js' @@ -7,8 +8,8 @@ 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 +/** The most ids one call may have (also in docs/API.md). The calculator page has the same limit (shared/routines.ts). */ +export const MAX_CALCULATOR_IDS = MAX_ROUTINE_ITEMS /** * The items of the ids, in the order of the ids (the same id twice gives the diff --git a/shared/routines.ts b/shared/routines.ts new file mode 100644 index 0000000..442e2be --- /dev/null +++ b/shared/routines.ts @@ -0,0 +1,19 @@ +// Rules of a routine that the backend and the frontend both use: the server +// scores routines (backend/src/dscore.ts and the calculator resolver) and the +// calculator builds them (src/lib/calculatorRoutine.ts). Both import this +// file, so they cannot disagree about them. +// +// This file must not import anything: it is compiled by the backend and by +// the frontend. + +/** The most items in a routine, and the most ids the calculator scores at once. */ +export const MAX_ROUTINE_ITEMS = 50 + +/** The name of the element group of the mounts in the catalog (data/elementGroups.json). */ +export const MOUNT_GROUP = 'Mounts' + +/** The name of the element group of the dismounts in the catalog (data/elementGroups.json). */ +export const DISMOUNT_GROUP = 'Dismounts' + +/** 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 diff --git a/src/components/calculator/RoutinePanel.tsx b/src/components/calculator/RoutinePanel.tsx index c9a65de..940e729 100644 --- a/src/components/calculator/RoutinePanel.tsx +++ b/src/components/calculator/RoutinePanel.tsx @@ -29,8 +29,8 @@ const HEAD = 'px-4 py-3 text-left text-xs font-bold tracking-wide uppercase' * a row to add elements, and the D-value. The score and which items count come from the API * (`scoreRoutine`), one request for each change of the routine. Apollo only shows the answer * for the routine as it is now, so an answer that arrives after a newer request is not shown. - * While a new answer is on its way the last D-value stays, and an error is shown without - * losing the routine. + * While a new answer is on its way the last D-value stays. After an error no D-value is + * shown (the last one is for another routine), and the routine is not lost. * * The order can be changed by dragging a row by its handle, and with "Move up" and "Move * down", so it can also be done without dragging (WCAG 2.5.7) and with the keyboard. @@ -64,14 +64,23 @@ export default function RoutinePanel({ }) { const auth = useAuth() const ids = routine.items.map((item) => item.id) - const { data, previousData, loading, error, refetch } = useQuery( - SCORE_ROUTINE_QUERY, - { variables: { itemIds: ids }, skip: ids.length === 0 }, - ) + const { data, loading, error, refetch } = useQuery(SCORE_ROUTINE_QUERY, { + variables: { itemIds: ids }, + skip: ids.length === 0, + }) const empty = ids.length === 0 // "Included" and "Not in top 8" are only shown for the routine they were calculated for. const counted = data?.scoreRoutine.counted - const dScore = empty ? 0 : (data ?? previousData)?.scoreRoutine.dScore + // The D-value of the routine as it is now, when the answer for it is here. + const current = empty ? 0 : data?.scoreRoutine.dScore + // The last D-value that was shown. While the answer for a change is on its way, this stays, + // so the number does not flicker. It is the routine just before the change (after "Clear + // routine", that is 0), never an older one. Kept in state and set during render, as React + // describes for a value that follows another one. + const [shown, setShown] = useState(current) + if (current !== undefined && current !== shown) setShown(current) + // After an error the last D-value is not for this routine, so none is shown. + const dScore = error ? undefined : (current ?? shown) const name = apparatusName(routine.apparatusId) const isVault = routine.apparatusId === 'VT' // A routine on vault is one vault, so there is nothing more to add when it has one. @@ -81,6 +90,16 @@ export default function RoutinePanel({ // The row being dragged, and the row it is over (shown with a line). const [dragged, setDragged] = useState(null) const [over, setOver] = useState(null) + const draggedIndex = routine.items.findIndex((item) => item.id === dragged) + // Where the dragged row will land, shown as a line: dragged down, it goes after the row it + // is dropped on (the rows below it move up), so the line is under that row; dragged up, it + // goes before it, so the line is above. + const dropLine = (index: number) => + over !== index || draggedIndex === -1 || draggedIndex === index + ? '' + : index > draggedIndex + ? 'shadow-[inset_0_-2px_0_0_var(--text-color)]' + : 'shadow-[inset_0_2px_0_0_var(--text-color)]' const dropOn = (index: number) => (event: DragEvent) => { event.preventDefault() if (dragged) onMove(dragged, index) @@ -150,7 +169,7 @@ export default function RoutinePanel({ setOver(index) }} onDrop={dropOn(index)} - className={`border-t border-(--box-stroke) ${over === index && dragged !== item.id ? 'shadow-[inset_0_2px_0_0_var(--text-color)]' : ''} ${dragged === item.id ? 'opacity-50' : ''}`} + className={`border-t border-(--box-stroke) ${dropLine(index)} ${dragged === item.id ? 'opacity-50' : ''}`} >
diff --git a/src/components/elements/ElementActions.tsx b/src/components/elements/ElementActions.tsx index cbec783..942d02e 100644 --- a/src/components/elements/ElementActions.tsx +++ b/src/components/elements/ElementActions.tsx @@ -31,6 +31,9 @@ export default function ElementActions({ const inRoutine = calculator.routine.items.some((one) => one.id === item.id) // The routine is on another apparatus: ask before starting a new one. const [askNewRoutine, setAskNewRoutine] = useState(false) + // The routine is already one vault: ask before replacing it with this one. + const [askReplaceVault, setAskReplaceVault] = useState(false) + const vaultInRoutine = item.isVault ? calculator.routine.items[0] : undefined const [linkToCalculator, setLinkToCalculator] = useState(false) function tryInRoutine() { @@ -47,6 +50,8 @@ export default function ElementActions({ setStatus(`${item.code} added to the routine in the calculator.`) } else if (result.reason === 'other-apparatus') { setAskNewRoutine(true) + } else if (result.reason === 'one-vault') { + setAskReplaceVault(true) } else { setStatus(refusalMessage(result.reason, item, calculator.routine)) } @@ -120,6 +125,26 @@ export default function ElementActions({ setAskNewRoutine(false) }} /> + {vaultInRoutine && ( + { + // The name of the routine is kept: only the vault changes. + calculator.remove(vaultInRoutine.id) + calculator.add(item) + setLinkToCalculator(true) + setStatus( + `${vaultInRoutine.code} was replaced by ${item.code} in the calculator.`, + ) + setAskReplaceVault(false) + }} + /> + )} {error && {error}}
) diff --git a/src/lib/calculatorRoutine.ts b/src/lib/calculatorRoutine.ts index 6ed7926..7d44ced 100644 --- a/src/lib/calculatorRoutine.ts +++ b/src/lib/calculatorRoutine.ts @@ -8,6 +8,12 @@ import type { ElementDetail } from '@/lib/catalog' import { getDifficultyLetter } from '@/lib/difficulty' import type { ElementListRow } from '@/lib/elementList' import { MAX_NAME_LENGTH, nameProblem, normalizeName } from '../../shared/names' +import { + apparatusOfId, + DISMOUNT_GROUP, + MAX_ROUTINE_ITEMS, + MOUNT_GROUP, +} from '../../shared/routines' /** The apparatus of the calculator, in the order of the tabs (as in the design). */ export const CALCULATOR_APPARATUS = [ @@ -21,9 +27,6 @@ export const CALCULATOR_APPARATUS = [ export const defaultRoutineName = (apparatusId: string): string => `${apparatusName(apparatusId)} routine` -/** The most items the API scores at once (`MAX_CALCULATOR_IDS` in the backend). */ -export const MAX_ROUTINE_ITEMS = 50 - /** One element or vault in the routine: what the routine panel shows. */ export type RoutineItem = { id: string @@ -43,9 +46,9 @@ export type ItemKind = 'mount' | 'dismount' | null /** Mount, dismount or neither, from the name of the element group (data/elementGroups.json). */ export const kindOfGroup = (groupName: string): ItemKind => - groupName === 'Mounts' + groupName === MOUNT_GROUP ? 'mount' - : groupName === 'Dismounts' + : groupName === DISMOUNT_GROUP ? 'dismount' : null @@ -64,9 +67,6 @@ export const newRoutine = (apparatusId: string): CalculatorRoutine => ({ items: [], }) -/** The apparatus of an id: "UB-1.101-a" is on "UB". */ -export const apparatusOfId = (id: string): string => id.split('-')[0]! - export const apparatusName = (apparatusId: string): string => CALCULATOR_APPARATUS.find((one) => one.id === apparatusId)?.name ?? apparatusId diff --git a/src/tests/components/elements/ElementActions.test.tsx b/src/tests/components/elements/ElementActions.test.tsx index 5234549..09a04fa 100644 --- a/src/tests/components/elements/ElementActions.test.tsx +++ b/src/tests/components/elements/ElementActions.test.tsx @@ -142,4 +142,46 @@ describe('ElementActions', () => { screen.getByText('A new routine on Uneven bars was started with 2.305.'), ).toBeTruthy() }) + + it('asks before it replaces the vault when the routine already has another vault', async () => { + const vault = (code: string, dValue: number) => ({ + id: `VT-${code}-a`, + code, + name: null, + description: 'Handspring fwd on – salto fwd tuck off', + value: dValue, + isVault: true, + apparatusId: 'VT', + kind: null, + }) + renderActions( + { + ...element, + ...vault('1.22', 2.6), + apparatusName: 'Vault', + elementGroupName: 'Group 1', + elementGroupNumber: 1, + }, + { apparatusId: 'VT', name: 'My vault', items: [vault('1.10', 2.0)] }, + ) + fireEvent.click(screen.getByRole('button', { name: 'Try in a routine' })) + expect((await screen.findByRole('alertdialog')).textContent).toContain( + 'Your routine in the calculator is 1.10, so it will be replaced by 1.22.', + ) + + fireEvent.click(screen.getByRole('button', { name: 'Replace the vault' })) + await waitFor(() => + expect( + screen + .getByRole('button', { name: 'Try in a routine' }) + .getAttribute('aria-pressed'), + ).toBe('true'), + ) + expect( + screen.getByText('1.10 was replaced by 1.22 in the calculator.'), + ).toBeTruthy() + expect( + screen.getByRole('link', { name: 'Open the calculator' }), + ).toBeTruthy() + }) }) diff --git a/src/tests/lib/calculatorRoutine.test.ts b/src/tests/lib/calculatorRoutine.test.ts index 3ccfd9a..bb2c309 100644 --- a/src/tests/lib/calculatorRoutine.test.ts +++ b/src/tests/lib/calculatorRoutine.test.ts @@ -5,13 +5,13 @@ import { moveItem, routineNameProblem, EMPTY_ROUTINE, - MAX_ROUTINE_ITEMS, refusalMessage, removeItem, valueText, type CalculatorRoutine, type RoutineItem, } from '@/lib/calculatorRoutine' +import { MAX_ROUTINE_ITEMS } from '../../../shared/routines' const item = ( id: string, diff --git a/src/tests/pages/CalculatorPage.test.tsx b/src/tests/pages/CalculatorPage.test.tsx index 2f2e7e0..60d16f3 100644 --- a/src/tests/pages/CalculatorPage.test.tsx +++ b/src/tests/pages/CalculatorPage.test.tsx @@ -242,6 +242,24 @@ describe('CalculatorPage', () => { ]) }) + it('shows the line where a row dragged down lands: under the row it is dropped on', async () => { + renderPage() + await addElements('1.101', '2.305', '6.101') + const handle = within(rows()[0]!).getByTitle('Drag to change the order') + const dataTransfer = { setData: () => undefined, effectAllowed: '' } + + fireEvent.dragStart(handle, { dataTransfer }) + fireEvent.dragOver(rows()[2]!, { dataTransfer }) + // The line is the bottom edge of the row (a negative inset shadow), not the top. + expect(rows()[2]!.className).toContain('inset_0_-2px') + fireEvent.drop(rows()[2]!, { dataTransfer }) + expect(codesInOrder().map((c) => c!.slice(0, 5))).toEqual([ + '2.305', + '6.101', + '1.101', + ]) + }) + it('renames the routine with the pencil: the whole name is in the field, to change as the visitor wants', async () => { renderPage() fireEvent.click(screen.getByRole('button', { name: 'Rename the routine' })) @@ -357,6 +375,39 @@ describe('CalculatorPage', () => { expect(rows()).toHaveLength(1) }) + it('does not show the D-value of an older routine after "Clear routine"', async () => { + const slow = deferred() + let cleared = false + renderPage((ids) => (cleared ? slow.promise : sumAll(ids))) + await addElements('1.101', '2.305') + await waitFor(() => expect(dValue()).toBe('0.4')) + + fireEvent.click(screen.getByRole('button', { name: 'Clear routine' })) + cleared = true + await addElements('2.305') + // Waiting for the answer: the last D-value shown was the empty routine, not 0.4. + expect(dValue()).toBe('0.0') + slow.resolve(sumAll(['UB-2.305-a']) as Answer) + await waitFor(() => expect(dValue()).toBe('0.3')) + }) + + it('shows no D-value after an error, since the last one is for another routine', async () => { + renderPage((ids) => + ids.length === 1 + ? sumAll(ids) + : failure( + 'INVALID_ITEMS', + 'UB-2.305-a is no longer in the Code of Points.', + ), + ) + await addElements('1.101') + await waitFor(() => expect(dValue()).toBe('0.1')) + + await addElements('2.305') + await screen.findByRole('alert') + expect(dValue()).toBe('–') + }) + it('has the apparatus as tabs: the arrow keys move the focus, and Enter chooses', async () => { renderPage() expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual([ From df8bad3121094c487707a3c4e52690bf046bd297 Mon Sep 17 00:00:00 2001 From: Anettkva Date: Wed, 11 Nov 2026 11:32:22 +0100 Subject: [PATCH 10/14] feat(calculator): say which element the dismount replaced --- backend/src/dscore.test.ts | 27 ++++++++++++++++++---- backend/src/dscore.ts | 12 +++++++++- backend/src/schema.graphql | 7 ++++++ backend/src/server/calculator.test.ts | 12 ++++++++-- docs/API.md | 2 +- src/components/calculator/RoutinePanel.tsx | 10 +++++++- src/lib/calculatorOperations.ts | 3 +++ src/lib/calculatorRoutine.ts | 20 +++++++++++----- src/tests/lib/calculatorRoutine.test.ts | 3 +++ 9 files changed, 81 insertions(+), 15 deletions(-) diff --git a/backend/src/dscore.test.ts b/backend/src/dscore.test.ts index 67fce7c..4357dea 100644 --- a/backend/src/dscore.test.ts +++ b/backend/src/dscore.test.ts @@ -117,7 +117,11 @@ describe('scoreItems', () => { it('marks every item as counted when all of them count', () => { expect( scoreItems([element('1.101', '0.1'), element('2.102', '0.5')]), - ).toEqual({ dScore: 0.6, counted: [true, true] }) + ).toEqual({ + dScore: 0.6, + counted: [true, true], + replacedByDismount: null, + }) }) it('does not count the items outside the eight highest, wherever they are in the list', () => { @@ -164,6 +168,7 @@ describe('scoreItems', () => { expect(scoreItems([a, b, a])).toEqual({ dScore: 0.4, counted: [true, false, false], + replacedByDismount: null, }) }) @@ -171,11 +176,16 @@ describe('scoreItems', () => { expect(scoreItems([vault('4.1', '2.6')])).toEqual({ dScore: 2.6, counted: [true], + replacedByDismount: null, }) }) it('gives an empty list for an empty routine', () => { - expect(scoreItems([])).toEqual({ dScore: 0, counted: [] }) + expect(scoreItems([])).toEqual({ + dScore: 0, + counted: [], + replacedByDismount: null, + }) }) it('refuses the same items as calculateDScore', () => { @@ -192,7 +202,10 @@ describe('scoreItems', () => { (v, i) => element(`2.${200 + i}`, v), ) const dismount = { ...element('6.101', '0.1'), isDismount: true } - const { dScore, counted } = scoreItems([...others, dismount]) + const { dScore, counted, replacedByDismount } = scoreItems([ + ...others, + dismount, + ]) // 0.9 + 0.8 + ... + 0.3 (seven) + the dismount 0.1, and not 0.2. expect(dScore).toBe(4.3) expect(counted).toEqual([ @@ -206,12 +219,18 @@ describe('scoreItems', () => { false, true, ]) + // The 0.2 (index 7) is among the eight highest; the dismount took its place. + expect(replacedByDismount).toBe(7) expect(calculateDScore([...others, dismount])).toBe(4.3) }) it('does not change anything when the dismount is among the eight highest', () => { const dismount = { ...element('6.501', '0.5'), isDismount: true } const items = [element('1.101', '0.1'), dismount] - expect(scoreItems(items)).toEqual({ dScore: 0.6, counted: [true, true] }) + expect(scoreItems(items)).toEqual({ + dScore: 0.6, + counted: [true, true], + replacedByDismount: null, + }) }) }) diff --git a/backend/src/dscore.ts b/backend/src/dscore.ts index 971df63..8848576 100644 --- a/backend/src/dscore.ts +++ b/backend/src/dscore.ts @@ -37,6 +37,11 @@ export type RoutineScore = { dScore: number /** One for each item, in the same order: true when the item counts in the score. */ counted: boolean[] + /** + * The index of the item that is among the eight highest but does not count, because + * the dismount took its place. null when the dismount is among the eight, or there is none. + */ + replacedByDismount: number | null } /** @@ -79,14 +84,19 @@ export function scoreItems(items: ScoredItem[]): RoutineScore { // The dismount always counts: the highest dismount outside the eight takes the place // of the lowest element in them that is not a dismount. const dismount = ranked.find((index) => items[index]!.isDismount) + let replacedByDismount: number | null = null if (dismount !== undefined && !chosen.includes(dismount)) { const lowest = chosen.findLastIndex((index) => !items[index]!.isDismount) - if (lowest !== -1) chosen[lowest] = dismount + if (lowest !== -1) { + replacedByDismount = chosen[lowest]! + chosen[lowest] = dismount + } } const counts = new Set(chosen) return { dScore: chosen.reduce((sum, index) => sum + tenths[index]!, 0) / 10, counted: items.map((_, index) => counts.has(index)), + replacedByDismount, } } diff --git a/backend/src/schema.graphql b/backend/src/schema.graphql index 37679b3..3ca9863 100644 --- a/backend/src/schema.graphql +++ b/backend/src/schema.graphql @@ -363,6 +363,13 @@ type RoutineScore { two items have the same value at the edge of the eight, the first counts. """ counted: [Boolean!]! + """ + The place in `itemIds` (from 0) of the element that is among the eight + highest values but does not count, because the dismount took its place, so + the calculator can say why ("Replaced by dismount"). null when the dismount + is among the eight highest, or the routine has no dismount. + """ + replacedByDismount: Int } type AuthPayload { diff --git a/backend/src/server/calculator.test.ts b/backend/src/server/calculator.test.ts index fd13cb6..4c37ba1 100644 --- a/backend/src/server/calculator.test.ts +++ b/backend/src/server/calculator.test.ts @@ -182,7 +182,7 @@ describe.runIf(hasDatabase)('calculateDScore', () => { const response = await server.executeOperation( { query: - 'query ($ids: [ID!]!) { scoreRoutine(itemIds: $ids) { dScore counted } }', + 'query ($ids: [ID!]!) { scoreRoutine(itemIds: $ids) { dScore counted replacedByDismount } }', variables: { ids }, }, { contextValue: createContext(prisma, config) }, @@ -191,7 +191,13 @@ describe.runIf(hasDatabase)('calculateDScore', () => { const { data, errors } = response.body.singleResult return { score: ( - data as { scoreRoutine: { dScore: number; counted: boolean[] } } | null + data as { + scoreRoutine: { + dScore: number + counted: boolean[] + replacedByDismount: number | null + } + } | null )?.scoreRoutine, code: errors?.[0]?.extensions?.['code'], } @@ -253,5 +259,7 @@ describe.runIf(hasDatabase)('calculateDScore', () => { ]) expect(result!.counted.at(-1)).toBe(true) expect(result!.counted.filter(Boolean)).toHaveLength(8) + // The lowest of the eight highest is the one the dismount replaced. + expect(result!.replacedByDismount).toBe(7) }) }) diff --git a/docs/API.md b/docs/API.md index b7ca8df..50a9da5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -80,7 +80,7 @@ When a new Code of Points drops an element or vault, it is marked as removed in - **Names.** The name of a gymnast and the name of a routine are normalized: written in Unicode normal form C (so an accented letter typed as one character, or as a letter and an accent, is the same name), with the spaces at the start and the end removed and every run of white space inside the name made into one space. The result must be 1–70 characters. A longer name, or a name with only spaces, throws `BAD_USER_INPUT`. The length is counted in Unicode characters (code points), not in bytes or UTF-16 units, so a name with accents or a simple emoji gets the same room as any other. A flag or a family of emoji is made of several characters and counts as several. 70 characters is the length that the UK Government Data Standards give for a full name in one field. The rule is one file, `shared/names.ts`, which the backend and the form in the frontend both import, so they cannot disagree about what a valid name is. The database does not check the limits yet. `NAME_TAKEN` is checked on the normalized name. The form for a gymnast has the same rule: it does not let more than 70 characters be typed or pasted, and answers "Name is required" when there is no 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, where a dismount always counts (it takes the place of the lowest of the eight when it is not among them), 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. -- `scoreRoutine(itemIds)` is what the calculator page uses. It has the same rule, the same limit and the same errors as `calculateDScore`, and gives `{ dScore, counted }`: the score, and one `true` or `false` for each id in `itemIds`, in the same order, so the page can mark the items that do not count ("Not counted"). An item does not count when it is outside the eight highest values, or when an item with the same code counts already. Of items with the same code, or the same value at the edge of the eight, the first in the list counts. The rule is in one place (`scoreItems` in `backend/src/dscore.ts`), and `calculateDScore` gives its `dScore`. +- `scoreRoutine(itemIds)` is what the calculator page uses. It has the same rule, the same limit and the same errors as `calculateDScore`, and gives `{ dScore, counted, replacedByDismount }`: the score, one `true` or `false` for each id in `itemIds`, in the same order, so the page can mark the items that do not count ("Not counted"), and the place in `itemIds` of the element that the dismount took the place of (or `null`), so the page can say why that one does not count. An item does not count when it is outside the eight highest values, when an item with the same code counts already, or when the dismount took its place (a dismount always counts, in place of the lowest of the eight). Of items with the same code, or the same value at the edge of the eight, the first in the list counts. The rule is in one place (`scoreItems` in `backend/src/dscore.ts`), and `calculateDScore` gives its `dScore`. - 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 diff --git a/src/components/calculator/RoutinePanel.tsx b/src/components/calculator/RoutinePanel.tsx index 940e729..02d3863 100644 --- a/src/components/calculator/RoutinePanel.tsx +++ b/src/components/calculator/RoutinePanel.tsx @@ -71,6 +71,7 @@ export default function RoutinePanel({ const empty = ids.length === 0 // "Included" and "Not in top 8" are only shown for the routine they were calculated for. const counted = data?.scoreRoutine.counted + const replacedByDismount = data?.scoreRoutine.replacedByDismount ?? null // The D-value of the routine as it is now, when the answer for it is here. const current = empty ? 0 : data?.scoreRoutine.dScore // The last D-value that was shown. While the answer for a change is on its way, this stays, @@ -230,7 +231,14 @@ export default function RoutinePanel({ - {counted ? countedText(routine.items, counted, index) : ''} + {counted + ? countedText( + routine.items, + counted, + index, + replacedByDismount, + ) + : ''} {/* The same trash button as on the gymnast pages, so removing looks the same everywhere. */} diff --git a/src/lib/calculatorOperations.ts b/src/lib/calculatorOperations.ts index 5401a33..7dd0e3e 100644 --- a/src/lib/calculatorOperations.ts +++ b/src/lib/calculatorOperations.ts @@ -7,6 +7,8 @@ export type RoutineScore = { dScore: number /** One for each id, in the same order: false for an item that does not count. */ counted: boolean[] + /** The place of the element whose place the dismount took, or null (see docs/API.md). */ + replacedByDismount: number | null } /** @@ -21,6 +23,7 @@ export const SCORE_ROUTINE_QUERY: TypedDocumentNode< scoreRoutine(itemIds: $itemIds) { dScore counted + replacedByDismount } } ` diff --git a/src/lib/calculatorRoutine.ts b/src/lib/calculatorRoutine.ts index 7d44ced..2ef67fa 100644 --- a/src/lib/calculatorRoutine.ts +++ b/src/lib/calculatorRoutine.ts @@ -175,21 +175,29 @@ export const tidyRoutineName = normalizeName export const MAX_ROUTINE_NAME_INPUT = MAX_NAME_LENGTH * 2 /** - * What the "Counted" column says about an item, from `counted` of `scoreRoutine`. An - * item that does not count is either outside the eight highest values, or has the code of - * an item that counts already (a variant, or the same element twice). + * What the "Counted" column says about an item, from `counted` and `replacedByDismount` of + * `scoreRoutine`. An item that does not count has the code of an item that counts already + * (a variant, or the same element twice), or is among the eight highest but the dismount + * took its place, or is outside the eight highest values. */ export function countedText( items: Pick[], counted: boolean[], index: number, + /** The place of the element whose place the dismount took, from the API, or null. */ + replacedByDismount: number | null = null, ): string { if (counted[index]) return 'Included' + const code = items[index]!.code const sameCodeCounts = items.some( - (other, i) => - i !== index && counted[i] && other.code === items[index]!.code, + (other, i) => i !== index && counted[i] && other.code === code, ) - return sameCodeCounts ? 'Same code counted' : 'Not in top 8' + if (sameCodeCounts) return 'Same code counted' + // In the eight highest, but the dismount always counts and took its place. + if (replacedByDismount !== null && items[replacedByDismount]?.code === code) { + return 'Replaced by dismount' + } + return 'Not in top 8' } /** What the status message says when an item could not be added. */ diff --git a/src/tests/lib/calculatorRoutine.test.ts b/src/tests/lib/calculatorRoutine.test.ts index bb2c309..07f4b06 100644 --- a/src/tests/lib/calculatorRoutine.test.ts +++ b/src/tests/lib/calculatorRoutine.test.ts @@ -150,6 +150,9 @@ describe('the order, the counted column and the name', () => { expect(countedText(items, counted, 0)).toBe('Included') expect(countedText(items, counted, 1)).toBe('Same code counted') expect(countedText(items, counted, 2)).toBe('Not in top 8') + // The API says the dismount took the place of the item at 2. + expect(countedText(items, counted, 2, 2)).toBe('Replaced by dismount') + expect(countedText(items, counted, 1, 2)).toBe('Same code counted') }) it('takes a name with the rule of a saved routine', () => { From 6ca225ae59e5f4272f7172b430e7aaf2190fb6ad Mon Sep 17 00:00:00 2001 From: Anettkva Date: Wed, 11 Nov 2026 11:33:12 +0100 Subject: [PATCH 11/14] fix(calculator): do not offer elements that are no longer in the Code of Points --- docs/UI_PLANS.md | 2 +- src/components/calculator/AddElementsList.tsx | 11 +++++++++++ src/lib/elementList.ts | 2 ++ src/lib/searchOperations.ts | 4 ++++ src/tests/elementRows.ts | 8 ++++++++ src/tests/lib/searchOperations.test.ts | 3 +++ 6 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/UI_PLANS.md b/docs/UI_PLANS.md index 2b84571..722735a 100644 --- a/docs/UI_PLANS.md +++ b/docs/UI_PLANS.md @@ -50,7 +50,7 @@ Mockups of some of the pages are in [design/README.md](design/README.md). The calculator (`/calculator`, `src/pages/CalculatorPage.tsx`) puts together a routine on one apparatus and shows its D-value. The routine is kept in the browser only while the site is open, and the element page ("Try in a routine") and the calculator share it, so both show the same routine. A reload starts with an empty routine, because nothing is saved. -- **One table, in the order of the routine.** The routine is a table with the order, the element, its value and whether it counts ("Included", "Not in top 8", or "Same code counted" for a variant of a code that counts already). The last row, "Add element", opens a popup with the search and the elements of the apparatus. The D-value is under the table. +- **One table, in the order of the routine.** The routine is a table with the order, the element, its value and whether it counts ("Included", "Not in top 8", "Same code counted" for a variant of a code that counts already, or "Replaced by dismount" for the element whose place the dismount took). An element that is no longer in the Code of Points cannot be added: its "+" is turned off and the popup says why. The last row, "Add element", opens a popup with the search and the elements of the apparatus. The D-value is under the table. - **The order can be changed** by dragging a row by its handle, and with "Move up" and "Move down" on each row, so it can be done without dragging (WCAG 2.5.7) and with the keyboard. The order is the order of the routine when it is saved; the D-value does not depend on it, except which of two equal values at the edge of the eight counts. - **The routine has a name** ("Uneven bars routine" to begin with, after the apparatus), changed with the pencil. The field has the whole name in it, so any part can be changed and the visitor erases what they do not want; it looks like the search field and has a fixed size on the line of the heading, so nothing on the page moves. It follows the rule of a saved routine (1–70 characters), so #152 can save it as it is. - **Changing the apparatus asks first.** A routine is on one apparatus, so changing the tab of a routine that has items would clear it. The page asks ("Change the apparatus?") instead of clearing it at once, because a routine can take a while to put together and one mis-click on a tab should not lose it. An empty routine changes at once, since there is nothing to lose. Because choosing a tab can ask this question, the arrow keys only move the focus between the tabs, and Enter, Space or a click chooses one (manual activation in the WAI-ARIA tabs pattern). When the question closes, the focus goes to the chosen tab. "Try in a routine" on the page of an element from another apparatus asks the same way before it starts a new routine. diff --git a/src/components/calculator/AddElementsList.tsx b/src/components/calculator/AddElementsList.tsx index 58c90a3..ee54054 100644 --- a/src/components/calculator/AddElementsList.tsx +++ b/src/components/calculator/AddElementsList.tsx @@ -127,6 +127,10 @@ export default function AddElementsList({ > {rows.map((row) => { const pressed = inRoutine(row.id) + // The API refuses a removed element in a routine (INVALID_ITEMS), so it cannot + // be added, as on the element page. One already in the routine can be removed. + const blocked = row.removed && !pressed + const reasonId = `calculator-removed-${row.id}` return (
  • @@ -139,6 +143,11 @@ export default function AddElementsList({ {row.name ? row.description : groupLabel(row)} + {row.removed && ( + + No longer in the Code of Points, so it cannot be added. + + )} {/* The badge and the button side by side, with their circles on one line (the value under the letter makes the badge taller), as the badge and the @@ -155,6 +164,8 @@ export default function AddElementsList({ variant="ghost" size="icon" aria-pressed={pressed} + disabled={blocked} + aria-describedby={row.removed ? reasonId : undefined} onClick={() => onToggle(rowToRoutineItem(row))} className={`size-11 shrink-0 rounded-full border-2 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-(--text-color) ${ pressed diff --git a/src/lib/elementList.ts b/src/lib/elementList.ts index 9f1f85e..477f5c9 100644 --- a/src/lib/elementList.ts +++ b/src/lib/elementList.ts @@ -15,6 +15,8 @@ export type ElementListRow = { hasVariants: boolean /** The drawing of the element, as the API gives it (`imageUrl`). */ imageUrl: string + /** No longer in the Code of Points (the calculator cannot add it). */ + removed: boolean } /** diff --git a/src/lib/searchOperations.ts b/src/lib/searchOperations.ts index 2469210..d250e29 100644 --- a/src/lib/searchOperations.ts +++ b/src/lib/searchOperations.ts @@ -124,6 +124,8 @@ type NodeFields = { name: string | null description: string imageUrl: string + /** No longer in the Code of Points: the API refuses it in a routine (INVALID_ITEMS). */ + removed: boolean elementGroup: { name: string number: number @@ -158,6 +160,7 @@ const RESULT = ` name description imageUrl + removed elementGroup { name number @@ -269,5 +272,6 @@ export function toListRow(node: SearchNode): ElementListRow { isVault, hasVariants: node.variants.length > 0, imageUrl: node.imageUrl, + removed: node.removed, } } diff --git a/src/tests/elementRows.ts b/src/tests/elementRows.ts index 8e6bbae..c3b9bb1 100644 --- a/src/tests/elementRows.ts +++ b/src/tests/elementRows.ts @@ -17,6 +17,7 @@ const rowsWithoutImage: Omit[] = [ value: 2.6, isVault: true, hasVariants: false, + removed: false, }, { id: 'VT-2.31-a', @@ -31,6 +32,7 @@ const rowsWithoutImage: Omit[] = [ value: 4.6, isVault: true, hasVariants: false, + removed: false, }, { id: 'VT-2.50-a', @@ -44,6 +46,7 @@ const rowsWithoutImage: Omit[] = [ value: 6, isVault: true, hasVariants: false, + removed: false, }, { id: 'VT-4.10-a', @@ -57,6 +60,7 @@ const rowsWithoutImage: Omit[] = [ value: 3, isVault: true, hasVariants: false, + removed: false, }, { id: 'VT-4.35-a', @@ -71,6 +75,7 @@ const rowsWithoutImage: Omit[] = [ value: 5.4, isVault: true, hasVariants: false, + removed: false, }, { id: 'VT-4.62-a', @@ -84,6 +89,7 @@ const rowsWithoutImage: Omit[] = [ value: 6.4, isVault: true, hasVariants: false, + removed: false, }, { id: 'UB-1.101-a', @@ -96,6 +102,7 @@ const rowsWithoutImage: Omit[] = [ value: 0.1, isVault: false, hasVariants: true, + removed: false, }, ] @@ -118,6 +125,7 @@ export const nodeOf = (row: ElementListRow) => ({ name: row.name, description: row.description, imageUrl: row.imageUrl, + removed: row.removed, elementGroup: { __typename: 'ElementGroup', name: row.elementGroupName, diff --git a/src/tests/lib/searchOperations.test.ts b/src/tests/lib/searchOperations.test.ts index 8524a9e..68b6141 100644 --- a/src/tests/lib/searchOperations.test.ts +++ b/src/tests/lib/searchOperations.test.ts @@ -19,6 +19,7 @@ const element: SearchNode = { name: null, description: 'Glide kip to support on LB', imageUrl: 'http://images.test/UB/UB-1.101-a.webp', + removed: false, elementGroup: group, value: 0.1, variants: [{ id: 'UB-1.101-b' }], @@ -31,6 +32,7 @@ const vault: SearchNode = { name: 'Amanar', description: 'Round-off, flic-flac on', imageUrl: 'http://images.test/VT/VT-4.35-a.webp', + removed: false, elementGroup: { name: 'A long title', number: 4, @@ -54,6 +56,7 @@ describe('toListRow', () => { isVault: false, hasVariants: true, imageUrl: 'http://images.test/UB/UB-1.101-a.webp', + removed: false, }) }) From 1861551ddb61965bb4e701e16954c7d802c15558 Mon Sep 17 00:00:00 2001 From: Anettkva Date: Wed, 11 Nov 2026 11:33:27 +0100 Subject: [PATCH 12/14] fix(calculator): replace a vault at once, and say nothing for a drop in place --- src/components/elements/ElementActions.tsx | 39 ++++-------- src/pages/CalculatorPage.tsx | 18 +++--- .../elements/ElementActions.test.tsx | 9 +-- src/tests/pages/CalculatorPage.test.tsx | 61 ++++++++++++++++++- 4 files changed, 86 insertions(+), 41 deletions(-) diff --git a/src/components/elements/ElementActions.tsx b/src/components/elements/ElementActions.tsx index 942d02e..89cdec9 100644 --- a/src/components/elements/ElementActions.tsx +++ b/src/components/elements/ElementActions.tsx @@ -29,11 +29,11 @@ export default function ElementActions({ const calculator = useCalculatorRoutine() const item = detailToRoutineItem(element) const inRoutine = calculator.routine.items.some((one) => one.id === item.id) - // The routine is on another apparatus: ask before starting a new one. + // The routine is on another apparatus: ask before starting a new one. This holds the + // apparatus the routine was on when the question opened, so the text does not change + // while the dialog closes after the new routine has started. const [askNewRoutine, setAskNewRoutine] = useState(false) - // The routine is already one vault: ask before replacing it with this one. - const [askReplaceVault, setAskReplaceVault] = useState(false) - const vaultInRoutine = item.isVault ? calculator.routine.items[0] : undefined + const [askedFrom, setAskedFrom] = useState('') const [linkToCalculator, setLinkToCalculator] = useState(false) function tryInRoutine() { @@ -49,9 +49,16 @@ export default function ElementActions({ setLinkToCalculator(true) setStatus(`${item.code} added to the routine in the calculator.`) } else if (result.reason === 'other-apparatus') { + setAskedFrom(calculator.routine.apparatusId) setAskNewRoutine(true) } else if (result.reason === 'one-vault') { - setAskReplaceVault(true) + // A routine on vault is one vault, so this one takes its place at once: only one vault + // is lost, and the message says which. The name of the routine is kept. + const old = calculator.routine.items[0]! + calculator.remove(old.id) + calculator.add(item) + setLinkToCalculator(true) + setStatus(`${old.code} was replaced by ${item.code} in the calculator.`) } else { setStatus(refusalMessage(result.reason, item, calculator.routine)) } @@ -113,7 +120,7 @@ export default function ElementActions({ open={askNewRoutine} onOpenChange={setAskNewRoutine} title="Start a new routine?" - description={`A routine is on one apparatus. Your routine in the calculator is on ${apparatusName(calculator.routine.apparatusId)}, so it will be cleared and a new routine on ${apparatusName(item.apparatusId)} started with ${item.code}.`} + description={`A routine is on one apparatus. Your routine in the calculator is on ${apparatusName(askedFrom)}, so it will be cleared and a new routine on ${apparatusName(item.apparatusId)} started with ${item.code}.`} confirmLabel="Start a new routine" busyLabel="Starting…" onConfirm={async () => { @@ -125,26 +132,6 @@ export default function ElementActions({ setAskNewRoutine(false) }} /> - {vaultInRoutine && ( - { - // The name of the routine is kept: only the vault changes. - calculator.remove(vaultInRoutine.id) - calculator.add(item) - setLinkToCalculator(true) - setStatus( - `${vaultInRoutine.code} was replaced by ${item.code} in the calculator.`, - ) - setAskReplaceVault(false) - }} - /> - )} {error && {error}} ) diff --git a/src/pages/CalculatorPage.tsx b/src/pages/CalculatorPage.tsx index 9731dd7..1b039f7 100644 --- a/src/pages/CalculatorPage.tsx +++ b/src/pages/CalculatorPage.tsx @@ -79,13 +79,17 @@ export default function CalculatorPage() { onClosePopup={() => setRefusal('')} onToggle={toggle} onMove={(id, index) => { - const item = routine.items.find((one) => one.id === id) - move(id, index) - if (item) { - setStatus( - `${item.code} moved to place ${Math.min(index, routine.items.length - 1) + 1} of ${routine.items.length}.`, - ) - } + const from = routine.items.findIndex((one) => one.id === id) + const to = Math.max( + 0, + Math.min(index, routine.items.length - 1), + ) + // Dropped where it was: nothing moved, so nothing is said. + if (from === -1 || from === to) return + move(id, to) + setStatus( + `${routine.items[from]!.code} moved to place ${to + 1} of ${routine.items.length}.`, + ) }} onRename={(name) => { rename(name) diff --git a/src/tests/components/elements/ElementActions.test.tsx b/src/tests/components/elements/ElementActions.test.tsx index 09a04fa..2a2c7c1 100644 --- a/src/tests/components/elements/ElementActions.test.tsx +++ b/src/tests/components/elements/ElementActions.test.tsx @@ -143,7 +143,7 @@ describe('ElementActions', () => { ).toBeTruthy() }) - it('asks before it replaces the vault when the routine already has another vault', async () => { + it('replaces the vault at once when the routine already has another vault, and keeps the name', async () => { const vault = (code: string, dValue: number) => ({ id: `VT-${code}-a`, code, @@ -165,11 +165,8 @@ describe('ElementActions', () => { { apparatusId: 'VT', name: 'My vault', items: [vault('1.10', 2.0)] }, ) fireEvent.click(screen.getByRole('button', { name: 'Try in a routine' })) - expect((await screen.findByRole('alertdialog')).textContent).toContain( - 'Your routine in the calculator is 1.10, so it will be replaced by 1.22.', - ) - - fireEvent.click(screen.getByRole('button', { name: 'Replace the vault' })) + // No question: a routine on vault is one vault, so only that vault is lost. + expect(screen.queryByRole('alertdialog')).toBeNull() await waitFor(() => expect( screen diff --git a/src/tests/pages/CalculatorPage.test.tsx b/src/tests/pages/CalculatorPage.test.tsx index 60d16f3..8a79d32 100644 --- a/src/tests/pages/CalculatorPage.test.tsx +++ b/src/tests/pages/CalculatorPage.test.tsx @@ -48,6 +48,7 @@ const row = ( isVault: false, hasVariants: false, imageUrl: `http://images.test/${id}.webp`, + removed: false, }) const ROWS = [ @@ -74,10 +75,11 @@ const sumAll: Score = (ids) => ) * 10, ) / 10, counted: ids.map(() => true), + replacedByDismount: null, }, }) -function renderPage(score: Score = sumAll, loggedIn = false) { +function renderPage(score: Score = sumAll, loggedIn = false, catalog = ROWS) { const { client, calls } = createTestClient((operation: Operation) => { if (operation.operationName === 'Me') { return ok({ @@ -95,7 +97,9 @@ function renderPage(score: Score = sumAll, loggedIn = false) { return score(operation.variables['itemIds'] as string[]) } const filter = operation.variables['filter'] as { apparatusId: string } - return searchAnswer(ROWS.filter((r) => r.id.startsWith(filter.apparatusId))) + return searchAnswer( + catalog.filter((r) => r.id.startsWith(filter.apparatusId)), + ) }) render( @@ -190,6 +194,7 @@ describe('CalculatorPage', () => { __typename: 'RoutineScore', dScore: 0.3, counted: ids.map((id) => id !== 'UB-1.101-a'), + replacedByDismount: null, }, }), ) @@ -202,6 +207,42 @@ describe('CalculatorPage', () => { expect(counted).toEqual(['Not in top 8', 'Included']) }) + it('cannot add an element that is no longer in the Code of Points, and says why', async () => { + renderPage(sumAll, false, [ + ...ROWS, + { ...row('UB-2.999-a', 0.5, 'An old element'), removed: true }, + ]) + await openAdd() + const add = await toggle('2.999') + expect((add as HTMLButtonElement).disabled).toBe(true) + expect( + document.getElementById(add.getAttribute('aria-describedby')!)! + .textContent, + ).toBe('No longer in the Code of Points, so it cannot be added.') + // The others can still be added. + expect(((await toggle('2.305')) as HTMLButtonElement).disabled).toBe(false) + }) + + it('says "Replaced by dismount" for the element whose place the dismount took', async () => { + renderPage((ids) => + ok({ + scoreRoutine: { + __typename: 'RoutineScore', + dScore: 0.2, + counted: ids.map((id) => id !== 'UB-2.305-a'), + replacedByDismount: ids.indexOf('UB-2.305-a'), + }, + }), + ) + await addElements('2.305', '6.101') + + await waitFor(() => expect(dValue()).toBe('0.2')) + const counted = rows().map( + (r) => within(r).getAllByRole('cell')[3]!.textContent, + ) + expect(counted).toEqual(['Replaced by dismount', 'Included']) + }) + it('changes the order with "Move up" and "Move down", and says so', async () => { renderPage() await addElements('1.101', '2.305') @@ -242,6 +283,22 @@ describe('CalculatorPage', () => { ]) }) + it('says nothing when a row is dropped where it was', async () => { + renderPage() + await addElements('1.101', '2.305') + const handle = within(rows()[1]!).getByTitle('Drag to change the order') + const dataTransfer = { setData: () => undefined, effectAllowed: '' } + + fireEvent.dragStart(handle, { dataTransfer }) + fireEvent.dragOver(rows()[1]!, { dataTransfer }) + fireEvent.drop(rows()[1]!, { dataTransfer }) + expect(screen.queryByText(/moved to place/)).toBeNull() + expect(codesInOrder().map((c) => c!.slice(0, 5))).toEqual([ + '1.101', + '2.305', + ]) + }) + it('shows the line where a row dragged down lands: under the row it is dropped on', async () => { renderPage() await addElements('1.101', '2.305', '6.101') From 4d82b7dc1283a9d0e3e73c10e7cd682393527124 Mon Sep 17 00:00:00 2001 From: Anettkva Date: Wed, 11 Nov 2026 11:40:29 +0100 Subject: [PATCH 13/14] revert(elements): leave "Try in a routine" unconnected Connecting the button is not part of #95, so the element page is as it is on dev. The calculator keeps its routine above the routes, so #91 can connect the button to it later. --- README.md | 2 +- docs/AI_USAGE.md | 2 +- docs/UI_PLANS.md | 2 +- src/App.tsx | 2 +- .../calculator/CalculatorRoutineProvider.tsx | 2 - .../calculator/useCalculatorRoutine.ts | 9 +- src/components/elements/ElementActions.tsx | 77 +----------- src/lib/calculatorRoutine.ts | 16 +-- src/pages/CalculatorPage.tsx | 2 +- .../elements/ElementActions.test.tsx | 116 +----------------- src/tests/pages/ElementDetailPage.test.tsx | 19 ++- 11 files changed, 25 insertions(+), 224 deletions(-) diff --git a/README.md b/README.md index 3d1d3cd..2edabc5 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The beta now has a working React frontend connected to the GraphQL API and Postg - **Your gymnasts:** view and create gymnast profiles using the development test login, which accepts any email address. - **Backend:** the GraphQL API and seeded PostgreSQL catalog run on the VM. -The calculator builds a routine on one apparatus and shows its D-value, and "Try in a routine" on the element page adds to it; saving a routine for a gymnast is not implemented yet. The About page is a placeholder. The Gymnasts screen covers profile listing and creation, not the full planned gymnast/routine workflows. Google sign-in is not planned; the development test login is the only login flow, and it accepts any email address. +The calculator builds a routine on one apparatus and shows its D-value; "Try in a routine" on the element page and saving a routine for a gymnast are not implemented yet. The About page is a placeholder. The Gymnasts screen covers profile listing and creation, not the full planned gymnast/routine workflows. Google sign-in is not planned; the development test login is the only login flow, and it accepts any email address. ## What to review diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 7e0e31a..58fe61f 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -586,4 +586,4 @@ - **Tool:** Claude Code - **What the team did:** Ronja wrote issue #95 from the design. Anette made the three decisions the issue left open: "Not counted" comes from a new API field (and not from the rule repeated in the frontend), changing the apparatus of a routine with items asks first, and the shared routine of #91 and "Try in a routine" on the element page are part of this work. Anette was in dialogue with Claude through the whole work, looked at the page in the browser after each round, and suggested the changes, from the new design direction to details such as the spacing, the warning in the popup, the trash button and which messages to show. -- **What Claude did:** Moved the rule of the D-score into `scoreItems`, which also says which items count, and added `scoreRoutine` next to `calculateDScore` (which is unchanged), with tests of the rule and of the API against a database. Built the page: the apparatus tabs of the gymnast page, the list with search and "Load more", the routine panel, the status messages and the confirmation dialog, and connected "Try in a routine". Wrote tests on the real Apollo Client, and removed nine behaviours one at a time (the toggle state, the "Not counted" mark, ignoring a late answer, the error, the confirmation, "Clear routine", the status message, adding from the element page and asking there) to check that a test fails each time. After that, Anette asked for a way to save the routine and for the list to follow the pagination of #162: added a "Save to a gymnast" button for a logged-in coach, which says it is not connected yet (saving is #152), and marked the list to move to the pagination of #162 when that is merged. Then Anette gave a new design direction (one table with the order, the element, the value and whether it counts, an "Add element" row, an editable name and the D-value below) and chose dragging plus "Move up"/"Move down" for the order, a popup for adding, and a Lucide pencil for the name. Built that with the colours of the site, and checked it with axe-core in light and dark mode and at 320 px: this found that the chosen pill tab had no background (the "line" variant of the tabs cleared it) and that the hidden labels of the table stuck out of its scroll area, and both were fixed. Last, Anette asked for less space at the top of the popup, for at most one mount and one dismount with the dismount always counted, and for the whole name to be editable: the rule that the dismount counts went into `scoreItems` in the backend (so saved routines get it too), and the limit of one mount and one dismount into the calculator's routine; the popup only says something when an element is refused. After that, Anette found that the question about changing the apparatus could not be closed: when it closed, the focus went back to the tab that was pressed, and because the tabs chose a tab when it got the focus, the question opened again. Fixed by choosing a tab only with Enter, Space or a click in the calculator, and by giving the focus to the chosen tab, with a test of both; the long button text now wraps inside its button, and the "+" in the popup is on one line with the circle of the badge. Then, at Anette's request: a refused element (a second mount, for example) is shown as a warning with an icon in the popup and is gone when the popup closes; the note that saving is not ready yet comes beside "Save to a gymnast" instead of under the page description; removing an element from the routine uses the trash button of the gymnast pages (`TrashButton`); and the messages about an added or removed element were removed, because the check mark and the row in the table already show it (the other status messages are kept for a screen reader only). Anette also asked that saving a routine without a dismount should give a warning: this is noted for #152 in the code and in `docs/UI_PLANS.md`. +- **What Claude did:** Moved the rule of the D-score into `scoreItems`, which also says which items count, and added `scoreRoutine` next to `calculateDScore` (which is unchanged), with tests of the rule and of the API against a database. Built the page: the apparatus tabs of the gymnast page, the list with search and "Load more", the routine panel, the status messages and the confirmation dialog, and connected "Try in a routine". Wrote tests on the real Apollo Client, and removed nine behaviours one at a time (the toggle state, the "Not counted" mark, ignoring a late answer, the error, the confirmation, "Clear routine", the status message, adding from the element page and asking there) to check that a test fails each time. After that, Anette asked for a way to save the routine and for the list to follow the pagination of #162: added a "Save to a gymnast" button for a logged-in coach, which says it is not connected yet (saving is #152), and marked the list to move to the pagination of #162 when that is merged. Then Anette gave a new design direction (one table with the order, the element, the value and whether it counts, an "Add element" row, an editable name and the D-value below) and chose dragging plus "Move up"/"Move down" for the order, a popup for adding, and a Lucide pencil for the name. Built that with the colours of the site, and checked it with axe-core in light and dark mode and at 320 px: this found that the chosen pill tab had no background (the "line" variant of the tabs cleared it) and that the hidden labels of the table stuck out of its scroll area, and both were fixed. Last, Anette asked for less space at the top of the popup, for at most one mount and one dismount with the dismount always counted, and for the whole name to be editable: the rule that the dismount counts went into `scoreItems` in the backend (so saved routines get it too), and the limit of one mount and one dismount into the calculator's routine; the popup only says something when an element is refused. After that, Anette found that the question about changing the apparatus could not be closed: when it closed, the focus went back to the tab that was pressed, and because the tabs chose a tab when it got the focus, the question opened again. Fixed by choosing a tab only with Enter, Space or a click in the calculator, and by giving the focus to the chosen tab, with a test of both; the long button text now wraps inside its button, and the "+" in the popup is on one line with the circle of the badge. Then, at Anette's request: a refused element (a second mount, for example) is shown as a warning with an icon in the popup and is gone when the popup closes; the note that saving is not ready yet comes beside "Save to a gymnast" instead of under the page description; removing an element from the routine uses the trash button of the gymnast pages (`TrashButton`); and the messages about an added or removed element were removed, because the check mark and the row in the table already show it (the other status messages are kept for a screen reader only). Anette also asked that saving a routine without a dismount should give a warning: this is noted for #152 in the code and in `docs/UI_PLANS.md`. Last, Anette took "Try in a routine" out of this work again, because connecting it is not part of #95: the element page is as it was before, and the routine is still kept above the routes, so the calculator keeps it when the visitor goes to another page and back, and #91 can connect the button to it. diff --git a/docs/UI_PLANS.md b/docs/UI_PLANS.md index 722735a..2da723f 100644 --- a/docs/UI_PLANS.md +++ b/docs/UI_PLANS.md @@ -48,7 +48,7 @@ Mockups of some of the pages are in [design/README.md](design/README.md). ## The calculator -The calculator (`/calculator`, `src/pages/CalculatorPage.tsx`) puts together a routine on one apparatus and shows its D-value. The routine is kept in the browser only while the site is open, and the element page ("Try in a routine") and the calculator share it, so both show the same routine. A reload starts with an empty routine, because nothing is saved. +The calculator (`/calculator`, `src/pages/CalculatorPage.tsx`) puts together a routine on one apparatus and shows its D-value. The routine is kept in the browser only while the site is open, also when the visitor goes to another page and back. "Try in a routine" on the element page is not connected to it yet (#91). A reload starts with an empty routine, because nothing is saved. - **One table, in the order of the routine.** The routine is a table with the order, the element, its value and whether it counts ("Included", "Not in top 8", "Same code counted" for a variant of a code that counts already, or "Replaced by dismount" for the element whose place the dismount took). An element that is no longer in the Code of Points cannot be added: its "+" is turned off and the popup says why. The last row, "Add element", opens a popup with the search and the elements of the apparatus. The D-value is under the table. - **The order can be changed** by dragging a row by its handle, and with "Move up" and "Move down" on each row, so it can be done without dragging (WCAG 2.5.7) and with the keyboard. The order is the order of the routine when it is saved; the D-value does not depend on it, except which of two equal values at the edge of the eight counts. diff --git a/src/App.tsx b/src/App.tsx index 56d913b..ea04af3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,7 +37,7 @@ export function App() { return ( - {/* The routine of the calculator, shared with "Try in a routine" on the element page. */} + {/* The routine of the calculator, kept when the visitor goes to another page and back. */} update(removeItem(current.current, id)), clear: () => update({ ...current.current, items: [] }), startOn: (apparatusId) => update(newRoutine(apparatusId)), - startWith: (item) => - update({ ...newRoutine(item.apparatusId), items: [item] }), move: (id, index) => update(moveItem(current.current, id, index)), rename: (name) => update({ ...current.current, name: tidyRoutineName(name) }), diff --git a/src/components/calculator/useCalculatorRoutine.ts b/src/components/calculator/useCalculatorRoutine.ts index cefd853..57b7575 100644 --- a/src/components/calculator/useCalculatorRoutine.ts +++ b/src/components/calculator/useCalculatorRoutine.ts @@ -6,9 +6,10 @@ import type { } from '@/lib/calculatorRoutine' /** - * The routine of the calculator, shared by the calculator page and "Try in a routine" on - * the element page, so both show the same routine. It lives while the site is open, and - * is gone after a reload: nothing is saved (see docs/UI_PLANS.md). + * The routine of the calculator. It is kept above the routes, so it is still there when the + * visitor leaves the calculator and comes back, and "Try in a routine" on the element page + * can use it later (#91). It lives while the site is open, and is gone after a reload: + * nothing is saved (see docs/UI_PLANS.md). */ export type CalculatorRoutineState = { routine: CalculatorRoutine @@ -19,8 +20,6 @@ export type CalculatorRoutineState = { clear: () => void /** Starts an empty routine on another apparatus. */ startOn: (apparatusId: string) => void - /** Starts a new routine with only this item, on its apparatus. */ - startWith: (item: RoutineItem) => void /** Moves the item to `index` in the order of the routine (0 is first). */ move: (id: string, index: number) => void /** Gives the routine a new name. Check it first with `routineNameProblem`. */ diff --git a/src/components/elements/ElementActions.tsx b/src/components/elements/ElementActions.tsx index 89cdec9..1002314 100644 --- a/src/components/elements/ElementActions.tsx +++ b/src/components/elements/ElementActions.tsx @@ -1,17 +1,9 @@ import { useId, useState } from 'react' -import { Check, Plus } from 'lucide-react' -import { Link } from 'react-router-dom' -import { useCalculatorRoutine } from '@/components/calculator/useCalculatorRoutine' -import ConfirmDialog from '@/components/ConfirmDialog' +import { Plus } from 'lucide-react' import AddToGymnast from '@/components/elements/AddToGymnast' import ErrorMessage from '@/components/ErrorMessage' import { Button } from '@/components/ui/button' import { PILL } from '@/components/ui/pill' -import { - apparatusName, - detailToRoutineItem, - refusalMessage, -} from '@/lib/calculatorRoutine' import type { ElementDetail } from '@/lib/catalog' export default function ElementActions({ @@ -24,45 +16,6 @@ export default function ElementActions({ const reasonId = useId() // A screen reader reads the reason with each disabled button. const describedBy = element.removed ? reasonId : undefined - // "Try in a routine" adds the element to the calculator's routine, which the calculator - // page shows. It is a toggle: pressed while the element is in the routine. - const calculator = useCalculatorRoutine() - const item = detailToRoutineItem(element) - const inRoutine = calculator.routine.items.some((one) => one.id === item.id) - // The routine is on another apparatus: ask before starting a new one. This holds the - // apparatus the routine was on when the question opened, so the text does not change - // while the dialog closes after the new routine has started. - const [askNewRoutine, setAskNewRoutine] = useState(false) - const [askedFrom, setAskedFrom] = useState('') - const [linkToCalculator, setLinkToCalculator] = useState(false) - - function tryInRoutine() { - setError('') - if (inRoutine) { - calculator.remove(item.id) - setLinkToCalculator(false) - setStatus(`${item.code} removed from the routine in the calculator.`) - return - } - const result = calculator.add(item) - if (result.added) { - setLinkToCalculator(true) - setStatus(`${item.code} added to the routine in the calculator.`) - } else if (result.reason === 'other-apparatus') { - setAskedFrom(calculator.routine.apparatusId) - setAskNewRoutine(true) - } else if (result.reason === 'one-vault') { - // A routine on vault is one vault, so this one takes its place at once: only one vault - // is lost, and the message says which. The name of the routine is kept. - const old = calculator.routine.items[0]! - calculator.remove(old.id) - calculator.add(item) - setLinkToCalculator(true) - setStatus(`${old.code} was replaced by ${item.code} in the calculator.`) - } else { - setStatus(refusalMessage(result.reason, item, calculator.routine)) - } - } return (
    setStatus('The routine builder is not connected yet.')} > - {inRoutine &&
    ) diff --git a/src/lib/calculatorRoutine.ts b/src/lib/calculatorRoutine.ts index 2ef67fa..5138f96 100644 --- a/src/lib/calculatorRoutine.ts +++ b/src/lib/calculatorRoutine.ts @@ -1,10 +1,8 @@ // The routine of the calculator: what the visitor has put together on one apparatus. It -// is kept in the client only, while the page is open (nothing is saved), and the element -// page ("Try in a routine") and the calculator page share it (see +// is kept in the client only, while the site is open (nothing is saved; see // src/components/calculator/useCalculatorRoutine.ts). The D-score comes from the API // (`scoreRoutine`), so the rule of the score is not repeated here. -import type { ElementDetail } from '@/lib/catalog' import { getDifficultyLetter } from '@/lib/difficulty' import type { ElementListRow } from '@/lib/elementList' import { MAX_NAME_LENGTH, nameProblem, normalizeName } from '../../shared/names' @@ -241,15 +239,3 @@ export const rowToRoutineItem = (row: ElementListRow): RoutineItem => ({ apparatusId: apparatusOfId(row.id), kind: kindOfGroup(row.elementGroupName), }) - -/** The element of the element page as an item of the routine ("Try in a routine"). */ -export const detailToRoutineItem = (element: ElementDetail): RoutineItem => ({ - id: element.id, - code: element.code, - name: element.name, - description: element.description, - value: element.value, - isVault: element.isVault, - apparatusId: element.apparatusId, - kind: kindOfGroup(element.elementGroupName), -}) diff --git a/src/pages/CalculatorPage.tsx b/src/pages/CalculatorPage.tsx index 1b039f7..7890b96 100644 --- a/src/pages/CalculatorPage.tsx +++ b/src/pages/CalculatorPage.tsx @@ -13,7 +13,7 @@ import { /** * The calculator: a routine on one apparatus and its D-value, for everyone. The routine is - * shared with "Try in a routine" on the element page and is kept while the site is open; + * kept while the site is open, also when the visitor goes to another page and back; * nothing is saved. Changing the apparatus of a routine that has items asks first, because * a routine is on one apparatus and the items would be lost (see docs/UI_PLANS.md). */ diff --git a/src/tests/components/elements/ElementActions.test.tsx b/src/tests/components/elements/ElementActions.test.tsx index 2a2c7c1..33b9039 100644 --- a/src/tests/components/elements/ElementActions.test.tsx +++ b/src/tests/components/elements/ElementActions.test.tsx @@ -1,19 +1,11 @@ // @vitest-environment jsdom import { ApolloProvider } from '@apollo/client/react' -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react' +import { cleanup, render, screen } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import { afterEach, describe, expect, it } from 'vitest' import { AuthProvider } from '@/components/auth/AuthProvider' -import { CalculatorRoutineProvider } from '@/components/calculator/CalculatorRoutineProvider' import ElementActions from '@/components/elements/ElementActions' -import type { CalculatorRoutine } from '@/lib/calculatorRoutine' import type { ElementDetail } from '@/lib/catalog' import { createTestClient, ok } from '@/tests/apollo' @@ -36,16 +28,14 @@ const element: ElementDetail = { } /** Nobody is logged in (`Me` answers null). */ -function renderActions(detail: ElementDetail, routine?: CalculatorRoutine) { +function renderActions(detail: ElementDetail) { const { client } = createTestClient(() => ok({ me: null })) return render( - - - - - + + + , ) @@ -85,100 +75,4 @@ describe('ElementActions', () => { expect(button.getAttribute('aria-describedby')).toBe(reason.id) } }) - - it('adds the element to the calculator with "Try in a routine", and removes it again', () => { - renderActions(element) - const button = screen.getByRole('button', { name: 'Try in a routine' }) - - fireEvent.click(button) - expect(button.getAttribute('aria-pressed')).toBe('true') - expect( - screen.getByText('2.305 added to the routine in the calculator.'), - ).toBeTruthy() - expect( - screen - .getByRole('link', { name: 'Open the calculator' }) - .getAttribute('href'), - ).toBe('/calculator') - - fireEvent.click(button) - expect(button.getAttribute('aria-pressed')).toBe('false') - expect( - screen.queryByRole('link', { name: 'Open the calculator' }), - ).toBeNull() - }) - - it('asks before it starts a new routine when the routine is on another apparatus', async () => { - renderActions(element, { - apparatusId: 'BB', - name: 'New routine', - items: [ - { - id: 'BB-1.101-a', - code: '1.101', - name: null, - description: 'Leap on', - value: 0.1, - isVault: false, - apparatusId: 'BB', - kind: 'mount', - }, - ], - }) - fireEvent.click(screen.getByRole('button', { name: 'Try in a routine' })) - expect((await screen.findByRole('alertdialog')).textContent).toContain( - 'is on Balance beam', - ) - - fireEvent.click(screen.getByRole('button', { name: 'Start a new routine' })) - await waitFor(() => - expect( - screen - .getByRole('button', { name: 'Try in a routine' }) - .getAttribute('aria-pressed'), - ).toBe('true'), - ) - expect( - screen.getByText('A new routine on Uneven bars was started with 2.305.'), - ).toBeTruthy() - }) - - it('replaces the vault at once when the routine already has another vault, and keeps the name', async () => { - const vault = (code: string, dValue: number) => ({ - id: `VT-${code}-a`, - code, - name: null, - description: 'Handspring fwd on – salto fwd tuck off', - value: dValue, - isVault: true, - apparatusId: 'VT', - kind: null, - }) - renderActions( - { - ...element, - ...vault('1.22', 2.6), - apparatusName: 'Vault', - elementGroupName: 'Group 1', - elementGroupNumber: 1, - }, - { apparatusId: 'VT', name: 'My vault', items: [vault('1.10', 2.0)] }, - ) - fireEvent.click(screen.getByRole('button', { name: 'Try in a routine' })) - // No question: a routine on vault is one vault, so only that vault is lost. - expect(screen.queryByRole('alertdialog')).toBeNull() - await waitFor(() => - expect( - screen - .getByRole('button', { name: 'Try in a routine' }) - .getAttribute('aria-pressed'), - ).toBe('true'), - ) - expect( - screen.getByText('1.10 was replaced by 1.22 in the calculator.'), - ).toBeTruthy() - expect( - screen.getByRole('link', { name: 'Open the calculator' }), - ).toBeTruthy() - }) }) diff --git a/src/tests/pages/ElementDetailPage.test.tsx b/src/tests/pages/ElementDetailPage.test.tsx index 36b1e5b..7432d55 100644 --- a/src/tests/pages/ElementDetailPage.test.tsx +++ b/src/tests/pages/ElementDetailPage.test.tsx @@ -22,7 +22,6 @@ import { type Operation, } from '@/tests/apollo' import { AuthProvider } from '@/components/auth/AuthProvider' -import { CalculatorRoutineProvider } from '@/components/calculator/CalculatorRoutineProvider' import ElementDetailPage from '@/pages/ElementDetailPage' const variant = (id: string) => ({ @@ -87,16 +86,14 @@ function renderPage(id: string, handler: Handler) { render( - - - - } - /> - - - + + + } + /> + + , ) From b4b3350de4532e1d78544f9f7e0486077a29291a Mon Sep 17 00:00:00 2001 From: Anettkva Date: Wed, 11 Nov 2026 15:52:57 +0100 Subject: [PATCH 14/14] fix(calculator): keep a given routine name on another apparatus, and say 1000+ in the popup --- src/components/calculator/AddElementsList.tsx | 3 +- .../calculator/CalculatorRoutineProvider.tsx | 5 ++-- .../calculator/useCalculatorRoutine.ts | 2 +- src/lib/calculatorRoutine.ts | 26 ++++++++++++---- src/tests/lib/calculatorRoutine.test.ts | 10 +++++++ src/tests/pages/CalculatorPage.test.tsx | 30 ++++++++++++++++--- 6 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/components/calculator/AddElementsList.tsx b/src/components/calculator/AddElementsList.tsx index ee54054..6819cfc 100644 --- a/src/components/calculator/AddElementsList.tsx +++ b/src/components/calculator/AddElementsList.tsx @@ -10,6 +10,7 @@ import { rowToRoutineItem, type RoutineItem } from '@/lib/calculatorRoutine' import { getDifficultyLetter } from '@/lib/difficulty' import { groupLabel } from '@/lib/elementList' import { + formatTotal, MAX_SEARCH_LENGTH, PAGE_SIZE, SEARCH_CATALOG_MORE_QUERY, @@ -189,7 +190,7 @@ export default function AddElementsList({

    - Showing {rows.length} of {shown.totalCount} + Showing {rows.length} of {formatTotal(shown.totalCount)}