feat: browse archived habit history and restore habits

This commit is contained in:
syntaxbullet
2026-09-04 18:33:18 +02:00
parent 9c8a96ba03
commit 19a9aa6862
7 changed files with 94 additions and 17 deletions

View File

@@ -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 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, its own recurrence. Both habit and task schedules support daily, selected weekdays,
day intervals, and week intervals with an editable start date and weekday. 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. 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

View File

@@ -116,6 +116,24 @@ 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("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 () => { test("historical check-ins use the selected date's tracking method and preserve today", async () => {
const f = connectAccount(); const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z"); f.setTime("2026-09-03T12:00:00Z");
@@ -240,12 +258,12 @@ describe("homepage", () => {
expect(saved.target).toBe(10); expect(saved.target).toBe(10);
expect(container.querySelector("form")).toBeNull(); expect(container.querySelector("form")).toBeNull();
expect(container.querySelector("h3")?.textContent).toContain("Daily water"); 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); expect((await f.json("/habits")).habits).toHaveLength(1);
await act(async () => buttonNamed("Cancel").click()); await act(async () => buttonNamed("Cancel").click());
expect((await f.json("/habits")).habits).toHaveLength(1); expect((await f.json("/habits")).habits).toHaveLength(1);
await act(async () => buttonNamed("Delete habit Daily water").click()); await act(async () => buttonNamed("Archive habit Daily water").click());
await act(async () => buttonNamed("Delete habit").click()); await act(async () => buttonNamed("Archive habit").click());
expect((await f.json("/habits")).habits).toHaveLength(0); expect((await f.json("/habits")).habits).toHaveLength(0);
expect(container.querySelector(".ds-habit-chart")).toBeNull(); expect(container.querySelector(".ds-habit-chart")).toBeNull();
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`); const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
@@ -398,15 +416,15 @@ describe("homepage", () => {
const f = connectAccount(); const f = connectAccount();
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201); const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
await render("/"); await render("/");
await act(async () => buttonNamed("Delete habit Reading").click()); await act(async () => buttonNamed("Archive habit Reading").click());
fetchMock.mockImplementationOnce((async (input, init) => { fetchMock.mockImplementationOnce((async (input, init) => {
return f.request(String(input).replace("/api", ""), init?.method); return f.request(String(input).replace("/api", ""), init?.method);
}) as typeof fetch); }) as typeof fetch);
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 })); 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.querySelector("form")).toBeNull();
expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh"); 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); expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true);
await act(async () => buttonNamed("Try again").click()); await act(async () => buttonNamed("Try again").click());
expect(container.querySelector(".ds-habit-chart")).toBeNull(); expect(container.querySelector(".ds-habit-chart")).toBeNull();

View File

@@ -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<TodayHabit[]>([]);
const [selected, setSelected] = useState<string>();
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 <section className="ds-section" id="archived-habits" aria-label="Archived habits">
<SectionHeading number="YOUR ARCHIVE" title="Archived habits" />
<p>Restore a habit to resume its schedule today. Earlier history stays available.</p>
{error && <p role="alert">{error} <Button onClick={() => setAttempt(value => value + 1)}>Retry archive</Button></p>}
{loading ? <p role="status">Loading archive</p> : !habits.length && <p>No archived habits.</p>}
<p role="status">{notice}</p>
{habits.map(habit => <Card key={habit.habitId} heading={habit.name ?? "Habit"}>
<div className="ds-actions">
<Button variant="secondary" disabled={busy || loading} onClick={() => void restore(habit)}>Restore {habit.name}</Button>
<Button variant="text" onClick={() => setSelected(selected === habit.habitId ? undefined : habit.habitId)} aria-expanded={selected === habit.habitId}>{selected === habit.habitId ? "Hide history" : "View history"}</Button>
</div>
{selected === habit.habitId && <HabitHistory habit={habit} date={date} revision={revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} />}
</Card>)}
</section>;
}

View File

@@ -191,11 +191,11 @@ export function HabitHistory({
{onDelete && ( {onDelete && (
<Button <Button
variant="text" variant="text"
aria-label={`Delete ${habit.name}`} aria-label={`Archive ${habit.name}`}
disabled={disabled} disabled={disabled}
onClick={onDelete} onClick={onDelete}
> >
Delete Archive
</Button> </Button>
)} )}
</div> </div>

View File

@@ -29,7 +29,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
}, [mode]); }, [mode]);
return ( return (
<form ref={form} className={`ds-inline-item-form ds-inline-item-form--${kind}`} aria-label={formLabel ?? `${mode === "edit" ? "Edit" : "Delete"} ${kind} ${name}`} <form ref={form} className={`ds-inline-item-form ds-inline-item-form--${kind}`} aria-label={formLabel ?? `${mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete"} ${kind} ${name}`}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Escape" && !saving.current) { event.preventDefault(); onClose(); } if (event.key === "Escape" && !saving.current) { event.preventDefault(); onClose(); }
}} }}
@@ -60,8 +60,8 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
</fieldset> </fieldset>
) : ( ) : (
<p className="ds-inline-delete-copy"> <p className="ds-inline-delete-copy">
Delete <strong>{name}</strong>? {kind === "habit" {kind === "habit" ? "Archive" : "Delete"} <strong>{name}</strong>? {kind === "habit"
? "This removes the habit from your dashboard. Earlier history is kept." ? "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."} : "This removes the task from today and future check-ins. Earlier history is kept."}
</p> </p>
)} )}
@@ -69,7 +69,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
<div className="ds-inline-form-actions"> <div className="ds-inline-form-actions">
{mode === "delete" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>} {mode === "delete" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>}
<Button type="submit" disabled={busy || disabled}> <Button type="submit" disabled={busy || disabled}>
{busy ? "Saving…" : mode === "delete" ? `Delete ${kind}` : submitLabel ?? "Save changes"} {busy ? "Saving…" : mode === "delete" ? `${kind === "habit" ? "Archive" : "Delete"} ${kind}` : submitLabel ?? "Save changes"}
</Button> </Button>
{mode === "edit" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>} {mode === "edit" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>}
</div> </div>

