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
| File | Role |
|---|---|
functions/_lib/programs-ics.ts | regenerateProgramsIcs(env) — paginated Notion query, type-agnostic property extraction, ICS serialization |
functions/api/cron/regenerate-programs-ics.ts | POST endpoint; bearer-gated; 502 on rebuild failure |
functions/scheduled.ts | Workers-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).401on 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
304instead of re-downloading every run. - Stable timestamps:
DTSTAMPis derived from each event’slast_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), notEvent End
Event Endis computed and cannot be edited directly.Duration (hrs)controls event length. A regression wheregetDateStart()only handled plaindateproperties caused noDTENDto be emitted, so every event rendered at the calendar client’s default length regardless of the duration set (fixed 2026-06-17).DTENDis only emitted when it is strictly afterDTSTART(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 field | Use |
|---|---|
Recurrence Day | Free text (e.g. “Mon/Wed”, “Tuesdays”) parsed into BYDAY codes plus a weekly INTERVAL. Unparseable values are skipped with a log line. |
Recurrence Time | Free-text time range, shown in the event description. |
created_time (Notion) | The immutable DTSTART anchor. |
Last Seen | Drives auto-expiry via UNTIL. |
How the VEVENT is built (functions/_lib/programs-ics.ts):
- All-day:
DTSTART;VALUE=DATE:is a bareYYYYMMDD(no time, noZ), shifted byfirstOccurrenceOnOrAfter()so the first instance lands on a realBYDAY, anchored to the immutablecreated_time(notnow()and not “Week Of”, so the bytes stay stable across runs). - Rule:
RRULE:FREQ=WEEKLY;[INTERVAL=n];BYDAY=<days>;[UNTIL=<date>]. TRANSP:TRANSPARENTso a standing banner never shows the subscriber as “busy” or blocks a timed slot.
Auto-expiry is intentional
UNTILisLast Seen + 14 days.Last Seenis 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, sinceLast Seennever 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=DATEuses the calendar date directly, without converting through a timezone.DTEND;VALUE=DATEis 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
DTENDis one day later. TRANSP:TRANSPARENTprevents 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
| Name | Type | Purpose |
|---|---|---|
NOTION_EVENTS_DB_ID | var | Notion Events database |
NOTION_TOKEN | secret | Notion API token |
CRON_BEARER_TOKEN | secret | Gates the cron endpoint |
Related
- architecture — calendar architecture
- integrations — Resend, Turnstile, Notion, RC Console, and the legacy Internal-feed proxy
- api — endpoint reference