Add personal progress overviews with saved combined calendars
This commit is contained in:
12
README.md
12
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
|
||||
|
||||
|
||||
153
src/components/ProgressOverview.test.tsx
Normal file
153
src/components/ProgressOverview.test.tsx
Normal file
@@ -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<string, PropertyDescriptor | undefined>();
|
||||
let createRoot: typeof import("react-dom/client").createRoot;
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let f: ReturnType<typeof fixture>;
|
||||
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<string, unknown>)[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<HTMLButtonElement>("button")].find((element) => element.textContent?.trim() === text)!;
|
||||
}
|
||||
|
||||
async function render() {
|
||||
await act(async () => root.render(<ProgressOverview date="2026-09-04" revision={0} onExpired={() => {}} onHistorySaved={() => {}} />));
|
||||
}
|
||||
function checkbox(label: string) {
|
||||
return [...container.querySelectorAll('label')].find(element => element.textContent === label)!.querySelector<HTMLInputElement>('input')!;
|
||||
}
|
||||
async function click(text: string) { await act(async () => button(text).click()); }
|
||||
async function name(value: string) {
|
||||
const input = container.querySelector<HTMLInputElement>('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<HTMLButtonElement>('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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLButtonElement>('button[aria-label="6 months"]')!.click());
|
||||
const squares = () => container.querySelectorAll('button[aria-label^="2026-"]').length;
|
||||
const count = squares();
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('button[aria-label="Close dialog"]')!.click());
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('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);
|
||||
});
|
||||
193
src/components/ProgressOverview.tsx
Normal file
193
src/components/ProgressOverview.tsx
Normal file
@@ -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<HabitService["chart"]>;
|
||||
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<Chart[]>([]);
|
||||
const [selected, setSelected] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [editing, setEditing] = useState<Chart | "new" | null>(null);
|
||||
const [deleting, setDeleting] = useState<Chart | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
const saving = useRef(false);
|
||||
const expired = useRef(onExpired); expired.current = onExpired;
|
||||
const heading = useRef<HTMLDivElement>(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 <section className="ds-section" aria-labelledby="overviews-title">
|
||||
<div className="ds-section-top" ref={heading} tabIndex={-1}>
|
||||
<SectionHeading id="overviews-title" title={<>Your progress, <em>together.</em></>} />
|
||||
<Button disabled={disabled || loading || busy} aria-haspopup="dialog" onClick={() => setEditing("new")}>Create overview</Button>
|
||||
</div>
|
||||
<p className="ds-supporting-copy">Group habits into a saved calendar. Each day counts completed habits out of those due; partial progress stays visible in the day details.</p>
|
||||
{error && <><FormMessage error>{error}</FormMessage><Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry overviews</Button></>}
|
||||
{loading && <p role="status">Loading overviews…</p>}
|
||||
{!loading && !error && !charts.length && <p className="ds-supporting-copy">Create your first overview for a morning routine, health goals, or the habits that matter most.</p>}
|
||||
{!!charts.length && <div className="ds-form-stack">
|
||||
<Field label="Saved overview">{id => <select id={id} value={selected} onChange={event => setSelected(event.target.value)} disabled={busy}>
|
||||
{charts.map(item => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>}</Field>
|
||||
{chart && <Card heading={chart.name} variant="outlined" footer={<div className="ds-action-row">
|
||||
<Button variant="secondary" disabled={disabled || loading || busy} aria-haspopup="dialog" onClick={() => setEditing(chart)}>Edit overview</Button>
|
||||
<Button variant="text" disabled={disabled || loading || busy} aria-haspopup="dialog" onClick={() => { setDeleteError(""); setDeleting(chart); }}>Delete overview</Button>
|
||||
</div>}>
|
||||
<OverviewCalendar key={chart.id} chart={chart} date={date} revision={`${revision}:${attempt}:${chart.updatedAt}`} disabled={disabled || loading || busy} onExpired={onExpired} onHistorySaved={onHistorySaved} />
|
||||
</Card>}
|
||||
</div>}
|
||||
{editing && <OverviewForm chart={editing === "new" ? undefined : editing} date={date} onClose={() => 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 && <Modal id="delete-overview" title="Delete overview?" size="compact" description={`Delete “${deleting.name}”? Your habits and check-ins will stay saved.`} busy={busy} initialFocus="[data-keep-overview]" returnFocus={() => heading.current} onClose={() => setDeleting(null)}>
|
||||
<FormMessage error>{deleteError}</FormMessage>
|
||||
<div className="ds-action-row"><Button data-keep-overview variant="secondary" disabled={busy} onClick={() => setDeleting(null)}>Keep overview</Button><Button disabled={busy} onClick={() => void remove()}>{busy ? "Deleting…" : "Delete overview"}</Button></div>
|
||||
</Modal>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
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<TodayHabit[]>([]);
|
||||
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>(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 <Modal id="overview-form" title={chart ? "Edit overview" : "Create overview"} onClose={onClose} busy={busy}>
|
||||
<form className="ds-form-stack" onSubmit={event => { event.preventDefault(); void save(); }}>
|
||||
<Field label="Overview name">{id => <input id={id} required maxLength={200} value={name} disabled={busy} placeholder="e.g. Morning routine" onChange={event => setName(event.target.value)} />}</Field>
|
||||
<fieldset className="ds-task-editor-fieldset ds-form-stack" disabled={busy || loading}><legend className="type-ui-heading">Habits</legend>
|
||||
<p className="ds-supporting-copy">Choose 1–100 habits. Archived habits can be included to review earlier progress.</p>
|
||||
{habits.map(habit => <Checkbox key={habit.habitId} label={`${habit.name ?? "Habit"}${habit.requirements?.archived ? " · Archived" : ""}`} checked={ids.includes(habit.habitId)} disabled={!ids.includes(habit.habitId) && ids.length >= 100} onChange={event => { const checked = event.target.checked; setIds(current => checked ? [...current, habit.habitId] : current.filter(id => id !== habit.habitId)); }} />)}
|
||||
</fieldset>
|
||||
{loading && <p role="status">Loading habits…</p>}
|
||||
{loadError && <><FormMessage error>{loadError}</FormMessage><Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry habits</Button></>}
|
||||
{!loading && !loadError && !habits.length && <p>Create a habit first, then add it to an overview.</p>}
|
||||
{chart && <p className="ds-supporting-copy">Changing the selected habits recalculates this overview’s entire history. Each habit keeps its original targets and schedules for earlier dates.</p>}
|
||||
<FormMessage error>{error}</FormMessage>
|
||||
<div className="ds-action-row"><Button type="submit" disabled={busy || loading || !!loadError || !ids.length || !name.trim()}>{busy ? "Saving…" : "Save overview"}</Button><Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button></div>
|
||||
</form>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
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<CalendarResponse | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [inspecting, setInspecting] = useState<string | null>(null);
|
||||
const expired = useRef(onExpired); expired.current = onExpired;
|
||||
useEffect(() => {
|
||||
const controller = new AbortController(); setLoading(true); setError("");
|
||||
habitRequest<CalendarResponse>(`/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 && <><FormMessage error>{error}</FormMessage><Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry calendar</Button></>}
|
||||
{!calendar ? !error && <p role="status">Loading combined calendar…</p> : <CalendarHeatmap days={toHeatmapDays(calendar)} unit="habits complete" label={`${chart.name} combined calendar`} color={calendar.settings.mainColor} initialMonths={3} availableRange={monthWindow(date, 12)} loading={loading} onRangeChange={(range, months) => setSelection({ range, months })} historyLabel="Completed habits out of those due" selectedDayAction={selected => <Button variant="secondary" disabled={loading || !!error || disabled} aria-haspopup="dialog" onClick={() => setInspecting(selected)}>View day details</Button>} />}
|
||||
{inspecting && <OverviewDay chartId={chart.id} date={inspecting} revision={`${revision}:${attempt}`} onClose={() => 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<Day | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const expired = useRef(onExpired); expired.current = onExpired;
|
||||
useEffect(() => {
|
||||
const controller = new AbortController(); setError(""); setDay(null);
|
||||
habitRequest<Day>(`/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 <Modal id="overview-day" title="Day details" description={date} busy={busy} onClose={onClose}>
|
||||
{error && <><FormMessage error>{error}</FormMessage><Button onClick={() => setAttempt(value => value + 1)}>Retry day details</Button></>}
|
||||
{!day ? !error && <p role="status">Loading day details…</p> : <>
|
||||
<p className="type-ui-heading">{!day.due ? "Nothing scheduled" : day.future ? "Upcoming" : `${day.completed} of ${day.due} due habits completed`}</p>
|
||||
<div className="ds-record-list">{day.habits.map(habit => <article key={habit.habitId} className="ds-record">
|
||||
<div className="ds-record-heading"><div className="ds-record-copy"><h3>{habit.name ?? "Habit"}</h3><p>{!habit.due ? "Nothing scheduled" : habit.future ? "Upcoming" : `${habit.value} of ${habit.target} ${habit.unit} · ${habit.complete ? "Complete" : "Incomplete"}`}</p></div>
|
||||
{habit.due && !habit.future && <Button variant="secondary" disabled={busy} onClick={() => setEditing(habit.habitId)}>Edit {habit.name ?? "habit"} check-in</Button>}
|
||||
</div>
|
||||
{!!habit.tasks?.length && <ul>{habit.tasks.map(task => <li key={task.taskId}>{task.name} · {task.done ? "Complete" : "Incomplete"}</li>)}</ul>}
|
||||
</article>)}</div>
|
||||
</>}
|
||||
{editing && <CheckInEditor key={`${editing}:${date}`} habitId={editing} date={date} onExpired={onExpired} onBusyChange={setBusy} onSaved={onSaved} />}
|
||||
</Modal>;
|
||||
}
|
||||
@@ -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
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<ProgressOverview date={today.date} revision={revision + attempt} disabled={busy || loading || needsRefresh || pendingHabits.size > 0} onExpired={expire} onHistorySaved={refreshHistory} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user