Improve overview rendering with custom colors and graphical details

This commit is contained in:
syntaxbullet
2026-10-25 14:30:17 +01:00
parent c9b1ba850f
commit 36706c6734
8 changed files with 197 additions and 66 deletions

View File

@@ -85,14 +85,19 @@ 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, 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 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. also reaches history outside the displayed year. Future dates and days off are read-only.
The personal progress overview below the habit list supports saved combined calendars. Saved combined calendars appear alongside individual habits in the same list,
with a colored title and a **Combined chart** subtitle.
Choose **Create overview**, name the group, and select 1–100 habits, including Choose **Create overview**, name the group, and select 1–100 habits, including
archived habits for historical review. **Saved overview** switches between groups; archived habits for historical review. Choose a palette or custom hex color for
**Edit overview** renames a group or changes its membership. Membership changes the title and graph; colors adapt to light and dark themes. All saved overviews
are visible without a selector. The heading’s edit control changes the name,
color, or membership. Membership changes
recalculate the entire chart using each habit’s historical requirements. recalculate the entire chart using each habit’s historical requirements.
The calendar starts with three months and supports longer and custom ranges. The calendar starts with three months and supports longer and custom ranges.
**View day details** shows completed-versus-due totals, each habit’s actual **View day details** shows a completion ring, count and task progress bars,
progress, and routine tasks, with access to the existing check-in editor. manual check-in symbols, and task completion indicators alongside exact values.
Days off and future dates use neutral states. Each due habit opens the existing
check-in editor.
Corrections refresh the combined calendar and dashboard. Days off are excluded Corrections refresh the combined calendar and dashboard. Days off are excluded
from totals; partial progress does not count as a completed habit. **Delete from totals; partial progress does not count as a completed habit. **Delete
overview** requires confirmation and keeps all habits and check-ins. overview** requires confirmation and keeps all habits and check-ins.

View File

@@ -0,0 +1,47 @@
import type { CSSProperties } from "react";
import { Check, Circle, Clock3, Minus } from "lucide-react";
import type { CalendarResponse } from "../shared/calendar";
import { habitProgressColor } from "../lib/habit-palette";
import { useTheme } from "./ThemeProvider";
import { Button } from "./design-system/primitives";
type Day = CalendarResponse["days"][number];
/** The ring counts completed habits; individual bars retain partial progress. */
export function OverviewDayGraphic({ day, color, busy, onEdit }: {
day: Day; color: string; busy: boolean; onEdit: (habitId: string) => void;
}) {
const { theme } = useTheme();
const neutral = !day.due || day.future;
const ratio = neutral ? 0 : day.completed / day.due;
const summary = !day.due ? "Nothing scheduled" : day.future ? "Upcoming" : `${day.completed} of ${day.due} due habits completed`;
return <div className="ds-overview-details" style={{ "--habit-color": habitProgressColor(color, 1, theme) } as CSSProperties}>
<div className="ds-overview-summary">
<div className="ds-overview-ring" aria-hidden="true">
<svg viewBox="0 0 120 120" focusable="false">
<circle className="ds-overview-ring-track" cx="60" cy="60" r="50" />
{!neutral && <circle className="ds-overview-ring-value" cx="60" cy="60" r="50" pathLength="100" strokeDasharray={`${ratio * 100} 100`} transform="rotate(-90 60 60)" />}
</svg>
<span className="type-title">{neutral ? "—" : `${Math.round(ratio * 100)}%`}</span>
</div>
<div><p className="type-ui-heading">{summary}</p><p className="ds-supporting-copy">{!day.due ? "Days off don’t count against your progress." : day.future ? "Check back on this date to record your progress." : "Each completed habit counts equally. Partial progress is shown below."}</p></div>
</div>
<div className="ds-overview-detail-grid">
{day.habits.map(habit => {
const inactive = !habit.due || habit.future;
const StatusIcon = !habit.due ? Minus : habit.future ? Clock3 : habit.complete ? Check : Circle;
const status = !habit.due ? "Nothing scheduled" : habit.future ? "Upcoming" : habit.complete ? "Complete" : habit.value > 0 ? "In progress" : "Not started";
return <article key={habit.habitId} className="ds-overview-habit" aria-label={`${habit.name ?? "Habit"} progress`}>
<header><h3 className="type-ui-heading">{habit.name ?? "Habit"}</h3><span className="ds-overview-status"><StatusIcon size={18} aria-hidden="true" />{status}</span></header>
{inactive ? <p className="ds-supporting-copy">{!habit.due ? "Excluded from this day’s total." : "Progress will be available on this date."}</p> : habit.method === "manual" ?
<div className={`ds-overview-check${habit.complete ? " is-complete" : ""}`}><StatusIcon size={28} aria-hidden="true" /><span>{habit.complete ? "Checked in" : "Not checked in"}</span></div> :
<div className="ds-overview-meter"><p><strong className="type-control">{habit.value}</strong><span> of {habit.target} {habit.unit}</span></p>
<progress aria-label={`${habit.name} progress`} aria-valuetext={`${habit.value} of ${habit.target} ${habit.unit}`} value={Math.min(habit.value, habit.target ?? 0)} max={Math.max(1, habit.target ?? 1)} />
</div>}
{!inactive && !!habit.tasks?.length && <ul className="ds-overview-tasks">{habit.tasks.map(task => <li key={task.taskId}><span className={task.done ? "is-complete" : ""}>{task.done ? <Check size={16} aria-hidden="true" /> : <Circle size={16} aria-hidden="true" />}</span><span>{task.name}<span className="ds-visually-hidden"> · {task.done ? "Complete" : "Incomplete"}</span></span></li>)}</ul>}
{!inactive && <Button variant="text" disabled={busy} aria-haspopup="dialog" aria-label={`Edit ${habit.name ?? "habit"} check-in`} onClick={() => onEdit(habit.habitId)}>Edit check-in</Button>}
</article>;
})}
</div>
</div>;
}

