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.
This commit is contained in:
2025-08-18 19:29:56 -06:00
parent f286f0882c
commit 311d453e41
12 changed files with 2082 additions and 42 deletions

74
PLAN.md
View File

@@ -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

View File

@@ -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<ICSSettingsData>) => 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<string>("");
const [testResult, setTestResult] = useState<string>("");
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 (
<VStack spacing={4} align="stretch" mt={6}>
<Text fontWeight="semibold" fontSize="lg">
Calendar Integration (ICS)
</Text>
<Field.Root>
<Checkbox.Root
checked={icsEnabled}
onCheckedChange={(e) => onChange({ ics_enabled: e.checked })}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>Enable ICS calendar sync</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
{icsEnabled && (
<>
<Field.Root>
<Field.Label>ICS Calendar URL</Field.Label>
<Input
placeholder="https://calendar.google.com/calendar/ical/..."
value={icsUrl}
onChange={(e) => onChange({ ics_url: e.target.value })}
/>
<Field.HelperText>
Enter the ICS URL from Google Calendar, Outlook, or other calendar
services
</Field.HelperText>
</Field.Root>
<Field.Root>
<Field.Label>Sync Interval</Field.Label>
<Select.Root
collection={fetchIntervalCollection}
value={[icsFetchInterval.toString()]}
onValueChange={(details) => {
const value = parseInt(details.value[0]);
onChange({ ics_fetch_interval: value });
}}
>
<Select.Trigger>
<Select.ValueText />
</Select.Trigger>
<Select.Content>
{fetchIntervalOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
</Select.Item>
))}
</Select.Content>
</Select.Root>
<Field.HelperText>
How often to check for calendar updates
</Field.HelperText>
</Field.Root>
{icsUrl && (
<HStack spacing={3}>
<Button
size="sm"
variant="outline"
onClick={handleTestConnection}
disabled={syncStatus === "syncing"}
leftIcon={
syncStatus === "syncing" ? <Spinner size="sm" /> : undefined
}
>
Test Connection
</Button>
{roomName && icsLastSync && (
<Button
size="sm"
variant="outline"
onClick={handleManualSync}
disabled={syncStatus === "syncing"}
leftIcon={<FaSync />}
>
Sync Now
</Button>
)}
</HStack>
)}
{testResult && (
<Alert status={syncStatus === "success" ? "success" : "error"}>
<AlertIcon />
<Text fontSize="sm">{testResult}</Text>
</Alert>
)}
{syncMessage && (
<Alert status={syncStatus === "success" ? "success" : "error"}>
<AlertIcon />
<Text fontSize="sm">{syncMessage}</Text>
</Alert>
)}
{icsLastSync && (
<HStack spacing={4} fontSize="sm" color="gray.600">
<HStack>
<FaCheckCircle color="green" />
<Text>Last sync: {new Date(icsLastSync).toLocaleString()}</Text>
</HStack>
{icsLastEtag && (
<Badge colorScheme="blue" fontSize="xs">
ETag: {icsLastEtag.slice(0, 8)}...
</Badge>
)}
</HStack>
)}
</>
)}
</VStack>
);
}

View File

