diff --git a/apps/lfx-pcc/src/app/app.routes.ts b/apps/lfx-pcc/src/app/app.routes.ts index 2cfc6094..ac34c270 100644 --- a/apps/lfx-pcc/src/app/app.routes.ts +++ b/apps/lfx-pcc/src/app/app.routes.ts @@ -9,6 +9,7 @@ import { CommitteeDashboardComponent } from './modules/project/committees/commit import { CommitteeViewComponent } from './modules/project/committees/committee-view/committee-view.component'; import { MailingListDashboardComponent } from './modules/project/mailing-lists/mailing-list-dashboard/mailing-list-dashboard.component'; import { MeetingDashboardComponent } from './modules/project/meetings/meeting-dashboard/meeting-dashboard.component'; +import { MeetingCreateComponent } from './modules/project/meetings/components/meeting-create/meeting-create.component'; import { SettingsDashboardComponent } from './modules/project/settings/settings-dashboard/settings-dashboard.component'; import { ProjectComponent } from './modules/project/dashboard/project-dashboard/project.component'; @@ -29,6 +30,10 @@ export const routes: Routes = [ path: 'meetings', component: MeetingDashboardComponent, }, + { + path: 'meetings/create', + component: MeetingCreateComponent, + }, { path: 'committees', component: CommitteeDashboardComponent, diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-create/meeting-create.component.html b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-create/meeting-create.component.html new file mode 100644 index 00000000..acfaab1d --- /dev/null +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-create/meeting-create.component.html @@ -0,0 +1,108 @@ + + + +
+
+ +
+
+

Create Meeting

+
Step {{ currentStep() + 1 }} of {{ totalSteps }}
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+

Step 3: Platform & Features - To be implemented

+
+
+
+ + + +
+

Step 4: Participants - To be implemented

+
+
+
+ + + +
+

Step 5: Resources & Summary - To be implemented

