| 1 | import { Link, useFetcher } from "react-router"; |
| 2 | import { useEffect, useId, useState } from "react"; |
| 3 | |
| 4 | import { Check, Sparkles } from "lucide-react"; |
| 5 | |
| 6 | import { APPETITES, HORIZONS, type Horizon, type Progress, type WorkItem, appetiteLetter, hillLabel, horizonLabel } from "@g1t/contracts"; |
| 7 | |
| 8 | import { AppetiteChip, HillMark, QuietBadge, StandingText } from "./plan"; |
| 9 | import { ErrorText } from "./ui"; |
| 10 | import { Input, InputAddon, InputGroup } from "./ui/input"; |
| 11 | import { Badge } from "./ui/badge"; |
| 12 | import { Button } from "./ui/button"; |
| 13 | import { SelectField } from "./ui/select"; |
| 14 | import { Switch } from "./ui/switch"; |
| 15 | import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group"; |
| 16 | import { Card } from "./ui/card"; |
| 17 | import { Hint } from "./ui/hint"; |
| 18 | import { cn } from "../lib/cn"; |
| 19 | import { itemPath } from "../lib/work-items"; |
| 20 | import { APPETITE_HINTS, APPETITE_SUMMARY, HORIZON_HINTS, acceptForm, budgetText, canAccept, planPath, suggestionText } from "../lib/plan"; |
| 21 | |
| 22 | /** |
| 23 | * The item page's Plan, in its sidebar beside Milestone: where it stands |
| 24 | * in the plan (a horizon), its appetite, a budget in dollars for an agent |
| 25 | * working on it, and urgent, which puts it first in Now. Each change posts |
| 26 | * `action=plan` to the page. Read-only for who cannot plan (`canEdit`, the |
| 27 | * Triage role); Now and urgent also take the right to manage milestones |
| 28 | * (`canNow`), which the work service checks again. In Triage, where g1t |
| 29 | * suggests it goes, with Accept; in Now, how it stands (`progress`): its |
| 30 | * hill and where it stands, as the plan reads them. |
| 31 | */ |
| 32 | export function PlanBox({ |
| 33 | item, |
| 34 | workspace, |
| 35 | canEdit, |
| 36 | canNow, |
| 37 | progress, |
| 38 | }: { |
| 39 | item: Pick<WorkItem, "horizon" | "appetite" | "budgetUsd" | "urgent" | "state" | "suggestion">; |
| 40 | workspace: string; |
| 41 | canEdit: boolean; |
| 42 | canNow: boolean; |
| 43 | /** How it stands, while it is in Now; null when it could not be read. */ |
| 44 | progress?: Progress | null; |
| 45 | }) { |
| 46 | const fetcher = useFetcher<{ error?: string } | null>(); |
| 47 | const budgetId = useId(); |
| 48 | const editable = canEdit && item.state === "open"; |
| 49 | // While saving, what was asked for. |
| 50 | const posted = fetcher.state !== "idle" ? fetcher.formData : null; |
| 51 | const horizon = (posted?.get("urgent") === "1" ? "now" : (posted?.get("horizon") as Horizon | null)) ?? item.horizon; |
| 52 | const appetite = posted?.has("appetite") ? (posted.get("appetite") === "none" ? null : (posted.get("appetite") as WorkItem["appetite"])) : item.appetite; |
| 53 | const urgent = posted?.has("urgent") ? posted.get("urgent") === "1" : item.urgent; |
| 54 | const [budget, setBudget] = useState(item.budgetUsd == null ? "" : String(item.budgetUsd)); |
| 55 | useEffect(() => setBudget(item.budgetUsd == null ? "" : String(item.budgetUsd)), [item.budgetUsd]); |
| 56 | const post = (fields: Record<string, string>) => fetcher.submit({ action: "plan", ...fields }, { method: "post" }); |
| 57 | const lockedInNow = horizon === "now" && !canNow; |
| 58 | const suggestion = item.horizon === "triage" && horizon === "triage" && item.state === "open" ? (item.suggestion ?? null) : null; |
| 59 | const standing = item.horizon === "now" && horizon === "now" && item.state === "open" ? (progress ?? null) : null; |
| 60 | return ( |
| 61 | <section> |
| 62 | <div className="flex items-center justify-between gap-2"> |
| 63 | <h3 className="text-sm font-medium">Plan</h3> |
| 64 | <Link to={planPath(workspace)} className="text-xs text-faint hover:text-fg"> |
| 65 | Open the plan |
| 66 | </Link> |
| 67 | </div> |
| 68 | {suggestion && ( |
| 69 | <Card tone="plain" radius="lg" className="mt-2 space-y-1.5 border-accent/20 bg-accent/5 px-3 py-2 text-xs"> |
| 70 | <p className="flex items-center gap-1.5"> |
| 71 | <Sparkles size={12} className="shrink-0 text-accent" aria-hidden /> |
| 72 | <span className="font-medium text-fg">Suggested: {suggestionText(suggestion)}</span> |
| 73 | </p> |
| 74 | {suggestion.duplicateOf != null && ( |
| 75 | <p className="text-muted"> |
| 76 | Looks like{" "} |
| 77 | <Link to={itemPath(workspace, suggestion.duplicateOf)} className="font-mono text-fg underline-offset-4 hover:underline"> |
| 78 | #{suggestion.duplicateOf} |
| 79 | </Link> |
| 80 | </p> |
| 81 | )} |
| 82 | <p className="text-muted">{suggestion.why}</p> |
| 83 | {editable && canAccept(suggestion, canNow) && ( |
| 84 | <Button size="xs" variant="accent" disabled={fetcher.state !== "idle"} onClick={() => post(acceptForm(suggestion))}> |
| 85 | <Check size={13} /> |
| 86 | Accept |
| 87 | </Button> |
| 88 | )} |
| 89 | </Card> |
| 90 | )} |
| 91 | {standing && ( |
| 92 | <div className="mt-2 space-y-1.5"> |
| 93 | <div className="flex flex-wrap items-center gap-2"> |
| 94 | <HillMark hill={standing.hill} /> |
| 95 | <span className="text-xs text-muted">{hillLabel(standing.hill)}</span> |
| 96 | <QuietBadge progress={standing} /> |
| 97 | </div> |
| 98 | <StandingText standing={standing} /> |
| 99 | </div> |
| 100 | )} |
| 101 | {editable ? ( |
| 102 | <div className={cn("space-y-3", suggestion || standing ? "mt-3" : "mt-2")}> |
| 103 | <SelectField |
| 104 | aria-label="Horizon" |
| 105 | size="sm" |
| 106 | value={horizon} |
| 107 | disabled={lockedInNow} |
| 108 | onValueChange={(value) => value !== horizon && post({ horizon: value })} |
| 109 | options={HORIZONS.map((one) => ({ |
| 110 | value: one, |
| 111 | label: horizonLabel(one), |
| 112 | description: HORIZON_HINTS[one], |
| 113 | disabled: one === "now" && !canNow, |
| 114 | }))} |
| 115 | /> |
| 116 | <div className="flex items-center justify-between gap-2"> |
| 117 | <Hint label={APPETITE_SUMMARY}> |
| 118 | <span className="text-xs text-muted underline decoration-line-strong decoration-dotted underline-offset-4">Appetite</span> |
| 119 | </Hint> |
| 120 | <ToggleGroup |
| 121 | type="single" |
| 122 | variant="outline" |
| 123 | size="sm" |
| 124 | aria-label="Appetite" |
| 125 | value={appetite ?? ""} |
| 126 | onValueChange={(value) => post({ appetite: value || "none" })} |
| 127 | > |
| 128 | {APPETITES.map((one) => ( |
| 129 | <ToggleGroupItem key={one} value={one} aria-label={APPETITE_HINTS[one]} className="font-mono"> |
| 130 | {appetiteLetter(one)} |
| 131 | </ToggleGroupItem> |
| 132 | ))} |
| 133 | </ToggleGroup> |
| 134 | </div> |
| 135 | <fetcher.Form method="post" className="flex items-center gap-2"> |
| 136 | <input type="hidden" name="action" value="plan" /> |
| 137 | <label htmlFor={budgetId} className="grow text-xs text-muted"> |
| 138 | Budget |
| 139 | </label> |
| 140 | <InputGroup className="h-8 w-24"> |
| 141 | <InputAddon className="pr-0 text-xs">$</InputAddon> |
| 142 | <Input id={budgetId} name="budget" inputMode="decimal" value={budget} onChange={(event) => setBudget(event.target.value)} className="pl-1.5" /> |
| 143 | </InputGroup> |
| 144 | <Button type="submit" size="sm" variant="outline" disabled={budget === (item.budgetUsd == null ? "" : String(item.budgetUsd))}> |
| 145 | Save |
| 146 | </Button> |
| 147 | </fetcher.Form> |
| 148 | <label className="flex items-center justify-between gap-2"> |
| 149 | <span className="min-w-0"> |
| 150 | <span className="block text-xs text-muted">Urgent</span> |
| 151 | <span className="block text-[0.6875rem] text-faint">First in Now, until it closes</span> |
| 152 | </span> |
| 153 | <Switch size="sm" checked={urgent} disabled={!canNow} onCheckedChange={(on) => post({ urgent: on ? "1" : "0" })} aria-label="Urgent" /> |
| 154 | </label> |
| 155 | {lockedInNow && <p className="text-xs text-faint">Moving it out of Now takes the right to manage milestones.</p>} |
| 156 | </div> |
| 157 | ) : ( |
| 158 | <div className={cn("flex flex-wrap items-center gap-1.5 text-sm", suggestion || standing ? "mt-3" : "mt-2")}> |
| 159 | {item.urgent && <Badge tone="danger">Urgent</Badge>} |
| 160 | <span>{horizonLabel(item.horizon)}</span> |
| 161 | {item.appetite && <AppetiteChip appetite={item.appetite} />} |
| 162 | {item.budgetUsd != null && <span className="text-xs text-muted">{budgetText(item.budgetUsd)} budget</span>} |
| 163 | </div> |
| 164 | )} |
| 165 | {fetcher.state === "idle" && fetcher.data?.error && ( |
| 166 | <div className="mt-2"> |
| 167 | <ErrorText>{fetcher.data.error}</ErrorText> |
| 168 | </div> |
| 169 | )} |
| 170 | </section> |
| 171 | ); |
| 172 | } |