| 1 | import { ArrowDown, ArrowUp, ArrowUpToLine, Bot, CornerDownRight, GripVertical, MoreHorizontal, Sparkles } from "lucide-react"; |
| 2 | import type { DragEvent, ReactNode } from "react"; |
| 3 | import { Link } from "react-router"; |
| 4 | |
| 5 | import { type Appetite, type Hill, type Horizon, type Progress as Standing, type WorkItem, appetiteLetter, hillLabel, 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 { |
| 17 | APPETITE_HINTS, |
| 18 | type CapState, |
| 19 | HILL_STEPS, |
| 20 | type Move, |
| 21 | type OnIt, |
| 22 | type Position, |
| 23 | hillStep, |
| 24 | menuHorizons, |
| 25 | moveDown, |
| 26 | moveUp, |
| 27 | quietText, |
| 28 | waitingLabel, |
| 29 | } from "../lib/plan"; |
| 30 | import { onItemSessionTitle, runsIn, sessionHref } from "../lib/sessions"; |
| 31 | import { timeAgo } from "../lib/time-ago"; |
| 32 | import { itemPath } from "../lib/work-items"; |
| 33 | |
| 34 | /** A work item's appetite as its letter in a small square, with what it means on hover. */ |
| 35 | export function AppetiteChip({ appetite }: { appetite: Appetite }) { |
| 36 | return ( |
| 37 | <Hint label={APPETITE_HINTS[appetite]}> |
| 38 | <Badge shape="tag" className="size-5 justify-center p-0 font-mono" aria-label={APPETITE_HINTS[appetite]}> |
| 39 | {appetiteLetter(appetite)} |
| 40 | </Badge> |
| 41 | </Hint> |
| 42 | ); |
| 43 | } |
| 44 | |
| 45 | /** Where each of a hill's four stages sits on its curve, as x and y in a 44 by 16 box. */ |
| 46 | const HILL_POINTS = HILL_STEPS.map((_, step) => { |
| 47 | const along = (step + 0.5) / HILL_STEPS.length; |
| 48 | return { x: 3 + along * 38, y: 14 - Math.sin(along * Math.PI) * 10 }; |
| 49 | }); |
| 50 | |
| 51 | /** |
| 52 | * How far along a work item in Now is, as its record says: a small hill |
| 53 | * with its four stages (figuring it out, decided, building, landing) and |
| 54 | * the one it is at filled in. Its stage is its accessible name and its |
| 55 | * hint; `label` also shows it in words, before the hill. |
| 56 | */ |
| 57 | export function HillMark({ hill, label = false }: { hill: Hill; label?: boolean }) { |
| 58 | const at = hillStep(hill); |
| 59 | const words = `${hillLabel(hill)}, stage ${at + 1} of ${HILL_STEPS.length}`; |
| 60 | return ( |
| 61 | <Hint label={words}> |
| 62 | <span role="img" aria-label={words} className="inline-flex shrink-0 items-center gap-1.5"> |
| 63 | {label && <span className="text-[0.6875rem] whitespace-nowrap text-faint">{hillLabel(hill)}</span>} |
| 64 | <svg viewBox="0 0 44 16" className="h-4 w-11" aria-hidden> |
| 65 | <path d="M2 15 Q 22 -5 42 15" fill="none" className="stroke-line-strong" strokeWidth="1.25" /> |
| 66 | {HILL_POINTS.map((point, step) => |
| 67 | step === at ? ( |
| 68 | <circle key={step} cx={point.x} cy={point.y} r="3" className="fill-accent" /> |
| 69 | ) : ( |
| 70 | <circle key={step} cx={point.x} cy={point.y} r="1.25" className={step < at ? "fill-accent/50" : "fill-line-strong"} /> |
| 71 | ), |
| 72 | )} |
| 73 | </svg> |
| 74 | </span> |
| 75 | </Hint> |
| 76 | ); |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Where a work item in Now stands, in g1t's words from its record (its |
| 81 | * sessions, decisions and pull requests), muted, with a small mark that |
| 82 | * says g1t drafted it and when. Nothing until it has been drafted. |
| 83 | */ |
| 84 | export function StandingText({ standing, className }: { standing: Pick<Standing, "standing" | "standingAt">; className?: string }) { |
| 85 | if (!standing.standing) return null; |
| 86 | const when = standing.standingAt ? ` ${timeAgo(standing.standingAt)}` : ""; |
| 87 | return ( |
| 88 | <p className={cn("text-xs leading-relaxed text-muted", className)}> |
| 89 | {standing.standing} |
| 90 | <Hint label={<span suppressHydrationWarning>{`Drafted by g1t from its record${when}`}</span>}> |
| 91 | <span className="ml-1 inline-flex translate-y-px items-center text-faint" aria-label="Drafted by g1t" role="img"> |
| 92 | <Sparkles size={11} /> |
| 93 | </span> |
| 94 | </Hint> |
| 95 | </p> |
| 96 | ); |
| 97 | } |
| 98 | |
| 99 | /** "Quiet for 6 days", for a work item in Now nothing has happened on lately. */ |
| 100 | export function QuietBadge({ progress }: { progress: Pick<Standing, "quiet" | "activeAt"> }) { |
| 101 | const text = quietText(progress, Date.now()); |
| 102 | if (!text) return null; |
| 103 | return ( |
| 104 | <Badge tone="warn" suppressHydrationWarning> |
| 105 | {text} |
| 106 | </Badge> |
| 107 | ); |
| 108 | } |
| 109 | |
| 110 | /** 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. */ |
| 111 | export function OnItLine({ on, workspace }: { on: OnIt | undefined; workspace: string }) { |
| 112 | if (!on || (on.people.length === 0 && on.sessions.length === 0)) return null; |
| 113 | const { people, sessions } = on; |
| 114 | return ( |
| 115 | <span className="inline-flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"> |
| 116 | {people.length > 0 && ( |
| 117 | <Hint label={people.join(", ")}> |
| 118 | <span className="flex -space-x-1.5"> |
| 119 | {people.slice(0, 3).map((name) => ( |
| 120 | <span key={name} className="rounded-full ring-2 ring-surface"> |
| 121 | <Avatar name={name} size={16} /> |
| 122 | </span> |
| 123 | ))} |
| 124 | </span> |
| 125 | </Hint> |
| 126 | )} |
| 127 | {people.length > 3 && <span className="text-[0.6875rem] text-faint">+{people.length - 3}</span>} |
| 128 | {sessions.slice(0, 1).map((session) => { |
| 129 | const to = sessionHref(workspace, session); |
| 130 | const label = ( |
| 131 | <span className="inline-flex min-w-0 items-center gap-1 text-[0.6875rem] text-muted"> |
| 132 | <StateDot state={session.state} /> |
| 133 | <HarnessMark session={session} size={11} /> |
| 134 | <span className="truncate">{session.actor ?? runsIn(session)}</span> |
| 135 | </span> |
| 136 | ); |
| 137 | return ( |
| 138 | <Hint key={session.id} label={`${onItemSessionTitle(session)} · ${runsIn(session)}`}> |
| 139 | {to ? ( |
| 140 | <Link to={to} className="min-w-0 hover:text-fg"> |
| 141 | {label} |
| 142 | </Link> |
| 143 | ) : ( |
| 144 | label |
| 145 | )} |
| 146 | </Hint> |
| 147 | ); |
| 148 | })} |
| 149 | {sessions.length > 1 && ( |
| 150 | <span className="inline-flex items-center gap-0.5 text-[0.6875rem] text-faint"> |
| 151 | <Bot size={11} />+{sessions.length - 1} |
| 152 | </span> |
| 153 | )} |
| 154 | </span> |
| 155 | ); |
| 156 | } |
| 157 | |
| 158 | /** Now's count against its cap, and a bar that turns to the warning color past it. */ |
| 159 | export function CapBar({ cap }: { cap: CapState }) { |
| 160 | return ( |
| 161 | <div className="flex items-center gap-2"> |
| 162 | <Progress |
| 163 | value={cap.share * 100} |
| 164 | aria-label={`${cap.count} of ${cap.cap} in Now`} |
| 165 | className="h-1 w-16 sm:w-24" |
| 166 | indicatorClassName={cap.over ? "bg-warn" : undefined} |
| 167 | /> |
| 168 | <span className={cn("font-mono text-xs tabular-nums", cap.over ? "text-warn" : "text-faint")}> |
| 169 | {cap.count} of {cap.cap} |
| 170 | </span> |
| 171 | </div> |
| 172 | ); |
| 173 | } |
| 174 | |
| 175 | /** |
| 176 | * A row's menu: the keyboard's and a phone's way to make every move a drag |
| 177 | * makes. Up, down and to the top within the list it is in (`list`, the |
| 178 | * numbers shown, when it is ranked there), then to each other horizon. |
| 179 | */ |
| 180 | export function PlanRowMenu({ |
| 181 | item, |
| 182 | list, |
| 183 | canNow, |
| 184 | onMove, |
| 185 | }: { |
| 186 | item: WorkItem; |
| 187 | /** The ranked list it is in, as shown, top first; null where it is not ranked (Later). */ |
| 188 | list: readonly number[] | null; |
| 189 | canNow: boolean; |
| 190 | onMove: (move: Move) => void; |
| 191 | }) { |
| 192 | const rankable = list != null && (item.horizon !== "now" || canNow); |
| 193 | const move = (position: Position | null, horizon: Horizon = item.horizon) => position && onMove({ number: item.number, horizon, ...position }); |
| 194 | const up = list ? moveUp(list, item.number) : null; |
| 195 | const down = list ? moveDown(list, item.number) : null; |
| 196 | const elsewhere = menuHorizons(item.horizon, canNow); |
| 197 | if (!rankable && elsewhere.length === 0) return null; |
| 198 | return ( |
| 199 | <DropdownMenu> |
| 200 | <DropdownMenuTrigger asChild> |
| 201 | <Button variant="ghost" size="icon-xs" aria-label={`Move #${item.number}`} className="text-faint max-sm:size-9"> |
| 202 | <MoreHorizontal size={15} /> |
| 203 | </Button> |
| 204 | </DropdownMenuTrigger> |
| 205 | <DropdownMenuContent align="end"> |
| 206 | {rankable && ( |
| 207 | <> |
| 208 | <DropdownMenuItem disabled={!up} onSelect={() => move(up)}> |
| 209 | <ArrowUp /> |
| 210 | Move up |
| 211 | </DropdownMenuItem> |
| 212 | <DropdownMenuItem disabled={!down} onSelect={() => move(down)}> |
| 213 | <ArrowDown /> |
| 214 | Move down |
| 215 | </DropdownMenuItem> |
| 216 | <DropdownMenuItem disabled={!up} onSelect={() => move({ top: true })}> |
| 217 | <ArrowUpToLine /> |
| 218 | Move to top |
| 219 | </DropdownMenuItem> |
| 220 | {elsewhere.length > 0 && <DropdownMenuSeparator />} |
| 221 | </> |
| 222 | )} |
| 223 | {elsewhere.length > 0 && <DropdownMenuLabel>Move to</DropdownMenuLabel>} |
| 224 | {elsewhere.map((horizon) => ( |
| 225 | <DropdownMenuItem key={horizon} onSelect={() => move({}, horizon)}> |
| 226 | <CornerDownRight /> |
| 227 | {horizonLabel(horizon)} |
| 228 | </DropdownMenuItem> |
| 229 | ))} |
| 230 | </DropdownMenuContent> |
| 231 | </DropdownMenu> |
| 232 | ); |
| 233 | } |
| 234 | |
| 235 | /** What a row needs to be dragged and dropped on; none for a viewer who cannot move it. */ |
| 236 | export type RowDrag = { |
| 237 | onDragStart: (event: DragEvent<HTMLLIElement>) => void; |
| 238 | onDragEnd: () => void; |
| 239 | onDragOver: (event: DragEvent<HTMLLIElement>) => void; |
| 240 | onDrop: (event: DragEvent<HTMLLIElement>) => void; |
| 241 | /** Where a drop would land, drawn as a line: above this row or below it. */ |
| 242 | edge: "above" | "below" | null; |
| 243 | dragging: boolean; |
| 244 | draggable: boolean; |
| 245 | }; |
| 246 | |
| 247 | /** |
| 248 | * One work item in the plan: its place (`rank`), urgent first, its number |
| 249 | * and title, its repositories, what it waits on, who is on it, its |
| 250 | * appetite, and its menu. In Now, also how it stands (`progress`): its |
| 251 | * hill, whether it has gone quiet, and where it stands under its title. |
| 252 | */ |
| 253 | export function PlanRow({ |
| 254 | item, |
| 255 | rank, |
| 256 | workspace, |
| 257 | on, |
| 258 | waiting, |
| 259 | drag, |
| 260 | menu, |
| 261 | progress, |
| 262 | }: { |
| 263 | item: WorkItem; |
| 264 | rank: number | null; |
| 265 | workspace: string; |
| 266 | on: OnIt | undefined; |
| 267 | /** Open work items it is blocked by. */ |
| 268 | waiting: readonly number[]; |
| 269 | drag?: RowDrag; |
| 270 | menu?: ReactNode; |
| 271 | progress?: Standing; |
| 272 | }) { |
| 273 | const quiet = progress?.quiet ?? false; |
| 274 | return ( |
| 275 | <li |
| 276 | draggable={drag?.draggable || undefined} |
| 277 | onDragStart={drag?.onDragStart} |
| 278 | onDragEnd={drag?.onDragEnd} |
| 279 | onDragOver={drag?.onDragOver} |
| 280 | onDrop={drag?.onDrop} |
| 281 | className={cn( |
| 282 | "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", |
| 283 | drag?.dragging && "opacity-40", |
| 284 | drag?.edge === "above" && "shadow-[inset_0_2px_0_0_var(--color-accent)]", |
| 285 | drag?.edge === "below" && "shadow-[inset_0_-2px_0_0_var(--color-accent)]", |
| 286 | )} |
| 287 | > |
| 288 | <span className="relative flex justify-end font-mono text-xs tabular-nums text-faint"> |
| 289 | {drag?.draggable && ( |
| 290 | <GripVertical |
| 291 | size={14} |
| 292 | aria-hidden |
| 293 | className="absolute top-1/2 -left-1 hidden -translate-y-1/2 cursor-grab text-faint opacity-0 group-hover:opacity-100 sm:block" |
| 294 | /> |
| 295 | )} |
| 296 | {rank ?? ""} |
| 297 | </span> |
| 298 | <div className="min-w-0"> |
| 299 | {/* One line on a computer; two on a phone, where the title has less room. */} |
| 300 | <Link |
| 301 | to={itemPath(workspace, item.number)} |
| 302 | prefetch="intent" |
| 303 | className="line-clamp-2 min-w-0 text-sm font-medium wrap-break-word hover:text-accent sm:line-clamp-1" |
| 304 | > |
| 305 | {item.urgent && ( |
| 306 | <Badge tone="danger" className="mr-1.5 align-[1px]"> |
| 307 | Urgent |
| 308 | </Badge> |
| 309 | )} |
| 310 | <span className="mr-1.5 font-mono text-xs font-normal text-faint">#{item.number}</span> |
| 311 | {item.title} |
| 312 | </Link> |
| 313 | {progress && <StandingText standing={progress} className="mt-0.5 line-clamp-2" />} |
| 314 | {(progress || item.repos.length > 0 || waiting.length > 0 || quiet || (on && (on.people.length > 0 || on.sessions.length > 0))) && ( |
| 315 | <div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-faint"> |
| 316 | {/* On a phone the hill goes under the title, in words, where there is room for it. */} |
| 317 | {progress && ( |
| 318 | <span className="sm:hidden"> |
| 319 | <HillMark hill={progress.hill} label /> |
| 320 | </span> |
| 321 | )} |
| 322 | <RepoChips repos={item.repos} workspace={workspace} max={1} /> |
| 323 | {progress && <QuietBadge progress={progress} />} |
| 324 | {waiting.length > 0 && ( |
| 325 | <Badge tone="warn" className="max-w-full"> |
| 326 | <span className="truncate">{waitingLabel(waiting)}</span> |
| 327 | </Badge> |
| 328 | )} |
| 329 | <OnItLine on={on} workspace={workspace} /> |
| 330 | </div> |
| 331 | )} |
| 332 | </div> |
| 333 | <div className="flex shrink-0 items-center gap-1"> |
| 334 | {progress && ( |
| 335 | <span className="mr-1 max-sm:hidden"> |
| 336 | <HillMark hill={progress.hill} /> |
| 337 | </span> |
| 338 | )} |
| 339 | {item.appetite && <AppetiteChip appetite={item.appetite} />} |
| 340 | {menu} |
| 341 | </div> |
| 342 | </li> |
| 343 | ); |
| 344 | } |