g1t/apps/web/app/components/mission.tsx
| 1 | /** |
| 2 | * The pieces mission control and a project's overview are made of: panels |
| 3 | * with a "View all", what needs the viewer, the activity feed with its |
| 4 | * filters, the week's pulse with its sparklines, a project's pipeline and |
| 5 | * its deploy history. |
| 6 | */ |
| 7 | import { |
| 8 | ArrowRight, |
| 9 | Brain, |
| 10 | CircleCheck, |
| 11 | CircleDot, |
| 12 | CircleSlash, |
| 13 | CreditCard, |
| 14 | Eye, |
| 15 | GitMerge, |
| 16 | GitPullRequest, |
| 17 | Hand, |
| 18 | MessageCircleQuestion, |
| 19 | MessageSquare, |
| 20 | Play, |
| 21 | Rocket, |
| 22 | Swords, |
| 23 | Terminal, |
| 24 | TimerOff, |
| 25 | TriangleAlert, |
| 26 | } from "lucide-react"; |
| 27 | import { type ReactNode, useMemo, useState } from "react"; |
| 28 | import { Link } from "react-router"; |
| 29 | |
| 30 | import type { DeployStatus } from "@g1t/contracts"; |
| 31 | |
| 32 | import { cn } from "../lib/cn"; |
| 33 | import { type ActivityGroup, type Need, type NeedKind, type Verb, isAgent, sparkPoints } from "../lib/mission"; |
| 34 | import { Avatar, TimeAgo } from "./ui"; |
| 35 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; |
| 36 | |
| 37 | // --- Panels ------------------------------------------------------------------- |
| 38 | |
| 39 | /** A titled section, with a count and a link to everything it summarises. */ |
| 40 | export function Panel({ |
| 41 | id, |
| 42 | title, |
| 43 | icon, |
| 44 | count, |
| 45 | all, |
| 46 | extra, |
| 47 | children, |
| 48 | className, |
| 49 | }: { |
| 50 | id?: string; |
| 51 | title: ReactNode; |
| 52 | icon?: ReactNode; |
| 53 | count?: number | null; |
| 54 | all?: { to: string; label?: string } | null; |
| 55 | extra?: ReactNode; |
| 56 | children: ReactNode; |
| 57 | className?: string; |
| 58 | }) { |
| 59 | return ( |
| 60 | <section id={id} className={cn("scroll-mt-20", className)}> |
| 61 | <div className="flex min-h-7 flex-wrap items-center gap-x-3 gap-y-2"> |
| 62 | <h2 className="flex items-center gap-2 text-sm font-semibold tracking-tight"> |
| 63 | {icon && <span className="text-faint">{icon}</span>} |
| 64 | {title} |
| 65 | {count != null && count > 0 && ( |
| 66 | <span className="rounded-full bg-line px-1.5 text-[0.6875rem] font-medium tabular-nums text-muted">{count}</span> |
| 67 | )} |
| 68 | </h2> |
| 69 | <span className="grow" /> |
| 70 | {extra} |
| 71 | {all && ( |
| 72 | <Link to={all.to} prefetch="intent" className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> |
| 73 | {all.label ?? "View all"} |
| 74 | <ArrowRight size={12} /> |
| 75 | </Link> |
| 76 | )} |
| 77 | </div> |
| 78 | <div className="mt-3">{children}</div> |
| 79 | </section> |
| 80 | ); |
| 81 | } |
| 82 | |
| 83 | /** Says a section could not be loaded, without taking the page down. */ |
| 84 | export function Unavailable({ what }: { what: string }) { |
| 85 | return ( |
| 86 | <p className="flex items-center gap-2 rounded-xl border border-dashed border-line px-4 py-5 text-sm text-muted"> |
| 87 | <TriangleAlert size={14} className="text-warn" /> |
| 88 | {what} could not be loaded just now. It will be back on the next refresh. |
| 89 | </p> |
| 90 | ); |
| 91 | } |
| 92 | |
| 93 | /** A quiet, dashed empty state with an optional call to action. */ |
| 94 | export function Quiet({ children, action }: { children: ReactNode; action?: ReactNode }) { |
| 95 | return ( |
| 96 | <div className="flex flex-wrap items-center gap-3 rounded-xl border border-dashed border-line px-4 py-5 text-sm text-muted"> |
| 97 | <span className="min-w-0 grow">{children}</span> |
| 98 | {action} |
| 99 | </div> |
| 100 | ); |
| 101 | } |
| 102 | |
| 103 | // --- Needs you ---------------------------------------------------------------- |
| 104 | |
| 105 | const NEED: Record<NeedKind, { icon: ReactNode; tone: string; label: string }> = { |
| 106 | limit: { icon: <CreditCard size={15} />, tone: "text-danger", label: "Usage" }, |
| 107 | deploy: { icon: <Rocket size={15} />, tone: "text-danger", label: "Production" }, |
| 108 | conflict: { icon: <Swords size={15} />, tone: "text-warn", label: "Conflict" }, |
| 109 | stalled: { icon: <Hand size={15} />, tone: "text-warn", label: "Stopped" }, |
| 110 | stuck: { icon: <TimerOff size={15} />, tone: "text-warn", label: "Quiet agent" }, |
| 111 | review: { icon: <Eye size={15} />, tone: "text-info", label: "Review" }, |
| 112 | checks: { icon: <Terminal size={15} />, tone: "text-danger", label: "Checks" }, |
| 113 | ready: { icon: <GitMerge size={15} />, tone: "text-accent", label: "Ready" }, |
| 114 | }; |
| 115 | |
| 116 | /** What is waiting on the viewer, most urgent first, each with its next step. */ |
| 117 | export function NeedsList({ needs, limit = 8 }: { needs: Need[]; limit?: number }) { |
| 118 | const [all, setAll] = useState(false); |
| 119 | const shown = all ? needs : needs.slice(0, limit); |
| 120 | return ( |
| 121 | <> |
| 122 | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> |
| 123 | {shown.map((need) => { |
| 124 | const look = NEED[need.kind]; |
| 125 | return ( |
| 126 | <li key={need.key} className="flex items-start gap-3 px-4 py-3"> |
| 127 | <span className={cn("mt-0.5 shrink-0", look.tone)} title={look.label}> |
| 128 | {look.icon} |
| 129 | </span> |
| 130 | <span className="min-w-0 grow"> |
| 131 | <Link to={need.to} prefetch="intent" className="block truncate text-sm font-medium hover:text-accent"> |
| 132 | {need.title} |
| 133 | </Link> |
| 134 | <span className="mt-0.5 block text-xs leading-5 text-muted"> |
| 135 | {need.where && <span className="font-mono text-faint">{need.where} · </span>} |
| 136 | {need.detail} |
| 137 | </span> |
| 138 | </span> |
| 139 | <span className="hidden shrink-0 pt-0.5 text-xs text-faint sm:block"> |
| 140 | <TimeAgo at={need.at} /> |
| 141 | </span> |
| 142 | <Link |
| 143 | to={need.to} |
| 144 | prefetch="intent" |
| 145 | className="shrink-0 rounded-md border border-line px-2.5 py-1 text-xs font-medium text-fg/85 transition-colors hover:border-line-strong hover:bg-raised hover:text-fg" |
| 146 | > |
| 147 | {need.action} |
| 148 | </Link> |
| 149 | </li> |
| 150 | ); |
| 151 | })} |
| 152 | </ul> |
| 153 | {needs.length > limit && ( |
| 154 | <button type="button" onClick={() => setAll(!all)} className="mt-2 text-xs text-muted hover:text-fg"> |
| 155 | {all ? "Show fewer" : `Show ${needs.length - limit} more`} |
| 156 | </button> |
| 157 | )} |
| 158 | </> |
| 159 | ); |
| 160 | } |
| 161 | |
| 162 | // --- Activity ----------------------------------------------------------------- |
| 163 | |
| 164 | const VERB: Record<Verb, { icon: ReactNode; tone: string; text: (n: string) => string }> = { |
| 165 | landed: { icon: <GitMerge size={14} />, tone: "text-merged", text: (n) => `landed ${n}` }, |
| 166 | opened_issue: { icon: <CircleDot size={14} />, tone: "text-accent", text: (n) => `opened ${n}` }, |
| 167 | closed_issue: { icon: <CircleSlash size={14} />, tone: "text-faint", text: (n) => `closed ${n}` }, |
| 168 | started: { icon: <Play size={14} />, tone: "text-merged", text: (n) => `started on ${n}` }, |
| 169 | ready: { icon: <GitPullRequest size={14} />, tone: "text-info", text: (n) => `marked ${n} ready for review` }, |
| 170 | checks_passed: { icon: <Terminal size={14} />, tone: "text-accent", text: (n) => `checks passed on ${n}` }, |
| 171 | checks_failed: { icon: <Terminal size={14} />, tone: "text-danger", text: (n) => `checks failed on ${n}` }, |
| 172 | approved: { icon: <CircleCheck size={14} />, tone: "text-accent", text: (n) => `approved ${n}` }, |
| 173 | changes_requested: { icon: <CircleSlash size={14} />, tone: "text-warn", text: (n) => `asked for changes on ${n}` }, |
| 174 | commented: { icon: <MessageSquare size={14} />, tone: "text-muted", text: (n) => `commented on ${n}` }, |
| 175 | asked: { icon: <MessageCircleQuestion size={14} />, tone: "text-merged", text: (n) => `asked the agent on ${n}` }, |
| 176 | deployed: { icon: <Rocket size={14} />, tone: "text-accent", text: () => "deployed production" }, |
| 177 | deploy_failed: { icon: <Rocket size={14} />, tone: "text-danger", text: () => "production build failed" }, |
| 178 | learned: { icon: <Brain size={14} />, tone: "text-merged", text: () => "learned" }, |
| 179 | }; |
| 180 | |
| 181 | function Refs({ numbers, base }: { numbers: number[]; base: string }) { |
| 182 | const shown = numbers.slice(0, 4); |
| 183 | return ( |
| 184 | <> |
| 185 | {shown.map((number, index) => ( |
| 186 | <span key={number}> |
| 187 | {index > 0 && (index === shown.length - 1 && numbers.length <= 4 ? " and " : ", ")} |
| 188 | <Link to={`${base}/issues/${number}`} prefetch="intent" className="font-medium text-fg hover:underline"> |
| 189 | #{number} |
| 190 | </Link> |
| 191 | </span> |
| 192 | ))} |
| 193 | {numbers.length > 4 && ` and ${numbers.length - 4} more`} |
| 194 | </> |
| 195 | ); |
| 196 | } |
| 197 | |
| 198 | /** One group as a sentence: "landed #4 and #3; started on #5". */ |
| 199 | function GroupLine({ group }: { group: ActivityGroup }) { |
| 200 | const base = `/${group.repo.namespace}/${group.repo.name}`; |
| 201 | return ( |
| 202 | <> |
| 203 | {group.parts.map((part, index) => { |
| 204 | const look = VERB[part.verb]; |
| 205 | const [before, after] = look.text("\u0000").split("\u0000"); |
| 206 | return ( |
| 207 | <span key={part.verb}> |
| 208 | {index > 0 && "; "} |
| 209 | {part.verb === "learned" ? ( |
| 210 | <> |
| 211 | learned{" "} |
| 212 | <Link to={part.to ?? `${base}/memory`} className="text-fg-soft hover:text-fg"> |
| 213 | “{part.texts[0]}” |
| 214 | </Link> |
| 215 | {part.texts.length > 1 && ` and ${part.texts.length - 1} more`} |
| 216 | </> |
| 217 | ) : part.numbers.length > 0 ? ( |
| 218 | <> |
| 219 | {before} |
| 220 | <Refs numbers={part.numbers} base={base} /> |
| 221 | {after} |
| 222 | </> |
| 223 | ) : part.to ? ( |
| 224 | <Link to={part.to} className="hover:text-fg"> |
| 225 | {look.text("")} |
| 226 | </Link> |
| 227 | ) : ( |
| 228 | look.text("") |
| 229 | )} |
| 230 | </span> |
| 231 | ); |
| 232 | })} |
| 233 | </> |
| 234 | ); |
| 235 | } |
| 236 | |
| 237 | type Who = "all" | "agents" | "people"; |
| 238 | |
| 239 | /** |
| 240 | * What moved across projects, newest first, a burst of one actor's work |
| 241 | * as one line, filterable by project and by who did it. |
| 242 | */ |
| 243 | export function ActivityFeed({ |
| 244 | groups, |
| 245 | showRepo = true, |
| 246 | limit = 14, |
| 247 | empty, |
| 248 | }: { |
| 249 | groups: ActivityGroup[]; |
| 250 | showRepo?: boolean; |
| 251 | limit?: number; |
| 252 | empty: ReactNode; |
| 253 | }) { |
| 254 | const [project, setProject] = useState("all"); |
| 255 | const [who, setWho] = useState<Who>("all"); |
| 256 | const [person, setPerson] = useState("all"); |
| 257 | const [more, setMore] = useState(false); |
| 258 | const projects = useMemo( |
| 259 | () => [...new Set(groups.map((g) => `${g.repo.namespace}/${g.repo.name}`))].sort(), |
| 260 | [groups], |
| 261 | ); |
| 262 | const people = useMemo( |
| 263 | () => [...new Set(groups.map((g) => g.actor).filter((a): a is string => a != null))].sort(), |
| 264 | [groups], |
| 265 | ); |
| 266 | const filtered = groups.filter( |
| 267 | (g) => |
| 268 | (project === "all" || `${g.repo.namespace}/${g.repo.name}` === project) && |
| 269 | (person === "all" || g.actor === person) && |
| 270 | (who === "all" || (who === "agents" ? g.actor == null || isAgent(g.actor) : g.actor != null && !isAgent(g.actor))), |
| 271 | ); |
| 272 | const shown = more ? filtered : filtered.slice(0, limit); |
| 273 | return ( |
| 274 | <div> |
| 275 | {groups.length > 0 && ( |
| 276 | <div className="mb-3 flex flex-wrap items-center gap-2"> |
| 277 | <div className="inline-flex rounded-lg border border-line bg-bg p-0.5 text-xs"> |
| 278 | {(["all", "agents", "people"] as const).map((value) => ( |
| 279 | <button |
| 280 | key={value} |
| 281 | type="button" |
| 282 | onClick={() => setWho(value)} |
| 283 | className={cn( |
| 284 | "rounded-md px-2.5 py-1 font-medium capitalize transition-colors", |
| 285 | who === value ? "bg-raised text-fg" : "text-muted hover:text-fg", |
| 286 | )} |
| 287 | > |
| 288 | {value === "all" ? "Everyone" : value} |
| 289 | </button> |
| 290 | ))} |
| 291 | </div> |
| 292 | {showRepo && projects.length > 1 && ( |
| 293 | <Select value={project} onValueChange={setProject}> |
| 294 | <SelectTrigger size="sm" className="w-auto max-w-56" aria-label="Project"> |
| 295 | <SelectValue /> |
| 296 | </SelectTrigger> |
| 297 | <SelectContent> |
| 298 | <SelectItem value="all">All projects</SelectItem> |
| 299 | {projects.map((name) => ( |
| 300 | <SelectItem key={name} value={name}> |
| 301 | {name.split("/")[1]} |
| 302 | </SelectItem> |
| 303 | ))} |
| 304 | </SelectContent> |
| 305 | </Select> |
| 306 | )} |
| 307 | {people.length > 1 && ( |
| 308 | <Select value={person} onValueChange={setPerson}> |
| 309 | <SelectTrigger size="sm" className="w-auto max-w-48" aria-label="Who"> |
| 310 | <SelectValue /> |
| 311 | </SelectTrigger> |
| 312 | <SelectContent> |
| 313 | <SelectItem value="all">Anyone</SelectItem> |
| 314 | {people.map((name) => ( |
| 315 | <SelectItem key={name} value={name} icon={<Avatar name={name} size={16} />}> |
| 316 | {name} |
| 317 | </SelectItem> |
| 318 | ))} |
| 319 | </SelectContent> |
| 320 | </Select> |
| 321 | )} |
| 322 | </div> |
| 323 | )} |
| 324 | {shown.length === 0 ? ( |
| 325 | <Quiet>{groups.length === 0 ? empty : "Nothing matches these filters."}</Quiet> |
| 326 | ) : ( |
| 327 | <ol className="relative space-y-0.5 before:absolute before:top-3 before:bottom-3 before:left-[0.9375rem] before:w-px before:bg-line"> |
| 328 | {shown.map((group) => { |
| 329 | const look = VERB[group.parts[0].verb]; |
| 330 | const agent = group.actor == null || isAgent(group.actor); |
| 331 | return ( |
| 332 | <li key={group.id} className="relative flex items-start gap-3 rounded-lg px-1 py-1.5 text-sm"> |
| 333 | <span |
| 334 | className={cn( |
| 335 | "relative z-10 mt-0.5 flex size-[1.375rem] shrink-0 items-center justify-center rounded-full bg-bg ring-1 ring-line", |
| 336 | look.tone, |
| 337 | )} |
| 338 | > |
| 339 | {look.icon} |
| 340 | </span> |
| 341 | <span className="min-w-0 grow leading-6 text-muted"> |
| 342 | {group.actor ? ( |
| 343 | <span className={cn("mr-1.5 inline-flex items-center gap-1.5 align-middle font-medium", agent ? "text-merged" : "text-fg")}> |
| 344 | <Avatar name={group.actor} size={16} /> |
| 345 | {group.actor} |
| 346 | </span> |
| 347 | ) : ( |
| 348 | <span className="mr-1 text-fg-soft">g1t:</span> |
| 349 | )} |
| 350 | <GroupLine group={group} /> |
| 351 | {showRepo && ( |
| 352 | <Link |
| 353 | to={`/${group.repo.namespace}/${group.repo.name}`} |
| 354 | className="ml-1.5 font-mono text-xs text-faint hover:text-muted" |
| 355 | > |
| 356 | {group.repo.name} |
| 357 | </Link> |
| 358 | )} |
| 359 | {group.count > 2 && <span className="ml-1.5 text-xs text-faint">· {group.count} events</span>} |
| 360 | </span> |
| 361 | <span className="shrink-0 pt-0.5 text-xs text-faint"> |
| 362 | <TimeAgo at={group.at} /> |
| 363 | </span> |
| 364 | </li> |
| 365 | ); |
| 366 | })} |
| 367 | </ol> |
| 368 | )} |
| 369 | {filtered.length > limit && ( |
| 370 | <button type="button" onClick={() => setMore(!more)} className="mt-2 text-xs text-muted hover:text-fg"> |
| 371 | {more ? "Show fewer" : `Show ${filtered.length - limit} more`} |
| 372 | </button> |
| 373 | )} |
| 374 | </div> |
| 375 | ); |
| 376 | } |
| 377 | |
| 378 | // --- Pulse -------------------------------------------------------------------- |
| 379 | |
| 380 | const DAY_LABEL = (offset: number) => { |
| 381 | const date = new Date(Date.now() - offset * 86_400_000); |
| 382 | return date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" }); |
| 383 | }; |
| 384 | |
| 385 | /** |
| 386 | * A week of one measure as a small line, the last day marked. Each day |
| 387 | * has a hover target that says its value. |
| 388 | */ |
| 389 | export function Sparkline({ |
| 390 | values, |
| 391 | format, |
| 392 | width = 96, |
| 393 | height = 28, |
| 394 | }: { |
| 395 | values: number[]; |
| 396 | format: (value: number) => string; |
| 397 | width?: number; |
| 398 | height?: number; |
| 399 | }) { |
| 400 | const points = sparkPoints(values, width, height, 3); |
| 401 | if (points.length === 0) return null; |
| 402 | const line = points.map(([x, y], i) => `${i ? "L" : "M"}${x},${y}`).join(" "); |
| 403 | const area = `${line} L${points[points.length - 1][0]},${height} L${points[0][0]},${height} Z`; |
| 404 | const [lastX, lastY] = points[points.length - 1]; |
| 405 | const slot = width / values.length; |
| 406 | return ( |
| 407 | <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="overflow-visible text-accent" role="img" aria-label={values.map(format).join(", ")}> |
| 408 | <path d={area} fill="currentColor" opacity={0.12} /> |
| 409 | <path d={line} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /> |
| 410 | <circle cx={lastX} cy={lastY} r={3} fill="currentColor" stroke="var(--color-surface)" strokeWidth={2} /> |
| 411 | {values.map((value, index) => ( |
| 412 | <rect key={index} x={index * slot} y={0} width={slot} height={height} fill="transparent" className="hover:fill-fg/5"> |
| 413 | <title>{`${DAY_LABEL(values.length - 1 - index)}: ${format(value)}`}</title> |
| 414 | </rect> |
| 415 | ))} |
| 416 | </svg> |
| 417 | ); |
| 418 | } |
| 419 | |
| 420 | /** One measure of the week: a headline number, what it means, and its line. */ |
| 421 | export function PulseTile({ |
| 422 | label, |
| 423 | value, |
| 424 | hint, |
| 425 | values, |
| 426 | format, |
| 427 | to, |
| 428 | }: { |
| 429 | label: string; |
| 430 | value: string; |
| 431 | hint?: string; |
| 432 | values?: number[]; |
| 433 | format?: (value: number) => string; |
| 434 | to?: string; |
| 435 | }) { |
| 436 | const body = ( |
| 437 | <> |
| 438 | <p className="truncate text-xs text-muted">{label}</p> |
| 439 | <div className="mt-1 flex items-end justify-between gap-2"> |
| 440 | <p className="text-2xl font-semibold tracking-tight tabular-nums">{value}</p> |
| 441 | {values && format && values.some((v) => v > 0) && ( |
| 442 | <span className="mb-1"> |
| 443 | <Sparkline values={values} format={format} width={56} height={22} /> |
| 444 | </span> |
| 445 | )} |
| 446 | </div> |
| 447 | <p className="mt-1.5 truncate text-[0.6875rem] text-faint" title={hint}> |
| 448 | {hint} |
| 449 | </p> |
| 450 | </> |
| 451 | ); |
| 452 | const box = "block rounded-xl border border-line bg-surface p-4 transition-colors"; |
| 453 | return to ? ( |
| 454 | <Link to={to} className={`${box} hover:border-line-strong`}> |
| 455 | {body} |
| 456 | </Link> |
| 457 | ) : ( |
| 458 | <div className={box}>{body}</div> |
| 459 | ); |
| 460 | } |
| 461 | |
| 462 | // --- Deploys ------------------------------------------------------------------ |
| 463 | |
| 464 | const STRIP_TONE: Record<DeployStatus, string> = { |
| 465 | ready: "bg-accent", |
| 466 | // Served once, until a newer build or a take-down: it worked. |
| 467 | replaced: "bg-accent/45", |
| 468 | down: "bg-line-strong", |
| 469 | failed: "bg-danger", |
| 470 | building: "bg-warn animate-pulse", |
| 471 | queued: "bg-line-strong animate-pulse", |
| 472 | skipped: "bg-line", |
| 473 | }; |
| 474 | |
| 475 | /** The last builds as a row of bars, oldest on the left; each links to its build. */ |
| 476 | export function DeployStrip({ |
| 477 | builds, |
| 478 | base, |
| 479 | slots = 20, |
| 480 | }: { |
| 481 | builds: { id: string; status: DeployStatus; kind: string; commit: string; createdAt: string }[]; |
| 482 | base: string; |
| 483 | slots?: number; |
| 484 | }) { |
| 485 | const shown = builds.slice(0, slots).reverse(); |
| 486 | return ( |
| 487 | <div className="flex h-8 items-end gap-[3px]" aria-label="Recent builds, oldest first"> |
| 488 | {Array.from({ length: slots - shown.length }, (_, i) => ( |
| 489 | <span key={`empty-${i}`} className="h-2 flex-1 rounded-sm bg-line/60" /> |
| 490 | ))} |
| 491 | {shown.map((build) => ( |
| 492 | <Link |
| 493 | key={build.id} |
| 494 | to={`${base}/deployments/${build.id}`} |
| 495 | title={`${build.kind === "production" ? "Production" : "Preview"} · ${build.commit.slice(0, 7)} · ${build.status} · ${new Date(build.createdAt).toLocaleString("en-US")}`} |
| 496 | className={cn("flex-1 rounded-sm transition-opacity hover:opacity-80", STRIP_TONE[build.status], build.kind === "production" ? "h-8" : "h-5")} |
| 497 | /> |
| 498 | ))} |
| 499 | </div> |
| 500 | ); |
| 501 | } |
| 502 | |
| 503 | /** A small meter for a share, 0 to 1. */ |
| 504 | export function Meter({ value, tone = "bg-accent" }: { value: number | null; tone?: string }) { |
| 505 | return ( |
| 506 | <span className="block h-1.5 w-full overflow-hidden rounded-full bg-line"> |
| 507 | {value != null && <span className={cn("block h-full rounded-full", tone)} style={{ width: `${Math.round(value * 100)}%` }} />} |
| 508 | </span> |
| 509 | ); |
| 510 | } |
| 511 | |
| 512 | export function percent(value: number | null): string { |
| 513 | return value == null ? "—" : `${Math.round(value * 100)}%`; |
| 514 | } |
| 515 |