diff --git a/README.md b/README.md index 6a77f45..585e3c6 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,17 @@ have retry states. The dashboard refreshes on window focus and every minute whil visible, using the server’s date and saved account timezone. Select a calendar date and choose **Edit selected check-in** to correct counts, checkboxes, or task occurrences using that date’s saved requirements. A date picker also reaches history outside the displayed year. Future dates and days off are read-only. -Combined-chart management is not exposed on this homepage. +The personal progress overview below the habit list supports saved combined calendars. +Choose **Create overview**, name the group, and select 1–100 habits, including +archived habits for historical review. **Saved overview** switches between groups; +**Edit overview** renames a group or changes its membership. Membership changes +recalculate the entire chart using each habit’s historical requirements. +The calendar starts with three months and supports longer and custom ranges. +**View day details** shows completed-versus-due totals, each habit’s actual +progress, and routine tasks, with access to the existing check-in editor. +Corrections refresh the combined calendar and dashboard. Days off are excluded +from totals; partial progress does not count as a completed habit. **Delete +overview** requires confirmation and keeps all habits and check-ins. ### Daily check-in experience diff --git a/src/components/ProgressOverview.test.tsx b/src/components/ProgressOverview.test.tsx new file mode 100644 index 0000000..5da44b3 --- /dev/null +++ b/src/components/ProgressOverview.test.tsx @@ -0,0 +1,153 @@ +import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { ProgressOverview } from "./ProgressOverview"; +import { fixture } from "../habits/test-fixture"; + +const dom = new Window({ url: "http://localhost:3000/design-system" }); +const originalGlobals = new Map(); +let createRoot: typeof import("react-dom/client").createRoot; +let root: Root; +let container: HTMLDivElement; +let f: ReturnType; +const originalFetch = globalThis.fetch; + +beforeAll(async () => { + for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLInputElement", "Element", "Node", "Event", "MouseEvent", "IS_REACT_ACT_ENVIRONMENT"]) { + originalGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value: key === "window" ? dom : key === "IS_REACT_ACT_ENVIRONMENT" ? true : (dom as unknown as Record)[key], + }); + } + ({ createRoot } = await import("react-dom/client")); +}); + +beforeEach(() => { + f = fixture(); + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + return f.request(String(input).replace(/^\/api/, ""), init?.method, init?.body ? JSON.parse(String(init.body)) : undefined); + }) as typeof fetch; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + globalThis.fetch = originalFetch; + f.close(); +}); + +afterAll(() => { + dom.happyDOM.abort(); + for (const [key, descriptor] of originalGlobals) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } +}); + +function button(text: string) { + return [...container.querySelectorAll("button")].find((element) => element.textContent?.trim() === text)!; +} + +async function render() { + await act(async () => root.render( {}} onHistorySaved={() => {}} />)); +} +function checkbox(label: string) { + return [...container.querySelectorAll('label')].find(element => element.textContent === label)!.querySelector('input')!; +} +async function click(text: string) { await act(async () => button(text).click()); } +async function name(value: string) { + const input = container.querySelector('input[placeholder="e.g. Morning routine"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +test("saved overview creation, membership editing with archived habits, and deletion preserve habits", async () => { + const water = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8 }, 201); + const reading = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201); + expect((await f.request(`/habits/${reading.id}`, 'DELETE')).status).toBe(204); + await render(); + await click('Create overview'); + expect(container.textContent).toContain('Reading · Archived'); + await name('Essentials'); + await act(async () => checkbox('Water').click()); + await click('Save overview'); + const saved = (await f.json('/charts')).charts[0]; + expect(saved.name).toBe('Essentials'); + expect(saved.habitIds).toEqual([water.id]); + expect(container.textContent).toContain('View day details'); + await click('Edit overview'); + await name('My progress'); + await act(async () => checkbox('Reading · Archived').click()); + expect(container.textContent).toContain('recalculates this overview’s entire history'); + await click('Save overview'); + expect((await f.json(`/charts/${saved.id}`)).habitIds).toEqual([water.id, reading.id]); + await click('Delete overview'); + const dialog = container.querySelector('#delete-overview')!; + await act(async () => [...dialog.querySelectorAll('button')].find(b => b.textContent === 'Delete overview')!.click()); + expect((await f.json('/charts')).charts).toHaveLength(0); + expect((await f.json('/habits?archived=true')).habits).toHaveLength(2); +}); + +test("combined calendars show completed versus due and corrections refresh the aggregate", async () => { + const water = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8, unit: 'glasses' }, 201); + const read = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201); + await f.json(`/habits/${water.id}/days/2026-09-04/progress`, 'PUT', { count: 7 }); + await f.json('/charts', 'POST', { name: 'Essentials', habitIds: [water.id, read.id] }, 201); + await render(); + expect(container.querySelector('button[aria-label^="2026-09-04:"]')?.getAttribute('aria-label')).toContain('0 of 2 habits complete'); + await click('View day details'); + expect(container.textContent).toContain('7 of 8 glasses'); + await click('Edit Reading check-in'); + await act(async () => container.querySelector('input[type="checkbox"]')!.click()); + expect(container.textContent).toContain('1 of 2 due habits completed'); + expect(container.querySelector('button[aria-label^="2026-09-04:"]')?.getAttribute('aria-label')).toContain('1 of 2 habits complete'); +}); + +test("failed lists retry and failed saves preserve the overview draft", async () => { + await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201); + const working = globalThis.fetch; + globalThis.fetch = (async () => Response.json({ error: 'Temporarily unavailable' }, { status: 503 })) as unknown as typeof fetch; + await render(); + expect(container.textContent).toContain('Temporarily unavailable'); + globalThis.fetch = working; + await click('Retry overviews'); + await click('Create overview'); + await name('Keep this draft'); + await act(async () => container.querySelector('input[type="checkbox"]')!.click()); + globalThis.fetch = (async () => Response.json({ error: 'Save failed' }, { status: 503 })) as unknown as typeof fetch; + await click('Save overview'); + expect(container.textContent).toContain('Save failed'); + expect(container.querySelector('input[placeholder="e.g. Morning routine"]')!.value).toBe('Keep this draft'); + globalThis.fetch = working; + await click('Save overview'); + expect((await f.json('/charts')).charts[0].name).toBe('Keep this draft'); +}); + +test("range changes retain the selected window and future or unscheduled days have no edit controls", async () => { + const habit = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual', schedule: { type: 'weekdays', days: [5] } }, 201); + await f.json('/charts', 'POST', { name: 'Fridays', habitIds: [habit.id] }, 201); + await render(); + await act(async () => container.querySelector('button[aria-label="6 months"]')!.click()); + const squares = () => container.querySelectorAll('button[aria-label^="2026-"]').length; + const count = squares(); + await act(async () => container.querySelector('button[aria-label^="2026-09-05:"]')!.click()); + expect(squares()).toBe(count); + expect(container.querySelector('.ds-calendar')?.getAttribute('data-months')).toBe('6'); + await click('View day details'); + const dialog = container.querySelector('#overview-day')!; + expect(dialog.textContent).toContain('Nothing scheduled'); + expect([...dialog.querySelectorAll('button')].some(b => b.textContent?.startsWith('Edit '))).toBe(false); + await act(async () => dialog.querySelector('button[aria-label="Close dialog"]')!.click()); + await act(async () => container.querySelector('button[aria-label^="2026-09-11:"]')!.click()); + await click('View day details'); + expect(container.querySelector('#overview-day')!.textContent).toContain('Upcoming'); + expect([...container.querySelectorAll('#overview-day button')].some(b => b.textContent?.startsWith('Edit '))).toBe(false); +}); diff --git a/src/components/ProgressOverview.tsx b/src/components/ProgressOverview.tsx new file mode 100644 index 0000000..a7b9ed4 --- /dev/null +++ b/src/components/ProgressOverview.tsx @@ -0,0 +1,193 @@ +import { useEffect, useRef, useState } from "react"; +import type { HabitService } from "../habits/service"; +import { habitRequest, type TodayHabit } from "../lib/dashboard"; +import { toHeatmapDays, type CalendarResponse } from "../shared/calendar"; +import { CalendarHeatmap } from "./design-system/CalendarHeatmap"; +import { monthWindow, type MonthView } from "./design-system/calendar-model"; +import { Button, Checkbox, SectionHeading } from "./design-system/primitives"; +import { Card } from "./design-system/Card"; +import { Field } from "./design-system/Field"; +import { Modal } from "./design-system/Modal"; +import { FormMessage } from "./design-system/WorkspacePanel"; +import { deferred } from "./design-system/deferred"; +const CheckInEditor = deferred(() => import("./CheckInEditor").then(module => module.CheckInEditor)); + +type Chart = ReturnType; +type Day = CalendarResponse["days"][number]; +const message = (error: unknown) => error instanceof Error ? error.message : "Could not load your overview. Try again."; + +export function ProgressOverview({ date, revision, disabled, onExpired, onHistorySaved }: { + date: string; revision: number; disabled?: boolean; onExpired: () => void; onHistorySaved: () => void; +}) { + const [charts, setCharts] = useState([]); + const [selected, setSelected] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [attempt, setAttempt] = useState(0); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + const [busy, setBusy] = useState(false); + const [deleteError, setDeleteError] = useState(""); + const saving = useRef(false); + const expired = useRef(onExpired); expired.current = onExpired; + const heading = useRef(null); + useEffect(() => { + const controller = new AbortController(); + setLoading(true); setError(""); + habitRequest<{ charts: Chart[] }>("/charts", { signal: controller.signal }) + .then(data => { if (!controller.signal.aborted) { + setCharts(data.charts); + setSelected(current => data.charts.some(chart => chart.id === current) ? current : data.charts[0]?.id ?? ""); + } }) + .catch(error => { if (!controller.signal.aborted) { setError(message(error)); if (message(error).includes("session has expired")) expired.current(); } }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [revision, date, attempt]); + const chart = charts.find(item => item.id === selected); + async function remove() { + if (!deleting || saving.current) return; + saving.current = true; setBusy(true); setDeleteError(""); + try { + await habitRequest(`/charts/${deleting.id}`, { method: "DELETE" }); + setCharts(current => current.filter(item => item.id !== deleting.id)); + setSelected(charts.find(item => item.id !== deleting.id)?.id ?? ""); + setDeleting(null); setAttempt(value => value + 1); + } catch (error) { setDeleteError(message(error)); if (message(error).includes("session has expired")) expired.current(); } + finally { saving.current = false; setBusy(false); } + } + return
+
+ Your progress, together.} /> + +
+

