Fastwhisper / frontend /src /pages /Plan.tsx
Mbonea's picture
Add ChatPlan paste/export loop with check-off and FSE annotations.
fb29daa
Raw
History Blame Contribute Delete
15.4 kB
/** 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>
);
}