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 { // 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(); + 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'], diff --git a/apps/web/src/components/AddToCalendarMenu.tsx b/apps/web/src/components/AddToCalendarMenu.tsx new file mode 100644 index 0000000..9719476 --- /dev/null +++ b/apps/web/src/components/AddToCalendarMenu.tsx @@ -0,0 +1,69 @@ +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, 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 ( + + + {variant === 'icon' ? ( + + ) : ( + + )} + + {/* Size to the labels, not the trigger, so no choice wraps (the shared content defaults to the trigger's width). */} + + + + Google Calendar + + + + + Outlook.com + + + + + Apple Calendar / .ics + + + + + ); +} 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 (
{event.rsvpCount} going

- {action &&
{action}
} + {/* Above the stretched link, so each control is its own focus target. */} + {(action || showCalendar) && ( +
+ {action} + {showCalendar && } +
+ )} ); } -/** `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..205af25 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`. + */ +export function outlookCalendarUrl(event: CalendarLinkEvent, origin: string): string { + const body = calendarDetails(event, origin) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
'); + 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', body], + ]; + 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() { {/* Add to calendar */} - {!draft && ( -
- - Add to Google Calendar - - - Download .ics - + {canAddToCalendar(event) && ( +
+
)} diff --git a/apps/web/src/screens/EventsIndex.tsx b/apps/web/src/screens/EventsIndex.tsx index 5557af2..a22d212 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,42 @@ 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 ( + + + + + {/* Size to the labels, not the trigger, so no choice wraps; keep clear of the screen edge on phones. */} + + All events + + Subscribe to all events + + copyFeed(urls.https)}>Copy all-events feed URL + + Events I’m going to + + Subscribe to events I’m going to + + copyFeed(mine.https)}>Copy my feed URL + + + ); + } return ( @@ -34,7 +77,7 @@ function SubscribeControl() { @@ -162,7 +205,7 @@ export function EventsIndex() {

- + {isStaff && (