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:
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