Add new styles for home and landing pages

- Created home.css with comprehensive styles for the home page layout, including typography, buttons, and responsive design adjustments.
- Created landing.css to style the landing page, focusing on typography, layout, and responsive behavior for various screen sizes.
This commit is contained in:
syntaxbullet
2026-09-04 11:50:24 +02:00
parent 1acf016169
commit 084603bb6e
43 changed files with 7191 additions and 65 deletions

View File

@@ -1,58 +1,7 @@
import { useEffect, useState } from "react";
import { useLocation } from "react-router";
import type { PublicUser } from "../shared/user";
const authErrors: Record<string, string> = {
not_configured: "Discord sign-in is not configured yet.",
invalid_state: "Your sign-in attempt expired or could not be verified. Please try again.",
denied: "Discord sign-in was cancelled. You can try again when ready.",
invalid_code: "Discord did not return a sign-in code. Please try again.",
discord_unavailable: "Could not complete Discord sign-in. Please try again.",
};
import { useAuth } from "./AuthProvider";
export function AuthControls() {
const [user, setUser] = useState<PublicUser | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const { search } = useLocation();
const signInError = authErrors[new URLSearchParams(search).get("auth_error") ?? ""];
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
try {
const response = await fetch("/api/me", { signal: controller.signal });
if (response.status === 401) return;
if (!response.ok) throw new Error("Could not load your account. Please reload to try again.");
setUser(await response.json());
} catch (error) {
if (!controller.signal.aborted) setError(error instanceof Error ? error.message : "Could not load your account.");
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}
void loadUser();
return () => controller.abort();
}, []);
function signIn() {
let timezone = "UTC";
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
}
async function signOut() {
setBusy(true);
setError("");
try {
const response = await fetch("/api/auth/logout", { method: "POST" });
if (!response.ok) throw new Error("Could not sign out. Please try again.");
setUser(null);
} catch (error) {
setError(error instanceof Error ? error.message : "Could not sign out.");
} finally { setBusy(false); }
}
const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth();
return (
<section aria-label="Account">
@@ -62,8 +11,9 @@ export function AuthControls() {
<button type="button" disabled={busy} onClick={signOut}>{busy ? "Signing out…" : "Sign out"}</button>{" "}
<a href="/api/me">View my profile</a>
</p>
) : <p><button type="button" onClick={signIn}>Sign in with Discord</button></p>}
{(error || signInError) && <p role="alert">{error || signInError}</p>}
) : !accountError && <p><button type="button" onClick={signIn}>Sign in with Discord</button></p>}
{error && <p role="alert">{error}</p>}
{accountError && <button type="button" onClick={retry}>Try again</button>}
</section>
);
}

View File

@@ -0,0 +1,91 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { useLocation } from "react-router";
import type { PublicUser } from "../shared/user";
const authErrors: Record<string, string> = {
not_configured: "Discord sign-in is not configured yet.",
invalid_state: "Your sign-in attempt expired or could not be verified. Please try again.",
denied: "Discord sign-in was cancelled. You can try again when ready.",
invalid_code: "Discord did not return a sign-in code. Please try again.",
discord_unavailable: "Could not complete Discord sign-in. Please try again.",
};
function signIn() {
let timezone = "UTC";
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
}
type AuthState = {
user: PublicUser | null;
loading: boolean;
busy: boolean;
accountError: string;
error: string;
signIn: () => void;
signOut: () => Promise<void>;
retry: () => void;
};
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<PublicUser | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [accountError, setAccountError] = useState("");
const [actionError, setActionError] = useState("");
const [attempt, setAttempt] = useState(0);
const { search } = useLocation();
const signInError = authErrors[new URLSearchParams(search).get("auth_error") ?? ""];
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
try {
const response = await fetch("/api/me", { signal: controller.signal });
if (controller.signal.aborted) return;
if (response.status === 401) { setUser(null); return; }
if (!response.ok) throw new Error("Could not load your account. Please try again.");
const account: PublicUser = await response.json();
if (!controller.signal.aborted) setUser(account);
} catch (error) {
if (!controller.signal.aborted) setAccountError(error instanceof Error ? error.message : "Could not load your account.");
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}
void loadUser();
return () => controller.abort();
}, [attempt]);
function retry() {
setAccountError("");
setLoading(true);
setAttempt((current) => current + 1);
}
async function signOut() {
setBusy(true);
setActionError("");
try {
const response = await fetch("/api/auth/logout", { method: "POST" });
if (!response.ok) throw new Error("Could not sign out. Please try again.");
setUser(null);
} catch (error) {
setActionError(error instanceof Error ? error.message : "Could not sign out.");
} finally { setBusy(false); }
}
return (
<AuthContext.Provider value={{ user, loading, busy, accountError, error: accountError || actionError || signInError || "", signIn, signOut, retry }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const auth = useContext(AuthContext);
if (!auth) throw new Error("useAuth must be used within AuthProvider");
return auth;
}

View File

@@ -0,0 +1,261 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { Button } from "./design-system/primitives";
import { ScheduleEditor } from "./design-system/EditingWorkbench";
import {
habitInput,
habitPatch,
type HabitConfig,
type Schedule,
} from "../habits/contracts";
import { habitRequest } from "../lib/dashboard";
import { HabitColorPicker } from "./design-system/HabitColorPicker";
export type HabitStarter = {
name: string;
method: "manual" | "count" | "tasks";
target?: number;
unit?: string;
tasks?: string;
color?: string;
};
export function CreateHabit({
date,
starter,
onClose,
onCreated,
editing,
}: {
date: string;
starter: HabitStarter;
onClose: () => void;
onCreated: (name: string) => void;
editing?: { id: string; config: HabitConfig };
}) {
const dialog = useRef<HTMLDialogElement>(null);
const saving = useRef(false);
const [name, setName] = useState(starter.name);
const [method, setMethod] = useState(starter.method);
const [target, setTarget] = useState(starter.target ?? 8);
const [unit, setUnit] = useState(starter.unit ?? "glasses");
const [tasks, setTasks] = useState(starter.tasks ?? "");
const [color, setColor] = useState(starter.color ?? "#58765b");
const [schedule, setSchedule] = useState<Schedule>(
editing?.config.schedule ?? { type: "daily" },
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
dialog.current?.showModal();
dialog.current?.querySelector<HTMLInputElement>("#habit-name")?.focus();
return () => {
previous?.focus();
};
}, []);
async function submit(event: FormEvent) {
event.preventDefault();
if (saving.current) return;
const taskNames = tasks
.split("\n")
.map((name) => name.trim())
.filter(Boolean);
if (!editing && method === "tasks" && !taskNames.length) {
setError("Add at least one task to get started.");
return;
}
const input = {
name,
method,
schedule,
color,
...(method === "count"
? { target, unit }
: method === "tasks" && !editing
? { tasks: taskNames.map((name) => ({ name })) }
: {}),
};
const parsed = editing
? habitPatch.safeParse(input)
: habitInput.safeParse(input);
if (!parsed.success) {
setError(parsed.error.issues.map((issue) => issue.message).join(" "));
return;
}
saving.current = true;
setBusy(true);
setError("");
try {
await habitRequest(editing ? `/habits/${editing.id}` : "/habits", {
method: editing ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(parsed.data),
});
onCreated(parsed.data.name!);
} catch (error) {
setError(
error instanceof Error ? error.message : "Could not save your habit.",
);
saving.current = false;
setBusy(false);
}
}
return (
<dialog
ref={dialog}
className="home-create"
aria-labelledby="create-title"
onCancel={(event) => {
event.preventDefault();
if (!saving.current) onClose();
}}
>
<div className="home-create-heading">
<p className="ds-eyebrow">
{editing ? "SHAPE YOUR RHYTHM" : "A LITTLE ROOM FOR SOMETHING GOOD"}
</p>
<Button
variant="text"
aria-label={editing ? "Close habit editor" : "Close new habit"}
disabled={busy}
onClick={onClose}
>
×
</Button>
</div>
<h2 id="create-title">
{editing ? (
<>
Edit your <em>habit.</em>
</>
) : (
<>
Make it <em>yours.</em>
</>
)}
</h2>
<p className="ds-muted">
{editing
? "Changes start today. Earlier targets and schedules stay as they were."
: "Start small. You dont need a perfect plan."}
</p>
<form onSubmit={submit}>
<fieldset disabled={busy} className="home-form-fields">
<div className="ds-field">
<label htmlFor="habit-name">Habit name</label>
<input
id="habit-name"
required
maxLength={200}
placeholder="Something you want to make time for"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="ds-field">
<label htmlFor="habit-method">How will you track it?</label>
<select
id="habit-method"
disabled={!!editing}
value={method}
onChange={(event) =>
setMethod(event.target.value as HabitStarter["method"])
}
>
<option value="manual">A simple check-in</option>
<option value="count">A count target</option>
<option value="tasks">A list of tasks</option>
</select>
{editing && (
<small>
Tracking method stays the same. Create another habit to track it
differently.
</small>
)}
</div>
{method === "count" && (
<div className="ds-form-grid">
<div className="ds-field">
<label htmlFor="habit-target">Daily target</label>
<input
id="habit-target"
type="number"
required
min={1}
max={10000}
step={1}
value={Number.isNaN(target) ? "" : target}
onChange={(event) => setTarget(event.target.valueAsNumber)}
/>
</div>
<div className="ds-field">
<label htmlFor="habit-unit">Unit</label>
<input
id="habit-unit"
required
maxLength={80}
value={unit}
onChange={(event) => setUnit(event.target.value)}
/>
</div>
</div>
)}
{method === "tasks" && !editing && (
<div className="ds-field">
<label htmlFor="habit-tasks">Tasks · one per line</label>
<textarea
id="habit-tasks"
required
rows={4}
value={tasks}
onChange={(event) => setTasks(event.target.value)}
placeholder={"Clear desk\nPlan tomorrow\nStretch"}
/>
<small>
These tasks repeat on each scheduled day. Up to 100 tasks.
</small>
</div>
)}
<ScheduleEditor
value={schedule}
onChange={setSchedule}
anchorDate={date}
/>
{method === "tasks" && editing && (
<p className="ds-footnote">
Your existing tasks and their individual schedules are kept.
</p>
)}
<HabitColorPicker
value={color}
onChange={setColor}
mode={editing ? "edit" : "create"}
/>
</fieldset>
{error && (
<p className="home-error" role="alert">
{error}
</p>
)}
<div className="home-form-actions">
<Button variant="secondary" disabled={busy} onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={busy}>
{busy
? editing
? "Saving…"
: "Creating…"
: editing
? "Save changes"
: "Create habit"}{" "}
<span aria-hidden="true"></span>
</Button>
</div>
</form>
</dialog>
);
}

