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.

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