From b6ad3d50d4ca355531ee9a0c76ae0b7c9a9b1d13 Mon Sep 17 00:00:00 2001 From: GrittyBot <30755786+codeforphilly-bot@users.noreply.github.com> Date: Wed, 11 Nov 2026 04:37:51 +0000 Subject: [PATCH 1/7] docs(specs): member calendar feed and one Add to calendar control A member can subscribe to only the events they RSVP'd to, and the two calendar text links become one menu with Google, Outlook.com and .ics. RSVPs are already public, so the member feed needs no token. Co-Authored-By: Claude Fable 5.2 --- specs/api/events.md | 15 ++++++++++++++- specs/behaviors/events.md | 17 +++++++++++++++-- specs/screens/event-detail.md | 9 +++++---- specs/screens/events-index.md | 8 +++++--- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/specs/api/events.md b/specs/api/events.md index d107929..f2a3a9c 100644 --- a/specs/api/events.md +++ b/specs/api/events.md @@ -7,7 +7,7 @@ Events and RSVPs. Rules in [behaviors/events.md](../behaviors/events.md); record | Method | Path | Auth | Summary | | ------ | ---- | ---- | ------- | | `GET` | `/api/events` | public | List events, upcoming by default. | -| `GET` | `/api/events/feed.ics` | public | iCalendar subscription feed. | +| `GET` | `/api/events/feed.ics` | public | iCalendar subscription feed: every event, or one member's with `?attendee=`. | | `GET` | `/api/events/:slug` | public | Event detail with the caller's RSVP state. | | `GET` | `/api/events/:slug/ics` | public | One event as an `.ics` file. | | `GET` | `/api/events/:slug/rsvps` | public | Going attendees. | @@ -50,8 +50,21 @@ Drafts are included only when the caller is staff; cancelled events are always i One `VCALENDAR` (`METHOD:PUBLISH`, `X-WR-CALNAME: Code for Philly`, `X-WR-TIMEZONE: America/New_York`) containing a `VEVENT` per `scheduled` or `cancelled` event with `endsAt ≥ now − 90 days`, built per [behaviors/events.md#calendar-files](../behaviors/events.md#calendar-files). Never includes drafts. +### Query + +| Param | Type | Notes | +| ----- | ---- | ----- | +| `attendee` | person slug | Optional. Narrows the feed to events this person has an RSVP on ([behaviors/events.md#calendar-files](../behaviors/events.md#calendar-files), the member feed). Public, no session: RSVPs are public. | + +With `attendee`, the calendar is named `X-WR-CALNAME: Code for Philly: 's events` and the `Content-Disposition` filename is `code-for-philly-.ics`; everything else (window, statuses, `VEVENT` contents, drafts never included) is identical. A person with no RSVPs gets a valid, empty `VCALENDAR`, not an error, so a subscription made before the first RSVP keeps working. + `Cache-Control: public, max-age=300`. Not rate-limited differently from other reads; calendar clients poll hourly or slower. +### Errors + +- `404 not_found` — `attendee` names no person, or a deactivated one. JSON envelope, per the `.ics` exception above. +- `422 validation_failed` — `attendee` is present but empty. + ## GET /api/events/:slug ### Response — 200 diff --git a/specs/behaviors/events.md b/specs/behaviors/events.md index 2b950d7..ea4f128 100644 --- a/specs/behaviors/events.md +++ b/specs/behaviors/events.md @@ -127,13 +127,26 @@ Every scheduled or cancelled event is representable as an iCalendar `VEVENT`: `icsSequence` starts at 0 and increments whenever any of `title`, `startsAt`, `endsAt`, `venueName`, `venueAddress`, `onlineUrl` changes, and on cancel. A client that already holds the earlier `UID` replaces its copy when it sees a higher `SEQUENCE`; this is what makes the "event updated" email actually move the entry on someone's calendar. -Three delivery forms, all `METHOD:PUBLISH`: +Four delivery forms, all `METHOD:PUBLISH`: 1. **Attachment** on the confirmation, update, and cancellation emails (`.ics`, `text/calendar`). 2. **Download** — `GET /api/events/:slug/ics` behind the "Download .ics" control. 3. **Subscription feed** — `GET /api/events/feed.ics`, one `VCALENDAR` of every scheduled and cancelled event from 90 days ago onward, with `X-WR-CALNAME: Code for Philly`. The index page offers it as a `webcal://` link so a member can subscribe once and never look at the site for dates again. Drafts never appear. +4. **Member feed** — `GET /api/events/feed.ics?attendee=`, the same feed narrowed to the events that member has RSVP'd to, with `X-WR-CALNAME: Code for Philly: 's events`. It is for the member who wants only the nights they said yes to on their calendar: RSVP on the site and the event appears, cancel the RSVP and it drops off at the calendar's next poll. The same window and `VEVENT` rules apply, so a cancelled event the member had RSVP'd to stays in the feed as `STATUS:CANCELLED` and their calendar marks it rather than silently losing it. -The "Add to Google Calendar" control is a prefilled `calendar.google.com/calendar/render?action=TEMPLATE` link carrying title, UTC `dates`, plain-text details with the canonical URL, and the same `LOCATION` string. It is a convenience link, not a feed. + The member feed is a **public URL with no token**. [RSVPs are public](#rsvps-are-public): the attendee list on every event page already says who is going, so the feed reveals nothing the site does not, and a tokenless URL is one a calendar app can poll without a session. A deactivated person has no feed (`404`). The feed is keyed by slug like every other URL; a member who renames their handle subscribes again from `/events`. + +### Add to calendar + +Every scheduled event that has not ended offers one **Add to calendar** control with three choices. All three are conveniences for a single event, not feeds: + +| Choice | What it is | +| ------ | ---------- | +| Google Calendar | a prefilled `calendar.google.com/calendar/render?action=TEMPLATE` link carrying title, UTC `dates`, plain-text details ending in the canonical URL, and the same `LOCATION` string | +| Outlook.com | a prefilled `outlook.live.com/calendar/0/deeplink/compose` link (`path=/calendar/action/compose`, `rru=addevent`) carrying `subject`, UTC `startdt` / `enddt`, the same plain-text `body`, and `location` | +| Apple Calendar / .ics | the event's `.ics` download (`icsUrl`), which Apple Calendar, desktop Outlook and every other client open | + +The plain-text details are the description (markdown stripped) followed by the canonical URL where the page holds the description (event detail), and the canonical URL alone where it does not (event cards). The control is hidden for drafts, for cancelled events, and once the event has ended; the `.ics` download route itself keeps answering for cancelled and past events, because the emails link to it. ## Reminders diff --git a/specs/screens/event-detail.md b/specs/screens/event-detail.md index 6c93525..3ec362e 100644 --- a/specs/screens/event-detail.md +++ b/specs/screens/event-detail.md @@ -51,7 +51,7 @@ Across the top of the content area, before the title: Clicking "I'm going" calls `PUT …/rsvp` with no body and swaps to the going state optimistically; on `409 rsvp_closed` it reloads the event. A toast confirms "You're going — check your email for a calendar invite." (or "…we couldn't send the calendar email, use Add to calendar below" when `confirmationDelivered = false`). -2. **Add to calendar** — a row of two links: "Add to Google Calendar" (prefilled template link, new tab) and "Download .ics" (`icsUrl`). Hidden for drafts. +2. **Add to calendar** — one outline button, "Add to calendar", that opens a menu of three choices per [behaviors/events.md#add-to-calendar](../behaviors/events.md#add-to-calendar): "Google Calendar" and "Outlook.com" (prefilled links, new tab) and "Apple Calendar / .ics" (downloads `icsUrl`). Shown only while the event is `scheduled` and has not ended; hidden for drafts, cancelled and past events. 3. **About** — `descriptionHtml`. Omitted entirely when the description is empty. @@ -82,8 +82,9 @@ Across the top of the content area, before the title: | RSVP | `PUT /api/events/:slug/rsvp`; confirmation email | "I'm going" | | Withdraw | `DELETE /api/events/:slug/rsvp` | "Cancel RSVP" + confirm | | Answer / change the follow-up | `PUT /api/events/:slug/rsvp` with `plan` (+ `projectSlug`) | Follow-up choices | -| Add to Google Calendar | Opens calendar.google.com template | Link | -| Download .ics | Downloads the file | Link | +| Add to calendar → Google Calendar | Opens calendar.google.com template | Menu item | +| Add to calendar → Outlook.com | Opens the outlook.live.com compose deeplink | Menu item | +| Add to calendar → Apple Calendar / .ics | Downloads the file | Menu item | | Publish | `POST /api/events/:slug/publish` | Draft banner button (staff) | | Cancel | `POST /api/events/:slug/cancel` with optional note; attendees emailed | Staff control + dialog | | Delete | `DELETE /api/events/:slug`; navigates to `/events` | Staff control + confirm; disabled with tooltip "Cancel instead — people have RSVP'd" when `canDelete = false` | @@ -93,7 +94,7 @@ Across the top of the content area, before the title: **To here:** events index, home page upcoming-events section, emails (confirmation, reminders, update, cancellation link here), the `URL` property of the `.ics`, project pages' attendee groups, legacy `/events/` URLs. -**From here:** `/events` (breadcrumb), `/events/series/` ("Part of"), `/events//edit`, `/events/new?from=`, `/projects/` (attendee groups, picker), `/members/` (avatars), `/login?return=…`, external: maps links, `onlineUrl`, `meetupUrl`, Google Calendar. +**From here:** `/events` (breadcrumb), `/events/series/` ("Part of"), `/events//edit`, `/events/new?from=`, `/projects/` (attendee groups, picker), `/members/` (avatars), `/login?return=…`, external: maps links, `onlineUrl`, `meetupUrl`, Google Calendar, Outlook.com. ## Authorization diff --git a/specs/screens/events-index.md b/specs/screens/events-index.md index 34a62ec..760b9b5 100644 --- a/specs/screens/events-index.md +++ b/specs/screens/events-index.md @@ -20,7 +20,7 @@ Rules in [behaviors/events.md](../behaviors/events.md). - H1 "Events" - Lead paragraph: "Code for Philly meets in person every month. RSVP so we know you're coming, and subscribe to get every event on your own calendar." - Right-aligned controls: - - "Subscribe to calendar" — a `webcal:///api/events/feed.ics` link with a copy-to-clipboard fallback for browsers that don't register the scheme + - "Subscribe to calendar" — signed out, a `webcal:///api/events/feed.ics` link with a "Copy feed URL" copy-to-clipboard fallback for browsers that don't register the scheme. Signed in, the same label is a button that opens a menu with two feeds, each as a `webcal://` link followed by its own copy fallback: **All events** (the site feed) and **Events I'm going to** (the member feed, `feed.ics?attendee=`, per [behaviors/events.md#calendar-files](../behaviors/events.md#calendar-files)) - "New event" button → `/events/new` — staff only ### View switch @@ -37,7 +37,8 @@ Events grouped under month headings (`November 2026`), in the order the API retu - Where line: `venueName` if set; else "Online" if `onlineUrl`; else "Location TBA". A venue with both renders `venueName · Online option` - Attendance: "14 going"; when the viewer has an RSVP, "You're going ✓" replaces the count's lead ("You're going ✓ · 14 going") - Badges: "Cancelled" when `status = cancelled` (card otherwise rendered muted); "Draft" when `status = draft` (staff only see these) -- The whole card links to the detail page +- **Add to calendar**, on the right edge of cards for `scheduled` events that have not ended: an icon button (accessible name "Add to calendar") opening the same three-choice menu as the detail page ([behaviors/events.md#add-to-calendar](../behaviors/events.md#add-to-calendar)). Absent on cancelled, draft and past cards +- The rest of the card links to the detail page ### Pagination @@ -55,7 +56,8 @@ Events grouped under month headings (`November 2026`), in the order the API retu | ------ | ------- | --------- | | Open an event | Navigation | Card click | | Switch Upcoming / Past | Navigation | Tab links | -| Subscribe to calendar | Opens the calendar app via `webcal://`, or copies the `https://` URL | Header control | +| Subscribe to calendar | Opens the calendar app via `webcal://`, or copies the `https://` URL; signed-in members pick all events or only the ones they RSVP'd to | Header control | +| Add to calendar | Opens Google Calendar or Outlook.com prefilled, or downloads the `.ics` | Card menu | | New event | Navigation to `/events/new` | Staff button | ## Navigation From 6b5c0fb8590eeae3d49657b66e3138a2caf689f1 Mon Sep 17 00:00:00 2001 From: GrittyBot <30755786+codeforphilly-bot@users.noreply.github.com> Date: Wed, 11 Nov 2026 04:37:51 +0000 Subject: [PATCH 2/7] chore(plans): add calendar-member-feed, in-progress Bridges the calendar spec change to code. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> --- plans/calendar-member-feed.md | 104 ++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 plans/calendar-member-feed.md diff --git a/plans/calendar-member-feed.md b/plans/calendar-member-feed.md new file mode 100644 index 0000000..bcd8a32 --- /dev/null +++ b/plans/calendar-member-feed.md @@ -0,0 +1,104 @@ +--- +status: in-progress +depends: [events-core] +specs: + - specs/behaviors/events.md + - specs/api/events.md + - specs/screens/event-detail.md + - specs/screens/events-index.md +--- + +# Plan: a calendar feed per member, and one Add to calendar button + +## Scope + +The site already publishes one subscription feed of every event and two +text links on the event page ("Add to Google Calendar", "Download .ics"). +Two gaps, raised in #website-relaunch as "ical subscription feed for +users, add to calendar button for events": + +- A member who subscribes gets every event, not the ones they said yes + to. Add a **member feed**: the same feed narrowed to one person's RSVPs. +- Adding a single event to a calendar is two unlabelled-as-a-group links + on one screen only. Replace them with **one "Add to calendar" button** + whose menu offers Google Calendar, Outlook.com and Apple Calendar / + .ics, and put the same menu on event cards. + +**In:** `GET /api/events/feed.ics?attendee=<slug>`; the `AddToCalendarMenu` +component on event detail and on the full event cards (events index, +series page); the signed-in Subscribe menu on `/events`; the Outlook.com +link builder. **Out:** private or tokenized feeds (RSVPs are public, so +there is nothing to protect); a feed link on the member's profile or +account page; the home strip's compact cards and its "Subscribe" link, +which keep their one RSVP action and the site feed; any change to the +emails. + +## Implements + +- [behaviors/events.md](../specs/behaviors/events.md#calendar-files) — + the member feed as the fourth delivery form, and the new + [Add to calendar](../specs/behaviors/events.md#add-to-calendar) section. +- [api/events.md](../specs/api/events.md#get-apieventsfeedics) — the + `attendee` query parameter, calendar name, filename and errors. +- [screens/event-detail.md](../specs/screens/event-detail.md) — the + button and its menu in place of the two links. +- [screens/events-index.md](../specs/screens/events-index.md) — the + signed-in Subscribe menu and the card's Add to calendar control. + +## Approach + +1. **Feed.** `EventService.memberFeed(slug)` walks `rsvpsByPerson`, maps + each RSVP to its event and applies the same window as `feedEvents` + (both now share `#feedWindow`). `buildFeedIcs` takes the calendar name. + The route keeps one path, `feed.ics`, and branches on `attendee`: + unknown or deactivated person is a 404 envelope, empty value a 422. +2. **Menu.** `apps/web/src/components/AddToCalendarMenu.tsx` on the + existing shadcn `dropdown-menu`: a labelled outline button on detail, + an icon button on cards. `canAddToCalendar` (scheduled and not ended) + is the single visibility rule. `outlookCalendarUrl` joins + `googleCalendarUrl` in `lib/event-time.ts`. +3. **Cards.** `EventCard` renders the menu for `variant="full"` in the + same above-the-stretched-link slot the home strip's RSVP action uses. +4. **Subscribe.** `calendarFeedUrls(attendeeSlug?)` builds either feed's + `webcal://` and `https://` forms; `SubscribeControl` becomes a menu + when a viewer is signed in. +5. **Tests.** API: the member feed's contents, headers, withdrawal, + empty calendar, 404s and 422. Web: the menus on detail, cards and the + Subscribe control; the Outlook link builder. + +## Validation + +- [ ] `GET /api/events/feed.ics?attendee=<slug>` with no session lists + exactly the scheduled and cancelled events that person RSVP'd to inside + the 90-day window, named `Code for Philly: <fullName>'s events`. +- [ ] Withdrawing an RSVP removes the event from the member feed; a + member with no RSVPs gets an empty, valid calendar. +- [ ] An unknown or deactivated `attendee` answers `404 not_found` in the + JSON envelope; an empty one `422`. +- [ ] The site feed without `attendee` is unchanged. +- [ ] Event detail shows one "Add to calendar" button whose menu has + Google Calendar, Outlook.com and Apple Calendar / .ics; it is absent on + drafts, cancelled and past events. +- [ ] Full event cards for scheduled, upcoming events carry the same + menu; cancelled, draft and past cards and the home strip do not. +- [ ] Signed out, `/events` offers the site feed with "Copy feed URL"; + signed in, a menu offers "All events" and "Events I'm going to", each + with a copy fallback. +- [ ] `npm run type-check && npm run lint && npm test` clean. +- [ ] Checked in a browser: both menus open, the Google and Outlook.com + links land on a prefilled event, the `.ics` opens in a calendar app, and + a calendar app subscribes to the member feed over `webcal://`. + +## Risks / unknowns + +- **Outlook.com deeplink.** The `outlook.live.com/calendar/0/deeplink/compose` + format is not a documented contract. The builder percent-encodes spaces + (Outlook shows `+` literally) and sends line breaks as `<br>`; only a + browser check against a real Outlook.com account confirms the result. +- **Handle renames.** The member feed is keyed by slug and the API does + not follow slug history, so a calendar subscribed to an old handle stops + updating after a rename. The spec says the member subscribes again. + +## Notes + +## Follow-ups From 675aadfc8953de0eb9d59761dd18c3bcb323bbb2 Mon Sep 17 00:00:00 2001 From: GrittyBot <30755786+codeforphilly-bot@users.noreply.github.com> Date: Wed, 11 Nov 2026 04:37:51 +0000 Subject: [PATCH 3/7] feat(api): member calendar feed at feed.ics?attendee=<slug> The same 90-day window and VEVENT rules as the site feed, narrowed to the events one member has an RSVP on, under its own calendar name. Unknown or deactivated people answer 404. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> --- apps/api/src/lib/ics.ts | 13 +++++-- apps/api/src/routes/events.ts | 33 +++++++++++++++--- apps/api/src/services/event.ts | 28 ++++++++++++--- apps/api/tests/events.test.ts | 63 ++++++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 12 deletions(-) diff --git a/apps/api/src/lib/ics.ts b/apps/api/src/lib/ics.ts index 6323662..4f96a8f 100644 --- a/apps/api/src/lib/ics.ts +++ b/apps/api/src/lib/ics.ts @@ -145,12 +145,19 @@ export function buildEventIcs(event: Event, siteHost: string): string { return calendar(vevent(event, siteHost)); } -/** The subscription feed: many events, named calendar. */ -export function buildFeedIcs(events: readonly Event[], siteHost: string): string { +export const SITE_FEED_NAME = 'Code for Philly'; + +/** Calendar name of one member's feed (the events they RSVP'd to). */ +export function memberFeedName(fullName: string): string { + return `${SITE_FEED_NAME}: ${fullName}'s events`; +} + +/** A subscription feed: many events, named calendar. */ +export function buildFeedIcs(events: readonly Event[], siteHost: string, name: string = SITE_FEED_NAME): string { const body: string[] = []; for (const e of events) body.push(...vevent(e, siteHost)); return calendar(body, [ - 'X-WR-CALNAME:Code for Philly', + `X-WR-CALNAME:${escapeText(name)}`, 'X-WR-TIMEZONE:America/New_York', ]); } diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 601edf4..140403b 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -22,7 +22,7 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'; import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js'; import { formatEventInstant } from '../lib/event-time.js'; -import { buildEventIcs, buildFeedIcs, googleCalendarUrl } from '../lib/ics.js'; +import { buildEventIcs, buildFeedIcs, googleCalendarUrl, memberFeedName } from '../lib/ics.js'; import { ok, paginated } from '../lib/response.js'; import { collectAttendees, eventEmailFacts } from '../services/event-notify.js'; import type { EventChange, EventInput, RsvpInput } from '../services/event.write.js'; @@ -140,11 +140,34 @@ export async function eventRoutes(fastify: FastifyInstance): Promise<void> { // GET /api/events/feed.ics — registered before /:slug --------------------- fastify.get( '/api/events/feed.ics', - { schema: { tags: ['events'], summary: 'iCalendar subscription feed' } }, - async (_request, reply) => { - const ics = buildFeedIcs(fastify.services.events.feedEvents(), siteHost()); + { + schema: { + tags: ['events'], + summary: "iCalendar subscription feed: every event, or one member's with ?attendee=", + querystring: { + type: 'object', + properties: { attendee: { type: 'string' } }, + additionalProperties: false, + }, + }, + }, + async (request, reply) => { + const { attendee } = request.query as { attendee?: string }; + let ics: string; + let filename = 'code-for-philly.ics'; + if (attendee === undefined) { + ics = buildFeedIcs(fastify.services.events.feedEvents(), siteHost()); + } else { + if (attendee.trim() === '') { + throw new ApiValidationError('Invalid attendee', { attendee: 'must be a person slug' }); + } + const feed = fastify.services.events.memberFeed(attendee); + if (!feed) throw new ApiNotFoundError(`Person '${attendee}' not found`); + ics = buildFeedIcs(feed.events, siteHost(), memberFeedName(feed.person.fullName)); + filename = `code-for-philly-${feed.person.slug}.ics`; + } return reply - .headers({ ...icsHeaders('inline; filename="code-for-philly.ics"'), 'Cache-Control': 'public, max-age=300' }) + .headers({ ...icsHeaders(`inline; filename="${filename}"`), 'Cache-Control': 'public, max-age=300' }) .send(ics); }, ); diff --git a/apps/api/src/services/event.ts b/apps/api/src/services/event.ts index cb2aeb3..ce39a62 100644 --- a/apps/api/src/services/event.ts +++ b/apps/api/src/services/event.ts @@ -5,7 +5,7 @@ * (roughly one a month), so "upcoming" and "past" are a filter + sort over * `state.events.values()` rather than a maintained sorted index. */ -import type { Event, EventRsvp } from '@cfp/shared/schemas'; +import type { Event, EventRsvp, Person } from '@cfp/shared/schemas'; import type { InMemoryState } from '../store/memory/state.js'; import { personBySlug, rsvpKey } from '../store/memory/state.js'; @@ -131,10 +131,30 @@ export class EventService { /** Events eligible for the subscription feed: scheduled or cancelled, ending within the trailing 90 days or later. */ feedEvents(now: Date = new Date()): Event[] { + return this.#feedWindow([...this.#state.events.values()], now); + } + + /** + * The member feed: the same window, narrowed to events `attendee` has an + * RSVP on. Null when the slug names no person or a deactivated one + * (specs/behaviors/events.md#calendar-files). + */ + memberFeed(attendeeSlug: string, now: Date = new Date()): { person: Person; events: Event[] } | null { + const person = personBySlug(this.#state, attendeeSlug); + if (!person || person.deletedAt) return null; + const ids = this.#state.rsvpsByPerson.get(person.slug) ?? new Set<string>(); + const events: Event[] = []; + for (const id of ids) { + const rsvp = this.#state.eventRsvps.get(id); + const event = rsvp ? this.findBySlug(rsvp.eventSlug) : undefined; + if (event) events.push(event); + } + return { person, events: this.#feedWindow(events, now) }; + } + + #feedWindow(candidates: Event[], now: Date): Event[] { const cutoff = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); - const events = [...this.#state.events.values()].filter( - (e) => e.status !== 'draft' && new Date(e.endsAt) >= cutoff, - ); + const events = candidates.filter((e) => e.status !== 'draft' && new Date(e.endsAt) >= cutoff); events.sort((a, b) => a.startsAt.localeCompare(b.startsAt)); return events; } diff --git a/apps/api/tests/events.test.ts b/apps/api/tests/events.test.ts index 00b1bae..79d1698 100644 --- a/apps/api/tests/events.test.ts +++ b/apps/api/tests/events.test.ts @@ -569,6 +569,69 @@ describe('calendar feed and legacy URLs', () => { expect(res.body).not.toContain(`/events/${ancient.slug}`); }); + it("narrows the feed to one member's RSVPs with ?attendee=", async () => { + const going = await createEvent(hackNight(7, { title: 'Going' })); + const notGoing = await createEvent(hackNight(14, { title: 'Not Going' })); + const cancelled = await createEvent(hackNight(21, { title: 'Called Off' })); + const jane = await janeCookie(); + for (const slug of [going.slug, cancelled.slug]) { + const rsvp = await app!.inject({ method: 'PUT', url: `/api/events/${slug}/rsvp`, headers: { cookie: jane } }); + expect(rsvp.statusCode, rsvp.body).toBe(201); + } + const cancel = await app!.inject({ + method: 'POST', + url: `/api/events/${cancelled.slug}/cancel`, + headers: { cookie: await staffCookie(), 'content-type': 'application/json' }, + payload: {}, + }); + expect(cancel.statusCode, cancel.body).toBe(200); + + const url = `/api/events/feed.ics?attendee=${fixtures.personSlug}`; + // No cookie: the member feed is public, like the attendee list. + let res = await app!.inject({ method: 'GET', url }); + expect(res.statusCode, res.body).toBe(200); + expect(res.headers['content-type']).toContain('text/calendar'); + expect(res.headers['cache-control']).toBe('public, max-age=300'); + expect(res.headers['content-disposition']).toBe(`inline; filename="code-for-philly-${fixtures.personSlug}.ics"`); + expect(res.body).toMatch(/X-WR-CALNAME:Code for Philly: .+'s events/); + expect(res.body).toContain(`/events/${going.slug}`); + expect(res.body).toContain(`/events/${cancelled.slug}`); + expect(res.body).toContain('STATUS:CANCELLED'); + expect(res.body).not.toContain(`/events/${notGoing.slug}`); + + // Withdrawing drops the event at the next poll. + await app!.inject({ method: 'DELETE', url: `/api/events/${going.slug}/rsvp`, headers: { cookie: jane } }); + res = await app!.inject({ method: 'GET', url }); + expect(res.body).not.toContain(`/events/${going.slug}`); + + // A member with no RSVPs gets a valid empty calendar, not an error. + const empty = await app!.inject({ method: 'GET', url: `/api/events/feed.ics?attendee=${STAFF_SLUG}` }); + expect(empty.statusCode).toBe(200); + expect(empty.body).toContain('BEGIN:VCALENDAR'); + expect(empty.body).not.toContain('BEGIN:VEVENT'); + }); + + it('answers 404 for an unknown or deactivated attendee and 422 for an empty one', async () => { + const unknown = await app!.inject({ method: 'GET', url: '/api/events/feed.ics?attendee=nobody-here' }); + expect(unknown.statusCode).toBe(404); + expect(unknown.json()).toMatchObject({ success: false, error: { code: 'not_found' } }); + + const blank = await app!.inject({ method: 'GET', url: '/api/events/feed.ics?attendee=' }); + expect(blank.statusCode).toBe(422); + + const { slug } = await createEvent(hackNight(7)); + const jane = await janeCookie(); + await app!.inject({ method: 'PUT', url: `/api/events/${slug}/rsvp`, headers: { cookie: jane } }); + const deactivate = await app!.inject({ + method: 'POST', + url: `/api/people/${fixtures.personSlug}/deactivate`, + headers: { cookie: jane }, + }); + expect(deactivate.statusCode, deactivate.body).toBeLessThan(300); + const gone = await app!.inject({ method: 'GET', url: `/api/events/feed.ics?attendee=${fixtures.personSlug}` }); + expect(gone.statusCode).toBe(404); + }); + it('301s /calendar and the laddr list views and segment URLs', async () => { const cases: Array<[string, string]> = [ ['/calendar', '/events'], From 4f252f31d47de0c68d96cd0700feead34675b769 Mon Sep 17 00:00:00 2001 From: GrittyBot <30755786+codeforphilly-bot@users.noreply.github.com> Date: Wed, 11 Nov 2026 04:37:51 +0000 Subject: [PATCH 4/7] feat(web): Add to calendar menu on events, member feed in Subscribe One menu replaces the two text links on event detail and appears on full event cards. Signed-in members can subscribe to all events or to only the ones they are going to. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> --- apps/web/src/components/AddToCalendarMenu.tsx | 68 +++++++++++++++++++ apps/web/src/components/EventCard.tsx | 25 +++++-- apps/web/src/lib/event-time.ts | 35 ++++++++-- apps/web/src/screens/EventDetail.tsx | 23 ++----- apps/web/src/screens/EventsIndex.tsx | 48 ++++++++++++- apps/web/tests/EventDetail.test.tsx | 13 +++- apps/web/tests/EventsIndex.test.tsx | 46 ++++++++++++- apps/web/tests/event-time.test.ts | 31 +++++++++ 8 files changed, 255 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/components/AddToCalendarMenu.tsx diff --git a/apps/web/src/components/AddToCalendarMenu.tsx b/apps/web/src/components/AddToCalendarMenu.tsx new file mode 100644 index 0000000..a5d4e3c --- /dev/null +++ b/apps/web/src/components/AddToCalendarMenu.tsx @@ -0,0 +1,68 @@ +import { CalendarPlusIcon } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { googleCalendarUrl, isPastEvent, outlookCalendarUrl, type CalendarLinkEvent } from '@/lib/event-time'; + +interface AddToCalendarEvent extends CalendarLinkEvent { + status: 'draft' | 'scheduled' | 'cancelled'; + icsUrl: string; +} + +/** Offered only while the event is scheduled and has not ended (specs/behaviors/events.md#add-to-calendar). */ +export function canAddToCalendar(event: Pick<AddToCalendarEvent, 'status' | 'endsAt'>, now: Date = new Date()): boolean { + return event.status === 'scheduled' && !isPastEvent(event.endsAt, now); +} + +interface AddToCalendarMenuProps { + event: AddToCalendarEvent; + /** "button" is the labelled control on event detail; "icon" is the compact one on cards. */ + variant?: 'button' | 'icon'; +} + +/** + * One "Add to calendar" control with three choices: Google Calendar and + * Outlook.com as prefilled links, and the `.ics` download for Apple Calendar + * and everything else. Renders nothing when the event can't be added. + */ +export function AddToCalendarMenu({ event, variant = 'button' }: AddToCalendarMenuProps) { + if (!canAddToCalendar(event)) return null; + const origin = window.location.origin; + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + {variant === 'icon' ? ( + <Button variant="ghost" size="icon" aria-label={`Add ${event.title} to calendar`}> + <CalendarPlusIcon /> + </Button> + ) : ( + <Button variant="outline" size="sm"> + <CalendarPlusIcon /> + Add to calendar + </Button> + )} + </DropdownMenuTrigger> + <DropdownMenuContent align={variant === 'icon' ? 'end' : 'start'}> + <DropdownMenuItem asChild> + <a href={googleCalendarUrl(event, origin)} target="_blank" rel="noopener noreferrer"> + Google Calendar + </a> + </DropdownMenuItem> + <DropdownMenuItem asChild> + <a href={outlookCalendarUrl(event, origin)} target="_blank" rel="noopener noreferrer"> + Outlook.com + </a> + </DropdownMenuItem> + <DropdownMenuItem asChild> + <a href={event.icsUrl} download> + Apple Calendar / .ics + </a> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); +} diff --git a/apps/web/src/components/EventCard.tsx b/apps/web/src/components/EventCard.tsx index 0378cee..e49345e 100644 --- a/apps/web/src/components/EventCard.tsx +++ b/apps/web/src/components/EventCard.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { Link } from 'react-router'; +import { AddToCalendarMenu, canAddToCalendar } from '@/components/AddToCalendarMenu'; import type { EventSummary } from '@/lib/api'; import { eventDateBlock, formatEventTimeOnly } from '@/lib/event-time'; import { cn } from '@/lib/utils'; @@ -48,6 +49,9 @@ export function EventCard({ event, variant = 'full', action, headingLevel = 'h3' const draft = event.status === 'draft'; const going = event.viewerRsvp !== null; const date = eventDateBlock(event.startsAt); + // The index and series cards carry the menu; the home strip's compact rows + // keep their one RSVP action (specs/screens/events-index.md#list). + const showCalendar = variant === 'full' && canAddToCalendar(event); return ( <div @@ -86,16 +90,27 @@ export function EventCard({ event, variant = 'full', action, headingLevel = 'h3' <span className="text-muted-foreground">{event.rsvpCount} going</span> </p> </div> - {action && <div className="relative z-10 shrink-0">{action}</div>} + {/* Above the stretched link, so each control is its own focus target. */} + {(action || showCalendar) && ( + <div className="relative z-10 flex shrink-0 items-center gap-2"> + {action} + {showCalendar && <AddToCalendarMenu event={event} variant="icon" />} + </div> + )} </div> ); } -/** `webcal://` and `https://` forms of the subscription feed for this host. */ -export function calendarFeedUrls(): { webcal: string; https: string } { +/** + * `webcal://` and `https://` forms of a subscription feed for this host: the + * site feed, or one member's (the events they RSVP'd to) when `attendeeSlug` + * is given. + */ +export function calendarFeedUrls(attendeeSlug?: string): { webcal: string; https: string } { const { host, protocol } = window.location; + const path = `/api/events/feed.ics${attendeeSlug ? `?attendee=${encodeURIComponent(attendeeSlug)}` : ''}`; return { - webcal: `webcal://${host}/api/events/feed.ics`, - https: `${protocol === 'http:' ? 'http' : 'https'}://${host}/api/events/feed.ics`, + webcal: `webcal://${host}${path}`, + https: `${protocol === 'http:' ? 'http' : 'https'}://${host}${path}`, }; } diff --git a/apps/web/src/lib/event-time.ts b/apps/web/src/lib/event-time.ts index fc50aa5..5a9e4fd 100644 --- a/apps/web/src/lib/event-time.ts +++ b/apps/web/src/lib/event-time.ts @@ -297,14 +297,19 @@ export function eventLocationString(e: Pick<CalendarLinkEvent, 'venueName' | 've return e.onlineUrl ?? ''; } +/** Plain-text details: the description (markdown stripped) when held, then the canonical URL. */ +function calendarDetails(event: CalendarLinkEvent, origin: string): string { + const canonical = `${origin}/events/${event.slug}`; + const body = event.description ? stripMarkdown(event.description) : ''; + return body ? `${body}\n\n${canonical}` : canonical; +} + /** * Prefilled `calendar.google.com/calendar/render?action=TEMPLATE` link per - * specs/behaviors/events.md#calendar-files. + * specs/behaviors/events.md#add-to-calendar. */ export function googleCalendarUrl(event: CalendarLinkEvent, origin: string): string { - const canonical = `${origin}/events/${event.slug}`; - const body = event.description ? stripMarkdown(event.description) : ''; - const details = body ? `${body}\n\n${canonical}` : canonical; + const details = calendarDetails(event, origin); const usp = new URLSearchParams({ action: 'TEMPLATE', text: event.title, @@ -315,3 +320,25 @@ export function googleCalendarUrl(event: CalendarLinkEvent, origin: string): str if (location) usp.set('location', location); return `https://calendar.google.com/calendar/render?${usp.toString()}`; } + +/** + * Prefilled Outlook.com compose deeplink per + * specs/behaviors/events.md#add-to-calendar. Built with encodeURIComponent + * rather than URLSearchParams: Outlook shows a `+` literally instead of + * reading it as a space, and renders the body as HTML, so line breaks are + * sent as `<br>`. + */ +export function outlookCalendarUrl(event: CalendarLinkEvent, origin: string): string { + const params: Array<[string, string]> = [ + ['path', '/calendar/action/compose'], + ['rru', 'addevent'], + ['subject', event.title], + ['startdt', toIsoZ(new Date(event.startsAt).getTime())], + ['enddt', toIsoZ(new Date(event.endsAt).getTime())], + ['body', calendarDetails(event, origin).replace(/\n/g, '<br>')], + ]; + const location = eventLocationString(event); + if (location) params.push(['location', location]); + const query = params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&'); + return `https://outlook.live.com/calendar/0/deeplink/compose?${query}`; +} diff --git a/apps/web/src/screens/EventDetail.tsx b/apps/web/src/screens/EventDetail.tsx index 2bf0e5e..5d92853 100644 --- a/apps/web/src/screens/EventDetail.tsx +++ b/apps/web/src/screens/EventDetail.tsx @@ -14,6 +14,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { AddToCalendarMenu, canAddToCalendar } from '@/components/AddToCalendarMenu'; import { Breadcrumbs } from '@/components/Breadcrumbs'; import { MarkdownView } from '@/components/MarkdownView'; import { PersonAvatar } from '@/components/PersonAvatar'; @@ -30,11 +31,7 @@ import { } from '@/lib/api'; import { copyWithToast } from '@/lib/clipboard'; import { groupAttendees } from '@/lib/event-attendees'; -import { - formatEventTime, - googleCalendarUrl, - isPastEvent, -} from '@/lib/event-time'; +import { formatEventTime, isPastEvent } from '@/lib/event-time'; import { formatAbsoluteDate, formatRelativeTime } from '@/lib/time'; import { cn } from '@/lib/utils'; @@ -490,19 +487,9 @@ export function EventDetail() { </section> {/* Add to calendar */} - {!draft && ( - <section aria-label="Add to calendar" className="flex flex-wrap gap-4 text-sm"> - <a - href={googleCalendarUrl(event, origin)} - target="_blank" - rel="noopener noreferrer" - className="text-primary hover:underline" - > - Add to Google Calendar - </a> - <a href={event.icsUrl} download className="text-primary hover:underline"> - Download .ics - </a> + {canAddToCalendar(event) && ( + <section aria-label="Add to calendar"> + <AddToCalendarMenu event={event} /> </section> )} diff --git a/apps/web/src/screens/EventsIndex.tsx b/apps/web/src/screens/EventsIndex.tsx index 5557af2..67beca3 100644 --- a/apps/web/src/screens/EventsIndex.tsx +++ b/apps/web/src/screens/EventsIndex.tsx @@ -1,7 +1,16 @@ import { useState } from 'react'; import { Link, useLocation, useSearchParams } from 'react-router'; import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { ChevronDownIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Breadcrumbs } from '@/components/Breadcrumbs'; import { EventCard, calendarFeedUrls } from '@/components/EventCard'; import { useAuth } from '@/hooks/useAuth'; @@ -24,8 +33,41 @@ function groupByMonth(events: EventSummary[]): Array<{ heading: string; events: return groups; } -function SubscribeControl() { +const copyFeed = (url: string) => copyWithToast(url, 'Calendar feed URL copied', "Couldn't copy the feed URL"); + +/** + * Signed out: the site feed as a link plus a copy fallback. Signed in: a menu + * offering the site feed and the member's own (events they RSVP'd to), each + * with its copy fallback (specs/screens/events-index.md#header). + */ +function SubscribeControl({ viewerSlug }: { viewerSlug: string | undefined }) { const urls = calendarFeedUrls(); + if (viewerSlug) { + const mine = calendarFeedUrls(viewerSlug); + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm"> + Subscribe to calendar + <ChevronDownIcon /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuLabel>All events</DropdownMenuLabel> + <DropdownMenuItem asChild> + <a href={urls.webcal}>Subscribe to all events</a> + </DropdownMenuItem> + <DropdownMenuItem onSelect={() => copyFeed(urls.https)}>Copy all-events feed URL</DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuLabel>Events I’m going to</DropdownMenuLabel> + <DropdownMenuItem asChild> + <a href={mine.webcal}>Subscribe to events I’m going to</a> + </DropdownMenuItem> + <DropdownMenuItem onSelect={() => copyFeed(mine.https)}>Copy my feed URL</DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); + } return ( <span className="inline-flex items-center gap-2 text-sm"> <a href={urls.webcal} className="text-primary hover:underline"> @@ -34,7 +76,7 @@ function SubscribeControl() { <button type="button" className="text-xs text-muted-foreground hover:text-foreground underline" - onClick={() => copyWithToast(urls.https, 'Calendar feed URL copied', "Couldn't copy the feed URL")} + onClick={() => copyFeed(urls.https)} > Copy feed URL </button> @@ -162,7 +204,7 @@ export function EventsIndex() { </p> </div> <div className="flex shrink-0 flex-wrap items-center gap-3"> - <SubscribeControl /> + <SubscribeControl viewerSlug={person?.slug} /> {isStaff && ( <Button asChild> <Link to="/events/new">New event</Link> diff --git a/apps/web/tests/EventDetail.test.tsx b/apps/web/tests/EventDetail.test.tsx index bb447e1..87890f1 100644 --- a/apps/web/tests/EventDetail.test.tsx +++ b/apps/web/tests/EventDetail.test.tsx @@ -83,13 +83,20 @@ describe('EventDetail', () => { expect(screen.getByText('Your name will appear on the attendee list.')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /I’m going/ })).not.toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Add to Google Calendar' }).getAttribute('href')).toMatch( + // One Add to calendar button; its menu holds the three choices. + expect(screen.queryByRole('menuitem', { name: 'Google Calendar' })).not.toBeInTheDocument(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Add to calendar' })); + expect((await screen.findByRole('menuitem', { name: 'Google Calendar' })).getAttribute('href')).toMatch( /^https:\/\/calendar\.google\.com\/calendar\/render\?action=TEMPLATE/, ); - expect(screen.getByRole('link', { name: 'Download .ics' })).toHaveAttribute( + expect(screen.getByRole('menuitem', { name: 'Outlook.com' }).getAttribute('href')).toMatch( + /^https:\/\/outlook\.live\.com\/calendar\/0\/deeplink\/compose\?path=/, + ); + expect(screen.getByRole('menuitem', { name: 'Apple Calendar / .ics' })).toHaveAttribute( 'href', '/api/events/2099-11-03-hack-night/ics', ); + await userEvent.setup().keyboard('{Escape}'); expect(screen.getByRole('link', { name: 'Also listed on Meetup ↗' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /^709 N 2nd St/ }).getAttribute('href')).toContain( 'https://maps.google.com/?q=', @@ -265,7 +272,7 @@ describe('EventDetail', () => { render(); expect(await screen.findByText('Draft — only staff can see this event.')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Publish' })).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'Add to Google Calendar' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Add to calendar' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Delete' })).toBeEnabled(); }); diff --git a/apps/web/tests/EventsIndex.test.tsx b/apps/web/tests/EventsIndex.test.tsx index 4b70c0b..bf10161 100644 --- a/apps/web/tests/EventsIndex.test.tsx +++ b/apps/web/tests/EventsIndex.test.tsx @@ -1,10 +1,11 @@ import { describe, expect, it, vi, afterEach } from 'vitest'; import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { Routes, Route } from 'react-router'; import { renderScreen, mockPaginated } from './test-utils.js'; import { EventsIndex } from '../src/screens/EventsIndex.js'; import { AuthProvider } from '../src/hooks/useAuth.js'; -import { STAFF_ME, json, makeSummary } from './event-fixtures.js'; +import { STAFF_ME, USER_ME, json, makeSummary } from './event-fixtures.js'; function mockFetch(opts: { me?: unknown; upcoming?: unknown[]; past?: unknown[] }) { const calls: string[] = []; @@ -76,10 +77,53 @@ describe('EventsIndex', () => { expect(screen.getByRole('link', { name: 'Subscribe to calendar' }).getAttribute('href')).toMatch( /^webcal:\/\/.+\/api\/events\/feed\.ics$/, ); + expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument(); + + // Add to calendar: on scheduled upcoming cards only, never the cancelled one. + expect(screen.getByRole('button', { name: 'Add Online Thing to calendar' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Add Cancelled One to calendar' })).not.toBeInTheDocument(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add Code for Philly Hack Night to calendar' })); + const google = await screen.findByRole('menuitem', { name: 'Google Calendar' }); + // Cards hold no description, so the details are the canonical URL alone. + expect(new URL(google.getAttribute('href')!).searchParams.get('details')).toMatch( + /\/events\/2099-11-03-hack-night$/, + ); + expect(screen.getByRole('menuitem', { name: 'Outlook.com' })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Apple Calendar / .ics' })).toHaveAttribute( + 'href', + '/api/events/2099-11-03-hack-night/ics', + ); + await user.keyboard('{Escape}'); + // Anonymous: no New event expect(screen.queryByRole('link', { name: 'New event' })).not.toBeInTheDocument(); }); + it('offers a signed-in member both feeds, each with a copy fallback', async () => { + mockFetch({ me: USER_ME, upcoming: [makeSummary()] }); + render('/events'); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Subscribe to calendar' })); + expect((await screen.findByRole('menuitem', { name: 'Subscribe to all events' })).getAttribute('href')).toMatch( + /^webcal:\/\/.+\/api\/events\/feed\.ics$/, + ); + expect(screen.getByRole('menuitem', { name: 'Subscribe to events I’m going to' }).getAttribute('href')).toMatch( + /^webcal:\/\/.+\/api\/events\/feed\.ics\?attendee=member$/, + ); + expect(screen.getByRole('menuitem', { name: 'Copy all-events feed URL' })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Copy my feed URL' })).toBeInTheDocument(); + }); + + it('leaves past cards without an Add to calendar control', async () => { + mockFetch({ + past: [makeSummary({ startsAt: '2020-11-03T23:00:00Z', endsAt: '2020-11-04T01:30:00Z' })], + }); + render('/events/past'); + expect(await screen.findByRole('link', { name: 'Code for Philly Hack Night' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /to calendar$/ })).not.toBeInTheDocument(); + }); + it('shows the empty state with the New event button for staff', async () => { mockFetch({ me: STAFF_ME, upcoming: [] }); render('/events'); diff --git a/apps/web/tests/event-time.test.ts b/apps/web/tests/event-time.test.ts index ede1ebf..7a0d280 100644 --- a/apps/web/tests/event-time.test.ts +++ b/apps/web/tests/event-time.test.ts @@ -8,6 +8,7 @@ import { formatEventTime, formatEventTimeOnly, googleCalendarUrl, + outlookCalendarUrl, phillyWallClockToUtc, shiftOneMonthInPhilly, utcToPhillyWallClock, @@ -159,3 +160,33 @@ describe('googleCalendarUrl', () => { expect(url.searchParams.get('location')).toBe('Indy Hall, 709 N 2nd St'); }); }); + +describe('outlookCalendarUrl', () => { + it('builds a prefilled compose deeplink with %20 spaces and <br> line breaks', () => { + const href = outlookCalendarUrl( + { + slug: '2026-11-03-hack-night', + title: 'Hack Night', + startsAt: '2026-11-03T23:00:00Z', + endsAt: '2026-11-04T01:30:00Z', + description: 'Bring a **laptop**.', + venueName: 'Indy Hall', + venueAddress: '709 N 2nd St', + onlineUrl: null, + }, + 'https://codeforphilly.org', + ); + expect(href).not.toContain('+'); + const url = new URL(href); + expect(url.origin + url.pathname).toBe('https://outlook.live.com/calendar/0/deeplink/compose'); + expect(url.searchParams.get('path')).toBe('/calendar/action/compose'); + expect(url.searchParams.get('rru')).toBe('addevent'); + expect(url.searchParams.get('subject')).toBe('Hack Night'); + expect(url.searchParams.get('startdt')).toBe('2026-11-03T23:00:00Z'); + expect(url.searchParams.get('enddt')).toBe('2026-11-04T01:30:00Z'); + expect(url.searchParams.get('body')).toBe( + 'Bring a laptop.<br><br>https://codeforphilly.org/events/2026-11-03-hack-night', + ); + expect(url.searchParams.get('location')).toBe('Indy Hall, 709 N 2nd St'); + }); +}); From 3098f9d31900b0536de712cab8c149b475e28545 Mon Sep 17 00:00:00 2001 From: GrittyBot <30755786+codeforphilly-bot@users.noreply.github.com> Date: Wed, 11 Nov 2026 06:17:46 +0000 Subject: [PATCH 5/7] fix(web): size the calendar menus to their labels The shared dropdown matches a menu's width to its trigger, so the menu on a card's icon button was 8rem wide and its items wrapped. On a phone the Subscribe menu also sat against the screen edge. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs --- apps/web/src/components/AddToCalendarMenu.tsx | 3 ++- apps/web/src/screens/EventsIndex.tsx | 3 ++- apps/web/tests/EventsIndex.test.tsx | 4 ++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/AddToCalendarMenu.tsx b/apps/web/src/components/AddToCalendarMenu.tsx index a5d4e3c..9719476 100644 --- a/apps/web/src/components/AddToCalendarMenu.tsx +++ b/apps/web/src/components/AddToCalendarMenu.tsx @@ -46,7 +46,8 @@ export function AddToCalendarMenu({ event, variant = 'button' }: AddToCalendarMe </Button> )} </DropdownMenuTrigger> - <DropdownMenuContent align={variant === 'icon' ? 'end' : 'start'}> + {/* Size to the labels, not the trigger, so no choice wraps (the shared content defaults to the trigger's width). */} + <DropdownMenuContent align={variant === 'icon' ? 'end' : 'start'} className="w-auto"> <DropdownMenuItem asChild> <a href={googleCalendarUrl(event, origin)} target="_blank" rel="noopener noreferrer"> Google Calendar diff --git a/apps/web/src/screens/EventsIndex.tsx b/apps/web/src/screens/EventsIndex.tsx index 67beca3..a22d212 100644 --- a/apps/web/src/screens/EventsIndex.tsx +++ b/apps/web/src/screens/EventsIndex.tsx @@ -52,7 +52,8 @@ function SubscribeControl({ viewerSlug }: { viewerSlug: string | undefined }) { <ChevronDownIcon /> </Button> </DropdownMenuTrigger> - <DropdownMenuContent align="end"> + {/* Size to the labels, not the trigger, so no choice wraps; keep clear of the screen edge on phones. */} + <DropdownMenuContent align="end" collisionPadding={16} className="w-auto"> <DropdownMenuLabel>All events</DropdownMenuLabel> <DropdownMenuItem asChild> <a href={urls.webcal}>Subscribe to all events</a> diff --git a/apps/web/tests/EventsIndex.test.tsx b/apps/web/tests/EventsIndex.test.tsx index bf10161..8de15ef 100644 --- a/apps/web/tests/EventsIndex.test.tsx +++ b/apps/web/tests/EventsIndex.test.tsx @@ -94,6 +94,8 @@ describe('EventsIndex', () => { 'href', '/api/events/2099-11-03-hack-night/ics', ); + // The icon trigger is narrow; the menu sizes to its labels instead. + expect(screen.getByRole('menu')).toHaveClass('w-auto'); await user.keyboard('{Escape}'); // Anonymous: no New event @@ -113,6 +115,8 @@ describe('EventsIndex', () => { ); expect(screen.getByRole('menuitem', { name: 'Copy all-events feed URL' })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: 'Copy my feed URL' })).toBeInTheDocument(); + // Sized to its labels, not the trigger, so "Subscribe to events I'm going to" doesn't wrap. + expect(screen.getByRole('menu')).toHaveClass('w-auto'); }); it('leaves past cards without an Add to calendar control', async () => { From 784b13380c87f13374256ba309dc8816695d9c50 Mon Sep 17 00:00:00 2001 From: GrittyBot <30755786+codeforphilly-bot@users.noreply.github.com> Date: Wed, 11 Nov 2026 06:17:46 +0000 Subject: [PATCH 6/7] fix(web): HTML-escape the Outlook.com body Outlook.com renders the body parameter as HTML, so a < or & in an event description was read as markup. The spec's Outlook row now says so. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs --- apps/web/src/lib/event-time.ts | 12 +++++++++--- apps/web/tests/event-time.test.ts | 19 +++++++++++++++++++ specs/behaviors/events.md | 2 +- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/event-time.ts b/apps/web/src/lib/event-time.ts index 5a9e4fd..205af25 100644 --- a/apps/web/src/lib/event-time.ts +++ b/apps/web/src/lib/event-time.ts @@ -325,17 +325,23 @@ export function googleCalendarUrl(event: CalendarLinkEvent, origin: string): str * Prefilled Outlook.com compose deeplink per * specs/behaviors/events.md#add-to-calendar. Built with encodeURIComponent * rather than URLSearchParams: Outlook shows a `+` literally instead of - * reading it as a space, and renders the body as HTML, so line breaks are - * sent as `<br>`. + * reading it as a space, and renders the body as HTML, so the plain text is + * HTML-escaped (a literal `<` or `&` in a description must not be parsed as + * markup) and line breaks are sent as `<br>`. */ export function outlookCalendarUrl(event: CalendarLinkEvent, origin: string): string { + const body = calendarDetails(event, origin) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/\n/g, '<br>'); const params: Array<[string, string]> = [ ['path', '/calendar/action/compose'], ['rru', 'addevent'], ['subject', event.title], ['startdt', toIsoZ(new Date(event.startsAt).getTime())], ['enddt', toIsoZ(new Date(event.endsAt).getTime())], - ['body', calendarDetails(event, origin).replace(/\n/g, '<br>')], + ['body', body], ]; const location = eventLocationString(event); if (location) params.push(['location', location]); diff --git a/apps/web/tests/event-time.test.ts b/apps/web/tests/event-time.test.ts index 7a0d280..72d7ccd 100644 --- a/apps/web/tests/event-time.test.ts +++ b/apps/web/tests/event-time.test.ts @@ -189,4 +189,23 @@ describe('outlookCalendarUrl', () => { ); expect(url.searchParams.get('location')).toBe('Indy Hall, 709 N 2nd St'); }); + + it('HTML-escapes the body, since Outlook renders it as HTML', () => { + const href = outlookCalendarUrl( + { + slug: 'x', + title: 'Hack Night', + startsAt: '2026-11-03T23:00:00Z', + endsAt: '2026-11-04T01:30:00Z', + description: 'Pizza & <laptops>\nfor 1 < 2', + venueName: null, + venueAddress: null, + onlineUrl: null, + }, + 'https://codeforphilly.org', + ); + expect(new URL(href).searchParams.get('body')).toBe( + 'Pizza & <laptops><br>for 1 < 2<br><br>https://codeforphilly.org/events/x', + ); + }); }); diff --git a/specs/behaviors/events.md b/specs/behaviors/events.md index ea4f128..0f929d3 100644 --- a/specs/behaviors/events.md +++ b/specs/behaviors/events.md @@ -143,7 +143,7 @@ Every scheduled event that has not ended offers one **Add to calendar** control | Choice | What it is | | ------ | ---------- | | Google Calendar | a prefilled `calendar.google.com/calendar/render?action=TEMPLATE` link carrying title, UTC `dates`, plain-text details ending in the canonical URL, and the same `LOCATION` string | -| Outlook.com | a prefilled `outlook.live.com/calendar/0/deeplink/compose` link (`path=/calendar/action/compose`, `rru=addevent`) carrying `subject`, UTC `startdt` / `enddt`, the same plain-text `body`, and `location` | +| Outlook.com | a prefilled `outlook.live.com/calendar/0/deeplink/compose` link (`path=/calendar/action/compose`, `rru=addevent`) carrying `subject`, UTC `startdt` / `enddt`, the same details as `body` (Outlook renders it as HTML, so the text is HTML-escaped and line breaks become `<br>`), and `location`; parameters are percent-encoded with `%20` for spaces, never `+` | | Apple Calendar / .ics | the event's `.ics` download (`icsUrl`), which Apple Calendar, desktop Outlook and every other client open | The plain-text details are the description (markdown stripped) followed by the canonical URL where the page holds the description (event detail), and the canonical URL alone where it does not (event cards). The control is hidden for drafts, for cancelled events, and once the event has ended; the `.ics` download route itself keeps answering for cancelled and past events, because the emails link to it. From 421ae5538691f6102801c03023ec611cfee5c91f Mon Sep 17 00:00:00 2001 From: GrittyBot <30755786+codeforphilly-bot@users.noreply.github.com> Date: Wed, 11 Nov 2026 06:25:35 +0000 Subject: [PATCH 7/7] chore(plans): mark calendar-member-feed done (PR #260) Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs --- plans/calendar-member-feed.md | 49 ++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/plans/calendar-member-feed.md b/plans/calendar-member-feed.md index bcd8a32..5e4c4a7 100644 --- a/plans/calendar-member-feed.md +++ b/plans/calendar-member-feed.md @@ -1,5 +1,6 @@ --- -status: in-progress +status: done +pr: 260 depends: [events-core] specs: - specs/behaviors/events.md @@ -68,23 +69,23 @@ emails. ## Validation -- [ ] `GET /api/events/feed.ics?attendee=<slug>` with no session lists +- [x] `GET /api/events/feed.ics?attendee=<slug>` with no session lists exactly the scheduled and cancelled events that person RSVP'd to inside the 90-day window, named `Code for Philly: <fullName>'s events`. -- [ ] Withdrawing an RSVP removes the event from the member feed; a +- [x] Withdrawing an RSVP removes the event from the member feed; a member with no RSVPs gets an empty, valid calendar. -- [ ] An unknown or deactivated `attendee` answers `404 not_found` in the +- [x] An unknown or deactivated `attendee` answers `404 not_found` in the JSON envelope; an empty one `422`. -- [ ] The site feed without `attendee` is unchanged. -- [ ] Event detail shows one "Add to calendar" button whose menu has +- [x] The site feed without `attendee` is unchanged. +- [x] Event detail shows one "Add to calendar" button whose menu has Google Calendar, Outlook.com and Apple Calendar / .ics; it is absent on drafts, cancelled and past events. -- [ ] Full event cards for scheduled, upcoming events carry the same +- [x] Full event cards for scheduled, upcoming events carry the same menu; cancelled, draft and past cards and the home strip do not. -- [ ] Signed out, `/events` offers the site feed with "Copy feed URL"; +- [x] Signed out, `/events` offers the site feed with "Copy feed URL"; signed in, a menu offers "All events" and "Events I'm going to", each with a copy fallback. -- [ ] `npm run type-check && npm run lint && npm test` clean. +- [x] `npm run type-check && npm run lint && npm test` clean. - [ ] Checked in a browser: both menus open, the Google and Outlook.com links land on a prefilled event, the `.ics` opens in a calendar app, and a calendar app subscribes to the member feed over `webcal://`. @@ -101,4 +102,34 @@ emails. ## Notes +- **Browser check, November 11, 2026.** Run in headless Chrome against the + `fixture` data with seeded events. The signed-in check used a session + minted with `mintSessionFor` against localhost. Both menus open, keyboard + included, and nothing overflows at 390px. +- **The last validation box stays open.** The Google link was followed as + far as the sign-in screen with every parameter intact. Not done: an + Outlook.com account seeing the prefilled form, the `.ics` opening in a + real calendar app, and a calendar app subscribing over `webcal://`. Each + needs a person with an account; see Follow-ups. +- **Two fixes came out of the check.** The menus now size to their labels + instead of to the trigger (on cards the menu had been 8rem wide and its + items wrapped). The Outlook body is HTML-escaped, because Outlook renders + it as HTML. +- **Outlook.com parameters** were checked against + InteractionDesignFoundation/add-event-to-calendar-docs (`outlook-web.md`, + verified by its authors on August 19, 2026) and spatie/calendar-links. + Microsoft does not document the format. +- **Slugs match case-sensitively** in `attendee`, as everywhere in the API. + ## Follow-ups + +- Open the Outlook.com link signed in to a real account, on desktop and on + a phone, and confirm title, time, body and location are filled. Phones + are a known weak spot on Microsoft's side. +- Open a downloaded `.ics` in a calendar app, and subscribe to a member + feed over `webcal://` from Apple Calendar or Google Calendar; confirm an + RSVP made or withdrawn shows at the next poll. +- The shared dropdown matches every menu's width to its trigger. The user + menu and the About menu inherit this; consider a site-wide default. +- Dropdown group labels such as "All events" are not exposed to screen + readers as group names.