| 1 | | − | import { ArrowDownWideNarrow, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, Plus } from "lucide-react"; |
| 2 | | − | import { type ReactNode, useEffect, useState } from "react"; |
| 3 | | − | import { Link, useFetcher, useLocation, useRouteLoaderData, useSearchParams } from "react-router"; |
| 4 | | − | |
| 5 | | − | import { type ActivityGroup, type Verb, isAgent } from "../lib/mission"; |
| 6 | | − | import { |
| 7 | | − | BLOCKING, |
| 8 | | − | type Fact, |
| 9 | | − | type LandedRow, |
| 10 | | − | type NeedRow, |
| 11 | | − | type QuickAction, |
| 12 | | − | REASON_LABEL, |
| 13 | | − | type Reason, |
| 14 | | − | type Sort, |
| 15 | | − | type Tab, |
| 16 | | − | type Week, |
| 17 | | − | type Who, |
| 18 | | − | change, |
| 19 | | − | dateLine, |
| 20 | | − | parseSort, |
| 21 | | − | parseTab, |
| 22 | | − | signedPercent, |
| 23 | | − | sortRows, |
| 24 | | − | } from "../lib/mission-control"; |
| 25 | | − | import { cn } from "../lib/cn"; |
| 26 | | − | import { money } from "../lib/money"; |
| 27 | | − | import { InboxNeedsCard } from "./inbox"; |
| 28 | | − | import type { ShellData } from "./shell"; |
| 29 | | − | import { Unavailable } from "./mission"; |
| 30 | | − | import { SubmitButton, TimeAgo } from "./ui"; |
| 31 | | − | import { Avatar } from "./ui/avatar"; |
| 32 | | − | import { UserAvatar } from "./user-card"; |
| 33 | | − | import { PersonLink } from "./work"; |
| 34 | | − | import { Card } from "./ui/card"; |
| 35 | | − | import { Hint } from "./ui/hint"; |
| 36 | | − | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu"; |
| 37 | | − | import type { Loaded } from "../lib/mission-control.server"; |
| 38 | | − | import { itemPath, newItemPath } from "../lib/work-items"; |
| 39 | | − | |
| 40 | | − | /* |
| 41 | | − | * Code's Overview: where the viewer is needed in the workspace's code, |
| 42 | | − | * what landed, and the week. It is a chunk of its own, loaded only for |
| 43 | | − | * those signed in. routes/workspace/code-overview.tsx loads its data; |
| 44 | | − | * lib/mission-control.ts shapes it. |
| 45 | | − | */ |
| 46 | | − | |
| 47 | | − | /** The viewer's time zone, set by the page itself, so the date fits their day. */ |
| 48 | | − | const TZ_COOKIE = "g1t_tz"; |
| 49 | | − | /** Rows a tab shows before "See all". */ |
| 50 | | − | const ROWS = 6; |
| 51 | | − | |
| 52 | | − | const dollars = (micros: number) => money(Math.max(0, micros)); |
| 53 | | − | const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`; |
| 54 | | − | |
| 55 | | − | // --- Small pieces ----------------------------------------------------------------- |
| 56 | | − | |
| 57 | | − | const CHIP_TONE: Record<Reason, string> = { |
| 58 | | − | blocking: "border-danger/35 bg-danger/10 text-danger", |
| 59 | | − | checks_failing: "border-danger/35 bg-danger/10 text-danger", |
| 60 | | − | stalled: "border-warn/35 bg-warn/10 text-warn", |
| 61 | | − | invited: "border-accent/35 bg-accent/10 text-accent", |
| 62 | | − | needs_review: "border-info/35 bg-info/10 text-info", |
| 63 | | − | ready: "border-success/35 bg-success/10 text-success", |
| 64 | | − | }; |
| 65 | | − | |
| 66 | | − | function Chip({ children, tone }: { children: ReactNode; tone: string }) { |
| 67 | | − | return ( |
| 68 | | − | <span |
| 69 | | − | className={cn( |
| 70 | | − | "inline-flex shrink-0 items-center rounded border px-1.5 py-px font-mono text-[0.625rem] font-medium tracking-wider whitespace-nowrap uppercase", |
| 71 | | − | tone, |
| 72 | | − | )} |
| 73 | | − | > |
| 74 | | − | {children} |
| 75 | | − | </span> |
| 76 | | − | ); |
| 77 | | − | } |
| 78 | | − | |
| 79 | | − | /** Someone, by name, with their avatar. */ |
| 80 | | − | function Person({ who, size = 16, className }: { who: Who; size?: number; className?: string }) { |
| 81 | | − | return ( |
| 82 | | − | <span className={cn("inline-flex min-w-0 items-center gap-1.5", className)}> |
| 83 | | − | <Avatar name={who.name} size={size} /> |
| 84 | | − | <span className="truncate">{who.name}</span> |
| 85 | | − | </span> |
| 86 | | − | ); |
| 87 | | − | } |
| 88 | | − | |
| 89 | | − | function Eyebrow({ children }: { children: ReactNode }) { |
| 90 | | − | return <p className="font-mono text-[0.625rem] font-medium tracking-[0.12em] text-faint uppercase">{children}</p>; |
| 91 | | − | } |
| 92 | | − | |
| 93 | | − | const FACT_TONE = { good: "text-success", warn: "text-warn", bad: "text-danger" } as const; |
| 94 | | − | |
| 95 | | − | function Facts({ facts }: { facts: Fact[] }) { |
| 96 | | − | if (facts.length === 0) return <p className="text-sm text-muted">Nothing more is known about it yet.</p>; |
| 97 | | − | return ( |
| 98 | | − | <dl className="grid grid-cols-2 gap-x-4 gap-y-2.5"> |
| 99 | | − | {facts.map((fact) => ( |
| 100 | | − | <div key={fact.label} className={cn("min-w-0", fact.wide && "col-span-2")}> |
| 101 | | − | <dt className="text-xs text-faint">{fact.label}</dt> |
| 102 | | − | <dd |
| 103 | | − | className={cn("mt-0.5 text-sm font-medium tabular-nums", fact.wide ? "leading-5" : "truncate", fact.tone ? FACT_TONE[fact.tone] : "text-fg-soft")} |
| 104 | | − | > |
| 105 | | − | {fact.value} |
| 106 | | − | </dd> |
| 107 | | − | </div> |
| 108 | | − | ))} |
| 109 | | − | </dl> |
| 110 | | − | ); |
| 111 | | − | } |
| 112 | | − | |
| 113 | | − | /** |
| 114 | | − | * A form that acts on a pull request from here: working until the action |
| 115 | | − | * has answered and the page has reloaded what it changed, then done, or |
| 116 | | − | * the error beside it. |
| 117 | | − | */ |
| 118 | | − | function QuickForm({ quick, variant = "outline" }: { quick: QuickAction; variant?: "outline" | "accent" }) { |
| 119 | | − | const fetcher = useFetcher<{ error?: string } | null>(); |
| 120 | | − | const [sent, setSent] = useState(false); |
| 121 | | − | const done = sent && fetcher.state === "idle" && !fetcher.data?.error; |
| 122 | | − | return ( |
| 123 | | − | <fetcher.Form method="post" action={quick.to} onSubmit={() => setSent(true)} className="contents"> |
| 124 | | − | {Object.entries(quick.fields).map(([name, value]) => ( |
| 125 | | − | <input key={name} type="hidden" name={name} value={value} /> |
| 126 | | − | ))} |
| 127 | | − | <SubmitButton |
| 128 | | − | fetcher={fetcher} |
| 129 | | − | disabled={done} |
| 130 | | − | className={cn( |
| 131 | | − | "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-default", |
| 132 | | − | variant === "accent" ? "bg-accent text-bg hover:bg-accent-hover" : "border border-line-strong text-fg/90 hover:bg-raised hover:text-fg", |
| 133 | | − | done && "border-success/40 text-success", |
| 134 | | − | )} |
| 135 | | − | > |
| 136 | | − | {done ? <Check size={14} /> : null} |
| 137 | | − | {done ? quick.done : quick.label} |
| 138 | | − | </SubmitButton> |
| 139 | | − | {fetcher.data?.error && <span className="basis-full text-xs text-danger">{fetcher.data.error}</span>} |
| 140 | | − | </fetcher.Form> |
| 141 | | − | ); |
| 142 | | − | } |
| 143 | | − | |
| 144 | | − | function ProjectMark({ repo }: { repo: { namespace: string; name: string } | null }) { |
| 145 | | − | return <Avatar name={repo ? repo.name : "g1t"} size={32} square />; |
| 146 | | − | } |
| 147 | | − | |
| 148 | | − | /** A row that opens to what is known about it; the first one starts open. */ |
| 149 | | − | function Row({ |
| 150 | | − | open, |
| 151 | | − | repo, |
| 152 | | − | by, |
| 153 | | − | title, |
| 154 | | − | sub, |
| 155 | | − | chip, |
| 156 | | − | at, |
| 157 | | − | children, |
| 158 | | − | }: { |
| 159 | | − | open: boolean; |
| 160 | | − | repo: { namespace: string; name: string } | null; |
| 161 | | − | by: Who | null; |
| 162 | | − | title: ReactNode; |
| 163 | | − | sub: ReactNode; |
| 164 | | − | chip: ReactNode; |
| 165 | | − | at: number; |
| 166 | | − | children: ReactNode; |
| 167 | | − | }) { |
| 168 | | − | return ( |
| 169 | | − | <li> |
| 170 | | − | <details open={open} className="group"> |
| 171 | | − | <summary className="flex cursor-pointer list-none items-center gap-3 px-4 py-3 transition-colors select-none hover:bg-raised/60 sm:px-5 [&::-webkit-details-marker]:hidden"> |
| 172 | | − | <ProjectMark repo={repo} /> |
| 173 | | − | <span className="min-w-0 grow"> |
| 174 | | − | <span className="block truncate text-sm">{title}</span> |
| 175 | | − | <span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted"> |
| 176 | | − | <span className="shrink-0 font-medium text-fg-soft">{repo ? repo.name : "Workspace"}</span> |
| 177 | | − | {by && ( |
| 178 | | − | <> |
| 179 | | − | <span className="text-faint">·</span> |
| 180 | | − | <Person who={by} size={14} className={cn("shrink-0", by.agent ? "text-accent" : "")} /> |
| 181 | | − | </> |
| 182 | | − | )} |
| 183 | | − | {sub && ( |
| 184 | | − | <> |
| 185 | | − | <span className="text-faint">—</span> |
| 186 | | − | <span className="truncate">{sub}</span> |
| 187 | | − | </> |
| 188 | | − | )} |
| 189 | | − | </span> |
| 190 | | − | </span> |
| 191 | | − | <span className="hidden sm:inline-flex">{chip}</span> |
| 192 | | − | <span className="hidden w-16 shrink-0 text-right text-xs text-faint sm:block"> |
| 193 | | − | <TimeAgo at={at} /> |
| 194 | | − | </span> |
| 195 | | − | <ChevronDown size={16} className="shrink-0 text-faint transition-transform group-open:rotate-180" /> |
| 196 | | − | </summary> |
| 197 | | − | <div className="px-4 pb-4 sm:px-5 sm:pb-5"> |
| 198 | | − | <div className="mb-3 flex items-center gap-2 sm:hidden"> |
| 199 | | − | {chip} |
| 200 | | − | <span className="text-xs text-faint"> |
| 201 | | − | <TimeAgo at={at} /> |
| 202 | | − | </span> |
| 203 | | − | </div> |
| 204 | | − | {children} |
| 205 | | − | </div> |
| 206 | | − | </details> |
| 207 | | − | </li> |
| 208 | | − | ); |
| 209 | | − | } |
| 210 | | − | |
| 211 | | − | function NeedCard({ row, first }: { row: NeedRow; first: boolean }) { |
| 212 | | − | const isPull = row.to.includes("/pull/"); |
| 213 | | − | return ( |
| 214 | | − | <Row |
| 215 | | − | open={first} |
| 216 | | − | repo={row.repo} |
| 217 | | − | by={row.by} |
| 218 | | − | title={ |
| 219 | | − | <> |
| 220 | | − | <span className="font-medium">{row.title}</span> |
| 221 | | − | {row.ref && <span className="ml-1 font-mono text-xs text-faint">{row.ref}</span>} |
| 222 | | − | </> |
| 223 | | − | } |
| 224 | | − | sub={row.ask} |
| 225 | | − | chip={<Chip tone={CHIP_TONE[row.reason]}>{REASON_LABEL[row.reason]}</Chip>} |
| 226 | | − | at={row.at} |
| 227 | | − | > |
| 228 | | − | <Card tone="plain" className="grid gap-4 bg-bg/50 p-4 md:grid-cols-2 md:gap-x-6 2xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)_minmax(0,0.95fr)]"> |
| 229 | | − | <div className="min-w-0"> |
| 230 | | − | <Eyebrow>The ask</Eyebrow> |
| 231 | | − | <p className="mt-2 text-sm leading-6 text-fg-soft">{row.ask}</p> |
| 232 | | − | <p className="mt-2 text-xs leading-5 text-muted"> |
| 233 | | − | <Link to={row.to} className="font-medium text-fg hover:underline"> |
| 234 | | − | {row.repo ? `${row.repo.name}${row.ref ?? ""}` : row.title} |
| 235 | | − | </Link> |
| 236 | | − | </p> |
| 237 | | − | </div> |
| 238 | | − | <div className="min-w-0"> |
| 239 | | − | <Eyebrow>What is known</Eyebrow> |
| 240 | | − | <div className="mt-2"> |
| 241 | | − | <Facts facts={row.facts} /> |
| 242 | | − | </div> |
| 243 | | − | </div> |
| 244 | | − | <div className="rounded-lg border border-warn/20 bg-warn/6 p-3.5 md:col-span-2 2xl:col-span-1"> |
| 245 | | − | <p className="text-xs font-semibold text-warn">Why this needs you</p> |
| 246 | | − | <p className="mt-1.5 text-sm leading-6 text-fg-soft">{row.why}</p> |
| 247 | | − | <p className="mt-2 text-xs text-faint"> |
| 248 | | − | Started waiting <TimeAgo at={row.at} /> |
| 249 | | − | </p> |
| 250 | | − | </div> |
| 251 | | − | </Card> |
| 252 | | − | <div className="mt-3 flex flex-wrap items-center gap-2"> |
| 253 | | − | <Link |
| 254 | | − | to={row.to} |
| 255 | | − | prefetch="intent" |
| 256 | | − | className="inline-flex items-center gap-1.5 rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg transition-colors hover:bg-fg-hover" |
| 257 | | − | > |
| 258 | | − | Review and respond <ArrowUpRight size={14} /> |
| 259 | | − | </Link> |
| 260 | | − | {row.quick && <QuickForm quick={row.quick} />} |
| 261 | | − | {row.link && ( |
| 262 | | − | <Link |
| 263 | | − | to={row.link.to} |
| 264 | | − | className="inline-flex items-center rounded-md border border-line-strong px-3 py-1.5 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg" |
| 265 | | − | > |
| 266 | | − | {row.link.label} |
| 267 | | − | </Link> |
| 268 | | − | )} |
| 269 | | − | {row.open !== row.to && ( |
| 270 | | − | <Link to={row.open} prefetch="intent" className="px-2 py-1.5 text-sm text-muted hover:text-fg"> |
| 271 | | − | {row.open.includes("/-/work/") ? "Open work item" : isPull ? "See the changes" : "Open pull request"} |
| 272 | | − | </Link> |
| 273 | | − | )} |
| 274 | | − | </div> |
| 275 | | − | </Row> |
| 276 | | − | ); |
| 277 | | − | } |
| 278 | | − | |
| 279 | | − | function LandedCard({ row, first }: { row: LandedRow; first: boolean }) { |
| 280 | | − | return ( |
| 281 | | − | <Row |
| 282 | | − | open={first} |
| 283 | | − | repo={row.repo} |
| 284 | | − | by={row.by} |
| 285 | | − | title={ |
| 286 | | − | <> |
| 287 | | − | <span className="font-medium">{row.title}</span> |
| 288 | | − | <span className="ml-1 font-mono text-xs text-faint">{row.ref}</span> |
| 289 | | − | </> |
| 290 | | − | } |
| 291 | | − | sub={row.automatic ? "merged automatically" : `merged by ${row.by?.name ?? "a person"}`} |
| 292 | | − | chip={ |
| 293 | | − | row.automatic ? ( |
| 294 | | − | <Chip tone="border-accent/35 bg-accent/10 text-accent">Auto-merged</Chip> |
| 295 | | − | ) : ( |
| 296 | | − | <Chip tone="border-line-strong bg-raised text-muted">Merged</Chip> |
| 297 | | − | ) |
| 298 | | − | } |
| 299 | | − | at={row.at} |
| 300 | | − | > |
| 301 | | − | <Card tone="plain" className="grid gap-4 bg-bg/50 p-4 md:grid-cols-2 md:gap-6"> |
| 302 | | − | <div className="min-w-0"> |
| 303 | | − | <Eyebrow>How it landed</Eyebrow> |
| 304 | | − | <p className="mt-2 text-sm leading-6 text-fg-soft"> |
| 305 | | − | {row.automatic |
| 306 | | − | ? "g1t merged it once everything the repository asks for was met, with no one pressing merge." |
| 307 | | − | : `${row.by?.name ?? "A person"} merged it.`} |
| 308 | | − | </p> |
| 309 | | − | </div> |
| 310 | | − | <div className="min-w-0"> |
| 311 | | − | <Eyebrow>What changed</Eyebrow> |
| 312 | | − | <div className="mt-2"> |
| 313 | | − | <Facts facts={row.facts} /> |
| 314 | | − | </div> |
| 315 | | − | </div> |
| 316 | | − | </Card> |
| 317 | | − | <div className="mt-3"> |
| 318 | | − | <Link |
| 319 | | − | to={row.to} |
| 320 | | − | prefetch="intent" |
| 321 | | − | className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-1.5 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg" |
| 322 | | − | > |
| 323 | | − | Open pull request <ArrowUpRight size={14} /> |
| 324 | | − | </Link> |
| 325 | | − | </div> |
| 326 | | − | </Row> |
| 327 | | − | ); |
| 328 | | − | } |
| 329 | | − | |
| 330 | | − | function List({ children }: { children: ReactNode }) { |
| 331 | | − | return <ul className="divide-y divide-line">{children}</ul>; |
| 332 | | − | } |
| 333 | | − | |
| 334 | | − | function Empty({ children, action }: { children: ReactNode; action?: ReactNode }) { |
| 335 | | − | return ( |
| 336 | | − | <div className="px-5 py-10 text-center"> |
| 337 | | − | <div className="mx-auto max-w-md text-sm leading-6 text-muted">{children}</div> |
| 338 | | − | {action && <div className="mt-4 flex flex-wrap justify-center gap-2">{action}</div>} |
| 339 | | − | </div> |
| 340 | | − | ); |
| 341 | | − | } |
| 342 | | − | |
| 343 | | − | // --- The stat strip ----------------------------------------------------------------- |
| 344 | | − | |
| 345 | | − | function Stat({ label, value, hint, dot, title }: { label: string; value: string; hint: ReactNode; dot?: string; title?: string }) { |
| 346 | | − | return ( |
| 347 | | − | <Hint label={title}> |
| 348 | | − | <div className="min-w-0 bg-surface px-4 py-3.5 sm:px-5 sm:py-4"> |
| 349 | | − | <p className="flex items-center gap-1.5 truncate text-xs text-muted"> |
| 350 | | − | {dot && <span className={cn("size-1.5 shrink-0 rounded-full", dot)} />} |
| 351 | | − | {label} |
| 352 | | − | </p> |
| 353 | | − | <p className="mt-1 font-display text-2xl font-semibold tracking-tight tabular-nums sm:text-[1.75rem]">{value}</p> |
| 354 | | − | <p className="mt-0.5 truncate text-xs text-faint">{hint}</p> |
| 355 | | − | </div> |
| 356 | | − | </Hint> |
| 357 | | − | ); |
| 358 | | − | } |
| 359 | | − | |
| 360 | | − | // --- This week ---------------------------------------------------------------------- |
| 361 | | − | |
| 362 | | − | /** Seven days of landed changes: merged pull requests and commits pushed straight to a default branch. */ |
| 363 | | − | function WeekChart({ week }: { week: Week }) { |
| 364 | | − | const max = Math.max(1, ...week.days.map((d) => d.count)); |
| 365 | | − | const height = 112; |
| 366 | | − | const delta = change(week.total, week.previous); |
| 367 | | − | return ( |
| 368 | | − | <div> |
| 369 | | − | <div className="flex items-end justify-between gap-3"> |
| 370 | | − | <div> |
| 371 | | − | <p className="font-display text-3xl font-semibold tracking-tight tabular-nums">{week.total.toLocaleString("en-US")}</p> |
| 372 | | − | <p className="text-xs text-muted">changes landed in 7 days</p> |
| 373 | | − | </div> |
| 374 | | − | {delta != null ? ( |
| 375 | | − | <span |
| 376 | | − | className={cn( |
| 377 | | − | "rounded-full px-2 py-0.5 text-xs font-medium tabular-nums ring-1", |
| 378 | | − | delta >= 0 ? "text-success ring-success/30" : "text-warn ring-warn/30", |
| 379 | | − | )} |
| 380 | | − | > |
| 381 | | − | {signedPercent(delta)} vs last week |
| 382 | | − | </span> |
| 383 | | − | ) : week.previous === 0 && week.total > 0 ? ( |
| 384 | | − | <span className="text-xs text-faint">none the week before</span> |
| 385 | | − | ) : null} |
| 386 | | − | </div> |
| 387 | | − | {week.total === 0 ? ( |
| 388 | | − | <Card asChild tone="plain" radius="lg" className="mt-4 border-dashed px-4 py-6 text-center text-sm leading-6 text-muted"> |
| 389 | | − | <p>Nothing landed in the last 7 days. Each merged pull request and each push to a default branch shows here, by day.</p> |
| 390 | | − | </Card> |
| 391 | | − | ) : ( |
| 392 | | − | <div className="relative mt-5" style={{ height: height + 20 }}> |
| 393 | | − | <div className="absolute inset-x-0 border-t border-line" style={{ top: height }} /> |
| 394 | | − | <div className="absolute inset-x-0 top-0 flex items-end justify-between gap-1" style={{ height }}> |
| 395 | | − | {week.days.map((day, index) => { |
| 396 | | − | const today = index === week.days.length - 1; |
| 397 | | − | return ( |
| 398 | | − | <div key={day.key} className="group relative flex h-full flex-1 flex-col items-center justify-end"> |
| 399 | | − | {day.count > 0 && ( |
| 400 | | − | <span |
| 401 | | − | className={cn("mb-1 text-[0.625rem] tabular-nums", today ? "text-fg-soft" : "text-faint opacity-0 group-hover:opacity-100 max-md:hidden")} |
| 402 | | − | > |
| 403 | | − | {day.count} |
| 404 | | − | </span> |
| 405 | | − | )} |
| 406 | | − | <div className="flex w-full max-w-6 flex-col items-stretch"> |
| 407 | | − | {day.count > 0 ? ( |
| 408 | | − | <span className="block rounded-t bg-accent" style={{ height: Math.max(3, Math.round((day.count / max) * (height - 4))) }} /> |
| 409 | | − | ) : ( |
| 410 | | − | <span className="block h-0.5 rounded-full bg-line-strong" /> |
| 411 | | − | )} |
| 412 | | − | </div> |
| 413 | | − | <div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden -translate-x-1/2 rounded-md border border-line-strong bg-raised px-2.5 py-1.5 text-xs whitespace-nowrap shadow-lg shadow-black/40 md:group-hover:block"> |
| 414 | | − | <p className="font-medium text-fg">{day.label}</p> |
| 415 | | − | <p className="mt-0.5 text-muted">{plural(day.count, "change")} landed</p> |
| 416 | | − | </div> |
| 417 | | − | </div> |
| 418 | | − | ); |
| 419 | | − | })} |
| 420 | | − | </div> |
| 421 | | − | <div className="absolute inset-x-0 bottom-0 flex justify-between gap-1"> |
| 422 | | − | {week.days.map((day, index) => ( |
| 423 | | − | <span |
| 424 | | − | key={day.key} |
| 425 | | − | className={cn("flex-1 text-center text-[0.6875rem]", index === week.days.length - 1 ? "font-medium text-fg-soft" : "text-faint")} |
| 426 | | − | > |
| 427 | | − | {day.label} |
| 428 | | − | </span> |
| 429 | | − | ))} |
| 430 | | − | </div> |
| 431 | | − | </div> |
| 432 | | − | )} |
| 433 | | − | {/* A table does not shrink to sr-only's 1px; its wrapper does. */} |
| 434 | | − | <div className="sr-only"> |
| 435 | | − | <table> |
| 436 | | − | <caption>Changes landed each day</caption> |
| 437 | | − | <thead> |
| 438 | | − | <tr> |
| 439 | | − | <th>Day</th> |
| 440 | | − | <th>Changes</th> |
| 441 | | − | </tr> |
| 442 | | − | </thead> |
| 443 | | − | <tbody> |
| 444 | | − | {week.days.map((day) => ( |
| 445 | | − | <tr key={day.key}> |
| 446 | | − | <td>{day.key}</td> |
| 447 | | − | <td>{day.count}</td> |
| 448 | | − | </tr> |
| 449 | | − | ))} |
| 450 | | − | </tbody> |
| 451 | | − | </table> |
| 452 | | − | </div> |
| 453 | | − | </div> |
| 454 | | − | ); |
| 455 | | − | } |
| 456 | | − | |
| 457 | | − | // --- Activity ----------------------------------------------------------------------- |
| 458 | | − | |
| 459 | | − | const VERB: Record<Verb, string> = { |
| 460 | | − | landed: "landed", |
| 461 | | − | opened_item: "opened", |
| 462 | | − | closed_item: "closed", |
| 463 | | − | started: "started on", |
| 464 | | − | ready: "marked ready", |
| 465 | | − | checks_passed: "checks passed on", |
| 466 | | − | checks_failed: "checks failed on", |
| 467 | | − | approved: "approved", |
| 468 | | − | changes_requested: "asked for changes on", |
| 469 | | − | commented: "commented on", |
| 470 | | − | deployed: "deployed production", |
| 471 | | − | deploy_failed: "production build failed", |
| 472 | | − | }; |
| 473 | | − | |
| 474 | | − | /** One line of the feed: who, what, and the one thing it was about, by name. */ |
| 475 | | − | function FeedLine({ group, titles }: { group: ActivityGroup; titles: Record<string, string> }) { |
| 476 | | − | const part = group.parts[0]; |
| 477 | | − | const base = `/${group.repo.namespace}/${group.repo.name}`; |
| 478 | | − | const actor = group.actor ?? "g1t"; |
| 479 | | − | const agent = group.actor == null || isAgent(group.actor); |
| 480 | | − | const number = part.numbers[0]; |
| 481 | | − | const title = number != null ? titles[`${group.repo.namespace}/${group.repo.name}#${number}`.toLowerCase()] : undefined; |
| 482 | | − | const more = group.parts.length - 1 + Math.max(0, part.numbers.length - 1); |
| 483 | | − | return ( |
| 484 | | − | <li className="flex gap-2.5 py-2"> |
| 485 | | − | <span className="mt-px">{agent ? <Avatar name={actor} image={null} size={22} /> : <UserAvatar username={actor} size={22} />}</span> |
| 486 | | − | <span className="min-w-0 grow text-[0.8125rem] leading-5 text-muted"> |
| 487 | | − | {agent ? <span className="font-medium text-accent">{actor}</span> : <PersonLink name={actor} className="font-medium text-fg hover:text-accent" />}{" "} |
| 488 | | − | {VERB[part.verb]}{" "} |
| 489 | | − | {number != null ? ( |
| 490 | | − | <Link |
| 491 | | − | to={part.verb === "opened_item" || part.verb === "closed_item" ? itemPath(group.repo.namespace, number) : `${base}/pull/${number}`} |
| 492 | | − | prefetch="intent" |
| 493 | | − | className="text-fg-soft hover:text-fg" |
| 494 | | − | > |
| 495 | | − | <span className="font-mono text-xs">#{number}</span> |
| 496 | | − | {title && <> {title}</>} |
| 497 | | − | </Link> |
| 498 | | − | ) : part.to ? ( |
| 499 | | − | <Link to={part.to} className="text-fg-soft hover:text-fg"> |
| 500 | | − | {group.repo.name} |
| 501 | | − | </Link> |
| 502 | | − | ) : null} |
| 503 | | − | {more > 0 && <span className="text-faint"> and {more} more</span>} |
| 504 | | − | <span className="block text-xs text-faint"> |
| 505 | | − | <span className="font-mono">{group.repo.name}</span> · <TimeAgo at={group.at} /> |
| 506 | | − | </span> |
| 507 | | − | </span> |
| 508 | | − | </li> |
| 509 | | − | ); |
| 510 | | − | } |
| 511 | | − | |
| 512 | | − | // --- The page ----------------------------------------------------------------------- |
| 513 | | − | |
| 514 | | − | const TAB_LABEL: Record<Tab, string> = { needs: "Needs you", landed: "Landed today" }; |
| 515 | | − | const TAB_SHORT: Record<Tab, string> = { needs: "Needs you", landed: "Today" }; |
| 516 | | − | const SORT_LABEL: Record<Sort, string> = { impact: "By impact", newest: "Newest" }; |
| 517 | | − | |
| 518 | | − | export default function MissionControl({ loaderData }: { loaderData: Loaded }) { |
| 519 | | − | const { pathname } = useLocation(); |
| 520 | | − | const shell = useRouteLoaderData("root")?.shell as ShellData | null | undefined; |
| 521 | | − | const loaded: Loaded = loaderData; |
| 522 | | − | const [params] = useSearchParams(); |
| 523 | | − | const [date, setDate] = useState(loaded.date); |
| 524 | | − | useEffect(() => { |
| 525 | | − | // The date follows the viewer's own clock, and the server learns their zone for next time. |
| 526 | | − | try { |
| 527 | | − | const zone = Intl.DateTimeFormat().resolvedOptions().timeZone; |
| 528 | | − | setDate(dateLine(Date.now(), zone ?? null)); |
| 529 | | − | if (zone) document.cookie = `${TZ_COOKIE}=${encodeURIComponent(zone)}; Path=/; Max-Age=31536000; SameSite=Lax`; |
| 530 | | − | } catch { |
| 531 | | − | // Nothing to remember. |
| 532 | | − | } |
| 533 | | − | }, []); |
| 534 | | − | |
| 535 | | − | const { viewer, repos, landed, week, groups, titles, stats } = loaded; |
| 536 | | − | const workspace = shell?.workspace?.slug ?? loaded.workspace ?? viewer.workspaces?.[0]?.slug ?? null; |
| 537 | | − | |
| 538 | | − | // The usage limit, as the sidebar knows it, heads the list when it bites. |
| 539 | | − | const limit = shell?.limit && !shell.limit.comped && shell.limit.state !== "ok" && workspace ? shell.limit : null; |
| 540 | | − | const limitRow: NeedRow[] = limit |
| 541 | | − | ? [ |
| 542 | | − | (() => { |
| 543 | | − | const detail = |
| 544 | | − | limit.state === "stopped" |
| 545 | | − | ? "Paid usage is paused until the limit is raised or a card is added." |
| 546 | | − | : `${dollars(limit.exposureMicros)} of ${dollars(limit.ceilingMicros ?? 0)} used. Add a card so work keeps going.`; |
| 547 | | − | return { |
| 548 | | − | key: "limit", |
| 549 | | − | reason: "blocking" as const, |
| 550 | | − | repo: null, |
| 551 | | − | ref: null, |
| 552 | | − | title: limit.state === "stopped" ? "The usage limit was reached" : "Usage is nearing its limit", |
| 553 | | − | ask: detail, |
| 554 | | − | by: null, |
| 555 | | − | at: Date.now(), |
| 556 | | − | to: `/${workspace}/-/settings/billing`, |
| 557 | | − | open: `/${workspace}/-/settings/billing`, |
| 558 | | − | facts: [{ label: "Used", value: `${dollars(limit.exposureMicros)} of ${dollars(limit.ceilingMicros ?? 0)}`, tone: "warn" as const }], |
| 559 | | − | why: "Workflows, checks and deployments that are paid for stop at the workspace's usage limit. Only an owner can raise it or add a card.", |
| 560 | | − | quick: null, |
| 561 | | − | link: null, |
| 562 | | − | }; |
| 563 | | − | })(), |
| 564 | | − | ] |
| 565 | | − | : []; |
| 566 | | − | const needs = [...limitRow, ...loaded.needs]; |
| 567 | | − | const blocking = needs.filter((row) => BLOCKING.has(row.reason)).length; |
| 568 | | − | |
| 569 | | − | const counts: Record<Tab, number> = { needs: needs.length, landed: landed.length }; |
| 570 | | − | const tab = parseTab(params.get("tab")) ?? "needs"; |
| 571 | | − | const sort = parseSort(params.get("sort")); |
| 572 | | − | const all = params.get("all") === "1"; |
| 573 | | − | const everyActivity = params.get("activity") === "all"; |
| 574 | | − | const link = (changes: Record<string, string | null>) => { |
| 575 | | − | const next = new URLSearchParams(params); |
| 576 | | − | for (const [key, value] of Object.entries(changes)) { |
| 577 | | − | if (value == null) next.delete(key); |
| 578 | | − | else next.set(key, value); |
| 579 | | − | } |
| 580 | | − | const text = next.toString(); |
| 581 | | − | return text ? `?${text}` : pathname; |
| 582 | | − | }; |
| 583 | | − | |
| 584 | | − | const noProjects = repos.length === 0; |
| 585 | | − | |
| 586 | | − | const rows = tab === "needs" ? sortRows(needs, sort) : sortRows(landed, sort); |
| 587 | | − | const shown = all ? rows : rows.slice(0, ROWS); |
| 588 | | − | const delta = change(week.total, week.previous); |
| 589 | | − | const feed = everyActivity ? groups : groups.slice(0, 8); |
| 590 | | − | |
| 591 | | − | const newItem = |
| 592 | | − | repos.length > 0 ? ( |
| 593 | | − | <DropdownMenu> |
| 594 | | − | <DropdownMenuTrigger className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-2 text-sm font-medium text-fg/90 transition-colors hover:bg-raised hover:text-fg"> |
| 595 | | − | <Plus size={14} /> New work item |
| 596 | | − | </DropdownMenuTrigger> |
| 597 | | − | <DropdownMenuContent align="start" className="max-h-80 overflow-y-auto"> |
| 598 | | − | <DropdownMenuLabel>Filed from which repository?</DropdownMenuLabel> |
| 599 | | − | {repos.map((repo) => ( |
| 600 | | − | <DropdownMenuItem key={repo.name} asChild> |
| 601 | | − | <Link to={newItemPath(repo.namespace, repo.name)}> |
| 602 | | − | <Avatar name={repo.name} size={18} square /> |
| 603 | | − | {repo.name} |
| 604 | | − | </Link> |
| 605 | | − | </DropdownMenuItem> |
| 606 | | − | ))} |
| 607 | | − | </DropdownMenuContent> |
| 608 | | − | </DropdownMenu> |
| 609 | | − | ) : ( |
| 610 | | − | <Link |
| 611 | | − | to={"/new"} |
| 612 | | − | className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-2 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg" |
| 613 | | − | > |
| 614 | | − | <Plus size={14} /> New repository |
| 615 | | − | </Link> |
| 616 | | − | ); |
| 617 | | − | |
| 618 | | − | return ( |
| 619 | | − | <main className="mx-auto max-w-7xl space-y-6 px-4 py-8 sm:px-6 sm:py-10"> |
| 620 | | − | <header className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4"> |
| 621 | | − | <div className="min-w-0"> |
| 622 | | − | <h1 className="text-2xl leading-tight font-semibold tracking-tight">Overview</h1> |
| 623 | | − | <p className="mt-1.5 text-sm text-muted" suppressHydrationWarning> |
| 624 | | − | <span className="text-fg-soft">{date}</span> · {loaded.summary} |
| 625 | | − | </p> |
| 626 | | − | </div> |
| 627 | | − | </header> |
| 628 | | − | |
| 629 | | − | <section aria-label="Start work" className="flex flex-wrap items-center gap-2"> |
| 630 | | − | {newItem} |
| 631 | | − | {needs.length > 0 && ( |
| 632 | | − | <Link |
| 633 | | − | to={`${link({ tab: "needs", all: null })}#work`} |
| 634 | | − | preventScrollReset |
| 635 | | − | className="inline-flex items-center gap-1.5 rounded-md bg-accent px-3.5 py-2 text-sm font-semibold text-bg transition-colors hover:bg-accent-hover" |
| 636 | | − | > |
| 637 | | − | Review {needs.length} that need{needs.length === 1 ? "s" : ""} you <ArrowRight size={14} /> |
| 638 | | − | </Link> |
| 639 | | − | )} |
| 640 | | − | </section> |
| 641 | | − | |
| 642 | | − | <Card asChild tone="plain" className="grid grid-cols-2 gap-px overflow-hidden bg-line lg:grid-cols-4"> |
| 643 | | − | <section aria-label="At a glance"> |
| 644 | | − | <Stat |
| 645 | | − | label="Repositories" |
| 646 | | − | value={stats.projects == null ? "—" : String(stats.projects)} |
| 647 | | − | hint={ |
| 648 | | − | stats.projectsThisMonth == null |
| 649 | | − | ? "in this workspace" |
| 650 | | − | : stats.projectsThisMonth > 0 |
| 651 | | − | ? `+${stats.projectsThisMonth} this month` |
| 652 | | − | : "none new this month" |
| 653 | | − | } |
| 654 | | − | /> |
| 655 | | − | <Stat label="Open pull requests" value={stats.openPulls == null ? "—" : String(stats.openPulls)} hint="drafts included" /> |
| 656 | | − | <Stat |
| 657 | | − | label="Changes this week" |
| 658 | | − | value={loaded.perRepoLoaded ? String(week.total) : "—"} |
| 659 | | − | hint={delta != null ? `${signedPercent(delta)} vs last week` : week.previous === 0 ? "none the week before" : "merges and direct pushes"} |
| 660 | | − | /> |
| 661 | | − | <Stat |
| 662 | | − | label="Needs you" |
| 663 | | − | dot="bg-warn" |
| 664 | | − | value={String(needs.length)} |
| 665 | | − | hint={blocking > 0 ? `${blocking} blocking` : needs.length > 0 ? "nothing blocking" : "all clear"} |
| 666 | | − | /> |
| 667 | | − | </section> |
| 668 | | − | </Card> |
| 669 | | − | |
| 670 | | − | <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] xl:grid-cols-[minmax(0,1fr)_23rem]"> |
| 671 | | − | <Card asChild className="min-w-0 scroll-mt-[calc(var(--topbar-h)+1rem)] self-start overflow-hidden"> |
| 672 | | − | <section id="work"> |
| 673 | | − | <div className="flex items-center gap-2 border-b border-line px-2 sm:px-3"> |
| 674 | | − | <nav className="-mb-px flex min-w-0 grow gap-1 overflow-x-auto scrollbar-none" aria-label="Overview"> |
| 675 | | − | {(["needs", "landed"] as const).map((value) => ( |
| 676 | | − | <Link |
| 677 | | − | key={value} |
| 678 | | − | to={link({ tab: value, all: null })} |
| 679 | | − | preventScrollReset |
| 680 | | − | aria-current={tab === value ? "page" : undefined} |
| 681 | | − | className={cn( |
| 682 | | − | "flex shrink-0 items-center gap-1.5 border-b-2 px-2 py-3 text-sm whitespace-nowrap transition-colors sm:px-2.5", |
| 683 | | − | tab === value ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg", |
| 684 | | − | )} |
| 685 | | − | > |
| 686 | | − | <span className="sm:hidden">{TAB_SHORT[value]}</span> |
| 687 | | − | <span className="hidden sm:inline">{TAB_LABEL[value]}</span> |
| 688 | | − | <span |
| 689 | | − | className={cn( |
| 690 | | − | "rounded-full px-1.5 text-[0.6875rem] tabular-nums", |
| 691 | | − | tab === value && value === "needs" && counts.needs > 0 ? "bg-warn/15 text-warn" : "bg-line text-muted", |
| 692 | | − | )} |
| 693 | | − | > |
| 694 | | − | {counts[value]} |
| 695 | | − | </span> |
| 696 | | − | </Link> |
| 697 | | − | ))} |
| 698 | | − | </nav> |
| 699 | | − | <DropdownMenu> |
| 700 | | − | <DropdownMenuTrigger |
| 701 | | − | className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted hover:bg-raised hover:text-fg" |
| 702 | | − | aria-label="Sort" |
| 703 | | − | > |
| 704 | | − | <ArrowDownWideNarrow size={14} /> |
| 705 | | − | <span className="hidden sm:inline">{SORT_LABEL[sort]}</span> |
| 706 | | − | </DropdownMenuTrigger> |
| 707 | | − | <DropdownMenuContent align="end"> |
| 708 | | − | {(["impact", "newest"] as const).map((value) => ( |
| 709 | | − | <DropdownMenuItem key={value} asChild> |
| 710 | | − | <Link to={link({ sort: value === "impact" ? null : value })} preventScrollReset> |
| 711 | | − | {sort === value ? <Check /> : <span className="size-4" />} |
| 712 | | − | {SORT_LABEL[value]} |
| 713 | | − | </Link> |
| 714 | | − | </DropdownMenuItem> |
| 715 | | − | ))} |
| 716 | | − | </DropdownMenuContent> |
| 717 | | − | </DropdownMenu> |
| 718 | | − | </div> |
| 719 | | − | |
| 720 | | − | {!loaded.perRepoLoaded ? ( |
| 721 | | − | <div className="p-4"> |
| 722 | | − | <Unavailable what={tab === "needs" ? "Some of what needs you" : "What landed"} /> |
| 723 | | − | </div> |
| 724 | | − | ) : null} |
| 725 | | − | |
| 726 | | − | {tab === "needs" && |
| 727 | | − | (needs.length === 0 ? ( |
| 728 | | − | noProjects ? ( |
| 729 | | − | <Empty |
| 730 | | − | action={ |
| 731 | | − | <Link to={"/new"} className="rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg hover:bg-fg-hover"> |
| 732 | | − | Create or import a repository |
| 733 | | − | </Link> |
| 734 | | − | } |
| 735 | | − | > |
| 736 | | − | <p className="font-medium text-fg">No repositories yet</p> |
| 737 | | − | <p className="mt-1">Create a repository or import one. Reviews, failed deploys and invitations for you show up here.</p> |
| 738 | | − | </Empty> |
| 739 | | − | ) : ( |
| 740 | | − | <> |
| 741 | | − | <Empty> |
| 742 | | − | <p className="font-medium text-fg">Nothing needs you</p> |
| 743 | | − | <p className="mt-1">{landed.length > 0 ? "Here's what landed today." : "Reviews, failed deploys and invitations show up here first."}</p> |
| 744 | | − | </Empty> |
| 745 | | − | {landed.length > 0 && ( |
| 746 | | − | <div className="border-t border-line"> |
| 747 | | − | <List> |
| 748 | | − | {landed.slice(0, ROWS).map((row) => ( |
| 749 | | − | <LandedCard key={row.key} row={row} first={false} /> |
| 750 | | − | ))} |
| 751 | | − | </List> |
| 752 | | − | </div> |
| 753 | | − | )} |
| 754 | | − | </> |
| 755 | | − | ) |
| 756 | | − | ) : ( |
| 757 | | − | <List> |
| 758 | | − | {(shown as NeedRow[]).map((row, index) => ( |
| 759 | | − | <NeedCard key={row.key} row={row} first={index === 0} /> |
| 760 | | − | ))} |
| 761 | | − | </List> |
| 762 | | − | ))} |
| 763 | | − | |
| 764 | | − | {tab === "landed" && |
| 765 | | − | loaded.perRepoLoaded && |
| 766 | | − | (landed.length === 0 ? ( |
| 767 | | − | <Empty> |
| 768 | | − | <p className="font-medium text-fg">Nothing has landed today yet</p> |
| 769 | | − | <p className="mt-1"> |
| 770 | | − | {week.total > 0 ? `${plural(week.total, "change")} landed in the last 7 days.` : "Merged pull requests show up here the moment they land."} |
| 771 | | − | </p> |
| 772 | | − | </Empty> |
| 773 | | − | ) : ( |
| 774 | | − | <List> |
| 775 | | − | {(shown as LandedRow[]).map((row, index) => ( |
| 776 | | − | <LandedCard key={row.key} row={row} first={index === 0} /> |
| 777 | | − | ))} |
| 778 | | − | </List> |
| 779 | | − | ))} |
| 780 | | − | |
| 781 | | − | {rows.length > ROWS && ( |
| 782 | | − | <div className="flex items-center justify-between border-t border-line px-4 py-2.5 text-xs text-muted sm:px-5"> |
| 783 | | − | <span> |
| 784 | | − | Showing {shown.length} of {rows.length} |
| 785 | | − | </span> |
| 786 | | − | <Link to={link({ all: all ? null : "1" })} preventScrollReset className="inline-flex items-center gap-1 font-medium text-fg-soft hover:text-fg"> |
| 787 | | − | {all ? "Show fewer" : "See all"} <ChevronRight size={12} /> |
| 788 | | − | </Link> |
| 789 | | − | </div> |
| 790 | | − | )} |
| 791 | | − | </section> |
| 792 | | − | </Card> |
| 793 | | − | |
| 794 | | − | <aside className="min-w-0 space-y-6"> |
| 795 | | − | <InboxNeedsCard items={loaderData.inboxNeeds.items} total={loaderData.inboxNeeds.total} /> |
| 796 | | − | <Card asChild className="p-5"> |
| 797 | | − | <section> |
| 798 | | − | <h2 className="text-base font-semibold tracking-tight">This week</h2> |
| 799 | | − | <div className="mt-4">{loaded.perRepoLoaded ? <WeekChart week={week} /> : <Unavailable what="The week" />}</div> |
| 800 | | − | </section> |
| 801 | | − | </Card> |
| 802 | | − | |
| 803 | | − | <Card asChild className="scroll-mt-[calc(var(--topbar-h)+1rem)] p-5"> |
| 804 | | − | <section id="activity"> |
| 805 | | − | <div className="flex items-center justify-between"> |
| 806 | | − | <h2 className="text-base font-semibold tracking-tight">Activity</h2> |
| 807 | | − | {groups.length > 8 && ( |
| 808 | | − | <Link |
| 809 | | − | to={`${link({ activity: everyActivity ? null : "all" })}#activity`} |
| 810 | | − | preventScrollReset |
| 811 | | − | className="inline-flex items-center gap-0.5 text-xs text-muted hover:text-fg" |
| 812 | | − | > |
| 813 | | − | {everyActivity ? "Less" : "All activity"} <ChevronRight size={12} /> |
| 814 | | − | </Link> |
| 815 | | − | )} |
| 816 | | − | </div> |
| 817 | | − | {!loaded.perRepoLoaded ? ( |
| 818 | | − | <div className="mt-3"> |
| 819 | | − | <Unavailable what="Activity" /> |
| 820 | | − | </div> |
| 821 | | − | ) : feed.length === 0 ? ( |
| 822 | | − | <p className="mt-3 text-sm leading-6 text-muted">Nothing has moved yet. Merges, deploys, checks and reviews show up here.</p> |
| 823 | | − | ) : ( |
| 824 | | − | <ol className="mt-2 divide-y divide-line/70"> |
| 825 | | − | {feed.map((group) => ( |
| 826 | | − | <FeedLine key={group.id} group={group} titles={titles} /> |
| 827 | | − | ))} |
| 828 | | − | </ol> |
| 829 | | − | )} |
| 830 | | − | </section> |
| 831 | | − | </Card> |
| 832 | | − | </aside> |
| 833 | | − | </div> |
| 834 | | − | </main> |
| 835 | | − | ); |
| 836 | | − | } |