improve timezone discovery

This commit is contained in:
Joyce
2026-01-28 14:53:12 -05:00
parent daa0afaa25
commit 880925f30d
23 changed files with 807 additions and 157 deletions

View File

@@ -7,30 +7,79 @@ import {
} from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Check, X, Loader2 } from 'lucide-react';
import { TimezoneSelector } from '@/components/TimezoneSelector';
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const hours = [9, 10, 11, 12, 13, 14, 15, 16, 17];
// Get the dates for Mon-Fri of the current week
const getWeekDates = () => {
// Get the dates for Mon-Fri of the current week in a specific timezone
const getWeekDates = (timezone: string): string[] => {
// Get "now" in the target timezone
const now = new Date();
const monday = new Date(now);
monday.setDate(now.getDate() - now.getDay() + 1);
monday.setHours(0, 0, 0, 0);
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
// Parse today's date in the target timezone
const todayStr = formatter.format(now);
const [year, month, day] = todayStr.split('-').map(Number);
// Calculate Monday of this week
const todayDate = new Date(year, month - 1, day);
const dayOfWeek = todayDate.getDay();
const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const mondayDate = new Date(year, month - 1, day + daysToMonday);
return dayNames.map((_, i) => {
const date = new Date(monday);
date.setDate(monday.getDate() + i);
return date.toISOString().split('T')[0]; // "YYYY-MM-DD"
const d = new Date(mondayDate);
d.setDate(mondayDate.getDate() + i);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${dd}`;
});
};
// 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
// Then parse it to get the UTC equivalent
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
const testDate = new Date(localDateStr + 'Z'); // Treat as UTC first
// Get what hour this would be in the target timezone
const tzHour = parseInt(
new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour: 'numeric',
hour12: false,
}).format(testDate)
);
// Calculate offset in hours
const offset = tzHour - testDate.getUTCHours();
// Adjust: if we want `hour` in timezone, subtract the offset to get UTC
const utcDate = new Date(localDateStr + 'Z');
utcDate.setUTCHours(utcDate.getUTCHours() - offset);
return utcDate;
};
interface AvailabilityHeatmapProps {
slots: TimeSlot[];
selectedParticipants: Participant[];
onSlotSelect: (slot: TimeSlot) => void;
showPartialAvailability?: boolean;
isLoading?: boolean;
displayTimezone: string;
onTimezoneChange: (timezone: string) => void;
}
export const AvailabilityHeatmap = ({
@@ -39,11 +88,21 @@ export const AvailabilityHeatmap = ({
onSlotSelect,
showPartialAvailability = false,
isLoading = false,
displayTimezone,
onTimezoneChange,
}: AvailabilityHeatmapProps) => {
const weekDates = getWeekDates();
const weekDates = getWeekDates(displayTimezone);
const getSlot = (dateStr: string, hour: number) => {
return slots.find((s) => s.day === dateStr && s.hour === hour);
// 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);
return slots.find((s) => {
const slotDate = new Date(s.start_time);
// Compare UTC timestamps (with some tolerance for rounding)
return Math.abs(slotDate.getTime() - targetUTC.getTime()) < 60000; // 1 minute tolerance
});
};
const getEffectiveAvailability = (slot: TimeSlot) => {
@@ -58,23 +117,35 @@ export const AvailabilityHeatmap = ({
};
const isSlotTooSoon = (dateStr: string, hour: number) => {
const slotTime = new Date(`${dateStr}T${formatHour(hour)}:00Z`);
// Convert to UTC and compare with current time
const slotTimeUTC = toUTCDate(dateStr, hour, displayTimezone);
const now = new Date();
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
return slotTime < twoHoursFromNow;
return slotTimeUTC < twoHoursFromNow;
};
const getWeekDateRange = () => {
const now = new Date();
const monday = new Date(now);
monday.setDate(now.getDate() - now.getDay() + 1);
const friday = new Date(monday);
friday.setDate(monday.getDate() + 4);
if (weekDates.length < 5) return '';
const monday = new Date(weekDates[0] + 'T12:00:00Z');
const friday = new Date(weekDates[4] + 'T12:00:00Z');
const format = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
const format = (d: Date) =>
d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
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
const date = new Date();
date.setHours(hour, 0, 0, 0);
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
}).format(date);
};
if (selectedParticipants.length === 0) {
return (
<div className="bg-card rounded-xl shadow-card p-8 text-center animate-fade-in">
@@ -97,25 +168,34 @@ export const AvailabilityHeatmap = ({
return (
<div className="bg-card rounded-xl shadow-card p-6 animate-slide-up">
<div className="mb-6">
<h3 className="text-lg font-semibold text-foreground">
Common Availability Week of {getWeekDateRange()}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{selectedParticipants.length} participant{selectedParticipants.length > 1 ? 's' : ''}: {selectedParticipants.map(p => p.name.split(' ')[0]).join(', ')}
</p>
<div className="mb-6 flex justify-between items-start">
<div>
<h3 className="text-lg font-semibold text-foreground">
Common Availability Week of {getWeekDateRange()}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{selectedParticipants.length} participant{selectedParticipants.length > 1 ? 's' : ''}: {selectedParticipants.map(p => p.name.split(' ')[0]).join(', ')}
</p>
</div>
<TimezoneSelector
value={displayTimezone}
onChange={onTimezoneChange}
/>
</div>
<div className="overflow-x-auto">
<div className="min-w-[600px]">
<div className="grid grid-cols-[60px_repeat(5,1fr)] gap-1 mb-2">
<div></div>
{dayNames.map((dayName) => (
{dayNames.map((dayName, i) => (
<div
key={dayName}
className="text-center text-sm font-medium text-muted-foreground py-2"
>
{dayName}
<div>{dayName}</div>
<div className="text-xs opacity-70">
{weekDates[i]?.slice(5).replace('-', '/')}
</div>
</div>
))}
</div>
@@ -151,7 +231,7 @@ export const AvailabilityHeatmap = ({
<PopoverContent className="w-64 p-4 animate-scale-in" align="center">
<div className="space-y-3">
<div className="font-semibold text-foreground">
{dayName} {formatHour(hour)}{formatHour(hour + 1)}
{dayName} {formatDisplayTime(hour)}{formatDisplayTime(hour + 1)}
</div>
{tooSoon && (
<div className="text-sm text-muted-foreground italic">
@@ -161,6 +241,20 @@ export const AvailabilityHeatmap = ({
<div className="space-y-2">
{selectedParticipants.map((participant) => {
const isAvailable = slot.availableParticipants.includes(participant.name);
// Calculate participant's local time for this slot
let participantTime = '';
try {
const slotDate = new Date(slot.start_time);
participantTime = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: 'numeric',
timeZone: participant.timezone,
}).format(slotDate);
} catch (e) {
// Fallback if timezone is invalid
}
return (
<div
key={participant.id}
@@ -174,7 +268,10 @@ export const AvailabilityHeatmap = ({
<span className={cn(
isAvailable ? "text-foreground" : "text-muted-foreground"
)}>
{participant.name.split(' ')[0]} {isAvailable ? 'free' : 'busy'}
{participant.name.split(' ')[0]}
<span className="opacity-70 ml-1">
({participantTime || '?'})
</span>
</span>
</div>
);