From 78a30b37c888f7768c0e8833634b673885b0596e Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Tue, 9 Sep 2025 18:58:56 -0600 Subject: [PATCH] feat: improve calendar integration and meeting UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor ICS sync tasks to use @asynctask decorator for cleaner async handling - Extract meeting creation logic into reusable function - Improve meeting selection UI with distinct current/upcoming sections - Add early join functionality for upcoming meetings within 5-minute window - Simplify non-ICS room workflow with direct Whereby embed - Fix import paths and component organization 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- server/reflector/worker/ics_sync.py | 161 ++++++------- www/app/[roomName]/MeetingSelection.tsx | 297 +++++++++++++----------- www/app/[roomName]/RoomClient.tsx | 37 ++- www/app/[roomName]/useRoomMeeting.tsx | 8 +- www/app/lib/WherebyWebinarEmbed.tsx | 7 +- www/app/webinars/[title]/page.tsx | 10 +- 6 files changed, 272 insertions(+), 248 deletions(-) diff --git a/server/reflector/worker/ics_sync.py b/server/reflector/worker/ics_sync.py index d9b7be3b..f312f8c5 100644 --- a/server/reflector/worker/ics_sync.py +++ b/server/reflector/worker/ics_sync.py @@ -4,7 +4,9 @@ import structlog from celery import shared_task from celery.utils.log import get_task_logger +from reflector.asynctask import asynctask from reflector.db import get_database +from reflector.db.calendar_events import calendar_events_controller from reflector.db.meetings import meetings_controller from reflector.db.rooms import rooms, rooms_controller from reflector.services.ics_sync import ics_sync_service @@ -14,11 +16,8 @@ logger = structlog.wrap_logger(get_task_logger(__name__)) @shared_task -def sync_room_ics(room_id: str): - asynctask(_sync_room_ics_async(room_id)) - - -async def _sync_room_ics_async(room_id: str): +@asynctask +async def sync_room_ics(room_id: str): try: room = await rooms_controller.get_by_id(room_id) if not room: @@ -55,11 +54,8 @@ async def _sync_room_ics_async(room_id: str): @shared_task -def sync_all_ics_calendars(): - asynctask(_sync_all_ics_calendars_async()) - - -async def _sync_all_ics_calendars_async(): +@asynctask +async def sync_all_ics_calendars(): try: logger.info("Starting sync for all ICS-enabled rooms") @@ -99,16 +95,68 @@ def _should_sync(room) -> bool: return time_since_sync.total_seconds() >= room.ics_fetch_interval -@shared_task -def pre_create_upcoming_meetings(): - asynctask(_pre_create_upcoming_meetings_async()) +async def create_upcoming_meetings_for_event(event, create_window, room_id, room): + if event.start_time <= create_window: + return + existing_meeting = await meetings_controller.get_by_calendar_event(event.id) + if existing_meeting: + return + + logger.info( + "Pre-creating meeting for calendar event", + room_id=room_id, + event_id=event.id, + event_title=event.title, + ) -async def _pre_create_upcoming_meetings_async(): try: - logger.info("Starting pre-creation of upcoming meetings") + end_date = event.end_time or (event.start_time + timedelta(hours=1)) - from reflector.db.calendar_events import calendar_events_controller + whereby_meeting = await create_meeting( + event.title or "Scheduled Meeting", + end_date=end_date, + room=room, + ) + await upload_logo(whereby_meeting["roomName"], "./images/logo.png") + + meeting = await meetings_controller.create( + id=whereby_meeting["meetingId"], + room_name=whereby_meeting["roomName"], + room_url=whereby_meeting["roomUrl"], + host_room_url=whereby_meeting["hostRoomUrl"], + start_date=datetime.fromisoformat(whereby_meeting["startDate"]), + end_date=datetime.fromisoformat(whereby_meeting["endDate"]), + user_id=room.user_id, + room=room, + calendar_event_id=event.id, + calendar_metadata={ + "title": event.title, + "description": event.description, + "attendees": event.attendees, + }, + ) + + logger.info( + "Meeting pre-created successfully", + meeting_id=meeting.id, + event_id=event.id, + ) + + except Exception as e: + logger.error( + "Failed to pre-create meeting", + room_id=room_id, + event_id=event.id, + error=str(e), + ) + + +@shared_task +@asynctask +async def create_upcoming_meetings(): + try: + logger.info("Starting creation of upcoming meetings") # Get ALL rooms with ICS enabled query = rooms.select().where( @@ -116,7 +164,7 @@ async def _pre_create_upcoming_meetings_async(): ) all_rooms = await get_database().fetch_all(query) now = datetime.now(timezone.utc) - pre_create_window = now + timedelta(minutes=1) + create_window = now - timedelta(minutes=6) for room_data in all_rooms: room_id = room_data["id"] @@ -126,84 +174,13 @@ async def _pre_create_upcoming_meetings_async(): continue events = await calendar_events_controller.get_upcoming( - room_id, minutes_ahead=2 + room_id, + minutes_ahead=7, ) for event in events: - if event.start_time <= pre_create_window: - existing_meeting = await meetings_controller.get_by_calendar_event( - event.id - ) - - if not existing_meeting: - logger.info( - "Pre-creating meeting for calendar event", - room_id=room_id, - event_id=event.id, - event_title=event.title, - ) - - try: - end_date = event.end_time or ( - event.start_time + timedelta(hours=1) - ) - - whereby_meeting = await create_meeting( - event.title or "Scheduled Meeting", - end_date=end_date, - room=room, - ) - await upload_logo( - whereby_meeting["roomName"], "./images/logo.png" - ) - - meeting = await meetings_controller.create( - id=whereby_meeting["meetingId"], - room_name=whereby_meeting["roomName"], - room_url=whereby_meeting["roomUrl"], - host_room_url=whereby_meeting["hostRoomUrl"], - start_date=datetime.fromisoformat( - whereby_meeting["startDate"] - ), - end_date=datetime.fromisoformat( - whereby_meeting["endDate"] - ), - user_id=room.user_id, - room=room, - calendar_event_id=event.id, - calendar_metadata={ - "title": event.title, - "description": event.description, - "attendees": event.attendees, - }, - ) - - logger.info( - "Meeting pre-created successfully", - meeting_id=meeting.id, - event_id=event.id, - ) - - except Exception as e: - logger.error( - "Failed to pre-create meeting", - room_id=room_id, - event_id=event.id, - error=str(e), - ) - + await create_upcoming_meetings_for_event(event) logger.info("Completed pre-creation check for upcoming meetings") except Exception as e: - logger.error("Error in pre_create_upcoming_meetings", error=str(e)) - - -def asynctask(coro): - import asyncio - - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - return loop.run_until_complete(coro) - finally: - loop.close() + logger.error("Error in create_upcoming_meetings", error=str(e)) diff --git a/www/app/[roomName]/MeetingSelection.tsx b/www/app/[roomName]/MeetingSelection.tsx index 74976423..2f6fad4a 100644 --- a/www/app/[roomName]/MeetingSelection.tsx +++ b/www/app/[roomName]/MeetingSelection.tsx @@ -17,7 +17,6 @@ import { LuX } from "react-icons/lu"; import type { components } from "../reflector-api"; import { useRoomActiveMeetings, - useRoomUpcomingMeetings, useRoomJoinMeeting, useMeetingDeactivate, useRoomGetByName, @@ -31,13 +30,16 @@ import { } from "../lib/timeUtils"; import MinimalHeader from "../components/MinimalHeader"; +// Meeting join settings +const EARLY_JOIN_MINUTES = 5; // Allow joining 5 minutes before meeting starts + type Meeting = components["schemas"]["Meeting"]; -type CalendarEventResponse = components["schemas"]["CalendarEventResponse"]; interface MeetingSelectionProps { roomName: string; isOwner: boolean; isSharedRoom: boolean; + authLoading: boolean; onMeetingSelect: (meeting: Meeting) => void; onCreateUnscheduled: () => void; } @@ -46,6 +48,7 @@ export default function MeetingSelection({ roomName, isOwner, isSharedRoom, + authLoading, onMeetingSelect, onCreateUnscheduled, }: MeetingSelectionProps) { @@ -54,20 +57,36 @@ export default function MeetingSelection({ // Use React Query hooks for data fetching const roomQuery = useRoomGetByName(roomName); const activeMeetingsQuery = useRoomActiveMeetings(roomName); - const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName); const joinMeetingMutation = useRoomJoinMeeting(); const deactivateMeetingMutation = useMeetingDeactivate(); const room = roomQuery.data; + const allMeetings = activeMeetingsQuery.data || []; - const activeMeetings = activeMeetingsQuery.data || []; - const upcomingEvents = upcomingMeetingsQuery.data || []; - const loading = - roomQuery.isLoading || - activeMeetingsQuery.isLoading || - upcomingMeetingsQuery.isLoading; - const error = - roomQuery.error || activeMeetingsQuery.error || upcomingMeetingsQuery.error; + // Separate current ongoing meetings from upcoming meetings (created by worker, within 5 minutes) + const now = new Date(); + const currentMeetings = allMeetings.filter((meeting) => { + const startTime = new Date(meeting.start_date); + // Meeting is ongoing if it started and participants have joined or it's been running for a while + return ( + meeting.num_clients > 0 || now.getTime() - startTime.getTime() > 60000 + ); // 1 minute threshold + }); + + const upcomingMeetings = allMeetings.filter((meeting) => { + const startTime = new Date(meeting.start_date); + const minutesUntilStart = Math.floor( + (startTime.getTime() - now.getTime()) / (1000 * 60), + ); + // Show meetings that start within 5 minutes and haven't started yet + return ( + minutesUntilStart <= EARLY_JOIN_MINUTES && + minutesUntilStart > 0 && + meeting.num_clients === 0 + ); + }); + const loading = roomQuery.isLoading || activeMeetingsQuery.isLoading; + const error = roomQuery.error || activeMeetingsQuery.error; const handleJoinMeeting = async (meetingId: string) => { try { @@ -86,9 +105,26 @@ export default function MeetingSelection({ } }; - const handleJoinUpcoming = async (event: CalendarEventResponse) => { - // Create an unscheduled meeting for this calendar event - onCreateUnscheduled(); + const handleJoinUpcoming = async (meeting: Meeting) => { + // Join the upcoming meeting directly + try { + await joinMeetingMutation.mutateAsync({ + params: { + path: { + room_name: roomName, + meeting_id: meeting.id, + }, + }, + }); + handleJoinDirect(meeting.room_url); + } catch (err) { + console.error("Failed to join upcoming meeting:", err); + } + }; + + const handleJoinDirect = (roomUrl: string) => { + // Go directly to the meeting URL (Whereby/etc) + window.open(roomUrl, "_blank"); }; const handleEndMeeting = async (meetingId: string) => { @@ -158,73 +194,93 @@ export default function MeetingSelection({ px={6} py={8} flex="1" + gap={6} > - {/* Active Meetings */} - {activeMeetings.length > 0 && ( - - - Active Meetings + {/* Current Ongoing Meetings - BIG DISPLAY */} + {currentMeetings.length > 0 && ( + + + Live Meeting{currentMeetings.length > 1 ? "s" : ""} - {activeMeetings.map((meeting) => ( + {currentMeetings.map((meeting) => ( - + - - - {(meeting.calendar_metadata as any)?.title || "Meeting"} + + + {(meeting.calendar_metadata as any)?.title || + "Live Meeting"} + + LIVE + {isOwner && (meeting.calendar_metadata as any)?.description && ( - + {(meeting.calendar_metadata as any).description} )} - + - - {meeting.num_clients} participants + + + {meeting.num_clients} participants + - + - Started {formatDateTime(meeting.start_date)} + Started {formatStartedAgo(meeting.start_date)} {isOwner && (meeting.calendar_metadata as any)?.attendees && ( - + {(meeting.calendar_metadata as any).attendees - .slice(0, 3) + .slice(0, 4) .map((attendee: any, idx: number) => ( {attendee.name || attendee.email} ))} {(meeting.calendar_metadata as any).attendees.length > - 3 && ( - + 4 && ( + + {(meeting.calendar_metadata as any).attendees - .length - 3}{" "} + .length - 4}{" "} more )} @@ -232,11 +288,15 @@ export default function MeetingSelection({ )} - + @@ -247,113 +307,76 @@ export default function MeetingSelection({ size="md" onClick={() => handleEndMeeting(meeting.id)} isLoading={deactivateMeetingMutation.isPending} + leftIcon={} > - End Meeting )} - + ))} )} - {/* Upcoming Meetings */} - {upcomingEvents.length > 0 && ( + {/* Upcoming Meetings - SMALLER ASIDE DISPLAY */} + {upcomingMeetings.length > 0 && ( - - Upcoming Meetings + + Starting Soon - {upcomingEvents.map((event) => { - const now = new Date(); - const startTime = new Date(event.start_time); - const endTime = new Date(event.end_time); - const isOngoing = startTime <= now && now <= endTime; - const minutesUntilStart = Math.floor( - (startTime.getTime() - now.getTime()) / (1000 * 60), - ); - const canJoinEarly = minutesUntilStart <= 5; // Allow joining 5 minutes before + + {upcomingMeetings.map((meeting) => { + const now = new Date(); + const startTime = new Date(meeting.start_date); + const minutesUntilStart = Math.floor( + (startTime.getTime() - now.getTime()) / (1000 * 60), + ); - return ( - - - + return ( + + - - - {event.title || "Scheduled Meeting"} - - - {isOngoing - ? formatStartedAgo(event.start_time) - : formatCountdown(event.start_time)} - - - - {isOwner && event.description && ( - - {event.description} - - )} - - - - {formatDateTime(event.start_time)} -{" "} - {formatDateTime(event.end_time)} + + + {(meeting.calendar_metadata as any)?.title || + "Upcoming Meeting"} - {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 - - )} - - )} + + in {minutesUntilStart} minute + {minutesUntilStart !== 1 ? "s" : ""} + + + + Starts: {formatDateTime(meeting.start_date)} + + + - - - - - ); - })} + + ); + })} + )} @@ -374,8 +397,8 @@ export default function MeetingSelection({ )} - {/* Message for non-owners of private rooms */} - {!isOwner && !isSharedRoom && ( + {/* Message for non-owners of private rooms - only show when auth is not loading */} + {!authLoading && !isOwner && !isSharedRoom && ( import("../lib/WherebyWebinarEmbed"), { + ssr: false, +}); type Meeting = components["schemas"]["Meeting"]; @@ -36,11 +42,20 @@ export default function RoomClient({ params }: RoomClientProps) { const activeMeetings = activeMeetingsQuery.data || []; const upcomingMeetings = upcomingMeetingsQuery.data || []; - const isOwner = - auth.status === "authenticated" ? auth.user?.id === room?.user_id : false; + // For non-ICS rooms, create a meeting and get Whereby URL + const roomMeeting = useRoomMeeting( + room && !room.ics_enabled ? roomName : null, + ); + const roomUrl = + roomMeeting?.response?.host_room_url || roomMeeting?.response?.room_url; const isLoading = auth.status === "loading" || roomQuery.isLoading; + const isOwner = + auth.status === "authenticated" && room + ? auth.user?.id === room.user_id + : false; + const handleMeetingSelect = (selectedMeeting: Meeting) => { // Navigate to specific meeting using path segment router.push(`/${roomName}/${selectedMeeting.id}`); @@ -60,14 +75,6 @@ export default function RoomClient({ params }: RoomClientProps) { } }; - // For non-ICS rooms, automatically create and join meeting - useEffect(() => { - if (!room || isLoading || room.ics_enabled) return; - - // Non-ICS room: create meeting automatically - handleCreateUnscheduled(); - }, [room, isLoading]); - // Handle room not found useEffect(() => { if (roomQuery.isError) { @@ -105,20 +112,26 @@ export default function RoomClient({ params }: RoomClientProps) { ); } - // For ICS-enabled rooms, ALWAYS show meeting selection (no auto-redirect) + // For ICS-enabled rooms, show meeting selection if (room.ics_enabled) { return ( ); } - // Non-ICS rooms will auto-redirect via useEffect above + // For non-ICS rooms, show Whereby embed directly + if (roomUrl) { + return ; + } + + // Loading state for non-ICS rooms while creating meeting return ( void; + isWebinar?: boolean; } -// currently used for webinars only +// used for both webinars and meetings export default function WherebyWebinarEmbed({ roomUrl, onLeave, + isWebinar = false, }: WherebyEmbedProps) { const wherebyRef = useRef(null); @@ -26,7 +28,8 @@ export default function WherebyWebinarEmbed({ - This webinar is being recorded. By continuing, you agree to our{" "} + This {isWebinar ? "webinar" : "meeting"} is being recorded. By + continuing, you agree to our{" "} {roomUrl && } + <> + {roomUrl && ( + + )} + ); } if (status === WebinarStatus.Ended) {