feat: edit historical check-ins from habit calendars

This commit is contained in:
syntaxbullet
2026-09-04 18:20:38 +02:00
parent ecc2620a4c
commit e88ffceb22
5 changed files with 105 additions and 2 deletions

View File

@@ -68,8 +68,10 @@ remain manageable on days off, while their checkboxes stay disabled.
Mutations are saved through the authenticated API; failed Mutations are saved through the authenticated API; failed
saves retain the recorded values and allow retry. Account and calendar failures saves retain the recorded values and allow retry. Account and calendar failures
have retry states. The dashboard refreshes on window focus and every minute while have retry states. The dashboard refreshes on window focus and every minute while
visible, using the servers date and saved account timezone. Historical editing visible, using the servers date and saved account timezone. Select a calendar date and choose **Edit selected check-in** to correct counts,
and combined-chart management are not exposed on this homepage. checkboxes, or task occurrences using that dates saved requirements. A date picker
also reaches history outside the displayed year. Future dates and days off are read-only.
Combined-chart management is not exposed on this homepage.
For browser QA without changing application data, run For browser QA without changing application data, run
`bun scripts/dashboard-preview.ts` and visit `bun scripts/dashboard-preview.ts` and visit

View File

@@ -116,6 +116,23 @@ const buttonNamed = (name: string) => [...container.querySelectorAll<HTMLButtonE
.find(button => button.textContent === name || button.getAttribute("aria-label") === name)!; .find(button => button.textContent === name || button.getAttribute("aria-label") === name)!;
describe("homepage", () => { describe("homepage", () => {
test("historical check-ins use the selected date's tracking method and preserve today", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
f.setTime("2026-09-04T12:00:00Z");
await f.json(`/habits/${habit.id}`, "PATCH", { method: "count", target: 10, unit: "pages" });
await render("/");
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label^="2026-09-03:"]')!.click());
await act(async () => buttonNamed("Edit selected check-in").click());
const past = [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => input.closest("label")?.textContent === "Completed on this date")!;
expect(past).toBeDefined();
await act(async () => past.click());
expect((await f.json(`/habits/${habit.id}/days/2026-09-03`)).complete).toBe(true);
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).value).toBe(0);
expect(container.textContent).toContain("Saved check-in for 2026-09-03");
});
test("signed-out visitors can try progress without calling private habit APIs", async () => { test("signed-out visitors can try progress without calling private habit APIs", async () => {
await render("/"); await render("/");
expect(container.querySelector("h1")?.textContent).toBe("Small steps.Lasting rhythm."); expect(container.querySelector("h1")?.textContent).toBe("Small steps.Lasting rhythm.");

View File

@@ -0,0 +1,60 @@
import { useEffect, useRef, useState } from "react";
import { habitRequest, type TodayHabit } from "../lib/dashboard";
import { Button, Checkbox } from "./design-system/primitives";
import { Field } from "./design-system/Field";
export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired }: {
habitId: string; date: string; disabled?: boolean; onSaved: () => void; onExpired?: () => void;
}) {
const [day, setDay] = useState<TodayHabit | null>(null);
const [count, setCount] = useState("");
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [busy, setBusy] = useState(false);
const [attempt, setAttempt] = useState(0);
const saving = useRef(false);
const expired = useRef(onExpired); expired.current = onExpired;
useEffect(() => {
const controller = new AbortController();
setDay(null); setError("");
habitRequest<TodayHabit>(`/habits/${habitId}/days/${date}`, { signal: controller.signal })
.then(value => { if (!controller.signal.aborted) { setDay(value); setCount(String(value.value)); } })
.catch(error => { if (!controller.signal.aborted) {
if (error.message.includes("session has expired")) expired.current?.();
setError(error.message);
} });
return () => controller.abort();
}, [habitId, date, attempt]);
async function save(body: { count: number } | { done: boolean }, taskId?: string) {
if (saving.current || disabled) return;
saving.current = true; setBusy(true); setError(""); setNotice("");
try {
const result = await habitRequest<TodayHabit>(`/habits/${habitId}/days/${date}/${taskId ? `tasks/${taskId}` : "progress"}`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
setDay(result); setCount(String(result.value)); setNotice(`Saved check-in for ${date}.`); onSaved();
} catch (error) {
const message = error instanceof Error ? error.message : "Could not save. Try again.";
setError(message); if (message.includes("session has expired")) expired.current?.();
} finally { saving.current = false; setBusy(false); }
}
return <div className="ds-inline-item-form" aria-label={`Check-in for ${date}`}>
<p className="type-ui-heading">Check-in · {date}</p>
{error && <p role="alert">{error}</p>}
{!day ? error ? <Button onClick={() => setAttempt(value => value + 1)}>Retry check-in</Button> : <p role="status">Loading check-in</p>
: day.future ? <p>Future check-ins cannot be edited.</p>
: !day.due ? <p>Nothing was scheduled for this date.</p>
: <>
<p className="type-small">{day.name} · {day.timezone}. Changes use the requirements saved for this date.</p>
{day.method === "count" ? <form onSubmit={event => { event.preventDefault(); void save({ count: Number(count) }); }}>
<Field label={`Count (${day.unit}, target ${day.target})`}>
{id => <input id={id} type="number" min={0} max={1000000000} step={1} required value={count} disabled={busy || disabled} onChange={event => setCount(event.target.value)} />}
</Field>
{day.carriedFrom && <p className="type-small">Includes carryover from {day.carriedFrom}. Saving sets this day's total.</p>}
<Button type="submit" disabled={busy || disabled}>{busy ? "Saving…" : "Save check-in"}</Button>
</form> : day.method === "manual" ? <Checkbox label="Completed on this date" checked={day.complete} disabled={busy || disabled} onChange={event => void save({ done: event.target.checked })} />
: day.tasks.map(task => <Checkbox key={task.taskId} label={task.name} checked={task.done} disabled={busy || disabled} onChange={event => void save({ done: event.target.checked }, task.taskId)} />)}
</>}
<p role="status">{notice}</p>
</div>;
}

View File

@@ -7,6 +7,8 @@ import { Button } from "./design-system/primitives";
import { HabitChart } from "./design-system/HabitChart"; import { HabitChart } from "./design-system/HabitChart";
import { CalendarLegend } from "./design-system/CalendarLegend"; import { CalendarLegend } from "./design-system/CalendarLegend";
import { shade } from "../habits/calendar"; import { shade } from "../habits/calendar";
import { CheckInEditor } from "./CheckInEditor";
import { Field } from "./design-system/Field";
import { ItemActions } from "./design-system/ItemActions"; import { ItemActions } from "./design-system/ItemActions";
export function HabitHistory({ export function HabitHistory({
@@ -20,6 +22,7 @@ export function HabitHistory({
tasks, tasks,
editor, editor,
onExpired, onExpired,
onHistorySaved,
}: { }: {
habit: TodayHabit; habit: TodayHabit;
date: string; date: string;
@@ -31,7 +34,10 @@ export function HabitHistory({
tasks?: ReactNode; tasks?: ReactNode;
editor?: ReactNode; editor?: ReactNode;
onExpired?: () => void; onExpired?: () => void;
onHistorySaved?: () => void;
}) { }) {
const [selectedDate, setSelectedDate] = useState<string>();
const [editingDay, setEditingDay] = useState(false);
const [calendar, setCalendar] = useState<CalendarResponse | null>(null); const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [attempt, setAttempt] = useState(0); const [attempt, setAttempt] = useState(0);
@@ -106,7 +112,10 @@ export function HabitHistory({
Loading your habit history Loading your habit history
</p> </p>
) : ( ) : (
<>
<CalendarHeatmap <CalendarHeatmap
onSelectDate={setSelectedDate}
selectedDate={selectedDate}
compact compact
historyLabel="Your recorded progress" historyLabel="Your recorded progress"
label={`${habit.name} progress calendar`} label={`${habit.name} progress calendar`}
@@ -123,6 +132,17 @@ export function HabitHistory({
/> />
} }
/> />
<div className="ds-actions">
<Button variant="text" disabled={disabled} onClick={() => { setSelectedDate(selectedDate ?? date); setEditingDay(!editingDay); }} aria-expanded={editingDay}>
{editingDay ? "Close check-in editor" : "Edit selected check-in"}
</Button>
</div>
{editingDay && <>
<Field label="Check-in date">{id => <input id={id} type="date" max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) setSelectedDate(event.target.value); }} />}</Field>
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
</>}
</>
); );
return ( return (
<HabitChart <HabitChart

View File

@@ -433,6 +433,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
date={today.date} date={today.date}
revision={revision} revision={revision}
disabled={busy || loading || needsRefresh} disabled={busy || loading || needsRefresh}
onHistorySaved={() => setAttempt(value => value + 1)}
onUpdate={update} onUpdate={update}
onManage={manage} onManage={manage}
onExpired={() => expired.current()} onExpired={() => expired.current()}
@@ -454,6 +455,7 @@ function SavedHabit({
disabled, disabled,
onUpdate, onUpdate,
onManage, onManage,
onHistorySaved,
onExpired, onExpired,
}: { }: {
habit: TodayHabit; habit: TodayHabit;
@@ -466,6 +468,7 @@ function SavedHabit({
taskId?: string taskId?: string
) => Promise<void>; ) => Promise<void>;
onExpired: () => void; onExpired: () => void;
onHistorySaved: () => void;
onManage: ( onManage: (
habit: TodayHabit, habit: TodayHabit,
patch: Record<string, unknown> | null, patch: Record<string, unknown> | null,
@@ -483,6 +486,7 @@ function SavedHabit({
revision={revision} revision={revision}
disabled={disabled} disabled={disabled}
onExpired={onExpired} onExpired={onExpired}
onHistorySaved={onHistorySaved}
onEdit={(color) => setEditing({ mode: "edit", color })} onEdit={(color) => setEditing({ mode: "edit", color })}
onDelete={() => setEditing({ mode: "delete", color: "#196127" })} onDelete={() => setEditing({ mode: "delete", color: "#196127" })}
editor={ editor={