From 5368bee3c3a5b83eedded2b11273b1d7b68f6d81 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 10 Aug 2026 14:41:33 +0000 Subject: [PATCH] feat: #14 in white, not just a name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "we should use team color and names not just names — that would help with detection (ie: #14 in white team not #14 on black team)." Correct, and the column was already there: `jersey_color` has been on the athlete row since the first migration, and nothing has ever written it or shown it. So the picker offered a name — the one attribute a detector cannot help you match against — while the two things a parent actually points with, the number and the shirt, went unrecorded. Both teams have a 14 and on a school court they are regularly on screen together. Identity is now collected where the user is already looking at the child, in the picker itself, rather than in a separate "Add an athlete" form they would have to find first: name, number, shirt colour, team, all optional. Quick-identify no longer produces "My athlete" when the user told us who it was. Everywhere an athlete is named now reads "Fred #14 in white (Triton)", colour before team because colour is the part visible in the footage. The appearance matcher already separates the teams — a shirt signature is how it refuses the black team's 14 — so this makes the thing it keys on visible to the person judging its suggestions, rather than adding a second mechanism. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/actions.ts | 45 +++++++++++++-- apps/web/src/client/identify.tsx | 75 +++++++++++++++++++++++-- packages/core/src/athletes.ts | 11 ++++ packages/core/src/whichfourteen.test.ts | 56 ++++++++++++++++++ 4 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/whichfourteen.test.ts diff --git a/apps/web/src/actions.ts b/apps/web/src/actions.ts index 31fb56b..b556e17 100644 --- a/apps/web/src/actions.ts +++ b/apps/web/src/actions.ts @@ -586,6 +586,10 @@ export const registerActions = (app: Hono): void => { id: athlete.id, name: athlete.name, jerseyNumber: athlete.jerseyNumber, + // The shirt is what distinguishes #14 in white from #14 in black, + // so the picker needs both, not just a name. + team: athlete.team, + jerseyColor: athlete.jerseyColor, isFocal: athlete.isFocal, focalTrackId: athlete.focalTrackId, })), @@ -686,9 +690,41 @@ export const registerActions = (app: Hono): void => { try { const root = await rootOf(c); const body = c.req.header('content-type')?.includes('application/json') === true - ? ((await c.req.json().catch(() => ({}))) as { trackId?: string; trackIds?: string[] }) + ? ((await c.req.json().catch(() => ({}))) as { + trackId?: string; + trackIds?: string[]; + name?: string; + jerseyNumber?: string; + team?: string; + jerseyColor?: string; + }) : { trackId: field(await c.req.parseBody(), 'trackId') }; + /** + * Who this is, if the picker asked. + * + * A number on its own does not identify a child: both teams have a 14 and + * they are regularly on court together. The shirt colour is the part a + * parent actually uses — "#14 in white" — and `jersey_color` has been on + * the athlete row since the first migration without anything ever writing + * it. Optional, because pointing at the right player remains the only + * thing scoring genuinely needs. + */ + const identity = { + ...(typeof body.name === 'string' && body.name.trim().length > 0 + ? { name: body.name.trim() } + : {}), + ...(typeof body.jerseyNumber === 'string' && body.jerseyNumber.trim().length > 0 + ? { jerseyNumber: body.jerseyNumber.trim() } + : {}), + ...(typeof body.team === 'string' && body.team.trim().length > 0 + ? { team: body.team.trim() } + : {}), + ...(typeof body.jerseyColor === 'string' && body.jerseyColor.trim().length > 0 + ? { jerseyColor: body.jerseyColor.trim() } + : {}), + }; + /** * Several tracks, because the tracker splits one child into several. * A comma-separated list keeps the no-JS form working unchanged. @@ -717,12 +753,13 @@ export const registerActions = (app: Hono): void => { const requested = c.req.param('id') ?? ''; const athlete = requested === 'new' - ? await addAthlete(root, { name: 'My athlete' }) + ? await addAthlete(root, { name: 'My athlete', ...identity }) : await getAthlete(root, requested); const athleteId = athlete.id; // Following and being bound to a track are different things; a picked - // athlete is obviously the one to follow. - await updateAthlete(root, athleteId, { focalTrackId: trackId, focal: true }); + // athlete is obviously the one to follow. Any identity the picker + // collected rides along, so "#14 in white" replaces "My athlete". + await updateAthlete(root, athleteId, { focalTrackId: trackId, focal: true, ...identity }); // Every fragment the user picked, not only the first. const assigned = await assignTracksToAthlete(root, athleteId, requestedIds); diff --git a/apps/web/src/client/identify.tsx b/apps/web/src/client/identify.tsx index f742b19..f4b6487 100644 --- a/apps/web/src/client/identify.tsx +++ b/apps/web/src/client/identify.tsx @@ -39,10 +39,27 @@ interface Athlete { id: string; name: string | null; jerseyNumber: string | null; + team: string | null; + jerseyColor: string | null; isFocal: boolean; focalTrackId: string | null; } +/** + * "#14 in white" — how a parent actually points at their own child. + * + * A number alone is ambiguous: both teams have a 14 and they are frequently on + * court at the same time. The colour is the part you can see in the footage. + */ +const describe = (athlete: Athlete): string => { + const parts: string[] = []; + if (athlete.name !== null && athlete.name.length > 0) parts.push(athlete.name); + if (athlete.jerseyNumber !== null) parts.push(`#${athlete.jerseyNumber}`); + if (athlete.jerseyColor !== null) parts.push(`in ${athlete.jerseyColor}`); + if (athlete.team !== null) parts.push(`(${athlete.team})`); + return parts.length > 0 ? parts.join(' ') : '(unnamed)'; +}; + const clock = (seconds: number): string => { const total = Math.max(0, Math.round(seconds)); return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`; @@ -92,6 +109,11 @@ const Identify = ({ base }: { base: string }) => { const [scores, setScores] = useState>({}); const [finding, setFinding] = useState(false); const [found, setFound] = useState(null); + /** Who this is, collected alongside the picking rather than in a second form. */ + const [name, setName] = useState(''); + const [jerseyNumber, setJerseyNumber] = useState(''); + const [jerseyColor, setJerseyColor] = useState(''); + const [team, setTeam] = useState(''); const load = async (): Promise => { try { @@ -109,7 +131,14 @@ const Identify = ({ base }: { base: string }) => { setCandidates(body.candidates ?? []); setAthletes(body.athletes ?? []); const focal = (body.athletes ?? []).find((a) => a.isFocal) ?? (body.athletes ?? [])[0]; - if (focal !== undefined) setAthleteId(focal.id); + if (focal !== undefined) { + setAthleteId(focal.id); + // Seed the identity fields so they read as an edit, not a blank form. + setName(focal.name ?? ''); + setJerseyNumber(focal.jerseyNumber ?? ''); + setJerseyColor(focal.jerseyColor ?? ''); + setTeam(focal.team ?? ''); + } // Reopen with the existing choice selected, so adding a fragment is an // edit rather than starting over. const already = body.assignedTrackIds ?? []; @@ -194,7 +223,7 @@ const Identify = ({ base }: { base: string }) => { const response = await fetch(`${base}/athletes/${target}/track`, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, - body: JSON.stringify({ trackId: picked[0], trackIds: picked }), + body: JSON.stringify({ trackId: picked[0], trackIds: picked, name, jerseyNumber, jerseyColor, team }), }); const body = (await response.json()) as { ok: boolean; error?: string }; if (!response.ok || !body.ok) throw new Error(body.error ?? 'Could not save that choice.'); @@ -243,14 +272,52 @@ const Identify = ({ base }: { base: string }) => { > {athletes.map((athlete) => ( ))} )} + {/* Collected here rather than in a separate "Add an athlete" form, because + this is the moment the user is looking at the child and knows the + answer. All optional: pointing at the right player is the only thing + scoring actually needs. */} +
+ setName((event.target as HTMLInputElement).value)} + style="font:inherit;padding:.35rem;border-radius:.4rem;max-width:11rem" + /> + setJerseyNumber((event.target as HTMLInputElement).value)} + style="font:inherit;padding:.35rem;border-radius:.4rem;max-width:5rem" + /> + setJerseyColor((event.target as HTMLInputElement).value)} + style="font:inherit;padding:.35rem;border-radius:.4rem;max-width:12rem" + /> + setTeam((event.target as HTMLInputElement).value)} + style="font:inherit;padding:.35rem;border-radius:.4rem;max-width:11rem" + /> +
+

