Spaces:
Sleeping
Sleeping
| /** Daily check-in in plain English; maps calm labels onto backend enums. */ | |
| import { X } from "lucide-preact"; | |
| import { useEffect, useState } from "preact/hooks"; | |
| import { | |
| getDaily, | |
| putDaily, | |
| type DailyRow, | |
| type DailyUpsert, | |
| type PrimaryBrick, | |
| type ProofBrickKind, | |
| } from "../api"; | |
| import { Button } from "../components/Button"; | |
| import { Pressable } from "../components/Pressable"; | |
| import { useToast } from "../components/Toast"; | |
| import { isoDate } from "../dates"; | |
| const EMPTY: 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: "", | |
| win: "", | |
| }; | |
| 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" }, | |
| ]; | |
| const BRICK_OPTIONS: { value: PrimaryBrick; label: string }[] = [ | |
| { value: "A", label: "Admin / trip task" }, | |
| { value: "B", label: "Body or room care" }, | |
| { value: "C", label: "Work / skill" }, | |
| { value: "D", label: "Home exit plan" }, | |
| { value: "E", label: "Held a boundary" }, | |
| { value: "S", label: "Just survived the day" }, | |
| { value: "none", label: "Nothing yet" }, | |
| ]; | |
| const DAYDREAM_OPTIONS: { value: DailyUpsert["daydream"]; label: string }[] = [ | |
| { value: "none", label: "No" }, | |
| { value: "done", label: "Yes, and I still did one real step" }, | |
| { value: "fc", label: "Yes, and I didn’t do a real step" }, | |
| ]; | |
| const RERUN_OPTIONS: { value: DailyUpsert["rerun"]; label: string }[] = [ | |
| { value: "clean", label: "I cut it short or didn’t go there" }, | |
| { value: "R", label: "I stayed stuck in it" }, | |
| ]; | |
| const COURT_OPTIONS: { value: DailyUpsert["court"]; label: string }[] = [ | |
| { value: "closed", label: "I closed it" }, | |
| { value: "court", label: "I kept prosecuting the case" }, | |
| ]; | |
| const TIP_KEY = "daily-tip-dismissed"; | |
| function ChoiceList<T extends string>({ | |
| options, | |
| value, | |
| onChange, | |
| }: { | |
| options: { value: T; label: string }[]; | |
| value: T; | |
| onChange: (v: T) => void; | |
| }) { | |
| return ( | |
| <div class="choice-list" role="radiogroup"> | |
| {options.map((option) => ( | |
| <Pressable | |
| key={option.value} | |
| className="choice-item" | |
| ariaPressed={option.value === value} | |
| onClick={() => onChange(option.value)} | |
| > | |
| {option.label} | |
| </Pressable> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| function Stepper({ | |
| value, | |
| onChange, | |
| }: { | |
| value: number; | |
| onChange: (v: number) => void; | |
| }) { | |
| return ( | |
| <div class="stepper-row"> | |
| <Pressable | |
| className="stepper-btn" | |
| ariaLabel="One less" | |
| disabled={value <= 0} | |
| onClick={() => onChange(Math.max(0, value - 1))} | |
| > | |
| − | |
| </Pressable> | |
| <strong class="stepper-value">{value >= 3 ? `${value}` : value}</strong> | |
| <Pressable | |
| className="stepper-btn" | |
| ariaLabel="One more" | |
| disabled={value >= 10} | |
| onClick={() => onChange(Math.min(10, value + 1))} | |
| > | |
| + | |
| </Pressable> | |
| </div> | |
| ); | |
| } | |
| function friendlyDate(day: string): string { | |
| return new Date(`${day}T00:00:00`).toLocaleDateString(undefined, { | |
| weekday: "long", | |
| month: "long", | |
| day: "numeric", | |
| }); | |
| } | |
| function breakdown(row: DailyRow): { label: string; earned: boolean; pts: number }[] { | |
| const cornOk = | |
| row.corn_sessions === 0 || (row.corn_sessions <= 1 && row.delay_ok); | |
| return [ | |
| { label: "Did the main action", earned: row.brick_done, pts: 2 }, | |
| { | |
| label: "No escapes — or just one, after waiting", | |
| earned: cornOk, | |
| pts: 1, | |
| }, | |
| { | |
| label: "Fantasy didn’t replace a real step", | |
| earned: row.daydream !== "fc", | |
| pts: 1, | |
| }, | |
| { label: "Cut the mental replay short", earned: row.rerun === "clean", pts: 1 }, | |
| { label: "Kept the case closed", earned: row.court === "closed", pts: 1 }, | |
| ]; | |
| } | |
| export function Daily() { | |
| const toast = useToast(); | |
| const [day, setDay] = useState(isoDate(new Date())); | |
| const [form, setForm] = useState<DailyUpsert>(EMPTY); | |
| const [saved, setSaved] = useState<DailyRow | null>(null); | |
| const [busy, setBusy] = useState(false); | |
| const [tipHidden, setTipHidden] = useState( | |
| () => localStorage.getItem(TIP_KEY) === "1", | |
| ); | |
| const load = async (date: string) => { | |
| setSaved(null); | |
| try { | |
| const row = await getDaily(date); | |
| setForm({ | |
| 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), | |
| 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: Boolean(row.proof_brick_done), | |
| proof_brick_kind: row.proof_brick_kind ?? null, | |
| fantasy_minutes_scheduled: row.fantasy_minutes_scheduled ?? 0, | |
| fantasy_minutes_unplanned: row.fantasy_minutes_unplanned ?? 0, | |
| note: row.note, | |
| win: row.win ?? "", | |
| }); | |
| setSaved(row); | |
| } catch { | |
| setForm(EMPTY); | |
| } | |
| }; | |
| useEffect(() => { | |
| void load(day); | |
| }, [day]); | |
| const save = async () => { | |
| setBusy(true); | |
| try { | |
| const row = await putDaily(day, form); | |
| setSaved(row); | |
| toast.show("Saved"); | |
| } catch (e) { | |
| toast.show(e instanceof Error ? e.message : "Save failed", "error"); | |
| } finally { | |
| setBusy(false); | |
| } | |
| }; | |
| return ( | |
| <div class="app-shell"> | |
| <header class="top-bar material-bar daily-header"> | |
| <div> | |
| <h1 class="page-title">Today</h1> | |
| <span class="daily-date">{friendlyDate(day)}</span> | |
| </div> | |
| <strong class="daily-points" aria-label="Points today"> | |
| {saved ? `${saved.points}/6` : "–/6"} | |
| </strong> | |
| </header> | |
| <main class="page stack"> | |
| {!tipHidden && ( | |
| <div class="tip-card" role="note"> | |
| <p> | |
| This page is your day’s scoreboard. Use <strong>Log</strong> for | |
| what happened and what you tried. | |
| </p> | |
| <Pressable | |
| className="tip-dismiss" | |
| ariaLabel="Dismiss tip" | |
| onClick={() => { | |
| localStorage.setItem(TIP_KEY, "1"); | |
| setTipHidden(true); | |
| }} | |
| > | |
| <X size={16} /> | |
| </Pressable> | |
| </div> | |
| )} | |
| <label class="field-label"> | |
| Date | |
| <input | |
| class="field-input" | |
| type="date" | |
| value={day} | |
| onInput={(e) => setDay((e.target as HTMLInputElement).value)} | |
| /> | |
| </label> | |
| <section class="surface-card form-card stack"> | |
| <span class="section-title">What was today’s one real thing?</span> | |
| <ChoiceList | |
| options={BRICK_OPTIONS} | |
| value={form.primary_brick} | |
| onChange={(primary_brick) => setForm({ ...form, primary_brick })} | |
| /> | |
| <label class="toggle-row"> | |
| <span>I actually did this today</span> | |
| <input | |
| type="checkbox" | |
| checked={form.brick_done} | |
| onChange={(e) => | |
| setForm({ | |
| ...form, | |
| brick_done: (e.target as HTMLInputElement).checked, | |
| }) | |
| } | |
| /> | |
| </label> | |
| </section> | |
| <section class="surface-card form-card stack win-card"> | |
| <span class="section-title">Today’s win</span> | |
| <p class="caption muted"> | |
| One thing that went well — a real step, a kind moment, anything. | |
| Optional. This is the part that compounds. | |
| </p> | |
| <input | |
| class="field-input" | |
| type="text" | |
| placeholder="e.g. Walked a new route" | |
| value={form.win} | |
| onInput={(e) => | |
| setForm({ ...form, win: (e.target as HTMLInputElement).value }) | |
| } | |
| /> | |
| </section> | |
| <section class="surface-card form-card stack"> | |
| <span class="section-title">Escapes</span> | |
| <span class="field-label"> | |
| How many times did you use porn/masturbation to escape today? | |
| </span> | |
| <Stepper | |
| value={form.corn_sessions} | |
| onChange={(corn_sessions) => setForm({ ...form, corn_sessions })} | |
| /> | |
| {form.corn_sessions >= 1 && ( | |
| <> | |
| <span class="field-label"> | |
| Before that, did you wait about 10 minutes and do one small real | |
| task first? | |
| </span> | |
| <ChoiceList | |
| options={[ | |
| { value: "yes", label: "Yes" }, | |
| { value: "no", label: "No" }, | |
| ]} | |
| value={form.delay_ok ? "yes" : "no"} | |
| onChange={(v) => setForm({ ...form, delay_ok: v === "yes" })} | |
| /> | |
| </> | |
| )} | |
| </section> | |
| <p class="caption muted core-hint"> | |
| Rough day? Just save the basics below — the extra detail can wait. | |
| </p> | |
| <Button className="btn-large" disabled={busy} onClick={save}> | |
| {busy ? "Saving…" : "Save the basics"} | |
| </Button> | |
| <details class="core-more"> | |
| <summary>More detail (optional)</summary> | |
| <div class="core-more-body stack"> | |
| <section class="surface-card form-card stack"> | |
| <span class="section-title">Did you drift into fantasy today?</span> | |
| <ChoiceList | |
| options={DAYDREAM_OPTIONS} | |
| value={form.daydream} | |
| onChange={(daydream) => setForm({ ...form, daydream })} | |
| /> | |
| </section> | |
| <section class="surface-card form-card stack"> | |
| <span class="section-title">Replaying painful scenes?</span> | |
| <ChoiceList | |
| options={RERUN_OPTIONS} | |
| value={form.rerun} | |
| onChange={(rerun) => setForm({ ...form, rerun })} | |
| /> | |
| <span class="section-title">Arguing the family case in your head?</span> | |
| <ChoiceList | |
| options={COURT_OPTIONS} | |
| value={form.court} | |
| onChange={(court) => setForm({ ...form, court })} | |
| /> | |
| </section> | |
| <section class="surface-card form-card stack"> | |
| <span class="section-title">Movement</span> | |
| <span class="field-label">Did you stay indoors the whole day?</span> | |
| <ChoiceList | |
| options={[ | |
| { value: "yes", label: "Yes" }, | |
| { value: "no", label: "No" }, | |
| ]} | |
| value={form.stayed_indoors_all_day ? "yes" : "no"} | |
| onChange={(v) => | |
| setForm({ ...form, stayed_indoors_all_day: v === "yes" }) | |
| } | |
| /> | |
| <span class="field-label">Did you leave the bedroom / room?</span> | |
| <ChoiceList | |
| options={[ | |
| { value: "yes", label: "Yes" }, | |
| { value: "no", label: "No" }, | |
| ]} | |
| value={form.left_room ? "yes" : "no"} | |
| onChange={(v) => setForm({ ...form, left_room: v === "yes" })} | |
| /> | |
| <span class="field-label">Did you leave the house/compound?</span> | |
| <ChoiceList | |
| options={[ | |
| { value: "yes", label: "Yes" }, | |
| { value: "no", label: "No" }, | |
| ]} | |
| value={form.left_home ? "yes" : "no"} | |
| onChange={(v) => setForm({ ...form, left_home: v === "yes" })} | |
| /> | |
| <label class="field-label"> | |
| Interrupt walk minutes (attention break — better without headphones) | |
| <input | |
| class="field-input" | |
| type="number" | |
| min={0} | |
| max={24 * 60} | |
| value={form.interrupt_walk_minutes} | |
| onInput={(e) => { | |
| const interrupt_walk_minutes = Math.max( | |
| 0, | |
| Number((e.target as HTMLInputElement).value) || 0, | |
| ); | |
| setForm({ | |
| ...form, | |
| interrupt_walk_minutes, | |
| movement_minutes: | |
| interrupt_walk_minutes + form.fantasy_walk_minutes, | |
| }); | |
| }} | |
| /> | |
| </label> | |
| <label class="field-label"> | |
| Fantasy / music walk minutes (honest log OK) | |
| <input | |
| class="field-input" | |
| type="number" | |
| min={0} | |
| max={24 * 60} | |
| value={form.fantasy_walk_minutes} | |
| onInput={(e) => { | |
| const fantasy_walk_minutes = Math.max( | |
| 0, | |
| Number((e.target as HTMLInputElement).value) || 0, | |
| ); | |
| setForm({ | |
| ...form, | |
| fantasy_walk_minutes, | |
| movement_minutes: | |
| form.interrupt_walk_minutes + fantasy_walk_minutes, | |
| }); | |
| }} | |
| /> | |
| </label> | |
| <span class="field-label">Headphones on a walk?</span> | |
| <ChoiceList | |
| options={[ | |
| { value: "yes", label: "Yes" }, | |
| { value: "no", label: "No" }, | |
| ]} | |
| value={form.headphones_on_walk ? "yes" : "no"} | |
| onChange={(v) => setForm({ ...form, headphones_on_walk: v === "yes" })} | |
| /> | |
| <span class="field-label">Cinematic / movie music on a walk?</span> | |
| <ChoiceList | |
| options={[ | |
| { value: "yes", label: "Yes" }, | |
| { value: "no", label: "No" }, | |
| ]} | |
| value={form.music_cinematic_on_walk ? "yes" : "no"} | |
| onChange={(v) => | |
| setForm({ ...form, music_cinematic_on_walk: v === "yes" }) | |
| } | |
| /> | |
| <p class="caption muted"> | |
| Compound laps and short walks count — no spend needed. Total moving:{" "} | |
| {form.interrupt_walk_minutes + form.fantasy_walk_minutes}m | |
| </p> | |
| </section> | |
| <section class="surface-card form-card stack"> | |
| <span class="section-title">Proof brick</span> | |
| <span class="field-label">Did you do one real proof step today?</span> | |
| <ChoiceList | |
| options={[ | |
| { value: "yes", label: "Yes" }, | |
| { value: "no", label: "No" }, | |
| ]} | |
| value={form.proof_brick_done ? "yes" : "no"} | |
| onChange={(v) => | |
| setForm({ | |
| ...form, | |
| proof_brick_done: v === "yes", | |
| proof_brick_kind: v === "yes" ? form.proof_brick_kind : null, | |
| }) | |
| } | |
| /> | |
| {form.proof_brick_done && ( | |
| <> | |
| <span class="field-label">What kind?</span> | |
| <ChoiceList | |
| options={PROOF_KINDS} | |
| value={(form.proof_brick_kind || "other") as ProofBrickKind} | |
| onChange={(proof_brick_kind) => | |
| setForm({ ...form, proof_brick_kind }) | |
| } | |
| /> | |
| </> | |
| )} | |
| </section> | |
| <section class="surface-card form-card stack"> | |
| <span class="section-title">Movie / fantasy time</span> | |
| <label class="field-label"> | |
| Minutes in a scheduled slot | |
| <input | |
| class="field-input" | |
| type="number" | |
| min={0} | |
| max={24 * 60} | |
| value={form.fantasy_minutes_scheduled} | |
| onInput={(e) => | |
| setForm({ | |
| ...form, | |
| fantasy_minutes_scheduled: Math.max( | |
| 0, | |
| Number((e.target as HTMLInputElement).value) || 0, | |
| ), | |
| }) | |
| } | |
| /> | |
| </label> | |
| <label class="field-label"> | |
| Minutes unplanned (drifted into it) | |
| <input | |
| class="field-input" | |
| type="number" | |
| min={0} | |
| max={24 * 60} | |
| value={form.fantasy_minutes_unplanned} | |
| onInput={(e) => | |
| setForm({ | |
| ...form, | |
| fantasy_minutes_unplanned: Math.max( | |
| 0, | |
| Number((e.target as HTMLInputElement).value) || 0, | |
| ), | |
| }) | |
| } | |
| /> | |
| </label> | |
| <p class="caption muted">Containment beats guilt. Log honestly.</p> | |
| </section> | |
| </div> | |
| </details> | |
| <label class="field-label"> | |
| Note (optional) | |
| <input | |
| class="field-input" | |
| type="text" | |
| placeholder="One line about today" | |
| value={form.note} | |
| onInput={(e) => | |
| setForm({ ...form, note: (e.target as HTMLInputElement).value }) | |
| } | |
| /> | |
| </label> | |
| {saved && ( | |
| <section class="surface-card form-card stack"> | |
| <span class="section-title">Points: {saved.points}/6</span> | |
| <ul class="points-breakdown"> | |
| {breakdown(saved).map((item) => ( | |
| <li key={item.label} class={item.earned ? "earned" : "missed"}> | |
| <span>{item.label}</span> | |
| <span class="points-pts"> | |
| {item.earned ? `+${item.pts}` : "0"} | |
| </span> | |
| </li> | |
| ))} | |
| </ul> | |
| </section> | |
| )} | |
| <details class="how-points surface-card"> | |
| <summary>How points work</summary> | |
| <ul> | |
| <li>Doing your one real thing earns 2 points.</li> | |
| <li> | |
| No escape sessions — or a single one after the 10-minute wait — | |
| earns 1 point. | |
| </li> | |
| <li>Fantasy is fine if you still did a real step: 1 point.</li> | |
| <li>Cutting mental replays short earns 1 point.</li> | |
| <li>Keeping the family case closed earns 1 point.</li> | |
| </ul> | |
| </details> | |
| <div class="daily-sticky"> | |
| <Button className="btn-large" disabled={busy} onClick={save}> | |
| {busy ? "Saving…" : "Save today’s check-in"} | |
| </Button> | |
| </div> | |
| </main> | |
| </div> | |
| ); | |
| } | |