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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
91
src/components/AuthProvider.tsx
Normal file
91
src/components/AuthProvider.tsx
Normal 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;
|
||||
}
|
||||
261
src/components/CreateHabit.tsx
Normal file
261
src/components/CreateHabit.tsx
Normal 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 don’t 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>
|
||||
);
|
||||
}
|
||||
73
src/components/DeleteHabit.tsx
Normal file
73
src/components/DeleteHabit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
src/components/HabitHistory.tsx
Normal file
156
src/components/HabitHistory.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/components/LandingDemo.tsx
Normal file
67
src/components/LandingDemo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
245
src/components/design-system/CalendarHeatmap.tsx
Normal file
245
src/components/design-system/CalendarHeatmap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/components/design-system/CalendarLegend.tsx
Normal file
53
src/components/design-system/CalendarLegend.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
909
src/components/design-system/EditingWorkbench.tsx
Normal file
909
src/components/design-system/EditingWorkbench.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
src/components/design-system/HabitChart.tsx
Normal file
84
src/components/design-system/HabitChart.tsx
Normal 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 isn’t 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>
|
||||
);
|
||||
}
|
||||
48
src/components/design-system/HabitColorPicker.test.ts
Normal file
48
src/components/design-system/HabitColorPicker.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
153
src/components/design-system/HabitColorPicker.tsx
Normal file
153
src/components/design-system/HabitColorPicker.tsx
Normal 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 habit’s 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>
|
||||
);
|
||||
}
|
||||
124
src/components/design-system/calendar-model.test.ts
Normal file
124
src/components/design-system/calendar-model.test.ts
Normal 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");
|
||||
});
|
||||
139
src/components/design-system/calendar-model.ts
Normal file
139
src/components/design-system/calendar-model.ts
Normal 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",
|
||||
};
|
||||
});
|
||||
}
|
||||
71
src/components/design-system/editing-model.test.ts
Normal file
71
src/components/design-system/editing-model.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
110
src/components/design-system/editing-model.ts
Normal file
110
src/components/design-system/editing-model.ts
Normal 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;
|
||||
}
|
||||
124
src/components/design-system/primitives.tsx
Normal file
124
src/components/design-system/primitives.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user