3 Commits

Author SHA1 Message Date
Joyce
8ea2987e3e update 2026-02-06 08:36:40 -05:00
Joyce
b1b92b445b update 2026-02-05 21:03:44 -05:00
Joyce
d6115dc30d update 2026-02-05 20:35:19 -05:00
11 changed files with 550 additions and 383 deletions

View File

@@ -4,6 +4,7 @@ from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models import BusyBlock, Participant
@@ -70,37 +71,41 @@ async def calculate_availability(
participants = {p.id: p for p in participants_result.scalars().all()}
days = ["Mon", "Tue", "Wed", "Thu", "Fri"]
hours = list(range(0, 24))
slot_interval = settings.slot_interval_minutes
slots_per_hour = 60 // slot_interval
slots = []
for day_offset, day_name in enumerate(days):
for hour in hours:
slot_start = week_start + timedelta(days=day_offset, hours=hour)
slot_end = slot_start + timedelta(hours=1)
for hour in range(24):
for slot_idx in range(slots_per_hour):
minute = slot_idx * slot_interval
slot_start = week_start + timedelta(days=day_offset, hours=hour, minutes=minute)
slot_end = slot_start + timedelta(minutes=slot_interval)
available_participants = []
for pid in participant_ids:
if is_participant_free(busy_map.get(pid, []), slot_start, slot_end):
participant = participants.get(pid)
if participant:
available_participants.append(participant.name)
available_participants = []
for pid in participant_ids:
if is_participant_free(busy_map.get(pid, []), slot_start, slot_end):
participant = participants.get(pid)
if participant:
available_participants.append(participant.name)
total = len(participant_ids)
available_count = len(available_participants)
total = len(participant_ids)
available_count = len(available_participants)
if available_count == total:
availability = "full"
elif available_count > 0:
availability = "partial"
else:
availability = "none"
if available_count == total:
availability = "full"
elif available_count > 0:
availability = "partial"
else:
availability = "none"
slots.append({
"day": slot_start.strftime("%Y-%m-%d"),
"hour": hour,
"start_time": slot_start,
"availability": availability,
"availableParticipants": available_participants,
})
slots.append({
"day": slot_start.strftime("%Y-%m-%d"),
"hour": hour,
"minute": minute,
"start_time": slot_start,
"availability": availability,
"availableParticipants": available_participants,
})
return slots

View File

@@ -5,6 +5,7 @@ class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://postgres:postgres@db:5432/availability"
sync_database_url: str = "postgresql://postgres:postgres@db:5432/availability"
ics_refresh_interval_minutes: int = 15
slot_interval_minutes: int = 15 # Time slot granularity (15, 30, or 60)
# SMTP Settings
smtp_host: str | None = None

View File

@@ -61,7 +61,8 @@ def send_zulip_notification(
f"📅 **Meeting Scheduled**\n"
f"**What:** {title}\n"
f"**When:** {zulip_time}\n"
f"**Who:** {people}"
f"**Who:** {people}\n"
f"*Booked via [Meetly](https://meetly.app.monadical.io/)*"
)
request = {

View File

@@ -12,7 +12,14 @@ import { Check, X, Loader2, ChevronLeft, ChevronRight, ChevronsRight } from 'luc
const DEFAULT_TIMEZONE = 'America/Toronto';
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const hours = Array.from({ length: 24 }, (_, i) => i); // 0-23
const SLOT_INTERVAL_MINUTES = 15;
const SLOTS_PER_HOUR = 60 / SLOT_INTERVAL_MINUTES;
// Generate time slots: [{hour: 0, minute: 0}, {hour: 0, minute: 30}, {hour: 1, minute: 0}, ...]
const timeSlots = Array.from({ length: 24 * SLOTS_PER_HOUR }, (_, i) => ({
hour: Math.floor(i / SLOTS_PER_HOUR),
minute: (i % SLOTS_PER_HOUR) * SLOT_INTERVAL_MINUTES,
}));
// 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[] => {
@@ -46,11 +53,11 @@ const getWeekDates = (timezone: string, weekOffset: number = 0): string[] => {
});
};
// Convert a date string and hour in a timezone to a UTC Date
const toUTCDate = (dateStr: string, hour: number, timezone: string): Date => {
// Create a date string that represents the given hour in the given timezone
// Convert a date string, hour, and minute in a timezone to a UTC Date
const toUTCDate = (dateStr: string, hour: number, minute: number, timezone: string): Date => {
// Create a date string that represents the given time in the given timezone
// Then parse it to get the UTC equivalent
const localDateStr = `${dateStr}T${String(hour).padStart(2, '0')}:00:00`;
const localDateStr = `${dateStr}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00`;
// Use a trick: format in UTC then in target TZ to find the offset
const testDate = new Date(localDateStr + 'Z'); // Treat as UTC first
@@ -111,29 +118,32 @@ const getTimezoneAbbrev = (timezone: string): string => {
}
};
// Convert an hour from one timezone to another
const convertHourBetweenTimezones = (
// Convert a time from one timezone to another
const convertTimeBetweenTimezones = (
hour: number,
minute: number,
dateStr: string,
fromTimezone: string,
toTimezone: string
): number => {
): { hour: number; minute: number } => {
try {
// Create a UTC date for the given hour in the source timezone
const utcDate = toUTCDate(dateStr, hour, fromTimezone);
// Create a UTC date for the given time in the source timezone
const utcDate = toUTCDate(dateStr, hour, minute, fromTimezone);
// Format the hour in the target timezone
const targetHour = parseInt(
new Intl.DateTimeFormat('en-US', {
timeZone: toTimezone,
hour: 'numeric',
hour12: false,
}).format(utcDate)
);
// Format the time in the target timezone
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: toTimezone,
hour: 'numeric',
minute: 'numeric',
hour12: false,
});
const parts = formatter.formatToParts(utcDate);
const targetHour = parseInt(parts.find(p => p.type === 'hour')?.value || '0');
const targetMinute = parseInt(parts.find(p => p.type === 'minute')?.value || '0');
return targetHour;
return { hour: targetHour, minute: targetMinute };
} catch {
return hour;
return { hour, minute };
}
};
@@ -216,24 +226,24 @@ export const AvailabilityHeatmap = ({
const timer = setTimeout(() => {
if (!scrollContainerRef.current) return;
const rowHeight = 52; // h-12 (48px) + gap (4px)
const rowsAbove = 2;
const rowHeight = 28; // h-6 (24px) + gap (4px)
const rowsAbove = 4; // Show a few slots before current time
// Calculate which hour should be at the top
// Calculate which slot should be at the top (multiply by slots per hour)
const targetHour = todayIndex >= 0
? Math.max(0, currentHour - rowsAbove)
? Math.max(0, currentHour - Math.floor(rowsAbove / SLOTS_PER_HOUR))
: 7; // Default to 7am for other weeks
scrollContainerRef.current.scrollTop = targetHour * rowHeight;
scrollContainerRef.current.scrollTop = targetHour * SLOTS_PER_HOUR * 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, displayTimezone);
// Find a slot that matches the given display timezone date/hour/minute
const getSlot = (dateStr: string, hour: number, minute: number): TimeSlot | undefined => {
// Convert display timezone date/hour/minute to UTC
const targetUTC = toUTCDate(dateStr, hour, minute, displayTimezone);
return slots.find((s) => {
const slotDate = new Date(s.start_time);
@@ -249,13 +259,13 @@ export const AvailabilityHeatmap = ({
return slot.availability;
};
const formatHour = (hour: number) => {
return `${hour.toString().padStart(2, '0')}:00`;
const formatTime = (hour: number, minute: number) => {
return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
};
const isSlotTooSoon = (dateStr: string, hour: number) => {
const isSlotTooSoon = (dateStr: string, hour: number, minute: number) => {
// Convert to UTC and compare with current time
const slotTimeUTC = toUTCDate(dateStr, hour, displayTimezone);
const slotTimeUTC = toUTCDate(dateStr, hour, minute, displayTimezone);
const now = new Date();
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
return slotTimeUTC < twoHoursFromNow;
@@ -271,11 +281,11 @@ export const AvailabilityHeatmap = ({
return `${format(monday)} ${format(friday)}`;
};
// Format hour for display in popover (in the display timezone)
const formatDisplayTime = (hour: number) => {
// Create a date at that hour
// Format time for display in popover (in the display timezone)
const formatDisplayTime = (hour: number, minute: number) => {
// Create a date at that time
const date = new Date();
date.setHours(hour, 0, 0, 0);
date.setHours(hour, minute, 0, 0);
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
@@ -398,144 +408,152 @@ export const AvailabilityHeatmap = ({
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 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}
className="text-center text-sm font-medium text-muted-foreground py-2"
>
<div>{dayName}</div>
<div className="text-xs opacity-70">
{weekDates[i]?.slice(5).replace('-', '/')}
</div>
</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}
className="text-center text-sm font-medium text-muted-foreground py-2"
>
<div>{dayName}</div>
<div className="text-xs opacity-70">
{weekDates[i]?.slice(5).replace('-', '/')}
</div>
</div>
))}
</div>
))}
</div>
<div className="space-y-1">
{hours.map((hour) => (
<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 && (
<div className="space-y-1">
{timeSlots.map(({ hour, minute }) => (
<div
key={`${hour}-${minute}`}
ref={todayIndex >= 0 && hour === currentHour && minute === 0 ? 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 && minute === 0 && (
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
)}
{formatTime(hour, minute)}
</div>
<div className="text-xs text-muted-foreground flex items-center justify-center">
{(() => {
const converted = convertTimeBetweenTimezones(hour, minute, weekDates[0] || '', displayTimezone, secondaryTimezone);
return formatTime(converted.hour, converted.minute);
})()}
</div>
</>
) : (
<div className="text-xs text-muted-foreground flex items-center justify-end pr-3 gap-1">
{todayIndex >= 0 && hour === currentHour && minute === 0 && (
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
)}
{formatHour(hour)}
{formatTime(hour, minute)}
</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];
const tooSoon = isSlotTooSoon(dateStr, hour);
if (!slot) return <div key={`${dateStr}-${hour}`} className="h-12 bg-muted rounded" />;
)}
{weekDates.map((dateStr, dayIndex) => {
const slot = getSlot(dateStr, hour, minute);
const dayName = dayNames[dayIndex];
const tooSoon = isSlotTooSoon(dateStr, hour, minute);
if (!slot) return <div key={`${dateStr}-${hour}-${minute}`} className="h-6 bg-muted rounded" />;
const effectiveAvailability = getEffectiveAvailability(slot);
const effectiveAvailability = getEffectiveAvailability(slot);
return (
<Popover key={`${dateStr}-${hour}`}>
<PopoverTrigger asChild>
<button
className={cn(
"h-12 rounded-md transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
tooSoon && "opacity-40 cursor-not-allowed",
!tooSoon && "hover:scale-105 hover:shadow-md",
effectiveAvailability === 'full' && "bg-availability-full hover:bg-availability-full/90",
effectiveAvailability === 'partial' && "bg-availability-partial hover:bg-availability-partial/90",
effectiveAvailability === 'none' && "bg-availability-none hover:bg-availability-none/90"
)}
/>
</PopoverTrigger>
<PopoverContent className="w-64 p-4 animate-scale-in" align="center">
<div className="space-y-3">
<div className="font-semibold text-foreground">
{dayName} {formatDisplayTime(hour)}{formatDisplayTime(hour + 1)}
</div>
{tooSoon && (
<div className="text-sm text-muted-foreground italic">
This time slot has passed or is too soon to schedule
// Calculate end time for display
const endMinute = minute + SLOT_INTERVAL_MINUTES;
const endHour = hour + Math.floor(endMinute / 60);
const endMinuteNormalized = endMinute % 60;
return (
<Popover key={`${dateStr}-${hour}-${minute}`}>
<PopoverTrigger asChild>
<button
className={cn(
"h-6 rounded-md transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
tooSoon && "opacity-40 cursor-not-allowed",
!tooSoon && "hover:scale-105 hover:shadow-md",
effectiveAvailability === 'full' && "bg-availability-full hover:bg-availability-full/90",
effectiveAvailability === 'partial' && "bg-availability-partial hover:bg-availability-partial/90",
effectiveAvailability === 'none' && "bg-availability-none hover:bg-availability-none/90"
)}
/>
</PopoverTrigger>
<PopoverContent className="w-64 p-4 animate-scale-in" align="center">
<div className="space-y-3">
<div className="font-semibold text-foreground">
{dayName} {formatDisplayTime(hour, minute)}{formatDisplayTime(endHour, endMinuteNormalized)}
</div>
)}
<div className="space-y-2">
{selectedParticipants.map((participant) => {
const isAvailable = slot.availableParticipants.includes(participant.name);
{tooSoon && (
<div className="text-sm text-muted-foreground italic">
This time slot has passed or is too soon to schedule
</div>
)}
<div className="space-y-2">
{selectedParticipants.map((participant) => {
const isAvailable = slot.availableParticipants.includes(participant.name);
return (
<div
key={participant.id}
className="flex items-center gap-2 text-sm"
>
{isAvailable ? (
<Check className="w-4 h-4 text-availability-full" />
) : (
<X className="w-4 h-4 text-destructive" />
)}
<span className={cn(
isAvailable ? "text-foreground" : "text-muted-foreground"
)}>
{participant.name.split(' ')[0]}
</span>
</div>
);
})}
return (
<div
key={participant.id}
className="flex items-center gap-2 text-sm"
>
{isAvailable ? (
<Check className="w-4 h-4 text-availability-full" />
) : (
<X className="w-4 h-4 text-destructive" />
)}
<span className={cn(
isAvailable ? "text-foreground" : "text-muted-foreground"
)}>
{participant.name.split(' ')[0]}
</span>
</div>
);
})}
</div>
{effectiveAvailability !== 'none' && !tooSoon && (
<Button
variant="schedule"
className="w-full mt-2"
onClick={() => onSlotSelect(slot)}
>
Schedule
</Button>
)}
</div>
{effectiveAvailability !== 'none' && !tooSoon && (
<Button
variant="schedule"
className="w-full mt-2"
onClick={() => onSlotSelect(slot)}
>
Schedule
</Button>
)}
</div>
</PopoverContent>
</Popover>
);
})}
</div>
))}
</PopoverContent>
</Popover>
);
})}
</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 gap-2">

View File

@@ -12,10 +12,19 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
const DEFAULT_TIMEZONE = 'America/Toronto';
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const SLOT_INTERVAL_MINUTES = 15;
const SLOTS_PER_HOUR = 60 / SLOT_INTERVAL_MINUTES;
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);
// Generate time slots with hour and minute
const ALL_TIME_SLOTS = Array.from({ length: 24 * SLOTS_PER_HOUR }, (_, i) => ({
hour: Math.floor(i / SLOTS_PER_HOUR),
minute: (i % SLOTS_PER_HOUR) * SLOT_INTERVAL_MINUTES,
}));
const WORKING_TIME_SLOTS = ALL_TIME_SLOTS.filter(
slot => slot.hour >= WORKING_HOUR_START && slot.hour < WORKING_HOUR_END
);
// Helper to check if a slot is in the past or too close (2h buffer)
const isSlotTooSoon = (slotDate: number) => {
@@ -88,21 +97,15 @@ export const AvailabilityHeatmapV2 = ({
secondaryTimezone = DEFAULT_TIMEZONE,
}: AvailabilityHeatmapV2Props) => {
const [showFullDay, setShowFullDay] = useState(false);
const activeHours = showFullDay ? ALL_HOURS : WORKING_HOURS;
const activeSlots = showFullDay ? ALL_TIME_SLOTS : WORKING_TIME_SLOTS;
const weekDates = useMemo(() => getWeekDates(displayTimezone, weekOffset), [displayTimezone, weekOffset]);
// Pre-compute slots lookup map for O(1) access
// Key: YYYY-MM-DD:Hour
// Key: YYYY-MM-DD:Hour:Minute
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', {
@@ -111,22 +114,22 @@ export const AvailabilityHeatmapV2 = ({
month: '2-digit',
day: '2-digit',
hour: 'numeric',
minute: '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;
const minute = parts.find(p => p.type === 'minute')?.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.
if (year && month && day && hour && minute) {
let h = parseInt(hour, 10);
let m = parseInt(minute, 10);
if (h === 24) h = 0; // Just in case
const key = `${year}-${month}-${day}:${h}`;
const key = `${year}-${month}-${day}:${h}:${m}`;
map.set(key, slot);
}
});
@@ -144,18 +147,43 @@ export const AvailabilityHeatmapV2 = ({
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
};
const getSlotForCell = (date: Date, hour: number) => {
const key = `${formatDateKey(date)}:${hour}`;
const getSlotForCell = (date: Date, hour: number, minute: number) => {
const key = `${formatDateKey(date)}:${hour}:${minute}`;
return slotsMap.get(key);
};
const formatHour = (hour: number) => {
const formatTime = (hour: number, minute: number) => {
return new Date(0, 0, 0, hour, minute).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
};
const formatHourOnly = (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">
@@ -245,9 +273,15 @@ export const AvailabilityHeatmapV2 = ({
<div className="overflow-auto max-h-[600px] w-full relative">
<div className="min-w-[700px]">
{/* Grid Header */}
<div className="grid grid-cols-[80px_repeat(5,1fr)] sticky top-0 z-30 bg-card border-b border-border shadow-sm">
<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">
TIME
<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();
@@ -267,34 +301,62 @@ export const AvailabilityHeatmapV2 = ({
{/* Grid Body */}
<div className="relative">
{activeHours.map((hour) => {
{activeSlots.map(({ hour, minute }) => {
const isNight = hour < 8 || hour >= 18;
// Calculate secondary time
let secondaryHour = hour + tzOffsetDiff;
if (secondaryHour >= 24) secondaryHour -= 24;
if (secondaryHour < 0) secondaryHour += 24;
// Calculate end time for display
const endMinute = minute + SLOT_INTERVAL_MINUTES;
const endHour = hour + Math.floor(endMinute / 60);
const endMinuteNormalized = endMinute % 60;
return (
<div
key={hour}
key={`${hour}-${minute}`}
className={cn(
"grid grid-cols-[80px_repeat(5,1fr)] group items-stretch transition-colors border-b border-border/30 last:border-0",
"grid group items-stretch transition-colors border-border/30 last:border-0",
isNight ? "bg-muted/30" : "bg-card",
minute === 0 ? "border-t border-border" : "border-t border-border/10",
"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 items-center justify-end gap-1.5",
"text-xs text-muted-foreground font-medium text-right pr-4 py-2 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"
)}>
{isNight ? (
<Moon className="w-3 h-3 text-slate-400/50" />
) : (
<Sun className="w-3 h-3 text-amber-500/50" />
<div className="flex items-center gap-1.5">
{minute === 0 ? (
<div className="flex items-center gap-1.5 font-bold text-foreground/80">
{isNight ? (
<Moon className="w-3 h-3 text-slate-400/50" />
) : (
<Sun className="w-3 h-3 text-amber-500/50" />
)}
<span>{formatTime(hour, minute)}</span>
</div>
) : (
<span className="text-[10px] opacity-0 group-hover:opacity-50 transition-opacity">
:{minute.toString().padStart(2, '0')}
</span>
)}
</div>
{showSecondaryTimezone && (
<span className="text-[10px] text-muted-foreground/60 font-mono">
{formatTime(secondaryHour, minute)}
</span>
)}
{formatHour(hour)}
</div>
{/* Days */}
{weekDates.map(date => {
const slot = getSlotForCell(date, hour);
const slot = getSlotForCell(date, hour, minute);
const tooSoon = slot ? isSlotTooSoon(new Date(slot.start_time).getTime()) : true;
// Availability logic
@@ -306,28 +368,34 @@ export const AvailabilityHeatmapV2 = ({
// Styling
let bgClass = ""; // Default transparent
if (!slot) bgClass = "bg-muted/10 pattern-diagonal-lines opacity-50";
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-transparent hover:bg-muted/20";
if (tooSoon && slot) bgClass = cn(bgClass, "opacity-30 cursor-not-allowed pattern-diagonal-lines");
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">
<div key={`${date.toISOString()}-${minute}`} className="p-0.5 h-[32px] 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" : ""
!tooSoon ? "scale-[0.98] hover:scale-105 hover:z-10 hover:shadow-lg 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" />}
{isFull && <Check className="w-3 h-3 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="start">
<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" />
@@ -335,7 +403,7 @@ export const AvailabilityHeatmapV2 = ({
</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)}
{formatTime(hour, minute)} - {formatTime(endHour, endMinuteNormalized)}
</div>
</div>
@@ -402,11 +470,11 @@ export const AvailabilityHeatmapV2 = ({
</div>
)}
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded border border-slate-300 bg-slate-100 dark:bg-slate-800 dark:border-slate-700"></div>
<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/30 pattern-diagonal-lines ring-1 ring-border/50"></div>
<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>

View File

@@ -145,6 +145,20 @@ export const ParticipantSelector = ({
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 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) => (

View File

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

View File

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

View File

@@ -233,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;
}

View File

@@ -16,6 +16,11 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Sheet,
SheetContent,
SheetTrigger,
} from '@/components/ui/sheet';
import {
AlertDialog,
AlertDialogAction,
@@ -62,8 +67,8 @@ interface SettingsState {
const defaultSettings: SettingsState = {
showPartialAvailability: false,
displayTimezone: getUserTimezone(),
showSecondaryTimezone: false,
secondaryTimezone: 'America/Toronto', // Company timezone as default secondary
showSecondaryTimezone: true,
secondaryTimezone: 'America/Montreal', // Company timezone as default secondary
};
function apiToParticipant(p: ParticipantAPI): Participant {
@@ -90,7 +95,7 @@ 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(false);
const [useRedesign, setUseRedesign] = useState(true);
const [settings, setSettings] = useState<SettingsState>(defaultSettings);
const [weekOffset, setWeekOffset] = useState(0);
const [isLoading, setIsLoading] = useState(false);
@@ -316,110 +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-80" align="end">
<div className="space-y-4">
<h4 className="font-medium">Settings</h4>
<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}
/>
</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="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 }))
}
/>
</div>
<p className="text-xs text-muted-foreground">
When enabled, shows time slots where only some participants are available.
</p>
<div className="border-t border-border pt-4">
<div className="flex items-center justify-between gap-4">
<Label htmlFor="secondary-timezone" className="text-sm cursor-pointer">
Show secondary timezone
<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="secondary-timezone"
checked={settings.showSecondaryTimezone}
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, showSecondaryTimezone: checked }))
setSettings((prev) => ({ ...prev, showPartialAvailability: checked }))
}
/>
</div>
{settings.showSecondaryTimezone && (
<div className="mt-3">
<Label className="text-xs text-muted-foreground mb-2 block">
Secondary timezone
<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>
<TimezoneSelector
value={settings.secondaryTimezone}
onChange={(tz) => setSettings((prev) => ({ ...prev, secondaryTimezone: tz }))}
<Switch
id="secondary-timezone"
checked={settings.showSecondaryTimezone}
onCheckedChange={(checked) =>
setSettings((prev) => ({ ...prev, showSecondaryTimezone: checked }))
}
/>
</div>
)}
<p className="text-xs text-muted-foreground mt-2">
Display times in two timezones side by side.
</p>
</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">
<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"
<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
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p className="text-xs text-muted-foreground mt-2">
Remove all scheduled meetings from the system.
</p>
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>
</div>
</PopoverContent>
</Popover>
</SheetContent>
</Sheet>
</div>
<h2 className="text-3xl font-bold text-foreground mb-2">
Schedule a Meeting
@@ -482,7 +491,7 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
</div>
</TabsContent>
</Tabs>
</main>
</main >
<ScheduleModal
isOpen={isModalOpen}
@@ -495,7 +504,7 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
displayTimezone={settings.displayTimezone}
onSuccess={loadAvailability}
/>
</div>
</div >
);
};

View File

@@ -11,6 +11,7 @@ export interface Participant {
export interface TimeSlot {
day: string;
hour: number;
minute: number;
start_time: string;
availability: 'full' | 'partial' | 'none';
availableParticipants: string[];