View File

@@ -1,4 +1,4 @@
import { Pencil, Trash2 } from "lucide-react"; import { Archive, Pencil, Trash2 } from "lucide-react";
import { Button } from "./primitives"; import { Button } from "./primitives";
export function ItemActions({ name, kind, disabled, onEdit, onDelete }: { export function ItemActions({ name, kind, disabled, onEdit, onDelete }: {
@@ -13,8 +13,8 @@ export function ItemActions({ name, kind, disabled, onEdit, onDelete }: {
<Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onEdit} aria-label={`Edit ${kind} ${name}`} title={`Edit ${kind}`}> <Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onEdit} aria-label={`Edit ${kind} ${name}`} title={`Edit ${kind}`}>
<Pencil size={16} aria-hidden="true" /> <Pencil size={16} aria-hidden="true" />
</Button> </Button>
<Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onDelete} aria-label={`Delete ${kind} ${name}`} title={`Delete ${kind}`}> <Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onDelete} aria-label={`${kind === "habit" ? "Archive" : "Delete"} ${kind} ${name}`} title={`${kind === "habit" ? "Archive" : "Delete"} ${kind}`}>
<Trash2 size={16} aria-hidden="true" /> {kind === "habit" ? <Archive size={16} aria-hidden="true" /> : <Trash2 size={16} aria-hidden="true" />}
</Button> </Button>
</span> </span>
); );

View File

@@ -1,3 +1,4 @@
import { ArchivedHabits } from "../components/ArchivedHabits";
import { AccountSettings } from "../components/AccountSettings"; import { AccountSettings } from "../components/AccountSettings";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "../components/AuthProvider"; import { useAuth } from "../components/AuthProvider";
@@ -167,6 +168,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [sharing, setSharing] = useState(false); const [sharing, setSharing] = useState(false);
const [settings, setSettings] = useState(false); const [settings, setSettings] = useState(false);
const [archive, setArchive] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [needsRefresh, setNeedsRefresh] = useState(false); const [needsRefresh, setNeedsRefresh] = useState(false);
const saving = useRef(false); const saving = useRef(false);
@@ -305,7 +307,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
} }
if (mounted.current) { if (mounted.current) {
setNotice( setNotice(
`${taskId || createTask ? "Task" : "Habit"} ${createTask ? "added" : patch ? "updated" : "deleted"}.` `${taskId || createTask ? "Task" : "Habit"} ${createTask ? "added" : patch ? "updated" : taskId ? "deleted" : "archived"}.`
); );
if (!patch) if (!patch)
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
@@ -356,6 +358,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
)} )}
</div> </div>
<div className="ds-actions"> <div className="ds-actions">
<Button variant="text" disabled={busy} aria-expanded={archive} onClick={() => setArchive(!archive)}>Archived habits</Button>
<Button variant="text" disabled={busy} aria-expanded={settings} onClick={() => setSettings(!settings)}>Settings</Button> <Button variant="text" disabled={busy} aria-expanded={settings} onClick={() => setSettings(!settings)}>Settings</Button>
<Button <Button
id="add-habit" id="add-habit"
@@ -376,6 +379,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
</> </>
)} )}
</WelcomePanel> </WelcomePanel>
{archive && today && <ArchivedHabits date={today.date} revision={revision} onExpired={onExpired} onChanged={() => setAttempt(value => value + 1)} />}
{settings && <AccountSettings user={user} onChanged={onExpired} onClose={() => setSettings(false)} />} {settings && <AccountSettings user={user} onChanged={onExpired} onClose={() => setSettings(false)} />}
{sharing && today && <ShareProgress user={user} today={today} revision={revision} onClose={() => { setSharing(false); requestAnimationFrame(() => document.getElementById("open-sharing")?.focus()); }} />} {sharing && today && <ShareProgress user={user} today={today} revision={revision} onClose={() => { setSharing(false); requestAnimationFrame(() => document.getElementById("open-sharing")?.focus()); }} />}
{error && ( {error && (