Group habits into a saved calendar. Each day counts completed habits out of those due; partial progress stays visible in the day details.

+ {error && <>{error}} + {loading &&

Loading overviews…

} + {!loading && !error && !charts.length &&

Create your first overview for a morning routine, health goals, or the habits that matter most.

} + {!!charts.length &&
+ {id => } + {chart && + + +
}> + + } + } + {editing && setEditing(null)} onExpired={onExpired} onSaved={saved => { + setCharts(current => [...current.filter(item => item.id !== saved.id), saved]); setSelected(saved.id); setEditing(null); setAttempt(value => value + 1); + }} />} + {deleting && heading.current} onClose={() => setDeleting(null)}> + {deleteError} +
+
} +
; +} + +function OverviewForm({ chart, date, onClose, onSaved, onExpired }: { + chart?: Chart; date: string; onClose: () => void; onSaved: (chart: Chart) => void; onExpired: () => void; +}) { + const [name, setName] = useState(chart?.name ?? ""); + const [ids, setIds] = useState(chart?.habitIds ?? []); + const [habits, setHabits] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [loadError, setLoadError] = useState(""); + const [attempt, setAttempt] = useState(0); + const saving = useRef(false); + const expired = useRef(onExpired); expired.current = onExpired; + useEffect(() => { + const controller = new AbortController(); setLoading(true); setLoadError(""); + habitRequest<{ habits: TodayHabit[] }>(`/days/${date}`, { signal: controller.signal }) + .then(data => { if (!controller.signal.aborted) setHabits(data.habits); }) + .catch(error => { if (!controller.signal.aborted) { setLoadError(message(error)); if (message(error).includes("session has expired")) expired.current(); } }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [date, attempt]); + async function save() { + if (saving.current || loading || loadError) return; + if (!name.trim() || !ids.length || ids.length > 100) { setError("Enter a name and choose between 1 and 100 habits."); return; } + saving.current = true; setBusy(true); setError(""); + try { + const saved = await habitRequest(chart ? `/charts/${chart.id}` : "/charts", { + method: chart ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name.trim(), habitIds: ids }), + }); + onSaved(saved); + } catch (error) { setError(message(error)); if (message(error).includes("session has expired")) expired.current(); } + finally { saving.current = false; setBusy(false); } + } + return +
{ event.preventDefault(); void save(); }}> + {id => setName(event.target.value)} />} +
Habits +

