Compare commits
16 Commits
a8ec0936d4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 942a7d3c3a | |||
|
|
26e553bfd0 | ||
| 7a0f11ee88 | |||
|
|
267c1747f4 | ||
| 9412d5c0a0 | |||
|
|
192b885149 | ||
| 10675b6846 | |||
|
|
e544872430 | ||
| f2142633d4 | |||
|
|
117b28c2e9 | ||
|
|
880925f30d | ||
| daa0afaa25 | |||
|
|
49dbc786e9 | ||
|
|
922b6f31d1 | ||
|
|
f02b6ca886 | ||
|
|
cd62d7f94b |
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
@@ -9,6 +10,10 @@ config = context.config
|
|||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name)
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# Use SYNC_DATABASE_URL env var if set, otherwise fall back to alembic.ini
|
||||||
|
if os.getenv("SYNC_DATABASE_URL"):
|
||||||
|
config.set_main_option("sqlalchemy.url", os.getenv("SYNC_DATABASE_URL"))
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline():
|
def run_migrations_offline():
|
||||||
url = config.get_main_option("sqlalchemy.url")
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
|||||||
@@ -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 ###
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ 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))
|
hours = list(range(0, 24))
|
||||||
slots = []
|
slots = []
|
||||||
|
|
||||||
for day_offset, day_name in enumerate(days):
|
for day_offset, day_name in enumerate(days):
|
||||||
@@ -98,6 +98,7 @@ 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,
|
||||||
|
"start_time": slot_start,
|
||||||
"availability": availability,
|
"availability": availability,
|
||||||
"availableParticipants": available_participants,
|
"availableParticipants": available_participants,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
50
docker-compose.dev.yml
Normal file
50
docker-compose.dev.yml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: availability
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/availability
|
||||||
|
SYNC_DATABASE_URL: postgresql://postgres:postgres@db:5432/availability
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "8001:8000"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./backend/src:/app/src
|
||||||
|
- ./backend/alembic:/app/alembic
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "5174:8080"
|
||||||
|
environment:
|
||||||
|
VITE_API_URL: http://localhost:8001
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
volumes:
|
||||||
|
- ./frontend/src:/app/src
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
services:
|
|
||||||
db:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required}
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-availability}
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
backend:
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-availability}
|
|
||||||
SYNC_DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-availability}
|
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
|
||||||
SMTP_USER: ${SMTP_USER:-}
|
|
||||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
|
||||||
ZULIP_SITE: ${ZULIP_SITE:-}
|
|
||||||
ZULIP_EMAIL: ${ZULIP_EMAIL:-}
|
|
||||||
ZULIP_API_KEY: ${ZULIP_API_KEY:-}
|
|
||||||
ZULIP_STREAM: ${ZULIP_STREAM:-general}
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build:
|
|
||||||
context: ./frontend
|
|
||||||
dockerfile: Dockerfile.prod
|
|
||||||
args:
|
|
||||||
VITE_API_URL: ${VITE_API_URL:-}
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
@@ -2,49 +2,53 @@ services:
|
|||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required}
|
||||||
POSTGRES_DB: availability
|
POSTGRES_DB: ${POSTGRES_DB:-availability}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
ports:
|
|
||||||
- "5433:5432"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/availability
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-availability}
|
||||||
SYNC_DATABASE_URL: postgresql://postgres:postgres@db:5432/availability
|
SYNC_DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-availability}
|
||||||
env_file:
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
- .env
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
ports:
|
SMTP_USER: ${SMTP_USER:-}
|
||||||
- "8001:8000"
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
|
ZULIP_SITE: ${ZULIP_SITE:-}
|
||||||
|
ZULIP_EMAIL: ${ZULIP_EMAIL:-}
|
||||||
|
ZULIP_API_KEY: ${ZULIP_API_KEY:-}
|
||||||
|
ZULIP_STREAM: ${ZULIP_STREAM:-general}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
labels:
|
||||||
- ./backend/src:/app/src
|
- traefik.http.middlewares.authentik-auth@file
|
||||||
- ./backend/alembic:/app/alembic
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile.prod
|
||||||
ports:
|
args:
|
||||||
- "5174:8080"
|
VITE_API_URL: ${VITE_API_URL:-}
|
||||||
environment:
|
|
||||||
VITE_API_URL: http://localhost:8001
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
volumes:
|
labels:
|
||||||
- ./frontend/src:/app/src
|
- traefik.http.middlewares.authentik-auth@file
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- '80'
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -15,18 +15,14 @@ ENV VITE_API_URL=${VITE_API_URL}
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM caddy:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
# Copy built assets
|
# Copy built assets
|
||||||
COPY --from=builder /app/dist /srv
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# Caddyfile for SPA routing
|
# Copy nginx config
|
||||||
RUN echo ':8080 { \
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
root * /srv \
|
|
||||||
file_server \
|
|
||||||
try_files {path} /index.html \
|
|
||||||
}' > /etc/caddy/Caddyfile
|
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 80
|
||||||
|
|
||||||
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
19
frontend/nginx.conf
Normal file
19
frontend/nginx.conf
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
}
|
||||||
BIN
frontend/public/fonts/inter-400.woff2
Normal file
BIN
frontend/public/fonts/inter-400.woff2
Normal file
Binary file not shown.
BIN
frontend/public/fonts/inter-500.woff2
Normal file
BIN
frontend/public/fonts/inter-500.woff2
Normal file
Binary file not shown.
BIN
frontend/public/fonts/inter-600.woff2
Normal file
BIN
frontend/public/fonts/inter-600.woff2
Normal file
Binary file not shown.
BIN
frontend/public/fonts/source-serif-pro-400.woff2
Normal file
BIN
frontend/public/fonts/source-serif-pro-400.woff2
Normal file
Binary file not shown.
BIN
frontend/public/fonts/source-serif-pro-600.woff2
Normal file
BIN
frontend/public/fonts/source-serif-pro-600.woff2
Normal file
Binary file not shown.
BIN
frontend/public/fonts/source-serif-pro-700.woff2
Normal file
BIN
frontend/public/fonts/source-serif-pro-700.woff2
Normal file
Binary file not shown.
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,31 +6,148 @@ 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 hours = Array.from({ length: 24 }, (_, i) => i); // 0-23
|
||||||
|
|
||||||
// Get the dates for Mon-Fri of the current week
|
// Get the dates for Mon-Fri of a week in a specific timezone, offset by N weeks
|
||||||
const getWeekDates = () => {
|
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 and hour in a timezone to a UTC Date
|
||||||
|
const toUTCDate = (dateStr: string, hour: number, timezone: string): Date => {
|
||||||
|
// Create a date string that represents the given hour in the given timezone
|
||||||
|
// Then parse it to get the UTC equivalent
|
||||||
|
const localDateStr = `${dateStr}T${String(hour).padStart(2, '0')}:00:00`;
|
||||||
|
|
||||||
|
// Use a trick: format in UTC then in target TZ to find the offset
|
||||||
|
const testDate = new Date(localDateStr + 'Z'); // Treat as UTC first
|
||||||
|
|
||||||
|
// Get what hour this would be in the target timezone
|
||||||
|
const tzHour = parseInt(
|
||||||
|
new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: timezone,
|
||||||
|
hour: 'numeric',
|
||||||
|
hour12: false,
|
||||||
|
}).format(testDate)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate offset in hours
|
||||||
|
const offset = tzHour - testDate.getUTCHours();
|
||||||
|
|
||||||
|
// Adjust: if we want `hour` in timezone, subtract the offset to get UTC
|
||||||
|
const utcDate = new Date(localDateStr + 'Z');
|
||||||
|
utcDate.setUTCHours(utcDate.getUTCHours() - offset);
|
||||||
|
|
||||||
|
return utcDate;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIN_WEEK_OFFSET = 0;
|
||||||
|
const DEFAULT_MAX_WEEK_OFFSET = 1;
|
||||||
|
const EXPANDED_MAX_WEEK_OFFSET = 4;
|
||||||
|
|
||||||
|
// Format timezone for display (e.g., "America/Toronto" -> "Toronto (EST)")
|
||||||
|
const formatTimezoneDisplay = (timezone: string): string => {
|
||||||
|
try {
|
||||||
|
const parts = timezone.split('/');
|
||||||
|
const city = parts[parts.length - 1].replace(/_/g, ' ');
|
||||||
|
const now = new Date();
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: timezone,
|
||||||
|
timeZoneName: 'short',
|
||||||
|
});
|
||||||
|
const formattedParts = formatter.formatToParts(now);
|
||||||
|
const tzAbbrev = formattedParts.find((p) => p.type === 'timeZoneName')?.value || '';
|
||||||
|
return `${city} (${tzAbbrev})`;
|
||||||
|
} catch {
|
||||||
|
return timezone;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get timezone abbreviation (e.g., "America/Toronto" -> "EST")
|
||||||
|
const getTimezoneAbbrev = (timezone: string): string => {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: timezone,
|
||||||
|
timeZoneName: 'short',
|
||||||
|
});
|
||||||
|
const parts = formatter.formatToParts(now);
|
||||||
|
return parts.find((p) => p.type === 'timeZoneName')?.value || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert an hour from one timezone to another
|
||||||
|
const convertHourBetweenTimezones = (
|
||||||
|
hour: number,
|
||||||
|
dateStr: string,
|
||||||
|
fromTimezone: string,
|
||||||
|
toTimezone: string
|
||||||
|
): number => {
|
||||||
|
try {
|
||||||
|
// Create a UTC date for the given hour in the source timezone
|
||||||
|
const utcDate = toUTCDate(dateStr, hour, fromTimezone);
|
||||||
|
|
||||||
|
// Format the hour in the target timezone
|
||||||
|
const targetHour = parseInt(
|
||||||
|
new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: toTimezone,
|
||||||
|
hour: 'numeric',
|
||||||
|
hour12: false,
|
||||||
|
}).format(utcDate)
|
||||||
|
);
|
||||||
|
|
||||||
|
return targetHour;
|
||||||
|
} catch {
|
||||||
|
return hour;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
interface AvailabilityHeatmapProps {
|
interface AvailabilityHeatmapProps {
|
||||||
slots: TimeSlot[];
|
slots: TimeSlot[];
|
||||||
selectedParticipants: Participant[];
|
selectedParticipants: Participant[];
|
||||||
onSlotSelect: (slot: TimeSlot) => void;
|
onSlotSelect: (slot: TimeSlot) => void;
|
||||||
showPartialAvailability?: boolean;
|
showPartialAvailability?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
weekOffset?: number;
|
||||||
|
onWeekOffsetChange?: (offset: number) => void;
|
||||||
|
displayTimezone?: string;
|
||||||
|
showSecondaryTimezone?: boolean;
|
||||||
|
secondaryTimezone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AvailabilityHeatmap = ({
|
export const AvailabilityHeatmap = ({
|
||||||
@@ -39,11 +156,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 = 52; // h-12 (48px) + gap (4px)
|
||||||
|
const rowsAbove = 2;
|
||||||
|
|
||||||
|
// Calculate which hour should be at the top
|
||||||
|
const targetHour = todayIndex >= 0
|
||||||
|
? Math.max(0, currentHour - rowsAbove)
|
||||||
|
: 7; // Default to 7am for other weeks
|
||||||
|
|
||||||
|
scrollContainerRef.current.scrollTop = targetHour * rowHeight;
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [weekOffset, todayIndex, currentHour, selectedParticipants.length, isLoading, slots.length]);
|
||||||
|
|
||||||
|
// Find a slot that matches the given display timezone date/hour
|
||||||
|
const getSlot = (dateStr: string, hour: number): TimeSlot | undefined => {
|
||||||
|
// Convert display timezone date/hour to UTC
|
||||||
|
const targetUTC = toUTCDate(dateStr, hour, displayTimezone);
|
||||||
|
|
||||||
|
return slots.find((s) => {
|
||||||
|
const slotDate = new Date(s.start_time);
|
||||||
|
// Compare UTC timestamps (with some tolerance for rounding)
|
||||||
|
return Math.abs(slotDate.getTime() - targetUTC.getTime()) < 60000; // 1 minute tolerance
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEffectiveAvailability = (slot: TimeSlot) => {
|
const getEffectiveAvailability = (slot: TimeSlot) => {
|
||||||
@@ -58,23 +254,35 @@ export const AvailabilityHeatmap = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isSlotTooSoon = (dateStr: string, hour: number) => {
|
const isSlotTooSoon = (dateStr: string, hour: number) => {
|
||||||
const slotTime = new Date(`${dateStr}T${formatHour(hour)}:00Z`);
|
// Convert to UTC and compare with current time
|
||||||
|
const slotTimeUTC = toUTCDate(dateStr, hour, displayTimezone);
|
||||||
const now = new Date();
|
const 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 hour for display in popover (in the display timezone)
|
||||||
|
const formatDisplayTime = (hour: number) => {
|
||||||
|
// Create a date at that hour
|
||||||
|
const date = new Date();
|
||||||
|
date.setHours(hour, 0, 0, 0);
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
if (selectedParticipants.length === 0) {
|
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,35 +305,162 @@ 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) => (
|
{hours.map((hour) => (
|
||||||
<div key={hour} className="grid grid-cols-[60px_repeat(5,1fr)] gap-1">
|
<div
|
||||||
<div className="text-xs text-muted-foreground flex items-center justify-end pr-3">
|
key={hour}
|
||||||
|
ref={todayIndex >= 0 && hour === currentHour ? currentHourRef : undefined}
|
||||||
|
className={cn(
|
||||||
|
"grid gap-1",
|
||||||
|
showSecondaryTimezone
|
||||||
|
? "grid-cols-[50px_50px_repeat(5,1fr)]"
|
||||||
|
: "grid-cols-[60px_repeat(5,1fr)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showSecondaryTimezone ? (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-primary font-medium flex items-center justify-center gap-1">
|
||||||
|
{todayIndex >= 0 && hour === currentHour && (
|
||||||
|
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
|
||||||
|
)}
|
||||||
{formatHour(hour)}
|
{formatHour(hour)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center justify-center">
|
||||||
|
{formatHour(convertHourBetweenTimezones(hour, weekDates[0] || '', displayTimezone, secondaryTimezone))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center justify-end pr-3 gap-1">
|
||||||
|
{todayIndex >= 0 && hour === currentHour && (
|
||||||
|
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
|
||||||
|
)}
|
||||||
|
{formatHour(hour)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{weekDates.map((dateStr, dayIndex) => {
|
{weekDates.map((dateStr, dayIndex) => {
|
||||||
const slot = getSlot(dateStr, hour);
|
const slot = getSlot(dateStr, hour);
|
||||||
const dayName = dayNames[dayIndex];
|
const dayName = dayNames[dayIndex];
|
||||||
@@ -151,7 +486,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)}–{formatDisplayTime(hour + 1)}
|
||||||
</div>
|
</div>
|
||||||
{tooSoon && (
|
{tooSoon && (
|
||||||
<div className="text-sm text-muted-foreground italic">
|
<div className="text-sm text-muted-foreground italic">
|
||||||
@@ -161,6 +496,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 +510,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 +535,7 @@ export const AvailabilityHeatmap = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-6 mt-6 pt-4 border-t border-border">
|
<div className="flex items-center justify-center gap-6 mt-6 pt-4 border-t border-border">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Calendar } from 'lucide-react';
|
import { Calendar } from 'lucide-react';
|
||||||
|
import { getAvatarColor } from '@/lib/utils';
|
||||||
|
|
||||||
export const Header = () => {
|
export const Header = () => {
|
||||||
return (
|
return (
|
||||||
@@ -14,7 +15,10 @@ export const Header = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-sm font-medium text-primary">
|
<div
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium text-white"
|
||||||
|
style={{ backgroundColor: getAvatarColor("AR") }}
|
||||||
|
>
|
||||||
AR
|
AR
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,40 +93,67 @@ export const ParticipantSelector = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div ref={containerRef} className="space-y-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
placeholder="Search people..."
|
placeholder="Search people..."
|
||||||
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)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
className="pl-10 h-12 bg-background border-border"
|
className="pl-10 h-12 bg-background border-border"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{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 z-10 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>
|
||||||
@@ -96,10 +171,16 @@ export const ParticipantSelector = ({
|
|||||||
)}
|
)}
|
||||||
style={{ animationDelay: `${index * 50}ms` }}
|
style={{ animationDelay: `${index * 50}ms` }}
|
||||||
>
|
>
|
||||||
<div className="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-xs font-medium">
|
<div
|
||||||
|
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium text-white"
|
||||||
|
style={{ backgroundColor: getAvatarColor(participant.name) }}
|
||||||
|
>
|
||||||
{getInitials(participant.name)}
|
{getInitials(participant.name)}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium">{participant.name.split(' ')[0]}</span>
|
<span className="font-medium">{participant.name.split(' ')[0]}</span>
|
||||||
|
{!participant.icsLink && (
|
||||||
|
<AlertCircle className="w-3 h-3 text-amber-600" title="No calendar linked" />
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => removeParticipant(participant.id)}
|
onClick={() => removeParticipant(participant.id)}
|
||||||
className="w-5 h-5 rounded-full hover:bg-primary/20 flex items-center justify-center transition-colors"
|
className="w-5 h-5 rounded-full hover:bg-primary/20 flex items-center justify-center transition-colors"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
217
frontend/src/components/TimezoneSelector.tsx
Normal file
217
frontend/src/components/TimezoneSelector.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Search, Globe, ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TimezoneSelectorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (timezone: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all IANA timezones
|
||||||
|
const getAllTimezones = (): string[] => {
|
||||||
|
try {
|
||||||
|
return Intl.supportedValuesOf('timeZone');
|
||||||
|
} catch {
|
||||||
|
// Fallback for older browsers
|
||||||
|
return [
|
||||||
|
'UTC',
|
||||||
|
'America/New_York',
|
||||||
|
'America/Chicago',
|
||||||
|
'America/Denver',
|
||||||
|
'America/Los_Angeles',
|
||||||
|
'America/Toronto',
|
||||||
|
'America/Vancouver',
|
||||||
|
'Europe/London',
|
||||||
|
'Europe/Paris',
|
||||||
|
'Europe/Berlin',
|
||||||
|
'Asia/Tokyo',
|
||||||
|
'Asia/Shanghai',
|
||||||
|
'Asia/Singapore',
|
||||||
|
'Australia/Sydney',
|
||||||
|
'Pacific/Auckland',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get UTC offset for a timezone
|
||||||
|
const getTimezoneOffset = (timezone: string): string => {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: timezone,
|
||||||
|
timeZoneName: 'shortOffset',
|
||||||
|
});
|
||||||
|
const parts = formatter.formatToParts(now);
|
||||||
|
const offsetPart = parts.find((p) => p.type === 'timeZoneName');
|
||||||
|
return offsetPart?.value || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get current time in a timezone
|
||||||
|
const getCurrentTimeInTimezone = (timezone: string): string => {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: timezone,
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
}).format(new Date());
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format timezone for display (e.g., "America/New_York" -> "New York")
|
||||||
|
const formatTimezoneLabel = (timezone: string): string => {
|
||||||
|
const parts = timezone.split('/');
|
||||||
|
const city = parts[parts.length - 1];
|
||||||
|
return city.replace(/_/g, ' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_TIMEZONES = getAllTimezones();
|
||||||
|
|
||||||
|
export const TimezoneSelector = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: TimezoneSelectorProps) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const [hoveredTimezone, setHoveredTimezone] = useState<string | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredTimezones = ALL_TIMEZONES.filter((tz) => {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
const tzLower = tz.toLowerCase();
|
||||||
|
const labelLower = formatTimezoneLabel(tz).toLowerCase();
|
||||||
|
const offset = getTimezoneOffset(tz).toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
tzLower.includes(query) ||
|
||||||
|
labelLower.includes(query) ||
|
||||||
|
offset.includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectTimezone = (timezone: string) => {
|
||||||
|
onChange(timezone);
|
||||||
|
setSearchQuery('');
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedOffset = getTimezoneOffset(value);
|
||||||
|
const selectedLabel = formatTimezoneLabel(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={cn('relative', className)}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm',
|
||||||
|
'bg-muted hover:bg-accent transition-colors',
|
||||||
|
'border border-transparent hover:border-border'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Globe className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-foreground font-medium">{selectedLabel}</span>
|
||||||
|
<span className="text-muted-foreground">{selectedOffset}</span>
|
||||||
|
<ChevronDown className={cn(
|
||||||
|
'w-4 h-4 text-muted-foreground transition-transform',
|
||||||
|
isDropdownOpen && 'rotate-180'
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<div className="absolute z-20 right-0 mt-2 w-80 bg-popover border border-border rounded-lg shadow-popover animate-scale-in overflow-hidden">
|
||||||
|
<div className="p-2 border-b border-border">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search timezone..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10 h-9 bg-background border-border"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-64 overflow-y-auto">
|
||||||
|
{filteredTimezones.length === 0 ? (
|
||||||
|
<div className="px-4 py-3 text-sm text-muted-foreground text-center">
|
||||||
|
No timezones found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredTimezones.slice(0, 50).map((timezone) => {
|
||||||
|
const isSelected = timezone === value;
|
||||||
|
const offset = getTimezoneOffset(timezone);
|
||||||
|
const label = formatTimezoneLabel(timezone);
|
||||||
|
const isHovered = hoveredTimezone === timezone;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={timezone}
|
||||||
|
onClick={() => selectTimezone(timezone)}
|
||||||
|
onMouseEnter={() => setHoveredTimezone(timezone)}
|
||||||
|
onMouseLeave={() => setHoveredTimezone(null)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-4 py-2.5 flex items-center justify-between text-left transition-colors',
|
||||||
|
isSelected ? 'bg-primary/10 text-primary' : 'hover:bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={cn(
|
||||||
|
'text-xs font-mono w-16',
|
||||||
|
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{offset}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className={cn(
|
||||||
|
'font-medium',
|
||||||
|
isSelected ? 'text-primary' : 'text-foreground'
|
||||||
|
)}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{timezone}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isHovered && (
|
||||||
|
<span className="text-xs text-muted-foreground animate-fade-in">
|
||||||
|
{getCurrentTimeInTimezone(timezone)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredTimezones.length > 50 && (
|
||||||
|
<div className="px-4 py-2 text-xs text-muted-foreground text-center border-t border-border">
|
||||||
|
Showing 50 of {filteredTimezones.length} results
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,104 +1,119 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap');
|
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/inter-400.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/inter-500.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/inter-600.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Serif Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/source-serif-pro-400.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Serif Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/source-serif-pro-600.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Serif Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/source-serif-pro-700.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: 210 20% 98%;
|
/* Greyhaven Colors converted to HSL */
|
||||||
--foreground: 222 47% 11%;
|
/* Light Mode Defaults */
|
||||||
|
--background: 60 9% 93%; /* #F0F0EC */
|
||||||
|
--foreground: 60 5% 8%; /* #161614 */
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
--card: 60 9% 97%; /* #F9F9F7 */
|
||||||
--card-foreground: 222 47% 11%;
|
--card-foreground: 60 5% 8%;
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
--popover: 60 9% 97%;
|
||||||
--popover-foreground: 222 47% 11%;
|
--popover-foreground: 60 5% 8%;
|
||||||
|
|
||||||
--primary: 173 58% 39%;
|
--primary: 18 68% 51%; /* #D95E2A (RGB 217 94 42) */
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 60 9% 97%;
|
||||||
|
|
||||||
--secondary: 210 20% 96%;
|
--secondary: 60 9% 93%;
|
||||||
--secondary-foreground: 222 47% 11%;
|
--secondary-foreground: 60 3% 18%; /* #2F2F2C */
|
||||||
|
|
||||||
--muted: 210 20% 94%;
|
--muted: 60 5% 84%; /* Darker than background for contrast */
|
||||||
--muted-foreground: 215 16% 47%;
|
--muted-foreground: 60 2% 34%; /* #575753 */
|
||||||
|
|
||||||
--accent: 173 58% 94%;
|
--accent: 60 9% 85%; /* #DDD7 */
|
||||||
--accent-foreground: 173 58% 25%;
|
--accent-foreground: 60 5% 8%;
|
||||||
|
|
||||||
--destructive: 0 84% 60%;
|
--destructive: 0 57% 45%; /* #B43232 (RGB 180 50 50) */
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 60 9% 97%;
|
||||||
|
|
||||||
--border: 214 32% 91%;
|
--border: 60 5% 77%; /* #C4C4BD (RGB 196 196 189) */
|
||||||
--input: 214 32% 91%;
|
--input: 60 5% 77%;
|
||||||
--ring: 173 58% 39%;
|
--ring: 18 68% 51%;
|
||||||
|
|
||||||
--radius: 0.75rem;
|
--radius: 0.375rem;
|
||||||
|
|
||||||
/* Custom colors for heatmap */
|
/* Custom colors for heatmap - Updated to match system tags */
|
||||||
--availability-full: 142 71% 45%;
|
--availability-full: 142 76% 36%; /* Tag Green */
|
||||||
--availability-partial: 48 96% 53%;
|
--availability-partial: 25 80% 65%; /* Tag Orange */
|
||||||
--availability-none: 215 16% 85%;
|
--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 */
|
/* Shadows */
|
||||||
--shadow-sm: 0 1px 2px 0 hsl(222 47% 11% / 0.05);
|
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
--shadow-md: 0 4px 6px -1px hsl(222 47% 11% / 0.1), 0 2px 4px -2px hsl(222 47% 11% / 0.1);
|
--shadow-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 hsl(222 47% 11% / 0.1), 0 4px 6px -4px hsl(222 47% 11% / 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 hsl(222 47% 11% / 0.1), 0 8px 10px -6px hsl(222 47% 11% / 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: 0 0% 98%;
|
--sidebar-background: 60 9% 93%;
|
||||||
--sidebar-foreground: 240 5.3% 26.1%;
|
--sidebar-foreground: 60 5% 8%;
|
||||||
--sidebar-primary: 240 5.9% 10%;
|
--sidebar-primary: 18 68% 51%;
|
||||||
--sidebar-primary-foreground: 0 0% 98%;
|
--sidebar-primary-foreground: 60 9% 97%;
|
||||||
--sidebar-accent: 240 4.8% 95.9%;
|
--sidebar-accent: 60 9% 85%;
|
||||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
--sidebar-accent-foreground: 60 5% 8%;
|
||||||
--sidebar-border: 220 13% 91%;
|
--sidebar-border: 60 5% 77%;
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-ring: 18 68% 51%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
/* Dark mode intentionally removed/reset to match light mode system for now,
|
||||||
--background: 222 47% 6%;
|
or you can define a proper dark mode if required.
|
||||||
--foreground: 210 20% 98%;
|
Keeping it simple as per previous apps. */
|
||||||
|
|
||||||
--card: 222 47% 8%;
|
|
||||||
--card-foreground: 210 20% 98%;
|
|
||||||
|
|
||||||
--popover: 222 47% 8%;
|
|
||||||
--popover-foreground: 210 20% 98%;
|
|
||||||
|
|
||||||
--primary: 173 58% 45%;
|
|
||||||
--primary-foreground: 0 0% 100%;
|
|
||||||
|
|
||||||
--secondary: 222 47% 14%;
|
|
||||||
--secondary-foreground: 210 20% 98%;
|
|
||||||
|
|
||||||
--muted: 222 47% 14%;
|
|
||||||
--muted-foreground: 215 16% 65%;
|
|
||||||
|
|
||||||
--accent: 173 58% 15%;
|
|
||||||
--accent-foreground: 173 58% 75%;
|
|
||||||
|
|
||||||
--destructive: 0 62% 30%;
|
|
||||||
--destructive-foreground: 210 20% 98%;
|
|
||||||
|
|
||||||
--border: 222 47% 17%;
|
|
||||||
--input: 222 47% 17%;
|
|
||||||
--ring: 173 58% 45%;
|
|
||||||
|
|
||||||
--availability-full: 142 71% 35%;
|
|
||||||
--availability-partial: 48 96% 40%;
|
|
||||||
--availability-none: 222 47% 20%;
|
|
||||||
|
|
||||||
--sidebar-background: 240 5.9% 10%;
|
|
||||||
--sidebar-foreground: 240 4.8% 95.9%;
|
|
||||||
--sidebar-primary: 224.3 76.3% 48%;
|
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
|
||||||
--sidebar-accent: 240 3.7% 15.9%;
|
|
||||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
|
||||||
--sidebar-border: 240 3.7% 15.9%;
|
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -107,8 +122,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
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 { 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 +15,54 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover';
|
} from '@/components/ui/popover';
|
||||||
|
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: false,
|
||||||
|
secondaryTimezone: 'America/Toronto', // Company timezone as default secondary
|
||||||
};
|
};
|
||||||
|
|
||||||
function apiToParticipant(p: ParticipantAPI): Participant {
|
function apiToParticipant(p: ParticipantAPI): Participant {
|
||||||
@@ -40,22 +70,41 @@ 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 [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 +130,7 @@ const Index = () => {
|
|||||||
} else {
|
} else {
|
||||||
setAvailabilitySlots([]);
|
setAvailabilitySlots([]);
|
||||||
}
|
}
|
||||||
}, [selectedParticipants]);
|
}, [selectedParticipants, weekOffset]);
|
||||||
|
|
||||||
const loadParticipants = async () => {
|
const loadParticipants = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -100,7 +149,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 +162,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 +201,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 +238,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 +294,7 @@ const Index = () => {
|
|||||||
participants={participants}
|
participants={participants}
|
||||||
onAddParticipant={handleAddParticipant}
|
onAddParticipant={handleAddParticipant}
|
||||||
onRemoveParticipant={handleRemoveParticipant}
|
onRemoveParticipant={handleRemoveParticipant}
|
||||||
|
onUpdateParticipant={handleUpdateParticipant}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -216,6 +302,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"
|
||||||
@@ -230,7 +320,7 @@ const Index = () => {
|
|||||||
<Settings className="w-5 h-5" />
|
<Settings className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-72" align="end">
|
<PopoverContent className="w-80" align="end">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-medium">Settings</h4>
|
<h4 className="font-medium">Settings</h4>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
@@ -248,6 +338,71 @@ const Index = () => {
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
When enabled, shows time slots where only some participants are available.
|
When enabled, shows time slots where only some participants are available.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="border-t border-border pt-4">
|
||||||
|
<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>
|
||||||
|
{settings.showSecondaryTimezone && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Label className="text-xs text-muted-foreground mb-2 block">
|
||||||
|
Secondary timezone
|
||||||
|
</Label>
|
||||||
|
<TimezoneSelector
|
||||||
|
value={settings.secondaryTimezone}
|
||||||
|
onChange={(tz) => setSettings((prev) => ({ ...prev, secondaryTimezone: tz }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Display times in two timezones side by side.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border pt-4">
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Clear All Bookings
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Clear all bookings?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will remove all scheduled meetings from the system.
|
||||||
|
This action cannot be undone. Calendar invites already
|
||||||
|
sent will not be affected.
|
||||||
|
</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>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Remove all scheduled meetings from the system.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -265,7 +420,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>
|
||||||
) : (
|
) : (
|
||||||
@@ -287,6 +442,11 @@ const Index = () => {
|
|||||||
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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -303,6 +463,8 @@ const Index = () => {
|
|||||||
}}
|
}}
|
||||||
slot={selectedSlot}
|
slot={selectedSlot}
|
||||||
participants={selectedParticipants}
|
participants={selectedParticipants}
|
||||||
|
displayTimezone={settings.displayTimezone}
|
||||||
|
onSuccess={loadAvailability}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,7 @@ export interface Participant {
|
|||||||
export interface TimeSlot {
|
export interface TimeSlot {
|
||||||
day: string;
|
day: string;
|
||||||
hour: number;
|
hour: number;
|
||||||
|
start_time: string;
|
||||||
availability: 'full' | 'partial' | 'none';
|
availability: 'full' | 'partial' | 'none';
|
||||||
availableParticipants: string[];
|
availableParticipants: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))",
|
||||||
|
|||||||
Reference in New Issue
Block a user