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

packages/forkhorse-dashboard/src/race-model.ts

Snapshot at 34e3175

Containing directory

4,591 bytes · typescriptView raw
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