Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
47 changes: 43 additions & 4 deletions apps/web/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
75 changes: 71 additions & 4 deletions apps/web/src/client/identify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')}`;
Expand Down Expand Up @@ -94,6 +111,11 @@ const Identify = ({ base }: { base: string }) => {
const [scores, setScores] = useState<Record<string, Match>>({});
const [finding, setFinding] = useState(false);
const [found, setFound] = useState<string | null>(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<string | null>(null);

Expand All @@ -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 ?? [];
Expand Down Expand Up @@ -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.');
Expand Down Expand Up @@ -260,14 +289,52 @@ const Identify = ({ base }: { base: string }) => {
>
{athletes.map((athlete) => (
<option key={athlete.id} value={athlete.id} selected={athlete.id === athleteId}>
{athlete.name ?? '(unnamed)'}
{athlete.jerseyNumber === null ? '' : ` #${athlete.jerseyNumber}`}
{describe(athlete)}
</option>
))}
</select>
</div>
)}

{/* 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. */}
<div class="row identity-fields" style="margin-bottom:.75rem;gap:.5rem;flex-wrap:wrap">
<input
type="text"
placeholder="Name (optional)"
value={name}
onInput={(event: Event) => setName((event.target as HTMLInputElement).value)}
style="font:inherit;padding:.35rem;border-radius:.4rem;max-width:11rem"
/>
<input
type="text"
placeholder="#14"
value={jerseyNumber}
onInput={(event: Event) => setJerseyNumber((event.target as HTMLInputElement).value)}
style="font:inherit;padding:.35rem;border-radius:.4rem;max-width:5rem"
/>
<input
type="text"
placeholder="Shirt colour, e.g. white"
value={jerseyColor}
onInput={(event: Event) => setJerseyColor((event.target as HTMLInputElement).value)}
style="font:inherit;padding:.35rem;border-radius:.4rem;max-width:12rem"
/>
<input
type="text"
placeholder="Team (optional)"
value={team}
onInput={(event: Event) => setTeam((event.target as HTMLInputElement).value)}
style="font:inherit;padding:.35rem;border-radius:.4rem;max-width:11rem"
/>
</div>
<p class="muted" style="margin-top:-.35rem">
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.
</p>

{error === null ? null : <p class="pill reject upload-error">{error}</p>}
{saved === null ? null : <p class="notice">{saved}</p>}

Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/athletes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,21 @@ export const removeAthlete = async (root: string, reference: string): Promise<At
return athlete;
};

/**
* How a person refers to their own child at a game: "#14 in white".
*
* The number alone is ambiguous — both teams have a 14, and on a school court
* they are frequently on screen together. `jersey_color` has existed on the
* athlete row since the first migration and nothing has ever written or shown
* it, so the picker could only offer names, which is the one thing a detector
* cannot help you match against.
*/
export const describeAthlete = (athlete: Athlete): string => {
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;
};
56 changes: 56 additions & 0 deletions packages/core/src/whichfourteen.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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');
});
});
Loading