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
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

View File

@@ -116,6 +116,24 @@ const buttonNamed = (name: string) => [...container.querySelectorAll<HTMLButtonE
.find(button => 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();

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 && (
<Button
variant="text"
aria-label={`Delete ${habit.name}`}
aria-label={`Archive ${habit.name}`}
disabled={disabled}
onClick={onDelete}
>
Delete
Archive
</Button>
)}
</div>

View File

@@ -29,7 +29,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
}, [mode]);
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) => {
if (event.key === "Escape" && !saving.current) { event.preventDefault(); onClose(); }
}}
@@ -60,8 +60,8 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
</fieldset>
) : (
<p className="ds-inline-delete-copy">
Delete <strong>{name}</strong>? {kind === "habit"
? "This removes the habit from your dashboard. Earlier history is kept."
{kind === "habit" ? "Archive" : "Delete"} <strong>{name}</strong>? {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."}
</p>
)}
@@ -69,7 +69,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o
<div className="ds-inline-form-actions">
{mode === "delete" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>}
<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>
{mode === "edit" && <Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button>}
</div>

View File

@@ -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 }: {
<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" />
</Button>
<Button variant="text" className="ds-icon-button" disabled={disabled} onClick={onDelete} aria-label={`Delete ${kind} ${name}`} title={`Delete ${kind}`}>
<Trash2 size={16} aria-hidden="true" />
<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}`}>
{kind === "habit" ? <Archive size={16} aria-hidden="true" /> : <Trash2 size={16} aria-hidden="true" />}
</Button>
</span>
);

View File

@@ -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
)}
</div>
<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
id="add-habit"
@@ -376,6 +379,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
</>
)}
</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)} />}
{sharing && today && <ShareProgress user={user} today={today} revision={revision} onClose={() => { setSharing(false); requestAnimationFrame(() => document.getElementById("open-sharing")?.focus()); }} />}
{error && (