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.

836 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("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 */
118function 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
144function 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. */
149function 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
211function 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
279function 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
330function List({ children }: { children: ReactNode }) {
331 return <ul className="divide-y divide-line">{children}</ul>;
332}
333
334function 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
345function 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. */
363function 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
459const 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. */
475function 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
514const TAB_LABEL: Record<Tab, string> = { needs: "Needs you", landed: "Landed today" };
515const TAB_SHORT: Record<Tab, string> = { needs: "Needs you", landed: "Today" };
516const SORT_LABEL: Record<Sort, string> = { impact: "By impact", newest: "Newest" };
517
518export 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}/-/billing`,
557 open: `/${workspace}/-/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 project?</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 project
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="Projects"
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-20 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 project
733 </Link>
734 }
735 >
736 <p className="font-medium text-fg">No projects 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-20 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}