The public programs calendar feed (programs.ics) is rebuilt from the Notion Events DB on Cloudflare and written to R2. This replaced the n8n workflow RC-Calendar-External-Generate-ICS; the serve path (functions/cal/programs.ics.ts) reads the same R2 object as before.

Flow

flowchart LR
  CRON["ihnyc-rc-cron-sync Worker<br/>every 15 min"] -->|"POST + bearer"| EP["/api/cron/<br/>regenerate-programs-ics"]
  EP --> GEN["regenerateProgramsIcs()"]
  GEN -->|"paginated query"| NOTION["Notion Events DB<br/>Include in External Feed = true"]
  GEN -->|"write only if bytes changed"| R2["R2: programs.ics"]
  R2 --> SERVE["GET /cal/programs.ics<br/>(304 when unchanged)"]

Pieces

FileRole
functions/_lib/programs-ics.tsregenerateProgramsIcs(env) — paginated Notion query, type-agnostic property extraction, ICS serialization
functions/api/cron/regenerate-programs-ics.tsPOST endpoint; bearer-gated; 502 on rebuild failure
functions/scheduled.tsWorkers-style handler; dormant on Pages (no [triggers] support), runs both crons isolated if enabled via dashboard

Why it’s pinged, not cron-triggered

Cloudflare Pages doesn’t support [triggers] in wrangler.toml, so the schedule lives in the master cron Worker ihnyc-rc-cron-sync (in the ihnyc-rc-landing repo), which POSTs this endpoint every 15 minutes.

Auth + idempotency

  • Auth: bearer token, constant-time compared against CRON_BEARER_TOKEN (the same secret the renewal-warnings cron uses). 401 on any mismatch.
  • Idempotent: the R2 write is skipped when the freshly built bytes match what’s stored, so re-pinging is cheap and the object’s etag is stable — clients keep getting 304 instead of re-downloading every run.
  • Stable timestamps: DTSTAMP is derived from each event’s last_edited_time, not the run time, so unchanged events produce identical bytes across runs.

Event duration (Event End is a Notion formula)

The Events DB does not store an end timestamp directly. Editors set a number in Duration (hrs); Event End is a Notion formula = Event Start + Duration (hrs) (defaulting to 1 hour when Duration is blank). The ICS generator reads Event End and emits DTEND.

Because Event End is a formula, the property extractor must resolve formula-typed dates, not just plain date properties:

// functions/_lib/programs-ics.ts — getDateStart()
if (prop.type === "date") return prop.date?.start ?? null;
if (prop.type === "formula" && prop.formula?.type === "date") return prop.formula.date?.start ?? null;

Set duration via Duration (hrs), not Event End

Event End is computed and cannot be edited directly. Duration (hrs) controls event length. A regression where getDateStart() only handled plain date properties caused no DTEND to be emitted, so every event rendered at the calendar client’s default length regardless of the duration set (fixed 2026-06-17). DTEND is only emitted when it is strictly after DTSTART (RFC 5545).

Recurring events (all-day RRULE banners)

Standing programs (the weekly recurring offerings delivered as a Canva graphic in the newsletter and parsed by happenings-ingest) render as all-day banner events with a weekly RRULE, so they pin to the top of the day in a calendar client instead of taking a timed slot.

A row is treated as recurring when its Notion Section select is Recurring Event. The generator then reads:

Notion fieldUse
Recurrence DayFree text (e.g. “Mon/Wed”, “Tuesdays”) parsed into BYDAY codes plus a weekly INTERVAL. Unparseable values are skipped with a log line.
Recurrence TimeFree-text time range, shown in the event description.
created_time (Notion)The immutable DTSTART anchor.
Last SeenDrives auto-expiry via UNTIL.

How the VEVENT is built (functions/_lib/programs-ics.ts):

  • All-day: DTSTART;VALUE=DATE: is a bare YYYYMMDD (no time, no Z), shifted by firstOccurrenceOnOrAfter() so the first instance lands on a real BYDAY, anchored to the immutable created_time (not now() and not “Week Of”, so the bytes stay stable across runs).
  • Rule: RRULE:FREQ=WEEKLY;[INTERVAL=n];BYDAY=<days>;[UNTIL=<date>].
  • TRANSP:TRANSPARENT so a standing banner never shows the subscriber as “busy” or blocks a timed slot.

Auto-expiry is intentional

UNTIL is Last Seen + 14 days. Last Seen is refreshed only on ingest (weekly), so a program that stops appearing in the newsletter stops getting refreshed and the series self-expires about two weeks later. That same property keeps the 15-minute cron’s output byte-stable, since Last Seen never changes between weekly ingests.

One-off all-day events

A non-recurring row whose Notion All-day checkbox is enabled also renders as a banner instead of a timed block.

  • DTSTART;VALUE=DATE uses the calendar date directly, without converting through a timezone.
  • DTEND;VALUE=DATE is exclusive, as required by RFC 5545. A one-day event ends on the following date.
  • For a Notion date range, the range end is treated as the last included day and the emitted DTEND is one day later.
  • TRANSP:TRANSPARENT prevents an informational all-day banner from marking the resident busy.

This is separate from recurring programs: one-off all-day events have no RRULE and use the event’s own date or date range.

Config

NameTypePurpose
NOTION_EVENTS_DB_IDvarNotion Events database
NOTION_TOKENsecretNotion API token
CRON_BEARER_TOKENsecretGates the cron endpoint
  • architecture — calendar architecture
  • integrations — Resend, Turnstile, Notion, RC Console, and the legacy Internal-feed proxy
  • api — endpoint reference