improve timezone discovery
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Calendar } from 'lucide-react';
|
||||
import { getAvatarColor } from '@/lib/utils';
|
||||
|
||||
export const Header = () => {
|
||||
return (
|
||||
@@ -14,7 +15,10 @@ export const Header = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-sm font-medium text-primary">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium text-white"
|
||||
style={{ backgroundColor: getAvatarColor("AR") }}
|
||||
>
|
||||
AR
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,23 +3,35 @@ import { Participant } from '@/types/calendar';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { UserPlus, Trash2, User } from 'lucide-react';
|
||||
import { UserPlus, Trash2, User, Pencil, Check, X, AlertCircle } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { getAvatarColor } from '@/lib/utils';
|
||||
import { TimezoneSelector } from '@/components/TimezoneSelector';
|
||||
|
||||
interface ParticipantManagerProps {
|
||||
participants: Participant[];
|
||||
onAddParticipant: (participant: { name: string; email: string; icsLink: string }) => void;
|
||||
onAddParticipant: (participant: { name: string; email: string; timezone: string; icsLink: string }) => void;
|
||||
onRemoveParticipant: (id: string) => void;
|
||||
onUpdateParticipant?: (id: string, data: { timezone?: string; ics_url?: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ParticipantManager = ({
|
||||
participants,
|
||||
onAddParticipant,
|
||||
onRemoveParticipant,
|
||||
onUpdateParticipant,
|
||||
}: ParticipantManagerProps) => {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [timezone, setTimezone] = useState('America/Toronto');
|
||||
const [icsLink, setIcsLink] = useState('');
|
||||
|
||||
// Edit state
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editTimezone, setEditTimezone] = useState('');
|
||||
const [editIcsLink, setEditIcsLink] = useState('');
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
@@ -34,7 +46,12 @@ export const ParticipantManager = ({
|
||||
return;
|
||||
}
|
||||
|
||||
onAddParticipant({ name: name.trim(), email: email.trim(), icsLink: icsLink.trim() || '' });
|
||||
onAddParticipant({
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
timezone: timezone.trim() || 'America/Toronto',
|
||||
icsLink: icsLink.trim() || ''
|
||||
});
|
||||
setName('');
|
||||
setEmail('');
|
||||
setIcsLink('');
|
||||
@@ -45,6 +62,43 @@ export const ParticipantManager = ({
|
||||
});
|
||||
};
|
||||
|
||||
const startEditing = (participant: Participant) => {
|
||||
setEditingId(participant.id);
|
||||
setEditTimezone(participant.timezone);
|
||||
setEditIcsLink(participant.icsLink || '');
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditingId(null);
|
||||
setEditTimezone('');
|
||||
setEditIcsLink('');
|
||||
};
|
||||
|
||||
const saveEditing = async (participantId: string) => {
|
||||
if (!onUpdateParticipant) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await onUpdateParticipant(participantId, {
|
||||
timezone: editTimezone,
|
||||
ics_url: editIcsLink || undefined,
|
||||
});
|
||||
toast({
|
||||
title: "Participant updated",
|
||||
description: "Changes saved successfully",
|
||||
});
|
||||
setEditingId(null);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Update failed",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(' ')
|
||||
@@ -64,7 +118,7 @@ export const ParticipantManager = ({
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
@@ -89,10 +143,18 @@ export const ParticipantManager = ({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="icsLink">Calendar ICS Link (optional)</Label>
|
||||
<Label>Timezone</Label>
|
||||
<TimezoneSelector
|
||||
value={timezone}
|
||||
onChange={setTimezone}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="icsLink">Calendar ICS Link</Label>
|
||||
<Input
|
||||
id="icsLink"
|
||||
placeholder="https://calendar.google.com/..."
|
||||
placeholder="https://..."
|
||||
value={icsLink}
|
||||
onChange={(e) => setIcsLink(e.target.value)}
|
||||
className="bg-background"
|
||||
@@ -123,26 +185,117 @@ export const ParticipantManager = ({
|
||||
{participants.map((participant) => (
|
||||
<div
|
||||
key={participant.id}
|
||||
className="flex items-center justify-between p-4 bg-background rounded-lg border border-border"
|
||||
className="p-4 bg-background rounded-lg border border-border"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-sm font-medium text-primary">
|
||||
{getInitials(participant.name)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{participant.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{participant.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
{editingId === participant.id ? (
|
||||
// Edit mode
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium text-white"
|
||||
style={{ backgroundColor: getAvatarColor(participant.name) }}
|
||||
>
|
||||
{getInitials(participant.name)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{participant.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{participant.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRemoveParticipant(participant.id)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Timezone</Label>
|
||||
<TimezoneSelector
|
||||
value={editTimezone}
|
||||
onChange={setEditTimezone}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`edit-ics-${participant.id}`}>Calendar ICS Link</Label>
|
||||
<Input
|
||||
id={`edit-ics-${participant.id}`}
|
||||
value={editIcsLink}
|
||||
onChange={(e) => setEditIcsLink(e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="bg-card"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveEditing(participant.id)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
{isUpdating ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={cancelEditing}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// View mode
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium text-white"
|
||||
style={{ backgroundColor: getAvatarColor(participant.name) }}
|
||||
>
|
||||
{getInitials(participant.name)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{participant.name}</div>
|
||||
<div className="text-sm text-muted-foreground flex flex-wrap gap-x-2">
|
||||
<span>{participant.email}</span>
|
||||
<span className="text-muted-foreground/60">•</span>
|
||||
<span>{participant.timezone}</span>
|
||||
<span className="text-muted-foreground/60">•</span>
|
||||
{participant.icsLink ? (
|
||||
<span className="text-primary truncate max-w-[200px]" title={participant.icsLink}>
|
||||
ICS linked
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-600 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
No calendar linked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{onUpdateParticipant && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => startEditing(participant)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRemoveParticipant(participant.id)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Participant } from '@/types/calendar';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { X, Plus, Search, User } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { X, Plus, Search, AlertCircle } from 'lucide-react';
|
||||
import { cn, getAvatarColor } from '@/lib/utils';
|
||||
|
||||
interface ParticipantSelectorProps {
|
||||
participants: Participant[];
|
||||
@@ -67,16 +67,20 @@ export const ParticipantSelector = ({
|
||||
onClick={() => addParticipant(participant)}
|
||||
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-accent transition-colors text-left"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium text-primary">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium text-white"
|
||||
style={{ backgroundColor: getAvatarColor(participant.name) }}
|
||||
>
|
||||
{getInitials(participant.name)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{participant.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{participant.email}</div>
|
||||
</div>
|
||||
{!participant.connected && (
|
||||
<span className="ml-auto text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
||||
Not connected
|
||||
{!participant.icsLink && (
|
||||
<span className="ml-auto text-xs text-amber-600 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
No calendar
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -96,10 +100,16 @@ export const ParticipantSelector = ({
|
||||
)}
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-xs font-medium">
|
||||
<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"
|
||||
|
||||
217
frontend/src/components/TimezoneSelector.tsx
Normal file
217
frontend/src/components/TimezoneSelector.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Search, Globe, ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface TimezoneSelectorProps {
|
||||
value: string;
|
||||
onChange: (timezone: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Get all IANA timezones
|
||||
const getAllTimezones = (): string[] => {
|
||||
try {
|
||||
return Intl.supportedValuesOf('timeZone');
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
return [
|
||||
'UTC',
|
||||
'America/New_York',
|
||||
'America/Chicago',
|
||||
'America/Denver',
|
||||
'America/Los_Angeles',
|
||||
'America/Toronto',
|
||||
'America/Vancouver',
|
||||
'Europe/London',
|
||||
'Europe/Paris',
|
||||
'Europe/Berlin',
|
||||
'Asia/Tokyo',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Singapore',
|
||||
'Australia/Sydney',
|
||||
'Pacific/Auckland',
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
// Get UTC offset for a timezone
|
||||
const getTimezoneOffset = (timezone: string): string => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
timeZoneName: 'shortOffset',
|
||||
});
|
||||
const parts = formatter.formatToParts(now);
|
||||
const offsetPart = parts.find((p) => p.type === 'timeZoneName');
|
||||
return offsetPart?.value || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Get current time in a timezone
|
||||
const getCurrentTimeInTimezone = (timezone: string): string => {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
}).format(new Date());
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Format timezone for display (e.g., "America/New_York" -> "New York")
|
||||
const formatTimezoneLabel = (timezone: string): string => {
|
||||
const parts = timezone.split('/');
|
||||
const city = parts[parts.length - 1];
|
||||
return city.replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
const ALL_TIMEZONES = getAllTimezones();
|
||||
|
||||
export const TimezoneSelector = ({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: TimezoneSelectorProps) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [hoveredTimezone, setHoveredTimezone] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const filteredTimezones = ALL_TIMEZONES.filter((tz) => {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const tzLower = tz.toLowerCase();
|
||||
const labelLower = formatTimezoneLabel(tz).toLowerCase();
|
||||
const offset = getTimezoneOffset(tz).toLowerCase();
|
||||
|
||||
return (
|
||||
tzLower.includes(query) ||
|
||||
labelLower.includes(query) ||
|
||||
offset.includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const selectTimezone = (timezone: string) => {
|
||||
onChange(timezone);
|
||||
setSearchQuery('');
|
||||
setIsDropdownOpen(false);
|
||||
};
|
||||
|
||||
const selectedOffset = getTimezoneOffset(value);
|
||||
const selectedLabel = formatTimezoneLabel(value);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn('relative', className)}>
|
||||
<button
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm',
|
||||
'bg-muted hover:bg-accent transition-colors',
|
||||
'border border-transparent hover:border-border'
|
||||
)}
|
||||
>
|
||||
<Globe className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-foreground font-medium">{selectedLabel}</span>
|
||||
<span className="text-muted-foreground">{selectedOffset}</span>
|
||||
<ChevronDown className={cn(
|
||||
'w-4 h-4 text-muted-foreground transition-transform',
|
||||
isDropdownOpen && 'rotate-180'
|
||||
)} />
|
||||
</button>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute z-20 right-0 mt-2 w-80 bg-popover border border-border rounded-lg shadow-popover animate-scale-in overflow-hidden">
|
||||
<div className="p-2 border-b border-border">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search timezone..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 h-9 bg-background border-border"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{filteredTimezones.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-muted-foreground text-center">
|
||||
No timezones found
|
||||
</div>
|
||||
) : (
|
||||
filteredTimezones.slice(0, 50).map((timezone) => {
|
||||
const isSelected = timezone === value;
|
||||
const offset = getTimezoneOffset(timezone);
|
||||
const label = formatTimezoneLabel(timezone);
|
||||
const isHovered = hoveredTimezone === timezone;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={timezone}
|
||||
onClick={() => selectTimezone(timezone)}
|
||||
onMouseEnter={() => setHoveredTimezone(timezone)}
|
||||
onMouseLeave={() => setHoveredTimezone(null)}
|
||||
className={cn(
|
||||
'w-full px-4 py-2.5 flex items-center justify-between text-left transition-colors',
|
||||
isSelected ? 'bg-primary/10 text-primary' : 'hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={cn(
|
||||
'text-xs font-mono w-16',
|
||||
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||
)}>
|
||||
{offset}
|
||||
</span>
|
||||
<div>
|
||||
<div className={cn(
|
||||
'font-medium',
|
||||
isSelected ? 'text-primary' : 'text-foreground'
|
||||
)}>
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{timezone}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isHovered && (
|
||||
<span className="text-xs text-muted-foreground animate-fade-in">
|
||||
{getCurrentTimeInTimezone(timezone)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user