diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 00000000..ac6419e3
--- /dev/null
+++ b/.claude/settings.local.json
@@ -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)"
+ ]
+ }
+}
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..e4a29665
--- /dev/null
+++ b/.dockerignore
@@ -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
diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..d341c26d
--- /dev/null
+++ b/.env.example
@@ -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
diff --git a/.gitignore b/.gitignore
index b1d22cc1..226eac49 100644
--- a/.gitignore
+++ b/.gitignore
@@ -174,3 +174,8 @@ cython_debug/
.DS_Store
+# Local SQLite preview data
+data/
+*.db
+*.db-shm
+*.db-wal
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..80c347b5
--- /dev/null
+++ b/CLAUDE.md
@@ -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`
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..d27d25ff
--- /dev/null
+++ b/Dockerfile
@@ -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"]
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index 6d2147f0..fdbbe025 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -1 +1,52 @@
-This file should be updated with a description of the Backend
\ No newline at end of file
+# 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.
diff --git a/backend/__init__.py b/backend/__init__.py
new file mode 100644
index 00000000..a589fb2a
--- /dev/null
+++ b/backend/__init__.py
@@ -0,0 +1 @@
+# Backend package for the PM MVP
diff --git a/backend/ai.py b/backend/ai.py
new file mode 100644
index 00000000..1712aaa5
--- /dev/null
+++ b/backend/ai.py
@@ -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")
diff --git a/backend/db.py b/backend/db.py
new file mode 100644
index 00000000..a7f3a806
--- /dev/null
+++ b/backend/db.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+import json
+import os
+import sqlite3
+from pathlib import Path
+from typing import Any
+
+DB_PATH = Path(os.getenv("PM_DB_PATH", Path(__file__).parent / "pm.db"))
+
+DEFAULT_BOARD = {"cards": {}, "columns": []}
+
+
+def get_connection() -> sqlite3.Connection:
+ return sqlite3.connect(DB_PATH, check_same_thread=False)
+
+
+def init_db() -> None:
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
+ with sqlite3.connect(DB_PATH) as conn:
+ conn.execute("PRAGMA foreign_keys = ON;")
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL UNIQUE,
+ display_name TEXT,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS boards (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ board_json TEXT NOT NULL,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(user_id),
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS conversations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ role TEXT NOT NULL,
+ message TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+ )
+ """
+ )
+ conn.commit()
+
+
+def create_user(username: str, display_name: str | None = None) -> int:
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute(
+ "INSERT OR IGNORE INTO users (username, display_name) VALUES (?, ?)",
+ (username, display_name),
+ )
+ conn.commit()
+ cursor.execute("SELECT id FROM users WHERE username = ?", (username,))
+ row = cursor.fetchone()
+ return row[0]
+
+
+def get_user_id(username: str) -> int | None:
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT id FROM users WHERE username = ?", (username,))
+ row = cursor.fetchone()
+ return row[0] if row else None
+
+
+def get_board(username: str) -> dict[str, Any] | None:
+ user_id = get_user_id(username)
+ if user_id is None:
+ return None
+
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT board_json FROM boards WHERE user_id = ?", (user_id,))
+ row = cursor.fetchone()
+ if not row:
+ return None
+ return json.loads(row[0])
+
+
+def save_board(username: str, board: Any) -> None:
+ user_id = create_user(username)
+ board_json = json.dumps(board)
+ with get_connection() as conn:
+ conn.execute("PRAGMA foreign_keys = ON;")
+ conn.execute(
+ """
+ INSERT INTO boards (user_id, board_json)
+ VALUES (?, ?)
+ ON CONFLICT(user_id) DO UPDATE SET
+ board_json = excluded.board_json,
+ updated_at = CURRENT_TIMESTAMP
+ """,
+ (user_id, board_json),
+ )
+ conn.commit()
diff --git a/backend/main.py b/backend/main.py
new file mode 100644
index 00000000..0b90bca2
--- /dev/null
+++ b/backend/main.py
@@ -0,0 +1,190 @@
+import json
+import logging
+from contextlib import asynccontextmanager
+from pathlib import Path
+from typing import Literal
+from typing import Any
+
+from dotenv import load_dotenv
+from fastapi import FastAPI
+from fastapi import HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel
+from pydantic import ValidationError
+from pydantic import model_validator
+
+from backend import ai
+from backend import db
+
+logger = logging.getLogger(__name__)
+
+project_root = Path(__file__).resolve().parent.parent
+load_dotenv(project_root / ".env")
+frontend_static_dir = project_root / "frontend" / "out"
+static_dir = frontend_static_dir if frontend_static_dir.exists() else Path(__file__).parent / "static"
+
+
+class CardModel(BaseModel):
+ id: str
+ title: str
+ details: str = ""
+
+
+class ColumnModel(BaseModel):
+ id: str
+ title: str
+ cardIds: list[str]
+
+
+class BoardModel(BaseModel):
+ columns: list[ColumnModel]
+ cards: dict[str, CardModel]
+
+ @model_validator(mode="after")
+ def card_ids_must_reference_cards(self) -> "BoardModel":
+ seen_card_ids: set[str] = set()
+ for column in self.columns:
+ for card_id in column.cardIds:
+ if card_id not in self.cards:
+ raise ValueError(f"Column references missing card {card_id}")
+ if card_id in seen_card_ids:
+ raise ValueError(f"Card {card_id} appears in more than one column")
+ seen_card_ids.add(card_id)
+ return self
+
+
+class BoardPayload(BaseModel):
+ board: BoardModel
+
+
+class BoardResponse(BaseModel):
+ user: str
+ board: BoardModel
+
+
+class ChatMessage(BaseModel):
+ role: Literal["user", "assistant"]
+ content: str
+
+
+class ChatRequest(BaseModel):
+ board: BoardModel
+ conversation: list[ChatMessage]
+ message: str
+
+
+class ChatResponse(BaseModel):
+ message: str
+ boardUpdate: BoardModel | None
+
+
+@asynccontextmanager
+async def lifespan(_: FastAPI):
+ db.init_db()
+ yield
+
+
+app = FastAPI(title="PM Backend", lifespan=lifespan)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+
+def _extract_json_object(raw_text: str) -> dict[str, Any]:
+ text = raw_text.strip()
+ if text.startswith("```"):
+ lines = text.splitlines()
+ if len(lines) >= 3:
+ text = "\n".join(lines[1:-1]).strip()
+
+ parsed = json.loads(text)
+ if not isinstance(parsed, dict):
+ raise ValueError("response is not a JSON object")
+ return parsed
+
+
+def _validated_board_or_default(board_data: dict[str, Any] | None) -> BoardModel:
+ if board_data is None:
+ return BoardModel.model_validate(db.DEFAULT_BOARD)
+ try:
+ return BoardModel.model_validate(board_data)
+ except ValidationError:
+ logger.warning("Stored board failed Pydantic validation; returning default board. Data: %s", board_data)
+ return BoardModel.model_validate(db.DEFAULT_BOARD)
+
+
+@app.get("/api/health")
+async def health() -> dict[str, str]:
+ return {"status": "ok"}
+
+
+@app.get("/api/hello")
+async def hello() -> dict[str, str]:
+ return {"message": "hello world"}
+
+
+@app.get("/api/board/{username}", response_model=BoardResponse)
+async def get_board(username: str) -> BoardResponse:
+ board_data = db.get_board(username)
+ return BoardResponse(user=username, board=_validated_board_or_default(board_data))
+
+
+@app.post("/api/board/{username}", response_model=BoardResponse)
+async def save_board(username: str, payload: BoardPayload) -> BoardResponse:
+ db.save_board(username, payload.board.model_dump())
+ return BoardResponse(user=username, board=payload.board)
+
+
+@app.post("/api/chat", response_model=ChatResponse)
+async def chat(payload: ChatRequest) -> ChatResponse:
+ messages: list[dict[str, str]] = [
+ {
+ "role": "system",
+ "content": (
+ "You are a Kanban assistant. Respond with JSON only. "
+ "Output format: {\"message\": string, \"boardUpdate\": object|null}. "
+ "Only include boardUpdate when a board change is needed. "
+ "When included, boardUpdate must be the complete board JSON with columns and cards."
+ ),
+ },
+ {
+ "role": "user",
+ "content": (
+ "Current board JSON:\n"
+ f"{payload.board.model_dump_json()}\n\n"
+ "Conversation history JSON:\n"
+ f"{json.dumps([message.model_dump() for message in payload.conversation])}\n\n"
+ f"User message:\n{payload.message}"
+ ),
+ },
+ ]
+
+ try:
+ raw_answer = await ai.ask_openrouter(messages)
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=f"AI request failed: {exc}") from exc
+
+ try:
+ parsed = _extract_json_object(raw_answer)
+ except (json.JSONDecodeError, ValueError) as exc:
+ raise HTTPException(status_code=502, detail="AI returned a non-JSON response") from exc
+
+ message = parsed.get("message")
+ if not isinstance(message, str) or not message.strip():
+ raise HTTPException(status_code=502, detail="AI request failed: invalid message field")
+
+ board_update = parsed.get("boardUpdate")
+ try:
+ board_update = BoardModel.model_validate(board_update) if board_update else None
+ except ValidationError:
+ board_update = None
+
+ return ChatResponse(message=message, boardUpdate=board_update)
+
+
+app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
new file mode 100644
index 00000000..648924f2
--- /dev/null
+++ b/backend/pyproject.toml
@@ -0,0 +1,16 @@
+[project]
+name = "pm-backend"
+version = "0.1.0"
+description = "Backend for the PM Kanban MVP"
+requires-python = ">=3.12"
+dependencies = [
+ "fastapi>=0.115.0",
+ "uvicorn[standard]>=0.23.0",
+ "aiofiles>=23.2.1",
+ "httpx>=0.28.1",
+ "python-dotenv>=1.1.1",
+]
+
+[build-system]
+requires = ["setuptools>=61.0", "wheel"]
+build-backend = "setuptools.build_meta"
diff --git a/backend/static/index.html b/backend/static/index.html
new file mode 100644
index 00000000..e3b70477
--- /dev/null
+++ b/backend/static/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ PM Backend
+
+
+ PM Backend is running
+ This page is served from the backend static assets.
+
+
diff --git a/backend/tests/test_backend.py b/backend/tests/test_backend.py
new file mode 100644
index 00000000..0dae5de1
--- /dev/null
+++ b/backend/tests/test_backend.py
@@ -0,0 +1,163 @@
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import AsyncMock
+from unittest.mock import patch
+
+from fastapi.testclient import TestClient
+import httpx
+
+from backend import ai
+from backend import db
+from backend.main import app
+
+
+class BackendPersistenceTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.tempdir = tempfile.TemporaryDirectory()
+ db.DB_PATH = Path(self.tempdir.name) / "test.db"
+ db.init_db()
+ self.client = TestClient(app)
+
+ def tearDown(self) -> None:
+ self.tempdir.cleanup()
+
+ def test_database_initializes_tables(self) -> None:
+ self.assertTrue(db.DB_PATH.exists())
+ with db.get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
+ )
+ self.assertIsNotNone(cursor.fetchone())
+ cursor.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='boards'"
+ )
+ self.assertIsNotNone(cursor.fetchone())
+
+ def test_health_endpoint(self) -> None:
+ response = self.client.get("/api/health")
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json(), {"status": "ok"})
+
+ def test_startup_creates_database_if_missing(self) -> None:
+ self.tempdir.cleanup()
+ self.tempdir = tempfile.TemporaryDirectory()
+ db.DB_PATH = Path(self.tempdir.name) / "startup.db"
+ self.assertFalse(db.DB_PATH.exists())
+
+ with TestClient(app) as startup_client:
+ response = startup_client.get("/api/health")
+ self.assertEqual(response.status_code, 200)
+
+ self.assertTrue(db.DB_PATH.exists())
+
+ def test_get_board_returns_default_for_new_user(self) -> None:
+ response = self.client.get("/api/board/user")
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json()["user"], "user")
+ self.assertEqual(response.json()["board"], db.DEFAULT_BOARD)
+
+ def test_save_board_then_read_board(self) -> None:
+ payload = {
+ "board": {
+ "cards": {
+ "card-1": {"id": "card-1", "title": "Task 1", "details": "Notes"}
+ },
+ "columns": [{"id": "col-1", "title": "Todo", "cardIds": ["card-1"]}],
+ }
+ }
+
+ save_response = self.client.post("/api/board/user", json=payload)
+ self.assertEqual(save_response.status_code, 200)
+ self.assertEqual(save_response.json()["board"], payload["board"])
+
+ read_response = self.client.get("/api/board/user")
+ self.assertEqual(read_response.status_code, 200)
+ self.assertEqual(read_response.json()["board"], payload["board"])
+
+ def test_chat_endpoint_returns_model_response(self) -> None:
+ with patch(
+ "backend.main.ai.ask_openrouter",
+ new=AsyncMock(
+ return_value='{"message":"Done. Added card.","boardUpdate":{"cards":{"c1":{"id":"c1","title":"Task"}},"columns":[]}}'
+ ),
+ ):
+ response = self.client.post(
+ "/api/chat",
+ json={"board": db.DEFAULT_BOARD, "conversation": [], "message": "Add a task"},
+ )
+ self.assertEqual(response.status_code, 200)
+ body = response.json()
+ self.assertEqual(body["message"], "Done. Added card.")
+ self.assertIsInstance(body["boardUpdate"], dict)
+
+ def test_chat_endpoint_returns_502_on_provider_error(self) -> None:
+ with patch(
+ "backend.main.ai.ask_openrouter",
+ new=AsyncMock(side_effect=RuntimeError("provider unavailable")),
+ ):
+ response = self.client.post(
+ "/api/chat",
+ json={"board": db.DEFAULT_BOARD, "conversation": [], "message": "2+2"},
+ )
+ self.assertEqual(response.status_code, 502)
+ self.assertIn("AI request failed", response.json()["detail"])
+
+ def test_chat_endpoint_returns_502_on_invalid_message_field(self) -> None:
+ with patch(
+ "backend.main.ai.ask_openrouter",
+ new=AsyncMock(return_value='{"message":null,"boardUpdate":null}'),
+ ):
+ response = self.client.post(
+ "/api/chat",
+ json={"board": db.DEFAULT_BOARD, "conversation": [], "message": "hello"},
+ )
+ self.assertEqual(response.status_code, 502)
+ self.assertIn("invalid message field", response.json()["detail"])
+
+ def test_chat_endpoint_ignores_non_object_board_update(self) -> None:
+ with patch(
+ "backend.main.ai.ask_openrouter",
+ new=AsyncMock(return_value='{"message":"No board change","boardUpdate":"skip"}'),
+ ):
+ response = self.client.post(
+ "/api/chat",
+ json={"board": db.DEFAULT_BOARD, "conversation": [], "message": "hello"},
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json()["message"], "No board change")
+ self.assertIsNone(response.json()["boardUpdate"])
+
+ def test_chat_endpoint_ignores_malformed_board_update(self) -> None:
+ with patch(
+ "backend.main.ai.ask_openrouter",
+ new=AsyncMock(
+ return_value='{"message":"No board change","boardUpdate":{"columns":[{"id":"todo","title":"Todo","cardIds":["missing"]}],"cards":{}}}'
+ ),
+ ):
+ response = self.client.post(
+ "/api/chat",
+ json={"board": db.DEFAULT_BOARD, "conversation": [], "message": "hello"},
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json()["message"], "No board change")
+ self.assertIsNone(response.json()["boardUpdate"])
+
+
+class OpenRouterConfigTests(unittest.IsolatedAsyncioTestCase):
+ async def test_openrouter_key_must_use_openrouter_prefix(self) -> None:
+ with patch.dict("os.environ", {"OPENROUTER_API_KEY": "not-an-openrouter-key"}):
+ with self.assertRaisesRegex(RuntimeError, "starting with sk-or-"):
+ await ai.ask_openrouter([{"role": "user", "content": "2+2"}])
+
+ def test_payment_required_error_is_actionable(self) -> None:
+ request = httpx.Request("POST", ai.OPENROUTER_URL)
+ response = httpx.Response(402, request=request)
+ error = httpx.HTTPStatusError("Payment Required", request=request, response=response)
+
+ self.assertIn("Add credits", ai._provider_error_message(error))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 974fc652..5a665d88 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -1,37 +1,213 @@
-# High level steps for project
+# Project plan for the MVP web app
+
+This plan expands the original roadmap into concrete implementation steps, with acceptance criteria and tests for each part.
+
+## Part 1: Plan
+
+Goals
+- Confirm the implementation approach before any feature work begins.
+- Capture the current frontend architecture in `frontend/AGENTS.md`.
+- Verify assumptions: single JSON blob storage, frontend passes full AI conversation context, and no backend yet.
-Part 1: Plan
-
-Enrich this document to plan out each of these parts in detail, with substeps listed out as a checklist to be checked off by the agent, and with tests and success critieria for each. Also create an AGENTS.md file inside the frontend directory that describes the existing code there. Ensure the user checks and approves the plan.
-
-Part 2: Scaffolding
-
-Set up the Docker infrastructure, the backend in backend/ with FastAPI, and write the start and stop scripts in the scripts/ directory. This should serve example static HTML to confirm that a 'hello world' example works running locally and also make an API call.
-
-Part 3: Add in Frontend
-
-Now update so that the frontend is statically built and served, so that the app has the demo Kanban board displayed at /. Comprehensive unit and integration tests.
-
-Part 4: Add in a fake user sign in experience
-
-Now update so that on first hitting /, you need to log in with dummy credentials ("user", "password") in order to see the Kanban, and you can log out. Comprehensive tests.
-
-Part 5: Database modeling
-
-Now propose a database schema for the Kanban, saving it as JSON. Document the database approach in docs/ and get user sign off.
-
-Part 6: Backend
-
-Now add API routes to allow the backend to read and change the Kanban for a given user; test this thoroughly with backend unit tests. The database should be created if it doesn't exist.
-
-Part 7: Frontend + Backend
-
-Now have the frontend actually use the backend API, so that the app is a proper persistent Kanban board. Test very throughly.
-
-Part 8: AI connectivity
-
-Now allow the backend to make an AI call via OpenRouter. Test connectivity with a simple "2+2" test and ensure the AI call is working.
-
-Part 9: Now extend the backend call so that it always calls the AI with the JSON of the Kanban board, plus the user's question (and conversation history). The AI should respond with Structured Outputs that includes the response to the user and optionaly an update to the Kanban. Test thoroughly.
-
-Part 10: Now add a beautiful sidebar widget to the UI supporting full AI chat, and allowing the LLM (as it determines) to update the Kanban based on its Structured Outputs. If the AI updates the Kanban, then the UI should refresh automatically.
\ No newline at end of file
+Tasks
+- Create `frontend/AGENTS.md` describing the existing frontend demo.
+- Review the current plan with the user and get approval before implementing.
+- Record the schema decision: board state is stored as one JSON blob in SQLite.
+- Record the chat decision: frontend will send the complete conversation history with each AI request.
+
+Success criteria
+- User approves the plan.
+- `frontend/AGENTS.md` exists and correctly describes the current frontend architecture.
+- The plan is detailed enough to drive implementation without guessing.
+
+## Part 2: Scaffolding
+
+Goals
+- Create the backend foundation and local dev infrastructure.
+- Verify that Docker can build and serve a minimal app.
+
+Tasks
+- Create `backend/` with a FastAPI app.
+- Add `Dockerfile` and `.dockerignore` at the project root.
+- Use `uv` as the Python package manager inside Docker.
+- Add `scripts/start.sh`, `scripts/stop.sh`, and/or platform-specific wrappers if needed.
+- Implement a FastAPI route that serves static files from the built frontend at `/`.
+- Implement a simple API route such as `/api/health` or `/api/hello` returning JSON.
+- Confirm that the container starts and that a browser/curl request returns the expected static HTML or JSON.
+
+Tests
+- Docker image builds successfully.
+- Container responds with HTTP 200 for `/` and `/api/health`.
+- `scripts/start.*` and `scripts/stop.*` work locally.
+
+Success criteria
+- A local container can run the app and serve a static page.
+- The backend can respond to a simple API request.
+
+## Part 3: Add in Frontend
+
+Goals
+- Serve the existing Next.js Kanban demo as a statically built frontend from the backend.
+
+Tasks
+- Configure the Next.js app to build a production output that can be served by FastAPI.
+- Update the Docker build pipeline to build the frontend first, then copy the static output into the backend image.
+- Ensure `frontend/src/app/page.tsx` continues to render the Kanban board.
+- Add any required build/test scripts in the repository README or scripts.
+
+Tests
+- `npm run build` succeeds in `frontend/`.
+- The backend container serves the built Kanban app at `/`.
+- Existing frontend unit tests and render tests pass.
+
+Success criteria
+- The demo app is served from the backend container as a static site.
+- The Kanban board appears at `/` in production mode.
+
+## Part 4: Add a fake user sign in experience
+
+Goals
+- Gate the app behind a simple dummy login before showing the board.
+
+Tasks
+- Add a login screen to the frontend requiring `user` / `password`.
+- Add logout support and return the user to the login screen.
+- Keep the login flow simple and local-state based at first.
+- Ensure the board page is not accessible until the user is authenticated.
+
+Tests
+- Valid credentials allow access to the Kanban board.
+- Invalid credentials show an error and do not allow access.
+- Logout returns the user to the login screen.
+
+Success criteria
+- The app requires a dummy login before displaying the Kanban board.
+- The credentials are exactly `user` / `password`.
+
+## Part 5: Database modeling
+
+Goals
+- Define the SQLite schema for storing user boards and app data.
+- Keep the Kanban board structure as a single JSON blob.
+
+Tasks
+- Create a documentation page in `docs/` describing the database schema.
+- Define at least these entities:
+ - `users` table with minimal identity fields.
+ - `boards` table with a JSON column for the Kanban board.
+ - Optionally `conversations` table if the AI history is stored later.
+- Capture the schema in a plain, user-readable form.
+
+Tests
+- The docs clearly state that board state is stored as one JSON blob per user.
+- The schema is reviewed and approved by the user.
+
+Success criteria
+- A documented SQLite schema exists in `docs/`.
+- The schema matches the product decisions and is ready for backend implementation.
+
+## Part 6: Backend
+
+Goals
+- Build the API surface for reading and writing a user's Kanban board.
+- Ensure the database is created automatically if missing.
+
+Tasks
+- Add backend routes such as:
+ - `GET /api/board` or `GET /api/board/{user}`
+ - `POST /api/board` or `POST /api/board/{user}`
+ - Optionally `POST /api/login` if auth is moved backend-side later.
+- Use SQLite and create the DB file on startup if it does not exist.
+- Store the board as a single JSON blob in the database.
+- Add backend unit tests for database creation, read, and write.
+
+Tests
+- New backend API routes return the expected JSON.
+- The SQLite file is created automatically when the backend starts.
+- Unit tests cover read/write operations.
+
+Success criteria
+- The backend can persist and return a Kanban board for a user.
+- DB initialization works automatically.
+
+## Part 7: Frontend + Backend
+
+Goals
+- Switch the frontend from local state to real backend persistence.
+
+Tasks
+- On login, fetch the user's board from the backend.
+- Send updates to the backend when cards move, columns rename, cards are added, or cards are removed.
+- Keep local UI state synchronized with backend responses.
+- Preserve the single-board-per-user model.
+
+Tests
+- Full app flow works end-to-end in the browser.
+- Reloading the page shows persisted board state.
+- Frontend/backend integration tests confirm API usage.
+
+Success criteria
+- The Kanban board state is persisted across refreshes.
+- The app uses the backend API instead of only local React state.
+
+## Part 8: AI connectivity
+
+Goals
+- Add backend support for OpenRouter AI calls.
+
+Tasks
+- Add a backend AI route such as `POST /api/ai` or `POST /api/chat`.
+- Use `OPENROUTER_API_KEY` from the project root `.env`.
+- Implement a minimal OpenRouter request for simple test prompts.
+- Confirm AI connectivity with a simple question like `2+2`.
+
+Tests
+- Backend AI route returns a valid response from OpenRouter.
+- A 2+2 test verifies the model is reachable.
+
+Success criteria
+- The backend can call OpenRouter successfully.
+- AI connectivity is proven with a simple test prompt.
+
+## Part 9: Structured AI updates
+
+Goals
+- Ensure AI requests include the current board JSON and conversation history.
+- Allow the AI to return structured updates that may modify the board.
+
+Tasks
+- Send the serialized board state and conversation history with each AI request.
+- Define a structured response format with fields such as `message` and `boardUpdate`.
+- Parse the AI output safely and apply updates only when valid.
+- Return both the AI text response and any updated board JSON to the frontend.
+
+Tests
+- Backend can process a structured AI response containing board updates.
+- The AI response format is validated before applying changes.
+
+Success criteria
+- AI responses can include an optional board update payload.
+- The API returns both chat text and structured update information.
+
+## Part 10: AI sidebar and chat UX
+
+Goals
+- Add a polished AI chat sidebar in the UI.
+- Let the AI optionally update the board and refresh the UI automatically.
+
+Tasks
+- Add a sidebar chat panel to the frontend layout.
+- Send user messages and full conversation history to the AI route.
+- Display AI responses in the sidebar.
+- If the AI returns board updates, merge them into the current board state.
+- Keep the chat and board synchronized.
+
+Tests
+- The AI sidebar shows conversation history and responses.
+- Board updates from the AI are applied automatically.
+- The UI remains stable when chat or board updates occur.
+
+Success criteria
+- Users can chat with the AI from the app.
+- AI-driven board updates appear in the Kanban automatically.
+- The UI remains responsive and coherent.
diff --git a/docs/code_review.md b/docs/code_review.md
new file mode 100644
index 00000000..3fdcd09a
--- /dev/null
+++ b/docs/code_review.md
@@ -0,0 +1,170 @@
+# Code review
+
+Reviewed: 2026-05-07
+Scope: entire repository (backend, frontend, tests, infra)
+
+---
+
+## Summary
+
+The codebase is clean and well-structured for an MVP. The separation of concerns is clear, the test coverage is good, and the Docker setup is production-ready. The issues below are ranked by severity: **high**, **medium**, and **low**.
+
+No high-severity issues were found.
+
+---
+
+## Medium severity
+
+### 1. Column rename fires a backend POST on every keystroke
+
+**File:** `frontend/src/components/KanbanColumn.tsx:43`, `frontend/src/components/KanbanBoard.tsx:111-118`
+
+The column title `` calls `onRename` on every `onChange`, which immediately calls `updateBoard`, which calls `saveBoard` (an HTTP POST). Renaming a 10-character column name fires 10 requests. This is wasteful and can cause race conditions where an earlier request resolves after a later one, leaving stale data in the DB.
+
+**Action:** Debounce `saveCurrentBoard` (300–500 ms) or only persist on `onBlur`.
+
+---
+
+### 2. `response_format: json_object` is not reliably honoured by free OpenRouter models
+
+**File:** `backend/ai.py:71-75`
+
+The request payload passes `"response_format": {"type": "json_object"}` to every model, including the free Gemma fallbacks. Many free models on OpenRouter ignore this field and return plain text or markdown-wrapped JSON. When they do, `_extract_json_object` strips code fences, but plain prose responses will still raise `json.JSONDecodeError`, returning a 502 to the user with no useful message.
+
+**Action:** Catch `json.JSONDecodeError` separately from the generic `Exception` in the `chat` route and return a more descriptive 502 detail (e.g., `"AI returned a non-JSON response"`). Consider also logging the raw response for debugging.
+
+---
+
+### 3. Silent swallow of the initial board seed error
+
+**File:** `frontend/src/components/KanbanBoard.tsx:49`
+
+```ts
+void saveBoard(username, nextBoard);
+```
+
+When a new user's board is empty, the frontend seeds it with `initialData` and fires `saveBoard` as a fire-and-forget. If this call fails (network error, backend down), the user sees their default board but it is never persisted. On reload, the backend returns an empty board again, and the seed fires again — silently looping. No error is surfaced to the user.
+
+**Action:** `await` the seed call inside `loadBoard` and propagate any error through `setError`.
+
+---
+
+### 4. `_validated_board_or_default` can silently discard a user's saved board
+
+**File:** `backend/main.py:108-114`
+
+If the board JSON stored in the database fails Pydantic validation (e.g. after a schema change or a manual DB edit), `GET /api/board/{username}` silently returns an empty default board. The caller has no way to know their real data was discarded, and the next `POST /api/board/{username}` will overwrite the corrupt-but-salvageable data.
+
+**Action:** Either raise a 500 with a clear error, or at minimum log a warning before falling back. The silent discard is the problematic part.
+
+---
+
+## Low severity
+
+### 5. AI request timeout is short for free-tier models
+
+**File:** `backend/ai.py:69`
+
+`timeout=12.0` seconds. Free-tier models on OpenRouter frequently queue for 15–30 seconds under load. The result is a 502 that gives no indication the request timed out vs. a model error.
+
+**Action:** Raise to `30.0` seconds, or surface timeout errors as a distinct message (e.g., `"AI request timed out — try again"`).
+
+---
+
+### 6. `backend/pm.db` is committed to the repository
+
+**File:** `./backend/pm.db`
+
+The SQLite database file is present in the repo. Even if empty now, committing a database file risks including real user data in future commits.
+
+**Action:** Add `backend/pm.db` and `data/pm.db` to `.gitignore` and remove `backend/pm.db` from git tracking (`git rm --cached backend/pm.db`).
+
+---
+
+### 7. CLAUDE.md documents the wrong backend test command
+
+**File:** `CLAUDE.md`
+
+CLAUDE.md says to run backend tests with `python -m pytest tests/`. `pytest` is not a declared dependency (it is not in `backend/pyproject.toml`) and the tests use stdlib `unittest`. The correct command (as confirmed by test runs) is:
+
+```bash
+python -m unittest discover -s backend/tests -v
+```
+
+run from the **project root** so the `backend` package is importable. Running from inside `backend/` causes `ModuleNotFoundError: No module named 'backend'`.
+
+**Action:** Fix the command in CLAUDE.md. Optionally add `pytest` to dev dependencies to make either form work.
+
+---
+
+### 8. `password.trim()` in login validation
+
+**File:** `frontend/src/components/LoginScreen.tsx:21`
+
+The login handler trims both username and password before comparing. Trimming passwords silently discards leading/trailing whitespace that users may have intentionally typed. For a hardcoded dummy password this causes no real issue, but the pattern should not be carried forward if real authentication is ever added.
+
+**Action:** Remove `.trim()` from the password comparison.
+
+---
+
+### 9. `details` default is inconsistent between frontend and backend
+
+**File:** `frontend/src/components/KanbanBoard.tsx:131`, `backend/main.py:27`
+
+When a card is added without details, the frontend stores `"No details yet."`:
+```ts
+details: details || "No details yet."
+```
+The backend `CardModel` defaults `details` to `""`. The AI can also create cards with `details: ""`. This means the board can have a mix of empty strings and placeholder text depending on who created the card.
+
+**Action:** Standardise to `""`. Remove the `|| "No details yet."` fallback in the frontend.
+
+---
+
+### 10. No in-flight AI request cancellation
+
+**File:** `frontend/src/components/KanbanBoard.tsx:155-187`
+
+Once an AI request is in flight, there is no way to cancel it. If the AI is slow (common with free-tier models), the user is stuck with a disabled Send button and a `...` label until the request resolves or errors. There is no timeout surfaced to the UI.
+
+**Action:** For now, a simple improvement is to add a visible loading message in the chat panel (e.g., "Thinking...") so the user knows the request is in progress. A Cancel button with `AbortController` would be the full fix.
+
+---
+
+### 11. Chat key uses array index, which can shift on re-render
+
+**File:** `frontend/src/components/KanbanBoard.tsx:306`
+
+```tsx
+key={`${message.role}-${index}`}
+```
+
+Using the array index as part of a key means React may reuse DOM nodes incorrectly if messages are ever prepended or removed. For a simple append-only list this works today, but it is not robust.
+
+**Action:** Assign a stable ID to each message when it is created (e.g., `createId("msg")`).
+
+---
+
+### 12. `cards.map((cardId) => board.cards[cardId])` is not guarded
+
+**File:** `frontend/src/components/KanbanBoard.tsx:268`
+
+```tsx
+cards={column.cardIds.map((cardId) => board.cards[cardId])}
+```
+
+If a `cardId` in a column does not exist in `board.cards`, this produces `undefined` in the array, which will crash `KanbanCard`. The backend validates this on write, but local client-side state (e.g. a partial AI board update that passes backend validation but has some edge case) could produce this situation.
+
+**Action:** Filter out missing cards: `.map(...).filter(Boolean)` or assert the card exists and log an error.
+
+---
+
+## Informational (no action required)
+
+**AI conversation design:** In `handleChatSubmit`, `chatMessages` (the history *before* the current message) is passed as `conversation`, and `nextMessage` is passed as `message`. This is correct and intentional — the backend prompt separates history from the current input.
+
+**SQLite `check_same_thread=False`:** Used in `db.py`. This is safe for FastAPI's async model where DB calls are made from async route handlers (not from multiple threads simultaneously). No action needed.
+
+**No card edit in place:** Cards can be added and deleted but not edited after creation. The AI can update card content via `boardUpdate`. This is a known MVP scope decision.
+
+**CORS origins:** `main.py` allows CORS from `localhost:3000` for dev mode. In production (Docker), the frontend is served from the same origin and CORS is irrelevant. This does not cause a problem but could be tightened if dev mode access needs to be locked down.
diff --git a/docs/database-schema.md b/docs/database-schema.md
new file mode 100644
index 00000000..f2485752
--- /dev/null
+++ b/docs/database-schema.md
@@ -0,0 +1,76 @@
+# Database schema for the PM MVP
+
+This document describes the SQLite schema for the MVP backend. The design keeps the board state simple: one JSON blob per user board.
+
+## Goals
+
+- Store user identity and board state in SQLite.
+- Keep the Kanban board structure as a single JSON blob.
+- Allow future extension for conversation history or AI chat.
+
+## Entities
+
+### users
+Stores authenticated users and minimal identity data.
+
+- `id` INTEGER PRIMARY KEY AUTOINCREMENT
+- `username` TEXT NOT NULL UNIQUE
+- `display_name` TEXT
+- `created_at` TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+
+### boards
+Stores one board per user as a single JSON payload.
+
+- `id` INTEGER PRIMARY KEY AUTOINCREMENT
+- `user_id` INTEGER NOT NULL REFERENCES users(id)
+- `board_json` TEXT NOT NULL
+- `updated_at` TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+
+Notes
+- `board_json` contains the complete Kanban board state.
+- The board state is stored as one JSON blob, not as normalized row data.
+- This schema supports one board per user.
+
+### conversations (optional)
+Stores AI chat history if the app later adds AI conversation persistence.
+
+- `id` INTEGER PRIMARY KEY AUTOINCREMENT
+- `user_id` INTEGER NOT NULL REFERENCES users(id)
+- `role` TEXT NOT NULL
+- `message` TEXT NOT NULL
+- `created_at` TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+
+## Board JSON shape
+
+The Kanban board JSON blob should capture the full board state. Example structure:
+
+```json
+{
+ "cards": {
+ "card-1": { "id": "card-1", "title": "Start project", "details": "Create board and add tasks." },
+ "card-2": { "id": "card-2", "title": "Design UI", "details": "Sketch core screens." }
+ },
+ "columns": [
+ { "id": "col-backlog", "title": "Backlog", "cardIds": ["card-1"] },
+ { "id": "col-in-progress", "title": "In progress", "cardIds": ["card-2"] }
+ ]
+}
+```
+
+This shape is intentionally small and stable. The backend validates the top-level board shape before storing or returning AI board updates, then stores the validated board as one JSON blob.
+
+## Database creation
+
+A simple SQLite initialization routine should:
+
+1. Create `users` if it does not exist.
+2. Create `boards` if it does not exist.
+3. Optionally create `conversations` if AI history storage is added.
+
+## Implementation notes
+
+- Use `TEXT` for `board_json` rather than SQLite native JSON to maximize compatibility.
+- Use `FOREIGN KEY` constraints to link `boards.user_id` to `users.id`.
+- Keep the first backend implementation focused on `GET /api/board/{user}` and `POST /api/board/{user}`.
+- Docker start scripts set `PM_DB_PATH=/app/data/pm.db` and mount local `data/` so the SQLite file survives container recreation.
+- Future enhancements may include persisting conversation rows in `conversations` or adding an `ai_requests` table.
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
new file mode 100644
index 00000000..fafdc2a2
--- /dev/null
+++ b/frontend/AGENTS.md
@@ -0,0 +1,63 @@
+# Frontend Agent Guidance
+
+## Purpose
+This file describes the existing frontend code in `frontend/` and explains how it is currently structured. It is intended for the agent to understand the current implementation before making backend integration changes.
+
+## Frontend architecture
+- `frontend/src/app/page.tsx`
+ - The app entrypoint for the Next.js route `/`.
+ - Gates the app behind the dummy login.
+ - Stores the local authenticated state in `localStorage`.
+ - Renders the `KanbanBoard` client component after login.
+
+- `frontend/src/components/KanbanBoard.tsx`
+ - Client-side component using React state and DnD Kit.
+ - Loads and saves `BoardData` through the backend API.
+ - Supports column rename, card creation, card deletion, and drag/drop card movement.
+ - Includes the AI chat sidebar.
+ - Applies valid AI `boardUpdate` responses and persists the updated board.
+ - Uses `DndContext` and `DragOverlay` from `@dnd-kit/core`.
+ - Contains the main page layout and board header.
+
+- `frontend/src/components/KanbanColumn.tsx`
+ - Renders a single column with a title input, card list, and add-card form.
+ - Uses `useDroppable` to accept dragged cards.
+ - Uses `SortableContext` for vertical card order.
+
+- `frontend/src/components/KanbanCard.tsx`
+ - Renders each card with drag handles and a remove button.
+ - Uses `useSortable` for drag interactions.
+
+- `frontend/src/components/KanbanCardPreview.tsx`
+ - Renders the preview card shown during drag overlay.
+
+- `frontend/src/components/NewCardForm.tsx`
+ - Renders a toggled add-card UI inside each column.
+ - Handles form submission and validation for new cards.
+
+- `frontend/src/components/LoginScreen.tsx`
+ - Renders a local login form for dummy credentials.
+ - Validates `user` / `password` and invokes a callback on success.
+
+- `frontend/src/lib/api.ts`
+ - Wraps backend API calls for board fetch/save and AI chat.
+
+- `frontend/src/lib/kanban.ts`
+ - Defines types: `Card`, `Column`, and `BoardData`.
+ - Exports `initialData` with 5 columns and sample cards.
+ - Implements `moveCard` helper for moving cards across and within columns.
+ - Implements `createId` helper for new card IDs.
+
+## Current limitations
+- Authentication is local only and uses hardcoded dummy credentials.
+- Chat history is kept in React state and is not persisted.
+- Live AI requires `OPENROUTER_API_KEY` in the project root `.env`.
+
+## Testing and build
+- `frontend/package.json` includes scripts for `dev`, `build`, `start`, `lint`, `test`, and `test:e2e`.
+- The frontend uses Next.js 16, React 19, Tailwind CSS, Vitest, and Playwright.
+
+## Integration notes
+- Keep the board shape aligned with `frontend/src/lib/kanban.ts`.
+- The backend expects `/api/chat` requests to include prior conversation history, the current board, and the latest user message.
+- AI board updates should replace the current board only after backend validation.
diff --git a/frontend/next.config.ts b/frontend/next.config.ts
index e9ffa308..573201c6 100644
--- a/frontend/next.config.ts
+++ b/frontend/next.config.ts
@@ -1,7 +1,14 @@
import type { NextConfig } from "next";
+import { dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const appDir = dirname(fileURLToPath(import.meta.url));
const nextConfig: NextConfig = {
- /* config options here */
+ output: "export",
+ turbopack: {
+ root: appDir,
+ },
};
export default nextConfig;
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index c3ef7b4c..24d68b28 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -12,7 +12,8 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"clsx": "^2.1.1",
- "next": "16.1.6",
+ "lucide-react": "^1.14.0",
+ "next": "^16.2.4",
"react": "19.2.3",
"react-dom": "19.2.3"
},
@@ -28,7 +29,7 @@
"@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9",
- "eslint-config-next": "16.1.6",
+ "eslint-config-next": "16.2.4",
"jsdom": "^27.0.1",
"tailwindcss": "^4",
"typescript": "^5",
@@ -1840,15 +1841,15 @@
}
},
"node_modules/@next/env": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
- "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz",
+ "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz",
- "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.4.tgz",
+ "integrity": "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1856,9 +1857,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
- "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz",
+ "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==",
"cpu": [
"arm64"
],
@@ -1872,9 +1873,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
- "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz",
+ "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==",
"cpu": [
"x64"
],
@@ -1888,12 +1889,15 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
- "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz",
+ "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==",
"cpu": [
"arm64"
],
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1904,12 +1908,15 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
- "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz",
+ "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==",
"cpu": [
"arm64"
],
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1920,12 +1927,15 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
- "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz",
+ "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==",
"cpu": [
"x64"
],
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1936,12 +1946,15 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
- "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz",
+ "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==",
"cpu": [
"x64"
],
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -1952,9 +1965,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
- "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz",
+ "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==",
"cpu": [
"arm64"
],
@@ -1968,9 +1981,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
- "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
+ "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
"cpu": [
"x64"
],
@@ -2066,9 +2079,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz",
- "integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz",
+ "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==",
"cpu": [
"arm"
],
@@ -2080,9 +2093,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz",
- "integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz",
+ "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==",
"cpu": [
"arm64"
],
@@ -2094,9 +2107,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz",
- "integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz",
+ "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==",
"cpu": [
"arm64"
],
@@ -2108,9 +2121,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz",
- "integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz",
+ "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==",
"cpu": [
"x64"
],
@@ -2122,9 +2135,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz",
- "integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz",
+ "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==",
"cpu": [
"arm64"
],
@@ -2136,9 +2149,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz",
- "integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz",
+ "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==",
"cpu": [
"x64"
],
@@ -2150,13 +2163,16 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz",
- "integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz",
+ "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==",
"cpu": [
"arm"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2164,13 +2180,16 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz",
- "integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz",
+ "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==",
"cpu": [
"arm"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2178,13 +2197,16 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz",
- "integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz",
+ "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==",
"cpu": [
"arm64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2192,13 +2214,16 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz",
- "integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz",
+ "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==",
"cpu": [
"arm64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2206,13 +2231,16 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz",
- "integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz",
+ "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==",
"cpu": [
"loong64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2220,13 +2248,16 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz",
- "integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz",
+ "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==",
"cpu": [
"loong64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2234,13 +2265,16 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz",
- "integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz",
+ "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==",
"cpu": [
"ppc64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2248,13 +2282,16 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz",
- "integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz",
+ "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==",
"cpu": [
"ppc64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2262,13 +2299,16 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz",
- "integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz",
+ "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==",
"cpu": [
"riscv64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2276,13 +2316,16 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz",
- "integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz",
+ "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==",
"cpu": [
"riscv64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2290,13 +2333,16 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz",
- "integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz",
+ "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==",
"cpu": [
"s390x"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2304,13 +2350,16 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz",
- "integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz",
+ "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==",
"cpu": [
"x64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2318,13 +2367,16 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz",
- "integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz",
+ "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==",
"cpu": [
"x64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2332,9 +2384,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz",
- "integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz",
+ "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==",
"cpu": [
"x64"
],
@@ -2346,9 +2398,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz",
- "integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz",
+ "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==",
"cpu": [
"arm64"
],
@@ -2360,9 +2412,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz",
- "integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz",
+ "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==",
"cpu": [
"arm64"
],
@@ -2374,9 +2426,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz",
- "integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz",
+ "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==",
"cpu": [
"ia32"
],
@@ -2388,9 +2440,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz",
- "integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz",
+ "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==",
"cpu": [
"x64"
],
@@ -2402,9 +2454,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz",
- "integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz",
+ "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==",
"cpu": [
"x64"
],
@@ -3125,9 +3177,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3135,13 +3187,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -3678,9 +3730,9 @@
}
},
"node_modules/ajv": {
- "version": "6.12.6",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
- "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4006,9 +4058,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4893,13 +4945,13 @@
}
},
"node_modules/eslint-config-next": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz",
- "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.4.tgz",
+ "integrity": "sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@next/eslint-plugin-next": "16.1.6",
+ "@next/eslint-plugin-next": "16.2.4",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
@@ -5395,9 +5447,9 @@
}
},
"node_modules/flatted": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
- "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -5438,7 +5490,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -5615,9 +5666,9 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5625,13 +5676,13 @@
}
},
"node_modules/glob/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -6949,6 +7000,15 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucide-react": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
+ "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -7063,9 +7123,9 @@
}
},
"node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -7144,14 +7204,14 @@
"license": "MIT"
},
"node_modules/next": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
- "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz",
+ "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==",
"license": "MIT",
"dependencies": {
- "@next/env": "16.1.6",
+ "@next/env": "16.2.4",
"@swc/helpers": "0.5.15",
- "baseline-browser-mapping": "^2.8.3",
+ "baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@@ -7163,15 +7223,15 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
- "@next/swc-darwin-arm64": "16.1.6",
- "@next/swc-darwin-x64": "16.1.6",
- "@next/swc-linux-arm64-gnu": "16.1.6",
- "@next/swc-linux-arm64-musl": "16.1.6",
- "@next/swc-linux-x64-gnu": "16.1.6",
- "@next/swc-linux-x64-musl": "16.1.6",
- "@next/swc-win32-arm64-msvc": "16.1.6",
- "@next/swc-win32-x64-msvc": "16.1.6",
- "sharp": "^0.34.4"
+ "@next/swc-darwin-arm64": "16.2.4",
+ "@next/swc-darwin-x64": "16.2.4",
+ "@next/swc-linux-arm64-gnu": "16.2.4",
+ "@next/swc-linux-arm64-musl": "16.2.4",
+ "@next/swc-linux-x64-gnu": "16.2.4",
+ "@next/swc-linux-x64-musl": "16.2.4",
+ "@next/swc-win32-arm64-msvc": "16.2.4",
+ "@next/swc-win32-x64-msvc": "16.2.4",
+ "sharp": "^0.34.5"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
@@ -7196,34 +7256,6 @@
}
}
},
- "node_modules/next/node_modules/postcss": {
- "version": "8.4.31",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
- "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "nanoid": "^3.3.6",
- "picocolors": "^1.0.0",
- "source-map-js": "^1.0.2"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -7530,9 +7562,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7585,10 +7617,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.6",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
- "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
- "dev": true,
+ "version": "8.5.14",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+ "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"funding": [
{
"type": "opencollective",
@@ -7863,9 +7894,9 @@
}
},
"node_modules/rollup": {
- "version": "4.57.0",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz",
- "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==",
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
+ "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7879,31 +7910,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.57.0",
- "@rollup/rollup-android-arm64": "4.57.0",
- "@rollup/rollup-darwin-arm64": "4.57.0",
- "@rollup/rollup-darwin-x64": "4.57.0",
- "@rollup/rollup-freebsd-arm64": "4.57.0",
- "@rollup/rollup-freebsd-x64": "4.57.0",
- "@rollup/rollup-linux-arm-gnueabihf": "4.57.0",
- "@rollup/rollup-linux-arm-musleabihf": "4.57.0",
- "@rollup/rollup-linux-arm64-gnu": "4.57.0",
- "@rollup/rollup-linux-arm64-musl": "4.57.0",
- "@rollup/rollup-linux-loong64-gnu": "4.57.0",
- "@rollup/rollup-linux-loong64-musl": "4.57.0",
- "@rollup/rollup-linux-ppc64-gnu": "4.57.0",
- "@rollup/rollup-linux-ppc64-musl": "4.57.0",
- "@rollup/rollup-linux-riscv64-gnu": "4.57.0",
- "@rollup/rollup-linux-riscv64-musl": "4.57.0",
- "@rollup/rollup-linux-s390x-gnu": "4.57.0",
- "@rollup/rollup-linux-x64-gnu": "4.57.0",
- "@rollup/rollup-linux-x64-musl": "4.57.0",
- "@rollup/rollup-openbsd-x64": "4.57.0",
- "@rollup/rollup-openharmony-arm64": "4.57.0",
- "@rollup/rollup-win32-arm64-msvc": "4.57.0",
- "@rollup/rollup-win32-ia32-msvc": "4.57.0",
- "@rollup/rollup-win32-x64-gnu": "4.57.0",
- "@rollup/rollup-win32-x64-msvc": "4.57.0",
+ "@rollup/rollup-android-arm-eabi": "4.60.3",
+ "@rollup/rollup-android-arm64": "4.60.3",
+ "@rollup/rollup-darwin-arm64": "4.60.3",
+ "@rollup/rollup-darwin-x64": "4.60.3",
+ "@rollup/rollup-freebsd-arm64": "4.60.3",
+ "@rollup/rollup-freebsd-x64": "4.60.3",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.3",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.3",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.3",
+ "@rollup/rollup-linux-arm64-musl": "4.60.3",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.3",
+ "@rollup/rollup-linux-loong64-musl": "4.60.3",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.3",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.3",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.3",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.3",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.3",
+ "@rollup/rollup-linux-x64-gnu": "4.60.3",
+ "@rollup/rollup-linux-x64-musl": "4.60.3",
+ "@rollup/rollup-openbsd-x64": "4.60.3",
+ "@rollup/rollup-openharmony-arm64": "4.60.3",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.3",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.3",
+ "@rollup/rollup-win32-x64-gnu": "4.60.3",
+ "@rollup/rollup-win32-x64-msvc": "4.60.3",
"fsevents": "~2.3.2"
}
},
@@ -8658,9 +8689,9 @@
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8668,13 +8699,13 @@
}
},
"node_modules/test-exclude/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -8733,9 +8764,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -9111,9 +9142,9 @@
}
},
"node_modules/vite": {
- "version": "7.3.1",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
- "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
+ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9242,9 +9273,9 @@
}
},
"node_modules/vite/node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -9328,9 +9359,9 @@
}
},
"node_modules/vitest/node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/frontend/package.json b/frontend/package.json
index e87f493d..3a3b6350 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,7 +10,9 @@
"test": "vitest run",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
+ "pretest:e2e": "node -e \"require('child_process').spawnSync('docker',['rm','-f','pm-app-e2e'],{stdio:'ignore'})\"",
"test:e2e": "playwright test",
+ "posttest:e2e": "node -e \"require('child_process').spawnSync('docker',['rm','-f','pm-app-e2e'],{stdio:'ignore'})\"",
"test:all": "npm run test:unit && npm run test:e2e"
},
"dependencies": {
@@ -18,7 +20,8 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"clsx": "^2.1.1",
- "next": "16.1.6",
+ "lucide-react": "^1.14.0",
+ "next": "^16.2.4",
"react": "19.2.3",
"react-dom": "19.2.3"
},
@@ -34,10 +37,13 @@
"@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9",
- "eslint-config-next": "16.1.6",
+ "eslint-config-next": "16.2.4",
"jsdom": "^27.0.1",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^3.2.4"
+ },
+ "overrides": {
+ "postcss": "^8.5.14"
}
}
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
index e85e9b04..796518f9 100644
--- a/frontend/playwright.config.ts
+++ b/frontend/playwright.config.ts
@@ -7,13 +7,14 @@ export default defineConfig({
timeout: 10_000,
},
use: {
- baseURL: "http://127.0.0.1:3000",
+ baseURL: "http://127.0.0.1:8001",
trace: "retain-on-failure",
},
webServer: {
- command: "npm run dev -- --hostname 127.0.0.1 --port 3000",
- url: "http://127.0.0.1:3000",
- reuseExistingServer: true,
+ command:
+ "cd .. && docker rm -f pm-app-e2e >/dev/null 2>&1 || true && docker build -t pm-app . && docker run --rm --name pm-app-e2e -e PM_DB_PATH=/app/data/pm.db -p 8001:8000 pm-app",
+ url: "http://127.0.0.1:8001",
+ reuseExistingServer: false,
timeout: 120_000,
},
projects: [
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index e7c512b8..49329237 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -1,5 +1,60 @@
+"use client";
+
+import { useEffect, useState } from "react";
import { KanbanBoard } from "@/components/KanbanBoard";
+import { LoginScreen } from "@/components/LoginScreen";
+
+const AUTH_STORAGE_KEY = "pm-authenticated";
+const AUTH_USER_KEY = "pm-username";
+const DEFAULT_USERNAME = "user";
export default function Home() {
- return ;
+ const [signedIn, setSignedIn] = useState(null);
+ const [username, setUsername] = useState(null);
+
+ useEffect(() => {
+ const storedValue = window.localStorage.getItem(AUTH_STORAGE_KEY);
+ const storedUser = window.localStorage.getItem(AUTH_USER_KEY);
+ queueMicrotask(() => {
+ setSignedIn(storedValue === "true");
+ setUsername(storedUser || null);
+ });
+ }, []);
+
+ const handleLogin = () => {
+ window.localStorage.setItem(AUTH_STORAGE_KEY, "true");
+ window.localStorage.setItem(AUTH_USER_KEY, DEFAULT_USERNAME);
+ setSignedIn(true);
+ setUsername(DEFAULT_USERNAME);
+ };
+
+ const handleLogout = () => {
+ window.localStorage.removeItem(AUTH_STORAGE_KEY);
+ window.localStorage.removeItem(AUTH_USER_KEY);
+ setSignedIn(false);
+ setUsername(null);
+ };
+
+ if (signedIn === null) {
+ return null;
+ }
+
+ if (!signedIn || !username) {
+ return ;
+ }
+
+ return (
+
+ );
}
diff --git a/frontend/src/components/KanbanBoard.test.tsx b/frontend/src/components/KanbanBoard.test.tsx
index 833fcb88..b2103214 100644
--- a/frontend/src/components/KanbanBoard.test.tsx
+++ b/frontend/src/components/KanbanBoard.test.tsx
@@ -1,26 +1,65 @@
-import { render, screen, within } from "@testing-library/react";
+import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { KanbanBoard } from "@/components/KanbanBoard";
+import { initialData } from "@/lib/kanban";
+import * as api from "@/lib/api";
+
+vi.mock("@/lib/api", () => ({
+ fetchBoard: vi.fn(),
+ saveBoard: vi.fn(),
+ sendChatMessage: vi.fn(),
+}));
const getFirstColumn = () => screen.getAllByTestId(/column-/i)[0];
+const username = "user";
+
+const fetchBoardMock = vi.mocked(api.fetchBoard);
+const saveBoardMock = vi.mocked(api.saveBoard);
+const sendChatMessageMock = vi.mocked(api.sendChatMessage);
describe("KanbanBoard", () => {
- it("renders five columns", () => {
- render();
- expect(screen.getAllByTestId(/column-/i)).toHaveLength(5);
+ beforeEach(() => {
+ fetchBoardMock.mockResolvedValue(structuredClone(initialData));
+ saveBoardMock.mockResolvedValue();
+ sendChatMessageMock.mockResolvedValue({
+ message: "Done",
+ boardUpdate: null,
+ });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders five columns", async () => {
+ render();
+ expect(await screen.findAllByTestId(/column-/i)).toHaveLength(5);
+ expect(fetchBoardMock).toHaveBeenCalledWith(username);
+ });
+
+ it("seeds the backend with demo data for a new empty board", async () => {
+ fetchBoardMock.mockResolvedValueOnce({ columns: [], cards: {} });
+
+ render();
+
+ expect(await screen.findAllByTestId(/column-/i)).toHaveLength(5);
+ expect(saveBoardMock).toHaveBeenCalledWith(username, initialData);
});
it("renames a column", async () => {
- render();
+ render();
+ await screen.findAllByTestId(/column-/i);
const column = getFirstColumn();
const input = within(column).getByLabelText("Column title");
await userEvent.clear(input);
await userEvent.type(input, "New Name");
expect(input).toHaveValue("New Name");
+ await waitFor(() => expect(saveBoardMock).toHaveBeenCalled());
});
it("adds and removes a card", async () => {
- render();
+ render();
+ await screen.findAllByTestId(/column-/i);
const column = getFirstColumn();
const addButton = within(column).getByRole("button", {
name: /add a card/i,
@@ -42,5 +81,42 @@ describe("KanbanBoard", () => {
await userEvent.click(deleteButton);
expect(within(column).queryByText("New card")).not.toBeInTheDocument();
+ await waitFor(() => expect(saveBoardMock).toHaveBeenCalled());
+ });
+
+ it("applies AI board updates and persists them", async () => {
+ const nextBoard = structuredClone(initialData);
+ nextBoard.columns[0].cardIds = [];
+ nextBoard.columns[4].cardIds = [...nextBoard.columns[4].cardIds, "card-1"];
+ sendChatMessageMock.mockResolvedValueOnce({
+ message: "Moved the card to done.",
+ boardUpdate: nextBoard,
+ });
+
+ render();
+ await screen.findAllByTestId(/column-/i);
+
+ await userEvent.type(screen.getByPlaceholderText(/tell the ai what to change/i), "Move card 1");
+ await userEvent.click(screen.getByRole("button", { name: /send/i }));
+
+ expect(await screen.findByText(/moved the card to done/i)).toBeInTheDocument();
+ expect(sendChatMessageMock).toHaveBeenCalledWith(initialData, [], "Move card 1");
+ await waitFor(() => expect(saveBoardMock).toHaveBeenCalledWith(username, nextBoard));
+ });
+
+ it("shows the backend chat error when AI setup is missing", async () => {
+ sendChatMessageMock.mockRejectedValueOnce(
+ new Error("AI request failed: OPENROUTER_API_KEY is not configured")
+ );
+
+ render();
+ await screen.findAllByTestId(/column-/i);
+
+ await userEvent.type(screen.getByPlaceholderText(/tell the ai what to change/i), "Help");
+ await userEvent.click(screen.getByRole("button", { name: /send/i }));
+
+ expect(
+ await screen.findByText(/openrouter_api_key is not configured/i)
+ ).toBeInTheDocument();
});
});
diff --git a/frontend/src/components/KanbanBoard.tsx b/frontend/src/components/KanbanBoard.tsx
index dc6bf5fb..beea3d8c 100644
--- a/frontend/src/components/KanbanBoard.tsx
+++ b/frontend/src/components/KanbanBoard.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import {
DndContext,
DragOverlay,
@@ -11,21 +11,92 @@ import {
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
+import { LayoutDashboard, User, Sparkles, Send } from "lucide-react";
import { KanbanColumn } from "@/components/KanbanColumn";
import { KanbanCardPreview } from "@/components/KanbanCardPreview";
import { createId, initialData, moveCard, type BoardData } from "@/lib/kanban";
+import { fetchBoard, saveBoard, sendChatMessage, type ChatMessage } from "@/lib/api";
-export const KanbanBoard = () => {
- const [board, setBoard] = useState(() => initialData);
+const COLUMN_ACCENTS = ["#209dd7", "#ecad0a", "#753991", "#0d9488", "#e06c54"];
+
+type LocalChatMessage = ChatMessage & { id: string };
+
+type KanbanBoardProps = {
+ username: string;
+};
+
+export const KanbanBoard = ({ username }: KanbanBoardProps) => {
+ const [board, setBoard] = useState(null);
const [activeCardId, setActiveCardId] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [chatMessages, setChatMessages] = useState([]);
+ const [chatInput, setChatInput] = useState("");
+ const [chatError, setChatError] = useState(null);
+ const [chatLoading, setChatLoading] = useState(false);
+ const chatEndRef = useRef(null);
const sensors = useSensors(
- useSensor(PointerSensor, {
- activationConstraint: { distance: 6 },
- })
+ useSensor(PointerSensor, { activationConstraint: { distance: 6 } })
);
- const cardsById = useMemo(() => board.cards, [board.cards]);
+ const saveTimerRef = useRef | null>(null);
+ const cardsById = useMemo(() => (board ? board.cards : {}), [board]);
+
+ useEffect(() => {
+ chatEndRef.current?.scrollIntoView?.({ behavior: "smooth" });
+ }, [chatMessages]);
+
+ useEffect(() => {
+ let mounted = true;
+ const loadBoard = async () => {
+ try {
+ const fetchedBoard = await fetchBoard(username);
+ const nextBoard = fetchedBoard.columns.length > 0 ? fetchedBoard : initialData;
+ if (fetchedBoard.columns.length === 0) {
+ await saveBoard(username, nextBoard);
+ }
+ if (mounted) setBoard(nextBoard);
+ } catch (error) {
+ console.error(error);
+ if (mounted) {
+ setError("Unable to load your board. Using default data.");
+ setBoard(initialData);
+ }
+ } finally {
+ if (mounted) setLoading(false);
+ }
+ };
+ loadBoard();
+ return () => {
+ mounted = false;
+ };
+ }, [username]);
+
+ const saveCurrentBoard = async (nextBoard: BoardData) => {
+ try {
+ await saveBoard(username, nextBoard);
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ const updateBoard = (
+ updater: (prev: BoardData) => BoardData,
+ { debounce = false }: { debounce?: boolean } = {}
+ ) => {
+ setBoard((prev) => {
+ if (!prev) return prev;
+ const nextBoard = updater(prev);
+ if (debounce) {
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
+ saveTimerRef.current = setTimeout(() => void saveCurrentBoard(nextBoard), 400);
+ } else {
+ void saveCurrentBoard(nextBoard);
+ }
+ return nextBoard;
+ });
+ };
const handleDragStart = (event: DragStartEvent) => {
setActiveCardId(event.active.id as string);
@@ -34,132 +105,268 @@ export const KanbanBoard = () => {
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveCardId(null);
-
- if (!over || active.id === over.id) {
- return;
- }
-
- setBoard((prev) => ({
+ if (!over || active.id === over.id) return;
+ updateBoard((prev) => ({
...prev,
columns: moveCard(prev.columns, active.id as string, over.id as string),
}));
};
const handleRenameColumn = (columnId: string, title: string) => {
- setBoard((prev) => ({
- ...prev,
- columns: prev.columns.map((column) =>
- column.id === columnId ? { ...column, title } : column
- ),
- }));
+ updateBoard(
+ (prev) => ({
+ ...prev,
+ columns: prev.columns.map((col) =>
+ col.id === columnId ? { ...col, title } : col
+ ),
+ }),
+ { debounce: true }
+ );
};
const handleAddCard = (columnId: string, title: string, details: string) => {
const id = createId("card");
- setBoard((prev) => ({
+ updateBoard((prev) => ({
...prev,
- cards: {
- ...prev.cards,
- [id]: { id, title, details: details || "No details yet." },
- },
- columns: prev.columns.map((column) =>
- column.id === columnId
- ? { ...column, cardIds: [...column.cardIds, id] }
- : column
+ cards: { ...prev.cards, [id]: { id, title, details } },
+ columns: prev.columns.map((col) =>
+ col.id === columnId ? { ...col, cardIds: [...col.cardIds, id] } : col
),
}));
};
const handleDeleteCard = (columnId: string, cardId: string) => {
- setBoard((prev) => {
- return {
- ...prev,
- cards: Object.fromEntries(
- Object.entries(prev.cards).filter(([id]) => id !== cardId)
- ),
- columns: prev.columns.map((column) =>
- column.id === columnId
- ? {
- ...column,
- cardIds: column.cardIds.filter((id) => id !== cardId),
- }
- : column
- ),
- };
- });
+ updateBoard((prev) => ({
+ ...prev,
+ cards: Object.fromEntries(
+ Object.entries(prev.cards).filter(([id]) => id !== cardId)
+ ),
+ columns: prev.columns.map((col) =>
+ col.id === columnId
+ ? { ...col, cardIds: col.cardIds.filter((id) => id !== cardId) }
+ : col
+ ),
+ }));
};
const activeCard = activeCardId ? cardsById[activeCardId] : null;
+ const handleChatSubmit = async () => {
+ if (!board || chatLoading) return;
+ const nextMessage = chatInput.trim();
+ if (!nextMessage) return;
+
+ const nextConversation: LocalChatMessage[] = [
+ ...chatMessages,
+ { role: "user" as const, content: nextMessage, id: createId("msg") },
+ ];
+ setChatMessages(nextConversation);
+ setChatInput("");
+ setChatError(null);
+ setChatLoading(true);
+
+ try {
+ const response = await sendChatMessage(board, chatMessages, nextMessage);
+ setChatMessages((prev) => [
+ ...prev,
+ { role: "assistant", content: response.message, id: createId("msg") },
+ ]);
+ if (response.boardUpdate) {
+ setBoard(response.boardUpdate);
+ await saveCurrentBoard(response.boardUpdate);
+ }
+ } catch (chatRequestError) {
+ console.error(chatRequestError);
+ setChatError(
+ chatRequestError instanceof Error
+ ? chatRequestError.message
+ : "AI is unavailable right now. Try again."
+ );
+ } finally {
+ setChatLoading(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!board) {
+ return (
+
+
+
Unable to load the board.
+
+
+ );
+ }
+
+ const totalCards = Object.keys(board.cards).length;
+
return (
-
-
-
+
+
+
-
-
-
+
+
+
+
+
+
-
- Single Board Kanban
+
+ Workspace
-
+
Kanban Studio
-
- Keep momentum visible. Rename columns, drag cards between stages,
- and capture quick notes without getting buried in settings.
-
-
-
- Focus
-
-
- One board. Five columns. Zero clutter.
-
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {totalCards}
+ {" "}card{totalCards !== 1 ? "s" : ""}
+
+
+
+ {username}
-
- {board.columns.map((column) => (
-
-
- {column.title}
+
+
+
+
+
+
+
+
+
+ {board.columns.map((column, index) => (
+ board.cards[id]).filter(Boolean)}
+ colorAccent={COLUMN_ACCENTS[index % COLUMN_ACCENTS.length]}
+ onRename={handleRenameColumn}
+ onAddCard={handleAddCard}
+ onDeleteCard={handleDeleteCard}
+ />
+ ))}
- ))}
+
+ {activeCard ? (
+
+
+
+ ) : null}
+
+
-
-
-
-
- {board.columns.map((column) => (
- board.cards[cardId])}
- onRename={handleRenameColumn}
- onAddCard={handleAddCard}
- onDeleteCard={handleDeleteCard}
- />
- ))}
-
-
- {activeCard ? (
-
-
+
+
+
+
);
};
diff --git a/frontend/src/components/KanbanCard.tsx b/frontend/src/components/KanbanCard.tsx
index 73564bc3..b48c0d9a 100644
--- a/frontend/src/components/KanbanCard.tsx
+++ b/frontend/src/components/KanbanCard.tsx
@@ -1,6 +1,7 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import clsx from "clsx";
+import { GripVertical, X } from "lucide-react";
import type { Card } from "@/lib/kanban";
type KanbanCardProps = {
@@ -10,7 +11,7 @@ type KanbanCardProps = {
export const KanbanCard = ({ card, onDelete }: KanbanCardProps) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
- useSortable({ id: card.id });
+ useSortable({ id: card.id, resizeObserverConfig: undefined });
const style = {
transform: CSS.Transform.toString(transform),
@@ -22,30 +23,36 @@ export const KanbanCard = ({ card, onDelete }: KanbanCardProps) => {
ref={setNodeRef}
style={style}
className={clsx(
- "rounded-2xl border border-transparent bg-white px-4 py-4 shadow-[0_12px_24px_rgba(3,33,71,0.08)]",
+ "group rounded-xl border border-transparent bg-white px-3 py-3 shadow-[0_2px_8px_rgba(3,33,71,0.07)]",
"transition-all duration-150",
- isDragging && "opacity-60 shadow-[0_18px_32px_rgba(3,33,71,0.16)]"
+ isDragging && "opacity-50 shadow-[0_8px_24px_rgba(3,33,71,0.14)]"
)}
- {...attributes}
- {...listeners}
data-testid={`card-${card.id}`}
>
-
-
-
+
+
+
+
{card.title}
-
- {card.details}
-
+ {card.details && (
+
{card.details}
+ )}
diff --git a/frontend/src/components/KanbanColumn.tsx b/frontend/src/components/KanbanColumn.tsx
index e45d1bfb..412182f1 100644
--- a/frontend/src/components/KanbanColumn.tsx
+++ b/frontend/src/components/KanbanColumn.tsx
@@ -8,6 +8,7 @@ import { NewCardForm } from "@/components/NewCardForm";
type KanbanColumnProps = {
column: Column;
cards: Card[];
+ colorAccent: string;
onRename: (columnId: string, title: string) => void;
onAddCard: (columnId: string, title: string, details: string) => void;
onDeleteCard: (columnId: string, cardId: string) => void;
@@ -16,6 +17,7 @@ type KanbanColumnProps = {
export const KanbanColumn = ({
column,
cards,
+ colorAccent,
onRename,
onAddCard,
onDeleteCard,
@@ -26,46 +28,55 @@ export const KanbanColumn = ({
-
-
-
-
-
- {cards.length} cards
-
-
-
onRename(column.id, event.target.value)}
- className="mt-3 w-full bg-transparent font-display text-lg font-semibold text-[var(--navy-dark)] outline-none"
- aria-label="Column title"
- />
+
+
+
+
+
+ {cards.length}
+
+
+ cards
+
+
onRename(column.id, event.target.value)}
+ className="mt-2 w-full bg-transparent font-display text-sm font-semibold text-[var(--navy-dark)] outline-none"
+ aria-label="Column title"
+ />
-
+
+
- {cards.map((card) => (
- onDeleteCard(column.id, cardId)}
- />
- ))}
+
+ {cards.map((card) => (
+ onDeleteCard(column.id, cardId)}
+ />
+ ))}
+
{cards.length === 0 && (
-
- Drop a card here
+
+ Drop here
)}
-
onAddCard(column.id, title, details)}
- />
+
+
+ onAddCard(column.id, title, details)} />
+
);
};
diff --git a/frontend/src/components/LoginScreen.test.tsx b/frontend/src/components/LoginScreen.test.tsx
new file mode 100644
index 00000000..de17cd88
--- /dev/null
+++ b/frontend/src/components/LoginScreen.test.tsx
@@ -0,0 +1,51 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { vi } from "vitest";
+import { LoginScreen } from "@/components/LoginScreen";
+
+describe("LoginScreen", () => {
+ it("renders the login form", () => {
+ render();
+
+ expect(screen.getByPlaceholderText(/user/i)).toBeInTheDocument();
+ expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument();
+ });
+
+ it("shows an error for invalid credentials", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.type(screen.getByPlaceholderText(/user/i), "wrong");
+ await user.type(screen.getByPlaceholderText(/password/i), "wrong");
+ await user.click(screen.getByRole("button", { name: /sign in/i }));
+
+ expect(await screen.findByText(/invalid credentials/i)).toBeInTheDocument();
+ });
+
+ it("calls onLogin for valid credentials", async () => {
+ const onLogin = vi.fn();
+ const user = userEvent.setup();
+
+ render();
+
+ await user.type(screen.getByPlaceholderText(/user/i), "user");
+ await user.type(screen.getByPlaceholderText(/password/i), "password");
+ await user.click(screen.getByRole("button", { name: /sign in/i }));
+
+ expect(onLogin).toHaveBeenCalled();
+ });
+
+ it("accepts valid credentials with username whitespace and casing", async () => {
+ const onLogin = vi.fn();
+ const user = userEvent.setup();
+
+ render();
+
+ await user.type(screen.getByPlaceholderText(/user/i), " User ");
+ await user.type(screen.getByPlaceholderText(/password/i), "password");
+ await user.click(screen.getByRole("button", { name: /sign in/i }));
+
+ expect(onLogin).toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/components/LoginScreen.tsx b/frontend/src/components/LoginScreen.tsx
new file mode 100644
index 00000000..89899b70
--- /dev/null
+++ b/frontend/src/components/LoginScreen.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { useState, type FormEvent } from "react";
+import { LayoutDashboard, LogIn } from "lucide-react";
+
+type LoginScreenProps = {
+ onLogin: () => void;
+};
+
+const validUsername = "user";
+const validPassword = "password";
+
+export const LoginScreen = ({ onLogin }: LoginScreenProps) => {
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState("");
+
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ const normalizedUsername = username.trim().toLowerCase();
+ const normalizedPassword = password;
+
+ if (normalizedUsername === validUsername && normalizedPassword === validPassword) {
+ setError("");
+ onLogin();
+ return;
+ }
+ setError("Invalid credentials. Use user / password.");
+ };
+
+ return (
+
+
+
+
+
+
+
+ Kanban Studio
+
+
+ Sign in to continue
+
+
+ Use the dummy credentials to open your board.
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/NewCardForm.tsx b/frontend/src/components/NewCardForm.tsx
index 5f8a8be4..f0cfd7ee 100644
--- a/frontend/src/components/NewCardForm.tsx
+++ b/frontend/src/components/NewCardForm.tsx
@@ -1,4 +1,5 @@
import { useState, type FormEvent } from "react";
+import { Plus, Check, X } from "lucide-react";
const initialFormState = { title: "", details: "" };
@@ -12,26 +13,25 @@ export const NewCardForm = ({ onAdd }: NewCardFormProps) => {
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
- if (!formState.title.trim()) {
- return;
- }
+ if (!formState.title.trim()) return;
onAdd(formState.title.trim(), formState.details.trim());
setFormState(initialFormState);
setIsOpen(false);
};
return (
-