8 Commits

Author SHA1 Message Date
daa0afaa25 Merge pull request 'cleanup-and-test' (#1) from cleanup-and-test into main
Reviewed-on: #1
2026-01-23 20:44:13 +00:00
Joyce
49dbc786e9 fix: use SYNC_DATABASE_URL env var for alembic migrations
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:24:41 -05:00
Joyce
922b6f31d1 add static serve 2026-01-21 16:36:40 -05:00
Joyce
f02b6ca886 chore: rename compose files - production as default
- docker-compose.yml → production (Coolify default)
- docker-compose.dev.yml → local development

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:25:05 -05:00
Joyce
cd62d7f94b chore: improve production deployment flexibility
- Switch frontend from nginx to caddy for consistency with Coolify
- Make VITE_API_URL optional, auto-derive from window.location.origin/api
- Remove hardcoded port mappings, let Coolify/Traefik handle routing
- Simplifies deployment configuration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:25:05 -05:00
Joyce
a8ec0936d4 chore: improve production deployment flexibility
- Switch frontend from nginx to caddy for consistency with Coolify
- Make VITE_API_URL optional, auto-derive from window.location.origin/api
- Simplifies deployment by not requiring build-time API URL

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:55:26 -05:00
Joyce
7fefd634f5 feat: add production deployment config
- Add docker-compose.prod.yml with env var support
- Add frontend/Dockerfile.prod with nginx for static serving
- Fix Zulip notification to run in thread pool (avoid blocking)
- Use Zulip time format for timezone-aware display
- Add Zulip @mentions for users matched by email

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:29:52 -05:00
Joyce
26311c867a 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 <noreply@anthropic.com>
2026-01-21 13:52:02 -05:00
21 changed files with 552 additions and 75 deletions

View File

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

View File

@@ -22,7 +22,7 @@ def upgrade() -> None:
sa.Column("id", sa.UUID(), nullable=False), sa.Column("id", sa.UUID(), nullable=False),
sa.Column("name", sa.String(255), nullable=False), sa.Column("name", sa.String(255), nullable=False),
sa.Column("email", 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("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False), sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"), sa.PrimaryKeyConstraint("id"),

View File

@@ -15,6 +15,9 @@ dependencies = [
"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",
"apscheduler>=3.10.4",
"aiosmtplib>=3.0.1",
"zulip>=0.9.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -96,7 +96,7 @@ async def calculate_availability(
availability = "none" availability = "none"
slots.append({ slots.append({
"day": day_name, "day": slot_start.strftime("%Y-%m-%d"),
"hour": hour, "hour": hour,
"availability": availability, "availability": availability,
"availableParticipants": available_participants, "availableParticipants": available_participants,

View File

@@ -6,6 +6,18 @@ class Settings(BaseSettings):
sync_database_url: str = "postgresql://postgres:postgres@db:5432/availability" sync_database_url: str = "postgresql://postgres:postgres@db:5432/availability"
ics_refresh_interval_minutes: int = 15 ics_refresh_interval_minutes: int = 15
# 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: class Config:
env_file = ".env" env_file = ".env"

View File

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

View File

@@ -1,3 +1,4 @@
import asyncio
import logging import logging
from uuid import UUID from uuid import UUID
@@ -16,12 +17,24 @@ from app.schemas import (
ParticipantCreate, ParticipantCreate,
ParticipantResponse, ParticipantResponse,
SyncResponse, 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) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -57,6 +70,7 @@ async def create_participant(
await db.refresh(participant) await db.refresh(participant)
try: try:
if participant.ics_url:
await sync_participant_calendar(db, participant) await sync_participant_calendar(db, participant)
except Exception as e: except Exception as e:
logger.warning(f"Initial sync failed for {participant.email}: {e}") logger.warning(f"Initial sync failed for {participant.email}: {e}")
@@ -121,7 +135,54 @@ async def sync_participant(participant_id: UUID, db: AsyncSession = Depends(get_
raise HTTPException(status_code=404, detail="Participant not found") raise HTTPException(status_code=404, detail="Participant not found")
try: try:
if participant.ics_url:
count = await sync_participant_calendar(db, participant) count = await sync_participant_calendar(db, participant)
return {"status": "success", "blocks_synced": count} return {"status": "success", "blocks_synced": count}
return {"status": "skipped", "message": "No ICS URL provided"}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/schedule")
async def schedule_meeting(
data: ScheduleRequest, db: AsyncSession = Depends(get_db)
):
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."
)
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
]
email_success = await send_meeting_invite(
participant_dicts,
data.title,
data.description,
data.start_time,
data.end_time
)
zulip_success = await asyncio.to_thread(
send_zulip_notification,
data.title,
data.start_time,
participant_dicts
)
return {
"status": "success",
"email_sent": email_success,
"zulip_sent": zulip_success
}

View File

@@ -18,7 +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)
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( created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, nullable=False DateTime, default=datetime.utcnow, nullable=False
) )

View File

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

View File

@@ -7,14 +7,14 @@ from pydantic import BaseModel, EmailStr
class ParticipantCreate(BaseModel): class ParticipantCreate(BaseModel):
name: str name: str
email: EmailStr email: EmailStr
ics_url: str ics_url: str | None = None
class ParticipantResponse(BaseModel): class ParticipantResponse(BaseModel):
id: UUID id: UUID
name: str name: str
email: str email: str
ics_url: str ics_url: str | None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -39,3 +39,11 @@ class AvailabilityResponse(BaseModel):
class SyncResponse(BaseModel): class SyncResponse(BaseModel):
results: dict[str, dict] results: dict[str, dict]
class ScheduleRequest(BaseModel):
participant_ids: list[UUID]
title: str
description: str
start_time: datetime
end_time: datetime

View File

@@ -0,0 +1,83 @@
import logging
import zulip
from app.config import settings
from datetime import datetime
logger = logging.getLogger(__name__)
def get_zulip_usernames_by_email(client: zulip.Client) -> dict[str, str]:
"""Fetch all Zulip users and return a mapping of email -> full_name for mentions."""
try:
result = client.get_users()
if result.get("result") == "success":
users_map = {
user["email"].lower(): user["full_name"]
for user in result.get("members", [])
if not user.get("is_bot", False)
}
return users_map
except Exception as e:
logger.warning(f"Failed to fetch Zulip users: {e}")
return {}
def format_participant_mentions(
participants: list[dict],
zulip_users: dict[str, str],
) -> str:
"""Format participants as Zulip mentions where possible, plain names otherwise."""
formatted = []
for p in participants:
email = p["email"].lower()
if email in zulip_users:
formatted.append(f'@**{zulip_users[email]}**')
else:
formatted.append(p["name"])
return ", ".join(formatted)
def send_zulip_notification(
title: str,
start_time: datetime,
participants: list[dict],
) -> bool:
if not settings.zulip_site or not settings.zulip_api_key or not settings.zulip_email:
return False
try:
client = zulip.Client(
email=settings.zulip_email,
api_key=settings.zulip_api_key,
site=settings.zulip_site
)
zulip_users = get_zulip_usernames_by_email(client)
people = format_participant_mentions(participants, zulip_users)
zulip_time = f"<time:{start_time.isoformat()}>"
content = (
f"📅 **Meeting Scheduled**\n"
f"**What:** {title}\n"
f"**When:** {zulip_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":
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

50
docker-compose.dev.yml Normal file
View 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:

View File

@@ -2,47 +2,49 @@ 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:
- "5432: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}
ports: SMTP_HOST: ${SMTP_HOST:-}
- "8000:8000" 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}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
volumes: restart: unless-stopped
- ./backend/src:/app/src
- ./backend/alembic:/app/alembic
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile.prod
ports: args:
- "5173:8080" VITE_API_URL: ${VITE_API_URL:-}
environment:
VITE_API_URL: http://localhost:8000
depends_on: depends_on:
- backend - backend
volumes: restart: unless-stopped
- ./frontend/src:/app/src expose:
- '80'
volumes: volumes:
postgres_data: postgres_data:

28
frontend/Dockerfile.prod Normal file
View File

@@ -0,0 +1,28 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# VITE_API_URL must be set at build time
ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built assets
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

19
frontend/nginx.conf Normal file
View 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;
}

View File

@@ -2859,7 +2859,6 @@
"integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==", "integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -2877,7 +2876,6 @@
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.0.2" "csstype": "^3.0.2"
@@ -2889,7 +2887,6 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
@@ -2940,7 +2937,6 @@
"integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/scope-manager": "8.38.0",
"@typescript-eslint/types": "8.38.0", "@typescript-eslint/types": "8.38.0",
@@ -3173,7 +3169,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3378,7 +3373,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001726", "caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173", "electron-to-chromium": "^1.5.173",
@@ -3712,7 +3706,6 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/kossnocorp" "url": "https://github.com/sponsors/kossnocorp"
@@ -3794,8 +3787,7 @@
"version": "8.6.0", "version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/embla-carousel-react": { "node_modules/embla-carousel-react": {
"version": "8.6.0", "version": "8.6.0",
@@ -3893,7 +3885,6 @@
"integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -5415,7 +5406,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -5602,7 +5592,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -5629,7 +5618,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -5643,7 +5631,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz",
"integrity": "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew==", "integrity": "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@@ -6197,7 +6184,6 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2", "arg": "^5.0.2",
@@ -6322,7 +6308,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -6502,7 +6487,6 @@
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",

View File

@@ -1,10 +1,11 @@
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; // Use VITE_API_URL if set at build time, otherwise derive from current origin
const API_URL = import.meta.env.VITE_API_URL || `${window.location.origin}/api`;
export interface ParticipantAPI { export interface ParticipantAPI {
id: string; id: string;
name: string; name: string;
email: string; email: string;
ics_url: string; ics_url: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -19,7 +20,7 @@ export interface TimeSlotAPI {
export interface CreateParticipantRequest { export interface CreateParticipantRequest {
name: string; name: string;
email: string; email: string;
ics_url: string; ics_url?: string;
} }
async function handleResponse<T>(response: Response): Promise<T> { async function handleResponse<T>(response: Response): Promise<T> {
@@ -76,3 +77,24 @@ export async function syncParticipant(id: string): Promise<void> {
throw new Error('Failed to sync participant calendar'); 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);
}

View File

@@ -8,9 +8,23 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Check, X, Loader2 } from 'lucide-react'; 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]; 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 { interface AvailabilityHeatmapProps {
slots: TimeSlot[]; slots: TimeSlot[];
selectedParticipants: Participant[]; selectedParticipants: Participant[];
@@ -26,8 +40,10 @@ export const AvailabilityHeatmap = ({
showPartialAvailability = false, showPartialAvailability = false,
isLoading = false, isLoading = false,
}: AvailabilityHeatmapProps) => { }: AvailabilityHeatmapProps) => {
const getSlot = (day: string, hour: number) => { const weekDates = getWeekDates();
return slots.find((s) => s.day === day && s.hour === hour);
const getSlot = (dateStr: string, hour: number) => {
return slots.find((s) => s.day === dateStr && s.hour === hour);
}; };
const getEffectiveAvailability = (slot: TimeSlot) => { const getEffectiveAvailability = (slot: TimeSlot) => {
@@ -41,6 +57,13 @@ export const AvailabilityHeatmap = ({
return `${hour.toString().padStart(2, '0')}:00`; 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 getWeekDateRange = () => {
const now = new Date(); const now = new Date();
const monday = new Date(now); const monday = new Date(now);
@@ -87,12 +110,12 @@ export const AvailabilityHeatmap = ({
<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="grid grid-cols-[60px_repeat(5,1fr)] gap-1 mb-2">
<div></div> <div></div>
{days.map((day) => ( {dayNames.map((dayName) => (
<div <div
key={day} 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"
> >
{day} {dayName}
</div> </div>
))} ))}
</div> </div>
@@ -103,18 +126,22 @@ export const AvailabilityHeatmap = ({
<div className="text-xs text-muted-foreground flex items-center justify-end pr-3"> <div className="text-xs text-muted-foreground flex items-center justify-end pr-3">
{formatHour(hour)} {formatHour(hour)}
</div> </div>
{days.map((day) => { {weekDates.map((dateStr, dayIndex) => {
const slot = getSlot(day, hour); const slot = getSlot(dateStr, hour);
if (!slot) return <div key={`${day}-${hour}`} className="h-12 bg-muted rounded" />; const dayName = dayNames[dayIndex];
const tooSoon = isSlotTooSoon(dateStr, hour);
if (!slot) return <div key={`${dateStr}-${hour}`} className="h-12 bg-muted rounded" />;
const effectiveAvailability = getEffectiveAvailability(slot); const effectiveAvailability = getEffectiveAvailability(slot);
return ( return (
<Popover key={`${day}-${hour}`}> <Popover key={`${dateStr}-${hour}`}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
className={cn( className={cn(
"h-12 rounded-md transition-all duration-200 hover:scale-105 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", "h-12 rounded-md transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
tooSoon && "opacity-40 cursor-not-allowed",
!tooSoon && "hover:scale-105 hover:shadow-md",
effectiveAvailability === 'full' && "bg-availability-full hover:bg-availability-full/90", effectiveAvailability === 'full' && "bg-availability-full hover:bg-availability-full/90",
effectiveAvailability === 'partial' && "bg-availability-partial hover:bg-availability-partial/90", effectiveAvailability === 'partial' && "bg-availability-partial hover:bg-availability-partial/90",
effectiveAvailability === 'none' && "bg-availability-none hover:bg-availability-none/90" effectiveAvailability === 'none' && "bg-availability-none hover:bg-availability-none/90"
@@ -124,8 +151,13 @@ 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">
{day} {formatHour(hour)}{formatHour(hour + 1)} {dayName} {formatHour(hour)}{formatHour(hour + 1)}
</div> </div>
{tooSoon && (
<div className="text-sm text-muted-foreground italic">
This time slot has passed or is too soon to schedule
</div>
)}
<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);
@@ -148,7 +180,7 @@ export const AvailabilityHeatmap = ({
); );
})} })}
</div> </div>
{effectiveAvailability !== 'none' && ( {effectiveAvailability !== 'none' && !tooSoon && (
<Button <Button
variant="schedule" variant="schedule"
className="w-full mt-2" className="w-full mt-2"

View File

@@ -25,16 +25,16 @@ export const ParticipantManager = ({
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!name.trim() || !email.trim() || !icsLink.trim()) { if (!name.trim() || !email.trim()) {
toast({ toast({
title: "Missing fields", title: "Missing fields",
description: "Please fill in all fields", description: "Please fill in name and email",
variant: "destructive", variant: "destructive",
}); });
return; return;
} }
onAddParticipant({ name: name.trim(), email: email.trim(), icsLink: icsLink.trim() }); onAddParticipant({ name: name.trim(), email: email.trim(), icsLink: icsLink.trim() || '' });
setName(''); setName('');
setEmail(''); setEmail('');
setIcsLink(''); setIcsLink('');
@@ -89,7 +89,7 @@ export const ParticipantManager = ({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="icsLink">Calendar ICS Link</Label> <Label htmlFor="icsLink">Calendar ICS Link (optional)</Label>
<Input <Input
id="icsLink" id="icsLink"
placeholder="https://calendar.google.com/..." placeholder="https://calendar.google.com/..."

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { TimeSlot, Participant } from '@/types/calendar'; import { TimeSlot, Participant } from '@/types/calendar';
import { scheduleMeeting } from '@/api/client';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -11,7 +12,7 @@ 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 { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { Calendar, Clock, Users, Send } from 'lucide-react'; import { Calendar, Clock, Users, Send, AlertCircle } from 'lucide-react';
interface ScheduleModalProps { interface ScheduleModalProps {
isOpen: boolean; isOpen: boolean;
@@ -34,7 +35,23 @@ export const ScheduleModal = ({
return `${hour.toString().padStart(2, '0')}:00`; return `${hour.toString().padStart(2, '0')}:00`;
}; };
const handleSubmit = () => { 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()) { if (!title.trim()) {
toast({ toast({
title: "Please enter a meeting title", title: "Please enter a meeting title",
@@ -43,19 +60,41 @@ export const ScheduleModal = ({
return; return;
} }
if (!slot) return;
setIsSubmitting(true); setIsSubmitting(true);
// Simulate API call try {
setTimeout(() => { // Calculate start and end times
setIsSubmitting(false); // 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({ toast({
title: "Meeting scheduled", title: "Meeting scheduled",
description: "Invitations sent to all participants", description: "Invitations sent via Email and Zulip",
}); });
setTitle(''); setTitle('');
setNotes(''); setNotes('');
onClose(); 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; if (!slot) return null;
@@ -72,7 +111,7 @@ export const ScheduleModal = ({
<div className="bg-accent/50 rounded-lg p-4 space-y-3"> <div className="bg-accent/50 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<Calendar className="w-4 h-4 text-primary" /> <Calendar className="w-4 h-4 text-primary" />
<span className="font-medium">{slot.day}</span> <span className="font-medium">{formatDate(slot.day)}</span>
</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" />
@@ -109,12 +148,20 @@ export const ScheduleModal = ({
</div> </div>
</div> </div>
{/* Lead Time Warning */}
{tooSoon && (
<div className="flex items-center gap-2 p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-sm text-destructive">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>Meetings must be scheduled at least 2 hours in advance</span>
</div>
)}
{/* Actions */} {/* Actions */}
<Button <Button
variant="schedule" variant="schedule"
className="w-full h-12" className="w-full h-12"
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitting} disabled={isSubmitting || tooSoon}
> >
{isSubmitting ? ( {isSubmitting ? (
<span className="animate-pulse">Sending...</span> <span className="animate-pulse">Sending...</span>

View File

@@ -118,7 +118,7 @@ const Index = () => {
const created = await createParticipant({ const created = await createParticipant({
name: data.name, name: data.name,
email: data.email, email: data.email,
ics_url: data.icsLink, ics_url: data.icsLink || undefined,
}); });
setParticipants((prev) => [...prev, apiToParticipant(created)]); setParticipants((prev) => [...prev, apiToParticipant(created)]);
toast({ toast({