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/ics.ts
// Calendar reminders. A web page cannot reliably wake a phone at a set time without a server
// sending pushes, so Habits hands the reminder to the person's calendar instead: a repeating event
// that follows the habit's schedule, as an .ics file or a Google Calendar link.
import { dayNum, keyOf, today, weekday } from "./dates";
import type { Habit } from "./habits";
import { SITE_URL } from "../../functions/lib/config";

const BYDAY = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];

/** The repeat rule and the first day it happens, for a habit's schedule. */
function rule(h: Habit): { rrule: string; first: number } {
  const now = today();
  const s = h.schedule;
  if (h.kind !== "avoid" && s.type === "days" && s.days.length) {
    let first = now;
    while (!s.days.includes(weekday(first))) first++;
    return { rrule: `FREQ=WEEKLY;BYDAY=${[...s.days].sort().map((d) => BYDAY[d]).join(",")}`, first };
  }
  if (h.kind !== "avoid" && s.type === "every") {
    const start = dayNum(h.start);
    const n = Math.max(2, s.n);
    const first = now <= start ? start : start + Math.ceil((now - start) / n) * n;
    return { rrule: `FREQ=DAILY;INTERVAL=${n}`, first };
  }
  // Every day, avoid habits, and "N times a week" (the days are up to the person): a daily reminder.
  return { rrule: "FREQ=DAILY", first: now };
}

const stamp = (day: number, time: string) => `${keyOf(day).replace(/-/g, "")}T${time.replace(":", "")}00`;
const pad = (n: number) => String(Math.floor(n)).padStart(2, "0");

/** Start and end of the first event: ten minutes, kept within the day. */
function span(first: number, time: string) {
  const [hh, mm] = time.split(":").map(Number);
  const end = Math.min(hh * 60 + mm + 10, 1439);
  return { start: stamp(first, time), end: stamp(first, `${pad(end / 60)}:${pad(end % 60)}`) };
}
const esc = (s: string) => s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r\n|\r|\n/g, "\\n");

/** Folds long lines at 75 octets, as the iCalendar format asks. */
function fold(line: string): string {
  const bytes = new TextEncoder().encode(line);
  if (bytes.length <= 75) return line;
  const out: string[] = [];
  let cur = "";
  let size = 0;
  for (const ch of line) {
    const n = new TextEncoder().encode(ch).length;
    if (size + n > (out.length ? 74 : 75)) { out.push(cur); cur = ""; size = 0; }
    cur += ch;
    size += n;
  }
  out.push(cur);
  return out.join("\r\n ");
}

/** A repeating calendar event at `time` (HH:MM, local) with an alert when it starts. */
export function reminderIcs(h: Habit, time: string): string {
  const { rrule, first } = rule(h);
  const { start, end } = span(first, time);
  const now = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+/, "");
  const text = h.kind === "avoid" ? `Still going without: ${h.name}` : h.name;
  const lines = [
    "BEGIN:VCALENDAR",
    "VERSION:2.0",
    "PRODID:-//OpenApps//Habits//EN",
    "CALSCALE:GREGORIAN",
    "METHOD:PUBLISH",
    "BEGIN:VEVENT",
    `UID:habit-${h.id}-${Date.now()}@habits-free.openapps.xyz`,
    `DTSTAMP:${now}`,
    `DTSTART:${start}`,
    `DTEND:${end}`,
    `RRULE:${rrule}`,
    `SUMMARY:${esc(text)}`,
    `DESCRIPTION:${esc(`Check it off in Habits: ${SITE_URL}/today`)}`,
    `URL:${SITE_URL}/today`,
    "TRANSP:TRANSPARENT",
    "BEGIN:VALARM",
    "ACTION:DISPLAY",
    `DESCRIPTION:${esc(text)}`,
    "TRIGGER:PT0M",
    "END:VALARM",
    "END:VEVENT",
    "END:VCALENDAR",
  ];
  return lines.map(fold).join("\r\n") + "\r\n";
}

/** The same reminder as a Google Calendar link (times are read in the calendar's own time zone). */
export function googleLink(h: Habit, time: string): string {
  const { rrule, first } = rule(h);
  const { start, end } = span(first, time);
  const q = new URLSearchParams({
    action: "TEMPLATE",
    text: h.kind === "avoid" ? `Still going without: ${h.name}` : h.name,
    details: `Check it off in Habits: ${SITE_URL}/today`,
    dates: `${start}/${end}`,
    recur: `RRULE:${rrule}`,
  });
  return `https://calendar.google.com/calendar/render?${q}`;
}

/** Which days the reminder repeats on, in words. */
export function reminderRepeats(h: Habit): string {
  const s = h.schedule;
  if (h.kind !== "avoid" && s.type === "days") return "on the habit's days";
  if (h.kind !== "avoid" && s.type === "every") return `every ${s.n} days`;
  if (h.kind !== "avoid" && s.type === "weekly") return "every day, since the days are up to you";
  return "every day";
}

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