diff --git a/aidd_docs/tasks/2026_08/2026_08_11_kanban-web-view/phase-1.md b/aidd_docs/tasks/2026_08/2026_08_11_kanban-web-view/phase-1.md new file mode 100644 index 000000000..00f5721d3 --- /dev/null +++ b/aidd_docs/tasks/2026_08/2026_08_11_kanban-web-view/phase-1.md @@ -0,0 +1,93 @@ +--- +status: done +--- + +# Instruction: Filesystem watcher adapter + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +kanban/ +├── src/ +│ ├── domain/ +│ │ └── ports/ +│ │ └── ✅ task-document-watcher.ts +│ └── infrastructure/ +│ └── filesystem/ +│ └── ✅ filesystem-task-document-watcher.ts +└── tests/ + └── infrastructure/ + └── ✅ filesystem-task-document-watcher.test.ts +``` + +## User Journey + +```mermaid +flowchart TD + Start["Watcher starts on aidd_docs/"] --> Watch["fs.watch recursive"] + Watch --> Change["File .md created/modified/deleted"] + Change --> Debounce["Debounce 500ms"] + Debounce --> Rescan["Re-run findAll via repository"] + Rescan --> Callback["Emit new TaskGroup[] to onChange listener"] + Callback --> Watch +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Create temp dir with aidd_docs/ and plan.md => ready: 5: system + section Happy path + Start watcher => onChange not called yet: 5: system + Write a new .md file => onChange fires with updated TaskGroup[]: 5: system + Modify frontmatter status => onChange fires with new status: 5: system + section Edge case - rapid changes + Write 3 files within 100ms => onChange fires once after debounce: 1: system + section Teardown + Stop watcher => no more callbacks, fs.watch closed: 5: system +``` + +## Tasks to do + +### `1)` Define the watcher port + +> Interface contract for watching task document changes. + +1. Create `task-document-watcher.ts` in `domain/ports/` +2. Export `TaskDocumentWatcher` interface with `start(projectPath: string): void`, `stop(): void`, and `onChange(callback: (groups: TaskGroup[]) => void): void` + +### `2)` Implement the filesystem watcher adapter + +> Adapter that watches `aidd_docs/` and re-scans on changes. + +1. Create `filesystem-task-document-watcher.ts` in `infrastructure/filesystem/` +2. Use `fs.watch` with `{ recursive: true }` on `/` +3. Filter for `.md` file events only +4. Debounce 500ms before re-scanning +5. On debounce trigger, call `taskDocumentRepository.findAll()` then `groupTaskDocumentsByDirectory()`, then invoke the onChange callback with the new TaskGroup[] +6. `stop()` closes the watcher and clears pending timers + +### `3)` Test the watcher + +> Integration test with real filesystem operations. + +1. Create temp directory with `aidd_docs/tasks/` structure +2. Start watcher, write a file, assert onChange fires with correct data +3. Test debounce: multiple rapid writes produce a single callback +4. Test stop: no callbacks after stop + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ---------------------------------------------------------------------------- | +| 1 | `TaskDocumentWatcher` interface exists with start, stop, onChange methods | +| 2 | Writing a .md file under the watched dir triggers onChange within 1s | +| 2 | Rapid successive writes produce a single onChange call after debounce | +| 2 | Calling stop() prevents further callbacks and releases the fs.watch handle | +| 3 | All tests pass with `vitest run` | diff --git a/aidd_docs/tasks/2026_08/2026_08_11_kanban-web-view/phase-2.md b/aidd_docs/tasks/2026_08/2026_08_11_kanban-web-view/phase-2.md new file mode 100644 index 000000000..968bf994f --- /dev/null +++ b/aidd_docs/tasks/2026_08/2026_08_11_kanban-web-view/phase-2.md @@ -0,0 +1,106 @@ +--- +status: done +--- + +# Instruction: HTTP server and SSE streaming + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +kanban/ +├── src/ +│ └── presentation/ +│ └── web/ +│ ├── ✅ http-server.ts +│ └── ✅ sse-manager.ts +└── tests/ + └── presentation/ + └── ✅ http-server.test.ts +``` + +## User Journey + +```mermaid +flowchart TD + Start["Server starts on port 3000"] --> Listen["HTTP listen"] + Listen --> Req{"Request path?"} + Req -->|"GET /"| Serve["Serve index.html"] + Req -->|"GET /styles.css"| CSS["Serve styles.css"] + Req -->|"GET /app.js"| JS["Serve app.js"] + Req -->|"GET /api/tasks"| REST["JSON: current TaskGroup[]"] + Req -->|"GET /events"| SSE["Open SSE connection"] + SSE --> Push["On watcher onChange => broadcast to all SSE clients"] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Start server on random port with mock data => listening: 5: system + section Happy path + GET / => 200 with HTML content-type: 5: system + GET /api/tasks => 200 with JSON array: 5: system + GET /events => 200 with text/event-stream, connection stays open: 5: system + Trigger data change => SSE client receives event with TaskGroup[] payload: 5: system + section Edge case - port taken + Start on occupied port => server picks next available port: 1: system + section Edge case - SSE disconnect + Client disconnects => server removes from broadcast list, no crash: 1: system + section Teardown + Close server => all connections dropped, port freed: 5: system +``` + +## Tasks to do + +### `1)` Build the SSE manager + +> Manage SSE client connections and broadcast events. + +1. Create `sse-manager.ts` in `presentation/web/` +2. `addClient(res: ServerResponse)`: set SSE headers (`text/event-stream`, `no-cache`, `keep-alive`), push to client list, remove on `close` event +3. `broadcast(data: TaskGroup[])`: write `data: JSON\n\n` to every connected client +4. `closeAll()`: end every response, clear list + +### `2)` Build the HTTP server + +> Lightweight Node native HTTP server serving the frontend and API. + +1. Create `http-server.ts` in `presentation/web/` +2. Export `KanbanWebServer` class taking constructor deps: `port`, `projectPath`, `docsDirectoryName`, `taskDocumentRepository`, `taskDocumentWatcher` +3. Route `GET /` => serve index.html (imported as string via tsup text loader) +4. Route `GET /styles.css` => serve CSS +5. Route `GET /app.js` => serve JS +6. Route `GET /api/tasks` => scan and return TaskGroup[] as JSON +7. Route `GET /events` => register SSE client via SseManager +8. On watcher onChange => `sseManager.broadcast(newGroups)` +9. `start()`: create http server, start watcher, listen. Print URL to output +10. `stop()`: stop watcher, close all SSE clients, close server + +### `3)` Test the server + +> Integration test with real HTTP requests. + +1. Start server on port 0 (random) +2. Test GET / returns HTML +3. Test GET /api/tasks returns valid JSON +4. Test GET /events opens SSE stream +5. Test broadcast sends data to connected SSE client +6. Test server stops cleanly + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | -------------------------------------------------------------------------------- | +| 1 | SSE clients receive JSON-encoded TaskGroup[] on broadcast | +| 1 | Disconnected clients are removed without crashing the server | +| 2 | GET / returns 200 with content-type text/html | +| 2 | GET /api/tasks returns 200 with content-type application/json and a valid array | +| 2 | GET /events returns 200 with content-type text/event-stream | +| 2 | Server prints its URL to the output channel on start | +| 3 | All tests pass with `vitest run` | diff --git a/aidd_docs/tasks/2026_08/2026_08_11_kanban-web-view/phase-3.md b/aidd_docs/tasks/2026_08/2026_08_11_kanban-web-view/phase-3.md new file mode 100644 index 000000000..2446da893 --- /dev/null +++ b/aidd_docs/tasks/2026_08/2026_08_11_kanban-web-view/phase-3.md @@ -0,0 +1,140 @@ +--- +status: done +--- + +# Instruction: Frontend kanban board + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +kanban/ +└── src/ + └── presentation/ + └── web/ + └── frontend/ + ├── ✅ index.html + ├── ✅ styles.css + └── ✅ app.js +``` + +## User Journey + +```mermaid +flowchart TD + Open["Browser opens localhost:3000"] --> Load["Fetch GET /api/tasks"] + Load --> Render["Render 5 columns by ProgressStatus"] + Render --> Connect["Open EventSource /events"] + Connect --> Wait["Wait for SSE events"] + Wait --> Update["Receive new TaskGroup[]"] + Update --> Rerender["Re-render columns with new data"] + Rerender --> Wait +``` + +## Wireframe + +```txt +┌──────────────────────────────────────────────────────────────────┐ +│ (1) Header: "aidd kanban" · project path · connection status │ +├────────────┬────────────┬────────────┬────────────┬─────────────┤ +│ (2) TODO │ (3) IN │ (4) DONE │ (5) BLOCKED│ (6) UNKNOWN │ +│ │ PROGRESS │ │ │ │ +│ ┌────────┐ │ ┌────────┐ │ ┌────────┐ │ │ │ +│ │(7) Card│ │ │ Card │ │ │ Card │ │ │ │ +│ │ name │ │ │ name │ │ │ name │ │ │ │ +│ │ status │ │ │ status │ │ │ status │ │ │ │ +│ │ 2/4 sub│ │ │ 1/3 sub│ │ │ 3/3 sub│ │ │ │ +│ └────────┘ │ └────────┘ │ └────────┘ │ │ │ +│ ┌────────┐ │ │ │ │ │ +│ │ Card │ │ │ │ │ │ +│ └────────┘ │ │ │ │ │ +├────────────┴────────────┴────────────┴────────────┴─────────────┤ +│ (8) Footer: task count · last update timestamp │ +└──────────────────────────────────────────────────────────────────┘ +``` + +1. Header: title, project path, green/red dot for SSE connection state +2. TODO: cards with ProgressStatus `todo` +3. IN PROGRESS: cards with ProgressStatus `in-progress` +4. DONE: cards with ProgressStatus `done` +5. BLOCKED: cards with ProgressStatus `blocked` +6. UNKNOWN: cards with ProgressStatus `unknown`, column hidden when empty +7. Card: TaskGroup parent name, literal status badge, sub-document count with done ratio +8. Footer: total task count, last-updated timestamp + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Start server with fixture data => browser opens: 5: browser + section Happy path + Page loads => 5 columns visible with cards in correct columns: 5: browser + Modify a plan.md frontmatter status => card moves to new column within 1s: 5: browser + section Edge case - empty project + No aidd_docs/ => board shows empty state message: 1: browser + section Edge case - SSE reconnect + Kill and restart server => board reconnects and refreshes: 1: browser +``` + +## Tasks to do + +### `1)` Build index.html + +> Single-page HTML shell with header, board container, footer. + +1. Create `index.html` in `presentation/web/frontend/` +2. Semantic structure: header, main (board), footer +3. Link styles.css, defer app.js +4. Meta viewport for responsive layout + +### `2)` Build styles.css + +> Dark-theme-first CSS for the kanban board. + +1. Create `styles.css` in `presentation/web/frontend/` +2. CSS custom properties for colors (dark theme default, `prefers-color-scheme: light` override) +3. Flexbox layout: header fixed top, columns flex-row equal-width, cards as column items +4. Card styles: border, padding, name bold, status as colored badge, sub-doc count dimmed +5. Column header: uppercase label, item count badge +6. Connection indicator: green/red dot in header +7. Responsive: columns stack vertically below 640px + +### `3)` Build app.js + +> Vanilla JS: fetch initial data, connect SSE, render and update the board. + +1. Create `app.js` in `presentation/web/frontend/` +2. On load: `fetch('/api/tasks')`, render columns +3. `renderBoard(taskGroups)`: clear board, for each ProgressStatus create column, place cards by parent's progressStatus +4. Card rendering: parent name, status badge (colored by progressStatus), sub-document progress bar ("N/M done") +5. `EventSource('/events')`: on message, parse JSON, call `renderBoard()` +6. Connection status: update header dot on EventSource open/error events +7. Footer: update task count and "Last updated: HH:MM:SS" +8. Hide UNKNOWN column when it has zero cards + +### `4)` Manual browser verification + +> Confirm the board renders and updates live. + +1. Start the server with the framework's own `aidd_docs/tasks/` +2. Verify columns display correctly in the browser +3. Edit a `plan.md` frontmatter status, confirm the card moves within 1s +4. Check dark and light themes + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------------------------------------------------------------------------ | +| 1 | index.html loads without console errors in a browser | +| 2 | Cards are visually grouped in columns, readable on a 1280px-wide screen | +| 2 | Light theme activates when OS preference is light | +| 3 | On page load, columns reflect the current TaskGroup data from /api/tasks | +| 3 | When a .md file is modified, the board updates within 1s without page reload | +| 3 | SSE disconnect shows a red indicator; reconnect restores the green dot | +| 3 | UNKNOWN column is hidden when no task has unknown progress status | +| 4 | Visual check passes on the framework's own aidd_docs/tasks/ data | diff --git a/aidd_docs/tasks/2026_08/2026_08_11_kanban-web-view/phase-4.md b/aidd_docs/tasks/2026_08/2026_08_11_kanban-web-view/phase-4.md new file mode 100644 index 000000000..2e4789cac --- /dev/null +++ b/aidd_docs/tasks/2026_08/2026_08_11_kanban-web-view/phase-4.md @@ -0,0 +1,107 @@ +--- +status: done +--- + +# Instruction: CLI command and bundling + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +kanban/ +└── src/ + └── presentation/ + └── commands/ + └── ✅ web-command.ts + +cli/ +├── src/ +│ └── application/ +│ └── commands/ +│ └── ✏️ kanban.ts +└── ✏️ tsup.config.ts +``` + +## User Journey + +```mermaid +flowchart TD + User["aidd kanban web ."] --> CLI["CLI parses args"] + CLI --> Deps["Inject deps via KanbanCommandDeps"] + Deps --> Server["KanbanWebServer.start()"] + Server --> Watcher["Watcher starts on aidd_docs/"] + Server --> Listen["HTTP server listens on port"] + Listen --> Print["Print: Kanban board at http://localhost:3000"] + Print --> Open["Browser opens automatically"] + Open --> Wait["Server runs until Ctrl+C"] + Wait --> Stop["SIGINT => server.stop()"] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Build CLI with pnpm build => dist/cli.js exists: 5: cli + section Happy path + Run aidd kanban web . => server starts, URL printed: 5: cli + Open printed URL in browser => kanban board loads: 5: browser + Ctrl+C => server stops cleanly, process exits 0: 5: cli + section Edge case - port flag + Run aidd kanban web . --port 8080 => server starts on 8080: 1: cli + section Edge case - missing aidd_docs + Run on a dir without aidd_docs/ => board shows empty state, no crash: 1: cli +``` + +## Tasks to do + +### `1)` Create the web command registration + +> Register `aidd kanban web [path]` as a subcommand. + +1. Create `web-command.ts` in `kanban/src/presentation/commands/` +2. Accept `[path]` argument (default: cwd) and `--port ` option (default: 3000) +3. Instantiate `FilesystemTaskDocumentRepository`, `FilesystemTaskDocumentWatcher`, `KanbanWebServer` +4. Call `server.start()` +5. On SIGINT, call `server.stop()` and exit cleanly +6. Open browser automatically via `child_process.exec` (`xdg-open` / `open` / `start` by platform) + +### `2)` Mount the web command in the CLI + +> Wire the new subcommand into the existing kanban command group. + +1. In `cli/src/application/commands/kanban.ts`, import `registerWebCommand` +2. Add `registerWebCommand(kanban.command("web"), deps)` alongside existing interactive and list registrations + +### `3)` Configure tsup to bundle frontend assets + +> Make HTML, CSS, and JS importable as strings. + +1. In `cli/tsup.config.ts`, add `.html` and `.css` to the esbuild `loader` map (same as `.md` => `text`) +2. Verify `pnpm build` succeeds and the frontend files are inlined in `dist/cli.js` + +### `4)` End-to-end smoke test + +> Verify the full chain works from CLI to browser. + +1. `pnpm build` in cli/ +2. Run `node dist/cli.js kanban web .` from the framework root +3. Verify the URL is printed +4. Open the URL, verify the board renders with the framework's own tasks +5. Ctrl+C, verify clean exit + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ---------------------------------------------------------------------------------- | +| 1 | `aidd kanban web .` starts a server and prints the URL | +| 1 | `--port 8080` makes the server listen on 8080 | +| 1 | Ctrl+C stops the server and exits with code 0 | +| 2 | `aidd kanban --help` does not show `web` (hidden like the parent kanban command) | +| 2 | `aidd kanban web --help` shows path argument and port option | +| 3 | `pnpm build` succeeds, `dist/cli.js` contains the HTML string | +| 4 | Browser loads the board from the built CLI binary | diff --git a/aidd_docs/tasks/2026_08/2026_08_11_kanban-web-view/plan.md b/aidd_docs/tasks/2026_08/2026_08_11_kanban-web-view/plan.md new file mode 100644 index 000000000..2ed1b0587 --- /dev/null +++ b/aidd_docs/tasks/2026_08/2026_08_11_kanban-web-view/plan.md @@ -0,0 +1,33 @@ +--- +objective: "aidd kanban --web launches a local HTTP server that streams the project's task documents as a live kanban board in the browser, reusing the existing domain and updating in real time via filesystem watching and SSE." +status: implemented +--- + +# Plan: Kanban web view + +## Overview + +| Field | Value | +| ---------- | ---------------------------------------------------------------------------------------- | +| **Goal** | Add a browser-based kanban board to the existing CLI kanban, with live filesystem updates | +| **Source** | User request + aveleo-dev-ux2 patterns + aidd-kanban.md product brief | + +## Phases + +| # | Phase | File | +| --- | -------------------------------- | ------------------------------ | +| 1 | Filesystem watcher adapter | [`phase-1.md`](./phase-1.md) | +| 2 | HTTP server and SSE streaming | [`phase-2.md`](./phase-2.md) | +| 3 | Frontend kanban board | [`phase-3.md`](./phase-3.md) | +| 4 | CLI command and bundling | [`phase-4.md`](./phase-4.md) | + +## Decisions + +| Decision | Why | +| ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Columns keyed by ProgressStatus (5 buckets), not literal status strings | Literal statuses produce too many columns on real projects (findings.md evidence). ProgressStatus normalizes to a usable set | +| Node native `http` module, no framework | The CLI is a short-lived tool; NestJS or Express adds weight the use case does not need | +| Frontend served as embedded strings (HTML/CSS/JS bundled via tsup text loader) | No separate dev server in production; the CLI must be self-contained after `pnpm build` | +| Vanilla JS for the frontend, no React build step | Avoids a frontend build pipeline inside a CLI tool; scope is one screen with five columns | +| SSE over WebSocket | Unidirectional server-to-browser push is all that is needed; SSE is simpler and needs no library | +| Watcher port in domain, adapter in infrastructure | Follows the existing hexagonal layout; the domain stays I/O-free | diff --git a/aidd_docs/tasks/2026_08/2026_08_29_kanban-hexagonal-refactor/phase-1.md b/aidd_docs/tasks/2026_08/2026_08_29_kanban-hexagonal-refactor/phase-1.md new file mode 100644 index 000000000..fc49183d5 --- /dev/null +++ b/aidd_docs/tasks/2026_08/2026_08_29_kanban-hexagonal-refactor/phase-1.md @@ -0,0 +1,114 @@ +--- +status: done +--- + +# Instruction: Composition root and the command layer + +> Scope: the composition root, the single registration entrypoint, and the `list` / `web` command wiring. The ink view (`status-columns-view.tsx`, `interactive-command.ts`) is phase 2. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +kanban/src/ +├── composition/ +│ └── kanban-runtime.ts ✅ turns KanbanCommandDeps + projectPath into wired collaborators +├── presentation/ +│ ├── register-kanban.ts ✅ single entrypoint: builds the runtime, registers every subcommand +│ ├── kanban-deps.ts ✏️ shape unchanged; consumed by register-kanban (and interactive-command.ts until phase 2) +│ └── commands/ +│ ├── list-command.ts ✏️ takes the runtime; drops `new FilesystemTaskDocumentRepository` +│ └── web-command.ts ✏️ takes the runtime; drops both `new Filesystem*` (server call site untouched here — phase 4) +├── index.ts ✅ minimal public surface now (so the cli consumer keeps compiling); phase 6 tightens the gate around it +kanban/tests/ +├── composition/kanban-runtime.test.ts ✅ runtime lists the fixture docs; watcher factory yields a fresh instance +├── presentation/register-kanban.test.ts ✅ list + web + default action all register and run (mocks frontend-assets + ink render) +├── presentation/commands/list-command.test.ts ✏️ built through register-kanban / injected use case +├── presentation/commands/interactive-command.test.ts ✏️ fix its registerListCommand call for the runtime signature (interactive itself stays phase 2) +└── presentation/commands/web-command.test.ts ✅ new — file does not exist yet +cli/src/ +└── application/commands/kanban.ts ✏️ swap the 3 removed `registerXCommand` imports for one `registerKanban` from `kanban/src/index.js`; deps still built inline here (moves to deps.ts in phase 6) +``` + +> Phase 1 must keep the whole tree compiling: `cli/tsconfig.json` compiles `../kanban/src/**`, and `cli/src/application/commands/kanban.ts` imports the three `registerXCommand` functions this phase removes. Updating that one cli line is in-scope here; the deps relocation and the import-boundary gate are phase 6. + +## Decision: how `projectPath` reaches the runtime + +Today each command resolves the project path itself (`process.cwd()` based, inside `list` / `web` / `interactive`). This phase moves that resolution for **`list` and `web`** to one call site: `registerKanban(program, deps)` resolves `projectPath` once and passes it to `createKanbanRuntime({ deps, projectPath })`. `list` and `web` read `runtime.projectPath` only; they never touch `cwd` again. `interactive` keeps its own `process.cwd()` default until phase 2, where it is rewired onto the runtime alongside the ink view (the two are coupled through `docsDirectoryName`, so they move together). `KanbanCommandDeps` does **not** gain a `projectPath` field — the host still passes only config; path resolution is the feature's concern. + +## User Journey + +```mermaid +flowchart TD + A[Host builds KanbanCommandDeps] --> B[registerKanban resolves projectPath once] + B --> C[createKanbanRuntime wires repository + ListTaskDocumentsUseCase] + C --> D[Runtime exposes: listTaskDocuments, createWatcher, output, projectPath] + D --> E[list command reads runtime.listTaskDocuments] + D --> F[web command reads runtime.listTaskDocuments + createWatcher] + E --> G[list and web import no infrastructure] + F --> G +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Write a fixture aidd_docs tree with known frontmatter => docs ready: 5: system + section Happy path + Call registerKanban on a bare Command => list, web, and the default action are registered: 5: cli + Run the list subcommand => table prints the fixture task groups unchanged: 5: cli + Inspect the runtime => projectPath resolved once, not per command: 5: cli + section Edge case - watcher factory + Call createWatcher twice => two distinct watcher instances: 1: cli + section Teardown + Remove the fixture tree => baseline restored: 5: system +``` + +## Tasks to do + +### `1)` Create `composition/kanban-runtime.ts` + +1. `interface CreateKanbanRuntimeInput { deps: KanbanCommandDeps; projectPath: string }`. +2. `interface KanbanRuntime { listTaskDocuments: ListTaskDocumentsUseCase; createWatcher: () => TaskDocumentWatcher; output: KanbanOutput; projectPath: string }`. +3. `createKanbanRuntime(input): KanbanRuntime` — instantiates `FilesystemTaskDocumentRepository(deps.docsDirectoryName)`, the use case, and closes over `createWatcher = () => new FilesystemTaskDocumentWatcher(deps.docsDirectoryName)`. +4. This is the **only** module under `kanban/src` allowed to import from `infrastructure/`. + +### `2)` Create `presentation/register-kanban.ts` + +1. `registerKanban(program: Command, deps: KanbanCommandDeps): void`. +2. Resolve `projectPath` once and carry it on the runtime for `list` and `web` (move the `process.cwd()` default out of those two commands). `interactive` keeps its own default until phase 2. +3. `const runtime = createKanbanRuntime({ deps, projectPath })`. +4. Register `list` and `web` via `registerXCommand(target, runtime, deps.onError)`; register the default `interactive` action via `registerInteractiveCommand(target, deps)` — its runtime rewiring is phase 2. + +### `3)` Rewire `list-command.ts` and `web-command.ts` + +1. Signature `(program, runtime, onError)`. +2. `list`: use `runtime.listTaskDocuments`, `runtime.projectPath`, `runtime.output`; delete the repository import. Board/DTO changes are later phases — keep current output shape. +3. `web`: use `runtime.listTaskDocuments`, `runtime.createWatcher()`, `runtime.projectPath`, `runtime.output`; delete both `Filesystem*` imports. Keep the existing `new KanbanWebServer({...})` call site verbatim — its move and the lifecycle fix are phase 4. + +### `4)` Create `kanban/src/index.ts` and repoint the cli consumer + +1. `index.ts`: `export { registerKanban } from "./presentation/register-kanban.js";` + `export type { KanbanCommandDeps, KanbanOutput } from "./presentation/kanban-deps.js";` +2. `cli/src/application/commands/kanban.ts`: replace the three `registerXCommand` imports with `import { registerKanban } from "../../../../kanban/src/index.js";`; keep the inline `deps` object; call `registerKanban(program, deps)`. + +### `5)` Update tests + +1. New `kanban-runtime.test.ts` and `register-kanban.test.ts`. `register-kanban.test.ts` must mock `../web/frontend-assets.js` (its module-load `readFileSync` throws ENOENT from source — phase 4 fixes this) and `ink`'s `render`, so wiring is asserted without starting a real server. +2. Rework `list-command.test.ts` and create `web-command.test.ts` (absent today) — build through `registerKanban` (with the mocks above) or inject a fake use case (`{ execute: async () => fixtureGroups }`) and fake watcher. +3. Fix the existing `interactive-command.test.ts`: update its `registerListCommand(program, deps)` call to `(program, runtime, onError)`. Do not rewire `interactive` itself — that is phase 2. +4. Add an import-boundary check (test or `package.json` script): no file under `presentation/` or `application/` imports `infrastructure/` except `composition/kanban-runtime.ts` and `presentation/components/status-columns-view.tsx` (phase 2 removes that last exception). + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------------------------------------------------------------------------------------------ | +| 1 | `createKanbanRuntime` returns a use case that lists the fixture documents and a factory yielding a fresh watcher per call | +| 2 | `registerKanban` on a bare `Command` exposes `list`, `web`, and the default action; `projectPath` is resolved once for `list` and `web` via the runtime (`interactive` still resolves its own until phase 2) | +| 3 | `list` and `web` contain no `new Filesystem*`; `list` output is byte-identical to pre-phase for the fixture | +| 4 | `pnpm --dir cli typecheck` passes: the cli consumer compiles against `kanban/src/index.js` | +| 5 | The import-boundary check passes: only `composition/kanban-runtime.ts` and `presentation/components/status-columns-view.tsx` (phase 2 clears the latter) import `infrastructure/`; `pnpm --dir kanban test` green | diff --git a/aidd_docs/tasks/2026_08/2026_08_29_kanban-hexagonal-refactor/phase-2.md b/aidd_docs/tasks/2026_08/2026_08_29_kanban-hexagonal-refactor/phase-2.md new file mode 100644 index 000000000..09fa88543 --- /dev/null +++ b/aidd_docs/tasks/2026_08/2026_08_29_kanban-hexagonal-refactor/phase-2.md @@ -0,0 +1,79 @@ +--- +status: done +--- + +# Instruction: The ink view receives injected dependencies + +> Scope: remove the adapter instantiated inside the React `useEffect`; the ink view receives the use case as a prop. Rendering behaviour is unchanged, the view stays fetch-once (no watcher — `interactive --live` is phase 7). The fixed-column rework is phase 3. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +kanban/src/presentation/ +├── register-kanban.ts ✏️ register interactive as (target, runtime, deps.onError) — phase 1 passed (target, deps) +├── commands/ +│ └── interactive-command.ts ✏️ takes the runtime; passes runtime.listTaskDocuments as a prop; drops the process.cwd() default +└── components/ + └── status-columns-view.tsx ✏️ use case arrives as a prop; no `new` in useEffect; drop docsDirectoryName prop +kanban/tests/presentation/ +├── commands/interactive-command.test.ts ✏️ built through register-kanban / injected fake use case +└── components/status-columns-view.test.tsx ✏️ inject a fake use case; assert the real filesystem is never touched +``` + +## User Journey + +```mermaid +flowchart TD + A[register-kanban builds the runtime] --> B[interactive-command reads runtime] + B --> C[render StatusColumnsView with the listTaskDocuments prop] + C --> D[useFetchedTaskGroups calls the injected use case once on mount] + D --> F[No React component imports infrastructure] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Fixture aidd_docs tree + a hand-rolled fake use case returning known groups => inputs ready: 5: system + section Happy path + Run the default interactive action => the ink board renders the fixture groups: 5: cli + Render StatusColumnsView with the fake use case => columns render, no filesystem read: 5: cli + section Teardown + Unmount the view => no open handles: 5: cli +``` + +## Tasks to do + +### `1)` Rewire `status-columns-view.tsx` + +1. Props gain `listTaskDocuments: ListTaskDocumentsUseCase`; drop `docsDirectoryName`. +2. `useFetchedTaskGroups` consumes the injected `listTaskDocuments`; delete the `new ListTaskDocumentsUseCase(new FilesystemTaskDocumentRepository(...))` line and the infrastructure imports. +3. The view stays fetch-once — no watcher subscription here (that is `interactive --live`, phase 7). +4. No change to the rendered output (columns, navigation, notices stay as they are — phase 3 removes the horizontal-scroll machinery). + +### `2)` Rewire `interactive-command.ts` + +1. Signature `(program, runtime, onError)`; update `register-kanban.ts` to register it as `registerInteractiveCommand(target, runtime, deps.onError)` (phase 1 still passed `deps`). +2. Replace the `.argument("[path]", "project path", process.cwd())` default with `runtime.projectPath` — `projectPath` is now resolved once for all three commands, closing the phase-1 deferral. +3. Pass `runtime.listTaskDocuments`, `runtime.projectPath`, and current `filters` as props to `StatusColumnsView`. + +### `3)` Update tests + +1. `status-columns-view.test.tsx`: render with a fake use case (`{ execute: async () => fixtureGroups }`); assert no real filesystem access. +2. `interactive-command.test.ts`: drive through `registerKanban`. +3. Extend the phase-1 import-boundary check to cover `components/`. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ---------------------------------------------------------------------------------------------- | +| 1 | `status-columns-view.tsx` imports nothing from `infrastructure/`; given a fake use case it renders without reading disk | +| 2 | The default interactive action renders the same board as before this phase for the fixture project | +| 3 | `interactive` creates no `FSWatcher`; the view fetches once; `pnpm --dir kanban test` green | +| 4 | `projectPath` is resolved once in `register-kanban` for `list`, `web`, and `interactive`; no command keeps a `process.cwd()` default; the import-boundary check now also covers `presentation/components/` | diff --git a/aidd_docs/tasks/2026_08/2026_08_29_kanban-hexagonal-refactor/phase-3.md b/aidd_docs/tasks/2026_08/2026_08_29_kanban-hexagonal-refactor/phase-3.md new file mode 100644 index 000000000..2d96c8174 --- /dev/null +++ b/aidd_docs/tasks/2026_08/2026_08_29_kanban-hexagonal-refactor/phase-3.md @@ -0,0 +1,127 @@ +--- +status: done +--- + +# Instruction: Unified board semantics in the domain + +> Scope: the `list` table and the ink view switch to the five fixed `Board` columns this phase. The web view's payload shape changes here too but its frontend is knowingly out of sync until phase 5 — `list` and `interactive` are the surfaces this phase verifies. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +kanban/src/ +├── domain/ +│ └── models/ +│ ├── progress-status.ts ✏️ add proposed|open|reported → todo; keep unmapped → unknown +│ ├── board.ts ✅ Board, BoardColumn, deriveBoard(taskGroups), PROGRESS_STATUS_LABELS +│ └── task-document.ts ✏️ filePath documented as project-relative +├── application/ +│ └── use-cases/ +│ └── list-task-documents.ts ✏️ returns Board (calls deriveBoard); filters still applied to task groups +├── presentation/web/ +│ └── http-server.ts ✏️ still compiles (SseManager.broadcast takes unknown, JSON.stringify any); the /api/tasks + SSE payload is now a raw Board until phase 4 wraps it in BoardDto — the web frontend realigns in phase 5 +├── infrastructure/ +│ └── filesystem/ +│ └── filesystem-task-document-repository.ts ✏️ filePath = relative(projectPath, entryPath) +├── presentation/ +│ ├── status-grouping.ts ❌ delete — logic absorbed by deriveBoard +│ ├── commands/ +│ │ └── list-command.ts ✏️ render Board columns; drop collectDistinctParentStatuses / groupTaskGroupsByParentStatus +│ └── components/ +│ ├── status-columns-view.tsx ✏️ consume Board; drop horizontal scroll / hidden-column machinery +│ └── status-column.tsx ✏️ header from PROGRESS_STATUS_LABELS, not status.toUpperCase() +kanban/tests/ +├── domain/models/board.test.ts ✅ deriveBoard: fixed order, unknown only when non-empty, mapping table +├── domain/models/progress-status.test.ts ✏️ new raw-status rows +├── application/use-cases/list-task-documents.test.ts ✏️ asserts Board shape +├── infrastructure/filesystem/*.test.ts ✏️ expects relative filePath +├── presentation/status-grouping.test.ts ❌ delete +└── presentation/components/*.test.tsx ✏️ Board-driven columns +``` + +## User Journey + +```mermaid +flowchart TD + A[Repository returns TaskDocument with relative filePath] --> B[Use case groups documents by directory] + B --> C[deriveProgressStatus maps each raw status to a bucket] + C --> D[deriveBoard places every group in one of five columns] + D --> E{unknown column empty?} + E -- yes --> F[Board exposes four columns] + E -- no --> G[Board exposes five columns] + F --> H[CLI table, ink view, use case callers all read the same Board] + G --> H +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Fixture docs covering pending, proposed, implemented, blocked, and a bogus status => inputs ready: 5: system + section Happy path + Call deriveBoard on the grouped fixture => columns in todo/in-progress/done/blocked order: 5: system + Run list on the fixture => table shows the same four or five columns with matching counts: 5: cli + Render the ink view on the fixture => same columns, headers from the label map: 5: cli + section Edge case - unknown bucket + All statuses recognised => board omits the unknown column: 1: system + One bogus status present => unknown column appears last with that card: 1: system + section Edge case - paths + Inspect a returned TaskDocument => filePath is relative to the project root: 1: api + section Teardown + Drop the fixture => baseline restored: 5: system +``` + +## Tasks to do + +### `1)` Extend `progress-status.ts` + +1. Add to `RAW_STATUS_TO_PROGRESS_STATUS`: `proposed`, `open`, `reported` → `PROGRESS_STATUS_TODO`. +2. Leave the `?? PROGRESS_STATUS_UNKNOWN` fallback: `superseded`, `cancelled`, and any unlisted value stay `unknown`. +3. No change to `PROGRESS_STATUSES_IN_COLUMN_ORDER`. + +### `2)` Create `domain/models/board.ts` + +1. `interface BoardColumn { progressStatus: ProgressStatus; label: string; taskGroups: TaskGroup[] }`. +2. `interface Board { columns: BoardColumn[] }`. +3. `PROGRESS_STATUS_LABELS: Record` = `{ todo: "TODO", "in-progress": "IN PROGRESS", done: "DONE", blocked: "BLOCKED", unknown: "UNKNOWN" }`. +4. `deriveBoard(taskGroups: TaskGroup[]): Board` — bucket each group by `group.parent.progressStatus`, emit columns in `PROGRESS_STATUSES_IN_COLUMN_ORDER`, drop the `unknown` column when its bucket is empty, keep the other four always. + +### `3)` Use case returns `Board` + +1. `ListTaskDocumentsUseCase.execute` keeps building filtered `TaskGroup[]`, then returns `deriveBoard(groups)`. +2. Update the return type; `shouldIncludeUnknownStatus` still filters groups before `deriveBoard`. + +### `4)` Repository returns relative paths + +1. In `filesystem-task-document-repository.ts`, set `filePath: relative(projectPath, entryPath)` (`node:path` `relative`). +2. Confirm nothing downstream depends on an absolute path (grep `filePath`). + +### `5)` Delete `presentation/status-grouping.ts` and rewire consumers + +1. Remove the file and its test. +2. `list-command.ts`: iterate `board.columns`; `buildStatusColumnTable` takes `Board`. The `--json` branch now serializes the `Board` as-is (the use case no longer returns `TaskGroup[]`); phase 4 replaces that with `BoardDto` and greps for consumers of the old shape. +3. `status-columns-view.tsx`: consume `board.columns`; delete `useColumnNavigation`, `HiddenColumnsNotice`, `computeVisibleColumnCount`, `clampToRange`, the `‹ n/m columns ›` notice. +4. `status-column.tsx`: header text = `column.label`. + +### `6)` Update tests + +1. New `board.test.ts`; extend `progress-status.test.ts`. +2. Rework use-case, repository, list-command, and component tests to the `Board` shape. +3. Full `pnpm --dir kanban test` green. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ----------------------------------------------------------------------------------------------------------- | +| 1 | `deriveProgressStatus("proposed" \| "open" \| "reported")` returns `todo`; `deriveProgressStatus("superseded")` returns `unknown` | +| 2 | `deriveBoard` output lists columns in fixed order, labels from the map, `unknown` present only with ≥1 group | +| 3 | `execute` returns a `Board`; `--all=false` still removes unknown-status groups before derivation | +| 4 | A returned `TaskDocument.filePath` equals the path relative to the project root | +| 5 | `status-grouping.ts` is gone; `list` and the ink view render five fixed columns with no horizontal-scroll UI | +| 6 | `pnpm --dir kanban test` passes with the new and reworked suites | diff --git a/aidd_docs/tasks/2026_08/2026_08_29_kanban-hexagonal-refactor/phase-4.md b/aidd_docs/tasks/2026_08/2026_08_29_kanban-hexagonal-refactor/phase-4.md new file mode 100644 index 000000000..c886c257c --- /dev/null +++ b/aidd_docs/tasks/2026_08/2026_08_29_kanban-hexagonal-refactor/phase-4.md @@ -0,0 +1,125 @@ +--- +status: done +--- + +# Instruction: HTTP transport to infrastructure and board DTO + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +kanban/src/ +├── infrastructure/ +│ └── http/ +│ ├── kanban-web-server.ts ✏️ moved from presentation/web/http-server.ts; serves BoardDto +│ ├── sse-manager.ts ✏️ moved from presentation/web/sse-manager.ts (unchanged logic) +│ └── frontend-assets.ts ✏️ moved from presentation/web/; no readFileSync-on-import +├── presentation/ +│ ├── dto/ +│ │ └── board-dto.ts ✅ BoardDto + toBoardDto(board): the transport contract +│ ├── web/ ❌ delete the folder (frontend/ moves to infrastructure/http/, phase 5 renders from it) +│ └── commands/ +│ ├── web-command.ts ✏️ awaits server.start(); validates --port; passes assets + toBoardDto +│ └── list-command.ts ✏️ --json prints toBoardDto(board) +├── composition/ +│ └── kanban-runtime.ts ✏️ exposes frontend assets + a web-server factory +kanban/src/presentation/web/frontend/ → moves to kanban/src/infrastructure/http/frontend/ (assets read by frontend-assets.ts) +kanban/tests/ +├── infrastructure/http/kanban-web-server.test.ts ✏️ moved from tests/presentation/http-server.test.ts; asserts BoardDto payload +├── presentation/dto/board-dto.test.ts ✅ toBoardDto maps every field, sub counts, relative paths +└── presentation/commands/web-command.test.ts ✏️ rejects a non-numeric --port; awaits start +cli/ +└── tsup.config.ts ✏️ copy frontend from infrastructure/http/frontend/ +``` + +## User Journey + +```mermaid +flowchart TD + A[web command builds runtime] --> B[runtime.createWebServer with useCase + watcher + assets + toBoardDto] + B --> C[KanbanWebServer in infrastructure/http listens] + C --> D[GET /api/tasks => toBoardDto(board) as JSON] + C --> E[watcher change => fetchAndBroadcast => SSE sends BoardDto] + A --> F[await server.start; then openBrowser] + F --> G{port flag numeric?} + G -- no --> H[throw before listen] + G -- yes --> C +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Start the server against a fixture project on port 0 => server listening: 5: system + section Happy path + GET /api/tasks => body is a BoardDto with columns, labels, card counts: 5: api + Touch a fixture .md file => an SSE event carries the updated BoardDto: 5: api + Run list --json => stdout parses as the same BoardDto shape: 5: cli + section Edge case - bad port + web --port abc => command throws a typed error, nothing listens: 1: cli + section Edge case - scan failure + Repository throws => /api/tasks responds 500 with an error body, no domain leak: 1: api + section Teardown + Stop the server and watcher => no open handles: 5: system +``` + +## Tasks to do + +### `1)` Create `presentation/dto/board-dto.ts` + +1. `interface BoardSubCardDto { name: string; status: string; progressStatus: ProgressStatus; path: string }`. +2. `interface BoardCardDto { name; status; type; progressStatus; description; path; subDocuments: BoardSubCardDto[]; doneSubCount: number; totalSubCount: number }`. +3. `interface BoardColumnDto { progressStatus: ProgressStatus; label: string; cards: BoardCardDto[] }`. +4. `interface BoardDto { columns: BoardColumnDto[] }`. +5. `toBoardDto(board: Board): BoardDto` — maps `column.taskGroups` to cards, `parent.filePath` → `path`, counts `subDocuments` with `progressStatus === "done"`. + +### `2)` Move HTTP transport into `infrastructure/http/` + +1. Move `http-server.ts` → `infrastructure/http/kanban-web-server.ts`; move `sse-manager.ts` alongside; fix relative imports. +2. `KanbanWebServerDeps`: replace `filters` passthrough with a `boardProvider: () => Promise` (the server no longer knows the use case or `toBoardDto`), keep `watcher`, `output`, and the three asset strings. +3. `handleApiTasks` and `fetchAndBroadcast` call `boardProvider()` and serialize its result directly. + +### `3)` Rework frontend-asset provision + +1. Move `frontend-assets.ts` and the `frontend/` folder under `infrastructure/http/`. +2. Replace module-load `readFileSync` with a function `readFrontendAssets(): { indexHtml; stylesCss; appJs }` called by the composition root at server-build time. +3. Update `cli/tsup.config.ts` `onSuccess` copy source path to `../kanban/src/infrastructure/http/frontend/`. + +### `4)` Wire through the composition root + +1. `kanban-runtime.ts` gains `createWebServer(port: number): KanbanWebServer` — builds `boardProvider = async () => toBoardDto(await listTaskDocuments.execute(runtime.projectPath, {}))`, calls `createWatcher()`, reads assets via `readFrontendAssets()`, `new`s the server. +2. `runtime.projectPath` already exists (resolved once in `register-kanban`, phase 1) — reuse it, do not re-resolve `cwd` here. + +### `5)` Fix `web-command.ts` lifecycle + +1. Parse `--port`: `const port = Number.parseInt(options.port, 10); if (Number.isNaN(port)) throw` a typed error routed through `onError`. +2. `const actualPort = await runtime.createWebServer(port).start(); openBrowser(...)` — awaited, errors caught. `web-command.ts` no longer calls `runtime.createWatcher()` directly (that moves inside `createWebServer`). +3. Keep the existing `SIGINT` / `SIGTERM` handlers that call `server.stop()`. + +### `6)` `list --json` emits `BoardDto` + +1. Before changing the shape: `grep -rn "kanban.*list.*json\|--json" plugins/ scripts/ cli/` and check `plugins/aidd-*/skills/**` for any consumer of the current `TaskGroup[]` output. The command is hidden/experimental; record what (if anything) reads it in the phase notes. +2. Replace `JSON.stringify(taskGroups)` with `JSON.stringify(toBoardDto(board), null, 2)`. +3. If a consumer exists, update it in this phase or flag it as a follow-up. + +### `7)` Move and update tests + +1. `tests/presentation/http-server.test.ts` → `tests/infrastructure/http/kanban-web-server.test.ts`; assert the `BoardDto` payload and the 500 branch. +2. New `board-dto.test.ts`; update `web-command.test.ts` for the port guard. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | --------------------------------------------------------------------------------------------------- | +| 1 | `toBoardDto` output has no `TaskGroup`/`TaskDocument` reference; `doneSubCount`/`totalSubCount` correct on a mixed fixture | +| 2 | `kanban-web-server.ts` lives under `infrastructure/http/`; it imports no `application/` use case, only `boardProvider` | +| 3 | Importing `frontend-assets.ts` performs no filesystem read; `readFrontendAssets()` returns the three strings | +| 4 | `runtime.createWebServer(0).start()` serves a `BoardDto` at `/api/tasks` | +| 5 | `web --port abc` exits non-zero via the error handler with nothing bound; a valid port opens the board | +| 6 | `list --json` stdout parses to `BoardDto` | +| 7 | `pnpm --dir kanban test` green; the HTTP test sits under `tests/infrastructure/http/` | diff --git a/aidd_docs/tasks/2026_08/2026_08_29_kanban-hexagonal-refactor/phase-5.md b/aidd_docs/tasks/2026_08/2026_08_29_kanban-hexagonal-refactor/phase-5.md new file mode 100644 index 000000000..2fc46db75 --- /dev/null +++ b/aidd_docs/tasks/2026_08/2026_08_29_kanban-hexagonal-refactor/phase-5.md @@ -0,0 +1,134 @@ +--- +status: done +--- + +# Instruction: Project path selection in the web transport + +> Phase 4 froze `boardProvider: () => Promise` and a single seed `projectPath`. +> This phase widens that contract so the running server can be re-pointed at another +> project from the browser, and pins the path when the CLI passed a positional. +> Backend and tests only — the frontend picker is phase 6. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +kanban/src/ +├── domain/ports/ +│ ├── task-document-watcher.ts ✏️ add retarget(projectPath: string): void +│ └── task-document-repository.ts ✏️ add projectExists(projectPath: string): Promise +├── infrastructure/ +│ ├── filesystem/ +│ │ ├── filesystem-task-document-watcher.ts ✏️ implement retarget = stop() then start(path), via a shared private watch helper +│ │ └── filesystem-task-document-repository.ts ✏️ implement projectExists via existsSync(join(path, docsDirectoryName)) +│ └── http/ +│ └── kanban-web-server.ts ✏️ active-path state, pinned flag, projectValidator dep, boardProvider(path), GET+POST /api/project +├── composition/ +│ └── kanban-runtime.ts ✏️ createWebServer(port, { projectPath, pinned }); wire boardProvider(path) + projectValidator +└── presentation/commands/ + └── web-command.ts ✏️ restore .argument("[path]"); pinned = path !== undefined; pass a target object +kanban/tests/ +├── infrastructure/ +│ ├── filesystem-task-document-watcher.test.ts ✏️ retarget swaps the watched directory, onChange survives +│ └── filesystem-task-document-repository.test.ts ✏️ projectExists true with a docs dir, false without +├── infrastructure/http/kanban-web-server.test.ts ✏️ /api/project GET + POST, pin 409, no-project 400, retarget + rebroadcast +├── composition/kanban-runtime.test.ts ✏️ createWebServer new signature; POST switches the served project end to end +└── presentation/commands/web-command.test.ts ✏️ positional pins; target object reaches createWebServer +``` + +## User Journey + +```mermaid +flowchart TD + A[web command] --> B{positional path?} + B -- yes --> C[createWebServer port, path, pinned true] + B -- no --> D[createWebServer port, cwd, pinned false] + C --> E[KanbanWebServer: activePath = seed] + D --> E + E --> F[GET /api/project => activePath + pinned] + E --> G[POST /api/project with path] + G --> H{pinned?} + H -- yes --> I[409 KANBAN_PROJECT_PINNED] + H -- no --> J{projectValidator path} + J -- false --> K[400 KANBAN_PROJECT_NOT_FOUND, activePath unchanged] + J -- true --> L[activePath = path; watcher.retarget path; fetchAndBroadcast] + L --> M[GET /api/tasks now reads the new path] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Build the server against fixture project A, pinned false, on port 0 => listening: 5: system + section Happy path + POST /api/project with fixture project B => watcher retargets and /api/tasks returns B's board and an SSE event carries it: 5: api + section Edge case - no project + A path with no docs directory => POST /api/project => 400 KANBAN_PROJECT_NOT_FOUND, watcher not retargeted, /api/project still reports A: 1: api + section Edge case - pinned server + Server built with pinned true => POST /api/project => 409 KANBAN_PROJECT_PINNED: 1: api + section Edge case - positional wiring + aidd kanban web /some/dir => command calls createWebServer with { projectPath: /some/dir, pinned: true }: 1: cli + section Teardown + Stop the server => watcher stopped, no open handles: 5: system +``` + +## Tasks to do + +### `1)` Extend the watcher port with `retarget` + +> Switching project moves the file watch without losing the subscription. + +1. `task-document-watcher.ts`: add `retarget(projectPath: string): void` to the interface. +2. `filesystem-task-document-watcher.ts`: extract the `watch(...)` body of `start` into a private `watchDirectory(projectPath)`; `start` and `retarget` both call it. `retarget` first runs the existing `stop()` (clears debounce, closes the FSWatcher), then `watchDirectory(projectPath)`. `this.callback` is untouched, so `onChange` survives. + +### `2)` Extend the repository port with `projectExists` + +> The picker checks a path is a project before anything is re-pointed. + +1. `task-document-repository.ts`: add `projectExists(projectPath: string): Promise`. +2. `filesystem-task-document-repository.ts`: implement as `existsSync(join(projectPath, this.docsDirectoryName))`. No scan, no read. + +### `3)` Give `KanbanWebServer` an active path and the project endpoints + +1. `KanbanWebServerDeps`: keep `projectPath` as the **seed**; add `pinned: boolean`; change `boardProvider` to `(projectPath: string) => Promise`; add `projectValidator: (projectPath: string) => Promise`. +2. Server holds `private activeProjectPath: string` initialised to `deps.projectPath`. `start()` calls `this.deps.watcher.start(this.activeProjectPath)`. `handleApiTasks` and `fetchAndBroadcast` call `this.deps.boardProvider(this.activeProjectPath)`. +3. Widen the `createServer` callback to pass `req`; `handleRequest(req, res)` branches on the pathname then `req.method`. +4. `GET /api/project` => `200 { path: activeProjectPath, pinned: deps.pinned }`. +5. `POST /api/project`: if `deps.pinned` => `409 { error, code: "KANBAN_PROJECT_PINNED" }`. Read and JSON-parse the body; a missing or non-string `path` => `400 { error, code: "KANBAN_PROJECT_INVALID_REQUEST" }`. `await deps.projectValidator(path)` false => `400 { error, code: "KANBAN_PROJECT_NOT_FOUND" }`. Otherwise set `activeProjectPath = path`, `deps.watcher.retarget(path)`, `await this.fetchAndBroadcast()`, respond `200 { path, pinned: false }`. +6. All error bodies carry a translated English message plus the code; no path echoed back beyond what the client sent. + +### `4)` Wire the composition root + +1. `KanbanRuntime.createWebServer` becomes `(port: number, target: { projectPath: string; pinned: boolean }) => KanbanWebServer`. `runtime.projectPath` (the cwd from phase 1) stays for `list` / `interactive` and as the bare-mode default. +2. Build the server with `projectPath: target.projectPath`, `pinned: target.pinned`, `boardProvider: (projectPath) => toBoardDto(await listTaskDocuments.execute(projectPath, {}))`, `projectValidator: (projectPath) => repository.projectExists(projectPath)`. + +### `5)` Restore the positional in `web-command.ts` + +1. `.argument("[path]", "project path to serve")` on the `web` command; action signature `(path: string | undefined, options: WebCommandOptions)`. +2. `const projectPath = path ?? runtime.projectPath;` `const pinned = path !== undefined;` +3. `runtime.createWebServer(port, { projectPath, pinned })`. `--port` parsing and the `SIGINT` / `SIGTERM` handlers are unchanged. + +### `6)` Move and update tests + +1. `filesystem-task-document-watcher.test.ts`: after `start(a)` then `retarget(b)`, a change under `b` fires `onChange` and a change under `a` does not. +2. `filesystem-task-document-repository.test.ts`: `projectExists` true for a dir containing the docs directory, false otherwise. +3. `kanban-web-server.test.ts`: mock watcher gains `retarget: vi.fn()`; `createServer` helper takes `pinned` (default false) and `projectValidator` (default `async () => true`). Add the four `/api/project` cases from the test scope; keep the existing route tests, updating `boardProvider` to accept the path arg. +4. `kanban-runtime.test.ts`: call `createWebServer(0, { projectPath: fixtureA, pinned: false })`; new test POSTs `fixtureB` and asserts `/api/tasks` then serves `fixtureB`'s cards. +5. `web-command.test.ts`: `createWebServer` asserted with `(8080, { projectPath: "/resolved/project/path", pinned: false })`; new test parses `["node","aidd-kanban","/some/dir"]` and asserts `{ projectPath: "/some/dir", pinned: true }`. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | `retarget(path)` closes the previous FS watch and watches `path`; a callback registered before `retarget` still fires | +| 2 | `projectExists(path)` is true only when `/` exists; it performs no file read | +| 3 | `GET /api/project` returns the active path and the pin flag; `POST /api/project` retargets the watcher, rebroadcasts, and shifts `/api/tasks` to the new path | +| 3 | `POST /api/project` with a non-project path => 400 `KANBAN_PROJECT_NOT_FOUND`, watcher untouched, active path unchanged; on a pinned server => 409 `KANBAN_PROJECT_PINNED` | +| 4 | `runtime.createWebServer(0, target)` serves `target.projectPath` and validates switches through `repository.projectExists` | +| 5 | `aidd kanban web ` builds the server `{ projectPath: , pinned: true }`; bare `aidd kanban web` builds `{ projectPath: cwd, pinned: false }` | +| 6 | `pnpm --dir kanban test` green | diff --git a/aidd_docs/tasks/2026_08/2026_08_29_kanban-hexagonal-refactor/phase-6.md b/aidd_docs/tasks/2026_08/2026_08_29_kanban-hexagonal-refactor/phase-6.md new file mode 100644 index 000000000..cea362d1d --- /dev/null +++ b/aidd_docs/tasks/2026_08/2026_08_29_kanban-hexagonal-refactor/phase-6.md @@ -0,0 +1,145 @@ +--- +status: done +--- + +# Instruction: Frontend renders the server board and the project picker + +> The frontend still re-implements the domain (progress order, labels, grouping) and +> reads a fixed project. This phase makes it render straight from `BoardDto.columns` +> and, when the server is not pinned, exposes a free-form project-path field wired to +> the `/api/project` endpoints from phase 5. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +kanban/src/infrastructure/http/frontend/ +├── app.js ✏️ render BoardDto.columns; drop PROGRESS_ORDER / PROGRESS_LABELS / groupByProgress / countDoneSubs; add the project-path control +├── index.html ✏️ add the project-path input + Scan button + error slot in the header +└── styles.css ✏️ styles for the project-path field and its error line +``` + +## User Journey + +```mermaid +flowchart TD + A[Page loads] --> B[GET /api/project => path + pinned] + B --> C{pinned?} + C -- yes --> D[show path as static text, no input] + C -- no --> E[show input pre-filled with path + Scan] + A --> F[GET /api/tasks => BoardDto] + F --> G[renderBoard iterates board.columns in server order] + G --> H[column header = column.label, count = column.cards.length] + H --> I[each card: name, status, doneSubCount/totalSubCount, sub list] + A --> J[EventSource /events => JSON.parse => renderBoard] + E --> K[submit path => POST /api/project] + K --> L{200?} + L -- yes --> M[clear error; board re-renders from the broadcast + a re-fetch] + L -- no --> N[show server error message under the input; board unchanged] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Serve fixture project A with todo, done and one bogus status, pinned false => board has five columns: 5: system + section Happy path + Load the page => columns appear in server order with server labels and counts, and the path input holds project A: 5: browser + section Edge case - switch project + Type project B's path and Scan => the board re-renders for B and edits under B refresh it: 1: browser + section Edge case - bad path + Type a path with no docs directory and Scan => the server's error text shows under the input, the board still shows A: 1: browser + section Edge case - pinned server + Serve with a positional path => the header shows the path as text, no input is rendered: 1: browser + section Edge case - all known statuses + Fixture with no unknowns => four columns, no unknown header: 1: browser + section Teardown + Close the page and stop the server => connection closes: 5: system +``` + +## Wireframe + +```txt +┌ (1) Header ───────────────────────────────────────────── (3) ● connected ─┐ +│ aidd kanban (2) [ /abs/path/to/project ] [ Scan ] │ +│ (8) project not found: no aidd_docs at that path │ +├──────────┬────────────┬─────────┬──────────┬──────────────────────────────┤ +│ (4) TODO 3 │ IN PROG 1 │ DONE 2 │ BLOCKED 0 │ UNKNOWN 1 │ +│ ┌───────┐ │ ┌────────┐│ │ │ ┌───────┐ │ +│ │ (5) │ │ │ name ││ │ │ │ name │ │ +│ │ name │ │ │2/4 done││ │ │ │ ??? │ │ +│ │ stat │ │ │▓▓░░░░ ││ │ │ └───────┘ │ +│ └───────┘ │ └────────┘│ │ │ │ +├────────────┴───────────┴─────────┴───────────┴──────────────────────────────┤ +│ (6) 7 tasks Last updated 12:00:03 │ +└────────────────────────────────────────────────────────────────────────────┘ + + ┌ (7) Detail panel (overlay) ──────────┐ + │ name [ x ] │ + │ Status: pending · plan │ + │ Path: aidd_docs/tasks/x/plan.md │ + │ Sub-tasks (2/4) ▓▓░░ │ + └──────────────────────────────────────┘ +``` + +1. Header: title; connection dot at right. +2. Project-path field: text input pre-filled with the active path + Scan action. Rendered only when the server reports `pinned:false`. +3. Connection indicator: connected / disconnected (unchanged). +4. Columns: from `BoardDto.columns` in server order, server label + card count. +5. Card: parent name, raw status, `doneSubCount/totalSubCount` + bar; opens the panel. +6. Footer: task count + last-updated (unchanged). +7. Detail panel overlay: name, status + type, relative path, sub-task list — from the card DTO. +8. Error line: the server's 400 message for a rejected scan; cleared on success. When `pinned:true`, region 2 is the path as static dimmed text and region 8 never appears. + +## Tasks to do + +### `1)` Render from `BoardDto` + +> The server sends columns already ordered and labelled; the page just draws them. + +1. `loadInitialData` / SSE `onmessage`: payload is `{ columns: [...] }`; call `renderBoard(boardDto.columns)`. +2. `renderBoard(columns)`: iterate `columns` directly; header text = `column.label`, count = `column.cards.length`; for each `card` in `column.cards` call `createCard(card)`. +3. Empty state: when every column has zero cards, show the existing "No task documents found." message. + +### `2)` Delete the re-implemented domain logic + +1. Remove `PROGRESS_ORDER`, `PROGRESS_LABELS`, `groupByProgress`, `countDoneSubs`. +2. Use `card.doneSubCount` / `card.totalSubCount` from the DTO for the progress summary and bar. + +### `3)` Adjust `createCard` / `openPanel` to the card shape + +1. `group.parent.X` => `card.X`; `group.subDocuments` => `card.subDocuments`; `group.parent.filePath` => `card.path`. +2. No visual change intended; keep every class name and DOM id already present. + +### `4)` Add the project-path control + +1. `index.html`: in the header, add ``, a Scan ` + + + + +
+ + disconnected +
+ + +
+ +
+ +
+ +
+ + +
+ + + + diff --git a/kanban/src/infrastructure/http/frontend/styles.css b/kanban/src/infrastructure/http/frontend/styles.css new file mode 100644 index 000000000..f1ce39e61 --- /dev/null +++ b/kanban/src/infrastructure/http/frontend/styles.css @@ -0,0 +1,536 @@ +:root { + --bg: #1a1a2e; + --bg-column: #16213e; + --bg-card: #0f3460; + --text: #e0e0e0; + --text-dim: #8a8a9a; + --border: #2a2a4a; + --accent-todo: #6c757d; + --accent-in-progress: #0d6efd; + --accent-done: #198754; + --accent-blocked: #dc3545; + --accent-unknown: #6c757d; + --dot-connected: #198754; + --dot-disconnected: #dc3545; + --font: "SF Mono", "Cascadia Code", "Fira Code", monospace; +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #f8f9fa; + --bg-column: #e9ecef; + --bg-card: #ffffff; + --text: #212529; + --text-dim: #6c757d; + --border: #ced4da; + } +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font); + font-size: 13px; + line-height: 1.4; + color: var(--text); + background: var(--bg); + display: flex; + flex-direction: column; + min-height: 100vh; +} + +header { + position: sticky; + top: 0; + z-index: 10; + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background: var(--bg); +} + +.header-left { + display: flex; + align-items: center; + gap: 12px; +} + +.header-right { + display: flex; + align-items: center; + gap: 6px; +} + +h1 { + font-size: 15px; + font-weight: 600; +} + +.dimmed { + color: var(--text-dim); + font-size: 12px; +} + +.project-path-picker { + display: flex; + flex-direction: column; + gap: 4px; +} + +.project-path-row { + display: flex; + align-items: center; + gap: 6px; +} + +.project-path-input { + width: 320px; + max-width: 40vw; + padding: 4px 8px; + font-family: var(--font); + font-size: 12px; + color: var(--text); + background: var(--bg-column); + border: 1px solid var(--border); + border-radius: 4px; +} + +.project-path-input:focus { + outline: 1px solid var(--accent-in-progress); +} + +.project-path-scan { + padding: 4px 10px; + font-family: var(--font); + font-size: 12px; + color: var(--text); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; +} + +.project-path-scan:hover { + border-color: var(--text-dim); +} + +.project-path-error { + font-size: 11px; + color: var(--accent-blocked); +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.dot-connected { + background: var(--dot-connected); +} + +.dot-disconnected { + background: var(--dot-disconnected); +} + +main { + flex: 1; + display: flex; + gap: 8px; + padding: 12px 16px; + overflow-x: auto; +} + +.column { + flex: 1; + min-width: 200px; + max-width: 320px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.column-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 10px; + background: var(--bg-column); + border-radius: 6px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.column-count { + font-size: 11px; + font-weight: 400; + color: var(--text-dim); +} + +.card { + padding: 10px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + border-left: 3px solid var(--accent-unknown); +} + +.card[data-progress="todo"] { + border-left-color: var(--accent-todo); +} + +.card[data-progress="in-progress"] { + border-left-color: var(--accent-in-progress); +} + +.card[data-progress="done"] { + border-left-color: var(--accent-done); +} + +.card[data-progress="blocked"] { + border-left-color: var(--accent-blocked); +} + +.card-name { + font-weight: 600; + font-size: 13px; + margin-bottom: 4px; + word-break: break-word; +} + +.card-status { + display: inline-block; + font-size: 10px; + padding: 1px 6px; + border-radius: 3px; + background: var(--bg-column); + color: var(--text-dim); + margin-bottom: 4px; +} + +.card-subs { + font-size: 11px; + color: var(--text-dim); +} + +.card-subs-toggle { + cursor: pointer; + user-select: none; +} + +.card-subs-toggle:hover { + color: var(--text); +} + +.chevron { + font-size: 9px; + margin-right: 4px; + display: inline-block; + transition: transform 0.15s; +} + +.sub-list { + display: none; + list-style: none; + margin-top: 6px; + padding-left: 2px; + border-top: 1px solid var(--border); + padding-top: 6px; +} + +.card-expanded .sub-list { + display: block; +} + +.sub-item { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 0; + font-size: 11px; +} + +.sub-bullet { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + background: var(--accent-unknown); +} + +.sub-item[data-progress="todo"] .sub-bullet { + background: var(--accent-todo); +} + +.sub-item[data-progress="in-progress"] .sub-bullet { + background: var(--accent-in-progress); +} + +.sub-item[data-progress="done"] .sub-bullet { + background: var(--accent-done); +} + +.sub-item[data-progress="blocked"] .sub-bullet { + background: var(--accent-blocked); +} + +.sub-status { + margin-left: auto; + font-size: 9px; + padding: 0 4px; + border-radius: 3px; + background: var(--bg-column); + color: var(--text-dim); + flex-shrink: 0; +} + +.progress-bar { + display: inline-block; + width: 40px; + height: 4px; + background: var(--border); + border-radius: 2px; + margin-left: 4px; + vertical-align: middle; +} + +.progress-fill { + display: block; + height: 100%; + background: var(--accent-done); + border-radius: 2px; +} + +.empty-state { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-dim); + font-size: 14px; +} + +footer { + display: flex; + justify-content: space-between; + padding: 8px 16px; + border-top: 1px solid var(--border); +} + +.panel-overlay { + position: fixed; + inset: 0; + z-index: 20; + display: none; + justify-content: flex-end; + background: rgba(0, 0, 0, 0.4); + opacity: 0; + transition: opacity 0.2s; +} + +.panel-overlay.panel-visible { + display: flex; + opacity: 1; +} + +.panel { + width: 380px; + max-width: 90vw; + height: 100%; + background: var(--bg); + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.2s; + overflow-y: auto; +} + +.panel-overlay.panel-visible .panel { + transform: translateX(0); +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 16px; + border-bottom: 1px solid var(--border); + gap: 12px; +} + +.panel-header h2 { + font-size: 14px; + font-weight: 600; + word-break: break-word; + flex: 1; +} + +.panel-close { + background: none; + border: none; + color: var(--text-dim); + font-size: 20px; + cursor: pointer; + padding: 0; + line-height: 1; + flex-shrink: 0; +} + +.panel-close:hover { + color: var(--text); +} + +.panel-body { + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.panel-section-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-dim); + margin-bottom: 4px; +} + +.panel-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.panel-badge { + font-size: 11px; + padding: 2px 8px; + border-radius: 3px; + background: var(--bg-column); + color: var(--text-dim); +} + +.panel-badge[data-progress="todo"] { + border-left: 3px solid var(--accent-todo); +} + +.panel-badge[data-progress="in-progress"] { + border-left: 3px solid var(--accent-in-progress); +} + +.panel-badge[data-progress="done"] { + border-left: 3px solid var(--accent-done); +} + +.panel-badge[data-progress="blocked"] { + border-left: 3px solid var(--accent-blocked); +} + +.panel-filepath { + font-size: 11px; + color: var(--text-dim); + word-break: break-all; +} + +.panel-progress-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; +} + +.panel-progress-row .progress-bar { + flex: 1; + width: auto; + height: 6px; +} + +.panel-sub-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 2px; +} + +.panel-sub-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 4px; + font-size: 12px; + background: var(--bg-card); + border: 1px solid var(--border); +} + +.panel-sub-bullet { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background: var(--accent-unknown); +} + +.panel-sub-item[data-progress="todo"] .panel-sub-bullet { + background: var(--accent-todo); +} + +.panel-sub-item[data-progress="in-progress"] .panel-sub-bullet { + background: var(--accent-in-progress); +} + +.panel-sub-item[data-progress="done"] .panel-sub-bullet { + background: var(--accent-done); +} + +.panel-sub-item[data-progress="blocked"] .panel-sub-bullet { + background: var(--accent-blocked); +} + +.panel-sub-name { + flex: 1; + word-break: break-word; +} + +.panel-sub-status { + font-size: 10px; + padding: 1px 6px; + border-radius: 3px; + background: var(--bg-column); + color: var(--text-dim); + flex-shrink: 0; +} + +.card { + cursor: pointer; +} + +.card:hover { + border-color: var(--text-dim); +} + +@media (max-width: 640px) { + main { + flex-direction: column; + } + + .column { + max-width: none; + } +} + +[hidden] { + display: none; +} diff --git a/kanban/src/infrastructure/http/kanban-web-server.ts b/kanban/src/infrastructure/http/kanban-web-server.ts new file mode 100644 index 000000000..961d8a313 --- /dev/null +++ b/kanban/src/infrastructure/http/kanban-web-server.ts @@ -0,0 +1,206 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { TaskDocumentWatcher } from "../../domain/ports/task-document-watcher.js"; +import type { BoardDto } from "../../presentation/dto/board-dto.js"; +import { SseManager } from "./sse-manager.js"; + +export interface WebServerOutput { + print(message: string): void; +} + +export interface KanbanWebServerDeps { + port: number; + projectPath: string; + pinned: boolean; + boardProvider: (projectPath: string) => Promise; + projectValidator: (projectPath: string) => Promise; + watcher: TaskDocumentWatcher; + output: WebServerOutput; + indexHtml: string; + stylesCss: string; + appJs: string; +} + +const CONTENT_TYPES: Record = { + html: "text/html; charset=utf-8", + css: "text/css; charset=utf-8", + js: "application/javascript; charset=utf-8", + json: "application/json; charset=utf-8", +}; + +const PROJECT_PINNED_MESSAGE = + "KANBAN_PROJECT_PINNED: the project path is fixed by the command line"; +const PROJECT_INVALID_REQUEST_MESSAGE = + 'KANBAN_PROJECT_INVALID_REQUEST: request body must be a JSON object with a string "path"'; +const PROJECT_NOT_FOUND_MESSAGE = + "KANBAN_PROJECT_NOT_FOUND: no task documents directory at the given path"; + +function serveText(res: ServerResponse, contentType: string, body: string): void { + res.writeHead(200, { "Content-Type": contentType, "Cache-Control": "no-store" }); + res.end(body); +} + +function serveJson(res: ServerResponse, status: number, payload: unknown): void { + res.writeHead(status, { "Content-Type": CONTENT_TYPES.json, "Cache-Control": "no-store" }); + res.end(JSON.stringify(payload)); +} + +function serveNotFound(res: ServerResponse): void { + serveJson(res, 404, { error: "not found" }); +} + +function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + req.on("error", reject); + }); +} + +function parseProjectPath(rawBody: string): string | undefined { + let body: unknown; + + try { + body = JSON.parse(rawBody); + } catch { + return undefined; + } + + if (typeof body !== "object" || body === null || !("path" in body)) { + return undefined; + } + + const candidate = body.path; + + return typeof candidate === "string" && candidate.length > 0 ? candidate : undefined; +} + +export class KanbanWebServer { + private server: Server | undefined; + private readonly sseManager = new SseManager(); + private readonly deps: KanbanWebServerDeps; + private activeProjectPath: string; + + constructor(deps: KanbanWebServerDeps) { + this.deps = deps; + this.activeProjectPath = deps.projectPath; + } + + async start(): Promise { + this.deps.watcher.onChange(() => { + void this.fetchAndBroadcast(); + }); + + this.server = createServer((req, res) => { + void this.handleRequest(req, res); + }); + + this.deps.watcher.start(this.activeProjectPath); + + const server = this.server; + + return new Promise((resolve) => { + server.listen(this.deps.port, () => { + const address = server.address(); + const actualPort = + typeof address === "object" && address !== null ? address.port : this.deps.port; + this.deps.output.print(`Kanban board at http://localhost:${actualPort}`); + resolve(actualPort); + }); + }); + } + + stop(): void { + this.deps.watcher.stop(); + this.sseManager.closeAll(); + + if (this.server !== undefined) { + this.server.close(); + this.server = undefined; + } + } + + private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + const path = (req.url ?? "/").split("?")[0]; + + switch (path) { + case "/": + serveText(res, CONTENT_TYPES.html, this.deps.indexHtml); + return; + case "/styles.css": + serveText(res, CONTENT_TYPES.css, this.deps.stylesCss); + return; + case "/app.js": + serveText(res, CONTENT_TYPES.js, this.deps.appJs); + return; + case "/api/tasks": + await this.handleApiTasks(res); + return; + case "/api/project": + await this.handleApiProject(req, res); + return; + case "/events": + this.sseManager.addClient(res); + return; + default: + serveNotFound(res); + } + } + + private async handleApiTasks(res: ServerResponse): Promise { + try { + const board = await this.deps.boardProvider(this.activeProjectPath); + serveText(res, CONTENT_TYPES.json, JSON.stringify(board)); + } catch { + serveJson(res, 500, { error: "failed to scan task documents" }); + } + } + + private async handleApiProject(req: IncomingMessage, res: ServerResponse): Promise { + if (req.method === "GET") { + serveJson(res, 200, { path: this.activeProjectPath, pinned: this.deps.pinned }); + return; + } + + if (req.method !== "POST") { + serveNotFound(res); + return; + } + + if (this.deps.pinned) { + serveJson(res, 409, { error: PROJECT_PINNED_MESSAGE, code: "KANBAN_PROJECT_PINNED" }); + return; + } + + const requestedPath = parseProjectPath(await readRequestBody(req)); + + if (requestedPath === undefined) { + serveJson(res, 400, { + error: PROJECT_INVALID_REQUEST_MESSAGE, + code: "KANBAN_PROJECT_INVALID_REQUEST", + }); + return; + } + + if (!(await this.deps.projectValidator(requestedPath))) { + serveJson(res, 400, { + error: PROJECT_NOT_FOUND_MESSAGE, + code: "KANBAN_PROJECT_NOT_FOUND", + }); + return; + } + + this.activeProjectPath = requestedPath; + this.deps.watcher.retarget(requestedPath); + await this.fetchAndBroadcast(); + + serveJson(res, 200, { path: requestedPath, pinned: false }); + } + + private fetchAndBroadcast(): Promise { + return this.deps + .boardProvider(this.activeProjectPath) + .then((board) => this.sseManager.broadcast(board)) + .catch(() => {}); + } +} diff --git a/kanban/src/infrastructure/http/sse-manager.ts b/kanban/src/infrastructure/http/sse-manager.ts new file mode 100644 index 000000000..869c7fb97 --- /dev/null +++ b/kanban/src/infrastructure/http/sse-manager.ts @@ -0,0 +1,43 @@ +import type { ServerResponse } from "node:http"; + +const SSE_HEADERS = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": "*", +} as const; + +export class SseManager { + private readonly clients: Set = new Set(); + + addClient(res: ServerResponse): void { + res.writeHead(200, SSE_HEADERS); + res.write("\n"); + + this.clients.add(res); + + res.on("close", () => { + this.clients.delete(res); + }); + } + + broadcast(data: unknown): void { + const payload = `data: ${JSON.stringify(data)}\n\n`; + + for (const client of this.clients) { + client.write(payload); + } + } + + closeAll(): void { + for (const client of this.clients) { + client.end(); + } + + this.clients.clear(); + } + + get clientCount(): number { + return this.clients.size; + } +} diff --git a/kanban/src/presentation/commands/interactive-command.ts b/kanban/src/presentation/commands/interactive-command.ts index 745ffd6c6..c10713fa0 100644 --- a/kanban/src/presentation/commands/interactive-command.ts +++ b/kanban/src/presentation/commands/interactive-command.ts @@ -1,9 +1,9 @@ import { type Command, Option } from "commander"; import { render } from "ink"; import { createElement } from "react"; +import type { KanbanRuntime } from "../../composition/kanban-runtime.js"; import { PROGRESS_STATUSES_IN_COLUMN_ORDER } from "../../domain/models/progress-status.js"; import { StatusColumnsView } from "../components/status-columns-view.js"; -import type { KanbanCommandDeps } from "../kanban-deps.js"; import { toProgressStatusFilter } from "./progress-status-filter.js"; interface InteractiveCommandOptions { @@ -11,30 +11,36 @@ interface InteractiveCommandOptions { status?: string; progress?: string; all?: boolean; + live?: boolean; } function runInteractiveCommand( path: string, options: InteractiveCommandOptions, - deps: KanbanCommandDeps + runtime: KanbanRuntime ): void { render( createElement(StatusColumnsView, { + listTaskDocuments: runtime.listTaskDocuments, projectPath: path, - docsDirectoryName: deps.docsDirectoryName, filters: { type: options.type, status: options.status, progress: toProgressStatusFilter(options.progress), shouldIncludeUnknownStatus: options.all, }, + createWatcher: options.live ? runtime.createWatcher : undefined, }) ); } -export function registerInteractiveCommand(program: Command, deps: KanbanCommandDeps): void { +export function registerInteractiveCommand( + program: Command, + runtime: KanbanRuntime, + onError: (error: unknown) => void +): void { program - .argument("[path]", "project path", process.cwd()) + .argument("[path]", "project path", runtime.projectPath) .option("--type ", "filter by document type") .option("--status ", "filter by document status") .addOption( @@ -43,11 +49,12 @@ export function registerInteractiveCommand(program: Command, deps: KanbanCommand ) ) .option("--all", "include task groups whose parent has no known status") + .option("--live", "refresh the board when a task document changes", false) .action((path: string, options: InteractiveCommandOptions) => { try { - runInteractiveCommand(path, options, deps); + runInteractiveCommand(path, options, runtime); } catch (error) { - deps.onError(error); + onError(error); } }); } diff --git a/kanban/src/presentation/commands/list-command.ts b/kanban/src/presentation/commands/list-command.ts index 1bc3be468..87d49d433 100644 --- a/kanban/src/presentation/commands/list-command.ts +++ b/kanban/src/presentation/commands/list-command.ts @@ -1,14 +1,10 @@ import Table from "cli-table3"; import { type Command, Option } from "commander"; -import { ListTaskDocumentsUseCase } from "../../application/use-cases/list-task-documents.js"; +import type { KanbanRuntime } from "../../composition/kanban-runtime.js"; +import type { Board } from "../../domain/models/board.js"; import { PROGRESS_STATUSES_IN_COLUMN_ORDER } from "../../domain/models/progress-status.js"; import type { TaskGroup } from "../../domain/models/task-group.js"; -import { FilesystemTaskDocumentRepository } from "../../infrastructure/filesystem/filesystem-task-document-repository.js"; -import type { KanbanCommandDeps } from "../kanban-deps.js"; -import { - collectDistinctParentStatuses, - groupTaskGroupsByParentStatus, -} from "../status-grouping.js"; +import { toBoardDto } from "../dto/board-dto.js"; import { toProgressStatusFilter } from "./progress-status-filter.js"; const FALLBACK_TERMINAL_WIDTH = 120; @@ -34,70 +30,47 @@ function resolveTerminalWidth(): number { return process.stdout.columns ?? FALLBACK_TERMINAL_WIDTH; } -function computeVisibleColumnCount(terminalWidth: number, totalColumnCount: number): number { - if (totalColumnCount === 0) { - return 0; - } - - const maxColumnsThatFit = Math.max(1, Math.floor(terminalWidth / MINIMUM_COLUMN_WIDTH)); - return Math.min(totalColumnCount, maxColumnsThatFit); -} - function computeColumnWidths(terminalWidth: number, columnCount: number): number[] { const columnWidth = Math.max(MINIMUM_COLUMN_WIDTH, Math.floor(terminalWidth / columnCount)); return new Array(columnCount).fill(columnWidth); } -function formatHiddenColumnsNotice(hiddenColumnCount: number): string { - return `\n${hiddenColumnCount} status column(s) not shown; widen the terminal to see them.`; -} - -function buildStatusColumnTable(taskGroups: TaskGroup[]): string { - const statuses = collectDistinctParentStatuses(taskGroups); +function buildStatusColumnTable(board: Board): string { + const hasAnyTaskGroup = board.columns.some((column) => column.taskGroups.length > 0); - if (statuses.length === 0) { + if (!hasAnyTaskGroup) { return "No task documents found."; } const terminalWidth = resolveTerminalWidth(); - const visibleColumnCount = computeVisibleColumnCount(terminalWidth, statuses.length); - const visibleStatuses = statuses.slice(0, visibleColumnCount); - const taskGroupsByStatus = groupTaskGroupsByParentStatus(taskGroups, visibleStatuses); - const rowCount = Math.max( - ...visibleStatuses.map((status) => taskGroupsByStatus.get(status)?.length ?? 0) - ); + const rowCount = Math.max(...board.columns.map((column) => column.taskGroups.length)); const table = new Table({ - head: visibleStatuses, - colWidths: computeColumnWidths(terminalWidth, visibleStatuses.length), + head: board.columns.map((column) => column.label), + colWidths: computeColumnWidths(terminalWidth, board.columns.length), wordWrap: true, + wrapOnWordBoundary: false, }); for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) { table.push( - visibleStatuses.map((status) => { - const taskGroup = taskGroupsByStatus.get(status)?.[rowIndex]; + board.columns.map((column) => { + const taskGroup = column.taskGroups[rowIndex]; return taskGroup === undefined ? "" : formatTaskGroupCell(taskGroup); }) ); } - const hiddenColumnCount = statuses.length - visibleStatuses.length; - return hiddenColumnCount > 0 - ? table.toString() + formatHiddenColumnsNotice(hiddenColumnCount) - : table.toString(); + return table.toString(); } async function runListCommand( path: string, options: ListCommandOptions, - deps: KanbanCommandDeps + runtime: KanbanRuntime ): Promise { - const taskDocumentRepository = new FilesystemTaskDocumentRepository(deps.docsDirectoryName); - const listTaskDocumentsUseCase = new ListTaskDocumentsUseCase(taskDocumentRepository); - - const taskGroups = await listTaskDocumentsUseCase.execute(path, { + const board = await runtime.listTaskDocuments.execute(path, { type: options.type, status: options.status, progress: toProgressStatusFilter(options.progress), @@ -105,17 +78,21 @@ async function runListCommand( }); if (options.json === true) { - deps.output.print(JSON.stringify(taskGroups, null, 2)); + runtime.output.print(JSON.stringify(toBoardDto(board), null, 2)); return; } - deps.output.print(buildStatusColumnTable(taskGroups)); + runtime.output.print(buildStatusColumnTable(board)); } -export function registerListCommand(program: Command, deps: KanbanCommandDeps): void { +export function registerListCommand( + program: Command, + runtime: KanbanRuntime, + onError: (error: unknown) => void +): void { program .command("list") - .argument("[path]", "project path", process.cwd()) + .argument("[path]", "project path", runtime.projectPath) .option("--type ", "filter by document type") .option("--status ", "filter by document status") .addOption( @@ -124,12 +101,12 @@ export function registerListCommand(program: Command, deps: KanbanCommandDeps): ) ) .option("--all", "include task groups whose parent has no known status") - .option("--json", "print the task groups as JSON instead of a table") + .option("--json", "print the board as JSON instead of a table") .action(async (path: string, options: ListCommandOptions) => { try { - await runListCommand(path, options, deps); + await runListCommand(path, options, runtime); } catch (error) { - deps.onError(error); + onError(error); } }); } diff --git a/kanban/src/presentation/commands/web-command.ts b/kanban/src/presentation/commands/web-command.ts new file mode 100644 index 000000000..ddc7819f2 --- /dev/null +++ b/kanban/src/presentation/commands/web-command.ts @@ -0,0 +1,80 @@ +import { exec } from "node:child_process"; +import { platform } from "node:os"; +import type { Command } from "commander"; +import type { KanbanRuntime } from "../../composition/kanban-runtime.js"; + +const DEFAULT_PORT = 3000; +const PORT_RADIX = 10; + +interface WebCommandOptions { + port?: string; +} + +class InvalidPortError extends Error { + constructor(rawPort: string) { + super(`KANBAN_INVALID_PORT: "${rawPort}" is not a valid port number`); + this.name = "InvalidPortError"; + } +} + +function parsePort(rawPort: string | undefined): number { + if (rawPort === undefined) { + return DEFAULT_PORT; + } + + const port = Number.parseInt(rawPort, PORT_RADIX); + + if (Number.isNaN(port)) { + throw new InvalidPortError(rawPort); + } + + return port; +} + +function openBrowser(url: string): void { + const os = platform(); + const command = + os === "darwin" ? `open ${url}` : os === "win32" ? `start ${url}` : `xdg-open ${url}`; + + exec(command); +} + +async function runWebCommand( + path: string | undefined, + options: WebCommandOptions, + runtime: KanbanRuntime +): Promise { + const port = parsePort(options.port); + const server = runtime.createWebServer(port, { + projectPath: path ?? runtime.projectPath, + pinned: path !== undefined, + }); + const actualPort = await server.start(); + + openBrowser(`http://localhost:${actualPort}`); + + const stopServer = (): void => { + server.stop(); + process.exit(0); + }; + + process.on("SIGINT", stopServer); + process.on("SIGTERM", stopServer); +} + +export function registerWebCommand( + program: Command, + runtime: KanbanRuntime, + onError: (error: unknown) => void +): void { + program + .argument("[path]", "project path to serve") + .option("--port ", "server port", String(DEFAULT_PORT)) + .action(async (path: string | undefined, options: WebCommandOptions) => { + try { + await runWebCommand(path, options, runtime); + } catch (error) { + onError(error); + } + }); +} diff --git a/kanban/src/presentation/components/status-column.tsx b/kanban/src/presentation/components/status-column.tsx index 29336fe6a..a66e51f02 100644 --- a/kanban/src/presentation/components/status-column.tsx +++ b/kanban/src/presentation/components/status-column.tsx @@ -4,25 +4,22 @@ import type { TaskGroup } from "../../domain/models/task-group.js"; const SUB_DOCUMENT_DETAIL_MIN_WIDTH = 24; export interface StatusColumnProps { - status: string; + label: string; taskGroups: TaskGroup[]; width: number; - selectedFilePath?: string; } -export function StatusColumn({ status, taskGroups, width, selectedFilePath }: StatusColumnProps) { +export function StatusColumn({ label, taskGroups, width }: StatusColumnProps) { const showSubDocuments = width >= SUB_DOCUMENT_DETAIL_MIN_WIDTH; return ( - {status.toUpperCase()} + {label} {taskGroups.map((taskGroup) => ( - - {taskGroup.parent.name} - + {taskGroup.parent.name} {showSubDocuments && taskGroup.subDocuments.map((subDocument) => ( diff --git a/kanban/src/presentation/components/status-columns-view.tsx b/kanban/src/presentation/components/status-columns-view.tsx index f289586b8..3127d3e6d 100644 --- a/kanban/src/presentation/components/status-columns-view.tsx +++ b/kanban/src/presentation/components/status-columns-view.tsx @@ -1,162 +1,91 @@ import { Box, Text, useApp, useInput } from "ink"; -import { type Dispatch, type SetStateAction, useEffect, useState } from "react"; -import { - type ListTaskDocumentsFilters, +import { useCallback, useEffect, useState } from "react"; +import type { + ListTaskDocumentsFilters, ListTaskDocumentsUseCase, } from "../../application/use-cases/list-task-documents.js"; -import type { TaskGroup } from "../../domain/models/task-group.js"; -import { FilesystemTaskDocumentRepository } from "../../infrastructure/filesystem/filesystem-task-document-repository.js"; -import { - collectDistinctParentStatuses, - groupTaskGroupsByParentStatus, -} from "../status-grouping.js"; +import type { Board } from "../../domain/models/board.js"; +import type { TaskDocumentWatcher } from "../../domain/ports/task-document-watcher.js"; import { StatusColumn } from "./status-column.js"; const FALLBACK_TERMINAL_WIDTH = 100; const MIN_COLUMN_WIDTH = 14; const HEADER_TEXT = "aidd kanban — interactive"; -const FOOTER_HINT_TEXT = "↑/↓ select · q quit"; +const FOOTER_HINT_TEXT = "q quit"; const EMPTY_FILTERS: ListTaskDocumentsFilters = {}; +const EMPTY_BOARD: Board = { columns: [] }; export interface StatusColumnsViewProps { + listTaskDocuments: ListTaskDocumentsUseCase; projectPath: string; - docsDirectoryName: string; filters?: ListTaskDocumentsFilters; terminalWidth?: number; + createWatcher?: () => TaskDocumentWatcher; } -interface FetchedTaskGroups { - taskGroups: TaskGroup[]; +interface FetchedBoard { + board: Board; fetchError: string | undefined; } -function computeVisibleColumnCount(terminalWidth: number, totalColumns: number): number { - if (totalColumns === 0) { - return 0; - } - - const maxColumnsThatFit = Math.max(1, Math.floor(terminalWidth / MIN_COLUMN_WIDTH)); - return Math.min(totalColumns, maxColumnsThatFit); -} - -function clampToRange(value: number, length: number): number { - if (length <= 0) { - return 0; - } - - return Math.min(Math.max(value, 0), length - 1); -} - function describeFetchError(error: unknown): string { const reason = error instanceof Error ? error.message : String(error); return `Failed to load task documents: ${reason}`; } -function useFetchedTaskGroups( +function useFetchedBoard( + listTaskDocuments: ListTaskDocumentsUseCase, projectPath: string, - docsDirectoryName: string, - filters: ListTaskDocumentsFilters -): FetchedTaskGroups { - const [taskGroups, setTaskGroups] = useState([]); + filters: ListTaskDocumentsFilters, + createWatcher: (() => TaskDocumentWatcher) | undefined +): FetchedBoard { + const [board, setBoard] = useState(EMPTY_BOARD); const [fetchError, setFetchError] = useState(undefined); - useEffect(() => { - const listTaskDocumentsUseCase = new ListTaskDocumentsUseCase( - new FilesystemTaskDocumentRepository(docsDirectoryName) - ); - - listTaskDocumentsUseCase + const loadBoard = useCallback(() => { + listTaskDocuments .execute(projectPath, filters) - .then(setTaskGroups) + .then(setBoard) .catch((error: unknown) => { setFetchError(describeFetchError(error)); }); - }, [projectPath, docsDirectoryName, filters]); + }, [listTaskDocuments, projectPath, filters]); - return { taskGroups, fetchError }; -} + useEffect(() => { + loadBoard(); + }, [loadBoard]); -function useColumnNavigation(totalColumns: number, visibleColumnCount: number) { - const [columnOffset, setColumnOffset] = useState(0); - const maxColumnOffset = Math.max(0, totalColumns - visibleColumnCount); - const shiftColumnOffset = (delta: number): void => { - setColumnOffset((current) => Math.min(Math.max(current + delta, 0), maxColumnOffset)); - }; + useLiveRefresh(createWatcher, projectPath, loadBoard); - return { columnOffset: Math.min(columnOffset, maxColumnOffset), shiftColumnOffset }; + return { board, fetchError }; } -function useColumnAndSelectionControls( - exit: () => void, - taskGroupCount: number, - shiftColumnOffset: (delta: number) => void, - setSelectedIndex: Dispatch> +function useLiveRefresh( + createWatcher: (() => TaskDocumentWatcher) | undefined, + projectPath: string, + onTaskDocumentChange: () => void ): void { - useInput((input, key) => { - if (input === "q") { - exit(); - return; - } - - if (key.downArrow) { - setSelectedIndex((current) => clampToRange(current + 1, taskGroupCount)); - return; - } - - if (key.upArrow) { - setSelectedIndex((current) => clampToRange(current - 1, taskGroupCount)); - return; - } - - if (key.rightArrow) { - shiftColumnOffset(1); + useEffect(() => { + if (createWatcher === undefined) { return; } + const watcher = createWatcher(); + watcher.onChange(onTaskDocumentChange); + watcher.start(projectPath); + return () => watcher.stop(); + }, [createWatcher, projectPath, onTaskDocumentChange]); +} - if (key.leftArrow) { - shiftColumnOffset(-1); +function useQuitControl(exit: () => void): void { + useInput((input) => { + if (input === "q") { + exit(); } }); } -interface StatusColumnsLayout { - visibleStatuses: string[]; - totalColumnCount: number; - taskGroupsByStatus: Map; - columnWidth: number; - selectedTaskGroup: TaskGroup | undefined; -} - -function useStatusColumnsLayout( - taskGroups: TaskGroup[], - resolvedWidth: number, - exit: () => void -): StatusColumnsLayout { - const statuses = collectDistinctParentStatuses(taskGroups); - const taskGroupsByStatus = groupTaskGroupsByParentStatus(taskGroups, statuses); - const visibleColumnCount = computeVisibleColumnCount(resolvedWidth, statuses.length); - const { columnOffset, shiftColumnOffset } = useColumnNavigation( - statuses.length, - visibleColumnCount - ); - const [selectedIndex, setSelectedIndex] = useState(0); - - useColumnAndSelectionControls(exit, taskGroups.length, shiftColumnOffset, setSelectedIndex); - - const visibleStatuses = statuses.slice(columnOffset, columnOffset + visibleColumnCount); - const columnWidth = Math.max( - MIN_COLUMN_WIDTH, - Math.floor(resolvedWidth / Math.max(visibleColumnCount, 1)) - ); - const selectedTaskGroup = taskGroups[clampToRange(selectedIndex, taskGroups.length)]; - - return { - visibleStatuses, - totalColumnCount: statuses.length, - taskGroupsByStatus, - columnWidth, - selectedTaskGroup, - }; +function resolveColumnWidth(resolvedWidth: number, columnCount: number): number { + return Math.max(MIN_COLUMN_WIDTH, Math.floor(resolvedWidth / Math.max(columnCount, 1))); } function FetchErrorMessage({ message }: { message: string }) { @@ -168,74 +97,55 @@ function FetchErrorMessage({ message }: { message: string }) { ); } -type StatusColumnsRowProps = Omit; - -function StatusColumnsRow({ - visibleStatuses, - taskGroupsByStatus, - columnWidth, - selectedTaskGroup, -}: StatusColumnsRowProps) { - return ( - - {visibleStatuses.map((status) => ( - - ))} - - ); -} - -interface HiddenColumnsNoticeProps { - visibleColumnCount: number; - totalColumnCount: number; -} - -function HiddenColumnsNotice({ visibleColumnCount, totalColumnCount }: HiddenColumnsNoticeProps) { - if (totalColumnCount <= visibleColumnCount) { - return null; - } - - return ( - - ‹ {visibleColumnCount}/{totalColumnCount} columns · → more › - - ); +interface StatusColumnsBoardProps { + board: Board; + columnWidth: number; } -function StatusColumnsBoard(layout: StatusColumnsLayout) { +function StatusColumnsBoard({ board, columnWidth }: StatusColumnsBoardProps) { return ( {HEADER_TEXT} - + + {board.columns.map((column) => ( + + ))} + {FOOTER_HINT_TEXT} - ); } export function StatusColumnsView({ + listTaskDocuments, projectPath, - docsDirectoryName, filters = EMPTY_FILTERS, terminalWidth, + createWatcher, }: StatusColumnsViewProps) { const { exit } = useApp(); - const { taskGroups, fetchError } = useFetchedTaskGroups(projectPath, docsDirectoryName, filters); + useQuitControl(exit); + const { board, fetchError } = useFetchedBoard( + listTaskDocuments, + projectPath, + filters, + createWatcher + ); const resolvedWidth = terminalWidth ?? process.stdout.columns ?? FALLBACK_TERMINAL_WIDTH; - const layout = useStatusColumnsLayout(taskGroups, resolvedWidth, exit); if (fetchError !== undefined) { return ; } - return ; + return ( + + ); } diff --git a/kanban/src/presentation/dto/board-dto.ts b/kanban/src/presentation/dto/board-dto.ts new file mode 100644 index 000000000..7cd142281 --- /dev/null +++ b/kanban/src/presentation/dto/board-dto.ts @@ -0,0 +1,72 @@ +import type { Board } from "../../domain/models/board.js"; +import { PROGRESS_STATUS_DONE, type ProgressStatus } from "../../domain/models/progress-status.js"; +import type { TaskDocument } from "../../domain/models/task-document.js"; +import type { TaskGroup } from "../../domain/models/task-group.js"; + +export interface BoardSubCardDto { + name: string; + status: string; + progressStatus: ProgressStatus; + path: string; +} + +export interface BoardCardDto { + name: string; + status: string; + type: string; + progressStatus: ProgressStatus; + description: string; + path: string; + subDocuments: BoardSubCardDto[]; + doneSubCount: number; + totalSubCount: number; +} + +export interface BoardColumnDto { + progressStatus: ProgressStatus; + label: string; + cards: BoardCardDto[]; +} + +export interface BoardDto { + columns: BoardColumnDto[]; +} + +function toSubCardDto(subDocument: TaskDocument): BoardSubCardDto { + return { + name: subDocument.name, + status: subDocument.status, + progressStatus: subDocument.progressStatus, + path: subDocument.filePath, + }; +} + +function countDoneSubCards(subCards: BoardSubCardDto[]): number { + return subCards.filter((subCard) => subCard.progressStatus === PROGRESS_STATUS_DONE).length; +} + +function toCardDto(taskGroup: TaskGroup): BoardCardDto { + const subDocuments = taskGroup.subDocuments.map(toSubCardDto); + + return { + name: taskGroup.parent.name, + status: taskGroup.parent.status, + type: taskGroup.parent.type, + progressStatus: taskGroup.parent.progressStatus, + description: taskGroup.parent.description, + path: taskGroup.parent.filePath, + subDocuments, + doneSubCount: countDoneSubCards(subDocuments), + totalSubCount: subDocuments.length, + }; +} + +export function toBoardDto(board: Board): BoardDto { + return { + columns: board.columns.map((column) => ({ + progressStatus: column.progressStatus, + label: column.label, + cards: column.taskGroups.map(toCardDto), + })), + }; +} diff --git a/kanban/src/presentation/register-kanban.ts b/kanban/src/presentation/register-kanban.ts new file mode 100644 index 000000000..1155a3569 --- /dev/null +++ b/kanban/src/presentation/register-kanban.ts @@ -0,0 +1,18 @@ +import type { Command } from "commander"; +import { createKanbanRuntime } from "../composition/kanban-runtime.js"; +import { registerInteractiveCommand } from "./commands/interactive-command.js"; +import { registerListCommand } from "./commands/list-command.js"; +import { registerWebCommand } from "./commands/web-command.js"; +import type { KanbanCommandDeps } from "./kanban-deps.js"; + +export function registerKanban(program: Command, deps: KanbanCommandDeps): void { + const runtime = createKanbanRuntime({ deps, projectPath: process.cwd() }); + + registerListCommand(program, runtime, deps.onError); + registerInteractiveCommand( + program.command("interactive", { isDefault: true }), + runtime, + deps.onError + ); + registerWebCommand(program.command("web"), runtime, deps.onError); +} diff --git a/kanban/src/presentation/status-grouping.ts b/kanban/src/presentation/status-grouping.ts deleted file mode 100644 index 18dadf3da..000000000 --- a/kanban/src/presentation/status-grouping.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { TaskGroup } from "../domain/models/task-group.js"; - -const CANONICAL_STATUS_ORDER = ["pending", "in-progress", "implemented", "reviewed", "blocked"]; - -export function collectDistinctParentStatuses(taskGroups: TaskGroup[]): string[] { - const distinctStatuses: string[] = []; - - for (const taskGroup of taskGroups) { - if (!distinctStatuses.includes(taskGroup.parent.status)) { - distinctStatuses.push(taskGroup.parent.status); - } - } - - const canonicalStatuses = CANONICAL_STATUS_ORDER.filter((status) => - distinctStatuses.includes(status) - ); - const nonCanonicalStatuses = distinctStatuses.filter( - (status) => !CANONICAL_STATUS_ORDER.includes(status) - ); - - return [...canonicalStatuses, ...nonCanonicalStatuses]; -} - -export function groupTaskGroupsByParentStatus( - taskGroups: TaskGroup[], - statuses: string[] -): Map { - const taskGroupsByStatus = new Map(statuses.map((status) => [status, []])); - - for (const taskGroup of taskGroups) { - taskGroupsByStatus.get(taskGroup.parent.status)?.push(taskGroup); - } - - return taskGroupsByStatus; -} diff --git a/kanban/tests/application/list-task-documents.test.ts b/kanban/tests/application/list-task-documents.test.ts index 80d2d8d9e..d491c9e3b 100644 --- a/kanban/tests/application/list-task-documents.test.ts +++ b/kanban/tests/application/list-task-documents.test.ts @@ -1,8 +1,18 @@ import { describe, expect, it } from "vitest"; import { ListTaskDocumentsUseCase } from "../../src/application/use-cases/list-task-documents.js"; +import type { Board } from "../../src/domain/models/board.js"; import type { TaskDocument } from "../../src/domain/models/task-document.js"; +import type { TaskGroup } from "../../src/domain/models/task-group.js"; import type { TaskDocumentRepository } from "../../src/domain/ports/task-document-repository.js"; +function collectTaskGroups(board: Board): TaskGroup[] { + return board.columns.flatMap((column) => column.taskGroups); +} + +function collectParents(board: Board): TaskDocument[] { + return collectTaskGroups(board).map((taskGroup) => taskGroup.parent); +} + const PLAN_DOCUMENT: TaskDocument = { name: "FID-560", description: "plan one", @@ -58,62 +68,63 @@ const FIXED_TASK_DOCUMENTS: TaskDocument[] = [ function createFakeRepository(taskDocuments: TaskDocument[]): TaskDocumentRepository { return { findAll: async () => taskDocuments, + projectExists: async () => true, }; } describe("ListTaskDocumentsUseCase", () => { - it("returns one TaskGroup per directory, each carrying its own subDocuments", async () => { + it("places one TaskGroup per directory on the board, each carrying its own subDocuments", async () => { const useCase = new ListTaskDocumentsUseCase(createFakeRepository(FIXED_TASK_DOCUMENTS)); - const result = await useCase.execute("/project", {}); + const board = await useCase.execute("/project", {}); - expect(result).toHaveLength(3); - const planGroup = result.find((taskGroup) => taskGroup.parent === PLAN_DOCUMENT); + expect(collectTaskGroups(board)).toHaveLength(3); + const planGroup = collectTaskGroups(board).find( + (taskGroup) => taskGroup.parent === PLAN_DOCUMENT + ); expect(planGroup?.subDocuments).toEqual([PLAN_PHASE_DOCUMENT]); }); - it("returns only groups whose parent matches the type filter", async () => { + it("keeps only groups whose parent matches the type filter", async () => { const useCase = new ListTaskDocumentsUseCase(createFakeRepository(FIXED_TASK_DOCUMENTS)); - const result = await useCase.execute("/project", { type: "plan" }); + const board = await useCase.execute("/project", { type: "plan" }); - expect(result).toEqual([{ parent: PLAN_DOCUMENT, subDocuments: [PLAN_PHASE_DOCUMENT] }]); + expect(collectTaskGroups(board)).toEqual([ + { parent: PLAN_DOCUMENT, subDocuments: [PLAN_PHASE_DOCUMENT] }, + ]); }); it("excludes a group whose parent status does not match, even when a sub-document would", async () => { const useCase = new ListTaskDocumentsUseCase(createFakeRepository(FIXED_TASK_DOCUMENTS)); - const result = await useCase.execute("/project", { status: "blocked" }); + const board = await useCase.execute("/project", { status: "blocked" }); - expect(result).toEqual([{ parent: DECISION_DOCUMENT, subDocuments: [] }]); + expect(collectTaskGroups(board)).toEqual([{ parent: DECISION_DOCUMENT, subDocuments: [] }]); }); - it("returns every group unchanged when no filters are supplied", async () => { + it("keeps every group on the board when no filters are supplied", async () => { const useCase = new ListTaskDocumentsUseCase(createFakeRepository(FIXED_TASK_DOCUMENTS)); - const result = await useCase.execute("/project", {}); + const board = await useCase.execute("/project", {}); - expect(result.map((taskGroup) => taskGroup.parent)).toEqual([ - PLAN_DOCUMENT, - MASTER_PLAN_DOCUMENT, - DECISION_DOCUMENT, - ]); + expect(collectParents(board)).toEqual([PLAN_DOCUMENT, MASTER_PLAN_DOCUMENT, DECISION_DOCUMENT]); }); it("still narrows by the parent's normalized progress bucket", async () => { const useCase = new ListTaskDocumentsUseCase(createFakeRepository(FIXED_TASK_DOCUMENTS)); - const result = await useCase.execute("/project", { progress: "blocked" }); + const board = await useCase.execute("/project", { progress: "blocked" }); - expect(result).toEqual([{ parent: DECISION_DOCUMENT, subDocuments: [] }]); + expect(collectTaskGroups(board)).toEqual([{ parent: DECISION_DOCUMENT, subDocuments: [] }]); }); it("combines the progress filter with the type filter", async () => { const useCase = new ListTaskDocumentsUseCase(createFakeRepository(FIXED_TASK_DOCUMENTS)); - const result = await useCase.execute("/project", { progress: "todo", type: "master_plan" }); + const board = await useCase.execute("/project", { progress: "todo", type: "master_plan" }); - expect(result).toEqual([]); + expect(collectTaskGroups(board)).toEqual([]); }); it("excludes a group whose parent has no known status by default", async () => { @@ -121,9 +132,10 @@ describe("ListTaskDocumentsUseCase", () => { createFakeRepository([...FIXED_TASK_DOCUMENTS, UNKNOWN_STATUS_DOCUMENT]) ); - const result = await useCase.execute("/project", {}); + const board = await useCase.execute("/project", {}); - expect(result.map((taskGroup) => taskGroup.parent)).not.toContain(UNKNOWN_STATUS_DOCUMENT); + expect(collectParents(board)).not.toContain(UNKNOWN_STATUS_DOCUMENT); + expect(board.columns.some((column) => column.progressStatus === "unknown")).toBe(false); }); it("includes a group whose parent has no known status when shouldIncludeUnknownStatus is true", async () => { @@ -131,9 +143,10 @@ describe("ListTaskDocumentsUseCase", () => { createFakeRepository([...FIXED_TASK_DOCUMENTS, UNKNOWN_STATUS_DOCUMENT]) ); - const result = await useCase.execute("/project", { shouldIncludeUnknownStatus: true }); + const board = await useCase.execute("/project", { shouldIncludeUnknownStatus: true }); - expect(result.map((taskGroup) => taskGroup.parent)).toContain(UNKNOWN_STATUS_DOCUMENT); + expect(collectParents(board)).toContain(UNKNOWN_STATUS_DOCUMENT); + expect(board.columns.at(-1)?.progressStatus).toBe("unknown"); }); it("still applies the type filter alongside shouldIncludeUnknownStatus", async () => { @@ -141,11 +154,13 @@ describe("ListTaskDocumentsUseCase", () => { createFakeRepository([...FIXED_TASK_DOCUMENTS, UNKNOWN_STATUS_DOCUMENT]) ); - const result = await useCase.execute("/project", { + const board = await useCase.execute("/project", { shouldIncludeUnknownStatus: true, type: "plan", }); - expect(result).toEqual([{ parent: PLAN_DOCUMENT, subDocuments: [PLAN_PHASE_DOCUMENT] }]); + expect(collectTaskGroups(board)).toEqual([ + { parent: PLAN_DOCUMENT, subDocuments: [PLAN_PHASE_DOCUMENT] }, + ]); }); }); diff --git a/kanban/tests/architecture/import-boundary.test.ts b/kanban/tests/architecture/import-boundary.test.ts new file mode 100644 index 000000000..ae3624041 --- /dev/null +++ b/kanban/tests/architecture/import-boundary.test.ts @@ -0,0 +1,31 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const SOURCE_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), "../../src"); + +const INFRASTRUCTURE_IMPORT = /from\s+"[^"]*infrastructure\//; + +const ALLOWED_INFRASTRUCTURE_IMPORTERS = ["composition/kanban-runtime.ts"]; + +function listSourceFiles(directory: string): string[] { + return readdirSync(directory, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && /\.tsx?$/.test(entry.name)) + .map((entry) => join(entry.parentPath, entry.name)); +} + +function toPosixRelativePath(absolutePath: string): string { + return relative(SOURCE_DIRECTORY, absolutePath).split(sep).join("/"); +} + +describe("infrastructure import boundary", () => { + it("keeps infrastructure imports inside the composition root", () => { + const importers = listSourceFiles(SOURCE_DIRECTORY) + .filter((filePath) => INFRASTRUCTURE_IMPORT.test(readFileSync(filePath, "utf-8"))) + .map(toPosixRelativePath) + .sort(); + + expect(importers).toEqual(ALLOWED_INFRASTRUCTURE_IMPORTERS); + }); +}); diff --git a/kanban/tests/composition/kanban-runtime.test.ts b/kanban/tests/composition/kanban-runtime.test.ts new file mode 100644 index 000000000..a5e09b3fe --- /dev/null +++ b/kanban/tests/composition/kanban-runtime.test.ts @@ -0,0 +1,127 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DOCS_DIRECTORY_NAME } from "../helpers/docs-directory.js"; +import { createTestKanbanDeps } from "../helpers/test-deps.js"; + +vi.mock("../../src/infrastructure/http/frontend-assets.js", () => ({ + readFrontendAssets: () => ({ indexHtml: "", stylesCss: "", appJs: "" }), +})); + +const { createKanbanRuntime } = await import("../../src/composition/kanban-runtime.js"); + +describe("kanban runtime", () => { + let projectPath: string; + + beforeEach(async () => { + projectPath = await mkdtemp(join(tmpdir(), "aidd-kanban-runtime-")); + const taskDirectory = join(projectPath, DOCS_DIRECTORY_NAME, "task-a"); + await mkdir(taskDirectory, { recursive: true }); + await writeFile( + join(taskDirectory, "plan.md"), + ["---", "name: FID-560", "type: plan", "status: pending", "---", ""].join("\n") + ); + }); + + afterEach(async () => { + await rm(projectPath, { recursive: true, force: true }); + }); + + it("wires a use case that lists the project's task documents", async () => { + const runtime = createKanbanRuntime({ deps: createTestKanbanDeps(), projectPath }); + + const board = await runtime.listTaskDocuments.execute(projectPath, {}); + + const parentNames = board.columns.flatMap((column) => + column.taskGroups.map((taskGroup) => taskGroup.parent.name) + ); + expect(parentNames).toEqual(["FID-560"]); + }); + + it("hands out a fresh watcher instance on every call", () => { + const runtime = createKanbanRuntime({ deps: createTestKanbanDeps(), projectPath }); + + expect(runtime.createWatcher()).not.toBe(runtime.createWatcher()); + }); + + it("carries the project path it was built with", () => { + const runtime = createKanbanRuntime({ deps: createTestKanbanDeps(), projectPath }); + + expect(runtime.projectPath).toBe(projectPath); + }); + + it("serves the project board as a DTO over the web server it builds", async () => { + const runtime = createKanbanRuntime({ deps: createTestKanbanDeps(), projectPath }); + const server = runtime.createWebServer(0, { projectPath, pinned: false }); + const actualPort = await server.start(); + + try { + const response = await fetch(`http://localhost:${actualPort}/api/tasks`); + const board = (await response.json()) as { + columns: { cards: { name: string }[] }[]; + }; + const cardNames = board.columns.flatMap((column) => column.cards.map((card) => card.name)); + + expect(response.status).toBe(200); + expect(cardNames).toEqual(["FID-560"]); + } finally { + server.stop(); + } + }); + + it("switches the served project when a valid path is posted to /api/project", async () => { + const otherProject = await mkdtemp(join(tmpdir(), "aidd-kanban-runtime-other-")); + const otherTask = join(otherProject, DOCS_DIRECTORY_NAME, "task-b"); + await mkdir(otherTask, { recursive: true }); + await writeFile( + join(otherTask, "plan.md"), + ["---", "name: FID-999", "type: plan", "status: done", "---", ""].join("\n") + ); + + const runtime = createKanbanRuntime({ deps: createTestKanbanDeps(), projectPath }); + const server = runtime.createWebServer(0, { projectPath, pinned: false }); + const actualPort = await server.start(); + + try { + const switchResponse = await fetch(`http://localhost:${actualPort}/api/project`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: otherProject }), + }); + expect(switchResponse.status).toBe(200); + + const board = (await (await fetch(`http://localhost:${actualPort}/api/tasks`)).json()) as { + columns: { cards: { name: string }[] }[]; + }; + const cardNames = board.columns.flatMap((column) => column.cards.map((card) => card.name)); + expect(cardNames).toEqual(["FID-999"]); + } finally { + server.stop(); + await rm(otherProject, { recursive: true, force: true }); + } + }); + + it("rejects a posted path that is not a project", async () => { + const notAProject = await mkdtemp(join(tmpdir(), "aidd-kanban-runtime-empty-")); + + const runtime = createKanbanRuntime({ deps: createTestKanbanDeps(), projectPath }); + const server = runtime.createWebServer(0, { projectPath, pinned: false }); + const actualPort = await server.start(); + + try { + const response = await fetch(`http://localhost:${actualPort}/api/project`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: notAProject }), + }); + expect(response.status).toBe(400); + expect((await response.json()) as { code: string }).toMatchObject({ + code: "KANBAN_PROJECT_NOT_FOUND", + }); + } finally { + server.stop(); + await rm(notAProject, { recursive: true, force: true }); + } + }); +}); diff --git a/kanban/tests/domain/models/board.test.ts b/kanban/tests/domain/models/board.test.ts new file mode 100644 index 000000000..52b55f9a1 --- /dev/null +++ b/kanban/tests/domain/models/board.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { deriveBoard } from "../../../src/domain/models/board.js"; +import type { ProgressStatus } from "../../../src/domain/models/progress-status.js"; +import type { TaskGroup } from "../../../src/domain/models/task-group.js"; + +function createTaskGroup(name: string, progressStatus: ProgressStatus): TaskGroup { + return { + parent: { + name, + description: "", + type: "plan", + status: progressStatus, + progressStatus, + filePath: `aidd_docs/${name}/plan.md`, + }, + subDocuments: [], + }; +} + +describe("deriveBoard", () => { + it("emits the four always-on columns in fixed lifecycle order with labels from the map", () => { + const board = deriveBoard([]); + + expect(board.columns.map((column) => column.progressStatus)).toEqual([ + "todo", + "in-progress", + "done", + "blocked", + ]); + expect(board.columns.map((column) => column.label)).toEqual([ + "TODO", + "IN PROGRESS", + "DONE", + "BLOCKED", + ]); + }); + + it("places each task group in the column matching its parent progress status", () => { + const todoGroup = createTaskGroup("alpha", "todo"); + const doneGroup = createTaskGroup("beta", "done"); + + const board = deriveBoard([doneGroup, todoGroup]); + + const columnFor = (progressStatus: ProgressStatus) => + board.columns.find((column) => column.progressStatus === progressStatus); + expect(columnFor("todo")?.taskGroups).toEqual([todoGroup]); + expect(columnFor("done")?.taskGroups).toEqual([doneGroup]); + expect(columnFor("in-progress")?.taskGroups).toEqual([]); + expect(columnFor("blocked")?.taskGroups).toEqual([]); + }); + + it("omits the unknown column when no task group carries an unknown progress status", () => { + const board = deriveBoard([createTaskGroup("alpha", "todo")]); + + expect(board.columns.some((column) => column.progressStatus === "unknown")).toBe(false); + }); + + it("appends the unknown column last when at least one task group has an unknown progress status", () => { + const unknownGroup = createTaskGroup("bogus", "unknown"); + + const board = deriveBoard([unknownGroup]); + + expect(board.columns.map((column) => column.progressStatus)).toEqual([ + "todo", + "in-progress", + "done", + "blocked", + "unknown", + ]); + expect(board.columns.at(-1)?.taskGroups).toEqual([unknownGroup]); + }); +}); diff --git a/kanban/tests/domain/progress-status.test.ts b/kanban/tests/domain/progress-status.test.ts index 538d161bb..ad53c9ad3 100644 --- a/kanban/tests/domain/progress-status.test.ts +++ b/kanban/tests/domain/progress-status.test.ts @@ -10,6 +10,17 @@ describe("deriveProgressStatus", () => { expect(deriveProgressStatus("pending")).toBe("todo"); }); + it("maps the kickoff raw statuses proposed, open and reported to the todo progress bucket", () => { + expect(deriveProgressStatus("proposed")).toBe("todo"); + expect(deriveProgressStatus("open")).toBe("todo"); + expect(deriveProgressStatus("reported")).toBe("todo"); + }); + + it("maps a terminal raw status like superseded to the unknown progress bucket", () => { + expect(deriveProgressStatus("superseded")).toBe("unknown"); + expect(deriveProgressStatus("cancelled")).toBe("unknown"); + }); + it("maps the raw status in-progress to the in-progress progress bucket", () => { expect(deriveProgressStatus("in-progress")).toBe("in-progress"); }); diff --git a/kanban/tests/infrastructure/filesystem-task-document-repository.test.ts b/kanban/tests/infrastructure/filesystem-task-document-repository.test.ts index a8c36fcc3..b8bc465ba 100644 --- a/kanban/tests/infrastructure/filesystem-task-document-repository.test.ts +++ b/kanban/tests/infrastructure/filesystem-task-document-repository.test.ts @@ -43,7 +43,7 @@ describe("FilesystemTaskDocumentRepository", () => { type: "plan", status: "pending", progressStatus: "todo", - filePath: join(aiddDocsPath, "plan.md"), + filePath: join(DOCS_DIRECTORY_NAME, "plan.md"), }, ]); }); @@ -173,4 +173,14 @@ describe("FilesystemTaskDocumentRepository", () => { expect(taskDocuments).toEqual([]); }); + + it("reports projectExists true only when the docs directory is present", async () => { + const repository = new FilesystemTaskDocumentRepository(DOCS_DIRECTORY_NAME); + + expect(await repository.projectExists(projectPath)).toBe(false); + + await mkdir(join(projectPath, DOCS_DIRECTORY_NAME), { recursive: true }); + + expect(await repository.projectExists(projectPath)).toBe(true); + }); }); diff --git a/kanban/tests/infrastructure/filesystem-task-document-watcher.test.ts b/kanban/tests/infrastructure/filesystem-task-document-watcher.test.ts new file mode 100644 index 000000000..7060544bc --- /dev/null +++ b/kanban/tests/infrastructure/filesystem-task-document-watcher.test.ts @@ -0,0 +1,131 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FilesystemTaskDocumentWatcher } from "../../src/infrastructure/filesystem/filesystem-task-document-watcher.js"; +import { DOCS_DIRECTORY_NAME } from "../helpers/docs-directory.js"; + +function waitForCallback(watcher: FilesystemTaskDocumentWatcher, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("onChange not called within timeout")), + timeoutMs + ); + watcher.onChange(() => { + clearTimeout(timer); + resolve(); + }); + }); +} + +describe("FilesystemTaskDocumentWatcher", () => { + let projectPath: string; + let watcher: FilesystemTaskDocumentWatcher; + + beforeEach(async () => { + projectPath = await mkdtemp(join(tmpdir(), "aidd-watcher-")); + }); + + afterEach(async () => { + watcher?.stop(); + await rm(projectPath, { recursive: true, force: true }); + }); + + it("fires onChange when a markdown file is written", async () => { + const docsPath = join(projectPath, DOCS_DIRECTORY_NAME, "tasks", "feature"); + await mkdir(docsPath, { recursive: true }); + + watcher = new FilesystemTaskDocumentWatcher(DOCS_DIRECTORY_NAME); + + const callbackPromise = waitForCallback(watcher); + watcher.start(projectPath); + + await writeFile(join(docsPath, "plan.md"), "---\nstatus: pending\n---\n# Plan"); + + await callbackPromise; + }); + + it("fires onChange when a file is modified", async () => { + const docsPath = join(projectPath, DOCS_DIRECTORY_NAME, "tasks", "feature"); + await mkdir(docsPath, { recursive: true }); + await writeFile(join(docsPath, "plan.md"), "---\nstatus: pending\n---\n# Plan"); + + watcher = new FilesystemTaskDocumentWatcher(DOCS_DIRECTORY_NAME); + + const callbackPromise = waitForCallback(watcher); + watcher.start(projectPath); + + await writeFile(join(docsPath, "plan.md"), "---\nstatus: in-progress\n---\n# Plan"); + + await callbackPromise; + }); + + it("debounces rapid changes into a single callback", async () => { + const docsPath = join(projectPath, DOCS_DIRECTORY_NAME, "tasks", "feature"); + await mkdir(docsPath, { recursive: true }); + + watcher = new FilesystemTaskDocumentWatcher(DOCS_DIRECTORY_NAME); + + const callback = vi.fn(); + watcher.onChange(callback); + watcher.start(projectPath); + + await writeFile(join(docsPath, "a.md"), "---\nstatus: pending\n---\n"); + await writeFile(join(docsPath, "b.md"), "---\nstatus: pending\n---\n"); + await writeFile(join(docsPath, "c.md"), "---\nstatus: pending\n---\n"); + + await new Promise((resolve) => setTimeout(resolve, 1500)); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("does not fire after stop is called", async () => { + const docsPath = join(projectPath, DOCS_DIRECTORY_NAME, "tasks", "feature"); + await mkdir(docsPath, { recursive: true }); + + watcher = new FilesystemTaskDocumentWatcher(DOCS_DIRECTORY_NAME); + + const callback = vi.fn(); + watcher.onChange(callback); + watcher.start(projectPath); + watcher.stop(); + + await writeFile(join(docsPath, "plan.md"), "---\nstatus: pending\n---\n# Plan"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + + expect(callback).not.toHaveBeenCalled(); + }); + + it("does nothing when the docs directory does not exist", () => { + watcher = new FilesystemTaskDocumentWatcher(DOCS_DIRECTORY_NAME); + + expect(() => watcher.start(projectPath)).not.toThrow(); + watcher.stop(); + }); + + it("follows the callback to the new directory after retarget and drops the old one", async () => { + const firstProject = await mkdtemp(join(tmpdir(), "aidd-watcher-a-")); + const secondProject = await mkdtemp(join(tmpdir(), "aidd-watcher-b-")); + const firstDocs = join(firstProject, DOCS_DIRECTORY_NAME, "tasks"); + const secondDocs = join(secondProject, DOCS_DIRECTORY_NAME, "tasks"); + await mkdir(firstDocs, { recursive: true }); + await mkdir(secondDocs, { recursive: true }); + + watcher = new FilesystemTaskDocumentWatcher(DOCS_DIRECTORY_NAME); + const callback = vi.fn(); + watcher.onChange(callback); + watcher.start(firstProject); + watcher.retarget(secondProject); + + await writeFile(join(firstDocs, "old.md"), "---\nstatus: pending\n---\n"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + expect(callback).not.toHaveBeenCalled(); + + await writeFile(join(secondDocs, "new.md"), "---\nstatus: pending\n---\n"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + expect(callback).toHaveBeenCalledTimes(1); + + await rm(firstProject, { recursive: true, force: true }); + await rm(secondProject, { recursive: true, force: true }); + }); +}); diff --git a/kanban/tests/infrastructure/http/frontend-assets.test.ts b/kanban/tests/infrastructure/http/frontend-assets.test.ts new file mode 100644 index 000000000..fdb21b5a8 --- /dev/null +++ b/kanban/tests/infrastructure/http/frontend-assets.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; + +describe("frontend assets", () => { + it("loads the module without reading the filesystem at import time", async () => { + const assetsModule = await import("../../../src/infrastructure/http/frontend-assets.js"); + + expect(typeof assetsModule.readFrontendAssets).toBe("function"); + }); +}); diff --git a/kanban/tests/infrastructure/http/kanban-web-server.test.ts b/kanban/tests/infrastructure/http/kanban-web-server.test.ts new file mode 100644 index 000000000..99e6d5659 --- /dev/null +++ b/kanban/tests/infrastructure/http/kanban-web-server.test.ts @@ -0,0 +1,333 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TaskDocumentWatcher } from "../../../src/domain/ports/task-document-watcher.js"; +import { KanbanWebServer } from "../../../src/infrastructure/http/kanban-web-server.js"; +import type { BoardDto } from "../../../src/presentation/dto/board-dto.js"; + +const SAMPLE_BOARD_DTO: BoardDto = { + columns: [ + { + progressStatus: "todo", + label: "TODO", + cards: [ + { + name: "test-plan", + status: "pending", + type: "plan", + progressStatus: "todo", + description: "a test plan", + path: "aidd_docs/tasks/plan.md", + subDocuments: [], + doneSubCount: 0, + totalSubCount: 0, + }, + ], + }, + ], +}; + +function createMockWatcher(): TaskDocumentWatcher & { triggerChange: () => void } { + let callback: (() => void) | undefined; + + return { + start: vi.fn(), + retarget: vi.fn(), + stop: vi.fn(), + onChange: vi.fn().mockImplementation((cb) => { + callback = cb; + }), + triggerChange() { + callback?.(); + }, + }; +} + +function createServer( + overrides: Partial<{ + boardProvider: (projectPath: string) => Promise; + projectValidator: (projectPath: string) => Promise; + watcher: ReturnType; + pinned: boolean; + }> = {} +): { + server: KanbanWebServer; + watcher: ReturnType; + output: { messages: string[]; print: (msg: string) => void }; +} { + const watcher = overrides.watcher ?? createMockWatcher(); + const boardProvider = overrides.boardProvider ?? (async () => SAMPLE_BOARD_DTO); + const projectValidator = overrides.projectValidator ?? (async () => true); + const output = { + messages: [] as string[], + print(msg: string) { + output.messages.push(msg); + }, + }; + + const server = new KanbanWebServer({ + port: 0, + projectPath: "/tmp/test", + pinned: overrides.pinned ?? false, + boardProvider, + projectValidator, + watcher, + output, + indexHtml: "kanban", + stylesCss: "body { margin: 0; }", + appJs: "console.log('kanban');", + }); + + return { server, watcher, output }; +} + +async function fetchFromServer(port: number, path: string): Promise { + return fetch(`http://localhost:${port}${path}`); +} + +async function readNextSseData(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) { + throw new Error("SSE stream closed before a data frame arrived"); + } + buffer += decoder.decode(value, { stream: true }); + const match = buffer.match(/data: (.*)\n\n/); + if (match?.[1] !== undefined) { + await reader.cancel(); + return match[1]; + } + } +} + +describe("KanbanWebServer", () => { + let server: KanbanWebServer; + let port: number; + + afterEach(() => { + server?.stop(); + }); + + it("serves index.html on GET /", async () => { + const ctx = createServer(); + server = ctx.server; + port = await server.start(); + + const res = await fetchFromServer(port, "/"); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + const body = await res.text(); + expect(body).toContain("kanban"); + }); + + it("serves styles.css on GET /styles.css", async () => { + const ctx = createServer(); + server = ctx.server; + port = await server.start(); + + const res = await fetchFromServer(port, "/styles.css"); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/css"); + }); + + it("serves app.js on GET /app.js", async () => { + const ctx = createServer(); + server = ctx.server; + port = await server.start(); + + const res = await fetchFromServer(port, "/app.js"); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/javascript"); + }); + + it("returns the board DTO as JSON on GET /api/tasks", async () => { + const ctx = createServer(); + server = ctx.server; + port = await server.start(); + + const res = await fetchFromServer(port, "/api/tasks"); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/json"); + const body = (await res.json()) as BoardDto; + const todoColumn = body.columns.find((column) => column.progressStatus === "todo"); + expect(todoColumn?.cards[0]?.name).toBe("test-plan"); + expect(todoColumn?.cards[0]?.totalSubCount).toBe(0); + }); + + it("responds 500 with a generic error body when the board provider throws", async () => { + const ctx = createServer({ + boardProvider: async () => { + throw new Error("scan failed"); + }, + }); + server = ctx.server; + port = await server.start(); + + const res = await fetchFromServer(port, "/api/tasks"); + + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("failed to scan task documents"); + expect(JSON.stringify(body)).not.toContain("scan failed"); + }); + + it("opens an SSE connection on GET /events", async () => { + const ctx = createServer(); + server = ctx.server; + port = await server.start(); + + const res = await fetchFromServer(port, "/events"); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/event-stream"); + res.body?.cancel(); + }); + + it("returns 404 for unknown paths", async () => { + const ctx = createServer(); + server = ctx.server; + port = await server.start(); + + const res = await fetchFromServer(port, "/unknown"); + + expect(res.status).toBe(404); + }); + + it("prints the server URL on start", async () => { + const ctx = createServer(); + server = ctx.server; + port = await server.start(); + + expect(ctx.output.messages.length).toBe(1); + expect(ctx.output.messages[0]).toContain(`http://localhost:${port}`); + }); + + it("starts the watcher on start", async () => { + const ctx = createServer(); + server = ctx.server; + await server.start(); + + expect(ctx.watcher.start).toHaveBeenCalledWith("/tmp/test"); + }); + + it("stops the watcher on stop", async () => { + const ctx = createServer(); + server = ctx.server; + await server.start(); + server.stop(); + + expect(ctx.watcher.stop).toHaveBeenCalled(); + }); + + it("reports the active path and the pin flag on GET /api/project", async () => { + const ctx = createServer({ pinned: true }); + server = ctx.server; + port = await server.start(); + + const res = await fetchFromServer(port, "/api/project"); + const body = (await res.json()) as { path: string; pinned: boolean }; + + expect(res.status).toBe(200); + expect(body).toEqual({ path: "/tmp/test", pinned: true }); + }); + + it("retargets the watcher and shifts the board when POST /api/project switches project", async () => { + const boardByPath: Record = { + "/tmp/test": SAMPLE_BOARD_DTO, + "/tmp/other": { columns: [{ progressStatus: "done", label: "DONE", cards: [] }] }, + }; + const ctx = createServer({ + boardProvider: async (projectPath) => boardByPath[projectPath] ?? SAMPLE_BOARD_DTO, + projectValidator: async (projectPath) => projectPath === "/tmp/other", + }); + server = ctx.server; + port = await server.start(); + + const events = await fetchFromServer(port, "/events"); + if (events.body === null) { + throw new Error("SSE response had no body"); + } + const nextEventData = readNextSseData(events.body); + + const res = await fetch(`http://localhost:${port}/api/project`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "/tmp/other" }), + }); + + expect(res.status).toBe(200); + expect((await res.json()) as { path: string; pinned: boolean }).toEqual({ + path: "/tmp/other", + pinned: false, + }); + expect(ctx.watcher.retarget).toHaveBeenCalledWith("/tmp/other"); + + const broadcastPayload = JSON.parse(await nextEventData) as BoardDto; + expect(broadcastPayload.columns[0]?.progressStatus).toBe("done"); + + const tasks = (await (await fetchFromServer(port, "/api/tasks")).json()) as BoardDto; + expect(tasks.columns[0]?.progressStatus).toBe("done"); + }); + + it("rejects a POST to a non-project path with 400 and leaves the active path untouched", async () => { + const ctx = createServer({ projectValidator: async () => false }); + server = ctx.server; + port = await server.start(); + + const res = await fetch(`http://localhost:${port}/api/project`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "/tmp/not-a-project" }), + }); + const body = (await res.json()) as { error: string; code: string }; + + expect(res.status).toBe(400); + expect(body.code).toBe("KANBAN_PROJECT_NOT_FOUND"); + expect(ctx.watcher.retarget).not.toHaveBeenCalled(); + + const project = (await (await fetchFromServer(port, "/api/project")).json()) as { + path: string; + }; + expect(project.path).toBe("/tmp/test"); + }); + + it("rejects a POST with a missing or non-string path with 400 KANBAN_PROJECT_INVALID_REQUEST", async () => { + const ctx = createServer(); + server = ctx.server; + port = await server.start(); + + const res = await fetch(`http://localhost:${port}/api/project`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: 42 }), + }); + const body = (await res.json()) as { code: string }; + + expect(res.status).toBe(400); + expect(body.code).toBe("KANBAN_PROJECT_INVALID_REQUEST"); + expect(ctx.watcher.retarget).not.toHaveBeenCalled(); + }); + + it("rejects a POST on a pinned server with 409 KANBAN_PROJECT_PINNED", async () => { + const ctx = createServer({ pinned: true }); + server = ctx.server; + port = await server.start(); + + const res = await fetch(`http://localhost:${port}/api/project`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "/tmp/other" }), + }); + const body = (await res.json()) as { code: string }; + + expect(res.status).toBe(409); + expect(body.code).toBe("KANBAN_PROJECT_PINNED"); + expect(ctx.watcher.retarget).not.toHaveBeenCalled(); + }); +}); diff --git a/kanban/tests/presentation/interactive-command.test.ts b/kanban/tests/presentation/commands/interactive-command.test.ts similarity index 65% rename from kanban/tests/presentation/interactive-command.test.ts rename to kanban/tests/presentation/commands/interactive-command.test.ts index 5601fe0c4..b34c817cc 100644 --- a/kanban/tests/presentation/interactive-command.test.ts +++ b/kanban/tests/presentation/commands/interactive-command.test.ts @@ -4,9 +4,10 @@ import { join } from "node:path"; import { Command } from "commander"; import type { ReactElement } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { StatusColumnsView } from "../../src/presentation/components/status-columns-view.js"; -import { DOCS_DIRECTORY_NAME } from "../helpers/docs-directory.js"; -import { createTestKanbanDeps } from "../helpers/test-deps.js"; +import { createKanbanRuntime } from "../../../src/composition/kanban-runtime.js"; +import { StatusColumnsView } from "../../../src/presentation/components/status-columns-view.js"; +import { DOCS_DIRECTORY_NAME } from "../../helpers/docs-directory.js"; +import { createTestKanbanDeps } from "../../helpers/test-deps.js"; const { renderMock } = vi.hoisted(() => ({ renderMock: vi.fn() })); @@ -16,15 +17,16 @@ vi.mock("ink", async (importOriginal) => { }); const { registerInteractiveCommand } = await import( - "../../src/presentation/commands/interactive-command.js" + "../../../src/presentation/commands/interactive-command.js" ); -const { registerListCommand } = await import("../../src/presentation/commands/list-command.js"); +const { registerListCommand } = await import("../../../src/presentation/commands/list-command.js"); function createProgram(): Command { const program = new Command(); const deps = createTestKanbanDeps(); - registerInteractiveCommand(program, deps); - registerListCommand(program, deps); + const runtime = createKanbanRuntime({ deps, projectPath: process.cwd() }); + registerInteractiveCommand(program, runtime, deps.onError); + registerListCommand(program, runtime, deps.onError); return program; } @@ -54,6 +56,22 @@ describe("interactive command wiring", () => { expect((renderedElement.props as { projectPath: string }).projectPath).toBe(projectPath); }); + it("hands the interactive view a watcher factory when --live is set", async () => { + await createProgram().parseAsync(["node", "aidd-kanban", projectPath, "--live"]); + + const [renderedElement] = renderMock.mock.calls[0] as [ReactElement]; + expect(typeof (renderedElement.props as { createWatcher?: unknown }).createWatcher).toBe( + "function" + ); + }); + + it("leaves the interactive view without a watcher factory when --live is absent", async () => { + await createProgram().parseAsync(["node", "aidd-kanban", projectPath]); + + const [renderedElement] = renderMock.mock.calls[0] as [ReactElement]; + expect((renderedElement.props as { createWatcher?: unknown }).createWatcher).toBeUndefined(); + }); + it("wires --all into shouldIncludeUnknownStatus for the interactive view", async () => { await createProgram().parseAsync(["node", "aidd-kanban", projectPath, "--all"]); diff --git a/kanban/tests/presentation/list-command.test.ts b/kanban/tests/presentation/commands/list-command.test.ts similarity index 76% rename from kanban/tests/presentation/list-command.test.ts rename to kanban/tests/presentation/commands/list-command.test.ts index 7a451d0bd..a5728e63f 100644 --- a/kanban/tests/presentation/list-command.test.ts +++ b/kanban/tests/presentation/commands/list-command.test.ts @@ -4,11 +4,15 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { registerListCommand } from "../../src/presentation/commands/list-command.js"; -import { DOCS_DIRECTORY_NAME } from "../helpers/docs-directory.js"; -import { createTestKanbanDeps } from "../helpers/test-deps.js"; +import { createKanbanRuntime } from "../../../src/composition/kanban-runtime.js"; +import { registerListCommand } from "../../../src/presentation/commands/list-command.js"; +import { DOCS_DIRECTORY_NAME } from "../../helpers/docs-directory.js"; +import { createTestKanbanDeps } from "../../helpers/test-deps.js"; -const FIXTURES_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), "../fixtures/frontmatter"); +const FIXTURES_DIRECTORY = join( + dirname(fileURLToPath(import.meta.url)), + "../../fixtures/frontmatter" +); async function writeTaskDocument( directoryPath: string, @@ -24,7 +28,9 @@ async function writeTaskDocument( function createProgram(): Command { const program = new Command(); - registerListCommand(program, createTestKanbanDeps()); + const deps = createTestKanbanDeps(); + const runtime = createKanbanRuntime({ deps, projectPath: process.cwd() }); + registerListCommand(program, runtime, deps.onError); return program; } @@ -33,6 +39,16 @@ function printedTable(consoleLogSpy: ReturnType): string { return typeof printedCall?.[0] === "string" ? printedCall[0] : ""; } +const ESCAPE_CHARACTER_CODE = 27; +const ANSI_SGR_PATTERN = new RegExp( + `${String.fromCharCode(ESCAPE_CHARACTER_CODE)}\\[[0-9;]*m`, + "g" +); + +function stripAnsi(value: string): string { + return value.replace(ANSI_SGR_PATTERN, ""); +} + describe("list command", () => { let projectPath: string; let aiddDocsPath: string; @@ -53,7 +69,7 @@ describe("list command", () => { await rm(projectPath, { recursive: true, force: true }); }); - it("shows one column per distinct parent status, headed by that literal status", async () => { + it("renders the fixed board columns, each parent under its normalized bucket", async () => { await writeTaskDocument(join(aiddDocsPath, "task-a"), "plan.md", [ "name: FID-560", "type: plan", @@ -68,8 +84,8 @@ describe("list command", () => { await createProgram().parseAsync(["node", "aidd-kanban", "list", projectPath]); const table = printedTable(consoleLogSpy); - expect(table).toContain("pending"); - expect(table).toContain("completed"); + expect(table).toContain("TODO"); + expect(table).toContain("UNKNOWN"); expect(table).toContain("FID-560"); expect(table).toContain("SPEC-001"); }); @@ -106,10 +122,10 @@ describe("list command", () => { await createProgram().parseAsync(["node", "aidd-kanban", "list", projectPath]); const table = printedTable(consoleLogSpy); - const linesMentioningDone = table.split("\n").filter((line) => line.includes("done")); + const linesMentioningDone = table.split("\n").filter((line) => line.includes(": done")); expect(linesMentioningDone).toHaveLength(1); expect(linesMentioningDone[0]).toContain("Phase 1"); - expect(table).toContain("pending"); + expect(table).toContain("TODO"); }); it("produces a bounded-width table when process.stdout.columns is unavailable (non-TTY)", async () => { @@ -128,7 +144,7 @@ describe("list command", () => { createProgram().parseAsync(["node", "aidd-kanban", "list", projectPath]) ).resolves.not.toThrow(); - const table = printedTable(consoleLogSpy); + const table = stripAnsi(printedTable(consoleLogSpy)); const longestLine = Math.max(...table.split("\n").map((line) => line.length)); expect(longestLine).toBeLessThan(200); } finally { @@ -253,11 +269,38 @@ describe("list command", () => { await createProgram().parseAsync(["node", "aidd-kanban", "list", projectPath, "--all"]); const table = printedTable(consoleLogSpy); - expect(table).toContain("unknown"); + expect(table).toContain("UNKNOWN"); expect(table).toContain("SPEC-001"); }); - it("end-to-end: a fixture document's name/type/status reach the printed table unaltered", async () => { + it("prints the board as a DTO whose columns carry cards with sub-document counts when --json is given", async () => { + await writeTaskDocument(join(aiddDocsPath, "task-a"), "plan.md", [ + "name: FID-560", + "type: plan", + "status: pending", + ]); + await writeTaskDocument(join(aiddDocsPath, "task-a"), "phase-1.md", [ + "name: Phase 1", + "status: done", + ]); + + await createProgram().parseAsync(["node", "aidd-kanban", "list", projectPath, "--json"]); + + const payload = JSON.parse(printedTable(consoleLogSpy)) as { + columns: { + progressStatus: string; + label: string; + cards: { name: string; doneSubCount: number; totalSubCount: number }[]; + }[]; + }; + const todoColumn = payload.columns.find((column) => column.progressStatus === "todo"); + expect(todoColumn?.cards[0]?.name).toBe("FID-560"); + expect(todoColumn?.cards[0]?.totalSubCount).toBe(1); + expect(todoColumn?.cards[0]?.doneSubCount).toBe(1); + expect(JSON.stringify(payload)).not.toContain("taskGroups"); + }); + + it("end-to-end: a fixture document's name reaches the table, its unmapped status landing under UNKNOWN", async () => { const fixtureContent = await readFile(join(FIXTURES_DIRECTORY, "valid-full.md"), "utf-8"); await mkdir(join(aiddDocsPath, "task-fixture"), { recursive: true }); await writeFile(join(aiddDocsPath, "task-fixture", "plan.md"), fixtureContent); @@ -266,6 +309,6 @@ describe("list command", () => { const table = printedTable(consoleLogSpy); expect(table).toContain("Test name"); - expect(table).toContain("completed"); + expect(table).toContain("UNKNOWN"); }); }); diff --git a/kanban/tests/presentation/commands/web-command.test.ts b/kanban/tests/presentation/commands/web-command.test.ts new file mode 100644 index 000000000..fccdf8d21 --- /dev/null +++ b/kanban/tests/presentation/commands/web-command.test.ts @@ -0,0 +1,100 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { KanbanRuntime } from "../../../src/composition/kanban-runtime.js"; + +vi.mock("node:child_process", () => ({ exec: vi.fn() })); + +const { registerWebCommand } = await import("../../../src/presentation/commands/web-command.js"); + +function createRuntime(): { + runtime: KanbanRuntime; + startMock: ReturnType; + stopMock: ReturnType; + createWebServerMock: ReturnType; +} { + const startMock = vi.fn().mockResolvedValue(4321); + const stopMock = vi.fn(); + const createWebServerMock = vi.fn(() => ({ start: startMock, stop: stopMock })); + + const runtime = { + createWebServer: createWebServerMock, + output: { print: vi.fn() }, + projectPath: "/resolved/project/path", + } as unknown as KanbanRuntime; + + return { runtime, startMock, stopMock, createWebServerMock }; +} + +describe("web command wiring", () => { + const errors: unknown[] = []; + + beforeEach(() => { + errors.length = 0; + }); + + it("exposes the --port option", () => { + const program = new Command(); + + registerWebCommand(program, createRuntime().runtime, (error) => errors.push(error)); + + expect(program.options.map((option) => option.long)).toContain("--port"); + }); + + it("builds the server for the requested port from the runtime and starts it", async () => { + const { runtime, startMock, createWebServerMock } = createRuntime(); + const program = new Command(); + registerWebCommand(program, runtime, (error) => errors.push(error)); + + await program.parseAsync(["node", "aidd-kanban", "--port", "8080"]); + await Promise.resolve(); + + expect(createWebServerMock).toHaveBeenCalledWith(8080, { + projectPath: "/resolved/project/path", + pinned: false, + }); + expect(startMock).toHaveBeenCalledTimes(1); + expect(errors).toEqual([]); + }); + + it("defaults to port 3000 when the flag is omitted", async () => { + const { runtime, createWebServerMock } = createRuntime(); + const program = new Command(); + registerWebCommand(program, runtime, (error) => errors.push(error)); + + await program.parseAsync(["node", "aidd-kanban"]); + await Promise.resolve(); + + expect(createWebServerMock).toHaveBeenCalledWith(3000, { + projectPath: "/resolved/project/path", + pinned: false, + }); + }); + + it("pins the path from the positional argument and hides the picker", async () => { + const { runtime, createWebServerMock } = createRuntime(); + const program = new Command(); + registerWebCommand(program, runtime, (error) => errors.push(error)); + + await program.parseAsync(["node", "aidd-kanban", "/some/dir"]); + await Promise.resolve(); + + expect(createWebServerMock).toHaveBeenCalledWith(3000, { + projectPath: "/some/dir", + pinned: true, + }); + }); + + it("routes a non-numeric port to the error handler without starting a server", async () => { + const { runtime, startMock, createWebServerMock } = createRuntime(); + const program = new Command(); + registerWebCommand(program, runtime, (error) => errors.push(error)); + + await program.parseAsync(["node", "aidd-kanban", "--port", "abc"]); + await Promise.resolve(); + + expect(createWebServerMock).not.toHaveBeenCalled(); + expect(startMock).not.toHaveBeenCalled(); + expect(errors).toHaveLength(1); + expect((errors[0] as Error).message).toContain("KANBAN_INVALID_PORT"); + }); +}); diff --git a/kanban/tests/presentation/components/status-columns-view.test.tsx b/kanban/tests/presentation/components/status-columns-view.test.tsx new file mode 100644 index 000000000..f39ebfc2f --- /dev/null +++ b/kanban/tests/presentation/components/status-columns-view.test.tsx @@ -0,0 +1,261 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { render } from "ink-testing-library"; +import { createElement } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { + type ListTaskDocumentsFilters, + ListTaskDocumentsUseCase, +} from "../../../src/application/use-cases/list-task-documents.js"; +import { createKanbanRuntime } from "../../../src/composition/kanban-runtime.js"; +import { UNKNOWN_DOCUMENT_STATUS } from "../../../src/domain/models/document-status.js"; +import type { ProgressStatus } from "../../../src/domain/models/progress-status.js"; +import type { TaskDocument } from "../../../src/domain/models/task-document.js"; +import type { TaskDocumentWatcher } from "../../../src/domain/ports/task-document-watcher.js"; +import { StatusColumnsView } from "../../../src/presentation/components/status-columns-view.js"; +import { DOCS_DIRECTORY_NAME } from "../../helpers/docs-directory.js"; +import { createTestKanbanDeps } from "../../helpers/test-deps.js"; + +interface TaskDocumentOverrides { + name: string; + filePath: string; + type?: string; + status?: string; + progressStatus?: ProgressStatus; +} + +function createTaskDocument({ + name, + filePath, + type = "plan", + status = "pending", + progressStatus = "todo", +}: TaskDocumentOverrides): TaskDocument { + return { name, description: "", type, status, progressStatus, filePath }; +} + +function createUseCase(taskDocuments: TaskDocument[]): ListTaskDocumentsUseCase { + return new ListTaskDocumentsUseCase({ + findAll: async () => taskDocuments, + projectExists: async () => true, + }); +} + +async function waitForFrame( + lastFrame: () => string | undefined, + expectedText: string +): Promise { + await vi.waitFor(() => { + expect(lastFrame()).toContain(expectedText); + }); + + const frame = lastFrame(); + return frame === undefined ? "" : frame; +} + +function renderView( + listTaskDocuments: ListTaskDocumentsUseCase, + filters: ListTaskDocumentsFilters, + terminalWidth: number +) { + return render( + createElement(StatusColumnsView, { + listTaskDocuments, + projectPath: "/virtual/project", + filters, + terminalWidth, + }) + ); +} + +const NO_FILTERS: ListTaskDocumentsFilters = {}; + +function createFakeWatcher() { + const calls = { start: 0, stop: 0 }; + let changeCallback: (() => void) | undefined; + const watcher: TaskDocumentWatcher = { + start: () => { + calls.start += 1; + }, + retarget: () => undefined, + stop: () => { + calls.stop += 1; + }, + onChange: (callback) => { + changeCallback = callback; + }, + }; + return { + watcher, + calls, + emitChange: () => changeCallback?.(), + }; +} + +describe("StatusColumnsView", () => { + it("places each parent under its fixed board column, sub-documents nested beneath their parent", async () => { + const useCase = createUseCase([ + createTaskDocument({ name: "FID-560", filePath: "/p/task-a/plan.md", status: "pending" }), + createTaskDocument({ + name: "Phase 1", + filePath: "/p/task-a/phase-1.md", + type: "phase", + status: "blocked", + progressStatus: "blocked", + }), + createTaskDocument({ + name: "SPEC-001", + filePath: "/p/task-b/spec.md", + type: "spec", + status: "completed", + progressStatus: "unknown", + }), + ]); + + const { lastFrame, unmount } = renderView(useCase, NO_FILTERS, 140); + + const frame = await waitForFrame(lastFrame, "SPEC-001"); + expect(frame).toContain("TODO"); + expect(frame).toContain("UNKNOWN"); + expect(frame).toContain("FID-560"); + expect(frame).toContain("- Phase 1: blocked"); + + unmount(); + }); + + it("keeps status header and parent name legible when narrowing the simulated terminal width", async () => { + const useCase = createUseCase([ + createTaskDocument({ name: "FID-560", filePath: "/p/task-a/plan.md", status: "pending" }), + createTaskDocument({ + name: "Phase 1", + filePath: "/p/task-a/phase-1.md", + type: "phase", + status: "blocked", + progressStatus: "blocked", + }), + ]); + + const { lastFrame, unmount } = renderView(useCase, NO_FILTERS, 20); + + const frame = await waitForFrame(lastFrame, "FID-560"); + expect(frame).toContain("TODO"); + expect(frame).toContain("FID-560"); + expect(frame).not.toContain("- Phase 1: blocked"); + + unmount(); + }); + + it("hides a document whose parent status is unknown by default, but shows it when shouldIncludeUnknownStatus is true", async () => { + const taskDocuments = [ + createTaskDocument({ + name: "SPEC-001", + filePath: "/p/task-a/spec.md", + type: "spec", + status: UNKNOWN_DOCUMENT_STATUS, + progressStatus: "unknown", + }), + createTaskDocument({ name: "FID-560", filePath: "/p/task-b/plan.md", status: "pending" }), + ]; + + const { lastFrame, unmount } = renderView(createUseCase(taskDocuments), NO_FILTERS, 100); + const frame = await waitForFrame(lastFrame, "FID-560"); + expect(frame).not.toContain("SPEC-001"); + unmount(); + + const withAll = renderView( + createUseCase(taskDocuments), + { shouldIncludeUnknownStatus: true }, + 100 + ); + const frameWithAll = await waitForFrame(withAll.lastFrame, "SPEC-001"); + expect(frameWithAll).toContain("SPEC-001"); + withAll.unmount(); + }); + + it("fetches the board exactly once from the injected use case", async () => { + const useCase = createUseCase([ + createTaskDocument({ name: "FID-560", filePath: "/p/task-a/plan.md", status: "pending" }), + ]); + const executeSpy = vi.spyOn(useCase, "execute"); + + const { lastFrame, unmount } = renderView(useCase, NO_FILTERS, 100); + await waitForFrame(lastFrame, "FID-560"); + + expect(executeSpy).toHaveBeenCalledTimes(1); + expect(executeSpy).toHaveBeenCalledWith("/virtual/project", NO_FILTERS); + + unmount(); + }); + + it("re-runs the injected use case when the live watcher reports a change and stops it on unmount", async () => { + const useCase = createUseCase([ + createTaskDocument({ name: "FID-560", filePath: "/p/task-a/plan.md", status: "pending" }), + ]); + const executeSpy = vi.spyOn(useCase, "execute"); + const fake = createFakeWatcher(); + + const { lastFrame, unmount } = render( + createElement(StatusColumnsView, { + listTaskDocuments: useCase, + projectPath: "/virtual/project", + filters: NO_FILTERS, + terminalWidth: 100, + createWatcher: () => fake.watcher, + }) + ); + + await waitForFrame(lastFrame, "FID-560"); + expect(executeSpy).toHaveBeenCalledTimes(1); + expect(fake.calls.start).toBe(1); + + fake.emitChange(); + await vi.waitFor(() => { + expect(executeSpy).toHaveBeenCalledTimes(2); + }); + + unmount(); + expect(fake.calls.stop).toBe(1); + }); + + it("never touches a watcher when no live factory is provided", async () => { + const useCase = createUseCase([ + createTaskDocument({ name: "FID-560", filePath: "/p/task-a/plan.md", status: "pending" }), + ]); + const executeSpy = vi.spyOn(useCase, "execute"); + const fake = createFakeWatcher(); + + const { lastFrame, unmount } = renderView(useCase, NO_FILTERS, 100); + await waitForFrame(lastFrame, "FID-560"); + unmount(); + + expect(fake.calls.start).toBe(0); + expect(fake.calls.stop).toBe(0); + expect(executeSpy).toHaveBeenCalledTimes(1); + }); + + it("renders the same board a filesystem-backed use case produces for the fixture project", async () => { + const projectPath = await mkdtemp(join(tmpdir(), "aidd-kanban-view-fs-")); + await mkdir(join(projectPath, DOCS_DIRECTORY_NAME, "task-fixture"), { recursive: true }); + await writeFile( + join(projectPath, DOCS_DIRECTORY_NAME, "task-fixture", "plan.md"), + ["---", "name: Test name", "type: plan", "status: completed", "---", ""].join("\n") + ); + + const runtime = createKanbanRuntime({ deps: createTestKanbanDeps(), projectPath }); + const { lastFrame, unmount } = render( + createElement(StatusColumnsView, { + listTaskDocuments: runtime.listTaskDocuments, + projectPath: runtime.projectPath, + terminalWidth: 100, + }) + ); + + const frame = await waitForFrame(lastFrame, "Test name"); + expect(frame).toContain("Test name"); + expect(frame).toContain("UNKNOWN"); + + unmount(); + await rm(projectPath, { recursive: true, force: true }); + }); +}); diff --git a/kanban/tests/presentation/dto/board-dto.test.ts b/kanban/tests/presentation/dto/board-dto.test.ts new file mode 100644 index 000000000..7135e2c09 --- /dev/null +++ b/kanban/tests/presentation/dto/board-dto.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { deriveBoard } from "../../../src/domain/models/board.js"; +import type { ProgressStatus } from "../../../src/domain/models/progress-status.js"; +import type { TaskDocument } from "../../../src/domain/models/task-document.js"; +import type { TaskGroup } from "../../../src/domain/models/task-group.js"; +import { toBoardDto } from "../../../src/presentation/dto/board-dto.js"; + +function createTaskDocument( + name: string, + progressStatus: ProgressStatus, + relativePath: string +): TaskDocument { + return { + name, + description: `${name} description`, + type: "plan", + status: progressStatus, + progressStatus, + filePath: relativePath, + }; +} + +const PARENT_WITH_MIXED_SUBS: TaskGroup = { + parent: createTaskDocument("FID-560", "in-progress", "aidd_docs/fid-560/plan.md"), + subDocuments: [ + createTaskDocument("Phase 1", "done", "aidd_docs/fid-560/phase-1.md"), + createTaskDocument("Phase 2", "done", "aidd_docs/fid-560/phase-2.md"), + createTaskDocument("Phase 3", "todo", "aidd_docs/fid-560/phase-3.md"), + ], +}; + +describe("toBoardDto", () => { + it("keeps the five-column contract with labels straight from the board", () => { + const dto = toBoardDto(deriveBoard([PARENT_WITH_MIXED_SUBS])); + + expect(dto.columns.map((column) => column.progressStatus)).toEqual([ + "todo", + "in-progress", + "done", + "blocked", + ]); + expect(dto.columns.map((column) => column.label)).toEqual([ + "TODO", + "IN PROGRESS", + "DONE", + "BLOCKED", + ]); + }); + + it("counts done sub-documents against the total for a mixed parent card", () => { + const dto = toBoardDto(deriveBoard([PARENT_WITH_MIXED_SUBS])); + + const card = dto.columns + .flatMap((column) => column.cards) + .find((each) => each.name === "FID-560"); + + expect(card?.totalSubCount).toBe(3); + expect(card?.doneSubCount).toBe(2); + }); + + it("carries the parent and sub-document relative paths through as plain strings", () => { + const dto = toBoardDto(deriveBoard([PARENT_WITH_MIXED_SUBS])); + + const card = dto.columns + .flatMap((column) => column.cards) + .find((each) => each.name === "FID-560"); + + expect(card?.path).toBe("aidd_docs/fid-560/plan.md"); + expect(card?.subDocuments.map((subDocument) => subDocument.path)).toEqual([ + "aidd_docs/fid-560/phase-1.md", + "aidd_docs/fid-560/phase-2.md", + "aidd_docs/fid-560/phase-3.md", + ]); + }); + + it("produces a structure whose values are all serializable primitives and arrays", () => { + const dto = toBoardDto(deriveBoard([PARENT_WITH_MIXED_SUBS])); + + expect(JSON.parse(JSON.stringify(dto))).toEqual(dto); + const card = dto.columns.flatMap((column) => column.cards)[0]; + expect(Object.keys(card ?? {}).sort()).toEqual([ + "description", + "doneSubCount", + "name", + "path", + "progressStatus", + "status", + "subDocuments", + "totalSubCount", + "type", + ]); + }); +}); diff --git a/kanban/tests/presentation/register-kanban.test.ts b/kanban/tests/presentation/register-kanban.test.ts new file mode 100644 index 000000000..7bd2f7223 --- /dev/null +++ b/kanban/tests/presentation/register-kanban.test.ts @@ -0,0 +1,78 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DOCS_DIRECTORY_NAME } from "../helpers/docs-directory.js"; +import { createTestKanbanDeps } from "../helpers/test-deps.js"; + +const { renderMock } = vi.hoisted(() => ({ renderMock: vi.fn() })); + +vi.mock("ink", async (importOriginal) => { + const actualInkModule = await importOriginal(); + return { ...actualInkModule, render: renderMock }; +}); + +vi.mock("../../src/infrastructure/http/frontend-assets.js", () => ({ + readFrontendAssets: () => ({ indexHtml: "", stylesCss: "", appJs: "" }), +})); + +const { registerKanban } = await import("../../src/presentation/register-kanban.js"); + +describe("register kanban", () => { + let projectPath: string; + let originalCwd: string; + let consoleLogSpy: ReturnType; + + beforeEach(async () => { + projectPath = await mkdtemp(join(tmpdir(), "aidd-kanban-register-")); + const taskDirectory = join(projectPath, DOCS_DIRECTORY_NAME, "task-a"); + await mkdir(taskDirectory, { recursive: true }); + await writeFile( + join(taskDirectory, "plan.md"), + ["---", "name: FID-560", "type: plan", "status: pending", "---", ""].join("\n") + ); + originalCwd = process.cwd(); + renderMock.mockClear(); + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + }); + + afterEach(async () => { + consoleLogSpy.mockRestore(); + process.chdir(originalCwd); + await rm(projectPath, { recursive: true, force: true }); + }); + + it("registers the list, web, and interactive subcommands on a bare command", () => { + const program = new Command(); + + registerKanban(program, createTestKanbanDeps()); + + expect(program.commands.map((command) => command.name()).sort()).toEqual([ + "interactive", + "list", + "web", + ]); + }); + + it("runs the list subcommand against the project path resolved once at registration", async () => { + process.chdir(projectPath); + const program = new Command(); + registerKanban(program, createTestKanbanDeps()); + + await program.parseAsync(["node", "aidd-kanban", "list"]); + + const printed = consoleLogSpy.mock.calls[0]?.[0]; + expect(typeof printed === "string" ? printed : "").toContain("FID-560"); + }); + + it("launches the interactive view for the default action", async () => { + process.chdir(projectPath); + const program = new Command(); + registerKanban(program, createTestKanbanDeps()); + + await program.parseAsync(["node", "aidd-kanban"]); + + expect(renderMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/kanban/tests/presentation/status-columns-view.test.tsx b/kanban/tests/presentation/status-columns-view.test.tsx deleted file mode 100644 index f014cb8cc..000000000 --- a/kanban/tests/presentation/status-columns-view.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { render } from "ink-testing-library"; -import { createElement } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { StatusColumnsView } from "../../src/presentation/components/status-columns-view.js"; -import { DOCS_DIRECTORY_NAME } from "../helpers/docs-directory.js"; - -const FIXTURES_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), "../fixtures/frontmatter"); - -async function writeTaskDocument( - directoryPath: string, - fileName: string, - frontmatterLines: string[] -): Promise { - await mkdir(directoryPath, { recursive: true }); - await writeFile( - join(directoryPath, fileName), - ["---", ...frontmatterLines, "---", ""].join("\n") - ); -} - -async function waitForFrame( - lastFrame: () => string | undefined, - expectedText: string -): Promise { - await vi.waitFor(() => { - expect(lastFrame()).toContain(expectedText); - }); - - const frame = lastFrame(); - return frame === undefined ? "" : frame; -} - -describe("StatusColumnsView", () => { - let projectPath: string; - let aiddDocsPath: string; - - beforeEach(async () => { - projectPath = await mkdtemp(join(tmpdir(), "aidd-kanban-interactive-")); - aiddDocsPath = join(projectPath, DOCS_DIRECTORY_NAME); - await mkdir(aiddDocsPath, { recursive: true }); - }); - - afterEach(async () => { - await rm(projectPath, { recursive: true, force: true }); - }); - - it("shows one column per distinct parent status, sub-documents nested beneath their parent", async () => { - await writeTaskDocument(join(aiddDocsPath, "task-a"), "plan.md", [ - "name: FID-560", - "type: plan", - "status: pending", - ]); - await writeTaskDocument(join(aiddDocsPath, "task-a"), "phase-1.md", [ - "name: Phase 1", - "status: blocked", - ]); - await writeTaskDocument(join(aiddDocsPath, "task-b"), "spec.md", [ - "name: SPEC-001", - "type: spec", - "status: completed", - ]); - - const { lastFrame, unmount } = render( - createElement(StatusColumnsView, { - projectPath, - docsDirectoryName: DOCS_DIRECTORY_NAME, - terminalWidth: 100, - }) - ); - - const frame = await waitForFrame(lastFrame, "SPEC-001"); - expect(frame).toContain("PENDING"); - expect(frame).toContain("COMPLETED"); - expect(frame).toContain("FID-560"); - expect(frame).toContain("- Phase 1: blocked"); - - unmount(); - }); - - it("keeps status header and parent name legible when narrowing the simulated terminal width", async () => { - await writeTaskDocument(join(aiddDocsPath, "task-a"), "plan.md", [ - "name: FID-560", - "type: plan", - "status: pending", - ]); - await writeTaskDocument(join(aiddDocsPath, "task-a"), "phase-1.md", [ - "name: Phase 1", - "status: blocked", - ]); - - const { lastFrame, unmount } = render( - createElement(StatusColumnsView, { - projectPath, - docsDirectoryName: DOCS_DIRECTORY_NAME, - terminalWidth: 20, - }) - ); - - const frame = await waitForFrame(lastFrame, "FID-560"); - expect(frame).toContain("PENDING"); - expect(frame).toContain("FID-560"); - expect(frame).not.toContain("- Phase 1: blocked"); - - unmount(); - }); - - it("hides a document missing its status field by default, but shows it when shouldIncludeUnknownStatus is true", async () => { - await writeTaskDocument(join(aiddDocsPath, "task-a"), "spec.md", [ - "name: SPEC-001", - "type: spec", - ]); - await writeTaskDocument(join(aiddDocsPath, "task-b"), "plan.md", [ - "name: FID-560", - "type: plan", - "status: pending", - ]); - - const { lastFrame, unmount } = render( - createElement(StatusColumnsView, { - projectPath, - docsDirectoryName: DOCS_DIRECTORY_NAME, - terminalWidth: 100, - }) - ); - - const frame = await waitForFrame(lastFrame, "FID-560"); - expect(frame).not.toContain("SPEC-001"); - unmount(); - - const { lastFrame: lastFrameWithAll, unmount: unmountWithAll } = render( - createElement(StatusColumnsView, { - projectPath, - docsDirectoryName: DOCS_DIRECTORY_NAME, - terminalWidth: 100, - filters: { shouldIncludeUnknownStatus: true }, - }) - ); - - const frameWithAll = await waitForFrame(lastFrameWithAll, "SPEC-001"); - expect(frameWithAll).toContain("SPEC-001"); - unmountWithAll(); - }); - - it("end-to-end: a fixture document's name/type/status reach the rendered output unaltered", async () => { - const fixtureContent = await readFile(join(FIXTURES_DIRECTORY, "valid-full.md"), "utf-8"); - await mkdir(join(aiddDocsPath, "task-fixture"), { recursive: true }); - await writeFile(join(aiddDocsPath, "task-fixture", "plan.md"), fixtureContent); - - const { lastFrame, unmount } = render( - createElement(StatusColumnsView, { - projectPath, - docsDirectoryName: DOCS_DIRECTORY_NAME, - terminalWidth: 100, - }) - ); - - const frame = await waitForFrame(lastFrame, "Test name"); - expect(frame).toContain("Test name"); - expect(frame).toContain("COMPLETED"); - - unmount(); - }); -}); diff --git a/kanban/tests/presentation/status-grouping.test.ts b/kanban/tests/presentation/status-grouping.test.ts deleted file mode 100644 index 742ade3e0..000000000 --- a/kanban/tests/presentation/status-grouping.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { TaskDocument } from "../../src/domain/models/task-document.js"; -import type { TaskGroup } from "../../src/domain/models/task-group.js"; -import { collectDistinctParentStatuses } from "../../src/presentation/status-grouping.js"; - -function createTaskGroup(status: string): TaskGroup { - const parent: TaskDocument = { - name: `${status} plan`, - description: "", - type: "plan", - status, - progressStatus: "todo", - filePath: `/project/aidd_docs/${status}/plan.md`, - }; - - return { parent, subDocuments: [] }; -} - -describe("collectDistinctParentStatuses", () => { - it("orders canonical statuses as pending, in-progress, implemented regardless of encounter order", () => { - const taskGroups = [ - createTaskGroup("in-progress"), - createTaskGroup("implemented"), - createTaskGroup("pending"), - ]; - - expect(collectDistinctParentStatuses(taskGroups)).toEqual([ - "pending", - "in-progress", - "implemented", - ]); - }); - - it("appends a non-canonical status after every canonical status present, in first-seen order", () => { - const taskGroups = [ - createTaskGroup("completed"), - createTaskGroup("implemented"), - createTaskGroup("archived"), - createTaskGroup("pending"), - ]; - - expect(collectDistinctParentStatuses(taskGroups)).toEqual([ - "pending", - "implemented", - "completed", - "archived", - ]); - }); - - it("includes reviewed and blocked in their canonical positions", () => { - const taskGroups = [ - createTaskGroup("blocked"), - createTaskGroup("reviewed"), - createTaskGroup("in-progress"), - ]; - - expect(collectDistinctParentStatuses(taskGroups)).toEqual([ - "in-progress", - "reviewed", - "blocked", - ]); - }); - - it("returns an empty array for no task groups", () => { - expect(collectDistinctParentStatuses([])).toEqual([]); - }); -}); diff --git a/lefthook.yml b/lefthook.yml index 21b099b4d..225494272 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -126,4 +126,4 @@ pre-push: commit-msg: commands: commitlint: - run: pnpm exec commitlint --edit {1} + run: cd cli && pnpm exec commitlint --edit {1}