Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .env.dev

This file was deleted.

15 changes: 11 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_DB=
DEBUG=false
OPENROUTER_API_KEY=
OPENROUTER_APP_TITLE=TaskCraft_v1_0_0
OPENROUTER_APP_REFERER=TaskCraft_v1_0_0
OPENROUTER_MODEL=anthropic/claude-3.5-haiku
POSTGRES_USER=dev
POSTGRES_PASSWORD=dev
POSTGRES_DB=myapp_dev
POSTGRES_HOST=db
POSTGRES_PORT=5432
DEBUG=false
LOG_LEVEL=INFO
20 changes: 12 additions & 8 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.10'
python-version: '3.12'

- name: Install uv
uses: astral-sh/setup-uv@v7
Expand All @@ -41,15 +41,22 @@ jobs:

- name: Create test environment file
run: |
cat > .env.test <<EOF
cat > .env.ci <<EOF
OPENROUTER_API_KEY=test-key
OPENROUTER_APP_TITLE=TaskCraft_CI
OPENROUTER_APP_REFERER=TaskCraft_CI
OPENROUTER_MODEL=anthropic/claude-3.5-haiku
POSTGRES_USER=test
POSTGRES_PASSWORD=test
POSTGRES_DB=myapp_test
POSTGRES_HOST=db
POSTGRES_PORT=5432
DEBUG=true
LOG_LEVEL=DEBUG
EOF

- name: Build and start services
run: docker compose -f docker-compose.dev.yml --env-file .env.test up -d --build
run: docker compose -f docker-compose.dev.yml --env-file .env.ci up -d --build

- name: Wait for services to be ready
run: |
Expand All @@ -62,13 +69,10 @@ jobs:
echo "Testing health endpoint..."
curl -f http://localhost:8000/api/health

echo "Testing items endpoint..."
curl -f http://localhost:8000/api/items

- name: Show logs on failure
if: failure()
run: docker compose -f docker-compose.dev.yml logs
run: docker compose -f docker-compose.dev.yml --env-file .env.ci logs

- name: Tear down services
if: always()
run: docker compose -f docker-compose.dev.yml down -v
run: docker compose -f docker-compose.dev.yml --env-file .env.ci down -v
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ dist/
.DS_Store # macOS
Thumbs.db # Windows

# Node
node_modules/

# Miscellaneous
# Logs and database files (if local)
*.log
Expand All @@ -35,4 +38,5 @@ htmlcov/
# Environment files
.env.test
.env.local
/.env
.env.dev
.env
37 changes: 23 additions & 14 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,52 +4,61 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

TaskCraft is a Python FastAPI backend with a vanilla JavaScript frontend. Uses uv for package management and Docker Compose for development.
TaskCraft is a Python FastAPI backend with a SolidJS + TypeScript frontend. Uses uv for Python package management and npm for frontend. Docker Compose for development.

## Development Commands

### Running Locally

Backend:
```bash
uv run uvicorn app.main:app --reload
```

Frontend:
```bash
cd frontend
npm run dev
```

### Docker Development

**Important**: Set environment variables via `.env.dev` file (gitignored). Copy from `.env.example` and fill in your values.

```bash
# Start all services (app + PostgreSQL)
docker compose -f docker-compose.dev.yml up
docker compose -f docker-compose.dev.yml --env-file .env.dev up

# Stop services
docker compose -f docker-compose.dev.yml down
docker compose -f docker-compose.dev.yml --env-file .env.dev down
```

### Database
```bash
# Create migration
docker compose -f docker-compose.dev.yml exec app uv run alembic revision --autogenerate -m "description"
docker compose -f docker-compose.dev.yml --env-file .env.dev exec app uv run alembic revision --autogenerate -m "description"

# Apply migrations
docker compose -f docker-compose.dev.yml exec app uv run alembic upgrade head

# Direct database access
docker compose -f docker-compose.dev.yml exec db psql -U dev -d myapp_dev
```
docker compose -f docker-compose.dev.yml --env-file .env.dev exec app uv run alembic upgrade head

### Deployment
```bash
./deploy.sh # Pulls latest, rebuilds containers, runs migrations
# Direct database access (use credentials from your .env.dev)
docker compose -f docker-compose.dev.yml --env-file .env.dev exec db psql -U ${POSTGRES_USER} -d ${POSTGRES_DB}
```

## Architecture

- **Backend**: FastAPI app in `app/` with entry point at `app/main.py`
- **Frontend**: Static HTML/JS in `frontend/`, served by Nginx in production
- **Frontend**: SolidJS + TypeScript app in `frontend/src/`, built with Vite
- **Database**: PostgreSQL 18, migrations via Alembic
- **Nginx**: Reverse proxy config in `config/nginx.conf` - routes `/api` to backend, serves static files from `frontend/`

