feat: browse archived habit history and restore habits
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
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 && (
|
{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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user