Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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