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

@@ -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 (
<Flex
flexDir="column"
w={{ base: "full", md: "container.xl" }}
mx="auto"
px={6}
py={8}
minH="100vh"
>
<Flex flexDir="column" minH="100vh">
<MinimalHeader
roomName={roomName}
displayName={room?.display_name || room?.name}
showLeaveButton={true}
onLeave={handleLeaveMeeting}
/>
<Flex
flexDir="row"
justifyContent="space-between"
alignItems="center"
mb={6}
flexDir="column"
w={{ base: "full", md: "container.xl" }}
mx="auto"
px={6}
py={8}
flex="1"
>
<Text fontSize="lg" fontWeight="semibold">
{displayName}'s room
</Text>
</Flex>
{/* Active Meetings */}
{activeMeetings.length > 0 && (
<VStack align="stretch" gap={4} mb={6}>
<Text fontSize="md" fontWeight="semibold" color="gray.700">
Active Meetings
</Text>
{activeMeetings.map((meeting) => (
<Box
key={meeting.id}
width="100%"
bg="white"
border="1px solid"
borderColor="gray.200"
borderRadius="md"
p={4}
_hover={{ borderColor: "gray.300" }}
>
<HStack justify="space-between" align="start">
<VStack align="start" gap={2} flex={1}>
<HStack>
<Icon as={FaCalendarAlt} color="blue.500" />
<Text fontWeight="semibold">
{(meeting.calendar_metadata as any)?.title || "Meeting"}
</Text>
</HStack>
{isOwner &&
(meeting.calendar_metadata as any)?.description && (
<Text fontSize="sm" color="gray.600">
{(meeting.calendar_metadata as any).description}
{/* Active Meetings */}
{activeMeetings.length > 0 && (
<VStack align="stretch" gap={4} mb={6}>
<Text fontSize="md" fontWeight="semibold" color="gray.700">
Active Meetings
</Text>
{activeMeetings.map((meeting) => (
<Box
key={meeting.id}
width="100%"
bg="white"
border="1px solid"
borderColor="gray.200"
borderRadius="md"
p={4}
_hover={{ borderColor: "gray.300" }}
>
<HStack justify="space-between" align="start">
<VStack align="start" gap={2} flex={1}>
<HStack>
<Icon as={FaCalendarAlt} color="blue.500" />
<Text fontWeight="semibold">
{(meeting.calendar_metadata as any)?.title || "Meeting"}
</Text>
)}
<HStack gap={4} fontSize="sm" color="gray.500">
<HStack>
<Icon as={FaUsers} />
<Text>{meeting.num_clients} participants</Text>
</HStack>
<HStack>
<Icon as={FaClock} />
<Text>Started {formatDateTime(meeting.start_date)}</Text>
</HStack>
</HStack>
{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">
{attendee.name || attendee.email}
</Badge>
))}
{(meeting.calendar_metadata as any).attendees.length >
3 && (
<Badge colorScheme="gray" fontSize="xs">
+
{(meeting.calendar_metadata as any).attendees.length -
3}{" "}
more
</Badge>
{isOwner &&
(meeting.calendar_metadata as any)?.description && (
<Text fontSize="sm" color="gray.600">
{(meeting.calendar_metadata as any).description}
</Text>
)}
</HStack>
)}
</VStack>
<HStack gap={2}>
<Button
colorScheme="blue"
size="md"
onClick={() => handleJoinMeeting(meeting.id)}
>
Join Now
</Button>
{isOwner && (
<HStack gap={4} fontSize="sm" color="gray.500">
<HStack>
<Icon as={FaUsers} />
<Text>{meeting.num_clients} participants</Text>
</HStack>
<HStack>
<Icon as={FaClock} />
<Text>
Started {formatDateTime(meeting.start_date)}
</Text>
</HStack>
</HStack>
{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"
>
{attendee.name || attendee.email}
</Badge>
))}
{(meeting.calendar_metadata as any).attendees.length >
3 && (
<Badge colorScheme="gray" fontSize="xs">
+
{(meeting.calendar_metadata as any).attendees
.length - 3}{" "}
more
</Badge>
)}
</HStack>
)}
</VStack>
<HStack gap={2}>
<Button
colorScheme="blue"
size="md"
onClick={() => handleJoinMeeting(meeting.id)}
>
Join Now
</Button>
{isOwner && (
<Button
variant="outline"
colorScheme="red"
size="md"
onClick={() => handleEndMeeting(meeting.id)}
isLoading={deactivateMeetingMutation.isPending}
>
<Icon as={LuX} />
End Meeting
</Button>
)}
</HStack>
</HStack>
</Box>
))}
</VStack>
)}
{/* Upcoming Meetings */}
{upcomingEvents.length > 0 && (
<VStack align="stretch" gap={4} mb={6}>
<Text fontSize="md" fontWeight="semibold" color="gray.700">
Upcoming Meetings
</Text>
{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%"
bg="white"
border="1px solid"
borderColor="gray.200"
borderRadius="md"
p={4}
_hover={{ borderColor: "gray.300" }}
>
<HStack justify="space-between" align="start">
<VStack align="start" gap={2} flex={1}>
<HStack>
<Icon
as={FaCalendarAlt}
color={isOngoing ? "blue.500" : "orange.500"}
/>
<Text fontWeight="semibold">
{event.title || "Scheduled Meeting"}
</Text>
<Badge
colorScheme={isOngoing ? "blue" : "orange"}
fontSize="xs"
>
{isOngoing
? formatStartedAgo(event.start_time)
: formatCountdown(event.start_time)}
</Badge>
</HStack>
{isOwner && event.description && (
<Text fontSize="sm" color="gray.600">
{event.description}
</Text>
)}
<HStack gap={4} fontSize="sm" color="gray.500">
<Text>
{formatDateTime(event.start_time)} -{" "}
{formatDateTime(event.end_time)}
</Text>
</HStack>
{isOwner && event.attendees && (
<HStack gap={2} flexWrap="wrap">
{event.attendees
.slice(0, 3)
.map((attendee: any, idx: number) => (
<Badge
key={idx}
colorScheme="purple"
fontSize="xs"
>
{attendee.name || attendee.email}
</Badge>
))}
{event.attendees.length > 3 && (
<Badge colorScheme="gray" fontSize="xs">
+{event.attendees.length - 3} more
</Badge>
)}
</HStack>
)}
</VStack>
<Button
variant="outline"
colorScheme="red"
colorScheme={isOngoing ? "blue" : "orange"}
size="md"
onClick={() => handleEndMeeting(meeting.id)}
isLoading={deactivateMeetingMutation.isPending}
onClick={() => handleJoinUpcoming(event)}
>
<Icon as={LuX} />
End Meeting
{isOngoing ? "Join Now" : "Join Early"}
</Button>
)}
</HStack>
</HStack>
</Box>
))}
</VStack>
)}
{/* Upcoming Meetings */}
{upcomingEvents.length > 0 && (
<VStack align="stretch" gap={4} mb={6}>
<Text fontSize="md" fontWeight="semibold" color="gray.700">
Upcoming Meetings
</Text>
{upcomingEvents.map((event) => (
<Box
key={event.id}
width="100%"
bg="white"
border="1px solid"
borderColor="gray.200"
borderRadius="md"
p={4}
_hover={{ borderColor: "gray.300" }}
>
<HStack justify="space-between" align="start">
<VStack align="start" gap={2} flex={1}>
<HStack>
<Icon as={FaCalendarAlt} color="orange.500" />
<Text fontWeight="semibold">
{event.title || "Scheduled Meeting"}
</Text>
<Badge colorScheme="orange" fontSize="xs">
{formatCountdown(event.start_time)}
</Badge>
</HStack>
</Box>
);
})}
</VStack>
)}
{isOwner && event.description && (
<Text fontSize="sm" color="gray.600">
{event.description}
</Text>
)}
{/* Create Unscheduled Meeting - Only for room owners or shared rooms */}
{(isOwner || isSharedRoom) && (
<Box width="100%" bg="gray.50" borderRadius="md" p={4} mt={6}>
<HStack justify="space-between" align="center">
<VStack align="start" gap={1}>
<Text fontWeight="semibold">Start a Quick Meeting</Text>
<Text fontSize="sm" color="gray.600">
Jump into a meeting room right away
</Text>
</VStack>
<Button colorScheme="green" onClick={onCreateUnscheduled}>
Create Meeting
</Button>
</HStack>
</Box>
)}
<HStack gap={4} fontSize="sm" color="gray.500">
<Text>
{formatDateTime(event.start_time)} -{" "}
{formatDateTime(event.end_time)}
</Text>
</HStack>
{isOwner && event.attendees && (
<HStack gap={2} flexWrap="wrap">
{event.attendees
.slice(0, 3)
.map((attendee: any, idx: number) => (
<Badge key={idx} colorScheme="purple" fontSize="xs">
{attendee.name || attendee.email}
</Badge>
))}
{event.attendees.length > 3 && (
<Badge colorScheme="gray" fontSize="xs">
+{event.attendees.length - 3} more
</Badge>
)}
</HStack>
)}
</VStack>
<Button
variant="outline"
colorScheme="orange"
size="md"
onClick={() => handleJoinUpcoming(event)}
>
Join Early
</Button>
</HStack>
</Box>
))}
</VStack>
)}
{/* Create Unscheduled Meeting - Only for room owners or shared rooms */}
{(isOwner || isSharedRoom) && (
<Box width="100%" bg="gray.50" borderRadius="md" p={4} mt={6}>
<HStack justify="space-between" align="center">
<VStack align="start" gap={1}>
<Text fontWeight="semibold">Start a Quick Meeting</Text>
<Text fontSize="sm" color="gray.600">
Jump into a meeting room right away
</Text>
</VStack>
<Button colorScheme="green" onClick={onCreateUnscheduled}>
Create Meeting
</Button>
</HStack>
</Box>
)}
{/* Message for non-owners of private rooms */}
{!isOwner && !isSharedRoom && (
<Box
width="100%"
bg="gray.50"
border="1px solid"
borderColor="gray.200"
borderRadius="md"
p={4}
mt={6}
>
<Text fontSize="sm" color="gray.600" textAlign="center">
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>
{/* Message for non-owners of private rooms */}
{!isOwner && !isSharedRoom && (
<Box
width="100%"
bg="gray.50"
border="1px solid"
borderColor="gray.200"
borderRadius="md"
p={4}
mt={6}
>
<Text fontSize="sm" color="gray.600" textAlign="center">
Only the room owner can create unscheduled meetings in this
private room.
</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 = () => {
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({
</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`;
};