Mbonea's picture
Close P1 arch-code gaps: loop summary, template-echo Rules, Move now, proof kinds.
57ed4c2
Raw
History Blame Contribute Delete
16.4 kB
/** 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>
);
}