View File

@@ -1,6 +1,6 @@
import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test"; import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test";
import { Window } from "happy-dom"; import { Window } from "happy-dom";
import { act } from "react"; import { act, useState } from "react";
import type { Root } from "react-dom/client"; import type { Root } from "react-dom/client";
import { ProgressOverview } from "./ProgressOverview"; import { ProgressOverview } from "./ProgressOverview";
import { fixture } from "../habits/test-fixture"; import { fixture } from "../habits/test-fixture";
@@ -51,11 +51,15 @@ afterAll(() => {
}); });
function button(text: string) { function button(text: string) {
return [...container.querySelectorAll<HTMLButtonElement>("button")].find((element) => element.textContent?.trim() === text)!; return [...container.querySelectorAll<HTMLButtonElement>("button")].find((element) => element.textContent?.trim() === text || element.getAttribute("aria-label") === text || element.getAttribute("aria-label")?.startsWith(`${text} `))!;
} }
function Dashboard() {
const [creating, setCreating] = useState(false);
return <><button id="create-overview" onClick={() => setCreating(true)}>Create overview</button><div className="ds-habit-list"><ProgressOverview creating={creating} onCloseCreate={() => setCreating(false)} date="2026-09-04" revision={0} onExpired={() => {}} onHistorySaved={() => {}} /></div></>;
}
async function render() { async function render() {
await act(async () => root.render(<ProgressOverview date="2026-09-04" revision={0} onExpired={() => {}} onHistorySaved={() => {}} />)); await act(async () => root.render(<Dashboard />));
} }
function checkbox(label: string) { function checkbox(label: string) {
return [...container.querySelectorAll('label')].find(element => element.textContent === label)!.querySelector<HTMLInputElement>('input')!; return [...container.querySelectorAll('label')].find(element => element.textContent === label)!.querySelector<HTMLInputElement>('input')!;
@@ -151,3 +155,42 @@ test("range changes retain the selected window and future or unscheduled days ha
expect(container.querySelector('#overview-day')!.textContent).toContain('Upcoming'); expect(container.querySelector('#overview-day')!.textContent).toContain('Upcoming');
expect([...container.querySelectorAll('#overview-day button')].some(b => b.textContent?.startsWith('Edit '))).toBe(false); expect([...container.querySelectorAll('#overview-day button')].some(b => b.textContent?.startsWith('Edit '))).toBe(false);
}); });
test("all overviews render in the habit list and custom colors persist without resetting other settings", async () => {
const habit = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
const first = await f.json('/charts', 'POST', { name: 'Health', habitIds: [habit.id], settings: { mainColor: '#112233', shadeCount: 7 } }, 201);
await f.json('/charts', 'POST', { name: 'Learning', habitIds: [habit.id] }, 201);
await render();
expect(container.querySelectorAll('.ds-habit-list > .ds-overview')).toHaveLength(2);
expect(container.querySelector('select')).toBeNull();
expect([...container.querySelectorAll('.ds-goal-meta')].map(el => el.textContent)).toEqual(['Combined chart · 1 habit', 'Combined chart · 1 habit']);
await click('Edit overview Health');
await click('Custom');
const hex = container.querySelector<HTMLInputElement>('input[placeholder="#426582"]')!;
await act(async () => {
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, 'value')!.set!.call(hex, '#a35488');
hex.dispatchEvent(new Event('input', { bubbles: true }));
});
await click('Save overview');
expect((await f.json(`/charts/${first.id}`)).settings).toMatchObject({ mainColor: '#a35488', shadeCount: 7 });
expect(container.querySelectorAll('.ds-overview')).toHaveLength(2);
await click('Edit overview Health');
expect(container.querySelector<HTMLInputElement>('input[placeholder="#426582"]')!.value).toBe('#a35488');
});
test("day graphics show partial task and count progress without crediting incomplete habits", async () => {
const count = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8, unit: 'glasses' }, 201);
const routine = await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: [{ name: 'Stretch' }, { name: 'Walk' }] }, 201);
const manual = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
await f.json(`/habits/${count.id}/days/2026-09-04/progress`, 'PUT', { count: 7 });
await f.json(`/habits/${manual.id}/days/2026-09-04/progress`, 'PUT', { done: true });
await f.json(`/habits/${routine.id}/days/2026-09-04/tasks/${routine.tasks[0].id}`, 'PUT', { done: true });
await f.json('/charts', 'POST', { name: 'Together', habitIds: [count.id, routine.id, manual.id] }, 201);
await render();
await click('View day details');
expect(container.querySelector('.ds-overview-ring')?.textContent).toBe('33%');
expect(container.querySelector('progress[aria-label="Water progress"]')?.getAttribute('value')).toBe('7');
expect(container.querySelector('progress[aria-label="Routine progress"]')?.getAttribute('value')).toBe('1');
expect(container.querySelectorAll('.ds-overview-tasks li')).toHaveLength(2);
expect(container.querySelector('.ds-overview-check')?.textContent).toBe('Checked in');
});

