Skip to content
13 changes: 10 additions & 3 deletions apps/api/src/lib/ics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
}
Expand Down
33 changes: 28 additions & 5 deletions apps/api/src/routes/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
},
);
Expand Down
28 changes: 24 additions & 4 deletions apps/api/src/services/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
63 changes: 63 additions & 0 deletions apps/api/tests/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
69 changes: 69 additions & 0 deletions apps/web/src/components/AddToCalendarMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<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>
{/* 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
</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>
);
}
25 changes: 20 additions & 5 deletions apps/web/src/components/EventCard.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}`,
};
}
41 changes: 37 additions & 4 deletions apps/web/src/lib/event-time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -315,3 +320,31 @@ 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 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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', 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}`;
}
Loading
Loading