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 5af1a249bbbef1db1b4c5ec8
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 Subs hands reminders to the person's calendar instead: an all-day event a few days before
// each renewal (repeating with the billing cycle), or before a free trial ends, as an .ics file or a
// Google Calendar link.
import { SITE_URL } from "../../functions/lib/config";
import { addDays, dayKey, fmtDay } from "./dates";
import { money } from "./money";
import { cycleLabel, nextCharge, onTrial, type Sub } from "./subs";
interface Plan { start: string; rrule: string | null; title: string; details: string }
/** The first reminder day (never in the past) and how it repeats. Null when there is nothing to remind. */
function plan(s: Sub, lead: number, currency: string, today = dayKey()): Plan | null {
if (s.deleted || s.cancelled) return null;
const price = `${money(s.amount, currency)} ${cycleLabel(s)}`;
const manage = s.url ? ` Manage or cancel it at ${s.url}.` : "";
if (onTrial(s, today)) {
return {
start: addDays(s.next, -lead) < today ? today : addDays(s.next, -lead),
rrule: null,
title: `${s.name} free trial ends ${fmtDay(s.next, today, { month: "short", day: "numeric" })}`,
details: `Your free trial of ${s.name} ends on ${fmtDay(s.next, today, { weekday: "long", month: "long", day: "numeric" })}. After that it costs ${price}. Cancel before then if you do not want it.${manage} Tracked in Subs: ${SITE_URL}/s/${s.id}`,
};
}
let charge = nextCharge(s, today);
if (addDays(charge, -lead) < today) charge = nextCharge(s, addDays(charge, 1));
let start = addDays(charge, -lead);
let when = `in ${lead} day${lead === 1 ? "" : "s"}`;
// Months without a 29th, 30th or 31st would skip a monthly reminder, so those come on the 28th.
if ((s.unit === "month" || s.unit === "year") && Number(start.slice(8, 10)) > 28) {
start = `${start.slice(0, 8)}28`;
when = "in a few days";
}
const freq = { day: "DAILY", week: "WEEKLY", month: "MONTHLY", year: "YEARLY" }[s.unit];
return {
start,
rrule: `FREQ=${freq}${s.every > 1 ? `;INTERVAL=${s.every}` : ""}`,
title: `${s.name} renews ${when}: ${money(s.amount, currency)}`,
details: `${s.name} renews ${when}, for ${price}. Still using it? If not, cancel before then.${manage} Tracked in Subs: ${SITE_URL}/s/${s.id}`,
};
}
const esc = (t: string) => t.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r\n|\r|\n/g, "\\n");
const compact = (key: string) => key.replace(/-/g, "");
/** Folds long lines at 75 octets, as the iCalendar format asks. */
function fold(line: string): string {
const enc = new TextEncoder();
if (enc.encode(line).length <= 75) return line;
const out: string[] = [];
let cur = "";
let size = 0;
for (const ch of line) {
const n = enc.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 ");
}
/** One calendar file with a reminder for each subscription given. */
export function remindersIcs(subs: Sub[], lead: number, currency: string): string {
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+/, "");
const lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//OpenApps//Subs//EN", "CALSCALE:GREGORIAN", "METHOD:PUBLISH", "X-WR-CALNAME:Subscription reminders"];
for (const s of subs) {
const p = plan(s, lead, currency);
if (!p) continue;
lines.push(
"BEGIN:VEVENT",
// The same id every time, so downloading again updates the reminder instead of adding a second one.
`UID:${p.rrule ? "renewal" : "trial"}-${s.id}@subs-free.openapps.xyz`,
`DTSTAMP:${stamp}`,
`DTSTART;VALUE=DATE:${compact(p.start)}`,
`DTEND;VALUE=DATE:${compact(addDays(p.start, 1))}`,
...(p.rrule ? [`RRULE:${p.rrule}`] : []),
`SUMMARY:${esc(p.title)}`,
`DESCRIPTION:${esc(p.details)}`,
`URL:${SITE_URL}/s/${s.id}`,
"TRANSP:TRANSPARENT",
"BEGIN:VALARM",
"ACTION:DISPLAY",
`DESCRIPTION:${esc(p.title)}`,
"TRIGGER;RELATED=START:PT9H",
"END:VALARM",
"END:VEVENT",
);
}
lines.push("END:VCALENDAR");
return lines.map(fold).join("\r\n") + "\r\n";
}
/** The same reminder as a Google Calendar link. Null when there is nothing to remind. */
export function googleLink(s: Sub, lead: number, currency: string): string | null {
const p = plan(s, lead, currency);
if (!p) return null;
const q = new URLSearchParams({ action: "TEMPLATE", text: p.title, details: p.details, dates: `${compact(p.start)}/${compact(addDays(p.start, 1))}` });
if (p.rrule) q.set("recur", `RRULE:${p.rrule}`);
return `https://calendar.google.com/calendar/render?${q}`;
}
/** When the first reminder comes, in words. */
export function firstReminder(s: Sub, lead: number, currency: string): string | null {
const p = plan(s, lead, currency);
return p ? fmtDay(p.start) : null;
}
Change history
Point store links to openapps.free
Bind agent address in policy
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync