From 98e05e484a9d9bbadf103b0f99f038bf97f3b527 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Tue, 9 Sep 2025 08:51:40 -0600 Subject: [PATCH] feat: complete calendar integration with UI improvements and code cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calendar Integration Tasks: - Update upcoming meetings window from 30 to 120 minutes - Include currently happening events in upcoming meetings API - Create shared time utility functions (formatDateTime, formatCountdown, formatStartedAgo) - Improve ongoing meetings UI logic with proper time detection - Fix backend code organization and remove excessive documentation UI/UX Improvements: - Restructure room page layout using MinimalHeader pattern - Remove borders from header and footer elements - Change button text from "Leave Meeting" to "Leave Room" - Remove "Back to Reflector" footer for cleaner design - Extract WaitPageClient component for better separation Backend Changes: - calendar_events.py: Fix import organization and extend timing window - rooms.py: Update API default from 30 to 120 minutes - Enhanced test coverage for ongoing meeting scenarios Frontend Changes: - MinimalHeader: Add onLeave prop for custom navigation - MeetingSelection: Complete layout restructure with shared utilities - timeUtils: New shared utility file for consistent time formatting 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- server/reflector/db/calendar_events.py | 19 +- server/reflector/views/rooms.py | 2 +- server/tests/test_calendar_event.py | 85 +++- server/tests/test_room_ics_api.py | 3 +- www/app/[roomName]/MeetingSelection.tsx | 477 +++++++++--------- .../wait/[eventId]/WaitPageClient.tsx | 190 +++++++ www/app/[roomName]/wait/[eventId]/page.tsx | 173 +------ www/app/components/MinimalHeader.tsx | 14 +- www/app/lib/timeUtils.ts | 41 ++ 9 files changed, 567 insertions(+), 437 deletions(-) create mode 100644 www/app/[roomName]/wait/[eventId]/WaitPageClient.tsx create mode 100644 www/app/lib/timeUtils.ts 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`; +};