Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ <h2 class="text-lg font-semibold text-gray-900">Meetings</h2>
<!-- Legend -->
<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-600 inline-block"></span>
<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">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,15 @@ export class CommitteeMeetingsComponent {
dismissableMask: true,
data: {
feedUrl,
committeeName,
name: committeeName,
},
});
}

/** Handles FullCalendar event click — navigates to meeting detail for meeting events. */
/** Handles FullCalendar event click — navigates to meeting detail. Cancelled occurrences are inert. */
public onCalendarEventClick(arg: EventClickArg): void {
const props = arg.event.extendedProps as { type: string; meetingId?: string };
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]);
}
Expand Down Expand Up @@ -271,6 +272,9 @@ export class CommitteeMeetingsComponent {
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.
Copy link
Copy Markdown
Contributor

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:

// cursor-default for cancelled occurrences; onCalendarEventClick also guards via extendedProps.cancelled.
classNames: isCancelled ? ['cursor-default'] : [],

classNames: isCancelled ? ['cursor-default'] : [],
extendedProps: { type: 'meeting', meetingId: meeting.id, cancelled: isCancelled },
Comment thread
manishdixitlfx marked this conversation as resolved.
};
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,14 @@ export class IcalSubscribeDialogComponent {
private readonly destroyRef = inject(DestroyRef);

public readonly feedUrl = this.dialogConfig.data?.feedUrl ?? '';
public readonly committeeName = this.dialogConfig.data?.committeeName ?? 'Committee';
public readonly name = this.dialogConfig.data?.name ?? 'Calendar';

public readonly googleCalendarUrl = this.feedUrl ? `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(toWebcal(this.feedUrl))}` : '';
public readonly outlookLiveUrl = this.feedUrl
? `https://outlook.live.com/calendar/0/addfromweb?url=${encodeURIComponent(this.feedUrl)}&name=${encodeURIComponent(this.committeeName)}`
? `https://outlook.live.com/calendar/0/addfromweb?url=${encodeURIComponent(this.feedUrl)}&name=${encodeURIComponent(this.name)}`
: '';
public readonly outlook365Url = this.feedUrl
? `https://outlook.office.com/calendar/0/addfromweb?url=${encodeURIComponent(this.feedUrl)}&name=${encodeURIComponent(this.committeeName)}`
? `https://outlook.office.com/calendar/0/addfromweb?url=${encodeURIComponent(this.feedUrl)}&name=${encodeURIComponent(this.name)}`
: '';
public readonly webcalUrl = this.feedUrl ? toWebcal(this.feedUrl) : '';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. -->
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 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.

<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>
Comment thread
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())) {
Comment thread
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">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
})
Expand All @@ -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.
Copy link
Copy Markdown
Contributor

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: 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.

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[]>;
Expand Down Expand Up @@ -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).
Copy link
Copy Markdown
Contributor

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: 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(() => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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));
  });
}

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 {
Expand All @@ -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]);
}
}

/**
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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);
}
Expand Down Expand Up @@ -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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 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[] {

* 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 },
};
Comment thread
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
Expand Up @@ -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.
Copy link
Copy Markdown
Contributor

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: 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 {

.fc-timegrid-col-events {
.fc-timegrid-event {
.fc-timegrid-event.meeting-event {
&:not(.fc-event-future) {
@apply bg-gray-100 #{!important};
}
Expand Down Expand Up @@ -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.
Copy link
Copy Markdown
Contributor

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: 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Copy Markdown
Contributor

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 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',
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Copy link
Copy Markdown
Contributor

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: 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');
Expand Down
Loading
Loading