update #6
@@ -12,6 +12,7 @@ dependencies = [
|
|||||||
"psycopg2-binary>=2.9.0",
|
"psycopg2-binary>=2.9.0",
|
||||||
"httpx>=0.28.0",
|
"httpx>=0.28.0",
|
||||||
"icalendar>=6.0.0",
|
"icalendar>=6.0.0",
|
||||||
|
"recurring-ical-events>=3.0.0",
|
||||||
"python-dateutil>=2.9.0",
|
"python-dateutil>=2.9.0",
|
||||||
"pydantic[email]>=2.10.0",
|
"pydantic[email]>=2.10.0",
|
||||||
"pydantic-settings>=2.6.0",
|
"pydantic-settings>=2.6.0",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import recurring_ical_events
|
||||||
from icalendar import Calendar
|
from icalendar import Calendar
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -10,6 +11,9 @@ from app.models import BusyBlock, Participant
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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 def fetch_ics_content(url: str) -> str:
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
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)
|
calendar = Calendar.from_ical(ics_content)
|
||||||
blocks = []
|
blocks = []
|
||||||
|
|
||||||
for component in calendar.walk():
|
# Define the time range for expanding recurring events
|
||||||
if component.name == "VEVENT":
|
now = datetime.now(timezone.utc)
|
||||||
dtstart = component.get("dtstart")
|
start_range = now - timedelta(days=7) # Include recent past
|
||||||
dtend = component.get("dtend")
|
end_range = now + timedelta(weeks=RECURRING_EVENT_HORIZON_WEEKS)
|
||||||
|
|
||||||
if dtstart is None or dtend is None:
|
# Use recurring_ical_events to expand recurring events
|
||||||
continue
|
events = recurring_ical_events.of(calendar).between(start_range, end_range)
|
||||||
|
|
||||||
start_dt = dtstart.dt
|
for event in events:
|
||||||
end_dt = dtend.dt
|
dtstart = event.get("dtstart")
|
||||||
|
dtend = event.get("dtend")
|
||||||
|
|
||||||
if not isinstance(start_dt, datetime):
|
if dtstart is None:
|
||||||
start_dt = datetime.combine(start_dt, datetime.min.time())
|
continue
|
||||||
if not isinstance(end_dt, datetime):
|
|
||||||
end_dt = datetime.combine(end_dt, datetime.min.time())
|
|
||||||
|
|
||||||
if start_dt.tzinfo is None:
|
start_dt = dtstart.dt
|
||||||
start_dt = start_dt.replace(tzinfo=timezone.utc)
|
end_dt = dtend.dt if dtend else None
|
||||||
if end_dt.tzinfo is None:
|
|
||||||
end_dt = end_dt.replace(tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
blocks.append(
|
# Handle all-day events (date instead of datetime)
|
||||||
BusyBlock(
|
if not isinstance(start_dt, datetime):
|
||||||
participant_id=participant_id,
|
start_dt = datetime.combine(start_dt, datetime.min.time())
|
||||||
start_time=start_dt,
|
if end_dt is None:
|
||||||
end_time=end_dt,
|
# 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
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.database import get_db
|
||||||
from app.ics_service import sync_all_calendars, sync_participant_calendar
|
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 (
|
from app.schemas import (
|
||||||
AvailabilityRequest,
|
AvailabilityRequest,
|
||||||
AvailabilityResponse,
|
AvailabilityResponse,
|
||||||
@@ -175,6 +175,15 @@ async def sync_participant(participant_id: UUID, db: AsyncSession = Depends(get_
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
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")
|
@app.post("/api/schedule")
|
||||||
async def schedule_meeting(
|
async def schedule_meeting(
|
||||||
data: ScheduleRequest, db: AsyncSession = Depends(get_db)
|
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)
|
min_start_time = datetime.now(timezone.utc) + timedelta(hours=2)
|
||||||
if data.start_time.replace(tzinfo=timezone.utc) < min_start_time:
|
if data.start_time.replace(tzinfo=timezone.utc) < min_start_time:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Meetings must be scheduled at least 2 hours in advance."
|
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))
|
select(Participant).where(Participant.id.in_(data.participant_ids))
|
||||||
)
|
)
|
||||||
participants = result.scalars().all()
|
participants = result.scalars().all()
|
||||||
|
|
||||||
if len(participants) != len(data.participant_ids):
|
if len(participants) != len(data.participant_ids):
|
||||||
raise HTTPException(status_code=400, detail="Some participants not found")
|
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 = [
|
participant_dicts = [
|
||||||
{"name": p.name, "email": p.email} for p in participants
|
{"name": p.name, "email": p.email} for p in participants
|
||||||
]
|
]
|
||||||
@@ -213,6 +241,16 @@ async def schedule_meeting(
|
|||||||
participant_dicts
|
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 {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"email_sent": email_success,
|
"email_sent": email_success,
|
||||||
|
|||||||
@@ -115,3 +115,10 @@ export async function scheduleMeeting(
|
|||||||
});
|
});
|
||||||
return handleResponse(response);
|
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,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover';
|
} from '@/components/ui/popover';
|
||||||
import { Button } from '@/components/ui/button';
|
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';
|
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 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
|
// 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[] => {
|
const getWeekDates = (timezone: string, weekOffset: number = 0): string[] => {
|
||||||
@@ -78,6 +78,65 @@ const MIN_WEEK_OFFSET = 0;
|
|||||||
const DEFAULT_MAX_WEEK_OFFSET = 1;
|
const DEFAULT_MAX_WEEK_OFFSET = 1;
|
||||||
const EXPANDED_MAX_WEEK_OFFSET = 4;
|
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 {
|
interface AvailabilityHeatmapProps {
|
||||||
slots: TimeSlot[];
|
slots: TimeSlot[];
|
||||||
selectedParticipants: Participant[];
|
selectedParticipants: Participant[];
|
||||||
@@ -86,6 +145,9 @@ interface AvailabilityHeatmapProps {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
weekOffset?: number;
|
weekOffset?: number;
|
||||||
onWeekOffsetChange?: (offset: number) => void;
|
onWeekOffsetChange?: (offset: number) => void;
|
||||||
|
displayTimezone?: string;
|
||||||
|
showSecondaryTimezone?: boolean;
|
||||||
|
secondaryTimezone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AvailabilityHeatmap = ({
|
export const AvailabilityHeatmap = ({
|
||||||
@@ -96,15 +158,82 @@ export const AvailabilityHeatmap = ({
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
weekOffset = 0,
|
weekOffset = 0,
|
||||||
onWeekOffsetChange,
|
onWeekOffsetChange,
|
||||||
|
displayTimezone = DEFAULT_TIMEZONE,
|
||||||
|
showSecondaryTimezone = false,
|
||||||
|
secondaryTimezone = DEFAULT_TIMEZONE,
|
||||||
}: AvailabilityHeatmapProps) => {
|
}: AvailabilityHeatmapProps) => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const maxWeekOffset = expanded ? EXPANDED_MAX_WEEK_OFFSET : DEFAULT_MAX_WEEK_OFFSET;
|
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
|
// Find a slot that matches the given display timezone date/hour
|
||||||
const getSlot = (dateStr: string, hour: number): TimeSlot | undefined => {
|
const getSlot = (dateStr: string, hour: number): TimeSlot | undefined => {
|
||||||
// Convert display timezone date/hour to UTC
|
// Convert display timezone date/hour to UTC
|
||||||
const targetUTC = toUTCDate(dateStr, hour, TIMEZONE);
|
const targetUTC = toUTCDate(dateStr, hour, displayTimezone);
|
||||||
|
|
||||||
return slots.find((s) => {
|
return slots.find((s) => {
|
||||||
const slotDate = new Date(s.start_time);
|
const slotDate = new Date(s.start_time);
|
||||||
@@ -126,7 +255,7 @@ export const AvailabilityHeatmap = ({
|
|||||||
|
|
||||||
const isSlotTooSoon = (dateStr: string, hour: number) => {
|
const isSlotTooSoon = (dateStr: string, hour: number) => {
|
||||||
// Convert to UTC and compare with current time
|
// 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 now = new Date();
|
||||||
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
|
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
|
||||||
return slotTimeUTC < twoHoursFromNow;
|
return slotTimeUTC < twoHoursFromNow;
|
||||||
@@ -185,6 +314,10 @@ export const AvailabilityHeatmap = ({
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
{selectedParticipants.length} participant{selectedParticipants.length > 1 ? 's' : ''}: {selectedParticipants.map(p => p.name.split(' ')[0]).join(', ')}
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{onWeekOffsetChange && (
|
{onWeekOffsetChange && (
|
||||||
@@ -241,10 +374,48 @@ export const AvailabilityHeatmap = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="relative">
|
||||||
<div className="min-w-[600px]">
|
{/* Scroll fade indicator - top */}
|
||||||
<div className="grid grid-cols-[60px_repeat(5,1fr)] gap-1 mb-2">
|
<div
|
||||||
<div></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) => (
|
{dayNames.map((dayName, i) => (
|
||||||
<div
|
<div
|
||||||
key={dayName}
|
key={dayName}
|
||||||
@@ -260,10 +431,36 @@ export const AvailabilityHeatmap = ({
|
|||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{hours.map((hour) => (
|
{hours.map((hour) => (
|
||||||
<div key={hour} className="grid grid-cols-[60px_repeat(5,1fr)] gap-1">
|
<div
|
||||||
<div className="text-xs text-muted-foreground flex items-center justify-end pr-3">
|
key={hour}
|
||||||
{formatHour(hour)}
|
ref={todayIndex >= 0 && hour === currentHour ? currentHourRef : undefined}
|
||||||
</div>
|
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) => {
|
{weekDates.map((dateStr, dayIndex) => {
|
||||||
const slot = getSlot(dateStr, hour);
|
const slot = getSlot(dateStr, hour);
|
||||||
const dayName = dayNames[dayIndex];
|
const dayName = dayNames[dayIndex];
|
||||||
@@ -338,6 +535,7 @@ export const AvailabilityHeatmap = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 justify-center gap-6 mt-6 pt-4 border-t border-border">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -3,9 +3,15 @@ import { Participant } from '@/types/calendar';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { 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 {
|
interface ParticipantManagerProps {
|
||||||
participants: Participant[];
|
participants: Participant[];
|
||||||
@@ -236,9 +242,19 @@ export const ParticipantManager = ({
|
|||||||
<span>{participant.email}</span>
|
<span>{participant.email}</span>
|
||||||
<span className="text-muted-foreground/60">•</span>
|
<span className="text-muted-foreground/60">•</span>
|
||||||
{participant.icsLink ? (
|
{participant.icsLink ? (
|
||||||
<span className="text-primary truncate max-w-[200px]" title={participant.icsLink}>
|
<TooltipProvider>
|
||||||
ICS linked
|
<Tooltip>
|
||||||
</span>
|
<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">
|
<span className="text-amber-600 flex items-center gap-1">
|
||||||
<AlertCircle className="w-3 h-3" />
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { Participant } from '@/types/calendar';
|
import { Participant } from '@/types/calendar';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { X, Plus, Search, AlertCircle } from 'lucide-react';
|
import { X, Plus, Search, AlertCircle, Info } from 'lucide-react';
|
||||||
import { cn, getAvatarColor } from '@/lib/utils';
|
import { cn, getAvatarColor, getCalendarNameFromUrl } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
|
||||||
interface ParticipantSelectorProps {
|
interface ParticipantSelectorProps {
|
||||||
participants: Participant[];
|
participants: Participant[];
|
||||||
@@ -126,12 +132,28 @@ export const ParticipantSelector = ({
|
|||||||
<div className="font-medium text-foreground">{participant.name}</div>
|
<div className="font-medium text-foreground">{participant.name}</div>
|
||||||
<div className="text-xs text-muted-foreground">{participant.email}</div>
|
<div className="text-xs text-muted-foreground">{participant.email}</div>
|
||||||
</div>
|
</div>
|
||||||
{!participant.icsLink && (
|
<span className="ml-auto text-xs flex items-center gap-1">
|
||||||
<span className="ml-auto text-xs text-amber-600 flex items-center gap-1">
|
{participant.icsLink ? (
|
||||||
<AlertCircle className="w-3 h-3" />
|
<TooltipProvider>
|
||||||
No calendar
|
<Tooltip>
|
||||||
</span>
|
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,30 +11,88 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Calendar, Clock, Users, Send, AlertCircle } from 'lucide-react';
|
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 {
|
interface ScheduleModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
slot: TimeSlot | null;
|
slot: TimeSlot | null;
|
||||||
participants: Participant[];
|
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 = ({
|
export const ScheduleModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
slot,
|
slot,
|
||||||
participants,
|
participants,
|
||||||
|
displayTimezone = getUserTimezone(),
|
||||||
|
onSuccess,
|
||||||
}: ScheduleModalProps) => {
|
}: ScheduleModalProps) => {
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
|
const [duration, setDuration] = useState(60); // default 1 hour
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const formatHour = (hour: number) => {
|
const formatHour = (hour: number) => {
|
||||||
return `${hour.toString().padStart(2, '0')}:00`;
|
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 formatDate = (dateStr: string) => {
|
||||||
const date = new Date(dateStr + 'T00:00:00');
|
const date = new Date(dateStr + 'T00:00:00');
|
||||||
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
||||||
@@ -68,7 +126,7 @@ export const ScheduleModal = ({
|
|||||||
// Calculate start and end times
|
// Calculate start and end times
|
||||||
// slot.day is YYYY-MM-DD
|
// slot.day is YYYY-MM-DD
|
||||||
const startDateTime = new Date(`${slot.day}T${formatHour(slot.hour)}:00Z`);
|
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(
|
await scheduleMeeting(
|
||||||
participants.map(p => p.id),
|
participants.map(p => p.id),
|
||||||
@@ -86,6 +144,7 @@ export const ScheduleModal = ({
|
|||||||
setTitle('');
|
setTitle('');
|
||||||
setNotes('');
|
setNotes('');
|
||||||
onClose();
|
onClose();
|
||||||
|
onSuccess?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: "Scheduling failed",
|
title: "Scheduling failed",
|
||||||
@@ -115,7 +174,7 @@ export const ScheduleModal = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<Clock className="w-4 h-4 text-primary" />
|
<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>
|
||||||
<div className="flex items-center gap-3 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<Users className="w-4 h-4 text-primary" />
|
<Users className="w-4 h-4 text-primary" />
|
||||||
@@ -125,6 +184,25 @@ export const ScheduleModal = ({
|
|||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<div className="space-y-4">
|
<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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="title">Meeting Title</Label>
|
<Label htmlFor="title">Meeting Title</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -5,6 +5,20 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return twMerge(clsx(inputs));
|
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 {
|
export function getAvatarColor(name?: string): string {
|
||||||
const colors = [
|
const colors = [
|
||||||
'hsl(var(--tag-green))',
|
'hsl(var(--tag-green))',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ParticipantSelector } from '@/components/ParticipantSelector';
|
|||||||
import { ParticipantManager } from '@/components/ParticipantManager';
|
import { ParticipantManager } from '@/components/ParticipantManager';
|
||||||
import { AvailabilityHeatmap } from '@/components/AvailabilityHeatmap';
|
import { AvailabilityHeatmap } from '@/components/AvailabilityHeatmap';
|
||||||
import { ScheduleModal } from '@/components/ScheduleModal';
|
import { ScheduleModal } from '@/components/ScheduleModal';
|
||||||
|
import { TimezoneSelector } from '@/components/TimezoneSelector';
|
||||||
import { Participant, TimeSlot } from '@/types/calendar';
|
import { Participant, TimeSlot } from '@/types/calendar';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
@@ -14,6 +15,17 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover';
|
} 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 { Button } from '@/components/ui/button';
|
||||||
import { Users, CalendarDays, Settings, RefreshCw } from 'lucide-react';
|
import { Users, CalendarDays, Settings, RefreshCw } from 'lucide-react';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
@@ -24,17 +36,33 @@ import {
|
|||||||
deleteParticipant,
|
deleteParticipant,
|
||||||
fetchAvailability,
|
fetchAvailability,
|
||||||
syncCalendars,
|
syncCalendars,
|
||||||
|
clearAllBookings,
|
||||||
ParticipantAPI,
|
ParticipantAPI,
|
||||||
} from '@/api/client';
|
} from '@/api/client';
|
||||||
|
|
||||||
const SETTINGS_KEY = 'calendar-settings';
|
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 {
|
interface SettingsState {
|
||||||
showPartialAvailability: boolean;
|
showPartialAvailability: boolean;
|
||||||
|
displayTimezone: string;
|
||||||
|
showSecondaryTimezone: boolean;
|
||||||
|
secondaryTimezone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: SettingsState = {
|
const defaultSettings: SettingsState = {
|
||||||
showPartialAvailability: false,
|
showPartialAvailability: false,
|
||||||
|
displayTimezone: getUserTimezone(),
|
||||||
|
showSecondaryTimezone: false,
|
||||||
|
secondaryTimezone: 'America/Toronto', // Company timezone as default secondary
|
||||||
};
|
};
|
||||||
|
|
||||||
function apiToParticipant(p: ParticipantAPI): Participant {
|
function apiToParticipant(p: ParticipantAPI): Participant {
|
||||||
@@ -210,6 +238,25 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
|
|||||||
setIsModalOpen(true);
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
@@ -255,6 +302,10 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="text-center relative">
|
<div className="text-center relative">
|
||||||
<div className="absolute right-0 top-0 flex items-center gap-2">
|
<div className="absolute right-0 top-0 flex items-center gap-2">
|
||||||
|
<TimezoneSelector
|
||||||
|
value={settings.displayTimezone}
|
||||||
|
onChange={(tz) => setSettings((prev) => ({ ...prev, displayTimezone: tz }))}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -269,7 +320,7 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
|
|||||||
<Settings className="w-5 h-5" />
|
<Settings className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-72" align="end">
|
<PopoverContent className="w-80" align="end">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-medium">Settings</h4>
|
<h4 className="font-medium">Settings</h4>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<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">
|
<p className="text-xs text-muted-foreground">
|
||||||
When enabled, shows time slots where only some participants are available.
|
When enabled, shows time slots where only some participants are available.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -328,6 +444,9 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
weekOffset={weekOffset}
|
weekOffset={weekOffset}
|
||||||
onWeekOffsetChange={setWeekOffset}
|
onWeekOffsetChange={setWeekOffset}
|
||||||
|
displayTimezone={settings.displayTimezone}
|
||||||
|
showSecondaryTimezone={settings.showSecondaryTimezone}
|
||||||
|
secondaryTimezone={settings.secondaryTimezone}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -344,6 +463,8 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
|
|||||||
}}
|
}}
|
||||||
slot={selectedSlot}
|
slot={selectedSlot}
|
||||||
participants={selectedParticipants}
|
participants={selectedParticipants}
|
||||||
|
displayTimezone={settings.displayTimezone}
|
||||||
|
onSuccess={loadAvailability}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user