Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
34592fb
feat: add the table row color and let DifficultyBadge hide its value
Oct 29, 2026
7309b22
feat: build the Elements page with the results table
Oct 29, 2026
cdda597
docs: describe the Elements page in AI_USAGE.md
Oct 29, 2026
42d6f68
Merge remote-tracking branch 'origin/dev' into 41-elements-page-ui
Oct 29, 2026
486c4e3
fix: remove the image-copy plugin that a merge brought back and broke…
Oct 29, 2026
8c6fc86
feat: rename Elements to Search in the navbar and point the logo to t…
Oct 29, 2026
07d4343
Merge remote-tracking branch 'origin/dev' into 41-elements-page-ui
Oct 29, 2026
759e02a
refactor: use DifficultyBadge as it is, without new props
Oct 29, 2026
6040e53
feat: let the whole row grow on hover and focus instead of underlinin…
Oct 29, 2026
84ac178
feat: make the hovered row taller instead of wider
Oct 29, 2026
0937096
feat: remove the lines between the table rows
Oct 29, 2026
a87f27b
feat: make the text of the hovered row look bolder without moving it
Oct 29, 2026
b06e8bb
feat: add the value column, chevron, bold code, focus rings and a dar…
Oct 29, 2026
b057ef4
Merge remote-tracking branch 'origin/dev' into 41-elements-page-ui
Oct 29, 2026
2f84882
feat: use the light More-variants color on the buttons and the value …
Oct 29, 2026
7b22db4
feat: give the search field the light button color
Oct 29, 2026
3aa10db
feat: make the search field see-through with a tan line in dark mode
Oct 29, 2026
440bea5
feat: use a tint of the blue accent for every other table row instead…
Oct 29, 2026
3e19543
feat: use the whole width on every page, and use the search field col…
Oct 29, 2026
603bfda
fix: no color change on hover in light mode, and keep the button text…
Oct 29, 2026
e13d75d
fix: make the text on a hovered row only a little bolder
Oct 29, 2026
b8825ec
fix: draw the clear X of the search field in the color of the search …
Oct 29, 2026
604074e
Merge remote-tracking branch 'origin/dev' into 41-elements-page-ui
Oct 29, 2026
593afd7
feat: show the apparatus and the group as labels in the mobile cards,…
Oct 29, 2026
e8801a1
feat: put the label above the value pill in the mobile cards, and kee…
Oct 29, 2026
6d13550
feat: more space around the apparatus and group labels in the mobile …
Oct 29, 2026
885d052
feat: indent the labels over the apparatus and group pills a little
Oct 29, 2026
a7e46e3
feat: keep the navy table heading on a phone (Element and D-value), a…
Oct 29, 2026
9868ce9
feat: show the results as cards on a small screen, with the drawing w…
Oct 29, 2026
bfcc78a
feat: cards with the drawing below, an arrow and a hover effect, two …
Oct 29, 2026
02ed9e7
feat: connect the search field and the results of the Elements page t…
Oct 29, 2026
acb278d
fix: call the group Element group in the table and the cards, and the…
Oct 29, 2026
4940b9c
Merge branch '41-elements-page-ui' into 117-connect-elements-search
Oct 29, 2026
8b76b5d
feat: make Load more elements fetch the next page, and disable it whe…
Oct 29, 2026
d2cd760
feat: stop counting the matches of a search at 1001
Oct 29, 2026
7d6295b
feat: say 1000+ when the server counts more than 1000
Oct 29, 2026
4cb06d4
Merge remote-tracking branch 'origin/110-cap-total-count' into 41-ele…
Oct 29, 2026
98423d0
refactor: call the value and the D-value "Difficulty" everywhere
Oct 30, 2026
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
51 changes: 51 additions & 0 deletions backend/src/catalog/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
BadUserInputError,
type CatalogSearchArgs,
listElementGroups,
MAX_TOTAL_COUNT,
searchCatalog,
searchTerms,
} from './search.js'
Expand Down Expand Up @@ -502,4 +503,54 @@ describe.runIf(hasDatabase)('searchCatalog on the seeded catalog', () => {
const page = await searchCatalog(prisma, { first: 1 })
expect(page.totalCount).toBeUndefined()
})

