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/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
Bind agent address in policy
Use OpenApp service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync