From 26e553bfd039a88d1beb63c61040807c03a6f27f Mon Sep 17 00:00:00 2001 From: Joyce <26967919+Joyce-O@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:45:32 -0500 Subject: [PATCH] update --- backend/pyproject.toml | 1 + backend/src/app/ics_service.py | 63 +++-- backend/src/app/main.py | 46 +++- frontend/src/api/client.ts | 7 + .../src/components/AvailabilityHeatmap.tsx | 226 ++++++++++++++++-- .../src/components/ParticipantManager.tsx | 26 +- .../src/components/ParticipantSelector.tsx | 38 ++- frontend/src/components/ScheduleModal.tsx | 82 ++++++- frontend/src/lib/utils.ts | 14 ++ frontend/src/pages/Index.tsx | 123 +++++++++- 10 files changed, 569 insertions(+), 57 deletions(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7efa732..4529063 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "psycopg2-binary>=2.9.0", "httpx>=0.28.0", "icalendar>=6.0.0", + "recurring-ical-events>=3.0.0", "python-dateutil>=2.9.0", "pydantic[email]>=2.10.0", "pydantic-settings>=2.6.0", diff --git a/backend/src/app/ics_service.py b/backend/src/app/ics_service.py index 02a3aab..9daadd2 100644 --- a/backend/src/app/ics_service.py +++ b/backend/src/app/ics_service.py @@ -1,7 +1,8 @@ import logging -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import httpx +import recurring_ical_events from icalendar import Calendar from sqlalchemy import delete from sqlalchemy.ext.asyncio import AsyncSession @@ -10,6 +11,9 @@ from app.models import BusyBlock, Participant logger = logging.getLogger(__name__) +# How far into the future to expand recurring events +RECURRING_EVENT_HORIZON_WEEKS = 8 + async def fetch_ics_content(url: str) -> str: async with httpx.AsyncClient(timeout=30.0) as client: @@ -24,35 +28,48 @@ def parse_ics_to_busy_blocks( calendar = Calendar.from_ical(ics_content) blocks = [] - for component in calendar.walk(): - if component.name == "VEVENT": - dtstart = component.get("dtstart") - dtend = component.get("dtend") + # Define the time range for expanding recurring events + now = datetime.now(timezone.utc) + start_range = now - timedelta(days=7) # Include recent past + end_range = now + timedelta(weeks=RECURRING_EVENT_HORIZON_WEEKS) - if dtstart is None or dtend is None: - continue + # Use recurring_ical_events to expand recurring events + events = recurring_ical_events.of(calendar).between(start_range, end_range) - start_dt = dtstart.dt - end_dt = dtend.dt + for event in events: + dtstart = event.get("dtstart") + dtend = event.get("dtend") - if not isinstance(start_dt, datetime): - start_dt = datetime.combine(start_dt, datetime.min.time()) - if not isinstance(end_dt, datetime): - end_dt = datetime.combine(end_dt, datetime.min.time()) + if dtstart is None: + continue - if start_dt.tzinfo is None: - start_dt = start_dt.replace(tzinfo=timezone.utc) - if end_dt.tzinfo is None: - end_dt = end_dt.replace(tzinfo=timezone.utc) + start_dt = dtstart.dt + end_dt = dtend.dt if dtend else None - blocks.append( - BusyBlock( - participant_id=participant_id, - start_time=start_dt, - end_time=end_dt, - ) + # Handle all-day events (date instead of datetime) + if not isinstance(start_dt, datetime): + start_dt = datetime.combine(start_dt, datetime.min.time()) + if end_dt is None: + # If no end time, assume 1 hour duration + end_dt = start_dt + timedelta(hours=1) + elif not isinstance(end_dt, datetime): + end_dt = datetime.combine(end_dt, datetime.min.time()) + + # Ensure timezone awareness + if start_dt.tzinfo is None: + start_dt = start_dt.replace(tzinfo=timezone.utc) + if end_dt.tzinfo is None: + end_dt = end_dt.replace(tzinfo=timezone.utc) + + blocks.append( + BusyBlock( + participant_id=participant_id, + start_time=start_dt, + end_time=end_dt, ) + ) + logger.info(f"Parsed {len(blocks)} events (including recurring) for participant {participant_id}") return blocks diff --git a/backend/src/app/main.py b/backend/src/app/main.py index bb8dc79..ac3f1a6 100644 --- a/backend/src/app/main.py +++ b/backend/src/app/main.py @@ -7,10 +7,10 @@ from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.availability_service import calculate_availability +from app.availability_service import calculate_availability, get_busy_blocks_for_participants, is_participant_free from app.database import get_db from app.ics_service import sync_all_calendars, sync_participant_calendar -from app.models import Participant +from app.models import Participant, BusyBlock from app.schemas import ( AvailabilityRequest, AvailabilityResponse, @@ -175,6 +175,15 @@ async def sync_participant(participant_id: UUID, db: AsyncSession = Depends(get_ raise HTTPException(status_code=500, detail=str(e)) +@app.delete("/api/bookings") +async def clear_all_bookings(db: AsyncSession = Depends(get_db)): + """Clear all busy blocks (scheduled meetings) from the database.""" + from sqlalchemy import delete + await db.execute(delete(BusyBlock)) + await db.commit() + return {"status": "success", "message": "All bookings cleared"} + + @app.post("/api/schedule") async def schedule_meeting( data: ScheduleRequest, db: AsyncSession = Depends(get_db) @@ -182,7 +191,7 @@ async def schedule_meeting( min_start_time = datetime.now(timezone.utc) + timedelta(hours=2) if data.start_time.replace(tzinfo=timezone.utc) < min_start_time: raise HTTPException( - status_code=400, + status_code=400, detail="Meetings must be scheduled at least 2 hours in advance." ) @@ -190,10 +199,29 @@ async def schedule_meeting( select(Participant).where(Participant.id.in_(data.participant_ids)) ) participants = result.scalars().all() - + if len(participants) != len(data.participant_ids): raise HTTPException(status_code=400, detail="Some participants not found") + # Check if all participants are actually free at the requested time + start_time = data.start_time.replace(tzinfo=timezone.utc) if data.start_time.tzinfo is None else data.start_time + end_time = data.end_time.replace(tzinfo=timezone.utc) if data.end_time.tzinfo is None else data.end_time + + busy_map = await get_busy_blocks_for_participants( + db, data.participant_ids, start_time, end_time + ) + + busy_participants = [] + for participant in participants: + if not is_participant_free(busy_map.get(participant.id, []), start_time, end_time): + busy_participants.append(participant.name) + + if busy_participants: + raise HTTPException( + status_code=409, + detail=f"Cannot schedule: {', '.join(busy_participants)} {'is' if len(busy_participants) == 1 else 'are'} not available at this time. Please refresh availability and try again." + ) + participant_dicts = [ {"name": p.name, "email": p.email} for p in participants ] @@ -213,6 +241,16 @@ async def schedule_meeting( participant_dicts ) + # Create busy blocks for all participants so the slot shows as taken immediately + for participant in participants: + busy_block = BusyBlock( + participant_id=participant.id, + start_time=start_time, + end_time=end_time, + ) + db.add(busy_block) + await db.commit() + return { "status": "success", "email_sent": email_success, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2ffdd40..bd025da 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -115,3 +115,10 @@ export async function scheduleMeeting( }); return handleResponse(response); } + +export async function clearAllBookings(): Promise { + const response = await fetch(`${API_URL}/api/bookings`, { method: 'DELETE' }); + if (!response.ok) { + throw new Error('Failed to clear bookings'); + } +} diff --git a/frontend/src/components/AvailabilityHeatmap.tsx b/frontend/src/components/AvailabilityHeatmap.tsx index e6b8cd0..27b733a 100644 --- a/frontend/src/components/AvailabilityHeatmap.tsx +++ b/frontend/src/components/AvailabilityHeatmap.tsx @@ -6,13 +6,13 @@ import { PopoverTrigger, } from '@/components/ui/popover'; import { Button } from '@/components/ui/button'; -import { useState } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { Check, X, Loader2, ChevronLeft, ChevronRight, ChevronsRight } from 'lucide-react'; -const TIMEZONE = 'America/Toronto'; +const DEFAULT_TIMEZONE = 'America/Toronto'; const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']; -const hours = [9, 10, 11, 12, 13, 14, 15, 16, 17]; +const hours = Array.from({ length: 24 }, (_, i) => i); // 0-23 // Get the dates for Mon-Fri of a week in a specific timezone, offset by N weeks const getWeekDates = (timezone: string, weekOffset: number = 0): string[] => { @@ -78,6 +78,65 @@ const MIN_WEEK_OFFSET = 0; const DEFAULT_MAX_WEEK_OFFSET = 1; const EXPANDED_MAX_WEEK_OFFSET = 4; +// Format timezone for display (e.g., "America/Toronto" -> "Toronto (EST)") +const formatTimezoneDisplay = (timezone: string): string => { + try { + const parts = timezone.split('/'); + const city = parts[parts.length - 1].replace(/_/g, ' '); + const now = new Date(); + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + timeZoneName: 'short', + }); + const formattedParts = formatter.formatToParts(now); + const tzAbbrev = formattedParts.find((p) => p.type === 'timeZoneName')?.value || ''; + return `${city} (${tzAbbrev})`; + } catch { + return timezone; + } +}; + +// Get timezone abbreviation (e.g., "America/Toronto" -> "EST") +const getTimezoneAbbrev = (timezone: string): string => { + try { + const now = new Date(); + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + timeZoneName: 'short', + }); + const parts = formatter.formatToParts(now); + return parts.find((p) => p.type === 'timeZoneName')?.value || ''; + } catch { + return ''; + } +}; + +// Convert an hour from one timezone to another +const convertHourBetweenTimezones = ( + hour: number, + dateStr: string, + fromTimezone: string, + toTimezone: string +): number => { + try { + // Create a UTC date for the given hour in the source timezone + const utcDate = toUTCDate(dateStr, hour, fromTimezone); + + // Format the hour in the target timezone + const targetHour = parseInt( + new Intl.DateTimeFormat('en-US', { + timeZone: toTimezone, + hour: 'numeric', + hour12: false, + }).format(utcDate) + ); + + return targetHour; + } catch { + return hour; + } +}; + interface AvailabilityHeatmapProps { slots: TimeSlot[]; selectedParticipants: Participant[]; @@ -86,6 +145,9 @@ interface AvailabilityHeatmapProps { isLoading?: boolean; weekOffset?: number; onWeekOffsetChange?: (offset: number) => void; + displayTimezone?: string; + showSecondaryTimezone?: boolean; + secondaryTimezone?: string; } export const AvailabilityHeatmap = ({ @@ -96,15 +158,82 @@ export const AvailabilityHeatmap = ({ isLoading = false, weekOffset = 0, onWeekOffsetChange, + displayTimezone = DEFAULT_TIMEZONE, + showSecondaryTimezone = false, + secondaryTimezone = DEFAULT_TIMEZONE, }: AvailabilityHeatmapProps) => { const [expanded, setExpanded] = useState(false); const maxWeekOffset = expanded ? EXPANDED_MAX_WEEK_OFFSET : DEFAULT_MAX_WEEK_OFFSET; - const weekDates = getWeekDates(TIMEZONE, weekOffset); + const weekDates = getWeekDates(displayTimezone, weekOffset); + + // Get current time info in display timezone + const getCurrentTimeInfo = () => { + const now = new Date(); + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone: displayTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + const hourFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: displayTimezone, + hour: 'numeric', + hour12: false, + }); + const todayStr = formatter.format(now); + const currentHour = parseInt(hourFormatter.format(now)); + return { todayStr, currentHour }; + }; + + const { todayStr, currentHour } = getCurrentTimeInfo(); + const todayIndex = weekDates.indexOf(todayStr); + const scrollContainerRef = useRef(null); + const currentHourRef = useRef(null); + + // Track scroll position for fade indicators + const [canScrollUp, setCanScrollUp] = useState(false); + const [canScrollDown, setCanScrollDown] = useState(true); + + const handleScroll = () => { + if (!scrollContainerRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; + setCanScrollUp(scrollTop > 10); + setCanScrollDown(scrollTop < scrollHeight - clientHeight - 10); + }; + + // Initialize scroll indicators after content loads + useEffect(() => { + const timer = setTimeout(handleScroll, 150); + return () => clearTimeout(timer); + }, [slots.length, selectedParticipants.length, isLoading]); + + // Auto-scroll to position current hour as 3rd visible row + useEffect(() => { + // Only scroll when we have content to show + if (selectedParticipants.length === 0 || isLoading) return; + + // Small delay to ensure DOM is ready after render + const timer = setTimeout(() => { + if (!scrollContainerRef.current) return; + + const rowHeight = 52; // h-12 (48px) + gap (4px) + const rowsAbove = 2; + + // Calculate which hour should be at the top + const targetHour = todayIndex >= 0 + ? Math.max(0, currentHour - rowsAbove) + : 7; // Default to 7am for other weeks + + scrollContainerRef.current.scrollTop = targetHour * rowHeight; + }, 100); + + return () => clearTimeout(timer); + }, [weekOffset, todayIndex, currentHour, selectedParticipants.length, isLoading, slots.length]); // Find a slot that matches the given display timezone date/hour const getSlot = (dateStr: string, hour: number): TimeSlot | undefined => { // Convert display timezone date/hour to UTC - const targetUTC = toUTCDate(dateStr, hour, TIMEZONE); + const targetUTC = toUTCDate(dateStr, hour, displayTimezone); return slots.find((s) => { const slotDate = new Date(s.start_time); @@ -126,7 +255,7 @@ export const AvailabilityHeatmap = ({ const isSlotTooSoon = (dateStr: string, hour: number) => { // Convert to UTC and compare with current time - const slotTimeUTC = toUTCDate(dateStr, hour, TIMEZONE); + const slotTimeUTC = toUTCDate(dateStr, hour, displayTimezone); const now = new Date(); const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000); return slotTimeUTC < twoHoursFromNow; @@ -185,6 +314,10 @@ export const AvailabilityHeatmap = ({

{selectedParticipants.length} participant{selectedParticipants.length > 1 ? 's' : ''}: {selectedParticipants.map(p => p.name.split(' ')[0]).join(', ')} + + + {`Times in ${formatTimezoneDisplay(displayTimezone)}`} +

{onWeekOffsetChange && ( @@ -241,10 +374,48 @@ export const AvailabilityHeatmap = ({ )} -
-
-
-
+
+ {/* Scroll fade indicator - top */} +
+ {/* Scroll fade indicator - bottom */} +
+ ↓ Scroll for more hours +
+ +
+
+
+ {showSecondaryTimezone ? ( + <> +
+ {getTimezoneAbbrev(displayTimezone)} +
+
+ {getTimezoneAbbrev(secondaryTimezone)} +
+ + ) : ( +
+ )} {dayNames.map((dayName, i) => (
{hours.map((hour) => ( -
-
- {formatHour(hour)} -
+
= 0 && hour === currentHour ? currentHourRef : undefined} + className={cn( + "grid gap-1", + showSecondaryTimezone + ? "grid-cols-[50px_50px_repeat(5,1fr)]" + : "grid-cols-[60px_repeat(5,1fr)]" + )} + > + {showSecondaryTimezone ? ( + <> +
+ {todayIndex >= 0 && hour === currentHour && ( + + )} + {formatHour(hour)} +
+
+ {formatHour(convertHourBetweenTimezones(hour, weekDates[0] || '', displayTimezone, secondaryTimezone))} +
+ + ) : ( +
+ {todayIndex >= 0 && hour === currentHour && ( + + )} + {formatHour(hour)} +
+ )} {weekDates.map((dateStr, dayIndex) => { const slot = getSlot(dateStr, hour); const dayName = dayNames[dayIndex]; @@ -338,6 +535,7 @@ export const AvailabilityHeatmap = ({
+
diff --git a/frontend/src/components/ParticipantManager.tsx b/frontend/src/components/ParticipantManager.tsx index 058b032..b43a4a2 100644 --- a/frontend/src/components/ParticipantManager.tsx +++ b/frontend/src/components/ParticipantManager.tsx @@ -3,9 +3,15 @@ import { Participant } from '@/types/calendar'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; -import { UserPlus, Trash2, User, Pencil, Check, X, AlertCircle } from 'lucide-react'; +import { UserPlus, Trash2, User, Pencil, Check, X, AlertCircle, Info } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; -import { getAvatarColor } from '@/lib/utils'; +import { getAvatarColor, getCalendarNameFromUrl } from '@/lib/utils'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; interface ParticipantManagerProps { participants: Participant[]; @@ -236,9 +242,19 @@ export const ParticipantManager = ({ {participant.email} {participant.icsLink ? ( - - ICS linked - + + + + + 📅 {getCalendarNameFromUrl(participant.icsLink) || 'Calendar linked'} + + + + +

Availability is based on this calendar only. Other calendars on the same account are not included.

+
+
+
) : ( diff --git a/frontend/src/components/ParticipantSelector.tsx b/frontend/src/components/ParticipantSelector.tsx index 65575a1..b3c55a5 100644 --- a/frontend/src/components/ParticipantSelector.tsx +++ b/frontend/src/components/ParticipantSelector.tsx @@ -1,8 +1,14 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import { Participant } from '@/types/calendar'; import { Input } from '@/components/ui/input'; -import { X, Plus, Search, AlertCircle } from 'lucide-react'; -import { cn, getAvatarColor } from '@/lib/utils'; +import { X, Plus, Search, AlertCircle, Info } from 'lucide-react'; +import { cn, getAvatarColor, getCalendarNameFromUrl } from '@/lib/utils'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; interface ParticipantSelectorProps { participants: Participant[]; @@ -126,12 +132,28 @@ export const ParticipantSelector = ({
{participant.name}
{participant.email}
- {!participant.icsLink && ( - - - No calendar - - )} + + {participant.icsLink ? ( + + + + + 📅 {getCalendarNameFromUrl(participant.icsLink) || 'Calendar'} + + + + +

Availability is based on this calendar only. Other calendars on the same account are not included.

+
+
+
+ ) : ( + + + No calendar + + )} +
))}
diff --git a/frontend/src/components/ScheduleModal.tsx b/frontend/src/components/ScheduleModal.tsx index 34a296e..f7dd54b 100644 --- a/frontend/src/components/ScheduleModal.tsx +++ b/frontend/src/components/ScheduleModal.tsx @@ -11,30 +11,88 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { toast } from '@/hooks/use-toast'; import { Calendar, Clock, Users, Send, AlertCircle } from 'lucide-react'; +const DURATION_OPTIONS = [ + { value: 15, label: '15 minutes' }, + { value: 30, label: '30 minutes' }, + { value: 45, label: '45 minutes' }, + { value: 60, label: '1 hour' }, + { value: 90, label: '1 hour 30 minutes' }, + { value: 120, label: '2 hours' }, + { value: 150, label: '2 hours 30 minutes' }, +]; + interface ScheduleModalProps { isOpen: boolean; onClose: () => void; slot: TimeSlot | null; participants: Participant[]; + displayTimezone?: string; + onSuccess?: () => void; } +// Get user's local timezone +const getUserTimezone = (): string => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return 'America/Toronto'; + } +}; + +// Format timezone for display (e.g., "America/Toronto" -> "EST") +const getTimezoneAbbrev = (timezone: string): string => { + try { + const now = new Date(); + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + timeZoneName: 'short', + }); + const parts = formatter.formatToParts(now); + return parts.find((p) => p.type === 'timeZoneName')?.value || ''; + } catch { + return ''; + } +}; + export const ScheduleModal = ({ isOpen, onClose, slot, participants, + displayTimezone = getUserTimezone(), + onSuccess, }: ScheduleModalProps) => { const [title, setTitle] = useState(''); const [notes, setNotes] = useState(''); + const [duration, setDuration] = useState(60); // default 1 hour const [isSubmitting, setIsSubmitting] = useState(false); const formatHour = (hour: number) => { return `${hour.toString().padStart(2, '0')}:00`; }; + const formatTime = (hour: number, minutes: number) => { + const totalMinutes = hour * 60 + minutes; + const h = Math.floor(totalMinutes / 60) % 24; + const m = totalMinutes % 60; + return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`; + }; + + const getEndTime = () => { + if (!slot) return ''; + return formatTime(slot.hour, duration); + }; + const formatDate = (dateStr: string) => { const date = new Date(dateStr + 'T00:00:00'); return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); @@ -68,7 +126,7 @@ export const ScheduleModal = ({ // Calculate start and end times // slot.day is YYYY-MM-DD const startDateTime = new Date(`${slot.day}T${formatHour(slot.hour)}:00Z`); - const endDateTime = new Date(`${slot.day}T${formatHour(slot.hour + 1)}:00Z`); + const endDateTime = new Date(startDateTime.getTime() + duration * 60 * 1000); await scheduleMeeting( participants.map(p => p.id), @@ -86,6 +144,7 @@ export const ScheduleModal = ({ setTitle(''); setNotes(''); onClose(); + onSuccess?.(); } catch (error) { toast({ title: "Scheduling failed", @@ -115,7 +174,7 @@ export const ScheduleModal = ({
- {formatHour(slot.hour)} – {formatHour(slot.hour + 1)} + {formatHour(slot.hour)} – {getEndTime()} ({getTimezoneAbbrev(displayTimezone)})
@@ -125,6 +184,25 @@ export const ScheduleModal = ({ {/* Form */}
+
+ + +
+
space) and remove .ics extension + const decoded = decodeURIComponent(filename).replace(/\.ics$/i, ''); + return decoded || null; + } catch { + return null; + } +} + export function getAvatarColor(name?: string): string { const colors = [ 'hsl(var(--tag-green))', diff --git a/frontend/src/pages/Index.tsx b/frontend/src/pages/Index.tsx index b1141f0..87090af 100644 --- a/frontend/src/pages/Index.tsx +++ b/frontend/src/pages/Index.tsx @@ -5,6 +5,7 @@ import { ParticipantSelector } from '@/components/ParticipantSelector'; import { ParticipantManager } from '@/components/ParticipantManager'; import { AvailabilityHeatmap } from '@/components/AvailabilityHeatmap'; import { ScheduleModal } from '@/components/ScheduleModal'; +import { TimezoneSelector } from '@/components/TimezoneSelector'; import { Participant, TimeSlot } from '@/types/calendar'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Switch } from '@/components/ui/switch'; @@ -14,6 +15,17 @@ import { PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { Users, CalendarDays, Settings, RefreshCw } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; @@ -24,17 +36,33 @@ import { deleteParticipant, fetchAvailability, syncCalendars, + clearAllBookings, ParticipantAPI, } from '@/api/client'; const SETTINGS_KEY = 'calendar-settings'; +// Get user's local timezone +const getUserTimezone = (): string => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return 'America/Toronto'; + } +}; + interface SettingsState { showPartialAvailability: boolean; + displayTimezone: string; + showSecondaryTimezone: boolean; + secondaryTimezone: string; } const defaultSettings: SettingsState = { showPartialAvailability: false, + displayTimezone: getUserTimezone(), + showSecondaryTimezone: false, + secondaryTimezone: 'America/Toronto', // Company timezone as default secondary }; function apiToParticipant(p: ParticipantAPI): Participant { @@ -210,6 +238,25 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => { setIsModalOpen(true); }; + const handleClearBookings = async () => { + try { + await clearAllBookings(); + if (selectedParticipants.length > 0) { + await loadAvailability(); + } + toast({ + title: 'Bookings cleared', + description: 'All scheduled meetings have been removed', + }); + } catch (error) { + toast({ + title: 'Error clearing bookings', + description: error instanceof Error ? error.message : 'Unknown error', + variant: 'destructive', + }); + } + }; + return (
@@ -255,6 +302,10 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
+ setSettings((prev) => ({ ...prev, displayTimezone: tz }))} + /> - +

Settings

@@ -287,6 +338,71 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {

When enabled, shows time slots where only some participants are available.

+ +
+
+ + + setSettings((prev) => ({ ...prev, showSecondaryTimezone: checked })) + } + /> +
+ {settings.showSecondaryTimezone && ( +
+ + setSettings((prev) => ({ ...prev, secondaryTimezone: tz }))} + /> +
+ )} +

+ Display times in two timezones side by side. +

+
+ +
+ + + + + + + Clear all bookings? + + This will remove all scheduled meetings from the system. + This action cannot be undone. Calendar invites already + sent will not be affected. + + + + Cancel + + Clear All + + + + +

+ Remove all scheduled meetings from the system. +

+
@@ -328,6 +444,9 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => { isLoading={isLoading} weekOffset={weekOffset} onWeekOffsetChange={setWeekOffset} + displayTimezone={settings.displayTimezone} + showSecondaryTimezone={settings.showSecondaryTimezone} + secondaryTimezone={settings.secondaryTimezone} /> )} @@ -344,6 +463,8 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => { }} slot={selectedSlot} participants={selectedParticipants} + displayTimezone={settings.displayTimezone} + onSuccess={loadAvailability} />
);