feat: full backend (untested)

This commit is contained in:
Nik L
2026-01-14 11:37:44 -05:00
parent 4a0db63a30
commit d585cf8613
32 changed files with 1317 additions and 57 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.env
.DS_Store
*.log

86
CLAUDE.md Normal file
View File

@@ -0,0 +1,86 @@
# LLM Development Guide
## Code Style Guidelines
- **Package Management**: ALWAYS use `uv` for all Python package management (install, run, sync, etc.). Never use pip, poetry, or other package managers.
- **Imports**: Group standard library, third-party, and local imports with a blank line between groups
- **Formatting**: Use Ruff with 88 character line length
- **Types**: Use type annotations everywhere; import types from typing module
- **Naming**: Use snake_case for variables/functions, PascalCase for classes, UPPER_CASE for constants
- **Error Handling**: Use specific exceptions with meaningful error messages
- **Documentation**: Use docstrings for all public functions, classes, and methods
- **Logging**: Use the structured logging module; avoid print statements
- **Async**: Use async/await for non-blocking operations, especially in FastAPI endpoints
- **Configuration**: Use environment variables with YAML for configuration
- **Requirements**: Use the most up-to-date versions of dependencies unless specifically instructed not to
## Process Guide
Always start by taking a look at the project. Any .md file in the top level directory is important context.
Generally, we will use `PLAN.md` as our "current state" scratchpad. We'll keep notes for ourselves there that include any context we'll need for future steps, including:
* Any design principles or core ideas we landed on that might be relevant later on
* A summary of where we are in our overall development cycle
* What we imagine our next steps will be
In total, PLAN.md should never exceed ~100 lines or so. A human being should be able to read & understand it in under 5 minutes.
### Updating PLAN.md
* If we need to update a PLAN, it often makes sense to ask the user clarifying questions before starting. Explain how you intend to update the PLAN document before actually doing so. Ask the user for feedback!
* PLANs should be as test-driven as possible. We will focus on Component/Acceptance tests, at the interface boundary between components. No unit tests! And we'll generally use the Red-Green-Refactor method of Test-Driven-Development. More on this in the next section.
* Sometimes a goal within a PLAN cannot be encapsulated within an automated test. In those cases, we'll still want to have some kind of validation step after a unit of work. This may be running a command and seeing that the result is as expected. Or asking the user to confirm that a frontend looks the way it should. Etc.
* A given goal within a plan should be a logical unit of work that would take a senior developer 4-8 hours to implement.
* The final output of a goal in our PLAN should typically be under a thousand lines of code and make sense as a single PR.
* Always stop and ask clarifying questions after implementing `PLAN.md`. You should not move on to implementing the plan until you have gotten an explicit go-ahead instrcution from the user.
### Testing
Coming up with a good test is **always the first step** of any plan. Tests should validate that the behaviour of a module is correct, by interacting with its interface. If the module's internals change, the tests should still pass!
Tests should generally follow this pattern (pseudocode):
self.set_up()
example_input = {'example': 'input'}
self.assert_property(self.get('/v1/api/endpoint', example_input))
Not every interface will be a REST API, and not every test will be directly measurable property of the output like this.
But in all cases, the test should include a setup, a call to an interface, and a test of some assumption we will make about calling that interface.
We should NEVER USE MOCKS.
Interfaces should be as pure-functional as possible. Use dependency injection. If a function has a side-effect, then *the side-effect should be in the name of the function, and its main purpose* if at all possible.
**At the beginning of any new goal, ask the user questions until you understand what tests you should write.** Then write the tests and ask the user if they match their expectations.
### Code style
**CRITICAL RULES - NEVER VIOLATE THESE:**
* **KISS (Keep It Simple, Stupid)**: Always choose the simplest solution that solves the problem. Avoid clever tricks, complex abstractions, and over-engineering.
* **Readable & Production-Ready**: All code must be immediately readable by any developer and production-ready. No shortcuts, no "TODO" comments, no placeholder implementations.
* **NO BOILERPLATE**: Do not write any boilerplate code. Do not create classes, functions, or structures "for future use." Write only what is needed right now for the current goal.
* **NO COMMENTS**: Code must be self-documenting through clear naming and simple structure. Comments are forbidden (except docstrings for public APIs). If you think you need a comment, refactor the code to be clearer instead.
**Additional Guidelines:**
Write the minimum code needed to accomplish a task.
Do not build for untested contingencies. If you believe there is an edgecase or contingency that needs to be accomodated in your code, that case should be explicitly tested.
Functions should clearly identify their purpose through their names and signatures.
Simple is better than complex, explicit is better than implicit, and boring code is better than clever code.
No magic; don't abuse metaprogramming. When I read code its behaviour should be obvious to me.
Tests should effectively serve as documentation. By reading the tests in a pull request, I should be able to infer the PR's primary purpose.

