Spaces:
Sleeping
Sleeping
| /** Fast Log page: save an entry; high-FSE offers proof brick; pending offers Finish. */ | |
| import { useEffect, useMemo, useState } from "preact/hooks"; | |
| import { | |
| ApiError, | |
| createEntry, | |
| getDaily, | |
| putDaily, | |
| type DailyUpsert, | |
| type ProofBrickKind, | |
| } from "../api"; | |
| import { Button } from "../components/Button"; | |
| import { Pressable } from "../components/Pressable"; | |
| import { ResultChips, type ResultValue } from "../components/ResultChips"; | |
| import { Sheet } from "../components/Sheet"; | |
| import { usePendingResolve } from "../components/PendingResolve"; | |
| import { useToast } from "../components/Toast"; | |
| import { isoDate } from "../dates"; | |
| import { navigate } from "../router"; | |
| const EMOTION_PICKS = [ | |
| "calm", | |
| "shame", | |
| "urge", | |
| "anxiety", | |
| "anger", | |
| "lonely", | |
| "tired", | |
| "hope", | |
| ]; | |
| const TAG_SUGGESTS = [ | |
| "indoors", | |
| "freeze", | |
| "movement", | |
| "interrupt_walk", | |
| "fantasy_walk", | |
| "headphones", | |
| "proof", | |
| ]; | |
| const FSE_EMOTIONS = new Set(["shame", "urge", "anxiety", "anger", "lonely"]); | |
| const FSE_TAGS = new Set([ | |
| "freeze", | |
| "indoors", | |
| "rerun", | |
| "court", | |
| "daydream", | |
| "fantasy", | |
| "fantasy_walk", | |
| ]); | |
| const AVOIDANCE_PICKS = [ | |
| "freeze", | |
| "bed", | |
| "scroll", | |
| "fantasy", | |
| "headphones", | |
| "court", | |
| "rerun", | |
| ]; | |
| const PROOF_KINDS: { value: ProofBrickKind; label: string }[] = [ | |
| { value: "interrupt_walk", label: "Interrupt walk" }, | |
| { value: "body_or_room", label: "Body or room" }, | |
| { value: "trip_admin", label: "Trip admin" }, | |
| { value: "earn", label: "Earn" }, | |
| { value: "boundary", label: "Boundary" }, | |
| { value: "food", label: "Food" }, | |
| { value: "survive", label: "Survive" }, | |
| { value: "other", label: "Other" }, | |
| ]; | |
| function isHighFse(intensity: number, emotions: string[], tagList: string[]): boolean { | |
| if (intensity < 7) return false; | |
| const emos = emotions.map((e) => e.toLowerCase()); | |
| const tags = tagList.map((t) => t.toLowerCase()); | |
| return emos.some((e) => FSE_EMOTIONS.has(e)) || tags.some((t) => FSE_TAGS.has(t)); | |
| } | |
| function isLabelOnlyRemedy(remedy: string): boolean { | |
| return /\blabel(?:ing|ling)?\b/i.test(remedy.trim()); | |
| } | |
| const EMPTY_DAILY_PATCH: Partial<DailyUpsert> = { | |
| primary_brick: "none", | |
| brick_done: false, | |
| corn_sessions: 0, | |
| delay_ok: true, | |
| daydream: "none", | |
| rerun: "clean", | |
| court: "closed", | |
| stayed_indoors_all_day: false, | |
| left_room: false, | |
| left_home: false, | |
| movement_minutes: 0, | |
| interrupt_walk_minutes: 0, | |
| fantasy_walk_minutes: 0, | |
| headphones_on_walk: false, | |
| music_cinematic_on_walk: false, | |
| proof_brick_done: false, | |
| proof_brick_kind: null, | |
| fantasy_minutes_scheduled: 0, | |
| fantasy_minutes_unplanned: 0, | |
| note: "", | |
| }; | |
| export function Log({ | |
| initialRemedy = "", | |
| initialHappened = "", | |
| }: { | |
| initialRemedy?: string; | |
| initialHappened?: string; | |
| }) { | |
| const toast = useToast(); | |
| const { openResolveId } = usePendingResolve(); | |
| const [happened, setHappened] = useState(initialHappened); | |
| const [emotions, setEmotions] = useState<string[]>([]); | |
| const [intensity, setIntensity] = useState(5); | |
| const [result, setResult] = useState<ResultValue>("pending"); | |
| const [remedy, setRemedy] = useState(initialRemedy); | |
| const [activity, setActivity] = useState(""); | |
| const [tags, setTags] = useState(""); | |
| const [notes, setNotes] = useState(""); | |
| const [moreOpen, setMoreOpen] = useState(false); | |
| const [saving, setSaving] = useState(false); | |
| const [finishPrompt, setFinishPrompt] = useState<{ id: string } | null>(null); | |
| const [proofOpen, setProofOpen] = useState(false); | |
| const [proofBusy, setProofBusy] = useState(false); | |
| const [avoidance, setAvoidance] = useState<string[]>([]); | |
| const [pendingAfterProof, setPendingAfterProof] = useState<{ | |
| id: string; | |
| pending: boolean; | |
| } | null>(null); | |
| useEffect(() => { | |
| setRemedy(initialRemedy); | |
| }, [initialRemedy]); | |
| useEffect(() => { | |
| if (initialHappened) setHappened(initialHappened); | |
| }, [initialHappened]); | |
| const canSave = useMemo( | |
| () => happened.trim().length > 0 && !saving, | |
| [happened, saving], | |
| ); | |
| const tagList = useMemo( | |
| () => | |
| tags | |
| .split(",") | |
| .map((t) => t.trim()) | |
| .filter(Boolean), | |
| [tags], | |
| ); | |
| const toggleEmotion = (emotion: string) => { | |
| setEmotions((current) => | |
| current.includes(emotion) | |
| ? current.filter((item) => item !== emotion) | |
| : current.length >= 12 | |
| ? current | |
| : [...current, emotion], | |
| ); | |
| }; | |
| const toggleTag = (tag: string) => { | |
| const parts = tagList; | |
| const lower = parts.map((t) => t.toLowerCase()); | |
| if (lower.includes(tag)) { | |
| setTags(parts.filter((t) => t.toLowerCase() !== tag).join(", ")); | |
| } else { | |
| setTags([...parts, tag].join(", ")); | |
| } | |
| }; | |
| const afterSaveNavigate = (entryId: string, isPending: boolean) => { | |
| if (isPending) { | |
| setFinishPrompt({ id: entryId }); | |
| } else { | |
| navigate("/"); | |
| } | |
| }; | |
| const labelOnlyWarn = | |
| intensity >= 7 && remedy.trim().length > 0 && isLabelOnlyRemedy(remedy); | |
| const toggleAvoidance = (tag: string) => { | |
| setAvoidance((current) => | |
| current.includes(tag) | |
| ? current.filter((item) => item !== tag) | |
| : [...current, tag], | |
| ); | |
| }; | |
| const onSave = async () => { | |
| if (!canSave) return; | |
| setSaving(true); | |
| try { | |
| const high = isHighFse(intensity, emotions, tagList); | |
| const entry = await createEntry({ | |
| activity: activity.trim() || "Log", | |
| happened: happened.trim(), | |
| emotions, | |
| intensity, | |
| remedy: remedy.trim(), | |
| result, | |
| tags: tagList, | |
| notes: notes.trim(), | |
| fse_spike: high || intensity >= 7 ? true : null, | |
| avoidance_types: avoidance, | |
| walk_type: tagList.includes("interrupt_walk") | |
| ? "interrupt" | |
| : tagList.includes("fantasy_walk") | |
| ? "fantasy" | |
| : null, | |
| }); | |
| toast.show("Saved"); | |
| if ( | |
| typeof navigator !== "undefined" && | |
| "vibrate" in navigator && | |
| !window.matchMedia("(prefers-reduced-motion: reduce)").matches | |
| ) { | |
| navigator.vibrate(10); | |
| } | |
| const pending = entry.result === "pending"; | |
| if (high) { | |
| setPendingAfterProof({ id: entry.id, pending }); | |
| setProofOpen(true); | |
| } else { | |
| afterSaveNavigate(entry.id, pending); | |
| } | |
| } catch (err) { | |
| const message = | |
| err instanceof ApiError ? err.message : "Could not save entry"; | |
| toast.show(message, "error"); | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| const closeProof = () => { | |
| setProofOpen(false); | |
| const next = pendingAfterProof; | |
| setPendingAfterProof(null); | |
| if (next) afterSaveNavigate(next.id, next.pending); | |
| else navigate("/"); | |
| }; | |
| const saveProof = async (kind: ProofBrickKind) => { | |
| setProofBusy(true); | |
| const today = isoDate(new Date()); | |
| try { | |
| let base: DailyUpsert = { ...EMPTY_DAILY_PATCH } as DailyUpsert; | |
| try { | |
| const row = await getDaily(today); | |
| base = { | |
| primary_brick: row.primary_brick, | |
| brick_done: row.brick_done, | |
| corn_sessions: row.corn_sessions, | |
| delay_ok: row.delay_ok, | |
| daydream: row.daydream, | |
| rerun: row.rerun, | |
| court: row.court, | |
| stayed_indoors_all_day: Boolean(row.stayed_indoors_all_day), | |
| left_room: | |
| Boolean(row.left_room) || | |
| kind === "body_or_room" || | |
| kind === "leave_room", | |
| left_home: Boolean(row.left_home), | |
| movement_minutes: row.movement_minutes ?? 0, | |
| interrupt_walk_minutes: row.interrupt_walk_minutes ?? 0, | |
| fantasy_walk_minutes: row.fantasy_walk_minutes ?? 0, | |
| headphones_on_walk: Boolean(row.headphones_on_walk), | |
| music_cinematic_on_walk: Boolean(row.music_cinematic_on_walk), | |
| proof_brick_done: true, | |
| proof_brick_kind: kind, | |
| fantasy_minutes_scheduled: row.fantasy_minutes_scheduled ?? 0, | |
| fantasy_minutes_unplanned: row.fantasy_minutes_unplanned ?? 0, | |
| note: row.note || "", | |
| }; | |
| } catch { | |
| base = { | |
| ...(EMPTY_DAILY_PATCH as DailyUpsert), | |
| left_room: kind === "body_or_room" || kind === "leave_room", | |
| proof_brick_done: true, | |
| proof_brick_kind: kind, | |
| }; | |
| } | |
| if (kind === "interrupt_walk" && !base.interrupt_walk_minutes) { | |
| base.interrupt_walk_minutes = 10; | |
| base.movement_minutes = | |
| (base.interrupt_walk_minutes || 0) + (base.fantasy_walk_minutes || 0); | |
| } | |
| await putDaily(today, base); | |
| toast.show("Proof noted on Daily"); | |
| closeProof(); | |
| } catch { | |
| try { | |
| await createEntry({ | |
| activity: "Proof brick", | |
| happened: `Proof step: ${kind}`, | |
| emotions: [], | |
| intensity: Math.min(intensity, 5), | |
| remedy: kind.replace(/_/g, " "), | |
| result: "worked", | |
| tags: ["proof", kind], | |
| }); | |
| toast.show("Proof logged"); | |
| closeProof(); | |
| } catch (err) { | |
| toast.show( | |
| err instanceof ApiError ? err.message : "Could not save proof", | |
| "error", | |
| ); | |
| } | |
| } finally { | |
| setProofBusy(false); | |
| } | |
| }; | |
| return ( | |
| <div class="app-shell"> | |
| <header class="top-bar material-bar"> | |
| <Pressable | |
| className="btn btn-plain" | |
| ariaLabel="Back" | |
| onClick={() => navigate("/")} | |
| > | |
| ‹ Back | |
| </Pressable> | |
| <h1 class="page-title">Log</h1> | |
| </header> | |
| <main class="page stack"> | |
| <div> | |
| <label class="field-label" for="happened"> | |
| Happened | |
| </label> | |
| <textarea | |
| id="happened" | |
| class="field-textarea" | |
| autofocus | |
| required | |
| value={happened} | |
| onInput={(e) => setHappened((e.target as HTMLTextAreaElement).value)} | |
| placeholder="What happened?" | |
| /> | |
| </div> | |
| <div> | |
| <span class="field-label" id="emotions-label"> | |
| Emotions | |
| </span> | |
| <div class="chip-row" role="group" aria-labelledby="emotions-label"> | |
| {EMOTION_PICKS.map((emotion) => ( | |
| <Pressable | |
| key={emotion} | |
| className="chip" | |
| ariaPressed={emotions.includes(emotion)} | |
| onClick={() => toggleEmotion(emotion)} | |
| > | |
| {emotion} | |
| </Pressable> | |
| ))} | |
| </div> | |
| </div> | |
| <div class="slider-wrap"> | |
| <label class="field-label" for="intensity"> | |
| Intensity · {intensity} | |
| </label> | |
| <input | |
| id="intensity" | |
| type="range" | |
| min={1} | |
| max={10} | |
| step={1} | |
| value={intensity} | |
| onInput={(e) => | |
| setIntensity(Number((e.target as HTMLInputElement).value)) | |
| } | |
| /> | |
| </div> | |
| <div> | |
| <span class="field-label">Result</span> | |
| <ResultChips value={result} onChange={setResult} /> | |
| </div> | |
| <div> | |
| <label class="field-label" for="remedy"> | |
| Remedy | |
| </label> | |
| <input | |
| id="remedy" | |
| class="field-input" | |
| type="text" | |
| value={remedy} | |
| onInput={(e) => setRemedy((e.target as HTMLInputElement).value)} | |
| placeholder="One concrete action — not more labeling at high heat" | |
| /> | |
| {labelOnlyWarn ? ( | |
| <p class="muted" role="status"> | |
| Label once (30s). Proof is visible outside your head. At intensity{" "} | |
| {intensity}, pick a proof brick — not labeling-only. | |
| </p> | |
| ) : null} | |
| </div> | |
| {intensity >= 7 ? ( | |
| <div> | |
| <span class="field-label">Avoidance (optional)</span> | |
| <div class="chip-row" role="group" aria-label="Avoidance"> | |
| {AVOIDANCE_PICKS.map((tag) => ( | |
| <Pressable | |
| key={tag} | |
| className="chip" | |
| ariaPressed={avoidance.includes(tag)} | |
| onClick={() => toggleAvoidance(tag)} | |
| > | |
| {tag} | |
| </Pressable> | |
| ))} | |
| </div> | |
| <p class="muted">Preview, not proof. Proof brick comes after save.</p> | |
| </div> | |
| ) : null} | |
| <div> | |
| <span class="field-label">Tags</span> | |
| <div class="chip-row" role="group" aria-label="Suggested tags"> | |
| {TAG_SUGGESTS.map((tag) => ( | |
| <Pressable | |
| key={tag} | |
| className="chip" | |
| ariaPressed={tagList.map((t) => t.toLowerCase()).includes(tag)} | |
| onClick={() => toggleTag(tag)} | |
| > | |
| {tag} | |
| </Pressable> | |
| ))} | |
| </div> | |
| </div> | |
| <Button disabled={!canSave} onClick={onSave} className="btn-large"> | |
| {saving ? "Saving…" : "Save entry"} | |
| </Button> | |
| <Pressable | |
| className="btn btn-plain" | |
| ariaPressed={moreOpen} | |
| onClick={() => setMoreOpen((open) => !open)} | |
| > | |
| {moreOpen ? "Hide more" : "More"} | |
| </Pressable> | |
| {moreOpen ? ( | |
| <div class="more-block stack"> | |
| <div> | |
| <label class="field-label" for="activity"> | |
| Activity | |
| </label> | |
| <input | |
| id="activity" | |
| class="field-input" | |
| type="text" | |
| value={activity} | |
| onInput={(e) => | |
| setActivity((e.target as HTMLInputElement).value) | |
| } | |
| /> | |
| </div> | |
| <div> | |
| <label class="field-label" for="tags"> | |
| More tags | |
| </label> | |
| <input | |
| id="tags" | |
| class="field-input" | |
| type="text" | |
| value={tags} | |
| onInput={(e) => setTags((e.target as HTMLInputElement).value)} | |
| placeholder="comma,separated" | |
| /> | |
| </div> | |
| <div> | |
| <label class="field-label" for="notes"> | |
| Notes | |
| </label> | |
| <textarea | |
| id="notes" | |
| class="field-textarea" | |
| value={notes} | |
| onInput={(e) => | |
| setNotes((e.target as HTMLTextAreaElement).value) | |
| } | |
| /> | |
| </div> | |
| </div> | |
| ) : null} | |
| </main> | |
| <Sheet | |
| open={proofOpen} | |
| title="One proof step (not more labeling)" | |
| onClose={closeProof} | |
| > | |
| <div class="stack"> | |
| <p class="muted"> | |
| High spike logged. Pick one small real step you can do now — body, | |
| room, message, or interrupt walk. | |
| </p> | |
| <div class="choice-list"> | |
| {PROOF_KINDS.map((opt) => ( | |
| <Pressable | |
| key={opt.value} | |
| className="choice-item" | |
| disabled={proofBusy} | |
| onClick={() => void saveProof(opt.value)} | |
| > | |
| {opt.label} | |
| </Pressable> | |
| ))} | |
| </div> | |
| <Button className="btn-secondary" disabled={proofBusy} onClick={closeProof}> | |
| Skip for now | |
| </Button> | |
| </div> | |
| </Sheet> | |
| <Sheet | |
| open={Boolean(finishPrompt)} | |
| title="Finish now?" | |
| onClose={() => { | |
| setFinishPrompt(null); | |
| navigate("/"); | |
| }} | |
| > | |
| <div class="stack"> | |
| <p class="muted">Set a result for this entry, or leave it pending.</p> | |
| <Button | |
| className="btn-large" | |
| onClick={() => { | |
| const id = finishPrompt?.id; | |
| setFinishPrompt(null); | |
| if (id) openResolveId(id); | |
| navigate("/"); | |
| }} | |
| > | |
| Finish now | |
| </Button> | |
| <Button | |
| variant="secondary" | |
| onClick={() => { | |
| setFinishPrompt(null); | |
| navigate("/"); | |
| }} | |
| > | |
| Later | |
| </Button> | |
| </div> | |
| </Sheet> | |
| </div> | |
| ); | |
| } | |