feat: edit historical check-ins from habit calendars
This commit is contained in:
@@ -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 server’s date and saved account timezone. Historical editing
|
visible, using the server’s 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 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
|
For browser QA without changing application data, run
|
||||||
`bun scripts/dashboard-preview.ts` and visit
|
`bun scripts/dashboard-preview.ts` and visit
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
60
src/components/CheckInEditor.tsx
Normal file
60
src/components/CheckInEditor.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
Reference in New Issue
Block a user