Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone e8a688fd21351d04b05468df
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/lib/habits.ts
// What a habit is, when it is due, and how its streaks, completion rate and history are counted.
// Pure functions over plain data, so the same rules hold on every device.
import { dayNum, keyOf, today as todayNum, weekday, weekOf, fmt, WEEKDAY_SHORT } from "./dates";
/** check: done or not. count: done when the day's count reaches the target. avoid: days without it; a check-in is a slip. */
export type Kind = "check" | "count" | "avoid";
export type Schedule =
| { type: "daily" }
| { type: "days"; days: number[] } // weekdays, 0 = Sunday
| { type: "weekly"; times: number } // any days, this many times each week
| { type: "every"; n: number }; // at least once in every n-day stretch, counted from the start day
/** One day's check-in. n is the count (1 for done, or a slip for avoid habits); t is when it last changed, for merging. */
export interface Day { n: number; t: number; note?: string }
export interface Habit {
id: string;
name: string;
kind: Kind;
target: number; // count habits: the day's goal
unit: string; // count habits: "glasses", "pages"
schedule: Schedule;
start: string; // YYYY-MM-DD
order: number;
archived: boolean;
team: string | null; // the shared habit this one is linked to
at: number; // last change to the settings above, ms
days: Record<string, Day>; // YYYY-MM-DD -> check-in
deleted?: boolean; // kept as a marker so another device does not bring it back
}
export const MAX_NAME = 60;
export const MAX_NOTE = 280;
const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
/** Random ids: 20 characters for share links (the link is the secret), shorter for habits. */
export const randomId = (n = 12) => Array.from(crypto.getRandomValues(new Uint8Array(n)), (b) => ALPHABET[b % 36]).join("");
export function newHabit(p: Partial<Habit> & { name: string }, order: number): Habit {
return {
id: randomId(),
kind: "check",
target: 1,
unit: "",
schedule: { type: "daily" },
start: keyOf(todayNum()),
archived: false,
team: null,
days: {},
...p,
order,
at: Date.now(),
};
}
/** Whether a check-in counts as done (or, for avoid habits, as a slip). */
export const met = (h: Habit, d: Day | undefined) => Boolean(d && d.n >= (h.kind === "count" ? Math.max(1, h.target) : 1));
export function scheduleLabel(h: Pick<Habit, "kind" | "schedule">): string {
if (h.kind === "avoid") return "Every day without it";
const s = h.schedule;
if (s.type === "daily") return "Every day";
if (s.type === "weekly") return s.times === 1 ? "Once a week" : s.times === 7 ? "7 times a week" : `${s.times} times a week`;
if (s.type === "every") return `Every ${s.n} days`;
const days = [...s.days].sort();
if (days.join() === "1,2,3,4,5") return "Weekdays";
if (days.join() === "0,6") return "Weekends";
return [1, 2, 3, 4, 5, 6, 0].filter((d) => days.includes(d)).map((d) => WEEKDAY_SHORT[d]).join(", ");
}
export type Unit = "day" | "week" | "time";
export const unitOf = (h: Habit): Unit => (h.kind === "avoid" || h.schedule.type === "daily" || h.schedule.type === "days" ? "day" : h.schedule.type === "weekly" ? "week" : "time");
/** "12 days", "3 weeks", "4 times in a row", "9 days without". */
export function streakLabel(n: number, unit: Unit, avoid = false): string {
if (unit === "week") return `${n} ${n === 1 ? "week" : "weeks"}`;
if (unit === "time") return `${n} ${n === 1 ? "time" : "times"} in a row`;
return `${n} ${n === 1 ? "day" : "days"}${avoid ? " without" : ""}`;
}
export interface Stats {
current: number;
best: number;
rate: number | null; // share of finished or met periods since the start, 0..1
total: number; // days done (slips, for avoid habits)
unit: Unit;
doneToday: boolean; // checked in today (slipped today, for avoid habits)
countToday: number;
due: boolean; // still to do today
rest: string | null; // why it is not due today: "3 of 3 this week", "Next on Fri"
week: number; // done days this week (weekly habits)
last: string | null; // last done day (last slip, for avoid habits)
}
/** Streaks, completion and today's state for a habit, as of day `now`. */
export function stats(h: Habit, weekStartsOn: 0 | 1, now = todayNum()): Stats {
const done = new Set<number>();
for (const [k, d] of Object.entries(h.days)) if (met(h, d)) done.add(dayNum(k));
const start = Math.min(dayNum(h.start), now);
const unit = unitOf(h);
const tk = keyOf(now);
const doneToday = done.has(now);
const lastN = [...done].filter((d) => d <= now).sort((a, b) => b - a)[0];
const base = { unit, doneToday, countToday: h.days[tk]?.n ?? 0, total: [...done].filter((d) => d >= start && d <= now).length, last: lastN === undefined ? null : keyOf(lastN), week: 0 };
if (h.kind === "avoid") {
const slips = [...done].filter((d) => d >= start && d <= now).sort((a, b) => a - b);
const marks = [start - 1, ...slips];
const current = doneToday ? 0 : now - marks[marks.length - 1];
let best = current;
for (let i = 1; i < marks.length; i++) best = Math.max(best, marks[i] - marks[i - 1] - 1);
const span = now - start + 1;
return { ...base, current, best, rate: (span - slips.length) / span, due: false, rest: null };
}
// Each period (a day, a scheduled day, a week or an n-day stretch) is met or not; the last one may still be open.
const periods: boolean[] = [];
let open = true;
let rateSkip = 0;
let due = !doneToday;
let rest: string | null = null;
let week = 0;
const s = h.schedule;
if (s.type === "daily") {
for (let d = start; d <= now; d++) periods.push(done.has(d));
} else if (s.type === "days") {
const on = new Set(s.days.length ? s.days : [0, 1, 2, 3, 4, 5, 6]);
for (let d = start; d <= now; d++) if (on.has(weekday(d))) periods.push(done.has(d));
open = on.has(weekday(now));
if (!open && !doneToday) {
due = false;
let next = now + 1;
while (!on.has(weekday(next))) next++;
rest = next === now + 1 ? "Next tomorrow" : `Next on ${WEEKDAY_SHORT[weekday(next)]}`;
}
} else if (s.type === "weekly") {
const times = Math.min(7, Math.max(1, s.times));
const first = weekOf(start, weekStartsOn);
for (let w = first; w <= now; w += 7) {
let c = 0;
for (let d = Math.max(w, start); d < w + 7 && d <= now; d++) if (done.has(d)) c++;
periods.push(c >= times);
week = c;
}
// A first week that began partway through only counts once it is met.
if (first < start && !periods[0]) rateSkip = 1;
if (week >= times && !doneToday) { due = false; rest = `${week} of ${times} this week`; }
} else {
const n = Math.max(2, s.n);
for (let w = start; w <= now; w += n) {
let any = false;
for (let d = w; d < w + n && d <= now; d++) if (done.has(d)) any = true;
periods.push(any);
}
if (periods[periods.length - 1] && !doneToday) {
due = false;
const next = start + periods.length * n;
rest = next === now + 1 ? "Next tomorrow" : `Next on ${fmt(next, { weekday: "short", month: "short", day: "numeric" })}`;
}
}
let i = periods.length - 1;
if (open && !periods[i]) i--;
let current = 0;
while (i >= 0 && periods[i]) { current++; i--; }
let best = 0;
let run = 0;
for (const p of periods) { run = p ? run + 1 : 0; best = Math.max(best, run); }
const counted = periods.length - rateSkip - (open && !periods[periods.length - 1] ? 1 : 0);
const metCount = periods.filter(Boolean).length;
return { ...base, current, best, rate: counted > 0 ? Math.min(1, metCount / counted) : null, due, rest, week };
}
/** Merges two copies of one habit: settings from the newer copy, each day from its newer check-in. A deleted copy stays deleted, with no check-ins or notes. */
export function mergeHabit(a: Habit, b: Habit): Habit {
if (a.deleted || b.deleted) return { ...(a.at >= b.at ? a : b), days: {}, team: null, deleted: true };
const base = a.at >= b.at ? a : b;
const days = { ...b.days };
for (const [k, d] of Object.entries(a.days)) if (!days[k] || d.t > days[k].t) days[k] = d;
return { ...base, days };
}
/** Drops undone check-ins with no note once they are a month old (they only matter while devices catch up). */
export function prune(h: Habit, now = Date.now()) {
for (const [k, d] of Object.entries(h.days)) if (d.n <= 0 && !d.note && d.t < now - 30 * 86400000) delete h.days[k];
}
/** The habit's check-ins as CSV rows: date, habit, type, schedule, count, goal, done, note. */
export function csv(habits: Habit[]): string {
const q = (v: string | number) => (/[",\n\r]/.test(String(v)) ? `"${String(v).replace(/"/g, '""')}"` : String(v));
const rows = [["date", "habit", "type", "schedule", "count", "goal", "done", "note"]];
for (const h of habits) {
const type = h.kind === "check" ? "yes or no" : h.kind === "count" ? `count${h.unit ? ` (${h.unit})` : ""}` : "avoid";
for (const k of Object.keys(h.days).sort()) {
const d = h.days[k];
if (d.n <= 0 && !d.note) continue;
const done = h.kind === "avoid" ? (d.n > 0 ? "slipped" : "") : met(h, d) ? "yes" : "no";
rows.push([k, h.name, type, scheduleLabel(h), String(d.n), h.kind === "count" ? String(h.target) : "", done, d.note || ""]);
}
}
return rows.map((r) => r.map(q).join(",")).join("\r\n") + "\r\n";
}
Change history
Point OpenApps links to openapps.free
Bind agent address in policy
Use OpenApp service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync