Merge pull request 'update' (#6) from implement-more-feeedback into main
Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user