From 8ea2987e3e65e0e94a1801d386337d5cb538b0d0 Mon Sep 17 00:00:00 2001 From: Joyce <26967919+Joyce-O@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:36:40 -0500 Subject: [PATCH] update --- backend/src/app/availability_service.py | 55 +-- backend/src/app/config.py | 1 + backend/src/app/zulip_service.py | 3 +- .../src/components/AvailabilityHeatmap.tsx | 342 +++++++++--------- .../src/components/AvailabilityHeatmapV2.tsx | 89 +++-- frontend/src/types/calendar.ts | 1 + 6 files changed, 271 insertions(+), 220 deletions(-) diff --git a/backend/src/app/availability_service.py b/backend/src/app/availability_service.py index 6f9981d..d0718e7 100644 --- a/backend/src/app/availability_service.py +++ b/backend/src/app/availability_service.py @@ -4,6 +4,7 @@ from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings from app.models import BusyBlock, Participant @@ -70,37 +71,41 @@ async def calculate_availability( participants = {p.id: p for p in participants_result.scalars().all()} days = ["Mon", "Tue", "Wed", "Thu", "Fri"] - hours = list(range(0, 24)) + slot_interval = settings.slot_interval_minutes + slots_per_hour = 60 // slot_interval slots = [] for day_offset, day_name in enumerate(days): - for hour in hours: - slot_start = week_start + timedelta(days=day_offset, hours=hour) - slot_end = slot_start + timedelta(hours=1) + for hour in range(24): + for slot_idx in range(slots_per_hour): + minute = slot_idx * slot_interval + slot_start = week_start + timedelta(days=day_offset, hours=hour, minutes=minute) + slot_end = slot_start + timedelta(minutes=slot_interval) - available_participants = [] - for pid in participant_ids: - if is_participant_free(busy_map.get(pid, []), slot_start, slot_end): - participant = participants.get(pid) - if participant: - available_participants.append(participant.name) + available_participants = [] + for pid in participant_ids: + if is_participant_free(busy_map.get(pid, []), slot_start, slot_end): + participant = participants.get(pid) + if participant: + available_participants.append(participant.name) - total = len(participant_ids) - available_count = len(available_participants) + total = len(participant_ids) + available_count = len(available_participants) - if available_count == total: - availability = "full" - elif available_count > 0: - availability = "partial" - else: - availability = "none" + if available_count == total: + availability = "full" + elif available_count > 0: + availability = "partial" + else: + availability = "none" - slots.append({ - "day": slot_start.strftime("%Y-%m-%d"), - "hour": hour, - "start_time": slot_start, - "availability": availability, - "availableParticipants": available_participants, - }) + slots.append({ + "day": slot_start.strftime("%Y-%m-%d"), + "hour": hour, + "minute": minute, + "start_time": slot_start, + "availability": availability, + "availableParticipants": available_participants, + }) return slots diff --git a/backend/src/app/config.py b/backend/src/app/config.py index 6dfd716..fb49ebe 100644 --- a/backend/src/app/config.py +++ b/backend/src/app/config.py @@ -5,6 +5,7 @@ class Settings(BaseSettings): database_url: str = "postgresql+asyncpg://postgres:postgres@db:5432/availability" sync_database_url: str = "postgresql://postgres:postgres@db:5432/availability" ics_refresh_interval_minutes: int = 15 + slot_interval_minutes: int = 15 # Time slot granularity (15, 30, or 60) # SMTP Settings smtp_host: str | None = None diff --git a/backend/src/app/zulip_service.py b/backend/src/app/zulip_service.py index 6ced82d..280f45d 100644 --- a/backend/src/app/zulip_service.py +++ b/backend/src/app/zulip_service.py @@ -61,7 +61,8 @@ def send_zulip_notification( f"📅 **Meeting Scheduled**\n" f"**What:** {title}\n" f"**When:** {zulip_time}\n" - f"**Who:** {people}" + f"**Who:** {people}\n" + f"*Booked via [Meetly](https://meetly.app.monadical.io/)*" ) request = { diff --git a/frontend/src/components/AvailabilityHeatmap.tsx b/frontend/src/components/AvailabilityHeatmap.tsx index 27b733a..f7ebd32 100644 --- a/frontend/src/components/AvailabilityHeatmap.tsx +++ b/frontend/src/components/AvailabilityHeatmap.tsx @@ -12,7 +12,14 @@ import { Check, X, Loader2, ChevronLeft, ChevronRight, ChevronsRight } from 'luc const DEFAULT_TIMEZONE = 'America/Toronto'; const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']; -const hours = Array.from({ length: 24 }, (_, i) => i); // 0-23 +const SLOT_INTERVAL_MINUTES = 15; +const SLOTS_PER_HOUR = 60 / SLOT_INTERVAL_MINUTES; + +// Generate time slots: [{hour: 0, minute: 0}, {hour: 0, minute: 30}, {hour: 1, minute: 0}, ...] +const timeSlots = Array.from({ length: 24 * SLOTS_PER_HOUR }, (_, i) => ({ + hour: Math.floor(i / SLOTS_PER_HOUR), + minute: (i % SLOTS_PER_HOUR) * SLOT_INTERVAL_MINUTES, +})); // Get the dates for Mon-Fri of a week in a specific timezone, offset by N weeks const getWeekDates = (timezone: string, weekOffset: number = 0): string[] => { @@ -46,11 +53,11 @@ const getWeekDates = (timezone: string, weekOffset: number = 0): string[] => { }); }; -// Convert a date string and hour in a timezone to a UTC Date -const toUTCDate = (dateStr: string, hour: number, timezone: string): Date => { - // Create a date string that represents the given hour in the given timezone +// Convert a date string, hour, and minute in a timezone to a UTC Date +const toUTCDate = (dateStr: string, hour: number, minute: number, timezone: string): Date => { + // Create a date string that represents the given time in the given timezone // Then parse it to get the UTC equivalent - const localDateStr = `${dateStr}T${String(hour).padStart(2, '0')}:00:00`; + const localDateStr = `${dateStr}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00`; // Use a trick: format in UTC then in target TZ to find the offset const testDate = new Date(localDateStr + 'Z'); // Treat as UTC first @@ -111,29 +118,32 @@ const getTimezoneAbbrev = (timezone: string): string => { } }; -// Convert an hour from one timezone to another -const convertHourBetweenTimezones = ( +// Convert a time from one timezone to another +const convertTimeBetweenTimezones = ( hour: number, + minute: number, dateStr: string, fromTimezone: string, toTimezone: string -): number => { +): { hour: number; minute: number } => { try { - // Create a UTC date for the given hour in the source timezone - const utcDate = toUTCDate(dateStr, hour, fromTimezone); + // Create a UTC date for the given time in the source timezone + const utcDate = toUTCDate(dateStr, hour, minute, fromTimezone); - // Format the hour in the target timezone - const targetHour = parseInt( - new Intl.DateTimeFormat('en-US', { - timeZone: toTimezone, - hour: 'numeric', - hour12: false, - }).format(utcDate) - ); + // Format the time in the target timezone + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: toTimezone, + hour: 'numeric', + minute: 'numeric', + hour12: false, + }); + const parts = formatter.formatToParts(utcDate); + const targetHour = parseInt(parts.find(p => p.type === 'hour')?.value || '0'); + const targetMinute = parseInt(parts.find(p => p.type === 'minute')?.value || '0'); - return targetHour; + return { hour: targetHour, minute: targetMinute }; } catch { - return hour; + return { hour, minute }; } }; @@ -216,24 +226,24 @@ export const AvailabilityHeatmap = ({ const timer = setTimeout(() => { if (!scrollContainerRef.current) return; - const rowHeight = 52; // h-12 (48px) + gap (4px) - const rowsAbove = 2; + const rowHeight = 28; // h-6 (24px) + gap (4px) + const rowsAbove = 4; // Show a few slots before current time - // Calculate which hour should be at the top + // Calculate which slot should be at the top (multiply by slots per hour) const targetHour = todayIndex >= 0 - ? Math.max(0, currentHour - rowsAbove) + ? Math.max(0, currentHour - Math.floor(rowsAbove / SLOTS_PER_HOUR)) : 7; // Default to 7am for other weeks - scrollContainerRef.current.scrollTop = targetHour * rowHeight; + scrollContainerRef.current.scrollTop = targetHour * SLOTS_PER_HOUR * rowHeight; }, 100); return () => clearTimeout(timer); }, [weekOffset, todayIndex, currentHour, selectedParticipants.length, isLoading, slots.length]); - // Find a slot that matches the given display timezone date/hour - const getSlot = (dateStr: string, hour: number): TimeSlot | undefined => { - // Convert display timezone date/hour to UTC - const targetUTC = toUTCDate(dateStr, hour, displayTimezone); + // Find a slot that matches the given display timezone date/hour/minute + const getSlot = (dateStr: string, hour: number, minute: number): TimeSlot | undefined => { + // Convert display timezone date/hour/minute to UTC + const targetUTC = toUTCDate(dateStr, hour, minute, displayTimezone); return slots.find((s) => { const slotDate = new Date(s.start_time); @@ -249,13 +259,13 @@ export const AvailabilityHeatmap = ({ return slot.availability; }; - const formatHour = (hour: number) => { - return `${hour.toString().padStart(2, '0')}:00`; + const formatTime = (hour: number, minute: number) => { + return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; }; - const isSlotTooSoon = (dateStr: string, hour: number) => { + const isSlotTooSoon = (dateStr: string, hour: number, minute: number) => { // Convert to UTC and compare with current time - const slotTimeUTC = toUTCDate(dateStr, hour, displayTimezone); + const slotTimeUTC = toUTCDate(dateStr, hour, minute, displayTimezone); const now = new Date(); const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000); return slotTimeUTC < twoHoursFromNow; @@ -271,11 +281,11 @@ export const AvailabilityHeatmap = ({ return `${format(monday)} – ${format(friday)}`; }; - // Format hour for display in popover (in the display timezone) - const formatDisplayTime = (hour: number) => { - // Create a date at that hour + // Format time for display in popover (in the display timezone) + const formatDisplayTime = (hour: number, minute: number) => { + // Create a date at that time const date = new Date(); - date.setHours(hour, 0, 0, 0); + date.setHours(hour, minute, 0, 0); return new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit', @@ -398,144 +408,152 @@ export const AvailabilityHeatmap = ({ onScroll={handleScroll} >
-
- {showSecondaryTimezone ? ( - <> -
- {getTimezoneAbbrev(displayTimezone)} +
+ {showSecondaryTimezone ? ( + <> +
+ {getTimezoneAbbrev(displayTimezone)} +
+
+ {getTimezoneAbbrev(secondaryTimezone)} +
+ + ) : ( +
+ )} + {dayNames.map((dayName, i) => ( +
+
{dayName}
+
+ {weekDates[i]?.slice(5).replace('-', '/')} +
-
- {getTimezoneAbbrev(secondaryTimezone)} -
- - ) : ( -
- )} - {dayNames.map((dayName, i) => ( -
-
{dayName}
-
- {weekDates[i]?.slice(5).replace('-', '/')} -
-
- ))} -
+ ))} +
-
- {hours.map((hour) => ( -
= 0 && hour === currentHour ? currentHourRef : undefined} - className={cn( - "grid gap-1", - showSecondaryTimezone - ? "grid-cols-[50px_50px_repeat(5,1fr)]" - : "grid-cols-[60px_repeat(5,1fr)]" - )} - > - {showSecondaryTimezone ? ( - <> -
- {todayIndex >= 0 && hour === currentHour && ( +
+ {timeSlots.map(({ hour, minute }) => ( +
= 0 && hour === currentHour && minute === 0 ? currentHourRef : undefined} + className={cn( + "grid gap-1", + showSecondaryTimezone + ? "grid-cols-[50px_50px_repeat(5,1fr)]" + : "grid-cols-[60px_repeat(5,1fr)]" + )} + > + {showSecondaryTimezone ? ( + <> +
+ {todayIndex >= 0 && hour === currentHour && minute === 0 && ( + + )} + {formatTime(hour, minute)} +
+
+ {(() => { + const converted = convertTimeBetweenTimezones(hour, minute, weekDates[0] || '', displayTimezone, secondaryTimezone); + return formatTime(converted.hour, converted.minute); + })()} +
+ + ) : ( +
+ {todayIndex >= 0 && hour === currentHour && minute === 0 && ( )} - {formatHour(hour)} + {formatTime(hour, minute)}
-
- {formatHour(convertHourBetweenTimezones(hour, weekDates[0] || '', displayTimezone, secondaryTimezone))} -
- - ) : ( -
- {todayIndex >= 0 && hour === currentHour && ( - - )} - {formatHour(hour)} -
- )} - {weekDates.map((dateStr, dayIndex) => { - const slot = getSlot(dateStr, hour); - const dayName = dayNames[dayIndex]; - const tooSoon = isSlotTooSoon(dateStr, hour); - if (!slot) return
; + )} + {weekDates.map((dateStr, dayIndex) => { + const slot = getSlot(dateStr, hour, minute); + const dayName = dayNames[dayIndex]; + const tooSoon = isSlotTooSoon(dateStr, hour, minute); + if (!slot) return
; - const effectiveAvailability = getEffectiveAvailability(slot); + const effectiveAvailability = getEffectiveAvailability(slot); - return ( - - - + )}
- {effectiveAvailability !== 'none' && !tooSoon && ( - - )} -
- - - ); - })} -
- ))} + + + ); + })} +
+ ))} +
-
diff --git a/frontend/src/components/AvailabilityHeatmapV2.tsx b/frontend/src/components/AvailabilityHeatmapV2.tsx index 53dc6b9..96c96c8 100644 --- a/frontend/src/components/AvailabilityHeatmapV2.tsx +++ b/frontend/src/components/AvailabilityHeatmapV2.tsx @@ -12,10 +12,19 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp const DEFAULT_TIMEZONE = 'America/Toronto'; const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']; +const SLOT_INTERVAL_MINUTES = 15; +const SLOTS_PER_HOUR = 60 / SLOT_INTERVAL_MINUTES; const WORKING_HOUR_START = 8; // 8 AM const WORKING_HOUR_END = 18; // 6 PM -const ALL_HOURS = Array.from({ length: 24 }, (_, i) => i); -const WORKING_HOURS = Array.from({ length: WORKING_HOUR_END - WORKING_HOUR_START + 1 }, (_, i) => i + WORKING_HOUR_START); + +// Generate time slots with hour and minute +const ALL_TIME_SLOTS = Array.from({ length: 24 * SLOTS_PER_HOUR }, (_, i) => ({ + hour: Math.floor(i / SLOTS_PER_HOUR), + minute: (i % SLOTS_PER_HOUR) * SLOT_INTERVAL_MINUTES, +})); +const WORKING_TIME_SLOTS = ALL_TIME_SLOTS.filter( + slot => slot.hour >= WORKING_HOUR_START && slot.hour < WORKING_HOUR_END +); // Helper to check if a slot is in the past or too close (2h buffer) const isSlotTooSoon = (slotDate: number) => { @@ -88,21 +97,15 @@ export const AvailabilityHeatmapV2 = ({ secondaryTimezone = DEFAULT_TIMEZONE, }: AvailabilityHeatmapV2Props) => { const [showFullDay, setShowFullDay] = useState(false); - const activeHours = showFullDay ? ALL_HOURS : WORKING_HOURS; + const activeSlots = showFullDay ? ALL_TIME_SLOTS : WORKING_TIME_SLOTS; const weekDates = useMemo(() => getWeekDates(displayTimezone, weekOffset), [displayTimezone, weekOffset]); // Pre-compute slots lookup map for O(1) access - // Key: YYYY-MM-DD:Hour + // Key: YYYY-MM-DD:Hour:Minute const slotsMap = useMemo(() => { const map = new Map(); slots.forEach(slot => { - // Assuming slot.start_time is ISO string. - // We need to match it to our grid's local time logic. - // This part is "flaky" in the original. - // The slot comes with a specific absolute time. - // We want to place it in the intersection of "Day (in DisplayTZ)" and "Hour (in DisplayTZ)". - const d = new Date(slot.start_time); // Format to DisplayTZ to find coordinate const formatter = new Intl.DateTimeFormat('en-CA', { @@ -111,22 +114,22 @@ export const AvailabilityHeatmapV2 = ({ month: '2-digit', day: '2-digit', hour: 'numeric', + minute: 'numeric', hour12: false }); - // "2023-10-25, 14" const parts = formatter.formatToParts(d); const year = parts.find(p => p.type === 'year')?.value; const month = parts.find(p => p.type === 'month')?.value; const day = parts.find(p => p.type === 'day')?.value; const hour = parts.find(p => p.type === 'hour')?.value; + const minute = parts.find(p => p.type === 'minute')?.value; - if (year && month && day && hour) { - // Hour in 24h format might be "24" in some locales? No `hour12: false` gives 0-23 usually. - // But Intl sometimes returns "24"? No, 0-23. + if (year && month && day && hour && minute) { let h = parseInt(hour, 10); + let m = parseInt(minute, 10); if (h === 24) h = 0; // Just in case - const key = `${year}-${month}-${day}:${h}`; + const key = `${year}-${month}-${day}:${h}:${m}`; map.set(key, slot); } }); @@ -144,12 +147,20 @@ export const AvailabilityHeatmapV2 = ({ return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); }; - const getSlotForCell = (date: Date, hour: number) => { - const key = `${formatDateKey(date)}:${hour}`; + const getSlotForCell = (date: Date, hour: number, minute: number) => { + const key = `${formatDateKey(date)}:${hour}:${minute}`; return slotsMap.get(key); }; - const formatHour = (hour: number) => { + const formatTime = (hour: number, minute: number) => { + return new Date(0, 0, 0, hour, minute).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + }; + + const formatHourOnly = (hour: number) => { return new Date(0, 0, 0, hour).toLocaleTimeString('en-US', { hour: 'numeric', hour12: true, @@ -290,7 +301,7 @@ export const AvailabilityHeatmapV2 = ({ {/* Grid Body */}
- {activeHours.map((hour) => { + {activeSlots.map(({ hour, minute }) => { const isNight = hour < 8 || hour >= 18; // Calculate secondary time @@ -298,40 +309,54 @@ export const AvailabilityHeatmapV2 = ({ 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 (
{/* Time Label - Sticky Left */}
- {isNight ? ( - + {minute === 0 ? ( +
+ {isNight ? ( + + ) : ( + + )} + {formatTime(hour, minute)} +
) : ( - + + :{minute.toString().padStart(2, '0')} + )} - {formatHour(hour)}
{showSecondaryTimezone && ( - {formatHour(secondaryHour)} + {formatTime(secondaryHour, minute)} )}
{/* Days */} {weekDates.map(date => { - const slot = getSlotForCell(date, hour); + const slot = getSlotForCell(date, hour, minute); const tooSoon = slot ? isSlotTooSoon(new Date(slot.start_time).getTime()) : true; // Availability logic @@ -357,17 +382,17 @@ export const AvailabilityHeatmapV2 = ({ } return ( -
+
{slot ? (
{/* Mini Indicators for color-blind accessibility or density */} - {isFull && } + {isFull && }
= 12 ? "end" : "start"}> @@ -378,7 +403,7 @@ export const AvailabilityHeatmapV2 = ({
- {formatHour(hour)} - {formatHour(hour + 1)} + {formatTime(hour, minute)} - {formatTime(endHour, endMinuteNormalized)}
diff --git a/frontend/src/types/calendar.ts b/frontend/src/types/calendar.ts index 22cf95e..69497f8 100644 --- a/frontend/src/types/calendar.ts +++ b/frontend/src/types/calendar.ts @@ -11,6 +11,7 @@ export interface Participant { export interface TimeSlot { day: string; hour: number; + minute: number; start_time: string; availability: 'full' | 'partial' | 'none'; availableParticipants: string[]; -- 2.49.1