fix(goal): never drop an unreadable goal by accident, and let the count be retyped

Review found three ordinary actions that deleted a goal this build could
not read: "It repeats daily", pressing an already-pressed Off, and "not
daily" without saying so. Only "not daily" clears a goal now, and its
warning keys off what is stored, not what parses.

The count field was bound straight to the goal, so emptying it snapped
the old number back and typing appended to it. It now keeps a draft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-13 13:24:41 +02:00
1 parent d5ffad38c5
commit 064cea25fd
8 files changed
+177 -44

No files matched your search

+80 -32
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { clampRequired, type Goal } from "../../shared/goal.ts";
const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
@@ -8,6 +9,44 @@ const MODES = [
{ id: "event", label: "Across the event" },
] as const;
type Mode = (typeof MODES)[number]["id"];
/**
* The goal a mode press asks for: a Goal, undefined to clear it, or null for
* "nothing to do". Pressing the mode already shown is null — including Off
* when no goal is *readable*, which may still be one stored by a newer
* version, and a press that looks like a no-op must not delete it.
*/
export function chooseGoal(
id: Mode,
goal: Goal | undefined,
defaultWeekStartsOn: number,
maxDays: number | null,
): Goal | undefined | null {
if (id === (goal?.period ?? "off")) return null;
if (id === "off") return undefined;
const required = goal?.required ?? 5;
if (id === "week") {
return {
period: "week",
measure: "days",
required: clampRequired("week", required, null),
weekStartsOn: goal?.period === "week" ? goal.weekStartsOn : defaultWeekStartsOn,
};
}
return { period: "event", measure: "days", required: clampRequired("event", required, maxDays) };
}
/** A typed count, clamped; null while the field is empty or unreadable — mid-edit, not an answer. */
export function parseDraft(
raw: string,
period: Goal["period"],
maxDays: number | null,
): number | null {
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? clampRequired(period, n, maxDays) : null;
}
/**
* How many check-ins the event actually wants.
*
@@ -32,30 +71,10 @@ export function GoalControl({
onChange: (goal: Goal | undefined) => void;
}) {
const mode = goal?.period ?? "off";
const required = goal?.required ?? 5;
const choose = (id: (typeof MODES)[number]["id"]) => {
if (id === "off") return onChange(undefined);
if (id === "week") {
return onChange({
period: "week",
measure: "days",
required: clampRequired("week", required, null),
weekStartsOn: goal?.period === "week" ? goal.weekStartsOn : defaultWeekStartsOn,
});
}
onChange({
period: "event",
measure: "days",
required: clampRequired("event", required, maxDays),
});
};
const setRequired = (raw: string) => {
if (goal === undefined) return;
// An emptied field is mid-edit, not an answer; keep the goal as it was.
if (raw.trim() === "") return;
onChange({ ...goal, required: clampRequired(goal.period, Number.parseInt(raw, 10), maxDays) });
const choose = (id: Mode) => {
const next = chooseGoal(id, goal, defaultWeekStartsOn, maxDays);
if (next !== null) onChange(next);
};
return (
@@ -84,15 +103,7 @@ export function GoalControl({
{goal !== undefined && (
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted">
<input
type="number"
aria-label="Days needed"
min={1}
max={goal.period === "week" ? 7 : (maxDays ?? 366)}
value={goal.required}
onChange={(e) => setRequired(e.target.value)}
className="tnum w-14 rounded-lg border border-hairline bg-ground px-2 py-1 text-sm text-ink focus:border-faint focus:outline-none"
/>
<CountField goal={goal} maxDays={maxDays} onChange={onChange} />
<span>
{goal.period === "week" ? "days each week, weeks start" : "days across the event"}
</span>
@@ -115,3 +126,40 @@ export function GoalControl({
</div>
);
}
/**
* The count, with its own draft text. Bound straight to `goal.required`, the
* field could never be emptied: an empty value changes no goal, so React put
* the old number straight back, and typing after a backspace appended to it.
* The draft holds what is typed; a readable value commits, and leaving the
* field shows the goal's count again.
*/
function CountField({
goal,
maxDays,
onChange,
}: {
goal: Goal;
maxDays: number | null;
onChange: (goal: Goal) => void;
}) {
const [draft, setDraft] = useState(String(goal.required));
useEffect(() => setDraft(String(goal.required)), [goal.required]);
return (
<input
type="number"
aria-label="Days needed"
min={1}
max={goal.period === "week" ? 7 : (maxDays ?? 366)}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
const n = parseDraft(e.target.value, goal.period, maxDays);
if (n !== null && n !== goal.required) onChange({ ...goal, required: n });
}}
onBlur={() => setDraft(String(goal.required))}
className="tnum w-14 rounded-lg border border-hairline bg-ground px-2 py-1 text-sm text-ink focus:border-faint focus:outline-none"
/>
);
}