feat: improve ICS calendar sync UX and fix room URL matching

- Replace "Test Connection" button with "Force Sync" button (Edit Room only)
- Show detailed sync results: total events downloaded vs room matches
- Remove emoticons and auto-hide timeout for cleaner UX
- Fix room URL matching to use UI_BASE_URL instead of BASE_URL
- Replace FaSync icon with LuRefreshCw for consistency
- Clear sync results when dialog closes or Force Sync pressed
- Update tests to reflect UI_BASE_URL change and exact URL matching

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-08 16:22:44 -06:00
parent f6c70ba277
commit f15e1dc7f7
5 changed files with 225 additions and 237 deletions

View File

@@ -36,8 +36,11 @@ class SyncStats(TypedDict):
events_deleted: int events_deleted: int
class SyncResult(TypedDict, total=False): class SyncResultBase(TypedDict):
status: str # "success", "unchanged", "error", "skipped" status: str # "success", "unchanged", "error", "skipped"
class SyncResult(SyncResultBase, total=False):
hash: str | None hash: str | None
events_found: int events_found: int
total_events: int total_events: int
@@ -73,41 +76,41 @@ class ICSFetchService:
window_end = now + timedelta(hours=24) window_end = now + timedelta(hours=24)
for component in calendar.walk(): for component in calendar.walk():
if component.name == "VEVENT": if component.name != "VEVENT":
# Skip cancelled events continue
status = component.get("STATUS", "").upper()
if status == "CANCELLED":
continue
# Count total non-cancelled events in the time window status = component.get("STATUS", "").upper()
event_data = self._parse_event(component) if status == "CANCELLED":
if ( continue
event_data
and window_start <= event_data["start_time"] <= window_end
):
total_events += 1
# Check if event matches this room # Count total non-cancelled events in the time window
if self._event_matches_room(component, room_name, room_url): event_data = self._parse_event(component)
events.append(event_data) print(room_url, event_data)
if event_data and window_start <= event_data["start_time"] <= window_end:
total_events += 1
# Check if event matches this room
if self._event_matches_room(component, room_name, room_url):
events.append(event_data)
return events, total_events return events, total_events
def _event_matches_room(self, event: Event, room_name: str, room_url: str) -> bool: def _event_matches_room(self, event: Event, room_name: str, room_url: str) -> bool:
print("_____", room_url)
location = str(event.get("LOCATION", "")) location = str(event.get("LOCATION", ""))
description = str(event.get("DESCRIPTION", "")) description = str(event.get("DESCRIPTION", ""))
# Only match full room URL (with or without protocol) # Only match full room URL
# XXX leaved here as a patterns, to later be extended with tinyurl or such too
patterns = [ patterns = [
room_url, # Full URL with protocol room_url,
room_url.replace("https://", ""), # Without https protocol
room_url.replace("http://", ""), # Without http protocol
] ]
# Check location and description for patterns # Check location and description for patterns
text_to_check = f"{location} {description}".lower() text_to_check = f"{location} {description}".lower()
for pattern in patterns: for pattern in patterns:
print(text_to_check, pattern.lower())
if pattern.lower() in text_to_check: if pattern.lower() in text_to_check:
return True return True
@@ -225,7 +228,7 @@ class ICSSyncService:
logger.info(f"No changes in ICS for room {room.id}") logger.info(f"No changes in ICS for room {room.id}")
# Still parse to get event count # Still parse to get event count
calendar = self.fetch_service.parse_ics(ics_content) calendar = self.fetch_service.parse_ics(ics_content)
room_url = f"{settings.BASE_URL}/room/{room.name}" room_url = f"{settings.UI_BASE_URL}/{room.name}"
events, total_events = self.fetch_service.extract_room_events( events, total_events = self.fetch_service.extract_room_events(
calendar, room.name, room_url calendar, room.name, room_url
) )
@@ -243,7 +246,7 @@ class ICSSyncService:
calendar = self.fetch_service.parse_ics(ics_content) calendar = self.fetch_service.parse_ics(ics_content)
# Build room URL # Build room URL
room_url = f"{settings.BASE_URL}/room/{room.name}" room_url = f"{settings.UI_BASE_URL}/{room.name}"
# Extract matching events # Extract matching events
events, total_events = self.fetch_service.extract_room_events( events, total_events = self.fetch_service.extract_room_events(

View File

@@ -36,7 +36,7 @@ async def test_sync_room_ics_task():
event.add("summary", "Task Test Meeting") event.add("summary", "Task Test Meeting")
from reflector.settings import settings from reflector.settings import settings
event.add("location", f"{settings.BASE_URL}/room/{room.name}") event.add("location", f"{settings.UI_BASE_URL}/{room.name}")
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
event.add("dtstart", now + timedelta(hours=1)) event.add("dtstart", now + timedelta(hours=1))
event.add("dtend", now + timedelta(hours=2)) event.add("dtend", now + timedelta(hours=2))

View File

@@ -13,7 +13,7 @@ from reflector.services.ics_sync import ICSFetchService, ICSSyncService
async def test_ics_fetch_service_event_matching(): async def test_ics_fetch_service_event_matching():
service = ICSFetchService() service = ICSFetchService()
room_name = "test-room" room_name = "test-room"
room_url = "https://example.com/room/test-room" room_url = "https://example.com/test-room"
# Create test event # Create test event
event = Event() event = Event()
@@ -21,12 +21,12 @@ async def test_ics_fetch_service_event_matching():
event.add("summary", "Test Meeting") event.add("summary", "Test Meeting")
# Test matching with full URL in location # Test matching with full URL in location
event.add("location", "https://example.com/room/test-room") event.add("location", "https://example.com/test-room")
assert service._event_matches_room(event, room_name, room_url) is True assert service._event_matches_room(event, room_name, room_url) is True
# Test matching with URL without protocol # Test non-matching with URL without protocol (exact matching only now)
event["location"] = "example.com/room/test-room" event["location"] = "example.com/test-room"
assert service._event_matches_room(event, room_name, room_url) is True assert service._event_matches_room(event, room_name, room_url) is False
# Test matching in description # Test matching in description
event["location"] = "Conference Room A" event["location"] = "Conference Room A"
@@ -39,7 +39,7 @@ async def test_ics_fetch_service_event_matching():
assert service._event_matches_room(event, room_name, room_url) is False assert service._event_matches_room(event, room_name, room_url) is False
# Test partial paths should NOT match anymore # Test partial paths should NOT match anymore
event["location"] = "/room/test-room" event["location"] = "/test-room"
assert service._event_matches_room(event, room_name, room_url) is False assert service._event_matches_room(event, room_name, room_url) is False
event["location"] = f"Room: {room_name}" event["location"] = f"Room: {room_name}"
@@ -55,7 +55,7 @@ async def test_ics_fetch_service_parse_event():
event.add("uid", "test-456") event.add("uid", "test-456")
event.add("summary", "Team Standup") event.add("summary", "Team Standup")
event.add("description", "Daily team sync") event.add("description", "Daily team sync")
event.add("location", "https://example.com/room/standup") event.add("location", "https://example.com/standup")
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
event.add("dtstart", now) event.add("dtstart", now)
@@ -73,7 +73,7 @@ async def test_ics_fetch_service_parse_event():
assert result["ics_uid"] == "test-456" assert result["ics_uid"] == "test-456"
assert result["title"] == "Team Standup" assert result["title"] == "Team Standup"
assert result["description"] == "Daily team sync" assert result["description"] == "Daily team sync"
assert result["location"] == "https://example.com/room/standup" assert result["location"] == "https://example.com/standup"
assert len(result["attendees"]) == 3 # 2 attendees + 1 organizer assert len(result["attendees"]) == 3 # 2 attendees + 1 organizer
@@ -81,7 +81,7 @@ async def test_ics_fetch_service_parse_event():
async def test_ics_fetch_service_extract_room_events(): async def test_ics_fetch_service_extract_room_events():
service = ICSFetchService() service = ICSFetchService()
room_name = "meeting" room_name = "meeting"
room_url = "https://example.com/room/meeting" room_url = "https://example.com/meeting"
# Create calendar with multiple events # Create calendar with multiple events
cal = Calendar() cal = Calendar()
@@ -100,7 +100,7 @@ async def test_ics_fetch_service_extract_room_events():
event2 = Event() event2 = Event()
event2.add("uid", "no-match") event2.add("uid", "no-match")
event2.add("summary", "Other Meeting") event2.add("summary", "Other Meeting")
event2.add("location", "https://example.com/room/other") event2.add("location", "https://example.com/other")
event2.add("dtstart", now + timedelta(hours=4)) event2.add("dtstart", now + timedelta(hours=4))
event2.add("dtend", now + timedelta(hours=5)) event2.add("dtend", now + timedelta(hours=5))
cal.add_component(event2) cal.add_component(event2)
@@ -125,9 +125,10 @@ async def test_ics_fetch_service_extract_room_events():
cal.add_component(event4) cal.add_component(event4)
# Extract events # Extract events
events = service.extract_room_events(cal, room_name, room_url) events, total_events = service.extract_room_events(cal, room_name, room_url)
assert len(events) == 2 assert len(events) == 2
assert total_events == 3 # 3 events in time window (excluding cancelled)
assert events[0]["ics_uid"] == "match-1" assert events[0]["ics_uid"] == "match-1"
assert events[1]["ics_uid"] == "match-2" assert events[1]["ics_uid"] == "match-2"
@@ -155,10 +156,10 @@ async def test_ics_sync_service_sync_room_calendar():
event = Event() event = Event()
event.add("uid", "sync-event-1") event.add("uid", "sync-event-1")
event.add("summary", "Sync Test Meeting") event.add("summary", "Sync Test Meeting")
# Use the actual BASE_URL from settings # Use the actual UI_BASE_URL from settings
from reflector.settings import settings from reflector.settings import settings
event.add("location", f"{settings.BASE_URL}/room/{room.name}") event.add("location", f"{settings.UI_BASE_URL}/{room.name}")
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
event.add("dtstart", now + timedelta(hours=1)) event.add("dtstart", now + timedelta(hours=1))
event.add("dtend", now + timedelta(hours=2)) event.add("dtend", now + timedelta(hours=2))

View File

@@ -12,8 +12,9 @@ import {
Spinner, Spinner,
Box, Box,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useState } from "react"; import { useState, useEffect } from "react";
import { FaSync, FaCheckCircle, FaExclamationCircle } from "react-icons/fa"; import { LuRefreshCw } from "react-icons/lu";
import { FaCheckCircle, FaExclamationCircle } from "react-icons/fa";
import { useRoomIcsSync, useRoomIcsStatus } from "../../../lib/apiHooks"; import { useRoomIcsSync, useRoomIcsStatus } from "../../../lib/apiHooks";
interface ICSSettingsProps { interface ICSSettingsProps {
@@ -26,6 +27,7 @@ interface ICSSettingsProps {
icsLastEtag?: string; icsLastEtag?: string;
onChange: (settings: Partial<ICSSettingsData>) => void; onChange: (settings: Partial<ICSSettingsData>) => void;
isOwner?: boolean; isOwner?: boolean;
isEditing?: boolean;
} }
export interface ICSSettingsData { export interface ICSSettingsData {
@@ -52,12 +54,18 @@ export default function ICSSettings({
icsLastEtag, icsLastEtag,
onChange, onChange,
isOwner = true, isOwner = true,
isEditing = false,
}: ICSSettingsProps) { }: ICSSettingsProps) {
const [syncStatus, setSyncStatus] = useState< const [syncStatus, setSyncStatus] = useState<
"idle" | "syncing" | "success" | "error" "idle" | "syncing" | "success" | "error"
>("idle"); >("idle");
const [syncMessage, setSyncMessage] = useState<string>(""); const [syncMessage, setSyncMessage] = useState<string>("");
const [testResult, setTestResult] = useState<string>(""); const [syncResult, setSyncResult] = useState<{
eventsFound: number;
totalEvents: number;
eventsCreated: number;
eventsUpdated: number;
} | null>(null);
// React Query hooks // React Query hooks
const syncMutation = useRoomIcsSync(); const syncMutation = useRoomIcsSync();
@@ -67,46 +75,21 @@ export default function ICSSettings({
items: fetchIntervalOptions, items: fetchIntervalOptions,
}); });
const handleTestConnection = async () => { // Clear sync results when dialog closes
if (!icsUrl || !roomName) return; useEffect(() => {
if (!isEditing) {
setSyncStatus("syncing"); setSyncStatus("idle");
setTestResult(""); setSyncResult(null);
setSyncMessage("");
try {
// First notify parent to update the room with the ICS URL
onChange({
ics_url: icsUrl,
ics_enabled: true,
ics_fetch_interval: icsFetchInterval,
});
// Then trigger a sync
const result = await syncMutation.mutateAsync({
params: {
path: { room_name: 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");
} }
}; }, [isEditing]);
const handleManualSync = async () => { const handleForceSync = async () => {
if (!roomName) return; if (!roomName || !isEditing) return;
// Clear previous results
setSyncStatus("syncing"); setSyncStatus("syncing");
setSyncResult(null);
setSyncMessage(""); setSyncMessage("");
try { try {
@@ -116,26 +99,22 @@ export default function ICSSettings({
}, },
}); });
if (result.status === "success") { if (result.status === "success" || result.status === "unchanged") {
setSyncStatus("success"); setSyncStatus("success");
setSyncMessage( setSyncResult({
`Sync complete! Found ${result.events_found} events, ` + eventsFound: result.events_found || 0,
`created ${result.events_created}, updated ${result.events_updated}.`, totalEvents: result.total_events || 0,
); eventsCreated: result.events_created || 0,
eventsUpdated: result.events_updated || 0,
});
} else { } else {
setSyncStatus("error"); setSyncStatus("error");
setSyncMessage(result.error || "Sync failed"); setSyncMessage(result.error || "Sync failed");
} }
} catch (err: any) { } catch (err: any) {
setSyncStatus("error"); setSyncStatus("error");
setSyncMessage(err.body?.detail || "Failed to sync calendar"); setSyncMessage(err.body?.detail || "Failed to force sync calendar");
} }
// Clear status after 5 seconds
setTimeout(() => {
setSyncStatus("idle");
setSyncMessage("");
}, 5000);
}; };
if (!isOwner) { if (!isOwner) {
@@ -198,46 +177,48 @@ export default function ICSSettings({
</Field.HelperText> </Field.HelperText>
</Field.Root> </Field.Root>
{icsUrl && ( {icsUrl && isEditing && roomName && (
<HStack gap={3}> <HStack gap={3}>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={handleTestConnection} onClick={handleForceSync}
disabled={syncStatus === "syncing"} disabled={syncStatus === "syncing"}
> >
{syncStatus === "syncing" && <Spinner size="sm" />} {syncStatus === "syncing" ? (
Test Connection <Spinner size="sm" />
) : (
<LuRefreshCw />
)}
Force Sync
</Button> </Button>
{roomName && icsLastSync && (
<Button
size="sm"
variant="outline"
onClick={handleManualSync}
disabled={syncStatus === "syncing"}
>
<FaSync />
Sync Now
</Button>
)}
</HStack> </HStack>
)} )}
{testResult && ( {syncResult && syncStatus === "success" && (
<Box <Box
p={3} p={3}
borderRadius="md" borderRadius="md"
bg={syncStatus === "success" ? "green.50" : "red.50"} bg="green.50"
borderLeft="4px solid" borderLeft="4px solid"
borderColor={syncStatus === "success" ? "green.400" : "red.400"} borderColor="green.400"
> >
<Text <VStack gap={1} align="stretch">
fontSize="sm" <Text fontSize="sm" color="green.800" fontWeight="medium">
color={syncStatus === "success" ? "green.800" : "red.800"} Sync completed
> </Text>
{testResult} <Text fontSize="sm" color="green.700">
</Text> {syncResult.totalEvents} events downloaded,{" "}
{syncResult.eventsFound} match this room
</Text>
{(syncResult.eventsCreated > 0 ||
syncResult.eventsUpdated > 0) && (
<Text fontSize="sm" color="green.700">
{syncResult.eventsCreated} created,{" "}
{syncResult.eventsUpdated} updated
</Text>
)}
</VStack>
</Box> </Box>
)} )}

View File

@@ -448,6 +448,7 @@ export default function RoomsList() {
<Tabs.Trigger value="general">General</Tabs.Trigger> <Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="calendar">Calendar</Tabs.Trigger> <Tabs.Trigger value="calendar">Calendar</Tabs.Trigger>
<Tabs.Trigger value="share">Share</Tabs.Trigger> <Tabs.Trigger value="share">Share</Tabs.Trigger>
<Tabs.Trigger value="webhook">Webhook</Tabs.Trigger>
</Tabs.List> </Tabs.List>
<Tabs.Content value="general" pt={6}> <Tabs.Content value="general" pt={6}>
@@ -613,9 +614,134 @@ export default function RoomsList() {
<Checkbox.Label>Shared room</Checkbox.Label> <Checkbox.Label>Shared room</Checkbox.Label>
</Checkbox.Root> </Checkbox.Root>
</Field.Root> </Field.Root>
</Tabs.Content>
{/* Webhook Configuration Section */} <Tabs.Content value="calendar" pt={6}>
<Field.Root mt={8}> <ICSSettings
roomId={editRoomId ?? undefined}
roomName={room.name}
icsUrl={room.icsUrl}
icsEnabled={room.icsEnabled}
icsFetchInterval={room.icsFetchInterval}
onChange={(settings) => {
setRoomInput({
...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}
isEditing={isEditing}
/>
</Tabs.Content>
<Tabs.Content value="share" pt={6}>
<Field.Root>
<Checkbox.Root
name="zulipAutoPost"
checked={room.zulipAutoPost}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "zulipAutoPost",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>
Automatically post transcription to Zulip
</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip stream</Field.Label>
<Select.Root
value={room.zulipStream ? [room.zulipStream] : []}
onValueChange={(e) =>
setRoomInput({
...room,
zulipStream: e.value[0],
zulipTopic: "",
})
}
collection={streamCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select stream" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{streamOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip topic</Field.Label>
<Select.Root
value={room.zulipTopic ? [room.zulipTopic] : []}
onValueChange={(e) =>
setRoomInput({ ...room, zulipTopic: e.value[0] })
}
collection={topicCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select topic" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{topicOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
</Tabs.Content>
<Tabs.Content value="webhook" pt={6}>
<Field.Root>
<Field.Label>Webhook URL</Field.Label> <Field.Label>Webhook URL</Field.Label>
<Input <Input
name="webhookUrl" name="webhookUrl"
@@ -718,129 +844,6 @@ export default function RoomsList() {
</> </>
)} )}
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="calendar" pt={6}>
<ICSSettings
roomId={editRoomId ?? undefined}
roomName={room.name}
icsUrl={room.icsUrl}
icsEnabled={room.icsEnabled}
icsFetchInterval={room.icsFetchInterval}
onChange={(settings) => {
setRoomInput({
...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}
/>
</Tabs.Content>
<Tabs.Content value="share" pt={6}>
<Field.Root>
<Checkbox.Root
name="zulipAutoPost"
checked={room.zulipAutoPost}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "zulipAutoPost",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>
Automatically post transcription to Zulip
</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip stream</Field.Label>
<Select.Root
value={room.zulipStream ? [room.zulipStream] : []}
onValueChange={(e) =>
setRoomInput({
...room,
zulipStream: e.value[0],
zulipTopic: "",
})
}
collection={streamCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select stream" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{streamOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip topic</Field.Label>
<Select.Root
value={room.zulipTopic ? [room.zulipTopic] : []}
onValueChange={(e) =>
setRoomInput({ ...room, zulipTopic: e.value[0] })
}
collection={topicCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select topic" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{topicOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
</Tabs.Content>
</Tabs.Root> </Tabs.Root>
</Dialog.Body> </Dialog.Body>
<Dialog.Footer> <Dialog.Footer>