Choose 1–100 habits. Archived habits can be included to review earlier progress.

+ {habits.map(habit => = 100} onChange={event => { const checked = event.target.checked; setIds(current => checked ? [...current, habit.habitId] : current.filter(id => id !== habit.habitId)); }} />)} +
+ {loading &&

Loading habits…

} + {loadError && <>{loadError}} + {!loading && !loadError && !habits.length &&

Create a habit first, then add it to an overview.

} + {chart &&

Changing the selected habits recalculates this overview’s entire history. Each habit keeps its original targets and schedules for earlier dates.

} + {error} +
+
+
; +} + +function OverviewCalendar({ chart, date, revision, disabled, onExpired, onHistorySaved }: { + chart: Chart; date: string; revision: string; disabled?: boolean; onExpired: () => void; onHistorySaved: () => void; +}) { + const [selection, setSelection] = useState({ months: 3 as MonthView | "custom", range: monthWindow(date, 3) }); + const range = selection.months === "custom" ? selection.range : monthWindow(date, selection.months); + const [calendar, setCalendar] = useState(null); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + const [attempt, setAttempt] = useState(0); + const [inspecting, setInspecting] = useState(null); + const expired = useRef(onExpired); expired.current = onExpired; + useEffect(() => { + const controller = new AbortController(); setLoading(true); setError(""); + habitRequest(`/charts/${chart.id}/calendar?${new URLSearchParams({ ...range, compact: "true" })}`, { signal: controller.signal }) + .then(data => { if (!controller.signal.aborted) setCalendar(data); }) + .catch(error => { if (!controller.signal.aborted) { setError(message(error)); if (message(error).includes("session has expired")) expired.current(); } }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [chart.id, revision, date, range.from, range.to, attempt]); + return <> + {error && <>{error}} + {!calendar ? !error &&

Loading combined calendar…

: setSelection({ range, months })} historyLabel="Completed habits out of those due" selectedDayAction={selected => } />} + {inspecting && setInspecting(null)} onExpired={onExpired} onSaved={() => { setAttempt(value => value + 1); onHistorySaved(); }} />} + ; +} + +function OverviewDay({ chartId, date, revision, onClose, onExpired, onSaved }: { + chartId: string; date: string; revision: string; onClose: () => void; onExpired: () => void; onSaved: () => void; +}) { + const [day, setDay] = useState(null); + const [error, setError] = useState(""); + const [attempt, setAttempt] = useState(0); + const [editing, setEditing] = useState(null); + const [busy, setBusy] = useState(false); + const expired = useRef(onExpired); expired.current = onExpired; + useEffect(() => { + const controller = new AbortController(); setError(""); setDay(null); + habitRequest(`/charts/${chartId}/days/${date}`, { signal: controller.signal }) + .then(data => { if (!controller.signal.aborted) setDay(data); }) + .catch(error => { if (!controller.signal.aborted) { setError(message(error)); if (message(error).includes("session has expired")) expired.current(); } }); + return () => controller.abort(); + }, [chartId, date, revision, attempt]); + return + {error && <>{error}} + {!day ? !error &&

Loading day details…

: <> +

{!day.due ? "Nothing scheduled" : day.future ? "Upcoming" : `${day.completed} of ${day.due} due habits completed`}

+
{day.habits.map(habit =>
+

{habit.name ?? "Habit"}

{!habit.due ? "Nothing scheduled" : habit.future ? "Upcoming" : `${habit.value} of ${habit.target} ${habit.unit} · ${habit.complete ? "Complete" : "Incomplete"}`}

+ {habit.due && !habit.future && } +
+ {!!habit.tasks?.length &&
    {habit.tasks.map(task =>
  • {task.name} · {task.done ? "Complete" : "Incomplete"}
  • )}
} +
)}
+ } + {editing && } +
; +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 1fa2db1..ed5cd05 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,3 +1,4 @@ +import { ProgressOverview } from "../components/ProgressOverview"; import { deferred } from "../components/design-system/deferred"; import { useOnline } from "../pwa/client"; import { CalendarCacheProvider } from "../components/CalendarCache"; @@ -484,6 +485,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi )} + 0} onExpired={expire} onHistorySaved={refreshHistory} /> )} diff --git a/src/shared/calendar.test.ts b/src/shared/calendar.test.ts index 4aa150e..2be1691 100644 --- a/src/shared/calendar.test.ts +++ b/src/shared/calendar.test.ts @@ -19,6 +19,7 @@ test('Nivo adapter preserves per-date colors, neutral/future states and readable const c = await f.json('/charts', 'POST', { name: 'One', habitIds: [h.id] }, 201); const combined = await f.json(`/charts/${c.id}/calendar?from=2026-09-04&to=2026-09-04`); expect(toResponsiveCalendarProps(combined).valueFormat(0)).toBe('0 of 1 habits complete'); + expect(toHeatmapDays(combined)[0]).toMatchObject({ value: 0, target: 1, unit: 'habits complete' }); } finally { f.close(); } }); diff --git a/src/shared/calendar.ts b/src/shared/calendar.ts index b3f9780..9900bab 100644 --- a/src/shared/calendar.ts +++ b/src/shared/calendar.ts @@ -11,9 +11,9 @@ export function toHeatmapDays(calendar: CalendarResponse): CalendarDay[] { return calendar.days.map(day => ({ date: day.date, color: day.color, - unit: day.habits[0]?.unit, - value: day.habits[0]?.value ?? 0, - target: day.habits[0]?.target ?? 0, + unit: calendar.kind === 'combined' ? 'habits complete' : day.habits[0]?.unit, + value: calendar.kind === 'combined' ? day.completed : day.habits[0]?.value ?? 0, + target: calendar.kind === 'combined' ? day.due : day.habits[0]?.target ?? 0, future: day.future, state: !day.due ? 'not-due' : day.future ? 'future' : 'due', }));