+
+
+
+
+
+
+ + +
+
+ + + +
+ + + @if (!isLastStep()) { + + + } @else { + + + } +
+
+
+
+
diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-create/meeting-create.component.ts b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-create/meeting-create.component.ts new file mode 100644 index 00000000..1a04edaf --- /dev/null +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-create/meeting-create.component.ts @@ -0,0 +1,462 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { CommonModule } from '@angular/common'; +import { Component, computed, DestroyRef, effect, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, ValidatorFn, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { ButtonComponent } from '@components/button/button.component'; +import { MeetingVisibility, RecurrenceType } from '@lfx-pcc/shared/enums'; +import { CreateMeetingRequest, MeetingRecurrence } from '@lfx-pcc/shared/interfaces'; +import { getUserTimezone } from '@lfx-pcc/shared/utils'; +import { MeetingService } from '@services/meeting.service'; +import { ProjectService } from '@services/project.service'; +import { MessageService } from 'primeng/api'; +import { StepperModule } from 'primeng/stepper'; + +import { MeetingDetailsComponent } from '../meeting-details/meeting-details.component'; +import { MeetingTypeSelectionComponent } from '../meeting-type-selection/meeting-type-selection.component'; + +@Component({ + selector: 'lfx-meeting-create', + standalone: true, + imports: [CommonModule, StepperModule, ButtonComponent, ReactiveFormsModule, MeetingTypeSelectionComponent, MeetingDetailsComponent], + templateUrl: './meeting-create.component.html', +}) +export class MeetingCreateComponent { + private readonly router = inject(Router); + private readonly meetingService = inject(MeetingService); + private readonly projectService = inject(ProjectService); + private readonly messageService = inject(MessageService); + private readonly destroyRef = inject(DestroyRef); + + // Stepper state + public currentStep = signal(0); + public readonly totalSteps = 5; + + // Form state + public form = signal(this.createMeetingFormGroup()); + public submitting = signal(false); + + // Validation signals for template + public readonly canProceed = signal(false); + public readonly canGoNext = computed(() => { + const next = this.currentStep() + 1; + return next < this.totalSteps && this.canNavigateToStep(next); + }); + public readonly canGoPrevious = computed(() => this.currentStep() > 0); + public readonly isFirstStep = computed(() => this.currentStep() === 0); + public readonly isLastStep = computed(() => this.currentStep() === this.totalSteps - 1); + public readonly currentStepTitle = computed(() => this.getStepTitle(this.currentStep())); + + public constructor() { + // Subscribe to form value changes and update validation signals + this.form() + .valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.updateCanProceed(); + }); + + // Use effect to watch for step changes and re-validate + effect(() => { + // Access the signal to create dependency + this.currentStep(); + // Update validation when step changes + this.updateCanProceed(); + }); + } + + // Navigation methods + public goToStep(step: number | undefined): void { + if (step !== undefined && this.canNavigateToStep(step)) { + this.currentStep.set(step); + this.scrollToStepper(); + } + } + + public nextStep(): void { + const next = this.currentStep() + 1; + if (next < this.totalSteps && this.canNavigateToStep(next)) { + this.currentStep.set(next); + this.scrollToStepper(); + } + } + + public previousStep(): void { + const previous = this.currentStep() - 1; + if (previous >= 0) { + this.currentStep.set(previous); + this.scrollToStepper(); + } + } + + public onCancel(): void { + const project = this.projectService.project(); + if (project?.slug) { + this.router.navigate(['/project', project.slug, 'meetings']); + } + } + + public onSubmit(): void { + // Mark all form controls as touched to show validation errors + Object.keys(this.form().controls).forEach((key) => { + const control = this.form().get(key); + control?.markAsTouched(); + control?.markAsDirty(); + }); + + if (this.form().invalid) { + return; + } + + const project = this.projectService.project(); + if (!project) { + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Project information is required to create a meeting.', + }); + return; + } + + this.submitting.set(true); + const formValue = this.form().value; + + // Process duration value - ensure it's always a number + const duration = formValue.duration === 'custom' ? Number(formValue.customDuration) : Number(formValue.duration); + + // Combine date and time for start_time + const startDateTime = this.combineDateTime(formValue.startDate, formValue.startTime); + + // Generate recurrence object if needed + const recurrenceObject = this.generateRecurrenceObject(formValue.recurrence, formValue.startDate); + + // Create meeting data + const meetingData: CreateMeetingRequest = { + project_uid: project.uid, + topic: formValue.topic, + agenda: formValue.agenda || '', + start_time: startDateTime, + duration: duration, + timezone: formValue.timezone, + meeting_type: formValue.meeting_type || 'None', + early_join_time: formValue.early_join_time || 10, + visibility: formValue.show_in_public_calendar ? MeetingVisibility.PUBLIC : MeetingVisibility.PRIVATE, + restricted: formValue.restricted || false, + recording_enabled: formValue.recording_enabled || false, + transcripts_enabled: formValue.transcripts_enabled || false, + youtube_enabled: formValue.youtube_enabled || false, + zoom_ai_enabled: formValue.zoom_ai_enabled || false, + require_ai_summary_approval: formValue.require_ai_summary_approval || false, + ai_summary_access: formValue.ai_summary_access || 'PCC', + recording_access: formValue.recording_access || 'Members', + recurrence: recurrenceObject, + }; + + this.meetingService.createMeeting(meetingData).subscribe({ + next: () => { + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'Meeting created successfully', + }); + this.router.navigate(['/project', project.slug, 'meetings']); + }, + error: (error) => { + console.error('Error creating meeting:', error); + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to create meeting. Please try again.', + }); + this.submitting.set(false); + }, + }); + } + + private canNavigateToStep(step: number): boolean { + // Allow navigation to previous steps or current step + if (step <= this.currentStep()) { + return true; + } + + // For forward navigation, validate all previous steps + for (let i = 0; i < step; i++) { + if (!this.isStepValid(i)) { + return false; + } + } + return true; + } + + private updateCanProceed(): void { + const isValid = this.isStepValid(this.currentStep()); + this.canProceed.set(isValid); + } + + private isStepValid(step: number): boolean { + const form = this.form(); + + switch (step) { + case 0: // Meeting Type + return !!form.get('meeting_type')?.value && form.get('meeting_type')?.value !== ''; + + case 1: // Meeting Details + return !!( + form.get('topic')?.value && + form.get('agenda')?.value && + form.get('startDate')?.value && + form.get('startTime')?.value && + form.get('timezone')?.value && + form.get('topic')?.valid && + form.get('startDate')?.valid && + form.get('startTime')?.valid && + !form.errors?.['futureDateTime'] + ); + + case 2: // Platform & Features + return !!form.get('meetingTool')?.value; + + case 3: // Participants (optional but should not have validation errors) + return true; + + case 4: // Resources & Summary (optional) + return true; + + default: + return false; + } + } + + private createMeetingFormGroup(): FormGroup { + const defaultDateTime = this.getDefaultStartDateTime(); + + return new FormGroup( + { + // Step 1: Meeting Type + meeting_type: new FormControl('', [Validators.required]), + show_in_public_calendar: new FormControl(false), + restricted: new FormControl(false), + + // Step 2: Meeting Details + topic: new FormControl('', [Validators.required]), + agenda: new FormControl('', [Validators.required]), + aiPrompt: new FormControl(''), + startDate: new FormControl(defaultDateTime.date, [Validators.required]), + startTime: new FormControl(defaultDateTime.time, [Validators.required]), + duration: new FormControl(60, [Validators.required]), + customDuration: new FormControl(''), + timezone: new FormControl(getUserTimezone(), [Validators.required]), + early_join_time: new FormControl(10, [Validators.min(10), Validators.max(60)]), + isRecurring: new FormControl(false), + recurrence: new FormControl('none'), + + // Step 3: Platform & Features + meetingTool: new FormControl('', [Validators.required]), + recording_enabled: new FormControl(false), + transcripts_enabled: new FormControl(false), + youtube_enabled: new FormControl(false), + zoom_ai_enabled: new FormControl(false), + require_ai_summary_approval: new FormControl(false), + ai_summary_access: new FormControl('PCC'), + recording_access: new FormControl('Members'), + + // Step 4: Participants + guestEmails: new FormControl([]), + + // Step 5: Resources + importantLinks: new FormControl<{ title: string; url: string }[]>([]), + }, + { validators: this.futureDateTimeValidator() } + ); + } + + private getDefaultStartDateTime(): { date: Date; time: string } { + const now = new Date(); + // Add 1 hour to current time + now.setHours(now.getHours() + 1); + + // Round up to next 15 minutes + const minutes = now.getMinutes(); + const roundedMinutes = Math.ceil(minutes / 15) * 15; + now.setMinutes(roundedMinutes); + now.setSeconds(0); + now.setMilliseconds(0); + + // If rounding pushed us to next hour, adjust accordingly + if (roundedMinutes === 60) { + now.setHours(now.getHours() + 1); + now.setMinutes(0); + } + + // Format time to 12-hour format (HH:MM AM/PM) + const hours = now.getHours(); + const mins = now.getMinutes(); + const period = hours >= 12 ? 'PM' : 'AM'; + let displayHours = hours > 12 ? hours - 12 : hours; + if (displayHours === 0) { + displayHours = 12; + } + const timeString = `${displayHours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')} ${period}`; + + return { + date: new Date(now), + time: timeString, + }; + } + + private futureDateTimeValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const formGroup = control as FormGroup; + const startDate = formGroup.get('startDate')?.value; + const startTime = formGroup.get('startTime')?.value; + const timezone = formGroup.get('timezone')?.value; + + if (!startDate || !startTime || !timezone) { + return null; // Don't validate if values are not set + } + + // Combine the date and time + const combinedDateTime = this.combineDateTime(startDate, startTime); + if (!combinedDateTime) { + return null; // Invalid time format + } + + // Parse the combined datetime + const selectedDate = new Date(combinedDateTime); + + // Get current time in the selected timezone + const now = new Date(); + + // Create timezone-aware date strings for comparison + const selectedTimeString = selectedDate.toLocaleString('en-US', { timeZone: timezone }); + const currentTimeString = now.toLocaleString('en-US', { timeZone: timezone }); + + // Convert back to Date objects for comparison + const selectedTimeInZone = new Date(selectedTimeString); + const currentTimeInZone = new Date(currentTimeString); + + // Check if the selected time is in the future + if (selectedTimeInZone <= currentTimeInZone) { + return { futureDateTime: true }; + } + + return null; + }; + } + + private combineDateTime(date: Date, time: string): string { + if (!date || !time) return ''; + + // Parse the 12-hour format time (e.g., "12:45 AM" or "1:30 PM") + const match = time.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i); + if (!match) { + console.error('Invalid time format:', time); + return ''; + } + + let hours = parseInt(match[1], 10); + const minutes = parseInt(match[2], 10); + const period = match[3].toUpperCase(); + + // Convert to 24-hour format + if (period === 'PM' && hours !== 12) { + hours += 12; + } else if (period === 'AM' && hours === 12) { + hours = 0; + } + + // Create a new date object with the selected date and time + const combinedDate = new Date(date); + combinedDate.setHours(hours, minutes, 0, 0); + + // Return ISO string + return combinedDate.toISOString(); + } + + private generateRecurrenceObject(recurrenceType: string, startDate: Date): MeetingRecurrence | undefined { + if (recurrenceType === 'none') { + return undefined; + } + + const dayOfWeek = startDate.getDay() + 1; // Zoom API uses 1-7 (Sunday=1) + const { weekOfMonth } = this.getWeekOfMonth(startDate); + + switch (recurrenceType) { + case 'daily': + return { + type: RecurrenceType.DAILY, + repeat_interval: 1, + }; + + case 'weekly': + return { + type: RecurrenceType.WEEKLY, + repeat_interval: 1, + weekly_days: dayOfWeek.toString(), + }; + + case 'monthly_nth': + return { + type: RecurrenceType.MONTHLY, + repeat_interval: 1, + monthly_week: weekOfMonth, + monthly_week_day: dayOfWeek, + }; + + case 'monthly_last': + return { + type: RecurrenceType.MONTHLY, + repeat_interval: 1, + monthly_week: -1, + monthly_week_day: dayOfWeek, + }; + + case 'weekdays': + return { + type: RecurrenceType.WEEKLY, + repeat_interval: 1, + weekly_days: '2,3,4,5,6', // Monday through Friday + }; + + default: + return undefined; + } + } + + private getWeekOfMonth(date: Date): { weekOfMonth: number; isLastWeek: boolean } { + // Find the first occurrence of this day of week in the month + const targetDayOfWeek = date.getDay(); + let firstOccurrence = 1; + while (new Date(date.getFullYear(), date.getMonth(), firstOccurrence).getDay() !== targetDayOfWeek) { + firstOccurrence++; + } + + // Calculate which week this date is in + const weekOfMonth = Math.floor((date.getDate() - firstOccurrence) / 7) + 1; + + // Check if this is the last occurrence of this day in the month + const nextWeekDate = new Date(date.getTime() + 7 * 24 * 60 * 60 * 1000); + const isLastWeek = nextWeekDate.getMonth() !== date.getMonth(); + + return { weekOfMonth, isLastWeek }; + } + + private getStepTitle(step: number): string { + const titles = ['Meeting Type', 'Meeting Details', 'Platform & Features', 'Participants', 'Resources & Summary']; + return titles[step] || ''; + } + + private scrollToStepper(): void { + // Find the meeting-create element and scroll to it minus 100px + const meetingCreate = document.getElementById('meeting-create'); + if (meetingCreate) { + const elementTop = meetingCreate.getBoundingClientRect().top + window.pageYOffset; + window.scrollTo({ + top: elementTop - 50, + behavior: 'smooth', + }); + } + } +} diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-details/meeting-details.component.html b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-details/meeting-details.component.html new file mode 100644 index 00000000..be4f30db --- /dev/null +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-details/meeting-details.component.html @@ -0,0 +1,286 @@ + + + +
+ +
+ +
+ + + @if (titleWasAutoGenerated()) { +

This title was auto-generated based on your meeting type and date. You can customize it.

+ } + @if (form().get('topic')?.errors?.['required'] && form().get('topic')?.touched) { +

Meeting title is required

+ } +
+ + +
+
+ + @if (!showAiHelper()) { + + } +
+ + @if (showAiHelper()) { +
+
+
+ +
+
+

AI Agenda Generator

+

Tell me what you want to accomplish in this meeting, and I'll create a structured agenda for you.

+
+
+ +
+ + +
+ + + +
+
+
+ } + + +

A clear agenda helps participants prepare and keeps discussions focused

+ @if (form().get('agenda')?.errors?.['required'] && form().get('agenda')?.touched) { +

Meeting agenda is required

+ } +
+
+ + +
+

Date & Time

+ +
+ +
+ + + @if (form().get('startDate')?.errors?.['required'] && form().get('startDate')?.touched) { +

Start date is required

+ } +
+ + +
+ + + @if (form().get('startTime')?.errors?.['required'] && form().get('startTime')?.touched) { +

Start time is required

+ } +
+ + +
+ + + @if (form().get('duration')?.errors?.['required'] && form().get('duration')?.touched) { +

Duration is required

+ } +
+
+ + + @if (form().errors?.['futureDateTime'] && (form().get('startDate')?.touched || form().get('startTime')?.touched)) { +

Meeting must be scheduled in the future

+ } + + + @if (form().get('duration')?.value === 'custom') { +
+ + + @if (form().get('customDuration')?.errors?.['required'] && form().get('customDuration')?.touched) { +

Custom duration is required

+ } + @if (form().get('customDuration')?.errors?.['min'] && form().get('customDuration')?.touched) { +

Custom duration must be at least 5 minutes

+ } + @if (form().get('customDuration')?.errors?.['max'] && form().get('customDuration')?.touched) { +

Custom duration cannot exceed 480 minutes (8 hours)

+ } +
+ } + + +
+ + + @if (form().get('timezone')?.errors?.['required'] && form().get('timezone')?.touched) { +

Timezone is required

+ } +
+ + +
+
+
+ +
+ +

This meeting repeats on a schedule

+
+
+ +
+
+ + @if (form().get('isRecurring')?.value) { +
+
+
+ +
+
+

+ Recurring meetings are great for regular touchpoints like weekly standups, monthly reviews, or quarterly planning sessions. +

+
+
+
+ } + + + @if (form().get('isRecurring')?.value) { +
+
+ + +
+ +
+ } + + +
+
+ + +
+ + @if (form().get('early_join_time')?.errors?.['min'] && form().get('early_join_time')?.touched) { +

Early join time must be at least 10 minutes

+ } + @if (form().get('early_join_time')?.errors?.['max'] && form().get('early_join_time')?.touched) { +

Early join time cannot exceed 60 minutes

+ } +
+
+
diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-details/meeting-details.component.ts b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-details/meeting-details.component.ts new file mode 100644 index 00000000..dc5c34d8 --- /dev/null +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-details/meeting-details.component.ts @@ -0,0 +1,349 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { CommonModule } from '@angular/common'; +import { Component, computed, DestroyRef, inject, input, OnInit, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ButtonComponent } from '@components/button/button.component'; +import { CalendarComponent } from '@components/calendar/calendar.component'; +import { InputTextComponent } from '@components/input-text/input-text.component'; +import { SelectComponent } from '@components/select/select.component'; +import { TextareaComponent } from '@components/textarea/textarea.component'; +import { TimePickerComponent } from '@components/time-picker/time-picker.component'; +import { ToggleComponent } from '@components/toggle/toggle.component'; +import { TIMEZONES } from '@lfx-pcc/shared/constants'; +import { MeetingType } from '@lfx-pcc/shared/enums'; +import { TooltipModule } from 'primeng/tooltip'; + +@Component({ + selector: 'lfx-meeting-details', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + ButtonComponent, + CalendarComponent, + InputTextComponent, + SelectComponent, + TextareaComponent, + TimePickerComponent, + ToggleComponent, + TooltipModule, + ], + templateUrl: './meeting-details.component.html', +}) +export class MeetingDetailsComponent implements OnInit { + // Form group input from parent + public readonly form = input.required(); + + // Dependency injection + private readonly destroyRef = inject(DestroyRef); + + // AI Agenda Helper signals + public readonly showAiHelper = signal(false); + public readonly isGeneratingAgenda = signal(false); + + // Auto-title generation signals + public readonly titleWasAutoGenerated = signal(false); + + // Duration options for the select dropdown + public readonly durationOptions = [ + { label: '15 minutes', value: 15 }, + { label: '30 minutes', value: 30 }, + { label: '60 minutes', value: 60 }, + { label: '90 minutes', value: 90 }, + { label: '120 minutes', value: 120 }, + { label: 'Custom...', value: 'custom' }, + ]; + + // Recurrence options (dynamically updated based on selected date) + public recurrenceOptions = signal>([]); + + // Timezone options from shared constants + public readonly timezoneOptions = TIMEZONES.map((tz) => ({ + label: `${tz.label} (${tz.offset})`, + value: tz.value, + })); + + // Minimum date (yesterday) + public readonly minDate = computed(() => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); + return yesterday; + }); + + public ngOnInit(): void { + // Initialize recurrence options with current start date + const initialStartDate = this.form().get('startDate')?.value; + if (initialStartDate) { + this.generateRecurrenceOptions(initialStartDate); + } else { + this.recurrenceOptions.set([{ label: 'Does not repeat', value: 'none' }]); + } + + // Add custom duration validator when duration is 'custom' + this.form() + .get('duration') + ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { + const customDurationControl = this.form().get('customDuration'); + if (value === 'custom') { + customDurationControl?.setValidators([Validators.required, Validators.min(5), Validators.max(480)]); + } else { + customDurationControl?.clearValidators(); + } + customDurationControl?.updateValueAndValidity(); + }); + + // Reset recurrence selection when start date changes + this.form() + .get('startDate') + ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { + // Reset recurrence to 'none' when date changes to avoid confusion + this.form().get('recurrence')?.setValue('none'); + this.generateRecurrenceOptions(value as Date); + }); + + // Auto-generate title when meeting type and date are available + this.form() + .get('startDate') + ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.generateMeetingTitleIfNeeded(); + }); + + // Watch for isRecurring changes to reset recurrence + this.form() + .get('isRecurring') + ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((isRecurring) => { + if (!isRecurring) { + this.form().get('recurrence')?.setValue('none'); + } else { + const recurrence = this.form().get('recurrence')?.value; + if (!recurrence || recurrence === 'none') { + this.form().get('recurrence')?.setValue('weekly'); + } + } + }); + } + + // AI Helper public methods + public showAiAgendaHelper(): void { + this.showAiHelper.set(true); + } + + public hideAiAgendaHelper(): void { + this.showAiHelper.set(false); + this.form().get('aiPrompt')?.setValue(''); + } + + public async generateAiAgenda(): Promise { + const promptValue = this.form().get('aiPrompt')?.value; + if (!promptValue?.trim()) return; + + this.isGeneratingAgenda.set(true); + + // Simulate API call delay + await new Promise((resolve) => setTimeout(resolve, 2500)); + + const meetingType = this.form().get('meeting_type')?.value || MeetingType.OTHER; + const generatedAgenda = this.getMockAgenda(meetingType, promptValue); + + this.form().get('agenda')?.setValue(generatedAgenda); + this.isGeneratingAgenda.set(false); + this.hideAiAgendaHelper(); + } + + private generateRecurrenceOptions(date: Date): void { + const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + const dayName = dayNames[date.getDay()]; + + // Calculate which occurrence of the day in the month (1st, 2nd, 3rd, 4th, or last) + const { weekOfMonth, isLastWeek } = this.getWeekOfMonth(date); + const ordinals = ['', '1st', '2nd', '3rd', '4th']; + const ordinal = ordinals[weekOfMonth] || `${weekOfMonth}th`; + + const options = [ + { label: 'Daily', value: 'daily' }, + { label: `Weekly on ${dayName}`, value: 'weekly' }, + { label: 'Every weekday', value: 'weekdays' }, + ]; + + // If this is the last occurrence, show "Monthly on the last [day]" instead of "Monthly on the Nth [day]" + if (isLastWeek) { + options.splice(3, 0, { label: `Monthly on the last ${dayName}`, value: 'monthly_last' }); + } else { + options.splice(3, 0, { label: `Monthly on the ${ordinal} ${dayName}`, value: 'monthly_nth' }); + } + + this.recurrenceOptions.set(options); + } + + private getWeekOfMonth(date: Date): { weekOfMonth: number; isLastWeek: boolean } { + // Find the first occurrence of this day of week in the month + const targetDayOfWeek = date.getDay(); + let firstOccurrence = 1; + while (new Date(date.getFullYear(), date.getMonth(), firstOccurrence).getDay() !== targetDayOfWeek) { + firstOccurrence++; + } + + // Calculate which week this date is in + const weekOfMonth = Math.floor((date.getDate() - firstOccurrence) / 7) + 1; + + // Check if this is the last occurrence of this day in the month + const nextWeekDate = new Date(date.getTime() + 7 * 24 * 60 * 60 * 1000); + const isLastWeek = nextWeekDate.getMonth() !== date.getMonth(); + + return { weekOfMonth, isLastWeek }; + } + + // Auto-title generation + private generateMeetingTitleIfNeeded(): void { + const meetingType = this.form().get('meeting_type')?.value; + const startDate = this.form().get('startDate')?.value; + const currentTitle = this.form().get('topic')?.value; + + // Only auto-generate if we have both type and date, and the current title is empty or was auto-generated + if (meetingType && startDate && (!currentTitle || this.titleWasAutoGenerated())) { + const newTitle = this.generateMeetingTitle(meetingType, startDate); + this.form().get('topic')?.setValue(newTitle); + this.titleWasAutoGenerated.set(true); + } + } + + private generateMeetingTitle(meetingType: string, date: Date): string { + const formattedDate = new Date(date).toLocaleDateString('en-US', { + month: '2-digit', + day: '2-digit', + year: 'numeric', + }); + + return `${meetingType} Meeting - ${formattedDate}`; + } + + private getMockAgenda(meetingType: string, prompt: string): string { + const mockAgendas: Record = { + [MeetingType.BOARD]: `**Meeting Objective**: ${prompt} + +**Agenda Items**: +1. **Opening & Welcome** (5 min) + - Roll call and attendance + - Review of previous meeting minutes + +2. **Strategic Discussion** (25 min) + - ${prompt} + - Financial overview and budget considerations + - Key performance indicators review + +3. **Decision Items** (15 min) + - Action items requiring board approval + - Risk assessment and mitigation strategies + +4. **Next Steps & Closing** (5 min) + - Assignment of action items + - Next meeting date confirmation`, + + [MeetingType.TECHNICAL]: `**Development Focus**: ${prompt} + +**Technical Agenda**: +1. **System Status Review** (10 min) + - Current sprint progress + - Infrastructure health check + +2. **Core Discussion** (30 min) + - ${prompt} + - Technical implementation approach + - Architecture considerations and trade-offs + +3. **Code Review & Quality** (15 min) + - Recent pull requests and code changes + - Testing coverage and quality metrics + +4. **Planning & Blockers** (5 min) + - Upcoming milestones + - Technical blockers and dependencies`, + + [MeetingType.MAINTAINERS]: `**Community Focus**: ${prompt} + +**Maintainers Sync Agenda**: +1. **Community Updates** (10 min) + - Recent contributor activity + - Community feedback highlights + +2. **Project Discussion** (25 min) + - ${prompt} + - Release planning and roadmap updates + - Contributor onboarding improvements + +3. **Issue Triage** (20 min) + - High-priority issues review + - Feature requests evaluation + +4. **Action Planning** (5 min) + - Task assignments and next steps`, + + [MeetingType.MARKETING]: `**Marketing Initiative**: ${prompt} + +**Marketing Meeting Agenda**: +1. **Performance Review** (10 min) + - Current campaign metrics + - Community growth statistics + +2. **Strategic Focus** (25 min) + - ${prompt} + - Brand positioning and messaging + - Content strategy alignment + +3. **Campaign Planning** (20 min) + - Upcoming marketing initiatives + - Budget allocation and resources + +4. **Collaboration & Next Steps** (5 min) + - Cross-team coordination + - Action item assignments`, + + [MeetingType.LEGAL]: `**Legal Review**: ${prompt} + +**Legal Meeting Agenda**: +1. **Compliance Overview** (10 min) + - Current legal standing + - Recent regulatory changes + +2. **Focus Discussion** (30 min) + - ${prompt} + - Legal risk assessment + - Policy and procedure updates + +3. **Documentation Review** (15 min) + - Contract updates and amendments + - Terms of service modifications + +4. **Action Items** (5 min) + - Legal task assignments + - Timeline for deliverables`, + + [MeetingType.OTHER]: `**Meeting Purpose**: ${prompt} + +**General Meeting Agenda**: +1. **Welcome & Introductions** (10 min) + - Participant introductions + - Meeting objectives overview + +2. **Main Discussion** (35 min) + - ${prompt} + - Open discussion and brainstorming + - Key points and considerations + +3. **Summary & Next Steps** (15 min) + - Key takeaways summary + - Action item assignments + - Follow-up meeting planning`, + }; + + return mockAgendas[meetingType] || mockAgendas[MeetingType.OTHER]; + } +} diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-type-selection/meeting-type-selection.component.html b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-type-selection/meeting-type-selection.component.html new file mode 100644 index 00000000..a56e70f5 --- /dev/null +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-type-selection/meeting-type-selection.component.html @@ -0,0 +1,70 @@ + + + +
+
+

Meeting Type

+

What kind of meeting are you organizing for your open source project?

+
+ +
+
+
+ +
+
+

Why meetings matter for open source projects

+

+ Regular meetings help maintain project momentum, align contributors, make important decisions, and build community. They provide structure and + transparency that keeps everyone on the same page. +

+
+
+
+ +
+
+ +
+ @for (option of meetingTypeOptions; track option.value) { + @let typeInfo = getMeetingTypeInfo(option.value); +
+
+
+ +
+
+

{{ option.label }}

+

{{ typeInfo.description }}

+

Examples: {{ typeInfo.examples }}

+
+
+
+ } +
+ @if (form().get('meeting_type')?.errors?.['required'] && form().get('meeting_type')?.touched) { +

Meeting type is required

+ } +
+ +
+
+
+ +
+ +

Restrict access to invited participants only

+
+
+ +
+
+
+
diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-type-selection/meeting-type-selection.component.ts b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-type-selection/meeting-type-selection.component.ts new file mode 100644 index 00000000..accbb540 --- /dev/null +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-type-selection/meeting-type-selection.component.ts @@ -0,0 +1,94 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { CommonModule } from '@angular/common'; +import { Component, input } from '@angular/core'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { ToggleComponent } from '@components/toggle/toggle.component'; +import { MeetingType } from '@lfx-pcc/shared/enums'; +import { TooltipModule } from 'primeng/tooltip'; + +interface MeetingTypeInfo { + icon: string; + description: string; + examples: string; + color: string; +} + +@Component({ + selector: 'lfx-meeting-type-selection', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, ToggleComponent, TooltipModule], + templateUrl: './meeting-type-selection.component.html', +}) +export class MeetingTypeSelectionComponent { + // Form group input from parent + public readonly form = input.required(); + + // Meeting type options using shared enum (excluding NONE for selection) + public readonly meetingTypeOptions = [ + { label: 'Board', value: MeetingType.BOARD }, + { label: 'Maintainers', value: MeetingType.MAINTAINERS }, + { label: 'Marketing', value: MeetingType.MARKETING }, + { label: 'Technical', value: MeetingType.TECHNICAL }, + { label: 'Legal', value: MeetingType.LEGAL }, + { label: 'Other', value: MeetingType.OTHER }, + ]; + + // Meeting type info mapping (using colors consistent with committee colors) + private readonly meetingTypeInfo: Record = { + [MeetingType.BOARD]: { + icon: 'fa-light fa-user-crown', + description: 'Governance meetings for project direction, funding, and strategic decisions', + examples: 'Quarterly reviews, budget planning, strategic roadmap discussions', + color: '#ef4444', // red-500 + }, + [MeetingType.MAINTAINERS]: { + icon: 'fa-light fa-gear', + description: 'Regular sync meetings for core maintainers to discuss project health', + examples: 'Weekly standups, release planning, code review discussions', + color: '#3b82f6', // blue-500 + }, + [MeetingType.MARKETING]: { + icon: 'fa-light fa-chart-line-up', + description: 'Community growth, outreach, and marketing strategy meetings', + examples: 'Conference planning, community campaigns, website updates', + color: '#10b981', // green-500 + }, + [MeetingType.TECHNICAL]: { + icon: 'fa-light fa-brackets-curly', + description: 'Technical discussions, architecture decisions, and development planning', + examples: 'RFC reviews, API design, performance optimization planning', + color: '#8b5cf6', // purple-500 + }, + [MeetingType.LEGAL]: { + icon: 'fa-light fa-scale-balanced', + description: 'Legal compliance, licensing, and policy discussions', + examples: 'License reviews, contributor agreements, compliance audits', + color: '#f59e0b', // amber-500 + }, + [MeetingType.OTHER]: { + icon: 'fa-light fa-folder-open', + description: "General project meetings that don't fit other categories", + examples: 'Community events, workshops, informal discussions', + color: '#6b7280', // gray-500 + }, + [MeetingType.NONE]: { + icon: 'fa-light fa-folder-open', + description: 'No specific meeting type', + examples: 'General meetings', + color: '#6b7280', // gray-500 + }, + }; + + // Get meeting type information + public getMeetingTypeInfo(type: MeetingType): MeetingTypeInfo { + return this.meetingTypeInfo[type] || this.meetingTypeInfo[MeetingType.OTHER]; + } + + // Handle meeting type selection + public onMeetingTypeSelect(meetingType: MeetingType): void { + this.form().get('meeting_type')?.setValue(meetingType); + this.form().get('meeting_type')?.markAsTouched(); + } +} diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.html b/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.html index ccd8346c..4df2ac3e 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.html +++ b/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.html @@ -119,7 +119,11 @@

No Meetings Yet

This project doesn't have any meetings yet.

- + } diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.ts b/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.ts index 701b7238..73326ab5 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT import { CommonModule } from '@angular/common'; -import { Component, computed, inject, Injector, signal, Signal, WritableSignal } from '@angular/core'; +import { Component, computed, inject, signal, Signal, WritableSignal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ButtonComponent } from '@components/button/button.component'; @@ -23,7 +23,6 @@ import { BehaviorSubject, of } from 'rxjs'; import { debounceTime, distinctUntilChanged, startWith, switchMap, take, tap } from 'rxjs/operators'; import { MeetingCardComponent } from '../components/meeting-card/meeting-card.component'; -import { MeetingFormComponent } from '../components/meeting-form/meeting-form.component'; import { MeetingModalComponent } from '../components/meeting-modal/meeting-modal.component'; @Component({ @@ -50,7 +49,6 @@ export class MeetingDashboardComponent { private readonly projectService = inject(ProjectService); private readonly meetingService = inject(MeetingService); private readonly dialogService = inject(DialogService); - private readonly injector = inject(Injector); // Class variables with types public project: typeof this.projectService.project; @@ -120,31 +118,6 @@ export class MeetingDashboardComponent { this.currentView.set(value); } - public onCreateMeeting(): void { - this.dialogService - .open(MeetingFormComponent, { - header: 'Create Meeting', - width: '600px', - modal: true, - closable: true, - dismissableMask: true, - data: { - isEditing: false, // This triggers form mode - }, - }) - .onClose.pipe(take(1)) - .subscribe((meeting) => { - if (meeting) { - this.refreshMeetings(); - this.openMeetingModal({ - ...meeting, - individual_participants_count: 0, - committee_members_count: 0, - }); - } - }); - } - public onCalendarEventClick(eventInfo: any): void { const meetingId = eventInfo.event.extendedProps?.meetingId; if (meetingId) { @@ -285,11 +258,12 @@ export class MeetingDashboardComponent { } private initializeMenuItems(): MenuItem[] { + const project = this.project(); return [ { label: 'Schedule Meeting', icon: 'fa-light fa-calendar-plus text-sm', - command: () => this.onCreateMeeting(), + routerLink: project ? `/project/${project.slug}/meetings/create` : '#', }, { label: this.meetingListView() === 'past' ? 'Upcoming Meetings' : 'Meeting History', diff --git a/apps/lfx-pcc/src/app/shared/components/button/button.component.html b/apps/lfx-pcc/src/app/shared/components/button/button.component.html index ea71766b..30853c74 100644 --- a/apps/lfx-pcc/src/app/shared/components/button/button.component.html +++ b/apps/lfx-pcc/src/app/shared/components/button/button.component.html @@ -26,6 +26,7 @@ [variant]="variant()" [fluid]="fluid()" [style]="style()" - (onClick)="handleClick($event)"> + (onClick)="handleClick($event)" + [routerLink]="routerLink()"> diff --git a/apps/lfx-pcc/src/app/shared/components/button/button.component.ts b/apps/lfx-pcc/src/app/shared/components/button/button.component.ts index 79d90715..3d47b8ae 100644 --- a/apps/lfx-pcc/src/app/shared/components/button/button.component.ts +++ b/apps/lfx-pcc/src/app/shared/components/button/button.component.ts @@ -3,13 +3,14 @@ import { CommonModule } from '@angular/common'; import { Component, input, output } from '@angular/core'; +import { RouterModule } from '@angular/router'; import { ButtonProps } from '@lfx-pcc/shared/interfaces'; import { ButtonModule } from 'primeng/button'; @Component({ selector: 'lfx-button', standalone: true, - imports: [CommonModule, ButtonModule], + imports: [CommonModule, ButtonModule, RouterModule], templateUrl: './button.component.html', }) export class ButtonComponent { @@ -48,6 +49,9 @@ export class ButtonComponent { // Accessibility public readonly ariaLabel = input(undefined); + // Navigation + public readonly routerLink = input(undefined); + // Events public readonly onClick = output(); public readonly onFocus = output();