12 Commits

Author SHA1 Message Date
Joyce
b1b92b445b update 2026-02-05 21:03:44 -05:00
Joyce
d6115dc30d update 2026-02-05 20:35:19 -05:00
Joyce
b4a8029fb0 update 2026-02-05 17:19:51 -05:00
Joyce
07c11ccd2e update 2026-02-05 17:07:42 -05:00
Joyce
26e553bfd0 update 2026-02-05 13:45:32 -05:00
7a0f11ee88 Merge pull request 'Improve participant selection' (#5) from more-improvements-autofocus into main
Reviewed-on: #5
2026-02-02 19:13:08 +00:00
Joyce
267c1747f4 update 2026-02-02 14:12:27 -05:00
9412d5c0a0 Merge pull request 'imporvements' (#4) from implement-feedback into main
Reviewed-on: #4
2026-02-02 18:52:36 +00:00
Joyce
192b885149 imporvements 2026-02-02 13:49:11 -05:00
10675b6846 Merge pull request 'update to use authentik' (#3) from implement-authentication into main
Reviewed-on: #3
2026-01-29 17:46:43 +00:00
Joyce
e544872430 update to use authentik 2026-01-29 12:46:01 -05:00
f2142633d4 Merge pull request 'improve timezone discovery' (#2) from fix-timezone-issue into main
Reviewed-on: #2
2026-01-28 20:35:27 +00:00
17 changed files with 1491 additions and 215 deletions

View File

@@ -12,6 +12,7 @@ dependencies = [
"psycopg2-binary>=2.9.0", "psycopg2-binary>=2.9.0",
"httpx>=0.28.0", "httpx>=0.28.0",
"icalendar>=6.0.0", "icalendar>=6.0.0",
"recurring-ical-events>=3.0.0",
"python-dateutil>=2.9.0", "python-dateutil>=2.9.0",
"pydantic[email]>=2.10.0", "pydantic[email]>=2.10.0",
"pydantic-settings>=2.6.0", "pydantic-settings>=2.6.0",

View File

@@ -1,7 +1,8 @@
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
import httpx import httpx
import recurring_ical_events
from icalendar import Calendar from icalendar import Calendar
from sqlalchemy import delete from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,6 +11,9 @@ from app.models import BusyBlock, Participant
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# How far into the future to expand recurring events
RECURRING_EVENT_HORIZON_WEEKS = 8
async def fetch_ics_content(url: str) -> str: async def fetch_ics_content(url: str) -> str:
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
@@ -24,22 +28,34 @@ def parse_ics_to_busy_blocks(
calendar = Calendar.from_ical(ics_content) calendar = Calendar.from_ical(ics_content)
blocks = [] blocks = []
for component in calendar.walk(): # Define the time range for expanding recurring events
if component.name == "VEVENT": now = datetime.now(timezone.utc)
dtstart = component.get("dtstart") start_range = now - timedelta(days=7) # Include recent past
dtend = component.get("dtend") end_range = now + timedelta(weeks=RECURRING_EVENT_HORIZON_WEEKS)
if dtstart is None or dtend is None: # Use recurring_ical_events to expand recurring events
events = recurring_ical_events.of(calendar).between(start_range, end_range)
for event in events:
dtstart = event.get("dtstart")
dtend = event.get("dtend")
if dtstart is None:
continue continue
start_dt = dtstart.dt start_dt = dtstart.dt
end_dt = dtend.dt end_dt = dtend.dt if dtend else None
# Handle all-day events (date instead of datetime)
if not isinstance(start_dt, datetime): if not isinstance(start_dt, datetime):
start_dt = datetime.combine(start_dt, datetime.min.time()) start_dt = datetime.combine(start_dt, datetime.min.time())
if not isinstance(end_dt, datetime): if end_dt is None:
# If no end time, assume 1 hour duration
end_dt = start_dt + timedelta(hours=1)
elif not isinstance(end_dt, datetime):
end_dt = datetime.combine(end_dt, datetime.min.time()) end_dt = datetime.combine(end_dt, datetime.min.time())
# Ensure timezone awareness
if start_dt.tzinfo is None: if start_dt.tzinfo is None:
start_dt = start_dt.replace(tzinfo=timezone.utc) start_dt = start_dt.replace(tzinfo=timezone.utc)
if end_dt.tzinfo is None: if end_dt.tzinfo is None:
@@ -53,6 +69,7 @@ def parse_ics_to_busy_blocks(
) )
) )
logger.info(f"Parsed {len(blocks)} events (including recurring) for participant {participant_id}")
return blocks return blocks

View File

@@ -7,10 +7,10 @@ from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.availability_service import calculate_availability from app.availability_service import calculate_availability, get_busy_blocks_for_participants, is_participant_free
from app.database import get_db from app.database import get_db
from app.ics_service import sync_all_calendars, sync_participant_calendar from app.ics_service import sync_all_calendars, sync_participant_calendar
from app.models import Participant from app.models import Participant, BusyBlock
from app.schemas import ( from app.schemas import (
AvailabilityRequest, AvailabilityRequest,
AvailabilityResponse, AvailabilityResponse,
@@ -144,7 +144,8 @@ async def delete_participant(participant_id: UUID, db: AsyncSession = Depends(ge
async def get_availability( async def get_availability(
request: AvailabilityRequest, db: AsyncSession = Depends(get_db) request: AvailabilityRequest, db: AsyncSession = Depends(get_db)
): ):
slots = await calculate_availability(db, request.participant_ids) reference_date = datetime.now(timezone.utc) + timedelta(weeks=request.week_offset)
slots = await calculate_availability(db, request.participant_ids, reference_date)
return {"slots": slots} return {"slots": slots}
@@ -174,6 +175,15 @@ async def sync_participant(participant_id: UUID, db: AsyncSession = Depends(get_
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.delete("/api/bookings")
async def clear_all_bookings(db: AsyncSession = Depends(get_db)):
"""Clear all busy blocks (scheduled meetings) from the database."""
from sqlalchemy import delete
await db.execute(delete(BusyBlock))
await db.commit()
return {"status": "success", "message": "All bookings cleared"}
@app.post("/api/schedule") @app.post("/api/schedule")
async def schedule_meeting( async def schedule_meeting(
data: ScheduleRequest, db: AsyncSession = Depends(get_db) data: ScheduleRequest, db: AsyncSession = Depends(get_db)
@@ -193,6 +203,25 @@ async def schedule_meeting(
if len(participants) != len(data.participant_ids): if len(participants) != len(data.participant_ids):
raise HTTPException(status_code=400, detail="Some participants not found") raise HTTPException(status_code=400, detail="Some participants not found")
# Check if all participants are actually free at the requested time
start_time = data.start_time.replace(tzinfo=timezone.utc) if data.start_time.tzinfo is None else data.start_time
end_time = data.end_time.replace(tzinfo=timezone.utc) if data.end_time.tzinfo is None else data.end_time
busy_map = await get_busy_blocks_for_participants(
db, data.participant_ids, start_time, end_time
)
busy_participants = []
for participant in participants:
if not is_participant_free(busy_map.get(participant.id, []), start_time, end_time):
busy_participants.append(participant.name)
if busy_participants:
raise HTTPException(
status_code=409,
detail=f"Cannot schedule: {', '.join(busy_participants)} {'is' if len(busy_participants) == 1 else 'are'} not available at this time. Please refresh availability and try again."
)
participant_dicts = [ participant_dicts = [
{"name": p.name, "email": p.email} for p in participants {"name": p.name, "email": p.email} for p in participants
] ]
@@ -212,6 +241,16 @@ async def schedule_meeting(
participant_dicts participant_dicts
) )
# Create busy blocks for all participants so the slot shows as taken immediately
for participant in participants:
busy_block = BusyBlock(
participant_id=participant.id,
start_time=start_time,
end_time=end_time,
)
db.add(busy_block)
await db.commit()
return { return {
"status": "success", "status": "success",
"email_sent": email_success, "email_sent": email_success,

View File

@@ -39,6 +39,7 @@ class TimeSlot(BaseModel):
class AvailabilityRequest(BaseModel): class AvailabilityRequest(BaseModel):
participant_ids: list[UUID] participant_ids: list[UUID]
week_offset: int = 0
class AvailabilityResponse(BaseModel): class AvailabilityResponse(BaseModel):

View File

@@ -32,6 +32,8 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
labels:
- traefik.http.middlewares.authentik-auth@file
restart: unless-stopped restart: unless-stopped
frontend: frontend:
@@ -42,6 +44,8 @@ services:
VITE_API_URL: ${VITE_API_URL:-} VITE_API_URL: ${VITE_API_URL:-}
depends_on: depends_on:
- backend - backend
labels:
- traefik.http.middlewares.authentik-auth@file
restart: unless-stopped restart: unless-stopped
expose: expose:
- '80' - '80'

View File

@@ -71,11 +71,11 @@ export async function deleteParticipant(id: string): Promise<void> {
} }
} }
export async function fetchAvailability(participantIds: string[]): Promise<TimeSlotAPI[]> { export async function fetchAvailability(participantIds: string[], weekOffset: number = 0): Promise<TimeSlotAPI[]> {
const response = await fetch(`${API_URL}/api/availability`, { const response = await fetch(`${API_URL}/api/availability`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ participant_ids: participantIds }), body: JSON.stringify({ participant_ids: participantIds, week_offset: weekOffset }),
}); });
const data = await handleResponse<{ slots: TimeSlotAPI[] }>(response); const data = await handleResponse<{ slots: TimeSlotAPI[] }>(response);
return data.slots; return data.slots;
@@ -115,3 +115,10 @@ export async function scheduleMeeting(
}); });
return handleResponse(response); return handleResponse(response);
} }
export async function clearAllBookings(): Promise<void> {
const response = await fetch(`${API_URL}/api/bookings`, { method: 'DELETE' });
if (!response.ok) {
throw new Error('Failed to clear bookings');
}
}

View File

@@ -6,15 +6,16 @@ import {
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover'; } from '@/components/ui/popover';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Check, X, Loader2 } from 'lucide-react'; import { useState, useRef, useEffect } from 'react';
import { Check, X, Loader2, ChevronLeft, ChevronRight, ChevronsRight } from 'lucide-react';
const TIMEZONE = 'America/Toronto'; const DEFAULT_TIMEZONE = 'America/Toronto';
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']; const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const hours = [9, 10, 11, 12, 13, 14, 15, 16, 17]; const hours = Array.from({ length: 24 }, (_, i) => i); // 0-23
// Get the dates for Mon-Fri of the current week in a specific timezone // Get the dates for Mon-Fri of a week in a specific timezone, offset by N weeks
const getWeekDates = (timezone: string): string[] => { const getWeekDates = (timezone: string, weekOffset: number = 0): string[] => {
// Get "now" in the target timezone // Get "now" in the target timezone
const now = new Date(); const now = new Date();
const formatter = new Intl.DateTimeFormat('en-CA', { const formatter = new Intl.DateTimeFormat('en-CA', {
@@ -33,7 +34,7 @@ const getWeekDates = (timezone: string): string[] => {
const dayOfWeek = todayDate.getDay(); const dayOfWeek = todayDate.getDay();
const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const mondayDate = new Date(year, month - 1, day + daysToMonday); const mondayDate = new Date(year, month - 1, day + daysToMonday + weekOffset * 7);
return dayNames.map((_, i) => { return dayNames.map((_, i) => {
const d = new Date(mondayDate); const d = new Date(mondayDate);
@@ -73,12 +74,80 @@ const toUTCDate = (dateStr: string, hour: number, timezone: string): Date => {
return utcDate; return utcDate;
}; };
const MIN_WEEK_OFFSET = 0;
const DEFAULT_MAX_WEEK_OFFSET = 1;
const EXPANDED_MAX_WEEK_OFFSET = 4;
// Format timezone for display (e.g., "America/Toronto" -> "Toronto (EST)")
const formatTimezoneDisplay = (timezone: string): string => {
try {
const parts = timezone.split('/');
const city = parts[parts.length - 1].replace(/_/g, ' ');
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZoneName: 'short',
});
const formattedParts = formatter.formatToParts(now);
const tzAbbrev = formattedParts.find((p) => p.type === 'timeZoneName')?.value || '';
return `${city} (${tzAbbrev})`;
} catch {
return timezone;
}
};
// Get timezone abbreviation (e.g., "America/Toronto" -> "EST")
const getTimezoneAbbrev = (timezone: string): string => {
try {
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZoneName: 'short',
});
const parts = formatter.formatToParts(now);
return parts.find((p) => p.type === 'timeZoneName')?.value || '';
} catch {
return '';
}
};
// Convert an hour from one timezone to another
const convertHourBetweenTimezones = (
hour: number,
dateStr: string,
fromTimezone: string,
toTimezone: string
): number => {
try {
// Create a UTC date for the given hour in the source timezone
const utcDate = toUTCDate(dateStr, hour, fromTimezone);
// Format the hour in the target timezone
const targetHour = parseInt(
new Intl.DateTimeFormat('en-US', {
timeZone: toTimezone,
hour: 'numeric',
hour12: false,
}).format(utcDate)
);
return targetHour;
} catch {
return hour;
}
};
interface AvailabilityHeatmapProps { interface AvailabilityHeatmapProps {
slots: TimeSlot[]; slots: TimeSlot[];
selectedParticipants: Participant[]; selectedParticipants: Participant[];
onSlotSelect: (slot: TimeSlot) => void; onSlotSelect: (slot: TimeSlot) => void;
showPartialAvailability?: boolean; showPartialAvailability?: boolean;
isLoading?: boolean; isLoading?: boolean;
weekOffset?: number;
onWeekOffsetChange?: (offset: number) => void;
displayTimezone?: string;
showSecondaryTimezone?: boolean;
secondaryTimezone?: string;
} }
export const AvailabilityHeatmap = ({ export const AvailabilityHeatmap = ({
@@ -87,13 +156,84 @@ export const AvailabilityHeatmap = ({
onSlotSelect, onSlotSelect,
showPartialAvailability = false, showPartialAvailability = false,
isLoading = false, isLoading = false,
weekOffset = 0,
onWeekOffsetChange,
displayTimezone = DEFAULT_TIMEZONE,
showSecondaryTimezone = false,
secondaryTimezone = DEFAULT_TIMEZONE,
}: AvailabilityHeatmapProps) => { }: AvailabilityHeatmapProps) => {
const weekDates = getWeekDates(TIMEZONE); const [expanded, setExpanded] = useState(false);
const maxWeekOffset = expanded ? EXPANDED_MAX_WEEK_OFFSET : DEFAULT_MAX_WEEK_OFFSET;
const weekDates = getWeekDates(displayTimezone, weekOffset);
// Get current time info in display timezone
const getCurrentTimeInfo = () => {
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: displayTimezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const hourFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: displayTimezone,
hour: 'numeric',
hour12: false,
});
const todayStr = formatter.format(now);
const currentHour = parseInt(hourFormatter.format(now));
return { todayStr, currentHour };
};
const { todayStr, currentHour } = getCurrentTimeInfo();
const todayIndex = weekDates.indexOf(todayStr);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const currentHourRef = useRef<HTMLDivElement>(null);
// Track scroll position for fade indicators
const [canScrollUp, setCanScrollUp] = useState(false);
const [canScrollDown, setCanScrollDown] = useState(true);
const handleScroll = () => {
if (!scrollContainerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
setCanScrollUp(scrollTop > 10);
setCanScrollDown(scrollTop < scrollHeight - clientHeight - 10);
};
// Initialize scroll indicators after content loads
useEffect(() => {
const timer = setTimeout(handleScroll, 150);
return () => clearTimeout(timer);
}, [slots.length, selectedParticipants.length, isLoading]);
// Auto-scroll to position current hour as 3rd visible row
useEffect(() => {
// Only scroll when we have content to show
if (selectedParticipants.length === 0 || isLoading) return;
// Small delay to ensure DOM is ready after render
const timer = setTimeout(() => {
if (!scrollContainerRef.current) return;
const rowHeight = 52; // h-12 (48px) + gap (4px)
const rowsAbove = 2;
// Calculate which hour should be at the top
const targetHour = todayIndex >= 0
? Math.max(0, currentHour - rowsAbove)
: 7; // Default to 7am for other weeks
scrollContainerRef.current.scrollTop = targetHour * rowHeight;
}, 100);
return () => clearTimeout(timer);
}, [weekOffset, todayIndex, currentHour, selectedParticipants.length, isLoading, slots.length]);
// Find a slot that matches the given display timezone date/hour // Find a slot that matches the given display timezone date/hour
const getSlot = (dateStr: string, hour: number): TimeSlot | undefined => { const getSlot = (dateStr: string, hour: number): TimeSlot | undefined => {
// Convert display timezone date/hour to UTC // Convert display timezone date/hour to UTC
const targetUTC = toUTCDate(dateStr, hour, TIMEZONE); const targetUTC = toUTCDate(dateStr, hour, displayTimezone);
return slots.find((s) => { return slots.find((s) => {
const slotDate = new Date(s.start_time); const slotDate = new Date(s.start_time);
@@ -115,7 +255,7 @@ export const AvailabilityHeatmap = ({
const isSlotTooSoon = (dateStr: string, hour: number) => { const isSlotTooSoon = (dateStr: string, hour: number) => {
// Convert to UTC and compare with current time // Convert to UTC and compare with current time
const slotTimeUTC = toUTCDate(dateStr, hour, TIMEZONE); const slotTimeUTC = toUTCDate(dateStr, hour, displayTimezone);
const now = new Date(); const now = new Date();
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000); const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
return slotTimeUTC < twoHoursFromNow; return slotTimeUTC < twoHoursFromNow;
@@ -167,19 +307,115 @@ export const AvailabilityHeatmap = ({
<div className="bg-card rounded-xl shadow-card p-6 animate-slide-up"> <div className="bg-card rounded-xl shadow-card p-6 animate-slide-up">
<div className="mb-6 flex justify-between items-start"> <div className="mb-6 flex justify-between items-start">
<div> <div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-foreground"> <h3 className="text-lg font-semibold text-foreground">
Common Availability Week of {getWeekDateRange()} Common Availability Week of {getWeekDateRange()}
</h3> </h3>
</div>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
{selectedParticipants.length} participant{selectedParticipants.length > 1 ? 's' : ''}: {selectedParticipants.map(p => p.name.split(' ')[0]).join(', ')} {selectedParticipants.length} participant{selectedParticipants.length > 1 ? 's' : ''}: {selectedParticipants.map(p => p.name.split(' ')[0]).join(', ')}
<span className="mx-2"></span>
<span className="text-primary font-medium">
{`Times in ${formatTimezoneDisplay(displayTimezone)}`}
</span>
</p> </p>
</div> </div>
{onWeekOffsetChange && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={weekOffset <= MIN_WEEK_OFFSET}
onClick={() => onWeekOffsetChange(weekOffset - 1)}
>
<ChevronLeft className="w-4 h-4" />
</Button>
{weekOffset !== 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-xs"
onClick={() => onWeekOffsetChange(0)}
>
This week
</Button>
)}
{weekOffset < maxWeekOffset ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onWeekOffsetChange(weekOffset + 1)}
>
<ChevronRight className="w-4 h-4" />
</Button>
) : !expanded ? (
<Button
variant="outline"
size="sm"
className="h-8 text-xs gap-1"
onClick={() => setExpanded(true)}
>
<ChevronsRight className="w-3.5 h-3.5" />
Look further ahead
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled
>
<ChevronRight className="w-4 h-4" />
</Button>
)}
</div>
)}
</div> </div>
<div className="overflow-x-auto"> <div className="relative">
{/* Scroll fade indicator - top */}
<div
className={cn(
"absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-card to-transparent z-20 pointer-events-none transition-opacity duration-200",
canScrollUp ? "opacity-100" : "opacity-0"
)}
/>
{/* Scroll fade indicator - bottom */}
<div
className={cn(
"absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-card via-card/80 to-transparent z-20 pointer-events-none transition-opacity duration-200 flex items-end justify-center pb-2",
canScrollDown ? "opacity-100" : "opacity-0"
)}
>
<span className="text-xs text-muted-foreground animate-pulse"> Scroll for more hours</span>
</div>
<div
className="overflow-x-auto overflow-y-auto max-h-[500px]"
ref={scrollContainerRef}
onScroll={handleScroll}
>
<div className="min-w-[600px]"> <div className="min-w-[600px]">
<div className="grid grid-cols-[60px_repeat(5,1fr)] gap-1 mb-2"> <div className={cn(
"grid gap-1 mb-2 sticky top-0 bg-card z-10",
showSecondaryTimezone
? "grid-cols-[50px_50px_repeat(5,1fr)]"
: "grid-cols-[60px_repeat(5,1fr)]"
)}>
{showSecondaryTimezone ? (
<>
<div className="text-center text-xs font-medium text-primary py-2">
{getTimezoneAbbrev(displayTimezone)}
</div>
<div className="text-center text-xs font-medium text-muted-foreground py-2">
{getTimezoneAbbrev(secondaryTimezone)}
</div>
</>
) : (
<div></div> <div></div>
)}
{dayNames.map((dayName, i) => ( {dayNames.map((dayName, i) => (
<div <div
key={dayName} key={dayName}
@@ -195,10 +431,36 @@ export const AvailabilityHeatmap = ({
<div className="space-y-1"> <div className="space-y-1">
{hours.map((hour) => ( {hours.map((hour) => (
<div key={hour} className="grid grid-cols-[60px_repeat(5,1fr)] gap-1"> <div
<div className="text-xs text-muted-foreground flex items-center justify-end pr-3"> key={hour}
ref={todayIndex >= 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 ? (
<>
<div className="text-xs text-primary font-medium flex items-center justify-center gap-1">
{todayIndex >= 0 && hour === currentHour && (
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
)}
{formatHour(hour)} {formatHour(hour)}
</div> </div>
<div className="text-xs text-muted-foreground flex items-center justify-center">
{formatHour(convertHourBetweenTimezones(hour, weekDates[0] || '', displayTimezone, secondaryTimezone))}
</div>
</>
) : (
<div className="text-xs text-muted-foreground flex items-center justify-end pr-3 gap-1">
{todayIndex >= 0 && hour === currentHour && (
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
)}
{formatHour(hour)}
</div>
)}
{weekDates.map((dateStr, dayIndex) => { {weekDates.map((dateStr, dayIndex) => {
const slot = getSlot(dateStr, hour); const slot = getSlot(dateStr, hour);
const dayName = dayNames[dayIndex]; const dayName = dayNames[dayIndex];
@@ -273,6 +535,7 @@ export const AvailabilityHeatmap = ({
</div> </div>
</div> </div>
</div> </div>
</div>
<div className="flex items-center justify-center gap-6 mt-6 pt-4 border-t border-border"> <div className="flex items-center justify-center gap-6 mt-6 pt-4 border-t border-border">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -0,0 +1,474 @@
import { TimeSlot, Participant } from '@/types/calendar';
import { cn } from '@/lib/utils';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { useState, useMemo, useRef, useEffect } from 'react';
import { Check, X, Loader2, ChevronLeft, ChevronRight, ChevronsRight, Clock, Calendar as CalendarIcon, Sun, Moon, Users } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
const DEFAULT_TIMEZONE = 'America/Toronto';
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
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);
// Helper to check if a slot is in the past or too close (2h buffer)
const isSlotTooSoon = (slotDate: number) => {
const now = Date.now();
const twoHoursFromNow = now + 2 * 60 * 60 * 1000;
return slotDate < twoHoursFromNow;
};
// Reuse previous timezone helpers or simplify
const getWeekDates = (timezone: string, weekOffset: number = 0): Date[] => {
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const todayStr = formatter.format(now);
const [year, month, day] = todayStr.split('-').map(Number);
const todayDate = new Date(year, month - 1, day);
const dayOfWeek = todayDate.getDay();
// If Sunday (0), go back 6 days to Monday. If Mon (1), go back 0. If Sat (6), go back 5.
// Actually standard logic: Mon=1...Sun=7.
// Let's assume standard ISO week start (Mon)
const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const mondayDate = new Date(year, month - 1, day + daysToMonday + weekOffset * 7);
return Array.from({ length: 5 }, (_, i) => {
const d = new Date(mondayDate);
d.setDate(mondayDate.getDate() + i);
return d;
});
};
const formatTimezoneDisplay = (timezone: string): string => {
try {
const parts = timezone.split('/');
const city = parts[parts.length - 1].replace(/_/g, ' ');
return city;
} catch {
return timezone;
}
};
interface AvailabilityHeatmapV2Props {
slots: TimeSlot[];
selectedParticipants: Participant[];
onSlotSelect: (slot: TimeSlot) => void;
showPartialAvailability?: boolean;
isLoading?: boolean;
weekOffset?: number;
onWeekOffsetChange?: (offset: number) => void;
displayTimezone?: string;
showSecondaryTimezone?: boolean;
secondaryTimezone?: string;
}
export const AvailabilityHeatmapV2 = ({
slots,
selectedParticipants,
onSlotSelect,
showPartialAvailability = false,
isLoading = false,
weekOffset = 0,
onWeekOffsetChange,
displayTimezone = DEFAULT_TIMEZONE,
showSecondaryTimezone = false,
secondaryTimezone = DEFAULT_TIMEZONE,
}: AvailabilityHeatmapV2Props) => {
const [showFullDay, setShowFullDay] = useState(false);
const activeHours = showFullDay ? ALL_HOURS : WORKING_HOURS;
const weekDates = useMemo(() => getWeekDates(displayTimezone, weekOffset), [displayTimezone, weekOffset]);
// Pre-compute slots lookup map for O(1) access
// Key: YYYY-MM-DD:Hour
const slotsMap = useMemo(() => {
const map = new Map<string, TimeSlot>();
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', {
timeZone: displayTimezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '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;
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.
let h = parseInt(hour, 10);
if (h === 24) h = 0; // Just in case
const key = `${year}-${month}-${day}:${h}`;
map.set(key, slot);
}
});
return map;
}, [slots, displayTimezone]);
const formatDateKey = (date: Date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
};
const formatDisplayDate = (date: Date) => {
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
};
const getSlotForCell = (date: Date, hour: number) => {
const key = `${formatDateKey(date)}:${hour}`;
return slotsMap.get(key);
};
const formatHour = (hour: number) => {
return new Date(0, 0, 0, hour).toLocaleTimeString('en-US', {
hour: 'numeric',
hour12: true,
});
};
// Move hooks to top level to avoid conditional hook execution error
const tzOffsetDiff = useMemo(() => {
try {
const now = new Date();
const p = parseInt(new Intl.DateTimeFormat('en-US', { timeZone: displayTimezone, hour: 'numeric', hour12: false }).format(now));
const s = parseInt(new Intl.DateTimeFormat('en-US', { timeZone: secondaryTimezone, hour: 'numeric', hour12: false }).format(now));
let diff = s - p;
if (diff > 12) diff -= 24;
if (diff < -12) diff += 24;
return diff;
} catch {
return 0;
}
}, [displayTimezone, secondaryTimezone]);
const timeColWidth = showSecondaryTimezone ? "120px" : "80px";
if (selectedParticipants.length === 0) {
return (
<div className="flex flex-col items-center justify-center p-12 border-2 border-dashed border-border/50 rounded-xl bg-muted/20 animate-fade-in">
<UsersPlaceholder />
<h3 className="text-xl font-semibold mt-4">No participants selected</h3>
<p className="text-muted-foreground text-center max-w-sm mt-2">
Select team members from the list above to compare calendars and find the perfect meeting time.
</p>
</div>
);
}
return (
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Controls Bar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-card p-4 rounded-xl border border-border shadow-sm">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-9 w-9"
onClick={() => onWeekOffsetChange?.(weekOffset - 1)}
disabled={!onWeekOffsetChange || weekOffset <= 0}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex flex-col items-center min-w-[140px]">
<span className="text-sm font-semibold">
{weekDates[0]?.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
{' - '}
{weekDates[4]?.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
<span className="text-xs text-muted-foreground">
{weekOffset === 0 ? "This Week" : weekOffset === 1 ? "Next Week" : `${weekOffset} weeks out`}
</span>
</div>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
onClick={() => onWeekOffsetChange?.(weekOffset + 1)}
disabled={!onWeekOffsetChange}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="h-8 w-px bg-border hidden sm:block" />
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-md">
<Clock className="w-4 h-4" />
{formatTimezoneDisplay(displayTimezone)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant={showFullDay ? "outline" : "secondary"}
size="sm"
onClick={() => setShowFullDay(!showFullDay)}
className="text-xs"
>
{showFullDay ? (
<>
<Sun className="w-3.5 h-3.5 mr-2" />
Show Work Hours
</>
) : (
<>
<Moon className="w-3.5 h-3.5 mr-2" />
Show Full Day
</>
)}
</Button>
</div>
</div>
{/* Main Grid Card */}
<div className="bg-card w-full overflow-hidden rounded-xl border border-border shadow-md">
{isLoading && (
<div className="absolute inset-0 bg-background/50 backdrop-blur-[1px] z-50 flex items-center justify-center">
<Loader2 className="w-10 h-10 animate-spin text-primary" />
</div>
)}
<div className="overflow-auto max-h-[600px] w-full relative">
<div className="min-w-[700px]">
{/* Grid Header */}
<div
className="grid sticky top-0 z-30 bg-card border-b border-border shadow-sm"
style={{ gridTemplateColumns: `${timeColWidth} repeat(5, 1fr)` }}
>
<div className="sticky left-0 z-40 bg-card text-xs font-semibold text-muted-foreground self-center p-3 text-right border-r border-border/50 flex flex-col items-end gap-1">
<span>{formatTimezoneDisplay(displayTimezone)}</span>
{showSecondaryTimezone && (
<span className="text-[10px] text-muted-foreground/60 font-normal">{formatTimezoneDisplay(secondaryTimezone)}</span>
)}
</div>
{weekDates.map(date => {
const isToday = new Date().toDateString() === date.toDateString();
return (
<div key={date.toISOString()} className={cn(
"text-center p-3 transition-colors border-r border-border/30 last:border-0",
isToday ? "bg-primary/5 text-primary font-bold" : "text-foreground"
)}>
<div className="text-sm">{date.toLocaleDateString('en-US', { weekday: 'short' })}</div>
<div className={cn("text-2xl", isToday ? "font-bold" : "font-light")}>
{date.getDate()}
</div>
</div>
);
})}
</div>
{/* Grid Body */}
<div className="relative">
{activeHours.map((hour) => {
const isNight = hour < 8 || hour >= 18;
// Calculate secondary time
let secondaryHour = hour + tzOffsetDiff;
if (secondaryHour >= 24) secondaryHour -= 24;
if (secondaryHour < 0) secondaryHour += 24;
return (
<div
key={hour}
className={cn(
"grid group items-stretch transition-colors border-b border-border/30 last:border-0",
isNight ? "bg-muted/30" : "bg-card",
"hover:bg-muted/10"
)}
style={{ gridTemplateColumns: `${timeColWidth} repeat(5, 1fr)` }}
>
{/* Time Label - Sticky Left */}
<div className={cn(
"text-xs text-muted-foreground font-medium text-right pr-4 py-3 flex flex-col items-end justify-center gap-0.5",
"sticky left-0 z-20 border-r border-border/50",
isNight ? "bg-muted/30 backdrop-blur-md" : "bg-card"
)}>
<div className="flex items-center gap-1.5">
{isNight ? (
<Moon className="w-3 h-3 text-slate-400/50" />
) : (
<Sun className="w-3 h-3 text-amber-500/50" />
)}
<span>{formatHour(hour)}</span>
</div>
{showSecondaryTimezone && (
<span className="text-[10px] text-muted-foreground/60 font-mono">
{formatHour(secondaryHour)}
</span>
)}
</div>
{/* Days */}
{weekDates.map(date => {
const slot = getSlotForCell(date, hour);
const tooSoon = slot ? isSlotTooSoon(new Date(slot.start_time).getTime()) : true;
// Availability logic
const availability = slot?.availability || 'none';
const isNone = availability === 'none';
const isPartial = availability === 'partial' && showPartialAvailability;
const isFull = availability === 'full';
const isPartialHidden = availability === 'partial' && !showPartialAvailability;
// Styling
let bgClass = ""; // Default transparent
if (!slot) {
bgClass = "bg-muted/10 pattern-diagonal-lines opacity-50";
} else if (tooSoon) {
bgClass = "bg-muted/20 pattern-diagonal-lines cursor-not-allowed border border-border/10";
} else if (isFull) {
bgClass = "bg-emerald-500/90 hover:bg-emerald-600 shadow-sm";
} else if (isPartial) {
bgClass = "bg-amber-400/80 hover:bg-amber-500 shadow-sm";
} else if (isNone || isPartialHidden) {
bgClass = "bg-muted/50 hover:bg-muted/70 border border-border/20";
}
return (
<div key={date.toISOString()} className="p-1 h-[52px] border-r border-border/30 last:border-0">
{slot ? (
<Popover>
<PopoverTrigger asChild disabled={tooSoon}>
<div className={cn(
"w-full h-full rounded-md transition-all duration-200 cursor-pointer flex items-center justify-center group/cell relative overflow-hidden",
bgClass,
!tooSoon && (isFull || isPartial) ? "scale-[0.98] hover:scale-100 hover:ring-2 ring-primary/20" : ""
)}>
{/* Mini Indicators for color-blind accessibility or density */}
{isFull && <Check className="w-4 h-4 text-white opacity-0 group-hover/cell:opacity-100 transition-opacity" />}
</div>
</PopoverTrigger>
<PopoverContent className="w-72 p-0 rounded-xl overflow-hidden shadow-xl border-border" side="right" align={hour >= 12 ? "end" : "start"}>
<div className="p-4 bg-muted/30 border-b border-border/50">
<h4 className="font-semibold text-base flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
{formatDisplayDate(date)}
</h4>
<div className="text-sm text-muted-foreground mt-1 flex items-center gap-2">
<Clock className="w-4 h-4" />
{formatHour(hour)} - {formatHour(hour + 1)}
</div>
</div>
<div className="p-4 space-y-3 max-h-[300px] overflow-y-auto">
<div className="space-y-2">
{selectedParticipants.map(participant => {
const isAvailable = slot.availableParticipants.includes(participant.name);
return (
<div key={participant.id} className="flex items-center justify-between text-sm p-2 rounded-lg hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3">
<div className={cn(
"w-2 h-2 rounded-full",
isAvailable ? "bg-emerald-500" : "bg-destructive"
)} />
<span className={cn(!isAvailable && "text-muted-foreground")}>
{participant.name}
</span>
</div>
{isAvailable ? (
<Check className="w-4 h-4 text-emerald-500" />
) : (
<span className="text-xs text-muted-foreground">Busy</span>
)}
</div>
);
})}
</div>
{(!isNone && !tooSoon) && (
<Button
className="w-full mt-4 bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={() => onSlotSelect(slot)}
>
Schedule Meeting
</Button>
)}
</div>
</PopoverContent>
</Popover>
) : (
// Placeholder for missing slots
<div className="w-full h-full rounded-md bg-muted/5 border border-dashed border-border/30"></div>
)}
</div>
);
})}
</div>
);
})}
</div>
</div>
</div>
{/* Legend */}
<div className="flex flex-wrap items-center justify-center bg-muted/20 border-t p-3 gap-6 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-emerald-500 shadow-sm"></div>
<span className="font-medium text-foreground">All Available</span>
</div>
{showPartialAvailability && (
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-amber-400 shadow-sm"></div>
<span className="font-medium text-foreground">Partial Match</span>
</div>
)}
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-muted/50 border border-border/20"></div>
<span className="text-muted-foreground">Busy / No Match</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-muted/20 pattern-diagonal-lines border border-border/10"></div>
<span className="text-muted-foreground">Past / Too Soon</span>
</div>
</div>
</div>
</div>
);
};
function UsersPlaceholder() {
return (
<div className="relative w-16 h-16 mb-2">
<div className="absolute top-0 left-0 w-10 h-10 rounded-full bg-muted border-2 border-background flex items-center justify-center">
<Users className="w-5 h-5 text-muted-foreground" />
</div>
<div className="absolute bottom-0 right-0 w-10 h-10 rounded-full bg-muted border-2 border-background flex items-center justify-center">
<Users className="w-5 h-5 text-muted-foreground" />
</div>
</div>
);
}
// Importing Users icon since it was missing in imports

View File

@@ -1,7 +1,27 @@
import { Calendar } from 'lucide-react'; import { Calendar, Moon, Sun } from 'lucide-react';
import { getAvatarColor } from '@/lib/utils'; import { getAvatarColor } from '@/lib/utils';
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
export const Header = () => { export const Header = () => {
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('theme') === 'dark' ? 'dark' : 'light';
}
return 'light';
});
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(theme);
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return ( return (
<header className="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-50"> <header className="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-50">
<div className="container max-w-5xl mx-auto px-4 py-4 flex items-center justify-between"> <div className="container max-w-5xl mx-auto px-4 py-4 flex items-center justify-between">
@@ -14,7 +34,15 @@ export const Header = () => {
<p className="text-xs text-muted-foreground">Calendar Coordination</p> <p className="text-xs text-muted-foreground">Calendar Coordination</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={toggleTheme} className="rounded-full">
{theme === 'light' ? (
<Sun className="h-5 w-5 text-amber-500" />
) : (
<Moon className="h-5 w-5 text-slate-400" />
)}
<span className="sr-only">Toggle theme</span>
</Button>
<div <div
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium text-white" className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium text-white"
style={{ backgroundColor: getAvatarColor("AR") }} style={{ backgroundColor: getAvatarColor("AR") }}

View File

@@ -3,9 +3,15 @@ import { Participant } from '@/types/calendar';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { UserPlus, Trash2, User, Pencil, Check, X, AlertCircle } from 'lucide-react'; import { UserPlus, Trash2, User, Pencil, Check, X, AlertCircle, Info } from 'lucide-react';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { getAvatarColor } from '@/lib/utils'; import { getAvatarColor, getCalendarNameFromUrl } from '@/lib/utils';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
interface ParticipantManagerProps { interface ParticipantManagerProps {
participants: Participant[]; participants: Participant[];
@@ -236,9 +242,19 @@ export const ParticipantManager = ({
<span>{participant.email}</span> <span>{participant.email}</span>
<span className="text-muted-foreground/60"></span> <span className="text-muted-foreground/60"></span>
{participant.icsLink ? ( {participant.icsLink ? (
<span className="text-primary truncate max-w-[200px]" title={participant.icsLink}> <TooltipProvider>
ICS linked <Tooltip>
<TooltipTrigger asChild>
<span className="text-primary truncate max-w-[200px] cursor-help flex items-center gap-1" title={participant.icsLink}>
📅 {getCalendarNameFromUrl(participant.icsLink) || 'Calendar linked'}
<Info className="w-3 h-3 opacity-50" />
</span> </span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[220px]">
<p>Availability is based on this calendar only. Other calendars on the same account are not included.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : ( ) : (
<span className="text-amber-600 flex items-center gap-1"> <span className="text-amber-600 flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> <AlertCircle className="w-3 h-3" />

View File

@@ -1,8 +1,14 @@
import { useState } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react';
import { Participant } from '@/types/calendar'; import { Participant } from '@/types/calendar';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { X, Plus, Search, AlertCircle } from 'lucide-react'; import { X, Plus, Search, AlertCircle, Info } from 'lucide-react';
import { cn, getAvatarColor } from '@/lib/utils'; import { cn, getAvatarColor, getCalendarNameFromUrl } from '@/lib/utils';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
interface ParticipantSelectorProps { interface ParticipantSelectorProps {
participants: Participant[]; participants: Participant[];
@@ -17,6 +23,19 @@ export const ParticipantSelector = ({
}: ParticipantSelectorProps) => { }: ParticipantSelectorProps) => {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const filteredParticipants = participants.filter( const filteredParticipants = participants.filter(
(p) => (p) =>
@@ -25,14 +44,43 @@ export const ParticipantSelector = ({
p.email.toLowerCase().includes(searchQuery.toLowerCase())) p.email.toLowerCase().includes(searchQuery.toLowerCase()))
); );
const addParticipant = (participant: Participant) => { const addParticipant = useCallback((participant: Participant) => {
onSelectionChange([...selectedParticipants, participant]); onSelectionChange([...selectedParticipants, participant]);
setSearchQuery(''); setSearchQuery('');
// Keep dropdown open for multi-select; clamp highlight to new list length
setHighlightedIndex((prev) => {
const newLength = filteredParticipants.length - 1;
return prev >= newLength ? Math.max(0, newLength - 1) : prev;
});
// Keep focus on input so user can continue selecting
requestAnimationFrame(() => inputRef.current?.focus());
}, [onSelectionChange, selectedParticipants, filteredParticipants.length]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isDropdownOpen || filteredParticipants.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightedIndex((prev) =>
prev < filteredParticipants.length - 1 ? prev + 1 : 0
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedIndex((prev) =>
prev > 0 ? prev - 1 : filteredParticipants.length - 1
);
} else if (e.key === 'Enter') {
e.preventDefault();
addParticipant(filteredParticipants[highlightedIndex]);
} else if (e.key === 'Escape') {
setIsDropdownOpen(false); setIsDropdownOpen(false);
}
}; };
const removeParticipant = (participantId: string) => { const removeParticipant = (participantId: string) => {
onSelectionChange(selectedParticipants.filter((p) => p.id !== participantId)); onSelectionChange(selectedParticipants.filter((p) => p.id !== participantId));
setIsDropdownOpen(false);
inputRef.current?.blur();
}; };
const getInitials = (name: string) => { const getInitials = (name: string) => {
@@ -45,27 +93,83 @@ export const ParticipantSelector = ({
}; };
return ( return (
<div className="space-y-4"> <div ref={containerRef} className="space-y-4">
<div className="relative"> <div
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> className={cn(
"relative min-h-[48px] bg-background border border-border rounded-lg shadow-sm flex flex-wrap items-center gap-2 px-3 py-1.5 transition-all",
"focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary"
)}
onClick={() => inputRef.current?.focus()}
>
<Search className="w-4 h-4 text-muted-foreground shrink-0 mr-1" />
{selectedParticipants.map((participant) => (
<div
key={participant.id}
className={cn(
"flex items-center gap-1.5 bg-accent text-accent-foreground pl-2 pr-1 py-1 rounded-full text-xs font-medium animate-scale-in",
"border border-primary/10 group hover:border-destructive/30 hover:bg-destructive/10 hover:text-destructive transition-colors cursor-default"
)}
onClick={(e) => e.stopPropagation()}
>
<div
className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold text-white shrink-0"
style={{ backgroundColor: getAvatarColor(participant.name) }}
>
{getInitials(participant.name)}
</div>
<span className="max-w-[100px] truncate">{participant.name.split(' ')[0]}</span>
{!participant.icsLink && (
<AlertCircle className="w-3 h-3 text-amber-600 shrink-0" title="No calendar linked" />
)}
<button
onClick={() => removeParticipant(participant.id)}
className="w-4 h-4 rounded-full hover:bg-black/10 dark:hover:bg-white/10 flex items-center justify-center transition-colors shrink-0"
>
<X className="w-2.5 h-2.5" />
</button>
</div>
))}
<Input <Input
placeholder="Search people..." ref={inputRef}
placeholder={selectedParticipants.length === 0 ? "Search people..." : "Add more..."}
value={searchQuery} value={searchQuery}
onChange={(e) => { onChange={(e) => {
setSearchQuery(e.target.value); setSearchQuery(e.target.value);
setHighlightedIndex(0);
setIsDropdownOpen(true); setIsDropdownOpen(true);
}} }}
onFocus={() => setIsDropdownOpen(true)} onFocus={() => setIsDropdownOpen(true)}
className="pl-10 h-12 bg-background border-border" onKeyDown={handleKeyDown}
className="flex-1 min-w-[100px] h-7 border-none shadow-none focus-visible:ring-0 p-0 text-sm bg-transparent placeholder:text-muted-foreground/70"
/> />
{isDropdownOpen && (
<button
onClick={(e) => {
e.stopPropagation();
setIsDropdownOpen(false);
inputRef.current?.blur();
}}
className="p-1 hover:bg-muted rounded-full transition-colors ml-1"
title="Close"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
)}
{isDropdownOpen && filteredParticipants.length > 0 && ( {isDropdownOpen && filteredParticipants.length > 0 && (
<div className="absolute z-10 w-full mt-2 bg-popover border border-border rounded-lg shadow-popover animate-scale-in overflow-hidden"> <div className="absolute top-full left-0 z-50 w-full mt-2 bg-popover border border-border rounded-lg shadow-popover animate-scale-in overflow-hidden">
{filteredParticipants.map((participant) => ( {filteredParticipants.map((participant, index) => (
<button <button
key={participant.id} key={participant.id}
onClick={() => addParticipant(participant)} onClick={() => addParticipant(participant)}
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-accent transition-colors text-left" onMouseEnter={() => setHighlightedIndex(index)}
className={cn(
"w-full px-4 py-3 flex items-center gap-3 hover:bg-accent transition-colors text-left",
index === highlightedIndex && "bg-accent"
)}
> >
<div <div
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium text-white" className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium text-white"
@@ -77,56 +181,33 @@ export const ParticipantSelector = ({
<div className="font-medium text-foreground">{participant.name}</div> <div className="font-medium text-foreground">{participant.name}</div>
<div className="text-xs text-muted-foreground">{participant.email}</div> <div className="text-xs text-muted-foreground">{participant.email}</div>
</div> </div>
{!participant.icsLink && ( <span className="ml-auto text-xs flex items-center gap-1">
<span className="ml-auto text-xs text-amber-600 flex items-center gap-1"> {participant.icsLink ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground cursor-help flex items-center gap-1">
📅 {getCalendarNameFromUrl(participant.icsLink) || 'Calendar'}
<Info className="w-3 h-3 opacity-50" />
</span>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-[220px]">
<p>Availability is based on this calendar only. Other calendars on the same account are not included.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-amber-600 flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> <AlertCircle className="w-3 h-3" />
No calendar No calendar
</span> </span>
)} )}
</span>
</button> </button>
))} ))}
</div> </div>
)} )}
</div> </div>
{selectedParticipants.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedParticipants.map((participant, index) => (
<div
key={participant.id}
className={cn(
"flex items-center gap-2 bg-accent text-accent-foreground px-3 py-2 rounded-full text-sm animate-scale-in",
"border border-primary/20"
)}
style={{ animationDelay: `${index * 50}ms` }}
>
<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"
>
<X className="w-3 h-3" />
</button>
</div>
))}
<button
onClick={() => setIsDropdownOpen(true)}
className="flex items-center gap-1 px-3 py-2 rounded-full text-sm border-2 border-dashed border-border text-muted-foreground hover:border-primary hover:text-primary transition-colors"
>
<Plus className="w-4 h-4" />
<span>Add</span>
</button>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -11,30 +11,88 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { Calendar, Clock, Users, Send, AlertCircle } from 'lucide-react'; import { Calendar, Clock, Users, Send, AlertCircle } from 'lucide-react';
const DURATION_OPTIONS = [
{ value: 15, label: '15 minutes' },
{ value: 30, label: '30 minutes' },
{ value: 45, label: '45 minutes' },
{ value: 60, label: '1 hour' },
{ value: 90, label: '1 hour 30 minutes' },
{ value: 120, label: '2 hours' },
{ value: 150, label: '2 hours 30 minutes' },
];
interface ScheduleModalProps { interface ScheduleModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
slot: TimeSlot | null; slot: TimeSlot | null;
participants: Participant[]; participants: Participant[];
displayTimezone?: string;
onSuccess?: () => void;
} }
// Get user's local timezone
const getUserTimezone = (): string => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return 'America/Toronto';
}
};
// Format timezone for display (e.g., "America/Toronto" -> "EST")
const getTimezoneAbbrev = (timezone: string): string => {
try {
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZoneName: 'short',
});
const parts = formatter.formatToParts(now);
return parts.find((p) => p.type === 'timeZoneName')?.value || '';
} catch {
return '';
}
};
export const ScheduleModal = ({ export const ScheduleModal = ({
isOpen, isOpen,
onClose, onClose,
slot, slot,
participants, participants,
displayTimezone = getUserTimezone(),
onSuccess,
}: ScheduleModalProps) => { }: ScheduleModalProps) => {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState('');
const [duration, setDuration] = useState(60); // default 1 hour
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const formatHour = (hour: number) => { const formatHour = (hour: number) => {
return `${hour.toString().padStart(2, '0')}:00`; return `${hour.toString().padStart(2, '0')}:00`;
}; };
const formatTime = (hour: number, minutes: number) => {
const totalMinutes = hour * 60 + minutes;
const h = Math.floor(totalMinutes / 60) % 24;
const m = totalMinutes % 60;
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
};
const getEndTime = () => {
if (!slot) return '';
return formatTime(slot.hour, duration);
};
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
const date = new Date(dateStr + 'T00:00:00'); const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
@@ -68,7 +126,7 @@ export const ScheduleModal = ({
// Calculate start and end times // Calculate start and end times
// slot.day is YYYY-MM-DD // slot.day is YYYY-MM-DD
const startDateTime = new Date(`${slot.day}T${formatHour(slot.hour)}:00Z`); const startDateTime = new Date(`${slot.day}T${formatHour(slot.hour)}:00Z`);
const endDateTime = new Date(`${slot.day}T${formatHour(slot.hour + 1)}:00Z`); const endDateTime = new Date(startDateTime.getTime() + duration * 60 * 1000);
await scheduleMeeting( await scheduleMeeting(
participants.map(p => p.id), participants.map(p => p.id),
@@ -86,6 +144,7 @@ export const ScheduleModal = ({
setTitle(''); setTitle('');
setNotes(''); setNotes('');
onClose(); onClose();
onSuccess?.();
} catch (error) { } catch (error) {
toast({ toast({
title: "Scheduling failed", title: "Scheduling failed",
@@ -115,7 +174,7 @@ export const ScheduleModal = ({
</div> </div>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<Clock className="w-4 h-4 text-primary" /> <Clock className="w-4 h-4 text-primary" />
<span>{formatHour(slot.hour)} {formatHour(slot.hour + 1)}</span> <span><span className="text-primary font-medium">{formatHour(slot.hour)} {getEndTime()}</span> <span className="text-muted-foreground">({getTimezoneAbbrev(displayTimezone)})</span></span>
</div> </div>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<Users className="w-4 h-4 text-primary" /> <Users className="w-4 h-4 text-primary" />
@@ -125,6 +184,25 @@ export const ScheduleModal = ({
{/* Form */} {/* Form */}
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="duration">Duration</Label>
<Select
value={duration.toString()}
onValueChange={(value) => setDuration(Number(value))}
>
<SelectTrigger className="h-12">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DURATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">Meeting Title</Label> <Label htmlFor="title">Meeting Title</Label>
<Input <Input

View File

@@ -11,11 +11,12 @@ interface TimezoneSelectorProps {
// Get all IANA timezones // Get all IANA timezones
const getAllTimezones = (): string[] => { const getAllTimezones = (): string[] => {
let timezones: string[] = [];
try { try {
return Intl.supportedValuesOf('timeZone'); timezones = Intl.supportedValuesOf('timeZone');
} catch { } catch {
// Fallback for older browsers // Fallback for older browsers
return [ timezones = [
'UTC', 'UTC',
'America/New_York', 'America/New_York',
'America/Chicago', 'America/Chicago',
@@ -23,6 +24,7 @@ const getAllTimezones = (): string[] => {
'America/Los_Angeles', 'America/Los_Angeles',
'America/Toronto', 'America/Toronto',
'America/Vancouver', 'America/Vancouver',
'America/Montreal',
'Europe/London', 'Europe/London',
'Europe/Paris', 'Europe/Paris',
'Europe/Berlin', 'Europe/Berlin',
@@ -33,6 +35,17 @@ const getAllTimezones = (): string[] => {
'Pacific/Auckland', 'Pacific/Auckland',
]; ];
} }
// Prioritize Montreal as requested
const priorityTimezone = 'America/Montreal';
if (!timezones.includes(priorityTimezone)) {
timezones.push(priorityTimezone);
}
return [
priorityTimezone,
...timezones.filter((tz) => tz !== priorityTimezone),
];
}; };
// Get UTC offset for a timezone // Get UTC offset for a timezone
@@ -158,7 +171,7 @@ export const TimezoneSelector = ({
No timezones found No timezones found
</div> </div>
) : ( ) : (
filteredTimezones.slice(0, 50).map((timezone) => { filteredTimezones.map((timezone) => {
const isSelected = timezone === value; const isSelected = timezone === value;
const offset = getTimezoneOffset(timezone); const offset = getTimezoneOffset(timezone);
const label = formatTimezoneLabel(timezone); const label = formatTimezoneLabel(timezone);
@@ -204,12 +217,6 @@ export const TimezoneSelector = ({
}) })
)} )}
</div> </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>
)} )}
</div> </div>

View File

@@ -1,17 +1,17 @@
import * as SheetPrimitive from "@radix-ui/react-dialog"; import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"; import * as SheetPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"; import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"; import { X } from "lucide-react"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root; const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger; const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close; const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal; const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef< const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>, React.ElementRef<typeof SheetPrimitive.Overlay>,
@@ -20,13 +20,13 @@ const SheetOverlay = React.forwardRef<
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className, className
)} )}
{...props} {...props}
ref={ref} ref={ref}
/> />
)); ))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva( const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
@@ -44,64 +44,95 @@ const sheetVariants = cva(
defaultVariants: { defaultVariants: {
side: "right", side: "right",
}, },
}, }
); )
interface SheetContentProps interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> { } VariantProps<typeof sheetVariants> { }
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>( const SheetContent = React.forwardRef<
({ side = "right", className, children, ...props }, ref) => ( React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal> <SheetPortal>
<SheetOverlay /> <SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}> <SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children} {children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"> <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
), ))
); SheetContent.displayName = SheetPrimitive.Content.displayName
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const SheetHeader = ({
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} /> className,
); ...props
SheetHeader.displayName = "SheetHeader"; }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const SheetFooter = ({
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} /> className,
); ...props
SheetFooter.displayName = "SheetFooter"; }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef< const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>, React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Title ref={ref} className={cn("text-lg font-semibold text-foreground", className)} {...props} /> <SheetPrimitive.Title
)); ref={ref}
SheetTitle.displayName = SheetPrimitive.Title.displayName; className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef< const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>, React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} /> <SheetPrimitive.Description
)); ref={ref}
SheetDescription.displayName = SheetPrimitive.Description.displayName; className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export { export {
Sheet, Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose, SheetClose,
SheetContent, SheetContent,
SheetDescription,
SheetFooter,
SheetHeader, SheetHeader,
SheetOverlay, SheetFooter,
SheetPortal,
SheetTitle, SheetTitle,
SheetTrigger, SheetDescription,
}; }

View File

@@ -111,9 +111,50 @@
--sidebar-ring: 18 68% 51%; --sidebar-ring: 18 68% 51%;
} }
/* Dark mode intentionally removed/reset to match light mode system for now, /* Dark mode */
or you can define a proper dark mode if required. .dark {
Keeping it simple as per previous apps. */ --background: 60 5% 8%; /* #161614 */
--foreground: 60 9% 93%; /* #F0F0EC */
--card: 60 5% 10%; /* Slightly lighter than bg */
--card-foreground: 60 9% 93%;
--popover: 60 5% 10%;
--popover-foreground: 60 9% 93%;
--primary: 18 68% 51%; /* Keep primary brand color */
--primary-foreground: 60 9% 97%;
--secondary: 60 5% 15%;
--secondary-foreground: 60 9% 93%;
--muted: 60 5% 15%;
--muted-foreground: 60 5% 65%;
--accent: 60 5% 15%;
--accent-foreground: 60 9% 93%;
--destructive: 0 62% 30%;
--destructive-foreground: 60 9% 97%;
--border: 60 5% 20%;
--input: 60 5% 20%;
--ring: 18 68% 51%;
/* Dark mode availability colors */
--availability-full: 142 70% 45%; /* Brighter green for dark mode */
--availability-partial: 25 80% 65%; /* Same orange */
--availability-none: 60 5% 20%; /* Darker grey */
--sidebar-background: 60 5% 8%;
--sidebar-foreground: 60 9% 93%;
--sidebar-primary: 18 68% 51%;
--sidebar-primary-foreground: 60 9% 97%;
--sidebar-accent: 60 5% 15%;
--sidebar-accent-foreground: 60 9% 93%;
--sidebar-border: 60 5% 20%;
--sidebar-ring: 18 68% 51%;
}
} }
@layer base { @layer base {
@@ -192,3 +233,15 @@
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.7; } 50% { opacity: 0.7; }
} }
.pattern-diagonal-lines {
background-image: repeating-linear-gradient(
45deg,
currentColor,
currentColor 1px,
transparent 1px,
transparent 10px
);
background-size: 10px 10px;
opacity: 0.1;
}

View File

@@ -5,6 +5,20 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
export function getCalendarNameFromUrl(icsUrl: string | null | undefined): string | null {
if (!icsUrl) return null;
try {
const url = new URL(icsUrl);
const pathname = url.pathname;
const filename = pathname.split('/').pop() || '';
// Decode URL encoding (e.g., %20 -> space) and remove .ics extension
const decoded = decodeURIComponent(filename).replace(/\.ics$/i, '');
return decoded || null;
} catch {
return null;
}
}
export function getAvatarColor(name?: string): string { export function getAvatarColor(name?: string): string {
const colors = [ const colors = [
'hsl(var(--tag-green))', 'hsl(var(--tag-green))',

View File

@@ -4,7 +4,9 @@ import { Header } from '@/components/Header';
import { ParticipantSelector } from '@/components/ParticipantSelector'; import { ParticipantSelector } from '@/components/ParticipantSelector';
import { ParticipantManager } from '@/components/ParticipantManager'; import { ParticipantManager } from '@/components/ParticipantManager';
import { AvailabilityHeatmap } from '@/components/AvailabilityHeatmap'; import { AvailabilityHeatmap } from '@/components/AvailabilityHeatmap';
import { AvailabilityHeatmapV2 } from '@/components/AvailabilityHeatmapV2';
import { ScheduleModal } from '@/components/ScheduleModal'; import { ScheduleModal } from '@/components/ScheduleModal';
import { TimezoneSelector } from '@/components/TimezoneSelector';
import { Participant, TimeSlot } from '@/types/calendar'; import { Participant, TimeSlot } from '@/types/calendar';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
@@ -14,6 +16,22 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover'; } from '@/components/ui/popover';
import {
Sheet,
SheetContent,
SheetTrigger,
} from '@/components/ui/sheet';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Users, CalendarDays, Settings, RefreshCw } from 'lucide-react'; import { Users, CalendarDays, Settings, RefreshCw } from 'lucide-react';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
@@ -24,17 +42,33 @@ import {
deleteParticipant, deleteParticipant,
fetchAvailability, fetchAvailability,
syncCalendars, syncCalendars,
clearAllBookings,
ParticipantAPI, ParticipantAPI,
} from '@/api/client'; } from '@/api/client';
const SETTINGS_KEY = 'calendar-settings'; const SETTINGS_KEY = 'calendar-settings';
// Get user's local timezone
const getUserTimezone = (): string => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return 'America/Toronto';
}
};
interface SettingsState { interface SettingsState {
showPartialAvailability: boolean; showPartialAvailability: boolean;
displayTimezone: string;
showSecondaryTimezone: boolean;
secondaryTimezone: string;
} }
const defaultSettings: SettingsState = { const defaultSettings: SettingsState = {
showPartialAvailability: false, showPartialAvailability: false,
displayTimezone: getUserTimezone(),
showSecondaryTimezone: true,
secondaryTimezone: 'America/Montreal', // Company timezone as default secondary
}; };
function apiToParticipant(p: ParticipantAPI): Participant { function apiToParticipant(p: ParticipantAPI): Participant {
@@ -61,7 +95,9 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
const [availabilitySlots, setAvailabilitySlots] = useState<TimeSlot[]>([]); const [availabilitySlots, setAvailabilitySlots] = useState<TimeSlot[]>([]);
const [selectedSlot, setSelectedSlot] = useState<TimeSlot | null>(null); const [selectedSlot, setSelectedSlot] = useState<TimeSlot | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [useRedesign, setUseRedesign] = useState(true);
const [settings, setSettings] = useState<SettingsState>(defaultSettings); const [settings, setSettings] = useState<SettingsState>(defaultSettings);
const [weekOffset, setWeekOffset] = useState(0);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
@@ -101,7 +137,7 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
} else { } else {
setAvailabilitySlots([]); setAvailabilitySlots([]);
} }
}, [selectedParticipants]); }, [selectedParticipants, weekOffset]);
const loadParticipants = async () => { const loadParticipants = async () => {
try { try {
@@ -120,7 +156,7 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
setIsLoading(true); setIsLoading(true);
try { try {
const ids = selectedParticipants.map((p) => p.id); const ids = selectedParticipants.map((p) => p.id);
const slots = await fetchAvailability(ids); const slots = await fetchAvailability(ids, weekOffset);
setAvailabilitySlots(slots); setAvailabilitySlots(slots);
} catch (error) { } catch (error) {
toast({ toast({
@@ -209,6 +245,25 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
setIsModalOpen(true); setIsModalOpen(true);
}; };
const handleClearBookings = async () => {
try {
await clearAllBookings();
if (selectedParticipants.length > 0) {
await loadAvailability();
}
toast({
title: 'Bookings cleared',
description: 'All scheduled meetings have been removed',
});
} catch (error) {
toast({
title: 'Error clearing bookings',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
}
};
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<Header /> <Header />
@@ -254,6 +309,10 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
<div className="space-y-8"> <div className="space-y-8">
<div className="text-center relative"> <div className="text-center relative">
<div className="absolute right-0 top-0 flex items-center gap-2"> <div className="absolute right-0 top-0 flex items-center gap-2">
<TimezoneSelector
value={settings.displayTimezone}
onChange={(tz) => setSettings((prev) => ({ ...prev, displayTimezone: tz }))}
/>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -262,19 +321,42 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
> >
<RefreshCw className={`w-5 h-5 ${isSyncing ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-5 h-5 ${isSyncing ? 'animate-spin' : ''}`} />
</Button> </Button>
<Popover> <Sheet>
<PopoverTrigger asChild> <SheetTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<Settings className="w-5 h-5" /> <Settings className="w-5 h-5" />
</Button> </Button>
</PopoverTrigger> </SheetTrigger>
<PopoverContent className="w-72" align="end"> <SheetContent side="right" className="w-[300px] sm:w-[350px]">
<div className="space-y-6 py-4">
<div className="space-y-2">
<h4 className="font-semibold text-lg tracking-tight">Settings</h4>
<p className="text-sm text-muted-foreground">
Configure your calendar preferences.
</p>
</div>
<div className="space-y-4"> <div className="space-y-4">
<h4 className="font-medium">Settings</h4> <div className="flex items-center justify-between gap-4 pb-4 border-b border-border">
<div className="flex items-center justify-between gap-4"> <Label htmlFor="use-redesign" className="text-sm cursor-pointer font-medium text-primary">
<Label htmlFor="partial-availability" className="text-sm cursor-pointer"> Try New Design
Show partial availability
</Label> </Label>
<Switch
id="use-redesign"
checked={useRedesign}
onCheckedChange={setUseRedesign}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor="partial-availability" className="text-sm font-medium cursor-pointer">
Partial Availability
</Label>
<p className="text-xs text-muted-foreground">
Show slots where some are busy
</p>
</div>
<Switch <Switch
id="partial-availability" id="partial-availability"
checked={settings.showPartialAvailability} checked={settings.showPartialAvailability}
@@ -283,12 +365,70 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
} }
/> />
</div> </div>
<p className="text-xs text-muted-foreground">
When enabled, shows time slots where only some participants are available. <div className="border-t border-border pt-4 space-y-4">
</p> <div className="flex items-center justify-between gap-4">
<Label htmlFor="secondary-timezone" className="text-sm cursor-pointer">
Show secondary timezone
</Label>
<Switch
id="secondary-timezone"
checked={settings.showSecondaryTimezone}
onCheckedChange={(checked) =>
setSettings((prev) => ({ ...prev, showSecondaryTimezone: checked }))
}
/>
</div> </div>
</PopoverContent> {settings.showSecondaryTimezone && (
</Popover> <div className="space-y-2">
<Label className="text-xs text-muted-foreground block">
Secondary timezone
</Label>
<TimezoneSelector
value={settings.secondaryTimezone}
onChange={(tz) => setSettings((prev) => ({ ...prev, secondaryTimezone: tz }))}
/>
</div>
)}
</div>
<div className="border-t border-border pt-4 mt-8">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3 block">
Danger Zone
</Label>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive-foreground hover:bg-destructive border-destructive/30"
>
Clear All Bookings
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear all bookings?</AlertDialogTitle>
<AlertDialogDescription>
This will remove all scheduled meetings. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleClearBookings}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Clear All
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
</SheetContent>
</Sheet>
</div> </div>
<h2 className="text-3xl font-bold text-foreground mb-2"> <h2 className="text-3xl font-bold text-foreground mb-2">
Schedule a Meeting Schedule a Meeting
@@ -319,13 +459,33 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
/> />
</div> </div>
{useRedesign ? (
<AvailabilityHeatmapV2
slots={availabilitySlots}
selectedParticipants={selectedParticipants}
onSlotSelect={handleSlotSelect}
showPartialAvailability={settings.showPartialAvailability}
isLoading={isLoading}
weekOffset={weekOffset}
onWeekOffsetChange={setWeekOffset}
displayTimezone={settings.displayTimezone}
showSecondaryTimezone={settings.showSecondaryTimezone}
secondaryTimezone={settings.secondaryTimezone}
/>
) : (
<AvailabilityHeatmap <AvailabilityHeatmap
slots={availabilitySlots} slots={availabilitySlots}
selectedParticipants={selectedParticipants} selectedParticipants={selectedParticipants}
onSlotSelect={handleSlotSelect} onSlotSelect={handleSlotSelect}
showPartialAvailability={settings.showPartialAvailability} showPartialAvailability={settings.showPartialAvailability}
isLoading={isLoading} isLoading={isLoading}
weekOffset={weekOffset}
onWeekOffsetChange={setWeekOffset}
displayTimezone={settings.displayTimezone}
showSecondaryTimezone={settings.showSecondaryTimezone}
secondaryTimezone={settings.secondaryTimezone}
/> />
)}
</> </>
)} )}
</div> </div>
@@ -341,6 +501,8 @@ const Index = ({ defaultTab = 'schedule' }: IndexProps) => {
}} }}
slot={selectedSlot} slot={selectedSlot}
participants={selectedParticipants} participants={selectedParticipants}
displayTimezone={settings.displayTimezone}
onSuccess={loadAvailability}
/> />
</div > </div >
); );