Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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; "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

Expand Down Expand Up @@ -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):

Expand Down Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions backend/src/catalog/structure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,6 +19,8 @@ export type CatalogStructure = {
* cost does not depend on how many requests there are.
*/
difficultyValuesByApparatus: ReadonlyMap<string, readonly number[]>
/** The groups of the dismounts (uneven bars and balance beam), for the D-score. */
dismountGroupIds: ReadonlySet<string>
}

const cache = new WeakMap<
Expand Down Expand Up @@ -45,6 +48,9 @@ async function load(prisma: PrismaClient): Promise<CatalogStructure> {
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),
),
}
}

Expand Down
124 changes: 123 additions & 1 deletion backend/src/dscore.test.ts
Original file line number Diff line number Diff line change
@@ -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 = (
Expand Down Expand Up @@ -112,3 +112,125 @@ 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],
replacedByDismount: null,
})
})

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],
replacedByDismount: null,
})
})

it('counts the vault of a routine on vault', () => {
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: [],
replacedByDismount: null,
})
})

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, 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([
true,
true,
true,
true,
true,
true,
true,
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],
replacedByDismount: null,
})
})
})
96 changes: 75 additions & 21 deletions backend/src/dscore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,46 +23,98 @@ 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 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)

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
}

/**
* 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<string, number>()
for (const item of items) {
const value = toTenths(item.value)
byCode.set(item.code, Math.max(byCode.get(item.code) ?? 0, value))
items.forEach((item, index) => {
const best = byCode.get(item.code)
if (best === undefined || tenths[index]! > tenths[best]!) {
byCode.set(item.code, index)
}
})

if (items.some((item) => item.isVault) && byCode.size > 1) {
throw new InvalidItemsError('A routine on vault has only one vault.')
}

if (items.some((item) => item.isVault)) {
if (byCode.size > 1) {
throw new InvalidItemsError('A routine on vault has only one vault.')
// 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)
let replacedByDismount: number | null = null
if (dismount !== undefined && !chosen.includes(dismount)) {
const lowest = chosen.findLastIndex((index) => !items[index]!.isDismount)
if (lowest !== -1) {
replacedByDismount = chosen[lowest]!
chosen[lowest] = dismount
}
return [...byCode.values()][0]! / 10
}

const highest = [...byCode.values()]
.sort((a, b) => b - a)
.slice(0, COUNTED_ELEMENTS)
return highest.reduce((sum, tenths) => sum + tenths, 0) / 10
const counts = new Set(chosen)
return {
dScore: chosen.reduce((sum, index) => sum + tenths[index]!, 0) / 10,
counted: items.map((_, index) => counts.has(index)),
replacedByDismount,
}
}

/** 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<string>,
): 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),
}
35 changes: 34 additions & 1 deletion backend/src/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"""
Expand Down Expand Up @@ -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.
Expand All @@ -339,6 +347,31 @@ 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!]!
"""
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 {
"""
Short-lived token (15 minutes). The client sends it in the header
Expand Down
Loading