View File

@@ -0,0 +1,73 @@
import { useEffect, useRef, useState } from "react";
import { habitRequest, type TodayHabit } from "../lib/dashboard";
import { Button } from "./design-system/primitives";
export function DeleteHabit({
habit,
onClose,
onDeleted,
}: {
habit: TodayHabit;
onClose: () => void;
onDeleted: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const saving = useRef(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
dialog.current?.showModal();
return () => previous?.focus();
}, []);
async function remove() {
if (saving.current) return;
saving.current = true;
setBusy(true);
setError("");
try {
await habitRequest(`/habits/${habit.habitId}`, { method: "DELETE" });
onDeleted();
} catch (error) {
setError(
error instanceof Error ? error.message : "Could not delete your habit.",
);
saving.current = false;
setBusy(false);
}
}
return (
<dialog
ref={dialog}
className="home-create home-delete"
aria-labelledby="delete-title"
aria-describedby="delete-description"
onCancel={(event) => {
event.preventDefault();
if (!saving.current) onClose();
}}
>
<p className="ds-eyebrow">MAKE A LITTLE ROOM</p>
<h2 id="delete-title">
Delete this <em>habit?</em>
</h2>
<p id="delete-description" className="ds-muted">
<strong>{habit.name}</strong> will leave your dashboard and stop future
check-ins. Your recorded history is kept, not permanently erased.
</p>
{error && (
<p className="home-error" role="alert">
{error}
</p>
)}
<div className="home-form-actions">
<Button variant="secondary" disabled={busy} onClick={onClose} autoFocus>
Keep habit
</Button>
<Button disabled={busy} onClick={() => void remove()}>
{busy ? "Deleting…" : "Delete habit"}
</Button>
</div>
</dialog>
);
}

View File

@@ -0,0 +1,156 @@
import { useEffect, useState } from "react";
import type { CalendarResponse } from "../shared/calendar";
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
import { monthWindow } from "./design-system/calendar-model";
import { Button } from "./design-system/primitives";
import { HabitChart } from "./design-system/HabitChart";
import { CalendarLegend } from "./design-system/CalendarLegend";
import { shade } from "../habits/calendar";
export function HabitHistory({
habit,
date,
revision,
onEdit,
onDelete,
disabled = false,
}: {
habit: TodayHabit;
date: string;
revision: number;
onEdit: (color: string) => void;
onDelete: () => void;
disabled?: boolean;
}) {
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
const [error, setError] = useState("");
const [attempt, setAttempt] = useState(0);
useEffect(() => {
const controller = new AbortController();
setError("");
const range = monthWindow(date, 12);
habitRequest<CalendarResponse>(
`/habits/${habit.habitId}/calendar?${new URLSearchParams(range)}`,
{ signal: controller.signal },
)
.then((result) => {
if (!controller.signal.aborted) setCalendar(result);
})
.catch((error) => {
if (!controller.signal.aborted) setError(error.message);
});
return () => controller.abort();
}, [habit.habitId, date, revision, attempt]);
const days =
calendar?.days.map((day) => ({
date: day.date,
color: day.color,
unit: day.habits[0]?.unit,
value: day.habits[0]?.value ?? 0,
target: day.habits[0]?.target ?? 0,
state: day.future
? ("future" as const)
: day.due
? ("due" as const)
: ("not-due" as const),
})) ?? [];
const steps = Math.max(
1,
calendar?.days.find((day) => day.date === date)?.shadeCount ??
habit.target ??
1,
);
const shades = calendar
? [
calendar.settings.emptyColor,
...Array.from(
{ length: Math.min(8, steps) },
(_, index) =>
shade(
Math.ceil(((index + 1) * steps) / Math.min(8, steps)) / steps,
steps,
calendar.settings,
true,
).color,
),
]
: [];
const chart = error ? (
<div className="home-state">
<p role="alert">{error}</p>
<Button
variant="secondary"
onClick={() => setAttempt((value) => value + 1)}
>
Retry history
</Button>
</div>
) : !calendar ? (
<p className="home-state" role="status">
Loading your habit history
</p>
) : (
<CalendarHeatmap
compact
historyLabel="Your recorded progress"
label={`${habit.name} progress calendar`}
unit={habit.unit}
color={calendar.settings.mainColor}
emptyColor={calendar.settings.emptyColor}
days={days}
legend={
<CalendarLegend
label={`${habit.name} progress calendar`}
days={days}
color={calendar.settings.mainColor}
shades={shades}
/>
}
/>
);
return (
<HabitChart
id={`history-${habit.habitId}`}
name={habit.name ?? "Habit"}
method={
habit.method === "count"
? "Count target"
: habit.method === "tasks"
? "Task-based"
: "Simple check-in"
}
schedule={scheduleLabel(habit.requirements?.schedule)}
due={habit.due}
value={habit.value}
target={habit.target ?? 0}
unit={habit.unit}
color={calendar?.settings.mainColor ?? "#196127"}
calendar={chart}
>
<div
className="home-habit-actions"
role="group"
aria-label={`${habit.name} actions`}
>
<Button
variant="text"
aria-label={`Edit ${habit.name}`}
disabled={disabled || !calendar}
onClick={() => onEdit(calendar!.settings.mainColor)}
>
Edit
</Button>
<Button
variant="text"
aria-label={`Delete ${habit.name}`}
disabled={disabled}
onClick={onDelete}
>
Delete
</Button>
</div>
</HabitChart>
);
}

View File

@@ -0,0 +1,67 @@
import { useState } from "react";
import { Button, Checkbox, Counter } from "./design-system/primitives";
import { HabitChart } from "./design-system/HabitChart";
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
import { combinedProgress, demoCalendar, HABIT_COLORS } from "./design-system/calendar-model";
export function LandingDemo() {
const [water, setWater] = useState(7);
const [tasks, setTasks] = useState([true, true, false]);
const [view, setView] = useState<"Today" | "Combined progress">("Today");
const taskCount = tasks.filter(Boolean).length;
const total = combinedProgress([
{ value: water, target: 8, due: true },
{ value: taskCount, target: 3, due: true },
]);
return (
<section className="ds-section landing-demo" id="demo" aria-labelledby="demo-title">
<div className="ds-section-top">
<div>
<span className="ds-eyebrow">01 / A LITTLE, EVERY DAY</span>
<h2 id="demo-title">Small steps. <em>Visible progress.</em></h2>
</div>
<span className="ds-demo-note">Interactive demo · nothing is saved</span>
</div>
<div className="ds-preview-toolbar">
<div className="ds-view-switch" role="group" aria-label="Demo view">
{(["Today", "Combined progress"] as const).map((item) => (
<button key={item} type="button" aria-pressed={view === item} onClick={() => setView(item)}>{item}</button>
))}
</div>
<Button variant="text" onClick={() => { setWater(7); setTasks([true, true, false]); setView("Today"); }}>
Reset demo <span aria-hidden="true"></span>
</Button>
</div>
<div className="ds-preview-heading">
<div>
<p className="ds-eyebrow">A SAMPLE DAY / SEPTEMBER 4, 2026</p>
<h3>{view === "Today" ? "Make a little room for yourself." : "See your habits, together."}</h3>
</div>
<p aria-live="polite">{total.completed} of {total.due} habits complete</p>
</div>
{view === "Today" ? (
<div className="ds-habit-chart-grid">
<HabitChart name="Drink water" method="Count target" value={water} target={8} unit="glasses" color={HABIT_COLORS.water}>
<Counter label="glasses of water" value={water} target={8} onChange={setWater} />
</HabitChart>
<HabitChart name="Evening reset" method="Task-based" value={taskCount} target={3} unit="tasks" color={HABIT_COLORS.tasks}
tasks={["Clear desk", "Plan tomorrow", "Stretch"].map((label, index) => (
<Checkbox key={label} label={label} checked={tasks[index]} onChange={(event) => {
const checked = event.target.checked;
setTasks((current) => current.map((done, i) => i === index ? checked : done));
}} />
))}
/>
</div>
) : (
<CalendarHeatmap days={demoCalendar(total.completed, total.due)} label="Combined habit calendar" unit="habits complete" />
)}
<p className="ds-footnote landing-demo-hint">
{view === "Today"
? "Try adding a glass of water, or open the evening tasks and tick off a small win. Select any day to take a closer look."
: "Each fully completed habit counts equally. Partial progress appears in its own calendar; unscheduled habits stay out of the combined score."}
</p>
</section>
);
}

