Compare commits
10 Commits
implement-
...
implement-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1b92b445b | ||
|
|
d6115dc30d | ||
|
|
b4a8029fb0 | ||
|
|
07c11ccd2e | ||
|
|
26e553bfd0 | ||
| 7a0f11ee88 | |||
|
|
267c1747f4 | ||
| 9412d5c0a0 | |||
|
|
192b885149 | ||
| 10675b6846 |
@@ -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,
|
||||
@@ -144,7 +144,8 @@ async def delete_participant(participant_id: UUID, db: AsyncSession = Depends(ge
|
||||
async def get_availability(
|
||||
request: AvailabilityRequest, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
slots = await calculate_availability(db, request.participant_ids)
|
||||
reference_date = datetime.now(timezone.utc) + timedelta(weeks=request.week_offset)
|
||||
slots = await calculate_availability(db, request.participant_ids, reference_date)
|
||||
return {"slots": slots}
|
||||
|
||||
|
||||
@@ -174,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)
|
||||
@@ -181,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."
|
||||
)
|
||||
|
||||
@@ -189,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
|
||||
]
|
||||
@@ -212,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,
|
||||
|
||||
@@ -39,6 +39,7 @@ class TimeSlot(BaseModel):
|
||||
|
||||
class AvailabilityRequest(BaseModel):
|
||||
participant_ids: list[UUID]
|
||||
week_offset: int = 0
|
||||
|
||||
|
||||
class AvailabilityResponse(BaseModel):
|
||||
|
||||
@@ -71,11 +71,11 @@ export async function deleteParticipant(id: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAvailability(participantIds: string[]): Promise<TimeSlotAPI[]> {
|
||||
export async function fetchAvailability(participantIds: string[], weekOffset: number = 0): Promise<TimeSlotAPI[]> {
|
||||
const response = await fetch(`${API_URL}/api/availability`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ participant_ids: participantIds }),
|
||||
body: JSON.stringify({ participant_ids: participantIds, week_offset: weekOffset }),
|
||||
});
|
||||
const data = await handleResponse<{ slots: TimeSlotAPI[] }>(response);
|
||||
return data.slots;
|
||||
@@ -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,15 +6,16 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Check, X, Loader2 } from 'lucide-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 the current week in a specific timezone
|
||||
const getWeekDates = (timezone: string): string[] => {
|
||||
// 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[] => {
|
||||
// Get "now" in the target timezone
|
||||
const now = new Date();
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
@@ -33,7 +34,7 @@ const getWeekDates = (timezone: string): string[] => {
|
||||
const dayOfWeek = todayDate.getDay();
|
||||
const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
||||
|
||||
const mondayDate = new Date(year, month - 1, day + daysToMonday);
|
||||
const mondayDate = new Date(year, month - 1, day + daysToMonday + weekOffset * 7);
|
||||
|
||||
return dayNames.map((_, i) => {
|
||||
const d = new Date(mondayDate);
|
||||
@@ -73,12 +74,80 @@ const toUTCDate = (dateStr: string, hour: number, timezone: string): Date => {
|
||||
return utcDate;
|
||||
};
|
||||
|
||||
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[];
|
||||
onSlotSelect: (slot: TimeSlot) => void;
|
||||
showPartialAvailability?: boolean;
|
||||
isLoading?: boolean;
|
||||
weekOffset?: number;
|
||||
onWeekOffsetChange?: (offset: number) => void;
|
||||
displayTimezone?: string;
|
||||
showSecondaryTimezone?: boolean;
|
||||
secondaryTimezone?: string;
|
||||
}
|
||||
|
||||
export const AvailabilityHeatmap = ({
|
||||
@@ -87,13 +156,84 @@ export const AvailabilityHeatmap = ({
|
||||
onSlotSelect,
|
||||
showPartialAvailability = false,
|
||||
isLoading = false,
|
||||
weekOffset = 0,
|
||||
onWeekOffsetChange,
|
||||
displayTimezone = DEFAULT_TIMEZONE,
|
||||
showSecondaryTimezone = false,
|
||||
secondaryTimezone = DEFAULT_TIMEZONE,
|
||||
}: AvailabilityHeatmapProps) => {
|
||||
const weekDates = getWeekDates(TIMEZONE);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const maxWeekOffset = expanded ? EXPANDED_MAX_WEEK_OFFSET : DEFAULT_MAX_WEEK_OFFSET;
|
||||
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);
|
||||
@@ -115,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;
|
||||
@@ -167,19 +307,115 @@ export const AvailabilityHeatmap = ({
|
||||
<div className="bg-card rounded-xl shadow-card p-6 animate-slide-up">
|
||||
<div className="mb-6 flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Common Availability — Week of {getWeekDateRange()}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Common Availability — Week of {getWeekDateRange()}
|
||||
</h3>
|
||||
</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 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={weekOffset <= MIN_WEEK_OFFSET}
|
||||
onClick={() => onWeekOffsetChange(weekOffset - 1)}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
{weekOffset !== 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => onWeekOffsetChange(0)}
|
||||
>
|
||||
This week
|
||||
</Button>
|
||||
)}
|
||||
{weekOffset < maxWeekOffset ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onWeekOffsetChange(weekOffset + 1)}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
) : !expanded ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs gap-1"
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<ChevronsRight className="w-3.5 h-3.5" />
|
||||
Look further ahead
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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}
|
||||
@@ -195,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];
|
||||
@@ -273,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">
|
||||
|
||||
474
frontend/src/components/AvailabilityHeatmapV2.tsx
Normal file
474
frontend/src/components/AvailabilityHeatmapV2.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
import { TimeSlot, Participant } from '@/types/calendar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { Check, X, Loader2, ChevronLeft, ChevronRight, ChevronsRight, Clock, Calendar as CalendarIcon, Sun, Moon, Users } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
const DEFAULT_TIMEZONE = 'America/Toronto';
|
||||
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
|
||||
const WORKING_HOUR_START = 8; // 8 AM
|
||||
const WORKING_HOUR_END = 18; // 6 PM
|
||||
const ALL_HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
const WORKING_HOURS = Array.from({ length: WORKING_HOUR_END - WORKING_HOUR_START + 1 }, (_, i) => i + WORKING_HOUR_START);
|
||||
|
||||
// Helper to check if a slot is in the past or too close (2h buffer)
|
||||
const isSlotTooSoon = (slotDate: number) => {
|
||||
const now = Date.now();
|
||||
const twoHoursFromNow = now + 2 * 60 * 60 * 1000;
|
||||
return slotDate < twoHoursFromNow;
|
||||
};
|
||||
|
||||
// Reuse previous timezone helpers or simplify
|
||||
const getWeekDates = (timezone: string, weekOffset: number = 0): Date[] => {
|
||||
const now = new Date();
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
|
||||
const todayStr = formatter.format(now);
|
||||
const [year, month, day] = todayStr.split('-').map(Number);
|
||||
|
||||
const todayDate = new Date(year, month - 1, day);
|
||||
const dayOfWeek = todayDate.getDay();
|
||||
// If Sunday (0), go back 6 days to Monday. If Mon (1), go back 0. If Sat (6), go back 5.
|
||||
// Actually standard logic: Mon=1...Sun=7.
|
||||
// Let's assume standard ISO week start (Mon)
|
||||
const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
||||
const mondayDate = new Date(year, month - 1, day + daysToMonday + weekOffset * 7);
|
||||
|
||||
return Array.from({ length: 5 }, (_, i) => {
|
||||
const d = new Date(mondayDate);
|
||||
d.setDate(mondayDate.getDate() + i);
|
||||
return d;
|
||||
});
|
||||
};
|
||||
|
||||
const formatTimezoneDisplay = (timezone: string): string => {
|
||||
try {
|
||||
const parts = timezone.split('/');
|
||||
const city = parts[parts.length - 1].replace(/_/g, ' ');
|
||||
return city;
|
||||
} catch {
|
||||
return timezone;
|
||||
}
|
||||
};
|
||||
|
||||
interface AvailabilityHeatmapV2Props {
|
||||
slots: TimeSlot[];
|
||||
selectedParticipants: Participant[];
|
||||
onSlotSelect: (slot: TimeSlot) => void;
|
||||
showPartialAvailability?: boolean;
|
||||
isLoading?: boolean;
|
||||
weekOffset?: number;
|
||||
onWeekOffsetChange?: (offset: number) => void;
|
||||
displayTimezone?: string;
|
||||
showSecondaryTimezone?: boolean;
|
||||
secondaryTimezone?: string;
|
||||
}
|
||||
|
||||
export const AvailabilityHeatmapV2 = ({
|
||||
slots,
|
||||
selectedParticipants,
|
||||
onSlotSelect,
|
||||
showPartialAvailability = false,
|
||||
isLoading = false,
|
||||
weekOffset = 0,
|
||||
onWeekOffsetChange,
|
||||
displayTimezone = DEFAULT_TIMEZONE,
|
||||
showSecondaryTimezone = false,
|
||||
secondaryTimezone = DEFAULT_TIMEZONE,
|
||||
}: AvailabilityHeatmapV2Props) => {
|
||||
const [showFullDay, setShowFullDay] = useState(false);
|
||||
const activeHours = showFullDay ? ALL_HOURS : WORKING_HOURS;
|
||||
|
||||
const weekDates = useMemo(() => getWeekDates(displayTimezone, weekOffset), [displayTimezone, weekOffset]);
|
||||
|
||||
// Pre-compute slots lookup map for O(1) access
|
||||
// Key: YYYY-MM-DD:Hour
|
||||
const slotsMap = useMemo(() => {
|
||||
const map = new Map<string, TimeSlot>();
|
||||
slots.forEach(slot => {
|
||||
// Assuming slot.start_time is ISO string.
|
||||
// We need to match it to our grid's local time logic.
|
||||
// This part is "flaky" in the original.
|
||||
// The slot comes with a specific absolute time.
|
||||
// We want to place it in the intersection of "Day (in DisplayTZ)" and "Hour (in DisplayTZ)".
|
||||
|
||||
const d = new Date(slot.start_time);
|
||||
// Format to DisplayTZ to find coordinate
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: displayTimezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: 'numeric',
|
||||
hour12: false
|
||||
});
|
||||
// "2023-10-25, 14"
|
||||
const parts = formatter.formatToParts(d);
|
||||
const year = parts.find(p => p.type === 'year')?.value;
|
||||
const month = parts.find(p => p.type === 'month')?.value;
|
||||
const day = parts.find(p => p.type === 'day')?.value;
|
||||
const hour = parts.find(p => p.type === 'hour')?.value;
|
||||
|
||||
if (year && month && day && hour) {
|
||||
// Hour in 24h format might be "24" in some locales? No `hour12: false` gives 0-23 usually.
|
||||
// But Intl sometimes returns "24"? No, 0-23.
|
||||
let h = parseInt(hour, 10);
|
||||
if (h === 24) h = 0; // Just in case
|
||||
|
||||
const key = `${year}-${month}-${day}:${h}`;
|
||||
map.set(key, slot);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [slots, displayTimezone]);
|
||||
|
||||
const formatDateKey = (date: Date) => {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
const formatDisplayDate = (date: Date) => {
|
||||
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
const getSlotForCell = (date: Date, hour: number) => {
|
||||
const key = `${formatDateKey(date)}:${hour}`;
|
||||
return slotsMap.get(key);
|
||||
};
|
||||
|
||||
const formatHour = (hour: number) => {
|
||||
return new Date(0, 0, 0, hour).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
hour12: true,
|
||||
});
|
||||
};
|
||||
|
||||
// Move hooks to top level to avoid conditional hook execution error
|
||||
const tzOffsetDiff = useMemo(() => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const p = parseInt(new Intl.DateTimeFormat('en-US', { timeZone: displayTimezone, hour: 'numeric', hour12: false }).format(now));
|
||||
const s = parseInt(new Intl.DateTimeFormat('en-US', { timeZone: secondaryTimezone, hour: 'numeric', hour12: false }).format(now));
|
||||
let diff = s - p;
|
||||
if (diff > 12) diff -= 24;
|
||||
if (diff < -12) diff += 24;
|
||||
return diff;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}, [displayTimezone, secondaryTimezone]);
|
||||
|
||||
const timeColWidth = showSecondaryTimezone ? "120px" : "80px";
|
||||
|
||||
if (selectedParticipants.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-12 border-2 border-dashed border-border/50 rounded-xl bg-muted/20 animate-fade-in">
|
||||
<UsersPlaceholder />
|
||||
<h3 className="text-xl font-semibold mt-4">No participants selected</h3>
|
||||
<p className="text-muted-foreground text-center max-w-sm mt-2">
|
||||
Select team members from the list above to compare calendars and find the perfect meeting time.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
{/* Controls Bar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-card p-4 rounded-xl border border-border shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={() => onWeekOffsetChange?.(weekOffset - 1)}
|
||||
disabled={!onWeekOffsetChange || weekOffset <= 0}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex flex-col items-center min-w-[140px]">
|
||||
<span className="text-sm font-semibold">
|
||||
{weekDates[0]?.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
{' - '}
|
||||
{weekDates[4]?.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{weekOffset === 0 ? "This Week" : weekOffset === 1 ? "Next Week" : `${weekOffset} weeks out`}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={() => onWeekOffsetChange?.(weekOffset + 1)}
|
||||
disabled={!onWeekOffsetChange}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="h-8 w-px bg-border hidden sm:block" />
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-md">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatTimezoneDisplay(displayTimezone)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={showFullDay ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setShowFullDay(!showFullDay)}
|
||||
className="text-xs"
|
||||
>
|
||||
{showFullDay ? (
|
||||
<>
|
||||
<Sun className="w-3.5 h-3.5 mr-2" />
|
||||
Show Work Hours
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Moon className="w-3.5 h-3.5 mr-2" />
|
||||
Show Full Day
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Grid Card */}
|
||||
<div className="bg-card w-full overflow-hidden rounded-xl border border-border shadow-md">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-background/50 backdrop-blur-[1px] z-50 flex items-center justify-center">
|
||||
<Loader2 className="w-10 h-10 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-auto max-h-[600px] w-full relative">
|
||||
<div className="min-w-[700px]">
|
||||
{/* Grid Header */}
|
||||
<div
|
||||
className="grid sticky top-0 z-30 bg-card border-b border-border shadow-sm"
|
||||
style={{ gridTemplateColumns: `${timeColWidth} repeat(5, 1fr)` }}
|
||||
>
|
||||
<div className="sticky left-0 z-40 bg-card text-xs font-semibold text-muted-foreground self-center p-3 text-right border-r border-border/50 flex flex-col items-end gap-1">
|
||||
<span>{formatTimezoneDisplay(displayTimezone)}</span>
|
||||
{showSecondaryTimezone && (
|
||||
<span className="text-[10px] text-muted-foreground/60 font-normal">{formatTimezoneDisplay(secondaryTimezone)}</span>
|
||||
)}
|
||||
</div>
|
||||
{weekDates.map(date => {
|
||||
const isToday = new Date().toDateString() === date.toDateString();
|
||||
return (
|
||||
<div key={date.toISOString()} className={cn(
|
||||
"text-center p-3 transition-colors border-r border-border/30 last:border-0",
|
||||
isToday ? "bg-primary/5 text-primary font-bold" : "text-foreground"
|
||||
)}>
|
||||
<div className="text-sm">{date.toLocaleDateString('en-US', { weekday: 'short' })}</div>
|
||||
<div className={cn("text-2xl", isToday ? "font-bold" : "font-light")}>
|
||||
{date.getDate()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Grid Body */}
|
||||
<div className="relative">
|
||||
{activeHours.map((hour) => {
|
||||
const isNight = hour < 8 || hour >= 18;
|
||||
|
||||
// Calculate secondary time
|
||||
let secondaryHour = hour + tzOffsetDiff;
|
||||
if (secondaryHour >= 24) secondaryHour -= 24;
|
||||
if (secondaryHour < 0) secondaryHour += 24;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={hour}
|
||||
className={cn(
|
||||
"grid group items-stretch transition-colors border-b border-border/30 last:border-0",
|
||||
isNight ? "bg-muted/30" : "bg-card",
|
||||
"hover:bg-muted/10"
|
||||
)}
|
||||
style={{ gridTemplateColumns: `${timeColWidth} repeat(5, 1fr)` }}
|
||||
>
|
||||
{/* Time Label - Sticky Left */}
|
||||
<div className={cn(
|
||||
"text-xs text-muted-foreground font-medium text-right pr-4 py-3 flex flex-col items-end justify-center gap-0.5",
|
||||
"sticky left-0 z-20 border-r border-border/50",
|
||||
isNight ? "bg-muted/30 backdrop-blur-md" : "bg-card"
|
||||
)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isNight ? (
|
||||
<Moon className="w-3 h-3 text-slate-400/50" />
|
||||
) : (
|
||||
<Sun className="w-3 h-3 text-amber-500/50" />
|
||||
)}
|
||||
<span>{formatHour(hour)}</span>
|
||||
</div>
|
||||
{showSecondaryTimezone && (
|
||||
<span className="text-[10px] text-muted-foreground/60 font-mono">
|
||||
{formatHour(secondaryHour)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Days */}
|
||||
{weekDates.map(date => {
|
||||
const slot = getSlotForCell(date, hour);
|
||||
const tooSoon = slot ? isSlotTooSoon(new Date(slot.start_time).getTime()) : true;
|
||||
|
||||
// Availability logic
|
||||
const availability = slot?.availability || 'none';
|
||||
const isNone = availability === 'none';
|
||||
const isPartial = availability === 'partial' && showPartialAvailability;
|
||||
const isFull = availability === 'full';
|
||||
const isPartialHidden = availability === 'partial' && !showPartialAvailability;
|
||||
|
||||
// Styling
|
||||
let bgClass = ""; // Default transparent
|
||||
|
||||
if (!slot) {
|
||||
bgClass = "bg-muted/10 pattern-diagonal-lines opacity-50";
|
||||
} else if (tooSoon) {
|
||||
bgClass = "bg-muted/20 pattern-diagonal-lines cursor-not-allowed border border-border/10";
|
||||
} else if (isFull) {
|
||||
bgClass = "bg-emerald-500/90 hover:bg-emerald-600 shadow-sm";
|
||||
} else if (isPartial) {
|
||||
bgClass = "bg-amber-400/80 hover:bg-amber-500 shadow-sm";
|
||||
} else if (isNone || isPartialHidden) {
|
||||
bgClass = "bg-muted/50 hover:bg-muted/70 border border-border/20";
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={date.toISOString()} className="p-1 h-[52px] border-r border-border/30 last:border-0">
|
||||
{slot ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild disabled={tooSoon}>
|
||||
<div className={cn(
|
||||
"w-full h-full rounded-md transition-all duration-200 cursor-pointer flex items-center justify-center group/cell relative overflow-hidden",
|
||||
bgClass,
|
||||
!tooSoon && (isFull || isPartial) ? "scale-[0.98] hover:scale-100 hover:ring-2 ring-primary/20" : ""
|
||||
)}>
|
||||
{/* Mini Indicators for color-blind accessibility or density */}
|
||||
{isFull && <Check className="w-4 h-4 text-white opacity-0 group-hover/cell:opacity-100 transition-opacity" />}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-0 rounded-xl overflow-hidden shadow-xl border-border" side="right" align={hour >= 12 ? "end" : "start"}>
|
||||
<div className="p-4 bg-muted/30 border-b border-border/50">
|
||||
<h4 className="font-semibold text-base flex items-center gap-2">
|
||||
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
|
||||
{formatDisplayDate(date)}
|
||||
</h4>
|
||||
<div className="text-sm text-muted-foreground mt-1 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatHour(hour)} - {formatHour(hour + 1)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3 max-h-[300px] overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
{selectedParticipants.map(participant => {
|
||||
const isAvailable = slot.availableParticipants.includes(participant.name);
|
||||
return (
|
||||
<div key={participant.id} className="flex items-center justify-between text-sm p-2 rounded-lg hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
isAvailable ? "bg-emerald-500" : "bg-destructive"
|
||||
)} />
|
||||
<span className={cn(!isAvailable && "text-muted-foreground")}>
|
||||
{participant.name}
|
||||
</span>
|
||||
</div>
|
||||
{isAvailable ? (
|
||||
<Check className="w-4 h-4 text-emerald-500" />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Busy</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(!isNone && !tooSoon) && (
|
||||
<Button
|
||||
className="w-full mt-4 bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
onClick={() => onSlotSelect(slot)}
|
||||
>
|
||||
Schedule Meeting
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
// Placeholder for missing slots
|
||||
<div className="w-full h-full rounded-md bg-muted/5 border border-dashed border-border/30"></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap items-center justify-center bg-muted/20 border-t p-3 gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-emerald-500 shadow-sm"></div>
|
||||
<span className="font-medium text-foreground">All Available</span>
|
||||
</div>
|
||||
{showPartialAvailability && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-amber-400 shadow-sm"></div>
|
||||
<span className="font-medium text-foreground">Partial Match</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded bg-muted/50 border border-border/20"></div>
|
||||
<span className="text-muted-foreground">Busy / No Match</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded bg-muted/20 pattern-diagonal-lines border border-border/10"></div>
|
||||
<span className="text-muted-foreground">Past / Too Soon</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function UsersPlaceholder() {
|
||||
return (
|
||||
<div className="relative w-16 h-16 mb-2">
|
||||
<div className="absolute top-0 left-0 w-10 h-10 rounded-full bg-muted border-2 border-background flex items-center justify-center">
|
||||
<Users className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="absolute bottom-0 right-0 w-10 h-10 rounded-full bg-muted border-2 border-background flex items-center justify-center">
|
||||
<Users className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Importing Users icon since it was missing in imports
|
||||
@@ -1,7 +1,27 @@
|
||||
import { Calendar } from 'lucide-react';
|
||||
import { Calendar, Moon, Sun } from 'lucide-react';
|
||||
import { getAvatarColor } from '@/lib/utils';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export const Header = () => {
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('theme') === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
return 'light';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prev => prev === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-50">
|
||||
<div className="container max-w-5xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
@@ -14,7 +34,15 @@ export const Header = () => {
|
||||
<p className="text-xs text-muted-foreground">Calendar Coordination</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={toggleTheme} className="rounded-full">
|
||||
{theme === 'light' ? (
|
||||
<Sun className="h-5 w-5 text-amber-500" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5 text-slate-400" />
|
||||
)}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium text-white"
|
||||
style={{ backgroundColor: getAvatarColor("AR") }}
|
||||
|
||||
@@ -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 } from 'react';
|
||||
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[];
|
||||
@@ -17,6 +23,19 @@ export const ParticipantSelector = ({
|
||||
}: ParticipantSelectorProps) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const filteredParticipants = participants.filter(
|
||||
(p) =>
|
||||
@@ -25,14 +44,43 @@ export const ParticipantSelector = ({
|
||||
p.email.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
const addParticipant = (participant: Participant) => {
|
||||
const addParticipant = useCallback((participant: Participant) => {
|
||||
onSelectionChange([...selectedParticipants, participant]);
|
||||
setSearchQuery('');
|
||||
setIsDropdownOpen(false);
|
||||
// Keep dropdown open for multi-select; clamp highlight to new list length
|
||||
setHighlightedIndex((prev) => {
|
||||
const newLength = filteredParticipants.length - 1;
|
||||
return prev >= newLength ? Math.max(0, newLength - 1) : prev;
|
||||
});
|
||||
// Keep focus on input so user can continue selecting
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}, [onSelectionChange, selectedParticipants, filteredParticipants.length]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isDropdownOpen || filteredParticipants.length === 0) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev < filteredParticipants.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : filteredParticipants.length - 1
|
||||
);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addParticipant(filteredParticipants[highlightedIndex]);
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeParticipant = (participantId: string) => {
|
||||
onSelectionChange(selectedParticipants.filter((p) => p.id !== participantId));
|
||||
setIsDropdownOpen(false);
|
||||
inputRef.current?.blur();
|
||||
};
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
@@ -45,27 +93,83 @@ export const ParticipantSelector = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<div ref={containerRef} className="space-y-4">
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-[48px] bg-background border border-border rounded-lg shadow-sm flex flex-wrap items-center gap-2 px-3 py-1.5 transition-all",
|
||||
"focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary"
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
<Search className="w-4 h-4 text-muted-foreground shrink-0 mr-1" />
|
||||
|
||||
{selectedParticipants.map((participant) => (
|
||||
<div
|
||||
key={participant.id}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 bg-accent text-accent-foreground pl-2 pr-1 py-1 rounded-full text-xs font-medium animate-scale-in",
|
||||
"border border-primary/10 group hover:border-destructive/30 hover:bg-destructive/10 hover:text-destructive transition-colors cursor-default"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold text-white shrink-0"
|
||||
style={{ backgroundColor: getAvatarColor(participant.name) }}
|
||||
>
|
||||
{getInitials(participant.name)}
|
||||
</div>
|
||||
<span className="max-w-[100px] truncate">{participant.name.split(' ')[0]}</span>
|
||||
{!participant.icsLink && (
|
||||
<AlertCircle className="w-3 h-3 text-amber-600 shrink-0" title="No calendar linked" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeParticipant(participant.id)}
|
||||
className="w-4 h-4 rounded-full hover:bg-black/10 dark:hover:bg-white/10 flex items-center justify-center transition-colors shrink-0"
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Input
|
||||
placeholder="Search people..."
|
||||
ref={inputRef}
|
||||
placeholder={selectedParticipants.length === 0 ? "Search people..." : "Add more..."}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setHighlightedIndex(0);
|
||||
setIsDropdownOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsDropdownOpen(true)}
|
||||
className="pl-10 h-12 bg-background border-border"
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 min-w-[100px] h-7 border-none shadow-none focus-visible:ring-0 p-0 text-sm bg-transparent placeholder:text-muted-foreground/70"
|
||||
/>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDropdownOpen(false);
|
||||
inputRef.current?.blur();
|
||||
}}
|
||||
className="p-1 hover:bg-muted rounded-full transition-colors ml-1"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isDropdownOpen && filteredParticipants.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-2 bg-popover border border-border rounded-lg shadow-popover animate-scale-in overflow-hidden">
|
||||
{filteredParticipants.map((participant) => (
|
||||
<div className="absolute top-full left-0 z-50 w-full mt-2 bg-popover border border-border rounded-lg shadow-popover animate-scale-in overflow-hidden">
|
||||
{filteredParticipants.map((participant, index) => (
|
||||
<button
|
||||
key={participant.id}
|
||||
onClick={() => addParticipant(participant)}
|
||||
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-accent transition-colors text-left"
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 flex items-center gap-3 hover:bg-accent transition-colors text-left",
|
||||
index === highlightedIndex && "bg-accent"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium text-white"
|
||||
@@ -77,56 +181,33 @@ 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedParticipants.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedParticipants.map((participant, index) => (
|
||||
<div
|
||||
key={participant.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 bg-accent text-accent-foreground px-3 py-2 rounded-full text-sm animate-scale-in",
|
||||
"border border-primary/20"
|
||||
)}
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium text-white"
|
||||
style={{ backgroundColor: getAvatarColor(participant.name) }}
|
||||
>
|
||||
{getInitials(participant.name)}
|
||||
</div>
|
||||
<span className="font-medium">{participant.name.split(' ')[0]}</span>
|
||||
{!participant.icsLink && (
|
||||
<AlertCircle className="w-3 h-3 text-amber-600" title="No calendar linked" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeParticipant(participant.id)}
|
||||
className="w-5 h-5 rounded-full hover:bg-primary/20 flex items-center justify-center transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setIsDropdownOpen(true)}
|
||||
className="flex items-center gap-1 px-3 py-2 rounded-full text-sm border-2 border-dashed border-border text-muted-foreground hover:border-primary hover:text-primary transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</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
|
||||
|
||||
@@ -11,11 +11,12 @@ interface TimezoneSelectorProps {
|
||||
|
||||
// Get all IANA timezones
|
||||
const getAllTimezones = (): string[] => {
|
||||
let timezones: string[] = [];
|
||||
try {
|
||||
return Intl.supportedValuesOf('timeZone');
|
||||
timezones = Intl.supportedValuesOf('timeZone');
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
return [
|
||||
timezones = [
|
||||
'UTC',
|
||||
'America/New_York',
|
||||
'America/Chicago',
|
||||
@@ -23,6 +24,7 @@ const getAllTimezones = (): string[] => {
|
||||
'America/Los_Angeles',
|
||||
'America/Toronto',
|
||||
'America/Vancouver',
|
||||
'America/Montreal',
|
||||
'Europe/London',
|
||||
'Europe/Paris',
|
||||
'Europe/Berlin',
|
||||
@@ -33,6 +35,17 @@ const getAllTimezones = (): string[] => {
|
||||
'Pacific/Auckland',
|
||||
];
|
||||
}
|
||||
|
||||
// Prioritize Montreal as requested
|
||||
const priorityTimezone = 'America/Montreal';
|
||||
if (!timezones.includes(priorityTimezone)) {
|
||||
timezones.push(priorityTimezone);
|
||||
}
|
||||
|
||||
return [
|
||||
priorityTimezone,
|
||||
...timezones.filter((tz) => tz !== priorityTimezone),
|
||||
];
|
||||
};
|
||||
|
||||
// Get UTC offset for a timezone
|
||||
@@ -158,7 +171,7 @@ export const TimezoneSelector = ({
|
||||
No timezones found
|
||||
</div>
|
||||
) : (
|
||||
filteredTimezones.slice(0, 50).map((timezone) => {
|
||||
filteredTimezones.map((timezone) => {
|
||||
const isSelected = timezone === value;
|
||||
const offset = getTimezoneOffset(timezone);
|
||||
const label = formatTimezoneLabel(timezone);
|
||||
@@ -204,12 +217,6 @@ export const TimezoneSelector = ({
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filteredTimezones.length > 50 && (
|
||||
<div className="px-4 py-2 text-xs text-muted-foreground text-center border-t border-border">
|
||||
Showing 50 of {filteredTimezones.length} results
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
@@ -19,14 +19,14 @@ const SheetOverlay = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
@@ -38,70 +38,101 @@ const sheetVariants = cva(
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
VariantProps<typeof sheetVariants> { }
|
||||
|
||||
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
|
||||
({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
),
|
||||
);
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
SheetHeader.displayName = "SheetHeader";
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
);
|
||||
SheetFooter.displayName = "SheetFooter";
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title ref={ref} className={cn("text-lg font-semibold text-foreground", className)} {...props} />
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetOverlay,
|
||||
SheetPortal,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
};
|
||||
SheetDescription,
|
||||
}
|
||||
|
||||
@@ -111,10 +111,51 @@
|
||||
--sidebar-ring: 18 68% 51%;
|
||||
}
|
||||
|
||||
/* Dark mode intentionally removed/reset to match light mode system for now,
|
||||
or you can define a proper dark mode if required.
|
||||
Keeping it simple as per previous apps. */
|
||||
}
|
||||
/* Dark mode */
|
||||
.dark {
|
||||
--background: 60 5% 8%; /* #161614 */
|
||||
--foreground: 60 9% 93%; /* #F0F0EC */
|
||||
|
||||
--card: 60 5% 10%; /* Slightly lighter than bg */
|
||||
--card-foreground: 60 9% 93%;
|
||||
|
||||
--popover: 60 5% 10%;
|
||||
--popover-foreground: 60 9% 93%;
|
||||
|
||||
--primary: 18 68% 51%; /* Keep primary brand color */
|
||||
--primary-foreground: 60 9% 97%;
|
||||
|
||||
--secondary: 60 5% 15%;
|
||||
--secondary-foreground: 60 9% 93%;
|
||||
|
||||
--muted: 60 5% 15%;
|
||||
--muted-foreground: 60 5% 65%;
|
||||
|
||||
--accent: 60 5% 15%;
|
||||
--accent-foreground: 60 9% 93%;
|
||||
|
||||
--destructive: 0 62% 30%;
|
||||
--destructive-foreground: 60 9% 97%;
|
||||
|
||||
--border: 60 5% 20%;
|
||||
--input: 60 5% 20%;
|
||||
--ring: 18 68% 51%;
|
||||
|
||||
/* Dark mode availability colors */
|
||||
--availability-full: 142 70% 45%; /* Brighter green for dark mode */
|
||||
--availability-partial: 25 80% 65%; /* Same orange */
|
||||
--availability-none: 60 5% 20%; /* Darker grey */
|
||||
|
||||
--sidebar-background: 60 5% 8%;
|
||||
--sidebar-foreground: 60 9% 93%;
|
||||
--sidebar-primary: 18 68% 51%;
|
||||
--sidebar-primary-foreground: 60 9% 97%;
|
||||
--sidebar-accent: 60 5% 15%;
|
||||
--sidebar-accent-foreground: 60 9% 93%;
|
||||
--sidebar-border: 60 5% 20%;
|
||||
--sidebar-ring: 18 68% 51%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@@ -192,3 +233,15 @@
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.pattern-diagonal-lines {
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
currentColor,
|
||||
currentColor 1px,
|
||||
transparent 1px,
|
||||
transparent 10px
|
||||
);
|
||||
background-size: 10px 10px;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
@@ -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))',
|
||||
|
||||
@@ -4,7 +4,9 @@ import { Header } from '@/components/Header';
|
||||
import { ParticipantSelector } from '@/components/ParticipantSelector';
|
||||
import { ParticipantManager } from '@/components/ParticipantManager';
|
||||
import { AvailabilityHeatmap } from '@/components/AvailabilityHeatmap';
|
||||
import { AvailabilityHeatmapV2 } from '@/components/AvailabilityHeatmapV2';
|
||||
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 +16,22 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet';
|
||||
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 +42,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: true,
|
||||
secondaryTimezone: 'America/Montreal', // Company timezone as default secondary
|
||||
};
|
||||
|
||||
function apiToParticipant(p: ParticipantAPI): Participant {
|
||||
@@ -61,7 +95,9 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
|
||||
const [availabilitySlots, setAvailabilitySlots] = useState<TimeSlot[]>([]);
|
||||
const [selectedSlot, setSelectedSlot] = useState<TimeSlot | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [useRedesign, setUseRedesign] = useState(true);
|
||||
const [settings, setSettings] = useState<SettingsState>(defaultSettings);
|
||||
const [weekOffset, setWeekOffset] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const { toast } = useToast();
|
||||
@@ -101,7 +137,7 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
|
||||
} else {
|
||||
setAvailabilitySlots([]);
|
||||
}
|
||||
}, [selectedParticipants]);
|
||||
}, [selectedParticipants, weekOffset]);
|
||||
|
||||
const loadParticipants = async () => {
|
||||
try {
|
||||
@@ -120,7 +156,7 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const ids = selectedParticipants.map((p) => p.id);
|
||||
const slots = await fetchAvailability(ids);
|
||||
const slots = await fetchAvailability(ids, weekOffset);
|
||||
setAvailabilitySlots(slots);
|
||||
} catch (error) {
|
||||
toast({
|
||||
@@ -209,6 +245,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 />
|
||||
@@ -254,6 +309,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"
|
||||
@@ -262,33 +321,114 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 ${isSyncing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="w-5 h-5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="end">
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium">Settings</h4>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="partial-availability" className="text-sm cursor-pointer">
|
||||
Show partial availability
|
||||
</Label>
|
||||
<Switch
|
||||
id="partial-availability"
|
||||
checked={settings.showPartialAvailability}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings((prev) => ({ ...prev, showPartialAvailability: checked }))
|
||||
}
|
||||
/>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[300px] sm:w-[350px]">
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold text-lg tracking-tight">Settings</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure your calendar preferences.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4 pb-4 border-b border-border">
|
||||
<Label htmlFor="use-redesign" className="text-sm cursor-pointer font-medium text-primary">
|
||||
Try New Design
|
||||
</Label>
|
||||
<Switch
|
||||
id="use-redesign"
|
||||
checked={useRedesign}
|
||||
onCheckedChange={setUseRedesign}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="partial-availability" className="text-sm font-medium cursor-pointer">
|
||||
Partial Availability
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Show slots where some are busy
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="partial-availability"
|
||||
checked={settings.showPartialAvailability}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings((prev) => ({ ...prev, showPartialAvailability: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-4 space-y-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="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground block">
|
||||
Secondary timezone
|
||||
</Label>
|
||||
<TimezoneSelector
|
||||
value={settings.secondaryTimezone}
|
||||
onChange={(tz) => setSettings((prev) => ({ ...prev, secondaryTimezone: tz }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-4 mt-8">
|
||||
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3 block">
|
||||
Danger Zone
|
||||
</Label>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive-foreground hover:bg-destructive border-destructive/30"
|
||||
>
|
||||
Clear All Bookings
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear all bookings?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove all scheduled meetings. This cannot be undone.
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, shows time slots where only some participants are available.
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-foreground mb-2">
|
||||
Schedule a Meeting
|
||||
@@ -319,19 +459,39 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AvailabilityHeatmap
|
||||
slots={availabilitySlots}
|
||||
selectedParticipants={selectedParticipants}
|
||||
onSlotSelect={handleSlotSelect}
|
||||
showPartialAvailability={settings.showPartialAvailability}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
{useRedesign ? (
|
||||
<AvailabilityHeatmapV2
|
||||
slots={availabilitySlots}
|
||||
selectedParticipants={selectedParticipants}
|
||||
onSlotSelect={handleSlotSelect}
|
||||
showPartialAvailability={settings.showPartialAvailability}
|
||||
isLoading={isLoading}
|
||||
weekOffset={weekOffset}
|
||||
onWeekOffsetChange={setWeekOffset}
|
||||
displayTimezone={settings.displayTimezone}
|
||||
showSecondaryTimezone={settings.showSecondaryTimezone}
|
||||
secondaryTimezone={settings.secondaryTimezone}
|
||||
/>
|
||||
) : (
|
||||
<AvailabilityHeatmap
|
||||
slots={availabilitySlots}
|
||||
selectedParticipants={selectedParticipants}
|
||||
onSlotSelect={handleSlotSelect}
|
||||
showPartialAvailability={settings.showPartialAvailability}
|
||||
isLoading={isLoading}
|
||||
weekOffset={weekOffset}
|
||||
onWeekOffsetChange={setWeekOffset}
|
||||
displayTimezone={settings.displayTimezone}
|
||||
showSecondaryTimezone={settings.showSecondaryTimezone}
|
||||
secondaryTimezone={settings.secondaryTimezone}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</main >
|
||||
|
||||
<ScheduleModal
|
||||
isOpen={isModalOpen}
|
||||
@@ -341,8 +501,10 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
|
||||
}}
|
||||
slot={selectedSlot}
|
||||
participants={selectedParticipants}
|
||||
displayTimezone={settings.displayTimezone}
|
||||
onSuccess={loadAvailability}
|
||||
/>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user