feat(meetings): add iCal Subscribe + Calendar view to Meetings dashboard (LFXV2-1771)#697
feat(meetings): add iCal Subscribe + Calendar view to Meetings dashboard (LFXV2-1771)#697manishdixitlfx wants to merge 2 commits into
Conversation
LFXV2-1771: Extend the iCal Subscribe modal (LFXV2-1714 / PR #689) and a List/Calendar view toggle to the global Meetings dashboard for the foundation and project lenses. Frontend (apps/lfx-one): - meetings-dashboard.component.ts: add viewMode signal + isListView / isCalendarView computeds; add calendarEvents signal projecting filteredMeetings into FullCalendar EventInput[]; add onSubscribe() that opens IcalSubscribeDialogComponent with the correct lens-aware feed URL; add onCalendarEventClick() (cancelled occurrences are inert). - meetings-dashboard.component.html: introduce the [List] [Calendar] [Subscribe] toggle row between the filter bar and the meeting list; render calendar legend + <lfx-fullcalendar> when isCalendarView() is active; Create Meeting stays at the top of the title row. - committee-meetings.component.ts: parity fix — cancelled occurrences no longer accept clicks and render with cursor-default. - ical-subscribe-dialog.component.ts + committees caller: rename dialog data key committeeName → name (dialog is no longer committee-specific). Shared infrastructure (apps/lfx-one/src/app/shared/components/fullcalendar): - fullcalendar.component.scss: scope the timegrid bg-gray-100 / bg-blue-50 forced backgrounds to .meeting-event-classed entries only. Previously the blanket !important override made any inline-coloured event invisible in Week view (white text on near-white background). This also fixes the latent Week-view visibility bug on the committee tab. - fullcalendar.component.ts: scrollTime fixed at 06:00 instead of the current hour, so Week view opens to a sensible starting hour. Backend (apps/lfx-one/src/server): - controllers/project.controller.ts: add getProjectCalendar that mirrors getCommitteeCalendar exactly — public access via M2M token, paginated upcoming + past meetings filtered by tag project_uid:<id>, helpers meetingsToVEvents + buildVCalendar, text/calendar response with 15-minute Cache-Control. - routes/public-projects.route.ts (NEW): single public route GET /:id/calendar.ics → projectController.getProjectCalendar. - server.ts (protected): wire app.use('/public/api/projects', publicProjectsRouter). Shared types (packages/shared/src/interfaces): - IcalSubscribeDialogData.committeeName → name to reflect the broader use case. Scope and follow-ups: - The "Me" lens is intentionally not implemented (parked in LFXV2-1772). A personal subscription URL needs a token design because calendar clients cannot carry session cookies. - The "Org" lens is parked in LFXV2-1770. - Foundation feed currently includes only meetings directly tagged to the foundation (project_uid:<foundation_uid>). Sub-project meetings are not yet rolled up; that requires either a new upstream tag or a cross-project fan-out and can be a follow-up. Signed-off-by: Manish Dixit <mdixit@linuxfoundation.org>
|
ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (6)
✅ Files skipped from review due to trivial changes (1)
🚧 Files skipped from review as they are similar to previous changes (2)
WalkthroughThis PR adds calendar view and iCal subscription to the Meetings dashboard. A shared interface is refactored to use a generic ChangesCalendar iCal Subscription and Meeting Calendar View
🎯 3 (Moderate) | ⏱️ ~20 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.html`:
- Around line 225-245: The calendar branch in meetings-dashboard.component.html
(inside the isCalendarView() block that renders <lfx-fullcalendar> and the
loading spinner) doesn't trigger pagination, so calendar view only shows page 1
while list view calls loadMore(); update the calendar branch to invoke the same
pagination fallback used by list view: when (timeFilter() === 'upcoming' &&
meetingsLoading()) || (timeFilter() === 'past' && pastMeetingsLoading()) show
the spinner AND, when calendarEvents() is rendered, ensure you call or wire the
existing loadMore() mechanism (or expose the component method that triggers
loading additional pages) so additional pages are fetched as the calendar needs
them (use the same loadMore() helper used elsewhere to keep behavior consistent
with list mode).
In
`@apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.ts`:
- Around line 252-253: The feed URL should encode the project ID to avoid
breaking links when it contains reserved characters; update the template that
builds feedUrl (the const feedUrl using projectCtx.uid in
meetings-dashboard.component.ts) to use an encoded ID (e.g., replace
projectCtx.uid with encodeURIComponent(projectCtx.uid)) so the path segment is
safely escaped while leaving the rest of the URL and the name assignment
unchanged.
In `@apps/lfx-one/src/server/controllers/project.controller.ts`:
- Around line 881-888: getProjectCalendar currently aggregates meetings via
fetchAllMeetingPages / meetingService.getMeetings but doesn't filter out
non-public items; after collecting upcoming and past (the variables upcoming and
past) combine them and filter to only include meetings where meeting.visibility
=== MeetingVisibility.PUBLIC and not meeting.restricted before rendering the
ICS. Apply the same change in getCommitteeCalendar: after fetching its
upcoming/past pages, replace the raw concatenation with a filtered list (use
MeetingVisibility.PUBLIC and the restricted flag) so private or restricted
meetings are excluded from the public calendar.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: bdf6de84-cdfe-4879-9745-fca34e991903
📒 Files selected for processing (10)
apps/lfx-one/src/app/modules/committees/components/committee-meetings/committee-meetings.component.tsapps/lfx-one/src/app/modules/committees/components/ical-subscribe-dialog/ical-subscribe-dialog.component.tsapps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.htmlapps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.tsapps/lfx-one/src/app/shared/components/fullcalendar/fullcalendar.component.scssapps/lfx-one/src/app/shared/components/fullcalendar/fullcalendar.component.tsapps/lfx-one/src/server/controllers/project.controller.tsapps/lfx-one/src/server/routes/public-projects.route.tsapps/lfx-one/src/server/server.tspackages/shared/src/interfaces/committee.interface.ts
There was a problem hiding this comment.
Pull request overview
Adds end-to-end iCal subscription for project/foundation meetings and introduces a calendar view on the Meetings dashboard, reusing the existing iCal Subscribe dialog and FullCalendar wrapper.
Changes:
- Frontend: Adds a List/Calendar toggle and Subscribe action to the Meetings dashboard; maps filtered meetings into FullCalendar events and handles event-click navigation (with cancelled occurrences intended to be inert).
- Backend: Adds a public
/public/api/projects/:id/calendar.icsICS feed endpoint (route + controller method) mirroring the existing committee calendar feed pattern. - Shared/UI: Generalizes the iCal Subscribe dialog data (
committeeName→name) and adjusts FullCalendar wrapper styling/behavior (scoped timegrid overrides + fixedscrollTime).
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/shared/src/interfaces/committee.interface.ts | Renames iCal subscribe dialog data field to be non-committee-specific (name). |
| apps/lfx-one/src/server/server.ts | Registers new public projects router under /public/api/projects. |
| apps/lfx-one/src/server/routes/public-projects.route.ts | Adds public route GET /:id/calendar.ics for project/foundation calendar feeds. |
| apps/lfx-one/src/server/controllers/project.controller.ts | Implements getProjectCalendar() ICS feed generation via MeetingService + ICS helpers. |
| apps/lfx-one/src/app/shared/components/fullcalendar/fullcalendar.component.ts | Sets week view scrollTime to a fixed 06:00:00. |
| apps/lfx-one/src/app/shared/components/fullcalendar/fullcalendar.component.scss | Scopes timegrid event background overrides to .meeting-event only. |
| apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.ts | Adds viewMode, calendar event projection, calendar click handling, and Subscribe modal wiring. |
| apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.html | Adds List/Calendar toggle UI, Subscribe button (foundation/project), and calendar view rendering + legend. |
| apps/lfx-one/src/app/modules/committees/components/ical-subscribe-dialog/ical-subscribe-dialog.component.ts | Updates dialog to use generalized name for Outlook deep-links. |
| apps/lfx-one/src/app/modules/committees/components/committee-meetings/committee-meetings.component.ts | Makes cancelled calendar occurrences inert (click short-circuit + attempts cursor change). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Address review comments from @coderabbitai and @copilot-pull-request-reviewer: - project.controller.ts, committee.controller.ts: filter PRIVATE / restricted meetings out of the public iCal feeds. The public endpoints previously returned ALL meetings matching the project_uid / committee_uid tag — anyone with the UID could subscribe and see private meeting titles + times. Now mirrors PublicMeetingController.getMeeting's visibility guard: `visibility === MeetingVisibility.PUBLIC && !restricted` (per @coderabbitai — security blocker) - meetings-dashboard.component.ts: switch the calendar event source from the list-view's paginated `filteredMeetings()` to the dashboard's non-paginated raw signals (rawUserMeetings + rawUserPastMeetings for the Me lens; rawFpUpcomingMeetings + rawFpPastMeetings for foundation/project). The calendar no longer silently misses meetings beyond the first scroll page (per @coderabbitai — major) - meetings-dashboard.component.ts: wrap projectCtx.uid with encodeURIComponent in the feed URL so reserved characters in IDs don't break links (per @coderabbitai — minor) - project.controller.ts: fix JSDoc route comment — was `GET /projects/:id/...`, now matches the actual public path `GET /public/api/projects/:id/calendar.ics` (per @copilot-pull-request-reviewer) - fullcalendar.component.scss: add explicit `.fc-event.cursor-default { cursor: default !important; }` override so cancelled occurrences (and the vote/survey markers on the committee tab) actually drop the pointer cursor — the wrapper's `.fc .fc-event { cursor: pointer }` rule has higher specificity than Tailwind's `.cursor-default` utility, so classNames: ['cursor-default'] alone wouldn't win. Also disable the hover-lift on inert events (per @copilot-pull-request-reviewer) - meetings-dashboard.component.html, committee-meetings.component.html: legend dot for "Meeting (default)" changed from bg-blue-600 → bg-blue-500 so it matches MEETING_TYPE_COLORS.default (#3b82f6 — Tailwind blue-500) (per @copilot-pull-request-reviewer) Resolves 7 review threads. Signed-off-by: Manish Dixit <mdixit@linuxfoundation.org>
Review Feedback AddressedCommit: bfd0993 Security fix (data leakage)
Calendar correctness
Polish
Threads Resolved7 of 7 unresolved threads addressed in this iteration. |
|
I'll kick off a fresh review against the updated commit. (◕‿◕✿) 🐇 ✅ Actions performedReview triggered.
|
| </div> | ||
| </div> | ||
|
|
||
| <!-- View toggle + Subscribe row — sits between the filter bar and the meeting cards. --> |
There was a problem hiding this comment.
Section-header comment — remove it.
Problem & Why: Comments that label WHAT a block does are not allowed. The data-testid="meetings-view-actions", role="group", and the button labels make the section self-documenting. Section-header comments add noise and drift out of date as the markup changes.
Suggested fix: Delete the comment line entirely.
|
|
||
| // View mode: list (default) or calendar. Calendar renders meetings as | ||
| // FullCalendar events; the existing filter pipeline (lens/foundation/project/ | ||
| // search/meetingType/timeFilter) applies to both views. |
There was a problem hiding this comment.
Multi-line comment block — one line max.
Problem & Why: The rule is one short line when the WHY is non-obvious; this 3-line block explains WHAT the signals do, not WHY a non-obvious constraint exists. signal<ViewMode>('list') is self-documenting. Multi-line comment blocks are explicitly forbidden.
Suggested fix: Delete the comment block entirely. If a reminder about filter applicability is needed, a single line on calendarEvents suffices.
| // silently miss meetings the user hasn't scrolled to yet. | ||
| // Search and meeting-type filters are applied; foundation/project and | ||
| // pendingRsvp filters intentionally don't narrow the calendar in this | ||
| // iteration (FP raw signals are already scoped to the active context). |
There was a problem hiding this comment.
Multi-line comment block — one line max.
Problem & Why: This 8-line block inside the constructor violates the one-line-max rule. The relevant WHY (non-paginated source to avoid calendar gaps) is valuable but must be compressed to a single line.
Suggested fix: Replace the entire block with one line above the assignment:
// Uses non-paginated raw signals to avoid calendar silently missing meetings the user hasn't scrolled to yet.
this.calendarEvents = this.initCalendarEvents();| // Search and meeting-type filters are applied; foundation/project and | ||
| // pendingRsvp filters intentionally don't narrow the calendar in this | ||
| // iteration (FP raw signals are already scoped to the active context). | ||
| this.calendarEvents = computed(() => { |
There was a problem hiding this comment.
Multi-line computed() must use a private init function.
Problem & Why: This component initializes every other complex signal via this.xxx = this.initializeXxx() (see initializeCommittees, initializeFilteredCommittees, initializeVotingStatusOptions, etc.). An inline multi-line computed() assigned directly in the constructor breaks that pattern, makes the constructor harder to read, and puts logic where it can't be easily navigated to. The signal declaration already exists at the class level (public calendarEvents: Signal<EventInput[]>) — it just needs to delegate to a private init function.
Suggested fix:
// In constructor:
this.calendarEvents = this.initCalendarEvents();
// New private method at the bottom of the class:
private initCalendarEvents(): Signal<EventInput[]> {
return computed(() => {
const lens = this.activeLens();
const meetings: (Meeting | PastMeeting)[] =
lens === 'me' ? [...this.rawUserMeetings(), ...this.rawUserPastMeetings()] : [...this.rawFpUpcomingMeetings(), ...this.rawFpPastMeetings()];
const filtered = this.filterBySearchAndType(meetings, this.debouncedSearchQuery(), this.meetingTypeFilter());
return filtered.flatMap((m) => this.meetingToEvents(m));
});
}| } | ||
| } | ||
|
|
||
| /** |
There was a problem hiding this comment.
Multi-line JSDoc block — one line max.
Problem & Why: The rule forbids multi-line JSDoc and multi-line comment blocks everywhere in the codebase. This 9-line block far exceeds the one-line maximum. The JIRA ticket references (LFXV2-1772, LFXV2-1770) belong in the PR description, not in source code.
Suggested fix: Replace with a single-line JSDoc:
/** Opens iCal Subscribe modal — foundation/project lenses only; me/org lenses tracked separately. */
public onSubscribe(): void {| } | ||
|
|
||
| /** | ||
| * Convert one Meeting (or PastMeeting) into FullCalendar EventInput entries. |
There was a problem hiding this comment.
Multi-line JSDoc block — one line max.
Problem & Why: Four-line JSDoc on a private helper method violates the one-line-max rule. The method signature meetingToEvents(meeting: Meeting | PastMeeting): EventInput[] already communicates inputs and outputs.
Suggested fix:
/** Expands one Meeting/PastMeeting into EventInput entries; recurring meetings produce one entry per occurrence. */
private meetingToEvents(meeting: Meeting | PastMeeting): EventInput[] {| const allMeetings = [...upcoming, ...past]; | ||
| // Public endpoint — filter out PRIVATE / restricted meetings so the feed | ||
| // never exposes private metadata to anyone holding the committee UID. | ||
| // Mirrors PublicMeetingController.getMeeting's visibility guard. |
There was a problem hiding this comment.
Multi-line comment block — one line max.
Problem & Why: Three-line inline comment. The rule is one short line when the WHY is non-obvious. The security rationale is worth keeping but must be a single line.
Suggested fix:
// Filter PRIVATE/restricted meetings from the public feed (mirrors PublicMeetingController visibility guard).
const allMeetings = [...upcoming, ...past].filter((m) => m.visibility === MeetingVisibility.PUBLIC && !m.restricted);| */ | ||
| export class ProjectController { | ||
| private projectService: ProjectService = new ProjectService(); | ||
| // Injected here (rather than on ProjectService) to keep the calendar endpoint's |
There was a problem hiding this comment.
Multi-line comment block — one line max.
Problem & Why: Three-line block explaining the injection decision. Collapse to one line.
Suggested fix:
// Injected here (not on ProjectService) to avoid circular dependency — mirrors CommitteeController.
private meetingService: MeetingService = new MeetingService();| } | ||
| } | ||
|
|
||
| // ── Calendar ICS Endpoint ──────────────────────────────────────────────── |
There was a problem hiding this comment.
Section-header comment — remove it.
Problem & Why: // ── Calendar ICS Endpoint ── is a section-header divider comment explaining WHAT. These are not allowed. The method JSDoc immediately below already describes the endpoint.
Suggested fix: Delete this line entirely.
|
|
||
| // ── Calendar ICS Endpoint ──────────────────────────────────────────────── | ||
|
|
||
| /** |
There was a problem hiding this comment.
Multi-line JSDoc block — one line max.
Problem & Why: Seven-line JSDoc on a public method violates the one-line-max rule. The route path is already documented in the route file and public-projects.route.ts. Rationale about the data model and circular dependency belongs in the PR description, not source code.
Suggested fix:
/** GET /public/api/projects/:id/calendar.ics — PUBLIC non-restricted meetings; serves both foundation and project lenses. */
public async getProjectCalendar(...| textColor: '#ffffff', | ||
| display: 'block', | ||
| // cursor-default on cancelled occurrences removes the pointer affordance; | ||
| // onCalendarEventClick also short-circuits when extendedProps.cancelled is true. |
There was a problem hiding this comment.
Multi-line comment block — one line max.
Problem & Why: Two-line comment explaining the classNames behaviour. One line max.
Suggested fix:
// cursor-default for cancelled occurrences; onCalendarEventClick also guards via extendedProps.cancelled.
classNames: isCancelled ? ['cursor-default'] : [],| // Generic events keep their inline backgroundColor (set via EventInput.backgroundColor), | ||
| // so meeting-type colors stay visible in Week view. The original blanket override | ||
| // forced bg-gray-100/bg-blue-50 on ALL timegrid events with !important, which combined | ||
| // with inline textColor:'#fff' rendered events as invisible white-on-near-white. |
There was a problem hiding this comment.
Multi-line comment block — one line max.
Problem & Why: Five-line comment explaining why only meeting-event classed entries are dimmed. The WHY is non-obvious (invisible text bug) and worth a comment, but the rule caps it at one line.
Suggested fix:
// Scope dimming to .meeting-event only — generic events use inline backgroundColor; !important override would hide their text.
.fc-timegrid-event.meeting-event {| // the pointer affordance and the hover lift. Higher specificity than | ||
| // the .fc-event selector above, so Tailwind's .cursor-default class | ||
| // alone wouldn't win — the explicit `cursor: default !important` | ||
| // guarantees the override regardless of source order. |
There was a problem hiding this comment.
Multi-line comment block — one line max.
Problem & Why: Five-line comment explaining the CSS specificity override for cursor. The WHY (Tailwind .cursor-default alone loses to .fc-event specificity) is non-obvious and worth one line.
Suggested fix:
// Explicit !important needed — .fc-event specificity beats Tailwind's .cursor-default alone.
&.cursor-default {| nowIndicator: true, | ||
| scrollTime: new Date().getHours() + ':00:00', | ||
| // Week view scrolls to 6am on open — earlier hours rarely have meetings | ||
| // and scrolling to "now" overshoots past the morning when checking after lunch. |
There was a problem hiding this comment.
Multi-line comment block — one line max.
Problem & Why: Two-line comment explaining the scrollTime change. Must be one line.
Suggested fix:
// Scroll to 6am — "now" overshoots when checking after lunch; most meetings are in the morning.
scrollTime: '06:00:00',| try { | ||
| // When called from the public route there is no OIDC session, so use an | ||
| // M2M token. When called from the authenticated route the user's bearer | ||
| // token is already on req and no replacement is needed. |
There was a problem hiding this comment.
Multi-line comment block — one line max.
Problem & Why: Three-line inline comment for the M2M token logic. Must be compressed to one line.
Suggested fix:
// Public route has no session — obtain M2M token so meeting service calls succeed.
if (!req.bearerToken) {|
|
||
| const query = { tags: `project_uid:${id}` }; | ||
|
|
||
| // Paginate both upcoming and past meetings — first page only would silently |
There was a problem hiding this comment.
Multi-line comment block — one line max.
Problem & Why: Two-line comment on pagination rationale. Collapse to one line.
Suggested fix:
// Fetch all pages — first-page-only would silently drop meetings beyond the default page size.
const [upcoming, past] = await Promise.all([| fetchAllMeetingPages((token) => this.meetingService.getMeetings(req, token ? { ...query, page_token: token } : query, 'v1_past_meeting', false)), | ||
| ]); | ||
|
|
||
| // Public endpoint — filter out PRIVATE / restricted meetings so the feed |
There was a problem hiding this comment.
Multi-line comment block — one line max.
Problem & Why: Three-line comment mirroring the same block in committee.controller.ts. Both must be one line.
Suggested fix:
// Filter PRIVATE/restricted meetings from the public feed (mirrors PublicMeetingController visibility guard).
const allMeetings = [...upcoming, ...past].filter((m) => m.visibility === MeetingVisibility.PUBLIC && !m.restricted);
Summary
Extends the iCal Subscribe modal shipped in LFXV2-1714 / PR #689 and adds a
[List] [Calendar]view toggle to the global Meetings dashboard for the foundation and project lenses. Also delivers the BFF backend endpoint so the Subscribe links actually resolve end-to-end.Related JIRAs: LFXV2-1771 (this), LFXV2-1770 (org lens, deferred), LFXV2-1772 (me lens, parked).
What changed
Frontend
meetings-dashboard.component.ts/.html): newviewModesignal +isListView/isCalendarViewcomputeds; newcalendarEventssignal that projects the existingfilteredMeetings()into FullCalendarEventInput[]; newonSubscribe()opensIcalSubscribeDialogComponentwith the lens-appropriate feed URL and aSubscribe — <name>header. The[List] [Calendar] [Subscribe]toggle row sits between the filter bar and the meeting list; Create Meeting stays in the title row.IcalSubscribeDialogData.committeeName→name(the dialog is no longer committee-specific). The existing committee caller is updated.Shared infrastructure
fullcalendar.component.scss): the blanketbg-gray-100/bg-blue-50 !importantoverrides on.fc-timegrid-eventare now scoped to.fc-timegrid-event.meeting-eventonly. Without this, every event with an inlinebackgroundColorrendered with white text on a near-white background in Week view, making the calendar look empty. This also fixes the same latent Week-view bug on the committee tab.scrollTimefixed at'06:00:00'(was scrolling to the current hour, which overshoots past the morning when checking after lunch). This also affects all other calendars in the app.Backend (BFF — Express in this repo)
controllers/project.controller.ts: newgetProjectCalendarmirroringgetCommitteeCalendarexactly — public access via M2M token, paginated upcoming + past meetings filtered bytags: project_uid:<id>, reusesmeetingsToVEvents+buildVCalendar, returnstext/calendarwithCache-Control: public, max-age=900.routes/public-projects.route.ts(new): single public routeGET /:id/calendar.ics.server.ts(protected file): wiresapp.use('/public/api/projects', publicProjectsRouter). Required for the route to work; flagged for code-owner attention.Shared types
packages/shared/src/interfaces/committee.interface.ts:IcalSubscribeDialogData.committeeName→name.Out of scope (intentional)
project_uid:<foundation_uid>). Sub-project meetings are not yet rolled up — would need a new upstream tag or a cross-project fan-out. Suitable for a follow-up.Test plan
/foundation/meetings): Subscribe button visible top-right of the list/calendar area; clicking opens the modal withhttps://app.lfx.dev/public/api/projects/<foundation_uid>/calendar.icsand headerSubscribe — <foundation name>./project/meetings): same UX as foundation; URL uses the project's UID.curl -i https://<dev or staging>/public/api/projects/<uid>/calendar.icsreturns200,Content-Type: text/calendar, validBEGIN:VCALENDAR ... END:VCALENDARbody.Notes for reviewers
server.tschange is protected — only adds one import and oneapp.use(...)line; pattern matches the existingpublicCommitteesRouterregistration./public/api/committees/:id/calendar.icsalready works.🤖 Generated with Claude Code