From 26311c867a4bb699e8f4edc3b06fd75531919e84 Mon Sep 17 00:00:00 2001 From: Joyce <26967919+Joyce-O@users.noreply.github.com> Date: Wed, 21 Jan 2026 13:52:02 -0500 Subject: [PATCH] feat: add email, scheduler, and Zulip integration services - Add email service for sending meeting invites with ICS attachments - Add scheduler for background calendar sync jobs - Add Zulip service for meeting notifications - Make ics_url optional for participants - Add /api/schedule endpoint with 2-hour lead time validation - Update frontend to support scheduling flow Co-Authored-By: Claude Opus 4.5 --- backend/alembic/versions/001_initial.py | 2 +- backend/pyproject.toml | 3 + backend/src/app/availability_service.py | 2 +- backend/src/app/config.py | 12 +++ backend/src/app/email_service.py | 82 +++++++++++++++++++ backend/src/app/main.py | 71 +++++++++++++++- backend/src/app/models.py | 2 +- backend/src/app/scheduler.py | 39 +++++++++ backend/src/app/schemas.py | 12 ++- backend/src/app/zulip_service.py | 51 ++++++++++++ docker-compose.yml | 10 ++- frontend/package-lock.json | 18 +--- frontend/src/api/client.ts | 25 +++++- .../src/components/AvailabilityHeatmap.tsx | 58 ++++++++++--- .../src/components/ParticipantManager.tsx | 8 +- frontend/src/components/ScheduleModal.tsx | 65 +++++++++++++-- frontend/src/pages/Index.tsx | 2 +- 17 files changed, 403 insertions(+), 59 deletions(-) create mode 100644 backend/src/app/email_service.py create mode 100644 backend/src/app/scheduler.py create mode 100644 backend/src/app/zulip_service.py diff --git a/backend/alembic/versions/001_initial.py b/backend/alembic/versions/001_initial.py index c0f685c..7d587b4 100644 --- a/backend/alembic/versions/001_initial.py +++ b/backend/alembic/versions/001_initial.py @@ -22,7 +22,7 @@ def upgrade() -> None: sa.Column("id", sa.UUID(), nullable=False), sa.Column("name", sa.String(255), nullable=False), sa.Column("email", sa.String(255), nullable=False), - sa.Column("ics_url", sa.Text(), nullable=False), + sa.Column("ics_url", sa.Text(), nullable=True), sa.Column("created_at", sa.DateTime(), nullable=False), sa.Column("updated_at", sa.DateTime(), nullable=False), sa.PrimaryKeyConstraint("id"), diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8722f96..7efa732 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -15,6 +15,9 @@ dependencies = [ "python-dateutil>=2.9.0", "pydantic[email]>=2.10.0", "pydantic-settings>=2.6.0", + "apscheduler>=3.10.4", + "aiosmtplib>=3.0.1", + "zulip>=0.9.0", ] [project.optional-dependencies] diff --git a/backend/src/app/availability_service.py b/backend/src/app/availability_service.py index 38f8d7b..9ef92bc 100644 --- a/backend/src/app/availability_service.py +++ b/backend/src/app/availability_service.py @@ -96,7 +96,7 @@ async def calculate_availability( availability = "none" slots.append({ - "day": day_name, + "day": slot_start.strftime("%Y-%m-%d"), "hour": hour, "availability": availability, "availableParticipants": available_participants, diff --git a/backend/src/app/config.py b/backend/src/app/config.py index 8286c28..6dfd716 100644 --- a/backend/src/app/config.py +++ b/backend/src/app/config.py @@ -6,6 +6,18 @@ class Settings(BaseSettings): sync_database_url: str = "postgresql://postgres:postgres@db:5432/availability" ics_refresh_interval_minutes: int = 15 + # SMTP Settings + smtp_host: str | None = None + smtp_port: int = 587 + smtp_user: str | None = None + smtp_password: str | None = None + + # Zulip Settings + zulip_site: str | None = None + zulip_email: str | None = None + zulip_api_key: str | None = None + zulip_stream: str = "general" + class Config: env_file = ".env" diff --git a/backend/src/app/email_service.py b/backend/src/app/email_service.py new file mode 100644 index 0000000..0099316 --- /dev/null +++ b/backend/src/app/email_service.py @@ -0,0 +1,82 @@ +import logging +import uuid +from datetime import datetime, timezone +import aiosmtplib +from email.message import EmailMessage +from icalendar import Calendar, Event, vCalAddress, vText +from app.config import settings + +logger = logging.getLogger(__name__) + +async def send_meeting_invite( + participants: list[dict], + title: str, + description: str, + start_time: datetime, + end_time: datetime, +) -> bool: + if not settings.smtp_host or not settings.smtp_user or not settings.smtp_password: + logger.warning("SMTP credentials not configured. Skipping email invite.") + return False + + # Create the ICS content + cal = Calendar() + cal.add('prodid', '-//Common Availability//m.com//') + cal.add('version', '2.0') + cal.add('method', 'REQUEST') + + event = Event() + event.add('summary', title) + event.add('dtstart', start_time) + event.add('dtend', end_time) + event.add('dtstamp', datetime.now(timezone.utc)) + event.add('description', description) + event.add('uid', str(uuid.uuid4())) + event.add('organizer', vCalAddress(f'MAILTO:{settings.smtp_user}')) + + attendee_emails = [] + for p in participants: + attendee = vCalAddress(f'MAILTO:{p["email"]}') + attendee.params['cn'] = vText(p["name"]) + attendee.params['ROLE'] = vText('REQ-PARTICIPANT') + event.add('attendee', attendee, encode=0) + attendee_emails.append(p["email"]) + + cal.add_component(event) + ics_data = cal.to_ical() + + # Create Email + msg = EmailMessage() + msg["Subject"] = f"Invitation: {title}" + msg["From"] = settings.smtp_user + msg["To"] = ", ".join(attendee_emails) + msg.set_content( + f"You have been invited to: {title}\n" + f"When: {start_time.strftime('%Y-%m-%d %H:%M %Z')}\n\n" + f"{description}" + ) + + # Attach ICS + msg.add_attachment( + ics_data, + maintype="text", + subtype="calendar", + filename="invite.ics", + params={"method": "REQUEST"} + ) + + # Send + try: + await aiosmtplib.send( + msg, + hostname=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_user, + password=settings.smtp_password, + start_tls=True + ) + logger.info(f"Sent meeting invites to {len(attendee_emails)} participants") + return True + except Exception as e: + logger.error(f"Failed to send email invite: {e}") + return False diff --git a/backend/src/app/main.py b/backend/src/app/main.py index bd18001..3b49be3 100644 --- a/backend/src/app/main.py +++ b/backend/src/app/main.py @@ -16,12 +16,24 @@ from app.schemas import ( ParticipantCreate, ParticipantResponse, SyncResponse, + ScheduleRequest, ) +from app.scheduler import start_scheduler, stop_scheduler +from app.email_service import send_meeting_invite +from app.zulip_service import send_zulip_notification +from contextlib import asynccontextmanager +from datetime import datetime, timezone, timedelta logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -app = FastAPI(title="Common Availability API") +@asynccontextmanager +async def lifespan(app: FastAPI): + start_scheduler() + yield + stop_scheduler() + +app = FastAPI(title="Common Availability API", lifespan=lifespan) app.add_middleware( CORSMiddleware, @@ -57,7 +69,8 @@ async def create_participant( await db.refresh(participant) try: - await sync_participant_calendar(db, participant) + if participant.ics_url: + await sync_participant_calendar(db, participant) except Exception as e: logger.warning(f"Initial sync failed for {participant.email}: {e}") @@ -121,7 +134,57 @@ async def sync_participant(participant_id: UUID, db: AsyncSession = Depends(get_ raise HTTPException(status_code=404, detail="Participant not found") try: - count = await sync_participant_calendar(db, participant) - return {"status": "success", "blocks_synced": count} + if participant.ics_url: + count = await sync_participant_calendar(db, participant) + return {"status": "success", "blocks_synced": count} + return {"status": "skipped", "message": "No ICS URL provided"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/schedule") +async def schedule_meeting( + data: ScheduleRequest, db: AsyncSession = Depends(get_db) +): + # 1. Validate Lead Time (2 hours) + min_start_time = datetime.now(timezone.utc) + timedelta(hours=2) + if data.start_time.replace(tzinfo=timezone.utc) < min_start_time: + raise HTTPException( + status_code=400, + detail="Meetings must be scheduled at least 2 hours in advance." + ) + + # 2. Fetch Participants + result = await db.execute( + select(Participant).where(Participant.id.in_(data.participant_ids)) + ) + participants = result.scalars().all() + + if len(participants) != len(data.participant_ids): + raise HTTPException(status_code=400, detail="Some participants not found") + + participant_dicts = [ + {"name": p.name, "email": p.email} for p in participants + ] + participant_names = [p.name for p in participants] + + # 3. Send Notifications + email_success = await send_meeting_invite( + participant_dicts, + data.title, + data.description, + data.start_time, + data.end_time + ) + + zulip_success = send_zulip_notification( + data.title, + data.start_time, + participant_names + ) + + return { + "status": "success", + "email_sent": email_success, + "zulip_sent": zulip_success + } diff --git a/backend/src/app/models.py b/backend/src/app/models.py index 8ca4106..4f29c45 100644 --- a/backend/src/app/models.py +++ b/backend/src/app/models.py @@ -18,7 +18,7 @@ class Participant(Base): ) name: Mapped[str] = mapped_column(String(255), nullable=False) email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) - ics_url: Mapped[str] = mapped_column(Text, nullable=False) + ics_url: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, nullable=False ) diff --git a/backend/src/app/scheduler.py b/backend/src/app/scheduler.py new file mode 100644 index 0000000..0662916 --- /dev/null +++ b/backend/src/app/scheduler.py @@ -0,0 +1,39 @@ +import logging +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.interval import IntervalTrigger +from sqlalchemy import select +from app.database import async_session_maker +from app.models import Participant +from app.ics_service import sync_participant_calendar +from app.config import settings + +logger = logging.getLogger(__name__) + +scheduler = AsyncIOScheduler() + +async def run_sync_job(): + logger.info("Starting background calendar sync...") + async with async_session_maker() as db: + result = await db.execute(select(Participant).where(Participant.ics_url.is_not(None))) + participants = result.scalars().all() + + for participant in participants: + try: + await sync_participant_calendar(db, participant) + except Exception as e: + logger.error(f"Background sync failed for {participant.email}: {e}") + logger.info("Background calendar sync completed.") + +def start_scheduler(): + scheduler.add_job( + run_sync_job, + IntervalTrigger(minutes=settings.ics_refresh_interval_minutes), + id="calendar_sync", + replace_existing=True + ) + scheduler.start() + logger.info("Scheduler started.") + +def stop_scheduler(): + scheduler.shutdown() + logger.info("Scheduler stopped.") diff --git a/backend/src/app/schemas.py b/backend/src/app/schemas.py index 6d47a0d..bf31033 100644 --- a/backend/src/app/schemas.py +++ b/backend/src/app/schemas.py @@ -7,14 +7,14 @@ from pydantic import BaseModel, EmailStr class ParticipantCreate(BaseModel): name: str email: EmailStr - ics_url: str + ics_url: str | None = None class ParticipantResponse(BaseModel): id: UUID name: str email: str - ics_url: str + ics_url: str | None created_at: datetime updated_at: datetime @@ -39,3 +39,11 @@ class AvailabilityResponse(BaseModel): class SyncResponse(BaseModel): results: dict[str, dict] + + +class ScheduleRequest(BaseModel): + participant_ids: list[UUID] + title: str + description: str + start_time: datetime + end_time: datetime diff --git a/backend/src/app/zulip_service.py b/backend/src/app/zulip_service.py new file mode 100644 index 0000000..57ca7cb --- /dev/null +++ b/backend/src/app/zulip_service.py @@ -0,0 +1,51 @@ +import logging +import zulip +from app.config import settings +from datetime import datetime + +logger = logging.getLogger(__name__) + +def send_zulip_notification( + title: str, + start_time: datetime, + participant_names: list[str], +) -> bool: + if not settings.zulip_site or not settings.zulip_api_key or not settings.zulip_email: + logger.warning("Zulip credentials not configured. Skipping notification.") + return False + + try: + client = zulip.Client( + email=settings.zulip_email, + api_key=settings.zulip_api_key, + site=settings.zulip_site + ) + + formatted_time = start_time.strftime('%Y-%m-%d %H:%M %Z') + people = ", ".join(participant_names) + + content = ( + f"📅 **Meeting Scheduled**\n" + f"**What:** {title}\n" + f"**When:** {formatted_time}\n" + f"**Who:** {people}" + ) + + request = { + "type": "stream", + "to": settings.zulip_stream, + "topic": "Meeting Announcements", + "content": content + } + + result = client.send_message(request) + if result.get("result") == "success": + logger.info("Sent Zulip notification") + return True + else: + logger.error(f"Zulip API error: {result.get('msg')}") + return False + + except Exception as e: + logger.error(f"Failed to send Zulip notification: {e}") + return False diff --git a/docker-compose.yml b/docker-compose.yml index d42b741..441185d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: volumes: - postgres_data:/var/lib/postgresql/data ports: - - "5432:5432" + - "5433:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s @@ -22,8 +22,10 @@ services: environment: DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/availability SYNC_DATABASE_URL: postgresql://postgres:postgres@db:5432/availability + env_file: + - .env ports: - - "8000:8000" + - "8001:8000" depends_on: db: condition: service_healthy @@ -36,9 +38,9 @@ services: context: ./frontend dockerfile: Dockerfile ports: - - "5173:8080" + - "5174:8080" environment: - VITE_API_URL: http://localhost:8000 + VITE_API_URL: http://localhost:8001 depends_on: - backend volumes: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d706b79..e1e8e54 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2859,7 +2859,6 @@ "integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2877,7 +2876,6 @@ "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2889,7 +2887,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2940,7 +2937,6 @@ "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", @@ -3173,7 +3169,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3378,7 +3373,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -3712,7 +3706,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -3794,8 +3787,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -3893,7 +3885,6 @@ "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -5415,7 +5406,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5602,7 +5592,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5629,7 +5618,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5643,7 +5631,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz", "integrity": "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6197,7 +6184,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -6322,7 +6308,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6502,7 +6487,6 @@ "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f45bbee..446cfac 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -4,7 +4,7 @@ export interface ParticipantAPI { id: string; name: string; email: string; - ics_url: string; + ics_url: string | null; created_at: string; updated_at: string; } @@ -19,7 +19,7 @@ export interface TimeSlotAPI { export interface CreateParticipantRequest { name: string; email: string; - ics_url: string; + ics_url?: string; } async function handleResponse(response: Response): Promise { @@ -76,3 +76,24 @@ export async function syncParticipant(id: string): Promise { throw new Error('Failed to sync participant calendar'); } } + +export async function scheduleMeeting( + participantIds: string[], + title: string, + description: string, + startTime: string, + endTime: string, +): Promise<{ email_sent: boolean; zulip_sent: boolean }> { + const response = await fetch(`${API_URL}/api/schedule`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + participant_ids: participantIds, + title, + description, + start_time: startTime, + end_time: endTime, + }), + }); + return handleResponse(response); +} diff --git a/frontend/src/components/AvailabilityHeatmap.tsx b/frontend/src/components/AvailabilityHeatmap.tsx index 9a7dfad..504a29a 100644 --- a/frontend/src/components/AvailabilityHeatmap.tsx +++ b/frontend/src/components/AvailabilityHeatmap.tsx @@ -8,9 +8,23 @@ import { import { Button } from '@/components/ui/button'; import { Check, X, Loader2 } from 'lucide-react'; -const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']; +const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']; const hours = [9, 10, 11, 12, 13, 14, 15, 16, 17]; +// Get the dates for Mon-Fri of the current week +const getWeekDates = () => { + const now = new Date(); + const monday = new Date(now); + monday.setDate(now.getDate() - now.getDay() + 1); + monday.setHours(0, 0, 0, 0); + + return dayNames.map((_, i) => { + const date = new Date(monday); + date.setDate(monday.getDate() + i); + return date.toISOString().split('T')[0]; // "YYYY-MM-DD" + }); +}; + interface AvailabilityHeatmapProps { slots: TimeSlot[]; selectedParticipants: Participant[]; @@ -26,8 +40,10 @@ export const AvailabilityHeatmap = ({ showPartialAvailability = false, isLoading = false, }: AvailabilityHeatmapProps) => { - const getSlot = (day: string, hour: number) => { - return slots.find((s) => s.day === day && s.hour === hour); + const weekDates = getWeekDates(); + + const getSlot = (dateStr: string, hour: number) => { + return slots.find((s) => s.day === dateStr && s.hour === hour); }; const getEffectiveAvailability = (slot: TimeSlot) => { @@ -41,6 +57,13 @@ export const AvailabilityHeatmap = ({ return `${hour.toString().padStart(2, '0')}:00`; }; + const isSlotTooSoon = (dateStr: string, hour: number) => { + const slotTime = new Date(`${dateStr}T${formatHour(hour)}:00Z`); + const now = new Date(); + const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000); + return slotTime < twoHoursFromNow; + }; + const getWeekDateRange = () => { const now = new Date(); const monday = new Date(now); @@ -87,12 +110,12 @@ export const AvailabilityHeatmap = ({
- {days.map((day) => ( + {dayNames.map((dayName) => (
- {day} + {dayName}
))}
@@ -103,18 +126,22 @@ export const AvailabilityHeatmap = ({
{formatHour(hour)}
- {days.map((day) => { - const slot = getSlot(day, hour); - if (!slot) return
; + {weekDates.map((dateStr, dayIndex) => { + const slot = getSlot(dateStr, hour); + const dayName = dayNames[dayIndex]; + const tooSoon = isSlotTooSoon(dateStr, hour); + if (!slot) return
; const effectiveAvailability = getEffectiveAvailability(slot); return ( - +
- + { + const formatDate = (dateStr: string) => { + const date = new Date(dateStr + 'T00:00:00'); + return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); + }; + + const isTooSoon = () => { + if (!slot) return false; + // Use UTC to match backend timezone + const startDateTime = new Date(`${slot.day}T${formatHour(slot.hour)}:00Z`); + const now = new Date(); + const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000); + return startDateTime < twoHoursFromNow; + }; + + const tooSoon = isTooSoon(); + + const handleSubmit = async () => { if (!title.trim()) { toast({ title: "Please enter a meeting title", @@ -43,19 +60,41 @@ export const ScheduleModal = ({ return; } + if (!slot) return; + setIsSubmitting(true); - // Simulate API call - setTimeout(() => { - setIsSubmitting(false); + try { + // Calculate start and end times + // slot.day is YYYY-MM-DD + const startDateTime = new Date(`${slot.day}T${formatHour(slot.hour)}:00Z`); + const endDateTime = new Date(`${slot.day}T${formatHour(slot.hour + 1)}:00Z`); + + await scheduleMeeting( + participants.map(p => p.id), + title, + notes, + startDateTime.toISOString(), + endDateTime.toISOString() + ); + toast({ title: "Meeting scheduled", - description: "Invitations sent to all participants", + description: "Invitations sent via Email and Zulip", }); + setTitle(''); setNotes(''); onClose(); - }, 1000); + } catch (error) { + toast({ + title: "Scheduling failed", + description: error instanceof Error ? error.message : "Unknown error", + variant: "destructive", + }); + } finally { + setIsSubmitting(false); + } }; if (!slot) return null; @@ -72,7 +111,7 @@ export const ScheduleModal = ({
- {slot.day} + {formatDate(slot.day)}
@@ -109,12 +148,20 @@ export const ScheduleModal = ({
+ {/* Lead Time Warning */} + {tooSoon && ( +
+ + Meetings must be scheduled at least 2 hours in advance +
+ )} + {/* Actions */}