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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -16,6 +16,8 @@ const App = () => (
<BrowserRouter>
<Routes>
<Route path="/" element={<Index />} />
<Route path="/participants" element={<Index defaultTab="participants" />} />
<Route path="/schedule" element={<Index defaultTab="schedule" />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>

View File

@@ -5,6 +5,7 @@ export interface ParticipantAPI {
id: string;
name: string;
email: string;
timezone: string;
ics_url: string | null;
created_at: string;
updated_at: string;
@@ -13,6 +14,7 @@ export interface ParticipantAPI {
export interface TimeSlotAPI {
day: string;
hour: number;
start_time: string;
availability: 'full' | 'partial' | 'none';
availableParticipants: string[];
}
@@ -20,6 +22,12 @@ export interface TimeSlotAPI {
export interface CreateParticipantRequest {
name: string;
email: string;
timezone: string;
ics_url?: string;
}
export interface UpdateParticipantRequest {
timezone?: string;
ics_url?: string;
}
@@ -45,6 +53,15 @@ export async function createParticipant(data: CreateParticipantRequest): Promise
return handleResponse<ParticipantAPI>(response);
}
export async function updateParticipant(id: string, data: UpdateParticipantRequest): Promise<ParticipantAPI> {
const response = await fetch(`${API_URL}/api/participants/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return handleResponse<ParticipantAPI>(response);
}
export async function deleteParticipant(id: string): Promise<void> {
const response = await fetch(`${API_URL}/api/participants/${id}`, {
method: 'DELETE',

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>
);

View File

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

View File

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

View File

@@ -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"

View 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>
);
};

View File

@@ -1,104 +1,119 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/inter-400.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/inter-500.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/inter-600.woff2') format('woff2');
}
@font-face {
font-family: 'Source Serif Pro';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/source-serif-pro-400.woff2') format('woff2');
}
@font-face {
font-family: 'Source Serif Pro';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/source-serif-pro-600.woff2') format('woff2');
}
@font-face {
font-family: 'Source Serif Pro';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/source-serif-pro-700.woff2') format('woff2');
}
:root {
--background: 210 20% 98%;
--foreground: 222 47% 11%;
/* Greyhaven Colors converted to HSL */
/* Light Mode Defaults */
--background: 60 9% 93%; /* #F0F0EC */
--foreground: 60 5% 8%; /* #161614 */
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--card: 60 9% 97%; /* #F9F9F7 */
--card-foreground: 60 5% 8%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
--popover: 60 9% 97%;
--popover-foreground: 60 5% 8%;
--primary: 173 58% 39%;
--primary-foreground: 0 0% 100%;
--primary: 18 68% 51%; /* #D95E2A (RGB 217 94 42) */
--primary-foreground: 60 9% 97%;
--secondary: 210 20% 96%;
--secondary-foreground: 222 47% 11%;
--secondary: 60 9% 93%;
--secondary-foreground: 60 3% 18%; /* #2F2F2C */
--muted: 210 20% 94%;
--muted-foreground: 215 16% 47%;
--muted: 60 5% 84%; /* Darker than background for contrast */
--muted-foreground: 60 2% 34%; /* #575753 */
--accent: 173 58% 94%;
--accent-foreground: 173 58% 25%;
--accent: 60 9% 85%; /* #DDD7 */
--accent-foreground: 60 5% 8%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--destructive: 0 57% 45%; /* #B43232 (RGB 180 50 50) */
--destructive-foreground: 60 9% 97%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 173 58% 39%;
--border: 60 5% 77%; /* #C4C4BD (RGB 196 196 189) */
--input: 60 5% 77%;
--ring: 18 68% 51%;
--radius: 0.75rem;
--radius: 0.375rem;
/* Custom colors for heatmap */
--availability-full: 142 71% 45%;
--availability-partial: 48 96% 53%;
--availability-none: 215 16% 85%;
/* Custom colors for heatmap - Updated to match system tags */
--availability-full: 142 76% 36%; /* Tag Green */
--availability-partial: 25 80% 65%; /* Tag Orange */
--availability-none: 0 0% 80%; /* Grey */
/* Tag Colors */
--tag-orange: 25 80% 65%;
--tag-green: 142 76% 36%;
--tag-blue: 210 100% 60%;
--tag-purple: 270 70% 65%;
--tag-brown: 30 40% 50%;
/* Typography */
--font-serif: 'Source Serif Pro', Georgia, 'Times New Roman', serif;
--font-display: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
/* Shadows */
--shadow-sm: 0 1px 2px 0 hsl(222 47% 11% / 0.05);
--shadow-md: 0 4px 6px -1px hsl(222 47% 11% / 0.1), 0 2px 4px -2px hsl(222 47% 11% / 0.1);
--shadow-lg: 0 10px 15px -3px hsl(222 47% 11% / 0.1), 0 4px 6px -4px hsl(222 47% 11% / 0.1);
--shadow-xl: 0 20px 25px -5px hsl(222 47% 11% / 0.1), 0 8px 10px -6px hsl(222 47% 11% / 0.1);
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
--sidebar-background: 60 9% 93%;
--sidebar-foreground: 60 5% 8%;
--sidebar-primary: 18 68% 51%;
--sidebar-primary-foreground: 60 9% 97%;
--sidebar-accent: 60 9% 85%;
--sidebar-accent-foreground: 60 5% 8%;
--sidebar-border: 60 5% 77%;
--sidebar-ring: 18 68% 51%;
}
.dark {
--background: 222 47% 6%;
--foreground: 210 20% 98%;
--card: 222 47% 8%;
--card-foreground: 210 20% 98%;
--popover: 222 47% 8%;
--popover-foreground: 210 20% 98%;
--primary: 173 58% 45%;
--primary-foreground: 0 0% 100%;
--secondary: 222 47% 14%;
--secondary-foreground: 210 20% 98%;
--muted: 222 47% 14%;
--muted-foreground: 215 16% 65%;
--accent: 173 58% 15%;
--accent-foreground: 173 58% 75%;
--destructive: 0 62% 30%;
--destructive-foreground: 210 20% 98%;
--border: 222 47% 17%;
--input: 222 47% 17%;
--ring: 173 58% 45%;
--availability-full: 142 71% 35%;
--availability-partial: 48 96% 40%;
--availability-none: 222 47% 20%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
/* Dark mode intentionally removed/reset to match light mode system for now,
or you can define a proper dark mode if required.
Keeping it simple as per previous apps. */
}
@layer base {
@@ -107,8 +122,12 @@
}
body {
@apply bg-background text-foreground font-sans antialiased;
font-family: 'DM Sans', sans-serif;
@apply bg-background text-foreground antialiased;
font-family: var(--font-display);
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-serif);
}
}

View File

@@ -4,3 +4,18 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function getAvatarColor(name?: string): string {
const colors = [
'hsl(var(--tag-green))',
'hsl(var(--tag-blue))',
'hsl(var(--tag-orange))',
'hsl(var(--tag-purple))',
'hsl(var(--tag-brown))',
];
if (!name) return colors[0];
const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
return colors[hash % colors.length];
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Header } from '@/components/Header';
import { ParticipantSelector } from '@/components/ParticipantSelector';
import { ParticipantManager } from '@/components/ParticipantManager';
@@ -19,6 +20,7 @@ import { useToast } from '@/hooks/use-toast';
import {
fetchParticipants,
createParticipant,
updateParticipant,
deleteParticipant,
fetchAvailability,
syncCalendars,
@@ -40,12 +42,20 @@ function apiToParticipant(p: ParticipantAPI): Participant {
id: p.id,
name: p.name,
email: p.email,
timezone: p.timezone,
icsLink: p.ics_url,
connected: true,
};
}
const Index = () => {
interface IndexProps {
defaultTab?: string;
}
const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
const navigate = useNavigate();
const location = useLocation();
const [activeTab, setActiveTab] = useState(defaultTab);
const [participants, setParticipants] = useState<Participant[]>([]);
const [selectedParticipants, setSelectedParticipants] = useState<Participant[]>([]);
const [availabilitySlots, setAvailabilitySlots] = useState<TimeSlot[]>([]);
@@ -54,8 +64,21 @@ const Index = () => {
const [settings, setSettings] = useState<SettingsState>(defaultSettings);
const [isLoading, setIsLoading] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [displayTimezone, setDisplayTimezone] = useState(
Intl.DateTimeFormat().resolvedOptions().timeZone
);
const { toast } = useToast();
useEffect(() => {
// Sync internal state if prop changes (e.g. browser back button)
setActiveTab(defaultTab);
}, [defaultTab]);
const handleTabChange = (value: string) => {
setActiveTab(value);
navigate(`/${value}`);
};
useEffect(() => {
const stored = localStorage.getItem(SETTINGS_KEY);
if (stored) {
@@ -113,11 +136,12 @@ const Index = () => {
}
};
const handleAddParticipant = async (data: { name: string; email: string; icsLink: string }) => {
const handleAddParticipant = async (data: { name: string; email: string; timezone: string; icsLink: string }) => {
try {
const created = await createParticipant({
name: data.name,
email: data.email,
timezone: data.timezone,
ics_url: data.icsLink || undefined,
});
setParticipants((prev) => [...prev, apiToParticipant(created)]);
@@ -151,6 +175,16 @@ const Index = () => {
}
};
const handleUpdateParticipant = async (id: string, data: { timezone?: string; ics_url?: string }) => {
const updated = await updateParticipant(id, data);
setParticipants((prev) =>
prev.map((p) => (p.id === id ? apiToParticipant(updated) : p))
);
setSelectedParticipants((prev) =>
prev.map((p) => (p.id === id ? apiToParticipant(updated) : p))
);
};
const handleSyncCalendars = async () => {
setIsSyncing(true);
try {
@@ -183,22 +217,28 @@ const Index = () => {
<Header />
<main className="container max-w-5xl mx-auto px-4 py-8">
<Tabs defaultValue="schedule" className="space-y-6">
<TabsList className="grid w-full max-w-md mx-auto grid-cols-2">
<TabsTrigger value="participants" className="flex items-center gap-2">
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
<TabsList className="grid w-full max-w-md mx-auto grid-cols-2 bg-muted p-1 rounded-xl">
<TabsTrigger
value="participants"
className="flex items-center gap-2 rounded-lg data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm transition-all"
>
<Users className="w-4 h-4" />
Participants
People
</TabsTrigger>
<TabsTrigger value="schedule" className="flex items-center gap-2">
<TabsTrigger
value="schedule"
className="flex items-center gap-2 rounded-lg data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm transition-all"
>
<CalendarDays className="w-4 h-4" />
Schedule
</TabsTrigger>
</TabsList>
<TabsContent value="participants" className="animate-fade-in">
<TabsContent value="participants" className="animate-fade-in focus-visible:outline-none">
<div className="text-center mb-6">
<h2 className="text-3xl font-bold text-foreground mb-2">
Manage Participants
Manage People
</h2>
<p className="text-muted-foreground">
Add team members with their calendar ICS links
@@ -209,6 +249,7 @@ const Index = () => {
participants={participants}
onAddParticipant={handleAddParticipant}
onRemoveParticipant={handleRemoveParticipant}
onUpdateParticipant={handleUpdateParticipant}
/>
</TabsContent>
@@ -265,7 +306,7 @@ const Index = () => {
<Users className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<h3 className="text-lg font-medium text-foreground mb-2">No participants yet</h3>
<p className="text-muted-foreground">
Add participants in the Participants tab to start scheduling.
Add people in the People tab to start scheduling.
</p>
</div>
) : (
@@ -287,6 +328,8 @@ const Index = () => {
onSlotSelect={handleSlotSelect}
showPartialAvailability={settings.showPartialAvailability}
isLoading={isLoading}
displayTimezone={displayTimezone}
onTimezoneChange={setDisplayTimezone}
/>
</>
)}

View File

@@ -2,6 +2,7 @@ export interface Participant {
id: string;
name: string;
email: string;
timezone: string;
icsLink?: string;
avatar?: string;
connected: boolean;
@@ -10,6 +11,7 @@ export interface Participant {
export interface TimeSlot {
day: string;
hour: number;
start_time: string;
availability: 'full' | 'partial' | 'none';
availableParticipants: string[];
}

View File

@@ -14,7 +14,8 @@ export default {
},
extend: {
fontFamily: {
sans: ['DM Sans', 'system-ui', 'sans-serif'],
sans: ['Inter', 'system-ui', 'sans-serif'],
serif: ['Source Serif Pro', 'Georgia', 'serif'],
},
colors: {
border: "hsl(var(--border))",