diff --git a/apps/web/src/actions.ts b/apps/web/src/actions.ts index 4fa7d6f..5bd10b6 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. @@ -727,12 +763,15 @@ export const registerActions = (app: Hono): void => { const reusable = existing.find((candidate) => candidate.isFocal) ?? existing[0]; const athlete = requested === 'new' - ? (reusable ?? (await addAthlete(root, { name: 'My athlete' }))) + ? (reusable ?? (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" — on a + // reused athlete too, which is how a name reaches one created before the + // fields existed. + await updateAthlete(root, athleteId, { focalTrackId: trackId, focal: true, ...identity }); /** * Add to the athlete, or set them, depending on what the caller knew. diff --git a/apps/web/src/client/identify.tsx b/apps/web/src/client/identify.tsx index bd25f83..b409a93 100644 --- a/apps/web/src/client/identify.tsx +++ b/apps/web/src/client/identify.tsx @@ -41,10 +41,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')}`; @@ -94,6 +111,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(''); /** What just happened, said here rather than via a redirect and a flash. */ const [saved, setSaved] = useState(null); @@ -113,7 +135,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 ?? []; @@ -199,7 +228,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; athleteId?: string; error?: string }; if (!response.ok || !body.ok) throw new Error(body.error ?? 'Could not save that choice.'); @@ -260,14 +289,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}

} {saved === null ? null :

{saved}

} 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'); + }); +});