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
854 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};
483
484/** One line of the feed: who, what, and the one thing it was about, by name. */
485function FeedLine({ group, titles }: { group: ActivityGroup; titles: Record<string, string> }) {
486 const part = group.parts[0];
487 const base = `/${group.repo.namespace}/${group.repo.name}`;
488 const actor = group.actor ?? "g1t";
489 const agent = group.actor == null || isAgent(group.actor);
490 const number = part.numbers[0];
491 const title = number != null ? titles[`${group.repo.namespace}/${group.repo.name}#${number}`.toLowerCase()] : undefined;
492 const more = group.parts.length - 1 + Math.max(0, part.numbers.length - 1);
493 return (
494 <li className="flex gap-2.5 py-2">
495 <span className="mt-px">{agent ? <Avatar name={actor} image={null} size={22} /> : <UserAvatar username={actor} size={22} />}</span>
496 <span className="min-w-0 grow text-[0.8125rem] leading-5 text-muted">
497 {agent ? (
498 <span className="font-medium text-accent">{actor}</span>
499 ) : (
500 <PersonLink name={actor} className="font-medium text-fg hover:text-accent" />
501 )}{" "}
502 {VERB[part.verb]}{" "}
503 {number != null ? (
504 <Link to={`${base}/issues/${number}`} prefetch="intent" className="text-fg-soft hover:text-fg">
505 <span className="font-mono text-xs">#{number}</span>
506 {title && <> {title}</>}
507 </Link>
508 ) : part.to ? (
509 <Link to={part.to} className="text-fg-soft hover:text-fg">
510 {group.repo.name}
511 </Link>
512 ) : null}
513 {more > 0 && <span className="text-faint"> and {more} more</span>}
514 <span className="block text-xs text-faint">
515 <span className="font-mono">{group.repo.name}</span> · <TimeAgo at={group.at} />
516 </span>
517 </span>
518 </li>
519 );
520}
521
522// --- The page -----------------------------------------------------------------------
523
524const TAB_LABEL: Record<Tab, string> = { needs: "Needs you", landed: "Landed today" };
525const TAB_SHORT: Record<Tab, string> = { needs: "Needs you", landed: "Today" };
526const SORT_LABEL: Record<Sort, string> = { impact: "By impact", newest: "Newest" };
527
528export default function MissionControl({ loaderData }: { loaderData: Loaded }) {
529 const { pathname } = useLocation();
530 const shell = useRouteLoaderData("root")?.shell as ShellData | null | undefined;
531 const loaded: Loaded = loaderData;
532 const [params] = useSearchParams();
533 const [date, setDate] = useState(loaded.date);
534 useEffect(() => {
535 // The date follows the viewer's own clock, and the server learns their zone for next time.
536 try {
537 const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
538 setDate(dateLine(Date.now(), zone ?? null));
539 if (zone) document.cookie = `${TZ_COOKIE}=${encodeURIComponent(zone)}; Path=/; Max-Age=31536000; SameSite=Lax`;
540 } catch {
541 // Nothing to remember.
542 }
543 }, []);
544
545 const { viewer, repos, landed, week, groups, titles, stats } = loaded;
546 const workspace = shell?.workspace?.slug ?? loaded.workspace ?? viewer.workspaces?.[0]?.slug ?? null;
547
548 // The usage limit, as the sidebar knows it, heads the list when it bites.
549 const limit = shell?.limit && !shell.limit.comped && shell.limit.state !== "ok" && workspace ? shell.limit : null;
550 const limitRow: NeedRow[] = limit
551 ? [
552 (() => {
553 const detail =
554 limit.state === "stopped"
555 ? "Paid usage is paused until the limit is raised or a card is added."
556 : `${dollars(limit.exposureMicros)} of ${dollars(limit.ceilingMicros ?? 0)} used. Add a card so work keeps going.`;
557 return {
558 key: "limit",
559 reason: "blocking" as const,
560 repo: null,
561 ref: null,
562 title: limit.state === "stopped" ? "The usage limit was reached" : "Usage is nearing its limit",
563 ask: detail,
564 by: null,
565 at: Date.now(),
566 to: `/${workspace}/-/billing`,
567 open: `/${workspace}/-/billing`,
568 facts: [
569 { label: "Used", value: `${dollars(limit.exposureMicros)} of ${dollars(limit.ceilingMicros ?? 0)}`, tone: "warn" as const },
570 ],
571 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.",
572 quick: null,
573 link: null,
574 };
575 })(),
576 ]
577 : [];
578 const needs = [...limitRow, ...loaded.needs];
579 const blocking = needs.filter((row) => BLOCKING.has(row.reason)).length;
580
581 const counts: Record<Tab, number> = { needs: needs.length, landed: landed.length };
582 const tab = parseTab(params.get("tab")) ?? "needs";
583 const sort = parseSort(params.get("sort"));
584 const all = params.get("all") === "1";
585 const everyActivity = params.get("activity") === "all";
586 const link = (changes: Record<string, string | null>) => {
587 const next = new URLSearchParams(params);
588 for (const [key, value] of Object.entries(changes)) {
589 if (value == null) next.delete(key);
590 else next.set(key, value);
591 }
592 const text = next.toString();
593 return text ? `?${text}` : pathname;
594 };
595
596 const noProjects = repos.length === 0;
597
598 const rows = tab === "needs" ? sortRows(needs, sort) : sortRows(landed, sort);
599 const shown = all ? rows : rows.slice(0, ROWS);
600 const delta = change(week.total, week.previous);
601 const feed = everyActivity ? groups : groups.slice(0, 8);
602
603 const newIssue =
604 repos.length > 0 ? (
605 <DropdownMenu>
606 <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">
607 <Plus size={14} /> New issue
608 </DropdownMenuTrigger>
609 <DropdownMenuContent align="start" className="max-h-80 overflow-y-auto">
610 <DropdownMenuLabel>In which project?</DropdownMenuLabel>
611 {repos.map((repo) => (
612 <DropdownMenuItem key={repo.name} asChild>
613 <Link to={`/${repo.namespace}/${repo.name}/issues/new`}>
614 <Avatar name={repo.name} size={18} square />
615 {repo.name}
616 </Link>
617 </DropdownMenuItem>
618 ))}
619 </DropdownMenuContent>
620 </DropdownMenu>
621 ) : (
622 <Link
623 to={"/new"}
624 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"
625 >
626 <Plus size={14} /> New project
627 </Link>
628 );
629
630 return (
631 <main className="mx-auto max-w-7xl space-y-6 px-4 py-8 sm:px-6 sm:py-10">
632 <header className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4">
633 <div className="min-w-0">
634 <h1 className="text-2xl leading-tight font-semibold tracking-tight">Overview</h1>
635 <p className="mt-1.5 text-sm text-muted" suppressHydrationWarning>
636 <span className="text-fg-soft">{date}</span> · {loaded.summary}
637 </p>
638 </div>
639 </header>
640
641 <section aria-label="Start work" className="flex flex-wrap items-center gap-2">
642 {newIssue}
643 {needs.length > 0 && (
644 <Link
645 to={`${link({ tab: "needs", all: null })}#work`}
646 preventScrollReset
647 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"
648 >
649 Review {needs.length} that need{needs.length === 1 ? "s" : ""} you <ArrowRight size={14} />
650 </Link>
651 )}
652 </section>
653
654 <Card asChild tone="plain" className="grid grid-cols-2 gap-px overflow-hidden bg-line lg:grid-cols-4">
655 <section aria-label="At a glance">
656 <Stat
657 label="Projects"
658 value={stats.projects == null ? "—" : String(stats.projects)}
659 hint={
660 stats.projectsThisMonth == null
661 ? "in this workspace"
662 : stats.projectsThisMonth > 0
663 ? `+${stats.projectsThisMonth} this month`
664 : "none new this month"
665 }
666 />
667 <Stat label="Open pull requests" value={stats.openPulls == null ? "—" : String(stats.openPulls)} hint="drafts included" />
668 <Stat
669 label="Changes this week"
670 value={loaded.perRepoLoaded ? String(week.total) : "—"}
671 hint={delta != null ? `${signedPercent(delta)} vs last week` : week.previous === 0 ? "none the week before" : "merges and direct pushes"}
672 />
673 <Stat
674 label="Needs you"
675 dot="bg-warn"
676 value={String(needs.length)}
677 hint={blocking > 0 ? `${blocking} blocking` : needs.length > 0 ? "nothing blocking" : "all clear"}
678 />
679 </section>
680 </Card>
681
682 <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] xl:grid-cols-[minmax(0,1fr)_23rem]">
683 <Card asChild className="min-w-0 scroll-mt-20 self-start overflow-hidden">
684 <section id="work">
685 <div className="flex items-center gap-2 border-b border-line px-2 sm:px-3">
686 <nav className="-mb-px flex min-w-0 grow gap-1 overflow-x-auto scrollbar-none" aria-label="Overview">
687 {(["needs", "landed"] as const).map((value) => (
688 <Link
689 key={value}
690 to={link({ tab: value, all: null })}
691 preventScrollReset
692 aria-current={tab === value ? "page" : undefined}
693 className={cn(
694 "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",
695 tab === value ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg",
696 )}
697 >
698 <span className="sm:hidden">{TAB_SHORT[value]}</span>
699 <span className="hidden sm:inline">{TAB_LABEL[value]}</span>
700 <span
701 className={cn(
702 "rounded-full px-1.5 text-[0.6875rem] tabular-nums",
703 tab === value && value === "needs" && counts.needs > 0 ? "bg-warn/15 text-warn" : "bg-line text-muted",
704 )}
705 >
706 {counts[value]}
707 </span>
708 </Link>
709 ))}
710 </nav>
711 <DropdownMenu>
712 <DropdownMenuTrigger
713 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"
714 aria-label="Sort"
715 >
716 <ArrowDownWideNarrow size={14} />
717 <span className="hidden sm:inline">{SORT_LABEL[sort]}</span>
718 </DropdownMenuTrigger>
719 <DropdownMenuContent align="end">
720 {(["impact", "newest"] as const).map((value) => (
721 <DropdownMenuItem key={value} asChild>
722 <Link to={link({ sort: value === "impact" ? null : value })} preventScrollReset>
723 {sort === value ? <Check /> : <span className="size-4" />}
724 {SORT_LABEL[value]}
725 </Link>
726 </DropdownMenuItem>
727 ))}
728 </DropdownMenuContent>
729 </DropdownMenu>
730 </div>
731
732 {!loaded.perRepoLoaded ? (
733 <div className="p-4">
734 <Unavailable what={tab === "needs" ? "Some of what needs you" : "What landed"} />
735 </div>
736 ) : null}
737
738 {tab === "needs" &&
739 (needs.length === 0 ? (
740 noProjects ? (
741 <Empty
742 action={
743 <Link to={"/new"} className="rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg hover:bg-fg-hover">
744 Create or import a project
745 </Link>
746 }
747 >
748 <p className="font-medium text-fg">No projects yet</p>
749 <p className="mt-1">Create a repository or import one. Reviews, failed deploys and invitations for you show up here.</p>
750 </Empty>
751 ) : (
752 <>
753 <Empty>
754 <p className="font-medium text-fg">Nothing needs you</p>
755 <p className="mt-1">
756 {landed.length > 0 ? "Here's what landed today." : "Reviews, failed deploys and invitations show up here first."}
757 </p>
758 </Empty>
759 {landed.length > 0 && (
760 <div className="border-t border-line">
761 <List>
762 {landed.slice(0, ROWS).map((row) => (
763 <LandedCard key={row.key} row={row} first={false} />
764 ))}
765 </List>
766 </div>
767 )}
768 </>
769 )
770 ) : (
771 <List>
772 {(shown as NeedRow[]).map((row, index) => (
773 <NeedCard key={row.key} row={row} first={index === 0} />
774 ))}
775 </List>
776 ))}
777
778 {tab === "landed" &&
779 loaded.perRepoLoaded &&
780 (landed.length === 0 ? (
781 <Empty>
782 <p className="font-medium text-fg">Nothing has landed today yet</p>
783 <p className="mt-1">
784 {week.total > 0 ? `${plural(week.total, "change")} landed in the last 7 days.` : "Merged pull requests show up here the moment they land."}
785 </p>
786 </Empty>
787 ) : (
788 <List>
789 {(shown as LandedRow[]).map((row, index) => (
790 <LandedCard key={row.key} row={row} first={index === 0} />
791 ))}
792 </List>
793 ))}
794
795 {rows.length > ROWS && (
796 <div className="flex items-center justify-between border-t border-line px-4 py-2.5 text-xs text-muted sm:px-5">
797 <span>
798 Showing {shown.length} of {rows.length}
799 </span>
800 <Link
801 to={link({ all: all ? null : "1" })}
802 preventScrollReset
803 className="inline-flex items-center gap-1 font-medium text-fg-soft hover:text-fg"
804 >
805 {all ? "Show fewer" : "See all"} <ChevronRight size={12} />
806 </Link>
807 </div>
808 )}
809 </section>
810 </Card>
811
812 <aside className="min-w-0 space-y-6">
813 <InboxNeedsCard items={loaderData.inboxNeeds.items} total={loaderData.inboxNeeds.total} />
814 <Card asChild className="p-5">
815 <section>
816 <h2 className="text-base font-semibold tracking-tight">This week</h2>
817 <div className="mt-4">{loaded.perRepoLoaded ? <WeekChart week={week} /> : <Unavailable what="The week" />}</div>
818 </section>
819 </Card>
820
821 <Card asChild className="scroll-mt-20 p-5">
822 <section id="activity">
823 <div className="flex items-center justify-between">
824 <h2 className="text-base font-semibold tracking-tight">Activity</h2>
825 {groups.length > 8 && (
826 <Link
827 to={`${link({ activity: everyActivity ? null : "all" })}#activity`}
828 preventScrollReset
829 className="inline-flex items-center gap-0.5 text-xs text-muted hover:text-fg"
830 >
831 {everyActivity ? "Less" : "All activity"} <ChevronRight size={12} />
832 </Link>
833 )}
834 </div>
835 {!loaded.perRepoLoaded ? (
836 <div className="mt-3">
837 <Unavailable what="Activity" />
838 </div>
839 ) : feed.length === 0 ? (
840 <p className="mt-3 text-sm leading-6 text-muted">Nothing has moved yet. Merges, deploys, checks and reviews show up here.</p>
841 ) : (
842 <ol className="mt-2 divide-y divide-line/70">
843 {feed.map((group) => (
844 <FeedLine key={group.id} group={group} titles={titles} />
845 ))}
846 </ol>
847 )}
848 </section>
849 </Card>
850 </aside>
851 </div>
852 </main>
853 );
854}