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