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