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

@@ -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 && (