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.

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