Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-dashboard/src/races.tsx
Snapshot at 34e3175
1import { Badge, ClipboardText, Empty, Table, Tooltip } from "@cloudflare/kumo";
2import brand from "../../../brand.json" with { type: "json" };
3import type { ProjectState, Race } from "./contracts.ts";
4import { ArchivedBadge } from "./archive.tsx";
5import { agentIdentities, Saddlecloth } from "./lane.tsx";
6import { duration } from "./receipts.tsx";
7import { photoFinish, raceEntrants, raceSplit, raceStart, timestamp } from "./race-model.ts";
8
9/** Only recorded starts/finishes determine order; empty open races follow started ones. */
10export function orderedRaces(races: Race[]): Race[] {
11 return races.toSorted((a, b) => {
12 if (a.status !== b.status) return a.status === "open" ? -1 : 1;
13 const at = a.status === "open" ? raceStart(a) : timestamp(a.finishedAt);
14 const bt = b.status === "open" ? raceStart(b) : timestamp(b.finishedAt);
15 return (bt ?? -Infinity) - (at ?? -Infinity) || a.packet.localeCompare(b.packet);
16 });
17}
18export const openRaceCount = (races: Race[]) =>
19 races.filter((race) => race.status === "open" && race.entrants.length > 0).length;
20
21const postTime = (start: number) => new Date(start).toLocaleTimeString(undefined, {
22 hour: "2-digit", minute: "2-digit", hour12: false,
23});
24
25/** FH-081 read-only list. Advice is never a promotion or a recorded winner. */
26export function Races({ state, owner, openRace }: {
27 state: ProjectState;
28 owner: boolean;
29 openRace(id: string): void;
30}) {
31 const races = orderedRaces(state.races ?? []);
32 const identities = agentIdentities(state.agents);
33 const runner = (name: string) => <span className="race-list-runner">
34 <Saddlecloth identity={identities.get(name)} />
35 <span className="race-list-name">{name}</span>
36 </span>;
37 const command = `${brand.slug} packet create packet.yaml --config "$CLI_CONFIG"`;
38 return <section id="races">
39 <h2 id="races-heading">Races</h2>
40 <p className="caption muted">Races are started by the owner (CLI or owner API).</p>
41 {!races.length ? <div data-first-run="races">
42 <Empty size="sm" title="No races yet"
43 description={`A race lets up to three agents work the same packet in their own forks. The first approved promote wins.${owner ? "" : " The owner starts races."}`}
44 contents={owner ? <div>
45 <p className="caption muted">Add <code>mode: race</code> to the packet file.</p>
46 <div className="first-run-command"><code>{command}</code>
47 <ClipboardText text="" textToCopy={command} size="sm" className="first-run-copy" />
48 </div>
49 </div> : undefined} />
50 </div> : <div className="table-scroll table-races">
51 <Table aria-labelledby="races-heading">
52 <Table.Header><Table.Row>
53 {["Race", "Status", "Entrants", "Winner", "Judge's pick", "Started"].map((heading, index) =>
54 <Table.Head key={heading} className={`race-list-column-${index}`}>{heading}</Table.Head>)}
55 </Table.Row></Table.Header>
56 <Table.Body>{races.map((race) => {
57 const packet = state.packets.find((entry) => entry.id === race.packet);
58 const title = packet?.title || race.packet;
59 const start = raceStart(race);
60 const finished = timestamp(race.finishedAt);
61 const pick = race.judge ? race.verdict?.ranking[0] : undefined;
62 const judge = !race.judge ? "—" : pick ? <>
63 {runner(pick)}<span className="caption muted">Judge {race.judge}</span>
64 </> : <span className="caption muted">{race.judge} · no advice yet</span>;
65 const started = start === undefined ? "—" : <>
66 <Tooltip content={new Date(start).toLocaleString()} render={<time dateTime={new Date(start).toISOString()} />}>
67 {postTime(start)}
68 </Tooltip>
69 {race.status === "finished" && finished !== undefined && finished >= start &&
70 <span className="caption muted">Finished {duration(finished - start)} later</span>}
71 </>;
72 return <Table.Row key={race.packet} data-race-packet={race.packet}>
73 <Table.Cell>
74 <a className="packet-link race-list-link" href={`#race/${encodeURIComponent(race.packet)}`}
75 aria-label={`Open race ${title}`} onClick={(event) => {
76 if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
77 event.preventDefault();
78 openRace(race.packet);
79 }}>{title}</a>
80 <code className="caption muted truncated-code">{race.packet}</code>
81 {packet?.archived && <ArchivedBadge at={packet.archived.at} />}
82 <div className="caption muted folded">
83 <span>Judge's pick: {pick ?? (race.judge ? `${race.judge} · no advice yet` : "—")}</span>
84 <span>Started {started}</span>
85 </div>
86 </Table.Cell>
87 <Table.Cell>{race.status === "open" && !race.entrants.length
88 ? <Badge variant="secondary">No entrants yet</Badge>
89 : <Badge appearance="dot" variant="neutral" className={`status-dot status-${race.status === "finished" ? "success" : "brand"}`}>
90 {race.status === "finished" ? "Finished" : "Racing"}
91 </Badge>}
92 {photoFinish(race) >= 2 && <span className="caption muted">photo finish</span>}
93 </Table.Cell>
94 <Table.Cell>{raceEntrants(race).slice(0, 3).map((entrant) =>
95 <div key={entrant.repoName} className="race-list-entrant">{runner(entrant.agent)}
96 {entrant.closedAt && <span className="caption muted">closed</span>}
97 </div>)}{!race.entrants.length && "—"}</Table.Cell>
98 <Table.Cell>{race.status !== "finished" ? "—" : race.winner ? <>
99 {runner(race.winner)}
100 {raceSplit(race.finishedAt, start) && <span className="caption muted">won in {raceSplit(race.finishedAt, start)}</span>}
101 </> : "No winner recorded"}</Table.Cell>
102 <Table.Cell className="race-list-column-4">{judge}</Table.Cell>
103 <Table.Cell className="race-list-column-5 tabular">{started}</Table.Cell>
104 </Table.Row>;
105 })}</Table.Body>
106 </Table>
107 </div>}
108 </section>;
109}
110