describe('the count stops at the most that is counted', () => {
const count = async (maxTotalCount?: number, search?: string) =>
(
await searchCatalog(prisma, {
first: 1,
search,
withTotalCount: true,
maxTotalCount,
})
).totalCount

it('counts up to 1000 as it is, and the catalog is smaller than that', async () => {
expect(MAX_TOTAL_COUNT).toBe(1000)
expect(totalEntries).toBeLessThanOrEqual(MAX_TOTAL_COUNT)
expect(await count()).toBe(totalEntries)
})

it('stops at one more than the most, so a count above it means "more than that"', async () => {
expect(totalEntries).toBeGreaterThan(10)
expect(await count(10)).toBe(11)
expect(await count(1)).toBe(2)
})

it('is not capped when the most is as big as the number of matches, or bigger', async () => {
expect(await count(totalEntries)).toBe(totalEntries)
expect(await count(totalEntries + 500)).toBe(totalEntries)
// The most that is one less than the number of matches: one more than it is the
// number of matches, which is above the most.
expect(await count(totalEntries - 1)).toBe(totalEntries)
})

it('caps a search too, and a search with fewer matches than the most is counted as it is', async () => {
const all = await searchCatalog(prisma, {
first: 100,
search: 'handspring',
withTotalCount: true,
})
expect(all.totalCount).toBeGreaterThan(5)

expect(await count(5, 'handspring')).toBe(6)
expect(await count(all.totalCount! + 10, 'handspring')).toBe(
all.totalCount,
)
})

it('counts nothing for text with no searchable words, also with a cap', async () => {
expect(await count(10, '%')).toBe(0)
})
})
})
30 changes: 25 additions & 5 deletions backend/src/catalog/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,33 @@ export type CatalogSearchArgs = {
/** Page size, 1–100. */
first?: number | null
after?: string | null
/** Count all matches. Only set it when the client asks for totalCount. */
/** Count the matches. Only set it when the client asks for totalCount. */
withTotalCount?: boolean
/**
* The most matches that are counted, MAX_TOTAL_COUNT if it is left out. The count
* stops at one more than this, so a count above it means "more than this many".
*/
maxTotalCount?: number
}

export type CatalogSearchResult = {
edges: { cursor: string; node: CatalogEntry }[]
pageInfo: { hasNextPage: boolean; endCursor: string | null }
/** Only set when withTotalCount was true. */
/**
* Only set when withTotalCount was true. At most maxTotalCount + 1: a value above
* maxTotalCount means that there are more matches than that.
*/
totalCount?: number
}

const DIRECTIONS: readonly SortDirection[] = ['ASC', 'DESC']
const MAX_PAGE_SIZE = 100
/**
* How many matches `totalCount` counts. A search that has more matches gets one more
* than this (1001), which the interface shows as "1000+". The database stops counting
* there, so the cost of the count does not grow with the catalog.
*/
export const MAX_TOTAL_COUNT = 1000
/** Limits for the search text. Far more than anyone types, and each word adds a condition to the query. */
export const MAX_SEARCH_LENGTH = 80
export const MAX_SEARCH_WORDS = 8
Expand Down Expand Up @@ -237,9 +251,15 @@ export async function searchCatalog(
endCursor: edges.at(-1)?.cursor ?? null,
},
}
// Counting reads every match, so it only runs when the client asks for it.
if (args.withTotalCount)
result.totalCount = await prisma.catalogEntry.count({ where })
// Counting reads the matches, so it only runs when the client asks for it, and it
// stops after one more than the most that is counted (take).
if (args.withTotalCount) {
const max = args.maxTotalCount ?? MAX_TOTAL_COUNT
result.totalCount = await prisma.catalogEntry.count({
where,
take: max + 1,
})
}
return result
}

Expand Down
3 changes: 2 additions & 1 deletion backend/src/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,8 @@ type CatalogConnection {
"""
Number of matches in the whole result set, not only this page. Counting is
expensive on a large result, so the server only counts when the client asks
for this field.
for this field, and it stops at 1001: a value above 1000 means that there are
more than 1000 matches (the interface shows "1000+").
"""
totalCount: Int!
}
Expand Down
31 changes: 31 additions & 0 deletions docs/AI_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,19 @@
id, a stopped server and "Try again" work. The JavaScript bundle went from
665 kB to 522 kB.

