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.

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