/** Plan day: timeline, paste ChatPlan, check-off, annotate, day review, export. */ import { ChevronLeft, ChevronRight } from "lucide-preact"; import { useEffect, useMemo, useState } from "preact/hooks"; import { ApiError, checkPlanBlock, exportChatPlan, getPlan, importChatPlan, patchPlanMeta, reschedulePlan, seedPlan, type DayPlanView, type DayReview, type ScheduledBlock, } from "../api"; import { BlockSheet } from "../components/BlockSheet"; import { Button } from "../components/Button"; import { Pressable } from "../components/Pressable"; import { Sheet } from "../components/Sheet"; import { Timeline } from "../components/Timeline"; import { useToast } from "../components/Toast"; import { isoDate } from "../dates"; import { navigate } from "../router"; function shiftDay(day: string, delta: number): string { const d = new Date(`${day}T00:00:00`); d.setDate(d.getDate() + delta); return isoDate(d); } function friendly(day: string): string { return new Date(`${day}T00:00:00`).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", }); } function parseMin(hhmm: string): number { const [h, m] = hhmm.split(":").map(Number); return h * 60 + m; } const EMPTY_REVIEW: DayReview = { comment: "", emotions: [], fse_events: "", what_moved: "", what_avoided: "", tomorrow_change: "", }; export function Plan({ initialDate }: { initialDate?: string }) { const toast = useToast(); const [day, setDay] = useState(initialDate || isoDate(new Date())); const [plan, setPlan] = useState(null); const [busy, setBusy] = useState(false); const [selected, setSelected] = useState(null); const [sheetMode, setSheetMode] = useState<"view" | "create">("view"); const [sheetOpen, setSheetOpen] = useState(false); const [pasteOpen, setPasteOpen] = useState(false); const [pasteText, setPasteText] = useState(""); const [pasteMode, setPasteMode] = useState<"replace" | "merge">("replace"); const [pasteError, setPasteError] = useState(""); const [dayReview, setDayReview] = useState(EMPTY_REVIEW); const load = async (date: string) => { try { const next = await getPlan(date); setPlan(next); setDayReview({ ...EMPTY_REVIEW, ...(next.day_review || {}) }); } catch { setPlan(null); setDayReview(EMPTY_REVIEW); } }; useEffect(() => { if (initialDate && initialDate !== day) setDay(initialDate); }, [initialDate]); useEffect(() => { void load(day); navigate(`/plan/${day}`); }, [day]); const progress = useMemo(() => { const blocks = plan?.blocks ?? []; const total = blocks.length; const done = blocks.filter((b) => b.status === "done" || b.status === "partial").length; const p0 = blocks.filter((b) => b.priority === "P0"); const p0Left = p0.filter((b) => b.status === "planned").length; return { total, done, p0Left, p0Total: p0.length }; }, [plan]); const openBlock = (block: ScheduledBlock) => { setSelected(block); setSheetMode("view"); setSheetOpen(true); }; const openCreate = () => { setSelected(null); setSheetMode("create"); setSheetOpen(true); }; const onSeed = async () => { setBusy(true); try { setPlan(await seedPlan(day)); toast.show("Starter day added"); } catch (e) { toast.show(e instanceof Error ? e.message : "Seed failed", "error"); } finally { setBusy(false); } }; const onReschedule = async () => { if (!confirm("Reschedule the rest of this day? Locked blocks stay put.")) return; setBusy(true); try { const result = await reschedulePlan(day, { reason: "user", force: true }); setPlan(result.plan); toast.show(result.source === "rules" ? "Rescheduled (rules)" : "Rescheduled"); } catch (e) { toast.show(e instanceof Error ? e.message : "Reschedule failed", "error"); } finally { setBusy(false); } }; const onExport = async () => { setBusy(true); try { const chat = await exportChatPlan(day); await navigator.clipboard.writeText(JSON.stringify(chat, null, 2)); toast.show("Copied — paste to your chat"); } catch (e) { toast.show(e instanceof Error ? e.message : "Export failed", "error"); } finally { setBusy(false); } }; const onPasteSubmit = async () => { setPasteError(""); let parsed: unknown; try { parsed = JSON.parse(pasteText); } catch { setPasteError("Not valid JSON"); return; } setBusy(true); try { const next = await importChatPlan(day, parsed, pasteMode); setPlan(next); setDayReview({ ...EMPTY_REVIEW, ...(next.day_review || {}) }); setPasteOpen(false); setPasteText(""); toast.show("Plan pasted"); if (next.import_warnings?.length) { toast.show(next.import_warnings[0], "error"); } } catch (e) { const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Import failed"; setPasteError(msg); } finally { setBusy(false); } }; const toggleCheck = async (block: ScheduledBlock) => { const nextStatus = block.status === "done" || block.status === "partial" ? "planned" : "done"; setBusy(true); try { setPlan(await checkPlanBlock(day, block.id, { status: nextStatus })); } catch (e) { toast.show(e instanceof Error ? e.message : "Check failed", "error"); } finally { setBusy(false); } }; const skipBlock = async (block: ScheduledBlock) => { setBusy(true); try { setPlan( await checkPlanBlock(day, block.id, { status: "skipped", skip_reason: "other", }), ); } catch (e) { toast.show(e instanceof Error ? e.message : "Skip failed", "error"); } finally { setBusy(false); } }; const saveDayReview = async () => { setBusy(true); try { const next = await patchPlanMeta(day, { day_review: dayReview }); setPlan(next); toast.show("Day review saved"); } catch (e) { toast.show(e instanceof Error ? e.message : "Save failed", "error"); } finally { setBusy(false); } }; const capacity = plan?.capacity_hint ?? 1; const health = plan?.health; const empty = !plan?.blocks.length; const nowMin = new Date().getHours() * 60 + new Date().getMinutes(); const sorted = [...(plan?.blocks ?? [])].sort( (a, b) => parseMin(a.start) - parseMin(b.start), ); return (
setDay(shiftDay(day, -1))}>

Plan

{friendly(day)}
setDay(shiftDay(day, 1))}>
setDay(isoDate(new Date()))}> Today
Capacity {Math.round(capacity * 100)}% {health && ( Health {Math.round(health.score * 100)}% · v{plan?.version ?? 1} )} {!empty && ( {progress.done}/{progress.total} {progress.p0Total ? ` · P0 left ${progress.p0Left}` : ""} )}
{(plan?.title || plan?.intention) && (
{plan?.title ? {plan.title} : null} {plan?.intention ?

{plan.intention}

: null}
)} {capacity < 0.55 && (

High load risk — lighter plan recommended.

)}

Paste the JSON your chat wrote. Check off when done in the real world. Export and paste back for review.

{empty ? (

No blocks yet. Paste a plan from chat, seed a starter day, or add one block.

) : ( <>

Checklist

{sorted.map((block) => { const overdue = day === isoDate(new Date()) && block.status === "planned" && parseMin(block.end) < nowMin; const checked = block.status === "done" || block.status === "partial"; return (
void toggleCheck(block)} > {checked ? "✓" : "○"} openBlock(block)}> {block.priority === "P0" ? "P0 · " : ""} {block.title} {block.status === "planned" && ( void skipBlock(block)}> Skip )}
); })}
)}

Day review

One line is enough. FSE = fear / shame / embarrassment spike — short phrase.