55
PLAN.md Normal file
View File

@@ -0,0 +1,55 @@
# Common Availability - Implementation Plan
## Project Overview
ICS-based calendar availability coordination system. Company members submit ICS URLs, and the system continuously syncs and calculates common availability for scheduling meetings.
## Architecture
### Backend (FastAPI + PostgreSQL)
- **Models**: `Participant` (id, name, email, ics_url) and `BusyBlock` (participant_id, start_time, end_time)
- **Services**: ICS fetching/parsing via `icalendar` library, availability calculation
- **API Endpoints**:
- `POST /api/participants` - Add participant with ICS URL (auto-syncs)
- `GET /api/participants` - List all participants
- `DELETE /api/participants/{id}` - Remove participant
- `POST /api/availability` - Calculate availability for given participants
- `POST /api/sync` - Sync all calendars
- `POST /api/sync/{id}` - Sync specific participant
### Frontend (React + TypeScript)
- Existing UI preserved (React, Vite, shadcn-ui)
- API client in `src/api/client.ts`
- Real-time availability from backend (replaces mock data)
## Current State
Implementation complete and tested. All systems operational.
## How to Run
```bash
just fresh # Build, start containers, run migrations
just up # Start services
just logs # View logs
just migrate # Run Alembic migrations
just sync-calendars # Sync all ICS feeds
```
Access:
- Frontend: http://localhost:5173
- Backend API: http://localhost:8000
- API docs: http://localhost:8000/docs
## Test Flow
1. Add participant with ICS URL (e.g., https://user.fm/freebusy/v1-.../Calendar.ics)
2. Select participants in Schedule tab
3. View availability heatmap (green = all free, yellow = partial, red = busy)
4. Click sync button to refresh calendars
## Verified Working
- [x] ICS parsing and sync (321 busy blocks from sample ICS)
- [x] Availability calculation
- [x] Participant CRUD operations
- [x] Frontend integration
## Next Steps
- [ ] Add background scheduler for periodic ICS sync
- [ ] Add calendar revocation mechanism

12
backend/.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
__pycache__
*.py[cod]
.git
.gitignore
.env
.venv
venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
tests/

13
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
*$py.class
.Python
*.so
.env
.venv
venv/
.uv/
*.egg-info/
.pytest_cache/
.ruff_cache/
.mypy_cache/

18
backend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip install uv
COPY pyproject.toml .
RUN uv sync --frozen --no-dev 2>/dev/null || uv sync --no-dev
COPY alembic.ini .
COPY alembic/ alembic/
COPY src/ src/
ENV PYTHONPATH=/app/src
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

38
backend/alembic.ini Normal file
View File

@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = src
sqlalchemy.url = postgresql://postgres:postgres@db:5432/availability
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

43
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,43 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.models import Base
config = context.config
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline():
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,48 @@
"""Initial migration
Revision ID: 001
Revises:
Create Date: 2024-01-08
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"participants",
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("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
op.create_table(
"busy_blocks",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("participant_id", sa.UUID(), nullable=False),
sa.Column("start_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("end_time", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_busy_blocks_participant_id", "busy_blocks", ["participant_id"]
)
def downgrade() -> None:
op.drop_index("ix_busy_blocks_participant_id", table_name="busy_blocks")
op.drop_table("busy_blocks")
op.drop_table("participants")

36
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,36 @@
[project]
name = "common-availability"
version = "0.1.0"
description = "Calendar availability coordination service"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
"sqlalchemy>=2.0.0",
"alembic>=1.14.0",
"asyncpg>=0.30.0",
"psycopg2-binary>=2.9.0",
"httpx>=0.28.0",
"icalendar>=6.0.0",
"python-dateutil>=2.9.0",
"pydantic[email]>=2.10.0",
"pydantic-settings>=2.6.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"ruff>=0.8.0",
]
[tool.ruff]
line-length = 88
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"

View File

View File

@@ -0,0 +1,105 @@
from datetime import datetime, timedelta, timezone
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import BusyBlock, Participant
def get_week_boundaries(reference_date: datetime | None = None) -> tuple[datetime, datetime]:
if reference_date is None:
reference_date = datetime.now(timezone.utc)
days_since_monday = reference_date.weekday()
monday = reference_date - timedelta(days=days_since_monday)
monday = monday.replace(hour=0, minute=0, second=0, microsecond=0)
friday = monday + timedelta(days=4)
friday = friday.replace(hour=23, minute=59, second=59, microsecond=999999)
return monday, friday
async def get_busy_blocks_for_participants(
db: AsyncSession,
participant_ids: list[UUID],
start_time: datetime,
end_time: datetime,
) -> dict[UUID, list[tuple[datetime, datetime]]]:
stmt = select(BusyBlock).where(
BusyBlock.participant_id.in_(participant_ids),
BusyBlock.start_time < end_time,
BusyBlock.end_time > start_time,
)
result = await db.execute(stmt)
blocks = result.scalars().all()
busy_map: dict[UUID, list[tuple[datetime, datetime]]] = {
pid: [] for pid in participant_ids
}
for block in blocks:
busy_map[block.participant_id].append((block.start_time, block.end_time))
return busy_map
def is_participant_free(
busy_blocks: list[tuple[datetime, datetime]],
slot_start: datetime,
slot_end: datetime,
) -> bool:
for block_start, block_end in busy_blocks:
if block_start < slot_end and block_end > slot_start:
return False
return True
async def calculate_availability(
db: AsyncSession,
participant_ids: list[UUID],
reference_date: datetime | None = None,
) -> list[dict]:
week_start, week_end = get_week_boundaries(reference_date)
busy_map = await get_busy_blocks_for_participants(
db, participant_ids, week_start, week_end
)
participants_stmt = select(Participant).where(Participant.id.in_(participant_ids))
participants_result = await db.execute(participants_stmt)
participants = {p.id: p for p in participants_result.scalars().all()}
days = ["Mon", "Tue", "Wed", "Thu", "Fri"]
hours = list(range(9, 18))
slots = []
for day_offset, day_name in enumerate(days):
for hour in hours:
slot_start = week_start + timedelta(days=day_offset, hours=hour)
slot_end = slot_start + timedelta(hours=1)
available_participants = []
for pid in participant_ids:
if is_participant_free(busy_map.get(pid, []), slot_start, slot_end):
participant = participants.get(pid)
if participant:
available_participants.append(participant.name)
total = len(participant_ids)
available_count = len(available_participants)
if available_count == total:
availability = "full"
elif available_count > 0:
availability = "partial"
else:
availability = "none"
slots.append({
"day": day_name,
"hour": hour,
"availability": availability,
"availableParticipants": available_participants,
})
return slots

13
backend/src/app/config.py Normal file
View File

@@ -0,0 +1,13 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://postgres:postgres@db:5432/availability"
sync_database_url: str = "postgresql://postgres:postgres@db:5432/availability"
ics_refresh_interval_minutes: int = 15
class Config:
env_file = ".env"
settings = Settings()

View File

@@ -0,0 +1,13 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
engine = create_async_engine(settings.database_url, echo=False)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
yield session

View File

@@ -0,0 +1,88 @@
import logging
from datetime import datetime, timezone
import httpx
from icalendar import Calendar
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import BusyBlock, Participant
logger = logging.getLogger(__name__)
async def fetch_ics_content(url: str) -> str:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url)
response.raise_for_status()
return response.text
def parse_ics_to_busy_blocks(
ics_content: str, participant_id: str
) -> list[BusyBlock]:
calendar = Calendar.from_ical(ics_content)
blocks = []
for component in calendar.walk():
if component.name == "VEVENT":
dtstart = component.get("dtstart")
dtend = component.get("dtend")
if dtstart is None or dtend is None:
continue
start_dt = dtstart.dt
end_dt = dtend.dt
if not isinstance(start_dt, datetime):
start_dt = datetime.combine(start_dt, datetime.min.time())
if not isinstance(end_dt, datetime):
end_dt = datetime.combine(end_dt, datetime.min.time())
if start_dt.tzinfo is None:
start_dt = start_dt.replace(tzinfo=timezone.utc)
if end_dt.tzinfo is None:
end_dt = end_dt.replace(tzinfo=timezone.utc)
blocks.append(
BusyBlock(
participant_id=participant_id,
start_time=start_dt,
end_time=end_dt,
)
)
return blocks
async def sync_participant_calendar(
db: AsyncSession, participant: Participant
) -> int:
logger.info(f"Syncing calendar for {participant.email}")
ics_content = await fetch_ics_content(participant.ics_url)
blocks = parse_ics_to_busy_blocks(ics_content, str(participant.id))
await db.execute(
delete(BusyBlock).where(BusyBlock.participant_id == participant.id)
)
for block in blocks:
db.add(block)
await db.commit()
logger.info(f"Synced {len(blocks)} busy blocks for {participant.email}")
return len(blocks)
async def sync_all_calendars(db: AsyncSession, participants: list[Participant]) -> dict:
results = {}
for participant in participants:
try:
count = await sync_participant_calendar(db, participant)
results[str(participant.id)] = {"status": "success", "blocks": count}
except Exception as e:
logger.error(f"Failed to sync {participant.email}: {e}")
results[str(participant.id)] = {"status": "error", "error": str(e)}
return results

127
backend/src/app/main.py Normal file
View File

@@ -0,0 +1,127 @@
import logging
from uuid import UUID
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.availability_service import calculate_availability
from app.database import get_db
from app.ics_service import sync_all_calendars, sync_participant_calendar
from app.models import Participant
from app.schemas import (
AvailabilityRequest,
AvailabilityResponse,
ParticipantCreate,
ParticipantResponse,
SyncResponse,
)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="Common Availability API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health_check():
return {"status": "healthy"}
@app.post("/api/participants", response_model=ParticipantResponse)
async def create_participant(
data: ParticipantCreate, db: AsyncSession = Depends(get_db)
):
existing = await db.execute(
select(Participant).where(Participant.email == data.email)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
participant = Participant(
name=data.name,
email=data.email,
ics_url=data.ics_url,
)
db.add(participant)
await db.commit()
await db.refresh(participant)
try:
await sync_participant_calendar(db, participant)
except Exception as e:
logger.warning(f"Initial sync failed for {participant.email}: {e}")
return participant
@app.get("/api/participants", response_model=list[ParticipantResponse])
async def list_participants(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Participant))
return result.scalars().all()
@app.get("/api/participants/{participant_id}", response_model=ParticipantResponse)
async def get_participant(participant_id: UUID, 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")
return participant
@app.delete("/api/participants/{participant_id}")
async def delete_participant(participant_id: UUID, 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")
await db.delete(participant)
await db.commit()
return {"status": "deleted"}
@app.post("/api/availability", response_model=AvailabilityResponse)
async def get_availability(
request: AvailabilityRequest, db: AsyncSession = Depends(get_db)
):
slots = await calculate_availability(db, request.participant_ids)
return {"slots": slots}
@app.post("/api/sync", response_model=SyncResponse)
async def sync_calendars(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Participant))
participants = result.scalars().all()
results = await sync_all_calendars(db, list(participants))
return {"results": results}
@app.post("/api/sync/{participant_id}")
async def sync_participant(participant_id: UUID, 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")
try:
count = await sync_participant_calendar(db, participant)
return {"status": "success", "blocks_synced": count}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

40
backend/src/app/models.py Normal file
View File

@@ -0,0 +1,40 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class Participant(Base):
__tablename__ = "participants"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
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)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
class BusyBlock(Base):
__tablename__ = "busy_blocks"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
participant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), nullable=False, index=True
)
start_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
end_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)

View File

@@ -0,0 +1,41 @@
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, EmailStr
class ParticipantCreate(BaseModel):
name: str
email: EmailStr
ics_url: str
class ParticipantResponse(BaseModel):
id: UUID
name: str
email: str
ics_url: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TimeSlot(BaseModel):
day: str
hour: int
availability: str
availableParticipants: list[str]
class AvailabilityRequest(BaseModel):
participant_ids: list[UUID]
class AvailabilityResponse(BaseModel):
slots: list[TimeSlot]
class SyncResponse(BaseModel):
results: dict[str, dict]

View File

16
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,16 @@
import pytest
@pytest.fixture
def sample_ics():
return """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
DTSTART:20260106T150000Z
DTEND:20260106T160000Z
SUMMARY:Meeting
UID:test-1
DTSTAMP:20260101T000000Z
END:VEVENT
END:VCALENDAR"""

View File

@@ -0,0 +1,73 @@
from datetime import datetime, timedelta, timezone
from app.availability_service import (
get_week_boundaries,
is_participant_free,
)
def test_get_week_boundaries_returns_monday_to_friday():
wednesday = datetime(2026, 1, 7, 12, 0, 0, tzinfo=timezone.utc)
monday, friday = get_week_boundaries(wednesday)
assert monday.weekday() == 0
assert friday.weekday() == 4
assert monday.hour == 0
assert friday.hour == 23
def test_get_week_boundaries_monday_input():
monday_input = datetime(2026, 1, 5, 12, 0, 0, tzinfo=timezone.utc)
monday, friday = get_week_boundaries(monday_input)
assert monday.day == 5
assert friday.day == 9
def test_is_participant_free_no_blocks():
slot_start = datetime(2026, 1, 6, 10, 0, 0, tzinfo=timezone.utc)
slot_end = datetime(2026, 1, 6, 11, 0, 0, tzinfo=timezone.utc)
assert is_participant_free([], slot_start, slot_end) is True
def test_is_participant_free_with_non_overlapping_block():
slot_start = datetime(2026, 1, 6, 10, 0, 0, tzinfo=timezone.utc)
slot_end = datetime(2026, 1, 6, 11, 0, 0, tzinfo=timezone.utc)
busy_blocks = [
(
datetime(2026, 1, 6, 14, 0, 0, tzinfo=timezone.utc),
datetime(2026, 1, 6, 15, 0, 0, tzinfo=timezone.utc),
)
]
assert is_participant_free(busy_blocks, slot_start, slot_end) is True
def test_is_participant_busy_with_overlapping_block():
slot_start = datetime(2026, 1, 6, 10, 0, 0, tzinfo=timezone.utc)
slot_end = datetime(2026, 1, 6, 11, 0, 0, tzinfo=timezone.utc)
busy_blocks = [
(
datetime(2026, 1, 6, 10, 30, 0, tzinfo=timezone.utc),
datetime(2026, 1, 6, 11, 30, 0, tzinfo=timezone.utc),
)
]
assert is_participant_free(busy_blocks, slot_start, slot_end) is False
def test_is_participant_busy_with_containing_block():
slot_start = datetime(2026, 1, 6, 10, 0, 0, tzinfo=timezone.utc)
slot_end = datetime(2026, 1, 6, 11, 0, 0, tzinfo=timezone.utc)
busy_blocks = [
(
datetime(2026, 1, 6, 9, 0, 0, tzinfo=timezone.utc),
datetime(2026, 1, 6, 12, 0, 0, tzinfo=timezone.utc),
)
]
assert is_participant_free(busy_blocks, slot_start, slot_end) is False

View File

@@ -0,0 +1,58 @@
import uuid
import pytest
from app.ics_service import parse_ics_to_busy_blocks
SAMPLE_ICS = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
DTSTART:20260106T150000Z
DTEND:20260106T160000Z
SUMMARY:Meeting
UID:test-1
DTSTAMP:20260101T000000Z
END:VEVENT
BEGIN:VEVENT
DTSTART:20260107T140000Z
DTEND:20260107T153000Z
SUMMARY:Another Meeting
UID:test-2
DTSTAMP:20260101T000000Z
END:VEVENT
END:VCALENDAR"""
def test_parse_ics_extracts_busy_blocks():
participant_id = str(uuid.uuid4())
blocks = parse_ics_to_busy_blocks(SAMPLE_ICS, participant_id)
assert len(blocks) == 2
assert all(str(b.participant_id) == participant_id for b in blocks)
def test_parse_ics_extracts_correct_times():
participant_id = str(uuid.uuid4())
blocks = parse_ics_to_busy_blocks(SAMPLE_ICS, participant_id)
sorted_blocks = sorted(blocks, key=lambda b: b.start_time)
assert sorted_blocks[0].start_time.hour == 15
assert sorted_blocks[0].end_time.hour == 16
assert sorted_blocks[1].start_time.hour == 14
assert sorted_blocks[1].end_time.hour == 15
assert sorted_blocks[1].end_time.minute == 30
def test_parse_empty_ics():
empty_ics = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
END:VCALENDAR"""
participant_id = str(uuid.uuid4())
blocks = parse_ics_to_busy_blocks(empty_ics, participant_id)
assert len(blocks) == 0

48
docker-compose.yml Normal file
View File

@@ -0,0 +1,48 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: availability
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432: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
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
volumes:
- ./backend/src:/app/src
- ./backend/alembic:/app/alembic
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "5173:8080"
environment:
VITE_API_URL: http://localhost:8000
depends_on:
- backend
volumes:
- ./frontend/src:/app/src
volumes:
postgres_data:

4
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
.git
.gitignore
dist

12
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 8080
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

View File

@@ -2859,6 +2859,7 @@
"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"
} }
@@ -2876,6 +2877,7 @@
"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"
@@ -2887,6 +2889,7 @@
"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"
} }
@@ -2937,6 +2940,7 @@
"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",
@@ -3169,6 +3173,7 @@
"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"
}, },
@@ -3373,6 +3378,7 @@
} }
], ],
"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",
@@ -3706,6 +3712,7 @@
"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"
@@ -3787,7 +3794,8 @@
"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",
@@ -3885,6 +3893,7 @@
"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",
@@ -5406,6 +5415,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -5592,6 +5602,7 @@
"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"
}, },
@@ -5618,6 +5629,7 @@
"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"
@@ -5631,6 +5643,7 @@
"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"
}, },
@@ -6184,6 +6197,7 @@
"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",
@@ -6308,6 +6322,7 @@
"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"
@@ -6487,6 +6502,7 @@
"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

@@ -0,0 +1,78 @@
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
export interface ParticipantAPI {
id: string;
name: string;
email: string;
ics_url: string;
created_at: string;
updated_at: string;
}
export interface TimeSlotAPI {
day: string;
hour: number;
availability: 'full' | 'partial' | 'none';
availableParticipants: string[];
}
export interface CreateParticipantRequest {
name: string;
email: string;
ics_url: string;
}
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Request failed' }));
throw new Error(error.detail || 'Request failed');
}
return response.json();
}
export async function fetchParticipants(): Promise<ParticipantAPI[]> {
const response = await fetch(`${API_URL}/api/participants`);
return handleResponse<ParticipantAPI[]>(response);
}
export async function createParticipant(data: CreateParticipantRequest): Promise<ParticipantAPI> {
const response = await fetch(`${API_URL}/api/participants`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return handleResponse<ParticipantAPI>(response);
}
export async function deleteParticipant(id: string): Promise<void> {
const response = await fetch(`${API_URL}/api/participants/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete participant');
}
}
export async function fetchAvailability(participantIds: string[]): Promise<TimeSlotAPI[]> {
const response = await fetch(`${API_URL}/api/availability`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ participant_ids: participantIds }),
});
const data = await handleResponse<{ slots: TimeSlotAPI[] }>(response);
return data.slots;
}
export async function syncCalendars(): Promise<void> {
const response = await fetch(`${API_URL}/api/sync`, { method: 'POST' });
if (!response.ok) {
throw new Error('Failed to sync calendars');
}
}
export async function syncParticipant(id: string): Promise<void> {
const response = await fetch(`${API_URL}/api/sync/${id}`, { method: 'POST' });
if (!response.ok) {
throw new Error('Failed to sync participant calendar');
}
}

View File

@@ -1,5 +1,4 @@
import { TimeSlot, Participant } from '@/types/calendar'; import { TimeSlot, Participant } from '@/types/calendar';
import { days, hours } from '@/data/mockData';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
Popover, Popover,
@@ -7,13 +6,17 @@ 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 } from 'lucide-react'; import { Check, X, Loader2 } from 'lucide-react';
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const hours = [9, 10, 11, 12, 13, 14, 15, 16, 17];
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;
} }
export const AvailabilityHeatmap = ({ export const AvailabilityHeatmap = ({
@@ -21,6 +24,7 @@ export const AvailabilityHeatmap = ({
selectedParticipants, selectedParticipants,
onSlotSelect, onSlotSelect,
showPartialAvailability = false, showPartialAvailability = false,
isLoading = false,
}: AvailabilityHeatmapProps) => { }: AvailabilityHeatmapProps) => {
const getSlot = (day: string, hour: number) => { const getSlot = (day: string, hour: number) => {
return slots.find((s) => s.day === day && s.hour === hour); return slots.find((s) => s.day === day && s.hour === hour);
@@ -59,6 +63,15 @@ export const AvailabilityHeatmap = ({
); );
} }
if (isLoading) {
return (
<div className="bg-card rounded-xl shadow-card p-8 text-center animate-fade-in">
<Loader2 className="w-8 h-8 mx-auto mb-4 animate-spin text-primary" />
<p className="text-muted-foreground">Loading availability...</p>
</div>
);
}
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">
@@ -72,7 +85,6 @@ export const AvailabilityHeatmap = ({
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="min-w-[600px]"> <div className="min-w-[600px]">
{/* Header */}
<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) => ( {days.map((day) => (
@@ -85,7 +97,6 @@ export const AvailabilityHeatmap = ({
))} ))}
</div> </div>
{/* Grid */}
<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 key={hour} className="grid grid-cols-[60px_repeat(5,1fr)] gap-1">
@@ -157,7 +168,6 @@ export const AvailabilityHeatmap = ({
</div> </div>
</div> </div>
{/* Legend */}
<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">
<div className="w-4 h-4 rounded bg-availability-full"></div> <div className="w-4 h-4 rounded bg-availability-full"></div>

View File

@@ -1,10 +1,9 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect } from 'react';
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 { generateMockAvailability } from '@/data/mockData';
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';
@@ -15,39 +14,48 @@ import {
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover'; } from '@/components/ui/popover';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Users, CalendarDays, Settings } from 'lucide-react'; import { Users, CalendarDays, Settings, RefreshCw } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import {
fetchParticipants,
createParticipant,
deleteParticipant,
fetchAvailability,
syncCalendars,
ParticipantAPI,
} from '@/api/client';
const STORAGE_KEY = 'calendar-participants';
const SETTINGS_KEY = 'calendar-settings'; const SETTINGS_KEY = 'calendar-settings';
interface Settings { interface SettingsState {
showPartialAvailability: boolean; showPartialAvailability: boolean;
} }
const defaultSettings: Settings = { const defaultSettings: SettingsState = {
showPartialAvailability: false, showPartialAvailability: false,
}; };
function apiToParticipant(p: ParticipantAPI): Participant {
return {
id: p.id,
name: p.name,
email: p.email,
icsLink: p.ics_url,
connected: true,
};
}
const Index = () => { const Index = () => {
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 [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<Settings>(defaultSettings); const [settings, setSettings] = useState<SettingsState>(defaultSettings);
const [isLoading, setIsLoading] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const { toast } = useToast();
// Load participants from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
setParticipants(JSON.parse(stored));
} catch (e) {
console.error('Failed to parse stored participants');
}
}
}, []);
// Load settings from localStorage on mount
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem(SETTINGS_KEY); const stored = localStorage.getItem(SETTINGS_KEY);
if (stored) { if (stored) {
@@ -59,37 +67,112 @@ const Index = () => {
} }
}, []); }, []);
// Save participants to localStorage when changed
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(participants));
}, [participants]);
// Save settings to localStorage when changed
useEffect(() => { useEffect(() => {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}, [settings]); }, [settings]);
const handleAddParticipant = (data: { name: string; email: string; icsLink: string }) => { useEffect(() => {
const newParticipant: Participant = { loadParticipants();
id: crypto.randomUUID(), }, []);
name: data.name,
email: data.email,
icsLink: data.icsLink,
connected: true,
};
setParticipants((prev) => [...prev, newParticipant]);
};
const handleRemoveParticipant = (id: string) => { useEffect(() => {
setParticipants((prev) => prev.filter((p) => p.id !== id)); if (selectedParticipants.length > 0) {
setSelectedParticipants((prev) => prev.filter((p) => p.id !== id)); loadAvailability();
}; } else {
setAvailabilitySlots([]);
// Generate availability when participants change }
const availabilitySlots = useMemo(() => {
return generateMockAvailability(selectedParticipants);
}, [selectedParticipants]); }, [selectedParticipants]);
const loadParticipants = async () => {
try {
const data = await fetchParticipants();
setParticipants(data.map(apiToParticipant));
} catch (error) {
toast({
title: 'Error loading participants',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
}
};
const loadAvailability = async () => {
setIsLoading(true);
try {
const ids = selectedParticipants.map((p) => p.id);
const slots = await fetchAvailability(ids);
setAvailabilitySlots(slots);
} catch (error) {
toast({
title: 'Error loading availability',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
const handleAddParticipant = async (data: { name: string; email: string; icsLink: string }) => {
try {
const created = await createParticipant({
name: data.name,
email: data.email,
ics_url: data.icsLink,
});
setParticipants((prev) => [...prev, apiToParticipant(created)]);
toast({
title: 'Participant added',
description: `${data.name} has been added and calendar synced`,
});
} catch (error) {
toast({
title: 'Error adding participant',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
}
};
const handleRemoveParticipant = async (id: string) => {
try {
await deleteParticipant(id);
setParticipants((prev) => prev.filter((p) => p.id !== id));
setSelectedParticipants((prev) => prev.filter((p) => p.id !== id));
toast({
title: 'Participant removed',
});
} catch (error) {
toast({
title: 'Error removing participant',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
}
};
const handleSyncCalendars = async () => {
setIsSyncing(true);
try {
await syncCalendars();
if (selectedParticipants.length > 0) {
await loadAvailability();
}
toast({
title: 'Calendars synced',
description: 'All calendars have been refreshed',
});
} catch (error) {
toast({
title: 'Error syncing calendars',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
setIsSyncing(false);
}
};
const handleSlotSelect = (slot: TimeSlot) => { const handleSlotSelect = (slot: TimeSlot) => {
setSelectedSlot(slot); setSelectedSlot(slot);
setIsModalOpen(true); setIsModalOpen(true);
@@ -132,7 +215,15 @@ const Index = () => {
<TabsContent value="schedule" className="animate-fade-in"> <TabsContent value="schedule" className="animate-fade-in">
<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"> <div className="absolute right-0 top-0 flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={handleSyncCalendars}
disabled={isSyncing}
>
<RefreshCw className={`w-5 h-5 ${isSyncing ? 'animate-spin' : ''}`} />
</Button>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
@@ -195,6 +286,7 @@ const Index = () => {
selectedParticipants={selectedParticipants} selectedParticipants={selectedParticipants}
onSlotSelect={handleSlotSelect} onSlotSelect={handleSlotSelect}
showPartialAvailability={settings.showPartialAvailability} showPartialAvailability={settings.showPartialAvailability}
isLoading={isLoading}
/> />
</> </>
)} )}

55
justfile Normal file
View File

@@ -0,0 +1,55 @@
set dotenv-load
default:
@just --list
build:
docker compose build
up:
docker compose up -d
down:
docker compose down
logs:
docker compose logs -f
logs-backend:
docker compose logs -f backend
logs-frontend:
docker compose logs -f frontend
restart:
docker compose restart
migrate:
docker compose exec backend uv run alembic upgrade head
migrate-down:
docker compose exec backend uv run alembic downgrade -1
shell-backend:
docker compose exec backend bash
shell-db:
docker compose exec db psql -U postgres -d availability
test:
docker compose exec backend uv run pytest -v
lint:
docker compose exec backend uv run ruff check src/
format:
docker compose exec backend uv run ruff format src/
sync-calendars:
curl -X POST http://localhost:8000/api/sync
fresh: down
docker compose down -v
docker compose up -d --build
sleep 5
just migrate

6
package-lock.json generated
View File

@@ -1,6 +0,0 @@
{
"name": "common-availability",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}