Habits
index.jsx 303 lines
Files · 10
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303import React, { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import {
Page,
Header,
Button,
Input,
Modal,
EmptyState,
} from "../../shared/ui.jsx";
import { useModel } from "../../shared/data.js";
import { Select, ErrorNotice, Stats } from "../../shared/ui.jsx";
import { FormActions } from "../../shared/forms.jsx";
import { DateNav } from "./date-nav.jsx";
import { createModel, scheduled, streak } from "./model.js";
import { localDate, addDays, monday } from "./runtime.js";
import "./style.css";
const model = createModel(window.lattice);
const weekNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
function HabitForm({ onSave, onClose, busy }) {
const [form, setForm] = useState({
title: "",
schedule: "daily",
weekdays: [1, 2, 3, 4, 5],
weekly_target: 3,
start_date: localDate(),
});
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
return (
<Modal title="New habit" onClose={onClose}>
<form
onSubmit={(e) => {
e.preventDefault();
onSave(form);
}}
>
<Input
label="Habit name"
value={form.title}
onChange={(e) => set("title", e.target.value)}
required
placeholder="e.g. Read for 20 minutes"
/>
<Select
label="Schedule"
value={form.schedule}
onChange={(e) => set("schedule", e.target.value)}
>
<option value="daily">Every day</option>
<option value="weekdays">Specific weekdays</option>
<option value="weekly">Times per week</option>
</Select>
{form.schedule === "weekdays" && (
<fieldset>
<legend>Scheduled weekdays</legend>
<div className="weekday-list">
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
<label key={d}>
<input
type="checkbox"
checked={form.weekdays.includes(d)}
onChange={(e) =>
set(
"weekdays",
e.target.checked
? [...form.weekdays, d]
: form.weekdays.filter((v) => v !== d),
)
}
/>
{weekNames[d]}
</label>
))}
</div>
</fieldset>
)}
{form.schedule === "weekly" && (
<Input
label="Days per week"
type="number"
min="1"
max="7"
value={form.weekly_target}
onChange={(e) => set("weekly_target", Number(e.target.value))}
hint="One check per day. Weeks run Monday–Sunday."
required
/>
)}
<Input
label="Start date"
type="date"
max={localDate()}
value={form.start_date}
onChange={(e) => set("start_date", e.target.value)}
required
hint="Past start dates are assessed for missed periods. Schedules stay fixed; archive a habit to end it."
/>
<FormActions busy={busy} onCancel={onClose} label="Create habit" />
</form>
</Modal>
);
}
function App() {
const { data, error, busy, act } = useModel(model),
[date, setDate] = useState(localDate()),
[open, setOpen] = useState(false),
[archived, setArchived] = useState(false),
[ending, setEnding] = useState(null);
useEffect(() => {
const timer = setInterval(() => act(() => model.tick()), 60000);
const wake = () => {
if (!document.hidden) act(() => model.tick());
};
document.addEventListener("visibilitychange", wake);
return () => {
clearInterval(timer);
document.removeEventListener("visibilitychange", wake);
};
}, []);
const habits = data?.habits ?? [],
checks = data?.checks ?? [],
today = data?.today ?? localDate();
const active = habits.filter((h) => !h.archived_on),
due = active.filter((h) => scheduled(h, date)),
done = due.filter((h) =>
checks.some((c) => c.habit_id === h.id && c.date === date),
).length;
const visible = habits.filter((h) =>
archived ? !!h.archived_on : !h.archived_on,
);
return (
<Page className="new-app habits">
<Header
title="Habits"
subtitle="Daily routines and weekly goals."
actions={
<>
<Button variant="primary" onClick={() => setOpen(true)}>
Add habit
</Button>
</>
}
/>
<ErrorNotice error={error} pending={data?.pending} />
<Stats
items={[
[`${done} / ${due.length}`, "Checked on selected day"],
[active.length, "Active habits"],
[checks.length, "Total checks"],
]}
/>
<div className="ui-list-header">
<DateNav date={date} setDate={setDate} today={today} />
<Button variant="ghost" onClick={() => setArchived(!archived)}>
{archived ? "Show active" : "Show archived"}
</Button>
</div>
<div className="ui-list-header">
<h2>{date === today ? "Today" : date}</h2>
<span className="ui-muted">Last 28 days · filled = checked</span>
</div>
<div className="ui-panel">
{!data ? (
<EmptyState title="Loading habits…" />
) : !visible.length ? (
<EmptyState
title={archived ? "No archived habits" : "No habits yet"}
action={
!archived && (
<div className="ui-actions">
<Button onClick={() => setOpen(true)}>Create a habit</Button>
{!habits.length && (
<Button
disabled={busy}
onClick={() => act(() => model.sample())}
>
Add sample data
</Button>
)}
</div>
)
}
>
Choose a daily routine or a weekly target.
</EmptyState>
) : (
visible.map((h) => {
const checked = checks.some(
(c) => c.habit_id === h.id && c.date === date,
),
n = streak(h, checks, today),
weekly = checks.filter(
(c) =>
c.habit_id === h.id &&
c.date >= monday(date) &&
c.date <= addDays(monday(date), 6),
).length;
return (
<div className="habit-row" key={h.id}>
<div className="habit-label">
<input
className="ui-check"
type="checkbox"
aria-label={`Complete ${h.title} on ${date}`}
checked={checked}
disabled={busy || !scheduled(h, date) || date > today}
onChange={() => act(() => model.toggle(h, date))}
/>
<div>
<strong>{h.title}</strong>
<div className="ui-muted">
{h.schedule === "daily"
? "Every day"
: h.schedule === "weekly"
? `${weekly} / ${h.weekly_target} this week`
: JSON.parse(h.weekdays)
.map((d) => weekNames[d])
.join(" · ")}
</div>
<div className="ui-meta">
<span className="ui-badge">
{h.schedule === "weekly"
? `${h.weekly_target} days / week`
: h.schedule === "daily"
? "Daily"
: "Weekdays"}
</span>
{!h.archived_on && (
<Button variant="ghost" onClick={() => setEnding(h)}>
Archive
</Button>
)}
</div>
</div>
</div>
<div
className="history-grid"
aria-label={`${h.title} check history`}
>
{Array.from({ length: 28 }, (_, i) => {
const d = addDays(today, i - 27),
yes = checks.some(
(c) => c.habit_id === h.id && c.date === d,
);
return (
<button
key={d}
title={`${d}: ${yes ? "checked" : scheduled(h, d) ? "unchecked" : "not scheduled"}`}
aria-label={`View ${h.title}, ${d}, ${yes ? "checked" : "unchecked"}`}
className={`history-dot ${scheduled(h, d) ? "scheduled" : ""} ${yes ? "done" : ""} ${d === date ? "selected" : ""}`}
onClick={() => setDate(d)}
/>
);
})}
</div>
<div className="streak">
<strong>{n}</strong>
<div className="ui-muted">
{h.schedule === "weekly" ? "week" : "scheduled-day"} streak
</div>
</div>
</div>
);
})
)}
</div>
{open && (
<HabitForm
busy={busy}
onClose={() => !busy && setOpen(false)}
onSave={(f) =>
act(() => model.add(f)).then((ok) => ok && setOpen(false))
}
/>
)}
{ending && (
<Modal title="Archive habit?" onClose={() => !busy && setEnding(null)}>
<p className="ui-muted">
{ending.title} will stop being scheduled after today. Its checks and
history stay available.
</p>
<div className="ui-form-actions">
<Button disabled={busy} onClick={() => setEnding(null)}>
Cancel
</Button>
<Button
variant="danger"
disabled={busy}
onClick={() =>
act(() => model.archive(ending)).then(
(ok) => ok && setEnding(null),
)
}
>
Archive habit
</Button>
</div>
</Modal>
)}
</Page>
);
}
createRoot(document.getElementById("root")).render(<App />);