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

View File

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

View File

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

View File

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

View File

@@ -12,10 +12,19 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
const DEFAULT_TIMEZONE = 'America/Toronto'; const DEFAULT_TIMEZONE = 'America/Toronto';
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']; 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_START = 8; // 8 AM
const WORKING_HOUR_END = 18; // 6 PM 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) // Helper to check if a slot is in the past or too close (2h buffer)
const isSlotTooSoon = (slotDate: number) => { const isSlotTooSoon = (slotDate: number) => {
@@ -88,21 +97,15 @@ export const AvailabilityHeatmapV2 = ({
secondaryTimezone = DEFAULT_TIMEZONE, secondaryTimezone = DEFAULT_TIMEZONE,
}: AvailabilityHeatmapV2Props) => { }: AvailabilityHeatmapV2Props) => {
const [showFullDay, setShowFullDay] = useState(false); 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]); const weekDates = useMemo(() => getWeekDates(displayTimezone, weekOffset), [displayTimezone, weekOffset]);
// Pre-compute slots lookup map for O(1) access // Pre-compute slots lookup map for O(1) access
// Key: YYYY-MM-DD:Hour // Key: YYYY-MM-DD:Hour:Minute
const slotsMap = useMemo(() => { const slotsMap = useMemo(() => {
const map = new Map<string, TimeSlot>(); const map = new Map<string, TimeSlot>();
slots.forEach(slot => { 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); const d = new Date(slot.start_time);
// Format to DisplayTZ to find coordinate // Format to DisplayTZ to find coordinate
const formatter = new Intl.DateTimeFormat('en-CA', { const formatter = new Intl.DateTimeFormat('en-CA', {
@@ -111,22 +114,22 @@ export const AvailabilityHeatmapV2 = ({
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: 'numeric', hour: 'numeric',
minute: 'numeric',
hour12: false hour12: false
}); });
// "2023-10-25, 14"
const parts = formatter.formatToParts(d); const parts = formatter.formatToParts(d);
const year = parts.find(p => p.type === 'year')?.value; const year = parts.find(p => p.type === 'year')?.value;
const month = parts.find(p => p.type === 'month')?.value; const month = parts.find(p => p.type === 'month')?.value;
const day = parts.find(p => p.type === 'day')?.value; const day = parts.find(p => p.type === 'day')?.value;
const hour = parts.find(p => p.type === 'hour')?.value; const hour = parts.find(p => p.type === 'hour')?.value;
const minute = parts.find(p => p.type === 'minute')?.value;
if (year && month && day && hour) { if (year && month && day && hour && minute) {
// 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); let h = parseInt(hour, 10);
let m = parseInt(minute, 10);
if (h === 24) h = 0; // Just in case 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); map.set(key, slot);
} }
}); });
@@ -144,18 +147,43 @@ export const AvailabilityHeatmapV2 = ({
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
}; };
const getSlotForCell = (date: Date, hour: number) => { const getSlotForCell = (date: Date, hour: number, minute: number) => {
const key = `${formatDateKey(date)}:${hour}`; const key = `${formatDateKey(date)}:${hour}:${minute}`;
return slotsMap.get(key); 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', { return new Date(0, 0, 0, hour).toLocaleTimeString('en-US', {
hour: 'numeric', hour: 'numeric',
hour12: true, 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) { if (selectedParticipants.length === 0) {
return ( 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"> <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="overflow-auto max-h-[600px] w-full relative">
<div className="min-w-[700px]"> <div className="min-w-[700px]">
{/* Grid Header */} {/* 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
<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"> className="grid sticky top-0 z-30 bg-card border-b border-border shadow-sm"
TIME 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> </div>
{weekDates.map(date => { {weekDates.map(date => {
const isToday = new Date().toDateString() === date.toDateString(); const isToday = new Date().toDateString() === date.toDateString();
@@ -267,34 +301,62 @@ export const AvailabilityHeatmapV2 = ({
{/* Grid Body */} {/* Grid Body */}
<div className="relative"> <div className="relative">
{activeHours.map((hour) => { {activeSlots.map(({ hour, minute }) => {
const isNight = hour < 8 || hour >= 18; 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 ( return (
<div <div
key={hour} key={`${hour}-${minute}`}
className={cn( 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", isNight ? "bg-muted/30" : "bg-card",
minute === 0 ? "border-t border-border" : "border-t border-border/10",
"hover:bg-muted/10" "hover:bg-muted/10"
)} )}
style={{ gridTemplateColumns: `${timeColWidth} repeat(5, 1fr)` }}
> >
{/* Time Label - Sticky Left */} {/* Time Label - Sticky Left */}
<div className={cn( <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", "sticky left-0 z-20 border-r border-border/50",
isNight ? "bg-muted/30 backdrop-blur-md" : "bg-card" isNight ? "bg-muted/30 backdrop-blur-md" : "bg-card"
)}> )}>
{isNight ? ( <div className="flex items-center gap-1.5">
<Moon className="w-3 h-3 text-slate-400/50" /> {minute === 0 ? (
) : ( <div className="flex items-center gap-1.5 font-bold text-foreground/80">
<Sun className="w-3 h-3 text-amber-500/50" /> {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> </div>
{/* Days */} {/* Days */}
{weekDates.map(date => { {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; const tooSoon = slot ? isSlotTooSoon(new Date(slot.start_time).getTime()) : true;
// Availability logic // Availability logic
@@ -306,28 +368,34 @@ export const AvailabilityHeatmapV2 = ({
// Styling // Styling
let bgClass = ""; // Default transparent 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 ( 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 ? ( {slot ? (
<Popover> <Popover>
<PopoverTrigger asChild disabled={tooSoon}> <PopoverTrigger asChild disabled={tooSoon}>
<div className={cn( <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", "w-full h-full rounded-md transition-all duration-200 cursor-pointer flex items-center justify-center group/cell relative overflow-hidden",
bgClass, 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 */} {/* 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> </div>
</PopoverTrigger> </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"> <div className="p-4 bg-muted/30 border-b border-border/50">
<h4 className="font-semibold text-base flex items-center gap-2"> <h4 className="font-semibold text-base flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-muted-foreground" /> <CalendarIcon className="w-4 h-4 text-muted-foreground" />
@@ -335,7 +403,7 @@ export const AvailabilityHeatmapV2 = ({
</h4> </h4>
<div className="text-sm text-muted-foreground mt-1 flex items-center gap-2"> <div className="text-sm text-muted-foreground mt-1 flex items-center gap-2">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
{formatHour(hour)} - {formatHour(hour + 1)} {formatTime(hour, minute)} - {formatTime(endHour, endMinuteNormalized)}
</div> </div>
</div> </div>
@@ -402,11 +470,11 @@ export const AvailabilityHeatmapV2 = ({
</div> </div>
)} )}
<div className="flex items-center gap-2"> <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> <span className="text-muted-foreground">Busy / No Match</span>
</div> </div>
<div className="flex items-center gap-2"> <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> <span className="text-muted-foreground">Past / Too Soon</span>
</div> </div>
</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" 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 && ( {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"> <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) => ( {filteredParticipants.map((participant, index) => (

View File

@@ -11,11 +11,12 @@ interface TimezoneSelectorProps {
// Get all IANA timezones // Get all IANA timezones
const getAllTimezones = (): string[] => { const getAllTimezones = (): string[] => {
let timezones: string[] = [];
try { try {
return Intl.supportedValuesOf('timeZone'); timezones = Intl.supportedValuesOf('timeZone');
} catch { } catch {
// Fallback for older browsers // Fallback for older browsers
return [ timezones = [
'UTC', 'UTC',
'America/New_York', 'America/New_York',
'America/Chicago', 'America/Chicago',
@@ -23,6 +24,7 @@ const getAllTimezones = (): string[] => {
'America/Los_Angeles', 'America/Los_Angeles',
'America/Toronto', 'America/Toronto',
'America/Vancouver', 'America/Vancouver',
'America/Montreal',
'Europe/London', 'Europe/London',
'Europe/Paris', 'Europe/Paris',
'Europe/Berlin', 'Europe/Berlin',
@@ -33,6 +35,17 @@ const getAllTimezones = (): string[] => {
'Pacific/Auckland', '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 // Get UTC offset for a timezone
@@ -158,7 +171,7 @@ export const TimezoneSelector = ({
No timezones found No timezones found
</div> </div>
) : ( ) : (
filteredTimezones.slice(0, 50).map((timezone) => { filteredTimezones.map((timezone) => {
const isSelected = timezone === value; const isSelected = timezone === value;
const offset = getTimezoneOffset(timezone); const offset = getTimezoneOffset(timezone);
const label = formatTimezoneLabel(timezone); const label = formatTimezoneLabel(timezone);
@@ -204,12 +217,6 @@ export const TimezoneSelector = ({
}) })
)} )}
</div> </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>
)} )}
</div> </div>

View File

@@ -1,17 +1,17 @@
import * as SheetPrimitive from "@radix-ui/react-dialog"; import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"; import * as SheetPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"; import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"; 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< const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>, React.ElementRef<typeof SheetPrimitive.Overlay>,
@@ -19,14 +19,14 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
className={cn( 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", "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, className
)} )}
{...props} {...props}
ref={ref} ref={ref}
/> />
)); ))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva( 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", "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", "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", 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: 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: { defaultVariants: {
side: "right", side: "right",
}, },
}, }
); )
interface SheetContentProps interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {} VariantProps<typeof sheetVariants> { }
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>( const SheetContent = React.forwardRef<
({ side = "right", className, children, ...props }, ref) => ( React.ElementRef<typeof SheetPrimitive.Content>,
<SheetPortal> SheetContentProps
<SheetOverlay /> >(({ side = "right", className, children, ...props }, ref) => (
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}> <SheetPortal>
{children} <SheetOverlay />
<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"> <SheetPrimitive.Content
<X className="h-4 w-4" /> ref={ref}
<span className="sr-only">Close</span> className={cn(sheetVariants({ side }), className)}
</SheetPrimitive.Close> {...props}
</SheetPrimitive.Content> >
</SheetPortal> {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" />
SheetContent.displayName = SheetPrimitive.Content.displayName; <span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const SheetHeader = ({
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} /> className,
); ...props
SheetHeader.displayName = "SheetHeader"; }: 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>) => ( const SheetFooter = ({
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} /> className,
); ...props
SheetFooter.displayName = "SheetFooter"; }: 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< const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>, React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Title ref={ref} className={cn("text-lg font-semibold text-foreground", className)} {...props} /> <SheetPrimitive.Title
)); ref={ref}
SheetTitle.displayName = SheetPrimitive.Title.displayName; className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef< const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>, React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} /> <SheetPrimitive.Description
)); ref={ref}
SheetDescription.displayName = SheetPrimitive.Description.displayName; className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export { export {
Sheet, Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose, SheetClose,
SheetContent, SheetContent,
SheetDescription,
SheetFooter,
SheetHeader, SheetHeader,
SheetOverlay, SheetFooter,
SheetPortal,
SheetTitle, SheetTitle,
SheetTrigger, SheetDescription,
}; }

View File

@@ -233,3 +233,15 @@
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.7; } 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, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover'; } from '@/components/ui/popover';
import {
Sheet,
SheetContent,
SheetTrigger,
} from '@/components/ui/sheet';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -62,8 +67,8 @@ interface SettingsState {
const defaultSettings: SettingsState = { const defaultSettings: SettingsState = {
showPartialAvailability: false, showPartialAvailability: false,
displayTimezone: getUserTimezone(), displayTimezone: getUserTimezone(),
showSecondaryTimezone: false, showSecondaryTimezone: true,
secondaryTimezone: 'America/Toronto', // Company timezone as default secondary secondaryTimezone: 'America/Montreal', // Company timezone as default secondary
}; };
function apiToParticipant(p: ParticipantAPI): Participant { function apiToParticipant(p: ParticipantAPI): Participant {
@@ -90,7 +95,7 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
const [availabilitySlots, setAvailabilitySlots] = useState<TimeSlot[]>([]); const [availabilitySlots, setAvailabilitySlots] = useState<TimeSlot[]>([]);
const [selectedSlot, setSelectedSlot] = useState<TimeSlot | null>(null); const [selectedSlot, setSelectedSlot] = useState<TimeSlot | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [useRedesign, setUseRedesign] = useState(false); const [useRedesign, setUseRedesign] = useState(true);
const [settings, setSettings] = useState<SettingsState>(defaultSettings); const [settings, setSettings] = useState<SettingsState>(defaultSettings);
const [weekOffset, setWeekOffset] = useState(0); const [weekOffset, setWeekOffset] = useState(0);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -316,110 +321,114 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
> >
<RefreshCw className={`w-5 h-5 ${isSyncing ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-5 h-5 ${isSyncing ? 'animate-spin' : ''}`} />
</Button> </Button>
<Popover> <Sheet>
<PopoverTrigger asChild> <SheetTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<Settings className="w-5 h-5" /> <Settings className="w-5 h-5" />
</Button> </Button>
</PopoverTrigger> </SheetTrigger>
<PopoverContent className="w-80" align="end"> <SheetContent side="right" className="w-[300px] sm:w-[350px]">
<div className="space-y-4"> <div className="space-y-6 py-4">
<h4 className="font-medium">Settings</h4> <div className="space-y-2">
<h4 className="font-semibold text-lg tracking-tight">Settings</h4>
<div className="flex items-center justify-between gap-4 pb-4 border-b border-border"> <p className="text-sm text-muted-foreground">
<Label htmlFor="use-redesign" className="text-sm cursor-pointer font-medium text-primary"> Configure your calendar preferences.
Try New Design </p>
</Label>
<Switch
id="use-redesign"
checked={useRedesign}
onCheckedChange={setUseRedesign}
/>
</div> </div>
<div className="flex items-center justify-between gap-4"> <div className="space-y-4">
<Label htmlFor="partial-availability" className="text-sm cursor-pointer"> <div className="flex items-center justify-between gap-4 pb-4 border-b border-border">
Show partial availability <Label htmlFor="use-redesign" className="text-sm cursor-pointer font-medium text-primary">
</Label> Try New Design
<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
</Label> </Label>
<Switch <Switch
id="secondary-timezone" id="use-redesign"
checked={settings.showSecondaryTimezone} 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) => onCheckedChange={(checked) =>
setSettings((prev) => ({ ...prev, showSecondaryTimezone: checked })) setSettings((prev) => ({ ...prev, showPartialAvailability: checked }))
} }
/> />
</div> </div>
{settings.showSecondaryTimezone && (
<div className="mt-3"> <div className="border-t border-border pt-4 space-y-4">
<Label className="text-xs text-muted-foreground mb-2 block"> <div className="flex items-center justify-between gap-4">
Secondary timezone <Label htmlFor="secondary-timezone" className="text-sm cursor-pointer">
Show secondary timezone
</Label> </Label>
<TimezoneSelector <Switch
value={settings.secondaryTimezone} id="secondary-timezone"
onChange={(tz) => setSettings((prev) => ({ ...prev, secondaryTimezone: tz }))} checked={settings.showSecondaryTimezone}
onCheckedChange={(checked) =>
setSettings((prev) => ({ ...prev, showSecondaryTimezone: checked }))
}
/> />
</div> </div>
)} {settings.showSecondaryTimezone && (
<p className="text-xs text-muted-foreground mt-2"> <div className="space-y-2">
Display times in two timezones side by side. <Label className="text-xs text-muted-foreground block">
</p> Secondary timezone
</div> </Label>
<TimezoneSelector
value={settings.secondaryTimezone}
onChange={(tz) => setSettings((prev) => ({ ...prev, secondaryTimezone: tz }))}
/>
</div>
)}
</div>
<div className="border-t border-border pt-4"> <div className="border-t border-border pt-4 mt-8">
<AlertDialog> <Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3 block">
<AlertDialogTrigger asChild> Danger Zone
<Button </Label>
variant="destructive" <AlertDialog>
size="sm" <AlertDialogTrigger asChild>
className="w-full" <Button
> variant="outline"
Clear All Bookings size="sm"
</Button> className="text-destructive hover:text-destructive-foreground hover:bg-destructive border-destructive/30"
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear all bookings?</AlertDialogTitle>
<AlertDialogDescription>
This will remove all scheduled meetings from the system.
This action cannot be undone. Calendar invites already
sent will not be affected.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleClearBookings}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
Clear All Clear All Bookings
</AlertDialogAction> </Button>
</AlertDialogFooter> </AlertDialogTrigger>
</AlertDialogContent> <AlertDialogContent>
</AlertDialog> <AlertDialogHeader>
<p className="text-xs text-muted-foreground mt-2"> <AlertDialogTitle>Clear all bookings?</AlertDialogTitle>
Remove all scheduled meetings from the system. <AlertDialogDescription>
</p> 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>
</div> </div>
</PopoverContent> </SheetContent>
</Popover> </Sheet>
</div> </div>
<h2 className="text-3xl font-bold text-foreground mb-2"> <h2 className="text-3xl font-bold text-foreground mb-2">
Schedule a Meeting Schedule a Meeting
@@ -482,7 +491,7 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
</div> </div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</main> </main >
<ScheduleModal <ScheduleModal
isOpen={isModalOpen} isOpen={isModalOpen}
@@ -495,7 +504,7 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
displayTimezone={settings.displayTimezone} displayTimezone={settings.displayTimezone}
onSuccess={loadAvailability} onSuccess={loadAvailability}
/> />
</div> </div >
); );
}; };

View File

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