@@ -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() {
<Checkbox.Label>Shared room</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<ICSSettings
roomId={editRoomId}
roomName={room.name}
icsUrl={room.icsUrl}
icsEnabled={room.icsEnabled}
icsFetchInterval={room.icsFetchInterval}
onChange={(settings) => {
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}
/>
</Dialog.Body>
<Dialog.Footer>
<Button variant="ghost" onClick={onClose}>

View File

@@ -0,0 +1,203 @@
import {
Box,
VStack,
HStack,
Text,
Badge,
Icon,
Divider,
} from "@chakra-ui/react";
import { FaCalendarAlt, FaUsers, FaClock, FaInfoCircle } from "react-icons/fa";
import { Meeting } from "../api";
interface MeetingInfoProps {
meeting: Meeting;
isOwner: boolean;
}
export default function MeetingInfo({ meeting, isOwner }: MeetingInfoProps) {
const formatDuration = (start: string | Date, end: string | Date) => {
const startDate = new Date(start);
const endDate = new Date(end);
const now = new Date();
// If meeting hasn't started yet
if (startDate > now) {
return `Scheduled for ${startDate.toLocaleTimeString()}`;
}
// Calculate duration
const durationMs = now.getTime() - startDate.getTime();
const hours = Math.floor(durationMs / (1000 * 60 * 60));
const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes} minutes`;
};
const isCalendarMeeting = !!meeting.calendar_event_id;
const metadata = meeting.calendar_metadata;
return (
<Box
position="absolute"
top="56px"
right="8px"
bg="white"
borderRadius="md"
boxShadow="lg"
p={4}
maxW="300px"
zIndex={999}
>
<VStack align="stretch" spacing={3}>
{/* Meeting Title */}
<HStack>
<Icon
as={isCalendarMeeting ? FaCalendarAlt : FaInfoCircle}
color="blue.500"
/>
<Text fontWeight="semibold" fontSize="md">
{metadata?.title ||
(isCalendarMeeting ? "Calendar Meeting" : "Unscheduled Meeting")}
</Text>
</HStack>
{/* Meeting Status */}
<HStack spacing={2}>
{meeting.is_active && (
<Badge colorScheme="green" fontSize="xs">
Active
</Badge>
)}
{isCalendarMeeting && (
<Badge colorScheme="blue" fontSize="xs">
Calendar
</Badge>
)}
{meeting.is_locked && (
<Badge colorScheme="orange" fontSize="xs">
Locked
</Badge>
)}
</HStack>
<Divider />
{/* Meeting Details */}
<VStack align="stretch" spacing={2} fontSize="sm">
{/* Participants */}
<HStack>
<Icon as={FaUsers} color="gray.500" />
<Text>
{meeting.num_clients}{" "}
{meeting.num_clients === 1 ? "participant" : "participants"}
</Text>
</HStack>
{/* Duration */}
<HStack>
<Icon as={FaClock} color="gray.500" />
<Text>
Duration: {formatDuration(meeting.start_date, meeting.end_date)}
</Text>
</HStack>
{/* Calendar Description (Owner only) */}
{isOwner && metadata?.description && (
<>
<Divider />
<Box>
<Text
fontWeight="semibold"
fontSize="xs"
color="gray.600"
mb={1}
>
Description
</Text>
<Text fontSize="xs" color="gray.700">
{metadata.description}
</Text>
</Box>
</>
)}
{/* Attendees (Owner only) */}
{isOwner && metadata?.attendees && metadata.attendees.length > 0 && (
<>
<Divider />
<Box>
<Text
fontWeight="semibold"
fontSize="xs"
color="gray.600"
mb={1}
>
Invited Attendees ({metadata.attendees.length})
</Text>
<VStack align="stretch" spacing={1}>
{metadata.attendees
.slice(0, 5)
.map((attendee: any, idx: number) => (
<HStack key={idx} fontSize="xs">
<Badge
colorScheme={
attendee.status === "ACCEPTED"
? "green"
: attendee.status === "DECLINED"
? "red"
: attendee.status === "TENTATIVE"
? "yellow"
: "gray"
}
fontSize="xs"
size="sm"
>
{attendee.status?.charAt(0) || "?"}
</Badge>
<Text color="gray.700" isTruncated>
{attendee.name || attendee.email}
</Text>
</HStack>
))}
{metadata.attendees.length > 5 && (
<Text fontSize="xs" color="gray.500" fontStyle="italic">
+{metadata.attendees.length - 5} more
</Text>
)}
</VStack>
</Box>
</>
)}
{/* Recording Info */}
{meeting.recording_type !== "none" && (
<>
<Divider />
<HStack fontSize="xs">
<Badge colorScheme="red" fontSize="xs">
Recording
</Badge>
<Text color="gray.600">
{meeting.recording_type === "cloud" ? "Cloud" : "Local"}
{meeting.recording_trigger !== "none" &&
` (${meeting.recording_trigger})`}
</Text>
</HStack>
</>
)}
</VStack>
{/* Meeting Times */}
<Divider />
<VStack align="stretch" spacing={1} fontSize="xs" color="gray.600">
<Text>Start: {new Date(meeting.start_date).toLocaleString()}</Text>
<Text>End: {new Date(meeting.end_date).toLocaleString()}</Text>
</VStack>
</VStack>
</Box>
);
}

View File

@@ -0,0 +1,335 @@
"use client";
import {
Box,
VStack,
HStack,
Text,
Button,
Spinner,
Card,
CardBody,
CardHeader,
Badge,
Divider,
Icon,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
} from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { FaUsers, FaClock, FaCalendarAlt, FaPlus } from "react-icons/fa";
import { Meeting, CalendarEventResponse } from "../api";
import useApi from "../lib/useApi";
import { useRouter } from "next/navigation";
interface MeetingSelectionProps {
roomName: string;
isOwner: boolean;
onMeetingSelect: (meeting: Meeting) => void;
onCreateUnscheduled: () => void;
}
const formatDateTime = (date: string | Date) => {
const d = new Date(date);
return d.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const formatCountdown = (startTime: string | Date) => {
const now = new Date();
const start = new Date(startTime);
const diff = start.getTime() - now.getTime();
if (diff <= 0) return "Starting now";
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `Starts in ${hours}h ${minutes % 60}m`;
}
return `Starts in ${minutes} minutes`;
};
export default function MeetingSelection({
roomName,
isOwner,
onMeetingSelect,
onCreateUnscheduled,
}: MeetingSelectionProps) {
const [activeMeetings, setActiveMeetings] = useState<Meeting[]>([]);
const [upcomingEvents, setUpcomingEvents] = useState<CalendarEventResponse[]>(
[],
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const api = useApi();
const router = useRouter();
useEffect(() => {
if (!api) return;
const fetchMeetings = async () => {
try {
setLoading(true);
// Fetch active meetings
const active = await api.v1RoomsListActiveMeetings({ roomName });
setActiveMeetings(active);
// Fetch upcoming calendar events (30 min ahead)
const upcoming = await api.v1RoomsListUpcomingMeetings({
roomName,
minutesAhead: 30,
});
setUpcomingEvents(upcoming);
setError(null);
} catch (err) {
console.error("Failed to fetch meetings:", err);
setError("Failed to load meetings. Please try again.");
} finally {
setLoading(false);
}
};
fetchMeetings();
// Refresh every 30 seconds
const interval = setInterval(fetchMeetings, 30000);
return () => clearInterval(interval);
}, [api, roomName]);
const handleJoinMeeting = async (meetingId: string) => {
if (!api) return;
try {
const meeting = await api.v1RoomsJoinMeeting({
roomName,
meetingId,
});
onMeetingSelect(meeting);
} catch (err) {
console.error("Failed to join meeting:", err);
setError("Failed to join meeting. Please try again.");
}
};
const handleJoinUpcoming = (event: CalendarEventResponse) => {
// Navigate to waiting page with event info
router.push(`/room/${roomName}/wait?eventId=${event.id}`);
};
if (loading) {
return (
<Box p={8} textAlign="center">
<Spinner size="lg" color="blue.500" />
<Text mt={4}>Loading meetings...</Text>
</Box>
);
}
if (error) {
return (
<Alert status="error" borderRadius="md">
<AlertIcon />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
);
}
return (
<VStack spacing={6} align="stretch" p={6}>
<Box>
<Text fontSize="2xl" fontWeight="bold" mb={4}>
Select a Meeting
</Text>
{/* Active Meetings */}
{activeMeetings.length > 0 && (
<>
<Text fontSize="lg" fontWeight="semibold" mb={3}>
Active Meetings
</Text>
<VStack spacing={3} mb={6}>
{activeMeetings.map((meeting) => (
<Card key={meeting.id} width="100%" variant="outline">
<CardBody>
<HStack justify="space-between" align="start">
<VStack align="start" spacing={2} flex={1}>
<HStack>
<Icon as={FaCalendarAlt} color="blue.500" />
<Text fontWeight="semibold">
{meeting.calendar_metadata?.title || "Meeting"}
</Text>
</HStack>
{isOwner && meeting.calendar_metadata?.description && (
<Text fontSize="sm" color="gray.600">
{meeting.calendar_metadata.description}
</Text>
)}
<HStack spacing={4} fontSize="sm" color="gray.500">
<HStack>
<Icon as={FaUsers} />
<Text>{meeting.num_clients} participants</Text>
</HStack>
<HStack>
<Icon as={FaClock} />
<Text>
Started {formatDateTime(meeting.start_date)}
</Text>
</HStack>
</HStack>
{isOwner && meeting.calendar_metadata?.attendees && (
<HStack spacing={2} flexWrap="wrap">
{meeting.calendar_metadata.attendees
.slice(0, 3)
.map((attendee: any, idx: number) => (
<Badge
key={idx}
colorScheme="green"
fontSize="xs"
>
{attendee.name || attendee.email}
</Badge>
))}
{meeting.calendar_metadata.attendees.length > 3 && (
<Badge colorScheme="gray" fontSize="xs">
+
{meeting.calendar_metadata.attendees.length - 3}{" "}
more
</Badge>
)}
</HStack>
)}
</VStack>
<Button
colorScheme="blue"
size="md"
onClick={() => handleJoinMeeting(meeting.id)}
>
Join Now
</Button>
</HStack>
</CardBody>
</Card>
))}
</VStack>
</>
)}
{/* Upcoming Meetings */}
{upcomingEvents.length > 0 && (
<>
<Text fontSize="lg" fontWeight="semibold" mb={3}>
Upcoming Meetings
</Text>
<VStack spacing={3} mb={6}>
{upcomingEvents.map((event) => (
<Card
key={event.id}
width="100%"
variant="outline"
bg="gray.50"
>
<CardBody>
<HStack justify="space-between" align="start">
<VStack align="start" spacing={2} flex={1}>
<HStack>
<Icon as={FaCalendarAlt} color="orange.500" />
<Text fontWeight="semibold">
{event.title || "Scheduled Meeting"}
</Text>
<Badge colorScheme="orange" fontSize="xs">
{formatCountdown(event.start_time)}
</Badge>
</HStack>
{isOwner && event.description && (
<Text fontSize="sm" color="gray.600">
{event.description}
</Text>
)}
<HStack spacing={4} fontSize="sm" color="gray.500">
<Text>
{formatDateTime(event.start_time)} -{" "}
{formatDateTime(event.end_time)}
</Text>
</HStack>
{isOwner && event.attendees && (
<HStack spacing={2} flexWrap="wrap">
{event.attendees
.slice(0, 3)
.map((attendee: any, idx: number) => (
<Badge
key={idx}
colorScheme="purple"
fontSize="xs"
>
{attendee.name || attendee.email}
</Badge>
))}
{event.attendees.length > 3 && (
<Badge colorScheme="gray" fontSize="xs">
+{event.attendees.length - 3} more
</Badge>
)}
</HStack>
)}
</VStack>
<Button
variant="outline"
colorScheme="orange"
size="md"
onClick={() => handleJoinUpcoming(event)}
>
Join Early
</Button>
</HStack>
</CardBody>
</Card>
))}
</VStack>
</>
)}
<Divider my={6} />
{/* Create Unscheduled Meeting */}
<Card width="100%" variant="filled" bg="gray.100">
<CardBody>
<HStack justify="space-between" align="center">
<VStack align="start" spacing={1}>
<Text fontWeight="semibold">Start an Unscheduled Meeting</Text>
<Text fontSize="sm" color="gray.600">
Create a new meeting room that's not on the calendar
</Text>
</VStack>
<Button
leftIcon={<FaPlus />}
colorScheme="green"
onClick={onCreateUnscheduled}
>
Create Meeting
</Button>
</HStack>
</CardBody>
</Card>
</Box>
</VStack>
);
}

View File

@@ -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<Room | null>(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 && (
<>
<Button
position="absolute"
top="56px"
right={showMeetingInfo ? "320px" : "8px"}
zIndex={1000}
colorPalette="blue"
size="sm"
onClick={() => setShowMeetingInfo(!showMeetingInfo)}
leftIcon={<Icon as={FaInfoCircle} />}
>
Meeting Info
</Button>
{showMeetingInfo && (
<MeetingInfo meeting={meeting.response} isOwner={isOwner} />
)}
</>
)}
</>
)}
</>

View File

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

View File

@@ -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: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Name",
},
zulip_auto_post: {
anyOf: [
{
type: "boolean",
},
{
type: "null",
},
],
title: "Zulip Auto Post",
},
zulip_stream: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Zulip Stream",
},
zulip_topic: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Zulip Topic",
},
is_locked: {
anyOf: [
{
type: "boolean",
},
{
type: "null",
},
],
title: "Is Locked",
},
room_mode: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Room Mode",
},
recording_type: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Recording Type",
},
recording_trigger: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Recording Trigger",
},
is_shared: {
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;

View File

@@ -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<V1RoomsSyncIcsResponse> {
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<V1RoomsIcsStatusResponse> {
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<V1RoomsListMeetingsResponse> {
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<V1RoomsListUpcomingMeetingsResponse> {
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<V1RoomsListActiveMeetingsResponse> {
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<V1RoomsJoinMeetingResponse> {
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.

View File

@@ -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<ValidationError>;
};
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<CalendarEventResponse>;
export type V1RoomsListUpcomingMeetingsData = {
minutesAhead?: number;
roomName: string;
};
export type V1RoomsListUpcomingMeetingsResponse = Array<CalendarEventResponse>;
export type V1RoomsListActiveMeetingsData = {
roomName: string;
};
export type V1RoomsListActiveMeetingsResponse = Array<Meeting>;
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<CalendarEventResponse>;
/**
* Validation Error
*/
422: HTTPValidationError;
};
};
};
"/v1/rooms/{room_name}/meetings/upcoming": {
get: {
req: V1RoomsListUpcomingMeetingsData;
res: {
/**
* Successful Response
*/
200: Array<CalendarEventResponse>;
/**
* Validation Error
*/
422: HTTPValidationError;
};
};
};
"/v1/rooms/{room_name}/meetings/active": {
get: {
req: V1RoomsListActiveMeetingsData;
res: {
/**
* Successful Response
*/
200: Array<Meeting>;
/**
* 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;

View File

@@ -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<Room | null>(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 (
<Box
minH="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.50"
>
<VStack spacing={4}>
<Spinner size="xl" color="blue.500" />
<Text>{loading ? "Loading room..." : "Checking meetings..."}</Text>
</VStack>
</Box>
);
}
if (!room) {
return (
<Box
minH="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.50"
>
<Text fontSize="lg">Room not found</Text>
</Box>
);
}
// Show meeting selection if ICS is enabled and we have multiple options
if (room.ics_enabled) {
return (
<Box minH="100vh" bg="gray.50">
<MeetingSelection
roomName={roomName}
isOwner={isOwner}
onMeetingSelect={handleMeetingSelect}
onCreateUnscheduled={handleCreateUnscheduled}
/>
</Box>
);
}
// Should not reach here - redirected above
return null;
}

View File

@@ -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<CalendarEventResponse | null>(null);
const [timeRemaining, setTimeRemaining] = useState<number>(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 (
<Box
minH="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.50"
>
<VStack spacing={4}>
<Spinner size="xl" color="blue.500" />
<Text>Loading meeting details...</Text>
</VStack>
</Box>
);
}
if (!event) {
return (
<Box
minH="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.50"
>
<VStack spacing={4}>
<Text fontSize="lg">Meeting not found</Text>
<Button
leftIcon={<FaArrowLeft />}
onClick={() => router.push(`/room/${roomName}`)}
>
Back to Room
</Button>
</VStack>
</Box>
);
}
return (
<Box
minH="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.50"
>
<Card maxW="lg" width="100%" mx={4}>
<CardBody>
<VStack spacing={6} py={4}>
<Icon as={FaClock} boxSize={16} color="blue.500" />
<VStack spacing={2}>
<Text fontSize="2xl" fontWeight="bold">
{event.title || "Scheduled Meeting"}
</Text>
<Text color="gray.600" textAlign="center">
The meeting will start automatically when it's time
</Text>
</VStack>
<Box width="100%">
<Text
fontSize="4xl"
fontWeight="bold"
textAlign="center"
color="blue.600"
>
{formatTime(timeRemaining)}
</Text>
<Progress
value={getProgressValue()}
colorScheme="blue"
size="sm"
mt={4}
borderRadius="full"
/>
</Box>
{event.description && (
<Box width="100%" p={4} bg="gray.100" borderRadius="md">
<Text fontSize="sm" fontWeight="semibold" mb={1}>
Meeting Description
</Text>
<Text fontSize="sm" color="gray.700">
{event.description}
</Text>
</Box>
)}
<VStack spacing={3} width="100%">
<Text fontSize="sm" color="gray.500">
Scheduled for {new Date(event.start_time).toLocaleString()}
</Text>
{checkingMeeting && (
<HStack spacing={2}>
<Spinner size="sm" color="blue.500" />
<Text fontSize="sm" color="blue.600">
Checking if meeting has started...
</Text>
</HStack>
)}
</VStack>
<Button
variant="outline"
leftIcon={<FaArrowLeft />}
onClick={() => router.push(`/room/${roomName}`)}
width="100%"
>
Back to Meeting Selection
</Button>
</VStack>
</CardBody>
</Card>
</Box>
);
}