subs-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 1ae4feb2bebf
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.
← 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 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
1ae4feb2be · 18 hours ago
Bind agent address in policy
99bbab3f0c · 19 hours ago
Use OpenApps service URLs and add contribution strip
781c3c23e5 · 19 hours ago
Bounded source sync
048e3257a5 · 1 day ago
Bounded source sync
7a358a3be6 · 1 day ago
Bounded source sync
0c833fcb0e · 1 day ago