Spaces:
Sleeping
Sleeping
| /** 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> | |
| ); | |
| } | |