feat: complete calendar integration with UI improvements and code cleanup

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 <noreply@anthropic.com>
This commit is contained in:
2025-09-09 08:51:40 -06:00
parent 7193b4dbba
commit 98e05e484a
9 changed files with 567 additions and 437 deletions

View File

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

View File

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

View File

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

View File

@@ -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}

View File

@@ -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,26 +138,27 @@ export default function MeetingSelection({
? `${displayName} Room`
: `${displayName}'s Room`;
const handleLeaveMeeting = () => {
router.push("/");
};
return (
<Flex flexDir="column" minH="100vh">
<MinimalHeader
roomName={roomName}
displayName={room?.display_name || room?.name}
showLeaveButton={true}
onLeave={handleLeaveMeeting}
/>
<Flex
flexDir="column"
w={{ base: "full", md: "container.xl" }}
mx="auto"
px={6}
py={8}
minH="100vh"
flex="1"
>
<Flex
flexDir="row"
justifyContent="space-between"
alignItems="center"
mb={6}
>
<Text fontSize="lg" fontWeight="semibold">
{displayName}'s room
</Text>
</Flex>
{/* Active Meetings */}
{activeMeetings.length > 0 && (
<VStack align="stretch" gap={4} mb={6}>
@@ -218,16 +199,23 @@ export default function MeetingSelection({
</HStack>
<HStack>
<Icon as={FaClock} />
<Text>Started {formatDateTime(meeting.start_date)}</Text>
<Text>
Started {formatDateTime(meeting.start_date)}
</Text>
</HStack>
</HStack>
{isOwner && (meeting.calendar_metadata as any)?.attendees && (
{isOwner &&
(meeting.calendar_metadata as any)?.attendees && (
<HStack gap={2} flexWrap="wrap">
{(meeting.calendar_metadata as any).attendees
.slice(0, 3)
.map((attendee: any, idx: number) => (
<Badge key={idx} colorScheme="green" fontSize="xs">
<Badge
key={idx}
colorScheme="green"
fontSize="xs"
>
{attendee.name || attendee.email}
</Badge>
))}
@@ -235,8 +223,8 @@ export default function MeetingSelection({
3 && (
<Badge colorScheme="gray" fontSize="xs">
+
{(meeting.calendar_metadata as any).attendees.length -
3}{" "}
{(meeting.calendar_metadata as any).attendees
.length - 3}{" "}
more
</Badge>
)}
@@ -277,7 +265,13 @@ export default function MeetingSelection({
<Text fontSize="md" fontWeight="semibold" color="gray.700">
Upcoming Meetings
</Text>
{upcomingEvents.map((event) => (
{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 (
<Box
key={event.id}
width="100%"
@@ -291,12 +285,20 @@ export default function MeetingSelection({
<HStack justify="space-between" align="start">
<VStack align="start" gap={2} flex={1}>
<HStack>
<Icon as={FaCalendarAlt} color="orange.500" />
<Icon
as={FaCalendarAlt}
color={isOngoing ? "blue.500" : "orange.500"}
/>
<Text fontWeight="semibold">
{event.title || "Scheduled Meeting"}
</Text>
<Badge colorScheme="orange" fontSize="xs">
{formatCountdown(event.start_time)}
<Badge
colorScheme={isOngoing ? "blue" : "orange"}
fontSize="xs"
>
{isOngoing
? formatStartedAgo(event.start_time)
: formatCountdown(event.start_time)}
</Badge>
</HStack>
@@ -318,7 +320,11 @@ export default function MeetingSelection({
{event.attendees
.slice(0, 3)
.map((attendee: any, idx: number) => (
<Badge key={idx} colorScheme="purple" fontSize="xs">
<Badge
key={idx}
colorScheme="purple"
fontSize="xs"
>
{attendee.name || attendee.email}
</Badge>
))}
@@ -333,15 +339,16 @@ export default function MeetingSelection({
<Button
variant="outline"
colorScheme="orange"
colorScheme={isOngoing ? "blue" : "orange"}
size="md"
onClick={() => handleJoinUpcoming(event)}
>
Join Early
{isOngoing ? "Join Now" : "Join Early"}
</Button>
</HStack>
</Box>
))}
);
})}
</VStack>
)}
@@ -374,18 +381,12 @@ export default function MeetingSelection({
mt={6}
>
<Text fontSize="sm" color="gray.600" textAlign="center">
Only the room owner can create unscheduled meetings in this private
room.
Only the room owner can create unscheduled meetings in this
private room.
</Text>
</Box>
)}
{/* Footer with back to reflector link */}
<Box mt="auto" pt={8} borderTop="1px solid" borderColor="gray.100">
<Text textAlign="center" fontSize="sm" color="gray.500">
<Link href="/browse"> Back to Reflector</Link>
</Text>
</Box>
</Flex>
</Flex>
);
}

View File

@@ -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<string>("");
// 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 (
<Box display="flex" flexDirection="column" minH="100vh">
<MinimalHeader
roomName={roomName}
displayName={room?.display_name || room?.name}
showLeaveButton={false}
/>
<Box
display="flex"
justifyContent="center"
alignItems="center"
flex="1"
p={4}
>
<VStack gap={4}>
<Spinner color="blue.500" size="xl" />
<Text fontSize="lg">Loading...</Text>
</VStack>
</Box>
</Box>
);
}
return (
<Box display="flex" flexDirection="column" minH="100vh">
<MinimalHeader
roomName={roomName}
displayName={room?.display_name || room?.name}
/>
<Box flex="1" p={4}>
<VStack gap={6} align="stretch" maxW="container.md" mx="auto" pt={8}>
<Box textAlign="center">
<Text fontSize="3xl" fontWeight="bold" mb={2}>
{mockEvent.title}
</Text>
<Badge colorScheme="orange" fontSize="lg" px={4} py={2}>
{countdown}
</Badge>
</Box>
<Box
bg="white"
borderRadius="md"
p={8}
textAlign="center"
boxShadow="sm"
>
<VStack gap={6}>
<VStack gap={2}>
<Text fontSize="lg" fontWeight="semibold">
Meeting Details
</Text>
<Text color="gray.600">
{formatDateTime(mockEvent.start_time)} -{" "}
{formatDateTime(mockEvent.end_time)}
</Text>
{mockEvent.description && (
<Text fontSize="sm" color="gray.500">
{mockEvent.description}
</Text>
)}
</VStack>
<Box h="1px" bg="gray.200" w="100%" />
<VStack gap={4}>
<Text fontSize="md" color="gray.600">
The meeting hasn't started yet. You can wait here or come back
later.
</Text>
<HStack gap={4}>
<Button
colorScheme="blue"
onClick={handleJoinEarly}
size="lg"
>
Join Early
</Button>
<Button
variant="outline"
onClick={handleBackToSelection}
size="lg"
>
Back to Meetings
</Button>
</HStack>
</VStack>
</VStack>
</Box>
</VStack>
</Box>
</Box>
);
}

View File

@@ -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<string>("");
// 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 (
<Box display="flex" flexDirection="column" minH="100vh">
<MinimalHeader
roomName={roomName}
displayName={room?.display_name || room?.name}
showLeaveButton={false}
/>
<Box
display="flex"
justifyContent="center"
alignItems="center"
flex="1"
bg="gray.50"
p={4}
>
<VStack gap={4}>
<Spinner color="blue.500" size="xl" />
<Text fontSize="lg">Loading...</Text>
</VStack>
</Box>
</Box>
);
}
return (
<Box display="flex" flexDirection="column" minH="100vh">
<MinimalHeader
roomName={roomName}
displayName={room?.display_name || room?.name}
/>
<Box flex="1" bg="gray.50" p={4}>
<VStack gap={6} align="stretch" maxW="container.md" mx="auto" pt={8}>
<Box textAlign="center">
<Text fontSize="3xl" fontWeight="bold" mb={2}>
{mockEvent.title}
</Text>
<Badge colorScheme="orange" fontSize="lg" px={4} py={2}>
{countdown}
</Badge>
</Box>
<Box
bg="white"
borderRadius="md"
p={8}
textAlign="center"
boxShadow="sm"
>
<VStack gap={6}>
<VStack gap={2}>
<Text fontSize="lg" fontWeight="semibold">
Meeting Details
</Text>
<Text color="gray.600">
{formatDateTime(mockEvent.start_time)} -{" "}
{formatDateTime(mockEvent.end_time)}
</Text>
{mockEvent.description && (
<Text fontSize="sm" color="gray.500">
{mockEvent.description}
</Text>
)}
</VStack>
<Box h="1px" bg="gray.200" w="100%" />
<VStack gap={4}>
<Text fontSize="md" color="gray.600">
The meeting hasn't started yet. You can wait here or come back
later.
</Text>
<HStack gap={4}>
<Button
colorScheme="blue"
onClick={handleJoinEarly}
size="lg"
>
Join Early
</Button>
<Button
variant="outline"
onClick={handleBackToSelection}
size="lg"
>
Back to Meetings
</Button>
</HStack>
</VStack>
</VStack>
</Box>
</VStack>
</Box>
</Box>
);
return <WaitPageClient params={params} />;
}

View File

@@ -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 = () => {
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({
</Text>
</Flex>
{/* Leave Meeting Button */}
{/* Leave Room Button */}
{showLeaveButton && (
<Button
variant="outline"
@@ -67,7 +71,7 @@ export default function MinimalHeader({
size="sm"
onClick={handleLeaveMeeting}
>
Leave Meeting
Leave Room
</Button>
)}
</Flex>

41
www/app/lib/timeUtils.ts Normal file
View File

@@ -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`;
};