Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-dashboard/src/races.tsx

Snapshot at 34e3175

Containing directory

6,250 bytes · textView raw
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