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.

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