diff --git a/README.md b/README.md index 585e3c6..03e7ac4 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/components/OverviewDayGraphic.tsx b/src/components/OverviewDayGraphic.tsx new file mode 100644 index 0000000..e0e13bf --- /dev/null +++ b/src/components/OverviewDayGraphic.tsx @@ -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
+
+ +

{summary}

{!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."}

+
+
+ {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
+

{habit.name ?? "Habit"}

+ {inactive ?

{!habit.due ? "Excluded from this day’s total." : "Progress will be available on this date."}

: habit.method === "manual" ? +
: +

{habit.value} of {habit.target} {habit.unit}

+ +
} + {!inactive && !!habit.tasks?.length &&
    {habit.tasks.map(task =>
  • {task.done ? {task.name} · {task.done ? "Complete" : "Incomplete"}
  • )}
} + {!inactive && } +
; + })} +
+
; +} diff --git a/src/components/ProgressOverview.test.tsx b/src/components/ProgressOverview.test.tsx index 5da44b3..a5e3d6a 100644 --- a/src/components/ProgressOverview.test.tsx +++ b/src/components/ProgressOverview.test.tsx @@ -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("button")].find((element) => element.textContent?.trim() === text)!; + return [...container.querySelectorAll("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 <>
setCreating(false)} date="2026-09-04" revision={0} onExpired={() => {}} onHistorySaved={() => {}} />
; +} async function render() { - await act(async () => root.render( {}} onHistorySaved={() => {}} />)); + await act(async () => root.render()); } function checkbox(label: string) { return [...container.querySelectorAll('label')].find(element => element.textContent === label)!.querySelector('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('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('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'); +}); diff --git a/src/components/ProgressOverview.tsx b/src/components/ProgressOverview.tsx index a7b9ed4..731e5fa 100644 --- a/src/components/ProgressOverview.tsx +++ b/src/components/ProgressOverview.tsx @@ -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; 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([]); - 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(null); + const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); const [busy, setBusy] = useState(false); const [deleteError, setDeleteError] = useState(""); const saving = useRef(false); const expired = useRef(onExpired); expired.current = onExpired; - const heading = useRef(null); + useEffect(() => { const controller = new AbortController(); setLoading(true); setError(""); habitRequest<{ charts: Chart[] }>("/charts", { signal: controller.signal }) .then(data => { if (!controller.signal.aborted) { setCharts(data.charts); - setSelected(current => data.charts.some(chart => chart.id === current) ? current : data.charts[0]?.id ?? ""); } }) .catch(error => { if (!controller.signal.aborted) { setError(message(error)); if (message(error).includes("session has expired")) expired.current(); } }) .finally(() => { if (!controller.signal.aborted) setLoading(false); }); return () => controller.abort(); }, [revision, date, attempt]); - const chart = charts.find(item => item.id === selected); async function remove() { if (!deleting || saving.current) return; saving.current = true; setBusy(true); setDeleteError(""); try { await habitRequest(`/charts/${deleting.id}`, { method: "DELETE" }); setCharts(current => current.filter(item => item.id !== deleting.id)); - setSelected(charts.find(item => item.id !== deleting.id)?.id ?? ""); setDeleting(null); setAttempt(value => value + 1); } catch (error) { setDeleteError(message(error)); if (message(error).includes("session has expired")) expired.current(); } finally { saving.current = false; setBusy(false); } } - return
-
- Your progress, together.} /> - -
-

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

- {error && <>{error}} - {loading &&

Loading overviews…

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

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

} - {!!charts.length &&
- {id => } - {chart && - - -
}> - - } - } - {editing && setEditing(null)} onExpired={onExpired} onSaved={saved => { - setCharts(current => [...current.filter(item => item.id !== saved.id), saved]); setSelected(saved.id); setEditing(null); setAttempt(value => value + 1); + return <> + {error &&
{error}
} + {loading && !charts.length &&

Loading overviews…

} + {charts.map(chart =>
+
+
+

{chart.name}

+

Combined chart · {chart.habitIds.length} {chart.habitIds.length === 1 ? "habit" : "habits"}

+
+ setEditing(chart)} onDelete={() => { setDeleteError(""); setDeleting(chart); }} /> +
+ +
)} + {(creating || editing) && { 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 && heading.current} onClose={() => setDeleting(null)}> + {deleting && document.getElementById("create-overview")} onClose={() => setDeleting(null)}> {deleteError}
} -
; + ; } function OverviewForm({ chart, date, onClose, onSaved, onExpired }: { chart?: Chart; date: string; onClose: () => void; onSaved: (chart: Chart) => void; onExpired: () => void; }) { const [name, setName] = useState(chart?.name ?? ""); + const [color, setColor] = useState(chart?.settings.mainColor ?? "#196127"); const [ids, setIds] = useState(chart?.habitIds ?? []); const [habits, setHabits] = useState([]); 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 ? `/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
{ event.preventDefault(); void save(); }}> {id => setName(event.target.value)} />} +
Habits

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

{habits.map(habit => = 100} onChange={event => { const checked = event.target.checked; setIds(current => checked ? [...current, habit.habitId] : current.filter(id => id !== habit.habitId)); }} />)} @@ -130,7 +128,7 @@ function OverviewForm({ chart, date, onClose, onSaved, onExpired }: { {!loading && !loadError && !habits.length &&

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

} {chart &&

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

} {error} -
+
; } @@ -157,37 +155,33 @@ function OverviewCalendar({ chart, date, revision, disabled, onExpired, onHistor return <> {error && <>{error}} {!calendar ? !error &&

Loading combined calendar…

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

Loading day details…

: <> -

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

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

{habit.name ?? "Habit"}

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

- {habit.due && !habit.future && } -
- {!!habit.tasks?.length &&
    {habit.tasks.map(task =>
  • {task.name} · {task.done ? "Complete" : "Incomplete"}
  • )}
} -
)}
+ } - {editing && } + {editing && habit.habitId === editing)?.name ?? date} busy={busy} onClose={() => setEditing(null)}>}
; } diff --git a/src/components/design-system/HabitColorPicker.tsx b/src/components/design-system/HabitColorPicker.tsx index e7ef245..06c00a0 100644 --- a/src/components/design-system/HabitColorPicker.tsx +++ b/src/components/design-system/HabitColorPicker.tsx @@ -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 (
- Habit color + {label}

Your color adapts to each theme. Custom colors use six-digit hex values, such as #426582.

-
+
{HABIT_COLOR_CHOICES.map((color) => ( + {today.habits.length > 0 && ( <> @@ -447,8 +449,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi }} /> )} - {today.habits.length > 0 && ( -
voi
- {habitView === "today" && today.due === 0 &&

You’re all set for today. Choose All habits to view or edit your routines.

} + {habitView === "today" && today.habits.length > 0 && today.due === 0 &&

You’re all set for today. Choose All habits to view or edit your routines.

}
{today.habits.filter(habit => habitView === "all" || habit.due).map((habit) => ( voi onExpired={expire} /> ))} + setAddingOverview(false)} date={today.date} revision={revision + attempt} disabled={busy || loading || needsRefresh || pendingHabits.size > 0} onExpired={expire} onHistorySaved={refreshHistory} />
- )} - 0} onExpired={expire} onHistorySaved={refreshHistory} /> )} diff --git a/styles/design-system.css b/styles/design-system.css index 9eeb246..91e537f 100644 --- a/styles/design-system.css +++ b/styles/design-system.css @@ -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; } +}