update #6

Merged
Joyce merged 1 commits from implement-more-feeedback into main 2026-02-05 18:48:37 +00:00
10 changed files with 569 additions and 57 deletions

View File

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

View File

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

View File

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

View File

@@ -115,3 +115,10 @@ export async function scheduleMeeting(
});
return handleResponse(response);
}
export async function clearAllBookings(): Promise<void> {
const response = await fetch(`${API_URL}/api/bookings`, { method: 'DELETE' });
if (!response.ok) {
throw new Error('Failed to clear bookings');
}
}

View File

@@ -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<HTMLDivElement>(null);
const currentHourRef = useRef<HTMLDivElement>(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 = ({
</div>
<p className="text-sm text-muted-foreground mt-1">
{selectedParticipants.length} participant{selectedParticipants.length > 1 ? 's' : ''}: {selectedParticipants.map(p => p.name.split(' ')[0]).join(', ')}
<span className="mx-2"></span>
<span className="text-primary font-medium">
{`Times in ${formatTimezoneDisplay(displayTimezone)}`}
</span>
</p>
</div>
{onWeekOffsetChange && (
@@ -241,10 +374,48 @@ export const AvailabilityHeatmap = ({
)}
</div>
<div className="overflow-x-auto">
<div className="min-w-[600px]">
<div className="grid grid-cols-[60px_repeat(5,1fr)] gap-1 mb-2">
<div></div>
<div className="relative">
{/* Scroll fade indicator - top */}
<div
className={cn(
"absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-card to-transparent z-20 pointer-events-none transition-opacity duration-200",
canScrollUp ? "opacity-100" : "opacity-0"
)}
/>
{/* Scroll fade indicator - bottom */}
<div
className={cn(
"absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-card via-card/80 to-transparent z-20 pointer-events-none transition-opacity duration-200 flex items-end justify-center pb-2",
canScrollDown ? "opacity-100" : "opacity-0"
)}
>
<span className="text-xs text-muted-foreground animate-pulse"> Scroll for more hours</span>
</div>
<div
className="overflow-x-auto overflow-y-auto max-h-[500px]"
ref={scrollContainerRef}
onScroll={handleScroll}
>
<div className="min-w-[600px]">
<div className={cn(
"grid gap-1 mb-2 sticky top-0 bg-card z-10",
showSecondaryTimezone
? "grid-cols-[50px_50px_repeat(5,1fr)]"
: "grid-cols-[60px_repeat(5,1fr)]"
)}>
{showSecondaryTimezone ? (
<>
<div className="text-center text-xs font-medium text-primary py-2">
{getTimezoneAbbrev(displayTimezone)}
</div>
<div className="text-center text-xs font-medium text-muted-foreground py-2">
{getTimezoneAbbrev(secondaryTimezone)}
</div>
</>
) : (
<div></div>
)}
{dayNames.map((dayName, i) => (
<div
key={dayName}
@@ -260,10 +431,36 @@ export const AvailabilityHeatmap = ({
<div className="space-y-1">
{hours.map((hour) => (
<div key={hour} className="grid grid-cols-[60px_repeat(5,1fr)] gap-1">
<div className="text-xs text-muted-foreground flex items-center justify-end pr-3">
{formatHour(hour)}
</div>
<div
key={hour}
ref={todayIndex >= 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 ? (
<>
<div className="text-xs text-primary font-medium flex items-center justify-center gap-1">
{todayIndex >= 0 && hour === currentHour && (
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
)}
{formatHour(hour)}
</div>
<div className="text-xs text-muted-foreground flex items-center justify-center">
{formatHour(convertHourBetweenTimezones(hour, weekDates[0] || '', displayTimezone, secondaryTimezone))}
</div>
</>
) : (
<div className="text-xs text-muted-foreground flex items-center justify-end pr-3 gap-1">
{todayIndex >= 0 && hour === currentHour && (
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
)}
{formatHour(hour)}
</div>
)}
{weekDates.map((dateStr, dayIndex) => {
const slot = getSlot(dateStr, hour);
const dayName = dayNames[dayIndex];
@@ -338,6 +535,7 @@ export const AvailabilityHeatmap = ({
</div>
</div>
</div>
</div>
<div className="flex items-center justify-center gap-6 mt-6 pt-4 border-t border-border">
<div className="flex items-center gap-2">

View File

@@ -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 = ({
<span>{participant.email}</span>
<span className="text-muted-foreground/60"></span>
{participant.icsLink ? (
<span className="text-primary truncate max-w-[200px]" title={participant.icsLink}>
ICS linked
</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-primary truncate max-w-[200px] cursor-help flex items-center gap-1" title={participant.icsLink}>
📅 {getCalendarNameFromUrl(participant.icsLink) || 'Calendar linked'}
<Info className="w-3 h-3 opacity-50" />
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[220px]">
<p>Availability is based on this calendar only. Other calendars on the same account are not included.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-amber-600 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />

View File

@@ -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 = ({
<div className="font-medium text-foreground">{participant.name}</div>
<div className="text-xs text-muted-foreground">{participant.email}</div>
</div>
{!participant.icsLink && (
<span className="ml-auto text-xs text-amber-600 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
No calendar
</span>
)}
<span className="ml-auto text-xs flex items-center gap-1">
{participant.icsLink ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground cursor-help flex items-center gap-1">
📅 {getCalendarNameFromUrl(participant.icsLink) || 'Calendar'}
<Info className="w-3 h-3 opacity-50" />
</span>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-[220px]">
<p>Availability is based on this calendar only. Other calendars on the same account are not included.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-amber-600 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
No calendar
</span>
)}
</span>
</button>
))}
</div>

View File

@@ -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 = ({
</div>
<div className="flex items-center gap-3 text-sm">
<Clock className="w-4 h-4 text-primary" />
<span>{formatHour(slot.hour)} {formatHour(slot.hour + 1)}</span>
<span><span className="text-primary font-medium">{formatHour(slot.hour)} {getEndTime()}</span> <span className="text-muted-foreground">({getTimezoneAbbrev(displayTimezone)})</span></span>
</div>
<div className="flex items-center gap-3 text-sm">
<Users className="w-4 h-4 text-primary" />
@@ -125,6 +184,25 @@ export const ScheduleModal = ({
{/* Form */}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="duration">Duration</Label>
<Select
value={duration.toString()}
onValueChange={(value) => setDuration(Number(value))}
>
<SelectTrigger className="h-12">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DURATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="title">Meeting Title</Label>
<Input

View File

@@ -5,6 +5,20 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function getCalendarNameFromUrl(icsUrl: string | null | undefined): string | null {
if (!icsUrl) return null;
try {
const url = new URL(icsUrl);
const pathname = url.pathname;
const filename = pathname.split('/').pop() || '';
// Decode URL encoding (e.g., %20 -> 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))',

View File

@@ -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 (
<div className="min-h-screen bg-background">
<Header />
@@ -255,6 +302,10 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
<div className="space-y-8">
<div className="text-center relative">
<div className="absolute right-0 top-0 flex items-center gap-2">
<TimezoneSelector
value={settings.displayTimezone}
onChange={(tz) => setSettings((prev) => ({ ...prev, displayTimezone: tz }))}
/>
<Button
variant="ghost"
size="icon"
@@ -269,7 +320,7 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
<Settings className="w-5 h-5" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72" align="end">
<PopoverContent className="w-80" align="end">
<div className="space-y-4">
<h4 className="font-medium">Settings</h4>
<div className="flex items-center justify-between gap-4">
@@ -287,6 +338,71 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
<p className="text-xs text-muted-foreground">
When enabled, shows time slots where only some participants are available.
</p>
<div className="border-t border-border pt-4">
<div className="flex items-center justify-between gap-4">
<Label htmlFor="secondary-timezone" className="text-sm cursor-pointer">
Show secondary timezone
</Label>
<Switch
id="secondary-timezone"
checked={settings.showSecondaryTimezone}
onCheckedChange={(checked) =>
setSettings((prev) => ({ ...prev, showSecondaryTimezone: checked }))
}
/>
</div>
{settings.showSecondaryTimezone && (
<div className="mt-3">
<Label className="text-xs text-muted-foreground mb-2 block">
Secondary timezone
</Label>
<TimezoneSelector
value={settings.secondaryTimezone}
onChange={(tz) => setSettings((prev) => ({ ...prev, secondaryTimezone: tz }))}
/>
</div>
)}
<p className="text-xs text-muted-foreground mt-2">
Display times in two timezones side by side.
</p>
</div>
<div className="border-t border-border pt-4">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
className="w-full"
>
Clear All Bookings
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear all bookings?</AlertDialogTitle>
<AlertDialogDescription>
This will remove all scheduled meetings from the system.
This action cannot be undone. Calendar invites already
sent will not be affected.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleClearBookings}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Clear All
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p className="text-xs text-muted-foreground mt-2">
Remove all scheduled meetings from the system.
</p>
</div>
</div>
</PopoverContent>
</Popover>
@@ -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}
/>
</div>
);