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();
}),
};
}