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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
61 changes: 61 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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<string, Card> }` — 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-<id>`, `card-<id>`) 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.
Empty file added backend/__init__.py
Empty file.
39 changes: 39 additions & 0 deletions backend/app.py
Original file line number Diff line number Diff line change
@@ -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()
4 changes: 4 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
fastapi==0.115.5
uvicorn[standard]==0.32.0
httpx==0.28.1
pydantic==2.10.2
30 changes: 30 additions & 0 deletions backend/tests/test_chat.py
Original file line number Diff line number Diff line change
@@ -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()
9 changes: 8 additions & 1 deletion frontend/next.config.ts
Original file line number Diff line number Diff line change
@@ -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;
1 change: 0 additions & 1 deletion frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 44 additions & 0 deletions frontend/src/components/ChatPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ChatPanel board={initialData} onBoardUpdate={onBoardUpdate} />);
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();
});
});
75 changes: 75 additions & 0 deletions frontend/src/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<aside className="flex h-full flex-col rounded-[24px] border border-[var(--stroke)] bg-white/80 p-6 shadow-[var(--shadow)] backdrop-blur">
<div className="flex items-center gap-2">
<SparkleIcon className="h-5 w-5 text-[var(--secondary-purple)]" />
<h2 className="text-lg font-semibold text-[var(--navy-dark)]">Simple AI chat</h2>
</div>
<p className="mt-2 text-sm text-[var(--gray-text)]">
Send a short request and the backend will respond with a simple reply.
</p>
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="Try: add a card for review"
className="min-h-[96px] w-full rounded-2xl border border-[var(--stroke)] bg-[var(--surface)] px-4 py-3 text-sm outline-none ring-0"
/>
<button
type="submit"
disabled={loading}
className="inline-flex items-center gap-2 rounded-full bg-[var(--secondary-purple)] px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
<SendIcon className="h-4 w-4" />
{loading ? "Sending..." : "Send"}
</button>
</form>
<div className="mt-4 flex-1 overflow-y-auto rounded-2xl border border-[var(--stroke)] bg-[var(--surface)] p-4 text-sm text-[var(--navy-dark)]">
{reply}
</div>
</aside>
);
};
28 changes: 27 additions & 1 deletion frontend/src/components/KanbanBoard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,39 @@ import { KanbanBoard } from "@/components/KanbanBoard";

const getFirstColumn = () => screen.getAllByTestId(/column-/i)[0];

const signIn = async () => {
await userEvent.type(screen.getByLabelText(/username/i), "user");
await userEvent.type(screen.getByLabelText(/password/i), "password");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
};

describe("KanbanBoard", () => {
it("renders five columns", () => {
it("shows a login form before the board is available", () => {
render(<KanbanBoard />);
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.queryAllByTestId(/column-/i)).toHaveLength(0);
});

it("allows a user to sign in and out", async () => {
render(<KanbanBoard />);
await signIn();

expect(screen.getAllByTestId(/column-/i)).toHaveLength(5);

await userEvent.click(screen.getByRole("button", { name: /log out/i }));
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
});

it("renders five columns", async () => {
render(<KanbanBoard />);
await signIn();
expect(screen.getAllByTestId(/column-/i)).toHaveLength(5);
});

it("renames a column", async () => {
render(<KanbanBoard />);
await signIn();
const column = getFirstColumn();
const input = within(column).getByLabelText("Column title");
await userEvent.clear(input);
Expand All @@ -21,6 +46,7 @@ describe("KanbanBoard", () => {

it("adds and removes a card", async () => {
render(<KanbanBoard />);
await signIn();
const column = getFirstColumn();
const addButton = within(column).getByRole("button", {
name: /add a card/i,
Expand Down
Loading