- @if ((timeFilter() === 'upcoming' && meetingsLoading()) || (timeFilter() === 'past' && pastMeetingsLoading())) {
+ @if (isCalendarView()) {
+
+ } @else if ((timeFilter() === 'upcoming' && meetingsLoading()) || (timeFilter() === 'past' && pastMeetingsLoading())) {
@for (item of [0, 1, 2]; track item) {
diff --git a/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.ts b/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.ts
index d96555a38..8344709c7 100644
--- a/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.ts
+++ b/apps/lfx-one/src/app/modules/meetings/meetings-dashboard/meetings-dashboard.component.ts
@@ -5,19 +5,24 @@ import { isPlatformBrowser } from '@angular/common';
import { Component, computed, inject, PLATFORM_ID, signal, Signal, WritableSignal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
+import { IcalSubscribeDialogComponent } from '@app/modules/committees/components/ical-subscribe-dialog/ical-subscribe-dialog.component';
import { MeetingCardComponent } from '@app/modules/meetings/components/meeting-card/meeting-card.component';
+import { FullCalendarComponent } from '@app/shared/components/fullcalendar/fullcalendar.component';
import { ButtonComponent } from '@components/button/button.component';
import { CardComponent } from '@components/card/card.component';
import { EmptyStateComponent } from '@components/empty-state/empty-state.component';
-import { MEETING_TYPE_CONFIGS } from '@lfx-one/shared/constants';
-import { Lens, Meeting, PageResult, PastMeeting, ProjectContext } from '@lfx-one/shared/interfaces';
-import { getCurrentOrNextOccurrence, hasMeetingEnded } from '@lfx-one/shared/utils';
+import { environment } from '@environments/environment';
+import { EventClickArg, EventInput } from '@fullcalendar/core';
+import { CANCELLED_COLOR, MEETING_TYPE_COLORS, MEETING_TYPE_CONFIGS } from '@lfx-one/shared/constants';
+import { Lens, Meeting, PageResult, PastMeeting, ProjectContext, ViewMode } from '@lfx-one/shared/interfaces';
+import { addMinutesToDate, getCurrentOrNextOccurrence, hasMeetingEnded } from '@lfx-one/shared/utils';
import { LensService } from '@services/lens.service';
import { MeetingService } from '@services/meeting.service';
import { PersonaService } from '@services/persona.service';
import { ProjectContextService } from '@services/project-context.service';
import { UserService } from '@services/user.service';
import { OnRenderDirective } from '@shared/directives/on-render.directive';
+import { DialogService } from 'primeng/dynamicdialog';
import {
BehaviorSubject,
catchError,
@@ -42,7 +47,8 @@ import { MeetingsTopBarComponent } from './components/meetings-top-bar/meetings-
@Component({
selector: 'lfx-meetings-dashboard',
- imports: [MeetingCardComponent, MeetingsTopBarComponent, ButtonComponent, CardComponent, OnRenderDirective, EmptyStateComponent],
+ imports: [MeetingCardComponent, MeetingsTopBarComponent, ButtonComponent, CardComponent, OnRenderDirective, EmptyStateComponent, FullCalendarComponent],
+ providers: [DialogService],
templateUrl: './meetings-dashboard.component.html',
styleUrl: './meetings-dashboard.component.scss',
})
@@ -55,9 +61,18 @@ export class MeetingsDashboardComponent {
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly platformId = inject(PLATFORM_ID);
+ private readonly dialogService = inject(DialogService);
public readonly activeLens: Signal = this.lensService.activeLens;
+ // 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.
+ public viewMode = signal('list');
+ public isListView = computed(() => this.viewMode() === 'list');
+ public isCalendarView = computed(() => this.viewMode() === 'calendar');
+ public calendarEvents: Signal;
+
public meetingsLoading: WritableSignal;
public pastMeetingsLoading: WritableSignal;
public upcomingMeetings: Signal;
@@ -191,6 +206,22 @@ export class MeetingsDashboardComponent {
// Sentinel is placed at 50% of the list to trigger auto-load as user scrolls
this.autoLoadTriggerIndex = computed(() => Math.floor(this.filteredMeetings().length / 2));
+
+ // Calendar events: source from the dashboard's NON-paginated raw signals
+ // (rawUserMeetings + rawUserPastMeetings for the Me lens;
+ // rawFpUpcomingMeetings + rawFpPastMeetings for foundation/project/org).
+ // This avoids the list-view pagination gap where the calendar would
+ // 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).
+ this.calendarEvents = 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));
+ });
}
public refreshMeetings(): void {
@@ -199,6 +230,50 @@ export class MeetingsDashboardComponent {
this.refresh$.next();
}
+ /** FullCalendar event click → navigate to the meeting detail. Cancelled occurrences are inert. */
+ public onCalendarEventClick(arg: EventClickArg): void {
+ const props = arg.event.extendedProps as { type: string; meetingId?: string; cancelled?: boolean };
+ if (props.cancelled) return;
+ if (props.type === 'meeting' && props.meetingId) {
+ void this.router.navigate(['/meetings', props.meetingId]);
+ }
+ }
+
+ /**
+ * Opens the iCal Subscribe modal for foundation / project lenses.
+ *
+ * Foundations and projects share the same upstream data model (a foundation
+ * IS a project at the data layer), so both lenses use the same backend route.
+ * The "me" lens is descoped — a personal feed requires a token-based public
+ * URL (calendar clients can't carry session cookies); tracked in LFXV2-1772.
+ * The "org" lens is tracked in LFXV2-1770.
+ */
+ public onSubscribe(): void {
+ const lens = this.activeLens();
+ const projectCtx = this.project();
+
+ if (lens !== 'foundation' && lens !== 'project') {
+ console.warn(`Subscribe is not supported on the "${lens}" lens; aborting dialog open`);
+ return;
+ }
+ if (!projectCtx?.uid) {
+ console.warn(`Subscribe clicked on ${lens} lens with no uid; aborting dialog open`);
+ return;
+ }
+
+ const feedUrl = `${environment.urls.home}/public/api/projects/${encodeURIComponent(projectCtx.uid)}/calendar.ics`;
+ const name = projectCtx.name ?? (lens === 'foundation' ? 'Foundation' : 'Project');
+
+ this.dialogService.open(IcalSubscribeDialogComponent, {
+ header: `Subscribe — ${name}`,
+ width: '480px',
+ modal: true,
+ closable: true,
+ dismissableMask: true,
+ data: { feedUrl, name },
+ });
+ }
+
public onMeetingTypeChange(value: string | null): void {
this.meetingTypeFilter.set(value);
}
@@ -711,4 +786,49 @@ export class MeetingsDashboardComponent {
() => !!this.debouncedSearchQuery() || !!this.meetingTypeFilter() || !!this.foundationFilter() || !!this.projectFilter() || this.pendingRsvpOnly()
);
}
+
+ /**
+ * Convert one Meeting (or PastMeeting) into FullCalendar EventInput entries.
+ * Recurring meetings expand to one event per occurrence; non-recurring
+ * meetings render as a single event. Mirrors CommitteeMeetingsComponent.meetingToEvents.
+ */
+ private meetingToEvents(meeting: Meeting | PastMeeting): EventInput[] {
+ const typeKey = (meeting.meeting_type ?? 'default').toLowerCase();
+ const colors = MEETING_TYPE_COLORS[typeKey] ?? MEETING_TYPE_COLORS['default'];
+
+ if (meeting.occurrences && meeting.occurrences.length > 0) {
+ return meeting.occurrences.map((occ) => {
+ const isCancelled = occ.status === 'cancel';
+ const c = isCancelled ? CANCELLED_COLOR : colors;
+ return {
+ id: `${meeting.id}-${occ.occurrence_id}`,
+ title: occ.title || meeting.title,
+ start: occ.start_time,
+ end: addMinutesToDate(occ.start_time, occ.duration ?? meeting.duration).toISOString(),
+ backgroundColor: c.bg,
+ borderColor: c.border,
+ textColor: '#ffffff',
+ display: 'block',
+ // cursor-default on cancelled occurrences removes the pointer affordance;
+ // onCalendarEventClick also short-circuits when extendedProps.cancelled is true.
+ classNames: isCancelled ? ['cursor-default'] : [],
+ extendedProps: { type: 'meeting', meetingId: meeting.id, cancelled: isCancelled },
+ };
+ });
+ }
+
+ return [
+ {
+ id: meeting.id,
+ title: meeting.title,
+ start: meeting.start_time,
+ end: addMinutesToDate(meeting.start_time, meeting.duration).toISOString(),
+ backgroundColor: colors.bg,
+ borderColor: colors.border,
+ textColor: '#ffffff',
+ display: 'block',
+ extendedProps: { type: 'meeting', meetingId: meeting.id },
+ },
+ ];
+ }
}
diff --git a/apps/lfx-one/src/app/shared/components/fullcalendar/fullcalendar.component.scss b/apps/lfx-one/src/app/shared/components/fullcalendar/fullcalendar.component.scss
index cebeea015..a7e29434e 100644
--- a/apps/lfx-one/src/app/shared/components/fullcalendar/fullcalendar.component.scss
+++ b/apps/lfx-one/src/app/shared/components/fullcalendar/fullcalendar.component.scss
@@ -21,8 +21,13 @@
}
.fc-timegrid {
+ // Past/future dimming for the legacy "meeting-event" classed entries only.
+ // 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.
.fc-timegrid-col-events {
- .fc-timegrid-event {
+ .fc-timegrid-event.meeting-event {
&:not(.fc-event-future) {
@apply bg-gray-100 #{!important};
}
@@ -86,6 +91,19 @@
@apply -translate-y-0.5 shadow-md;
}
+ // Inert events (cancelled occurrences, vote/survey markers) opt out of
+ // 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.
+ &.cursor-default {
+ cursor: default !important;
+
+ &:hover {
+ @apply translate-y-0 shadow-none;
+ }
+ }
+
&.meeting-event {
.fc-event-title {
@apply font-medium leading-tight overflow-hidden line-clamp-2 text-gray-400;
diff --git a/apps/lfx-one/src/app/shared/components/fullcalendar/fullcalendar.component.ts b/apps/lfx-one/src/app/shared/components/fullcalendar/fullcalendar.component.ts
index dc88dd500..eaea84807 100644
--- a/apps/lfx-one/src/app/shared/components/fullcalendar/fullcalendar.component.ts
+++ b/apps/lfx-one/src/app/shared/components/fullcalendar/fullcalendar.component.ts
@@ -55,7 +55,9 @@ export class FullCalendarComponent {
displayEventTime: true,
eventOrder: 'start',
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.
+ scrollTime: '06:00:00',
};
}
diff --git a/apps/lfx-one/src/server/controllers/committee.controller.ts b/apps/lfx-one/src/server/controllers/committee.controller.ts
index d4c042967..429b75f86 100644
--- a/apps/lfx-one/src/server/controllers/committee.controller.ts
+++ b/apps/lfx-one/src/server/controllers/committee.controller.ts
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: MIT
import { ALLOWED_FILE_TYPES } from '@lfx-one/shared/constants';
+import { MeetingVisibility } from '@lfx-one/shared/enums';
import {
CommitteeCreateData,
CommitteeUpdateData,
@@ -1043,13 +1044,17 @@ export class CommitteeController {
fetchAllMeetingPages((token) => this.meetingService.getMeetings(req, token ? { ...query, page_token: token } : query, 'v1_past_meeting', false)),
]);
- 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.
+ const allMeetings = [...upcoming, ...past].filter((m) => m.visibility === MeetingVisibility.PUBLIC && !m.restricted);
const events = meetingsToVEvents(allMeetings);
const ics = buildVCalendar(events);
logger.success(req, 'get_committee_calendar', startTime, {
committee_id: id,
event_count: events.length,
+ filtered_out: upcoming.length + past.length - allMeetings.length,
});
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
diff --git a/apps/lfx-one/src/server/controllers/project.controller.ts b/apps/lfx-one/src/server/controllers/project.controller.ts
index ae4ef3004..87bac4ac2 100644
--- a/apps/lfx-one/src/server/controllers/project.controller.ts
+++ b/apps/lfx-one/src/server/controllers/project.controller.ts
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: MIT
import { ALLOWED_FILE_TYPES } from '@lfx-one/shared/constants';
+import { MeetingVisibility } from '@lfx-one/shared/enums';
import { AddUserToProjectRequest, CreateProjectDocumentRequest, UpdateUserRoleRequest, UploadProjectDocumentRequest } from '@lfx-one/shared/interfaces';
import { isFileTypeAllowed, isUuid } from '@lfx-one/shared/utils';
import { NextFunction, Request, Response } from 'express';
@@ -11,10 +12,13 @@ import { ReadableStream as NodeReadableStream } from 'node:stream/web';
import { ServiceValidationError } from '../errors';
import { contentDispositionAttachment } from '../helpers/content-disposition.helper';
+import { buildVCalendar, fetchAllMeetingPages, meetingsToVEvents } from '../helpers/ics.helper';
import { getStringQueryParam } from '../helpers/validation.helper';
import { logger } from '../services/logger.service';
+import { MeetingService } from '../services/meeting.service';
import { ProjectService } from '../services/project.service';
import { getEffectiveEmail } from '../utils/auth-helper';
+import { generateM2MToken } from '../utils/m2m-token.util';
const FOLDER_UID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -23,6 +27,10 @@ const FOLDER_UID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-
*/
export class ProjectController {
private projectService: ProjectService = new ProjectService();
+ // Injected here (rather than on ProjectService) to keep the calendar endpoint's
+ // meeting access independent of ProjectService — mirrors the CommitteeController
+ // pattern that avoids a circular dependency with MeetingService.
+ private meetingService: MeetingService = new MeetingService();
/**
* GET /projects
@@ -842,4 +850,63 @@ export class ProjectController {
next(error);
}
}
+
+ // ── Calendar ICS Endpoint ────────────────────────────────────────────────
+
+ /**
+ * GET /public/api/projects/:id/calendar.ics
+ * Returns an iCalendar (.ics) file containing the project's PUBLIC, non-restricted
+ * meetings. Also serves foundation-lens subscriptions since foundations and
+ * projects share the same data model (a foundation IS a project with no parent).
+ * MeetingService is injected at the controller to avoid the same circular
+ * dependency CommitteeController works around.
+ */
+ public async getProjectCalendar(req: Request, res: Response, next: NextFunction): Promise {
+ const { id } = req.params;
+ const startTime = logger.startOperation(req, 'get_project_calendar', { project_id: id });
+
+ // Validate UID before using in query tags and Content-Disposition header
+ if (!id || !/^[A-Za-z0-9_-]+$/.test(id)) {
+ next(ServiceValidationError.forField('id', 'Invalid project ID', { operation: 'get_project_calendar' }));
+ return;
+ }
+
+ 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.
+ if (!req.bearerToken) {
+ req.bearerToken = await generateM2MToken(req);
+ }
+
+ const query = { tags: `project_uid:${id}` };
+
+ // Paginate both upcoming and past meetings — first page only would silently
+ // drop meetings once a project exceeds the default page size.
+ const [upcoming, past] = await Promise.all([
+ fetchAllMeetingPages((token) => this.meetingService.getMeetings(req, token ? { ...query, page_token: token } : query, 'v1_meeting', false)),
+ 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
+ // never exposes private metadata to anyone holding the project UID.
+ // Mirrors PublicMeetingController.getMeeting's visibility guard.
+ const allMeetings = [...upcoming, ...past].filter((m) => m.visibility === MeetingVisibility.PUBLIC && !m.restricted);
+ const events = meetingsToVEvents(allMeetings);
+ const ics = buildVCalendar(events);
+
+ logger.success(req, 'get_project_calendar', startTime, {
+ project_id: id,
+ event_count: events.length,
+ filtered_out: upcoming.length + past.length - allMeetings.length,
+ });
+
+ res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
+ res.setHeader('Content-Disposition', `attachment; filename="project-${id}.ics"`);
+ res.setHeader('Cache-Control', 'public, max-age=900'); // 15 minutes — reduces load from calendar clients polling every 15-60 minutes
+ res.send(ics);
+ } catch (error) {
+ next(error);
+ }
+ }
}
diff --git a/apps/lfx-one/src/server/routes/public-projects.route.ts b/apps/lfx-one/src/server/routes/public-projects.route.ts
new file mode 100644
index 000000000..179878754
--- /dev/null
+++ b/apps/lfx-one/src/server/routes/public-projects.route.ts
@@ -0,0 +1,17 @@
+// Copyright The Linux Foundation and each contributor to LFX.
+// SPDX-License-Identifier: MIT
+
+import { Router } from 'express';
+
+import { ProjectController } from '../controllers/project.controller';
+
+const router = Router();
+const projectController = new ProjectController();
+
+// GET /public/api/projects/:id/calendar.ics
+// Returns the iCalendar (.ics) feed for a project's (or foundation's) meetings.
+// Public access — no authentication required so external calendar clients
+// (Google Calendar, Outlook, Apple Calendar) can subscribe by URL.
+router.get('/:id/calendar.ics', (req, res, next) => projectController.getProjectCalendar(req, res, next));
+
+export default router;
diff --git a/apps/lfx-one/src/server/server.ts b/apps/lfx-one/src/server/server.ts
index f7a693454..ce0fc9a46 100644
--- a/apps/lfx-one/src/server/server.ts
+++ b/apps/lfx-one/src/server/server.ts
@@ -36,6 +36,7 @@ import profileRouter from './routes/profile.route';
import projectsRouter from './routes/projects.route';
import publicCommitteesRouter from './routes/public-committees.route';
import publicMeetingsRouter from './routes/public-meetings.route';
+import publicProjectsRouter from './routes/public-projects.route';
import rewardsRouter from './routes/rewards.route';
import searchRouter from './routes/search.route';
import surveysRouter from './routes/surveys.route';
@@ -172,6 +173,7 @@ app.use('/login', authRateLimiter);
app.use('/public/api/meetings', publicMeetingsRouter);
app.use('/public/api/committees', publicCommitteesRouter);
+app.use('/public/api/projects', publicProjectsRouter);
app.use('/api/projects', projectsRouter);
app.use('/api/committees', committeesRouter);
diff --git a/packages/shared/src/interfaces/committee.interface.ts b/packages/shared/src/interfaces/committee.interface.ts
index d0e442a29..562626d7c 100644
--- a/packages/shared/src/interfaces/committee.interface.ts
+++ b/packages/shared/src/interfaces/committee.interface.ts
@@ -713,7 +713,7 @@ export interface DescriptionDialogData {
export interface IcalSubscribeDialogData {
feedUrl: string;
- committeeName: string;
+ name: string;
}
export interface EditChairsDialogData {