16 Commits

Author SHA1 Message Date
Joyce
8ea2987e3e update 2026-02-06 08:36:40 -05:00
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
Joyce
117b28c2e9 improve timezone discovery 2026-01-28 15:31:30 -05:00
Joyce
880925f30d improve timezone discovery 2026-01-28 14:53:12 -05:00
daa0afaa25 Merge pull request 'cleanup-and-test' (#1) from cleanup-and-test into main
Reviewed-on: #1
2026-01-23 20:44:13 +00:00
31 changed files with 2345 additions and 421 deletions

View File

@@ -0,0 +1,29 @@
"""add timezone to participant
Revision ID: 46a2e388b20a
Revises: 001
Create Date: 2026-01-28 18:48:09.141869
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '46a2e388b20a'
down_revision: Union[str, None] = '001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('participants', sa.Column('timezone', sa.String(length=50), nullable=False, server_default='UTC'))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('participants', 'timezone')
# ### end Alembic commands ###

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

@@ -4,6 +4,7 @@ from uuid import UUID
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models import BusyBlock, Participant from app.models import BusyBlock, Participant
@@ -70,13 +71,16 @@ async def calculate_availability(
participants = {p.id: p for p in participants_result.scalars().all()} participants = {p.id: p for p in participants_result.scalars().all()}
days = ["Mon", "Tue", "Wed", "Thu", "Fri"] days = ["Mon", "Tue", "Wed", "Thu", "Fri"]
hours = list(range(9, 18)) slot_interval = settings.slot_interval_minutes
slots_per_hour = 60 // slot_interval
slots = [] slots = []
for day_offset, day_name in enumerate(days): for day_offset, day_name in enumerate(days):
for hour in hours: for hour in range(24):
slot_start = week_start + timedelta(days=day_offset, hours=hour) for slot_idx in range(slots_per_hour):
slot_end = slot_start + timedelta(hours=1) minute = slot_idx * slot_interval
slot_start = week_start + timedelta(days=day_offset, hours=hour, minutes=minute)
slot_end = slot_start + timedelta(minutes=slot_interval)
available_participants = [] available_participants = []
for pid in participant_ids: for pid in participant_ids:
@@ -98,6 +102,8 @@ async def calculate_availability(
slots.append({ slots.append({
"day": slot_start.strftime("%Y-%m-%d"), "day": slot_start.strftime("%Y-%m-%d"),
"hour": hour, "hour": hour,
"minute": minute,
"start_time": slot_start,
"availability": availability, "availability": availability,
"availableParticipants": available_participants, "availableParticipants": available_participants,
}) })

View File

@@ -5,6 +5,7 @@ class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://postgres:postgres@db:5432/availability" database_url: str = "postgresql+asyncpg://postgres:postgres@db:5432/availability"
sync_database_url: str = "postgresql://postgres:postgres@db:5432/availability" sync_database_url: str = "postgresql://postgres:postgres@db:5432/availability"
ics_refresh_interval_minutes: int = 15 ics_refresh_interval_minutes: int = 15
slot_interval_minutes: int = 15 # Time slot granularity (15, 30, or 60)
# SMTP Settings # SMTP Settings
smtp_host: str | None = None smtp_host: str | None = None

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,14 +7,15 @@ 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,
ParticipantCreate, ParticipantCreate,
ParticipantUpdate,
ParticipantResponse, ParticipantResponse,
SyncResponse, SyncResponse,
ScheduleRequest, ScheduleRequest,
@@ -63,6 +64,7 @@ async def create_participant(
participant = Participant( participant = Participant(
name=data.name, name=data.name,
email=data.email, email=data.email,
timezone=data.timezone,
ics_url=data.ics_url, ics_url=data.ics_url,
) )
db.add(participant) db.add(participant)
@@ -95,6 +97,35 @@ async def get_participant(participant_id: UUID, db: AsyncSession = Depends(get_d
return participant return participant
@app.patch("/api/participants/{participant_id}", response_model=ParticipantResponse)
async def update_participant(
participant_id: UUID, data: ParticipantUpdate, db: AsyncSession = Depends(get_db)
):
result = await db.execute(
select(Participant).where(Participant.id == participant_id)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
if data.timezone is not None:
participant.timezone = data.timezone
if data.ics_url is not None:
participant.ics_url = data.ics_url if data.ics_url else None
await db.commit()
await db.refresh(participant)
# Re-sync calendar if ICS URL was updated
if data.ics_url is not None and participant.ics_url:
try:
await sync_participant_calendar(db, participant)
except Exception as e:
logger.warning(f"Calendar sync failed for {participant.email}: {e}")
return participant
@app.delete("/api/participants/{participant_id}") @app.delete("/api/participants/{participant_id}")
async def delete_participant(participant_id: UUID, db: AsyncSession = Depends(get_db)): async def delete_participant(participant_id: UUID, db: AsyncSession = Depends(get_db)):
result = await db.execute( result = await db.execute(
@@ -113,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}
@@ -143,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)
@@ -162,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
] ]
@@ -181,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

@@ -18,6 +18,7 @@ class Participant(Base):
) )
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
timezone: Mapped[str] = mapped_column(String(50), nullable=False, default="America/Toronto")
ics_url: Mapped[str | None] = mapped_column(Text, nullable=True) ics_url: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, nullable=False DateTime, default=datetime.utcnow, nullable=False

View File

@@ -7,6 +7,12 @@ from pydantic import BaseModel, EmailStr
class ParticipantCreate(BaseModel): class ParticipantCreate(BaseModel):
name: str name: str
email: EmailStr email: EmailStr
timezone: str = "America/Toronto"
ics_url: str | None = None
class ParticipantUpdate(BaseModel):
timezone: str | None = None
ics_url: str | None = None ics_url: str | None = None
@@ -14,6 +20,7 @@ class ParticipantResponse(BaseModel):
id: UUID id: UUID
name: str name: str
email: str email: str
timezone: str
ics_url: str | None ics_url: str | None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -25,12 +32,14 @@ class ParticipantResponse(BaseModel):
class TimeSlot(BaseModel): class TimeSlot(BaseModel):
day: str day: str
hour: int hour: int
start_time: datetime
availability: str availability: str
availableParticipants: list[str] availableParticipants: list[str]
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

@@ -61,7 +61,8 @@ def send_zulip_notification(
f"📅 **Meeting Scheduled**\n" f"📅 **Meeting Scheduled**\n"
f"**What:** {title}\n" f"**What:** {title}\n"
f"**When:** {zulip_time}\n" f"**When:** {zulip_time}\n"
f"**Who:** {people}" f"**Who:** {people}\n"
f"*Booked via [Meetly](https://meetly.app.monadical.io/)*"
) )
request = { request = {

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'

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> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<Index />} /> <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 */} {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>

View File

@@ -5,6 +5,7 @@ export interface ParticipantAPI {
id: string; id: string;
name: string; name: string;
email: string; email: string;
timezone: string;
ics_url: string | null; ics_url: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -13,6 +14,7 @@ export interface ParticipantAPI {
export interface TimeSlotAPI { export interface TimeSlotAPI {
day: string; day: string;
hour: number; hour: number;
start_time: string;
availability: 'full' | 'partial' | 'none'; availability: 'full' | 'partial' | 'none';
availableParticipants: string[]; availableParticipants: string[];
} }
@@ -20,6 +22,12 @@ export interface TimeSlotAPI {
export interface CreateParticipantRequest { export interface CreateParticipantRequest {
name: string; name: string;
email: string; email: string;
timezone: string;
ics_url?: string;
}
export interface UpdateParticipantRequest {
timezone?: string;
ics_url?: string; ics_url?: string;
} }
@@ -45,6 +53,15 @@ export async function createParticipant(data: CreateParticipantRequest): Promise
return handleResponse<ParticipantAPI>(response); 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> { export async function deleteParticipant(id: string): Promise<void> {
const response = await fetch(`${API_URL}/api/participants/${id}`, { const response = await fetch(`${API_URL}/api/participants/${id}`, {
method: 'DELETE', method: 'DELETE',
@@ -54,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;
@@ -98,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,31 +6,158 @@ 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 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 SLOT_INTERVAL_MINUTES = 15;
const SLOTS_PER_HOUR = 60 / SLOT_INTERVAL_MINUTES;
// Get the dates for Mon-Fri of the current week // Generate time slots: [{hour: 0, minute: 0}, {hour: 0, minute: 30}, {hour: 1, minute: 0}, ...]
const getWeekDates = () => { const timeSlots = Array.from({ length: 24 * SLOTS_PER_HOUR }, (_, i) => ({
hour: Math.floor(i / SLOTS_PER_HOUR),
minute: (i % SLOTS_PER_HOUR) * SLOT_INTERVAL_MINUTES,
}));
// Get the dates for Mon-Fri of a week in a specific timezone, offset by N weeks
const getWeekDates = (timezone: string, weekOffset: number = 0): string[] => {
// Get "now" in the target timezone
const now = new Date(); const now = new Date();
const monday = new Date(now); const formatter = new Intl.DateTimeFormat('en-CA', {
monday.setDate(now.getDate() - now.getDay() + 1); timeZone: timezone,
monday.setHours(0, 0, 0, 0); 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 + weekOffset * 7);
return dayNames.map((_, i) => { return dayNames.map((_, i) => {
const date = new Date(monday); const d = new Date(mondayDate);
date.setDate(monday.getDate() + i); d.setDate(mondayDate.getDate() + i);
return date.toISOString().split('T')[0]; // "YYYY-MM-DD" 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, hour, and minute in a timezone to a UTC Date
const toUTCDate = (dateStr: string, hour: number, minute: number, timezone: string): Date => {
// Create a date string that represents the given time in the given timezone
// Then parse it to get the UTC equivalent
const localDateStr = `${dateStr}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00`;
// Use a trick: format in UTC then in target TZ to find the offset
const testDate = new Date(localDateStr + 'Z'); // Treat as UTC first
// 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;
};
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 a time from one timezone to another
const convertTimeBetweenTimezones = (
hour: number,
minute: number,
dateStr: string,
fromTimezone: string,
toTimezone: string
): { hour: number; minute: number } => {
try {
// Create a UTC date for the given time in the source timezone
const utcDate = toUTCDate(dateStr, hour, minute, fromTimezone);
// Format the time in the target timezone
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: toTimezone,
hour: 'numeric',
minute: 'numeric',
hour12: false,
});
const parts = formatter.formatToParts(utcDate);
const targetHour = parseInt(parts.find(p => p.type === 'hour')?.value || '0');
const targetMinute = parseInt(parts.find(p => p.type === 'minute')?.value || '0');
return { hour: targetHour, minute: targetMinute };
} catch {
return { hour, minute };
}
};
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 = ({
@@ -39,11 +166,90 @@ 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(); const [expanded, setExpanded] = useState(false);
const maxWeekOffset = expanded ? EXPANDED_MAX_WEEK_OFFSET : DEFAULT_MAX_WEEK_OFFSET;
const weekDates = getWeekDates(displayTimezone, weekOffset);
const getSlot = (dateStr: string, hour: number) => { // Get current time info in display timezone
return slots.find((s) => s.day === dateStr && s.hour === hour); 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 = 28; // h-6 (24px) + gap (4px)
const rowsAbove = 4; // Show a few slots before current time
// Calculate which slot should be at the top (multiply by slots per hour)
const targetHour = todayIndex >= 0
? Math.max(0, currentHour - Math.floor(rowsAbove / SLOTS_PER_HOUR))
: 7; // Default to 7am for other weeks
scrollContainerRef.current.scrollTop = targetHour * SLOTS_PER_HOUR * rowHeight;
}, 100);
return () => clearTimeout(timer);
}, [weekOffset, todayIndex, currentHour, selectedParticipants.length, isLoading, slots.length]);
// Find a slot that matches the given display timezone date/hour/minute
const getSlot = (dateStr: string, hour: number, minute: number): TimeSlot | undefined => {
// Convert display timezone date/hour/minute to UTC
const targetUTC = toUTCDate(dateStr, hour, minute, displayTimezone);
return slots.find((s) => {
const slotDate = new Date(s.start_time);
// Compare UTC timestamps (with some tolerance for rounding)
return Math.abs(slotDate.getTime() - targetUTC.getTime()) < 60000; // 1 minute tolerance
});
}; };
const getEffectiveAvailability = (slot: TimeSlot) => { const getEffectiveAvailability = (slot: TimeSlot) => {
@@ -53,28 +259,40 @@ export const AvailabilityHeatmap = ({
return slot.availability; return slot.availability;
}; };
const formatHour = (hour: number) => { const formatTime = (hour: number, minute: number) => {
return `${hour.toString().padStart(2, '0')}:00`; return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
}; };
const isSlotTooSoon = (dateStr: string, hour: number) => { const isSlotTooSoon = (dateStr: string, hour: number, minute: number) => {
const slotTime = new Date(`${dateStr}T${formatHour(hour)}:00Z`); // Convert to UTC and compare with current time
const slotTimeUTC = toUTCDate(dateStr, hour, minute, 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 slotTime < twoHoursFromNow; return slotTimeUTC < twoHoursFromNow;
}; };
const getWeekDateRange = () => { const getWeekDateRange = () => {
const now = new Date(); if (weekDates.length < 5) return '';
const monday = new Date(now); const monday = new Date(weekDates[0] + 'T12:00:00Z');
monday.setDate(now.getDate() - now.getDay() + 1); const friday = new Date(weekDates[4] + 'T12:00:00Z');
const friday = new Date(monday);
friday.setDate(monday.getDate() + 4);
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)}`; return `${format(monday)} ${format(friday)}`;
}; };
// Format time for display in popover (in the display timezone)
const formatDisplayTime = (hour: number, minute: number) => {
// Create a date at that time
const date = new Date();
date.setHours(hour, minute, 0, 0);
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
}).format(date);
};
if (selectedParticipants.length === 0) { if (selectedParticipants.length === 0) {
return ( return (
<div className="bg-card rounded-xl shadow-card p-8 text-center animate-fade-in"> <div className="bg-card rounded-xl shadow-card p-8 text-center animate-fade-in">
@@ -97,49 +315,184 @@ export const AvailabilityHeatmap = ({
return ( return (
<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"> <div className="mb-6 flex justify-between items-start">
<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 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) => ( )}
{dayNames.map((dayName, i) => (
<div <div
key={dayName} key={dayName}
className="text-center text-sm font-medium text-muted-foreground py-2" 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>
))} ))}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{hours.map((hour) => ( {timeSlots.map(({ hour, minute }) => (
<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}-${minute}`}
{formatHour(hour)} ref={todayIndex >= 0 && hour === currentHour && minute === 0 ? 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 && minute === 0 && (
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
)}
{formatTime(hour, minute)}
</div> </div>
<div className="text-xs text-muted-foreground flex items-center justify-center">
{(() => {
const converted = convertTimeBetweenTimezones(hour, minute, weekDates[0] || '', displayTimezone, secondaryTimezone);
return formatTime(converted.hour, converted.minute);
})()}
</div>
</>
) : (
<div className="text-xs text-muted-foreground flex items-center justify-end pr-3 gap-1">
{todayIndex >= 0 && hour === currentHour && minute === 0 && (
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
)}
{formatTime(hour, minute)}
</div>
)}
{weekDates.map((dateStr, dayIndex) => { {weekDates.map((dateStr, dayIndex) => {
const slot = getSlot(dateStr, hour); const slot = getSlot(dateStr, hour, minute);
const dayName = dayNames[dayIndex]; const dayName = dayNames[dayIndex];
const tooSoon = isSlotTooSoon(dateStr, hour); const tooSoon = isSlotTooSoon(dateStr, hour, minute);
if (!slot) return <div key={`${dateStr}-${hour}`} className="h-12 bg-muted rounded" />; if (!slot) return <div key={`${dateStr}-${hour}-${minute}`} className="h-6 bg-muted rounded" />;
const effectiveAvailability = getEffectiveAvailability(slot); const effectiveAvailability = getEffectiveAvailability(slot);
// Calculate end time for display
const endMinute = minute + SLOT_INTERVAL_MINUTES;
const endHour = hour + Math.floor(endMinute / 60);
const endMinuteNormalized = endMinute % 60;
return ( return (
<Popover key={`${dateStr}-${hour}`}> <Popover key={`${dateStr}-${hour}-${minute}`}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
className={cn( className={cn(
"h-12 rounded-md transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", "h-6 rounded-md transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
tooSoon && "opacity-40 cursor-not-allowed", tooSoon && "opacity-40 cursor-not-allowed",
!tooSoon && "hover:scale-105 hover:shadow-md", !tooSoon && "hover:scale-105 hover:shadow-md",
effectiveAvailability === 'full' && "bg-availability-full hover:bg-availability-full/90", effectiveAvailability === 'full' && "bg-availability-full hover:bg-availability-full/90",
@@ -151,7 +504,7 @@ export const AvailabilityHeatmap = ({
<PopoverContent className="w-64 p-4 animate-scale-in" align="center"> <PopoverContent className="w-64 p-4 animate-scale-in" align="center">
<div className="space-y-3"> <div className="space-y-3">
<div className="font-semibold text-foreground"> <div className="font-semibold text-foreground">
{dayName} {formatHour(hour)}{formatHour(hour + 1)} {dayName} {formatDisplayTime(hour, minute)}{formatDisplayTime(endHour, endMinuteNormalized)}
</div> </div>
{tooSoon && ( {tooSoon && (
<div className="text-sm text-muted-foreground italic"> <div className="text-sm text-muted-foreground italic">
@@ -161,6 +514,7 @@ export const AvailabilityHeatmap = ({
<div className="space-y-2"> <div className="space-y-2">
{selectedParticipants.map((participant) => { {selectedParticipants.map((participant) => {
const isAvailable = slot.availableParticipants.includes(participant.name); const isAvailable = slot.availableParticipants.includes(participant.name);
return ( return (
<div <div
key={participant.id} key={participant.id}
@@ -174,7 +528,7 @@ export const AvailabilityHeatmap = ({
<span className={cn( <span className={cn(
isAvailable ? "text-foreground" : "text-muted-foreground" isAvailable ? "text-foreground" : "text-muted-foreground"
)}> )}>
{participant.name.split(' ')[0]} {isAvailable ? 'free' : 'busy'} {participant.name.split(' ')[0]}
</span> </span>
</div> </div>
); );
@@ -199,6 +553,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,499 @@
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 SLOT_INTERVAL_MINUTES = 15;
const SLOTS_PER_HOUR = 60 / SLOT_INTERVAL_MINUTES;
const WORKING_HOUR_START = 8; // 8 AM
const WORKING_HOUR_END = 18; // 6 PM
// Generate time slots with hour and minute
const ALL_TIME_SLOTS = Array.from({ length: 24 * SLOTS_PER_HOUR }, (_, i) => ({
hour: Math.floor(i / SLOTS_PER_HOUR),
minute: (i % SLOTS_PER_HOUR) * SLOT_INTERVAL_MINUTES,
}));
const WORKING_TIME_SLOTS = ALL_TIME_SLOTS.filter(
slot => slot.hour >= WORKING_HOUR_START && slot.hour < WORKING_HOUR_END
);
// 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 activeSlots = showFullDay ? ALL_TIME_SLOTS : WORKING_TIME_SLOTS;
const weekDates = useMemo(() => getWeekDates(displayTimezone, weekOffset), [displayTimezone, weekOffset]);
// Pre-compute slots lookup map for O(1) access
// Key: YYYY-MM-DD:Hour:Minute
const slotsMap = useMemo(() => {
const map = new Map<string, TimeSlot>();
slots.forEach(slot => {
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',
minute: 'numeric',
hour12: false
});
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;
const minute = parts.find(p => p.type === 'minute')?.value;
if (year && month && day && hour && minute) {
let h = parseInt(hour, 10);
let m = parseInt(minute, 10);
if (h === 24) h = 0; // Just in case
const key = `${year}-${month}-${day}:${h}:${m}`;
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, minute: number) => {
const key = `${formatDateKey(date)}:${hour}:${minute}`;
return slotsMap.get(key);
};
const formatTime = (hour: number, minute: number) => {
return new Date(0, 0, 0, hour, minute).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
};
const formatHourOnly = (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">
{activeSlots.map(({ hour, minute }) => {
const isNight = hour < 8 || hour >= 18;
// Calculate secondary time
let secondaryHour = hour + tzOffsetDiff;
if (secondaryHour >= 24) secondaryHour -= 24;
if (secondaryHour < 0) secondaryHour += 24;
// Calculate end time for display
const endMinute = minute + SLOT_INTERVAL_MINUTES;
const endHour = hour + Math.floor(endMinute / 60);
const endMinuteNormalized = endMinute % 60;
return (
<div
key={`${hour}-${minute}`}
className={cn(
"grid group items-stretch transition-colors border-border/30 last:border-0",
isNight ? "bg-muted/30" : "bg-card",
minute === 0 ? "border-t border-border" : "border-t border-border/10",
"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-2 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">
{minute === 0 ? (
<div className="flex items-center gap-1.5 font-bold text-foreground/80">
{isNight ? (
<Moon className="w-3 h-3 text-slate-400/50" />
) : (
<Sun className="w-3 h-3 text-amber-500/50" />
)}
<span>{formatTime(hour, minute)}</span>
</div>
) : (
<span className="text-[10px] opacity-0 group-hover:opacity-50 transition-opacity">
:{minute.toString().padStart(2, '0')}
</span>
)}
</div>
{showSecondaryTimezone && (
<span className="text-[10px] text-muted-foreground/60 font-mono">
{formatTime(secondaryHour, minute)}
</span>
)}
</div>
{/* Days */}
{weekDates.map(date => {
const slot = getSlotForCell(date, hour, minute);
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()}-${minute}`} className="p-0.5 h-[32px] 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 ? "scale-[0.98] hover:scale-105 hover:z-10 hover:shadow-lg hover:ring-2 ring-primary/20" : ""
)}>
{/* Mini Indicators for color-blind accessibility or density */}
{isFull && <Check className="w-3 h-3 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" />
{formatTime(hour, minute)} - {formatTime(endHour, endMinuteNormalized)}
</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,6 +1,27 @@
import { Calendar } from 'lucide-react'; import { Calendar, Moon, Sun } from 'lucide-react';
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">
@@ -13,8 +34,19 @@ 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">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-sm font-medium text-primary"> <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
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium text-white"
style={{ backgroundColor: getAvatarColor("AR") }}
>
AR AR
</div> </div>
</div> </div>

View File

@@ -3,23 +3,38 @@ 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 } 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, getCalendarNameFromUrl } from '@/lib/utils';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
interface ParticipantManagerProps { interface ParticipantManagerProps {
participants: Participant[]; 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; onRemoveParticipant: (id: string) => void;
onUpdateParticipant?: (id: string, data: { timezone?: string; ics_url?: string }) => Promise<void>;
} }
export const ParticipantManager = ({ export const ParticipantManager = ({
participants, participants,
onAddParticipant, onAddParticipant,
onRemoveParticipant, onRemoveParticipant,
onUpdateParticipant,
}: ParticipantManagerProps) => { }: ParticipantManagerProps) => {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [icsLink, setIcsLink] = useState(''); const [icsLink, setIcsLink] = useState('');
// Edit state
const [editingId, setEditingId] = useState<string | null>(null);
const [editIcsLink, setEditIcsLink] = useState('');
const [isUpdating, setIsUpdating] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
@@ -34,7 +49,12 @@ export const ParticipantManager = ({
return; return;
} }
onAddParticipant({ name: name.trim(), email: email.trim(), icsLink: icsLink.trim() || '' }); onAddParticipant({
name: name.trim(),
email: email.trim(),
timezone: 'America/Toronto',
icsLink: icsLink.trim() || ''
});
setName(''); setName('');
setEmail(''); setEmail('');
setIcsLink(''); setIcsLink('');
@@ -45,6 +65,40 @@ export const ParticipantManager = ({
}); });
}; };
const startEditing = (participant: Participant) => {
setEditingId(participant.id);
setEditIcsLink(participant.icsLink || '');
};
const cancelEditing = () => {
setEditingId(null);
setEditIcsLink('');
};
const saveEditing = async (participantId: string) => {
if (!onUpdateParticipant) return;
setIsUpdating(true);
try {
await onUpdateParticipant(participantId, {
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) => { const getInitials = (name: string) => {
return name return name
.split(' ') .split(' ')
@@ -64,7 +118,7 @@ export const ParticipantManager = ({
</h3> </h3>
<form onSubmit={handleSubmit} className="space-y-4"> <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-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Name</Label> <Label htmlFor="name">Name</Label>
<Input <Input
@@ -89,10 +143,10 @@ export const ParticipantManager = ({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="icsLink">Calendar ICS Link (optional)</Label> <Label htmlFor="icsLink">Calendar ICS Link</Label>
<Input <Input
id="icsLink" id="icsLink"
placeholder="https://calendar.google.com/..." placeholder="https://..."
value={icsLink} value={icsLink}
onChange={(e) => setIcsLink(e.target.value)} onChange={(e) => setIcsLink(e.target.value)}
className="bg-background" className="bg-background"
@@ -123,10 +177,16 @@ export const ParticipantManager = ({
{participants.map((participant) => ( {participants.map((participant) => (
<div <div
key={participant.id} 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"
> >
{editingId === participant.id ? (
// Edit mode
<div className="space-y-4">
<div className="flex items-center gap-3"> <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"> <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)} {getInitials(participant.name)}
</div> </div>
<div> <div>
@@ -135,6 +195,87 @@ export const ParticipantManager = ({
</div> </div>
</div> </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 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>
{participant.icsLink ? (
<TooltipProvider>
<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>
</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">
<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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -144,6 +285,9 @@ export const ParticipantManager = ({
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</Button> </Button>
</div> </div>
</div>
)}
</div>
))} ))}
</div> </div>
)} )}

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, User } from 'lucide-react'; import { X, Plus, Search, AlertCircle, Info } from 'lucide-react';
import { cn } 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,78 +93,121 @@ 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
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium text-white"
style={{ backgroundColor: getAvatarColor(participant.name) }}
> >
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium text-primary">
{getInitials(participant.name)} {getInitials(participant.name)}
</div> </div>
<div> <div>
<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.connected && ( <span className="ml-auto text-xs flex items-center gap-1">
<span className="ml-auto text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded"> {participant.icsLink ? (
Not connected <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" />
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 bg-primary/20 flex items-center justify-center text-xs font-medium">
{getInitials(participant.name)}
</div>
<span className="font-medium">{participant.name.split(' ')[0]}</span>
<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

@@ -0,0 +1,224 @@
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[] => {
let timezones: string[] = [];
try {
timezones = Intl.supportedValuesOf('timeZone');
} catch {
// Fallback for older browsers
timezones = [
'UTC',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Toronto',
'America/Vancouver',
'America/Montreal',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Asia/Tokyo',
'Asia/Shanghai',
'Asia/Singapore',
'Australia/Sydney',
'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
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.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>
</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

@@ -1,103 +1,159 @@
@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 base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
:root { @font-face {
--background: 210 20% 98%; font-family: 'Inter';
--foreground: 222 47% 11%; font-style: normal;
font-weight: 400;
--card: 0 0% 100%; font-display: swap;
--card-foreground: 222 47% 11%; src: url('/fonts/inter-400.woff2') format('woff2');
}
--popover: 0 0% 100%; @font-face {
--popover-foreground: 222 47% 11%; font-family: 'Inter';
font-style: normal;
--primary: 173 58% 39%; font-weight: 500;
--primary-foreground: 0 0% 100%; font-display: swap;
src: url('/fonts/inter-500.woff2') format('woff2');
--secondary: 210 20% 96%; }
--secondary-foreground: 222 47% 11%; @font-face {
font-family: 'Inter';
--muted: 210 20% 94%; font-style: normal;
--muted-foreground: 215 16% 47%; font-weight: 600;
font-display: swap;
--accent: 173 58% 94%; src: url('/fonts/inter-600.woff2') format('woff2');
--accent-foreground: 173 58% 25%; }
@font-face {
--destructive: 0 84% 60%; font-family: 'Source Serif Pro';
--destructive-foreground: 0 0% 100%; font-style: normal;
font-weight: 400;
--border: 214 32% 91%; font-display: swap;
--input: 214 32% 91%; src: url('/fonts/source-serif-pro-400.woff2') format('woff2');
--ring: 173 58% 39%; }
@font-face {
--radius: 0.75rem; font-family: 'Source Serif Pro';
font-style: normal;
/* Custom colors for heatmap */ font-weight: 600;
--availability-full: 142 71% 45%; font-display: swap;
--availability-partial: 48 96% 53%; src: url('/fonts/source-serif-pro-600.woff2') format('woff2');
--availability-none: 215 16% 85%; }
@font-face {
/* Shadows */ font-family: 'Source Serif Pro';
--shadow-sm: 0 1px 2px 0 hsl(222 47% 11% / 0.05); font-style: normal;
--shadow-md: 0 4px 6px -1px hsl(222 47% 11% / 0.1), 0 2px 4px -2px hsl(222 47% 11% / 0.1); font-weight: 700;
--shadow-lg: 0 10px 15px -3px hsl(222 47% 11% / 0.1), 0 4px 6px -4px hsl(222 47% 11% / 0.1); font-display: swap;
--shadow-xl: 0 20px 25px -5px hsl(222 47% 11% / 0.1), 0 8px 10px -6px hsl(222 47% 11% / 0.1); src: url('/fonts/source-serif-pro-700.woff2') format('woff2');
--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%;
} }
:root {
/* Greyhaven Colors converted to HSL */
/* Light Mode Defaults */
--background: 60 9% 93%; /* #F0F0EC */
--foreground: 60 5% 8%; /* #161614 */
--card: 60 9% 97%; /* #F9F9F7 */
--card-foreground: 60 5% 8%;
--popover: 60 9% 97%;
--popover-foreground: 60 5% 8%;
--primary: 18 68% 51%; /* #D95E2A (RGB 217 94 42) */
--primary-foreground: 60 9% 97%;
--secondary: 60 9% 93%;
--secondary-foreground: 60 3% 18%; /* #2F2F2C */
--muted: 60 5% 84%; /* Darker than background for contrast */
--muted-foreground: 60 2% 34%; /* #575753 */
--accent: 60 9% 85%; /* #DDD7 */
--accent-foreground: 60 5% 8%;
--destructive: 0 57% 45%; /* #B43232 (RGB 180 50 50) */
--destructive-foreground: 60 9% 97%;
--border: 60 5% 77%; /* #C4C4BD (RGB 196 196 189) */
--input: 60 5% 77%;
--ring: 18 68% 51%;
--radius: 0.375rem;
/* 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 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 mode */
.dark { .dark {
--background: 222 47% 6%; --background: 60 5% 8%; /* #161614 */
--foreground: 210 20% 98%; --foreground: 60 9% 93%; /* #F0F0EC */
--card: 222 47% 8%; --card: 60 5% 10%; /* Slightly lighter than bg */
--card-foreground: 210 20% 98%; --card-foreground: 60 9% 93%;
--popover: 222 47% 8%; --popover: 60 5% 10%;
--popover-foreground: 210 20% 98%; --popover-foreground: 60 9% 93%;
--primary: 173 58% 45%; --primary: 18 68% 51%; /* Keep primary brand color */
--primary-foreground: 0 0% 100%; --primary-foreground: 60 9% 97%;
--secondary: 222 47% 14%; --secondary: 60 5% 15%;
--secondary-foreground: 210 20% 98%; --secondary-foreground: 60 9% 93%;
--muted: 222 47% 14%; --muted: 60 5% 15%;
--muted-foreground: 215 16% 65%; --muted-foreground: 60 5% 65%;
--accent: 173 58% 15%; --accent: 60 5% 15%;
--accent-foreground: 173 58% 75%; --accent-foreground: 60 9% 93%;
--destructive: 0 62% 30%; --destructive: 0 62% 30%;
--destructive-foreground: 210 20% 98%; --destructive-foreground: 60 9% 97%;
--border: 222 47% 17%; --border: 60 5% 20%;
--input: 222 47% 17%; --input: 60 5% 20%;
--ring: 173 58% 45%; --ring: 18 68% 51%;
--availability-full: 142 71% 35%; /* Dark mode availability colors */
--availability-partial: 48 96% 40%; --availability-full: 142 70% 45%; /* Brighter green for dark mode */
--availability-none: 222 47% 20%; --availability-partial: 25 80% 65%; /* Same orange */
--availability-none: 60 5% 20%; /* Darker grey */
--sidebar-background: 240 5.9% 10%; --sidebar-background: 60 5% 8%;
--sidebar-foreground: 240 4.8% 95.9%; --sidebar-foreground: 60 9% 93%;
--sidebar-primary: 224.3 76.3% 48%; --sidebar-primary: 18 68% 51%;
--sidebar-primary-foreground: 0 0% 100%; --sidebar-primary-foreground: 60 9% 97%;
--sidebar-accent: 240 3.7% 15.9%; --sidebar-accent: 60 5% 15%;
--sidebar-accent-foreground: 240 4.8% 95.9%; --sidebar-accent-foreground: 60 9% 93%;
--sidebar-border: 240 3.7% 15.9%; --sidebar-border: 60 5% 20%;
--sidebar-ring: 217.2 91.2% 59.8%; --sidebar-ring: 18 68% 51%;
} }
} }
@@ -107,8 +163,12 @@
} }
body { body {
@apply bg-background text-foreground font-sans antialiased; @apply bg-background text-foreground antialiased;
font-family: 'DM Sans', sans-serif; font-family: var(--font-display);
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-serif);
} }
} }
@@ -173,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

@@ -4,3 +4,32 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { 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 {
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,9 +1,12 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Header } from '@/components/Header'; 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';
@@ -13,26 +16,59 @@ 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';
import { import {
fetchParticipants, fetchParticipants,
createParticipant, createParticipant,
updateParticipant,
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 {
@@ -40,22 +76,42 @@ function apiToParticipant(p: ParticipantAPI): Participant {
id: p.id, id: p.id,
name: p.name, name: p.name,
email: p.email, email: p.email,
timezone: p.timezone,
icsLink: p.ics_url, icsLink: p.ics_url,
connected: true, 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 [participants, setParticipants] = useState<Participant[]>([]);
const [selectedParticipants, setSelectedParticipants] = useState<Participant[]>([]); const [selectedParticipants, setSelectedParticipants] = useState<Participant[]>([]);
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();
useEffect(() => {
// Sync internal state if prop changes (e.g. browser back button)
setActiveTab(defaultTab);
}, [defaultTab]);
const handleTabChange = (value: string) => {
setActiveTab(value);
navigate(`/${value}`);
};
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem(SETTINGS_KEY); const stored = localStorage.getItem(SETTINGS_KEY);
if (stored) { if (stored) {
@@ -81,7 +137,7 @@ const Index = () => {
} else { } else {
setAvailabilitySlots([]); setAvailabilitySlots([]);
} }
}, [selectedParticipants]); }, [selectedParticipants, weekOffset]);
const loadParticipants = async () => { const loadParticipants = async () => {
try { try {
@@ -100,7 +156,7 @@ const Index = () => {
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({
@@ -113,11 +169,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 { try {
const created = await createParticipant({ const created = await createParticipant({
name: data.name, name: data.name,
email: data.email, email: data.email,
timezone: data.timezone,
ics_url: data.icsLink || undefined, ics_url: data.icsLink || undefined,
}); });
setParticipants((prev) => [...prev, apiToParticipant(created)]); setParticipants((prev) => [...prev, apiToParticipant(created)]);
@@ -151,6 +208,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 () => { const handleSyncCalendars = async () => {
setIsSyncing(true); setIsSyncing(true);
try { try {
@@ -178,27 +245,52 @@ const Index = () => {
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 />
<main className="container max-w-5xl mx-auto px-4 py-8"> <main className="container max-w-5xl mx-auto px-4 py-8">
<Tabs defaultValue="schedule" className="space-y-6"> <Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
<TabsList className="grid w-full max-w-md mx-auto grid-cols-2"> <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"> <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" /> <Users className="w-4 h-4" />
Participants People
</TabsTrigger> </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" /> <CalendarDays className="w-4 h-4" />
Schedule Schedule
</TabsTrigger> </TabsTrigger>
</TabsList> </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"> <div className="text-center mb-6">
<h2 className="text-3xl font-bold text-foreground mb-2"> <h2 className="text-3xl font-bold text-foreground mb-2">
Manage Participants Manage People
</h2> </h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Add team members with their calendar ICS links Add team members with their calendar ICS links
@@ -209,6 +301,7 @@ const Index = () => {
participants={participants} participants={participants}
onAddParticipant={handleAddParticipant} onAddParticipant={handleAddParticipant}
onRemoveParticipant={handleRemoveParticipant} onRemoveParticipant={handleRemoveParticipant}
onUpdateParticipant={handleUpdateParticipant}
/> />
</TabsContent> </TabsContent>
@@ -216,6 +309,10 @@ const Index = () => {
<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"
@@ -224,19 +321,42 @@ const Index = () => {
> >
<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}
@@ -245,12 +365,70 @@ const Index = () => {
} }
/> />
</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
@@ -265,7 +443,7 @@ const Index = () => {
<Users className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" /> <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> <h3 className="text-lg font-medium text-foreground mb-2">No participants yet</h3>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Add participants in the Participants tab to start scheduling. Add people in the People tab to start scheduling.
</p> </p>
</div> </div>
) : ( ) : (
@@ -281,13 +459,33 @@ const Index = () => {
/> />
</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>
@@ -303,6 +501,8 @@ const Index = () => {
}} }}
slot={selectedSlot} slot={selectedSlot}
participants={selectedParticipants} participants={selectedParticipants}
displayTimezone={settings.displayTimezone}
onSuccess={loadAvailability}
/> />
</div > </div >
); );

View File

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

View File

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