Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

833 linesCodeBlameRaw
1/**
2 * Home's parts (routes/workspace/home.tsx): Where you're needed, Start
3 * here, what happened in the span (what people did, what landed, builds,
4 * spend) and Running now. Each takes what
5 * lib/home.ts worked out and only draws it.
6 */
7import {
8 AlertTriangle,
9 ArrowRight,
10 AtSign,
11 Bell,
12 CheckCheck,
13 GitCommitHorizontal,
14 GitMerge,
15 GitPullRequest,
16 Loader,
17 Mail,
18 Package,
19 Rocket,
20 Server,
21 Tag,
22 User,
23 Users,
24 Workflow,
25} from "lucide-react";
26import type { ReactNode } from "react";
27import { Link } from "react-router";
28
29import {
30 type Attention,
31 type AttentionKind,
32 type AttentionRow,
33 type Away,
34 type DeployRow,
35 type Digest,
36 type DigestActor,
37 type DigestColumn,
38 type LandedChange,
39 type RunningRow,
40 type ShipInfo,
41 type Span,
42 type Spend,
43 type Stake,
44 WINDOWS,
45 agentLabel,
46 landedByDay,
47 listOf,
48 shippedLabel,
49 timeOfDay,
50 waited,
51 whenShort,
52} from "../lib/home";
53import { cn } from "../lib/cn";
54import { money } from "../lib/money";
55import { CommitText } from "./commit-message";
56import { ButtonLink } from "./ui";
57import { AgentMarker, Avatar } from "./ui/avatar";
58import { Badge, type BadgeTone } from "./ui/badge";
59import { Skeleton } from "./ui/skeleton";
60import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
61
62export function Card({
63 title,
64 hint,
65 link,
66 children,
67 className = "",
68}: {
69 title: ReactNode;
70 /** A few words under the title. */
71 hint?: string;
72 link?: { label: string; to: string } | null;
73 children: ReactNode;
74 className?: string;
75}) {
76 return (
77 <section className={`rounded-2xl border border-line bg-surface ${className}`}>
78 <header className="flex items-start gap-2 px-5 pt-4 pb-3">
79 <div className="min-w-0">
80 <h2 className="text-sm font-semibold text-fg">{title}</h2>
81 {hint && <p className="mt-0.5 text-xs text-muted">{hint}</p>}
82 </div>
83 {link && (
84 <Link to={link.to} className="ml-auto inline-flex shrink-0 items-center gap-1 pt-0.5 text-xs text-muted hover:text-fg">
85 {link.label}
86 <ArrowRight size={12} />
87 </Link>
88 )}
89 </header>
90 {children}
91 </section>
92 );
93}
94
95export function Quiet({ children }: { children: ReactNode }) {
96 return <p className="px-5 pb-5 text-sm text-muted">{children}</p>;
97}
98
99function Missing({ names, what }: { names: string[]; what: string }) {
100 if (names.length === 0) return null;
101 return (
102 <p className="flex items-center gap-1.5 border-t border-line px-5 py-2.5 text-xs text-faint">
103 <AlertTriangle size={12} className="shrink-0" />
104 {listOf(names)} didn&apos;t answer, so {what} isn&apos;t listed.
105 </p>
106 );
107}
108
109// --- The window switch ----------------------------------------------------------
110
111/** Since last visit, last 24 hours, last 7 days: links, so the span is in the address and works without script. */
112export function WindowSwitch({ span }: { span: Pick<Span, "key"> }) {
113 return (
114 <nav aria-label="What happened over" className="flex shrink-0 self-start rounded-lg border border-line bg-surface p-0.5">
115 {WINDOWS.map((option) => {
116 const current = option.key === span.key;
117 return (
118 <Link
119 key={option.key}
120 to={option.key === "last" ? "?" : `?window=${option.key}`}
121 preventScrollReset
122 aria-current={current ? "true" : undefined}
123 className={cn(
124 "rounded-md px-2.5 py-1 text-xs font-medium whitespace-nowrap transition-colors",
125 current ? "bg-raised text-fg shadow-[0_0_0_1px_var(--color-line-strong)]" : "text-muted hover:text-fg",
126 )}
127 >
128 {option.label}
129 </Link>
130 );
131 })}
132 </nav>
133 );
134}
135
136// --- Where you're needed ---------------------------------------------------------
137
138const ICON: Record<AttentionKind, ReactNode> = {
139 deploy: <Rocket size={16} className="text-danger" />,
140 pull_blocked: <GitPullRequest size={16} className="text-warn" />,
141 review: <GitPullRequest size={16} className="text-info" />,
142 invitation: <Mail size={16} className="text-info" />,
143 ready: <GitMerge size={16} className="text-success" />,
144 runner: <Server size={16} className="text-warn" />,
145 notification: <Bell size={16} className="text-warn" />,
146 chat: <AtSign size={16} className="text-accent" />,
147};
148
149const STAKE_TONE: Record<NonNullable<Stake["tone"]> | "none", string> = {
150 danger: "text-danger",
151 warn: "text-warn",
152 accent: "text-accent",
153 none: "text-fg-soft",
154};
155
156function StakeText({ stake }: { stake: Stake }) {
157 return <span className={`text-xs font-medium tabular-nums ${STAKE_TONE[stake.tone ?? "none"]}`}>{stake.text}</span>;
158}
159
160/** Rows shown before the rest fold away. */
161const ROWS = 8;
162
163function NeedItem({ row, now }: { row: AttentionRow; now: number }) {
164 return (
165 <li className="flex items-start gap-3 border-t border-line px-5 py-3 first:border-t-0">
166 <span className="mt-0.5 shrink-0">{ICON[row.kind]}</span>
167 <div className="min-w-0 grow">
168 <Link to={row.action.to} className="line-clamp-2 text-sm font-semibold text-fg hover:underline sm:block sm:truncate">
169 {row.title}
170 </Link>
171 <p className="truncate text-xs text-muted">{row.detail}</p>
172 <div className="mt-1 flex flex-wrap items-center gap-x-2 text-xs sm:hidden">
173 {row.stake && <StakeText stake={row.stake} />}
174 {row.at > 0 && (
175 <span className="text-faint" suppressHydrationWarning>
176 waiting {waited(row.at, now)}
177 </span>
178 )}
179 </div>
180 </div>
181 <div className="hidden max-w-48 shrink-0 pt-0.5 text-right whitespace-nowrap sm:block">
182 {row.stake && <StakeText stake={row.stake} />}
183 {row.at > 0 && (
184 <div className="text-[0.6875rem] text-faint tabular-nums" suppressHydrationWarning>
185 waiting {waited(row.at, now)}
186 </div>
187 )}
188 </div>
189 <ButtonLink to={row.action.to} variant="outline" size="xs" className="border-line-strong px-2.5 text-fg hover:border-accent/50 hover:bg-raised">
190 {row.action.label}
191 </ButtonLink>
192 </li>
193 );
194}
195
196/** Everything waiting on you, most pressing and longest waiting first; past the first few, the rest fold away. */
197export function NeedsList({ attention, now }: { attention: Attention; now: number }) {
198 const { rows, missing } = attention;
199 return (
200 <>
201 {rows.length === 0 ? (
202 <Quiet>{missing.length > 0 ? "Nothing needs you in what could be read." : "Nothing needs you. Reviews, invitations and messages for you show here as soon as they arrive."}</Quiet>
203 ) : (
204 <ul>
205 {rows.slice(0, ROWS).map((row) => (
206 <NeedItem key={row.key} row={row} now={now} />
207 ))}
208 </ul>
209 )}
210 {rows.length > ROWS && (
211 <details className="group border-t border-line">
212 <summary className="cursor-pointer list-none px-5 py-2.5 text-xs font-medium text-fg-soft hover:text-fg">
213 <span className="group-open:hidden">Show {(rows.length - ROWS).toLocaleString("en-US")} more</span>
214 <span className="hidden group-open:inline">Show fewer</span>
215 </summary>
216 <ul className="border-t border-line">
217 {rows.slice(ROWS).map((row) => (
218 <NeedItem key={row.key} row={row} now={now} />
219 ))}
220 </ul>
221 </details>
222 )}
223 <Missing names={missing} what="what waits there" />
224 </>
225 );
226}
227
228export function RowsSkeleton({ rows = 3 }: { rows?: number }) {
229 return (
230 <div className="space-y-4 px-5 pb-5" aria-busy="true">
231 {Array.from({ length: rows }, (_, i) => (
232 <div key={i} className="flex items-center gap-3">
233 <Skeleton className="size-4 rounded" />
234 <div className="grow space-y-1.5">
235 <Skeleton className="h-3.5" style={{ width: `${60 - i * 8}%` }} />
236 <Skeleton className="h-3" style={{ width: `${80 - i * 10}%` }} />
237 </div>
238 <Skeleton className="h-7 w-16" />
239 </div>
240 ))}
241 </div>
242 );
243}
244
245// --- Start here -----------------------------------------------------------------
246
247export function StartHere({ attention, now }: { attention: Attention; now: number }) {
248 const start = attention.start;
249 return (
250 <Card
251 title={
252 <>
253 Start here <span className="font-normal text-faint">· picked by g1t</span>
254 </>
255 }
256 >
257 {!start ? (
258 <Quiet>{attention.missing.length > 0 ? "Nothing to pick from what could be read." : "Nothing needs you, so there's nothing to pick."}</Quiet>
259 ) : (
260 <div className="px-5 pb-5">
261 <div className="flex items-start gap-3">
262 <span className="mt-0.5 shrink-0">{ICON[start.row.kind]}</span>
263 <div className="min-w-0">
264 <p className="text-[0.9375rem] leading-snug font-semibold text-fg">{start.row.title}</p>
265 <p className="mt-1 line-clamp-2 text-xs text-muted">{start.row.detail}</p>
266 </div>
267 </div>
268 <dl className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
269 {start.row.owner && (
270 <>
271 <dt className="text-muted">Owner</dt>
272 <dd className="flex min-w-0 items-center gap-1.5 text-fg-soft">
273 <User size={12} className="shrink-0 text-muted" />
274 <span className="truncate">{start.row.owner.agent ? `@${start.row.owner.name}` : start.row.owner.name}</span>
275 </dd>
276 </>
277 )}
278 <dt className="text-muted">Next</dt>
279 <dd className="text-fg-soft">{start.row.action.label}</dd>
280 {start.row.at > 0 && (
281 <>
282 <dt className="text-muted">Waiting</dt>
283 <dd className="text-fg-soft" suppressHydrationWarning>
284 {waited(start.row.at, now)}
285 </dd>
286 </>
287 )}
288 {start.row.stake && (
289 <>
290 <dt className="text-muted">At stake</dt>
291 <dd>
292 <StakeText stake={start.row.stake} />
293 </dd>
294 </>
295 )}
296 </dl>
297 <p className="mt-4 rounded-lg bg-raised/60 px-3 py-2.5 text-xs leading-relaxed text-fg-soft">
298 <span className="font-medium text-fg">Why this one: </span>
299 {start.why}
300 </p>
301 <Link
302 to={start.row.action.to}
303 className="mt-4 inline-flex h-8 items-center gap-1.5 rounded-lg bg-accent px-3 text-sm font-medium text-bg transition-colors hover:bg-accent-hover"
304 >
305 {start.row.action.label}
306 <ArrowRight size={14} />
307 </Link>
308 </div>
309 )}
310 </Card>
311 );
312}
313
314// --- What people did in the span ------------------------------------------------
315
316/** "in the span" said the way the span is: "since you were last here", "in the last 24 hours". */
317function inSpan(span: Pick<Span, "key" | "note" | "words">): string {
318 return span.key === "last" && span.note == null ? "since you were last here" : span.words.charAt(0).toLowerCase() + span.words.slice(1);
319}
320
321/** The faces of the most active, with a tooltip naming each, and how many more there were. */
322const FACES = 5;
323
324function Faces({ actors }: { actors: DigestActor[] }) {
325 if (actors.length === 0) return null;
326 const shown = actors.slice(0, FACES);
327 const more = actors.length - shown.length;
328 return (
329 <div className="flex items-center gap-1.5">
330 <ul className="flex -space-x-1.5" aria-label="Most active">
331 {shown.map((actor) => (
332 <li key={actor.key} className="rounded-full ring-2 ring-surface">
333 <Tooltip>
334 <TooltipTrigger asChild>
335 <span className="block">
336 <Avatar name={actor.name} size={22} />
337 </span>
338 </TooltipTrigger>
339 <TooltipContent>
340 {actor.name} · {actor.count.toLocaleString("en-US")} {actor.count === 1 ? "thing" : "things"}
341 </TooltipContent>
342 </Tooltip>
343 </li>
344 ))}
345 </ul>
346 {more > 0 && <span className="text-xs text-muted">+{more.toLocaleString("en-US")}</span>}
347 </div>
348 );
349}
350
351/** The lines: each what was done, with its count in the words, linked to where it is listed. */
352function Lines({ column }: { column: DigestColumn }) {
353 return (
354 <ul className="mt-3 space-y-1.5">
355 {column.lines.map((line) => (
356 <li key={line.key} className="flex items-start gap-2 text-sm text-fg">
357 <span className="mt-1.75 size-1.5 shrink-0 rounded-full bg-fg-soft/60" aria-hidden />
358 {line.to ? (
359 <Link to={line.to} className="hover:underline">
360 {line.text}
361 </Link>
362 ) : (
363 <span>{line.text}</span>
364 )}
365 </li>
366 ))}
367 </ul>
368 );
369}
370
371/**
372 * What people did in the span: lines with counts linked to where each
373 * thing is listed, and the most active faces. It says when it is empty,
374 * and which part didn't answer.
375 */
376export function PeopleCard({ digest, span, code }: { digest: Digest | null; span: Pick<Span, "key" | "note" | "words">; code: boolean }) {
377 const when = inSpan(span);
378 if (!digest) {
379 return (
380 <section className="rounded-2xl border border-line bg-surface p-5">
381 <p className="text-sm text-muted">What happened couldn&apos;t be read right now. It shows here once Code and Chat answer.</p>
382 </section>
383 );
384 }
385 const silent = digest.missing.filter((name) => name === "Code" || name === "Chat");
386 const col = digest.people;
387 return (
388 <section className="rounded-2xl border border-line bg-surface p-5" aria-label="What people did">
389 <div className="flex flex-wrap items-center gap-3">
390 <h3 className="inline-flex items-center gap-1.5 text-sm font-semibold text-fg">
391 <Users size={15} className="text-muted" />
392 People
393 </h3>
394 {col.actors.length > 0 && (
395 <Badge tone="neutral" size="md" className="tabular-nums">
396 {col.actors.length.toLocaleString("en-US")} active
397 </Badge>
398 )}
399 <span className="ml-auto">
400 <Faces actors={col.actors} />
401 </span>
402 </div>
403 {col.lines.length > 0 ? (
404 <Lines column={col} />
405 ) : (
406 <p className="mt-3 text-sm text-muted">
407 {silent.length > 0
408 ? `Nothing from what could be read ${when}.`
409 : code
410 ? `Nobody pushed, opened, merged, reviewed, edited a doc or said anything ${when}.`
411 : `Nobody edited a doc or said anything ${when}.`}
412 </p>
413 )}
414 {silent.length > 0 && (
415 <p className="mt-3 flex items-center gap-1.5 text-xs text-faint">
416 <AlertTriangle size={12} className="shrink-0" />
417 {listOf(silent)} didn&apos;t answer, so {silent.length === 1 ? "its part" : "their parts"} {silent.length === 1 ? "isn't" : "aren't"} counted.
418 </p>
419 )}
420 {digest.partial && <p className="mt-3 text-xs text-faint">More happened than Home reads at once, so some counts are low.</p>}
421 </section>
422 );
423}
424
425export function SpanSkeleton() {
426 return (
427 <section className="space-y-3 rounded-2xl border border-line bg-surface p-5" aria-busy="true">
428 <div className="flex items-center justify-between">
429 <Skeleton className="h-3.5 w-20" />
430 <Skeleton className="h-5 w-24 rounded-full" />
431 </div>
432 <Skeleton className="h-3.5 w-3/4" />
433 <Skeleton className="h-3.5 w-1/2" />
434 <Skeleton className="h-3.5 w-2/3" />
435 </section>
436 );
437}
438
439// --- Landed and builds -----------------------------------------------------------
440
441/** Rows each list shows before the rest fold away. */
442const LIST_ROWS = 5;
443
444function When({ at, now, tz, timeOnly }: { at: number; now: number; tz: string | null; timeOnly?: boolean }) {
445 return (
446 <span className="shrink-0 text-xs text-faint tabular-nums" suppressHydrationWarning>
447 {timeOnly ? timeOfDay(at, tz) : whenShort(at, now, tz)}
448 </span>
449 );
450}
451
452function Folded<T>({ items, render }: { items: T[]; render: (item: T) => ReactNode }) {
453 return (
454 <>
455 <ul>{items.slice(0, LIST_ROWS).map(render)}</ul>
456 {items.length > LIST_ROWS && (
457 <details className="group border-t border-line">
458 <summary className="cursor-pointer list-none px-5 py-2.5 text-xs font-medium text-fg-soft hover:text-fg">
459 <span className="group-open:hidden">Show {(items.length - LIST_ROWS).toLocaleString("en-US")} more</span>
460 <span className="hidden group-open:inline">Show fewer</span>
461 </summary>
462 <ul className="border-t border-line">{items.slice(LIST_ROWS).map(render)}</ul>
463 </details>
464 )}
465 </>
466 );
467}
468
469const rowClass = "flex items-center gap-3 border-t border-line px-5 py-2.5 first:border-t-0";
470
471const LANDED_ICON: Record<LandedChange["kind"], ReactNode> = {
472 merge: <GitMerge size={15} className="shrink-0 text-success" />,
473 push: <GitCommitHorizontal size={15} className="shrink-0 text-success" />,
474 deploy: <Rocket size={15} className="shrink-0 text-success" />,
475 release: <Tag size={15} className="shrink-0 text-info" />,
476 package: <Package size={15} className="shrink-0 text-info" />,
477};
478
479/** A few words on the card's line: how many of each kind landed. */
480function landedHint(landed: LandedChange[]): string {
481 const count = (kind: LandedChange["kind"]) => landed.filter((change) => change.kind === kind).length;
482 const parts: string[] = [];
483 const merged = count("merge");
484 const pushes = count("push");
485 const deploys = count("deploy");
486 const published = count("release") + count("package");
487 if (merged > 0) parts.push(`${merged.toLocaleString("en-US")} merged`);
488 if (pushes > 0) parts.push(`${pushes.toLocaleString("en-US")} pushed direct`);
489 if (deploys > 0) parts.push(`${deploys.toLocaleString("en-US")} ${deploys === 1 ? "deploy" : "deploys"}`);
490 if (published > 0) parts.push(`${published.toLocaleString("en-US")} published`);
491 return parts.join(" · ");
492}
493
494const SHIP_TONE: Record<ShipInfo["state"], BadgeTone> = {
495 live: "success",
496 deploying: "info",
497 waiting: "neutral",
498 failed: "danger",
499};
500
501/** Whether a merge or a push is live yet, linked to the deployment that shipped it (or the one under way, or the one that failed). */
502function Shipped({ change }: { change: LandedChange }) {
503 const ship = change.shipped;
504 if (!ship) return null;
505 const label = shippedLabel(ship, change);
506 const badge = (
507 <Badge tone={SHIP_TONE[ship.state]} className="relative z-10">
508 {ship.state === "live" && <Rocket size={10} aria-hidden="true" />}
509 {ship.state === "deploying" && <Loader size={10} aria-hidden="true" className="animate-spin motion-reduce:animate-none" />}
510 {label}
511 </Badge>
512 );
513 return ship.to ? (
514 <Link to={ship.to} className="relative z-10 rounded-full focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none">
515 {badge}
516 </Link>
517 ) : (
518 badge
519 );
520}
521
522/** The agent marker on a change made in a harness session, linked to the session. */
523function Agent({ change }: { change: LandedChange }) {
524 const mark = change.agent;
525 if (!mark) return null;
526 const label = agentLabel(mark);
527 const marker = <AgentMarker size={16} inline />;
528 return (
529 <Tooltip>
530 <TooltipTrigger asChild>
531 {mark.to ? (
532 <Link to={mark.to} aria-label={label} className="relative z-10 inline-flex shrink-0 rounded-full focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none">
533 {marker}
534 </Link>
535 ) : (
536 <span role="img" aria-label={label} className="inline-flex shrink-0">
537 {marker}
538 </span>
539 )}
540 </TooltipTrigger>
541 <TooltipContent>{label}</TooltipContent>
542 </Tooltip>
543 );
544}
545
546function LandedItem({ change, now, tz, timeOnly }: { change: LandedChange; now: number; tz: string | null; timeOnly?: boolean }) {
547 return (
548 <li className={cn(rowClass, "items-start")}>
549 <span className="mt-0.5 shrink-0">{LANDED_ICON[change.kind]}</span>
550 <div className="min-w-0 grow">
551 <div className="flex min-w-0 items-center gap-1.5">
552 {change.refsIn ? (
553 // A commit's subject: its `#12`s link to their issues, the rest to the commit.
554 <span className="line-clamp-2 text-sm text-fg sm:truncate">
555 <CommitText text={change.title} base={`/${change.refsIn}`} to={change.to} linkClassName="hover:underline" />
556 </span>
557 ) : (
558 <Link to={change.to} className="line-clamp-2 text-sm text-fg hover:underline sm:truncate">
559 {change.title}
560 </Link>
561 )}
562 <Agent change={change} />
563 </div>
564 <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
565 <p className="min-w-0 truncate text-xs text-muted">{change.detail}</p>
566 <Shipped change={change} />
567 </div>
568 </div>
569 <When at={change.at} now={now} tz={tz} timeOnly={timeOnly} />
570 </li>
571 );
572}
573
574/**
575 * Everything that landed in the span, newest first: pull requests merged,
576 * commits pushed straight to a default branch, production builds that went
577 * live, releases and package versions published. Each merge and push says
578 * whether it is live yet, and one made in a harness session carries the
579 * agent marker. Over the last 7 days, day by day.
580 */
581export function LandedCard({
582 landed,
583 missing,
584 code,
585 now,
586 tz,
587 byDay = false,
588}: {
589 landed: LandedChange[] | null;
590 missing: string[];
591 code: boolean;
592 now: number;
593 tz: string | null;
594 byDay?: boolean;
595}) {
596 if (!code) return null;
597 const render = (change: LandedChange) => <LandedItem key={change.key} change={change} now={now} tz={tz} timeOnly={byDay} />;
598 return (
599 <Card title="Landed" hint={landed && landed.length > 0 ? landedHint(landed) : undefined}>
600 {!landed ? (
601 <Quiet>Code didn&apos;t answer, so what landed can&apos;t be listed right now.</Quiet>
602 ) : landed.length === 0 ? (
603 <Quiet>{missing.length > 0 ? "Nothing landed in what could be read." : "Nothing merged, pushed to a default branch, deployed to production or published in this span."}</Quiet>
604 ) : byDay ? (
605 landedByDay(landed, now, tz).map((day) => (
606 <section key={day.key} aria-label={day.label} className="border-t border-line first-of-type:border-t-0">
607 <h3 className="flex items-baseline gap-2 bg-raised/40 px-5 py-1.5 text-xs font-medium text-fg-soft">
608 {day.label}
609 <span className="font-normal text-faint tabular-nums">{day.rows.length.toLocaleString("en-US")}</span>
610 </h3>
611 <Folded items={day.rows} render={render} />
612 </section>
613 ))
614 ) : (
615 <Folded items={landed} render={render} />
616 )}
617 {landed && <Missing names={missing} what="everything that landed" />}
618 </Card>
619 );
620}
621
622// --- While you were away -----------------------------------------------------------
623
624/**
625 * What g1t settled for you since you were last here (a later run passed,
626 * it merged, it deployed), what shipped, and what still needs you: the
627 * counts on one line, then each thing that settled and why.
628 */
629export function AwayCard({ away, span, now }: { away: Away | null; span: Pick<Span, "key" | "note" | "words">; now: number }) {
630 const title = span.key === "last" && span.note == null ? "While you were away" : "Settled for you";
631 if (!away) {
632 return (
633 <Card title={title}>
634 <Quiet>Your notifications couldn&apos;t be read, so what settled can&apos;t be listed right now.</Quiet>
635 </Card>
636 );
637 }
638 const facts: { key: string; text: string; tone: BadgeTone; to: string | null }[] = [];
639 const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
640 facts.push({ key: "settled", text: `${plural(away.settled.length, "notification")} settled`, tone: away.settled.length > 0 ? "success" : "neutral", to: "/notifications?view=done" });
641 if (away.shipped > 0) facts.push({ key: "shipped", text: `${plural(away.shipped, "change")} live`, tone: "success", to: null });
642 if (away.deploying > 0) facts.push({ key: "deploying", text: `${away.deploying.toLocaleString("en-US")} deploying`, tone: "info", to: null });
643 if (away.waiting > 0) facts.push({ key: "waiting", text: `${away.waiting.toLocaleString("en-US")} waiting to deploy`, tone: "neutral", to: null });
644 if (away.failed > 0) facts.push({ key: "failed", text: `${away.failed.toLocaleString("en-US")} deploy failed`, tone: "danger", to: null });
645 facts.push({ key: "needs", text: away.needs > 0 ? `${plural(away.needs, "thing")} still ${away.needs === 1 ? "needs" : "need"} you` : "Nothing needs you", tone: away.needs > 0 ? "warn" : "neutral", to: "#needed" });
646 return (
647 <Card title={title} hint="What finished by itself, and what is live" link={{ label: "Done", to: "/notifications?view=done" }}>
648 <ul className="flex flex-wrap gap-1.5 px-5 pb-3" aria-label="In short">
649 {facts.map((fact) => (
650 <li key={fact.key}>
651 {fact.to ? (
652 <Link to={fact.to} className="rounded-full focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none">
653 <Badge tone={fact.tone} size="md">
654 {fact.text}
655 </Badge>
656 </Link>
657 ) : (
658 <Badge tone={fact.tone} size="md">
659 {fact.text}
660 </Badge>
661 )}
662 </li>
663 ))}
664 </ul>
665 {away.settled.length > 0 && (
666 <div className="border-t border-line">
667 <Folded
668 items={away.settled}
669 render={(row) => (
670 <li key={row.key} className={rowClass}>
671 <CheckCheck size={15} className="shrink-0 text-success" aria-hidden="true" />
672 <div className="min-w-0 grow">
673 <Link to={row.to} className="block truncate text-sm text-fg hover:underline">
674 {row.title}
675 </Link>
676 <p className="truncate text-xs text-muted">
677 <span className="text-fg-soft">{row.why}</span>
678 {row.where && ` · ${row.where}`}
679 </p>
680 </div>
681 <span className="shrink-0 text-xs text-faint tabular-nums" suppressHydrationWarning>
682 {waited(row.at, now)} ago
683 </span>
684 </li>
685 )}
686 />
687 </div>
688 )}
689 </Card>
690 );
691}
692
693export function DeploysCard({ deploys, complete, code, now, tz }: { deploys: DeployRow[] | null; complete: boolean; code: boolean; now: number; tz: string | null }) {
694 // A workspace that doesn't deploy has nothing to say here.
695 if (!code || (deploys && deploys.length === 0)) return null;
696 const failed = deploys?.filter((row) => row.outcome === "failed").length ?? 0;
697 return (
698 <Card title="Deploys" hint={deploys ? `${deploys.length.toLocaleString("en-US")} finished${failed ? ` · ${failed} failed` : ""}` : undefined}>
699 {!deploys ? (
700 <Quiet>Deployments didn&apos;t answer, so builds can&apos;t be listed right now.</Quiet>
701 ) : (
702 <Folded
703 items={deploys}
704 render={(row) => (
705 <li key={row.key} className={rowClass}>
706 <Rocket size={15} className={cn("shrink-0", row.outcome === "failed" ? "text-danger" : "text-success")} />
707 <div className="min-w-0 grow">
708 <Link to={row.to} className="block truncate text-sm text-fg hover:underline">
709 {row.kind === "production" ? "Production" : `Preview of ${row.branch ?? "a branch"}`} of {row.project}
710 <span className={cn("ml-2 text-xs font-medium", row.outcome === "failed" ? "text-danger" : "text-success")}>
711 {row.outcome === "failed" ? "Failed" : "Went live"}
712 </span>
713 </Link>
714 <p className="truncate text-xs text-muted">
715 <span className="font-mono">{row.commit}</span> · by {row.by}
716 </p>
717 </div>
718 <When at={row.at} now={now} tz={tz} />
719 </li>
720 )}
721 />
722 )}
723 {deploys && !complete && <p className="border-t border-line px-5 py-2.5 text-xs text-faint">Some projects&apos; builds couldn&apos;t be read.</p>}
724 </Card>
725 );
726}
727
728// --- Spend in the span ------------------------------------------------------------
729
730/**
731 * Each kind of charge keeps its colour, whatever its rank: sandboxes and
732 * runners one, hosting another, storage and the rest grey.
733 */
734function kindColour(kind: string): string {
735 if (/sandbox|runner/i.test(kind)) return "bg-info";
736 if (/deploy|domain/i.test(kind)) return "bg-fg-soft";
737 if (/storage|git|cache|package/i.test(kind)) return "bg-muted";
738 return "bg-faint";
739}
740
741const utcDay = (day: string) => new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" }).format(Date.parse(`${day}T12:00:00Z`));
742
743export function SpendCard({ spend, slug, span }: { spend: Spend | null; slug: string; span: Pick<Span, "key" | "note" | "words"> }) {
744 return (
745 <Card title="Spent" link={{ label: "Billing", to: `/${slug}/-/billing` }}>
746 {!spend ? (
747 <Quiet>Billing didn&apos;t answer, so spend can&apos;t be shown right now.</Quiet>
748 ) : (
749 <div className="px-5 pb-5">
750 <div className="text-3xl font-semibold tracking-tight text-fg tabular-nums">{money(spend.totalMicros)}</div>
751 <p className="mt-1 text-xs text-muted">
752 {spend.from === spend.to ? utcDay(spend.from) : `${utcDay(spend.from)} – ${utcDay(spend.to)}`}
753 {spend.monthMicros != null && ` · ${money(spend.monthMicros)} this month so far`}
754 </p>
755 {spend.lines.length === 0 ? (
756 <p className="mt-4 text-sm text-muted">Nothing on the statement {inSpan(span)}.</p>
757 ) : (
758 <>
759 <div className="mt-4 flex h-2.5 gap-0.5 overflow-hidden rounded-full" role="img" aria-label="Spend by kind of charge">
760 {spend.lines.map((line) => (
761 <span key={line.kind} className={`h-full min-w-0.75 ${kindColour(line.kind)}`} style={{ flexGrow: line.micros }} />
762 ))}
763 </div>
764 <ul className="mt-3 space-y-2">
765 {spend.lines.map((line) => (
766 <li key={line.kind} className="flex items-center gap-2 text-sm">
767 <span className={`size-2 shrink-0 rounded-xs ${kindColour(line.kind)}`} aria-hidden />
768 <span className="min-w-0 grow truncate text-fg-soft">{line.kind}</span>
769 <span className="text-fg tabular-nums">{money(line.micros)}</span>
770 </li>
771 ))}
772 </ul>
773 </>
774 )}
775 <p className="mt-4 text-[0.6875rem] leading-relaxed text-faint">
776 At price, as your statement shows it. The statement keeps whole days, midnight to midnight UTC, so this counts from the start of the day the span began.
777 </p>
778 </div>
779 )}
780 </Card>
781 );
782}
783
784export function SpendSkeleton() {
785 return (
786 <div className="space-y-3 px-5 pb-5" aria-busy="true">
787 <Skeleton className="h-8 w-28" />
788 <Skeleton className="h-2.5 w-full rounded-full" />
789 <Skeleton className="h-3 w-3/4" />
790 <Skeleton className="h-3 w-2/3" />
791 </div>
792 );
793}
794
795// --- Running now -------------------------------------------------------------------
796
797const RUNNING_ICON: Record<RunningRow["kind"], ReactNode> = {
798 workflow: <Workflow size={15} className="text-info" />,
799 deploy: <Rocket size={15} className="text-info" />,
800};
801
802export function RunningList({ rows, missing, now }: { rows: RunningRow[]; missing: string[]; now: number }) {
803 return (
804 <>
805 {rows.length === 0 ? (
806 <Quiet>{missing.length > 0 ? "Nothing is running in what could be read." : "Nothing is running. Workflow runs and builds show here while they go."}</Quiet>
807 ) : (
808 <Folded
809 items={rows}
810 render={(row) => (
811 <li key={row.key} className={rowClass}>
812 <span className="shrink-0">{RUNNING_ICON[row.kind]}</span>
813 <div className="min-w-0 grow">
814 <Link to={row.to} className="block truncate text-sm text-fg hover:underline">
815 {row.title}
816 </Link>
817 <p className="truncate text-xs text-muted">{row.detail}</p>
818 </div>
819 <span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-raised px-2 py-0.5 text-xs text-fg-soft">
820 <Loader size={11} className="animate-spin text-accent motion-reduce:animate-none" aria-hidden />
821 {row.status}
822 </span>
823 <span className="hidden w-14 shrink-0 text-right text-xs text-faint tabular-nums sm:inline" suppressHydrationWarning>
824 {waited(row.at, now)}
825 </span>
826 </li>
827 )}
828 />
829 )}
830 <Missing names={missing} what="everything running" />
831 </>
832 );
833}