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