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.

484 linesCodeBlameRaw
1import { ChevronRight, Inbox, ListOrdered, Plus } from "lucide-react";
2import { type DragEvent, useEffect, useMemo, useState } from "react";
3import { Link, redirect, useFetcher } from "react-router";
4
5import type { PlanGroup, WorkItem } from "@g1t/contracts";
6
7import type { Route } from "./+types/plan";
8import { CapBar, PlanRow, PlanRowMenu, type RowDrag } from "../../../components/plan";
9import { ButtonLink } from "../../../components/ui";
10import { Alert } from "../../../components/ui/alert";
11import { Button } from "../../../components/ui/button";
12import { Card } from "../../../components/ui/card";
13import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../components/ui/collapsible";
14import { cn } from "../../../lib/cn";
15import { page } from "../../../lib/meta";
16import { isFailed, part } from "../../../lib/partial";
17import {
18 type Move,
19 type OnIt,
20 type PlanView,
21 applyMove,
22 blockersToRead,
23 capState,
24 dropPosition,
25 listRedirect,
26 moreText,
27 moveForm,
28 moveFrom,
29 onItems,
30 planInput,
31 planPath,
32 setAsidePath,
33 triagePath,
34 waitingOn,
35} from "../../../lib/plan";
36import { useRefreshWhile } from "../../../lib/refresh";
37import { workPages } from "../../../lib/repo-live";
38import { work } from "../../../lib/services.server";
39import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server";
40import { useWorkspaceLive } from "../../../lib/use-repo-live";
41import { canItem } from "../../../lib/work-access";
42import { newItemPath, shortPath, workPath } from "../../../lib/work-items";
43
44export function meta({ params, ...args }: Route.MetaArgs) {
45 return page(args, { title: `Plan · Work · ${params.owner} · g1t` });
46}
47
48export async function loader({ params, context, request }: Route.LoaderArgs) {
49 const viewer = getViewer(context);
50 const workspace = params.owner.toLowerCase();
51 const query = new URL(request.url).searchParams;
52 // `-/work` with the list's filters is an old link to the list.
53 const moved = listRedirect(workspace, query);
54 if (moved) throw redirect(moved);
55 const all = query.get("all") === "1";
56 const member = roleIn(viewer, workspace) != null;
57 // The plan is what the page is; who is on each item is a part each.
58 const [plan, claims, live] = await Promise.all([
59 work.getPlan(workspace, viewer, { all }),
60 part(work.listClaims(workspace, viewer, { state: "active" }), "Claims"),
61 part(work.listWorkspaceSessions(workspace, viewer, { shown: "live", sessionsOnly: true, limit: 200 }), "Live sessions"),
62 ]);
63 const { workspace: _, ...shown } = unwrap(plan);
64 const rows = [...shown.now, ...shown.next];
65 // What each waits on: the open ones in the plan are known; the rest are looked up, a few at most.
66 const open = new Set(rows.map((item) => item.number));
67 const looked = await Promise.all(
68 blockersToRead(rows, open).map((number) =>
69 work
70 .getWorkItem(workspace, number, viewer)
71 .then((found) => (found.ok && found.value.item.state === "closed" ? number : null))
72 .catch(() => null),
73 ),
74 );
75 const sessions = isFailed(live) || !live.ok ? [] : live.value.sessions;
76 return {
77 workspace,
78 member,
79 signedIn: viewer != null,
80 all,
81 plan: shown,
82 triageTotal: shown.triageTotal,
83 on: onItems(rows, isFailed(claims) || !claims.ok ? [] : claims.value, sessions),
84 closedBlockers: looked.filter((number): number is number => number != null),
85 // Anyone with Triage places work and ranks Next and Later; Now takes
86 // the right to manage milestones, which the work service checks again.
87 canPlan: canItem(viewer, workspace, null, "triage"),
88 canNow: canItem(viewer, workspace, null, "manage_labels"),
89 canOpen: viewer != null && member,
90 unavailable: [claims, live].filter(isFailed).map((failed) => failed.what),
91 live: sessions.length,
92 };
93}
94
95export async function action({ request, params, context }: Route.ActionArgs) {
96 assertSameOrigin(request);
97 const user = requireUser(context, request);
98 const workspace = params.owner.toLowerCase();
99 const form = await request.formData();
100 const number = Number(form.get("number"));
101 if (!Number.isInteger(number) || number < 1) return { error: "Choose a work item to move.", number: null };
102 const input = planInput(form);
103 if (typeof input === "string") return { error: input, number };
104 const result = await work.planWorkItem(user, workspace, number, input);
105 return result.ok ? { error: null, number } : { error: result.error.message, number };
106}
107
108/** A list a row can be dropped into. */
109type Lane = "now" | "next" | "later";
110
111type Dragging = { item: WorkItem; from: Lane };
112type Over = { lane: Lane; index: number };
113
114/**
115 * Work's Plan: Now, what the workspace has taken on, against its cap;
116 * Next, ranked, the top taken first; Later, grouped by repository; and how
117 * many are set aside. Rows move by dragging within and between Now and
118 * Next (and onto Later), or from each row's menu, which is how a keyboard
119 * or a phone moves them.
120 */
121export default function PlanPage({ loaderData }: Route.ComponentProps) {
122 const { workspace, member, canPlan, canNow, on, all } = loaderData;
123 const fetcher = useFetcher<typeof action>();
124 useWorkspaceLive({ workspace, member, match: workPages.list() });
125 // Sessions go idle and end with no event to say so.
126 useRefreshWhile(loaderData.live > 0, 30_000);
127
128 // What a move from a Later group brings with it, to show it where it lands before the answer.
129 const [carried, setCarried] = useState<WorkItem | null>(null);
130 const pending = fetcher.formData ? moveFrom(fetcher.formData) : null;
131 const plan: PlanView = pending ? applyMove(loaderData.plan, pending, carried?.number === pending.number ? carried : undefined) : loaderData.plan;
132 const closed = useMemo(() => new Set(loaderData.closedBlockers), [loaderData.closedBlockers]);
133 const [error, setError] = useState<string | null>(null);
134 useEffect(() => {
135 if (fetcher.state === "idle" && fetcher.data) setError(fetcher.data.error);
136 }, [fetcher.state, fetcher.data]);
137
138 const submit = (move: Move, item?: WorkItem) => {
139 setError(null);
140 setCarried(item ?? null);
141 fetcher.submit(moveForm(move), { method: "post" });
142 };
143
144 // Dragging: what is held, and the gap it would land in.
145 const [dragging, setDragging] = useState<Dragging | null>(null);
146 const [over, setOver] = useState<Over | null>(null);
147 const lists: Record<"now" | "next", number[]> = { now: plan.now.map((item) => item.number), next: plan.next.map((item) => item.number) };
148 const accepts = (lane: Lane) => {
149 if (!dragging) return false;
150 // Into or out of Now, or within it, takes the right to manage milestones.
151 if ((lane === "now" || dragging.from === "now") && !canNow) return false;
152 return !(lane === "later" && dragging.from === "later");
153 };
154 const drop = (lane: Lane, index: number) => {
155 const held = dragging;
156 setDragging(null);
157 setOver(null);
158 if (!held || !accepts(lane)) return;
159 if (lane === "later") {
160 submit({ number: held.item.number, horizon: "later" }, held.item);
161 return;
162 }
163 const position = dropPosition(lists[lane], index, held.item.number);
164 if (!position) return;
165 submit({ number: held.item.number, horizon: lane, ...position }, held.item);
166 };
167 const rowDrag = (lane: Lane, item: WorkItem, index: number): RowDrag | undefined => {
168 if (!canPlan) return undefined;
169 const draggable = item.horizon !== "now" || canNow;
170 return {
171 draggable,
172 dragging: dragging?.item.number === item.number,
173 edge:
174 over?.lane === lane && lane !== "later"
175 ? over.index === index
176 ? "above"
177 : over.index === index + 1 && index === lists[lane].length - 1
178 ? "below"
179 : null
180 : null,
181 onDragStart: (event) => {
182 event.dataTransfer.effectAllowed = "move";
183 event.dataTransfer.setData("text/plain", `#${item.number}`);
184 setDragging({ item, from: lane });
185 },
186 onDragEnd: () => {
187 setDragging(null);
188 setOver(null);
189 },
190 onDragOver: (event) => {
191 if (!accepts(lane)) return;
192 event.preventDefault();
193 event.stopPropagation();
194 if (lane === "later") return setOver({ lane, index: 0 });
195 const box = event.currentTarget.getBoundingClientRect();
196 const at = index + (event.clientY > box.top + box.height / 2 ? 1 : 0);
197 if (over?.lane !== lane || over.index !== at) setOver({ lane, index: at });
198 },
199 onDrop: (event) => {
200 event.preventDefault();
201 event.stopPropagation();
202 drop(lane, over?.lane === lane ? over.index : index);
203 },
204 };
205 };
206 const laneDrop = (lane: Lane) => ({
207 onDragOver: (event: DragEvent<HTMLElement>) => {
208 if (!accepts(lane)) return;
209 event.preventDefault();
210 const end = lane === "later" ? 0 : lists[lane].length;
211 if (over?.lane !== lane) setOver({ lane, index: end });
212 },
213 onDragLeave: (event: DragEvent<HTMLElement>) => {
214 if (!event.currentTarget.contains(event.relatedTarget as Node | null) && over?.lane === lane) setOver(null);
215 },
216 onDrop: (event: DragEvent<HTMLElement>) => {
217 event.preventDefault();
218 drop(lane, over?.lane === lane ? over.index : lane === "later" ? 0 : lists[lane].length);
219 },
220 });
221
222 const cap = capState(plan.now.length, loaderData.plan.nowCap);
223 const nextMore = moreText(plan.next.length, plan.nextTotal);
224 const row = (lane: "now" | "next", item: WorkItem, index: number, rank: number) => (
225 <PlanRow
226 key={item.id}
227 item={item}
228 rank={rank}
229 workspace={workspace}
230 on={on[item.number] as OnIt | undefined}
231 waiting={waitingOn(item, closed)}
232 drag={rowDrag(lane, item, index)}
233 menu={canPlan ? <PlanRowMenu item={item} list={lists[lane]} canNow={canNow} onMove={(move) => submit(move, item)} /> : undefined}
234 />
235 );
236
237 return (
238 <div>
239 <div className="flex flex-wrap items-center justify-between gap-3">
240 <div className="min-w-0">
241 <h1 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
242 <ListOrdered size={17} className="text-accent" />
243 Plan
244 </h1>
245 <p className="mt-1 text-sm text-muted">
246 Now is what the workspace has taken on. Next is ranked, and the top is taken first. Later is wanted, and waits.
247 {!canPlan && loaderData.signedIn && " You can read the plan; placing work takes the Triage role."}
248 </p>
249 </div>
250 {loaderData.canOpen && (
251 <ButtonLink to={newItemPath(workspace)} className="max-sm:h-10">
252 <Plus size={15} />
253 New work item
254 </ButtonLink>
255 )}
256 </div>
257
258 {loaderData.unavailable.length > 0 && (
259 <p className="mt-3 text-xs text-warn">{loaderData.unavailable.join(" and ")} could not be loaded just now, so who is on each item may be missing.</p>
260 )}
261 {error && (
262 <Alert role="alert" className="mt-3 flex items-start justify-between gap-3">
263 <span>{error}</span>
264 <Button variant="ghost" size="xs" onClick={() => setError(null)} className="-my-1 text-danger">
265 Dismiss
266 </Button>
267 </Alert>
268 )}
269 {plan.triageTotal > 0 && (
270 <Link
271 to={triagePath(workspace)}
272 className="mt-4 flex items-center gap-2 rounded-lg border border-line px-3 py-2 text-sm text-muted transition-colors hover:border-line-strong hover:text-fg"
273 >
274 <Inbox size={15} className="shrink-0 text-accent" />
275 <span className="min-w-0 grow">
276 {plan.triageTotal === 1 ? "1 new work item waits" : `${plan.triageTotal.toLocaleString("en-US")} new work items wait`} in Triage to be placed.
277 </span>
278 <ChevronRight size={15} className="shrink-0" />
279 </Link>
280 )}
281
282 <div className="mt-5 grid gap-4 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
283 <Card className="min-w-0 self-start">
284 <section
285 aria-labelledby="plan-now"
286 {...laneDrop("now")}
287 className={cn("rounded-t-xl", over?.lane === "now" && plan.now.length === 0 && "bg-accent/5")}
288 >
289 <header className="flex flex-wrap items-center gap-x-2 gap-y-1 px-4 pt-3 pb-2">
290 <h2 id="plan-now" className="text-sm font-semibold">
291 Now
292 </h2>
293 <span className="text-xs text-faint">what the workspace is on</span>
294 <span className="ml-auto">
295 <CapBar cap={cap} />
296 </span>
297 </header>
298 {cap.over && <p className="px-4 pb-2 text-xs text-warn">Now is past its cap of {cap.cap}. Finish something, or move one back to Next.</p>}
299 {plan.now.length === 0 ? (
300 <p className="mx-4 mb-3 rounded-lg border border-dashed border-line px-3 py-4 text-center text-xs text-faint">
301 {canNow ? "Nothing in Now. Move what you're taking on from Next." : "Nothing in Now yet."}
302 </p>
303 ) : (
304 <ol className="px-2 pb-2">{plan.now.map((item, index) => row("now", item, index, index + 1))}</ol>
305 )}
306 </section>
307 <section
308 aria-labelledby="plan-next"
309 {...laneDrop("next")}
310 className={cn("border-t border-line", over?.lane === "next" && plan.next.length === 0 && "bg-accent/5")}
311 >
312 <header className="flex flex-wrap items-center gap-x-2 gap-y-1 px-4 pt-3 pb-2">
313 <h2 id="plan-next" className="text-sm font-semibold">
314 Next
315 </h2>
316 <span className="text-xs text-faint">{canPlan && <span className="max-sm:hidden">drag to rank · </span>}the top is taken first</span>
317 <span className="ml-auto font-mono text-xs tabular-nums text-faint">{plan.nextTotal}</span>
318 </header>
319 {plan.next.length === 0 ? (
320 <p className="mx-4 mb-3 rounded-lg border border-dashed border-line px-3 py-4 text-center text-xs text-faint">
321 {canPlan ? "Nothing in Next. Place work here from Triage or Later, in the order it should be taken." : "Nothing in Next yet."}
322 </p>
323 ) : (
324 <ol className="px-2 pb-2">{plan.next.map((item, index) => row("next", item, index, plan.now.length + index + 1))}</ol>
325 )}
326 {nextMore && !all && (
327 <div className="px-4 pb-3">
328 <ButtonLink to={planPath(workspace, "all=1")} variant="outline" size="sm" className="w-full border-dashed" preventScrollReset>
329 Show all {plan.nextTotal.toLocaleString("en-US")}
330 </ButtonLink>
331 </div>
332 )}
333 {all && plan.nextTotal > 10 && (
334 <div className="px-4 pb-3">
335 <ButtonLink to={planPath(workspace)} variant="ghost" size="sm" className="w-full" preventScrollReset>
336 Show the top 10
337 </ButtonLink>
338 </div>
339 )}
340 </section>
341 </Card>
342
343 <div className="grid min-w-0 content-start gap-4">
344 <Card asChild className={cn("min-w-0", over?.lane === "later" && "border-accent/60 bg-accent/5")}>
345 <section aria-labelledby="plan-later" {...laneDrop("later")}>
346 <header className="flex flex-wrap items-center gap-x-2 gap-y-1 px-4 pt-3 pb-2">
347 <h2 id="plan-later" className="text-sm font-semibold">
348 Later
349 </h2>
350 <span className="text-xs text-faint">by repository</span>
351 <span className="ml-auto font-mono text-xs tabular-nums text-faint">{plan.laterTotal}</span>
352 </header>
353 {plan.later.length === 0 ? (
354 <p className="mx-4 mb-3 rounded-lg border border-dashed border-line px-3 py-4 text-center text-xs text-faint">
355 {canPlan ? "Nothing in Later. Drop what you want but won't take soon here." : "Nothing in Later."}
356 </p>
357 ) : (
358 <ul className="px-2 pb-2">
359 {plan.later.map((group) => (
360 <LaterGroup
361 key={group.repo ?? ""}
362 group={group}
363 workspace={workspace}
364 canPlan={canPlan}
365 canNow={canNow}
366 hidden={pending?.number ?? null}
367 onMove={submit}
368 onDrag={(item) => setDragging(item ? { item, from: "later" } : null)}
369 />
370 ))}
371 </ul>
372 )}
373 </section>
374 </Card>
375 <Card className="flex items-center gap-3 px-4 py-3">
376 <h2 className="text-sm font-semibold">Set aside</h2>
377 {plan.setAsideTotal > 0 ? (
378 <Link to={setAsidePath(workspace)} className="ml-auto text-sm text-muted hover:text-fg">
379 {plan.setAsideTotal.toLocaleString("en-US")} <span className="text-faint">in All items</span>
380 </Link>
381 ) : (
382 <span className="ml-auto text-sm text-faint">None</span>
383 )}
384 </Card>
385 <p className="px-1 text-xs text-faint">
386 Set aside keeps what nobody means to do soon out of every view but search. Nothing is deleted: move one back from its page.
387 </p>
388 </div>
389 </div>
390 </div>
391 );
392}
393
394/**
395 * One repository's Later, collapsed to its count; opened, its items (at
396 * most 100), read when it opens, each with its menu, and draggable to Now
397 * or Next.
398 */
399function LaterGroup({
400 group,
401 workspace,
402 canPlan,
403 canNow,
404 hidden,
405 onMove,
406 onDrag,
407}: {
408 group: PlanGroup;
409 workspace: string;
410 canPlan: boolean;
411 canNow: boolean;
412 /** One being moved just now, left out until the plan says where it went. */
413 hidden: number | null;
414 onMove: (move: Move, item: WorkItem) => void;
415 onDrag: (item: WorkItem | null) => void;
416}) {
417 const [open, setOpen] = useState(false);
418 const fetcher = useFetcher<{ items: WorkItem[]; more: boolean } | { error: string }>();
419 const url = `${workPath(workspace, "plan/later")}?${new URLSearchParams({ repo: group.repo ?? "" }).toString()}`;
420 // Read when it opens, and again when its count changes while it is open.
421 useEffect(() => {
422 if (open) fetcher.load(url);
423 // eslint-disable-next-line react-hooks/exhaustive-deps
424 }, [open, url, group.count]);
425 const data = fetcher.data;
426 const items = data && "items" in data ? data.items.filter((item) => item.number !== hidden) : null;
427 const name = group.repo ? shortPath(group.repo, workspace) : "Filed in the workspace";
428 return (
429 <li>
430 <Collapsible open={open} onOpenChange={setOpen}>
431 <CollapsibleTrigger className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left hover:bg-raised/60">
432 <ChevronRight size={14} className="shrink-0" />
433 <span className={cn("min-w-0 grow truncate", group.repo ? "font-mono text-[0.8125rem]" : "text-sm")}>{name}</span>
434 <span className="font-mono text-xs tabular-nums text-faint">{group.count}</span>
435 </CollapsibleTrigger>
436 <CollapsibleContent>
437 {items == null ? (
438 data && "error" in data ? (
439 <p className="px-3 py-2 text-xs text-danger">{data.error}</p>
440 ) : (
441 <p className="px-3 py-2 text-xs text-faint">Loading…</p>
442 )
443 ) : items.length === 0 ? (
444 <p className="px-3 py-2 text-xs text-faint">Nothing here now.</p>
445 ) : (
446 <ul className="pl-4">
447 {items.map((item) => (
448 <PlanRow
449 key={item.id}
450 item={item}
451 rank={null}
452 workspace={workspace}
453 on={undefined}
454 waiting={[]}
455 drag={
456 canPlan
457 ? {
458 draggable: true,
459 dragging: false,
460 edge: null,
461 onDragStart: (event) => {
462 event.dataTransfer.effectAllowed = "move";
463 event.dataTransfer.setData("text/plain", `#${item.number}`);
464 onDrag(item);
465 },
466 onDragEnd: () => onDrag(null),
467 onDragOver: () => {},
468 onDrop: () => {},
469 }
470 : undefined
471 }
472 menu={canPlan ? <PlanRowMenu item={item} list={null} canNow={canNow} onMove={(move) => onMove(move, item)} /> : undefined}
473 />
474 ))}
475 </ul>
476 )}
477 {data && "more" in data && data.more && (
478 <p className="px-3 pb-2 text-xs text-faint">Showing the first 100 of {group.count.toLocaleString("en-US")}.</p>
479 )}
480 </CollapsibleContent>
481 </Collapsible>
482 </li>
483 );
484}