## The Elements page (`src/components/elements/ElementsPage.tsx`, `ResultsTable.tsx`)

- **Tool:** Claude Code
- **What the team did:** Drew the page in a design (`elements_page.png`) and
chose that it should look exactly like it, without lines between the rows. Decided that this pull request is only the
design, and that the search, filters, sort and paging are connected in #110.
- **What Claude did:** Measured the colors in the design and used the existing
tokens for all of them but the purple of every other row (new:
`--table-row-alt`). Wrote the page and the table with the existing badge for
the circle. Checked the page at 390, 768, 927 and 1440 pixels wide in a
browser, and chose how the table becomes cards on a phone and how the whole
row is one link. Wrote the tests.

## Tests for the element page (`src/components/elements/ElementDetailPage.test.tsx`, `src/tests/apollo.ts`)

- **Tool:** Claude Code
Expand All @@ -460,3 +473,21 @@
page one at a time (the drawing, the warning, the disabled buttons, the
not-found page, "Try again", "Trying again…", keeping the current element and
the variant link) and checked that a test failed each time.

## The search and the results of the Elements page (`src/components/elements/ElementsPage.tsx`, `src/lib/searchOperations.ts`)

- **Tool:** Claude Code
- **What the team did:** Decided that making the page work is done in parts, each with its own issue and pull request, and that the first part is the search field and the results (#117). Chose that the pull request is stacked on the design (#114) until that is merged.
- **What Claude did:** Wrote the query `searchCatalog` and the page with the results, the loading, error and empty states, the debounce and the search in the address. Moved the example rows to a fixture for the tests. Wrote the tests on the real Apollo Client with a stub for `fetch`, and checked them by breaking five behaviors one at a time (the tests fail without each of them). Checked in a browser against the real backend: the first 20 of 562, a search, no result, a search from the address, and 9 words.

## Load more elements (`src/components/elements/ElementsPage.tsx`, `src/lib/searchOperations.ts`)

- **Tool:** Claude Code
- **What the team did:** Decided that "Load more elements" is disabled when everything is shown ("Showing N of N"), and that all the work to make the page work is one issue (#110), in several pull requests.
- **What Claude did:** Wrote the query for the next page (without `totalCount`, which the API counts only when it is asked), the button with its states (loading, disabled when everything is shown, error with a new try) and the tests, and checked the tests by breaking six behaviors one at a time. Checked in a browser against the real backend: 20, 40 and 60 of 562, one result (disabled at once), and 36 of 36 for a search that has two pages.

## The count of the search stops at 1001 (`backend/src/catalog/search.ts`)

- **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.
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ The requirement is that the solution works with any amount of data. The catalog
- **Filter and sort need indexes.** `schema.prisma` declares an index for each sort field combined with `id` (`codeSort`, `difficulty`, and `hasName` with `nameSort`), and for the filter columns (apparatus, group). Without them the database sorts the whole catalog for every request. The name sort puts named items first in both directions (`hasName` descending), so ascending and descending each have their own index. The apparatus filter is often combined with a sort, so there are also indexes on apparatus with `difficulty` and with `codeSort`. The difficulty sort always has an apparatus filter, so it uses the index on apparatus and `difficulty`. The difficulty filter without an apparatus uses the index on `difficulty`. Without them, a filtered page walks past the rows of the other apparatus first, which is slow in one direction (for example vault sorted by ascending difficulty).
- **Sort by name needs a rule for items without a name.** Most items have no name. A cursor does not accept `nulls` placement, so the sort uses a plain `hasName` field and a `nameSort` field that is never `null` (see Search without raw SQL).
- **The text search must not scan the table.** A search for `%word%` cannot use an ordinary index. The search uses a word table with a B-tree index and a prefix match, which finds the rows that have a word starting with the search term without reading every row. A search that is correct but reads every row works on 500 rows and not on 5 million.
- **`totalCount` is only calculated when the client asks for it.** Counting a large result takes time: about 30 ms for the whole catalog and about 100 ms for a search in a test with 225 000 entries, and the cost grows with the catalog. The resolver for the field runs only if `totalCount` is in the query, so a client that scrolls without showing a total does not pay for it. The client should therefore ask for `totalCount` only with the first page of a new search or filter, and not with the pages that are loaded while scrolling. Together with the debounce on the search field, this keeps the count from running on every keystroke. If the count is still too slow, it can be capped (for example "1000+"), which needs a change in the schema.
- **`totalCount` is only calculated when the client asks for it.** Counting a large result takes time: about 30 ms for the whole catalog and about 100 ms for a search in a test with 225 000 entries, and the cost grows with the catalog. The resolver for the field runs only if `totalCount` is in the query, so a client that scrolls without showing a total does not pay for it. The client should therefore ask for `totalCount` only with the first page of a new search or filter, and not with the pages that are loaded while scrolling. Together with the debounce on the search field, this keeps the count from running on every keystroke. **The count is capped:** it stops at 1001 (`MAX_TOTAL_COUNT` is 1000, and the database counts one more), so the cost does not grow with the catalog. A `totalCount` above 1000 means that there are more than 1000 matches, and the interface shows "1000+". The schema is the same (`Int!`), only the meaning of a value above 1000 is new.
- **Elements and vaults are different tables, but one list.** A query cannot combine two tables into one sorted and paged result. The data model keeps them apart because they work differently, so the list is read from `CatalogEntry`, a read-only table with the shared columns that the seed script fills. It never gets out of date because the catalog never changes between seed runs.
- **Variants are few.** A variant group has two or three members, so `variants` does not need paging.

Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Faustina:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap"
href="https://fonts.googleapis.com/css2?family=Faustina:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>t29-project-2</title>
Expand Down
24 changes: 20 additions & 4 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,30 @@ describe('App', () => {
expect(screen.getAllByRole('main')).toHaveLength(1);
});

it('opens the search page from the start address, the logo and the Search link', () => {
renderApp();
expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`);
expect(screen.getByRole('heading', { level: 1, name: 'Elements' })).toBeTruthy();

for (const link of [
() => screen.getByRole('link', { name: /wag cop planner home/i }),
() => screen.getByRole('link', { name: 'Search' }),
]) {
fireEvent.click(screen.getByRole('link', { name: 'About' }));
expect(screen.getByRole('heading', { name: 'About' })).toBeTruthy();

fireEvent.click(link());
expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`);
expect(screen.getByRole('heading', { level: 1, name: 'Elements' })).toBeTruthy();
}
});

