diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..79b57c58 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -r backend/requirements.txt pytest + - run: python -m pytest backend/tests/ + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - run: npm run lint + - run: npm run test:unit + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..184fe064 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,61 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +A Project Management MVP: a single-user (MVP scope) Kanban board with an AI chat sidebar that can create/edit/move cards. Full requirements, technical decisions, and color scheme live in `AGENTS.md` (root) — read it before making product decisions. The build is being executed in phases tracked in `docs/PLAN.md`; check that file for current phase/status before assuming a feature (auth, database, AI wiring) is finished. + +Target architecture per `AGENTS.md`: NextJS frontend + Python FastAPI backend (serving the built static Next site at `/`), packaged as one Docker container, SQLite for storage, OpenRouter (`openai/gpt-oss-120b`) for AI calls, `uv` as the Python package manager in Docker. + +**Current implementation state (important, don't assume otherwise):** `backend/app.py` is now a FastAPI app (module-level `app = FastAPI()`) with `/api/health` and `/api/chat` (still keyword-based fake replies — no SQLite, no OpenRouter call yet). `python3 backend/app.py` runs it via `uvicorn.run()` in-process, so the OS-level process still shows up as `python3 backend/app.py` (relevant for `scripts/stop.sh`'s pkill pattern, see below). `backend/tests/test_chat.py` imports `from backend.app import app` and passes. + +The frontend Kanban board (`frontend/`) is a working, self-contained demo: board state lives in React state (`KanbanBoard.tsx`), not persisted to any backend yet. Sign-in is a hardcoded `user`/`password` check in client state (not real auth). `ChatPanel.tsx` calls `POST /api/chat` and expects `{ reply, updated_board? }`; `next.config.ts` rewrites `/api/:path*` to `http://127.0.0.1:8000/api/:path*` (the Python backend) in dev. + +## Commands + +### Frontend (`frontend/`) +```bash +npm install +npm run dev # Next dev server on :3000, proxies /api/* to :8000 +npm run build +npm run lint +npm run test:unit # vitest run +npm run test:unit:watch # vitest watch +npm run test:e2e # playwright (auto-starts dev server on 127.0.0.1:3000) +npm run test:all # unit then e2e +``` +Run a single vitest file/test: `npx vitest run src/components/KanbanBoard.test.tsx` or `npx vitest run -t "test name"`. +Run a single playwright test: `npx playwright test tests/kanban.spec.ts -g "test name"`. +Unit tests (vitest, jsdom) live alongside source as `*.test.tsx`/`*.test.ts`; e2e tests (Playwright) live in `frontend/tests/`. Don't put Playwright specs under `src/` — vitest's `include` is `src/**/*.{test,spec}.{ts,tsx}` and explicitly excludes `tests/`. + +### Backend (`backend/`) +```bash +python3 -m venv .venv && source .venv/bin/activate +pip install -r backend/requirements.txt +python3 backend/app.py # runs the current stdlib server on :8000 +``` +Backend tests use `unittest`/FastAPI's `TestClient` (`backend/tests/test_chat.py`) — run with `python -m pytest backend/tests/` or `python -m unittest discover backend/tests`. + +### Local dev (both services) +```bash +scripts/start.sh # creates/activates .venv, starts backend (:8000) and frontend (:3000) in background, logs to /tmp/pm-backend.log and /tmp/pm-frontend.log +scripts/stop.sh # kills uvicorn and next dev processes +``` +Note: `scripts/start.sh` launches the backend with `python3 backend/app.py` (which runs `uvicorn.run()` in-process); `scripts/stop.sh` pkills on `"backend/app.py"` to match that process's actual command line. + +## Architecture notes + +- **Board data model** (`frontend/src/lib/kanban.ts`): a `BoardData` is `{ columns: Column[], cards: Record }` — columns hold ordered `cardIds`, cards are looked up by id. `moveCard(columns, activeId, overId)` is the single pure function handling both same-column reorder and cross-column move logic for drag-and-drop; it's the place to change if drop behavior needs adjusting. It's unit-tested directly in `kanban.test.ts` — prefer testing move logic there over through the DnD component. +- **Drag and drop**: `@dnd-kit/core` in `KanbanBoard.tsx` — `DndContext` wraps the columns, `handleDragEnd` calls `moveCard` and replaces `board.columns`. `KanbanCardPreview` renders the `DragOverlay` ghost. +- **Chat → board update contract**: `ChatPanel` posts `{ message, board }` to `/api/chat` and merges back `data.updated_board` into board state via `onBoardUpdate` (passed down from `KanbanBoard`). Any backend chat implementation must honor this shape (`reply: string`, optional `updated_board: BoardData`) for the frontend to pick up AI-driven board edits. +- **Styling**: Tailwind v4 (`@import "tailwindcss"` in `globals.css`, no separate config file) with the brand palette defined as CSS custom properties in `globals.css` (`--accent-yellow`, `--primary-blue`, `--secondary-purple`, `--navy-dark`, `--gray-text`) matching the values in `AGENTS.md`. Reference these vars (`var(--navy-dark)` etc.) rather than hardcoding hex values. +- **E2e hooks**: Playwright tests rely on `data-testid` attributes (`column-`, `card-`) on the Kanban components — preserve these when refactoring markup. +- Each of `backend/`, `scripts/`, and `frontend/` has (or is intended to have) its own `AGENTS.md` describing that subtree; `backend/AGENTS.md` and `scripts/AGENTS.md` are currently placeholders and should be filled in as those areas are built out. + +## Coding standards (from AGENTS.md) + +- Use latest, idiomatic versions of libraries. +- Keep it simple — no over-engineering, no unnecessary defensive programming, no speculative features. +- Keep docs/README minimal. No emojis, ever. +- When debugging, find the root cause with evidence before fixing — don't guess-and-check. diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 00000000..49df1ef1 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,39 @@ +from fastapi import FastAPI +from pydantic import BaseModel +from starlette.responses import JSONResponse + +app = FastAPI() + + +class ChatRequest(BaseModel): + message: str = "" + board: dict = {} + + +@app.get("/api/health") +def health() -> dict: + return {"status": "ok"} + + +@app.post("/api/chat") +def chat(payload: ChatRequest): + message = payload.message.strip() + if not message: + return JSONResponse(status_code=400, content={"reply": "Please enter a message."}) + + if "add" in message.lower() and "card" in message.lower(): + reply = "I can add a card once the board is connected to the backend." + else: + reply = f"Echo: {message}" + + return {"reply": reply} + + +def main() -> None: + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) + + +if __name__ == "__main__": + main() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 00000000..e6a9ad23 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.5 +uvicorn[standard]==0.32.0 +httpx==0.28.1 +pydantic==2.10.2 diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py new file mode 100644 index 00000000..9c0b1d0c --- /dev/null +++ b/backend/tests/test_chat.py @@ -0,0 +1,30 @@ +import unittest + +from fastapi.testclient import TestClient + +from backend.app import app + + +class ChatEndpointTests(unittest.TestCase): + def setUp(self) -> None: + self.client = TestClient(app) + + 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_chat_endpoint_returns_reply(self) -> None: + response = self.client.post( + "/api/chat", + json={ + "message": "Add a card for review", + "board": {"columns": [], "cards": {}}, + }, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("reply", response.json()) + + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e9ffa308..55d8d157 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,14 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + async rewrites() { + return [ + { + source: "/api/:path*", + destination: "http://127.0.0.1:8000/api/:path*", + }, + ]; + }, }; export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c3ef7b4c..e308d1cf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5438,7 +5438,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, diff --git a/frontend/src/components/ChatPanel.test.tsx b/frontend/src/components/ChatPanel.test.tsx new file mode 100644 index 00000000..512be27a --- /dev/null +++ b/frontend/src/components/ChatPanel.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ChatPanel } from "@/components/ChatPanel"; +import { initialData } from "@/lib/kanban"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("ChatPanel", () => { + it("applies an updated board when the backend returns one", async () => { + const onBoardUpdate = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ + reply: "Added card", + updated_board: { + ...initialData, + cards: { + ...initialData.cards, + "card-999": { + id: "card-999", + title: "Review task", + details: "Added from chat", + }, + }, + }, + }), + }) + ); + + render(); + await userEvent.type( + screen.getByPlaceholderText(/try: add a card for review/i), + "add a card for review" + ); + await userEvent.click(screen.getByRole("button", { name: /send/i })); + + expect(onBoardUpdate).toHaveBeenCalled(); + expect(screen.getByText(/added card/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ChatPanel.tsx b/frontend/src/components/ChatPanel.tsx new file mode 100644 index 00000000..01fbba23 --- /dev/null +++ b/frontend/src/components/ChatPanel.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useState } from "react"; +import { SendIcon, SparkleIcon } from "@/components/icons"; +import type { BoardData } from "@/lib/kanban"; + +type ChatPanelProps = { + board: BoardData; + onBoardUpdate?: (updatedBoard: BoardData) => void; +}; + +export const ChatPanel = ({ board, onBoardUpdate }: ChatPanelProps) => { + const [message, setMessage] = useState(""); + const [reply, setReply] = useState("Ask for a simple change to the board."); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!message.trim()) { + return; + } + + setLoading(true); + setReply("Thinking..."); + + try { + const response = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message, board }), + }); + const data = await response.json(); + setReply(data.reply || "No reply received."); + if (data.updated_board && onBoardUpdate) { + onBoardUpdate(data.updated_board); + } + } catch { + setReply("Chat service is unavailable right now."); + } finally { + setLoading(false); + setMessage(""); + } + }; + + return ( +