Fastwhisper / frontend /src /pages /Daily.tsx
Mbonea's picture
Add discretion, positive reinforcement, and low-friction daily
8d145ad
Raw
History Blame Contribute Delete
19.3 kB
/** 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>
);
}