View File

@@ -1,11 +1,15 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState, type CSSProperties } from "react";
import type { HabitService } from "../habits/service"; import type { HabitService } from "../habits/service";
import { habitRequest, type TodayHabit } from "../lib/dashboard"; import { habitRequest, type TodayHabit } from "../lib/dashboard";
import { toHeatmapDays, type CalendarResponse } from "../shared/calendar"; import { toHeatmapDays, type CalendarResponse } from "../shared/calendar";
import { CalendarHeatmap } from "./design-system/CalendarHeatmap"; import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
import { monthWindow, type MonthView } from "./design-system/calendar-model"; import { monthWindow, type MonthView } from "./design-system/calendar-model";
import { Button, Checkbox, SectionHeading } from "./design-system/primitives"; import { Button, Checkbox } from "./design-system/primitives";
import { Card } from "./design-system/Card"; import { HabitColorPicker, isHabitColor } from "./design-system/HabitColorPicker";
import { ItemActions } from "./design-system/ItemActions";
import { useTheme } from "./ThemeProvider";
import { habitProgressColor } from "../lib/habit-palette";
import { OverviewDayGraphic } from "./OverviewDayGraphic";
import { Field } from "./design-system/Field"; import { Field } from "./design-system/Field";
import { Modal } from "./design-system/Modal"; import { Modal } from "./design-system/Modal";
import { FormMessage } from "./design-system/WorkspacePanel"; import { FormMessage } from "./design-system/WorkspacePanel";
@@ -16,79 +20,71 @@ type Chart = ReturnType<HabitService["chart"]>;
type Day = CalendarResponse["days"][number]; type Day = CalendarResponse["days"][number];
const message = (error: unknown) => error instanceof Error ? error.message : "Could not load your overview. Try again."; const message = (error: unknown) => error instanceof Error ? error.message : "Could not load your overview. Try again.";
export function ProgressOverview({ date, revision, disabled, onExpired, onHistorySaved }: { export function ProgressOverview({ date, revision, disabled, onExpired, onHistorySaved, creating = false, onCloseCreate = () => {} }: {
creating?: boolean; onCloseCreate?: () => void;
date: string; revision: number; disabled?: boolean; onExpired: () => void; onHistorySaved: () => void; date: string; revision: number; disabled?: boolean; onExpired: () => void; onHistorySaved: () => void;
}) { }) {
const [charts, setCharts] = useState<Chart[]>([]); const [charts, setCharts] = useState<Chart[]>([]);
const [selected, setSelected] = useState(""); const { theme } = useTheme();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [attempt, setAttempt] = useState(0); const [attempt, setAttempt] = useState(0);
const [editing, setEditing] = useState<Chart | "new" | null>(null); const [editing, setEditing] = useState<Chart | null>(null);
const [deleting, setDeleting] = useState<Chart | null>(null); const [deleting, setDeleting] = useState<Chart | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [deleteError, setDeleteError] = useState(""); const [deleteError, setDeleteError] = useState("");
const saving = useRef(false); const saving = useRef(false);
const expired = useRef(onExpired); expired.current = onExpired; const expired = useRef(onExpired); expired.current = onExpired;
const heading = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
setLoading(true); setError(""); setLoading(true); setError("");
habitRequest<{ charts: Chart[] }>("/charts", { signal: controller.signal }) habitRequest<{ charts: Chart[] }>("/charts", { signal: controller.signal })
.then(data => { if (!controller.signal.aborted) { .then(data => { if (!controller.signal.aborted) {
setCharts(data.charts); 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(); } }) .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); }); .finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort(); return () => controller.abort();
}, [revision, date, attempt]); }, [revision, date, attempt]);
const chart = charts.find(item => item.id === selected);
async function remove() { async function remove() {
if (!deleting || saving.current) return; if (!deleting || saving.current) return;
saving.current = true; setBusy(true); setDeleteError(""); saving.current = true; setBusy(true); setDeleteError("");
try { try {
await habitRequest(`/charts/${deleting.id}`, { method: "DELETE" }); await habitRequest(`/charts/${deleting.id}`, { method: "DELETE" });
setCharts(current => current.filter(item => item.id !== deleting.id)); setCharts(current => current.filter(item => item.id !== deleting.id));
setSelected(charts.find(item => item.id !== deleting.id)?.id ?? "");
setDeleting(null); setAttempt(value => value + 1); setDeleting(null); setAttempt(value => value + 1);
} catch (error) { setDeleteError(message(error)); if (message(error).includes("session has expired")) expired.current(); } } catch (error) { setDeleteError(message(error)); if (message(error).includes("session has expired")) expired.current(); }
finally { saving.current = false; setBusy(false); } finally { saving.current = false; setBusy(false); }
} }
return <section className="ds-section" aria-labelledby="overviews-title"> return <>
<div className="ds-section-top" ref={heading} tabIndex={-1}> {error && <div><FormMessage error>{error}</FormMessage><Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry overviews</Button></div>}
<SectionHeading id="overviews-title" title={<>Your progress, <em>together.</em></>} /> {loading && !charts.length && <p role="status">Loading overviews…</p>}
<Button disabled={disabled || loading || busy} aria-haspopup="dialog" onClick={() => setEditing("new")}>Create overview</Button> {charts.map(chart => <section key={chart.id} className="ds-habit-chart ds-goal ds-overview" aria-labelledby={`overview-${chart.id}`} style={{ "--habit-color": habitProgressColor(chart.settings.mainColor, 1, theme) } as CSSProperties}>
</div> <header className="ds-goal-heading">
<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> <div className="ds-goal-identity">
{error && <><FormMessage error>{error}</FormMessage><Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry overviews</Button></>} <h3 id={`overview-${chart.id}`} className="type-title ds-overview-title">{chart.name}</h3>
{loading && <p role="status">Loading overviews…</p>} <p className="ds-goal-meta">Combined chart · {chart.habitIds.length} {chart.habitIds.length === 1 ? "habit" : "habits"}</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>} </div>
{!!charts.length && <div className="ds-form-stack"> <ItemActions name={chart.name} kind="overview" disabled={disabled || loading || busy} onEdit={() => setEditing(chart)} onDelete={() => { setDeleteError(""); setDeleting(chart); }} />
<Field label="Saved overview">{id => <select id={id} value={selected} onChange={event => setSelected(event.target.value)} disabled={busy}> </header>
{charts.map(item => <option key={item.id} value={item.id}>{item.name}</option>)} <OverviewCalendar chart={chart} date={date} revision={`${revision}:${attempt}:${chart.updatedAt}`} disabled={disabled || loading || busy} onExpired={onExpired} onHistorySaved={onHistorySaved} />
</select>}</Field> </section>)}
{chart && <Card heading={chart.name} variant="outlined" footer={<div className="ds-action-row"> {(creating || editing) && <OverviewForm chart={editing ?? undefined} date={date} onClose={() => { setEditing(null); onCloseCreate(); }} onExpired={onExpired} onSaved={saved => {
<Button variant="secondary" disabled={disabled || loading || busy} aria-haspopup="dialog" onClick={() => setEditing(chart)}>Edit overview</Button> setCharts(current => current.some(item => item.id === saved.id) ? current.map(item => item.id === saved.id ? saved : item) : [...current, saved]); setEditing(null); onCloseCreate(); setAttempt(value => value + 1);
<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)}> {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={() => document.getElementById("create-overview")} onClose={() => setDeleting(null)}>
<FormMessage error>{deleteError}</FormMessage> <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> <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>} </Modal>}
</section>; </>;
} }
function OverviewForm({ chart, date, onClose, onSaved, onExpired }: { function OverviewForm({ chart, date, onClose, onSaved, onExpired }: {
chart?: Chart; date: string; onClose: () => void; onSaved: (chart: Chart) => void; onExpired: () => void; chart?: Chart; date: string; onClose: () => void; onSaved: (chart: Chart) => void; onExpired: () => void;
}) { }) {
const [name, setName] = useState(chart?.name ?? ""); const [name, setName] = useState(chart?.name ?? "");
const [color, setColor] = useState(chart?.settings.mainColor ?? "#196127");
const [ids, setIds] = useState(chart?.habitIds ?? []); const [ids, setIds] = useState(chart?.habitIds ?? []);
const [habits, setHabits] = useState<TodayHabit[]>([]); const [habits, setHabits] = useState<TodayHabit[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -109,10 +105,11 @@ function OverviewForm({ chart, date, onClose, onSaved, onExpired }: {
async function save() { async function save() {
if (saving.current || loading || loadError) return; 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; } if (!name.trim() || !ids.length || ids.length > 100) { setError("Enter a name and choose between 1 and 100 habits."); return; }
if (!isHabitColor(color)) { setError("Choose a six-digit hex color."); return; }
saving.current = true; setBusy(true); setError(""); saving.current = true; setBusy(true); setError("");
try { try {
const saved = await habitRequest<Chart>(chart ? `/charts/${chart.id}` : "/charts", { 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 }), method: chart ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name.trim(), habitIds: ids, settings: { ...chart?.settings, mainColor: color } }),
}); });
onSaved(saved); onSaved(saved);
} catch (error) { setError(message(error)); if (message(error).includes("session has expired")) expired.current(); } } catch (error) { setError(message(error)); if (message(error).includes("session has expired")) expired.current(); }
@@ -121,6 +118,7 @@ function OverviewForm({ chart, date, onClose, onSaved, onExpired }: {
return <Modal id="overview-form" title={chart ? "Edit overview" : "Create overview"} onClose={onClose} busy={busy}> 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(); }}> <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> <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" disabled={busy}><HabitColorPicker label="Overview color" value={color} onChange={setColor} /></fieldset>
<fieldset className="ds-task-editor-fieldset ds-form-stack" disabled={busy || loading}><legend className="type-ui-heading">Habits</legend> <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> <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)); }} />)} {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)); }} />)}
@@ -130,7 +128,7 @@ function OverviewForm({ chart, date, onClose, onSaved, onExpired }: {
{!loading && !loadError && !habits.length && <p>Create a habit first, then add it to an overview.</p>} {!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>} {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> <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> <div className="ds-action-row"><Button type="submit" disabled={busy || loading || !!loadError || !ids.length || !name.trim() || !isHabitColor(color)}>{busy ? "Saving…" : "Save overview"}</Button><Button variant="text" disabled={busy} onClick={onClose}>Cancel</Button></div>
</form> </form>
</Modal>; </Modal>;
} }
@@ -157,37 +155,33 @@ function OverviewCalendar({ chart, date, revision, disabled, onExpired, onHistor
return <> return <>
{error && <><FormMessage error>{error}</FormMessage><Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry calendar</Button></>} {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>} />} {!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(); }} />} {inspecting && <OverviewDay color={chart.settings.mainColor} chartName={chart.name} 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 }: { function OverviewDay({ color, chartName, chartId, date, revision, onClose, onExpired, onSaved }: {
chartId: string; date: string; revision: string; onClose: () => void; onExpired: () => void; onSaved: () => void; color: string; chartName: string; chartId: string; date: string; revision: string; onClose: () => void; onExpired: () => void; onSaved: () => void;
}) { }) {
const [day, setDay] = useState<Day | null>(null); const [day, setDay] = useState<Day | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [attempt, setAttempt] = useState(0); const [attempt, setAttempt] = useState(0);
const [editing, setEditing] = useState<string | null>(null); const [editing, setEditing] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const expired = useRef(onExpired); expired.current = onExpired; const expired = useRef(onExpired); expired.current = onExpired;
useEffect(() => { useEffect(() => {
const controller = new AbortController(); setError(""); setDay(null); const controller = new AbortController(); setError(""); setLoading(true);
habitRequest<Day>(`/charts/${chartId}/days/${date}`, { signal: controller.signal }) habitRequest<Day>(`/charts/${chartId}/days/${date}`, { signal: controller.signal })
.then(data => { if (!controller.signal.aborted) setDay(data); }) .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(); } }); .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(); return () => controller.abort();
}, [chartId, date, revision, attempt]); }, [chartId, date, revision, attempt]);
return <Modal id="overview-day" title="Day details" description={date} busy={busy} onClose={onClose}> return <Modal id="overview-day" title={chartName} description={`Day details · ${date}`} busy={busy} onClose={onClose}>
{error && <><FormMessage error>{error}</FormMessage><Button onClick={() => setAttempt(value => value + 1)}>Retry day details</Button></>} {error && <><FormMessage error>{error}</FormMessage><Button onClick={() => setAttempt(value => value + 1)}>Retry day details</Button></>}
{!day ? !error && <p role="status">Loading day details…</p> : <> {!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> <OverviewDayGraphic day={day} color={color} busy={busy || loading} onEdit={setEditing} />
<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} />} {editing && <Modal id="overview-check-in" title="Edit check-in" description={day?.habits.find(habit => habit.habitId === editing)?.name ?? date} busy={busy} onClose={() => setEditing(null)}><CheckInEditor key={`${editing}:${date}`} habitId={editing} date={date} onExpired={onExpired} onBusyChange={setBusy} onSaved={onSaved} /></Modal>}
</Modal>; </Modal>;
} }

