From 9e289b372d878260aae099cc1f87c6ef8fb8974f Mon Sep 17 00:00:00 2001 From: Juma Alnuaimi Date: Mon, 13 Jul 2026 22:47:26 +0400 Subject: [PATCH 1/5] Added CLAUDE.md --- backend/__init__.py | 0 backend/app.py | 60 ++++++++ backend/requirements.txt | 4 + backend/tests/test_chat.py | 30 ++++ frontend/next.config.ts | 9 +- frontend/package-lock.json | 1 - frontend/src/components/ChatPanel.test.tsx | 44 ++++++ frontend/src/components/ChatPanel.tsx | 72 +++++++++ frontend/src/components/KanbanBoard.test.tsx | 30 +++- frontend/src/components/KanbanBoard.tsx | 151 +++++++++++++++---- requirements.txt | 4 + scripts/start.sh | 16 ++ scripts/stop.sh | 6 + 13 files changed, 391 insertions(+), 36 deletions(-) create mode 100644 backend/__init__.py create mode 100644 backend/app.py create mode 100644 backend/requirements.txt create mode 100644 backend/tests/test_chat.py create mode 100644 frontend/src/components/ChatPanel.test.tsx create mode 100644 frontend/src/components/ChatPanel.tsx create mode 100644 requirements.txt create mode 100644 scripts/start.sh create mode 100644 scripts/stop.sh 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..077aa913 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,60 @@ +import json +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse + + +class BackendHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + parsed = urlparse(self.path) + if parsed.path == "/api/health": + self._send_json(HTTPStatus.OK, {"status": "ok"}) + return + + self._send_json(HTTPStatus.NOT_FOUND, {"error": "not found"}) + + def do_POST(self) -> None: # noqa: N802 + parsed = urlparse(self.path) + if parsed.path != "/api/chat": + self._send_json(HTTPStatus.NOT_FOUND, {"error": "not found"}) + return + + length = int(self.headers.get("Content-Length", "0")) + raw_body = self.rfile.read(length) if length else b"{}" + try: + payload = json.loads(raw_body.decode("utf-8") or "{}") + except json.JSONDecodeError: + payload = {} + + message = str(payload.get("message", "")).strip() + if not message: + self._send_json(HTTPStatus.BAD_REQUEST, {"reply": "Please enter a message."}) + return + + 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}" + + self._send_json(HTTPStatus.OK, {"reply": reply}) + + def _send_json(self, status: HTTPStatus, payload: dict) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args) -> None: # noqa: A003 + return + + +def main() -> None: + server = ThreadingHTTPServer(("0.0.0.0", 8000), BackendHandler) + print("Backend listening on http://0.0.0.0:8000") + server.serve_forever() + + +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..5dfab1b0 --- /dev/null +++ b/frontend/src/components/ChatPanel.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useState } from "react"; + +type ChatPanelProps = { + board: { + columns: Array<{ id: string; title: string; cardIds: string[] }>; + cards: Record; + }; + onBoardUpdate?: (updatedBoard: ChatPanelProps["board"]) => 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 ( +