## Tech Stack

- Python 3.10+ with FastAPI
- uv for dependency management
- uv for Python dependency management
- SolidJS with Solid Router for frontend
- TypeScript for type safety
- Vite for frontend build tool
- PostgreSQL 18
- Docker/Docker Compose
- Nginx for production serving
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ uv run uvicorn app.main:app --reload
```

#### Frontend Development
Serve the frontend with SPA routing support:
The frontend uses Vite + SolidJS. Start the development server:
```
npx serve -p 8080 -s frontend
cd frontend
npm run dev
```
The `-s` flag enables single-page app mode, allowing client-side routing to work correctly.
This starts Vite with hot module replacement on http://localhost:5173/

#### Using Docker Compose

Expand Down
32 changes: 32 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import os
from typing import Optional

class Settings:
"""Application configuration"""

# Database
POSTGRES_USER: str = os.getenv("POSTGRES_USER", "dev")
POSTGRES_PASSWORD: str = os.getenv("POSTGRES_PASSWORD", "dev")
POSTGRES_DB: str = os.getenv("POSTGRES_DB", "myapp_dev")
POSTGRES_HOST: str = os.getenv("POSTGRES_HOST", "db")
POSTGRES_PORT: str = os.getenv("POSTGRES_PORT", "5432")

@property
def DATABASE_URL(self) -> str:
return (
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)

# OpenRouter
OPENROUTER_API_KEY: Optional[str] = os.getenv("OPENROUTER_API_KEY")
OPENROUTER_APP_TITLE: str = os.getenv("OPENROUTER_APP_TITLE", "TaskCraft")
OPENROUTER_APP_REFERER: str = os.getenv("OPENROUTER_APP_REFERER", "TaskCraft")
OPENROUTER_MODEL: str = os.getenv("OPENROUTER_MODEL", "anthropic/claude-3.5-haiku")

# Logging
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"


settings = Settings()
29 changes: 29 additions & 0 deletions app/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import declarative_base
from app.config import settings

# Create async engine
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
future=True,
)

# Create async session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)

# Base class for models
Base = declarative_base()


async def get_db():
"""Dependency for getting database session"""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
86 changes: 79 additions & 7 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import logging
from contextlib import asynccontextmanager
from app.config import settings
from app.database import engine

app = FastAPI()
logger = logging.getLogger(__name__)

# Configure logging level from environment variable
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO),
format="%(asctime)s | %(levelname)-8s | %(module)s:%(funcName)s:%(lineno)d - %(message)s"
)


@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage application lifecycle"""
logger.info("Starting up application...")
logger.info(f"Database URL: {settings.DATABASE_URL.split('@')[1]}") # Log DB host (not credentials)
yield
logger.info("Shutting down application...")
await engine.dispose()


app = FastAPI(lifespan=lifespan)


# CORS if needed during development
app.add_middleware(
CORSMiddleware,
Expand All @@ -15,10 +37,60 @@

@app.get("/api/health")
async def health():
return {"status": "ok"}
"""Health check endpoint with database connectivity test"""
from sqlalchemy import text
from app.database import AsyncSessionLocal

db_status = "unknown"
try:
async with AsyncSessionLocal() as session:
result = await session.execute(text("SELECT 1"))
result.scalar()
db_status = "connected"
except Exception as e:
logger.error(f"Database health check failed: {e}")
db_status = "disconnected"

return {
"status": "ok",
"database": db_status
}

from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from app.services.event_extractor import extract_event_from_text
from fastapi import HTTPException
from app.services.openrouter_client import OpenRouterError

class TaskCreateRequest(BaseModel):
text: str = Field(..., min_length=1)
email: Optional[str] = None
timezone: str = "Asia/Jerusalem"

class EventDraft(BaseModel):
title: str
start_at: Optional[datetime] = None
end_at: Optional[datetime] = None
notes: str = ""
missing_info: List[str] = []

class TaskCreateResponse(BaseModel):
raw_text: str
event: EventDraft

@app.post("/api/tasks", response_model=TaskCreateResponse)
async def create_task(payload: TaskCreateRequest):
logger.info("Creating task from text: %s" % payload.text)
try:
event_dict = await extract_event_from_text(payload.text, payload.timezone)
except OpenRouterError as e:
logger.error('Failed to send OpenRouter request: %s' % str(e))
raise HTTPException(status_code=503, detail=str(e))

logger.info("Response: %s" % str(event_dict))
return TaskCreateResponse(
raw_text=payload.text,
event=EventDraft(**event_dict),
)

# Your API routes here
@app.get("/api/items")
async def get_items():
logger.info('Fetching items')
return {"items": ['apple']}
Empty file added app/schemas/__init__.py
Empty file.
Empty file added app/services/__init__.py
Empty file.
Loading