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,
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.
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
archived habits for historical review. **Saved overview** switches between groups;
**Edit overview** renames a group or changes its membership. Membership changes
archived habits for historical review. Choose a palette or custom hex color for
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.
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.
**View day details** shows a completion ring, count and task progress bars,
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
from totals; partial progress does not count as a completed habit. **Delete
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 { Window } from "happy-dom";
import { act } from "react";
import { act, useState } from "react";
import type { Root } from "react-dom/client";
import { ProgressOverview } from "./ProgressOverview";
import { fixture } from "../habits/test-fixture";
@@ -51,11 +51,15 @@ afterAll(() => {
});
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() {
await act(async () => root.render(<ProgressOverview date="2026-09-04" revision={0} onExpired={() => {}} onHistorySaved={() => {}} />));
await act(async () => root.render(<Dashboard />));
}
function checkbox(label: string) {
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.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 { 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 { Button, Checkbox } from "./design-system/primitives";
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 { Modal } from "./design-system/Modal";
import { FormMessage } from "./design-system/WorkspacePanel";
@@ -16,79 +20,71 @@ 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 }: {
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;
}) {
const [charts, setCharts] = useState<Chart[]>([]);
const [selected, setSelected] = useState("");
const { theme } = useTheme();
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
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 [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>
return <>
{error && <div><FormMessage error>{error}</FormMessage><Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry overviews</Button></div>}
{loading && !charts.length && <p role="status">Loading overviews…</p>}
{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}>
<header className="ds-goal-heading">
<div className="ds-goal-identity">
<h3 id={`overview-${chart.id}`} className="type-title ds-overview-title">{chart.name}</h3>
<p className="ds-goal-meta">Combined chart · {chart.habitIds.length} {chart.habitIds.length === 1 ? "habit" : "habits"}</p>
</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);
<ItemActions name={chart.name} kind="overview" disabled={disabled || loading || busy} onEdit={() => setEditing(chart)} onDelete={() => { setDeleteError(""); setDeleting(chart); }} />
</header>
<OverviewCalendar chart={chart} date={date} revision={`${revision}:${attempt}:${chart.updatedAt}`} disabled={disabled || loading || busy} onExpired={onExpired} onHistorySaved={onHistorySaved} />
</section>)}
{(creating || editing) && <OverviewForm chart={editing ?? undefined} date={date} onClose={() => { setEditing(null); onCloseCreate(); }} onExpired={onExpired} onSaved={saved => {
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);
}} />}
{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>
<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 [color, setColor] = useState(chart?.settings.mainColor ?? "#196127");
const [ids, setIds] = useState(chart?.habitIds ?? []);
const [habits, setHabits] = useState<TodayHabit[]>([]);
const [loading, setLoading] = useState(true);
@@ -109,10 +105,11 @@ function OverviewForm({ chart, date, onClose, onSaved, onExpired }: {
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; }
if (!isHabitColor(color)) { setError("Choose a six-digit hex color."); 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 }),
method: chart ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name.trim(), habitIds: ids, settings: { ...chart?.settings, mainColor: color } }),
});
onSaved(saved);
} 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}>
<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" 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>
<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)); }} />)}
@@ -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>}
{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>
<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>
</Modal>;
}
@@ -157,37 +155,33 @@ function OverviewCalendar({ chart, date, revision, disabled, onExpired, onHistor
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(); }} />}
{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 }: {
chartId: string; date: string; revision: string; onClose: () => void; onExpired: () => void; onSaved: () => void;
function OverviewDay({ color, chartName, chartId, date, revision, onClose, onExpired, onSaved }: {
color: string; chartName: string; chartId: string; date: string; revision: string; onClose: () => void; onExpired: () => void; onSaved: () => void;
}) {
const [day, setDay] = useState<Day | null>(null);
const [loading, setLoading] = useState(true);
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);
const controller = new AbortController(); setError(""); setLoading(true);
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(); } });
.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();
}, [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></>}
{!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>
<OverviewDayGraphic day={day} color={color} busy={busy || loading} onEdit={setEditing} />
</>}
{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>;
}

View File

@@ -38,7 +38,9 @@ export function isHabitColor(value: string) {
export function HabitColorPicker({
value,
onChange,
label = "Habit color",
}: {
label?: string;
value: string;
onChange: (color: string) => void;
mode?: "demo" | "create" | "edit";
@@ -50,11 +52,11 @@ export function HabitColorPicker({
const showCustom = expanded || !valid;
return (
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
<legend>Habit color</legend>
<legend>{label}</legend>
<p id={`${id}-hint`} className="ds-visually-hidden">
Your color adapts to each theme. Custom colors use six-digit hex values, such as #426582.
</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) => (
<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" }}>

View File

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

View File

@@ -179,6 +179,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
const [loading, setLoading] = useState(true);
const [habitView, setHabitView] = useState<"today" | "all">("today");
const [adding, setAdding] = useState(false);
const [addingOverview, setAddingOverview] = useState(false);
const [sharing, setSharing] = useState(false);
const [settings, setSettings] = useState(false);
const [archive, setArchive] = useState(false);
@@ -398,6 +399,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
>
Add a habit +
</Button>
<Button id="create-overview" variant="secondary" disabled={busy || loading || needsRefresh} aria-haspopup="dialog" onClick={() => setAddingOverview(true)}>Create overview</Button>
{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>
@@ -447,7 +449,6 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
}}
/>
)}
{today.habits.length > 0 && (
<section
className="ds-section ds-habits-section"
aria-labelledby="habits-title"
@@ -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>
</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">
{today.habits.filter(habit => habitView === "all" || habit.due).map((habit) => (
<SavedHabit
@@ -482,10 +483,9 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
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>
</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) .ds-field { margin: 0; }
.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; }
}