Skip to content
Open
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
21 changes: 21 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"permissions": {
"allow": [
"Bash(python -m unittest discover -s tests -v)",
"Bash(npm run *)",
"Bash(open -a Docker)",
"Bash(docker info *)",
"Bash(osascript -e 'quit app \"Docker Desktop\"')",
"Bash(git -C /Users/eppo.luiken/Projects/pm add CLAUDE.md)",
"Bash(git -C /Users/eppo.luiken/Projects/pm commit -m \"Added CLAUDE.MD\")",
"Bash(git *)",
"Bash(npm test *)",
"Bash(./scripts/stop.sh)",
"Bash(code --version)",
"Bash(docker compose *)",
"Bash(osascript *)",
"Skill(update-config)",
"Skill(ralph-loop:ralph-loop)"
]
}
}
18 changes: 18 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.git
.gitignore
node_modules
frontend/node_modules
frontend/.next
frontend/out
data
__pycache__
*.py[cod]
*.pyo
*.pyd
*.swp
*.DS_Store
.env
.venv
venv
ENV
env
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
OPENROUTER_API_KEY=your-openrouter-api-key
OPENROUTER_MODEL=google/gemma-4-26b-a4b-it:free,google/gemma-4-31b-it:free,openrouter/free
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,8 @@ cython_debug/

.DS_Store

# Local SQLite preview data
data/
*.db
*.db-shm
*.db-wal
110 changes: 110 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

A Kanban project management app with an AI chat sidebar. One board per user, hardcoded login (`user` / `password`). Runs locally in Docker.

## Commands

### Run the app (Docker)
```bash
./scripts/start.sh # builds image, starts container on :8000
./scripts/stop.sh # stops and removes pm-app container
```

### Backend development (without Docker)
Run all commands from the **project root**:
```bash
pip install -e backend/
uvicorn backend.main:app --reload --port 8000
```

### Backend tests
Run from the **project root** (so the `backend` package is importable):
```bash
python -m unittest discover -s backend/tests -v
# or a single test:
python -m unittest backend.tests.test_backend.BackendPersistenceTests.test_health_endpoint
```

### Frontend development
```bash
cd frontend
npm install
npm run dev # starts Next.js dev server on :3000
```

Set `NEXT_PUBLIC_API_BASE_URL=http://localhost:8000/api` in `frontend/.env.local` when running frontend dev server against a local backend.

### Frontend lint and tests
```bash
cd frontend
npm run lint # ESLint
npm test # unit tests via vitest
npm run test:e2e # Playwright e2e (builds Docker image first)
```

## Architecture

```
/ (project root)
.env OPENROUTER_API_KEY and OPENROUTER_MODEL
Dockerfile multi-stage: builds Next.js then installs Python backend
scripts/ start/stop scripts for Mac, Windows, Linux
data/pm.db SQLite database (Docker mounts this for persistence)

backend/
main.py FastAPI app, Pydantic models, all API routes
db.py SQLite access — stores the full board as one JSON blob per user
ai.py OpenRouter calls with model fallback list
tests/test_backend.py unittest + FastAPI TestClient

frontend/src/
app/page.tsx root page: login gate, passes username to KanbanBoard
components/ KanbanBoard, KanbanColumn, KanbanCard, KanbanCardPreview, LoginScreen, NewCardForm
lib/kanban.ts BoardData types, moveCard logic, createId (pure functions)
lib/api.ts fetch wrappers for /api/board and /api/chat
```

### Key data flow

- Board state lives in the frontend (`KanbanBoard`) and is synced to `POST /api/board/{username}` on every change.
- The AI chat (`POST /api/chat`) receives the full board JSON plus conversation history and returns `{ message, boardUpdate }`. If `boardUpdate` is non-null and valid, the frontend replaces its board state with it.
- `BoardModel` in `main.py` validates that every `cardId` referenced by a column actually exists in `cards` — invalid AI responses are silently dropped.
- `ai.py` tries each model in `OPENROUTER_MODEL` (or the default list) in sequence, falling back on any error.

### Frontend patterns

- Drag-and-drop uses `@dnd-kit/core` with `closestCorners` collision detection. `KanbanCardPreview` renders inside `DragOverlay`.
- `updateBoard(updater, { debounce })` in `KanbanBoard` is the single mutation path: it runs the updater, syncs to the backend, and optionally debounces (400 ms) for high-frequency changes like column renames.
- Styling uses Tailwind with CSS variables (`--navy-dark`, `--primary-blue`, `--secondary-purple`, `--stroke`, `--surface`, `--shadow`, `--gray-text`) defined in the global stylesheet — use these instead of hard-coding hex values.

### Static serving

The backend serves the Next.js static export (`next build` produces `frontend/out/`). In development, `main.py` falls back to `backend/static/` if `frontend/out/` doesn't exist. In Docker, the Dockerfile copies `frontend/out` into `backend/static`.

## Environment variables

| Variable | Required | Description |
|---|---|---|
| `OPENROUTER_API_KEY` | Yes | Must start with `sk-or-` |
| `OPENROUTER_MODEL` | No | Comma-separated model list; defaults to free Gemma models |
| `PM_DB_PATH` | No | SQLite path; defaults to `backend/pm.db`; Docker sets to `/app/data/pm.db` |

## Coding standards

- No over-engineering, no unnecessary defensive programming, no extra features.
- No emojis anywhere.
- Identify root cause before fixing — prove with evidence, don't guess.
- Use latest idiomatic library approaches.
- Keep comments minimal: only when the why is non-obvious.

## Color scheme