View File

@@ -38,7 +38,9 @@ export function isHabitColor(value: string) {
export function HabitColorPicker({ export function HabitColorPicker({
value, value,
onChange, onChange,
label = "Habit color",
}: { }: {
label?: string;
value: string; value: string;
onChange: (color: string) => void; onChange: (color: string) => void;
mode?: "demo" | "create" | "edit"; mode?: "demo" | "create" | "edit";
@@ -50,11 +52,11 @@ export function HabitColorPicker({
const showCustom = expanded || !valid; const showCustom = expanded || !valid;
return ( return (
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}> <fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
<legend>Habit color</legend> <legend>{label}</legend>
<p id={`${id}-hint`} className="ds-visually-hidden"> <p id={`${id}-hint`} className="ds-visually-hidden">
Your color adapts to each theme. Custom colors use six-digit hex values, such as #426582. Your color adapts to each theme. Custom colors use six-digit hex values, such as #426582.
</p> </p>
<div className="ds-color-swatches" role="group" aria-label="Habit colors"> <div className="ds-color-swatches" role="group" aria-label={`${label}s`}>
{HABIT_COLOR_CHOICES.map((color) => ( {HABIT_COLOR_CHOICES.map((color) => (
<button key={color} type="button" aria-label={`Color ${color}`} aria-pressed={value.toLowerCase() === color} title={color} onClick={() => onChange(color)}> <button key={color} type="button" aria-label={`Color ${color}`} aria-pressed={value.toLowerCase() === color} title={color} onClick={() => onChange(color)}>
<span style={{ backgroundColor: habitProgressColor(color, 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}> <span style={{ backgroundColor: habitProgressColor(color, 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>

View File

@@ -3,7 +3,7 @@ import { Button } from "./primitives";
export function ItemActions({ name, kind, disabled, onEdit, onDelete }: { export function ItemActions({ name, kind, disabled, onEdit, onDelete }: {
name: string; name: string;
kind: "habit" | "task"; kind: "habit" | "task" | "overview";
disabled?: boolean; disabled?: boolean;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;

View File

@@ -179,6 +179,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [habitView, setHabitView] = useState<"today" | "all">("today"); const [habitView, setHabitView] = useState<"today" | "all">("today");
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [addingOverview, setAddingOverview] = 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 [archive, setArchive] = useState(false);
@@ -398,6 +399,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
> >
Add a habit + Add a habit +
</Button> </Button>
<Button id="create-overview" variant="secondary" disabled={busy || loading || needsRefresh} aria-haspopup="dialog" onClick={() => setAddingOverview(true)}>Create overview</Button>
{today.habits.length > 0 && ( {today.habits.length > 0 && (
<> <>
<Button id="open-sharing" aria-haspopup="dialog" variant="secondary" disabled={busy || loading || needsRefresh || sharing} aria-expanded={sharing} aria-controls={sharing ? "share-progress" : undefined} onClick={() => setSharing(true)}>Share progress</Button> <Button id="open-sharing" aria-haspopup="dialog" variant="secondary" disabled={busy || loading || needsRefresh || sharing} aria-expanded={sharing} aria-controls={sharing ? "share-progress" : undefined} onClick={() => setSharing(true)}>Share progress</Button>
@@ -447,8 +449,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
}} }}
/> />
)} )}
{today.habits.length > 0 && ( <section
<section
className="ds-section ds-habits-section" className="ds-section ds-habits-section"
aria-labelledby="habits-title" aria-labelledby="habits-title"
aria-busy={busy || loading} aria-busy={busy || loading}
@@ -467,7 +468,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
<Button variant={habitView === "all" ? "primary" : "text"} aria-pressed={habitView === "all"} onClick={() => setHabitView("all")}>All habits · {today.habits.length}</Button> <Button variant={habitView === "all" ? "primary" : "text"} aria-pressed={habitView === "all"} onClick={() => setHabitView("all")}>All habits · {today.habits.length}</Button>
</div> </div>
</div> </div>
{habitView === "today" && today.due === 0 && <p className="ds-supporting-copy">You’re all set for today. Choose All habits to view or edit your routines.</p>} {habitView === "today" && today.habits.length > 0 && today.due === 0 && <p className="ds-supporting-copy">You’re all set for today. Choose All habits to view or edit your routines.</p>}
<div className="ds-habit-chart-grid ds-habit-list"> <div className="ds-habit-chart-grid ds-habit-list">
{today.habits.filter(habit => habitView === "all" || habit.due).map((habit) => ( {today.habits.filter(habit => habitView === "all" || habit.due).map((habit) => (
<SavedHabit <SavedHabit
@@ -482,10 +483,9 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
onExpired={expire} onExpired={expire}
/> />
))} ))}
<ProgressOverview creating={addingOverview} onCloseCreate={() => setAddingOverview(false)} date={today.date} revision={revision + attempt} disabled={busy || loading || needsRefresh || pendingHabits.size > 0} onExpired={expire} onHistorySaved={refreshHistory} />
</div> </div>
</section> </section>
)}
<ProgressOverview date={today.date} revision={revision + attempt} disabled={busy || loading || needsRefresh || pendingHabits.size > 0} onExpired={expire} onHistorySaved={refreshHistory} />
</> </>
)} )}
</> </>

View File

@@ -1761,3 +1761,43 @@
.ds-inline-item-fields:not(.ds-habit-form-grid) { display: grid; gap: 12px; } .ds-inline-item-fields:not(.ds-habit-form-grid) { display: grid; gap: 12px; }
.ds-inline-item-fields:not(.ds-habit-form-grid) .ds-field { margin: 0; } .ds-inline-item-fields:not(.ds-habit-form-grid) .ds-field { margin: 0; }
.ds-inline-item-fields:not(.ds-habit-form-grid) .ds-schedule-editor { display: grid; gap: 12px; } .ds-inline-item-fields:not(.ds-habit-form-grid) .ds-schedule-editor { display: grid; gap: 12px; }
/* Combined calendars live alongside habits; their color identifies the group. */
.ds-overview .ds-overview-title { color: var(--habit-color); margin: 0 0 8px; }
.ds-overview > .ds-calendar { min-width: 0; }
.ds-overview-details { display: grid; gap: 24px; }
.ds-overview-summary { display: flex; align-items: center; gap: 24px; padding: 24px; background: var(--ds-surface); }
.ds-overview-summary .ds-supporting-copy { margin-top: 8px; }
.ds-overview-ring { position: relative; flex: 0 0 112px; width: 112px; height: 112px; display: grid; place-items: center; color: var(--habit-color); }
.ds-overview-ring svg { position: absolute; inset: 0; width: 100%; height: 100%; fill: none; stroke-width: 8; }
.ds-overview-ring-track { stroke: var(--ds-rule); }
.ds-overview-ring-value { stroke: currentColor; }
.ds-overview-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.ds-overview-habit { display: flex; flex-direction: column; gap: 16px; padding: 20px; border: 1px solid var(--ds-rule); min-width: 0; }
.ds-overview-habit header { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; }
.ds-overview-habit h3 { overflow-wrap: anywhere; }
.ds-overview-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ds-secondary); @apply type-small; }
.ds-overview-status svg { flex-shrink: 0; }
.ds-overview-meter { display: grid; gap: 12px; }
.ds-overview-meter p { color: var(--ds-secondary); }
.ds-overview-meter strong { color: var(--habit-color); font-variant-numeric: tabular-nums; }
.ds-overview-meter progress { appearance: none; width: 100%; height: 8px; border: 0; background: var(--ds-empty); color: var(--habit-color); accent-color: var(--habit-color); }
.ds-overview-meter progress::-webkit-progress-bar { background: var(--ds-empty); }
.ds-overview-meter progress::-webkit-progress-value { background: var(--habit-color); }
.ds-overview-meter progress::-moz-progress-bar { background: var(--habit-color); }
.ds-overview-check { display: flex; align-items: center; gap: 12px; padding-block: 8px; color: var(--ds-secondary); }
.ds-overview-check.is-complete, .ds-overview-tasks .is-complete { color: var(--habit-color); }
.ds-overview-tasks { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
.ds-overview-tasks li { display: flex; align-items: baseline; gap: 10px; overflow-wrap: anywhere; }
.ds-overview-tasks li > span:first-child { flex-shrink: 0; align-self: flex-start; padding-top: 3px; }
.ds-overview-habit > .ds-button { margin-top: auto; align-self: flex-start; }
@media (max-width: 640px) {
.ds-overview-detail-grid { grid-template-columns: minmax(0, 1fr); }
.ds-overview-summary { padding: 16px; gap: 16px; flex-wrap: wrap; }
.ds-overview-ring { flex-basis: 88px; width: 88px; height: 88px; }
}
@media (forced-colors: active) {
.ds-overview-ring-track { stroke: GrayText; }
.ds-overview-ring-value { stroke: Highlight; }
.ds-overview-meter progress { appearance: auto; }
}