Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-dashboard/src/race-model.ts
Snapshot at 34e3175
1import type { ProjectState, Race, RaceEntrant, Review } from "./contracts.ts";
2import { duration } from "./receipts.tsx";
3
4/** This order also determines core placings; a missing milestone is never inferred. */
5export const raceMilestones = [
6 { key: "claimedAt", label: "Claimed" },
7 { key: "firstPushAt", label: "First push" },
8 { key: "reviewRequestedAt", label: "Review requested" },
9 { key: "receiptPassedAt", label: "Checks passed" },
10 { key: "approvedAt", label: "Approved" },
11 { key: "promotedAt", label: "Promoted" },
12] as const;
13export const timestamp = (value?: string | null): number | undefined => {
14 const time = value ? Date.parse(value) : NaN;
15 return Number.isFinite(time) ? time : undefined;
16};
17export function raceEntrants(race: Race): RaceEntrant[] {
18 return race.entrants.toSorted(
19 (a, b) =>
20 (timestamp(a.claimedAt) ?? Infinity) - (timestamp(b.claimedAt) ?? Infinity) ||
21 a.agent.localeCompare(b.agent),
22 );
23}
24export function raceStart(race: Race): number | undefined {
25 return timestamp(raceEntrants(race)[0]?.claimedAt);
26}
27export function ordinal(value: number): string {
28 const suffix =
29 value % 100 >= 11 && value % 100 <= 13
30 ? "th"
31 : (({ 1: "st", 2: "nd", 3: "rd" } as Record<number, string>)[value % 10] ?? "th");
32 return `${value}${suffix}`;
33}
34/** Show recorded precision on one shared clock; never count up with wall time. */
35export function raceSplit(at: string | null | undefined, start: number | undefined): string {
36 const time = timestamp(at);
37 if (time === undefined || start === undefined || time < start) return "";
38 const elapsed = time - start;
39 const seconds = Math.floor(elapsed / 1000);
40 const minutes = Math.floor(seconds / 60);
41 const clock =
42 seconds >= 3600
43 ? `${Math.floor(seconds / 3600)}:${String(minutes % 60).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`
44 : `${String(minutes).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
45 return /\.\d+/.test(at!) ? `${clock}.${Math.floor((elapsed % 1000) / 100)}` : clock;
46}
47export function raceTimeText(at: string | null | undefined, start: number | undefined): string {
48 const time = timestamp(at);
49 if (time === undefined || start === undefined || time < start) return "";
50 const seconds = Math.floor((time - start) / 1000);
51 const hours = Math.floor(seconds / 3600);
52 const minutes = Math.floor((seconds % 3600) / 60);
53 const words = [
54 hours && `${hours} ${hours === 1 ? "hour" : "hours"}`,
55 minutes && `${minutes} ${minutes === 1 ? "minute" : "minutes"}`,
56 (seconds % 60 || !seconds) && `${seconds % 60} ${seconds % 60 === 1 ? "second" : "seconds"}`,
57 ].filter(Boolean);
58 return `plus ${words.join(" ")} on the race clock`;
59}
60export function entrantReviews(state: ProjectState, race: Race, entrant: RaceEntrant): Review[] {
61 return state.reviews
62 .filter((review) => review.packet === race.packet && review.repoName === entrant.repoName)
63 .toSorted((a, b) => (timestamp(a.requestedAt) ?? 0) - (timestamp(b.requestedAt) ?? 0));
64}
65/** Both race tables count passing receipts, rather than individual checks in one receipt. */
66export function entrantChecks(state: ProjectState, race: Race, entrant: RaceEntrant): string {
67 const receipts = entrantReviews(state, race, entrant).flatMap((review) =>
68 review.receipt ? [review.receipt] : [],
69 );
70 return receipts.length
71 ? `${receipts.filter((receipt) => receipt.status === "passed").length} of ${receipts.length}`
72 : "—";
73}
74export function entrantResult(entrant: RaceEntrant, owner: boolean): string {
75 if (entrant.promotedAt) return "Won";
76 if (!entrant.closedAt) return "Racing";
77 if (entrant.closedBy === "race-finished") return "Closed at the finish";
78 if (entrant.closedBy === "release") return "Left the race";
79 if (entrant.closedBy === "owner") return owner ? "Closed by you" : "Closed by the owner";
80 return "Closed";
81}
82export function photoFinish(race: Race): number {
83 const finished = timestamp(race.finishedAt);
84 return race.status === "finished" && finished !== undefined
85 ? race.entrants.filter((entrant) => {
86 const at = timestamp(entrant.approvedAt);
87 return at !== undefined && at <= finished;
88 }).length
89 : 0;
90}
91export function rejectionDelta(at: string, finishedAt?: string | null): string {
92 const time = timestamp(at),
93 finished = timestamp(finishedAt);
94 if (time === undefined || finished === undefined || time < finished) return "";
95 const delta = time - finished;
96 return delta < 10_000 ? `${(delta / 1000).toFixed(1)} s after` : `${duration(delta)} after`;
97}
98