Skip to content

Commit eea72fd

Browse files
Merge pull request #260 from CodeForPhilly/feat/calendar-member-feed
feat(events): member calendar feed and one Add to calendar menu
2 parents 10c9778 + 421ae55 commit eea72fd

17 files changed

Lines changed: 585 additions & 56 deletions

File tree

‎apps/api/src/lib/ics.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,19 @@ export function buildEventIcs(event: Event, siteHost: string): string {
145145
return calendar(vevent(event, siteHost));
146146
}
147147

148-
/** The subscription feed: many events, named calendar. */
149-
export function buildFeedIcs(events: readonly Event[], siteHost: string): string {
148+
export const SITE_FEED_NAME = 'Code for Philly';
149+
150+
/** Calendar name of one member's feed (the events they RSVP'd to). */
151+
export function memberFeedName(fullName: string): string {
152+
return `${SITE_FEED_NAME}: ${fullName}'s events`;
153+
}
154+
155+
/** A subscription feed: many events, named calendar. */
156+
export function buildFeedIcs(events: readonly Event[], siteHost: string, name: string = SITE_FEED_NAME): string {
150157
const body: string[] = [];
151158
for (const e of events) body.push(...vevent(e, siteHost));
152159
return calendar(body, [
153-
'X-WR-CALNAME:Code for Philly',
160+
`X-WR-CALNAME:${escapeText(name)}`,
154161
'X-WR-TIMEZONE:America/New_York',
155162
]);
156163
}

‎apps/api/src/routes/events.ts‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import type { FastifyInstance, FastifyRequest } from 'fastify';
2222

2323
import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js';
2424
import { formatEventInstant } from '../lib/event-time.js';
25-
import { buildEventIcs, buildFeedIcs, googleCalendarUrl } from '../lib/ics.js';
25+
import { buildEventIcs, buildFeedIcs, googleCalendarUrl, memberFeedName } from '../lib/ics.js';
2626
import { ok, paginated } from '../lib/response.js';
2727
import { collectAttendees, eventEmailFacts } from '../services/event-notify.js';
2828
import type { EventChange, EventInput, RsvpInput } from '../services/event.write.js';
@@ -140,11 +140,34 @@ export async function eventRoutes(fastify: FastifyInstance): Promise<void> {
140140
// GET /api/events/feed.ics — registered before /:slug ---------------------
141141
fastify.get(
142142
'/api/events/feed.ics',
143-
{ schema: { tags: ['events'], summary: 'iCalendar subscription feed' } },
144-
async (_request, reply) => {
145-
const ics = buildFeedIcs(fastify.services.events.feedEvents(), siteHost());
143+
{
144+
schema: {
145+
tags: ['events'],
146+
summary: "iCalendar subscription feed: every event, or one member's with ?attendee=",
147+
querystring: {
148+
type: 'object',
149+
properties: { attendee: { type: 'string' } },
150+
additionalProperties: false,
151+
},
152+
},
153+
},
154+
async (request, reply) => {
155+
const { attendee } = request.query as { attendee?: string };
156+
let ics: string;
157+
let filename = 'code-for-philly.ics';
158+
if (attendee === undefined) {
159+
ics = buildFeedIcs(fastify.services.events.feedEvents(), siteHost());
160+
} else {
161+
if (attendee.trim() === '') {
162+
throw new ApiValidationError('Invalid attendee', { attendee: 'must be a person slug' });
163+
}
164+
const feed = fastify.services.events.memberFeed(attendee);
165+
if (!feed) throw new ApiNotFoundError(`Person '${attendee}' not found`);
166+
ics = buildFeedIcs(feed.events, siteHost(), memberFeedName(feed.person.fullName));
167+
filename = `code-for-philly-${feed.person.slug}.ics`;
168+
}
146169
return reply
147-
.headers({ ...icsHeaders('inline; filename="code-for-philly.ics"'), 'Cache-Control': 'public, max-age=300' })
170+
.headers({ ...icsHeaders(`inline; filename="${filename}"`), 'Cache-Control': 'public, max-age=300' })
148171
.send(ics);
149172
},
150173
);

‎apps/api/src/services/event.ts‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* (roughly one a month), so "upcoming" and "past" are a filter + sort over
66
* `state.events.values()` rather than a maintained sorted index.
77
*/
8-
import type { Event, EventRsvp } from '@cfp/shared/schemas';
8+
import type { Event, EventRsvp, Person } from '@cfp/shared/schemas';
99

1010
import type { InMemoryState } from '../store/memory/state.js';
1111
import { personBySlug, rsvpKey } from '../store/memory/state.js';
@@ -131,10 +131,30 @@ export class EventService {
131131

132132
/** Events eligible for the subscription feed: scheduled or cancelled, ending within the trailing 90 days or later. */
133133
feedEvents(now: Date = new Date()): Event[] {
134+
return this.#feedWindow([...this.#state.events.values()], now);
135+
}
136+
137+
/**
138+
* The member feed: the same window, narrowed to events `attendee` has an
139+
* RSVP on. Null when the slug names no person or a deactivated one
140+
* (specs/behaviors/events.md#calendar-files).
141+
*/
142+
memberFeed(attendeeSlug: string, now: Date = new Date()): { person: Person; events: Event[] } | null {
143+
const person = personBySlug(this.#state, attendeeSlug);
144+
if (!person || person.deletedAt) return null;
145+
const ids = this.#state.rsvpsByPerson.get(person.slug) ?? new Set<string>();
146+
const events: Event[] = [];
147+
for (const id of ids) {
148+
const rsvp = this.#state.eventRsvps.get(id);
149+
const event = rsvp ? this.findBySlug(rsvp.eventSlug) : undefined;
150+
if (event) events.push(event);
151+
}
152+
return { person, events: this.#feedWindow(events, now) };
153+
}
154+
155+
#feedWindow(candidates: Event[], now: Date): Event[] {
134156
const cutoff = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
135-
const events = [...this.#state.events.values()].filter(
136-
(e) => e.status !== 'draft' && new Date(e.endsAt) >= cutoff,
137-
);
157+
const events = candidates.filter((e) => e.status !== 'draft' && new Date(e.endsAt) >= cutoff);
138158
events.sort((a, b) => a.startsAt.localeCompare(b.startsAt));
139159
return events;
140160
}

‎apps/api/tests/events.test.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,69 @@ describe('calendar feed and legacy URLs', () => {
569569
expect(res.body).not.toContain(`/events/${ancient.slug}`);
570570
});
571571

572+
it("narrows the feed to one member's RSVPs with ?attendee=", async () => {
573+
const going = await createEvent(hackNight(7, { title: 'Going' }));
574+
const notGoing = await createEvent(hackNight(14, { title: 'Not Going' }));
575+
const cancelled = await createEvent(hackNight(21, { title: 'Called Off' }));
576+
const jane = await janeCookie();
577+
for (const slug of [going.slug, cancelled.slug]) {
578+
const rsvp = await app!.inject({ method: 'PUT', url: `/api/events/${slug}/rsvp`, headers: { cookie: jane } });
579+
expect(rsvp.statusCode, rsvp.body).toBe(201);
580+
}
581+
const cancel = await app!.inject({
582+
method: 'POST',
583+
url: `/api/events/${cancelled.slug}/cancel`,
584+
headers: { cookie: await staffCookie(), 'content-type': 'application/json' },
585+
payload: {},
586+
});
587+
expect(cancel.statusCode, cancel.body).toBe(200);
588+
589+
const url = `/api/events/feed.ics?attendee=${fixtures.personSlug}`;
590+
// No cookie: the member feed is public, like the attendee list.
591+
let res = await app!.inject({ method: 'GET', url });
592+
expect(res.statusCode, res.body).toBe(200);
593+
expect(res.headers['content-type']).toContain('text/calendar');
594+
expect(res.headers['cache-control']).toBe('public, max-age=300');
595+
expect(res.headers['content-disposition']).toBe(`inline; filename="code-for-philly-${fixtures.personSlug}.ics"`);
596+
expect(res.body).toMatch(/X-WR-CALNAME:Code for Philly: .+'s events/);
597+
expect(res.body).toContain(`/events/${going.slug}`);
598+
expect(res.body).toContain(`/events/${cancelled.slug}`);
599+
expect(res.body).toContain('STATUS:CANCELLED');
600+
expect(res.body).not.toContain(`/events/${notGoing.slug}`);
601+
602+
// Withdrawing drops the event at the next poll.
603+
await app!.inject({ method: 'DELETE', url: `/api/events/${going.slug}/rsvp`, headers: { cookie: jane } });
604+
res = await app!.inject({ method: 'GET', url });
605+
expect(res.body).not.toContain(`/events/${going.slug}`);
606+
607+
// A member with no RSVPs gets a valid empty calendar, not an error.
608+
const empty = await app!.inject({ method: 'GET', url: `/api/events/feed.ics?attendee=${STAFF_SLUG}` });
609+
expect(empty.statusCode).toBe(200);
610+
expect(empty.body).toContain('BEGIN:VCALENDAR');
611+
expect(empty.body).not.toContain('BEGIN:VEVENT');
612+
});
613+
614+
it('answers 404 for an unknown or deactivated attendee and 422 for an empty one', async () => {
615+
const unknown = await app!.inject({ method: 'GET', url: '/api/events/feed.ics?attendee=nobody-here' });
616+
expect(unknown.statusCode).toBe(404);
617+
expect(unknown.json()).toMatchObject({ success: false, error: { code: 'not_found' } });
618+
619+
const blank = await app!.inject({ method: 'GET', url: '/api/events/feed.ics?attendee=' });
620+
expect(blank.statusCode).toBe(422);
621+
622+
const { slug } = await createEvent(hackNight(7));
623+
const jane = await janeCookie();
624+
await app!.inject({ method: 'PUT', url: `/api/events/${slug}/rsvp`, headers: { cookie: jane } });
625+
const deactivate = await app!.inject({
626+
method: 'POST',
627+
url: `/api/people/${fixtures.personSlug}/deactivate`,
628+
headers: { cookie: jane },
629+
});
630+
expect(deactivate.statusCode, deactivate.body).toBeLessThan(300);
631+
const gone = await app!.inject({ method: 'GET', url: `/api/events/feed.ics?attendee=${fixtures.personSlug}` });
632+
expect(gone.statusCode).toBe(404);
633+
});
634+
572635
it('301s /calendar and the laddr list views and segment URLs', async () => {
573636
const cases: Array<[string, string]> = [
574637
['/calendar', '/events'],
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { CalendarPlusIcon } from 'lucide-react';
2+
import { Button } from '@/components/ui/button';
3+
import {
4+
DropdownMenu,
5+
DropdownMenuContent,
6+
DropdownMenuItem,
7+
DropdownMenuTrigger,
8+
} from '@/components/ui/dropdown-menu';
9+
import { googleCalendarUrl, isPastEvent, outlookCalendarUrl, type CalendarLinkEvent } from '@/lib/event-time';
10+
11+
interface AddToCalendarEvent extends CalendarLinkEvent {
12+
status: 'draft' | 'scheduled' | 'cancelled';
13+
icsUrl: string;
14+
}
15+
16+
/** Offered only while the event is scheduled and has not ended (specs/behaviors/events.md#add-to-calendar). */
17+
export function canAddToCalendar(event: Pick<AddToCalendarEvent, 'status' | 'endsAt'>, now: Date = new Date()): boolean {
18+
return event.status === 'scheduled' && !isPastEvent(event.endsAt, now);
19+
}
20+
21+
interface AddToCalendarMenuProps {
22+
event: AddToCalendarEvent;
23+
/** "button" is the labelled control on event detail; "icon" is the compact one on cards. */
24+
variant?: 'button' | 'icon';
25+
}
26+
27+
/**
28+
* One "Add to calendar" control with three choices: Google Calendar and
29+
* Outlook.com as prefilled links, and the `.ics` download for Apple Calendar
30+
* and everything else. Renders nothing when the event can't be added.
31+
*/
32+
export function AddToCalendarMenu({ event, variant = 'button' }: AddToCalendarMenuProps) {
33+
if (!canAddToCalendar(event)) return null;
34+
const origin = window.location.origin;
35+
return (
36+
<DropdownMenu>
37+
<DropdownMenuTrigger asChild>
38+
{variant === 'icon' ? (
39+
<Button variant="ghost" size="icon" aria-label={`Add ${event.title} to calendar`}>
40+
<CalendarPlusIcon />
41+
</Button>
42+
) : (
43+
<Button variant="outline" size="sm">
44+
<CalendarPlusIcon />
45+
Add to calendar
46+
</Button>
47+
)}
48+
</DropdownMenuTrigger>
49+
{/* Size to the labels, not the trigger, so no choice wraps (the shared content defaults to the trigger's width). */}
50+
<DropdownMenuContent align={variant === 'icon' ? 'end' : 'start'} className="w-auto">
51+
<DropdownMenuItem asChild>
52+
<a href={googleCalendarUrl(event, origin)} target="_blank" rel="noopener noreferrer">
53+
Google Calendar
54+
</a>
55+
</DropdownMenuItem>
56+
<DropdownMenuItem asChild>
57+
<a href={outlookCalendarUrl(event, origin)} target="_blank" rel="noopener noreferrer">
58+
Outlook.com
59+
</a>
60+
</DropdownMenuItem>
61+
<DropdownMenuItem asChild>
62+
<a href={event.icsUrl} download>
63+
Apple Calendar / .ics
64+
</a>
65+
</DropdownMenuItem>
66+
</DropdownMenuContent>
67+
</DropdownMenu>
68+
);
69+
}

‎apps/web/src/components/EventCard.tsx‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ReactNode } from 'react';
22
import { Link } from 'react-router';
3+
import { AddToCalendarMenu, canAddToCalendar } from '@/components/AddToCalendarMenu';
34
import type { EventSummary } from '@/lib/api';
45
import { eventDateBlock, formatEventTimeOnly } from '@/lib/event-time';
56
import { cn } from '@/lib/utils';
@@ -48,6 +49,9 @@ export function EventCard({ event, variant = 'full', action, headingLevel = 'h3'
4849
const draft = event.status === 'draft';
4950
const going = event.viewerRsvp !== null;
5051
const date = eventDateBlock(event.startsAt);
52+
// The index and series cards carry the menu; the home strip's compact rows
53+
// keep their one RSVP action (specs/screens/events-index.md#list).
54+
const showCalendar = variant === 'full' && canAddToCalendar(event);
5155

5256
return (
5357
<div
@@ -86,16 +90,27 @@ export function EventCard({ event, variant = 'full', action, headingLevel = 'h3'
8690
<span className="text-muted-foreground">{event.rsvpCount} going</span>
8791
</p>
8892
</div>
89-
{action && <div className="relative z-10 shrink-0">{action}</div>}
93+
{/* Above the stretched link, so each control is its own focus target. */}
94+
{(action || showCalendar) && (
95+
<div className="relative z-10 flex shrink-0 items-center gap-2">
96+
{action}
97+
{showCalendar && <AddToCalendarMenu event={event} variant="icon" />}
98+
</div>
99+
)}
90100
</div>
91101
);
92102
}
93103

94-
/** `webcal://` and `https://` forms of the subscription feed for this host. */
95-
export function calendarFeedUrls(): { webcal: string; https: string } {
104+
/**
105+
* `webcal://` and `https://` forms of a subscription feed for this host: the
106+
* site feed, or one member's (the events they RSVP'd to) when `attendeeSlug`
107+
* is given.
108+
*/
109+
export function calendarFeedUrls(attendeeSlug?: string): { webcal: string; https: string } {
96110
const { host, protocol } = window.location;
111+
const path = `/api/events/feed.ics${attendeeSlug ? `?attendee=${encodeURIComponent(attendeeSlug)}` : ''}`;
97112
return {
98-
webcal: `webcal://${host}/api/events/feed.ics`,
99-
https: `${protocol === 'http:' ? 'http' : 'https'}://${host}/api/events/feed.ics`,
113+
webcal: `webcal://${host}${path}`,
114+
https: `${protocol === 'http:' ? 'http' : 'https'}://${host}${path}`,
100115
};
101116
}

‎apps/web/src/lib/event-time.ts‎

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -297,14 +297,19 @@ export function eventLocationString(e: Pick<CalendarLinkEvent, 'venueName' | 've
297297
return e.onlineUrl ?? '';
298298
}
299299

300+
/** Plain-text details: the description (markdown stripped) when held, then the canonical URL. */
301+
function calendarDetails(event: CalendarLinkEvent, origin: string): string {
302+
const canonical = `${origin}/events/${event.slug}`;
303+
const body = event.description ? stripMarkdown(event.description) : '';
304+
return body ? `${body}\n\n${canonical}` : canonical;
305+
}
306+
300307
/**
301308
* Prefilled `calendar.google.com/calendar/render?action=TEMPLATE` link per
302-
* specs/behaviors/events.md#calendar-files.
309+
* specs/behaviors/events.md#add-to-calendar.
303310
*/
304311
export function googleCalendarUrl(event: CalendarLinkEvent, origin: string): string {
305-
const canonical = `${origin}/events/${event.slug}`;
306-
const body = event.description ? stripMarkdown(event.description) : '';
307-
const details = body ? `${body}\n\n${canonical}` : canonical;
312+
const details = calendarDetails(event, origin);
308313
const usp = new URLSearchParams({
309314
action: 'TEMPLATE',
310315
text: event.title,
@@ -315,3 +320,31 @@ export function googleCalendarUrl(event: CalendarLinkEvent, origin: string): str
315320
if (location) usp.set('location', location);
316321
return `https://calendar.google.com/calendar/render?${usp.toString()}`;
317322
}
323+
324+
/**
325+
* Prefilled Outlook.com compose deeplink per
326+
* specs/behaviors/events.md#add-to-calendar. Built with encodeURIComponent
327+
* rather than URLSearchParams: Outlook shows a `+` literally instead of
328+
* reading it as a space, and renders the body as HTML, so the plain text is
329+
* HTML-escaped (a literal `<` or `&` in a description must not be parsed as
330+
* markup) and line breaks are sent as `<br>`.
331+
*/
332+
export function outlookCalendarUrl(event: CalendarLinkEvent, origin: string): string {
333+
const body = calendarDetails(event, origin)
334+
.replace(/&/g, '&amp;')
335+
.replace(/</g, '&lt;')
336+
.replace(/>/g, '&gt;')
337+
.replace(/\n/g, '<br>');
338+
const params: Array<[string, string]> = [
339+
['path', '/calendar/action/compose'],
340+
['rru', 'addevent'],
341+
['subject', event.title],
342+
['startdt', toIsoZ(new Date(event.startsAt).getTime())],
343+
['enddt', toIsoZ(new Date(event.endsAt).getTime())],
344+
['body', body],
345+
];
346+
const location = eventLocationString(event);
347+
if (location) params.push(['location', location]);
348+
const query = params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
349+
return `https://outlook.live.com/calendar/0/deeplink/compose?${query}`;
350+
}

0 commit comments

Comments
 (0)