-
Notifications
You must be signed in to change notification settings - Fork 0
feat(meetings): add iCal Subscribe + Calendar view to Meetings dashboard (LFXV2-1771) #697
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -186,8 +186,63 @@ <h1 class="font-display font-light text-2xl">{{ activeLens() === 'me' ? 'My Meet | |
| </div> | ||
| </div> | ||
|
|
||
| <!-- View toggle + Subscribe row — sits between the filter bar and the meeting cards. --> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Section-header comment — remove it. Problem & Why: Comments that label WHAT a block does are not allowed. The Suggested fix: Delete the comment line entirely. |
||
| <div class="flex items-center justify-end gap-2 mb-4" data-testid="meetings-view-actions"> | ||
| <div role="group" aria-label="Meetings view" class="flex items-center gap-2" data-testid="meetings-view-toggle"> | ||
| <lfx-button | ||
| label="List" | ||
| icon="fa-light fa-list-ul" | ||
| size="small" | ||
| [severity]="isListView() ? 'info' : 'secondary'" | ||
| [outlined]="!isListView()" | ||
| [ariaLabel]="'List view' + (isListView() ? ' (active)' : '')" | ||
| (onClick)="viewMode.set('list')" | ||
| data-testid="meetings-list-btn"></lfx-button> | ||
| <lfx-button | ||
| label="Calendar" | ||
| icon="fa-light fa-calendar-days" | ||
| size="small" | ||
| [severity]="isCalendarView() ? 'info' : 'secondary'" | ||
| [outlined]="!isCalendarView()" | ||
| [ariaLabel]="'Calendar view' + (isCalendarView() ? ' (active)' : '')" | ||
| (onClick)="viewMode.set('calendar')" | ||
| data-testid="meetings-calendar-btn"></lfx-button> | ||
| </div> | ||
| @if (activeLens() === 'foundation' || activeLens() === 'project') { | ||
| <lfx-button | ||
| label="Subscribe" | ||
| icon="fa-light fa-calendar-arrow-down" | ||
| severity="secondary" | ||
| [outlined]="true" | ||
| size="small" | ||
| ariaLabel="Subscribe to calendar" | ||
| (onClick)="onSubscribe()" | ||
| data-testid="meetings-subscribe-btn"></lfx-button> | ||
| } | ||
| </div> | ||
|
|
||
| <div class="min-h-[400px]"> | ||
| @if ((timeFilter() === 'upcoming' && meetingsLoading()) || (timeFilter() === 'past' && pastMeetingsLoading())) { | ||
| @if (isCalendarView()) { | ||
| <div class="flex flex-col gap-4" data-testid="meetings-calendar-view"> | ||
| <div class="flex items-center gap-4 flex-wrap px-1 text-sm text-gray-500"> | ||
| <span class="flex items-center gap-1.5"> | ||
| <span class="w-2.5 h-2.5 rounded-full bg-blue-500 inline-block"></span> | ||
| Meeting (default) | ||
| </span> | ||
| <span class="flex items-center gap-1.5"> | ||
| <span class="w-2.5 h-2.5 rounded-full bg-gray-400 inline-block"></span> | ||
| Cancelled | ||
| </span> | ||
|
manishdixitlfx marked this conversation as resolved.
|
||
| </div> | ||
| @if ((timeFilter() === 'upcoming' && meetingsLoading()) || (timeFilter() === 'past' && pastMeetingsLoading())) { | ||
| <div class="flex items-center justify-center py-20"> | ||
| <i class="fa-light fa-spinner-third fa-spin text-2xl text-gray-400" aria-hidden="true"></i> | ||
| </div> | ||
| } @else { | ||
| <lfx-fullcalendar [events]="calendarEvents()" (eventClick)="onCalendarEventClick($event)" data-testid="meetings-calendar"></lfx-fullcalendar> | ||
| } | ||
| </div> | ||
| } @else if ((timeFilter() === 'upcoming' && meetingsLoading()) || (timeFilter() === 'past' && pastMeetingsLoading())) { | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| <div class="flex flex-col gap-4"> | ||
| @for (item of [0, 1, 2]; track item) { | ||
| <div class="bg-white border border-gray-200 rounded-lg p-6 animate-pulse"> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Lens> = 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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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. Suggested fix: Delete the comment block entirely. If a reminder about filter applicability is needed, a single line on |
||
| public viewMode = signal<ViewMode>('list'); | ||
| public isListView = computed(() => this.viewMode() === 'list'); | ||
| public isCalendarView = computed(() => this.viewMode() === 'calendar'); | ||
| public calendarEvents: Signal<EventInput[]>; | ||
|
|
||
| public meetingsLoading: WritableSignal<boolean>; | ||
| public pastMeetingsLoading: WritableSignal<boolean>; | ||
| public upcomingMeetings: Signal<Meeting[]>; | ||
|
|
@@ -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). | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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(); |
||
| this.calendarEvents = computed(() => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Multi-line Problem & Why: This component initializes every other complex signal via 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));
});
} |
||
| 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]); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 { |
||
| * 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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 Suggested fix: /** Expands one Meeting/PastMeeting into EventInput entries; recurring meetings produce one entry per occurrence. */
private meetingToEvents(meeting: Meeting | PastMeeting): EventInput[] { |
||
| * 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 }, | ||
| }; | ||
|
manishdixitlfx marked this conversation as resolved.
|
||
| }); | ||
| } | ||
|
|
||
| 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 }, | ||
| }, | ||
| ]; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Multi-line comment block — one line max. Problem & Why: Five-line comment explaining why only Suggested fix: // Scope dimming to .meeting-event only — generic events use inline backgroundColor; !important override would hide their text.
.fc-timegrid-event.meeting-event { |
||
| .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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 { |
||
| &.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; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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', |
||
| scrollTime: '06:00:00', | ||
| }; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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); |
||
| 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'); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Multi-line comment block — one line max.
Problem & Why: Two-line comment explaining the classNames behaviour. One line max.
Suggested fix: