From 4b3418846f2a18924f4aada2b7aef97ea7dc47af Mon Sep 17 00:00:00 2001
From: Eppo Luiken
Date: Thu, 7 May 2026 09:41:02 +0200
Subject: [PATCH 1/4] Build PM kanban app with backend, frontend and tests
---
.dockerignore | 18 +
.env.example | 2 +
.gitignore | 5 +
Dockerfile | 24 +
backend/AGENTS.md | 53 +-
backend/__init__.py | 1 +
backend/ai.py | 95 ++++
backend/db.py | 109 +++++
backend/main.py | 182 +++++++
backend/pyproject.toml | 16 +
backend/static/index.html | 12 +
backend/tests/test_backend.py | 163 +++++++
docs/PLAN.md | 248 ++++++++--
docs/database-schema.md | 76 +++
frontend/AGENTS.md | 63 +++
frontend/next.config.ts | 9 +-
frontend/package-lock.json | 489 ++++++++++---------
frontend/package.json | 9 +-
frontend/playwright.config.ts | 9 +-
frontend/src/app/page.tsx | 57 ++-
frontend/src/components/KanbanBoard.test.tsx | 88 +++-
frontend/src/components/KanbanBoard.tsx | 283 +++++++++--
frontend/src/components/KanbanCard.tsx | 2 +-
frontend/src/components/LoginScreen.test.tsx | 51 ++
frontend/src/components/LoginScreen.tsx | 83 ++++
frontend/src/lib/api.ts | 72 +++
frontend/tests/kanban.spec.ts | 49 +-
scripts/AGENTS.md | 8 +-
scripts/start.bat | 19 +
scripts/start.ps1 | 21 +
scripts/start.sh | 33 ++
scripts/stop.bat | 7 +
scripts/stop.ps1 | 6 +
scripts/stop.sh | 8 +
34 files changed, 2031 insertions(+), 339 deletions(-)
create mode 100644 .dockerignore
create mode 100644 .env.example
create mode 100644 Dockerfile
create mode 100644 backend/__init__.py
create mode 100644 backend/ai.py
create mode 100644 backend/db.py
create mode 100644 backend/main.py
create mode 100644 backend/pyproject.toml
create mode 100644 backend/static/index.html
create mode 100644 backend/tests/test_backend.py
create mode 100644 docs/database-schema.md
create mode 100644 frontend/AGENTS.md
create mode 100644 frontend/src/components/LoginScreen.test.tsx
create mode 100644 frontend/src/components/LoginScreen.tsx
create mode 100644 frontend/src/lib/api.ts
create mode 100644 scripts/start.bat
create mode 100644 scripts/start.ps1
create mode 100755 scripts/start.sh
create mode 100644 scripts/stop.bat
create mode 100644 scripts/stop.ps1
create mode 100755 scripts/stop.sh
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/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..3fe6f6c7
--- /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=12.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..c49fc87b
--- /dev/null
+++ b/backend/main.py
@@ -0,0 +1,182 @@
+import json
+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
+
+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:
+ 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)
+ parsed = _extract_json_object(raw_answer)
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=f"AI request failed: {exc}") 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/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..dbb5b236 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -12,7 +12,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"clsx": "^2.1.1",
- "next": "16.1.6",
+ "next": "^16.2.4",
"react": "19.2.3",
"react-dom": "19.2.3"
},
@@ -28,7 +28,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 +1840,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 +1856,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 +1872,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 +1888,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 +1907,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 +1926,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 +1945,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 +1964,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 +1980,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 +2078,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 +2092,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 +2106,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 +2120,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 +2134,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 +2148,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 +2162,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 +2179,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 +2196,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 +2213,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 +2230,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 +2247,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 +2264,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 +2281,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 +2298,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 +2315,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 +2332,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 +2349,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 +2366,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 +2383,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 +2397,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 +2411,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 +2425,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 +2439,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 +2453,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 +3176,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 +3186,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 +3729,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 +4057,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 +4944,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 +5446,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 +5489,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 +5665,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 +5675,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"
@@ -7063,9 +7113,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 +7194,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 +7213,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 +7246,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 +7552,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 +7607,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 +7884,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 +7900,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 +8679,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 +8689,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 +8754,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 +9132,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 +9263,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 +9349,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..c61ab802 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,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"clsx": "^2.1.1",
- "next": "16.1.6",
+ "next": "^16.2.4",
"react": "19.2.3",
"react-dom": "19.2.3"
},
@@ -34,10 +36,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..182473fa 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, useState } from "react";
import {
DndContext,
DragOverlay,
@@ -14,10 +14,21 @@ import {
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);
+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 sensors = useSensors(
useSensor(PointerSensor, {
@@ -25,7 +36,59 @@ export const KanbanBoard = () => {
})
);
- const cardsById = useMemo(() => board.cards, [board.cards]);
+ const cardsById = useMemo(() => (board ? board.cards : {}), [board]);
+
+ 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) {
+ void 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) => {
+ setBoard((prev) => {
+ if (!prev) {
+ return prev;
+ }
+ const nextBoard = updater(prev);
+ void saveCurrentBoard(nextBoard);
+ return nextBoard;
+ });
+ };
const handleDragStart = (event: DragStartEvent) => {
setActiveCardId(event.active.id as string);
@@ -39,14 +102,14 @@ export const KanbanBoard = () => {
return;
}
- setBoard((prev) => ({
+ updateBoard((prev) => ({
...prev,
columns: moveCard(prev.columns, active.id as string, over.id as string),
}));
};
const handleRenameColumn = (columnId: string, title: string) => {
- setBoard((prev) => ({
+ updateBoard((prev) => ({
...prev,
columns: prev.columns.map((column) =>
column.id === columnId ? { ...column, title } : column
@@ -56,7 +119,7 @@ export const KanbanBoard = () => {
const handleAddCard = (columnId: string, title: string, details: string) => {
const id = createId("card");
- setBoard((prev) => ({
+ updateBoard((prev) => ({
...prev,
cards: {
...prev.cards,
@@ -71,26 +134,78 @@ export const KanbanBoard = () => {
};
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((column) =>
+ column.id === columnId
+ ? {
+ ...column,
+ cardIds: column.cardIds.filter((id) => id !== cardId),
+ }
+ : column
+ ),
+ }));
};
const activeCard = activeCardId ? cardsById[activeCardId] : null;
+ const handleChatSubmit = async () => {
+ if (!board || chatLoading) {
+ return;
+ }
+ const nextMessage = chatInput.trim();
+ if (!nextMessage) {
+ return;
+ }
+
+ const nextConversation = [...chatMessages, { role: "user", content: nextMessage } as const];
+ setChatMessages(nextConversation);
+ setChatInput("");
+ setChatError(null);
+ setChatLoading(true);
+
+ try {
+ const response = await sendChatMessage(board, chatMessages, nextMessage);
+ setChatMessages((prev) => [...prev, { role: "assistant", content: response.message }]);
+ 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.
+
+
+ );
+ }
+
return (
@@ -121,6 +236,11 @@ export const KanbanBoard = () => {
+ {error ? (
+
+ {error}
+
+ ) : null}
{board.columns.map((column) => (
{
-
-
- {board.columns.map((column) => (
- board.cards[cardId])}
- onRename={handleRenameColumn}
- onAddCard={handleAddCard}
- onDeleteCard={handleDeleteCard}
- />
- ))}
-
-
- {activeCard ? (
-
-
-
+
+
+
+ {board.columns.map((column) => (
+ board.cards[cardId])}
+ onRename={handleRenameColumn}
+ onAddCard={handleAddCard}
+ onDeleteCard={handleDeleteCard}
+ />
+ ))}
+
+
+ {activeCard ? (
+
+
+
+ ) : null}
+
+
+
+
+
);
diff --git a/frontend/src/components/KanbanCard.tsx b/frontend/src/components/KanbanCard.tsx
index 73564bc3..e8fdb317 100644
--- a/frontend/src/components/KanbanCard.tsx
+++ b/frontend/src/components/KanbanCard.tsx
@@ -10,7 +10,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),
diff --git a/frontend/src/components/LoginScreen.test.tsx b/frontend/src/components/LoginScreen.test.tsx
new file mode 100644
index 00000000..0f9eb271
--- /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 extra whitespace and username casing differences", 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..65a1a38d
--- /dev/null
+++ b/frontend/src/components/LoginScreen.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import { useState, type FormEvent } from "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.trim();
+
+ 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/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 00000000..e634539a
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -0,0 +1,72 @@
+import type { BoardData } from "./kanban";
+
+export type BoardResponse = {
+ user: string;
+ board: BoardData;
+};
+
+export type ChatMessage = {
+ role: "user" | "assistant";
+ content: string;
+};
+
+export type ChatResponse = {
+ message: string;
+ boardUpdate: BoardData | null;
+};
+
+const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api";
+
+const errorMessageFrom = async (response: Response, fallback: string) => {
+ try {
+ const body = (await response.json()) as { detail?: unknown };
+ return typeof body.detail === "string" ? body.detail : fallback;
+ } catch {
+ return fallback;
+ }
+};
+
+export const fetchBoard = async (username: string): Promise => {
+ const response = await fetch(`${apiBase}/board/${encodeURIComponent(username)}`);
+ if (!response.ok) {
+ throw new Error("Unable to load board");
+ }
+ const data = (await response.json()) as BoardResponse;
+ return data.board;
+};
+
+export const saveBoard = async (username: string, board: BoardData): Promise => {
+ const response = await fetch(`${apiBase}/board/${encodeURIComponent(username)}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ board }),
+ });
+ if (!response.ok) {
+ throw new Error("Unable to save board");
+ }
+};
+
+export const sendChatMessage = async (
+ board: BoardData,
+ conversation: ChatMessage[],
+ message: string
+): Promise => {
+ const response = await fetch(`${apiBase}/chat`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ board,
+ conversation,
+ message,
+ }),
+ });
+ if (!response.ok) {
+ const message = await errorMessageFrom(response, "Unable to send chat message");
+ throw new Error(message);
+ }
+ return (await response.json()) as ChatResponse;
+};
diff --git a/frontend/tests/kanban.spec.ts b/frontend/tests/kanban.spec.ts
index adea248a..b989ef60 100644
--- a/frontend/tests/kanban.spec.ts
+++ b/frontend/tests/kanban.spec.ts
@@ -1,13 +1,40 @@
import { expect, test } from "@playwright/test";
+import { initialData } from "../src/lib/kanban";
-test("loads the kanban board", async ({ page }) => {
+test.beforeEach(async ({ request }) => {
+ const response = await request.post("/api/board/user", {
+ data: { board: initialData },
+ });
+ expect(response.ok()).toBeTruthy();
+});
+
+const signIn = async (page: Parameters[0]["page"]) => {
await page.goto("/");
- await expect(page.getByRole("heading", { name: "Kanban Studio" })).toBeVisible();
+ await page.getByPlaceholder("user").fill("user");
+ await page.getByPlaceholder("password").fill("password");
+ await page.getByRole("button", { name: /sign in/i }).click();
+ await expect(page.getByRole("button", { name: /log out/i })).toBeVisible();
+ await expect(page.getByText(/one board\.\s*five columns\.\s*zero clutter\./i)).toBeVisible();
+};
+
+test("requires login before showing the board", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.getByRole("heading", { name: /sign in to continue/i })).toBeVisible();
+ await expect(page.getByText(/one board\.\s*five columns\.\s*zero clutter\./i)).not.toBeVisible();
+ await page.getByPlaceholder("user").fill("user");
+ await page.getByPlaceholder("password").fill("password");
+ await page.getByRole("button", { name: /sign in/i }).click();
+ await expect(page.getByRole("button", { name: /log out/i })).toBeVisible();
+ await expect(page.getByText(/one board\.\s*five columns\.\s*zero clutter\./i)).toBeVisible();
+});
+
+test("loads the kanban board", async ({ page }) => {
+ await signIn(page);
await expect(page.locator('[data-testid^="column-"]')).toHaveCount(5);
});
test("adds a card to a column", async ({ page }) => {
- await page.goto("/");
+ await signIn(page);
const firstColumn = page.locator('[data-testid^="column-"]').first();
await firstColumn.getByRole("button", { name: /add a card/i }).click();
await firstColumn.getByPlaceholder("Card title").fill("Playwright card");
@@ -17,7 +44,7 @@ test("adds a card to a column", async ({ page }) => {
});
test("moves a card between columns", async ({ page }) => {
- await page.goto("/");
+ await signIn(page);
const card = page.getByTestId("card-card-1");
const targetColumn = page.getByTestId("column-col-review");
const cardBox = await card.boundingBox();
@@ -39,3 +66,17 @@ test("moves a card between columns", async ({ page }) => {
await page.mouse.up();
await expect(targetColumn.getByTestId("card-card-1")).toBeVisible();
});
+
+test("persists board state after reload", async ({ page }) => {
+ await signIn(page);
+ const backlogColumn = page.getByTestId("column-col-backlog");
+
+ await backlogColumn.getByRole("button", { name: /add a card/i }).click();
+ await backlogColumn.getByPlaceholder("Card title").fill("Persistent card");
+ await backlogColumn.getByPlaceholder("Details").fill("This should survive refresh.");
+ await backlogColumn.getByRole("button", { name: /add card/i }).click();
+
+ await expect(backlogColumn.getByText("Persistent card")).toBeVisible();
+ await page.reload();
+ await expect(page.getByText("Persistent card")).toBeVisible();
+});
diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md
index e823c0d3..d59a1bc7 100644
--- a/scripts/AGENTS.md
+++ b/scripts/AGENTS.md
@@ -1 +1,7 @@
-This folder will contain start and stop scripts for Mac, PC and Linux
\ No newline at end of file
+This folder contains start and stop scripts for local Docker usage.
+
+- `start.sh` / `stop.sh` support macOS and Linux shells.
+- `start.ps1` / `stop.ps1` support PowerShell.
+- `start.bat` / `stop.bat` support Windows Command Prompt.
+
+The start scripts build the Docker image, start `pm-app` on port `8000`, mount local `data/` for SQLite persistence, and pass root `.env` into the container when it exists.
diff --git a/scripts/start.bat b/scripts/start.bat
new file mode 100644
index 00000000..ae0f3fc2
--- /dev/null
+++ b/scripts/start.bat
@@ -0,0 +1,19 @@
+@echo off
+setlocal
+
+cd /d "%~dp0\.."
+
+docker rm -f pm-app >nul 2>&1
+docker build -t pm-app .
+
+if not exist data mkdir data
+if not exist data\pm.db if exist backend\pm.db copy backend\pm.db data\pm.db >nul
+
+if exist .env (
+ docker run -d --name pm-app --env-file .env -e PM_DB_PATH=/app/data/pm.db -v "%cd%\data:/app/data" -p 8000:8000 pm-app
+) else (
+ echo Warning: .env not found; OPENROUTER_API_KEY will be unavailable in container
+ docker run -d --name pm-app -e PM_DB_PATH=/app/data/pm.db -v "%cd%\data:/app/data" -p 8000:8000 pm-app
+)
+
+echo Application running at http://localhost:8000
diff --git a/scripts/start.ps1 b/scripts/start.ps1
new file mode 100644
index 00000000..2ed3cbf2
--- /dev/null
+++ b/scripts/start.ps1
@@ -0,0 +1,21 @@
+$ErrorActionPreference = "Stop"
+
+Set-Location (Join-Path $PSScriptRoot "..")
+
+docker rm -f pm-app *> $null
+docker build -t pm-app .
+
+New-Item -ItemType Directory -Force -Path data | Out-Null
+if (-not (Test-Path "data/pm.db") -and (Test-Path "backend/pm.db")) {
+ Copy-Item "backend/pm.db" "data/pm.db"
+}
+
+$volume = "$(Get-Location)/data:/app/data"
+if (Test-Path ".env") {
+ docker run -d --name pm-app --env-file .env -e PM_DB_PATH=/app/data/pm.db -v $volume -p 8000:8000 pm-app
+} else {
+ Write-Host "Warning: .env not found; OPENROUTER_API_KEY will be unavailable in container"
+ docker run -d --name pm-app -e PM_DB_PATH=/app/data/pm.db -v $volume -p 8000:8000 pm-app
+}
+
+Write-Host "Application running at http://localhost:8000"
diff --git a/scripts/start.sh b/scripts/start.sh
new file mode 100755
index 00000000..146db1ef
--- /dev/null
+++ b/scripts/start.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+docker rm -f pm-app >/dev/null 2>&1 || true
+
+docker build -t pm-app .
+
+mkdir -p data
+if [[ ! -f data/pm.db && -f backend/pm.db ]]; then
+ cp backend/pm.db data/pm.db
+fi
+
+if [[ -f .env ]]; then
+ docker run -d \
+ --name pm-app \
+ --env-file .env \
+ -e PM_DB_PATH=/app/data/pm.db \
+ -v "$PWD/data:/app/data" \
+ -p 8000:8000 \
+ pm-app
+else
+ echo "Warning: .env not found; OPENROUTER_API_KEY will be unavailable in container"
+ docker run -d \
+ --name pm-app \
+ -e PM_DB_PATH=/app/data/pm.db \
+ -v "$PWD/data:/app/data" \
+ -p 8000:8000 \
+ pm-app
+fi
+
+echo "Application running at http://localhost:8000"
diff --git a/scripts/stop.bat b/scripts/stop.bat
new file mode 100644
index 00000000..4298b0ca
--- /dev/null
+++ b/scripts/stop.bat
@@ -0,0 +1,7 @@
+@echo off
+setlocal
+
+cd /d "%~dp0\.."
+docker rm -f pm-app >nul 2>&1
+
+echo Stopped pm-app
diff --git a/scripts/stop.ps1 b/scripts/stop.ps1
new file mode 100644
index 00000000..c6753143
--- /dev/null
+++ b/scripts/stop.ps1
@@ -0,0 +1,6 @@
+$ErrorActionPreference = "Stop"
+
+Set-Location (Join-Path $PSScriptRoot "..")
+docker rm -f pm-app *> $null
+
+Write-Host "Stopped pm-app"
diff --git a/scripts/stop.sh b/scripts/stop.sh
new file mode 100755
index 00000000..75bce115
--- /dev/null
+++ b/scripts/stop.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+docker rm -f pm-app >/dev/null 2>&1 || true
+
+echo "Stopped pm-app"
From 78cf7f77b34d04b0266ef173e920f3836908a05d Mon Sep 17 00:00:00 2001
From: Eppo Luiken
Date: Thu, 7 May 2026 10:00:26 +0200
Subject: [PATCH 2/4] Added CLAUDE.MD
---
CLAUDE.md | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 102 insertions(+)
create mode 100644 CLAUDE.md
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..2b474f6a
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,102 @@
+# 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)
+```bash
+cd backend
+pip install -e .
+uvicorn backend.main:app --reload --port 8000
+```
+
+### Backend tests
+```bash
+cd backend
+python -m pytest tests/
+# or a single test:
+python -m pytest tests/test_backend.py::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 tests
+```bash
+cd frontend
+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, LoginScreen, NewCardForm
+ lib/kanban.ts BoardData types and moveCard logic (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.
+
+### 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`
From fc373d4a241ca1965f3846ff7e184ab3f3a77f9c Mon Sep 17 00:00:00 2001
From: Eppo Luiken
Date: Thu, 7 May 2026 10:43:13 +0200
Subject: [PATCH 3/4] reworked fixed kanban
---
.claude/settings.local.json | 17 ++
CLAUDE.md | 6 +-
backend/ai.py | 2 +-
backend/main.py | 10 +-
docs/code_review.md | 170 +++++++++++++++++++
frontend/src/components/KanbanBoard.tsx | 41 +++--
frontend/src/components/LoginScreen.test.tsx | 4 +-
frontend/src/components/LoginScreen.tsx | 2 +-
8 files changed, 232 insertions(+), 20 deletions(-)
create mode 100644 .claude/settings.local.json
create mode 100644 docs/code_review.md
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 00000000..7e254b8c
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,17 @@
+{
+ "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)"
+ ]
+ }
+}
diff --git a/CLAUDE.md b/CLAUDE.md
index 2b474f6a..6281a278 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,11 +22,11 @@ uvicorn backend.main:app --reload --port 8000
```
### Backend tests
+Run from the **project root** (so the `backend` package is importable):
```bash
-cd backend
-python -m pytest tests/
+python -m unittest discover -s backend/tests -v
# or a single test:
-python -m pytest tests/test_backend.py::BackendPersistenceTests::test_health_endpoint
+python -m unittest backend.tests.test_backend.BackendPersistenceTests.test_health_endpoint
```
### Frontend development
diff --git a/backend/ai.py b/backend/ai.py
index 3fe6f6c7..1712aaa5 100644
--- a/backend/ai.py
+++ b/backend/ai.py
@@ -66,7 +66,7 @@ async def ask_openrouter(messages: list[dict[str, str]]) -> str:
}
errors: list[str] = []
- async with httpx.AsyncClient(timeout=12.0) as client:
+ async with httpx.AsyncClient(timeout=30.0) as client:
for model_name in get_model_names():
payload: dict[str, Any] = {
"model": model_name,
diff --git a/backend/main.py b/backend/main.py
index c49fc87b..0b90bca2 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,4 +1,5 @@
import json
+import logging
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Literal
@@ -16,6 +17,8 @@
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"
@@ -111,6 +114,7 @@ def _validated_board_or_default(board_data: dict[str, Any] | None) -> BoardModel
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)
@@ -162,10 +166,14 @@ async def chat(payload: ChatRequest) -> ChatResponse:
try:
raw_answer = await ai.ask_openrouter(messages)
- parsed = _extract_json_object(raw_answer)
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")
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/frontend/src/components/KanbanBoard.tsx b/frontend/src/components/KanbanBoard.tsx
index 182473fa..3f8747f6 100644
--- a/frontend/src/components/KanbanBoard.tsx
+++ b/frontend/src/components/KanbanBoard.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import {
DndContext,
DragOverlay,
@@ -16,6 +16,8 @@ import { KanbanCardPreview } from "@/components/KanbanCardPreview";
import { createId, initialData, moveCard, type BoardData } from "@/lib/kanban";
import { fetchBoard, saveBoard, sendChatMessage, type ChatMessage } from "@/lib/api";
+type LocalChatMessage = ChatMessage & { id: string };
+
type KanbanBoardProps = {
username: string;
};
@@ -25,7 +27,7 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
const [activeCardId, setActiveCardId] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- const [chatMessages, setChatMessages] = useState([]);
+ const [chatMessages, setChatMessages] = useState([]);
const [chatInput, setChatInput] = useState("");
const [chatError, setChatError] = useState(null);
const [chatLoading, setChatLoading] = useState(false);
@@ -36,6 +38,8 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
})
);
+ const saveTimerRef = useRef | null>(null);
+
const cardsById = useMemo(() => (board ? board.cards : {}), [board]);
useEffect(() => {
@@ -46,7 +50,7 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
const fetchedBoard = await fetchBoard(username);
const nextBoard = fetchedBoard.columns.length > 0 ? fetchedBoard : initialData;
if (fetchedBoard.columns.length === 0) {
- void saveBoard(username, nextBoard);
+ await saveBoard(username, nextBoard);
}
if (mounted) {
setBoard(nextBoard);
@@ -79,13 +83,18 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
}
};
- const updateBoard = (updater: (prev: BoardData) => BoardData) => {
+ const updateBoard = (updater: (prev: BoardData) => BoardData, { debounce = false }: { debounce?: boolean } = {}) => {
setBoard((prev) => {
if (!prev) {
return prev;
}
const nextBoard = updater(prev);
- void saveCurrentBoard(nextBoard);
+ if (debounce) {
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
+ saveTimerRef.current = setTimeout(() => void saveCurrentBoard(nextBoard), 400);
+ } else {
+ void saveCurrentBoard(nextBoard);
+ }
return nextBoard;
});
};
@@ -114,7 +123,7 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
columns: prev.columns.map((column) =>
column.id === columnId ? { ...column, title } : column
),
- }));
+ }), { debounce: true });
};
const handleAddCard = (columnId: string, title: string, details: string) => {
@@ -123,7 +132,7 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
...prev,
cards: {
...prev.cards,
- [id]: { id, title, details: details || "No details yet." },
+ [id]: { id, title, details },
},
columns: prev.columns.map((column) =>
column.id === columnId
@@ -161,7 +170,10 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
return;
}
- const nextConversation = [...chatMessages, { role: "user", content: nextMessage } as const];
+ const nextConversation: LocalChatMessage[] = [
+ ...chatMessages,
+ { role: "user" as const, content: nextMessage, id: createId("msg") },
+ ];
setChatMessages(nextConversation);
setChatInput("");
setChatError(null);
@@ -169,7 +181,7 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
try {
const response = await sendChatMessage(board, chatMessages, nextMessage);
- setChatMessages((prev) => [...prev, { role: "assistant", content: response.message }]);
+ setChatMessages((prev) => [...prev, { role: "assistant", content: response.message, id: createId("msg") }]);
if (response.boardUpdate) {
setBoard(response.boardUpdate);
await saveCurrentBoard(response.boardUpdate);
@@ -265,7 +277,7 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
board.cards[cardId])}
+ cards={column.cardIds.map((cardId) => board.cards[cardId]).filter(Boolean)}
onRename={handleRenameColumn}
onAddCard={handleAddCard}
onDeleteCard={handleDeleteCard}
@@ -302,9 +314,9 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
Ask for card edits or moves, for example: move all review tasks to done.
) : null}
- {chatMessages.map((message, index) => (
+ {chatMessages.map((message) => (
{
{message.content}
))}
+ {chatLoading ? (
+
+ Thinking...
+
+ ) : null}
{chatError ? (
diff --git a/frontend/src/components/LoginScreen.test.tsx b/frontend/src/components/LoginScreen.test.tsx
index 0f9eb271..de17cd88 100644
--- a/frontend/src/components/LoginScreen.test.tsx
+++ b/frontend/src/components/LoginScreen.test.tsx
@@ -36,14 +36,14 @@ describe("LoginScreen", () => {
expect(onLogin).toHaveBeenCalled();
});
- it("accepts valid credentials with extra whitespace and username casing differences", async () => {
+ 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.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
index 65a1a38d..cf0aa085 100644
--- a/frontend/src/components/LoginScreen.tsx
+++ b/frontend/src/components/LoginScreen.tsx
@@ -17,7 +17,7 @@ export const LoginScreen = ({ onLogin }: LoginScreenProps) => {
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
const normalizedUsername = username.trim().toLowerCase();
- const normalizedPassword = password.trim();
+ const normalizedPassword = password;
if (normalizedUsername === validUsername && normalizedPassword === validPassword) {
setError("");
From e1759f1c8fe5e5f7629667d80dd7f8e8ca0a491f Mon Sep 17 00:00:00 2001
From: Eppo Luiken
Date: Fri, 8 May 2026 09:05:26 +0200
Subject: [PATCH 4/4] before YOLO
---
.claude/settings.local.json | 6 +-
CLAUDE.md | 18 +-
frontend/package-lock.json | 10 +
frontend/package.json | 1 +
frontend/src/components/KanbanBoard.tsx | 301 ++++++++++++-----------
frontend/src/components/KanbanCard.tsx | 31 ++-
frontend/src/components/KanbanColumn.tsx | 67 ++---
frontend/src/components/LoginScreen.tsx | 21 +-
frontend/src/components/NewCardForm.tsx | 27 +-
9 files changed, 267 insertions(+), 215 deletions(-)
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 7e254b8c..ac6419e3 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -11,7 +11,11 @@
"Bash(git *)",
"Bash(npm test *)",
"Bash(./scripts/stop.sh)",
- "Bash(code --version)"
+ "Bash(code --version)",
+ "Bash(docker compose *)",
+ "Bash(osascript *)",
+ "Skill(update-config)",
+ "Skill(ralph-loop:ralph-loop)"
]
}
}
diff --git a/CLAUDE.md b/CLAUDE.md
index 6281a278..80c347b5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -15,9 +15,9 @@ A Kanban project management app with an AI chat sidebar. One board per user, har
```
### Backend development (without Docker)
+Run all commands from the **project root**:
```bash
-cd backend
-pip install -e .
+pip install -e backend/
uvicorn backend.main:app --reload --port 8000
```
@@ -38,9 +38,10 @@ 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 tests
+### 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)
```
@@ -62,8 +63,8 @@ backend/
frontend/src/
app/page.tsx root page: login gate, passes username to KanbanBoard
- components/ KanbanBoard, KanbanColumn, KanbanCard, LoginScreen, NewCardForm
- lib/kanban.ts BoardData types and moveCard logic (pure functions)
+ 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
```
@@ -72,6 +73,13 @@ frontend/src/
- 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
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index dbb5b236..24d68b28 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -12,6 +12,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"clsx": "^2.1.1",
+ "lucide-react": "^1.14.0",
"next": "^16.2.4",
"react": "19.2.3",
"react-dom": "19.2.3"
@@ -6999,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",
diff --git a/frontend/package.json b/frontend/package.json
index c61ab802..3a3b6350 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -20,6 +20,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"clsx": "^2.1.1",
+ "lucide-react": "^1.14.0",
"next": "^16.2.4",
"react": "19.2.3",
"react-dom": "19.2.3"
diff --git a/frontend/src/components/KanbanBoard.tsx b/frontend/src/components/KanbanBoard.tsx
index 3f8747f6..beea3d8c 100644
--- a/frontend/src/components/KanbanBoard.tsx
+++ b/frontend/src/components/KanbanBoard.tsx
@@ -11,11 +11,14 @@ 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";
+const COLUMN_ACCENTS = ["#209dd7", "#ecad0a", "#753991", "#0d9488", "#e06c54"];
+
type LocalChatMessage = ChatMessage & { id: string };
type KanbanBoardProps = {
@@ -31,20 +34,21 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
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 saveTimerRef = useRef | null>(null);
-
const cardsById = useMemo(() => (board ? board.cards : {}), [board]);
useEffect(() => {
- let mounted = true;
+ chatEndRef.current?.scrollIntoView?.({ behavior: "smooth" });
+ }, [chatMessages]);
+ useEffect(() => {
+ let mounted = true;
const loadBoard = async () => {
try {
const fetchedBoard = await fetchBoard(username);
@@ -52,9 +56,7 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
if (fetchedBoard.columns.length === 0) {
await saveBoard(username, nextBoard);
}
- if (mounted) {
- setBoard(nextBoard);
- }
+ if (mounted) setBoard(nextBoard);
} catch (error) {
console.error(error);
if (mounted) {
@@ -62,14 +64,10 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
setBoard(initialData);
}
} finally {
- if (mounted) {
- setLoading(false);
- }
+ if (mounted) setLoading(false);
}
};
-
loadBoard();
-
return () => {
mounted = false;
};
@@ -83,11 +81,12 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
}
};
- const updateBoard = (updater: (prev: BoardData) => BoardData, { debounce = false }: { debounce?: boolean } = {}) => {
+ const updateBoard = (
+ updater: (prev: BoardData) => BoardData,
+ { debounce = false }: { debounce?: boolean } = {}
+ ) => {
setBoard((prev) => {
- if (!prev) {
- return prev;
- }
+ if (!prev) return prev;
const nextBoard = updater(prev);
if (debounce) {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
@@ -106,11 +105,7 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveCardId(null);
-
- if (!over || active.id === over.id) {
- return;
- }
-
+ if (!over || active.id === over.id) return;
updateBoard((prev) => ({
...prev,
columns: moveCard(prev.columns, active.id as string, over.id as string),
@@ -118,26 +113,24 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
};
const handleRenameColumn = (columnId: string, title: string) => {
- updateBoard((prev) => ({
- ...prev,
- columns: prev.columns.map((column) =>
- column.id === columnId ? { ...column, title } : column
- ),
- }), { debounce: true });
+ 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");
updateBoard((prev) => ({
...prev,
- cards: {
- ...prev.cards,
- [id]: { id, title, details },
- },
- 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
),
}));
};
@@ -148,13 +141,10 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
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
+ columns: prev.columns.map((col) =>
+ col.id === columnId
+ ? { ...col, cardIds: col.cardIds.filter((id) => id !== cardId) }
+ : col
),
}));
};
@@ -162,13 +152,9 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
const activeCard = activeCardId ? cardsById[activeCardId] : null;
const handleChatSubmit = async () => {
- if (!board || chatLoading) {
- return;
- }
+ if (!board || chatLoading) return;
const nextMessage = chatInput.trim();
- if (!nextMessage) {
- return;
- }
+ if (!nextMessage) return;
const nextConversation: LocalChatMessage[] = [
...chatMessages,
@@ -181,7 +167,10 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
try {
const response = await sendChatMessage(board, chatMessages, nextMessage);
- setChatMessages((prev) => [...prev, { role: "assistant", content: response.message, id: createId("msg") }]);
+ setChatMessages((prev) => [
+ ...prev,
+ { role: "assistant", content: response.message, id: createId("msg") },
+ ]);
if (response.boardUpdate) {
setBoard(response.boardUpdate);
await saveCurrentBoard(response.boardUpdate);
@@ -200,7 +189,7 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
if (loading) {
return (
-
+
@@ -210,7 +199,7 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
if (!board) {
return (
-
+
Unable to load the board.
@@ -218,128 +207,141 @@ export const KanbanBoard = ({ username }: KanbanBoardProps) => {
);
}
- return (
-
-
-
+ 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 && (
+
{error}
-
- ) : null}
- {board.columns.map((column) => (
-
-
- {column.title}
-
- ))}
+
+ )}
+
+ {totalCards}
+ {" "}card{totalCards !== 1 ? "s" : ""}
+
+
+
+ {username}
+
-
-
-
-
-
- {board.columns.map((column) => (
- board.cards[cardId]).filter(Boolean)}
- onRename={handleRenameColumn}
- onAddCard={handleAddCard}
- onDeleteCard={handleDeleteCard}
- />
- ))}
-
-
+
+
+
+
+
+
+
- {activeCard ? (
-
-
-
- ) : null}
-
-
-
-
);
};
diff --git a/frontend/src/components/KanbanCard.tsx b/frontend/src/components/KanbanCard.tsx
index e8fdb317..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 = {
@@ -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.tsx b/frontend/src/components/LoginScreen.tsx
index cf0aa085..89899b70 100644
--- a/frontend/src/components/LoginScreen.tsx
+++ b/frontend/src/components/LoginScreen.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState, type FormEvent } from "react";
+import { LayoutDashboard, LogIn } from "lucide-react";
type LoginScreenProps = {
onLogin: () => void;
@@ -29,39 +30,42 @@ export const LoginScreen = ({ onLogin }: LoginScreenProps) => {
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 (
-