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 />);