| 1 | import { Bot, Columns3, GitMerge, Plus } from "lucide-react"; |
| 2 | import { Link } from "react-router"; |
| 3 | |
| 4 | import { LIST_PAGE } from "@g1t/contracts"; |
| 5 | |
| 6 | import type { Route } from "./+types/board"; |
| 7 | import { HarnessMark, StateDot } from "../../../components/sessions"; |
| 8 | import { ButtonLink, EmptyState } from "../../../components/ui"; |
| 9 | import { Avatar } from "../../../components/ui/avatar"; |
| 10 | import { Card } from "../../../components/ui/card"; |
| 11 | import { Hint } from "../../../components/ui/hint"; |
| 12 | import { RepoChips } from "../../../components/work-items"; |
| 13 | import { ItemIcon } from "../../../components/work-icons"; |
| 14 | import { |
| 15 | type Board, |
| 16 | type BoardCard, |
| 17 | DONE_DAYS, |
| 18 | ITEM_PAGES, |
| 19 | PULL_PAGES, |
| 20 | type ReadPull, |
| 21 | STAGES, |
| 22 | STAGE_INFO, |
| 23 | type Stage, |
| 24 | boardOf, |
| 25 | cappedNotes, |
| 26 | pullRepos, |
| 27 | pullsByItem, |
| 28 | readPages, |
| 29 | shownStage, |
| 30 | } from "../../../lib/board"; |
| 31 | import { cn } from "../../../lib/cn"; |
| 32 | import { page } from "../../../lib/meta"; |
| 33 | import { isFailed, part } from "../../../lib/partial"; |
| 34 | import { useRefreshWhile } from "../../../lib/refresh"; |
| 35 | import { workPages } from "../../../lib/repo-live"; |
| 36 | import { work } from "../../../lib/services.server"; |
| 37 | import { getViewer, roleIn, unwrap } from "../../../lib/session.server"; |
| 38 | import { onItemSessionTitle, runsIn, sessionHref } from "../../../lib/sessions"; |
| 39 | import { useWorkspaceLive } from "../../../lib/use-repo-live"; |
| 40 | import { itemPath, newItemPath, repoFilter, shortPath, workPath } from "../../../lib/work-items"; |
| 41 | |
| 42 | export function meta({ params, location, ...args }: Route.MetaArgs) { |
| 43 | const repo = new URLSearchParams(location.search).get("repo"); |
| 44 | return page({ location, ...args }, { title: `Board${repo ? ` · ${repo}` : ""} · Work · ${params.owner} · g1t` }); |
| 45 | } |
| 46 | |
| 47 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 48 | const viewer = getViewer(context); |
| 49 | const workspace = params.owner.toLowerCase(); |
| 50 | const query = new URL(request.url).searchParams; |
| 51 | const repoName = query.get("repo")?.trim() || null; |
| 52 | const repo = repoName ? repoFilter(workspace, repoName) : null; |
| 53 | const member = roleIn(viewer, workspace) != null; |
| 54 | const now = Date.now(); |
| 55 | const since = new Date(now - DONE_DAYS * 86_400_000).toISOString(); |
| 56 | // The open work items are what the board is, every page of them; what says where each stands, a part each. |
| 57 | const [open, closed, claims, live] = await Promise.all([ |
| 58 | readPages((before) => work.listWorkItems(workspace, viewer, { state: "open", repo: repo ?? undefined, before }).then(unwrap), LIST_PAGE, ITEM_PAGES), |
| 59 | part(work.listWorkItems(workspace, viewer, { state: "closed", repo: repo ?? undefined, closedSince: since }), "Done"), |
| 60 | part(work.listClaims(workspace, viewer, { state: "active", repo: repo ?? undefined }), "Claims"), |
| 61 | part(work.listWorkspaceSessions(workspace, viewer, { shown: "live", sessionsOnly: true, repo: repoName ?? undefined, limit: 200 }), "Live sessions"), |
| 62 | ]); |
| 63 | const sessions = isFailed(live) || !live.ok ? [] : live.value.sessions; |
| 64 | // The open and recently merged pull requests in every repository an open item is linked to. |
| 65 | const repos = pullRepos(open.rows); |
| 66 | const read = (path: string, state: "open" | "closed") => { |
| 67 | const [namespace = "", name = ""] = path.split("/"); |
| 68 | // Not found or refused is an answer: none the viewer can see there. |
| 69 | const page = (before: number | undefined) => |
| 70 | work |
| 71 | .listPulls({ namespace, name }, viewer, state, { before, mergedSince: state === "closed" ? since : undefined }) |
| 72 | .then((found) => (found.ok ? found.value : [])); |
| 73 | return part(readPages(page, LIST_PAGE, PULL_PAGES), `Pull requests in ${path}`); |
| 74 | }; |
| 75 | const pulls = await Promise.all(repos.map((path) => Promise.all([read(path, "open"), read(path, "closed")]))); |
| 76 | const readPulls: ReadPull[] = []; |
| 77 | const unread: string[] = []; |
| 78 | const capped: string[] = []; |
| 79 | repos.forEach((path, index) => { |
| 80 | const both = pulls[index]!; |
| 81 | if (both.some(isFailed)) unread.push(path); |
| 82 | for (const found of both) { |
| 83 | if (isFailed(found)) continue; |
| 84 | if (found.capped) capped.push(path); |
| 85 | readPulls.push(...found.rows.map((pull) => ({ ...pull, repo: path }))); |
| 86 | } |
| 87 | }); |
| 88 | const done = isFailed(closed) || !closed.ok ? [] : closed.value; |
| 89 | const board = boardOf([...open.rows, ...done], { claims: isFailed(claims) || !claims.ok ? [] : claims.value, sessions, pulls: pullsByItem(readPulls) }, now); |
| 90 | return { |
| 91 | workspace, |
| 92 | member, |
| 93 | signedIn: viewer != null, |
| 94 | repo: repoName, |
| 95 | board, |
| 96 | stage: shownStage(query.get("stage"), board), |
| 97 | // A part that could not be read: its items may be missing, or stand a column early, and the board says so. |
| 98 | unavailable: [ |
| 99 | ...[closed, claims, live].filter(isFailed).map((failed) => failed.what), |
| 100 | ...(unread.length === 0 ? [] : [unread.length === 1 ? `Pull requests in ${unread[0]}` : `Pull requests in ${unread.length} repositories`]), |
| 101 | ], |
| 102 | // What was read only in part, said plainly rather than left out silently. |
| 103 | capped: cappedNotes({ items: open.capped ? open.rows.length : null, done: done.length >= LIST_PAGE ? LIST_PAGE : null, pulls: [...new Set(capped)] }), |
| 104 | live: sessions.length, |
| 105 | }; |
| 106 | } |
| 107 | |
| 108 | /** Who is on a card: people's avatars, and the live sessions on it with what they run in. */ |
| 109 | function OnIt({ card, workspace }: { card: BoardCard; workspace: string }) { |
| 110 | const { people, sessions } = card.on; |
| 111 | if (people.length === 0 && sessions.length === 0) return null; |
| 112 | return ( |
| 113 | <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"> |
| 114 | {people.length > 0 && ( |
| 115 | <Hint label={people.join(", ")}> |
| 116 | <span className="flex -space-x-1.5"> |
| 117 | {people.slice(0, 3).map((name) => ( |
| 118 | <span key={name} className="rounded-full ring-2 ring-surface"> |
| 119 | <Avatar name={name} size={18} /> |
| 120 | </span> |
| 121 | ))} |
| 122 | </span> |
| 123 | </Hint> |
| 124 | )} |
| 125 | {people.length > 3 && <span className="text-[0.6875rem] text-faint">+{people.length - 3}</span>} |
| 126 | {sessions.slice(0, 2).map((session) => { |
| 127 | const to = sessionHref(workspace, session); |
| 128 | const label = ( |
| 129 | <span className="inline-flex min-w-0 items-center gap-1 text-[0.6875rem] text-muted"> |
| 130 | <StateDot state={session.state} /> |
| 131 | <HarnessMark session={session} size={11} /> |
| 132 | <span className="truncate">{session.actor ?? runsIn(session)}</span> |
| 133 | </span> |
| 134 | ); |
| 135 | return ( |
| 136 | <Hint key={session.id} label={`${onItemSessionTitle(session)} · ${runsIn(session)}`}> |
| 137 | {to ? ( |
| 138 | <Link to={to} className="min-w-0 hover:text-fg"> |
| 139 | {label} |
| 140 | </Link> |
| 141 | ) : ( |
| 142 | label |
| 143 | )} |
| 144 | </Hint> |
| 145 | ); |
| 146 | })} |
| 147 | {sessions.length > 2 && ( |
| 148 | <span className="inline-flex items-center gap-0.5 text-[0.6875rem] text-faint"> |
| 149 | <Bot size={11} />+{sessions.length - 2} |
| 150 | </span> |
| 151 | )} |
| 152 | </div> |
| 153 | ); |
| 154 | } |
| 155 | |
| 156 | function ItemCard({ card, workspace }: { card: BoardCard; workspace: string }) { |
| 157 | const { item } = card; |
| 158 | return ( |
| 159 | <li> |
| 160 | <Card className="flex flex-col gap-2 p-3 transition-colors hover:border-line-strong"> |
| 161 | <Link to={itemPath(workspace, item.number)} prefetch="intent" className="flex min-w-0 items-start gap-2 text-sm font-medium hover:text-accent"> |
| 162 | <span className="mt-0.5"> |
| 163 | <ItemIcon item={item} size={14} /> |
| 164 | </span> |
| 165 | <span className="min-w-0 wrap-break-word"> |
| 166 | {item.title} <span className="font-normal text-faint">#{item.number}</span> |
| 167 | </span> |
| 168 | </Link> |
| 169 | {(item.repos.length > 0 || card.merged > 0) && ( |
| 170 | <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"> |
| 171 | <RepoChips repos={item.repos} workspace={workspace} /> |
| 172 | {card.merged > 0 && ( |
| 173 | <Hint label={`${card.merged === 1 ? "A pull request" : `${card.merged} pull requests`} for it merged in the last ${DONE_DAYS} days`}> |
| 174 | <span className="inline-flex shrink-0 items-center gap-1 text-[0.6875rem] text-merged"> |
| 175 | <GitMerge size={11} /> |
| 176 | {card.merged} merged |
| 177 | </span> |
| 178 | </Hint> |
| 179 | )} |
| 180 | </div> |
| 181 | )} |
| 182 | <OnIt card={card} workspace={workspace} /> |
| 183 | </Card> |
| 184 | </li> |
| 185 | ); |
| 186 | } |
| 187 | |
| 188 | function Column({ stage, board, workspace }: { stage: Stage; board: Board; workspace: string }) { |
| 189 | const cards = board[stage]; |
| 190 | return ( |
| 191 | <section aria-label={STAGE_INFO[stage].label} className="flex min-w-0 flex-col"> |
| 192 | <h2 className="mb-2 flex items-center justify-between gap-2 px-1 text-xs font-medium text-muted"> |
| 193 | <Hint label={STAGE_INFO[stage].hint}> |
| 194 | <span>{STAGE_INFO[stage].label}</span> |
| 195 | </Hint> |
| 196 | <span className="tabular-nums text-faint">{cards.length}</span> |
| 197 | </h2> |
| 198 | {cards.length === 0 ? ( |
| 199 | <p className="rounded-lg border border-dashed border-line px-3 py-4 text-center text-xs text-faint">Nothing here</p> |
| 200 | ) : ( |
| 201 | <ol className="space-y-2"> |
| 202 | {cards.map((card) => ( |
| 203 | <ItemCard key={card.item.id} card={card} workspace={workspace} /> |
| 204 | ))} |
| 205 | </ol> |
| 206 | )} |
| 207 | </section> |
| 208 | ); |
| 209 | } |
| 210 | |
| 211 | /** |
| 212 | * Work's Board: the workspace's work items as they move, in columns read |
| 213 | * from what is so. On a phone, one column at a time, picked from a row of |
| 214 | * stages that wraps rather than scrolling sideways. |
| 215 | */ |
| 216 | export default function BoardPage({ loaderData }: Route.ComponentProps) { |
| 217 | const { workspace, board, stage, repo, member, signedIn, unavailable, capped, live } = loaderData; |
| 218 | const total = STAGES.reduce((sum, one) => sum + board[one].length, 0); |
| 219 | const query = (more: Record<string, string>) => `?${new URLSearchParams({ ...(repo ? { repo } : {}), ...more }).toString()}`; |
| 220 | useWorkspaceLive({ workspace, member, match: workPages.list() }); |
| 221 | // Sessions go idle and end with no event to say so. |
| 222 | useRefreshWhile(live > 0, 30_000); |
| 223 | return ( |
| 224 | <div> |
| 225 | <div className="flex flex-wrap items-center justify-between gap-3"> |
| 226 | <div className="min-w-0"> |
| 227 | <h1 className="flex items-center gap-2 text-lg font-semibold tracking-tight"> |
| 228 | <Columns3 size={17} className="text-accent" /> |
| 229 | Board |
| 230 | {repo && <span className="font-mono text-sm font-normal text-muted">{shortPath(repo, workspace)}</span>} |
| 231 | </h1> |
| 232 | <p className="mt-1 text-sm text-muted">Work items as they move: who is on each, what is in review, and what is landing where.</p> |
| 233 | </div> |
| 234 | {signedIn && (member || repo) && ( |
| 235 | <ButtonLink to={newItemPath(workspace, repo)} className="max-sm:h-10"> |
| 236 | <Plus size={15} /> |
| 237 | New work item |
| 238 | </ButtonLink> |
| 239 | )} |
| 240 | </div> |
| 241 | {unavailable.length > 0 && ( |
| 242 | <p className="mt-3 text-xs text-warn"> |
| 243 | {unavailable.join(", ")} could not be loaded just now, so this board is partial: some work items may be missing, or stand a column early. |
| 244 | </p> |
| 245 | )} |
| 246 | {capped.length > 0 && <p className="mt-2 text-xs text-muted">{capped.join(" ")}</p>} |
| 247 | |
| 248 | {total === 0 ? ( |
| 249 | <div className="mt-5"> |
| 250 | <EmptyState title={repo ? `No work items linked to ${shortPath(repo, workspace)}` : "No work items yet"}> |
| 251 | A work item moves across the board by itself: claimed, a session on it, a pull request opened, landed in its repositories, closed.{" "} |
| 252 | <Link to={workPath(workspace)} className="text-fg underline underline-offset-4"> |
| 253 | The Work list |
| 254 | </Link> |
| 255 | . |
| 256 | </EmptyState> |
| 257 | </div> |
| 258 | ) : ( |
| 259 | <> |
| 260 | {/* A phone: the stages as a wrapping row, one column under it. */} |
| 261 | <nav aria-label="Stages" className="mt-4 flex flex-wrap gap-1.5 md:hidden"> |
| 262 | {STAGES.map((one) => ( |
| 263 | <Link |
| 264 | key={one} |
| 265 | to={query({ stage: one })} |
| 266 | replace |
| 267 | preventScrollReset |
| 268 | aria-current={stage === one ? "page" : undefined} |
| 269 | className={cn( |
| 270 | "inline-flex h-9 items-center gap-1.5 rounded-full border px-3 text-sm transition-colors", |
| 271 | stage === one ? "border-accent/50 bg-accent/10 font-medium text-fg" : "border-line text-muted active:bg-raised", |
| 272 | )} |
| 273 | > |
| 274 | {STAGE_INFO[one].label} |
| 275 | <span className="text-xs tabular-nums text-faint">{board[one].length}</span> |
| 276 | </Link> |
| 277 | ))} |
| 278 | </nav> |
| 279 | <div className="mt-4 md:hidden"> |
| 280 | <Column stage={stage} board={board} workspace={workspace} /> |
| 281 | </div> |
| 282 | <div className="mt-5 hidden gap-3 md:grid md:grid-cols-5"> |
| 283 | {STAGES.map((one) => ( |
| 284 | <Column key={one} stage={one} board={board} workspace={workspace} /> |
| 285 | ))} |
| 286 | </div> |
| 287 | </> |
| 288 | )} |
| 289 | </div> |
| 290 | ); |
| 291 | } |