Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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