mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-21 12:49:06 +00:00
feat: update ics, first version working
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
"""add_ics_uid_to_calendar_event
|
||||||
|
|
||||||
|
Revision ID: a256772ef058
|
||||||
|
Revises: d4a1c446458c
|
||||||
|
Create Date: 2025-08-19 09:27:26.472456
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "a256772ef058"
|
||||||
|
down_revision: Union[str, None] = "d4a1c446458c"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table("calendar_event", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("ics_uid", sa.Text(), nullable=False))
|
||||||
|
batch_op.drop_constraint(batch_op.f("uq_room_calendar_event"), type_="unique")
|
||||||
|
batch_op.create_unique_constraint(
|
||||||
|
"uq_room_calendar_event", ["room_id", "ics_uid"]
|
||||||
|
)
|
||||||
|
batch_op.drop_column("external_id")
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table("calendar_event", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("external_id", sa.TEXT(), autoincrement=False, nullable=True)
|
||||||
|
)
|
||||||
|
batch_op.drop_constraint("uq_room_calendar_event", type_="unique")
|
||||||
|
batch_op.create_unique_constraint(
|
||||||
|
batch_op.f("uq_room_calendar_event"), ["room_id", "external_id"]
|
||||||
|
)
|
||||||
|
batch_op.drop_column("ics_uid")
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -36,6 +36,18 @@ class SyncStats(TypedDict):
|
|||||||
events_deleted: int
|
events_deleted: int
|
||||||
|
|
||||||
|
|
||||||
|
class SyncResult(TypedDict, total=False):
|
||||||
|
status: str # "success", "unchanged", "error", "skipped"
|
||||||
|
hash: str | None
|
||||||
|
events_found: int
|
||||||
|
total_events: int
|
||||||
|
events_created: int
|
||||||
|
events_updated: int
|
||||||
|
events_deleted: int
|
||||||
|
error: str | None
|
||||||
|
reason: str | None
|
||||||
|
|
||||||
|
|
||||||
class ICSFetchService:
|
class ICSFetchService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.client = httpx.AsyncClient(
|
self.client = httpx.AsyncClient(
|
||||||
@@ -53,8 +65,9 @@ class ICSFetchService:
|
|||||||
|
|
||||||
def extract_room_events(
|
def extract_room_events(
|
||||||
self, calendar: Calendar, room_name: str, room_url: str
|
self, calendar: Calendar, room_name: str, room_url: str
|
||||||
) -> list[EventData]:
|
) -> tuple[list[EventData], int]:
|
||||||
events = []
|
events = []
|
||||||
|
total_events = 0
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
window_start = now - timedelta(hours=1)
|
window_start = now - timedelta(hours=1)
|
||||||
window_end = now + timedelta(hours=24)
|
window_end = now + timedelta(hours=24)
|
||||||
@@ -66,18 +79,19 @@ class ICSFetchService:
|
|||||||
if status == "CANCELLED":
|
if status == "CANCELLED":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 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)
|
||||||
event_data = self._parse_event(component)
|
if (
|
||||||
|
event_data
|
||||||
|
and window_start <= event_data["start_time"] <= window_end
|
||||||
|
):
|
||||||
|
total_events += 1
|
||||||
|
|
||||||
# Only include events in our time window
|
# Check if event matches this room
|
||||||
if (
|
if self._event_matches_room(component, room_name, room_url):
|
||||||
event_data
|
|
||||||
and window_start <= event_data["start_time"] <= window_end
|
|
||||||
):
|
|
||||||
events.append(event_data)
|
events.append(event_data)
|
||||||
|
|
||||||
return 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:
|
||||||
location = str(event.get("LOCATION", ""))
|
location = str(event.get("LOCATION", ""))
|
||||||
@@ -160,20 +174,19 @@ class ICSFetchService:
|
|||||||
attendees = []
|
attendees = []
|
||||||
|
|
||||||
# Parse ATTENDEE properties
|
# Parse ATTENDEE properties
|
||||||
for attendee in event.get("ATTENDEE", []):
|
attendees = event.get("ATTENDEE", [])
|
||||||
if not isinstance(attendee, list):
|
if not isinstance(attendees, list):
|
||||||
attendee = [attendee]
|
attendees = [attendees]
|
||||||
|
for att in attendees:
|
||||||
for att in attendee:
|
att_data: AttendeeData = {
|
||||||
att_data: AttendeeData = {
|
"email": str(att).replace("mailto:", "") if att else None,
|
||||||
"email": str(att).replace("mailto:", "") if att else None,
|
"name": att.params.get("CN") if hasattr(att, "params") else None,
|
||||||
"name": att.params.get("CN") if hasattr(att, "params") else None,
|
"status": att.params.get("PARTSTAT")
|
||||||
"status": att.params.get("PARTSTAT")
|
if hasattr(att, "params")
|
||||||
if hasattr(att, "params")
|
else None,
|
||||||
else None,
|
"role": att.params.get("ROLE") if hasattr(att, "params") else None,
|
||||||
"role": att.params.get("ROLE") if hasattr(att, "params") else None,
|
}
|
||||||
}
|
attendees.append(att_data)
|
||||||
attendees.append(att_data)
|
|
||||||
|
|
||||||
# Add organizer
|
# Add organizer
|
||||||
organizer = event.get("ORGANIZER")
|
organizer = event.get("ORGANIZER")
|
||||||
@@ -194,7 +207,7 @@ class ICSSyncService:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.fetch_service = ICSFetchService()
|
self.fetch_service = ICSFetchService()
|
||||||
|
|
||||||
async def sync_room_calendar(self, room: Room) -> dict:
|
async def sync_room_calendar(self, room: Room) -> SyncResult:
|
||||||
if not room.ics_enabled or not room.ics_url:
|
if not room.ics_enabled or not room.ics_url:
|
||||||
return {"status": "skipped", "reason": "ICS not configured"}
|
return {"status": "skipped", "reason": "ICS not configured"}
|
||||||
|
|
||||||
@@ -210,7 +223,21 @@ class ICSSyncService:
|
|||||||
content_hash = hashlib.md5(ics_content.encode()).hexdigest()
|
content_hash = hashlib.md5(ics_content.encode()).hexdigest()
|
||||||
if room.ics_last_etag == content_hash:
|
if room.ics_last_etag == content_hash:
|
||||||
logger.info(f"No changes in ICS for room {room.id}")
|
logger.info(f"No changes in ICS for room {room.id}")
|
||||||
return {"status": "unchanged", "hash": content_hash}
|
# Still parse to get event count
|
||||||
|
calendar = self.fetch_service.parse_ics(ics_content)
|
||||||
|
room_url = f"{settings.BASE_URL}/room/{room.name}"
|
||||||
|
events, total_events = self.fetch_service.extract_room_events(
|
||||||
|
calendar, room.name, room_url
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "unchanged",
|
||||||
|
"hash": content_hash,
|
||||||
|
"events_found": len(events),
|
||||||
|
"total_events": total_events,
|
||||||
|
"events_created": 0,
|
||||||
|
"events_updated": 0,
|
||||||
|
"events_deleted": 0,
|
||||||
|
}
|
||||||
|
|
||||||
# Parse calendar
|
# Parse calendar
|
||||||
calendar = self.fetch_service.parse_ics(ics_content)
|
calendar = self.fetch_service.parse_ics(ics_content)
|
||||||
@@ -219,7 +246,7 @@ class ICSSyncService:
|
|||||||
room_url = f"{settings.BASE_URL}/room/{room.name}"
|
room_url = f"{settings.BASE_URL}/room/{room.name}"
|
||||||
|
|
||||||
# Extract matching events
|
# Extract matching events
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -240,6 +267,7 @@ class ICSSyncService:
|
|||||||
"status": "success",
|
"status": "success",
|
||||||
"hash": content_hash,
|
"hash": content_hash,
|
||||||
"events_found": len(events),
|
"events_found": len(events),
|
||||||
|
"total_events": total_events,
|
||||||
**sync_result,
|
**sync_result,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from enum import Enum
|
||||||
from typing import Annotated, Any, Literal, Optional
|
from typing import Annotated, Any, Literal, Optional
|
||||||
|
|
||||||
import asyncpg.exceptions
|
import asyncpg.exceptions
|
||||||
@@ -300,14 +301,23 @@ class ICSStatus(BaseModel):
|
|||||||
events_count: int = 0
|
events_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class SyncStatus(str, Enum):
|
||||||
|
success = "success"
|
||||||
|
unchanged = "unchanged"
|
||||||
|
error = "error"
|
||||||
|
skipped = "skipped"
|
||||||
|
|
||||||
|
|
||||||
class ICSSyncResult(BaseModel):
|
class ICSSyncResult(BaseModel):
|
||||||
status: str
|
status: SyncStatus
|
||||||
hash: Optional[str] = None
|
hash: Optional[str] = None
|
||||||
events_found: int = 0
|
events_found: int = 0
|
||||||
|
total_events: int = 0
|
||||||
events_created: int = 0
|
events_created: int = 0
|
||||||
events_updated: int = 0
|
events_updated: int = 0
|
||||||
events_deleted: int = 0
|
events_deleted: int = 0
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rooms/{room_name}/ics/sync", response_model=ICSSyncResult)
|
@router.post("/rooms/{room_name}/ics/sync", response_model=ICSSyncResult)
|
||||||
|
|||||||
29
server/test.ics
Normal file
29
server/test.ics
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
CALSCALE:GREGORIAN
|
||||||
|
METHOD:PUBLISH
|
||||||
|
PRODID:-//Fastmail/2020.5/EN
|
||||||
|
X-APPLE-CALENDAR-COLOR:#0F6A0F
|
||||||
|
X-WR-CALNAME:Test reflector
|
||||||
|
X-WR-TIMEZONE:America/Costa_Rica
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:America/Costa_Rica
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19700101T000000
|
||||||
|
TZOFFSETFROM:-0600
|
||||||
|
TZOFFSETTO:-0600
|
||||||
|
END:STANDARD
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
ATTENDEE;CN=Mathieu Virbel;PARTSTAT=ACCEPTED:MAILTO:mathieu@monadical.com
|
||||||
|
DTEND;TZID=America/Costa_Rica:20250819T143000
|
||||||
|
DTSTAMP:20250819T155951Z
|
||||||
|
DTSTART;TZID=America/Costa_Rica:20250819T140000
|
||||||
|
LOCATION:http://localhost:1250/room/mathieu
|
||||||
|
ORGANIZER;CN=Mathieu Virbel:MAILTO:mathieu@monadical.com
|
||||||
|
SEQUENCE:1
|
||||||
|
SUMMARY:Checkin
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
UID:867df50d-8105-4c58-9280-2b5d26cc9cd3
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
18
server/tests/test_attendee_parsing_bug.ics
Normal file
18
server/tests/test_attendee_parsing_bug.ics
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
CALSCALE:GREGORIAN
|
||||||
|
METHOD:PUBLISH
|
||||||
|
PRODID:-//Test/1.0/EN
|
||||||
|
X-WR-CALNAME:Test Attendee Bug
|
||||||
|
BEGIN:VEVENT
|
||||||
|
ATTENDEE:MAILTO:alice@example.com,bob@example.com,charlie@example.com,diana@example.com,eve@example.com,frank@example.com,george@example.com,helen@example.com,ivan@example.com,jane@example.com,kevin@example.com,laura@example.com,mike@example.com,nina@example.com,oscar@example.com,paul@example.com,queen@example.com,robert@example.com,sarah@example.com,tom@example.com,ursula@example.com,victor@example.com,wendy@example.com,xavier@example.com,yvonne@example.com,zack@example.com,amy@example.com,bill@example.com,carol@example.com
|
||||||
|
DTEND:20250819T190000Z
|
||||||
|
DTSTAMP:20250819T174000Z
|
||||||
|
DTSTART:20250819T180000Z
|
||||||
|
LOCATION:http://localhost:1250/room/test-room
|
||||||
|
ORGANIZER;CN=Test Organizer:MAILTO:organizer@example.com
|
||||||
|
SEQUENCE:1
|
||||||
|
SUMMARY:Test Meeting with Many Attendees
|
||||||
|
UID:test-attendee-bug-event
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
167
server/tests/test_attendee_parsing_bug.py
Normal file
167
server/tests/test_attendee_parsing_bug.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import os
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from reflector.db.rooms import rooms_controller
|
||||||
|
from reflector.services.ics_sync import ICSSyncService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_attendee_parsing_bug():
|
||||||
|
"""
|
||||||
|
Test that reproduces the attendee parsing bug where a string with comma-separated
|
||||||
|
emails gets parsed as individual characters instead of separate email addresses.
|
||||||
|
|
||||||
|
The bug manifests as getting 29 attendees with emails like "M", "A", "I", etc.
|
||||||
|
instead of properly parsed email addresses.
|
||||||
|
"""
|
||||||
|
# Create a test room
|
||||||
|
room = await rooms_controller.add(
|
||||||
|
name="test-room",
|
||||||
|
user_id="test-user",
|
||||||
|
zulip_auto_post=False,
|
||||||
|
zulip_stream="",
|
||||||
|
zulip_topic="",
|
||||||
|
is_locked=False,
|
||||||
|
room_mode="normal",
|
||||||
|
recording_type="cloud",
|
||||||
|
recording_trigger="automatic-2nd-participant",
|
||||||
|
is_shared=False,
|
||||||
|
ics_url="http://test.com/test.ics",
|
||||||
|
ics_enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read the test ICS file that reproduces the bug
|
||||||
|
test_ics_path = os.path.join(
|
||||||
|
os.path.dirname(__file__), "test_attendee_parsing_bug.ics"
|
||||||
|
)
|
||||||
|
with open(test_ics_path, "r") as f:
|
||||||
|
ics_content = f.read()
|
||||||
|
|
||||||
|
# Create sync service and mock the fetch
|
||||||
|
sync_service = ICSSyncService()
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
sync_service.fetch_service, "fetch_ics", new_callable=AsyncMock
|
||||||
|
) as mock_fetch:
|
||||||
|
mock_fetch.return_value = ics_content
|
||||||
|
|
||||||
|
# Debug: Parse the ICS content directly to examine attendee parsing
|
||||||
|
calendar = sync_service.fetch_service.parse_ics(ics_content)
|
||||||
|
from reflector.settings import settings
|
||||||
|
|
||||||
|
room_url = f"{settings.BASE_URL}/room/{room.name}"
|
||||||
|
|
||||||
|
print(f"Room URL being used for matching: {room_url}")
|
||||||
|
print(f"ICS content:\n{ics_content}")
|
||||||
|
|
||||||
|
events, total_events = sync_service.fetch_service.extract_room_events(
|
||||||
|
calendar, room.name, room_url
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Total events in calendar: {total_events}")
|
||||||
|
print(f"Events matching room: {len(events)}")
|
||||||
|
|
||||||
|
# Perform the sync
|
||||||
|
result = await sync_service.sync_room_calendar(room)
|
||||||
|
|
||||||
|
# Check that the sync succeeded
|
||||||
|
assert result.get("status") == "success"
|
||||||
|
assert result.get("events_found", 0) >= 0 # Allow for debugging
|
||||||
|
|
||||||
|
# We already have the matching events from the debug code above
|
||||||
|
assert len(events) == 1
|
||||||
|
event = events[0]
|
||||||
|
|
||||||
|
# This is where the bug manifests - check the attendees
|
||||||
|
attendees = event["attendees"]
|
||||||
|
|
||||||
|
# Print attendee info for debugging
|
||||||
|
print(f"Number of attendees found: {len(attendees)}")
|
||||||
|
for i, attendee in enumerate(attendees):
|
||||||
|
print(
|
||||||
|
f"Attendee {i}: email='{attendee.get('email')}', name='{attendee.get('name')}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The bug would cause individual characters to be parsed as attendees
|
||||||
|
# Check if we have the problematic parsing (emails like "M", "A", "I", etc.)
|
||||||
|
single_char_emails = [
|
||||||
|
att for att in attendees if att.get("email") and len(att["email"]) == 1
|
||||||
|
]
|
||||||
|
|
||||||
|
if single_char_emails:
|
||||||
|
print(
|
||||||
|
f"BUG DETECTED: Found {len(single_char_emails)} single-character emails:"
|
||||||
|
)
|
||||||
|
for att in single_char_emails:
|
||||||
|
print(f" - '{att['email']}'")
|
||||||
|
|
||||||
|
# For now, just assert that we have attendees (the test will show the bug)
|
||||||
|
# In a fix, we would expect proper email addresses, not single characters
|
||||||
|
assert len(attendees) > 0
|
||||||
|
|
||||||
|
if len(attendees) > 3:
|
||||||
|
pytest.fail(
|
||||||
|
f"ATTENDEE PARSING BUG DETECTED: "
|
||||||
|
f"Found {len(attendees)} attendees with {len(single_char_emails)} single-character emails. "
|
||||||
|
f"This suggests a comma-separated string was parsed as individual characters."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_correct_attendee_parsing():
|
||||||
|
"""
|
||||||
|
Test what correct attendee parsing should look like.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from icalendar import Event
|
||||||
|
|
||||||
|
from reflector.services.ics_sync import ICSFetchService
|
||||||
|
|
||||||
|
service = ICSFetchService()
|
||||||
|
|
||||||
|
# Create a properly formatted event with multiple attendees
|
||||||
|
event = Event()
|
||||||
|
event.add("uid", "test-correct-attendees")
|
||||||
|
event.add("summary", "Test Meeting")
|
||||||
|
event.add("location", "http://test.com/test")
|
||||||
|
event.add("dtstart", datetime.now(timezone.utc))
|
||||||
|
event.add("dtend", datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
# Add attendees the correct way (separate ATTENDEE lines)
|
||||||
|
event.add("attendee", "mailto:alice@example.com", parameters={"CN": "Alice"})
|
||||||
|
event.add("attendee", "mailto:bob@example.com", parameters={"CN": "Bob"})
|
||||||
|
event.add("attendee", "mailto:charlie@example.com", parameters={"CN": "Charlie"})
|
||||||
|
event.add(
|
||||||
|
"organizer", "mailto:organizer@example.com", parameters={"CN": "Organizer"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse the event
|
||||||
|
result = service._parse_event(event)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
attendees = result["attendees"]
|
||||||
|
|
||||||
|
# Should have 4 attendees (3 attendees + 1 organizer)
|
||||||
|
assert len(attendees) == 4
|
||||||
|
|
||||||
|
# Check that all emails are valid email addresses
|
||||||
|
emails = [att["email"] for att in attendees if att.get("email")]
|
||||||
|
expected_emails = [
|
||||||
|
"alice@example.com",
|
||||||
|
"bob@example.com",
|
||||||
|
"charlie@example.com",
|
||||||
|
"organizer@example.com",
|
||||||
|
]
|
||||||
|
|
||||||
|
for email in emails:
|
||||||
|
assert "@" in email, f"Invalid email format: {email}"
|
||||||
|
assert len(email) > 5, f"Email too short: {email}"
|
||||||
|
|
||||||
|
# Check that we have the expected emails
|
||||||
|
assert "alice@example.com" in emails
|
||||||
|
assert "bob@example.com" in emails
|
||||||
|
assert "charlie@example.com" in emails
|
||||||
|
assert "organizer@example.com" in emails
|
||||||
365
www/app/(app)/rooms/[roomName]/calendar/page.tsx
Normal file
365
www/app/(app)/rooms/[roomName]/calendar/page.tsx
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
VStack,
|
||||||
|
Heading,
|
||||||
|
Text,
|
||||||
|
Card,
|
||||||
|
HStack,
|
||||||
|
Badge,
|
||||||
|
Spinner,
|
||||||
|
Flex,
|
||||||
|
Link,
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
Wrap,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { FaSync, FaClock, FaUsers, FaEnvelope } from "react-icons/fa";
|
||||||
|
import { LuArrowLeft } from "react-icons/lu";
|
||||||
|
import useApi from "../../../../lib/useApi";
|
||||||
|
import { CalendarEventResponse } from "../../../../api";
|
||||||
|
|
||||||
|
export default function RoomCalendarPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const roomName = params.roomName as string;
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
const [events, setEvents] = useState<CalendarEventResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
|
||||||
|
const fetchEvents = async () => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await api.v1RoomsListMeetings({ roomName });
|
||||||
|
setEvents(response);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.body?.detail || "Failed to load calendar events");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSyncing(true);
|
||||||
|
await api.v1RoomsSyncIcs({ roomName });
|
||||||
|
await fetchEvents(); // Refresh events after sync
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.body?.detail || "Failed to sync calendar");
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEvents();
|
||||||
|
}, [api, roomName]);
|
||||||
|
|
||||||
|
const formatEventTime = (start: string, end: string) => {
|
||||||
|
const startDate = new Date(start);
|
||||||
|
const endDate = new Date(end);
|
||||||
|
const options: Intl.DateTimeFormatOptions = {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
weekday: "long",
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSameDay = startDate.toDateString() === endDate.toDateString();
|
||||||
|
|
||||||
|
if (isSameDay) {
|
||||||
|
return `${startDate.toLocaleDateString(undefined, dateOptions)} • ${startDate.toLocaleTimeString(undefined, options)} - ${endDate.toLocaleTimeString(undefined, options)}`;
|
||||||
|
} else {
|
||||||
|
return `${startDate.toLocaleDateString(undefined, dateOptions)} ${startDate.toLocaleTimeString(undefined, options)} - ${endDate.toLocaleDateString(undefined, dateOptions)} ${endDate.toLocaleTimeString(undefined, options)}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEventActive = (start: string, end: string) => {
|
||||||
|
const now = new Date();
|
||||||
|
const startDate = new Date(start);
|
||||||
|
const endDate = new Date(end);
|
||||||
|
return now >= startDate && now <= endDate;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEventUpcoming = (start: string) => {
|
||||||
|
const now = new Date();
|
||||||
|
const startDate = new Date(start);
|
||||||
|
const hourFromNow = new Date(now.getTime() + 60 * 60 * 1000);
|
||||||
|
return startDate > now && startDate <= hourFromNow;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAttendeeDisplay = (attendee: any) => {
|
||||||
|
// Use name if available, otherwise use email
|
||||||
|
const displayName = attendee.name || attendee.email || "Unknown";
|
||||||
|
// Extract just the name part if it's in "Name <email>" format
|
||||||
|
const cleanName = displayName.replace(/<.*>/, "").trim();
|
||||||
|
return cleanName;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAttendeeEmail = (attendee: any) => {
|
||||||
|
return attendee.email || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAttendees = (attendees: any[]) => {
|
||||||
|
if (!attendees || attendees.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack fontSize="sm" color="gray.600" flexWrap="wrap">
|
||||||
|
<FaUsers />
|
||||||
|
<Text>Attendees:</Text>
|
||||||
|
<Wrap spacing={2}>
|
||||||
|
{attendees.map((attendee, index) => {
|
||||||
|
const email = getAttendeeEmail(attendee);
|
||||||
|
const display = getAttendeeDisplay(attendee);
|
||||||
|
|
||||||
|
if (email && email !== display) {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={index}
|
||||||
|
content={
|
||||||
|
<HStack>
|
||||||
|
<FaEnvelope size="12" />
|
||||||
|
<Text>{email}</Text>
|
||||||
|
</HStack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Badge variant="subtle" colorPalette="blue" cursor="help">
|
||||||
|
{display}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Badge key={index} variant="subtle" colorPalette="blue">
|
||||||
|
{display}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Wrap>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedEvents = [...events].sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.start_time).getTime() - new Date(b.start_time).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Separate events by status
|
||||||
|
const now = new Date();
|
||||||
|
const activeEvents = sortedEvents.filter((e) =>
|
||||||
|
isEventActive(e.start_time, e.end_time),
|
||||||
|
);
|
||||||
|
const upcomingEvents = sortedEvents.filter(
|
||||||
|
(e) => new Date(e.start_time) > now,
|
||||||
|
);
|
||||||
|
const pastEvents = sortedEvents
|
||||||
|
.filter((e) => new Date(e.end_time) < now)
|
||||||
|
.reverse();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box w={{ base: "full", md: "container.xl" }} mx="auto" pt={2}>
|
||||||
|
<VStack align="stretch" spacing={6}>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<HStack spacing={3}>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Back to rooms"
|
||||||
|
title="Back to rooms"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => router.push("/rooms")}
|
||||||
|
>
|
||||||
|
<LuArrowLeft />
|
||||||
|
</IconButton>
|
||||||
|
<Heading size="lg">Calendar for {roomName}</Heading>
|
||||||
|
</HStack>
|
||||||
|
<Button
|
||||||
|
colorPalette="blue"
|
||||||
|
onClick={handleSync}
|
||||||
|
leftIcon={syncing ? <Spinner size="sm" /> : <FaSync />}
|
||||||
|
disabled={syncing}
|
||||||
|
>
|
||||||
|
Force Sync
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert.Root status="error">
|
||||||
|
<Alert.Indicator />
|
||||||
|
<Alert.Title>{error}</Alert.Title>
|
||||||
|
</Alert.Root>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Flex justify="center" py={8}>
|
||||||
|
<Spinner size="xl" />
|
||||||
|
</Flex>
|
||||||
|
) : events.length === 0 ? (
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Body>
|
||||||
|
<Text textAlign="center" color="gray.500">
|
||||||
|
No calendar events found. Make sure your calendar is configured
|
||||||
|
and synced.
|
||||||
|
</Text>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
) : (
|
||||||
|
<VStack align="stretch" spacing={6}>
|
||||||
|
{/* Active Events */}
|
||||||
|
{activeEvents.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Heading size="md" mb={3} color="green.600">
|
||||||
|
Active Now
|
||||||
|
</Heading>
|
||||||
|
<VStack align="stretch" spacing={3}>
|
||||||
|
{activeEvents.map((event) => (
|
||||||
|
<Card.Root
|
||||||
|
key={event.id}
|
||||||
|
borderColor="green.200"
|
||||||
|
borderWidth={2}
|
||||||
|
>
|
||||||
|
<Card.Body>
|
||||||
|
<Flex justify="space-between" align="start">
|
||||||
|
<VStack align="start" spacing={2} flex={1}>
|
||||||
|
<HStack>
|
||||||
|
<Heading size="sm">
|
||||||
|
{event.title || "Untitled Event"}
|
||||||
|
</Heading>
|
||||||
|
<Badge colorPalette="green">Active</Badge>
|
||||||
|
</HStack>
|
||||||
|
<HStack fontSize="sm" color="gray.600">
|
||||||
|
<FaClock />
|
||||||
|
<Text>
|
||||||
|
{formatEventTime(
|
||||||
|
event.start_time,
|
||||||
|
event.end_time,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
{event.description && (
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
color="gray.700"
|
||||||
|
noOfLines={2}
|
||||||
|
>
|
||||||
|
{event.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{renderAttendees(event.attendees)}
|
||||||
|
</VStack>
|
||||||
|
<Link href={`/${roomName}`}>
|
||||||
|
<Button size="sm" colorPalette="green">
|
||||||
|
Join Room
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</Flex>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upcoming Events */}
|
||||||
|
{upcomingEvents.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Heading size="md" mb={3}>
|
||||||
|
Upcoming Events
|
||||||
|
</Heading>
|
||||||
|
<VStack align="stretch" spacing={3}>
|
||||||
|
{upcomingEvents.map((event) => (
|
||||||
|
<Card.Root key={event.id}>
|
||||||
|
<Card.Body>
|
||||||
|
<VStack align="start" spacing={2}>
|
||||||
|
<HStack>
|
||||||
|
<Heading size="sm">
|
||||||
|
{event.title || "Untitled Event"}
|
||||||
|
</Heading>
|
||||||
|
{isEventUpcoming(event.start_time) && (
|
||||||
|
<Badge colorPalette="orange">Starting Soon</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
<HStack fontSize="sm" color="gray.600">
|
||||||
|
<FaClock />
|
||||||
|
<Text>
|
||||||
|
{formatEventTime(
|
||||||
|
event.start_time,
|
||||||
|
event.end_time,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
{event.description && (
|
||||||
|
<Text fontSize="sm" color="gray.700" noOfLines={2}>
|
||||||
|
{event.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{renderAttendees(event.attendees)}
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Past Events */}
|
||||||
|
{pastEvents.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Heading size="md" mb={3} color="gray.500">
|
||||||
|
Past Events
|
||||||
|
</Heading>
|
||||||
|
<VStack align="stretch" spacing={3}>
|
||||||
|
{pastEvents.slice(0, 5).map((event) => (
|
||||||
|
<Card.Root key={event.id} opacity={0.7}>
|
||||||
|
<Card.Body>
|
||||||
|
<VStack align="start" spacing={2}>
|
||||||
|
<Heading size="sm">
|
||||||
|
{event.title || "Untitled Event"}
|
||||||
|
</Heading>
|
||||||
|
<HStack fontSize="sm" color="gray.600">
|
||||||
|
<FaClock />
|
||||||
|
<Text>
|
||||||
|
{formatEventTime(
|
||||||
|
event.start_time,
|
||||||
|
event.end_time,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
{renderAttendees(event.attendees)}
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
))}
|
||||||
|
{pastEvents.length > 5 && (
|
||||||
|
<Text fontSize="sm" color="gray.500" textAlign="center">
|
||||||
|
And {pastEvents.length - 5} more past events...
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user