View File

@@ -0,0 +1,245 @@
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { CalendarLegend } from "./CalendarLegend";
import {
describeDay,
progressShade,
MONTH_VIEWS,
visibleCalendarDays,
type CalendarDay,
type MonthView,
} from "./calendar-model";
export function CalendarHeatmap({
days: allDays,
unit,
label,
color = "#111111",
emptyColor = "#eeeeee",
compact = false,
selectedDate: controlledDate,
onSelectDate,
historyLabel,
legend,
}: {
days: CalendarDay[];
unit: string;
label: string;
color?: string;
emptyColor?: string;
compact?: boolean;
selectedDate?: string;
onSelectDate?: (date: string) => void;
historyLabel?: string;
legend?: ReactNode;
}) {
const [months, setMonths] = useState<MonthView>(6);
const latestDate =
allDays.findLast((day) => day.state !== "future")?.date ??
allDays.at(-1)?.date;
// A date selected in the editor must stay visible even outside the current window.
const anchor =
controlledDate && allDays.some((day) => day.date === controlledDate)
? controlledDate
: latestDate;
const [localSelectedDate, setLocalSelectedDate] = useState(anchor);
const selectedDate = controlledDate ?? localSelectedDate;
function setSelectedDate(date: string) {
if (onSelectDate) onSelectDate(date);
else setLocalSelectedDate(date);
}
const days = anchor ? visibleCalendarDays(allDays, months, anchor) : [];
const selected =
days.find((day) => day.date === selectedDate) ??
days.find((day) => day.date === anchor) ??
days[0];
const offset = days[0]
? new Date(`${days[0].date}T12:00:00Z`).getUTCDay()
: 0;
const weeks = Math.ceil((days.length + offset) / 7);
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
const scrollArea = useRef<HTMLDivElement | null>(null);
const inspectorId = useId();
const selectedIndex = selected ? days.indexOf(selected) : -1;
useEffect(() => {
const container = scrollArea.current;
const button = buttons.current[selectedIndex];
if (!container || !button) return;
const cellBounds = button.getBoundingClientRect();
const bounds = container.getBoundingClientRect();
if (cellBounds.right > bounds.right - 5)
container.scrollLeft += cellBounds.right - bounds.right + 5;
else if (cellBounds.left < bounds.left + 5)
container.scrollLeft += cellBounds.left - bounds.left - 5;
}, [months, selectedIndex]);
if (!selected) return <p>No calendar dates to display.</p>;
return (
<div
className={`ds-calendar${compact ? " ds-calendar--compact" : ""}`}
data-months={months}
>
<div className="ds-calendar-range-toolbar">
<span className="ds-calendar-range-label" aria-live="polite">
{new Date(`${days[0]!.date}T12:00:00Z`).toLocaleDateString("en", {
month: "short",
year: "numeric",
timeZone: "UTC",
})}{" "}
{" "}
{new Date(`${days.at(-1)!.date}T12:00:00Z`).toLocaleDateString("en", {
month: "short",
year: "numeric",
timeZone: "UTC",
})}
</span>
<div
className="ds-month-views"
role="group"
aria-label={`${label} time range`}
>
{MONTH_VIEWS.map((value) => (
<button
key={value}
type="button"
aria-label={`${value} months`}
aria-pressed={months === value}
onClick={() => setMonths(value)}
>
{value}m
</button>
))}
</div>
</div>
<div
ref={scrollArea}
className="ds-calendar-scroll"
role="group"
aria-label={label}
>
<div
className="ds-calendar-inner"
style={{
minWidth:
months === 12
? 0
: Math.max(compact ? 280 : 600, weeks * 14 + 38),
}}
>
<div
className="ds-calendar-months"
aria-hidden="true"
style={{
gridTemplateColumns: `repeat(${weeks}, minmax(0, 1fr))`,
}}
>
{days.map(
(day, index) =>
(index === 0 || day.date.endsWith("-01")) && (
<span
key={day.date}
style={{ gridColumn: Math.floor((index + offset) / 7) + 1 }}
>
{new Date(`${day.date}T12:00:00Z`).toLocaleDateString(
"en",
{ month: "short", timeZone: "UTC" },
)}
</span>
),
)}
</div>
<div className="ds-calendar-body">
<div className="ds-calendar-weekdays" aria-hidden="true">
<span>Mon</span>
<span>Wed</span>
<span>Fri</span>
</div>
<div
className="ds-calendar-grid"
style={{
gridTemplateColumns: `repeat(${weeks}, 1fr)`,
}}
>
{Array.from({ length: offset }, (_, index) => (
<span key={`padding-${index}`} aria-hidden="true" />
))}
{days.map((day, index) => (
<button
key={day.date}
ref={(element) => {
buttons.current[index] = element;
}}
type="button"
className={`ds-day ds-day--${day.state}`}
style={{
backgroundColor:
day.state === "not-due"
? "transparent"
: day.state === "future"
? emptyColor
: progressShade(day, color),
}}
aria-label={`${day.date}: ${describeDay(day, unit)}`}
title={`${day.date}: ${describeDay(day, unit)}`}
aria-pressed={day.date === selected.date}
aria-describedby={inspectorId}
tabIndex={day.date === selected.date ? 0 : -1}
onClick={() => setSelectedDate(day.date)}
onKeyDown={(event) => {
const offset = {
ArrowDown: 1,
ArrowUp: -1,
ArrowRight: 7,
ArrowLeft: -7,
}[event.key];
const next =
event.key === "Home"
? 0
: event.key === "End"
? days.length - 1
: offset !== undefined
? Math.max(
0,
Math.min(days.length - 1, index + offset),
)
: undefined;
if (next !== undefined) {
event.preventDefault();
setSelectedDate(days[next]!.date);
buttons.current[next]?.focus();
}
}}
/>
))}
</div>
</div>
</div>
</div>
{legend ?? <CalendarLegend days={days} color={color} label={label} />}
<div className="ds-calendar-caption">
<span>
{months} months{" "}
<span className="ds-muted">
/{" "}
{historyLabel ??
(compact ? "Demo history" : "Illustrative history")}
</span>
</span>
<span className="ds-muted">
{compact
? "Select a day · Arrow keys"
: "Select a day to inspect · Arrow keys to move"}
</span>
</div>
<div id={inspectorId} className="ds-date-inspector" aria-live="polite">
<span>
{new Date(`${selected.date}T12:00:00Z`).toLocaleDateString("en", {
month: "long",
day: "numeric",
year: "numeric",
timeZone: "UTC",
})}
</span>
<span>{describeDay(selected, unit)}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { legendShades, type CalendarDay } from "./calendar-model";
export function CalendarLegend({
days,
color,
label,
shades: suppliedShades,
}: {
days: CalendarDay[];
color: string;
label: string;
shades?: string[];
}) {
const shades = suppliedShades ?? legendShades(days, color);
return (
<div
className="ds-calendar-legend"
role="group"
aria-label={`${label} legend`}
>
<div className="ds-legend-scale">
<span>0 / Upcoming</span>
<span
className="ds-legend-swatches"
role="img"
aria-label={
shades.length > 2
? "Empty, then increasing partial progress through complete"
: shades.length === 2
? "Empty or complete"
: "Empty"
}
>
{shades.map((shade) => (
<span
key={shade}
className="ds-legend-swatch"
style={{ backgroundColor: shade }}
/>
))}
</span>
{shades.length > 1 && <span>Complete</span>}
</div>
<span className="ds-legend-neutral">
<span
className="ds-legend-swatch ds-legend-swatch--not-due"
aria-hidden="true"
/>
Not scheduled
</span>
</div>
);
}

View File

@@ -0,0 +1,909 @@
import { useEffect, useId, useState, type ReactNode } from "react";
import type { HabitConfig, Schedule } from "../../habits/contracts";
import { Button, Checkbox } from "./primitives";
import { CalendarHeatmap } from "./CalendarHeatmap";
import {
HabitColorPicker,
isHabitColor,
previewHabitColors,
type HabitColors,
} from "./HabitColorPicker";
import {
EDITOR_HABITS,
EDITOR_START,
EDITOR_TODAY,
backfillError,
historicalDays,
validateEditor,
validateTasks,
} from "./editing-model";
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: (id: string) => ReactNode;
}) {
const id = useId();
return (
<div className="ds-field">
<label htmlFor={id}>{label}</label>
{children(id)}
{hint && <small>{hint}</small>}
</div>
);
}
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
export function ScheduleEditor({
value,
onChange,
anchorDate = EDITOR_TODAY,
}: {
value: Schedule;
onChange: (schedule: Schedule) => void;
anchorDate?: string;
}) {
return (
<div className="ds-schedule-editor">
<Field label="Repeats">
{(id) => (
<select
id={id}
value={value.type}
onChange={(event) => {
const type = event.target.value;
onChange(
type === "daily"
? { type }
: type === "weekdays"
? { type, days: [1, 2, 3, 4, 5] }
: type === "interval"
? { type, every: 2, anchor: anchorDate }
: {
type: "weekly",
every: 1,
weekday: 1,
anchor: anchorDate,
},
);
}}
>
<option value="daily">Every day</option>
<option value="weekdays">Selected weekdays</option>
<option value="interval">Every few days</option>
<option value="weekly">Every few weeks</option>
</select>
)}
</Field>
{value.type === "weekdays" && (
<fieldset className="ds-weekday-field">
<legend>Scheduled days · choose at least one</legend>
<div className="ds-weekdays">
{WEEKDAYS.map((day, index) => (
<button
type="button"
key={day}
aria-label={day}
aria-pressed={value.days.includes(index)}
onClick={() =>
onChange({
...value,
days: value.days.includes(index)
? value.days.filter((item) => item !== index)
: [...value.days, index].sort(),
})
}
>
{day}
</button>
))}
</div>
</fieldset>
)}
{(value.type === "interval" || value.type === "weekly") && (
<div className="ds-form-grid">
<Field
label={value.type === "interval" ? "Every (days)" : "Every (weeks)"}
>
{(id) => (
<input
id={id}
type="number"
required
min={1}
max={value.type === "interval" ? 3650 : 520}
value={Number.isNaN(value.every) ? "" : value.every}
onChange={(event) =>
onChange({ ...value, every: event.target.valueAsNumber })
}
/>
)}
</Field>
<Field label="Starting on">
{(id) => (
<input
id={id}
type="date"
required
min="1970-01-01"
max="9998-12-31"
value={value.anchor}
onInput={(event) =>
onChange({ ...value, anchor: event.currentTarget.value })
}
/>
)}
</Field>
</div>
)}
{value.type === "weekly" && (
<Field label="On weekday">
{(id) => (
<select
id={id}
value={value.weekday}
onChange={(event) =>
onChange({ ...value, weekday: Number(event.target.value) })
}
>
{WEEKDAYS.map((day, index) => (
<option key={day} value={index}>
{day}
</option>
))}
</select>
)}
</Field>
)}
</div>
);
}
function SaveBar({
dirty,
error,
onCancel,
label = "Save changes",
disabled = false,
}: {
dirty: boolean;
error: string | null;
onCancel: () => void;
label?: string;
disabled?: boolean;
}) {
return (
<>
<p className="ds-form-feedback" role={error ? "alert" : "status"}>
{error || (dirty ? "Unsaved changes" : "No unsaved changes")}
</p>
<div className="ds-form-actions">
<Button type="submit" disabled={!dirty || disabled}>
{label} <span aria-hidden="true"></span>
</Button>
<Button variant="text" onClick={onCancel} disabled={!dirty}>
Cancel
</Button>
</div>
</>
);
}
type EditorTab = "Habit settings" | "Tasks" | "Backfill progress";
type TaskConfig = Extract<HabitConfig, { method: "tasks" }>;
type Correction = {
habitId: string;
name: string;
date: string;
before: number;
after: number;
target: number;
unit: string;
};
export function EditingWorkbench({
onColorsChange,
}: {
onColorsChange?: (colors: HabitColors) => void;
}) {
const [tab, setTab] = useState<EditorTab>("Habit settings");
const [habits, setHabits] = useState(() => structuredClone(EDITOR_HABITS));
const [selectedId, setSelectedId] = useState("water");
const selected = habits.find((habit) => habit.id === selectedId)!;
const [draftColor, setDraftColor] = useState(selected.color);
const previewColor = isHabitColor(draftColor) ? draftColor : selected.color;
useEffect(() => {
onColorsChange?.(previewHabitColors(habits, selectedId, previewColor));
}, [habits, selectedId, previewColor, onColorsChange]);
const [draft, setDraft] = useState<HabitConfig>(() =>
structuredClone(selected.config),
);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState("");
const [corrections, setCorrections] = useState<Correction[]>([]);
const [entries, setEntries] = useState<
Record<string, { value: number; tasks: boolean[] }>
>({});
const [date, setDate] = useState("2026-09-03");
const historical = EDITOR_HABITS.find(
(habit) => habit.id === selectedId,
)!.config;
const [progress, setProgress] = useState(
() => historicalDays("water").find((day) => day.date === date)!.value,
);
const [taskChecks, setTaskChecks] = useState<boolean[]>([]);
const entryKey = `${selectedId}:${date}`;
const day = historicalDays(selectedId).find((day) => day.date === date);
const savedProgress = entries[entryKey]?.value ?? day?.value ?? 0;
const savedChecks =
entries[entryKey]?.tasks ??
(historical.method === "tasks"
? historical.tasks.map((_, index) => index < savedProgress)
: []);
const configDirty =
JSON.stringify(draft) !== JSON.stringify(selected.config) ||
draftColor.toLowerCase() !== selected.color.toLowerCase();
const progressDirty =
progress !== savedProgress ||
(historical.method === "tasks" &&
JSON.stringify(taskChecks) !== JSON.stringify(savedChecks));
const dirty = tab === "Backfill progress" ? progressDirty : configDirty;
const blocked = backfillError(selectedId, date, progress);
function loadProgress(id: string, nextDate: string) {
const original = EDITOR_HABITS.find((habit) => habit.id === id)!.config;
const entry = entries[`${id}:${nextDate}`];
const value =
entry?.value ??
historicalDays(id).find((day) => day.date === nextDate)?.value ??
0;
setProgress(value);
setTaskChecks(
entry?.tasks ??
(original.method === "tasks"
? original.tasks.map((_, index) => index < value)
: []),
);
setError(null);
}
function canLeave() {
return !dirty || window.confirm("Discard unsaved changes in this preview?");
}
function selectHabit(id: string) {
if (!canLeave()) return;
setSelectedId(id);
setDraft(structuredClone(habits.find((habit) => habit.id === id)!.config));
setDraftColor(habits.find((habit) => habit.id === id)!.color);
loadProgress(id, date);
setNotice("");
}
function selectTab(next: EditorTab) {
if (next === tab || !canLeave()) return;
const id = next === "Tasks" ? "tasks" : selectedId;
setTab(next);
setSelectedId(id);
setDraft(structuredClone(habits.find((habit) => habit.id === id)!.config));
setDraftColor(habits.find((habit) => habit.id === id)!.color);
loadProgress(id, date);
setNotice("");
}
function selectDate(next: string) {
if (next === date || !canLeave()) return;
setDate(next);
loadProgress(selectedId, next);
setNotice("");
}
function saveConfig() {
if (!isHabitColor(draftColor)) {
setError("Use a six-digit hex color, such as #426582.");
return;
}
const validation =
tab === "Tasks" && draft.method === "tasks"
? validateTasks(draft.tasks)
: validateEditor(draft);
if (validation) {
setError(validation);
return;
}
const clean = {
...draft,
name: draft.name.trim(),
...(draft.method === "count" ? { unit: draft.unit.trim() } : {}),
...(draft.method === "tasks"
? {
tasks: draft.tasks.map((task) => ({
...task,
name: task.name.trim(),
})),
}
: {}),
} as HabitConfig;
setHabits((current) =>
current.map((habit) =>
habit.id === selectedId
? {
...habit,
config: structuredClone(clean),
color: draftColor.toLowerCase(),
}
: habit,
),
);
setDraft(clean);
setDraftColor(draftColor.toLowerCase());
setError(null);
setNotice(
`${tab === "Tasks" ? "Tasks" : clean.name} saved in this preview. Effective September 4; earlier dates are unchanged.`,
);
}
function updateTask(id: string, patch: Partial<TaskConfig["tasks"][number]>) {
if (draft.method === "tasks")
setDraft({
...draft,
tasks: draft.tasks.map((task) =>
task.id === id ? { ...task, ...patch } : task,
),
});
setError(null);
}
return (
<section
className="ds-section"
id="editing"
aria-labelledby="editing-title"
>
<div className="ds-section-top">
<div>
<span className="ds-eyebrow">02 / EDITING & HISTORY</span>
<h2 id="editing-title">
Room for <em>real life.</em>
</h2>
</div>
<span className="ds-demo-note">
Independent editor demo · not saved
</span>
</div>
<div className="ds-preview-toolbar">
<div className="ds-view-switch" role="group" aria-label="Editing view">
{(["Habit settings", "Tasks", "Backfill progress"] as const).map(
(item) => (
<button
key={item}
type="button"
aria-pressed={tab === item}
onClick={() => selectTab(item)}
>
{item}
</button>
),
)}
</div>
<Button
variant="text"
onClick={() => {
if (
!window.confirm(
"Reset all editor changes and corrections? This only affects the preview.",
)
)
return;
setHabits(structuredClone(EDITOR_HABITS));
setSelectedId("water");
setDraft(structuredClone(EDITOR_HABITS[0]!.config));
setDraftColor(EDITOR_HABITS[0]!.color);
setTab("Habit settings");
setEntries({});
setCorrections([]);
setDate("2026-09-03");
setProgress(
historicalDays("water").find((day) => day.date === "2026-09-03")!
.value,
);
setTaskChecks([]);
setError(null);
setNotice("Editor demo reset.");
}}
>
Reset editor
</Button>
</div>
<div className="ds-edit-layout">
<aside className="ds-edit-context">
<span className="ds-eyebrow">
{tab === "Backfill progress"
? "A DATE, NOT A RESET"
: "MAKE IT YOURS"}
</span>
<h3>
{tab === "Habit settings"
? "Shape your rhythm."
: tab === "Tasks"
? "Small steps, clearly defined."
: "A missed log is not a missed day."}
</h3>
<p>
{tab === "Habit settings"
? "Adjust a name, target, or schedule. Changes start today; your history stays as it was."
: tab === "Tasks"
? "Give each task its own repeat rule. It is due only when both the habit and task schedules match."
: "Choose a past date and enter what actually happened. That date keeps its original requirements."}
</p>
{tab !== "Tasks" && (
<Field label="Habit">
{(id) => (
<select
id={id}
value={selectedId}
onChange={(event) => selectHabit(event.target.value)}
>
{habits.map((habit) => (
<option value={habit.id} key={habit.id}>
{habit.config.name}
{habit.config.archived ? " · Archived" : ""}
</option>
))}
</select>
)}
</Field>
)}
<div className="ds-edit-meta">
<span
className="ds-habit-dot"
style={{ background: previewColor }}
/>
<span>
{tab === "Backfill progress"
? historical.name
: selected.config.name}
</span>
</div>
<p className="ds-footnote">
Demo clock · September 4, 2026
<br />
Tracking timezone · Europe/Belgrade
</p>
</aside>
<div className="ds-edit-content">
{tab === "Habit settings" && (
<form
onSubmit={(event) => {
event.preventDefault();
saveConfig();
}}
>
<div className="ds-spec-heading">
<h3>Edit habit</h3>
<span className="ds-code">Effective today</span>
</div>
<Field label="Habit name">
{(id) => (
<input
id={id}
required
maxLength={200}
value={draft.name}
onChange={(event) => {
setDraft({ ...draft, name: event.target.value });
setError(null);
}}
/>
)}
</Field>
<HabitColorPicker
value={draftColor}
onChange={(color) => {
setDraftColor(color);
setError(null);
setNotice("");
}}
/>
<Field
label="Completion method"
hint="Method is fixed in this preview. Choose another habit to try its editor."
>
{(id) => (
<input
id={id}
readOnly
value={
draft.method === "count"
? "Count target"
: draft.method === "manual"
? "Manual checkbox"
: "Task checklist"
}
/>
)}
</Field>
{draft.method === "count" && (
<>
<div className="ds-form-grid">
<Field label="Daily target">
{(id) => (
<input
id={id}
type="number"
required
min={1}
max={10000}
value={Number.isNaN(draft.target) ? "" : draft.target}
onChange={(event) =>
setDraft({
...draft,
target: event.target.valueAsNumber,
})
}
/>
)}
</Field>
<Field label="Unit">
{(id) => (
<input
id={id}
required
maxLength={80}
value={draft.unit}
onChange={(event) =>
setDraft({ ...draft, unit: event.target.value })
}
/>
)}
</Field>
</div>
<Checkbox
label="Carry unfinished counts to the next scheduled day"
checked={draft.carryPartialProgress}
onChange={(event) =>
setDraft({
...draft,
carryPartialProgress: event.target.checked,
})
}
/>
<p className="ds-footnote">
Completed counts reset. Earlier explicit logs are never
overwritten.
</p>
</>
)}
<div className="ds-form-section">
<ScheduleEditor
value={draft.schedule}
onChange={(schedule) => {
setDraft({ ...draft, schedule });
setError(null);
}}
/>
</div>
<div className="ds-form-section">
<Checkbox
label="Archive this habit"
checked={draft.archived}
onChange={(event) =>
setDraft({ ...draft, archived: event.target.checked })
}
/>
<p className="ds-footnote">
Stops tracking from today. History is kept. Uncheck and save
to restore.
</p>
</div>
<SaveBar
dirty={configDirty}
error={error}
onCancel={() => {
setDraft(structuredClone(selected.config));
setDraftColor(selected.color);
setError(null);
}}
/>
</form>
)}
{tab === "Tasks" && draft.method === "tasks" && (
<form
onSubmit={(event) => {
event.preventDefault();
saveConfig();
}}
>
<div className="ds-spec-heading">
<h3>Edit tasks</h3>
<span className="ds-code">
{draft.tasks.length} / 100 tasks
</span>
</div>
{draft.archived && (
<p className="ds-form-feedback" role="status">
This habit is archived. Restore it in Habit settings before
editing tasks.
</p>
)}
<fieldset
className="ds-task-editor-fieldset"
disabled={draft.archived}
aria-label="Task definitions"
>
{draft.tasks.length === 0 && (
<p className="ds-empty-state">
No tasks yet. Add a first step; a habit with no due tasks is
not scored.
</p>
)}
{draft.tasks.map((task, index) => (
<div className="ds-edit-task" key={task.id}>
<div className="ds-spec-heading">
<span className="ds-code">
STEP {String(index + 1).padStart(2, "0")}
</span>
<Button
variant="text"
aria-label={`Remove ${task.name || "unnamed task"}`}
onClick={() =>
setDraft({
...draft,
tasks: draft.tasks.filter(
(item) => item.id !== task.id,
),
})
}
>
Remove
</Button>
</div>
<Field label="Task name">
{(id) => (
<input
id={id}
required
maxLength={200}
value={task.name}
onChange={(event) =>
updateTask(task.id, { name: event.target.value })
}
/>
)}
</Field>
<ScheduleEditor
value={task.schedule}
onChange={(schedule) => updateTask(task.id, { schedule })}
/>
</div>
))}
<Button
variant="secondary"
disabled={draft.tasks.length >= 100}
onClick={() =>
setDraft({
...draft,
tasks: [
...draft.tasks,
{
id: crypto.randomUUID(),
name: "",
schedule: { type: "daily" },
},
],
})
}
>
Add task +
</Button>
</fieldset>
<p className="ds-footnote">
Removing a task takes effect when you save. Earlier checklists
and their completion records remain available for backfilling.
</p>
<SaveBar
dirty={configDirty}
error={error}
disabled={draft.archived}
onCancel={() => {
setDraft(structuredClone(selected.config));
setDraftColor(selected.color);
setError(null);
}}
label="Save tasks"
/>
</form>
)}
{tab === "Backfill progress" && (
<>
<div className="ds-spec-heading">
<h3>Correct a past day</h3>
<span className="ds-code">Historical snapshot</span>
</div>
<Field
label="Progress date"
hint="Select a date here or in the calendar below."
>
{(id) => (
<input
id={id}
type="date"
required
min={EDITOR_START}
max="2026-09-03"
value={date}
onInput={(event) => selectDate(event.currentTarget.value)}
/>
)}
</Field>
<form
onSubmit={(event) => {
event.preventDefault();
if (blocked) {
setError(blocked);
return;
}
const correction = {
habitId: selectedId,
name: historical.name,
date,
before: savedProgress,
after: progress,
target: day!.target,
unit:
historical.method === "count"
? historical.unit
: historical.method === "tasks"
? "tasks"
: "session",
};
setEntries((current) => ({
...current,
[entryKey]: { value: progress, tasks: [...taskChecks] },
}));
setCorrections((current) => [correction, ...current]);
setError(null);
setNotice(
`Correction saved in this preview for ${date}. The calendar below is updated.`,
);
}}
>
{blocked && (
<p className="ds-form-feedback" role="status">
{blocked}
</p>
)}
{day?.state === "due" && date < EDITOR_TODAY && (
<>
<div className="ds-history-requirement">
<span>Required on {date}</span>
<strong>
{day.target}{" "}
{historical.method === "count"
? historical.unit
: historical.method === "tasks"
? "tasks"
: "session"}
</strong>
</div>
{historical.method === "count" ? (
<Field
label="Actual count"
hint="Enter the total, not an increment. Zero clears progress; counts may exceed the target."
>
{(id) => (
<input
id={id}
type="number"
required
min={0}
max={1_000_000_000}
step={1}
value={Number.isNaN(progress) ? "" : progress}
onChange={(event) => {
setProgress(event.target.valueAsNumber);
setError(null);
}}
/>
)}
</Field>
) : historical.method === "manual" ? (
<Checkbox
label="Completed on this date"
checked={progress === 1}
onChange={(event) =>
setProgress(Number(event.target.checked))
}
/>
) : (
<div className="ds-backfill-tasks">
{historical.tasks.map((task, index) => (
<Checkbox
key={task.id}
label={task.name}
checked={taskChecks[index] ?? false}
onChange={(event) => {
const next = taskChecks.map((done, i) =>
i === index ? event.target.checked : done,
);
setTaskChecks(next);
setProgress(next.filter(Boolean).length);
}}
/>
))}
</div>
)}
<p className="ds-correction-summary">
{savedProgress} {" "}
{Number.isFinite(progress) ? progress : "—"} /{" "}
{day.target}
<span>
{progress >= day.target ? "Complete" : "Incomplete"}
</span>
</p>
</>
)}
<SaveBar
dirty={progressDirty}
error={error}
disabled={!!blocked}
label="Save correction"
onCancel={() => loadProgress(selectedId, date)}
/>
</form>
<div className="ds-backfill-calendar">
<CalendarHeatmap
key={selectedId}
compact
color={selected.color}
days={historicalDays(selectedId).map((item) => ({
...item,
value:
entries[`${selectedId}:${item.date}`]?.value ??
item.value,
}))}
unit={
historical.method === "count"
? historical.unit
: historical.method === "tasks"
? "tasks"
: "session"
}
label="Backfill history"
selectedDate={date}
onSelectDate={selectDate}
/>
</div>
<p className="ds-footnote">
This preview updates only the calendar here. In the connected
app, corrections also recalculate combined charts and inherited
counts.
</p>
</>
)}
<p className="ds-form-feedback ds-save-notice" role="status">
{notice}
</p>
</div>
</div>
{corrections.length > 0 && (
<div className="ds-correction-log">
<div className="ds-spec-heading">
<h3>Correction history</h3>
<span className="ds-code">This preview only · newest first</span>
</div>
<ol>
{corrections.map((item, index) => (
<li key={corrections.length - index}>
<time dateTime={item.date}>{item.date}</time>
<span>{item.name}</span>
<span>
{item.before} {item.after} / {item.target} {item.unit}
</span>
</li>
))}
</ol>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,84 @@
import { useId, type ReactNode } from "react";
import { CalendarHeatmap } from "./CalendarHeatmap";
import { demoCalendar } from "./calendar-model";
/** Open chart section: intentionally no card surface or enclosing border. */
export function HabitChart({
name,
method,
value,
target,
unit,
color,
children,
tasks,
calendar,
schedule = "Every day",
due = true,
id,
}: {
name: string;
method: string;
value: number;
target: number;
unit: string;
color: string;
children?: ReactNode;
tasks?: ReactNode;
calendar?: ReactNode;
schedule?: string;
due?: boolean;
id?: string;
}) {
const headingId = useId();
return (
<section className="ds-habit-chart" id={id} aria-labelledby={headingId}>
<header className="ds-habit-chart-heading">
<div>
<h4 id={headingId}>
<span style={{ backgroundColor: color }} aria-hidden="true" />
{name}
</h4>
<p>
{method} · {schedule}
</p>
</div>
{children}
</header>
<p className="ds-habit-chart-progress" aria-live="polite">
<span>
{due
? `${value} of ${target} ${unit}`
: "A day off isnt a missed day."}
</span>
<span>
{!due
? "Not scheduled"
: value >= target
? "Complete"
: "In progress"}
</span>
</p>
{calendar ?? (
<CalendarHeatmap
days={demoCalendar(value, target)}
label={`${name} progress calendar`}
unit={unit}
color={color}
compact
/>
)}
{tasks && (
<details className="ds-task-accordion">
<summary>
Tasks for today{" "}
<span>
{value} / {target}
</span>
</summary>
<div className="ds-task-accordion-content">{tasks}</div>
</details>
)}
</section>
);
}

View File

@@ -0,0 +1,48 @@
import { expect, test } from "bun:test";
import {
HABIT_PALETTES,
isHabitColor,
previewHabitColors,
} from "./HabitColorPicker";
import { HABIT_COLORS } from "./calendar-model";
test("eight suggested palettes contain thirty-two unique, valid habit colors", () => {
const colors = HABIT_PALETTES.flatMap((palette) => [...palette.colors]);
expect(HABIT_PALETTES.length).toBe(8);
expect(HABIT_PALETTES.every((palette) => palette.colors.length === 4)).toBe(true);
expect(new Set(colors).size).toBe(32);
expect(colors.every(isHabitColor)).toBe(true);
});
test("custom colors accept six hex digits and reject incomplete or malformed colors", () => {
for (const color of ["#123456", "#abcdef", "#ABCDEF", "#ffffff", "#000000"])
expect(isHabitColor(color)).toBe(true);
for (const color of ["", "red", "123456", "#fff", "#gggggg", "#12345678"])
expect(isHabitColor(color)).toBe(false);
});
test("a draft color updates only its habit and cancel restores the saved color", () => {
const habits = [
{ id: "water", color: HABIT_COLORS.water },
{ id: "reading", color: "#123456" },
];
const preview = previewHabitColors(habits, "water", "#9c647c");
expect(preview.water).toBe("#9c647c");
expect(preview.reading).toBe("#123456");
expect(preview.movement).toBe(HABIT_COLORS.movement);
expect(previewHabitColors(habits, "water", habits[0]!.color).water).toBe(
HABIT_COLORS.water,
);
expect(habits[0]!.color).toBe(HABIT_COLORS.water);
});
test("invalid drafts fall back to saved colors and saved edits survive selecting another habit", () => {
const habits = [
{ id: "water", color: "#9c647c" },
{ id: "reading", color: HABIT_COLORS.reading },
];
expect(previewHabitColors(habits, "water", "#zz").water).toBe("#9c647c");
expect(previewHabitColors(habits, "reading", "#654321").water).toBe(
"#9c647c",
);
});

View File

@@ -0,0 +1,153 @@
import { useId } from "react";
import { HABIT_COLORS, progressShade } from "./calendar-model";
export type HabitColors = {
-readonly [K in keyof typeof HABIT_COLORS]: string;
};
export function previewHabitColors(
habits: { id: string; color: string }[],
selectedId: string,
draftColor: string,
): HabitColors {
const colors: HabitColors = { ...HABIT_COLORS };
for (const habit of habits) {
if (!Object.hasOwn(colors, habit.id)) continue;
colors[habit.id as keyof HabitColors] =
habit.id === selectedId && isHabitColor(draftColor)
? draftColor
: habit.color;
}
return colors;
}
export const HABIT_PALETTES = [
{ name: "Earth", colors: ["#58765b", "#977344", "#a3604d", "#76734e"] },
{ name: "Coast", colors: ["#426582", "#427b80", "#657e9c", "#667d70"] },
{ name: "Dusk", colors: ["#79618d", "#9c647c", "#736a9c", "#686878"] },
{ name: "Forest", colors: ["#386641", "#52734d", "#6b705c", "#3d6b62"] },
{ name: "Citrus", colors: ["#b45309", "#a16207", "#9a5b35", "#7a801c"] },
{ name: "Blossom", colors: ["#b05276", "#a34e62", "#a65f5a", "#8e5572"] },
{ name: "Jewel", colors: ["#6d28a8", "#1d678d", "#087f73", "#a12e54"] },
{ name: "Slate", colors: ["#475569", "#52616b", "#65605b", "#404047"] },
] as const;
export function isHabitColor(value: string) {
return /^#[0-9a-f]{6}$/i.test(value);
}
export function HabitColorPicker({
value,
onChange,
mode = "demo",
}: {
value: string;
onChange: (color: string) => void;
mode?: "demo" | "create" | "edit";
}) {
const id = useId();
const valid = isHabitColor(value);
return (
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
<legend>Habit color</legend>
<p id={`${id}-hint`} className="ds-footnote">
A color for this habit and its calendar. Choose a suggested shade or
make it your own.
</p>
<div className="ds-color-palettes">
{HABIT_PALETTES.map((palette) => (
<div
key={palette.name}
className="ds-color-palette"
role="group"
aria-label={`${palette.name} palette`}
>
<span>{palette.name}</span>
<div className="ds-color-swatches">
{palette.colors.map((color) => (
<button
key={color}
type="button"
aria-label={`${palette.name} ${color}`}
aria-pressed={value.toLowerCase() === color}
title={color}
onClick={() => onChange(color)}
>
<span style={{ backgroundColor: color }}>
{value.toLowerCase() === color ? "✓" : ""}
</span>
</button>
))}
</div>
</div>
))}
</div>
<div className="ds-custom-color">
<div className="ds-field">
<label htmlFor={`${id}-native`}>Custom color</label>
<input
id={`${id}-native`}
type="color"
value={valid ? value : "#426582"}
onInput={(event) => onChange(event.currentTarget.value)}
/>
</div>
<div className="ds-field">
<label htmlFor={`${id}-hex`}>Hex color</label>
<input
id={`${id}-hex`}
type="text"
spellCheck={false}
autoComplete="off"
maxLength={7}
pattern="#[0-9a-fA-F]{6}"
required
placeholder="#426582"
value={value}
aria-invalid={!valid}
aria-describedby={!valid ? `${id}-error` : undefined}
onChange={(event) => onChange(event.target.value)}
/>
</div>
</div>
{valid && (
<div
className="ds-color-live-preview"
aria-label="Live calendar color preview"
>
<span className="ds-muted">Calendar preview</span>
<div
className="ds-color-preview-shades"
role="img"
aria-label={`Progress shades using ${value}`}
>
{Array.from({ length: 9 }, (_, count) => (
<span
key={count}
style={{
backgroundColor: progressShade(
{ date: "", value: count, target: 8, state: "due" },
value,
),
}}
/>
))}
</div>
<span className="ds-muted">0 complete</span>
</div>
)}
<p className="ds-footnote">
{mode === "create"
? "Your chosen color is saved with your habit and used in its progress calendar."
: mode === "edit"
? "Save changes to apply this color to your habits calendar. Cancel keeps your current color."
: "Previews update immediately, including the charts above. Save to keep this color in the demo; Cancel to revert."}
</p>
{!valid && (
<p id={`${id}-error`} role="alert" className="ds-form-feedback">
Use a six-digit hex color, such as #426582.
</p>
)}
</fieldset>
);
}

View File

@@ -0,0 +1,124 @@
import { expect, test } from "bun:test";
import {
combinedProgress,
demoCalendar,
describeDay,
progressShade,
PROGRESS_SHADES,
monthWindow,
visibleCalendarDays,
MONTH_VIEWS,
legendShades,
} from "./calendar-model";
test("legends match habit colors and only display possible progress shades", () => {
for (const color of ["#111111", "#426582", "#977344", "#79618d", "#58765b"]) {
for (const target of [1, 3, 4, 8, 20]) {
const shades = legendShades(demoCalendar(0, target), color);
expect(shades).toHaveLength(Math.min(target, 8) + 1);
expect(shades[0]).toBe(PROGRESS_SHADES[0]);
expect(shades.at(-1)).toBe(color);
for (let value = 0; value <= target; value++) {
expect(shades).toContain(
progressShade({ date: "", value, target, state: "due" }, color),
);
}
}
}
expect(legendShades(demoCalendar(0, 0))).toEqual([PROGRESS_SHADES[0]]);
expect(legendShades([])).toEqual([PROGRESS_SHADES[0]]);
});
test("eight glasses have eight positive shades plus empty; 7/8 remains incomplete", () => {
const days = Array.from({ length: 9 }, (_, value) => ({
date: "2026-09-04",
value,
target: 8,
state: "due" as const,
}));
expect(new Set(days.map((day) => progressShade(day))).size).toBe(9);
for (const color of ["#426582", "#977344", "#79618d", "#58765b"]) {
expect(new Set(days.map((day) => progressShade(day, color))).size).toBe(9);
expect(progressShade(days[8]!, color)).toBe(color);
}
expect(describeDay(days[7]!, "glasses")).toBe("7 of 8 glasses · Incomplete");
expect(progressShade({ ...days[8]!, value: 10 })).toBe(PROGRESS_SHADES[8]);
});
test("combined progress counts only completed due habits with equal weights", () => {
expect(
combinedProgress([
{ value: 7, target: 8, due: true },
{ value: 1, target: 1, due: true },
{ value: 2, target: 3, due: true },
{ value: 0, target: 1, due: false },
]),
).toEqual({ completed: 1, due: 3 });
expect(combinedProgress([{ value: 0, target: 0, due: true }])).toEqual({
completed: 0,
due: 0,
});
});
test("future and zero share a fill while inspection retains their distinct meaning", () => {
const days = demoCalendar(7, 8);
expect(days).toHaveLength(365);
expect(days.find((day) => day.date === "2026-09-04")?.value).toBe(7);
expect(
describeDay(
days.find((day) => day.state === "not-due")!,
"glasses",
),
).toContain("Nothing scheduled");
expect(describeDay(days.at(-1)!, "glasses")).toContain("Upcoming");
const zero = {
date: "2026-09-04",
value: 0,
target: 8,
state: "due" as const,
};
for (const color of ["#111111", "#426582", "#977344", "#79618d", "#58765b"]) {
expect(progressShade(days.at(-1)!, color)).toBe(progressShade(zero, color));
expect(progressShade(days.at(-1)!, color)).toBe(PROGRESS_SHADES[0]);
}
expect(describeDay(zero, "glasses")).toBe("0 of 8 glasses · Incomplete");
});
test("month presets include exact whole months and cross year and leap-year boundaries", () => {
expect(
MONTH_VIEWS.map((months) => monthWindow("2026-09-04", months)),
).toEqual([
{ from: "2026-07-01", to: "2026-09-30" },
{ from: "2026-06-01", to: "2026-09-30" },
{ from: "2026-04-01", to: "2026-09-30" },
{ from: "2025-10-01", to: "2026-09-30" },
]);
expect(monthWindow("2024-02-29", 3)).toEqual({
from: "2023-12-01",
to: "2024-02-29",
});
});
test("switching month views preserves dated values, complete coverage and future states", () => {
const days = demoCalendar(7, 8);
expect(
MONTH_VIEWS.map(
(months) => visibleCalendarDays(days, months, "2026-09-04").length,
),
).toEqual([92, 122, 183, 365]);
for (const months of MONTH_VIEWS) {
const visible = visibleCalendarDays(days, months, "2026-09-04");
expect(visible.find((day) => day.date === "2026-09-04")).toEqual(
days.find((day) => day.date === "2026-09-04"),
);
expect(visible.filter((day) => day.state === "future")).toHaveLength(26);
expect(new Set(visible.map((day) => day.date)).size).toBe(visible.length);
}
expect(visibleCalendarDays([], 3, "2026-09-04")).toEqual([]);
});
test("live calendars preserve server colors and historical units", () => {
const day = { date: "2026-09-03", value: 3, target: 8, state: "due" as const, color: "#abcdef", unit: "pages" };
expect(progressShade(day, "#111111")).toBe("#abcdef");
expect(describeDay(day, "glasses")).toBe("3 of 8 pages · Incomplete");
});

View File

@@ -0,0 +1,139 @@
export const PROGRESS_SHADES = [
"#eeeeee",
"#d8d8d8",
"#bfbfbf",
"#a5a5a5",
"#8a8a8a",
"#707070",
"#555555",
"#363636",
"#111111",
] as const;
export type CalendarDay = {
color?: string;
unit?: string;
date: string;
value: number;
target: number;
state: "due" | "not-due" | "future";
};
export const MONTH_VIEWS = [3, 4, 6, 12] as const;
export type MonthView = (typeof MONTH_VIEWS)[number];
/** Whole calendar months, including the month containing the anchor date. */
export function monthWindow(anchor: string, months: MonthView) {
const date = new Date(`${anchor}T12:00:00Z`);
const first = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth() - months + 1, 1),
);
const last = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0),
);
return {
from: first.toISOString().slice(0, 10),
to: last.toISOString().slice(0, 10),
};
}
export function visibleCalendarDays(
days: CalendarDay[],
months: MonthView,
anchor: string,
) {
const range = monthWindow(anchor, months);
return days.filter((day) => day.date >= range.from && day.date <= range.to);
}
export const HABIT_COLORS = {
water: "#426582",
reading: "#977344",
tasks: "#79618d",
movement: "#58765b",
} as const;
export function progressShade(day: CalendarDay, color = "#111111") {
if (day.color) return day.color;
if (day.state === "not-due") return "#ffffff";
if (day.state === "future") return PROGRESS_SHADES[0];
if (day.target <= 0 || day.value <= 0) return PROGRESS_SHADES[0];
const step = Math.max(
1,
Math.min(8, Math.round((day.value / day.target) * 8)),
);
if (color === "#111111" || !/^#[0-9a-f]{6}$/i.test(color))
return PROGRESS_SHADES[step]!;
const weight = step / 8;
return `#${[1, 3, 5]
.map((offset) =>
Math.round(
238 + (parseInt(color.slice(offset, offset + 2), 16) - 238) * weight,
)
.toString(16)
.padStart(2, "0"),
)
.join("")}`;
}
export function describeDay(day: CalendarDay, unit: string) {
if (day.state === "future") return "Upcoming · Logging is not available yet";
if (day.state === "not-due")
return "Nothing scheduled · Not included in the score";
return `${day.value} of ${day.target} ${day.unit ?? unit} · ${day.value >= day.target ? "Complete" : "Incomplete"}`;
}
/** Only show shades that the chart's completion targets can produce. */
export function legendShades(days: CalendarDay[], color = "#111111") {
const levels = new Set<number>();
for (const target of new Set(days.map((day) => day.target))) {
if (!Number.isFinite(target) || target <= 0) continue;
for (let value = 1; value <= Math.min(target, 8); value++) {
levels.add(
target > 8 ? value : Math.max(1, Math.round((value / target) * 8)),
);
}
}
return [
PROGRESS_SHADES[0],
...[...levels]
.sort((a, b) => a - b)
.map((value) =>
progressShade({ date: "", value, target: 8, state: "due" }, color),
),
];
}
export function combinedProgress(
habits: { value: number; target: number; due: boolean }[],
) {
const due = habits.filter((habit) => habit.due && habit.target > 0);
return {
completed: due.filter((habit) => habit.value >= habit.target).length,
due: due.length,
};
}
/** A full 12 calendar months of stable demo history, including future dates. */
export function demoCalendar(value: number, target: number): CalendarDay[] {
return Array.from({ length: 365 }, (_, index) => {
const timestamp = Date.UTC(2025, 9, 1 + index);
// Keep the previously displayed dates' fixture values unchanged.
const fixtureIndex = (timestamp - Date.UTC(2026, 2, 8)) / 86_400_000;
const rawValue = fixtureIndex * 17 + Math.floor(fixtureIndex / 7) * 3;
const date = new Date(timestamp).toISOString().slice(0, 10);
return {
date,
target,
value:
date === "2026-09-04"
? value
: ((rawValue % (target + 1)) + target + 1) % (target + 1),
state:
date > "2026-09-04"
? "future"
: fixtureIndex % 19 === 0
? "not-due"
: "due",
};
});
}

View File

@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test";
import {
EDITOR_HABITS,
backfillError,
historicalDays,
validateEditor,
validateTasks,
} from "./editing-model";
describe("design-system editors", () => {
test("all initial configurations are valid", () => {
for (const habit of EDITOR_HABITS)
expect(validateEditor(habit.config)).toBeNull();
});
test("requires a name, valid target, and nonempty weekday schedule", () => {
const config = EDITOR_HABITS[0]!.config;
expect(validateEditor({ ...config, name: " " })).not.toBeNull();
if (config.method !== "count") throw new Error("Expected count fixture");
for (const target of [0, 1.5, NaN, 10001])
expect(validateEditor({ ...config, target })).not.toBeNull();
expect(
validateEditor({ ...config, schedule: { type: "weekdays", days: [] } }),
).not.toBeNull();
});
test("validates task names and independent recurrence", () => {
expect(
validateTasks([{ id: "1", name: "", schedule: { type: "daily" } }]),
).not.toBeNull();
expect(
validateTasks([
{
id: "1",
name: "Read",
schedule: { type: "interval", every: 0, anchor: "2026-09-04" },
},
]),
).not.toBeNull();
expect(validateTasks([])).toBeNull();
});
test("backfills reject today, future, nonexistent and unscheduled dates", () => {
for (const date of [
"2026-09-04",
"2026-09-05",
"2026-02-30",
"2025-09-30",
"",
])
expect(backfillError("water", date, 1)).not.toBeNull();
const notDue = historicalDays("water").find(
(day) => day.state === "not-due",
)!;
expect(backfillError("water", notDue.date, 1)).not.toBeNull();
});
test("corrections allow zero and over-target counts but reject invalid totals", () => {
for (const value of [0, 8, 12, 1_000_000_000])
expect(backfillError("water", "2026-09-03", value)).toBeNull();
for (const value of [-1, 1.5, NaN, 1_000_000_001])
expect(backfillError("water", "2026-09-03", value)).not.toBeNull();
expect(backfillError("reading", "2026-09-03", 2)).not.toBeNull();
expect(backfillError("tasks", "2026-09-03", 4)).not.toBeNull();
});
test("editing a current target or task list does not rewrite historical requirements", () => {
const copy = structuredClone(EDITOR_HABITS);
const water = copy[0]!.config;
const tasks = copy[2]!.config;
if (water.method === "count") water.target = 12;
if (tasks.method === "tasks") tasks.tasks.pop();
expect(historicalDays("water")[0]!.target).toBe(8);
expect(historicalDays("tasks")[0]!.target).toBe(3);
});
});

View File

@@ -0,0 +1,110 @@
import {
dateSchema,
habitInput,
taskInput,
type HabitConfig,
} from "../../habits/contracts";
import { demoCalendar, HABIT_COLORS } from "./calendar-model";
export const EDITOR_TODAY = "2026-09-04";
export const EDITOR_START = "2025-10-01";
export const EDITOR_HABITS: {
id: string;
color: string;
config: HabitConfig;
}[] = [
{
id: "water",
color: HABIT_COLORS.water,
config: {
name: "Drink water",
method: "count",
target: 8,
unit: "glasses",
carryPartialProgress: false,
schedule: { type: "daily" },
archived: false,
},
},
{
id: "reading",
color: HABIT_COLORS.reading,
config: {
name: "Read a little",
method: "manual",
schedule: { type: "daily" },
archived: false,
},
},
{
id: "tasks",
color: HABIT_COLORS.tasks,
config: {
name: "Evening reset",
method: "tasks",
schedule: { type: "daily" },
archived: false,
tasks: ["Clear desk", "Plan tomorrow", "Stretch"].map((name, index) => ({
id: `task-${index}`,
name,
schedule: { type: "daily" },
})),
},
},
];
export function validateEditor(config: HabitConfig): string | null {
const { archived: _, ...input } = config;
const result = habitInput.safeParse(
config.method === "tasks"
? { ...input, tasks: config.tasks.map(({ id: _, ...task }) => task) }
: input,
);
return result.success
? null
: (result.error.issues[0]?.message ?? "Check the habit settings.");
}
export function validateTasks(
tasks: Extract<HabitConfig, { method: "tasks" }>["tasks"],
): string | null {
if (tasks.length > 100) return "Use no more than 100 tasks.";
for (const { id: _, ...task } of tasks) {
const result = taskInput.safeParse(task);
if (!result.success)
return result.error.issues[0]?.message ?? "Check each task.";
}
return null;
}
/** Frozen historical requirements deliberately do not read today's edited config. */
export function historicalDays(id: string) {
const config = EDITOR_HABITS.find((habit) => habit.id === id)!.config;
return demoCalendar(
0,
config.method === "count"
? config.target
: config.method === "tasks"
? config.tasks.length
: 1,
);
}
export function backfillError(
id: string,
date: string,
value: number,
): string | null {
if (!dateSchema.safeParse(date).success) return "Choose a valid date.";
if (date >= EDITOR_TODAY)
return "Choose a past date. Today is edited in the daily view.";
if (date < EDITOR_START) return "This habit did not exist on that date.";
const day = historicalDays(id).find((day) => day.date === date);
if (!day || day.state !== "due")
return "Nothing was scheduled on this date. There is no progress to correct.";
if (!Number.isInteger(value) || value < 0 || value > 1_000_000_000)
return "Enter a whole number from 0 to 1,000,000,000.";
if (id !== "water" && value > day.target)
return "Progress cannot exceed the scheduled items.";
return null;
}

View File

@@ -0,0 +1,124 @@
import type {
ButtonHTMLAttributes,
InputHTMLAttributes,
ReactNode,
} from "react";
export function Button({
variant = "primary",
className = "",
type = "button",
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "text";
}) {
return (
<button
type={type}
className={`ds-button ds-button--${variant} ${className}`}
{...props}
/>
);
}
export function SectionHeading({
number,
title,
children,
}: {
number: string;
title: string;
children?: ReactNode;
}) {
return (
<header className="ds-section-heading">
<span className="ds-eyebrow">{number}</span>
<h2>{title}</h2>
{children && <p>{children}</p>}
</header>
);
}
export function Checkbox({
label,
className = "",
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string }) {
return (
<label className={`ds-checkbox ${className}`}>
<input type="checkbox" {...props} />
<span>{label}</span>
</label>
);
}
export function Counter({
label,
value,
target,
onChange,
disabled = false,
}: {
label: string;
value: number;
target: number;
onChange: (value: number) => void;
disabled?: boolean;
}) {
return (
<div className="ds-counter" role="group" aria-label={label}>
<Button
variant="text"
aria-label={`Decrease ${label}`}
disabled={disabled || value <= 0}
onClick={() => onChange(Math.max(0, value - 1))}
>
</Button>
<output aria-live="polite">
<span>{value}</span>
<span className="ds-muted"> / {target}</span>
</output>
<Button
variant="text"
aria-label={`Increase ${label}`}
disabled={disabled || value >= target}
onClick={() => onChange(Math.min(target, value + 1))}
>
+
</Button>
</div>
);
}
export function HabitRow({
name,
description,
complete,
children,
tasks,
}: {
name: string;
description: string;
complete: boolean;
children: ReactNode;
tasks?: ReactNode;
}) {
return (
<div className="ds-habit-row">
<div className="ds-habit-main">
<div>
<h4>{name}</h4>
<p>{description}</p>
</div>
<div className="ds-habit-control">
{children}
<span className="ds-status">
{complete ? "Complete" : "In progress"}
</span>
</div>
</div>
{tasks && <div className="ds-task-list">{tasks}</div>}
</div>
);
}