+ Both teams have a #14. The shirt colour is what tells them apart — and what + the matcher uses when it looks for the same child elsewhere in the game. +

+ {error === null ? null :

{error}

} {candidates.length === 0 ? ( diff --git a/packages/core/src/athletes.ts b/packages/core/src/athletes.ts index 6d1b0d1..87ef2b1 100644 --- a/packages/core/src/athletes.ts +++ b/packages/core/src/athletes.ts @@ -177,10 +177,21 @@ export const removeAthlete = async (root: string, reference: string): Promise { const parts: string[] = []; if (athlete.name !== null) parts.push(athlete.name); if (athlete.jerseyNumber !== null) parts.push(`#${athlete.jerseyNumber}`); + // Colour before team: it is what you can actually see in the footage. + if (athlete.jerseyColor !== null) parts.push(`in ${athlete.jerseyColor}`); if (athlete.team !== null) parts.push(`(${athlete.team})`); return parts.length > 0 ? parts.join(' ') : athlete.id; }; diff --git a/packages/core/src/whichfourteen.test.ts b/packages/core/src/whichfourteen.test.ts new file mode 100644 index 0000000..58d0eee --- /dev/null +++ b/packages/core/src/whichfourteen.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { describeAthlete } from './athletes.js'; +import type { Athlete } from './types.js'; + +/** + * "#14 on white, not #14 on black." + * + * A jersey number does not identify a child — both teams have a 14, and on a + * school court they are regularly on screen together. The shirt colour is the + * part a parent actually uses to point, and `jersey_color` has been on the + * athlete row since the first migration with nothing ever writing or showing + * it. So the picker could only offer names, which is the one attribute a + * detector cannot help you match against. + */ + +const athlete = (over: Partial = {}): Athlete => + ({ + id: 'ath_test', + projectId: 'prj_test', + name: null, + jerseyNumber: null, + team: null, + jerseyColor: null, + focalTrackId: null, + isFocal: false, + createdAt: '', + updatedAt: '', + ...over, + }) as Athlete; + +describe('naming an athlete the way a parent would', () => { + it('says which shirt, so two number 14s are distinguishable', () => { + const white = describeAthlete(athlete({ jerseyNumber: '14', jerseyColor: 'white' })); + const black = describeAthlete(athlete({ jerseyNumber: '14', jerseyColor: 'black' })); + expect(white).toBe('#14 in white'); + expect(black).toBe('#14 in black'); + expect(white).not.toBe(black); + }); + + it('reads naturally with everything filled in', () => { + expect( + describeAthlete( + athlete({ name: 'Fred', jerseyNumber: '14', jerseyColor: 'white', team: 'Triton' }), + ), + ).toBe('Fred #14 in white (Triton)'); + }); + + it('still works with only a name, which is all it used to have', () => { + expect(describeAthlete(athlete({ name: 'Fred' }))).toBe('Fred'); + }); + + it('falls back to the id rather than an empty label', () => { + expect(describeAthlete(athlete())).toBe('ath_test'); + }); +});