From 311d453e412af85e2327b440f5506d788c245d2e Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Mon, 18 Aug 2025 19:29:56 -0600 Subject: [PATCH] feat: implement frontend for calendar integration (Phase 3 & 4) ## Frontend Implementation ### Meeting Selection & Management - Created MeetingSelection component for choosing between multiple active meetings - Shows both active meetings and upcoming calendar events (30 min ahead) - Displays meeting metadata with privacy controls (owner-only details) - Supports creation of unscheduled meetings alongside calendar meetings ### Waiting Room - Added waiting page for users joining before scheduled start time - Shows countdown timer until meeting begins - Auto-transitions to meeting when calendar event becomes active - Handles early joining with proper routing ### Meeting Info Panel - Created collapsible info panel showing meeting details - Displays calendar metadata (title, description, attendees) - Shows participant count and duration - Privacy-aware: sensitive info only visible to room owners ### ICS Configuration UI - Integrated ICS settings into room configuration dialog - Test connection functionality with immediate feedback - Manual sync trigger with detailed results - Shows last sync time and ETag for monitoring - Configurable sync intervals (1 min to 1 hour) ### Routing & Navigation - New /room/{roomName} route for meeting selection - Waiting room at /room/{roomName}/wait?eventId={id} - Classic room page at /{roomName} with meeting info - Uses sessionStorage to pass selected meeting between pages ### API Integration - Added new endpoints for active/upcoming meetings - Regenerated TypeScript client with latest OpenAPI spec - Proper error handling and loading states - Auto-refresh every 30 seconds for live updates ### UI/UX Improvements - Color-coded badges for meeting status - Attendee status indicators (accepted/declined/tentative) - Responsive design with Chakra UI components - Clear visual hierarchy between active and upcoming meetings - Smart truncation for long attendee lists This completes the frontend implementation for calendar integration, enabling users to seamlessly join scheduled meetings from their calendar applications. --- PLAN.md | 74 +++- .../(app)/rooms/_components/ICSSettings.tsx | 258 ++++++++++++ www/app/(app)/rooms/page.tsx | 36 ++ www/app/[roomName]/MeetingInfo.tsx | 203 +++++++++ www/app/[roomName]/MeetingSelection.tsx | 335 +++++++++++++++ www/app/[roomName]/page.tsx | 38 +- www/app/[roomName]/useRoomMeeting.tsx | 14 + www/app/api/schemas.gen.ts | 398 +++++++++++++++++- www/app/api/services.gen.ts | 152 +++++++ www/app/api/types.gen.ts | 192 ++++++++- www/app/room/[roomName]/page.tsx | 147 +++++++ www/app/room/[roomName]/wait/page.tsx | 277 ++++++++++++ 12 files changed, 2082 insertions(+), 42 deletions(-) create mode 100644 www/app/(app)/rooms/_components/ICSSettings.tsx create mode 100644 www/app/[roomName]/MeetingInfo.tsx create mode 100644 www/app/[roomName]/MeetingSelection.tsx create mode 100644 www/app/room/[roomName]/page.tsx create mode 100644 www/app/room/[roomName]/wait/page.tsx diff --git a/PLAN.md b/PLAN.md index 1d1dde11..0648d5a0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -184,20 +184,43 @@ ICS calendar URLs are attached to rooms (not users) to enable automatic meeting - Simple HTTP fetching without unnecessary validation - Proper TypedDict typing for event data structures - Supports any standard ICS format -4. ⚠️ API endpoints for ICS configuration (partial) + - Event matching on full room URL only +4. ✅ API endpoints for ICS configuration - Room model updated to support ICS fields via existing PATCH endpoint - - Dedicated ICS endpoints still pending -5. ⚠️ Celery background tasks for periodic sync (pending) + - POST /v1/rooms/{room_name}/ics/sync - Trigger manual sync (owner only) + - GET /v1/rooms/{room_name}/ics/status - Get sync status (owner only) + - GET /v1/rooms/{room_name}/meetings - List meetings with privacy controls + - GET /v1/rooms/{room_name}/meetings/upcoming - List upcoming meetings +5. ✅ Celery background tasks for periodic sync + - sync_room_ics - Sync individual room calendar + - sync_all_ics_calendars - Check all rooms and queue sync based on fetch intervals + - pre_create_upcoming_meetings - Pre-create Whereby meetings 1 minute before start + - Tasks scheduled in beat schedule (every minute for checking, respects individual intervals) 6. ✅ Tests written and passing - 6 tests for Room ICS fields - 7 tests for CalendarEvent model - - All 13 tests passing + - 7 tests for ICS sync service + - 11 tests for API endpoints + - 6 tests for background tasks + - All 31 ICS-related tests passing -### Phase 2: Meeting Management (Week 2) -1. Update meeting lifecycle logic -2. Support multiple active meetings -3. Implement grace period logic -4. Link meetings to calendar events +### Phase 2: Meeting Management (Week 2) ✅ COMPLETED (2025-08-19) +1. ✅ Updated meeting lifecycle logic with grace period support + - 15-minute grace period after last participant leaves + - Automatic reactivation when participants rejoin + - Force close calendar meetings 30 minutes after scheduled end +2. ✅ Support multiple active meetings per room + - Removed unique constraint on active meetings + - Added get_all_active_for_room() method + - Added get_active_by_calendar_event() method +3. ✅ Implemented grace period logic + - Added last_participant_left_at and grace_period_minutes fields + - Process meetings task handles grace period checking + - Whereby webhooks clear grace period on participant join +4. ✅ Link meetings to calendar events + - Pre-created meetings properly linked via calendar_event_id + - Calendar metadata stored with meeting + - API endpoints for listing and joining specific meetings ### Phase 3: Frontend Meeting Selection (Week 3) 1. Build meeting selection page @@ -232,6 +255,25 @@ ICS calendar URLs are attached to rooms (not users) to enable automatic meeting 9. **Configurable fetch interval** - Balance between freshness and server load 10. **ICS over CalDAV** - Simpler implementation, wider compatibility, no complex auth +## Phase 2 Implementation Files + +### Database Migrations +- `/server/migrations/versions/6025e9b2bef2_remove_one_active_meeting_per_room_.py` - Remove unique constraint +- `/server/migrations/versions/d4a1c446458c_add_grace_period_fields_to_meeting.py` - Add grace period fields + +### Updated Models +- `/server/reflector/db/meetings.py` - Added grace period fields and new query methods + +### Updated Services +- `/server/reflector/worker/process.py` - Enhanced with grace period logic and multiple meeting support + +### Updated API +- `/server/reflector/views/rooms.py` - Added endpoints for listing active meetings and joining specific meetings +- `/server/reflector/views/whereby.py` - Clear grace period on participant join + +### Tests +- `/server/tests/test_multiple_active_meetings.py` - Comprehensive tests for Phase 2 features (5 tests) + ## Phase 1 Implementation Files Created ### Database Models @@ -240,11 +282,21 @@ ICS calendar URLs are attached to rooms (not users) to enable automatic meeting - `/server/reflector/db/meetings.py` - Updated with calendar_event_id and calendar_metadata (JSONB) ### Services -- `/server/reflector/services/ics_sync.py` - ICS fetching with TypedDict for proper typing +- `/server/reflector/services/ics_sync.py` - ICS fetching and parsing with TypedDict for proper typing + +### API Endpoints +- `/server/reflector/views/rooms.py` - Added ICS management endpoints with privacy controls + +### Background Tasks +- `/server/reflector/worker/ics_sync.py` - Celery tasks for automatic periodic sync +- `/server/reflector/worker/app.py` - Updated beat schedule for ICS tasks ### Tests - `/server/tests/test_room_ics.py` - Room model ICS fields tests (6 tests) - `/server/tests/test_calendar_event.py` - CalendarEvent model tests (7 tests) +- `/server/tests/test_ics_sync.py` - ICS sync service tests (7 tests) +- `/server/tests/test_room_ics_api.py` - API endpoint tests (11 tests) +- `/server/tests/test_ics_background_tasks.py` - Background task tests (6 tests) ### Key Design Decisions - No encryption needed - ICS URLs are read-only access @@ -252,6 +304,8 @@ ICS calendar URLs are attached to rooms (not users) to enable automatic meeting - Proper TypedDict typing for event data structures - Removed unnecessary URL validation and webcal handling - calendar_metadata in meetings stores flexible calendar data (organizer, recurrence, etc) +- Background tasks query all rooms directly to avoid filtering issues +- Sync intervals respected per-room configuration ## Implementation Approach diff --git a/www/app/(app)/rooms/_components/ICSSettings.tsx b/www/app/(app)/rooms/_components/ICSSettings.tsx new file mode 100644 index 00000000..715f312e --- /dev/null +++ b/www/app/(app)/rooms/_components/ICSSettings.tsx @@ -0,0 +1,258 @@ +import { + VStack, + HStack, + Field, + Input, + Select, + Checkbox, + Button, + Text, + Alert, + AlertIcon, + AlertTitle, + Badge, + createListCollection, + Spinner, +} from "@chakra-ui/react"; +import { useState } from "react"; +import { FaSync, FaCheckCircle, FaExclamationCircle } from "react-icons/fa"; +import useApi from "../../../lib/useApi"; + +interface ICSSettingsProps { + roomId?: string; + roomName?: string; + icsUrl?: string; + icsEnabled?: boolean; + icsFetchInterval?: number; + icsLastSync?: string; + icsLastEtag?: string; + onChange: (settings: Partial) => void; + isOwner?: boolean; +} + +export interface ICSSettingsData { + ics_url: string; + ics_enabled: boolean; + ics_fetch_interval: number; +} + +const fetchIntervalOptions = [ + { label: "1 minute", value: "1" }, + { label: "5 minutes", value: "5" }, + { label: "10 minutes", value: "10" }, + { label: "30 minutes", value: "30" }, + { label: "1 hour", value: "60" }, +]; + +export default function ICSSettings({ + roomId, + roomName, + icsUrl = "", + icsEnabled = false, + icsFetchInterval = 5, + icsLastSync, + icsLastEtag, + onChange, + isOwner = true, +}: ICSSettingsProps) { + const [syncStatus, setSyncStatus] = useState< + "idle" | "syncing" | "success" | "error" + >("idle"); + const [syncMessage, setSyncMessage] = useState(""); + const [testResult, setTestResult] = useState(""); + const api = useApi(); + + const fetchIntervalCollection = createListCollection({ + items: fetchIntervalOptions, + }); + + const handleTestConnection = async () => { + if (!api || !icsUrl || !roomName) return; + + setSyncStatus("syncing"); + setTestResult(""); + + try { + // First update the room with the ICS URL + await api.v1RoomsPartialUpdate({ + roomId: roomId || roomName, + requestBody: { + ics_url: icsUrl, + ics_enabled: true, + ics_fetch_interval: icsFetchInterval, + }, + }); + + // Then trigger a sync + const result = await api.v1RoomsTriggerIcsSync({ roomName }); + + if (result.status === "success") { + setSyncStatus("success"); + setTestResult( + `Successfully synced! Found ${result.events_found} events.`, + ); + } else { + setSyncStatus("error"); + setTestResult(result.error || "Sync failed"); + } + } catch (err: any) { + setSyncStatus("error"); + setTestResult(err.body?.detail || "Failed to test ICS connection"); + } + }; + + const handleManualSync = async () => { + if (!api || !roomName) return; + + setSyncStatus("syncing"); + setSyncMessage(""); + + try { + const result = await api.v1RoomsTriggerIcsSync({ roomName }); + + if (result.status === "success") { + setSyncStatus("success"); + setSyncMessage( + `Sync complete! Found ${result.events_found} events, ` + + `created ${result.events_created}, updated ${result.events_updated}.`, + ); + } else { + setSyncStatus("error"); + setSyncMessage(result.error || "Sync failed"); + } + } catch (err: any) { + setSyncStatus("error"); + setSyncMessage(err.body?.detail || "Failed to sync calendar"); + } + + // Clear status after 5 seconds + setTimeout(() => { + setSyncStatus("idle"); + setSyncMessage(""); + }, 5000); + }; + + if (!isOwner) { + return null; // ICS settings only visible to room owner + } + + return ( + + + Calendar Integration (ICS) + + + + onChange({ ics_enabled: e.checked })} + > + + + + + Enable ICS calendar sync + + + + {icsEnabled && ( + <> + + ICS Calendar URL + onChange({ ics_url: e.target.value })} + /> + + Enter the ICS URL from Google Calendar, Outlook, or other calendar + services + + + + + Sync Interval + { + const value = parseInt(details.value[0]); + onChange({ ics_fetch_interval: value }); + }} + > + + + + + {fetchIntervalOptions.map((option) => ( + + {option.label} + + ))} + + + + How often to check for calendar updates + + + + {icsUrl && ( + + + + {roomName && icsLastSync && ( + + )} + + )} + + {testResult && ( + + + {testResult} + + )} + + {syncMessage && ( + + + {syncMessage} + + )} + + {icsLastSync && ( + + + + Last sync: {new Date(icsLastSync).toLocaleString()} + + {icsLastEtag && ( + + ETag: {icsLastEtag.slice(0, 8)}... + + )} + + )} + + )} + + ); +} diff --git a/www/app/(app)/rooms/page.tsx b/www/app/(app)/rooms/page.tsx index 03a4858b..35b0c2ce 100644 --- a/www/app/(app)/rooms/page.tsx +++ b/www/app/(app)/rooms/page.tsx @@ -19,6 +19,7 @@ import useApi from "../../lib/useApi"; import useRoomList from "./useRoomList"; import { ApiError, Room } from "../../api"; import { RoomList } from "./_components/RoomList"; +import ICSSettings from "./_components/ICSSettings"; interface SelectOption { label: string; @@ -54,6 +55,9 @@ const roomInitialState = { recordingType: "cloud", recordingTrigger: "automatic-2nd-participant", isShared: false, + icsUrl: "", + icsEnabled: false, + icsFetchInterval: 5, }; export default function RoomsList() { @@ -170,6 +174,9 @@ export default function RoomsList() { recording_type: room.recordingType, recording_trigger: room.recordingTrigger, is_shared: room.isShared, + ics_url: room.icsUrl, + ics_enabled: room.icsEnabled, + ics_fetch_interval: room.icsFetchInterval, }; if (isEditing) { @@ -215,6 +222,9 @@ export default function RoomsList() { recordingType: roomData.recording_type, recordingTrigger: roomData.recording_trigger, isShared: roomData.is_shared, + icsUrl: roomData.ics_url || "", + icsEnabled: roomData.ics_enabled || false, + icsFetchInterval: roomData.ics_fetch_interval || 5, }); setEditRoomId(roomId); setIsEditing(true); @@ -553,6 +563,32 @@ export default function RoomsList() { Shared room + + { + setRoom({ + ...room, + icsUrl: + settings.ics_url !== undefined + ? settings.ics_url + : room.icsUrl, + icsEnabled: + settings.ics_enabled !== undefined + ? settings.ics_enabled + : room.icsEnabled, + icsFetchInterval: + settings.ics_fetch_interval !== undefined + ? settings.ics_fetch_interval + : room.icsFetchInterval, + }); + }} + isOwner={true} + /> + + + + ))} + + + )} + + {/* Upcoming Meetings */} + {upcomingEvents.length > 0 && ( + <> + + Upcoming Meetings + + + {upcomingEvents.map((event) => ( + + + + + + + + {event.title || "Scheduled Meeting"} + + + {formatCountdown(event.start_time)} + + + + {isOwner && event.description && ( + + {event.description} + + )} + + + + {formatDateTime(event.start_time)} -{" "} + {formatDateTime(event.end_time)} + + + + {isOwner && event.attendees && ( + + {event.attendees + .slice(0, 3) + .map((attendee: any, idx: number) => ( + + {attendee.name || attendee.email} + + ))} + {event.attendees.length > 3 && ( + + +{event.attendees.length - 3} more + + )} + + )} + + + + + + + ))} + + + )} + + + + {/* Create Unscheduled Meeting */} + + + + + Start an Unscheduled Meeting + + Create a new meeting room that's not on the calendar + + + + + + + + + ); +} diff --git a/www/app/[roomName]/page.tsx b/www/app/[roomName]/page.tsx index b03a7e4f..e989ed6b 100644 --- a/www/app/[roomName]/page.tsx +++ b/www/app/[roomName]/page.tsx @@ -24,8 +24,9 @@ import { notFound } from "next/navigation"; import useSessionStatus from "../lib/useSessionStatus"; import { useRecordingConsent } from "../recordingConsentContext"; import useApi from "../lib/useApi"; -import { Meeting } from "../api"; -import { FaBars } from "react-icons/fa6"; +import { Meeting, Room } from "../api"; +import { FaBars, FaInfoCircle } from "react-icons/fa6"; +import MeetingInfo from "./MeetingInfo"; export type RoomDetails = { params: { @@ -254,7 +255,10 @@ export default function Room(details: RoomDetails) { const roomName = details.params.roomName; const meeting = useRoomMeeting(roomName); const router = useRouter(); - const { isLoading, isAuthenticated } = useSessionStatus(); + const { isLoading, isAuthenticated, data: session } = useSessionStatus(); + const [showMeetingInfo, setShowMeetingInfo] = useState(false); + const [room, setRoom] = useState(null); + const api = useApi(); const roomUrl = meeting?.response?.host_room_url ? meeting?.response?.host_room_url @@ -268,6 +272,15 @@ export default function Room(details: RoomDetails) { router.push("/browse"); }, [router]); + // Fetch room details + useEffect(() => { + if (!api || !roomName) return; + + api.v1RoomsRetrieve({ roomName }).then(setRoom).catch(console.error); + }, [api, roomName]); + + const isOwner = session?.user?.id === room?.user_id; + useEffect(() => { if ( !isLoading && @@ -319,6 +332,25 @@ export default function Room(details: RoomDetails) { wherebyRef={wherebyRef} /> )} + {meeting?.response && ( + <> + + {showMeetingInfo && ( + + )} + + )} )} diff --git a/www/app/[roomName]/useRoomMeeting.tsx b/www/app/[roomName]/useRoomMeeting.tsx index 98c2f1f2..fe011186 100644 --- a/www/app/[roomName]/useRoomMeeting.tsx +++ b/www/app/[roomName]/useRoomMeeting.tsx @@ -40,6 +40,20 @@ const useRoomMeeting = ( useEffect(() => { if (!roomName || !api) return; + // Check if meeting was pre-selected from meeting selection page + const storedMeeting = sessionStorage.getItem(`meeting_${roomName}`); + if (storedMeeting) { + try { + const meeting = JSON.parse(storedMeeting); + sessionStorage.removeItem(`meeting_${roomName}`); // Clean up + setResponse(meeting); + setLoading(false); + return; + } catch (e) { + console.error("Failed to parse stored meeting:", e); + } + } + if (!response) { setLoading(true); } diff --git a/www/app/api/schemas.gen.ts b/www/app/api/schemas.gen.ts index ac5010d0..3e9f21c0 100644 --- a/www/app/api/schemas.gen.ts +++ b/www/app/api/schemas.gen.ts @@ -30,6 +30,108 @@ export const $Body_transcript_record_upload_v1_transcripts__transcript_id__recor "Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post", } as const; +export const $CalendarEventResponse = { + properties: { + id: { + type: "string", + title: "Id", + }, + room_id: { + type: "string", + title: "Room Id", + }, + ics_uid: { + type: "string", + title: "Ics Uid", + }, + title: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Title", + }, + description: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Description", + }, + start_time: { + type: "string", + format: "date-time", + title: "Start Time", + }, + end_time: { + type: "string", + format: "date-time", + title: "End Time", + }, + attendees: { + anyOf: [ + { + items: { + additionalProperties: true, + type: "object", + }, + type: "array", + }, + { + type: "null", + }, + ], + title: "Attendees", + }, + location: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Location", + }, + last_synced: { + type: "string", + format: "date-time", + title: "Last Synced", + }, + created_at: { + type: "string", + format: "date-time", + title: "Created At", + }, + updated_at: { + type: "string", + format: "date-time", + title: "Updated At", + }, + }, + type: "object", + required: [ + "id", + "room_id", + "ics_uid", + "start_time", + "end_time", + "last_synced", + "created_at", + "updated_at", + ], + title: "CalendarEventResponse", +} as const; + export const $CreateParticipant = { properties: { speaker: { @@ -91,6 +193,27 @@ export const $CreateRoom = { type: "boolean", title: "Is Shared", }, + ics_url: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Ics Url", + }, + ics_fetch_interval: { + type: "integer", + title: "Ics Fetch Interval", + default: 300, + }, + ics_enabled: { + type: "boolean", + title: "Ics Enabled", + default: false, + }, }, type: "object", required: [ @@ -687,6 +810,112 @@ export const $HTTPValidationError = { title: "HTTPValidationError", } as const; +export const $ICSStatus = { + properties: { + status: { + type: "string", + title: "Status", + }, + last_sync: { + anyOf: [ + { + type: "string", + format: "date-time", + }, + { + type: "null", + }, + ], + title: "Last Sync", + }, + next_sync: { + anyOf: [ + { + type: "string", + format: "date-time", + }, + { + type: "null", + }, + ], + title: "Next Sync", + }, + last_etag: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Last Etag", + }, + events_count: { + type: "integer", + title: "Events Count", + default: 0, + }, + }, + type: "object", + required: ["status"], + title: "ICSStatus", +} as const; + +export const $ICSSyncResult = { + properties: { + status: { + type: "string", + title: "Status", + }, + hash: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Hash", + }, + events_found: { + type: "integer", + title: "Events Found", + default: 0, + }, + events_created: { + type: "integer", + title: "Events Created", + default: 0, + }, + events_updated: { + type: "integer", + title: "Events Updated", + default: 0, + }, + events_deleted: { + type: "integer", + title: "Events Deleted", + default: 0, + }, + error: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Error", + }, + }, + type: "object", + required: ["status"], + title: "ICSSyncResult", +} as const; + export const $Meeting = { properties: { id: { @@ -950,6 +1179,50 @@ export const $Room = { type: "boolean", title: "Is Shared", }, + ics_url: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Ics Url", + }, + ics_fetch_interval: { + type: "integer", + title: "Ics Fetch Interval", + default: 300, + }, + ics_enabled: { + type: "boolean", + title: "Ics Enabled", + default: false, + }, + ics_last_sync: { + anyOf: [ + { + type: "string", + format: "date-time", + }, + { + type: "null", + }, + ], + title: "Ics Last Sync", + }, + ics_last_etag: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Ics Last Etag", + }, }, type: "object", required: [ @@ -1294,54 +1567,139 @@ export const $UpdateParticipant = { export const $UpdateRoom = { properties: { name: { - type: "string", + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], title: "Name", }, zulip_auto_post: { - type: "boolean", + anyOf: [ + { + type: "boolean", + }, + { + type: "null", + }, + ], title: "Zulip Auto Post", }, zulip_stream: { - type: "string", + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], title: "Zulip Stream", }, zulip_topic: { - type: "string", + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], title: "Zulip Topic", }, is_locked: { - type: "boolean", + anyOf: [ + { + type: "boolean", + }, + { + type: "null", + }, + ], title: "Is Locked", }, room_mode: { - type: "string", + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], title: "Room Mode", }, recording_type: { - type: "string", + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], title: "Recording Type", }, recording_trigger: { - type: "string", + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], title: "Recording Trigger", }, is_shared: { - type: "boolean", + anyOf: [ + { + type: "boolean", + }, + { + type: "null", + }, + ], title: "Is Shared", }, + ics_url: { + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], + title: "Ics Url", + }, + ics_fetch_interval: { + anyOf: [ + { + type: "integer", + }, + { + type: "null", + }, + ], + title: "Ics Fetch Interval", + }, + ics_enabled: { + anyOf: [ + { + type: "boolean", + }, + { + type: "null", + }, + ], + title: "Ics Enabled", + }, }, type: "object", - required: [ - "name", - "zulip_auto_post", - "zulip_stream", - "zulip_topic", - "is_locked", - "room_mode", - "recording_type", - "recording_trigger", - "is_shared", - ], title: "UpdateRoom", } as const; diff --git a/www/app/api/services.gen.ts b/www/app/api/services.gen.ts index 8bfbe299..772dda30 100644 --- a/www/app/api/services.gen.ts +++ b/www/app/api/services.gen.ts @@ -16,6 +16,18 @@ import type { V1RoomsDeleteResponse, V1RoomsCreateMeetingData, V1RoomsCreateMeetingResponse, + V1RoomsSyncIcsData, + V1RoomsSyncIcsResponse, + V1RoomsIcsStatusData, + V1RoomsIcsStatusResponse, + V1RoomsListMeetingsData, + V1RoomsListMeetingsResponse, + V1RoomsListUpcomingMeetingsData, + V1RoomsListUpcomingMeetingsResponse, + V1RoomsListActiveMeetingsData, + V1RoomsListActiveMeetingsResponse, + V1RoomsJoinMeetingData, + V1RoomsJoinMeetingResponse, V1TranscriptsListData, V1TranscriptsListResponse, V1TranscriptsCreateData, @@ -227,6 +239,146 @@ export class DefaultService { }); } + /** + * Rooms Sync Ics + * @param data The data for the request. + * @param data.roomName + * @returns ICSSyncResult Successful Response + * @throws ApiError + */ + public v1RoomsSyncIcs( + data: V1RoomsSyncIcsData, + ): CancelablePromise { + return this.httpRequest.request({ + method: "POST", + url: "/v1/rooms/{room_name}/ics/sync", + path: { + room_name: data.roomName, + }, + errors: { + 422: "Validation Error", + }, + }); + } + + /** + * Rooms Ics Status + * @param data The data for the request. + * @param data.roomName + * @returns ICSStatus Successful Response + * @throws ApiError + */ + public v1RoomsIcsStatus( + data: V1RoomsIcsStatusData, + ): CancelablePromise { + return this.httpRequest.request({ + method: "GET", + url: "/v1/rooms/{room_name}/ics/status", + path: { + room_name: data.roomName, + }, + errors: { + 422: "Validation Error", + }, + }); + } + + /** + * Rooms List Meetings + * @param data The data for the request. + * @param data.roomName + * @returns CalendarEventResponse Successful Response + * @throws ApiError + */ + public v1RoomsListMeetings( + data: V1RoomsListMeetingsData, + ): CancelablePromise { + return this.httpRequest.request({ + method: "GET", + url: "/v1/rooms/{room_name}/meetings", + path: { + room_name: data.roomName, + }, + errors: { + 422: "Validation Error", + }, + }); + } + + /** + * Rooms List Upcoming Meetings + * @param data The data for the request. + * @param data.roomName + * @param data.minutesAhead + * @returns CalendarEventResponse Successful Response + * @throws ApiError + */ + public v1RoomsListUpcomingMeetings( + data: V1RoomsListUpcomingMeetingsData, + ): CancelablePromise { + return this.httpRequest.request({ + method: "GET", + url: "/v1/rooms/{room_name}/meetings/upcoming", + path: { + room_name: data.roomName, + }, + query: { + minutes_ahead: data.minutesAhead, + }, + errors: { + 422: "Validation Error", + }, + }); + } + + /** + * Rooms List Active Meetings + * List all active meetings for a room (supports multiple active meetings) + * @param data The data for the request. + * @param data.roomName + * @returns Meeting Successful Response + * @throws ApiError + */ + public v1RoomsListActiveMeetings( + data: V1RoomsListActiveMeetingsData, + ): CancelablePromise { + return this.httpRequest.request({ + method: "GET", + url: "/v1/rooms/{room_name}/meetings/active", + path: { + room_name: data.roomName, + }, + errors: { + 422: "Validation Error", + }, + }); + } + + /** + * Rooms Join Meeting + * Join a specific meeting by ID + * @param data The data for the request. + * @param data.roomName + * @param data.meetingId + * @returns Meeting Successful Response + * @throws ApiError + */ + public v1RoomsJoinMeeting( + data: V1RoomsJoinMeetingData, + ): CancelablePromise { + return this.httpRequest.request({ + method: "POST", + url: "/v1/rooms/{room_name}/meetings/{meeting_id}/join", + path: { + room_name: data.roomName, + meeting_id: data.meetingId, + }, + errors: { + 422: "Validation Error", + }, + }); + } + /** * Transcripts List * @param data The data for the request. diff --git a/www/app/api/types.gen.ts b/www/app/api/types.gen.ts index 421fa414..08c1f201 100644 --- a/www/app/api/types.gen.ts +++ b/www/app/api/types.gen.ts @@ -9,6 +9,23 @@ export type Body_transcript_record_upload_v1_transcripts__transcript_id__record_ chunk: Blob | File; }; +export type CalendarEventResponse = { + id: string; + room_id: string; + ics_uid: string; + title?: string | null; + description?: string | null; + start_time: string; + end_time: string; + attendees?: Array<{ + [key: string]: unknown; + }> | null; + location?: string | null; + last_synced: string; + created_at: string; + updated_at: string; +}; + export type CreateParticipant = { speaker?: number | null; name: string; @@ -24,6 +41,9 @@ export type CreateRoom = { recording_type: string; recording_trigger: string; is_shared: boolean; + ics_url?: string | null; + ics_fetch_interval?: number; + ics_enabled?: boolean; }; export type CreateTranscript = { @@ -123,6 +143,24 @@ export type HTTPValidationError = { detail?: Array; }; +export type ICSStatus = { + status: string; + last_sync?: string | null; + next_sync?: string | null; + last_etag?: string | null; + events_count?: number; +}; + +export type ICSSyncResult = { + status: string; + hash?: string | null; + events_found?: number; + events_created?: number; + events_updated?: number; + events_deleted?: number; + error?: string | null; +}; + export type Meeting = { id: string; room_name: string; @@ -174,6 +212,11 @@ export type Room = { recording_type: string; recording_trigger: string; is_shared: boolean; + ics_url?: string | null; + ics_fetch_interval?: number; + ics_enabled?: boolean; + ics_last_sync?: string | null; + ics_last_etag?: string | null; }; export type RtcOffer = { @@ -266,15 +309,18 @@ export type UpdateParticipant = { }; export type UpdateRoom = { - name: string; - zulip_auto_post: boolean; - zulip_stream: string; - zulip_topic: string; - is_locked: boolean; - room_mode: string; - recording_type: string; - recording_trigger: string; - is_shared: boolean; + name?: string | null; + zulip_auto_post?: boolean | null; + zulip_stream?: string | null; + zulip_topic?: string | null; + is_locked?: boolean | null; + room_mode?: string | null; + recording_type?: string | null; + recording_trigger?: string | null; + is_shared?: boolean | null; + ics_url?: string | null; + ics_fetch_interval?: number | null; + ics_enabled?: boolean | null; }; export type UpdateTranscript = { @@ -371,6 +417,44 @@ export type V1RoomsCreateMeetingData = { export type V1RoomsCreateMeetingResponse = Meeting; +export type V1RoomsSyncIcsData = { + roomName: string; +}; + +export type V1RoomsSyncIcsResponse = ICSSyncResult; + +export type V1RoomsIcsStatusData = { + roomName: string; +}; + +export type V1RoomsIcsStatusResponse = ICSStatus; + +export type V1RoomsListMeetingsData = { + roomName: string; +}; + +export type V1RoomsListMeetingsResponse = Array; + +export type V1RoomsListUpcomingMeetingsData = { + minutesAhead?: number; + roomName: string; +}; + +export type V1RoomsListUpcomingMeetingsResponse = Array; + +export type V1RoomsListActiveMeetingsData = { + roomName: string; +}; + +export type V1RoomsListActiveMeetingsResponse = Array; + +export type V1RoomsJoinMeetingData = { + meetingId: string; + roomName: string; +}; + +export type V1RoomsJoinMeetingResponse = Meeting; + export type V1TranscriptsListData = { /** * Page number @@ -670,6 +754,96 @@ export type $OpenApiTs = { }; }; }; + "/v1/rooms/{room_name}/ics/sync": { + post: { + req: V1RoomsSyncIcsData; + res: { + /** + * Successful Response + */ + 200: ICSSyncResult; + /** + * Validation Error + */ + 422: HTTPValidationError; + }; + }; + }; + "/v1/rooms/{room_name}/ics/status": { + get: { + req: V1RoomsIcsStatusData; + res: { + /** + * Successful Response + */ + 200: ICSStatus; + /** + * Validation Error + */ + 422: HTTPValidationError; + }; + }; + }; + "/v1/rooms/{room_name}/meetings": { + get: { + req: V1RoomsListMeetingsData; + res: { + /** + * Successful Response + */ + 200: Array; + /** + * Validation Error + */ + 422: HTTPValidationError; + }; + }; + }; + "/v1/rooms/{room_name}/meetings/upcoming": { + get: { + req: V1RoomsListUpcomingMeetingsData; + res: { + /** + * Successful Response + */ + 200: Array; + /** + * Validation Error + */ + 422: HTTPValidationError; + }; + }; + }; + "/v1/rooms/{room_name}/meetings/active": { + get: { + req: V1RoomsListActiveMeetingsData; + res: { + /** + * Successful Response + */ + 200: Array; + /** + * Validation Error + */ + 422: HTTPValidationError; + }; + }; + }; + "/v1/rooms/{room_name}/meetings/{meeting_id}/join": { + post: { + req: V1RoomsJoinMeetingData; + res: { + /** + * Successful Response + */ + 200: Meeting; + /** + * Validation Error + */ + 422: HTTPValidationError; + }; + }; + }; "/v1/transcripts": { get: { req: V1TranscriptsListData; diff --git a/www/app/room/[roomName]/page.tsx b/www/app/room/[roomName]/page.tsx new file mode 100644 index 00000000..da0667f8 --- /dev/null +++ b/www/app/room/[roomName]/page.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Box, Spinner, VStack, Text } from "@chakra-ui/react"; +import { useRouter } from "next/navigation"; +import useApi from "../../lib/useApi"; +import useSessionStatus from "../../lib/useSessionStatus"; +import MeetingSelection from "../../[roomName]/MeetingSelection"; +import { Meeting, Room } from "../../api"; + +interface RoomPageProps { + params: { + roomName: string; + }; +} + +export default function RoomPage({ params }: RoomPageProps) { + const { roomName } = params; + const router = useRouter(); + const api = useApi(); + const { data: session } = useSessionStatus(); + + const [room, setRoom] = useState(null); + const [loading, setLoading] = useState(true); + const [checkingMeetings, setCheckingMeetings] = useState(false); + + const isOwner = session?.user?.id === room?.user_id; + + useEffect(() => { + if (!api) return; + + const fetchRoom = async () => { + try { + // Get room details + const roomData = await api.v1RoomsRetrieve({ roomName }); + setRoom(roomData); + + // Check if we should show meeting selection + if (roomData.ics_enabled) { + setCheckingMeetings(true); + + // Check for active meetings + const activeMeetings = await api.v1RoomsListActiveMeetings({ + roomName, + }); + + // Check for upcoming meetings + const upcomingEvents = await api.v1RoomsListUpcomingMeetings({ + roomName, + minutesAhead: 30, + }); + + // If there's only one active meeting and no upcoming, auto-join + if (activeMeetings.length === 1 && upcomingEvents.length === 0) { + handleMeetingSelect(activeMeetings[0]); + } else if ( + activeMeetings.length === 0 && + upcomingEvents.length === 0 + ) { + // No meetings, create unscheduled + handleCreateUnscheduled(); + } + // Otherwise, show selection UI (handled by render) + } else { + // ICS not enabled, use traditional flow + handleCreateUnscheduled(); + } + } catch (err) { + console.error("Failed to fetch room:", err); + // Room not found or error + router.push("/rooms"); + } finally { + setLoading(false); + setCheckingMeetings(false); + } + }; + + fetchRoom(); + }, [api, roomName]); + + const handleMeetingSelect = (meeting: Meeting) => { + // Navigate to the classic room page with the meeting + // Store meeting in session storage for the classic page to use + sessionStorage.setItem(`meeting_${roomName}`, JSON.stringify(meeting)); + router.push(`/${roomName}`); + }; + + const handleCreateUnscheduled = async () => { + if (!api) return; + + try { + // Create a new unscheduled meeting + const meeting = await api.v1RoomsCreateMeeting({ roomName }); + handleMeetingSelect(meeting); + } catch (err) { + console.error("Failed to create meeting:", err); + } + }; + + if (loading || checkingMeetings) { + return ( + + + + {loading ? "Loading room..." : "Checking meetings..."} + + + ); + } + + if (!room) { + return ( + + Room not found + + ); + } + + // Show meeting selection if ICS is enabled and we have multiple options + if (room.ics_enabled) { + return ( + + + + ); + } + + // Should not reach here - redirected above + return null; +} diff --git a/www/app/room/[roomName]/wait/page.tsx b/www/app/room/[roomName]/wait/page.tsx new file mode 100644 index 00000000..999ffe0b --- /dev/null +++ b/www/app/room/[roomName]/wait/page.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { + Box, + VStack, + HStack, + Text, + Spinner, + Progress, + Card, + CardBody, + Button, + Icon, +} from "@chakra-ui/react"; +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { FaClock, FaArrowLeft } from "react-icons/fa"; +import useApi from "../../../lib/useApi"; +import { CalendarEventResponse } from "../../../api"; + +interface WaitingPageProps { + params: { + roomName: string; + }; +} + +export default function WaitingPage({ params }: WaitingPageProps) { + const { roomName } = params; + const router = useRouter(); + const searchParams = useSearchParams(); + const eventId = searchParams.get("eventId"); + + const [event, setEvent] = useState(null); + const [timeRemaining, setTimeRemaining] = useState(0); + const [loading, setLoading] = useState(true); + const [checkingMeeting, setCheckingMeeting] = useState(false); + const api = useApi(); + + useEffect(() => { + if (!api || !eventId) return; + + const fetchEvent = async () => { + try { + const events = await api.v1RoomsListUpcomingMeetings({ + roomName, + minutesAhead: 60, + }); + + const targetEvent = events.find((e) => e.id === eventId); + if (targetEvent) { + setEvent(targetEvent); + } else { + // Event not found or already started + router.push(`/room/${roomName}`); + } + } catch (err) { + console.error("Failed to fetch event:", err); + router.push(`/room/${roomName}`); + } finally { + setLoading(false); + } + }; + + fetchEvent(); + }, [api, eventId, roomName]); + + useEffect(() => { + if (!event) return; + + const updateCountdown = () => { + const now = new Date(); + const start = new Date(event.start_time); + const diff = Math.max(0, start.getTime() - now.getTime()); + + setTimeRemaining(diff); + + // Check if meeting has started + if (diff <= 0) { + checkForActiveMeeting(); + } + }; + + const checkForActiveMeeting = async () => { + if (!api || checkingMeeting) return; + + setCheckingMeeting(true); + try { + // Check for active meetings + const activeMeetings = await api.v1RoomsListActiveMeetings({ + roomName, + }); + + // Find meeting for this calendar event + const calendarMeeting = activeMeetings.find( + (m) => m.calendar_event_id === eventId, + ); + + if (calendarMeeting) { + // Meeting is now active, join it + const meeting = await api.v1RoomsJoinMeeting({ + roomName, + meetingId: calendarMeeting.id, + }); + + // Navigate to the meeting room + router.push(`/${roomName}?meetingId=${meeting.id}`); + } + } catch (err) { + console.error("Failed to check for active meeting:", err); + } finally { + setCheckingMeeting(false); + } + }; + + // Update countdown every second + const interval = setInterval(updateCountdown, 1000); + + // Check for meeting every 10 seconds when close to start time + let checkInterval: NodeJS.Timeout | null = null; + if (timeRemaining < 60000) { + // Less than 1 minute + checkInterval = setInterval(checkForActiveMeeting, 10000); + } + + updateCountdown(); // Initial update + + return () => { + clearInterval(interval); + if (checkInterval) clearInterval(checkInterval); + }; + }, [event, api, eventId, roomName, checkingMeeting]); + + const formatTime = (ms: number) => { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds + .toString() + .padStart(2, "0")}`; + } + return `${minutes}:${seconds.toString().padStart(2, "0")}`; + }; + + const getProgressValue = () => { + if (!event) return 0; + + const now = new Date(); + const created = new Date(event.created_at); + const start = new Date(event.start_time); + const totalTime = start.getTime() - created.getTime(); + const elapsed = now.getTime() - created.getTime(); + + return Math.min(100, (elapsed / totalTime) * 100); + }; + + if (loading) { + return ( + + + + Loading meeting details... + + + ); + } + + if (!event) { + return ( + + + Meeting not found + + + + ); + } + + return ( + + + + + + + + + {event.title || "Scheduled Meeting"} + + + The meeting will start automatically when it's time + + + + + + {formatTime(timeRemaining)} + + + + + {event.description && ( + + + Meeting Description + + + {event.description} + + + )} + + + + Scheduled for {new Date(event.start_time).toLocaleString()} + + + {checkingMeeting && ( + + + + Checking if meeting has started... + + + )} + + + + + + + + ); +}