diff --git a/README.md b/README.md index 2b49f37..e3cbea2 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,9 @@ tracking method, schedule, color, count target/unit, and unfinished-count carryo Each task can be renamed, rescheduled, or deleted in place; Add task also supports its own recurrence. Both habit and task schedules support daily, selected weekdays, day intervals, and week intervals with an editable start date and weekday. -Delete asks for confirmation in place and preserves earlier history. Tasks +Archive asks for confirmation in place and preserves earlier history. +**Archived habits** opens the archive, with history inspection and restoration +starting today. Task deletion still uses an in-place confirmation. Tasks 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 diff --git a/src/App.test.tsx b/src/App.test.tsx index a5e0850..8b1d814 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -116,6 +116,24 @@ const buttonNamed = (name: string) => [...container.querySelectorAll button.textContent === name || button.getAttribute("aria-label") === name)!; describe("homepage", () => { + test("archived habits expose history and restore to the dashboard", async () => { + const f = connectAccount(); + f.setTime("2026-09-03T12:00:00Z"); + const h = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201); + await f.json(`/habits/${h.id}/days/2026-09-03/progress`, "PUT", { done: true }); + f.setTime("2026-09-04T12:00:00Z"); + await f.request(`/habits/${h.id}`, "DELETE"); + await render("/"); + await act(async () => buttonNamed("Archived habits").click()); + expect(container.textContent).toContain("Restore Reading"); + await act(async () => buttonNamed("View history").click()); + expect(container.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull(); + await act(async () => buttonNamed("Restore Reading").click()); + expect(container.textContent).toContain("No archived habits."); + expect((await f.json("/today")).habits[0].name).toBe("Reading"); + expect((await f.json(`/habits/${h.id}/days/2026-09-03`)).complete).toBe(true); + }); + 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"); @@ -240,12 +258,12 @@ describe("homepage", () => { expect(saved.target).toBe(10); expect(container.querySelector("form")).toBeNull(); expect(container.querySelector("h3")?.textContent).toContain("Daily water"); - await act(async () => buttonNamed("Delete habit Daily water").click()); + await act(async () => buttonNamed("Archive habit Daily water").click()); expect((await f.json("/habits")).habits).toHaveLength(1); await act(async () => buttonNamed("Cancel").click()); expect((await f.json("/habits")).habits).toHaveLength(1); - await act(async () => buttonNamed("Delete habit Daily water").click()); - await act(async () => buttonNamed("Delete habit").click()); + await act(async () => buttonNamed("Archive habit Daily water").click()); + await act(async () => buttonNamed("Archive habit").click()); expect((await f.json("/habits")).habits).toHaveLength(0); expect(container.querySelector(".ds-habit-chart")).toBeNull(); const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`); @@ -398,15 +416,15 @@ describe("homepage", () => { const f = connectAccount(); const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201); await render("/"); - await act(async () => buttonNamed("Delete habit Reading").click()); + await act(async () => buttonNamed("Archive habit Reading").click()); fetchMock.mockImplementationOnce((async (input, init) => { return f.request(String(input).replace("/api", ""), init?.method); }) as typeof fetch); fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 })); - await act(async () => buttonNamed("Delete habit").click()); + await act(async () => buttonNamed("Archive habit").click()); expect(container.querySelector("form")).toBeNull(); expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh"); - expect(buttonNamed("Delete habit Reading").disabled).toBe(true); + expect(buttonNamed("Archive habit Reading").disabled).toBe(true); expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true); await act(async () => buttonNamed("Try again").click()); expect(container.querySelector(".ds-habit-chart")).toBeNull(); diff --git a/src/components/ArchivedHabits.tsx b/src/components/ArchivedHabits.tsx new file mode 100644 index 0000000..e835e16 --- /dev/null +++ b/src/components/ArchivedHabits.tsx @@ -0,0 +1,53 @@ +import { useEffect, useRef, useState } from "react"; +import { habitRequest, type TodayHabit } from "../lib/dashboard"; +import { Button, SectionHeading } from "./design-system/primitives"; +import { Card } from "./design-system/Card"; +import { HabitHistory } from "./HabitHistory"; + +export function ArchivedHabits({ date, revision, onChanged, onExpired }: { + date: string; revision: number; onChanged: () => void; onExpired: () => void; +}) { + const [habits, setHabits] = useState([]); + const [selected, setSelected] = useState(); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [loading, setLoading] = useState(true); + 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(); setLoading(true); setError(""); + habitRequest<{ habits: TodayHabit[] }>(`/days/${date}`, { signal: controller.signal }) + .then(data => { if (!controller.signal.aborted) setHabits(data.habits.filter(habit => habit.requirements?.archived)); }) + .catch(error => { if (!controller.signal.aborted) { setError(error.message); if (error.message.includes("session has expired")) expired.current(); } }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [date, revision, attempt]); + async function restore(habit: TodayHabit) { + if (saving.current) return; + saving.current = true; setBusy(true); setError(""); setNotice(""); + try { + await habitRequest(`/habits/${habit.habitId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ archived: false }) }); + setHabits(current => current.filter(item => item.habitId !== habit.habitId)); + setNotice(`${habit.name} restored. Check-ins resume today.`); onChanged(); + } catch (error) { + const message = error instanceof Error ? error.message : "Could not restore. Try again."; + setError(message); if (message.includes("session has expired")) expired.current(); + } finally { saving.current = false; setBusy(false); } + } + return
+ +

Restore a habit to resume its schedule today. Earlier history stays available.

+ {error &&

{error}

} + {loading ?

Loading archive…

: !habits.length &&

No archived habits.

} +

{notice}

+ {habits.map(habit => +
+ + +
+ {selected === habit.habitId && setAttempt(value => value + 1)} />} +
)} +
; +} diff --git a/src/components/HabitHistory.tsx b/src/components/HabitHistory.tsx index cd4f4c9..dc60d62 100644 --- a/src/components/HabitHistory.tsx +++ b/src/components/HabitHistory.tsx @@ -191,11 +191,11 @@ export function HabitHistory({ {onDelete && ( )} diff --git a/src/components/InlineItemForm.tsx b/src/components/InlineItemForm.tsx index 8dd2804..b6e3690 100644 --- a/src/components/InlineItemForm.tsx +++ b/src/components/InlineItemForm.tsx @@ -29,7 +29,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o }, [mode]); return ( -
{ if (event.key === "Escape" && !saving.current) { event.preventDefault(); onClose(); } }} @@ -60,8 +60,8 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o ) : (

- Delete {name}? {kind === "habit" - ? "This removes the habit from your dashboard. Earlier history is kept." + {kind === "habit" ? "Archive" : "Delete"} {name}? {kind === "habit" + ? "This removes the habit from your dashboard starting today. Earlier history is kept. You can restore it from Archived habits." : "This removes the task from today and future check-ins. Earlier history is kept."}

)} @@ -69,7 +69,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
{mode === "delete" && } {mode === "edit" && }
diff --git a/src/components/design-system/ItemActions.tsx b/src/components/design-system/ItemActions.tsx index c4927a7..6afa229 100644 --- a/src/components/design-system/ItemActions.tsx +++ b/src/components/design-system/ItemActions.tsx @@ -1,4 +1,4 @@ -import { Pencil, Trash2 } from "lucide-react"; +import { Archive, Pencil, Trash2 } from "lucide-react"; import { Button } from "./primitives"; export function ItemActions({ name, kind, disabled, onEdit, onDelete }: { @@ -13,8 +13,8 @@ export function ItemActions({ name, kind, disabled, onEdit, onDelete }: { - ); diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index b883d19..74d3e21 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,3 +1,4 @@ +import { ArchivedHabits } from "../components/ArchivedHabits"; import { AccountSettings } from "../components/AccountSettings"; import { useCallback, useEffect, useRef, useState } from "react"; import { useAuth } from "../components/AuthProvider"; @@ -167,6 +168,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi const [adding, setAdding] = useState(false); const [sharing, setSharing] = useState(false); const [settings, setSettings] = useState(false); + const [archive, setArchive] = useState(false); const [busy, setBusy] = useState(false); const [needsRefresh, setNeedsRefresh] = useState(false); const saving = useRef(false); @@ -305,7 +307,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi } if (mounted.current) { setNotice( - `${taskId || createTask ? "Task" : "Habit"} ${createTask ? "added" : patch ? "updated" : "deleted"}.` + `${taskId || createTask ? "Task" : "Habit"} ${createTask ? "added" : patch ? "updated" : taskId ? "deleted" : "archived"}.` ); if (!patch) window.requestAnimationFrame(() => { @@ -356,6 +358,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi )}
+