1 Commits

Author SHA1 Message Date
942a7d3c3a Merge pull request 'update' (#6) from implement-more-feeedback into main
Reviewed-on: #6
2026-02-05 18:48:36 +00:00
12 changed files with 392 additions and 1084 deletions

View File

@@ -4,7 +4,6 @@ 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
@@ -71,16 +70,13 @@ 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"]
slot_interval = settings.slot_interval_minutes hours = list(range(0, 24))
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 range(24): for hour in hours:
for slot_idx in range(slots_per_hour): slot_start = week_start + timedelta(days=day_offset, hours=hour)
minute = slot_idx * slot_interval slot_end = slot_start + timedelta(hours=1)
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:
@@ -102,7 +98,6 @@ async def calculate_availability(
slots.append({ slots.append({
"day": slot_start.strftime("%Y-%m-%d"), "day": slot_start.strftime("%Y-%m-%d"),
"hour": hour, "hour": hour,
"minute": minute,
"start_time": slot_start, "start_time": slot_start,
"availability": availability, "availability": availability,
"availableParticipants": available_participants, "availableParticipants": available_participants,

View File

@@ -5,7 +5,6 @@ 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,8 +61,7 @@ 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}\n" f"**Who:** {people}"
f"*Booked via [Meetly](https://meetly.app.monadical.io/)*"
) )
request = { request = {

View File

@@ -12,14 +12,7 @@ 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 SLOT_INTERVAL_MINUTES = 15; const hours = Array.from({ length: 24 }, (_, i) => i); // 0-23
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[] => {
@@ -53,11 +46,11 @@ const getWeekDates = (timezone: string, weekOffset: number = 0): string[] => {
}); });
}; };
// Convert a date string, hour, and minute in a timezone to a UTC Date // Convert a date string and hour in a timezone to a UTC Date
const toUTCDate = (dateStr: string, hour: number, minute: number, timezone: string): Date => { const toUTCDate = (dateStr: string, hour: number, timezone: string): Date => {
// Create a date string that represents the given time in the given timezone // Create a date string that represents the given hour 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')}:${String(minute).padStart(2, '0')}:00`; const localDateStr = `${dateStr}T${String(hour).padStart(2, '0')}:00: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
@@ -118,32 +111,29 @@ const getTimezoneAbbrev = (timezone: string): string => {
} }
}; };
// Convert a time from one timezone to another // Convert an hour from one timezone to another
const convertTimeBetweenTimezones = ( const convertHourBetweenTimezones = (
hour: number, hour: number,
minute: number,
dateStr: string, dateStr: string,
fromTimezone: string, fromTimezone: string,
toTimezone: string toTimezone: string
): { hour: number; minute: number } => { ): number => {
try { try {
// Create a UTC date for the given time in the source timezone // Create a UTC date for the given hour in the source timezone
const utcDate = toUTCDate(dateStr, hour, minute, fromTimezone); const utcDate = toUTCDate(dateStr, hour, fromTimezone);
// Format the time in the target timezone // Format the hour in the target timezone
const formatter = new Intl.DateTimeFormat('en-US', { const targetHour = parseInt(
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 { hour: targetHour, minute: targetMinute }; return targetHour;
} catch { } catch {
return { hour, minute }; return hour;
} }
}; };
@@ -226,24 +216,24 @@ export const AvailabilityHeatmap = ({
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (!scrollContainerRef.current) return; if (!scrollContainerRef.current) return;
const rowHeight = 28; // h-6 (24px) + gap (4px) const rowHeight = 52; // h-12 (48px) + gap (4px)
const rowsAbove = 4; // Show a few slots before current time const rowsAbove = 2;
// Calculate which slot should be at the top (multiply by slots per hour) // Calculate which hour should be at the top
const targetHour = todayIndex >= 0 const targetHour = todayIndex >= 0
? Math.max(0, currentHour - Math.floor(rowsAbove / SLOTS_PER_HOUR)) ? Math.max(0, currentHour - rowsAbove)
: 7; // Default to 7am for other weeks : 7; // Default to 7am for other weeks
scrollContainerRef.current.scrollTop = targetHour * SLOTS_PER_HOUR * rowHeight; scrollContainerRef.current.scrollTop = targetHour * 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/minute // Find a slot that matches the given display timezone date/hour
const getSlot = (dateStr: string, hour: number, minute: number): TimeSlot | undefined => { const getSlot = (dateStr: string, hour: number): TimeSlot | undefined => {
// Convert display timezone date/hour/minute to UTC // Convert display timezone date/hour to UTC
const targetUTC = toUTCDate(dateStr, hour, minute, displayTimezone); const targetUTC = toUTCDate(dateStr, hour, displayTimezone);
return slots.find((s) => { return slots.find((s) => {
const slotDate = new Date(s.start_time); const slotDate = new Date(s.start_time);
@@ -259,13 +249,13 @@ export const AvailabilityHeatmap = ({
return slot.availability; return slot.availability;
}; };
const formatTime = (hour: number, minute: number) => { const formatHour = (hour: number) => {
return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; return `${hour.toString().padStart(2, '0')}:00`;
}; };
const isSlotTooSoon = (dateStr: string, hour: number, minute: number) => { const isSlotTooSoon = (dateStr: string, hour: number) => {
// Convert to UTC and compare with current time // Convert to UTC and compare with current time
const slotTimeUTC = toUTCDate(dateStr, hour, minute, displayTimezone); const slotTimeUTC = toUTCDate(dateStr, hour, displayTimezone);
const now = new Date(); const now = new Date();
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000); const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
return slotTimeUTC < twoHoursFromNow; return slotTimeUTC < twoHoursFromNow;
@@ -281,11 +271,11 @@ export const AvailabilityHeatmap = ({
return `${format(monday)} ${format(friday)}`; return `${format(monday)} ${format(friday)}`;
}; };
// Format time for display in popover (in the display timezone) // Format hour for display in popover (in the display timezone)
const formatDisplayTime = (hour: number, minute: number) => { const formatDisplayTime = (hour: number) => {
// Create a date at that time // Create a date at that hour
const date = new Date(); const date = new Date();
date.setHours(hour, minute, 0, 0); date.setHours(hour, 0, 0, 0);
return new Intl.DateTimeFormat('en-US', { return new Intl.DateTimeFormat('en-US', {
hour: 'numeric', hour: 'numeric',
minute: '2-digit', minute: '2-digit',
@@ -440,10 +430,10 @@ export const AvailabilityHeatmap = ({
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{timeSlots.map(({ hour, minute }) => ( {hours.map((hour) => (
<div <div
key={`${hour}-${minute}`} key={hour}
ref={todayIndex >= 0 && hour === currentHour && minute === 0 ? currentHourRef : undefined} ref={todayIndex >= 0 && hour === currentHour ? currentHourRef : undefined}
className={cn( className={cn(
"grid gap-1", "grid gap-1",
showSecondaryTimezone showSecondaryTimezone
@@ -454,45 +444,37 @@ export const AvailabilityHeatmap = ({
{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 && minute === 0 && ( {todayIndex >= 0 && hour === currentHour && (
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" /> <span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
)} )}
{formatTime(hour, minute)} {formatHour(hour)}
</div> </div>
<div className="text-xs text-muted-foreground flex items-center justify-center"> <div className="text-xs text-muted-foreground flex items-center justify-center">
{(() => { {formatHour(convertHourBetweenTimezones(hour, weekDates[0] || '', displayTimezone, secondaryTimezone))}
const converted = convertTimeBetweenTimezones(hour, minute, weekDates[0] || '', displayTimezone, secondaryTimezone);
return formatTime(converted.hour, converted.minute);
})()}
</div> </div>
</> </>
) : ( ) : (
<div className="text-xs text-muted-foreground flex items-center justify-end pr-3 gap-1"> <div className="text-xs text-muted-foreground flex items-center justify-end pr-3 gap-1">
{todayIndex >= 0 && hour === currentHour && minute === 0 && ( {todayIndex >= 0 && hour === currentHour && (
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" /> <span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
)} )}
{formatTime(hour, minute)} {formatHour(hour)}
</div> </div>
)} )}
{weekDates.map((dateStr, dayIndex) => { {weekDates.map((dateStr, dayIndex) => {
const slot = getSlot(dateStr, hour, minute); const slot = getSlot(dateStr, hour);
const dayName = dayNames[dayIndex]; const dayName = dayNames[dayIndex];
const tooSoon = isSlotTooSoon(dateStr, hour, minute); const tooSoon = isSlotTooSoon(dateStr, hour);
if (!slot) return <div key={`${dateStr}-${hour}-${minute}`} className="h-6 bg-muted rounded" />; if (!slot) return <div key={`${dateStr}-${hour}`} className="h-12 bg-muted rounded" />;
const effectiveAvailability = getEffectiveAvailability(slot); const effectiveAvailability = getEffectiveAvailability(slot);
// Calculate end time for display
const endMinute = minute + SLOT_INTERVAL_MINUTES;
const endHour = hour + Math.floor(endMinute / 60);
const endMinuteNormalized = endMinute % 60;
return ( return (
<Popover key={`${dateStr}-${hour}-${minute}`}> <Popover key={`${dateStr}-${hour}`}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
className={cn( className={cn(
"h-6 rounded-md transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", "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 && "opacity-40 cursor-not-allowed",
!tooSoon && "hover:scale-105 hover:shadow-md", !tooSoon && "hover:scale-105 hover:shadow-md",
effectiveAvailability === 'full' && "bg-availability-full hover:bg-availability-full/90", effectiveAvailability === 'full' && "bg-availability-full hover:bg-availability-full/90",
@@ -504,7 +486,7 @@ export const AvailabilityHeatmap = ({
<PopoverContent className="w-64 p-4 animate-scale-in" align="center"> <PopoverContent className="w-64 p-4 animate-scale-in" align="center">
<div className="space-y-3"> <div className="space-y-3">
<div className="font-semibold text-foreground"> <div className="font-semibold text-foreground">
{dayName} {formatDisplayTime(hour, minute)}{formatDisplayTime(endHour, endMinuteNormalized)} {dayName} {formatDisplayTime(hour)}{formatDisplayTime(hour + 1)}
</div> </div>
{tooSoon && ( {tooSoon && (
<div className="text-sm text-muted-foreground italic"> <div className="text-sm text-muted-foreground italic">

View File

@@ -1,499 +0,0 @@
import { TimeSlot, Participant } from '@/types/calendar';
import { cn } from '@/lib/utils';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { useState, useMemo, useRef, useEffect } from 'react';
import { Check, X, Loader2, ChevronLeft, ChevronRight, ChevronsRight, Clock, Calendar as CalendarIcon, Sun, Moon, Users } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
const DEFAULT_TIMEZONE = 'America/Toronto';
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const 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
// 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) => {
const now = Date.now();
const twoHoursFromNow = now + 2 * 60 * 60 * 1000;
return slotDate < twoHoursFromNow;
};
// Reuse previous timezone helpers or simplify
const getWeekDates = (timezone: string, weekOffset: number = 0): Date[] => {
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const todayStr = formatter.format(now);
const [year, month, day] = todayStr.split('-').map(Number);
const todayDate = new Date(year, month - 1, day);
const dayOfWeek = todayDate.getDay();
// If Sunday (0), go back 6 days to Monday. If Mon (1), go back 0. If Sat (6), go back 5.
// Actually standard logic: Mon=1...Sun=7.
// Let's assume standard ISO week start (Mon)
const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const mondayDate = new Date(year, month - 1, day + daysToMonday + weekOffset * 7);
return Array.from({ length: 5 }, (_, i) => {
const d = new Date(mondayDate);
d.setDate(mondayDate.getDate() + i);
return d;
});
};
const formatTimezoneDisplay = (timezone: string): string => {
try {
const parts = timezone.split('/');
const city = parts[parts.length - 1].replace(/_/g, ' ');
return city;
} catch {
return timezone;
}
};
interface AvailabilityHeatmapV2Props {
slots: TimeSlot[];
selectedParticipants: Participant[];
onSlotSelect: (slot: TimeSlot) => void;
showPartialAvailability?: boolean;
isLoading?: boolean;
weekOffset?: number;
onWeekOffsetChange?: (offset: number) => void;
displayTimezone?: string;
showSecondaryTimezone?: boolean;
secondaryTimezone?: string;
}
export const AvailabilityHeatmapV2 = ({
slots,
selectedParticipants,
onSlotSelect,
showPartialAvailability = false,
isLoading = false,
weekOffset = 0,
onWeekOffsetChange,
displayTimezone = DEFAULT_TIMEZONE,
showSecondaryTimezone = false,
secondaryTimezone = DEFAULT_TIMEZONE,
}: AvailabilityHeatmapV2Props) => {
const [showFullDay, setShowFullDay] = useState(false);
const 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:Minute
const slotsMap = useMemo(() => {
const map = new Map<string, TimeSlot>();
slots.forEach(slot => {
const d = new Date(slot.start_time);
// Format to DisplayTZ to find coordinate
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: displayTimezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: 'numeric',
minute: 'numeric',
hour12: false
});
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 && 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}:${m}`;
map.set(key, slot);
}
});
return map;
}, [slots, displayTimezone]);
const formatDateKey = (date: Date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
};
const formatDisplayDate = (date: Date) => {
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
};
const getSlotForCell = (date: Date, hour: number, minute: number) => {
const key = `${formatDateKey(date)}:${hour}:${minute}`;
return slotsMap.get(key);
};
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">
<UsersPlaceholder />
<h3 className="text-xl font-semibold mt-4">No participants selected</h3>
<p className="text-muted-foreground text-center max-w-sm mt-2">
Select team members from the list above to compare calendars and find the perfect meeting time.
</p>
</div>
);
}
return (
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Controls Bar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-card p-4 rounded-xl border border-border shadow-sm">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-9 w-9"
onClick={() => onWeekOffsetChange?.(weekOffset - 1)}
disabled={!onWeekOffsetChange || weekOffset <= 0}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex flex-col items-center min-w-[140px]">
<span className="text-sm font-semibold">
{weekDates[0]?.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
{' - '}
{weekDates[4]?.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
<span className="text-xs text-muted-foreground">
{weekOffset === 0 ? "This Week" : weekOffset === 1 ? "Next Week" : `${weekOffset} weeks out`}
</span>
</div>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
onClick={() => onWeekOffsetChange?.(weekOffset + 1)}
disabled={!onWeekOffsetChange}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="h-8 w-px bg-border hidden sm:block" />
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-md">
<Clock className="w-4 h-4" />
{formatTimezoneDisplay(displayTimezone)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant={showFullDay ? "outline" : "secondary"}
size="sm"
onClick={() => setShowFullDay(!showFullDay)}
className="text-xs"
>
{showFullDay ? (
<>
<Sun className="w-3.5 h-3.5 mr-2" />
Show Work Hours
</>
) : (
<>
<Moon className="w-3.5 h-3.5 mr-2" />
Show Full Day
</>
)}
</Button>
</div>
</div>
{/* Main Grid Card */}
<div className="bg-card w-full overflow-hidden rounded-xl border border-border shadow-md">
{isLoading && (
<div className="absolute inset-0 bg-background/50 backdrop-blur-[1px] z-50 flex items-center justify-center">
<Loader2 className="w-10 h-10 animate-spin text-primary" />
</div>
)}
<div className="overflow-auto max-h-[600px] w-full relative">
<div className="min-w-[700px]">
{/* Grid Header */}
<div
className="grid sticky top-0 z-30 bg-card border-b border-border shadow-sm"
style={{ gridTemplateColumns: `${timeColWidth} repeat(5, 1fr)` }}
>
<div className="sticky left-0 z-40 bg-card text-xs font-semibold text-muted-foreground self-center p-3 text-right border-r border-border/50 flex flex-col items-end gap-1">
<span>{formatTimezoneDisplay(displayTimezone)}</span>
{showSecondaryTimezone && (
<span className="text-[10px] text-muted-foreground/60 font-normal">{formatTimezoneDisplay(secondaryTimezone)}</span>
)}
</div>
{weekDates.map(date => {
const isToday = new Date().toDateString() === date.toDateString();
return (
<div key={date.toISOString()} className={cn(
"text-center p-3 transition-colors border-r border-border/30 last:border-0",
isToday ? "bg-primary/5 text-primary font-bold" : "text-foreground"
)}>
<div className="text-sm">{date.toLocaleDateString('en-US', { weekday: 'short' })}</div>
<div className={cn("text-2xl", isToday ? "font-bold" : "font-light")}>
{date.getDate()}
</div>
</div>
);
})}
</div>
{/* Grid Body */}
<div className="relative">
{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}-${minute}`}
className={cn(
"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-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"
)}>
<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>
)}
</div>
{/* Days */}
{weekDates.map(date => {
const slot = getSlotForCell(date, hour, minute);
const tooSoon = slot ? isSlotTooSoon(new Date(slot.start_time).getTime()) : true;
// Availability logic
const availability = slot?.availability || 'none';
const isNone = availability === 'none';
const isPartial = availability === 'partial' && showPartialAvailability;
const isFull = availability === 'full';
const isPartialHidden = availability === 'partial' && !showPartialAvailability;
// Styling
let bgClass = ""; // Default transparent
if (!slot) {
bgClass = "bg-muted/10 pattern-diagonal-lines opacity-50";
} else if (tooSoon) {
bgClass = "bg-muted/20 pattern-diagonal-lines cursor-not-allowed border border-border/10";
} else if (isFull) {
bgClass = "bg-emerald-500/90 hover:bg-emerald-600 shadow-sm";
} else if (isPartial) {
bgClass = "bg-amber-400/80 hover:bg-amber-500 shadow-sm";
} else if (isNone || isPartialHidden) {
bgClass = "bg-muted/50 hover:bg-muted/70 border border-border/20";
}
return (
<div key={`${date.toISOString()}-${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 ? "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-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={hour >= 12 ? "end" : "start"}>
<div className="p-4 bg-muted/30 border-b border-border/50">
<h4 className="font-semibold text-base flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
{formatDisplayDate(date)}
</h4>
<div className="text-sm text-muted-foreground mt-1 flex items-center gap-2">
<Clock className="w-4 h-4" />
{formatTime(hour, minute)} - {formatTime(endHour, endMinuteNormalized)}
</div>
</div>
<div className="p-4 space-y-3 max-h-[300px] overflow-y-auto">
<div className="space-y-2">
{selectedParticipants.map(participant => {
const isAvailable = slot.availableParticipants.includes(participant.name);
return (
<div key={participant.id} className="flex items-center justify-between text-sm p-2 rounded-lg hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3">
<div className={cn(
"w-2 h-2 rounded-full",
isAvailable ? "bg-emerald-500" : "bg-destructive"
)} />
<span className={cn(!isAvailable && "text-muted-foreground")}>
{participant.name}
</span>
</div>
{isAvailable ? (
<Check className="w-4 h-4 text-emerald-500" />
) : (
<span className="text-xs text-muted-foreground">Busy</span>
)}
</div>
);
})}
</div>
{(!isNone && !tooSoon) && (
<Button
className="w-full mt-4 bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={() => onSlotSelect(slot)}
>
Schedule Meeting
</Button>
)}
</div>
</PopoverContent>
</Popover>
) : (
// Placeholder for missing slots
<div className="w-full h-full rounded-md bg-muted/5 border border-dashed border-border/30"></div>
)}
</div>
);
})}
</div>
);
})}
</div>
</div>
</div>
{/* Legend */}
<div className="flex flex-wrap items-center justify-center bg-muted/20 border-t p-3 gap-6 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-emerald-500 shadow-sm"></div>
<span className="font-medium text-foreground">All Available</span>
</div>
{showPartialAvailability && (
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-amber-400 shadow-sm"></div>
<span className="font-medium text-foreground">Partial Match</span>
</div>
)}
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-muted/50 border border-border/20"></div>
<span className="text-muted-foreground">Busy / No Match</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-muted/20 pattern-diagonal-lines border border-border/10"></div>
<span className="text-muted-foreground">Past / Too Soon</span>
</div>
</div>
</div>
</div>
);
};
function UsersPlaceholder() {
return (
<div className="relative w-16 h-16 mb-2">
<div className="absolute top-0 left-0 w-10 h-10 rounded-full bg-muted border-2 border-background flex items-center justify-center">
<Users className="w-5 h-5 text-muted-foreground" />
</div>
<div className="absolute bottom-0 right-0 w-10 h-10 rounded-full bg-muted border-2 border-background flex items-center justify-center">
<Users className="w-5 h-5 text-muted-foreground" />
</div>
</div>
);
}
// Importing Users icon since it was missing in imports

View File

@@ -1,27 +1,7 @@
import { Calendar, Moon, Sun } from 'lucide-react'; import { Calendar } from 'lucide-react';
import { getAvatarColor } from '@/lib/utils'; import { getAvatarColor } from '@/lib/utils';
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
export const Header = () => { export const Header = () => {
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('theme') === 'dark' ? 'dark' : 'light';
}
return 'light';
});
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(theme);
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return ( return (
<header className="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-50"> <header className="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-50">
<div className="container max-w-5xl mx-auto px-4 py-4 flex items-center justify-between"> <div className="container max-w-5xl mx-auto px-4 py-4 flex items-center justify-between">
@@ -34,15 +14,7 @@ export const Header = () => {
<p className="text-xs text-muted-foreground">Calendar Coordination</p> <p className="text-xs text-muted-foreground">Calendar Coordination</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={toggleTheme} className="rounded-full">
{theme === 'light' ? (
<Sun className="h-5 w-5 text-amber-500" />
) : (
<Moon className="h-5 w-5 text-slate-400" />
)}
<span className="sr-only">Toggle theme</span>
</Button>
<div <div
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium text-white" className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium text-white"
style={{ backgroundColor: getAvatarColor("AR") }} style={{ backgroundColor: getAvatarColor("AR") }}

View File

@@ -94,46 +94,11 @@ export const ParticipantSelector = ({
return ( return (
<div ref={containerRef} className="space-y-4"> <div ref={containerRef} className="space-y-4">
<div <div className="relative">
className={cn( <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
"relative min-h-[48px] bg-background border border-border rounded-lg shadow-sm flex flex-wrap items-center gap-2 px-3 py-1.5 transition-all",
"focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary"
)}
onClick={() => inputRef.current?.focus()}
>
<Search className="w-4 h-4 text-muted-foreground shrink-0 mr-1" />
{selectedParticipants.map((participant) => (
<div
key={participant.id}
className={cn(
"flex items-center gap-1.5 bg-accent text-accent-foreground pl-2 pr-1 py-1 rounded-full text-xs font-medium animate-scale-in",
"border border-primary/10 group hover:border-destructive/30 hover:bg-destructive/10 hover:text-destructive transition-colors cursor-default"
)}
onClick={(e) => e.stopPropagation()}
>
<div
className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold text-white shrink-0"
style={{ backgroundColor: getAvatarColor(participant.name) }}
>
{getInitials(participant.name)}
</div>
<span className="max-w-[100px] truncate">{participant.name.split(' ')[0]}</span>
{!participant.icsLink && (
<AlertCircle className="w-3 h-3 text-amber-600 shrink-0" title="No calendar linked" />
)}
<button
onClick={() => removeParticipant(participant.id)}
className="w-4 h-4 rounded-full hover:bg-black/10 dark:hover:bg-white/10 flex items-center justify-center transition-colors shrink-0"
>
<X className="w-2.5 h-2.5" />
</button>
</div>
))}
<Input <Input
ref={inputRef} ref={inputRef}
placeholder={selectedParticipants.length === 0 ? "Search people..." : "Add more..."} placeholder="Search people..."
value={searchQuery} value={searchQuery}
onChange={(e) => { onChange={(e) => {
setSearchQuery(e.target.value); setSearchQuery(e.target.value);
@@ -142,25 +107,11 @@ export const ParticipantSelector = ({
}} }}
onFocus={() => setIsDropdownOpen(true)} onFocus={() => setIsDropdownOpen(true)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className="flex-1 min-w-[100px] h-7 border-none shadow-none focus-visible:ring-0 p-0 text-sm bg-transparent placeholder:text-muted-foreground/70" className="pl-10 h-12 bg-background border-border"
/> />
{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 z-10 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) => (
<button <button
key={participant.id} key={participant.id}
@@ -208,6 +159,45 @@ export const ParticipantSelector = ({
</div> </div>
)} )}
</div> </div>
{selectedParticipants.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedParticipants.map((participant, index) => (
<div
key={participant.id}
className={cn(
"flex items-center gap-2 bg-accent text-accent-foreground px-3 py-2 rounded-full text-sm animate-scale-in",
"border border-primary/20"
)}
style={{ animationDelay: `${index * 50}ms` }}
>
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium text-white"
style={{ backgroundColor: getAvatarColor(participant.name) }}
>
{getInitials(participant.name)}
</div>
<span className="font-medium">{participant.name.split(' ')[0]}</span>
{!participant.icsLink && (
<AlertCircle className="w-3 h-3 text-amber-600" title="No calendar linked" />
)}
<button
onClick={() => removeParticipant(participant.id)}
className="w-5 h-5 rounded-full hover:bg-primary/20 flex items-center justify-center transition-colors"
>
<X className="w-3 h-3" />
</button>
</div>
))}
<button
onClick={() => setIsDropdownOpen(true)}
className="flex items-center gap-1 px-3 py-2 rounded-full text-sm border-2 border-dashed border-border text-muted-foreground hover:border-primary hover:text-primary transition-colors"
>
<Plus className="w-4 h-4" />
<span>Add</span>
</button>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -11,12 +11,11 @@ interface TimezoneSelectorProps {
// Get all IANA timezones // Get all IANA timezones
const getAllTimezones = (): string[] => { const getAllTimezones = (): string[] => {
let timezones: string[] = [];
try { try {
timezones = Intl.supportedValuesOf('timeZone'); return Intl.supportedValuesOf('timeZone');
} catch { } catch {
// Fallback for older browsers // Fallback for older browsers
timezones = [ return [
'UTC', 'UTC',
'America/New_York', 'America/New_York',
'America/Chicago', 'America/Chicago',
@@ -24,7 +23,6 @@ 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',
@@ -35,17 +33,6 @@ 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
@@ -171,7 +158,7 @@ export const TimezoneSelector = ({
No timezones found No timezones found
</div> </div>
) : ( ) : (
filteredTimezones.map((timezone) => { filteredTimezones.slice(0, 50).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);
@@ -217,6 +204,12 @@ 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 React from "react" import * as SheetPrimitive from "@radix-ui/react-dialog";
import * as SheetPrimitive from "@radix-ui/react-dialog" import { cva, type VariantProps } from "class-variance-authority";
import { cva, type VariantProps } from "class-variance-authority" import { X } from "lucide-react";
import { X } from "lucide-react" import * as React from "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>,
@@ -20,13 +20,13 @@ const SheetOverlay = React.forwardRef<
<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",
@@ -44,95 +44,64 @@ const sheetVariants = cva(
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< const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
React.ElementRef<typeof SheetPrimitive.Content>, ({ side = "right", className, children, ...props }, ref) => (
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal> <SheetPortal>
<SheetOverlay /> <SheetOverlay />
<SheetPrimitive.Content <SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children} {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"> <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" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
)) ),
SheetContent.displayName = SheetPrimitive.Content.displayName );
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
className, <div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
...props );
}: React.HTMLAttributes<HTMLDivElement>) => ( SheetHeader.displayName = "SheetHeader";
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({ const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
className, <div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
...props );
}: React.HTMLAttributes<HTMLDivElement>) => ( SheetFooter.displayName = "SheetFooter";
<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 <SheetPrimitive.Title ref={ref} className={cn("text-lg font-semibold text-foreground", className)} {...props} />
ref={ref} ));
className={cn("text-lg font-semibold text-foreground", className)} SheetTitle.displayName = SheetPrimitive.Title.displayName;
{...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 <SheetPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
ref={ref} ));
className={cn("text-sm text-muted-foreground", className)} SheetDescription.displayName = SheetPrimitive.Description.displayName;
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export { export {
Sheet, Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose, SheetClose,
SheetContent, SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription, SheetDescription,
} SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
};

View File

@@ -111,50 +111,9 @@
--sidebar-ring: 18 68% 51%; --sidebar-ring: 18 68% 51%;
} }
/* Dark mode */ /* Dark mode intentionally removed/reset to match light mode system for now,
.dark { or you can define a proper dark mode if required.
--background: 60 5% 8%; /* #161614 */ Keeping it simple as per previous apps. */
--foreground: 60 9% 93%; /* #F0F0EC */
--card: 60 5% 10%; /* Slightly lighter than bg */
--card-foreground: 60 9% 93%;
--popover: 60 5% 10%;
--popover-foreground: 60 9% 93%;
--primary: 18 68% 51%; /* Keep primary brand color */
--primary-foreground: 60 9% 97%;
--secondary: 60 5% 15%;
--secondary-foreground: 60 9% 93%;
--muted: 60 5% 15%;
--muted-foreground: 60 5% 65%;
--accent: 60 5% 15%;
--accent-foreground: 60 9% 93%;
--destructive: 0 62% 30%;
--destructive-foreground: 60 9% 97%;
--border: 60 5% 20%;
--input: 60 5% 20%;
--ring: 18 68% 51%;
/* Dark mode availability colors */
--availability-full: 142 70% 45%; /* Brighter green for dark mode */
--availability-partial: 25 80% 65%; /* Same orange */
--availability-none: 60 5% 20%; /* Darker grey */
--sidebar-background: 60 5% 8%;
--sidebar-foreground: 60 9% 93%;
--sidebar-primary: 18 68% 51%;
--sidebar-primary-foreground: 60 9% 97%;
--sidebar-accent: 60 5% 15%;
--sidebar-accent-foreground: 60 9% 93%;
--sidebar-border: 60 5% 20%;
--sidebar-ring: 18 68% 51%;
}
} }
@layer base { @layer base {
@@ -233,15 +192,3 @@
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

@@ -4,7 +4,6 @@ import { Header } from '@/components/Header';
import { ParticipantSelector } from '@/components/ParticipantSelector'; import { ParticipantSelector } from '@/components/ParticipantSelector';
import { ParticipantManager } from '@/components/ParticipantManager'; import { ParticipantManager } from '@/components/ParticipantManager';
import { AvailabilityHeatmap } from '@/components/AvailabilityHeatmap'; import { AvailabilityHeatmap } from '@/components/AvailabilityHeatmap';
import { AvailabilityHeatmapV2 } from '@/components/AvailabilityHeatmapV2';
import { ScheduleModal } from '@/components/ScheduleModal'; import { ScheduleModal } from '@/components/ScheduleModal';
import { TimezoneSelector } from '@/components/TimezoneSelector'; import { TimezoneSelector } from '@/components/TimezoneSelector';
import { Participant, TimeSlot } from '@/types/calendar'; import { Participant, TimeSlot } from '@/types/calendar';
@@ -16,11 +15,6 @@ 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,
@@ -67,8 +61,8 @@ interface SettingsState {
const defaultSettings: SettingsState = { const defaultSettings: SettingsState = {
showPartialAvailability: false, showPartialAvailability: false,
displayTimezone: getUserTimezone(), displayTimezone: getUserTimezone(),
showSecondaryTimezone: true, showSecondaryTimezone: false,
secondaryTimezone: 'America/Montreal', // Company timezone as default secondary secondaryTimezone: 'America/Toronto', // Company timezone as default secondary
}; };
function apiToParticipant(p: ParticipantAPI): Participant { function apiToParticipant(p: ParticipantAPI): Participant {
@@ -95,7 +89,6 @@ 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(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);
@@ -321,42 +314,19 @@ 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>
<Sheet> <Popover>
<SheetTrigger asChild> <PopoverTrigger 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>
</SheetTrigger> </PopoverTrigger>
<SheetContent side="right" className="w-[300px] sm:w-[350px]"> <PopoverContent className="w-80" align="end">
<div className="space-y-6 py-4">
<div className="space-y-2">
<h4 className="font-semibold text-lg tracking-tight">Settings</h4>
<p className="text-sm text-muted-foreground">
Configure your calendar preferences.
</p>
</div>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between gap-4 pb-4 border-b border-border"> <h4 className="font-medium">Settings</h4>
<Label htmlFor="use-redesign" className="text-sm cursor-pointer font-medium text-primary">
Try New Design
</Label>
<Switch
id="use-redesign"
checked={useRedesign}
onCheckedChange={setUseRedesign}
/>
</div>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="space-y-0.5"> <Label htmlFor="partial-availability" className="text-sm cursor-pointer">
<Label htmlFor="partial-availability" className="text-sm font-medium cursor-pointer"> Show partial availability
Partial Availability
</Label> </Label>
<p className="text-xs text-muted-foreground">
Show slots where some are busy
</p>
</div>
<Switch <Switch
id="partial-availability" id="partial-availability"
checked={settings.showPartialAvailability} checked={settings.showPartialAvailability}
@@ -365,8 +335,11 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
} }
/> />
</div> </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 space-y-4"> <div className="border-t border-border pt-4">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<Label htmlFor="secondary-timezone" className="text-sm cursor-pointer"> <Label htmlFor="secondary-timezone" className="text-sm cursor-pointer">
Show secondary timezone Show secondary timezone
@@ -380,8 +353,8 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
/> />
</div> </div>
{settings.showSecondaryTimezone && ( {settings.showSecondaryTimezone && (
<div className="space-y-2"> <div className="mt-3">
<Label className="text-xs text-muted-foreground block"> <Label className="text-xs text-muted-foreground mb-2 block">
Secondary timezone Secondary timezone
</Label> </Label>
<TimezoneSelector <TimezoneSelector
@@ -390,18 +363,18 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
/> />
</div> </div>
)} )}
<p className="text-xs text-muted-foreground mt-2">
Display times in two timezones side by side.
</p>
</div> </div>
<div className="border-t border-border pt-4 mt-8"> <div className="border-t border-border pt-4">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3 block">
Danger Zone
</Label>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
variant="outline" variant="destructive"
size="sm" size="sm"
className="text-destructive hover:text-destructive-foreground hover:bg-destructive border-destructive/30" className="w-full"
> >
Clear All Bookings Clear All Bookings
</Button> </Button>
@@ -410,7 +383,9 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Clear all bookings?</AlertDialogTitle> <AlertDialogTitle>Clear all bookings?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This will remove all scheduled meetings. This cannot be undone. This will remove all scheduled meetings from the system.
This action cannot be undone. Calendar invites already
sent will not be affected.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@@ -424,11 +399,13 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<p className="text-xs text-muted-foreground mt-2">
Remove all scheduled meetings from the system.
</p>
</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
@@ -459,20 +436,6 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
/> />
</div> </div>
{useRedesign ? (
<AvailabilityHeatmapV2
slots={availabilitySlots}
selectedParticipants={selectedParticipants}
onSlotSelect={handleSlotSelect}
showPartialAvailability={settings.showPartialAvailability}
isLoading={isLoading}
weekOffset={weekOffset}
onWeekOffsetChange={setWeekOffset}
displayTimezone={settings.displayTimezone}
showSecondaryTimezone={settings.showSecondaryTimezone}
secondaryTimezone={settings.secondaryTimezone}
/>
) : (
<AvailabilityHeatmap <AvailabilityHeatmap
slots={availabilitySlots} slots={availabilitySlots}
selectedParticipants={selectedParticipants} selectedParticipants={selectedParticipants}
@@ -485,7 +448,6 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
showSecondaryTimezone={settings.showSecondaryTimezone} showSecondaryTimezone={settings.showSecondaryTimezone}
secondaryTimezone={settings.secondaryTimezone} secondaryTimezone={settings.secondaryTimezone}
/> />
)}
</> </>
)} )}
</div> </div>

View File

@@ -11,7 +11,6 @@ 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[];