From 812e5c2f2a69432692d091ec4132bfa8f33a4387 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 5 Apr 2026 16:20:39 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8feature:=20Initial=20MVP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 + .phpunit.cache/test-results | 1 + .vscode/settings.json | 16 + AGENTS.md | 23 + Notes/IronKanban.md | 23 + Notes/dev-spec.md | 635 ++++++++++++++++++ README.md | 37 + bootstrap.php | 33 + composer.json | 29 + config/app.php | 19 + package-lock.json | 76 +++ package.json | 10 + phpcs-results.txt | 1 + phpcs.xml | 29 + phpunit.xml | 12 + playwright.config.js | 19 + public/api/_bootstrap.php | 14 + public/api/board-state.php | 27 + public/api/create-column.php | 35 + public/api/create-task.php | 37 + public/api/delete-task.php | 36 + public/api/move-task.php | 37 + public/api/save-note.php | 35 + public/api/save-task.php | 36 + public/api/update-board.php | 36 + public/assets/app.css | 551 +++++++++++++++ public/assets/app.js | 619 +++++++++++++++++ public/index.php | 228 +++++++ src/Domain/Board.php | 44 ++ src/Domain/Column.php | 44 ++ src/Domain/Note.php | 50 ++ src/Domain/Project.php | 50 ++ src/Domain/Task.php | 65 ++ src/Repository/BoardRepository.php | 171 +++++ src/Repository/NoteRepository.php | 112 +++ src/Repository/ProjectRepository.php | 75 +++ src/Repository/TaskRepository.php | 241 +++++++ src/Service/BoardService.php | 515 ++++++++++++++ src/Service/GitService.php | 64 ++ src/Service/RevisionService.php | 83 +++ src/Service/TaskOrderingService.php | 71 ++ src/Support/AtomicWriter.php | 51 ++ src/Support/FileLock.php | 45 ++ src/Support/FrontMatter.php | 420 ++++++++++++ src/Support/FrontMatterDocument.php | 28 + src/Support/Http.php | 105 +++ src/Support/HttpHalt.php | 17 + src/Support/Path.php | 56 ++ src/Support/helpers.php | 133 ++++ storage/projects/demo-project/.revision | 2 +- .../notes/note-20260405-161609-3d2504.md | 3 +- .../tasks/task-20260404-180000-a1b2c3.md | 3 +- test-results/.last-run.json | 4 + tests/Api/BoardApiTest.php | 322 +++++++++ tests/Domain/BoardTest.php | 49 ++ tests/Service/BoardServiceTest.php | 107 +++ tests/Support/FrontMatterTest.php | 57 ++ tests/Support/IntegrationTestCase.php | 200 ++++++ tests/bootstrap.php | 29 + tests/e2e/app.spec.js | 45 ++ 60 files changed, 5917 insertions(+), 5 deletions(-) create mode 100644 .gitignore create mode 100644 .phpunit.cache/test-results create mode 100644 .vscode/settings.json create mode 100644 AGENTS.md create mode 100644 Notes/IronKanban.md create mode 100644 Notes/dev-spec.md create mode 100644 README.md create mode 100644 bootstrap.php create mode 100644 composer.json create mode 100644 config/app.php create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 phpcs-results.txt create mode 100644 phpcs.xml create mode 100644 phpunit.xml create mode 100644 playwright.config.js create mode 100644 public/api/_bootstrap.php create mode 100644 public/api/board-state.php create mode 100644 public/api/create-column.php create mode 100644 public/api/create-task.php create mode 100644 public/api/delete-task.php create mode 100644 public/api/move-task.php create mode 100644 public/api/save-note.php create mode 100644 public/api/save-task.php create mode 100644 public/api/update-board.php create mode 100644 public/assets/app.css create mode 100644 public/assets/app.js create mode 100644 public/index.php create mode 100644 src/Domain/Board.php create mode 100644 src/Domain/Column.php create mode 100644 src/Domain/Note.php create mode 100644 src/Domain/Project.php create mode 100644 src/Domain/Task.php create mode 100644 src/Repository/BoardRepository.php create mode 100644 src/Repository/NoteRepository.php create mode 100644 src/Repository/ProjectRepository.php create mode 100644 src/Repository/TaskRepository.php create mode 100644 src/Service/BoardService.php create mode 100644 src/Service/GitService.php create mode 100644 src/Service/RevisionService.php create mode 100644 src/Service/TaskOrderingService.php create mode 100644 src/Support/AtomicWriter.php create mode 100644 src/Support/FileLock.php create mode 100644 src/Support/FrontMatter.php create mode 100644 src/Support/FrontMatterDocument.php create mode 100644 src/Support/Http.php create mode 100644 src/Support/HttpHalt.php create mode 100644 src/Support/Path.php create mode 100644 src/Support/helpers.php create mode 100644 test-results/.last-run.json create mode 100644 tests/Api/BoardApiTest.php create mode 100644 tests/Domain/BoardTest.php create mode 100644 tests/Service/BoardServiceTest.php create mode 100644 tests/Support/FrontMatterTest.php create mode 100644 tests/Support/IntegrationTestCase.php create mode 100644 tests/bootstrap.php create mode 100644 tests/e2e/app.spec.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aaf52be --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/node_modules/ +/vendor/ +/composer.lock +/storage/projects/*/.task.lock +/storage/projects/*/.board.lock +/storage/projects/*/.notes.lock +/storage/projects/*/.revision.lock diff --git a/.phpunit.cache/test-results b/.phpunit.cache/test-results new file mode 100644 index 0000000..1329119 --- /dev/null +++ b/.phpunit.cache/test-results @@ -0,0 +1 @@ +{"version":2,"defects":{"Tests\\Support\\FrontMatterTest::testDumpAndParseRoundTrip":7,"Tests\\Api\\BoardApiTest::testBoardStateEndpointReturnsProjectState":7,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointRejectsMissingCsrfToken":7,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointCreatesTaskWithValidCsrfToken":7,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointReturnsConflictForStaleRevision":7,"Tests\\Api\\BoardApiTest::testSaveTaskEndpointUpdatesTaskFields":7},"times":{"Tests\\Domain\\BoardTest::testToArrayIncludesSerializedColumnsAndMeta":0,"Tests\\Support\\FrontMatterTest::testDumpAndParseRoundTrip":0,"Tests\\Support\\FrontMatterTest::testParseWithoutFrontMatterReturnsBodyOnly":0,"Tests\\Service\\BoardServiceTest::testGetBoardStateBuildsDefaultColumnsForNewProject":0.015,"Tests\\Service\\BoardServiceTest::testCreateTaskPersistsTaskInRequestedColumn":0.075,"Tests\\Service\\BoardServiceTest::testDeleteTaskMovesTaskToTrashByDefault":0.132,"Tests\\Service\\BoardServiceTest::testUpdateBoardMovesDeletedColumnTasksIntoTrash":0.153,"Tests\\Api\\BoardApiTest::testBoardStateEndpointReturnsProjectState":0.036,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointRejectsMissingCsrfToken":0.004,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointCreatesTaskWithValidCsrfToken":0.07,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointReturnsConflictForStaleRevision":0.073,"Tests\\Api\\BoardApiTest::testMoveTaskEndpointMovesTaskToRequestedColumn":0.121,"Tests\\Api\\BoardApiTest::testDeleteTaskEndpointMovesTaskToTrash":0.107,"Tests\\Api\\BoardApiTest::testSaveNoteEndpointCreatesNote":0.061,"Tests\\Api\\BoardApiTest::testCreateColumnEndpointCreatesNewColumn":0.072,"Tests\\Api\\BoardApiTest::testSaveTaskEndpointUpdatesTaskFields":0.139,"Tests\\Api\\BoardApiTest::testUpdateBoardEndpointReplacesColumnDefinitions":0.074}} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..01509d5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "workbench.colorCustomizations": { + "tree.indentGuidesStroke": "#3d92ec", + "activityBar.background": "#4C0A64", + "titleBar.activeBackground": "#6A0E8C", + "titleBar.activeForeground": "#FDFAFE", + "titleBar.inactiveBackground": "#4C0A64", + "titleBar.inactiveForeground": "#FDFAFE", + "statusBar.background": "#4C0A64", + "statusBar.foreground": "#FDFAFE", + "statusBar.debuggingBackground": "#4C0A64", + "statusBar.debuggingForeground": "#FDFAFE", + "statusBar.noFolderBackground": "#4C0A64", + "statusBar.noFolderForeground": "#FDFAFE" + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..21d8df9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# Working Agreements + +## 1. Code Style & Quality + +- PHP: PHPCS with my custom coding standards (`phpcs.xml`), PHPStan level ≥ 6. +- JS: ESLint (airbnb/base), Prettier. +- Commits: Conventional Commits. +- Branches: main (stable), develop, feature branches → PRs. +- CI Required Checks: lint, static analysis, unit/integration tests, e2e (smoke), composer validate. No merge to main without green. + +## 2. Security Model + +- Nonces on all state‑changing forms/requests; verify before mutate. +- Always sanitize input and escape output. + +## 3. Build & Tooling + +Composer dev deps (suggested): + +- dealerdirect/phpcodesniffer-composer-installer +- squizlabs/php_codesniffer +- phpstan/phpstan +- phpunit/phpunit diff --git a/Notes/IronKanban.md b/Notes/IronKanban.md new file mode 100644 index 0000000..5c8b859 --- /dev/null +++ b/Notes/IronKanban.md @@ -0,0 +1,23 @@ +# IronKanban + +I'm currently using this project in a custom docker container I wrapped it in. While it works, and I like the flat-file git-backed nature of it, I'm not thrilled with the interaction pattern. I would like to keep the markdown powered (using the same format for the notes and projects if at all possible) git repo aspect of it, but build it out into a kanban-style interface. I would also like to move away from the stack IronPad is using, and move to something I'm more comfortable with. + +I would prefer PHP with vanilla javascript as needed. That said, I would like to have the abilty to drag and drop cards between columns, and have the interface update in real(ish) time as changes are made. I would also like to have the ability to create new columns and cards, and have those changes reflected in the underlying markdown files. Each Project is currently built as a folder that is laid out like this: + +```plain +project-name/ +├── index.md +├── notes/ +│ └── 20260314-154222.md +├── tasks/ +│ ├── task-20260227-124827.md +│ ├── task-20260227-124901.md +│ ├── task-20260227-165334.md +│ └── task-20260314-154158.md +``` + +The index.md file contains the project description and any relevant information about the project. The optional notes folder contains any notes related to the project, and the tasks folder contains the individual tasks for the project. + +The UI should be dark mode by default, and should be responsive. The columns should be able to be reordered, and the tasks within the columns should also be able to be reordered. The tasks should also be able to be moved between columns. The UI should also have a way to create new columns and tasks, and to edit existing ones. The UI should also have a way to delete columns and tasks, and to move tasks to a "trash" column before they are permanently deleted. + +See `dev-spec.md` for more detailed specifications on the system architecture, request lifecycle, and filesystem contract. diff --git a/Notes/dev-spec.md b/Notes/dev-spec.md new file mode 100644 index 0000000..7904507 --- /dev/null +++ b/Notes/dev-spec.md @@ -0,0 +1,635 @@ +# IronKanban — Technical Blueprint (Dev Spec) + +## 0. Scope + +MVP implementation of: + +- Git-backed markdown project/task system +- Kanban board UI +- Drag & drop between columns +- Real-time-ish updates (polling → SSE-ready) +- PHP backend + vanilla JS frontend +- No framework (structured, but lightweight) + +## 1. System Architecture + +### 1.1 High-level flow + +```plain +Filesystem (markdown + git) + ↑ ↓ +Repositories (PHP) + ↑ ↓ +Services (business logic) + ↑ ↓ +API Endpoints (public/api/*.php) + ↑ ↓ +Frontend (vanilla JS + SortableJS) +``` + +### 1.2 Request lifecycle (example: move task) + +```plain +Drag card → +JS computes new position → +POST /api/move-task.php → +TaskRepository loads file → +BoardService updates column/order → +TaskRepository writes file → +GitService commits → +RevisionService updates revision → +Response JSON → +UI updates (already optimistic) +``` + +## 2. Filesystem Contract + +### 2.1 Root config + +```php +const PROJECT_ROOT = '/data/projects'; +``` + +All file access must resolve within this root. + +### 2.2 Project layout + +```plain +/data/projects/{projectSlug}/ +├── index.md +├── board.md +├── notes/ +│ └── *.md +├── tasks/ +│ └── *.md +``` + +## 3. Markdown Contract + +### 3.1 Front matter parser contract + +```php +class FrontMatterDocument { + public array $meta; + public string $body; +} +``` + +### 3.2 Required keys by type + +#### Project (`index.md`) + +```yaml +type: project +id: string +title: string +created: ISO8601 +updated: ISO8601 +``` + +#### Board (`board.md`) + +```yaml +type: board +project_id: string +columns: + - id: string + label: string + order: int +updated: ISO8601 +``` + +#### Task (`tasks/*.md`) + +```yaml +type: task +id: string +title: string +project_id: string + +column: string +order: int + +completed: bool +priority: string +is_active: bool + +created: ISO8601 +updated: ISO8601 +``` + +Optional (must be preserved if present): + +```yaml +status: string|null +status_note: string|null +assignee: string|null +tags: array +section: string # legacy +``` + +#### Note (`notes/*.md`) + +```yaml +type: note +id: string +title: string +project_id: string +created: ISO8601 +updated: ISO8601 +``` + +## 4. Domain Models + +### 4.1 Project + +```php +class Project { + public string $id; + public string $title; + public string $path; +} +``` + +### 4.2 Task + +```php +class Task { + public string $id; + public string $title; + public string $projectId; + + public string $column; + public int $order; + + public bool $completed; + public string $priority; + public bool $isActive; + + public array $meta; + public string $body; +} +``` + +### 4.3 Board + +```php +class Board { + public string $projectId; + + /** @var Column[] */ + public array $columns; +} +``` + +### 4.4 Column + +```php +class Column { + public string $id; + public string $label; + public int $order; +} +``` + +### 4.5 Note + +```php +class Note { + public string $id; + public string $title; + public string $body; +} +``` + +## 5. Repositories + +### 5.1 ProjectRepository + +```php +class ProjectRepository { + public function getAll(): array; + public function get(string $slug): Project; +} +``` + +### 5.2 TaskRepository + +```php +class TaskRepository { + public function getAll(string $projectId): array; + public function get(string $projectId, string $taskId): Task; + + public function save(Task $task): void; + public function create(array $data): Task; + public function delete(string $projectId, string $taskId): void; +} +``` + +#### Responsibilities + +- read/write markdown +- preserve unknown front matter keys +- handle file paths +- ensure atomic writes + +### 5.3 BoardRepository + +```php +class BoardRepository { + public function get(string $projectId): Board; + public function save(Board $board): void; +} +``` + +### 5.4 NoteRepository + +```php +class NoteRepository { + public function getAll(string $projectId): array; + public function save(Note $note): void; +} +``` + +## 6. Services + +### 6.1 BoardService + +```php +class BoardService { + public function getBoardState(string $projectId): array; + + public function moveTask( + string $projectId, + string $taskId, + string $column, + int $newOrder + ): void; +} +``` + +### 6.2 TaskOrderingService + +```php +class TaskOrderingService { + public function computeOrder(array $tasks, int $targetIndex): int; + public function rebalance(array $tasks): array; +} +``` + +#### Rule + +- Use gaps: 100, 200, 300 +- Insert = midpoint +- Rebalance only if gap < threshold + +### 6.3 GitService + +```php +class GitService { + public function commit(string $message): void; +} +``` + +#### Implementation (MVP) + +```bash +git add . +git commit -m "..." +``` + +#### Future + +- scoped add +- branch support + +### 6.4 RevisionService + +```php +class RevisionService { + public function get(string $projectId): string; + public function bump(string $projectId): void; +} +``` + +#### Storage + +```plain +/data/projects/{project}/.revision +``` + +### 6.5 FileLock + +```php +class FileLock { + public static function run(string $path, callable $callback); +} +``` + +Uses `flock()`. + +## 7. API Specification + +All endpoints return JSON. + +### 7.1 GET `/api/board-state.php` + +#### Query + +`?project=personal-projects` + +#### Response + +```json +{ + "project": {}, + "board": {}, + "columns": [], + "tasks": [], + "notes": [], + "revision": "timestamp" +} +``` + +### 7.2 POST `/api/move-task.php` + +#### Input + +```json +{ + "projectId": "personal-projects", + "taskId": "task-123", + "column": "doing", + "index": 2 +} +``` + +#### Flow + +```plain +load tasks in column → +compute order → +update task → +save → +git commit → +revision bump +``` + +### 7.3 POST `/api/create-task.php` + +```json +{ + "projectId": "...", + "title": "...", + "column": "backlog", + "body": "" +} +``` + +### 7.4 POST `/api/save-task.php` + +Full update: + +```json +{ + "projectId": "...", + "taskId": "...", + "title": "...", + "body": "...", + "meta": {} +} +``` + +### 7.5 POST `/api/delete-task.php` + +```json +{ + "projectId": "...", + "taskId": "..." +} +``` + +### 7.6 POST `/api/create-column.php` + +```json +{ + "projectId": "...", + "label": "Blocked" +} +``` + +### 7.7 POST `/api/update-board.php` + +Reorder / rename columns. + +### 7.8 POST `/api/save-note.php` + +### 7.9 GET `/api/events.php` (Phase 2) + +SSE stream: + +```plain +event: boardUpdated +data: { "revision": "..." } +``` + +## 8. Frontend Spec + +### 8.1 Stack + +- Vanilla JS +- SortableJS (drag/drop) +- Server-rendered HTML + JS hydration + +### 8.2 State model + +```javaScript +const state = { + projectId: null, + columns: [], + tasks: [], + revision: null +}; +``` + +### 8.3 Drag/drop flow + +```javaScript +onEnd → + get taskId + get new column + get index + compute optimistic order + update DOM + POST move-task + if fail → revert +``` + +### 8.4 Polling loop (MVP) + +```javaScript +setInterval(async () => { + const res = await fetch('/api/board-state?project=...'); + if (res.revision !== state.revision) { + reloadBoard(); + } +}, 5000); +``` + +### 8.5 SSE (Phase 2) + +```javaScript +const evt = new EventSource('/api/events.php?project=...'); +evt.onmessage = () => reloadBoard(); +``` + +## 9. Migration Logic + +### 9.1 Missing board.md + +```plain +scan tasks → +collect unique section values → +generate columns → +write board.md +``` + +### 9.2 Missing column + +```php +if (!column && section) { + column = slugify(section); +} +``` + +### 9.3 Missing order + +Assign: + +`order = index * 100;` + +## 10. File Write Rules + +### 10.1 Atomic write + +```plain +write temp file → +fsync → +rename → +unlock +``` + +### 10.2 Preserve unknown metadata + +Never discard keys. + +### 10.3 Encoding + +UTF-8 only. + +## 11. Error Handling + +### 11.1 API response format + +```json +{ + "success": false, + "error": "message" +} +``` + +### 11.2 Conflict detection + +If revision mismatch: + +```json +{ + "success": false, + "error": "conflict" +} +``` + +## 12. Security + +- No path traversal +- Sanitize projectId +- Whitelist file extensions +- Escape shell commands +- Run git in repo root only + +## 13. Performance Considerations + +- Cache parsed board in memory per request +- Avoid re-reading unchanged files +- Lazy-load notes if needed +- Debounce save operations (frontend) + +## 14. MVP Milestones + +### Phase 1 + +- File parsing +- Board rendering +- Basic UI + +### Phase 2 + +- Drag/drop +- Save APIs + +### Phase 3 + +- Task CRUD +- Column CRUD + +### Phase 4 + +- Git integration +- Revision tracking + +### Phase 5 + +- Polling updates + +### Phase 6 (optional) + +- SSE + +## 15. Out-of-Scope (MVP) + +- Auth system +- Multi-repo support +- Comments +- Subtasks +- Attachments +- Permissions +- Websockets + +## 16. Build Order (Practical) + +I suggest this order: + +1. Markdown parser + writer +2. TaskRepository +3. BoardRepository +4. BoardService (read-only) +5. Render board (no JS yet) +6. Add SortableJS +7. Move-task endpoint +8. Create/edit task +9. GitService +10. Revision + polling + +## 17. Key Architectural Decisions (Locked) + +These are your “don’t rethink this at 2am” decisions: + +- ✅ tasks remain flat files in /tasks +- ✅ kanban state = column in front matter +- ✅ board config = board.md +- ✅ ordering = sparse integers +- ✅ git is first-class +- ✅ PHP handles everything server-side +- ✅ SortableJS is allowed +- ❌ no folder-per-column model +- ❌ no heavy framework diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e6e71e --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# IronKanban + +IronKanban is a lightweight PHP and vanilla JavaScript app for managing markdown-backed projects as a kanban board. It keeps project notes and tasks as flat files, stores board column configuration in `board.md`, and updates the filesystem whenever cards are moved or edited. + +## Features + +- Git-backed markdown storage under `storage/projects` by default +- Server-rendered project view with dark responsive UI +- SortableJS drag-and-drop for columns and task cards +- Sparse integer ordering with rebalance support +- Task create, edit, move, trash, and permanent delete flows +- Note create and edit flow +- Revision file polling for real-time-ish refreshes +- Best-effort git commits after project changes +- Sensible default columns for brand-new projects: Backlog, Ready, In Progress, Review, Done, and Trash + +## Project Layout + +```plain +storage/projects/{projectSlug}/ +├── index.md +├── board.md +├── notes/ +│ └── *.md +└── tasks/ + └── *.md +``` + +## Running Locally + +```bash +php -S localhost:8000 -t public +``` + +Then open `http://localhost:8000`. + +To point at a different projects directory, set `IKB_PROJECT_ROOT`. diff --git a/bootstrap.php b/bootstrap.php new file mode 100644 index 0000000..3d0177b --- /dev/null +++ b/bootstrap.php @@ -0,0 +1,33 @@ + 'IronKanban', + 'base_path' => $rootPath, + 'project_root' => $projectRoot !== false && $projectRoot !== '' + ? $projectRoot + : $rootPath . DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR . 'projects', +]; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1dc99a5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "ironkanban", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ironkanban", + "devDependencies": { + "@playwright/test": "^1.54.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ceb47de --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "ironkanban", + "private": true, + "scripts": { + "test:e2e": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.54.2" + } +} diff --git a/phpcs-results.txt b/phpcs-results.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/phpcs-results.txt @@ -0,0 +1 @@ + diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..8981f33 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,29 @@ + + + Coding Style Checks + + + + + + + + vendor/ + node_modules/ + + + + + + + + + + + + + + + 0 + + diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..cf5592e --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,12 @@ + + + + + tests + + + diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..3f5a0bf --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,19 @@ +// @ts-check + +const { defineConfig } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + reporter: 'list', + use: { + baseURL: 'http://127.0.0.1:8101', + trace: 'on-first-retry', + }, + webServer: { + command: 'php -S 127.0.0.1:8101 -t public', + url: 'http://127.0.0.1:8101', + reuseExistingServer: true, + timeout: 30000, + }, +}); diff --git a/public/api/_bootstrap.php b/public/api/_bootstrap.php new file mode 100644 index 0000000..9be6ebc --- /dev/null +++ b/public/api/_bootstrap.php @@ -0,0 +1,14 @@ +getBoardState($projectId)); +} catch (HttpHalt $halt) { + return; +} catch (Throwable $exception) { + Http::error($exception->getMessage(), 500); +} diff --git a/public/api/create-column.php b/public/api/create-column.php new file mode 100644 index 0000000..59103ac --- /dev/null +++ b/public/api/create-column.php @@ -0,0 +1,35 @@ +assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null); + + Http::json( + [ + 'success' => true, + 'state' => $boardService->createColumn( + (string) $input['projectId'], + trim((string) ($input['label'] ?? 'Untitled Column')) + ), + ] + ); +} catch (HttpHalt $halt) { + return; +} catch (Throwable $exception) { + Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500); +} diff --git a/public/api/create-task.php b/public/api/create-task.php new file mode 100644 index 0000000..1e7877a --- /dev/null +++ b/public/api/create-task.php @@ -0,0 +1,37 @@ +assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null); + + Http::json( + [ + 'success' => true, + 'state' => $boardService->createTask( + (string) $input['projectId'], + trim((string) ($input['title'] ?? 'Untitled Task')), + (string) ($input['column'] ?? 'backlog'), + (string) ($input['body'] ?? '') + ), + ] + ); +} catch (HttpHalt $halt) { + return; +} catch (Throwable $exception) { + Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500); +} diff --git a/public/api/delete-task.php b/public/api/delete-task.php new file mode 100644 index 0000000..445cc5a --- /dev/null +++ b/public/api/delete-task.php @@ -0,0 +1,36 @@ +assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null); + + Http::json( + [ + 'success' => true, + 'state' => $boardService->deleteTask( + (string) $input['projectId'], + (string) $input['taskId'], + (bool) ($input['permanent'] ?? false) + ), + ] + ); +} catch (HttpHalt $halt) { + return; +} catch (Throwable $exception) { + Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500); +} diff --git a/public/api/move-task.php b/public/api/move-task.php new file mode 100644 index 0000000..031cf16 --- /dev/null +++ b/public/api/move-task.php @@ -0,0 +1,37 @@ +assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null); + + Http::json( + [ + 'success' => true, + 'state' => $boardService->moveTask( + (string) $input['projectId'], + (string) $input['taskId'], + (string) $input['column'], + max(0, (int) ($input['index'] ?? 0)) + ), + ] + ); +} catch (HttpHalt $halt) { + return; +} catch (Throwable $exception) { + Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500); +} diff --git a/public/api/save-note.php b/public/api/save-note.php new file mode 100644 index 0000000..18cc905 --- /dev/null +++ b/public/api/save-note.php @@ -0,0 +1,35 @@ +assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null); + + Http::json( + [ + 'success' => true, + 'state' => $boardService->saveNote( + (string) $input['projectId'], + $input + ), + ] + ); +} catch (HttpHalt $halt) { + return; +} catch (Throwable $exception) { + Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500); +} diff --git a/public/api/save-task.php b/public/api/save-task.php new file mode 100644 index 0000000..846287a --- /dev/null +++ b/public/api/save-task.php @@ -0,0 +1,36 @@ +assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null); + + Http::json( + [ + 'success' => true, + 'state' => $boardService->saveTask( + (string) $input['projectId'], + (string) $input['taskId'], + $input + ), + ] + ); +} catch (HttpHalt $halt) { + return; +} catch (Throwable $exception) { + Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500); +} diff --git a/public/api/update-board.php b/public/api/update-board.php new file mode 100644 index 0000000..96e0223 --- /dev/null +++ b/public/api/update-board.php @@ -0,0 +1,36 @@ +assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null); + + Http::json( + [ + 'success' => true, + 'state' => $boardService->updateBoard( + (string) $input['projectId'], + is_array($input['columns'] ?? null) ? $input['columns'] : [], + isset($input['deletedColumnId']) ? (string) $input['deletedColumnId'] : null + ), + ] + ); +} catch (HttpHalt $halt) { + return; +} catch (Throwable $exception) { + Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500); +} diff --git a/public/assets/app.css b/public/assets/app.css new file mode 100644 index 0000000..e475aa0 --- /dev/null +++ b/public/assets/app.css @@ -0,0 +1,551 @@ +:root { + --bg: #0b0f14; + --panel: rgba(18, 27, 38, 0.94); + --border: rgba(160, 190, 220, 0.16); + --text: #f3f7fb; + --muted: #9fb2c7; + --accent: #7dd3a7; + --warning: #f6c76a; + --shadow: 0 24px 60px rgba(0, 0, 0, 0.35); + --radius: 22px; + --font-sans: "Segoe UI", "Aptos", "Helvetica Neue", sans-serif; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; + background: + radial-gradient(circle at top left, rgba(125, 211, 167, 0.18), transparent 28%), + radial-gradient(circle at top right, rgba(98, 160, 255, 0.16), transparent 24%), + linear-gradient(180deg, #071018 0%, #0b0f14 100%); + color: var(--text); + font-family: var(--font-sans); +} + +button, +input, +textarea, +select { + font: inherit; +} + +code { + font-family: "Cascadia Code", Consolas, monospace; +} + +.app-shell { + display: grid; + grid-template-columns: minmax(260px, 304px) 1fr; + min-height: 100vh; +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 24px; + padding: 28px; + border-right: 1px solid var(--border); + background: rgba(10, 16, 24, 0.75); + backdrop-filter: blur(14px); +} + +.workspace { + padding: 28px; + min-width: 0; +} + +.workspace-stack { + display: grid; + gap: 20px; + min-width: 0; +} + +.eyebrow { + margin: 0 0 8px; + color: var(--accent); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +h1, +h2, +h3, +h4, +p { + margin-top: 0; +} + +.sidebar-copy, +.project-description, +.project-root p, +.note-preview, +.column-meta, +.task-card p { + color: var(--muted); +} + +.section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.project-list, +.project-root, +.board-panel, +.notes-panel, +.trash-panel, +.hero { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--panel); + box-shadow: var(--shadow); +} + +.project-list, +.project-root, +.board-panel, +.notes-panel, +.trash-panel, +.hero { + padding: 18px; +} + +.board-panel, +.notes-panel, +.trash-panel { + min-width: 0; + overflow: hidden; +} + +.project-link { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; + padding: 14px; + border: 1px solid transparent; + border-radius: 16px; + color: inherit; + text-decoration: none; + background: rgba(255, 255, 255, 0.02); +} + +.project-link:hover, +.project-link.active { + border-color: rgba(125, 211, 167, 0.45); + background: rgba(125, 211, 167, 0.1); +} + +.project-link span { + color: var(--muted); + font-size: 0.85rem; +} + +.workspace-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin-bottom: 22px; +} + +.workspace-header > div, +.notes-panel-header > div, +.column-title-wrap { + min-width: 0; +} + +.notes-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.trash-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.notes-panel-header h2 { + margin-bottom: 0; +} + +.trash-panel-header h2 { + margin-bottom: 0; +} + +.notes-panel-actions { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + min-width: 0; +} + +.header-actions, +.dialog-actions { + display: flex; + gap: 12px; + align-items: center; +} + +.button, +.icon-button, +.column-actions button, +.task-mini-actions button { + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.03); + color: var(--text); + cursor: pointer; +} + +.button { + padding: 11px 18px; + border-radius: 999px; +} + +.button.primary { + border-color: transparent; + background: linear-gradient(135deg, var(--accent) 0%, #70e0c6 100%); + color: #0b1410; + font-weight: 700; +} + +.button.compact { + padding: 8px 14px; +} + +.board-scroll { + overflow-x: auto; + max-width: 100%; + padding-bottom: 8px; +} + +.trash-columns-wrap { + margin-top: 18px; +} + +.trash-dropzone { + display: grid; + gap: 6px; + margin-top: 18px; + padding: 18px; + border: 1px dashed rgba(246, 199, 106, 0.42); + border-radius: 18px; + background: rgba(246, 199, 106, 0.08); + color: var(--muted); + min-height: 92px; + align-content: center; + transition: + border-color 160ms ease, + background 160ms ease, + color 160ms ease; +} + +.trash-dropzone-title, +.trash-dropzone-meta { + margin-bottom: 0; +} + +.trash-dropzone.sortable-ghost, +.trash-dropzone.sortable-chosen, +.trash-dropzone.is-active-dropzone { + border-color: rgba(246, 199, 106, 0.78); + background: rgba(246, 199, 106, 0.16); + color: var(--text); +} + +.board-columns { + display: flex; + gap: 18px; + align-items: flex-start; + min-height: 60vh; + width: max-content; + min-width: 100%; +} + +.column { + display: flex; + flex-direction: column; + width: min(320px, calc(100vw - 72px)); + min-width: 280px; + border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 20px; + background: linear-gradient(180deg, rgba(28, 39, 54, 0.96), rgba(18, 25, 36, 0.96)); +} + +.trash-panel .column { + width: min(360px, 100%); +} + +.column-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.column-title-wrap h3 { + margin-bottom: 4px; +} + +.column-actions, +.task-mini-actions { + display: flex; + gap: 8px; +} + +.column-actions button, +.task-mini-actions button, +.icon-button { + width: 34px; + height: 34px; + border-radius: 12px; +} + +.task-list { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + min-height: 140px; +} + +.task-card, +.note-card { + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 18px; + padding: 14px; + background: rgba(8, 12, 18, 0.55); + min-width: 0; + overflow-wrap: anywhere; +} + +.task-card { + cursor: pointer; +} + +.task-card.is-completed { + opacity: 0.7; +} + +.pill { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + background: rgba(125, 211, 167, 0.12); + color: var(--accent); + font-size: 0.8rem; +} + +.pill.priority-high, +.pill.priority-urgent { + background: rgba(246, 199, 106, 0.14); + color: var(--warning); +} + +.pill.priority-low { + background: rgba(98, 160, 255, 0.14); + color: #8ec1ff; +} + +.notes-list { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-top: 18px; + min-width: 0; +} + +.notes-panel.is-collapsed .notes-list { + display: none; +} + +.notes-panel.is-expanded .notes-list { + display: grid; +} + +.note-card { + cursor: pointer; + min-height: 160px; + display: flex; + flex-direction: column; +} + +.note-card h4, +.task-card h4, +.column-title-wrap h3, +.project-description, +.note-preview, +.task-card p { + overflow-wrap: anywhere; +} + +.note-preview { + flex: 1; +} + +.collapse-toggle { + min-width: 122px; +} + +.collapse-toggle::after { + content: "▾"; + margin-left: 8px; + font-size: 0.9rem; +} + +.collapse-toggle[aria-expanded="true"]::after { + content: "▴"; +} + +.dialog { + width: min(720px, calc(100vw - 24px)); + border: 0; + padding: 0; + background: transparent; +} + +.dialog::backdrop { + background: rgba(2, 5, 10, 0.72); + backdrop-filter: blur(5px); +} + +.dialog-card { + display: grid; + gap: 16px; + padding: 20px; + border: 1px solid var(--border); + border-radius: 24px; + background: #0e1620; + color: var(--text); +} + +.dialog label { + display: grid; + gap: 8px; +} + +.dialog input[type="text"], +.dialog textarea, +.dialog select { + width: 100%; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 14px; + background: rgba(255, 255, 255, 0.03); + color: var(--text); +} + +.checkbox-row { + grid-template-columns: auto 1fr; + align-items: center; +} + +.empty-state { + padding: 24px; + text-align: center; +} + +.empty-state.compact { + padding: 12px 0 0; + text-align: left; +} + +.sortable-ghost { + opacity: 0.35; +} + +.task-card-footer { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + margin-top: 12px; + flex-wrap: wrap; +} + +.task-tags { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +@media (max-width: 1080px) { + .app-shell { + grid-template-columns: 1fr; + } + + .sidebar { + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .notes-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .workspace, + .sidebar { + padding: 18px; + } + + .workspace-header { + flex-direction: column; + } + + .notes-panel-header { + flex-direction: column; + } + + .trash-panel-header { + flex-direction: column; + } + + .header-actions { + width: 100%; + } + + .notes-panel-actions { + width: 100%; + } + + .header-actions .button { + flex: 1; + } + + .notes-panel-actions .button { + flex: 1; + } + + .trash-panel .column { + width: 100%; + } + + .column { + width: min(320px, calc(100vw - 52px)); + } + + .notes-list { + grid-template-columns: 1fr; + } +} diff --git a/public/assets/app.js b/public/assets/app.js new file mode 100644 index 0000000..0c0e9e9 --- /dev/null +++ b/public/assets/app.js @@ -0,0 +1,619 @@ +const initialStateNode = document.getElementById('initial-state'); +const appRoot = document.getElementById('app'); + +if (initialStateNode && appRoot && initialStateNode.textContent !== 'null') { + const state = JSON.parse(initialStateNode.textContent); + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''; + const boardColumnsEl = document.getElementById('board-columns'); + const trashColumnsEl = document.getElementById('trash-columns'); + const trashDropzoneEl = document.getElementById('trash-dropzone'); + const trashDropzoneMetaEl = document.getElementById('trash-dropzone-meta'); + const notesPanelEl = document.getElementById('notes-panel'); + const notesListEl = document.getElementById('notes-list'); + const notesToggleEl = document.querySelector('[data-action="toggle-notes"]'); + const trashWrapEl = document.getElementById('trash-columns-wrap'); + const trashToggleEl = document.querySelector('[data-action="toggle-trash"]'); + const trashPanelEl = document.getElementById('trash-panel'); + const taskDialog = document.getElementById('task-dialog'); + const noteDialog = document.getElementById('note-dialog'); + const taskForm = document.getElementById('task-form'); + const noteForm = document.getElementById('note-form'); + const projectId = appRoot.dataset.projectId; + const uiState = { + notesExpanded: false, + trashExpanded: false, + trashPinned: false, + }; + + let columnSortable = null; + const taskSortables = []; + let trashAutoCollapseTimer = null; + + const escapeHtml = (value = '') => + value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + + const markdownPreview = (value = '') => escapeHtml(value).replaceAll('\n', '
'); + + const fetchJson = async (url, options = {}) => { + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, + ...(options.headers || {}), + }, + ...options, + }); + + const payload = await response.json(); + + if (!response.ok || payload.success === false) { + throw new Error(payload.error || 'Request failed'); + } + + return payload; + }; + + const updateState = (nextState) => { + state.project = nextState.project; + state.board = nextState.board; + state.columns = nextState.columns; + state.tasks = nextState.tasks; + state.notes = nextState.notes; + state.revision = nextState.revision; + render(); + }; + + const taskForId = (taskId) => state.tasks.find((task) => task.id === taskId); + const noteForId = (noteId) => state.notes.find((note) => note.id === noteId); + const priorityClass = (priority) => `priority-${priority || 'normal'}`; + const notesToggleLabel = (count, isExpanded) => + isExpanded ? `Hide Notes (${count})` : `Show Notes (${count})`; + const trashToggleLabel = (count, isExpanded) => + isExpanded ? `Hide Trash (${count})` : `Show Trash (${count})`; + const sortedColumns = () => state.columns.slice().sort((a, b) => a.order - b.order); + const boardColumns = () => sortedColumns().filter((column) => column.id !== 'trash'); + const trashColumn = () => state.columns.find((column) => column.id === 'trash') || null; + + const tasksByColumn = () => + state.tasks.reduce((acc, task) => { + if (!acc[task.column]) { + acc[task.column] = []; + } + + acc[task.column].push(task); + return acc; + }, {}); + + const renderColumn = (column, tasks) => ` +
+
+
+

${escapeHtml(column.label)}

+

${tasks.length} task${tasks.length === 1 ? '' : 's'}

+
+
+ + +
+
+
+ ${tasks + .map( + (task) => ` +
+

${escapeHtml(task.title)}

+

${markdownPreview(task.body.slice(0, 160))}

+ +
+ ` + ) + .join('')} +
+
+ `; + + const renderBoard = () => { + const tasksByColumnMap = tasksByColumn(); + + boardColumnsEl.innerHTML = boardColumns() + .map((column) => { + const tasks = (tasksByColumnMap[column.id] || []).slice().sort((a, b) => a.order - b.order); + + return renderColumn(column, tasks); + }) + .join(''); + + const trash = trashColumn(); + const trashTasks = trash + ? (tasksByColumnMap[trash.id] || []).slice().sort((a, b) => a.order - b.order) + : []; + + if (trashDropzoneEl) { + trashDropzoneEl.querySelectorAll('.task-card').forEach((taskCard) => taskCard.remove()); + } + + if (trashPanelEl && trashWrapEl && trashToggleEl && trashColumnsEl) { + trashPanelEl.hidden = trash === null; + + if (trash === null) { + trashColumnsEl.innerHTML = ''; + if (trashDropzoneMetaEl) { + trashDropzoneMetaEl.textContent = 'Trash is unavailable.'; + } + return; + } + + trashColumnsEl.innerHTML = renderColumn(trash, trashTasks); + if (trashDropzoneMetaEl) { + trashDropzoneMetaEl.textContent = + trashTasks.length === 0 + ? 'Trash is empty.' + : `${trashTasks.length} task${trashTasks.length === 1 ? '' : 's'} in trash.`; + } + setTrashExpanded(uiState.trashExpanded, trashTasks.length); + } + }; + + const renderNotes = () => { + notesListEl.innerHTML = + state.notes.length === 0 + ? '

No notes yet.

' + : state.notes + .map( + (note) => ` +
+

${escapeHtml(note.title)}

+

${markdownPreview((note.body || '').slice(0, 200))}

+
+ ` + ) + .join(''); + }; + + const setNotesExpanded = (isExpanded) => { + if (!notesListEl || !notesToggleEl || !notesPanelEl) { + return; + } + + const noteCount = state.notes.length; + uiState.notesExpanded = isExpanded; + notesListEl.hidden = !isExpanded; + notesListEl.setAttribute('aria-hidden', String(!isExpanded)); + notesPanelEl.classList.toggle('is-expanded', isExpanded); + notesPanelEl.classList.toggle('is-collapsed', !isExpanded); + notesToggleEl.setAttribute('aria-expanded', String(isExpanded)); + notesToggleEl.textContent = notesToggleLabel(noteCount, isExpanded); + }; + + const setTrashExpanded = (isExpanded, count = 0, pinned = uiState.trashPinned) => { + if (!trashWrapEl || !trashToggleEl) { + return; + } + + uiState.trashPinned = pinned; + uiState.trashExpanded = isExpanded; + trashWrapEl.hidden = !isExpanded; + trashToggleEl.setAttribute('aria-expanded', String(isExpanded)); + trashToggleEl.textContent = trashToggleLabel(count, isExpanded); + }; + + const revealTrashTemporarily = () => { + const trashTaskCount = state.tasks.filter((task) => task.column === 'trash').length; + + if (!trashPanelEl || trashTaskCount === 0) { + return; + } + + if (trashAutoCollapseTimer !== null) { + window.clearTimeout(trashAutoCollapseTimer); + trashAutoCollapseTimer = null; + } + + setTrashExpanded(true, trashTaskCount, false); + trashPanelEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + + trashAutoCollapseTimer = window.setTimeout(() => { + if (!uiState.trashPinned) { + setTrashExpanded(false, state.tasks.filter((task) => task.column === 'trash').length, false); + } + + trashAutoCollapseTimer = null; + }, 4000); + }; + + const destroySortables = () => { + if (columnSortable) { + columnSortable.destroy(); + columnSortable = null; + } + + while (taskSortables.length > 0) { + taskSortables.pop().destroy(); + } + }; + + const saveColumns = async (deletedColumnId = null) => { + const columns = [...boardColumnsEl.querySelectorAll('.column')].map((columnEl, index) => ({ + id: columnEl.dataset.columnId, + label: columnEl.querySelector('h3')?.textContent || columnEl.dataset.columnId, + order: (index + 1) * 100, + })); + const trash = trashColumn(); + + if (trash) { + columns.push({ ...trash, order: (columns.length + 1) * 100 }); + } + + const payload = await fetchJson('/api/update-board.php', { + method: 'POST', + body: JSON.stringify({ projectId, revision: state.revision, columns, deletedColumnId }), + }); + + updateState(payload.state); + }; + + const mountSortables = () => { + destroySortables(); + + columnSortable = new Sortable(boardColumnsEl, { + animation: 180, + draggable: '.column', + ghostClass: 'sortable-ghost', + onEnd: () => { + saveColumns().catch(handleError); + }, + }); + + document.querySelectorAll('.task-list').forEach((listEl) => { + const sortable = new Sortable(listEl, { + group: 'tasks', + animation: 180, + draggable: '.task-card', + ghostClass: 'sortable-ghost', + onEnd: async (event) => { + try { + const payload = await fetchJson('/api/move-task.php', { + method: 'POST', + body: JSON.stringify({ + projectId, + taskId: event.item.dataset.taskId, + column: event.to.dataset.columnId, + index: event.newIndex ?? 0, + revision: state.revision, + }), + }); + + updateState(payload.state); + + if (event.to.dataset.columnId === 'trash') { + revealTrashTemporarily(); + } + } catch (error) { + handleError(error); + reloadBoard(); + } + }, + }); + + taskSortables.push(sortable); + }); + + if (trashDropzoneEl) { + const trashDropzoneSortable = new Sortable(trashDropzoneEl, { + group: 'tasks', + animation: 180, + draggable: '.task-card', + ghostClass: 'sortable-ghost', + onStart: () => { + trashDropzoneEl.classList.add('is-active-dropzone'); + }, + onEnd: async (event) => { + trashDropzoneEl.classList.remove('is-active-dropzone'); + + if (event.to !== trashDropzoneEl) { + return; + } + + try { + const payload = await fetchJson('/api/move-task.php', { + method: 'POST', + body: JSON.stringify({ + projectId, + taskId: event.item.dataset.taskId, + column: 'trash', + index: 0, + revision: state.revision, + }), + }); + + updateState(payload.state); + revealTrashTemporarily(); + } catch (error) { + handleError(error); + reloadBoard(); + } + }, + }); + + taskSortables.push(trashDropzoneSortable); + } + }; + + const render = () => { + renderBoard(); + renderNotes(); + mountSortables(); + setNotesExpanded(uiState.notesExpanded); + }; + + const populateTaskDialog = (task = null) => { + taskForm.reset(); + taskForm.elements.id.value = task?.id || ''; + taskForm.elements.title.value = task?.title || ''; + taskForm.elements.priority.value = task?.priority || 'normal'; + taskForm.elements.body.value = task?.body || ''; + taskForm.elements.completed.checked = Boolean(task?.completed); + taskForm.elements.is_active.checked = task ? Boolean(task.is_active) : true; + taskForm.querySelector('[data-action="trash-task"]').hidden = !task; + taskForm.elements.column.innerHTML = state.columns + .map( + (column) => + `` + ) + .join(''); + }; + + const populateNoteDialog = (note = null) => { + noteForm.reset(); + noteForm.elements.id.value = note?.id || ''; + noteForm.elements.title.value = note?.title || ''; + noteForm.elements.body.value = note?.body || ''; + }; + + const openTaskDialog = (taskId = null) => { + populateTaskDialog(taskId ? taskForId(taskId) : null); + taskDialog.showModal(); + }; + + const openNoteDialog = (noteId = null) => { + populateNoteDialog(noteId ? noteForId(noteId) : null); + noteDialog.showModal(); + }; + + const handleError = (error) => { + window.alert(error.message || 'Something went wrong.'); + }; + + const reloadBoard = async () => { + const response = await fetch(`/api/board-state.php?project=${encodeURIComponent(projectId)}`); + updateState(await response.json()); + }; + + appRoot.addEventListener('click', async (event) => { + const button = event.target.closest('[data-action]'); + const taskCard = event.target.closest('.task-card'); + const noteCard = event.target.closest('.note-card'); + + if (taskCard && !button) { + openTaskDialog(taskCard.dataset.taskId); + return; + } + + if (noteCard && !button) { + openNoteDialog(noteCard.dataset.noteId); + return; + } + + if (!button) { + return; + } + + try { + if (button.dataset.action === 'new-task') { + openTaskDialog(); + } + + if (button.dataset.action === 'new-note') { + openNoteDialog(); + setNotesExpanded(true); + } + + if (button.dataset.action === 'toggle-notes') { + setNotesExpanded(!uiState.notesExpanded); + } + + if (button.dataset.action === 'toggle-trash') { + const trashTaskCount = state.tasks.filter((task) => task.column === 'trash').length; + const nextExpanded = !uiState.trashExpanded; + + if (trashAutoCollapseTimer !== null) { + window.clearTimeout(trashAutoCollapseTimer); + trashAutoCollapseTimer = null; + } + + setTrashExpanded(nextExpanded, trashTaskCount, nextExpanded); + } + + if (button.dataset.action === 'new-column') { + const label = window.prompt('Column name'); + + if (label) { + updateState( + ( + await fetchJson('/api/create-column.php', { + method: 'POST', + body: JSON.stringify({ projectId, label, revision: state.revision }), + }) + ).state + ); + } + } + + if (button.dataset.action === 'edit-task') { + openTaskDialog(button.dataset.taskId); + } + + if (button.dataset.action === 'rename-column') { + const column = state.columns.find((item) => item.id === button.dataset.columnId); + const label = window.prompt('Rename column', column?.label || button.dataset.columnId); + + if (label) { + const columns = state.columns.map((item) => + item.id === button.dataset.columnId ? { ...item, label } : item + ); + updateState( + ( + await fetchJson('/api/update-board.php', { + method: 'POST', + body: JSON.stringify({ projectId, revision: state.revision, columns }), + }) + ).state + ); + } + } + + if (button.dataset.action === 'delete-column') { + if (button.dataset.columnId === 'trash') { + window.alert('Trash cannot be removed.'); + return; + } + + if (window.confirm('Delete this column and move its tasks to Trash?')) { + updateState( + ( + await fetchJson('/api/update-board.php', { + method: 'POST', + body: JSON.stringify({ + projectId, + revision: state.revision, + columns: state.columns, + deletedColumnId: button.dataset.columnId, + }), + }) + ).state + ); + } + } + } catch (error) { + handleError(error); + } + }); + + taskForm.addEventListener('submit', async (event) => { + event.preventDefault(); + + const formData = new FormData(taskForm); + const taskId = formData.get('id'); + const payload = { + projectId, + revision: state.revision, + title: formData.get('title'), + column: formData.get('column'), + body: formData.get('body'), + priority: formData.get('priority'), + completed: taskForm.elements.completed.checked, + is_active: taskForm.elements.is_active.checked, + meta: { + priority: formData.get('priority'), + completed: taskForm.elements.completed.checked, + is_active: taskForm.elements.is_active.checked, + }, + }; + + try { + updateState( + ( + await fetchJson(taskId ? '/api/save-task.php' : '/api/create-task.php', { + method: 'POST', + body: JSON.stringify(taskId ? { ...payload, taskId } : payload), + }) + ).state + ); + taskDialog.close(); + } catch (error) { + handleError(error); + } + }); + + noteForm.addEventListener('submit', async (event) => { + event.preventDefault(); + + const formData = new FormData(noteForm); + + try { + updateState( + ( + await fetchJson('/api/save-note.php', { + method: 'POST', + body: JSON.stringify({ + projectId, + revision: state.revision, + id: formData.get('id') || undefined, + title: formData.get('title'), + body: formData.get('body'), + }), + }) + ).state + ); + setNotesExpanded(true); + noteDialog.close(); + } catch (error) { + handleError(error); + } + }); + + taskForm.querySelector('[data-action="trash-task"]').addEventListener('click', async () => { + const taskId = taskForm.elements.id.value; + + if (!taskId) { + taskDialog.close(); + return; + } + + try { + updateState( + ( + await fetchJson('/api/delete-task.php', { + method: 'POST', + body: JSON.stringify({ projectId, taskId, revision: state.revision }), + }) + ).state + ); + revealTrashTemporarily(); + taskDialog.close(); + } catch (error) { + handleError(error); + } + }); + + window.setInterval(async () => { + try { + const response = await fetch(`/api/board-state.php?project=${encodeURIComponent(projectId)}`); + const payload = await response.json(); + + if (payload.revision !== state.revision) { + updateState(payload); + } + } catch (error) { + console.error(error); + } + }, 5000); + + render(); + setNotesExpanded(false); + setTrashExpanded(false, state.tasks.filter((task) => task.column === 'trash').length); +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..9a20857 --- /dev/null +++ b/public/index.php @@ -0,0 +1,228 @@ +getAll(); +$requestedProject = isset($_GET['project']) ? trim((string) $_GET['project']) : ''; +$activeProjectId = $requestedProject !== '' ? $requestedProject : ($projects[0]->id ?? ''); +$boardService = new BoardService(); +$initialState = null; + +if ($activeProjectId !== '') { + try { + $initialState = $boardService->getBoardState($activeProjectId); + } catch (Throwable $exception) { + $initialState = null; + } +} +?> + + + + + + + <?php echo e((string) config('app_name')); ?> + + + +
+ + +
+ +
+

No project selected

+

Create or copy a project folder into using the markdown layout from the notes.

+
+ +
+
+

Project Board

+

+

+
+
+ + +
+
+ +
+ + +
+
+
+
+
+ +
+
+
+

Discarded Tasks

+

Trash

+
+ +
+
+

Drop tasks here to discard them

+

Trash is empty.

+
+ +
+
+ +
+
+ + +
+
+

Task

+ +
+ + + + + + + +
+ + +
+
+
+ + +
+
+

Note

+ +
+ + + +
+ +
+
+
+ + + + + + diff --git a/src/Domain/Board.php b/src/Domain/Board.php new file mode 100644 index 0000000..ae49a67 --- /dev/null +++ b/src/Domain/Board.php @@ -0,0 +1,44 @@ + $columns Board columns. + * @param array $meta Additional board metadata. + */ + public function __construct( + public string $projectId, + public array $columns, + public array $meta = [] + ) { + } + + /** + * Convert the board into an API-friendly array. + * + * @return array + */ + public function toArray(): array + { + return [ + 'project_id' => $this->projectId, + 'columns' => array_map(static fn (Column $column) => $column->toArray(), $this->columns), + 'meta' => $this->meta, + ]; + } +} diff --git a/src/Domain/Column.php b/src/Domain/Column.php new file mode 100644 index 0000000..69dad86 --- /dev/null +++ b/src/Domain/Column.php @@ -0,0 +1,44 @@ + + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'label' => $this->label, + 'order' => $this->order, + ]; + } +} diff --git a/src/Domain/Note.php b/src/Domain/Note.php new file mode 100644 index 0000000..62c3040 --- /dev/null +++ b/src/Domain/Note.php @@ -0,0 +1,50 @@ + $meta Additional note metadata. + */ + public function __construct( + public string $id, + public string $title, + public string $projectId, + public string $body, + public array $meta = [] + ) { + } + + /** + * Convert the note into an API-friendly array. + * + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'project_id' => $this->projectId, + 'body' => $this->body, + 'meta' => $this->meta, + ]; + } +} diff --git a/src/Domain/Project.php b/src/Domain/Project.php new file mode 100644 index 0000000..aa0b019 --- /dev/null +++ b/src/Domain/Project.php @@ -0,0 +1,50 @@ + $meta Additional project metadata. + */ + public function __construct( + public string $id, + public string $title, + public string $path, + public string $body = '', + public array $meta = [] + ) { + } + + /** + * Convert the project into an API-friendly array. + * + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'path' => $this->path, + 'body' => $this->body, + 'meta' => $this->meta, + ]; + } +} diff --git a/src/Domain/Task.php b/src/Domain/Task.php new file mode 100644 index 0000000..35aee49 --- /dev/null +++ b/src/Domain/Task.php @@ -0,0 +1,65 @@ + $meta Additional task metadata. + */ + public function __construct( + public string $id, + public string $title, + public string $projectId, + public string $column, + public int $order, + public bool $completed, + public string $priority, + public bool $isActive, + public string $body, + public array $meta = [] + ) { + } + + /** + * Convert the task into an API-friendly array. + * + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'project_id' => $this->projectId, + 'column' => $this->column, + 'order' => $this->order, + 'completed' => $this->completed, + 'priority' => $this->priority, + 'is_active' => $this->isActive, + 'body' => $this->body, + 'meta' => $this->meta, + ]; + } +} diff --git a/src/Repository/BoardRepository.php b/src/Repository/BoardRepository.php new file mode 100644 index 0000000..f7e0d6b --- /dev/null +++ b/src/Repository/BoardRepository.php @@ -0,0 +1,171 @@ +boardPath($projectId); + + if (!is_file($boardPath)) { + $board = $this->createFromTasks($projectId); + $this->save($board); + + return $board; + } + + $document = FrontMatter::parseFile($boardPath); + $columnRows = is_array($document->meta['columns'] ?? null) ? $document->meta['columns'] : []; + $columns = []; + + foreach ($columnRows as $row) { + if (!is_array($row)) { + continue; + } + + $columns[] = new Column( + (string) ($row['id'] ?? ''), + (string) ($row['label'] ?? 'Untitled'), + (int) ($row['order'] ?? 0) + ); + } + + usort($columns, static fn (Column $left, Column $right): int => $left->order <=> $right->order); + + if ($columns === []) { + $board = $this->createFromTasks($projectId); + $this->save($board); + + return $board; + } + + return new Board($projectId, $columns, $document->meta); + } + + /** + * Persist a board definition. + * + * @param Board $board Board instance. + * + * @return void + */ + public function save(Board $board): void + { + $meta = $board->meta; + $meta['type'] = 'board'; + $meta['project_id'] = $board->projectId; + $meta['columns'] = array_map(static fn (Column $column): array => $column->toArray(), $board->columns); + $meta['updated'] = now_iso8601(); + + FileLock::run( + $this->lockFile($board->projectId), function () use ($board, $meta): void { + AtomicWriter::write($this->boardPath($board->projectId), FrontMatter::dump($meta, '')); + } + ); + } + + /** + * Build a default board from the project's tasks. + * + * @param string $projectId Project identifier. + * + * @return Board + */ + private function createFromTasks(string $projectId): Board + { + $tasks = $this->taskRepository->getAll($projectId); + $columnIds = []; + + foreach ($tasks as $task) { + $columnIds[$task->column] = ucwords(str_replace('-', ' ', $task->column)); + } + + if ($columnIds === []) { + $columnIds = [ + 'backlog' => 'Backlog', + 'ready' => 'Ready', + 'in-progress' => 'In Progress', + 'review' => 'Review', + 'done' => 'Done', + 'trash' => 'Trash', + ]; + } elseif (!isset($columnIds['trash'])) { + $columnIds['trash'] = 'Trash'; + } + + $order = 100; + $columns = []; + + foreach ($columnIds as $id => $label) { + $columns[] = new Column($id, $label, $order); + $order += 100; + } + + return new Board( + $projectId, $columns, [ + 'type' => 'board', + 'project_id' => $projectId, + ] + ); + } + + /** + * Resolve the board markdown path. + * + * @param string $projectId Project identifier. + * + * @return string + */ + private function boardPath(string $projectId): string + { + return Path::project($projectId) . DIRECTORY_SEPARATOR . 'board.md'; + } + + /** + * Resolve the board lock file path. + * + * @param string $projectId Project identifier. + * + * @return string + */ + private function lockFile(string $projectId): string + { + return Path::project($projectId) . DIRECTORY_SEPARATOR . '.board.lock'; + } +} diff --git a/src/Repository/NoteRepository.php b/src/Repository/NoteRepository.php new file mode 100644 index 0000000..96adf0c --- /dev/null +++ b/src/Repository/NoteRepository.php @@ -0,0 +1,112 @@ +notesPath($projectId); + ensure_directory($notesPath); + + $notes = []; + $files = glob($notesPath . DIRECTORY_SEPARATOR . '*.md') ?: []; + + foreach ($files as $file) { + $document = FrontMatter::parseFile($file); + $notes[] = new Note( + (string) ($document->meta['id'] ?? ''), + (string) ($document->meta['title'] ?? 'Untitled Note'), + (string) ($document->meta['project_id'] ?? $projectId), + $document->body, + $document->meta + ); + } + + usort( + $notes, + static fn (Note $left, Note $right): int => strcmp( + (string) ($right->meta['updated'] ?? ''), + (string) ($left->meta['updated'] ?? '') + ) + ); + + return $notes; + } + + /** + * Persist a note. + * + * @param Note $note Note instance. + * + * @return void + */ + public function save(Note $note): void + { + $path = $this->notesPath($note->projectId) . DIRECTORY_SEPARATOR . $note->id . '.md'; + $preserved = array_diff_key($note->meta, array_flip(['type', 'id', 'title', 'project_id', 'created', 'updated'])); + $created = $note->meta['created'] ?? now_iso8601(); + $meta = [ + 'type' => 'note', + 'id' => $note->id, + 'title' => $note->title, + 'project_id' => $note->projectId, + 'created' => $created, + 'updated' => now_iso8601(), + ] + $preserved; + + FileLock::run( + $this->lockFile($note->projectId), static function () use ($path, $meta, $note): void { + AtomicWriter::write($path, FrontMatter::dump($meta, $note->body)); + } + ); + } + + /** + * Resolve the notes directory path. + * + * @param string $projectId Project identifier. + * + * @return string + */ + private function notesPath(string $projectId): string + { + return Path::project($projectId) . DIRECTORY_SEPARATOR . 'notes'; + } + + /** + * Resolve the notes lock file path. + * + * @param string $projectId Project identifier. + * + * @return string + */ + private function lockFile(string $projectId): string + { + return Path::project($projectId) . DIRECTORY_SEPARATOR . '.notes.lock'; + } +} diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php new file mode 100644 index 0000000..4e96634 --- /dev/null +++ b/src/Repository/ProjectRepository.php @@ -0,0 +1,75 @@ +get($entry); + } + + usort( + $projects, + static fn (Project $left, Project $right): int => strcasecmp($left->title, $right->title) + ); + + return $projects; + } + + /** + * Load a single project by slug. + * + * @param string $slug Project slug. + * + * @return Project + */ + public function get(string $slug): Project + { + $projectPath = Path::project($slug); + $document = FrontMatter::parseFile($projectPath . DIRECTORY_SEPARATOR . 'index.md'); + + return new Project( + (string) ($document->meta['id'] ?? $slug), + (string) ($document->meta['title'] ?? $slug), + $projectPath, + $document->body, + $document->meta + ); + } +} diff --git a/src/Repository/TaskRepository.php b/src/Repository/TaskRepository.php new file mode 100644 index 0000000..9ed15fd --- /dev/null +++ b/src/Repository/TaskRepository.php @@ -0,0 +1,241 @@ +tasksPath($projectId); + ensure_directory($tasksPath); + + $tasks = []; + $files = glob($tasksPath . DIRECTORY_SEPARATOR . '*.md') ?: []; + + foreach ($files as $file) { + $tasks[] = $this->hydrate(FrontMatter::parseFile($file)); + } + + usort( + $tasks, static function (Task $left, Task $right): int { + if ($left->column === $right->column) { + return $left->order <=> $right->order ?: strcasecmp($left->title, $right->title); + } + + return strcmp($left->column, $right->column); + } + ); + + return $tasks; + } + + /** + * Load a single task by identifier. + * + * @param string $projectId Project identifier. + * @param string $taskId Task identifier. + * + * @return Task + */ + public function get(string $projectId, string $taskId): Task + { + Path::assertSlug($taskId); + $path = $this->taskFile($projectId, $taskId); + + if (!is_file($path)) { + throw new \RuntimeException('Task not found.'); + } + + return $this->hydrate(FrontMatter::parseFile($path)); + } + + /** + * Persist a task. + * + * @param Task $task Task instance. + * + * @return void + */ + public function save(Task $task): void + { + $path = $this->taskFile($task->projectId, $task->id); + $knownKeys = [ + 'type', + 'id', + 'title', + 'project_id', + 'column', + 'order', + 'completed', + 'priority', + 'is_active', + 'created', + 'updated', + ]; + $preserved = array_diff_key($task->meta, array_flip($knownKeys)); + $created = $task->meta['created'] ?? now_iso8601(); + + $meta = [ + 'type' => 'task', + 'id' => $task->id, + 'title' => $task->title, + 'project_id' => $task->projectId, + 'column' => $task->column, + 'order' => $task->order, + 'completed' => $task->completed, + 'priority' => $task->priority, + 'is_active' => $task->isActive, + 'created' => $created, + 'updated' => now_iso8601(), + ] + $preserved; + + FileLock::run( + $this->lockFile($task->projectId), static function () use ($path, $meta, $task): void { + AtomicWriter::write($path, FrontMatter::dump($meta, $task->body)); + } + ); + } + + /** + * Create and persist a new task from raw data. + * + * @param array $data Task seed data. + * + * @return Task + */ + public function create(array $data): Task + { + $task = new Task( + (string) ($data['id'] ?? generate_id('task')), + trim((string) ($data['title'] ?? 'Untitled Task')), + (string) $data['projectId'], + (string) ($data['column'] ?? 'backlog'), + (int) ($data['order'] ?? 100), + (bool) ($data['completed'] ?? false), + (string) ($data['priority'] ?? 'normal'), + (bool) ($data['is_active'] ?? true), + (string) ($data['body'] ?? ''), + [ + 'created' => now_iso8601(), + 'updated' => now_iso8601(), + 'tags' => [], + ] + (array) ($data['meta'] ?? []) + ); + + $this->save($task); + + return $task; + } + + /** + * Delete a persisted task if it exists. + * + * @param string $projectId Project identifier. + * @param string $taskId Task identifier. + * + * @return void + */ + public function delete(string $projectId, string $taskId): void + { + $path = $this->taskFile($projectId, $taskId); + + if (!is_file($path)) { + return; + } + + FileLock::run( + $this->lockFile($projectId), static function () use ($path): void { + if (!unlink($path)) { + throw new \RuntimeException('Unable to delete task.'); + } + } + ); + } + + /** + * Hydrate a task from a parsed markdown document. + * + * @param FrontMatterDocument $document Parsed document. + * + * @return Task + */ + private function hydrate(FrontMatterDocument $document): Task + { + $meta = $document->meta; + + return new Task( + (string) ($meta['id'] ?? ''), + (string) ($meta['title'] ?? 'Untitled Task'), + (string) ($meta['project_id'] ?? ''), + (string) ($meta['column'] ?? ($meta['section'] ?? 'backlog')), + (int) ($meta['order'] ?? 100), + (bool) ($meta['completed'] ?? false), + (string) ($meta['priority'] ?? 'normal'), + (bool) ($meta['is_active'] ?? true), + $document->body, + $meta + ); + } + + /** + * Resolve the tasks directory path. + * + * @param string $projectId Project identifier. + * + * @return string + */ + private function tasksPath(string $projectId): string + { + return Path::project($projectId) . DIRECTORY_SEPARATOR . 'tasks'; + } + + /** + * Resolve a task file path. + * + * @param string $projectId Project identifier. + * @param string $taskId Task identifier. + * + * @return string + */ + private function taskFile(string $projectId, string $taskId): string + { + return $this->tasksPath($projectId) . DIRECTORY_SEPARATOR . $taskId . '.md'; + } + + /** + * Resolve the task lock file path. + * + * @param string $projectId Project identifier. + * + * @return string + */ + private function lockFile(string $projectId): string + { + return Path::project($projectId) . DIRECTORY_SEPARATOR . '.task.lock'; + } +} diff --git a/src/Service/BoardService.php b/src/Service/BoardService.php new file mode 100644 index 0000000..f2d3ae2 --- /dev/null +++ b/src/Service/BoardService.php @@ -0,0 +1,515 @@ + + */ + public function getBoardState(string $projectId): array + { + $project = $this->projectRepository->get($projectId); + $board = $this->boardRepository->get($projectId); + $tasks = $this->normalizeTasks($projectId, $board, $this->taskRepository->getAll($projectId)); + $notes = $this->noteRepository->getAll($projectId); + + return [ + 'project' => $project->toArray(), + 'board' => $board->toArray(), + 'columns' => array_map(static fn (Column $column): array => $column->toArray(), $board->columns), + 'tasks' => array_map(static fn (Task $task): array => $task->toArray(), $tasks), + 'notes' => array_map(static fn (Note $note): array => $note->toArray(), $notes), + 'revision' => $this->revisionService->get($projectId), + ]; + } + + /** + * Move a task to a new column and index. + * + * @param string $projectId Project identifier. + * @param string $taskId Task identifier. + * @param string $columnId Destination column identifier. + * @param int $newIndex Destination position. + * + * @return array + */ + public function moveTask(string $projectId, string $taskId, string $columnId, int $newIndex): array + { + $board = $this->ensureTrashColumn($this->boardRepository->get($projectId)); + $tasks = $this->normalizeTasks($projectId, $board, $this->taskRepository->getAll($projectId)); + $task = $this->taskRepository->get($projectId, $taskId); + + if (!$this->columnExists($board, $columnId)) { + throw new \RuntimeException('Column not found.'); + } + + $sourceColumnTasks = array_values(array_filter($tasks, static fn (Task $item): bool => $item->column === $task->column && $item->id !== $taskId)); + $targetColumnTasks = array_values(array_filter($tasks, static fn (Task $item): bool => $item->column === $columnId && $item->id !== $taskId)); + + if ($task->column === $columnId) { + $targetColumnTasks = $sourceColumnTasks; + } + + if ($task->column !== $columnId || $this->needsRebalance($targetColumnTasks, $newIndex)) { + $targetColumnTasks = $this->taskOrderingService->rebalance($targetColumnTasks); + + foreach ($targetColumnTasks as $targetTask) { + $this->taskRepository->save($targetTask); + } + } + + if ($task->column !== $columnId) { + $sourceColumnTasks = $this->taskOrderingService->rebalance($sourceColumnTasks); + + foreach ($sourceColumnTasks as $sourceTask) { + $this->taskRepository->save($sourceTask); + } + } + + $task->column = $columnId; + $task->order = $this->taskOrderingService->computeOrder($targetColumnTasks, $newIndex); + $this->taskRepository->save($task); + + return $this->touchProject($projectId, sprintf('chore(board): move task %s', $taskId)); + } + + /** + * Create a new task in the given column. + * + * @param string $projectId Project identifier. + * @param string $title Task title. + * @param string $columnId Destination column identifier. + * @param string $body Markdown body. + * + * @return array + */ + public function createTask(string $projectId, string $title, string $columnId, string $body = ''): array + { + $board = $this->ensureTrashColumn($this->boardRepository->get($projectId)); + + if (!$this->columnExists($board, $columnId)) { + throw new \RuntimeException('Column not found.'); + } + + $tasks = $this->taskRepository->getAll($projectId); + $columnTasks = array_values(array_filter($tasks, static fn (Task $task): bool => $task->column === $columnId)); + $order = $this->taskOrderingService->computeOrder($columnTasks, count($columnTasks)); + + $task = $this->taskRepository->create( + [ + 'projectId' => $projectId, + 'title' => $title, + 'column' => $columnId, + 'body' => $body, + 'order' => $order, + ] + ); + + $state = $this->touchProject($projectId, sprintf('feat(task): create %s', $task->id)); + $state['task'] = $task->toArray(); + + return $state; + } + + /** + * Save changes to an existing task. + * + * @param string $projectId Project identifier. + * @param string $taskId Task identifier. + * @param array $payload Task payload. + * + * @return array + */ + public function saveTask(string $projectId, string $taskId, array $payload): array + { + $task = $this->taskRepository->get($projectId, $taskId); + $meta = isset($payload['meta']) && is_array($payload['meta']) ? $payload['meta'] : []; + + $task->title = trim((string) ($payload['title'] ?? $task->title)); + $task->body = (string) ($payload['body'] ?? $task->body); + $task->completed = (bool) ($meta['completed'] ?? $payload['completed'] ?? $task->completed); + $task->priority = (string) ($meta['priority'] ?? $payload['priority'] ?? $task->priority); + $task->isActive = (bool) ($meta['is_active'] ?? $payload['is_active'] ?? $task->isActive); + $task->meta = array_merge($task->meta, $meta); + + if (isset($payload['column']) && is_string($payload['column']) && $payload['column'] !== '') { + $task->column = $payload['column']; + } + + $this->taskRepository->save($task); + + return $this->touchProject($projectId, sprintf('feat(task): update %s', $taskId)); + } + + /** + * Delete a task or move it to trash. + * + * @param string $projectId Project identifier. + * @param string $taskId Task identifier. + * @param bool $permanent Whether to permanently delete. + * + * @return array + */ + public function deleteTask(string $projectId, string $taskId, bool $permanent = false): array + { + $task = $this->taskRepository->get($projectId, $taskId); + + if (!$permanent && $task->column !== 'trash') { + return $this->moveTask($projectId, $taskId, 'trash', PHP_INT_MAX); + } + + $this->taskRepository->delete($projectId, $taskId); + + return $this->touchProject($projectId, sprintf('chore(task): delete %s', $taskId)); + } + + /** + * Create a new board column. + * + * @param string $projectId Project identifier. + * @param string $label Column label. + * + * @return array + */ + public function createColumn(string $projectId, string $label): array + { + $board = $this->ensureTrashColumn($this->boardRepository->get($projectId)); + $id = slugify($label); + $existingIds = array_map(static fn (Column $column): string => $column->id, $board->columns); + $suffix = 2; + $baseId = $id; + + while (in_array($id, $existingIds, true)) { + $id = $baseId . '-' . $suffix; + $suffix++; + } + + $lastOrder = $board->columns === [] + ? 0 + : max(array_map(static fn (Column $column): int => $column->order, $board->columns)); + + $board->columns[] = new Column($id, trim($label), $lastOrder + 100); + $this->boardRepository->save($board); + + return $this->touchProject($projectId, sprintf('feat(board): create column %s', $id)); + } + + /** + * Replace the board column definitions. + * + * @param string $projectId Project identifier. + * @param array> $columns Column payloads. + * @param string|null $deletedColumnId Deleted column identifier. + * + * @return array + */ + public function updateBoard(string $projectId, array $columns, ?string $deletedColumnId = null): array + { + $board = $this->ensureTrashColumn($this->boardRepository->get($projectId)); + $existingLabels = []; + + foreach ($board->columns as $column) { + $existingLabels[$column->id] = $column->label; + } + + $newColumns = []; + $order = 100; + + foreach ($columns as $column) { + if (!is_array($column) || !isset($column['id'])) { + continue; + } + + $id = (string) $column['id']; + $label = trim((string) ($column['label'] ?? ($existingLabels[$id] ?? ucwords(str_replace('-', ' ', $id))))); + + if ($deletedColumnId !== null && $id === $deletedColumnId) { + continue; + } + + $newColumns[] = new Column($id, $label, $order); + $order += 100; + } + + if (!$this->columnExists(new Board($projectId, $newColumns), 'trash')) { + $newColumns[] = new Column('trash', 'Trash', $order); + } + + if ($deletedColumnId !== null && $deletedColumnId !== 'trash') { + $tasks = $this->taskRepository->getAll($projectId); + $trashTasks = array_values(array_filter($tasks, static fn (Task $task): bool => $task->column === 'trash')); + + foreach ($tasks as $task) { + if ($task->column !== $deletedColumnId) { + continue; + } + + $task->column = 'trash'; + $task->order = $this->taskOrderingService->computeOrder($trashTasks, count($trashTasks)); + $trashTasks[] = $task; + $this->taskRepository->save($task); + } + } + + $board->columns = $newColumns; + $this->boardRepository->save($board); + + return $this->touchProject($projectId, 'feat(board): update columns'); + } + + /** + * Create or update a project note. + * + * @param string $projectId Project identifier. + * @param array $payload Note payload. + * + * @return array + */ + public function saveNote(string $projectId, array $payload): array + { + $title = trim((string) ($payload['title'] ?? 'Untitled Note')); + $existingMeta = []; + + if (isset($payload['id']) && is_string($payload['id']) && $payload['id'] !== '') { + foreach ($this->noteRepository->getAll($projectId) as $note) { + if ($note->id === $payload['id']) { + $existingMeta = $note->meta; + break; + } + } + } + + $note = new Note( + (string) ($payload['id'] ?? generate_id('note')), + $title, + $projectId, + (string) ($payload['body'] ?? ''), + $existingMeta + ['created' => now_iso8601()] + ); + + $this->noteRepository->save($note); + + $state = $this->touchProject($projectId, sprintf('feat(note): save %s', $note->id)); + $state['note'] = $note->toArray(); + + return $state; + } + + /** + * Validate a client revision before mutating state. + * + * @param string $projectId Project identifier. + * @param string|null $revision Client revision token. + * + * @return void + */ + public function assertRevision(string $projectId, ?string $revision): void + { + if ($revision === null || $revision === '') { + return; + } + + if ($revision !== $this->revisionService->get($projectId)) { + throw new \RuntimeException('conflict'); + } + } + + /** + * Normalize tasks so they align with the current board columns. + * + * @param string $projectId Project identifier. + * @param Board $board Board definition. + * @param Task[] $tasks Task collection. + * + * @return Task[] + */ + private function normalizeTasks(string $projectId, Board $board, array $tasks): array + { + $validColumns = array_map(static fn (Column $column): string => $column->id, $board->columns); + $updated = false; + $groupedTasks = []; + + foreach ($tasks as $task) { + if (!in_array($task->column, $validColumns, true)) { + $task->column = isset($task->meta['section']) && is_string($task->meta['section']) + ? slugify($task->meta['section']) + : 'backlog'; + $updated = true; + } + + $groupedTasks[$task->column][] = $task; + } + + foreach ($groupedTasks as $columnTasks) { + usort($columnTasks, static fn (Task $left, Task $right): int => $left->order <=> $right->order); + + foreach ($columnTasks as $index => $task) { + if ($task->order <= 0) { + $task->order = ($index + 1) * 100; + $updated = true; + } + } + } + + if ($updated) { + foreach ($tasks as $task) { + $this->taskRepository->save($task); + } + + $this->revisionService->bump($projectId); + } + + $columnOrderLookup = []; + + foreach ($board->columns as $column) { + $columnOrderLookup[$column->id] = $column->order; + } + + usort( + $tasks, static function (Task $left, Task $right) use ($columnOrderLookup): int { + $leftColumn = $columnOrderLookup[$left->column] ?? PHP_INT_MAX; + $rightColumn = $columnOrderLookup[$right->column] ?? PHP_INT_MAX; + + if ($leftColumn === $rightColumn) { + return $left->order <=> $right->order; + } + + return $leftColumn <=> $rightColumn; + } + ); + + return $tasks; + } + + /** + * Check whether a board contains the requested column. + * + * @param Board $board Board definition. + * @param string $columnId Column identifier. + * + * @return bool + */ + private function columnExists(Board $board, string $columnId): bool + { + foreach ($board->columns as $column) { + if ($column->id === $columnId) { + return true; + } + } + + return false; + } + + /** + * Ensure the board includes a trash column. + * + * @param Board $board Board definition. + * + * @return Board + */ + private function ensureTrashColumn(Board $board): Board + { + if ($this->columnExists($board, 'trash')) { + return $board; + } + + $lastOrder = $board->columns === [] + ? 0 + : max(array_map(static fn (Column $column): int => $column->order, $board->columns)); + + $board->columns[] = new Column('trash', 'Trash', $lastOrder + 100); + $this->boardRepository->save($board); + + return $board; + } + + /** + * Determine whether a column needs order rebalancing. + * + * @param Task[] $tasks Tasks already present in the target column. + * @param int $targetIndex Target insertion index. + * + * @return bool + */ + private function needsRebalance(array $tasks, int $targetIndex): bool + { + usort($tasks, static fn (Task $left, Task $right): int => $left->order <=> $right->order); + $count = count($tasks); + + if ($count < 2 || $targetIndex <= 0 || $targetIndex >= $count) { + return false; + } + + return ($tasks[$targetIndex]->order - $tasks[$targetIndex - 1]->order) <= 1; + } + + /** + * Refresh revision state and attempt a git commit. + * + * @param string $projectId Project identifier. + * @param string $message Commit message. + * + * @return array + */ + private function touchProject(string $projectId, string $message): array + { + $revision = $this->revisionService->bump($projectId); + + try { + $this->gitService->commit($projectId, $message); + } catch (\Throwable $exception) { + } + + $state = $this->getBoardState($projectId); + $state['revision'] = $revision; + + return $state; + } +} diff --git a/src/Service/GitService.php b/src/Service/GitService.php new file mode 100644 index 0000000..ce7c694 --- /dev/null +++ b/src/Service/GitService.php @@ -0,0 +1,64 @@ +&1', + escapeshellarg($repoRoot), + escapeshellarg($relativeProjectPath) + ); + exec($addCommand, $addOutput, $addCode); + + if ($addCode !== 0) { + throw new \RuntimeException('Git add failed: ' . implode("\n", $addOutput)); + } + + $statusCommand = sprintf( + 'git -C %s diff --cached --quiet -- %s 2>&1', + escapeshellarg($repoRoot), + escapeshellarg($relativeProjectPath) + ); + exec($statusCommand, $statusOutput, $statusCode); + + if ($statusCode === 0) { + return; + } + + $commitCommand = sprintf( + 'git -C %s commit -m %s -- %s 2>&1', + escapeshellarg($repoRoot), + escapeshellarg($message), + escapeshellarg($relativeProjectPath) + ); + exec($commitCommand, $commitOutput, $commitCode); + + if ($commitCode !== 0) { + throw new \RuntimeException('Git commit failed: ' . implode("\n", $commitOutput)); + } + } +} diff --git a/src/Service/RevisionService.php b/src/Service/RevisionService.php new file mode 100644 index 0000000..a2b8fcb --- /dev/null +++ b/src/Service/RevisionService.php @@ -0,0 +1,83 @@ +revisionPath($projectId); + + if (!is_file($path)) { + return '0'; + } + + return trim((string) file_get_contents($path)); + } + + /** + * Bump the current project revision. + * + * @param string $projectId Project identifier. + * + * @return string + */ + public function bump(string $projectId): string + { + $revision = (string) round(microtime(true) * 1000); + + FileLock::run( + $this->lockFile($projectId), static function () use ($projectId, $revision): void { + AtomicWriter::write(Path::project($projectId) . DIRECTORY_SEPARATOR . '.revision', $revision . "\n"); + } + ); + + return $revision; + } + + /** + * Resolve the revision file path. + * + * @param string $projectId Project identifier. + * + * @return string + */ + private function revisionPath(string $projectId): string + { + return Path::project($projectId) . DIRECTORY_SEPARATOR . '.revision'; + } + + /** + * Resolve the revision lock file path. + * + * @param string $projectId Project identifier. + * + * @return string + */ + private function lockFile(string $projectId): string + { + return Path::project($projectId) . DIRECTORY_SEPARATOR . '.revision.lock'; + } +} diff --git a/src/Service/TaskOrderingService.php b/src/Service/TaskOrderingService.php new file mode 100644 index 0000000..822ee56 --- /dev/null +++ b/src/Service/TaskOrderingService.php @@ -0,0 +1,71 @@ + $left->order <=> $right->order); + $count = count($tasks); + + if ($count === 0) { + return 100; + } + + if ($targetIndex <= 0) { + return max(100, (int) floor($tasks[0]->order / 2)); + } + + if ($targetIndex >= $count) { + return $tasks[$count - 1]->order + 100; + } + + $previous = $tasks[$targetIndex - 1]->order; + $next = $tasks[$targetIndex]->order; + + if (($next - $previous) <= 1) { + return $previous + 1; + } + + return (int) floor(($previous + $next) / 2); + } + + /** + * Rebalance task ordering values within a column. + * + * @param Task[] $tasks Tasks to rebalance. + * + * @return Task[] + */ + public function rebalance(array $tasks): array + { + usort($tasks, static fn (Task $left, Task $right): int => $left->order <=> $right->order); + + foreach ($tasks as $index => $task) { + $task->order = ($index + 1) * 100; + } + + return $tasks; + } +} diff --git a/src/Support/AtomicWriter.php b/src/Support/AtomicWriter.php new file mode 100644 index 0000000..344e92f --- /dev/null +++ b/src/Support/AtomicWriter.php @@ -0,0 +1,51 @@ + $meta Metadata map. + * @param string $body Markdown body. + * + * @return string + */ + public static function dump(array $meta, string $body): string + { + $yaml = rtrim(self::dumpYaml($meta)); + $body = ltrim(str_replace(["\r\n", "\r"], "\n", $body), "\n"); + + return "---\n" . $yaml . "\n---\n\n" . $body . "\n"; + } + + /** + * Parse a YAML-like string into an array. + * + * @param string $yaml Raw YAML block. + * + * @return array + */ + private static function parseYaml(string $yaml): array + { + $lines = preg_split("/\n/", $yaml) ?: []; + $index = 0; + + return self::parseMap($lines, $index, 0); + } + + /** + * Parse an indented map structure. + * + * @param list $lines Source lines. + * @param int $index Current parse index. + * @param int $indent Expected indentation level. + * + * @return array + */ + private static function parseMap(array $lines, int &$index, int $indent): array + { + $result = []; + $count = count($lines); + + while ($index < $count) { + $rawLine = rtrim($lines[$index]); + + if ($rawLine === '' || preg_match('/^\s*#/', $rawLine) === 1) { + $index++; + continue; + } + + $lineIndent = self::indent($rawLine); + + if ($lineIndent < $indent) { + break; + } + + if ($lineIndent > $indent) { + $index++; + continue; + } + + $trimmed = trim($rawLine); + + if (str_starts_with($trimmed, '- ')) { + break; + } + + [$key, $rest] = array_pad(explode(':', $trimmed, 2), 2, ''); + $key = trim($key); + $rest = ltrim($rest); + $index++; + + if ($rest !== '') { + $result[$key] = self::parseScalar($rest); + continue; + } + + if ($index >= $count) { + $result[$key] = null; + continue; + } + + $nextRaw = rtrim($lines[$index]); + + if ($nextRaw === '') { + $result[$key] = null; + continue; + } + + $nextIndent = self::indent($nextRaw); + + if ($nextIndent <= $indent) { + $result[$key] = null; + continue; + } + + $nextTrimmed = trim($nextRaw); + $result[$key] = str_starts_with($nextTrimmed, '- ') + ? self::parseList($lines, $index, $nextIndent) + : self::parseMap($lines, $index, $nextIndent); + } + + return $result; + } + + /** + * Parse an indented list structure. + * + * @param list $lines Source lines. + * @param int $index Current parse index. + * @param int $indent Expected indentation level. + * + * @return list + */ + private static function parseList(array $lines, int &$index, int $indent): array + { + $result = []; + $count = count($lines); + + while ($index < $count) { + $rawLine = rtrim($lines[$index]); + + if ($rawLine === '' || preg_match('/^\s*#/', $rawLine) === 1) { + $index++; + continue; + } + + $lineIndent = self::indent($rawLine); + + if ($lineIndent < $indent) { + break; + } + + $trimmed = trim($rawLine); + + if (!str_starts_with($trimmed, '- ')) { + break; + } + + $itemContent = substr($trimmed, 2); + $index++; + + if ($itemContent === '') { + $result[] = self::parseMap($lines, $index, $indent + 2); + continue; + } + + if (str_contains($itemContent, ':')) { + [$inlineKey, $inlineRest] = array_pad(explode(':', $itemContent, 2), 2, ''); + $item = [trim($inlineKey) => self::parseScalar(ltrim($inlineRest))]; + + if ($index < $count) { + $nextRaw = rtrim($lines[$index]); + $nextIndent = $nextRaw === '' ? 0 : self::indent($nextRaw); + + if ($nextRaw !== '' && $nextIndent > $indent) { + $item = array_merge($item, self::parseMap($lines, $index, $indent + 2)); + } + } + + $result[] = $item; + continue; + } + + $result[] = self::parseScalar($itemContent); + } + + return $result; + } + + /** + * Serialize an array into YAML-like text. + * + * @param array $value Value to serialize. + * @param int $indent Indentation level. + * + * @return string + */ + private static function dumpYaml(array $value, int $indent = 0): string + { + $lines = []; + $prefix = str_repeat(' ', $indent); + + foreach ($value as $key => $item) { + if (is_array($item)) { + if (self::isList($item)) { + $lines[] = $prefix . $key . ':'; + + foreach ($item as $listItem) { + if (is_array($listItem)) { + $first = true; + + foreach ($listItem as $nestedKey => $nestedValue) { + if ($first) { + $firstLine = $prefix . ' - ' . $nestedKey . ':'; + + if (!is_array($nestedValue)) { + $firstLine .= ' ' . self::dumpScalar($nestedValue); + } + + $lines[] = $firstLine; + $first = false; + + if (!is_array($nestedValue)) { + continue; + } + } elseif (is_array($nestedValue)) { + $lines[] = $prefix . ' ' . $nestedKey . ':'; + } else { + $lines[] = $prefix . ' ' . $nestedKey . ': ' . self::dumpScalar($nestedValue); + continue; + } + + if (is_array($nestedValue)) { + $lines[] = rtrim(self::dumpNested($nestedValue, $indent + 6)); + } + } + } else { + $lines[] = $prefix . ' - ' . self::dumpScalar($listItem); + } + } + + continue; + } + + $lines[] = $prefix . $key . ':'; + $lines[] = rtrim(self::dumpYaml($item, $indent + 2)); + continue; + } + + $lines[] = $prefix . $key . ': ' . self::dumpScalar($item); + } + + return implode("\n", array_filter($lines, static fn ($line) => $line !== '')) . "\n"; + } + + /** + * Serialize nested arrays for YAML-like output. + * + * @param array $value Value to serialize. + * @param int $indent Indentation level. + * + * @return string + */ + private static function dumpNested(array $value, int $indent): string + { + if (self::isList($value)) { + $lines = []; + $prefix = str_repeat(' ', $indent); + + foreach ($value as $item) { + if (is_array($item)) { + $lines[] = $prefix . '-'; + $lines[] = rtrim(self::dumpYaml($item, $indent + 2)); + } else { + $lines[] = $prefix . '- ' . self::dumpScalar($item); + } + } + + return implode("\n", $lines) . "\n"; + } + + return self::dumpYaml($value, $indent); + } + + /** + * Parse a scalar value from YAML-like text. + * + * @param string $value Scalar text. + * + * @return mixed + */ + private static function parseScalar(string $value): mixed + { + $trimmed = trim($value); + + if ($trimmed === 'null' || $trimmed === '~') { + return null; + } + + if ($trimmed === 'true') { + return true; + } + + if ($trimmed === 'false') { + return false; + } + + if (preg_match('/^-?\d+$/', $trimmed) === 1) { + return (int) $trimmed; + } + + if ((str_starts_with($trimmed, '"') && str_ends_with($trimmed, '"')) + || (str_starts_with($trimmed, "'") && str_ends_with($trimmed, "'")) + ) { + return substr($trimmed, 1, -1); + } + + return $trimmed; + } + + /** + * Serialize a scalar value for YAML-like output. + * + * @param mixed $value Scalar value. + * + * @return string + */ + private static function dumpScalar(mixed $value): string + { + if ($value === null) { + return 'null'; + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (is_int($value) || is_float($value)) { + return (string) $value; + } + + $stringValue = (string) $value; + + if ($stringValue === '') { + return '""'; + } + + if (preg_match('/[:#\-\{\}\[\],&\*!|>\'%@`"]|\s$/', $stringValue) === 1 || str_contains($stringValue, "\n")) { + return '"' . addcslashes($stringValue, "\\\"") . '"'; + } + + return $stringValue; + } + + /** + * Count the leading spaces for a line. + * + * @param string $line Input line. + * + * @return int + */ + private static function indent(string $line): int + { + return strlen($line) - strlen(ltrim($line, ' ')); + } + + /** + * Determine whether an array is a list. + * + * @param array $value Array value. + * + * @return bool + */ + private static function isList(array $value): bool + { + return $value === [] || array_keys($value) === range(0, count($value) - 1); + } +} diff --git a/src/Support/FrontMatterDocument.php b/src/Support/FrontMatterDocument.php new file mode 100644 index 0000000..3c394c8 --- /dev/null +++ b/src/Support/FrontMatterDocument.php @@ -0,0 +1,28 @@ + $meta Parsed metadata. + * @param string $body Markdown body content. + */ + public function __construct( + public array $meta, + public string $body + ) { + } +} diff --git a/src/Support/Http.php b/src/Support/Http.php new file mode 100644 index 0000000..13b8388 --- /dev/null +++ b/src/Support/Http.php @@ -0,0 +1,105 @@ + $payload Response payload. + * @param int $status HTTP status code. + * + * @return never + */ + public static function json(array $payload, int $status = 200): never + { + http_response_code($status); + + if (!(defined('IKB_TEST_MODE') && IKB_TEST_MODE)) { + header('Content-Type: application/json; charset=utf-8'); + } + + echo json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + + if (defined('IKB_TEST_MODE') && IKB_TEST_MODE) { + throw new HttpHalt(); + } + + exit; + } + + /** + * Send an error response and end execution. + * + * @param string $message Error message. + * @param int $status HTTP status code. + * + * @return never + */ + public static function error(string $message, int $status = 400): never + { + self::json( + [ + 'success' => false, + 'error' => $message, + ], $status + ); + } + + /** + * Parse request input from JSON or form data. + * + * @return array + */ + public static function input(): array + { + $raw = file_get_contents('php://input'); + + if ($raw === false || $raw === '') { + return $_POST; + } + + $decoded = json_decode($raw, true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * Ensure the current request method matches the expected method. + * + * @param string $method Expected HTTP method. + * + * @return void + */ + public static function requireMethod(string $method): void + { + if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== strtoupper($method)) { + self::error('method_not_allowed', 405); + } + } + + /** + * Verify the CSRF token for the current request. + * + * @return void + */ + public static function verifyCsrf(): void + { + $token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ($_POST['_token'] ?? ''); + + if (!is_string($token) || $token === '' || !hash_equals(csrf_token(), $token)) { + self::error('invalid_csrf', 403); + } + } +} diff --git a/src/Support/HttpHalt.php b/src/Support/HttpHalt.php new file mode 100644 index 0000000..4f9d0e2 --- /dev/null +++ b/src/Support/HttpHalt.php @@ -0,0 +1,17 @@ +format(DateTimeInterface::ATOM); +} + +/** + * Convert a string into a slug. + * + * @param string $value Raw input value. + * + * @return string + */ +function slugify(string $value): string +{ + $value = strtolower(trim($value)); + $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? ''; + $value = trim($value, '-'); + + return $value !== '' ? $value : 'item'; +} + +/** + * Generate a timestamp-based identifier. + * + * @param string $prefix Identifier prefix. + * + * @return string + */ +function generate_id(string $prefix): string +{ + $timestamp = (new DateTimeImmutable('now'))->format('Ymd-His'); + $random = substr(bin2hex(random_bytes(4)), 0, 6); + + return $prefix . '-' . $timestamp . '-' . $random; +} + +/** + * Get or create the session CSRF token. + * + * @return string + */ +function csrf_token(): string +{ + if (!isset($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + + return $_SESSION['csrf_token']; +} + +/** + * Create a directory if it does not exist. + * + * @param string $path Directory path. + * + * @return void + */ +function ensure_directory(string $path): void +{ + if (!is_dir($path) && !mkdir($path, 0777, true) && !is_dir($path)) { + throw new RuntimeException('Unable to create directory: ' . $path); + } +} diff --git a/storage/projects/demo-project/.revision b/storage/projects/demo-project/.revision index 8f0cc51..1540594 100644 --- a/storage/projects/demo-project/.revision +++ b/storage/projects/demo-project/.revision @@ -1 +1 @@ -1775423621208 +1775422119437 diff --git a/storage/projects/demo-project/notes/note-20260405-161609-3d2504.md b/storage/projects/demo-project/notes/note-20260405-161609-3d2504.md index 99d4815..e6ed640 100644 --- a/storage/projects/demo-project/notes/note-20260405-161609-3d2504.md +++ b/storage/projects/demo-project/notes/note-20260405-161609-3d2504.md @@ -4,9 +4,8 @@ id: "note-20260405-161609-3d2504" title: New note project_id: "demo-project" created: "2026-04-05T16:16:09+00:00" -updated: "2026-04-05T21:13:40+00:00" +updated: "2026-04-05T16:18:43+00:00" --- what happens when I add another note? - diff --git a/storage/projects/demo-project/tasks/task-20260404-180000-a1b2c3.md b/storage/projects/demo-project/tasks/task-20260404-180000-a1b2c3.md index 10a9345..661f01b 100644 --- a/storage/projects/demo-project/tasks/task-20260404-180000-a1b2c3.md +++ b/storage/projects/demo-project/tasks/task-20260404-180000-a1b2c3.md @@ -9,7 +9,7 @@ completed: false priority: high is_active: true created: "2026-04-04T18:00:00+00:00" -updated: "2026-04-05T21:13:41+00:00" +updated: "2026-04-05T16:06:59+00:00" tags: - mvp - backend @@ -19,4 +19,3 @@ Implement front matter parsing, atomic writes, and the repositories that read an - diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/tests/Api/BoardApiTest.php b/tests/Api/BoardApiTest.php new file mode 100644 index 0000000..2506102 --- /dev/null +++ b/tests/Api/BoardApiTest.php @@ -0,0 +1,322 @@ +createProject(); + + $response = $this->callApi( + 'public/api/board-state.php', + 'GET', + ['project' => $projectId] + ); + + self::assertSame(200, $response['status']); + self::assertSame('Demo Project', $response['payload']['project']['title']); + self::assertContains('trash', array_column($response['payload']['columns'], 'id')); + } + + /** + * Ensure state-changing endpoints reject missing CSRF tokens. + * + * @return void + */ + public function testCreateTaskEndpointRejectsMissingCsrfToken(): void + { + $projectId = $this->createProject(); + + $response = $this->callApi( + 'public/api/create-task.php', + 'POST', + [], + [ + 'projectId' => $projectId, + 'title' => 'Write docs', + 'column' => 'backlog', + ] + ); + + self::assertSame(403, $response['status']); + self::assertFalse($response['payload']['success']); + self::assertSame('invalid_csrf', $response['payload']['error']); + } + + /** + * Ensure create-task returns a successful JSON state payload. + * + * @return void + */ + public function testCreateTaskEndpointCreatesTaskWithValidCsrfToken(): void + { + $projectId = $this->createProject(); + $token = csrf_token(); + + $response = $this->callApi( + 'public/api/create-task.php', + 'POST', + [], + [ + 'projectId' => $projectId, + 'title' => 'Write docs', + 'column' => 'backlog', + 'body' => 'Add the release checklist.', + '_token' => $token, + ], + ['HTTP_X_CSRF_TOKEN' => $token] + ); + + self::assertSame(200, $response['status']); + self::assertTrue($response['payload']['success']); + self::assertSame('Write docs', $response['payload']['state']['task']['title']); + self::assertSame('backlog', $response['payload']['state']['task']['column']); + self::assertCount(1, $response['payload']['state']['tasks']); + } + + /** + * Ensure stale revisions return a conflict response. + * + * @return void + */ + public function testCreateTaskEndpointReturnsConflictForStaleRevision(): void + { + $projectId = $this->createProject(); + $service = new BoardService(); + $current = $service->getBoardState($projectId); + $token = csrf_token(); + + $service->createTask($projectId, 'Existing task', 'backlog'); + + $response = $this->callApi( + 'public/api/create-task.php', + 'POST', + [], + [ + 'projectId' => $projectId, + 'title' => 'Conflicting task', + 'column' => 'ready', + 'revision' => (string) $current['revision'], + '_token' => $token, + ], + ['HTTP_X_CSRF_TOKEN' => $token] + ); + + self::assertSame(409, $response['status']); + self::assertFalse($response['payload']['success']); + self::assertSame('conflict', $response['payload']['error']); + } + + /** + * Ensure move-task updates the task column and preserves a success response. + * + * @return void + */ + public function testMoveTaskEndpointMovesTaskToRequestedColumn(): void + { + $projectId = $this->createProject(); + $service = new BoardService(); + $created = $service->createTask($projectId, 'Move me', 'backlog'); + $token = csrf_token(); + + $response = $this->callApi( + 'public/api/move-task.php', + 'POST', + [], + [ + 'projectId' => $projectId, + 'taskId' => $created['task']['id'], + 'column' => 'done', + 'index' => 0, + 'revision' => (string) $created['revision'], + '_token' => $token, + ], + ['HTTP_X_CSRF_TOKEN' => $token] + ); + + self::assertSame(200, $response['status']); + self::assertTrue($response['payload']['success']); + self::assertSame('done', $response['payload']['state']['tasks'][0]['column']); + } + + /** + * Ensure delete-task trashes a task by default. + * + * @return void + */ + public function testDeleteTaskEndpointMovesTaskToTrash(): void + { + $projectId = $this->createProject(); + $service = new BoardService(); + $created = $service->createTask($projectId, 'Archive me', 'review'); + $token = csrf_token(); + + $response = $this->callApi( + 'public/api/delete-task.php', + 'POST', + [], + [ + 'projectId' => $projectId, + 'taskId' => $created['task']['id'], + 'revision' => (string) $created['revision'], + '_token' => $token, + ], + ['HTTP_X_CSRF_TOKEN' => $token] + ); + + self::assertSame(200, $response['status']); + self::assertTrue($response['payload']['success']); + self::assertSame('trash', $response['payload']['state']['tasks'][0]['column']); + } + + /** + * Ensure save-note creates a note and returns it in the response state. + * + * @return void + */ + public function testSaveNoteEndpointCreatesNote(): void + { + $projectId = $this->createProject(); + $service = new BoardService(); + $state = $service->getBoardState($projectId); + $token = csrf_token(); + + $response = $this->callApi( + 'public/api/save-note.php', + 'POST', + [], + [ + 'projectId' => $projectId, + 'title' => 'Sprint Notes', + 'body' => 'Capture follow-up items.', + 'revision' => (string) $state['revision'], + '_token' => $token, + ], + ['HTTP_X_CSRF_TOKEN' => $token] + ); + + self::assertSame(200, $response['status']); + self::assertTrue($response['payload']['success']); + self::assertSame('Sprint Notes', $response['payload']['state']['note']['title']); + self::assertCount(1, $response['payload']['state']['notes']); + } + + /** + * Ensure create-column appends a new column to the board. + * + * @return void + */ + public function testCreateColumnEndpointCreatesNewColumn(): void + { + $projectId = $this->createProject(); + $service = new BoardService(); + $state = $service->getBoardState($projectId); + $token = csrf_token(); + + $response = $this->callApi( + 'public/api/create-column.php', + 'POST', + [], + [ + 'projectId' => $projectId, + 'label' => 'Blocked', + 'revision' => (string) $state['revision'], + '_token' => $token, + ], + ['HTTP_X_CSRF_TOKEN' => $token] + ); + + self::assertSame(200, $response['status']); + self::assertTrue($response['payload']['success']); + self::assertContains('blocked', array_column($response['payload']['state']['columns'], 'id')); + } + + /** + * Ensure save-task updates persisted task fields. + * + * @return void + */ + public function testSaveTaskEndpointUpdatesTaskFields(): void + { + $projectId = $this->createProject(); + $service = new BoardService(); + $created = $service->createTask($projectId, 'Initial title', 'backlog'); + $token = csrf_token(); + + $response = $this->callApi( + 'public/api/save-task.php', + 'POST', + [], + [ + 'projectId' => $projectId, + 'taskId' => $created['task']['id'], + 'title' => 'Updated title', + 'body' => 'Updated body', + 'priority' => 'urgent', + 'completed' => true, + 'revision' => (string) $created['revision'], + '_token' => $token, + ], + ['HTTP_X_CSRF_TOKEN' => $token] + ); + + self::assertSame(200, $response['status']); + self::assertTrue($response['payload']['success']); + self::assertSame('Updated title', $response['payload']['state']['tasks'][0]['title']); + self::assertSame("Updated body\n", $response['payload']['state']['tasks'][0]['body']); + self::assertSame('urgent', $response['payload']['state']['tasks'][0]['priority']); + self::assertTrue($response['payload']['state']['tasks'][0]['completed']); + } + + /** + * Ensure update-board accepts column replacements and keeps trash available. + * + * @return void + */ + public function testUpdateBoardEndpointReplacesColumnDefinitions(): void + { + $projectId = $this->createProject(); + $service = new BoardService(); + $state = $service->getBoardState($projectId); + $token = csrf_token(); + + $response = $this->callApi( + 'public/api/update-board.php', + 'POST', + [], + [ + 'projectId' => $projectId, + 'columns' => [ + ['id' => 'ideas', 'label' => 'Ideas'], + ['id' => 'doing', 'label' => 'Doing'], + ], + 'revision' => (string) $state['revision'], + '_token' => $token, + ], + ['HTTP_X_CSRF_TOKEN' => $token] + ); + + self::assertSame(200, $response['status']); + self::assertTrue($response['payload']['success']); + self::assertSame(['ideas', 'doing', 'trash'], array_column($response['payload']['state']['columns'], 'id')); + } +} diff --git a/tests/Domain/BoardTest.php b/tests/Domain/BoardTest.php new file mode 100644 index 0000000..246258a --- /dev/null +++ b/tests/Domain/BoardTest.php @@ -0,0 +1,49 @@ + 3] + ); + + self::assertSame( + [ + 'project_id' => 'demo-project', + 'columns' => [ + ['id' => 'backlog', 'label' => 'Backlog', 'order' => 100], + ['id' => 'done', 'label' => 'Done', 'order' => 200], + ], + 'meta' => ['wip_limit' => 3], + ], + $board->toArray() + ); + } +} diff --git a/tests/Service/BoardServiceTest.php b/tests/Service/BoardServiceTest.php new file mode 100644 index 0000000..ce5327d --- /dev/null +++ b/tests/Service/BoardServiceTest.php @@ -0,0 +1,107 @@ +createProject(); + $service = new BoardService(); + + $state = $service->getBoardState($projectId); + + self::assertSame( + ['backlog', 'ready', 'in-progress', 'review', 'done', 'trash'], + array_column($state['columns'], 'id') + ); + self::assertSame([], $state['tasks']); + self::assertSame([], $state['notes']); + self::assertSame('Demo Project', $state['project']['title']); + } + + /** + * Ensure a created task is persisted and returned in board state. + * + * @return void + */ + public function testCreateTaskPersistsTaskInRequestedColumn(): void + { + $projectId = $this->createProject(); + $service = new BoardService(); + + $state = $service->createTask($projectId, 'Ship first milestone', 'ready', 'Write release notes'); + + self::assertSame('Ship first milestone', $state['task']['title']); + self::assertSame('ready', $state['task']['column']); + self::assertSame('Write release notes', $state['task']['body']); + self::assertCount(1, $state['tasks']); + self::assertSame($state['task']['id'], $state['tasks'][0]['id']); + self::assertNotSame('0', $state['revision']); + } + + /** + * Ensure deleting a task without force moves it into trash. + * + * @return void + */ + public function testDeleteTaskMovesTaskToTrashByDefault(): void + { + $projectId = $this->createProject(); + $service = new BoardService(); + $created = $service->createTask($projectId, 'Clean up copy', 'in-progress'); + + $state = $service->deleteTask($projectId, $created['task']['id']); + + self::assertCount(1, $state['tasks']); + self::assertSame('trash', $state['tasks'][0]['column']); + self::assertSame($created['task']['id'], $state['tasks'][0]['id']); + } + + /** + * Ensure removing a column moves its tasks into trash and keeps trash available. + * + * @return void + */ + public function testUpdateBoardMovesDeletedColumnTasksIntoTrash(): void + { + $projectId = $this->createProject(); + $service = new BoardService(); + + $service->createTask($projectId, 'Review architecture', 'review'); + + $state = $service->updateBoard( + $projectId, + [ + ['id' => 'backlog', 'label' => 'Backlog'], + ['id' => 'ready', 'label' => 'Ready'], + ['id' => 'in-progress', 'label' => 'In Progress'], + ['id' => 'done', 'label' => 'Done'], + ['id' => 'trash', 'label' => 'Trash'], + ], + 'review' + ); + + self::assertContains('trash', array_column($state['columns'], 'id')); + self::assertCount(1, $state['tasks']); + self::assertSame('trash', $state['tasks'][0]['column']); + } +} diff --git a/tests/Support/FrontMatterTest.php b/tests/Support/FrontMatterTest.php new file mode 100644 index 0000000..f69b8a0 --- /dev/null +++ b/tests/Support/FrontMatterTest.php @@ -0,0 +1,57 @@ + 'Plan launch', + 'completed' => false, + 'order' => 200, + 'tags' => ['alpha', 'beta'], + 'assignee' => [ + 'name' => 'Casey', + 'role' => 'owner', + ], + ]; + $body = "Ship the first milestone.\n\nKeep the checklist updated."; + + $document = FrontMatter::parse(FrontMatter::dump($meta, $body)); + + self::assertSame($meta, $document->meta); + self::assertSame($body . "\n", $document->body); + } + + /** + * Ensure plain markdown without front matter is preserved. + * + * @return void + */ + public function testParseWithoutFrontMatterReturnsBodyOnly(): void + { + $document = FrontMatter::parse("Just markdown\n\n- item"); + + self::assertSame([], $document->meta); + self::assertSame("Just markdown\n\n- item", $document->body); + } +} diff --git a/tests/Support/IntegrationTestCase.php b/tests/Support/IntegrationTestCase.php new file mode 100644 index 0000000..11602aa --- /dev/null +++ b/tests/Support/IntegrationTestCase.php @@ -0,0 +1,200 @@ + + */ + private array $_originalAppConfig; + + /** + * Repository root used to resolve actual application scripts. + * + * @var string + */ + private string $_repoRoot; + + /** + * Prepare an isolated workspace for each test. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + global $appConfig; + + $this->_originalAppConfig = is_array($appConfig) ? $appConfig : []; + $this->_repoRoot = (string) ($this->_originalAppConfig['base_path'] ?? dirname(__DIR__, 2)); + $this->tempRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ikb-tests-' . bin2hex(random_bytes(6)); + $this->projectRoot = $this->tempRoot . DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR . 'projects'; + + mkdir($this->projectRoot, 0777, true); + + $appConfig['base_path'] = $this->tempRoot; + $appConfig['project_root'] = $this->projectRoot; + } + + /** + * Restore the original workspace after the test. + * + * @return void + */ + protected function tearDown(): void + { + global $appConfig; + + $appConfig = $this->_originalAppConfig; + $this->_removeDirectory($this->tempRoot); + + parent::tearDown(); + } + + /** + * Create a markdown-backed project for tests. + * + * @param string $projectId Project identifier. + * @param string $title Project title. + * @param string $body Project body content. + * + * @return string + */ + protected function createProject(string $projectId = 'demo-project', string $title = 'Demo Project', string $body = 'Project body'): string + { + $projectPath = $this->projectRoot . DIRECTORY_SEPARATOR . $projectId; + mkdir($projectPath, 0777, true); + + file_put_contents( + $projectPath . DIRECTORY_SEPARATOR . 'index.md', + FrontMatter::dump( + [ + 'type' => 'project', + 'id' => $projectId, + 'title' => $title, + ], + $body + ) + ); + + return $projectId; + } + + /** + * Invoke an API endpoint script with request globals. + * + * @param string $script Relative script path from repo root. + * @param string $method Request method. + * @param array $get Query parameters. + * @param array $post Form payload. + * @param array $server Additional server variables. + * + * @return array{status:int, payload:array, raw:string} + */ + protected function callApi( + string $script, + string $method = 'GET', + array $get = [], + array $post = [], + array $server = [] + ): array { + $_GET = $get; + $_POST = $post; + $_SERVER = array_merge( + [ + 'REQUEST_METHOD' => strtoupper($method), + ], + $server + ); + + http_response_code(200); + + if (function_exists('header_remove')) { + header_remove(); + } + + ob_start(); + + try { + include $this->_repoRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $script); + } catch (HttpHalt $halt) { + } + + $raw = (string) ob_get_clean(); + $decoded = json_decode($raw, true); + + self::assertIsArray($decoded, 'Expected a JSON object response from ' . $script . '.'); + + return [ + 'status' => http_response_code(), + 'payload' => $decoded, + 'raw' => $raw, + ]; + } + + /** + * Recursively remove a directory tree. + * + * @param string $path Directory path. + * + * @return void + */ + private function _removeDirectory(string $path): void + { + if ($path === '' || !is_dir($path)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $item) { + if ($item->isDir()) { + rmdir($item->getPathname()); + continue; + } + + unlink($item->getPathname()); + } + + rmdir($path); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..58875db --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,29 @@ + { + test('loads the demo board and core UI sections', async ({ page }) => { + await page.goto('/?project=demo-project'); + + await expect(page.getByRole('heading', { name: 'IronKanban' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Demo Project' })).toBeVisible(); + await expect(page.locator('#board-columns .column')).toHaveCount(4); + await expect(page.locator('#trash-panel')).toBeVisible(); + await expect(page.locator('#notes-panel')).toBeVisible(); + }); + + test('toggles notes and opens existing note and task dialogs', async ({ page }) => { + await page.goto('/?project=demo-project'); + + const notesToggle = page.getByRole('button', { name: /Show Notes \(\d+\)/ }); + await notesToggle.click(); + await expect(page.getByRole('button', { name: /Hide Notes \(\d+\)/ })).toBeVisible(); + await expect(page.locator('#notes-list .note-card')).toHaveCount(2); + + await page.locator('#notes-list .note-card').first().click(); + await expect(page.locator('#note-dialog')).toBeVisible(); + await expect(page.locator('#note-form input[name="title"]')).not.toHaveValue(''); + await page.keyboard.press('Escape'); + await expect(page.locator('#note-dialog')).not.toBeVisible(); + + await page.locator('#board-columns .task-card').first().click(); + await expect(page.locator('#task-dialog')).toBeVisible(); + await expect(page.locator('#task-form input[name="title"]')).not.toHaveValue(''); + await page.keyboard.press('Escape'); + await expect(page.locator('#task-dialog')).not.toBeVisible(); + }); + + test('toggles the trash section and shows trashed tasks', async ({ page }) => { + await page.goto('/?project=demo-project'); + + const trashToggle = page.getByRole('button', { name: /Show Trash \(\d+\)/ }); + await trashToggle.click(); + + await expect(page.getByRole('button', { name: /Hide Trash \(\d+\)/ })).toBeVisible(); + await expect(page.locator('#trash-columns .task-card')).toHaveCount(1); + await expect(page.locator('#trash-dropzone-meta')).toContainText('1 task'); + }); +});