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 && (