import { useState, type KeyboardEvent } from 'react' import { StatusIcon } from './board-icons.tsx' import { STATUS_LABEL, type Status } from '../../shared/status.ts' import type { StatusWord } from '../../shared/board-types.ts' import type { BoardMeta } from './store.ts' /** * A board's status, from its right-click menu - Linear's picker, on Marver's rule (spec 20): a * person decides only what the evidence cannot. With `context/` that is Blocked (with a reason), * Paused and Archived, and "Back to the evidence" undoes the decision - plus In progress and Building * where an open plan names the capability (they write the plan's `stage`, the evidence itself); * without it, Backlog, To do, In progress and Building as well. Done is never offered - it comes from * the shipped record. The status the evidence gives shows read-only at the top, with where it came from. * * Type to filter, arrows and Enter, or a number. Blocked asks why before it writes. */ type Opt = { key: string; status: StatusWord | null; label: string; icon: Status | 'clear' } export function StatusPicker({ meta, onPick }: { meta: BoardMeta; onPick: (status: StatusWord | null, reason?: string) => void }) { const settable = meta.settable ?? [] const current = meta.status const hasContext = !settable.includes('todo') const decided = !!current && (current.row ?? 9) <= 3 // rows 1-3: a decision on the board const opts: Opt[] = [ ...settable.map((s) => ({ key: s, status: s, label: s === 'blocked' ? 'Blocked…' : STATUS_LABEL[s], icon: s })), ...(hasContext && decided ? [{ key: 'clear', status: null, label: 'Back to the evidence', icon: 'clear' as const }] : []), ] const [q, setQ] = useState('') const shown = opts.map((o, i) => ({ ...o, n: i + 1 })).filter((o) => o.label.toLowerCase().includes(q.trim().toLowerCase())) const [hi, setHi] = useState(0) const [asking, setAsking] = useState(false) const [reason, setReason] = useState(current?.status === 'blocked' ? current.reason ?? '' : '') const choose = (o: Opt | undefined) => { if (!o) return if (o.status === 'blocked') { setAsking(true); return } onPick(o.status) } const keys = (e: KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); setHi((h) => Math.min(h + 1, shown.length - 1)) } else if (e.key === 'ArrowUp') { e.preventDefault(); setHi((h) => Math.max(h - 1, 0)) } else if (e.key === 'Enter') { e.preventDefault(); choose(shown[hi]) } else if (/^[1-9]$/.test(e.key) && !q) { e.preventDefault(); choose(opts[Number(e.key) - 1]) } } if (asking) { const ok = !!reason.trim() return (
Blocked - why?
setReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && ok) { e.preventDefault(); onPick('blocked', reason.trim()) } }} />
{ok ? 'Enter to save' : 'A blocked board says why'} · Esc to close
) } // what the evidence says, when no decision hides it: the status and the line that decided it const evidence = !decided && current && hasContext ? current.evidence?.[0] : undefined return (
{ setQ(e.target.value); setHi(0) }} onKeyDown={keys} /> {current && evidence && (
{STATUS_LABEL[current.status]} {evidence.replace(/:\s.*$/, '').replace(/^context\//, '')}
)}
{shown.map((o, i) => { // with context/, In progress and Building are the plan's - current when the evidence says so const fromPlan = o.status === 'in-progress' || o.status === 'building' const isCurrent = o.status !== null && o.status === current?.status && (decided || !hasContext || fromPlan) return ( ) })} {!shown.length &&
No status matches
}
{!hasContext ? 'Done needs a record - context/shipped.md' : settable.includes('building') ? 'Building and In progress write the plan’s stage · Done needs a record' : 'Building needs an open plan - context/plans/'}
) }