Improve overview rendering with custom colors and graphical details
This commit is contained in:
15
README.md
15
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,
|
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.
|
||||||
|
|||||||
47
src/components/OverviewDayGraphic.tsx
Normal file
47
src/components/OverviewDayGraphic.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
<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>
|
</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>
|
<ItemActions name={chart.name} kind="overview" disabled={disabled || loading || busy} onEdit={() => setEditing(chart)} onDelete={() => { setDeleteError(""); setDeleting(chart); }} />
|
||||||
{error && <><FormMessage error>{error}</FormMessage><Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry overviews</Button></>}
|
</header>
|
||||||
{loading && <p role="status">Loading overviews…</p>}
|
<OverviewCalendar chart={chart} date={date} revision={`${revision}:${attempt}:${chart.updatedAt}`} disabled={disabled || loading || busy} onExpired={onExpired} onHistorySaved={onHistorySaved} />
|
||||||
{!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>}
|
</section>)}
|
||||||
{!!charts.length && <div className="ds-form-stack">
|
{(creating || editing) && <OverviewForm chart={editing ?? undefined} date={date} onClose={() => { setEditing(null); onCloseCreate(); }} onExpired={onExpired} onSaved={saved => {
|
||||||
<Field label="Saved overview">{id => <select id={id} value={selected} onChange={event => setSelected(event.target.value)} disabled={busy}>
|
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);
|
||||||
{charts.map(item => <option key={item.id} value={item.id}>{item.name}</option>)}
|
|
||||||
</select>}</Field>
|
|
||||||
{chart && <Card heading={chart.name} variant="outlined" footer={<div className="ds-action-row">
|
|
||||||
<Button variant="secondary" disabled={disabled || loading || busy} aria-haspopup="dialog" onClick={() => setEditing(chart)}>Edit overview</Button>
|
|
||||||
<Button variant="text" disabled={disabled || loading || busy} aria-haspopup="dialog" onClick={() => { setDeleteError(""); setDeleting(chart); }}>Delete overview</Button>
|
|
||||||
</div>}>
|
|
||||||
<OverviewCalendar key={chart.id} chart={chart} date={date} revision={`${revision}:${attempt}:${chart.updatedAt}`} disabled={disabled || loading || busy} onExpired={onExpired} onHistorySaved={onHistorySaved} />
|
|
||||||
</Card>}
|
|
||||||
</div>}
|
|
||||||
{editing && <OverviewForm chart={editing === "new" ? undefined : editing} date={date} onClose={() => setEditing(null)} onExpired={onExpired} onSaved={saved => {
|
|
||||||
setCharts(current => [...current.filter(item => item.id !== saved.id), saved]); setSelected(saved.id); setEditing(null); setAttempt(value => value + 1);
|
|
||||||
}} />}
|
}} />}
|
||||||
{deleting && <Modal id="delete-overview" title="Delete overview?" size="compact" description={`Delete “${deleting.name}”? Your habits and check-ins will stay saved.`} busy={busy} initialFocus="[data-keep-overview]" returnFocus={() => heading.current} onClose={() => setDeleting(null)}>
|
{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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,7 +449,6 @@ 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"
|
||||||
@@ -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} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user