Spaces:
Sleeping
Sleeping
| /** 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<DayPlanView | null>(null); | |
| const [busy, setBusy] = useState(false); | |
| const [selected, setSelected] = useState<ScheduledBlock | null>(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<DayReview>(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 ( | |
| <div class="app-shell"> | |
| <header class="top-bar material-bar plan-header"> | |
| <div class="plan-day-nav"> | |
| <Pressable className="icon-btn" ariaLabel="Previous day" onClick={() => setDay(shiftDay(day, -1))}> | |
| <ChevronLeft size={22} /> | |
| </Pressable> | |
| <div> | |
| <h1 class="page-title">Plan</h1> | |
| <span class="daily-date">{friendly(day)}</span> | |
| </div> | |
| <Pressable className="icon-btn" ariaLabel="Next day" onClick={() => setDay(shiftDay(day, 1))}> | |
| <ChevronRight size={22} /> | |
| </Pressable> | |
| </div> | |
| <Pressable className="chip-btn" onClick={() => setDay(isoDate(new Date()))}> | |
| Today | |
| </Pressable> | |
| </header> | |
| <main class="page stack plan-page"> | |
| <div class="plan-meta-row"> | |
| <span class="caption">Capacity {Math.round(capacity * 100)}%</span> | |
| {health && ( | |
| <span class="caption"> | |
| Health {Math.round(health.score * 100)}% · v{plan?.version ?? 1} | |
| </span> | |
| )} | |
| {!empty && ( | |
| <span class="caption"> | |
| {progress.done}/{progress.total} | |
| {progress.p0Total ? ` · P0 left ${progress.p0Left}` : ""} | |
| </span> | |
| )} | |
| </div> | |
| {(plan?.title || plan?.intention) && ( | |
| <section class="tip-card" role="status"> | |
| {plan?.title ? <strong>{plan.title}</strong> : null} | |
| {plan?.intention ? <p class="muted">{plan.intention}</p> : null} | |
| </section> | |
| )} | |
| {capacity < 0.55 && ( | |
| <div class="tip-card" role="status"> | |
| <p>High load risk — lighter plan recommended.</p> | |
| </div> | |
| )} | |
| <div class="plan-actions"> | |
| <Button className="btn-secondary" disabled={busy} onClick={() => setPasteOpen(true)}> | |
| Paste plan | |
| </Button> | |
| <Button className="btn-secondary" disabled={busy || empty} onClick={onExport}> | |
| Export / Copy | |
| </Button> | |
| <Button className="btn-secondary" disabled={busy} onClick={openCreate}> | |
| + Block | |
| </Button> | |
| <Button className="btn-secondary" disabled={busy} onClick={onReschedule}> | |
| Reschedule | |
| </Button> | |
| </div> | |
| <p class="muted"> | |
| Paste the JSON your chat wrote. Check off when done in the real world. Export and paste | |
| back for review. | |
| </p> | |
| {empty ? ( | |
| <section class="surface-card empty-card stack"> | |
| <p>No blocks yet. Paste a plan from chat, seed a starter day, or add one block.</p> | |
| <Button disabled={busy} onClick={() => setPasteOpen(true)}> | |
| Paste plan from chat | |
| </Button> | |
| <Button className="btn-secondary" disabled={busy} onClick={onSeed}> | |
| Seed starter day | |
| </Button> | |
| <Button className="btn-secondary" disabled={busy} onClick={openCreate}> | |
| Add block | |
| </Button> | |
| </section> | |
| ) : ( | |
| <> | |
| <section class="surface-card stack" aria-label="Checklist"> | |
| <h2 class="section-title">Checklist</h2> | |
| {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 ( | |
| <div | |
| key={block.id} | |
| class={`entry-row ${overdue ? "indoors-nudge" : ""}`.trim()} | |
| > | |
| <Pressable | |
| className="chip" | |
| ariaPressed={checked} | |
| ariaLabel={checked ? "Mark planned" : "Mark done"} | |
| onClick={() => void toggleCheck(block)} | |
| > | |
| {checked ? "✓" : "○"} | |
| </Pressable> | |
| <Pressable className="entry-row-main" onClick={() => openBlock(block)}> | |
| <span class="entry-preview"> | |
| {block.priority === "P0" ? "P0 · " : ""} | |
| {block.title} | |
| </span> | |
| <time> | |
| {block.start}–{block.end} | |
| {overdue ? " · overdue" : ""} | |
| </time> | |
| </Pressable> | |
| {block.status === "planned" && ( | |
| <Pressable className="chip-btn" onClick={() => void skipBlock(block)}> | |
| Skip | |
| </Pressable> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </section> | |
| <section class="surface-card timeline-card"> | |
| <Timeline blocks={plan!.blocks} onSelect={openBlock} /> | |
| </section> | |
| </> | |
| )} | |
| <section class="surface-card form-card stack" aria-label="Day review"> | |
| <h2 class="section-title">Day review</h2> | |
| <p class="muted">One line is enough. FSE = fear / shame / embarrassment spike — short phrase.</p> | |
| <label class="field-label" for="dr-comment"> | |
| Comment | |
| </label> | |
| <textarea | |
| id="dr-comment" | |
| class="field-textarea" | |
| value={dayReview.comment} | |
| onInput={(e) => | |
| setDayReview({ ...dayReview, comment: (e.target as HTMLTextAreaElement).value }) | |
| } | |
| /> | |
| <label class="field-label" for="dr-fse"> | |
| FSE events | |
| </label> | |
| <input | |
| id="dr-fse" | |
| class="field-input" | |
| value={dayReview.fse_events} | |
| onInput={(e) => | |
| setDayReview({ ...dayReview, fse_events: (e.target as HTMLInputElement).value }) | |
| } | |
| /> | |
| <label class="field-label" for="dr-moved"> | |
| What moved | |
| </label> | |
| <input | |
| id="dr-moved" | |
| class="field-input" | |
| value={dayReview.what_moved} | |
| onInput={(e) => | |
| setDayReview({ ...dayReview, what_moved: (e.target as HTMLInputElement).value }) | |
| } | |
| /> | |
| <label class="field-label" for="dr-avoided"> | |
| What avoided | |
| </label> | |
| <input | |
| id="dr-avoided" | |
| class="field-input" | |
| value={dayReview.what_avoided} | |
| onInput={(e) => | |
| setDayReview({ ...dayReview, what_avoided: (e.target as HTMLInputElement).value }) | |
| } | |
| /> | |
| <label class="field-label" for="dr-tomorrow"> | |
| Tomorrow change | |
| </label> | |
| <input | |
| id="dr-tomorrow" | |
| class="field-input" | |
| value={dayReview.tomorrow_change} | |
| onInput={(e) => | |
| setDayReview({ | |
| ...dayReview, | |
| tomorrow_change: (e.target as HTMLInputElement).value, | |
| }) | |
| } | |
| /> | |
| <Button disabled={busy} onClick={() => void saveDayReview()}> | |
| Save day review | |
| </Button> | |
| </section> | |
| </main> | |
| <BlockSheet | |
| open={sheetOpen} | |
| day={day} | |
| block={selected} | |
| mode={sheetMode} | |
| onClose={() => setSheetOpen(false)} | |
| onChanged={() => void load(day)} | |
| /> | |
| <Sheet open={pasteOpen} title="Paste plan" onClose={() => setPasteOpen(false)} tall> | |
| <div class="stack"> | |
| <p class="muted">Paste the JSON your chat wrote.</p> | |
| <textarea | |
| class="field-textarea" | |
| rows={14} | |
| value={pasteText} | |
| onInput={(e) => setPasteText((e.target as HTMLTextAreaElement).value)} | |
| placeholder='{ "schema_version": 1, "blocks": [ ... ] }' | |
| /> | |
| <span class="field-label">Mode</span> | |
| <div class="chip-row" role="group"> | |
| <Pressable | |
| className="chip" | |
| ariaPressed={pasteMode === "replace"} | |
| onClick={() => setPasteMode("replace")} | |
| > | |
| Replace | |
| </Pressable> | |
| <Pressable | |
| className="chip" | |
| ariaPressed={pasteMode === "merge"} | |
| onClick={() => setPasteMode("merge")} | |
| > | |
| Merge | |
| </Pressable> | |
| </div> | |
| {pasteError ? <p class="muted" role="alert">{pasteError}</p> : null} | |
| <Button disabled={busy || !pasteText.trim()} onClick={() => void onPasteSubmit()}> | |
| Import plan | |
| </Button> | |
| </div> | |
| </Sheet> | |
| </div> | |
| ); | |
| } | |