Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
71 changes: 64 additions & 7 deletions backend/src/catalog/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
searchCatalog,
searchTerms,
} from './search.js'
import { catalogStructure } from './structure.js'

describe('searchTerms', () => {
it('uses the same rules as the stored words: lowercase, no accents, codes kept whole', () => {
Expand Down Expand Up @@ -218,22 +219,78 @@ describe.runIf(hasDatabase)('searchCatalog on the seeded catalog', () => {
)
})

it('rejects difficulty values that are not 0.1–1.0 in steps of 0.1, and vault', async () => {
it('rejects difficulty values that are not in steps of 0.1 or out of range', async () => {
for (const value of [0, 0.15, 1.1, -0.1, Number.NaN]) {
await expectBadInput(
{ filter: { difficultyValues: [value] } },
/Difficulty value .* is not valid/,
)
}
await expectBadInput(
{ filter: { apparatusId: 'VT', difficultyValues: [0.3] } },
/cannot be used with vault/,
// Vault D-values go higher, but not above 10.0, and also in steps of 0.1.
for (const value of [0, 2.45, 10.1, Number.NaN]) {
await expectBadInput(
{ filter: { apparatusId: 'VT', difficultyValues: [value] } },
/Difficulty value .* is not valid/,
)
}
})

it('filters vault by its D-value, and never mixes it with the element values', async () => {
const dValues = (
await prisma.catalogEntry.findMany({
where: { type: 'VAULT' },
select: { difficulty: true },
distinct: ['difficulty'],
orderBy: { difficulty: 'asc' },
})
).map((e) => Number(e.difficulty))
const chosen = [dValues[1], dValues[3]]
const found = await searchCatalog(prisma, {
filter: { apparatusId: 'VT', difficultyValues: chosen },
withTotalCount: true,
first: 100,
})
expect(found.edges.length).toBeGreaterThan(0)
expect(
found.edges.every(
(e) =>
e.node.type === 'VAULT' && chosen.includes(Number(e.node.difficulty)),
),
).toBe(true)
expect(found.totalCount).toBe(
await prisma.catalogEntry.count({
where: {
type: 'VAULT',
difficulty: { in: chosen.map((v) => v.toFixed(1)) },
},
}),
)
// A vault group without an apparatus filter is vault too, not 0 hits.
await expectBadInput(
{ filter: { elementGroupId: 'VT-1', difficultyValues: [0.3] } },
/cannot be used with vault/,
const byGroup = await searchCatalog(prisma, {
filter: { elementGroupId: 'VT-1', difficultyValues: dValues },
withTotalCount: true,
})
expect(byGroup.totalCount).toBe(
await prisma.catalogEntry.count({ where: { elementGroupId: 'VT-1' } }),
)
// The element scale 0.1–1.0 matches no vault, and without an apparatus it is elements.
const none = await searchCatalog(prisma, {
filter: { apparatusId: 'VT', difficultyValues: [0.3] },
withTotalCount: true,
})
expect(none.totalCount).toBe(0)
})

it('lists the difficulty values that exist, in order, in the structure', async () => {
const { difficultyValuesByApparatus } = await catalogStructure(prisma)
for (const [apparatusId, values] of difficultyValuesByApparatus) {
expect(values).toEqual([...values].sort((x, y) => x - y))
expect(new Set(values).size).toBe(values.length)
expect(
values.every((v) => (apparatusId === 'VT' ? v >= 1 : v <= 1)),
).toBe(true)
}
expect(difficultyValuesByApparatus.get('VT')!.length).toBeGreaterThan(5)
})

it('sorts by difficulty only within an apparatus', async () => {
Expand Down
36 changes: 24 additions & 12 deletions backend/src/catalog/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ export type { SortDirection, SortField } from './sorts.js'
export type CatalogFilter = {
apparatusId?: string | null
elementGroupId?: string | null
/** Difficulty values 0.1–1.0 in steps of 0.1. Only elements, never vaults. */
/**
* Difficulty values in steps of 0.1: 0.1–1.0 for elements, and the D-values (0.1–10.0)
* for vault, when the apparatus or the group is vault. Without an apparatus it only
* finds elements.
*/
difficultyValues?: number[] | null
namedOnly?: boolean | null
}
Expand Down Expand Up @@ -84,16 +88,21 @@ export function searchTerms(text: string | null | undefined): string[] {
}

/** Checks a difficulty value and returns it as a Decimal with one decimal. */
function difficultyValue(value: unknown): Prisma.Decimal {
/** The highest D-value that can be filtered on: element values are 0.1–1.0, vault D-values 0.1–10.0. */
const MAX_ELEMENT_TENTHS = 10
const MAX_VAULT_TENTHS = 100

function difficultyValue(value: unknown, isVault: boolean): Prisma.Decimal {
const maxTenths = isVault ? MAX_VAULT_TENTHS : MAX_ELEMENT_TENTHS
const tenths = typeof value === 'number' ? Math.round(value * 10) : NaN
if (
!Number.isFinite(tenths) ||
Math.abs((value as number) * 10 - tenths) > 1e-6 ||
tenths < 1 ||
tenths > 10
tenths > maxTenths
) {
throw new BadUserInputError(
`Difficulty value ${String(value)} is not valid. Use 0.1–1.0 in steps of 0.1.`,
`Difficulty value ${String(value)} is not valid. Use ${isVault ? '0.1–10.0 for vault' : '0.1–1.0'} in steps of 0.1.`,
)
}
return new Prisma.Decimal(tenths).dividedBy(10)
Expand Down Expand Up @@ -134,14 +143,17 @@ async function buildWhere(
groupApparatusId = group.apparatusId
}
if (filter.difficultyValues != null && filter.difficultyValues.length > 0) {
// A vault group without an apparatus filter is vault too.
if (filter.apparatusId === 'VT' || groupApparatusId === 'VT') {
throw new BadUserInputError(
'The difficulty filter cannot be used with vault. Vaults have D-values, not difficulty values.',
)
}
const values = filter.difficultyValues.map(difficultyValue)
and.push({ type: 'ELEMENT', difficulty: { in: values } })
// A vault group without an apparatus filter is vault too. Vault is filtered on its
// D-value (the same column), and elements on their value. The two scales are never
// mixed: without an apparatus only elements are filtered, as before.
const isVault = filter.apparatusId === 'VT' || groupApparatusId === 'VT'
const values = filter.difficultyValues.map((v) =>
difficultyValue(v, isVault),
)
and.push({
type: isVault ? 'VAULT' : 'ELEMENT',
difficulty: { in: values },
})
}
if (filter.namedOnly === true) and.push({ hasName: true })

Expand Down
20 changes: 19 additions & 1 deletion backend/src/catalog/structure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ export const CACHE_TTL_MS = 10 * 60 * 1000
export type CatalogStructure = {
apparatusIds: ReadonlySet<string>
groupsById: ReadonlyMap<string, ElementGroup>
/**
* The difficulty values that exist on each apparatus, ascending: element values
* (0.1–1.0) and vault D-values. Read with one `groupBy`, once per CACHE_TTL_MS, so the
* cost does not depend on how many requests there are.
*/
difficultyValuesByApparatus: ReadonlyMap<string, readonly number[]>
}

const cache = new WeakMap<
Expand All @@ -20,13 +26,25 @@ const cache = new WeakMap<
>()

async function load(prisma: PrismaClient): Promise<CatalogStructure> {
const [apparatus, groups] = await Promise.all([
const [apparatus, groups, difficulties] = await Promise.all([
prisma.apparatus.findMany({ select: { id: true } }),
prisma.elementGroup.findMany({ orderBy: { number: 'asc' } }),
// The index on (apparatus_id, difficulty, id) serves this.
prisma.catalogEntry.groupBy({
by: ['apparatusId', 'difficulty'],
orderBy: { difficulty: 'asc' },
}),
])
const difficultyValuesByApparatus = new Map<string, number[]>()
for (const { apparatusId, difficulty } of difficulties) {
const values = difficultyValuesByApparatus.get(apparatusId) ?? []
values.push(Number(difficulty))
difficultyValuesByApparatus.set(apparatusId, values)
}
return {
apparatusIds: new Set(apparatus.map((a) => a.id)),
groupsById: new Map(groups.map((g) => [g.id, g])),
difficultyValuesByApparatus,
}
}

Expand Down
26 changes: 18 additions & 8 deletions backend/src/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
# is not already in the routine or the gymnast's list.
# BAD_USER_INPUT Empty name, name longer than 70 characters, malformed id,
# `first` out of range, a DIFFICULTY sort without an
# apparatus filter, a difficulty filter together with vault,
# or a difficulty value that is not 0.1-1.0 in steps of 0.1.
# apparatus filter, or a difficulty value that is not in
# steps of 0.1 or outside 0.1-1.0 (0.1-10.0 for vault).
# LIMIT_REACHED The coach has reached a limit: 200 gymnasts, 50 routines
# per gymnast, 50 items in a routine, or 100 items in a
# gymnast's list.
Expand All @@ -47,6 +47,13 @@ type Apparatus {
id: ID!
name: String!
groups: [ElementGroup!]!
"""
The difficulty values that exist on this apparatus, lowest first: the element
values (0.1-1.0) on uneven bars, balance beam and floor, and the D-values
(1.6-6.4) on vault. These are the values that `filter.difficultyValues` can
choose from together with this apparatus. Read once and kept for ten minutes.
"""
difficultyValues: [Float!]!
}

"""
Expand Down Expand Up @@ -172,12 +179,15 @@ input CatalogFilter {
apparatusId: ID
elementGroupId: ID
"""
Only elements with one of these difficulty values (several can be chosen),
e.g. [0.1, 0.2]. The frontend shows the values as letters, so choosing A
and B sends [0.1, 0.2]. Each value must be 0.1-1.0 in steps of 0.1,
otherwise BAD_USER_INPUT. Vaults have D-values and not difficulty values,
so they are left out when this is set, and together with apparatusId "VT"
it throws BAD_USER_INPUT. Empty or missing means no difficulty filter.
Only items with one of these difficulty values (several can be chosen). For
elements, e.g. [0.1, 0.2]: the frontend shows the values as letters, so
choosing A and B sends [0.1, 0.2], and each value must be 0.1-1.0 in steps of
0.1. For vault (apparatusId "VT", or an elementGroupId of vault) the values
are D-values, e.g. [2.4, 3.2], 0.1-10.0 in steps of 0.1. A value outside the
range or not in steps of 0.1 throws BAD_USER_INPUT. Without an apparatus or a
vault group only elements are filtered, because the two scales cannot be
compared, so vaults are left out. `Apparatus.difficultyValues` lists the
values that exist. Empty or missing means no difficulty filter.
"""
difficultyValues: [Float!]
"""
Expand Down
9 changes: 9 additions & 0 deletions backend/src/server/resolvers/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,15 @@ export const catalogResolvers = {
_args: unknown,
ctx: Context,
): Promise<ElementGroup[]> => listElementGroups(ctx.prisma, apparatus.id),
difficultyValues: async (
apparatus: Apparatus,
_args: unknown,
ctx: Context,
): Promise<number[]> => [
...((await catalogStructure(ctx.prisma)).difficultyValuesByApparatus.get(
apparatus.id,
) ?? []),
],
},

ElementGroup: {
Expand Down
28 changes: 25 additions & 3 deletions backend/src/server/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,28 @@ describe.runIf(hasDatabase)('the GraphQL server on the seeded catalog', () => {
}
})

it('lists the difficulty values of each apparatus, lowest first: values on bars, beam and floor, D-values on vault', async () => {
const { data, errors } = await run<{
apparatus: { id: string; difficultyValues: number[] }[]
}>('{ apparatus { id difficultyValues } }')

expect(errors).toBeUndefined()
expect(data!.apparatus.length).toBeGreaterThan(0)
for (const { id, difficultyValues } of data!.apparatus) {
const expected = (
await prisma.catalogEntry.findMany({
where: { apparatusId: id },
select: { difficulty: true },
distinct: ['difficulty'],
orderBy: { difficulty: 'asc' },
})
).map((e) => Number(e.difficulty))
expect(difficultyValues).toEqual(expected)
}
const vault = data!.apparatus.find((a) => a.id === 'VT')!
expect(Math.min(...vault.difficultyValues)).toBeGreaterThan(1)
})

it('gives an element with its value, drawing, group and removed flag', async () => {
const row = await prisma.element.findFirstOrThrow({
orderBy: { id: 'asc' },
Expand Down Expand Up @@ -452,9 +474,9 @@ describe.runIf(hasDatabase)('the GraphQL server on the seeded catalog', () => {
/Unknown apparatus/,
],
[
'difficulty together with vault',
'{ searchCatalog(filter: { apparatusId: "VT", difficultyValues: [0.1] }) { totalCount } }',
/cannot be used with vault/,
'a vault difficulty value above 10.0',
'{ searchCatalog(filter: { apparatusId: "VT", difficultyValues: [10.1] }) { totalCount } }',
/not valid/,
],
[
'a difficulty value that does not exist',
Expand Down
6 changes: 6 additions & 0 deletions docs/AI_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,3 +491,9 @@
- **Tool:** Claude Code
- **What the team did:** Decided that the page says "1000+" when there are more than 1000 results, and that the server must not count more than that.
- **What Claude did:** Made the database stop counting at 1001 (`count` with `take`), kept the schema the same (a `totalCount` above 1000 means more than 1000), wrote the tests (the count stops at one more than the most, it is not capped below that, and a search is capped too) and broke the cap to check that they fail without it.

## The filters of the Elements page (`src/components/elements/FilterMenu.tsx`, `src/lib/elementFilters.ts`)

- **Tool:** Claude Code
- **What the team did:** Wrote issue #127 with the three filters, the address (`?apparatus=UB&group=UB-2&difficulty=A,B`), and the rule that difficulty is not offered for vault. Asked for ready-made solutions only, a conventional design, and that it scales to many entries.
- **What Claude did:** Used the Base UI `Menu` (already in the project, same as the sort in #128): radio items for apparatus and group, checkbox items for the difficulty letters, a menu that closes after one choice and stays open for several. Used `useSearchParams` from React Router for the address. The options are the apparatus with their groups (one small query, read once and cached) and the letters A-J, and the filtering runs on the server with cursor pages, so nothing depends on how many elements there are. An address with a value that does not exist is cleaned before it is sent, and the search waits for the structure when the address has a filter. Wrote the tests on the real Apollo Client with a stub for `fetch`, and checked them by breaking seven behaviors one at a time (two did not fail at first: one test was too weak, and one finding was that Base UI radio items do not close the menu by default).
Loading