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