diff --git a/server/reflector/db/calendar_events.py b/server/reflector/db/calendar_events.py
index 931fd979..f4e0ed1e 100644
--- a/server/reflector/db/calendar_events.py
+++ b/server/reflector/db/calendar_events.py
@@ -1,4 +1,4 @@
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
from typing import Any
import sqlalchemy as sa
@@ -65,7 +65,6 @@ class CalendarEventController:
start_after: datetime | None = None,
end_before: datetime | None = None,
) -> list[CalendarEvent]:
- """Get calendar events for a room."""
query = calendar_events.select().where(calendar_events.c.room_id == room_id)
if not include_deleted:
@@ -83,9 +82,9 @@ class CalendarEventController:
return [CalendarEvent(**result) for result in results]
async def get_upcoming(
- self, room_id: str, minutes_ahead: int = 30
+ self, room_id: str, minutes_ahead: int = 120
) -> list[CalendarEvent]:
- """Get upcoming events for a room within the specified minutes."""
+ """Get upcoming events for a room within the specified minutes, including currently happening events."""
now = datetime.now(timezone.utc)
future_time = now + timedelta(minutes=minutes_ahead)
@@ -95,8 +94,8 @@ class CalendarEventController:
sa.and_(
calendar_events.c.room_id == room_id,
calendar_events.c.is_deleted == False,
- calendar_events.c.start_time >= now,
calendar_events.c.start_time <= future_time,
+ calendar_events.c.end_time >= now,
)
)
.order_by(calendar_events.c.start_time.asc())
@@ -106,7 +105,6 @@ class CalendarEventController:
return [CalendarEvent(**result) for result in results]
async def get_by_ics_uid(self, room_id: str, ics_uid: str) -> CalendarEvent | None:
- """Get a calendar event by its ICS UID."""
query = calendar_events.select().where(
sa.and_(
calendar_events.c.room_id == room_id,
@@ -117,11 +115,9 @@ class CalendarEventController:
return CalendarEvent(**result) if result else None
async def upsert(self, event: CalendarEvent) -> CalendarEvent:
- """Create or update a calendar event."""
existing = await self.get_by_ics_uid(event.room_id, event.ics_uid)
if existing:
- # Update existing event
event.id = existing.id
event.created_at = existing.created_at
event.updated_at = datetime.now(timezone.utc)
@@ -132,7 +128,6 @@ class CalendarEventController:
.values(**event.model_dump())
)
else:
- # Insert new event
query = calendar_events.insert().values(**event.model_dump())
await get_database().execute(query)
@@ -144,7 +139,6 @@ class CalendarEventController:
"""Soft delete future events that are no longer in the calendar."""
now = datetime.now(timezone.utc)
- # First, get the IDs of events to delete
select_query = calendar_events.select().where(
sa.and_(
calendar_events.c.room_id == room_id,
@@ -160,7 +154,6 @@ class CalendarEventController:
delete_count = len(to_delete)
if delete_count > 0:
- # Now update them
update_query = (
calendar_events.update()
.where(
@@ -181,13 +174,9 @@ class CalendarEventController:
return delete_count
async def delete_by_room(self, room_id: str) -> int:
- """Hard delete all events for a room (used when room is deleted)."""
query = calendar_events.delete().where(calendar_events.c.room_id == room_id)
result = await get_database().execute(query)
return result.rowcount
-# Add missing import
-from datetime import timedelta
-
calendar_events_controller = CalendarEventController()
diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py
index e87710cd..95263bcd 100644
--- a/server/reflector/views/rooms.py
+++ b/server/reflector/views/rooms.py
@@ -431,7 +431,7 @@ async def rooms_list_meetings(
async def rooms_list_upcoming_meetings(
room_name: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
- minutes_ahead: int = 30,
+ minutes_ahead: int = 120,
):
user_id = user["sub"] if user else None
room = await rooms_controller.get_by_name(room_name)
diff --git a/server/tests/test_calendar_event.py b/server/tests/test_calendar_event.py
index 1f0cad61..ece5f56a 100644
--- a/server/tests/test_calendar_event.py
+++ b/server/tests/test_calendar_event.py
@@ -132,6 +132,16 @@ async def test_calendar_event_get_upcoming():
)
await calendar_events_controller.upsert(upcoming_event)
+ # Currently happening event (started 10 minutes ago, ends in 20 minutes)
+ current_event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="current-event",
+ title="Current Meeting",
+ start_time=now - timedelta(minutes=10),
+ end_time=now + timedelta(minutes=20),
+ )
+ await calendar_events_controller.upsert(current_event)
+
# Future event beyond 30 minutes
future_event = CalendarEvent(
room_id=room.id,
@@ -142,20 +152,83 @@ async def test_calendar_event_get_upcoming():
)
await calendar_events_controller.upsert(future_event)
- # Get upcoming events (default 30 minutes)
+ # Get upcoming events (default 120 minutes) - should include current, upcoming, and future
upcoming = await calendar_events_controller.get_upcoming(room.id)
- assert len(upcoming) == 1
- assert upcoming[0].ics_uid == "upcoming-event"
+ assert len(upcoming) == 3
+ # Events should be sorted by start_time (current event first, then upcoming, then future)
+ assert upcoming[0].ics_uid == "current-event"
+ assert upcoming[1].ics_uid == "upcoming-event"
+ assert upcoming[2].ics_uid == "future-event"
# Get upcoming with custom window
upcoming_extended = await calendar_events_controller.get_upcoming(
room.id, minutes_ahead=180
)
- assert len(upcoming_extended) == 2
- assert upcoming_extended[0].ics_uid == "upcoming-event"
- assert upcoming_extended[1].ics_uid == "future-event"
+ assert len(upcoming_extended) == 3
+ # Events should be sorted by start_time
+ assert upcoming_extended[0].ics_uid == "current-event"
+ assert upcoming_extended[1].ics_uid == "upcoming-event"
+ assert upcoming_extended[2].ics_uid == "future-event"
+
+
+@pytest.mark.asyncio
+async def test_calendar_event_get_upcoming_includes_currently_happening():
+ """Test that get_upcoming includes currently happening events but excludes ended events."""
+ # Create room
+ room = await rooms_controller.add(
+ name="current-happening-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,
+ )
+
+ now = datetime.now(timezone.utc)
+
+ # Event that ended in the past (should NOT be included)
+ past_ended_event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="past-ended-event",
+ title="Past Ended Meeting",
+ start_time=now - timedelta(hours=2),
+ end_time=now - timedelta(minutes=30),
+ )
+ await calendar_events_controller.upsert(past_ended_event)
+
+ # Event currently happening (started 10 minutes ago, ends in 20 minutes) - SHOULD be included
+ currently_happening_event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="currently-happening",
+ title="Currently Happening Meeting",
+ start_time=now - timedelta(minutes=10),
+ end_time=now + timedelta(minutes=20),
+ )
+ await calendar_events_controller.upsert(currently_happening_event)
+
+ # Event starting soon (in 5 minutes) - SHOULD be included
+ upcoming_soon_event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="upcoming-soon",
+ title="Upcoming Soon Meeting",
+ start_time=now + timedelta(minutes=5),
+ end_time=now + timedelta(minutes=35),
+ )
+ await calendar_events_controller.upsert(upcoming_soon_event)
+
+ # Get upcoming events
+ upcoming = await calendar_events_controller.get_upcoming(room.id, minutes_ahead=30)
+
+ # Should only include currently happening and upcoming soon events
+ assert len(upcoming) == 2
+ assert upcoming[0].ics_uid == "currently-happening"
+ assert upcoming[1].ics_uid == "upcoming-soon"
@pytest.mark.asyncio
diff --git a/server/tests/test_room_ics_api.py b/server/tests/test_room_ics_api.py
index a54d8645..976fb915 100644
--- a/server/tests/test_room_ics_api.py
+++ b/server/tests/test_room_ics_api.py
@@ -357,8 +357,9 @@ async def test_list_upcoming_meetings(authenticated_client):
response = await client.get(f"/rooms/{room.name}/meetings/upcoming")
assert response.status_code == 200
data = response.json()
- assert len(data) == 1
+ assert len(data) == 2
assert data[0]["title"] == "Soon"
+ assert data[1]["title"] == "Later"
response = await client.get(
f"/rooms/{room.name}/meetings/upcoming", params={"minutes_ahead": 180}
diff --git a/www/app/[roomName]/MeetingSelection.tsx b/www/app/[roomName]/MeetingSelection.tsx
index b8b07cc9..984dc36e 100644
--- a/www/app/[roomName]/MeetingSelection.tsx
+++ b/www/app/[roomName]/MeetingSelection.tsx
@@ -24,6 +24,12 @@ import {
} from "../lib/apiHooks";
import { useRouter } from "next/navigation";
import Link from "next/link";
+import {
+ formatDateTime,
+ formatCountdown,
+ formatStartedAgo,
+} from "../lib/timeUtils";
+import MinimalHeader from "../components/MinimalHeader";
type Meeting = components["schemas"]["Meeting"];
type CalendarEventResponse = components["schemas"]["CalendarEventResponse"];
@@ -36,32 +42,6 @@ interface MeetingSelectionProps {
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,
@@ -158,234 +138,255 @@ export default function MeetingSelection({
? `${displayName} Room`
: `${displayName}'s Room`;
+ const handleLeaveMeeting = () => {
+ router.push("/");
+ };
+
return (
-
+
+
+
-
- {displayName}'s room
-
-
-
- {/* Active Meetings */}
- {activeMeetings.length > 0 && (
-
-
- Active Meetings
-
- {activeMeetings.map((meeting) => (
-
-
-
-
-
-
- {(meeting.calendar_metadata as any)?.title || "Meeting"}
-
-
-
- {isOwner &&
- (meeting.calendar_metadata as any)?.description && (
-
- {(meeting.calendar_metadata as any).description}
+ {/* Active Meetings */}
+ {activeMeetings.length > 0 && (
+
+
+ Active Meetings
+
+ {activeMeetings.map((meeting) => (
+
+
+
+
+
+
+ {(meeting.calendar_metadata as any)?.title || "Meeting"}
- )}
-
-
-
-
- {meeting.num_clients} participants
-
-
- Started {formatDateTime(meeting.start_date)}
-
-
- {isOwner && (meeting.calendar_metadata as any)?.attendees && (
-
- {(meeting.calendar_metadata as any).attendees
- .slice(0, 3)
- .map((attendee: any, idx: number) => (
-
- {attendee.name || attendee.email}
-
- ))}
- {(meeting.calendar_metadata as any).attendees.length >
- 3 && (
-
- +
- {(meeting.calendar_metadata as any).attendees.length -
- 3}{" "}
- more
-
+ {isOwner &&
+ (meeting.calendar_metadata as any)?.description && (
+
+ {(meeting.calendar_metadata as any).description}
+
)}
-
- )}
-
-
-
- {isOwner && (
+
+
+
+ {meeting.num_clients} participants
+
+
+
+
+ Started {formatDateTime(meeting.start_date)}
+
+
+
+
+ {isOwner &&
+ (meeting.calendar_metadata as any)?.attendees && (
+
+ {(meeting.calendar_metadata as any).attendees
+ .slice(0, 3)
+ .map((attendee: any, idx: number) => (
+
+ {attendee.name || attendee.email}
+
+ ))}
+ {(meeting.calendar_metadata as any).attendees.length >
+ 3 && (
+
+ +
+ {(meeting.calendar_metadata as any).attendees
+ .length - 3}{" "}
+ more
+
+ )}
+
+ )}
+
+
+
+
+ {isOwner && (
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+ {/* Upcoming Meetings */}
+ {upcomingEvents.length > 0 && (
+
+
+ Upcoming Meetings
+
+ {upcomingEvents.map((event) => {
+ const now = new Date();
+ const startTime = new Date(event.start_time);
+ const endTime = new Date(event.end_time);
+ const isOngoing = startTime <= now && now <= endTime;
+
+ return (
+
+
+
+
+
+
+ {event.title || "Scheduled Meeting"}
+
+
+ {isOngoing
+ ? formatStartedAgo(event.start_time)
+ : formatCountdown(event.start_time)}
+
+
+
+ {isOwner && event.description && (
+
+ {event.description}
+
+ )}
+
+
+
+ {formatDateTime(event.start_time)} -{" "}
+ {formatDateTime(event.end_time)}
+
+
+
+ {isOwner && event.attendees && (
+
+ {event.attendees
+ .slice(0, 3)
+ .map((attendee: any, idx: number) => (
+
+ {attendee.name || attendee.email}
+
+ ))}
+ {event.attendees.length > 3 && (
+
+ +{event.attendees.length - 3} more
+
+ )}
+
+ )}
+
+
- )}
-
-
-
- ))}
-
- )}
-
- {/* Upcoming Meetings */}
- {upcomingEvents.length > 0 && (
-
-
- Upcoming Meetings
-
- {upcomingEvents.map((event) => (
-
-
-
-
-
-
- {event.title || "Scheduled Meeting"}
-
-
- {formatCountdown(event.start_time)}
-
+
+ );
+ })}
+
+ )}
- {isOwner && event.description && (
-
- {event.description}
-
- )}
+ {/* Create Unscheduled Meeting - Only for room owners or shared rooms */}
+ {(isOwner || isSharedRoom) && (
+
+
+
+ Start a Quick Meeting
+
+ Jump into a meeting room right away
+
+
+
+
+
+ )}
-
-
- {formatDateTime(event.start_time)} -{" "}
- {formatDateTime(event.end_time)}
-
-
-
- {isOwner && event.attendees && (
-
- {event.attendees
- .slice(0, 3)
- .map((attendee: any, idx: number) => (
-
- {attendee.name || attendee.email}
-
- ))}
- {event.attendees.length > 3 && (
-
- +{event.attendees.length - 3} more
-
- )}
-
- )}
-
-
-
-
-
- ))}
-
- )}
-
- {/* Create Unscheduled Meeting - Only for room owners or shared rooms */}
- {(isOwner || isSharedRoom) && (
-
-
-
- Start a Quick Meeting
-
- Jump into a meeting room right away
-
-
-
-
-
- )}
-
- {/* Message for non-owners of private rooms */}
- {!isOwner && !isSharedRoom && (
-
-
- Only the room owner can create unscheduled meetings in this private
- room.
-
-
- )}
-
- {/* Footer with back to reflector link */}
-
-
- ← Back to Reflector
-
-
+ {/* Message for non-owners of private rooms */}
+ {!isOwner && !isSharedRoom && (
+
+
+ Only the room owner can create unscheduled meetings in this
+ private room.
+
+
+ )}
+
);
}
diff --git a/www/app/[roomName]/wait/[eventId]/WaitPageClient.tsx b/www/app/[roomName]/wait/[eventId]/WaitPageClient.tsx
new file mode 100644
index 00000000..36046c0c
--- /dev/null
+++ b/www/app/[roomName]/wait/[eventId]/WaitPageClient.tsx
@@ -0,0 +1,190 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import {
+ Box,
+ Spinner,
+ Text,
+ VStack,
+ Button,
+ HStack,
+ Badge,
+} from "@chakra-ui/react";
+import { useRouter } from "next/navigation";
+import { useRoomGetByName } from "../../../lib/apiHooks";
+import MinimalHeader from "../../../components/MinimalHeader";
+
+interface WaitPageClientProps {
+ params: {
+ roomName: string;
+ eventId: string;
+ };
+}
+
+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 "Meeting should start now";
+
+ const minutes = Math.floor(diff / 60000);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+
+ if (days > 0) return `Starts in ${days}d ${hours % 24}h ${minutes % 60}m`;
+ if (hours > 0) return `Starts in ${hours}h ${minutes % 60}m`;
+ return `Starts in ${minutes} minutes`;
+};
+
+export default function WaitPageClient({ params }: WaitPageClientProps) {
+ const { roomName, eventId } = params;
+ const router = useRouter();
+
+ const [countdown, setCountdown] = useState("");
+
+ // Fetch room data
+ const roomQuery = useRoomGetByName(roomName);
+ const room = roomQuery.data;
+
+ // Mock event data - in a real implementation, you'd fetch the actual event
+ const mockEvent = {
+ id: eventId,
+ title: "Upcoming Meeting",
+ start_time: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes from now
+ end_time: new Date(Date.now() + 75 * 60 * 1000), // 1 hour 15 minutes from now
+ description: "Meeting will start soon",
+ };
+
+ // Update countdown every second
+ useEffect(() => {
+ const timer = setInterval(() => {
+ setCountdown(formatCountdown(mockEvent.start_time));
+ }, 1000);
+
+ return () => clearInterval(timer);
+ }, [mockEvent.start_time]);
+
+ // Redirect to selection if room not found
+ useEffect(() => {
+ if (roomQuery.isError) {
+ router.push(`/${roomName}`);
+ }
+ }, [roomQuery.isError, router, roomName]);
+
+ const handleJoinEarly = () => {
+ // In a real implementation, this would create a meeting and join
+ alert("Join early functionality not yet implemented");
+ };
+
+ const handleBackToSelection = () => {
+ router.push(`/${roomName}`);
+ };
+
+ if (roomQuery.isLoading) {
+ return (
+
+
+
+
+
+ Loading...
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ {mockEvent.title}
+
+
+ {countdown}
+
+
+
+
+
+
+
+ Meeting Details
+
+
+ {formatDateTime(mockEvent.start_time)} -{" "}
+ {formatDateTime(mockEvent.end_time)}
+
+ {mockEvent.description && (
+
+ {mockEvent.description}
+
+ )}
+
+
+
+
+
+
+ The meeting hasn't started yet. You can wait here or come back
+ later.
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/www/app/[roomName]/wait/[eventId]/page.tsx b/www/app/[roomName]/wait/[eventId]/page.tsx
index 2bf610ff..1066c2de 100644
--- a/www/app/[roomName]/wait/[eventId]/page.tsx
+++ b/www/app/[roomName]/wait/[eventId]/page.tsx
@@ -1,6 +1,3 @@
-"use client";
-
-import { useEffect, useState } from "react";
import {
Box,
Spinner,
@@ -10,10 +7,9 @@ import {
HStack,
Badge,
} from "@chakra-ui/react";
-import { useRouter } from "next/navigation";
-import { useRoomGetByName } from "../../../lib/apiHooks";
import MinimalHeader from "../../../components/MinimalHeader";
import { Metadata } from "next";
+import WaitPageClient from "./WaitPageClient";
interface WaitPageProps {
params: {
@@ -22,32 +18,6 @@ interface WaitPageProps {
};
}
-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 "Meeting should start now";
-
- const minutes = Math.floor(diff / 60000);
- const hours = Math.floor(minutes / 60);
- const days = Math.floor(hours / 24);
-
- if (days > 0) return `Starts in ${days}d ${hours % 24}h ${minutes % 60}m`;
- if (hours > 0) return `Starts in ${hours}h ${minutes % 60}m`;
- return `Starts in ${minutes} minutes`;
-};
-
// Generate dynamic metadata for the waiting page
export async function generateMetadata({
params,
@@ -83,144 +53,5 @@ export async function generateMetadata({
}
export default function WaitPage({ params }: WaitPageProps) {
- const { roomName, eventId } = params;
- const router = useRouter();
-
- const [countdown, setCountdown] = useState("");
-
- // Fetch room data
- const roomQuery = useRoomGetByName(roomName);
- const room = roomQuery.data;
-
- // Mock event data - in a real implementation, you'd fetch the actual event
- const mockEvent = {
- id: eventId,
- title: "Upcoming Meeting",
- start_time: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes from now
- end_time: new Date(Date.now() + 75 * 60 * 1000), // 1 hour 15 minutes from now
- description: "Meeting will start soon",
- };
-
- // Update countdown every second
- useEffect(() => {
- const timer = setInterval(() => {
- setCountdown(formatCountdown(mockEvent.start_time));
- }, 1000);
-
- return () => clearInterval(timer);
- }, [mockEvent.start_time]);
-
- // Redirect to selection if room not found
- useEffect(() => {
- if (roomQuery.isError) {
- router.push(`/${roomName}`);
- }
- }, [roomQuery.isError, router, roomName]);
-
- const handleJoinEarly = () => {
- // In a real implementation, this would create a meeting and join
- alert("Join early functionality not yet implemented");
- };
-
- const handleBackToSelection = () => {
- router.push(`/${roomName}`);
- };
-
- if (roomQuery.isLoading) {
- return (
-
-
-
-
-
- Loading...
-
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
- {mockEvent.title}
-
-
- {countdown}
-
-
-
-
-
-
-
- Meeting Details
-
-
- {formatDateTime(mockEvent.start_time)} -{" "}
- {formatDateTime(mockEvent.end_time)}
-
- {mockEvent.description && (
-
- {mockEvent.description}
-
- )}
-
-
-
-
-
-
- The meeting hasn't started yet. You can wait here or come back
- later.
-
-
-
-
-
-
-
-
-
-
-
-
- );
+ return ;
}
diff --git a/www/app/components/MinimalHeader.tsx b/www/app/components/MinimalHeader.tsx
index b2901101..859ce55e 100644
--- a/www/app/components/MinimalHeader.tsx
+++ b/www/app/components/MinimalHeader.tsx
@@ -9,17 +9,23 @@ interface MinimalHeaderProps {
roomName: string;
displayName?: string;
showLeaveButton?: boolean;
+ onLeave?: () => void;
}
export default function MinimalHeader({
roomName,
displayName,
showLeaveButton = true,
+ onLeave,
}: MinimalHeaderProps) {
const router = useRouter();
const handleLeaveMeeting = () => {
- router.push(`/${roomName}`);
+ if (onLeave) {
+ onLeave();
+ } else {
+ router.push(`/${roomName}`);
+ }
};
const roomTitle = displayName
@@ -36,8 +42,6 @@ export default function MinimalHeader({
w="100%"
py="2"
px="4"
- borderBottom="1px solid"
- borderColor="gray.200"
bg="white"
position="sticky"
top="0"
@@ -59,7 +63,7 @@ export default function MinimalHeader({
- {/* Leave Meeting Button */}
+ {/* Leave Room Button */}
{showLeaveButton && (
)}
diff --git a/www/app/lib/timeUtils.ts b/www/app/lib/timeUtils.ts
new file mode 100644
index 00000000..507d11f5
--- /dev/null
+++ b/www/app/lib/timeUtils.ts
@@ -0,0 +1,41 @@
+export const formatDateTime = (date: string | Date): string => {
+ const d = new Date(date);
+ return d.toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+};
+
+export const formatCountdown = (startTime: string | Date): string => {
+ 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);
+ const days = Math.floor(hours / 24);
+
+ if (days > 0) return `Starts in ${days}d ${hours % 24}h ${minutes % 60}m`;
+ if (hours > 0) return `Starts in ${hours}h ${minutes % 60}m`;
+ return `Starts in ${minutes} minutes`;
+};
+
+export const formatStartedAgo = (startTime: string | Date): string => {
+ const now = new Date();
+ const start = new Date(startTime);
+ const diff = now.getTime() - start.getTime();
+
+ if (diff <= 0) return "Starting now";
+
+ const minutes = Math.floor(diff / 60000);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+
+ if (days > 0) return `Started ${days}d ${hours % 24}h ${minutes % 60}m ago`;
+ if (hours > 0) return `Started ${hours}h ${minutes % 60}m ago`;
+ return `Started ${minutes} minutes ago`;
+};