habits-free.openapps.xyz

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.
Owned app · rev bc888334fc51
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.
← All files
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
bc888334fc · 18 hours ago
Bind agent address in policy
1f744a2b83 · 19 hours ago
Use OpenApp service URLs and add contribution strip
a9bb601ee0 · 19 hours ago
Bounded source sync
5646afc3a0 · 1 day ago
Bounded source sync
f1b1bac8d6 · 1 day ago
Bounded source sync
b1e14b41b0 · 1 day ago