Skip to content

feat(programs): "Add to calendar" export for program events#952

Open
jee7s wants to merge 1 commit into
mainfrom
feat/program-calendar-export
Open

feat(programs): "Add to calendar" export for program events#952
jee7s wants to merge 1 commit into
mainfrom
feat/program-calendar-export

Conversation

@jee7s

@jee7s jee7s commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What & why

Signed-in users viewing a program had no way to get its sessions into their own
calendar — the Event rows carry name/startAt/endAt/description but never
surfaced outside staff views. This adds an "Add to calendar" affordance on the
program detail page:

  • a program-level .ics (hand-rolled RFC 5545, no new dependency) with all
    the program's events, and
  • per-event Google Calendar template links in the schedule list.

Design doc: checkin-app/docs/designs/PROGRAM_CALENDAR_EXPORT.md.

Decisions (from the product interview)

  • Mechanism: ICS download + Google Calendar template links only. No Google
    API credentials, no subscribable live feed (deferred — see the doc §7).
  • Placement: an affordance on /programs/[id] for signed-in users who can
    see the program (not a page/email/kiosk surface).
  • Scope: the program's Event rows — one .ics per program + per-event links.

Authorization posture

The export is gated like the program's visibility, not its roster. The
catalog route GET /api/programs/[id] already returns the program's events to
any caller who clears its visibility gate (event fields are @sensitivity:public),
so the schedule is public-catalog data. The new route:

  • reads only Program scalars + Event rows — no ProgramParticipant /
    ProgramVolunteer / RSVP / Visit — so there is no roster to leak and it
    stays off routeAuthDrift's EDGE_INCLUDE_ALLOWLIST by construction;
  • uses withAuth (mandatory session → 401 anon, 403 kiosk);
  • mirrors the program route's orgMemberOnly gate (member-only schedules go only
    to the lead mentor / sysadmin / board / active member; others get 403).

One deliberate simplification (documented in the route header + doc §4): the
program route also treats core volunteers as privileged via a ProgramVolunteer
read; this route omits that branch to stay roster-free, since a core volunteer who
isn't also a member is rare and still has the per-event Google links / an admin's copy.

Implementation

  • checkin-app/src/lib/calendar/ics.tsbuildIcs() (VCALENDAR/VEVENT, stable
    UID, DTSTAMP, DTSTART/DTEND in UTC, SUMMARY/DESCRIPTION/LOCATION,
    CRLF endings, TEXT escaping of \ ; , + newlines, 75-char folding, missing-end
    • all-day handling) and a client-safe googleCalendarEventUrl(). Pure, no deps.
  • checkin-app/src/app/api/programs/[id]/calendar.ics/route.ts — the download route.
  • checkin-app/src/app/programs/[id]/page.tsx — a Schedule card with the .ics
    download button and per-event Google Calendar links (signed-in + has-events).

No schema change, no migration, no new dependency, no external call.

Testing

  • 16 unit tests (src/lib/calendar/ics.test.ts): escaping, CRLF, structure,
    UTC formatting, missing-end, all-day (VALUE=DATE), folding, Google URL.
  • 8 integration tests (src/app/__tests__/programCalendarICS.integration.test.ts):
    401 anon; text/calendar + Content-Disposition attachment for a public
    program; empty-program VCALENDAR; member-only gate (403 non-member, 200 for
    member/admin/lead); 404 missing / 400 unparseable id.
  • routeAuthDrift guard green (route needs no allowlist entry). Full pre-commit
    suite green (1695 tests), tsc --noEmit and eslint --max-warnings 0 clean.

🤖 Generated with Claude Code

Signed-in users viewing a program can export its event schedule: a
program-level .ics (hand-rolled RFC 5545, no new dependency) plus per-event
Google Calendar template links on the detail page.

- lib/calendar/ics.ts: VCALENDAR/VEVENT builder (stable UID, DTSTAMP,
  DTSTART/DTEND in UTC, SUMMARY/DESCRIPTION/LOCATION, CRLF endings, TEXT
  escaping, 75-char folding, missing-end + all-day handling) and a
  client-safe googleCalendarEventUrl().
- GET /api/programs/[id]/calendar.ics via withAuth: reads only Program +
  Event rows (no roster), gated exactly like the program's visibility
  (member-only mirrors GET /api/programs/[id]), so it stays off the
  routeAuthDrift EDGE_INCLUDE_ALLOWLIST by construction.
- programs/[id] page: a Schedule card with the .ics download and per-event
  Google links, shown to signed-in viewers when the program has events.

No schema change. Design: docs/designs/PROGRAM_CALENDAR_EXPORT.md.
Tests: 16 unit (ICS builder) + 8 integration (route auth + content).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jee7s jee7s added the post-first-release Deferred until after the first production release label Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

post-first-release Deferred until after the first production release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant