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.

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