it('guards the Gymnasts route and makes it available after login', () => {
window.history.replaceState({}, '', `${import.meta.env.BASE_URL}gymnasts`);
renderApp();

// Logged out, /gymnasts goes to the start page, which shows an element.
expect(window.location.pathname).toBe(
`${import.meta.env.BASE_URL}elements/UB-1.101-a`,
);
// Logged out, /gymnasts goes to the start page, which is the Elements page.
expect(window.location.pathname).toBe(`${import.meta.env.BASE_URL}elements`);
expect(screen.queryByRole('link', { name: 'Gymnasts' })).toBeNull();

fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
Expand Down
13 changes: 6 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SkipLink } from './components/skipLink/SkipLink';
import { ProtectedRoute } from './components/ProtectedRoute';

import ElementDetailPage from './components/elements/ElementDetailPage';
import ElementsPage from './components/elements/ElementsPage';
import YourGymnastsPage from './components/gymnasts/YourGymnastsPage';

import './index.css';
Expand All @@ -14,17 +15,17 @@ import './styles/global.css';
// Placeholder pages for routes without full implementations yet. Each page
// has its own <main>, like the real pages.
const CalculatorPage = () => (
<main>
<main className="px-5 py-8 text-left sm:px-8 lg:px-12">
<h1>Calculator</h1>
</main>
);
const AboutPage = () => (
<main>
<main className="px-5 py-8 text-left sm:px-8 lg:px-12">
<h1>About</h1>
</main>
);
const NotFoundPage = () => (
<main>
<main className="px-5 py-8 text-left sm:px-8 lg:px-12">
<h1>404 - Page not found</h1>
</main>
);
Expand Down Expand Up @@ -56,10 +57,8 @@ export function App() {
{/* The pages have their own <main>, so this is only the target of the skip link. */}
<div id="main-content" tabIndex={-1} className="outline-none">
<Routes>
<Route
path="/"
element={<Navigate to="/elements/UB-1.101-a" replace />}
/>
<Route path="/" element={<Navigate to="/elements" replace />} />
<Route path="/elements" element={<ElementsPage />} />
<Route path="/elements/:elementId" element={<ElementDetailPage />} />

<Route path="/calculator" element={<CalculatorPage />} />
Expand Down
16 changes: 8 additions & 8 deletions src/components/elements/ElementDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ export default function ElementDetailPage() {
if (problem) {
const { message, retryable } = problem
return (
<main className="element-detail-page min-h-svh bg-(--secondary-color) px-4 py-12 text-left">
<div className="mx-auto max-w-3xl space-y-3">
<main className="element-detail-page min-h-svh bg-(--secondary-color) px-5 py-12 text-left sm:px-8 lg:px-12">
<div className="space-y-3">
<h1 className="text-3xl font-bold">Element details unavailable</h1>
<p role="alert" className="text-(--text-color-dark)">
{message}
Expand All @@ -63,12 +63,12 @@ export default function ElementDetailPage() {
return (
<main
aria-busy="true"
className="element-detail-page min-h-svh bg-(--secondary-color) px-4 py-8 text-left"
className="element-detail-page min-h-svh bg-(--secondary-color) px-5 py-8 text-left sm:px-8 lg:px-12"
>
<p role="status" className="sr-only">
Loading element details
</p>
<div className="mx-auto max-w-3xl animate-pulse space-y-5 motion-reduce:animate-none">
<div className="animate-pulse space-y-5 motion-reduce:animate-none">
<div className="h-16 rounded-xl bg-(--background-color-element)" />
<div className="h-24 rounded-xl bg-(--background-color-element)" />
<div className="h-80 rounded-xl bg-(--background-color-element)" />
Expand All @@ -79,8 +79,8 @@ export default function ElementDetailPage() {

if (!item) {
return (
<main className="element-detail-page min-h-svh bg-(--secondary-color) px-4 py-12 text-left">
<div className="mx-auto max-w-3xl space-y-3">
<main className="element-detail-page min-h-svh bg-(--secondary-color) px-5 py-12 text-left sm:px-8 lg:px-12">
<div className="space-y-3">
<h1 className="text-3xl font-bold">Element not found</h1>
<p className="text-(--text-color-dark)">
No catalog item matches this address.
Expand All @@ -101,9 +101,9 @@ export default function ElementDetailPage() {
return (
<main
aria-busy={loading}
className="element-detail-page min-h-svh bg-(--secondary-color) px-4 py-6 text-left sm:py-10"
className="element-detail-page min-h-svh bg-(--secondary-color) px-5 py-6 text-left sm:px-8 sm:py-10 lg:px-12"
>
<div className="mx-auto flex max-w-3xl flex-col gap-5 sm:gap-6">
<div className="flex flex-col gap-5 sm:gap-6">
<ElementDetailHeader element={element} />
{element.removed && (
<p
Expand Down
2 changes: 1 addition & 1 deletion src/components/elements/ElementDiagram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default function ElementDiagram({
<img
src={element.imageUrl}
alt={`Illustration for ${displayCode(element, element.variants.length > 0)}${element.name ? `: ${element.name}` : ''}`}
className="block h-auto w-full object-contain"
className="block h-auto max-h-[75svh] w-full object-contain"
/>
</CardContent>
</Card>
Expand Down
4 changes: 2 additions & 2 deletions src/components/elements/ElementMetadataCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ export default function ElementMetadataCard({
<Card className="w-full bg-(--background-color-element)">
<CardContent className="p-0">
<dl className="grid grid-cols-1 gap-5 px-5 py-4 text-center sm:grid-cols-3 sm:gap-8">
<MetadataItem label={element.isVault ? 'D-value' : 'Difficulty'}>
<MetadataItem label="Difficulty">
{element.isVault || !difficulty ? (
<ElementValueBadge
label={element.isVault ? 'D-value' : 'Difficulty'}
label="Difficulty"
value={element.value}
/>
) : (
Expand Down
Loading