feat: implement frontend for calendar integration (Phase 3 & 4)

- 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

- 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

- 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

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

- 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

- 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

- 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 29725ee72d
commit 575f20fee2
12 changed files with 5606 additions and 12 deletions

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

@@ -31,6 +31,7 @@ import {
import { RoomList } from "./_components/RoomList";
import { PaginationPage } from "../browse/_components/Pagination";
import { assertExists } from "../../lib/utils";
import ICSSettings from "./_components/ICSSettings";
type Room = components["schemas"]["Room"];
@@ -70,6 +71,9 @@ const roomInitialState = {
isShared: false,
webhookUrl: "",
webhookSecret: "",
icsUrl: "",
icsEnabled: false,
icsFetchInterval: 5,
};
export default function RoomsList() {
@@ -275,6 +279,9 @@ export default function RoomsList() {
is_shared: room.isShared,
webhook_url: room.webhookUrl,
webhook_secret: room.webhookSecret,
ics_url: room.icsUrl,
ics_enabled: room.icsEnabled,
ics_fetch_interval: room.icsFetchInterval,
};
if (isEditing) {
@@ -316,6 +323,22 @@ export default function RoomsList() {
setShowWebhookSecret(false);
setWebhookTestResult(null);
setRoom({
name: roomData.name,
zulipAutoPost: roomData.zulip_auto_post,
zulipStream: roomData.zulip_stream,
zulipTopic: roomData.zulip_topic,
isLocked: roomData.is_locked,
roomMode: roomData.room_mode,
recordingType: roomData.recording_type,
recordingTrigger: roomData.recording_trigger,
isShared: roomData.is_shared,
webhookUrl: roomData.webhook_url || "",
webhookSecret: roomData.webhook_secret || "",
icsUrl: roomData.ics_url || "",
icsEnabled: roomData.ics_enabled || false,
icsFetchInterval: roomData.ics_fetch_interval || 5,
});
setEditRoomId(roomId);
setIsEditing(true);
setNameError("");
@@ -763,6 +786,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={handleCloseDialog}>