diff --git a/README.md b/README.md index 078218f..feef4dc 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,10 @@ remain manageable on days off, while their checkboxes stay disabled. Mutations are saved through the authenticated API; failed 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 -visible, using the server’s date and saved account timezone. Historical editing -and combined-chart management are not exposed on this homepage. +visible, using the server’s date and saved account timezone. Select a calendar date and choose **Edit selected check-in** to correct counts, +checkboxes, or task occurrences using that date’s 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 `bun scripts/dashboard-preview.ts` and visit diff --git a/src/App.test.tsx b/src/App.test.tsx index 46a5848..a5e0850 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -116,6 +116,23 @@ const buttonNamed = (name: string) => [...container.querySelectorAll button.textContent === name || button.getAttribute("aria-label") === name)!; 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('[aria-label^="2026-09-03:"]')!.click()); + await act(async () => buttonNamed("Edit selected check-in").click()); + const past = [...container.querySelectorAll('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 () => { await render("/"); expect(container.querySelector("h1")?.textContent).toBe("Small steps.Lasting rhythm."); diff --git a/src/components/CheckInEditor.tsx b/src/components/CheckInEditor.tsx new file mode 100644 index 0000000..1740095 --- /dev/null +++ b/src/components/CheckInEditor.tsx @@ -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(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(`/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(`/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
+

Check-in · {date}

+ {error &&

{error}

} + {!day ? error ? :

Loading check-in…

+ : day.future ?

Future check-ins cannot be edited.

+ : !day.due ?

Nothing was scheduled for this date.

+ : <> +

{day.name} · {day.timezone}. Changes use the requirements saved for this date.

+ {day.method === "count" ?
{ event.preventDefault(); void save({ count: Number(count) }); }}> + + {id => setCount(event.target.value)} />} + + {day.carriedFrom &&

Includes carryover from {day.carriedFrom}. Saving sets this day's total.

} + +
: day.method === "manual" ? void save({ done: event.target.checked })} /> + : day.tasks.map(task => void save({ done: event.target.checked }, task.taskId)} />)} + } +

{notice}

+
; +} diff --git a/src/components/HabitHistory.tsx b/src/components/HabitHistory.tsx index faab3b6..cd4f4c9 100644 --- a/src/components/HabitHistory.tsx +++ b/src/components/HabitHistory.tsx @@ -7,6 +7,8 @@ import { Button } from "./design-system/primitives"; import { HabitChart } from "./design-system/HabitChart"; import { CalendarLegend } from "./design-system/CalendarLegend"; import { shade } from "../habits/calendar"; +import { CheckInEditor } from "./CheckInEditor"; +import { Field } from "./design-system/Field"; import { ItemActions } from "./design-system/ItemActions"; export function HabitHistory({ @@ -20,6 +22,7 @@ export function HabitHistory({ tasks, editor, onExpired, + onHistorySaved, }: { habit: TodayHabit; date: string; @@ -31,7 +34,10 @@ export function HabitHistory({ tasks?: ReactNode; editor?: ReactNode; onExpired?: () => void; + onHistorySaved?: () => void; }) { + const [selectedDate, setSelectedDate] = useState(); + const [editingDay, setEditingDay] = useState(false); const [calendar, setCalendar] = useState(null); const [error, setError] = useState(""); const [attempt, setAttempt] = useState(0); @@ -106,7 +112,10 @@ export function HabitHistory({ Loading your habit history…

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