Fastwhisper / frontend /src /components /BlockSheet.tsx
Mbonea's picture
Add ChatPlan paste/export loop with check-off and FSE annotations.
fb29daa
Raw
History Blame Contribute Delete
15 kB
/** Block detail + thick feedback sheet for schedule blocks. */
import { useEffect, useState } from "preact/hooks";
import {
addPlanBlock,
deletePlanBlock,
getScheduleTemplates,
postBlockFeedback,
type ScheduledBlock,
type ScheduleTemplate,
type TaskKind,
} from "../api";
import { Button } from "./Button";
import { Pressable } from "./Pressable";
import { Sheet } from "./Sheet";
import { useToast } from "./Toast";
const KIND_OPTIONS: { value: TaskKind; label: string }[] = [
{ value: "earn_ship", label: "Work / earn" },
{ value: "admin_spain", label: "Trip / admin" },
{ value: "body_care", label: "Body or room" },
{ value: "move_out", label: "Move-out step" },
{ value: "food_out", label: "Food" },
{ value: "stabilize", label: "Stabilize / reset" },
{ value: "explore", label: "Try something new" },
{ value: "restore_fun", label: "Fun recharge" },
{ value: "boundary", label: "Boundary practice" },
{ value: "sleep_window", label: "Sleep wind-down" },
{ value: "other", label: "Other" },
];
function addMinutes(hhmm: string, mins: number): string {
const [h, m] = hhmm.split(":").map(Number);
const total = h * 60 + m + mins;
const nh = Math.floor(((total % (24 * 60)) + 24 * 60) % (24 * 60) / 60);
const nm = ((total % (24 * 60)) + 24 * 60) % (24 * 60) % 60;
return `${String(nh).padStart(2, "0")}:${String(nm).padStart(2, "0")}`;
}
type Props = {
open: boolean;
day: string;
block: ScheduledBlock | null;
mode: "view" | "create";
onClose: () => void;
onChanged: () => void;
};
export function BlockSheet({ open, day, block, mode, onClose, onChanged }: Props) {
const toast = useToast();
const [did, setDid] = useState<"done" | "partial" | "skipped">("done");
const [actualMin, setActualMin] = useState(30);
const [quality, setQuality] = useState(3);
const [fun, setFun] = useState(3);
const [energy, setEnergy] = useState(0);
const [wouldRepeat, setWouldRepeat] = useState<"yes" | "no" | "maybe" | "">("");
const [skipReason, setSkipReason] = useState("");
const [note, setNote] = useState("");
const [money, setMoney] = useState("");
const [emotions, setEmotions] = useState<string[]>([]);
const [fseEvent, setFseEvent] = useState("");
const [intensity, setIntensity] = useState(5);
const [showThick, setShowThick] = useState(false);
const [busy, setBusy] = useState(false);
const [templates, setTemplates] = useState<ScheduleTemplate[]>([]);
const [title, setTitle] = useState("New block");
const [kind, setKind] = useState<TaskKind>("other");
const [start, setStart] = useState("09:00");
const [end, setEnd] = useState("09:30");
const [priority, setPriority] = useState<"P0" | "P1" | "P2">("P2");
const [intent, setIntent] = useState<ScheduledBlock["intent"]>("duty");
useEffect(() => {
if (!open) return;
if (mode === "create") {
void getScheduleTemplates()
.then((r) => setTemplates(r.items || []))
.catch(() => setTemplates([]));
}
if (mode === "view" && block) {
setActualMin(block.feedback?.actual_min ?? block.planned_min ?? 30);
setDid((block.feedback?.did as typeof did) || (block.status === "skipped" ? "skipped" : "done"));
setQuality(block.feedback?.quality ?? 3);
setFun(block.feedback?.fun ?? 3);
setEnergy(block.feedback?.energy_after ?? 0);
setWouldRepeat((block.feedback?.would_repeat as typeof wouldRepeat) || "");
setSkipReason(block.feedback?.skip_reason || "");
setNote(block.feedback?.note || "");
setMoney(block.feedback?.money_amount != null ? String(block.feedback.money_amount) : "");
setEmotions(block.feedback?.emotions || []);
setFseEvent(block.feedback?.fse_event || "");
setIntensity(block.feedback?.intensity ?? 5);
setShowThick(false);
}
if (mode === "create") {
setTitle("New block");
setKind("other");
setStart("09:00");
setEnd("09:30");
setPriority("P2");
setIntent("duty");
}
}, [open, mode, block?.id]);
const applyTemplate = (tmpl: ScheduleTemplate) => {
setTitle(tmpl.title);
setKind(tmpl.kind);
setPriority((tmpl.priority as "P0" | "P1" | "P2") || "P2");
setIntent(
(tmpl.intent as ScheduledBlock["intent"]) ||
(tmpl.kind === "explore"
? "explore"
: tmpl.kind === "restore_fun"
? "restore_fun"
: "duty"),
);
setEnd(addMinutes(start, tmpl.default_min || 30));
};
const saveFeedback = async () => {
if (!block) return;
setBusy(true);
try {
await postBlockFeedback(day, block.id, {
did,
actual_min: did === "skipped" ? null : actualMin,
quality: did === "skipped" || !showThick ? null : quality,
fun: did === "skipped" || !showThick ? null : fun,
energy_after: showThick ? energy : null,
money_amount: money ? Number(money) : null,
would_repeat: wouldRepeat || null,
skip_reason: did === "skipped" ? skipReason || "other" : null,
note,
emotions,
fse_event: fseEvent,
intensity: did === "skipped" ? null : intensity,
});
toast.show("Saved");
onChanged();
onClose();
} catch (e) {
toast.show(e instanceof Error ? e.message : "Save failed", "error");
} finally {
setBusy(false);
}
};
const createBlock = async () => {
setBusy(true);
try {
await addPlanBlock(day, {
title,
kind,
start,
end,
priority,
intent:
kind === "explore"
? "explore"
: kind === "restore_fun"
? "restore_fun"
: kind === "stabilize"
? "measure"
: intent,
locked: priority === "P0",
});
toast.show("Block added");
onChanged();
onClose();
} catch (e) {
toast.show(e instanceof Error ? e.message : "Could not add", "error");
} finally {
setBusy(false);
}
};
const removeBlock = async () => {
if (!block || !confirm("Remove this block?")) return;
setBusy(true);
try {
await deletePlanBlock(day, block.id);
toast.show("Removed");
onChanged();
onClose();
} catch (e) {
toast.show(e instanceof Error ? e.message : "Delete failed", "error");
} finally {
setBusy(false);
}
};
return (
<Sheet
open={open}
tall
title={mode === "create" ? "Add block" : block?.title || "Block"}
onClose={onClose}
>
{mode === "create" ? (
<div class="stack">
{templates.length > 0 && (
<>
<span class="field-label">From template</span>
<div class="choice-list">
{templates.slice(0, 8).map((tmpl) => (
<Pressable
key={tmpl.id}
className="choice-item"
onClick={() => applyTemplate(tmpl)}
>
{tmpl.title}
</Pressable>
))}
</div>
</>
)}
<label class="field-label">
Title
<input class="field-input" value={title} onInput={(e) => setTitle((e.target as HTMLInputElement).value)} />
</label>
<span class="field-label">Type</span>
<div class="choice-list">
{KIND_OPTIONS.map((opt) => (
<Pressable
key={opt.value}
className="choice-item"
ariaPressed={kind === opt.value}
onClick={() => setKind(opt.value)}
>
{opt.label}
</Pressable>
))}
</div>
<div class="plan-time-row">
<label class="field-label">
Start
<input class="field-input" type="time" value={start} onInput={(e) => setStart((e.target as HTMLInputElement).value)} />
</label>
<label class="field-label">
End
<input class="field-input" type="time" value={end} onInput={(e) => setEnd((e.target as HTMLInputElement).value)} />
</label>
</div>
<span class="field-label">Priority</span>
<div class="segment-row">
{(["P0", "P1", "P2"] as const).map((p) => (
<Pressable key={p} className="segment" ariaPressed={priority === p} onClick={() => setPriority(p)}>
{p === "P0" ? "Locked" : p === "P1" ? "Important" : "Flex"}
</Pressable>
))}
</div>
<div class="resolve-sticky">
<Button className="btn-large" disabled={busy} onClick={createBlock}>
{busy ? "Saving…" : "Add block"}
</Button>
</div>
</div>
) : block ? (
<div class="stack">
<p class="meta-line">
{block.start}–{block.end} · {block.label || block.kind} ·{" "}
{block.priority === "P0" ? "Locked" : block.priority}
{block.locked ? " · locked" : ""}
</p>
<span class="section-title">How did it go?</span>
<div class="segment-row">
{([
["done", "Done"],
["partial", "Partial"],
["skipped", "Skipped"],
] as const).map(([value, label]) => (
<Pressable
key={value}
className="segment"
ariaPressed={did === value}
onClick={() => setDid(value)}
>
{label}
</Pressable>
))}
</div>
{did !== "skipped" && (
<label class="field-label">
Actual minutes
<input
class="field-input"
type="number"
min={0}
max={24 * 60}
value={actualMin}
onInput={(e) => setActualMin(Number((e.target as HTMLInputElement).value))}
/>
</label>
)}
<label class="field-label">
Comment
<textarea
class="field-textarea"
value={note}
onInput={(e) => setNote((e.target as HTMLTextAreaElement).value)}
placeholder="What happened?"
/>
</label>
<span class="field-label">Emotions</span>
<div class="chip-row" role="group">
{["calm", "shame", "urge", "anxiety", "anger", "lonely", "tired", "hope"].map((em) => (
<Pressable
key={em}
className="chip"
ariaPressed={emotions.includes(em)}
onClick={() =>
setEmotions((cur) =>
cur.includes(em) ? cur.filter((x) => x !== em) : [...cur, em],
)
}
>
{em}
</Pressable>
))}
</div>
<label class="field-label">
FSE event (short phrase)
<input
class="field-input"
value={fseEvent}
onInput={(e) => setFseEvent((e.target as HTMLInputElement).value)}
placeholder="What spiked?"
/>
</label>
{did !== "skipped" && (
<label class="field-label">
Intensity · {intensity}
<input
type="range"
min={1}
max={10}
value={intensity}
onInput={(e) => setIntensity(Number((e.target as HTMLInputElement).value))}
/>
</label>
)}
{did === "skipped" && (
<>
<span class="field-label">Why skipped?</span>
<div class="segment-row">
{["time", "fear", "urge", "boring", "fse", "other"].map((v) => (
<Pressable
key={v}
className="segment"
ariaPressed={skipReason === v}
onClick={() => setSkipReason(v)}
>
{v}
</Pressable>
))}
</div>
</>
)}
<Pressable className="btn btn-plain" onClick={() => setShowThick((v) => !v)}>
{showThick ? "Hide quality / fun / energy" : "More: quality, fun, energy, money"}
</Pressable>
{showThick && did !== "skipped" && (
<>
<span class="field-label">Quality</span>
<div class="segment-row">
{[1, 2, 3, 4, 5].map((n) => (
<Pressable key={n} className="segment" ariaPressed={quality === n} onClick={() => setQuality(n)}>
{n}
</Pressable>
))}
</div>
<span class="field-label">Fun</span>
<div class="segment-row">
{[1, 2, 3, 4, 5].map((n) => (
<Pressable key={n} className="segment" ariaPressed={fun === n} onClick={() => setFun(n)}>
{n}
</Pressable>
))}
</div>
<span class="field-label">Energy after</span>
<div class="segment-row">
{([-2, -1, 0, 1, 2] as const).map((v) => (
<Pressable
key={String(v)}
className="segment"
ariaPressed={energy === v}
onClick={() => setEnergy(v)}
>
{v > 0 ? `+${v}` : String(v)}
</Pressable>
))}
</div>
<span class="field-label">Would repeat?</span>
<div class="segment-row">
{(["yes", "maybe", "no"] as const).map((v) => (
<Pressable
key={v}
className="segment"
ariaPressed={wouldRepeat === v}
onClick={() => setWouldRepeat(v)}
>
{v}
</Pressable>
))}
</div>
<label class="field-label">
Money (optional)
<input
class="field-input"
type="number"
value={money}
onInput={(e) => setMoney((e.target as HTMLInputElement).value)}
/>
</label>
</>
)}
<div class="resolve-sticky">
<Button className="btn-large" disabled={busy} onClick={saveFeedback}>
{busy ? "Saving…" : "Save how it went"}
</Button>
<Button className="btn-secondary" disabled={busy} onClick={removeBlock}>
Remove block
</Button>
</div>
</div>
) : null}
</Sheet>
);
}