Habits

model.js 301 lines

Files · 10
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301import manifest from "./manifest.json";
import { DDL } from "./schema.js";
import {
  runtime,
  localDate,
  validDate,
  addDays,
  weekday,
  monday,
  daysBetween,
  nowISO,
  uid,
  nonblank,
} from "./runtime.js";
export const scheduled = (h, date) =>
  date >= h.start_date &&
  (!h.archived_on || date < h.archived_on) &&
  (h.schedule !== "weekdays" || JSON.parse(h.weekdays).includes(weekday(date)));
export function periods(h, through) {
  const result = [];
  if (h.schedule === "weekly") {
    for (
      let start = monday(h.start_date);
      start <= through;
      start = addDays(start, 7)
    ) {
      const end = addDays(start, 6),
        first = start < h.start_date ? h.start_date : start;
      const last =
        h.archived_on && h.archived_on <= end
          ? addDays(h.archived_on, -1)
          : end;
      if (first > last) break;
      result.push({
        start: first,
        end,
        target: Math.min(h.weekly_target, daysBetween(first, last) + 1),
      });
    }
  } else
    for (let d = h.start_date; d <= through; d = addDays(d, 1)) {
      if (h.archived_on && d >= h.archived_on) break;
      if (scheduled(h, d)) result.push({ start: d, end: d, target: 1 });
    }
  return result;
}
export function streak(h, checks, asOf = localDate()) {
  const dates = new Set(
    checks
      .filter((c) => c.habit_id === h.id && scheduled(h, c.date))
      .map((c) => c.date),
  );
  let count = 0;
  for (const p of periods(h, asOf).reverse()) {
    let done = 0;
    for (const date of dates)
      if (date >= p.start && date <= p.end && date <= asOf) done++;
    if (done >= p.target) count++;
    else if (p.end < asOf) break;
  }
  return count;
}
export function createModel(L) {
  const rt = runtime(L, manifest, DDL);
  async function all() {
    return {
      habits: await L.db.query("SELECT * FROM habits ORDER BY created_at,id"),
      checks: await L.db.query("SELECT * FROM habit_checks ORDER BY date"),
      today: localDate(),
      pending: rt.deliveryError,
    };
  }
  async function check(data) {
    const date = validDate(data.date ?? localDate());
    if (date > localDate()) throw new Error("Future dates cannot be checked.");
    const matches = data.habit_id
      ? await L.db.query("SELECT * FROM habits WHERE id=?", [data.habit_id])
      : (await L.db.query("SELECT * FROM habits")).filter(
          (h) =>
            h.title.toLocaleLowerCase() ===
            nonblank(data.title, "Habit title").toLocaleLowerCase(),
        );
    if (matches.length !== 1)
      throw new Error("Choose one existing habit by ID or a unique title.");
    const h = matches[0];
    if (!scheduled(h, date))
      throw new Error("This date is outside the habit’s schedule.");
    if (
      (
        await L.db.query(
          "SELECT date FROM habit_checks WHERE habit_id=? AND date=?",
          [h.id, date],
        )
      ).length
    )
      return;
    const checked_at = nowISO();
    await L.db.exec("INSERT INTO habit_checks VALUES (?,?,?)", [
      h.id,
      date,
      checked_at,
    ]);
    const checks = await L.db.query(
      "SELECT * FROM habit_checks WHERE habit_id=?",
      [h.id],
    );
    const n = streak(h, checks, date),
      streak_unit = h.schedule === "weekly" ? "weeks" : "scheduled-days";
    const payload = {
      habit_id: h.id,
      title: h.title,
      date,
      streak: n,
      streak_unit,
      checked_at,
    };
    await rt.publish("habit-checked", payload);
    if ([7, 14, 30, 60, 100, 365].includes(n)) {
      const end = h.schedule === "weekly" ? monday(date) : date;
      const key = `milestone:${h.id}:${end}:${n}`;
      if (
        !(
          await L.db.query(
            "SELECT milestone FROM habit_milestones WHERE habit_id=? AND period_start=? AND milestone=?",
            [h.id, end, n],
          )
        ).length
      ) {
        await rt.publish("streak-milestone", { ...payload, milestone: n }, key);
        await L.db.exec(
          "INSERT OR IGNORE INTO habit_milestones VALUES (?,?,?,?)",
          [h.id, end, n, nowISO()],
        );
      }
    }
  }
  async function assess() {
    const { habits, checks, today } = await all();
    for (const h of habits) {
      const assessed = new Set(
        (
          await L.db.query(
            "SELECT period_end FROM habit_periods WHERE habit_id=?",
            [h.id],
          )
        ).map((p) => p.period_end),
      );
      const dates = checks
        .filter((c) => c.habit_id === h.id && scheduled(h, c.date))
        .map((c) => c.date);
      for (const p of periods(h, addDays(today, -1))) {
        if (p.end >= today || assessed.has(p.end)) continue;
        const completed = dates.filter(
          (d) => d >= p.start && d <= p.end,
        ).length;
        if (completed < p.target)
          await rt.publish(
            "habit-missed",
            {
              habit_id: h.id,
              title: h.title,
              date: p.end,
              period_start: p.start,
              target: p.target,
              completed,
            },
            `miss:${h.id}:${p.end}`,
          );
        await L.db.exec(
          "INSERT OR IGNORE INTO habit_periods VALUES (?,?,?,?,?,?)",
          [h.id, p.end, p.start, p.target, completed, nowISO()],
        );
      }
    }
    await rt.flush();
  }
  async function add({
    title,
    schedule = "daily",
    weekdays = [],
    weekly_target = 1,
    start_date = localDate(),
  }) {
    title = nonblank(title, "Title");
    validDate(start_date);
    if (!["daily", "weekdays", "weekly"].includes(schedule))
      throw new Error("Choose a valid schedule.");
    if (
      !Array.isArray(weekdays) ||
      weekdays.some((d) => !Number.isInteger(d) || d < 0 || d > 6) ||
      (schedule === "weekdays" && !weekdays.length)
    )
      throw new Error("Choose at least one weekday.");
    if (
      !Number.isInteger(weekly_target) ||
      weekly_target < 1 ||
      weekly_target > 7
    )
      throw new Error("Weekly target must be 1–7.");
    if (start_date > localDate())
      throw new Error("Start date must be today or earlier.");
    await L.db.exec(
      "INSERT INTO habits (id,title,schedule,weekdays,weekly_target,start_date,created_at) VALUES (?,?,?,?,?,?,?)",
      [
        uid(),
        title,
        schedule,
        JSON.stringify([...new Set(weekdays)]),
        weekly_target,
        start_date,
        nowISO(),
      ],
    );
  }
  const ready = rt.ready.then(() => {
    rt.listen("check-habit", check);
    return rt.run(assess);
  });
  return {
    subscribe: rt.subscribe,
    ready,
    load: async () => {
      await ready;
      return all();
    },
    add: (form) =>
      rt.run(async () => {
        await add(form);
        await assess();
      }),
    toggle: (h, date) =>
      rt.run(async () => {
        validDate(date);
        if (date > localDate() || !scheduled(h, date))
          throw new Error("Choose an eligible date.");
        const found = await L.db.query(
          "SELECT date FROM habit_checks WHERE habit_id=? AND date=?",
          [h.id, date],
        );
        if (found.length)
          await L.db.exec(
            "DELETE FROM habit_checks WHERE habit_id=? AND date=?",
            [h.id, date],
          );
        else await check({ habit_id: h.id, date });
        await rt.flush();
      }),
    archive: (h) =>
      rt.run(async () => {
        await L.db.exec("UPDATE habits SET archived_on=? WHERE id=?", [
          addDays(localDate(), 1),
          h.id,
        ]);
        await assess();
      }),
    tick: () => rt.run(assess),
    sample: () =>
      rt.run(async () => {
        if ((await L.db.query("SELECT id FROM habits")).length)
          throw new Error(
            "Sample data can only be added to an empty habit list.",
          );
        const today = localDate();
        await add({
          title: "Read for 20 minutes",
          start_date: addDays(today, -13),
        });
        await add({
          title: "Exercise",
          schedule: "weekly",
          weekly_target: 3,
          start_date: addDays(today, -13),
        });
        await add({
          title: "Plan the day",
          schedule: "weekdays",
          weekdays: [1, 2, 3, 4, 5],
          start_date: addDays(today, -13),
        });
        const habits = await L.db.query("SELECT * FROM habits");
        for (const [idx, h] of habits.entries())
          for (let n = -13; n <= 0; n++) {
            const date = addDays(today, n);
            if (
              scheduled(h, date) &&
              (idx === 0
                ? n !== -9
                : idx === 1
                  ? [-12, -10, -8, -5, -3, 0].includes(n)
                  : weekday(date) !== 3)
            )
              await L.db.exec("INSERT INTO habit_checks VALUES (?,?,?)", [
                h.id,
                date,
                nowISO(),
              ]);
          }
        await assess();
      }),
  };
}