Skip to content
File

Blob: src/client/lib/project-presentation.ts

typescript143 lines
1import type { ProjectSummary, RunStatus } from "@/contracts";
2 
3const DATE_FORMATTER = new Intl.DateTimeFormat("en-US", {
4 month: "short",
5 day: "numeric",
6 hour: "numeric",
7 minute: "2-digit",
8});
9 
10const RELATIVE_TIME_FORMATTER = new Intl.RelativeTimeFormat("en", {
11 numeric: "auto",
12});
13 
14const STATUS_META: Record<
15 RunStatus | "idle",
16 {
17 label: string;
18 tone: string;
19 dot: string;
20 }
21> = {
22 idle: {
23 label: "Idle",
24 tone: "border-zinc-700/70 bg-zinc-800/70 text-zinc-300",
25 dot: "bg-zinc-500",
26 },
27 queued: {
28 label: "Queued",
29 tone: "border-accent-500/20 bg-accent-500/10 text-accent-400",
30 dot: "bg-accent-500",
31 },
32 starting: {
33 label: "Starting",
34 tone: "border-amber-500/20 bg-amber-500/10 text-amber-300",
35 dot: "bg-amber-500 animate-pulse",
36 },
37 running: {
38 label: "Running",
39 tone: "border-amber-500/20 bg-amber-500/10 text-amber-300",
40 dot: "bg-amber-500 animate-pulse",
41 },
42 cancel_requested: {
43 label: "Cancel Requested",
44 tone: "border-amber-500/20 bg-amber-500/10 text-amber-300",
45 dot: "bg-amber-500 animate-pulse",
46 },
47 canceling: {
48 label: "Canceling",
49 tone: "border-amber-500/20 bg-amber-500/10 text-amber-300",
50 dot: "bg-amber-500 animate-pulse",
51 },
52 passed: {
53 label: "Passed",
54 tone: "border-emerald-500/20 bg-emerald-500/10 text-emerald-400",
55 dot: "bg-emerald-500",
56 },
57 failed: {
58 label: "Failed",
59 tone: "border-red-500/20 bg-red-500/10 text-red-400",
60 dot: "bg-red-500",
61 },
62 canceled: {
63 label: "Canceled",
64 tone: "border-zinc-700/70 bg-zinc-800/70 text-zinc-300",
65 dot: "bg-zinc-500",
66 },
67};
68 
69const toRelativeUnit = (milliseconds: number): { value: number; unit: Intl.RelativeTimeFormatUnit } => {
70 const seconds = Math.round(milliseconds / 1000);
71 if (Math.abs(seconds) < 60) {
72 return { value: seconds, unit: "second" };
73 }
74 
75 const minutes = Math.round(seconds / 60);
76 if (Math.abs(minutes) < 60) {
77 return { value: minutes, unit: "minute" };
78 }
79 
80 const hours = Math.round(minutes / 60);
81 if (Math.abs(hours) < 24) {
82 return { value: hours, unit: "hour" };
83 }
84 
85 const days = Math.round(hours / 24);
86 return { value: days, unit: "day" };
87};
88 
89export const getStatusMeta = (status: RunStatus | null) => STATUS_META[status ?? "idle"];
90 
91export const formatTimestamp = (isoDateTime: string): string => DATE_FORMATTER.format(new Date(isoDateTime));
92 
93export const formatRelativeTime = (isoDateTime: string): string => {
94 const { value, unit } = toRelativeUnit(Date.parse(isoDateTime) - Date.now());
95 return RELATIVE_TIME_FORMATTER.format(value, unit);
96};
97 
98export const formatProjectUpdatedLabel = (project: ProjectSummary): string =>
99 `${formatRelativeTime(project.updatedAt)} • ${formatTimestamp(project.updatedAt)}`;
100 
101export const inferRepositoryProvider = (repoUrl: string): string => {
102 try {
103 return new URL(repoUrl).hostname.replace(/^www\./u, "");
104 } catch {
105 return "custom";
106 }
107};
108 
109export const TRIGGER_TYPE_LABELS: Record<string, string> = {
110 manual: "Manual",
111 webhook: "Webhook",
112};
113 
114export const formatDuration = (startedAt: string, finishedAt: string): string => {
115 const ms = Date.parse(finishedAt) - Date.parse(startedAt);
116 const seconds = Math.round(ms / 1000);
117 if (seconds < 60) return `${seconds}s`;
118 const minutes = Math.floor(seconds / 60);
119 const remainingSeconds = seconds % 60;
120 return `${minutes}m ${remainingSeconds}s`;
121};
122 
123export const formatRunFailureMessage = (message: string): string => {
124 switch (message) {
125 case "runner_lost":
126 return "The runner stopped unexpectedly before the run could finish.";
127 case "dispatch_failed":
128 return "anvil could not dispatch this run.";
129 case "cancel_cleanup_failed":
130 return "The run stopped, but cleanup did not finish cleanly.";
131 default:
132 return message;
133 }
134};
135 
136export const buildProjectSlug = (value: string): string =>
137 value
138 .trim()
139 .toLowerCase()
140 .replace(/[^a-z0-9_-]+/g, "-")
141 .replace(/^-+|-+$/g, "")
142 .slice(0, 64);