feat: browse archived habit history and restore habits
This commit is contained in:
@@ -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();
|
||||
|
||||
53
src/components/ArchivedHabits.tsx
Normal file
53
src/components/ArchivedHabits.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user