1import type { MergebotPullRequestLiveStatus } from "@t3tools/contracts";
2import type {
3 MergebotStatusPill as Pill,
4 MergebotStatusPillTone,
5} from "@t3tools/client-runtime/mergebot-status-pill";
6import {
7 CircleCheckIcon,
8 CircleDashedIcon,
9 CircleXIcon,
10 FlaskConicalIcon,
11 GlobeIcon,
12 HourglassIcon,
13 RocketIcon,
14 TriangleAlertIcon,
15 type LucideIcon,
16} from "lucide-react";
17
18import { useNowMinute } from "~/hooks/useNowMinute";
19import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
20import { cn } from "~/lib/utils";
21
22const TONE: Record<MergebotStatusPillTone, string> = {
23 yellow: "bg-yellow-500/15 text-yellow-700 dark:text-yellow-400",
24 red: "bg-error/12 text-error-foreground",
25 orange: "bg-orange-500/12 text-orange-700 dark:text-orange-400",
26 blue: "bg-info/12 text-info-foreground",
27 green: "bg-success/12 text-success-foreground",
28 muted: "bg-muted text-muted-foreground",
29};
30
31const ICON: Record<Exclude<Pill["kind"], "running-ci">, LucideIcon> = {
32 rebasing: PullRequestGlyph.pullRequest,
33 "ci-failed": CircleXIcon,
34 "merge-conflicts": TriangleAlertIcon,
35 "draft-passes-ci": CircleDashedIcon,
36 ready: CircleCheckIcon,
37 queued: HourglassIcon,
38 merging: PullRequestGlyph.merged,
39 merged: PullRequestGlyph.merged,
40 deploying: RocketIcon,
41 "deploy-failed": RocketIcon,
42 "on-staging": FlaskConicalIcon,
43 shipped: GlobeIcon,
44};
45
46/** A still arc of finished checks; nothing on the pill repaints on its own. */
47function ChecksProgress({ completed, total }: { completed: number; total: number }) {
48 const radius = 8;
49 const circumference = 2 * Math.PI * radius;
50 const filled = total > 0 ? (completed / total) * circumference : 0;
51 return (
52 <svg viewBox="0 0 24 24" className="size-3 shrink-0" aria-hidden>
53 <circle
54 cx="12"
55 cy="12"
56 r={radius}
57 fill="none"
58 stroke="currentColor"
59 strokeOpacity={0.3}
60 strokeWidth={3}
61 />
62 {filled > 0 ? (
63 <circle
64 cx="12"
65 cy="12"
66 r={radius}
67 fill="none"
68 stroke="currentColor"
69 strokeWidth={3}
70 strokeLinecap="round"
71 strokeDasharray={`${filled} ${circumference}`}
72 transform="rotate(-90 12 12)"
73 />
74 ) : null}
75 </svg>
76 );
77}
78
79function minutesUntil(etaAt: string, now: string) {
80 const minutes = Math.round((Date.parse(etaAt) - Date.parse(now)) / 60_000);
81 return minutes < 1 ? "<1m" : `~${minutes}m`;
82}
83
84export function MergebotStatusPill({
85 pill,
86 status,
87 className,
88}: {
89 pill: Pill;
90 status: MergebotPullRequestLiveStatus;
91 className?: string;
92}) {
93 const now = useNowMinute();
94 const Icon = pill.kind === "running-ci" ? null : ICON[pill.kind];
95 return (
96 <span
97 className={cn(
98 "inline-flex min-w-0 shrink-0 items-center gap-1 rounded-full py-px ps-1.5 pe-2 text-xs leading-4 font-medium whitespace-nowrap",
99 TONE[pill.tone],
100 className,
101 )}
102 >
103 {Icon ? (
104 <Icon
105 className={cn("size-3 shrink-0", pill.kind === "shipped" && "text-success-foreground")}
106 />
107 ) : (
108 <ChecksProgress
109 completed={status.checks?.completed ?? 0}
110 total={status.checks?.total ?? 0}
111 />
112 )}
113 <span className="truncate">{pill.label}</span>
114 {pill.etaAt ? (
115 <span className="font-normal tabular-nums opacity-60">{minutesUntil(pill.etaAt, now)}</span>
116 ) : null}
117 </span>
118 );
119}