- Accent yellow: `#ecad0a`
- Blue primary: `#209dd7`
- Purple secondary: `#753991`
- Dark navy: `#032147`
- Gray text: `#888888`
24 changes: 24 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
FROM node:20-alpine AS frontend-build

WORKDIR /app/frontend
ENV NEXT_TELEMETRY_DISABLED=1
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build

FROM python:3.12-slim

WORKDIR /app

RUN python -m pip install --upgrade pip && \
python -m pip install uv

COPY backend/pyproject.toml backend/ .
RUN uv sync --no-install-project

COPY backend backend
COPY --from=frontend-build /app/frontend/out ./backend/static

EXPOSE 8000
CMD ["uv", "run", "--no-project", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
53 changes: 52 additions & 1 deletion backend/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1 +1,52 @@
This file should be updated with a description of the Backend
# Backend Agent Guidance

## Purpose
This file describes the backend scaffold currently in `backend/` and explains the existing structure for the PM MVP backend.

## Backend architecture
- `backend/main.py`
- FastAPI application entrypoint.
- Initializes SQLite during app lifespan startup.
- Serves the built Next.js static site from `backend/static/`.
- Provides these API routes:
- `GET /api/health` returns `{ "status": "ok" }`
- `GET /api/hello` returns `{ "message": "hello world" }`
- `GET /api/board/{username}` returns the user's board JSON
- `POST /api/board/{username}` stores the user's board JSON
- `POST /api/chat` sends board context to OpenRouter and returns a structured AI response

- `backend/db.py`
- Creates and accesses the SQLite database.
- Stores the full board as one JSON blob per user.
- Reads `PM_DB_PATH` when provided; Docker scripts set this to `/app/data/pm.db`.

- `backend/ai.py`
- Calls OpenRouter with the configured chat model.
- Requires `OPENROUTER_API_KEY` from the runtime environment.
- Defaults to a comma-separated free fallback list; set `OPENROUTER_MODEL` in `.env` to override it.

- `backend/pyproject.toml`
- Declares backend dependencies.
- Used by the `uv` package manager inside Docker.

## Docker and scripts
- `Dockerfile`
- Builds the Next.js frontend first.
- Copies the static export into the backend image.
- Installs backend dependencies with `uv`.
- Starts the app with `uv run --no-project uvicorn backend.main:app --host 0.0.0.0 --port 8000`.

- `.dockerignore`
- Prevents node_modules, build artifacts, and Python environment files from being copied into the image.

- `scripts/start.sh`
- Builds the Docker image and starts the container on port `8000`.
- Mounts local `data/` into the container for SQLite persistence.
- Passes root `.env` into Docker if it exists.

- `scripts/stop.sh`
- Stops and removes the running Docker container named `pm-app`.

## Current state
- Backend persistence and AI connectivity routes are in place.
- The AI route validates structured board updates before returning them to the frontend.
1 change: 1 addition & 0 deletions backend/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Backend package for the PM MVP
95 changes: 95 additions & 0 deletions backend/ai.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from __future__ import annotations

import os
from typing import Any

import httpx

OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
DEFAULT_MODEL_NAMES = [
"google/gemma-4-26b-a4b-it:free",
"google/gemma-4-31b-it:free",
"openrouter/free",
]


def get_model_names() -> list[str]:
configured_models = os.getenv("OPENROUTER_MODEL")
if not configured_models:
return DEFAULT_MODEL_NAMES
return [
model.strip()
for model in configured_models.split(",")
if model.strip()
]


def _provider_error_message(error: httpx.HTTPStatusError) -> str:
status_code = error.response.status_code
if status_code == 401:
return "OpenRouter rejected OPENROUTER_API_KEY. Create a valid OpenRouter key and restart the app."
if status_code == 402:
return (
"OpenRouter returned 402 Payment Required. Add credits to the OpenRouter "
f"account for {', '.join(get_model_names())}, or configure a model ending in :free."
)

try:
body = error.response.json()
except ValueError:
body = None

if isinstance(body, dict):
provider_error = body.get("error")
if isinstance(provider_error, dict):
metadata = provider_error.get("metadata")
if isinstance(metadata, dict) and isinstance(metadata.get("raw"), str):
return metadata["raw"]
if isinstance(provider_error.get("message"), str):
return provider_error["message"]
if isinstance(body.get("message"), str):
return body["message"]

return f"OpenRouter request failed with HTTP {status_code}"


async def ask_openrouter(messages: list[dict[str, str]]) -> str:
api_key = os.getenv("OPENROUTER_API_KEY")
if not api_key:
raise RuntimeError("OPENROUTER_API_KEY is not configured")
if not api_key.startswith("sk-or-"):
raise RuntimeError("OPENROUTER_API_KEY must be an OpenRouter key starting with sk-or-")

headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}

errors: list[str] = []
async with httpx.AsyncClient(timeout=30.0) as client:
for model_name in get_model_names():
payload: dict[str, Any] = {
"model": model_name,
"messages": messages,
"response_format": {"type": "json_object"},
}
try:
response = await client.post(OPENROUTER_URL, json=payload, headers=headers)
response.raise_for_status()
data = response.json()
choices = data.get("choices", [])
if not choices:
raise RuntimeError("OpenRouter returned no choices")

message = choices[0].get("message", {})
content = message.get("content")
if not isinstance(content, str) or not content.strip():
raise RuntimeError(f"{model_name} returned empty content")

return content
except httpx.HTTPStatusError as exc:
errors.append(_provider_error_message(exc))
except (httpx.TimeoutException, httpx.RequestError, RuntimeError) as exc:
errors.append(str(exc))

raise RuntimeError("; ".join(errors) or "OpenRouter returned no usable response")
Loading