| 1 | import { ArrowDown, ArrowUp, ArrowUpToLine, Bot, CornerDownRight, GripVertical, MoreHorizontal } from "lucide-react"; |
| 2 | import type { DragEvent, ReactNode } from "react"; |
| 3 | import { Link } from "react-router"; |
| 4 | |
| 5 | import { type Appetite, type Horizon, type WorkItem, appetiteLetter, horizonLabel } from "@g1t/contracts"; |
| 6 | |
| 7 | import { HarnessMark, StateDot } from "./sessions"; |
| 8 | import { RepoChips } from "./work-items"; |
| 9 | import { Avatar } from "./ui/avatar"; |
| 10 | import { Badge } from "./ui/badge"; |
| 11 | import { Button } from "./ui/button"; |
| 12 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu"; |
| 13 | import { Hint } from "./ui/hint"; |
| 14 | import { Progress } from "./ui/progress"; |
| 15 | import { cn } from "../lib/cn"; |
| 16 | import { APPETITE_HINTS, type CapState, type Move, type OnIt, type Position, menuHorizons, moveDown, moveUp, waitingLabel } from "../lib/plan"; |
| 17 | import { onItemSessionTitle, runsIn, sessionHref } from "../lib/sessions"; |
| 18 | import { itemPath } from "../lib/work-items"; |
| 19 | |
| 20 | /** A work item's appetite as its letter in a small square, with what it means on hover. */ |
| 21 | export function AppetiteChip({ appetite }: { appetite: Appetite }) { |
| 22 | return ( |
| 23 | <Hint label={APPETITE_HINTS[appetite]}> |
| 24 | <Badge shape="tag" className="size-5 justify-center p-0 font-mono" aria-label={APPETITE_HINTS[appetite]}> |
| 25 | {appetiteLetter(appetite)} |
| 26 | </Badge> |
| 27 | </Hint> |
| 28 | ); |
| 29 | } |
| 30 | |
| 31 | /** Who is on a work item: people's avatars, and the live sessions on it with what they run in, as the board shows them. */ |
| 32 | export function OnItLine({ on, workspace }: { on: OnIt | undefined; workspace: string }) { |
| 33 | if (!on || (on.people.length === 0 && on.sessions.length === 0)) return null; |
| 34 | const { people, sessions } = on; |
| 35 | return ( |
| 36 | <span className="inline-flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"> |
| 37 | {people.length > 0 && ( |
| 38 | <Hint label={people.join(", ")}> |
| 39 | <span className="flex -space-x-1.5"> |
| 40 | {people.slice(0, 3).map((name) => ( |
| 41 | <span key={name} className="rounded-full ring-2 ring-surface"> |
| 42 | <Avatar name={name} size={16} /> |
| 43 | </span> |
| 44 | ))} |
| 45 | </span> |
| 46 | </Hint> |
| 47 | )} |
| 48 | {people.length > 3 && <span className="text-[0.6875rem] text-faint">+{people.length - 3}</span>} |
| 49 | {sessions.slice(0, 1).map((session) => { |
| 50 | const to = sessionHref(workspace, session); |
| 51 | const label = ( |
| 52 | <span className="inline-flex min-w-0 items-center gap-1 text-[0.6875rem] text-muted"> |
| 53 | <StateDot state={session.state} /> |
| 54 | <HarnessMark session={session} size={11} /> |
| 55 | <span className="truncate">{session.actor ?? runsIn(session)}</span> |
| 56 | </span> |
| 57 | ); |
| 58 | return ( |
| 59 | <Hint key={session.id} label={`${onItemSessionTitle(session)} · ${runsIn(session)}`}> |
| 60 | {to ? ( |
| 61 | <Link to={to} className="min-w-0 hover:text-fg"> |
| 62 | {label} |
| 63 | </Link> |
| 64 | ) : ( |
| 65 | label |
| 66 | )} |
| 67 | </Hint> |
| 68 | ); |
| 69 | })} |
| 70 | {sessions.length > 1 && ( |
| 71 | <span className="inline-flex items-center gap-0.5 text-[0.6875rem] text-faint"> |
| 72 | <Bot size={11} />+{sessions.length - 1} |
| 73 | </span> |
| 74 | )} |
| 75 | </span> |
| 76 | ); |
| 77 | } |
| 78 | |
| 79 | /** Now's count against its cap, and a bar that turns to the warning color past it. */ |
| 80 | export function CapBar({ cap }: { cap: CapState }) { |
| 81 | return ( |
| 82 | <div className="flex items-center gap-2"> |
| 83 | <Progress |
| 84 | value={cap.share * 100} |
| 85 | aria-label={`${cap.count} of ${cap.cap} in Now`} |
| 86 | className="h-1 w-16 sm:w-24" |
| 87 | indicatorClassName={cap.over ? "bg-warn" : undefined} |
| 88 | /> |
| 89 | <span className={cn("font-mono text-xs tabular-nums", cap.over ? "text-warn" : "text-faint")}> |
| 90 | {cap.count} of {cap.cap} |
| 91 | </span> |
| 92 | </div> |
| 93 | ); |
| 94 | } |
| 95 | |
| 96 | /** |
| 97 | * A row's menu: the keyboard's and a phone's way to make every move a drag |
| 98 | * makes. Up, down and to the top within the list it is in (`list`, the |
| 99 | * numbers shown, when it is ranked there), then to each other horizon. |
| 100 | */ |
| 101 | export function PlanRowMenu({ |
| 102 | item, |
| 103 | list, |
| 104 | canNow, |
| 105 | onMove, |
| 106 | }: { |
| 107 | item: WorkItem; |
| 108 | /** The ranked list it is in, as shown, top first; null where it is not ranked (Later). */ |
| 109 | list: readonly number[] | null; |
| 110 | canNow: boolean; |
| 111 | onMove: (move: Move) => void; |
| 112 | }) { |
| 113 | const rankable = list != null && (item.horizon !== "now" || canNow); |
| 114 | const move = (position: Position | null, horizon: Horizon = item.horizon) => position && onMove({ number: item.number, horizon, ...position }); |
| 115 | const up = list ? moveUp(list, item.number) : null; |
| 116 | const down = list ? moveDown(list, item.number) : null; |
| 117 | const elsewhere = menuHorizons(item.horizon, canNow); |
| 118 | if (!rankable && elsewhere.length === 0) return null; |
| 119 | return ( |
| 120 | <DropdownMenu> |
| 121 | <DropdownMenuTrigger asChild> |
| 122 | <Button variant="ghost" size="icon-xs" aria-label={`Move #${item.number}`} className="text-faint max-sm:size-9"> |
| 123 | <MoreHorizontal size={15} /> |
| 124 | </Button> |
| 125 | </DropdownMenuTrigger> |
| 126 | <DropdownMenuContent align="end"> |
| 127 | {rankable && ( |
| 128 | <> |
| 129 | <DropdownMenuItem disabled={!up} onSelect={() => move(up)}> |
| 130 | <ArrowUp /> |
| 131 | Move up |
| 132 | </DropdownMenuItem> |
| 133 | <DropdownMenuItem disabled={!down} onSelect={() => move(down)}> |
| 134 | <ArrowDown /> |
| 135 | Move down |
| 136 | </DropdownMenuItem> |
| 137 | <DropdownMenuItem disabled={!up} onSelect={() => move({ top: true })}> |
| 138 | <ArrowUpToLine /> |
| 139 | Move to top |
| 140 | </DropdownMenuItem> |
| 141 | {elsewhere.length > 0 && <DropdownMenuSeparator />} |
| 142 | </> |
| 143 | )} |
| 144 | {elsewhere.length > 0 && <DropdownMenuLabel>Move to</DropdownMenuLabel>} |
| 145 | {elsewhere.map((horizon) => ( |
| 146 | <DropdownMenuItem key={horizon} onSelect={() => move({}, horizon)}> |
| 147 | <CornerDownRight /> |
| 148 | {horizonLabel(horizon)} |
| 149 | </DropdownMenuItem> |
| 150 | ))} |
| 151 | </DropdownMenuContent> |
| 152 | </DropdownMenu> |
| 153 | ); |
| 154 | } |
| 155 | |
| 156 | /** What a row needs to be dragged and dropped on; none for a viewer who cannot move it. */ |
| 157 | export type RowDrag = { |
| 158 | onDragStart: (event: DragEvent<HTMLLIElement>) => void; |
| 159 | onDragEnd: () => void; |
| 160 | onDragOver: (event: DragEvent<HTMLLIElement>) => void; |
| 161 | onDrop: (event: DragEvent<HTMLLIElement>) => void; |
| 162 | /** Where a drop would land, drawn as a line: above this row or below it. */ |
| 163 | edge: "above" | "below" | null; |
| 164 | dragging: boolean; |
| 165 | draggable: boolean; |
| 166 | }; |
| 167 | |
| 168 | /** |
| 169 | * One work item in the plan: its place (`rank`), urgent first, its number |
| 170 | * and title, its repositories, what it waits on, who is on it, its |
| 171 | * appetite, and its menu. |
| 172 | */ |
| 173 | export function PlanRow({ |
| 174 | item, |
| 175 | rank, |
| 176 | workspace, |
| 177 | on, |
| 178 | waiting, |
| 179 | drag, |
| 180 | menu, |
| 181 | }: { |
| 182 | item: WorkItem; |
| 183 | rank: number | null; |
| 184 | workspace: string; |
| 185 | on: OnIt | undefined; |
| 186 | /** Open work items it is blocked by. */ |
| 187 | waiting: readonly number[]; |
| 188 | drag?: RowDrag; |
| 189 | menu?: ReactNode; |
| 190 | }) { |
| 191 | return ( |
| 192 | <li |
| 193 | draggable={drag?.draggable || undefined} |
| 194 | onDragStart={drag?.onDragStart} |
| 195 | onDragEnd={drag?.onDragEnd} |
| 196 | onDragOver={drag?.onDragOver} |
| 197 | onDrop={drag?.onDrop} |
| 198 | className={cn( |
| 199 | "group relative grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-center gap-2 rounded-md px-2 py-2 transition-colors hover:bg-raised/60", |
| 200 | drag?.dragging && "opacity-40", |
| 201 | drag?.edge === "above" && "shadow-[inset_0_2px_0_0_var(--color-accent)]", |
| 202 | drag?.edge === "below" && "shadow-[inset_0_-2px_0_0_var(--color-accent)]", |
| 203 | )} |
| 204 | > |
| 205 | <span className="relative flex justify-end font-mono text-xs tabular-nums text-faint"> |
| 206 | {drag?.draggable && ( |
| 207 | <GripVertical |
| 208 | size={14} |
| 209 | aria-hidden |
| 210 | className="absolute top-1/2 -left-1 hidden -translate-y-1/2 cursor-grab text-faint opacity-0 group-hover:opacity-100 sm:block" |
| 211 | /> |
| 212 | )} |
| 213 | {rank ?? ""} |
| 214 | </span> |
| 215 | <div className="min-w-0"> |
| 216 | {/* One line on a computer; two on a phone, where the title has less room. */} |
| 217 | <Link |
| 218 | to={itemPath(workspace, item.number)} |
| 219 | prefetch="intent" |
| 220 | className="line-clamp-2 min-w-0 text-sm font-medium wrap-break-word hover:text-accent sm:line-clamp-1" |
| 221 | > |
| 222 | {item.urgent && ( |
| 223 | <Badge tone="danger" className="mr-1.5 align-[1px]"> |
| 224 | Urgent |
| 225 | </Badge> |
| 226 | )} |
| 227 | <span className="mr-1.5 font-mono text-xs font-normal text-faint">#{item.number}</span> |
| 228 | {item.title} |
| 229 | </Link> |
| 230 | {(item.repos.length > 0 || waiting.length > 0 || (on && (on.people.length > 0 || on.sessions.length > 0))) && ( |
| 231 | <div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-faint"> |
| 232 | <RepoChips repos={item.repos} workspace={workspace} max={1} /> |
| 233 | {waiting.length > 0 && ( |
| 234 | <Badge tone="warn" className="max-w-full"> |
| 235 | <span className="truncate">{waitingLabel(waiting)}</span> |
| 236 | </Badge> |
| 237 | )} |
| 238 | <OnItLine on={on} workspace={workspace} /> |
| 239 | </div> |
| 240 | )} |
| 241 | </div> |
| 242 | <div className="flex shrink-0 items-center gap-1"> |
| 243 | {item.appetite && <AppetiteChip appetite={item.appetite} />} |
| 244 | {menu} |
| 245 | </div> |
| 246 | </li> |
| 247 | ); |
| 248 | } |