docs(04): create phase 4 tasks-kanban plan (4 plans, 3 waves)
Wave 1 (Plan 01): DB migration, sqlc queries, RED test scaffold, Sortable.js bootstrap, soft-danger CSS. Wave 2 (Plan 02): Kanban board render + task create + task delete vertical slice (TASK-01, TASK-02, TASK-06). Wave 3 (Plan 03): Inline task edit + Sortable.js drag reorder/move (TASK-03, TASK-04, TASK-05, TASK-07). Wave 4 (Plan 04): Human-verify checkpoint — full browser verification of all 7 TASK requirements. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cfd30eb277
commit
7f58588f5a
5 changed files with 1109 additions and 0 deletions
|
|
@ -103,6 +103,13 @@ Plans:
|
|||
|
||||
**User-in-loop:** Approve the `task_columns` (or fixed-column) schema and the ordering strategy (fractional indices, gaps-of-100, linked list — to be decided with research). Approve whether reorder is drag-and-drop or button-driven.
|
||||
|
||||
**Plans:** 4 plans
|
||||
Plans:
|
||||
- [ ] 04-01-PLAN.md — Wave 0: migration 0004_tasks + sqlc queries + handlers_tasks_test.go RED scaffold + soft-danger button CSS + Sortable.js bootstrap
|
||||
- [ ] 04-02-PLAN.md — Vertical slice 1: kanban board render + task create + task delete (TASK-01, TASK-02, TASK-06)
|
||||
- [ ] 04-03-PLAN.md — Vertical slice 2: task inline edit + Sortable.js drag reorder/move (TASK-03, TASK-04, TASK-05, TASK-07)
|
||||
- [ ] 04-04-PLAN.md — Human-verify checkpoint: full kanban board browser verification
|
||||
|
||||
### Phase 5: Files
|
||||
**Goal:** A user can attach files to a tablo, list them, download them via signed URLs, and delete them — backed by S3-compatible storage.
|
||||
**Mode:** mvp
|
||||
|
|
@ -161,3 +168,4 @@ Plans:
|
|||
---
|
||||
*Roadmap created: 2026-05-14*
|
||||
*Phase 3 plans added: 2026-05-15*
|
||||
*Phase 4 plans added: 2026-05-15*
|
||||
|
|
|
|||
276
.planning/phases/04-tasks-kanban/04-01-PLAN.md
Normal file
276
.planning/phases/04-tasks-kanban/04-01-PLAN.md
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
---
|
||||
phase: 04-tasks-kanban
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- backend/migrations/0004_tasks.sql
|
||||
- backend/internal/db/queries/tasks.sql
|
||||
- backend/internal/web/ui/button.css
|
||||
- backend/internal/web/handlers_tasks_test.go
|
||||
- backend/templates/tasks_forms.go
|
||||
- backend/justfile
|
||||
autonomous: true
|
||||
requirements:
|
||||
- TASK-01
|
||||
- TASK-02
|
||||
- TASK-03
|
||||
- TASK-04
|
||||
- TASK-05
|
||||
- TASK-06
|
||||
- TASK-07
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "migration 0004_tasks.sql applies cleanly with just migrate up"
|
||||
- "sqlc generate produces TaskStatus type and Task struct in internal/db/sqlc/"
|
||||
- "handlers_tasks_test.go compiles with RED stubs for all 7 TASK requirements"
|
||||
- "Sortable.js 1.15.7 downloaded to static/sortable.min.js via just bootstrap"
|
||||
- "ui-button-soft-danger-md CSS class exists in button.css"
|
||||
artifacts:
|
||||
- path: "backend/migrations/0004_tasks.sql"
|
||||
provides: "task_status ENUM + tasks table with position index"
|
||||
contains: "CREATE TYPE task_status AS ENUM"
|
||||
- path: "backend/internal/db/queries/tasks.sql"
|
||||
provides: "sqlc query source for all task CRUD operations"
|
||||
exports: ["ListTasksByTablo", "InsertTask", "GetTaskByID", "UpdateTask", "DeleteTask", "MaxPositionByTabloAndStatus"]
|
||||
- path: "backend/internal/web/handlers_tasks_test.go"
|
||||
provides: "RED test stubs for TASK-01..07"
|
||||
contains: "TestTasksKanbanRenders"
|
||||
- path: "backend/templates/tasks_forms.go"
|
||||
provides: "TaskCreateForm, TaskCreateErrors, TaskUpdateErrors structs"
|
||||
- path: "backend/internal/web/ui/button.css"
|
||||
provides: "ui-button-soft-danger-md CSS rule"
|
||||
key_links:
|
||||
- from: "backend/migrations/0004_tasks.sql"
|
||||
to: "backend/internal/db/sqlc/"
|
||||
via: "sqlc generate reads schema from migrations"
|
||||
pattern: "task_status|tasks"
|
||||
- from: "backend/internal/db/queries/tasks.sql"
|
||||
to: "backend/internal/db/sqlc/tasks.sql.go"
|
||||
via: "sqlc generate"
|
||||
pattern: "InsertTask|ListTasksByTablo"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wave 0 foundation: establish the DB schema, sqlc query source, test RED scaffold, Sortable.js asset, soft-danger button CSS, and form structs that all subsequent plans depend on. Nothing is executable from the user's perspective yet — this plan creates the preconditions for Plans 02 and 03 to deliver vertical slices.
|
||||
|
||||
Purpose: Ensure sqlc generate has the query source it needs; ensure setupTestDB applies the migration; ensure test functions compile as RED; ensure Sortable.js is available at /static/sortable.min.js.
|
||||
Output: migration file, sqlc queries, RED test stubs, CSS rule, form structs, justfile update.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-CONTEXT.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-RESEARCH.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-VALIDATION.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and patterns the executor needs. Extracted from codebase. -->
|
||||
|
||||
From backend/internal/db/queries/tablos.sql (sqlc query style reference):
|
||||
-- name: ListTablosByUser :many
|
||||
SELECT ... FROM tablos WHERE user_id = $1 ORDER BY created_at DESC;
|
||||
-- name: InsertTablo :one
|
||||
INSERT INTO tablos (...) VALUES ($1, $2, $3, $4) RETURNING ...;
|
||||
-- name: DeleteTablo :exec
|
||||
DELETE FROM tablos WHERE id = $1 AND user_id = $2;
|
||||
|
||||
From backend/migrations/0003_tablos.sql (migration style reference):
|
||||
-- +goose Up
|
||||
CREATE TABLE tablos ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), ... );
|
||||
CREATE INDEX tablos_user_id_idx ON tablos(user_id);
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS tablos;
|
||||
|
||||
From backend/internal/web/handlers_tablos_test.go (test patterns):
|
||||
package web
|
||||
func newTabloTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { ... }
|
||||
func loginUser(t *testing.T, router http.Handler, email, password string) []*http.Cookie { ... }
|
||||
setupTestDB(t) / preInsertUser / getCSRFToken are in testdb_test.go / handlers_test.go (same package)
|
||||
|
||||
From backend/internal/web/ui/button.css (CSS rule pattern for new soft-danger variant):
|
||||
Existing: .ui-button-soft-neutral-md { display: inline-flex; align-items: center;
|
||||
border-radius: 0.375rem; background-color: #f1f5f9; padding: 0.5rem 1rem;
|
||||
font-size: 1rem; font-weight: 600; color: #475569; border: 1px solid #e2e8f0; min-height: 44px; }
|
||||
|
||||
From backend/templates/tablos_forms.go (form struct pattern):
|
||||
package templates
|
||||
type TabloCreateForm struct { Title, Description, Color string }
|
||||
type TabloCreateErrors struct { Title, Color, General string }
|
||||
|
||||
From backend/justfile (bootstrap pattern):
|
||||
htmx_version := "2"
|
||||
curl -sSL -o static/htmx.min.js "https://unpkg.com/htmx.org@{{ htmx_version }}/dist/htmx.min.js"
|
||||
clean: rm -rf bin/ tmp/ static/htmx.min.js static/tailwind.css
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Migration 0004_tasks.sql + sqlc queries tasks.sql</name>
|
||||
<files>backend/migrations/0004_tasks.sql, backend/internal/db/queries/tasks.sql</files>
|
||||
<read_first>
|
||||
- backend/migrations/0003_tablos.sql (migration file style — goose Up/Down sections, uuid PK, indexes)
|
||||
- backend/internal/db/queries/tablos.sql (sqlc query style — :many/:one/:exec annotations, $N params)
|
||||
- backend/internal/web/handlers_tablos.go (how Queries methods are called — confirms field names)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- After `just migrate up`, `SELECT id FROM tasks LIMIT 1` on a fresh DB does not error
|
||||
- After `just migrate down` (one step), tasks table and task_status type are gone
|
||||
- After `sqlc generate`, backend/internal/db/sqlc/ contains TaskStatus type with constants TaskStatusTodo, TaskStatusInProgress, TaskStatusInReview, TaskStatusDone
|
||||
- After `sqlc generate`, sqlc.Task struct has fields: ID uuid.UUID, TabloID uuid.UUID, Title string, Description pgtype.Text, Status TaskStatus, Position int32, CreatedAt pgtype.Timestamptz, UpdatedAt pgtype.Timestamptz
|
||||
- ListTasksByTablo query accepts a single uuid.UUID param and returns []Task ordered by status, position, created_at
|
||||
- InsertTask returns a single Task row (`:one`)
|
||||
- DeleteTask is `:exec` with params (id uuid, tablo_id uuid)
|
||||
- MaxPositionByTabloAndStatus uses COALESCE(MAX(position), 0)::integer and returns a single int32 row
|
||||
</behavior>
|
||||
<action>
|
||||
Create `backend/migrations/0004_tasks.sql` with goose Up/Down sections. Up section:
|
||||
(1) CREATE TYPE task_status AS ENUM ('todo', 'in_progress', 'in_review', 'done') — declaration order must match visual left-to-right column order (Pitfall 6 from RESEARCH.md).
|
||||
(2) CREATE TABLE tasks with columns: id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tablo_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, title text NOT NULL, description text, status task_status NOT NULL DEFAULT 'todo', position integer NOT NULL DEFAULT 100, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now().
|
||||
(3) CREATE INDEX tasks_tablo_id_status_idx ON tasks(tablo_id, status, position).
|
||||
Down section: DROP TABLE IF EXISTS tasks; then DROP TYPE IF EXISTS task_status; — table MUST precede type (Pitfall 3 from RESEARCH.md).
|
||||
|
||||
Create `backend/internal/db/queries/tasks.sql` with these named queries (use same SELECT column list in every query — no wildcards):
|
||||
- ListTasksByTablo :many — WHERE tablo_id = $1 ORDER BY status, position, created_at
|
||||
- InsertTask :one — INSERT with params (tablo_id, title, description, status, position) RETURNING all columns
|
||||
- GetTaskByID :one — WHERE id = $1 AND tablo_id = $2
|
||||
- UpdateTask :one — UPDATE SET title=$2, description=$3, status=$4, position=$5, updated_at=now() WHERE id=$1 RETURNING all columns
|
||||
- DeleteTask :exec — DELETE WHERE id=$1 AND tablo_id=$2
|
||||
- MaxPositionByTabloAndStatus :one — SELECT COALESCE(MAX(position), 0)::integer AS max_position WHERE tablo_id=$1 AND status=$2
|
||||
|
||||
After creating both files, run: cd backend && just migrate up && just generate
|
||||
Verify sqlc output compiled by running: cd backend && go build ./...
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && just migrate up && just generate && go build ./...</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`just migrate up` exits 0. `just generate` exits 0. `go build ./...` exits 0. backend/internal/db/sqlc/ contains file(s) with TaskStatus type and InsertTask/ListTasksByTablo/DeleteTask functions visible via grep.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: RED test scaffold handlers_tasks_test.go + form structs tasks_forms.go</name>
|
||||
<files>backend/internal/web/handlers_tasks_test.go, backend/templates/tasks_forms.go</files>
|
||||
<read_first>
|
||||
- backend/internal/web/handlers_tablos_test.go (full file — test function names, newTabloTestRouter, loginUser, preInsertUser, getCSRFToken patterns to replicate)
|
||||
- backend/internal/web/router.go (NewRouter signature — see what params it takes; tasks router wiring will be added in Plan 02)
|
||||
- backend/internal/db/sqlc/ (generated Task, TaskStatus types — confirm field names before using in test setup)
|
||||
- backend/templates/tablos_forms.go (form struct pattern to mirror in tasks_forms.go)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `backend/templates/tasks_forms.go` in package templates with these exported structs:
|
||||
- TaskCreateForm: fields Title string, Status string (holds the column status value for the form)
|
||||
- TaskCreateErrors: fields Title string, General string
|
||||
- TaskUpdateForm: fields Title string, Description string
|
||||
- TaskUpdateErrors: fields Title string, Description string, General string
|
||||
|
||||
Create `backend/internal/web/handlers_tasks_test.go` in package web. The file must compile but all test functions must call t.Skip("handlers_tasks not yet implemented") or stub to t.Fatal so they are RED until Plan 02 implements the handlers. Include these test functions covering TASK-01 through TASK-07:
|
||||
- TestTasksKanbanRenders — GET /tablos/{id} by owner shows 4 column headers (TASK-01)
|
||||
- TestTaskCreate — POST /tablos/{id}/tasks creates task, returns 200+fragment for HTMX (TASK-02)
|
||||
- TestTaskCreateValidation — POST /tablos/{id}/tasks with empty title returns 422 (TASK-02)
|
||||
- TestTaskUpdate — POST /tablos/{id}/tasks/{task_id} updates title/desc, returns card fragment (TASK-03)
|
||||
- TestTaskReorderCrossColumn — POST /tablos/{id}/tasks/reorder changes task column (TASK-04)
|
||||
- TestTaskReorderSameColumn — POST /tablos/{id}/tasks/reorder changes position within column (TASK-05)
|
||||
- TestTaskDelete — POST /tablos/{id}/tasks/{task_id}/delete removes task, returns empty div (TASK-06)
|
||||
- TestTaskOrderPersists — GET /tablos/{id} after reorder shows tasks in new position order (TASK-07)
|
||||
- TestTaskOwnership — GET/POST task routes by non-owner return 404 (T-04-IDOR)
|
||||
|
||||
Each function must declare a newTaskTestRouter helper (or reuse newTabloTestRouter — same params). The test router will need TasksDeps once Plan 02 adds it; stub it as:
|
||||
type TasksDeps struct{ Queries *sqlc.Queries }
|
||||
declared at the top of handlers_tasks_test.go (will be moved to handlers_tasks.go in Plan 02).
|
||||
|
||||
Important: the test file compiles in the same `package web` as the other test files. Use setupTestDB, loginUser, preInsertUser, getCSRFToken, testCSRFKey from the existing test files — they are in the same package.
|
||||
|
||||
Run `cd backend && go build ./... && go test ./internal/web/ -run TestTask -v -count=1` — expect compile success and all TestTask* functions to be skipped or report t.Skip.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./... && go test ./internal/web/ -run TestTask -v -count=1 2>&1 | head -40</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`go build ./...` exits 0. `go test ./internal/web/ -run TestTask -v` exits 0 with all TestTask* functions appearing as SKIP or PASS (not compile errors, not unexpected FAIL). tasks_forms.go exports TaskCreateForm, TaskCreateErrors, TaskUpdateForm, TaskUpdateErrors structs.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Sortable.js bootstrap + soft-danger button CSS</name>
|
||||
<files>backend/justfile, backend/internal/web/ui/button.css</files>
|
||||
<read_first>
|
||||
- backend/justfile (full file — bootstrap recipe pattern, version variable declarations, clean recipe)
|
||||
- backend/internal/web/ui/button.css (full file — existing soft-neutral CSS rule to mirror for soft-danger)
|
||||
</read_first>
|
||||
<action>
|
||||
In `backend/justfile`:
|
||||
(1) Add variable at the top with pinned tools section: `sortable_version := "1.15.7"`
|
||||
(2) In the `bootstrap` recipe, after the HTMX download line, add:
|
||||
`curl -sSL -o static/sortable.min.js "https://cdn.jsdelivr.net/npm/sortablejs@{{ sortable_version }}/Sortable.min.js"`
|
||||
(3) In the `clean` recipe, add `static/sortable.min.js` to the rm -rf list alongside `static/htmx.min.js`.
|
||||
|
||||
In `backend/internal/web/ui/button.css`:
|
||||
Add the `.ui-button-soft-danger-md` CSS rule per UI-SPEC Component Inventory. The rule must include:
|
||||
display: inline-flex; align-items: center; border-radius: 0.375rem; background-color: #fee2e2; padding: 0.5rem 1rem; font-size: 1rem; font-weight: 600; color: #b91c1c; border: 1px solid #fecaca; min-height: 44px.
|
||||
Also add :hover rule (background-color: #fecaca) and :focus-visible rule (outline: 2px solid #b91c1c; outline-offset: 2px).
|
||||
|
||||
After editing button.css, run `just generate` to regenerate tailwind.css (no button.css compilation needed — it's a plain CSS file imported by tailwind.input.css or similar).
|
||||
|
||||
Then run `just bootstrap` to download sortable.min.js (requires network access).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && grep -c 'ui-button-soft-danger-md' internal/web/ui/button.css && grep -c 'sortable_version' justfile && ls static/sortable.min.js</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`grep -c 'ui-button-soft-danger-md' backend/internal/web/ui/button.css` returns 1 or more (rule exists). `grep -c 'sortable_version' backend/justfile` returns 1 or more. `static/sortable.min.js` exists on disk. `just generate` exits 0.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Migration → DB | goose applies SQL schema; task_status ENUM values hardcoded in migration |
|
||||
| sqlc queries → handler layer | Generated Go functions called by handlers; no user input reaches query source |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-04-01 | Tampering | 0004_tasks.sql Down migration | mitigate | DROP TABLE before DROP TYPE (Pitfall 3); verified by `just migrate down` in CI |
|
||||
| T-04-02 | Information Disclosure | tasks table ENUM declaration order | accept | ENUM declaration order matches visual column order; Pitfall 6 documented |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After all three tasks complete:
|
||||
- `cd backend && go build ./...` exits 0
|
||||
- `cd backend && go test ./internal/web/ -run TestTask -v` exits 0 (all SKIP)
|
||||
- `grep 'ui-button-soft-danger-md' backend/internal/web/ui/button.css` returns a match
|
||||
- `ls backend/static/sortable.min.js` succeeds
|
||||
- `ls backend/internal/db/queries/tasks.sql` succeeds
|
||||
- `ls backend/migrations/0004_tasks.sql` succeeds
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Wave 0 complete when:
|
||||
1. `just migrate up` applies 0004_tasks.sql cleanly against local Postgres
|
||||
2. `just generate` produces TaskStatus type and Task struct in internal/db/sqlc/
|
||||
3. `go build ./...` exits 0 — all packages compile
|
||||
4. `go test ./internal/web/ -run TestTask -v` exits 0 with 9 TestTask* functions SKIP'd
|
||||
5. button.css contains .ui-button-soft-danger-md rule
|
||||
6. static/sortable.min.js exists at version 1.15.7
|
||||
7. justfile has sortable_version variable and bootstrap downloads sortable.min.js
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-tasks-kanban/04-01-SUMMARY.md`
|
||||
</output>
|
||||
365
.planning/phases/04-tasks-kanban/04-02-PLAN.md
Normal file
365
.planning/phases/04-tasks-kanban/04-02-PLAN.md
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
---
|
||||
phase: 04-tasks-kanban
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- 04-01-PLAN.md
|
||||
files_modified:
|
||||
- backend/internal/web/handlers_tasks.go
|
||||
- backend/templates/tasks.templ
|
||||
- backend/internal/web/router.go
|
||||
- backend/cmd/web/main.go
|
||||
- backend/templates/tablos.templ
|
||||
- backend/templates/layout.templ
|
||||
- backend/internal/web/handlers_tasks_test.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- TASK-01
|
||||
- TASK-02
|
||||
- TASK-06
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "GET /tablos/{id} renders a kanban board with 4 column headers: To do, In progress, In review, Done"
|
||||
- "Authenticated owner sees task count badge (0 initially) on each column header"
|
||||
- "POST /tablos/{id}/tasks creates a task and returns TaskCard fragment for HTMX"
|
||||
- "POST /tablos/{id}/tasks with empty title returns 422 with inline error"
|
||||
- "GET /tablos/{id}/tasks/{task_id}/delete-confirm returns TaskDeleteConfirmFragment"
|
||||
- "POST /tablos/{id}/tasks/{task_id}/delete hard-deletes the task and returns empty div"
|
||||
- "Non-owner GET/POST task routes return 404"
|
||||
- "Unauthenticated requests redirect to /login"
|
||||
artifacts:
|
||||
- path: "backend/internal/web/handlers_tasks.go"
|
||||
provides: "TasksDeps, TaskCreateHandler, TaskDeleteConfirmHandler, TaskDeleteHandler, TaskShowHandler, TaskNewFormHandler, TaskCancelNewHandler"
|
||||
exports: ["TasksDeps", "TaskCreateHandler"]
|
||||
- path: "backend/templates/tasks.templ"
|
||||
provides: "KanbanBoard, KanbanColumn, TaskCard, TaskCreateForm, TaskDeleteConfirmFragment, AddTaskTrigger"
|
||||
contains: "KanbanBoard"
|
||||
- path: "backend/internal/web/router.go"
|
||||
provides: "task routes wired inside RequireAuth group"
|
||||
contains: "tasks/reorder"
|
||||
- path: "backend/templates/tablos.templ"
|
||||
provides: "KanbanBoard embedded below tablo detail header"
|
||||
contains: "KanbanBoard"
|
||||
key_links:
|
||||
- from: "backend/templates/tasks.templ"
|
||||
to: "backend/internal/web/handlers_tasks.go"
|
||||
via: "templ components rendered by handler functions"
|
||||
pattern: "templates.KanbanBoard|templates.TaskCard"
|
||||
- from: "backend/internal/web/router.go"
|
||||
to: "backend/internal/web/handlers_tasks.go"
|
||||
via: "chi route group inside RequireAuth"
|
||||
pattern: "TaskCreateHandler|TaskDeleteHandler"
|
||||
- from: "backend/templates/tablos.templ"
|
||||
to: "backend/templates/tasks.templ"
|
||||
via: "TabloDetailPage calls KanbanBoard"
|
||||
pattern: "@KanbanBoard"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Vertical slice 1: render the kanban board on the tablo detail page (TASK-01), add task creation via inline column form (TASK-02), and add task deletion with inline confirmation (TASK-06). After this plan, a real user can open a tablo, see 4 columns, create tasks, and delete them.
|
||||
|
||||
Purpose: Delivers the first user-visible slice — the kanban board scaffolding plus the two simplest task mutations. No drag-and-drop yet; that lands in Plan 03.
|
||||
Output: handlers_tasks.go with 7 handlers, tasks.templ with 6 components, router.go updated, tablos.templ updated to embed kanban, handlers_tasks_test.go updated from SKIP to real assertions.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-CONTEXT.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-RESEARCH.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-UI-SPEC.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. -->
|
||||
|
||||
From backend/internal/web/handlers_tablos.go (handler pattern to mirror exactly):
|
||||
type TablosDeps struct { Queries *sqlc.Queries }
|
||||
func loadOwnedTablo(w, r, deps TablosDeps) (sqlc.Tablo, *auth.User, bool) { ... }
|
||||
// HTMX-aware response pattern:
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = templates.TaskCard(task, csrfToken).Render(ctx, w)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/tablos/"+tabloID.String(), http.StatusSeeOther)
|
||||
|
||||
From backend/internal/web/router.go (route registration pattern):
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.RequireAuth)
|
||||
// static segments BEFORE parametric (Pitfall 1):
|
||||
r.Get("/tablos/new", ...)
|
||||
r.Post("/tablos", ...)
|
||||
r.Get("/tablos/{id}", ...)
|
||||
...
|
||||
})
|
||||
|
||||
From backend/internal/db/sqlc/ (after Plan 01 generates these):
|
||||
type TaskStatus string
|
||||
const (
|
||||
TaskStatusTodo TaskStatus = "todo"
|
||||
TaskStatusInProgress TaskStatus = "in_progress"
|
||||
TaskStatusInReview TaskStatus = "in_review"
|
||||
TaskStatusDone TaskStatus = "done"
|
||||
)
|
||||
type Task struct {
|
||||
ID uuid.UUID
|
||||
TabloID uuid.UUID
|
||||
Title string
|
||||
Description pgtype.Text
|
||||
Status TaskStatus
|
||||
Position int32
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
// Key query signatures:
|
||||
func (q *Queries) ListTasksByTablo(ctx, tabloID uuid.UUID) ([]Task, error)
|
||||
func (q *Queries) InsertTask(ctx, params InsertTaskParams) (Task, error)
|
||||
func (q *Queries) DeleteTask(ctx, params DeleteTaskParams) error
|
||||
func (q *Queries) MaxPositionByTabloAndStatus(ctx, params MaxPositionByTabloAndStatusParams) (int32, error)
|
||||
|
||||
From backend/templates/tasks_forms.go (Plan 01):
|
||||
type TaskCreateForm struct { Title string; Status string }
|
||||
type TaskCreateErrors struct { Title string; General string }
|
||||
type TaskUpdateForm struct { Title string; Description string }
|
||||
type TaskUpdateErrors struct { Title string; Description string; General string }
|
||||
|
||||
From backend/internal/web/ui/variants.go:
|
||||
ButtonVariantDanger ButtonVariant = "danger"
|
||||
ButtonToneSolid ButtonTone = "solid"
|
||||
ButtonToneSoft ButtonTone = "soft"
|
||||
BadgeVariantInfo BadgeVariant = "info"
|
||||
SizeMD Size = "md"
|
||||
|
||||
From backend/templates/tablos.templ (TabloDetailPage signature to update):
|
||||
templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo) { ... }
|
||||
|
||||
From backend/templates/layout.templ (Sortable.js script tag placement):
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
// Add after htmx: <script src="/static/sortable.min.js" defer></script>
|
||||
|
||||
UI-SPEC locked interaction contracts:
|
||||
- D-08/UI-SPEC §2: POST /tablos/{id}/tasks target="#column-{status}" swap="beforeend" for new TaskCard
|
||||
- D-08/UI-SPEC §2: OOB swap resets #add-task-slot-{status} to AddTaskTrigger
|
||||
- D-09/UI-SPEC §5: POST /tablos/{id}/tasks/{task_id}/delete returns <div id="task-{task_id}" class="task-card-zone"></div>
|
||||
- D-08/UI-SPEC §1: Column min-h-16, space-y-2, sortable-column; empty state "No tasks yet" in italic
|
||||
- UI-SPEC §4: drag handle .task-drag-handle with ⠿ glyph, aria-hidden="true"
|
||||
- UI-SPEC §3: .task-card-zone on both TaskCard wrapper and TaskEditFragment wrapper (for outerHTML round-trips)
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: handlers_tasks.go — TasksDeps + create/delete/show handlers</name>
|
||||
<files>backend/internal/web/handlers_tasks.go, backend/internal/web/router.go, backend/cmd/web/main.go</files>
|
||||
<read_first>
|
||||
- backend/internal/web/handlers_tablos.go (full file — TablosDeps, loadOwnedTablo, all handler functions — replicate this pattern exactly)
|
||||
- backend/internal/web/router.go (full file — RequireAuth group, route registration order for Pitfall 1)
|
||||
- backend/cmd/web/main.go (NewRouter call site — must be updated to pass TasksDeps)
|
||||
- backend/internal/db/sqlc/ (generated task types and query functions — confirm exact signatures)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- GET /tablos/{id}/tasks/new?status=todo returns 200 + TaskCreateForm HTML fragment
|
||||
- GET /tablos/{id}/tasks/new?status=invalid_status returns 200 + TaskCreateForm HTML (status defaults to "todo" if invalid)
|
||||
- POST /tablos/{id}/tasks with HX-Request:true, title="My task", status="todo" returns 200 + TaskCard HTML containing "My task"
|
||||
- POST /tablos/{id}/tasks with HX-Request:true, title="" returns 422 + form HTML containing "Title is required"
|
||||
- POST /tablos/{id}/tasks without HX-Request header returns 303 redirect to /tablos/{id}
|
||||
- GET /tablos/{id}/tasks/{task_id}/delete-confirm returns 200 + TaskDeleteConfirmFragment HTML
|
||||
- POST /tablos/{id}/tasks/{task_id}/delete with HX-Request:true returns 200 + empty div with id="task-{task_id}"
|
||||
- Any task route called by non-owner returns 404
|
||||
- GET /tablos/{id}/tasks/{task_id}/show returns 200 + TaskCard HTML (used by cancel paths)
|
||||
- GET /tablos/{id}/tasks/cancel-new?status=todo returns 200 + AddTaskTrigger HTML
|
||||
</behavior>
|
||||
<action>
|
||||
Create `backend/internal/web/handlers_tasks.go` in package web. Structure mirrors handlers_tablos.go exactly.
|
||||
|
||||
TasksDeps struct: field Queries *sqlc.Queries. This struct is referenced in handlers_tasks_test.go (Plan 01).
|
||||
|
||||
Define these exported Go constants/variables in handlers_tasks.go:
|
||||
- TaskColumns []sqlc.TaskStatus = {TaskStatusTodo, TaskStatusInProgress, TaskStatusInReview, TaskStatusDone}
|
||||
- TaskColumnLabels map[sqlc.TaskStatus]string with values: todo→"To do", in_progress→"In progress", in_review→"In review", done→"Done"
|
||||
|
||||
loadOwnedTabloForTask helper: accepts (w, r, deps TasksDeps) — calls loadOwnedTablo (which is in handlers_tablos.go, same package) to get the tablo, then parses chi.URLParam(r, "task_id") as uuid.UUID, then calls deps.Queries.GetTaskByID with (ctx, GetTaskByIDParams{ID: taskID, TabloID: tablo.ID}) — returns (sqlc.Tablo, sqlc.Task, *auth.User, bool). Returns false+writes HTTP error on any failure.
|
||||
|
||||
Implement these handler functions, each returning http.HandlerFunc:
|
||||
- TaskNewFormHandler(deps) — GET /tablos/{id}/tasks/new?status={status}: read status from r.URL.Query().Get("status"), validate it is one of the 4 valid statuses (default to "todo" if invalid), return TaskCreateForm fragment
|
||||
- TaskCancelNewHandler(deps) — GET /tablos/{id}/tasks/cancel-new?status={status}: return AddTaskTrigger fragment
|
||||
- TaskCreateHandler(deps) — POST /tablos/{id}/tasks: read title=r.PostFormValue("title"), status=r.PostFormValue("status"). Validate title non-empty (max 255 chars). On validation error: 422 + form fragment (HTMX) or 422 + redirect (non-HTMX). On success: call MaxPositionByTabloAndStatus to get current max, compute nextPos = maxPos + 100. Call InsertTask with InsertTaskParams{TabloID: tablo.ID, Title: title, Description: pgtype.Text{Valid: false}, Status: sqlc.TaskStatus(status), Position: nextPos}. HTMX response: set HX-Reswap: "beforeend" + HX-Retarget: "#column-{status}", return TaskCard fragment; also include OOB AddTaskTrigger swap targeting #add-task-slot-{status}. Non-HTMX: 303 to /tablos/{id}.
|
||||
- TaskShowHandler(deps) — GET /tablos/{id}/tasks/{task_id}/show: return TaskCard fragment
|
||||
- TaskDeleteConfirmHandler(deps) — GET /tablos/{id}/tasks/{task_id}/delete-confirm: return TaskDeleteConfirmFragment
|
||||
- TaskDeleteHandler(deps) — POST /tablos/{id}/tasks/{task_id}/delete: call DeleteTask(ctx, DeleteTaskParams{ID: task.ID, TabloID: tablo.ID}). HTMX: return 200 + empty div `<div id="task-{task_id}" class="task-card-zone"></div>`. Non-HTMX: 303 to /tablos/{id}.
|
||||
|
||||
In `backend/internal/web/router.go`, inside the RequireAuth group, add task routes AFTER the existing tablo routes but register static segments BEFORE parametric (Pitfall 1 from RESEARCH.md). Route order within /tablos/{id}/tasks*:
|
||||
1. r.Get("/tablos/{id}/tasks/new", TaskNewFormHandler(taskDeps))
|
||||
2. r.Post("/tablos", ...) — existing
|
||||
3. r.Post("/tablos/{id}/tasks", TaskCreateHandler(taskDeps))
|
||||
4. r.Get("/tablos/{id}/tasks/cancel-new", TaskCancelNewHandler(taskDeps))
|
||||
5. r.Post("/tablos/{id}/tasks/reorder", TaskReorderHandler(taskDeps)) — stub returning 501 for now; Plan 03 implements it
|
||||
6. r.Get("/tablos/{id}/tasks/{task_id}/show", TaskShowHandler(taskDeps))
|
||||
7. r.Get("/tablos/{id}/tasks/{task_id}/edit", TaskEditHandler(taskDeps)) — stub returning 501 for Plan 03
|
||||
8. r.Post("/tablos/{id}/tasks/{task_id}", TaskUpdateHandler(taskDeps)) — stub returning 501 for Plan 03
|
||||
9. r.Get("/tablos/{id}/tasks/{task_id}/delete-confirm", TaskDeleteConfirmHandler(taskDeps))
|
||||
10. r.Post("/tablos/{id}/tasks/{task_id}/delete", TaskDeleteHandler(taskDeps))
|
||||
|
||||
Update NewRouter signature to accept taskDeps TasksDeps as a parameter (after tabloDeps). Update all call sites. In `backend/cmd/web/main.go`, instantiate TasksDeps{Queries: queries} and pass to NewRouter.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./... && go test ./internal/web/ -run "TestTaskCreate|TestTaskDelete|TestTaskOwnership" -v -count=1</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`go build ./...` exits 0. TestTaskCreate, TestTaskDelete, TestTaskOwnership tests pass (green). Handler functions TaskCreateHandler and TaskDeleteHandler exist in handlers_tasks.go. Router has task routes registered. `grep -c 'TaskCreateHandler' internal/web/router.go` returns 1 or more.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: tasks.templ — KanbanBoard, KanbanColumn, TaskCard, TaskCreateForm, TaskDeleteConfirmFragment, AddTaskTrigger</name>
|
||||
<files>backend/templates/tasks.templ, backend/templates/tablos.templ, backend/templates/layout.templ</files>
|
||||
<read_first>
|
||||
- backend/templates/tablos.templ (full file — TabloDetailPage, TabloCard patterns; understand how KanbanBoard is embedded below tablo header)
|
||||
- backend/templates/layout.templ (full file — script tag location for adding sortable.min.js)
|
||||
- backend/internal/web/ui/button.templ (Button component signature)
|
||||
- backend/internal/web/ui/badge.templ (Badge component signature)
|
||||
- backend/internal/web/ui/csrf_field.templ (CSRFField usage)
|
||||
- backend/internal/web/handlers_tasks.go (TaskColumns, TaskColumnLabels — used in templates)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- KanbanBoard renders a div#kanban-board with flex gap-4 overflow-x-auto pb-4 containing 4 KanbanColumn components
|
||||
- KanbanBoard contains a hidden form#reorder-form with hx-post="/tablos/{id}/tasks/reorder" hx-target="#kanban-board" hx-swap="outerHTML"
|
||||
- KanbanColumn renders: column header with h3 text-sm font-semibold text-slate-700 + ui.Badge(info, count), sortable-column div with data-status and id="column-{status}", empty state "No tasks yet" when len(tasks)==0, add-task slot div with id="add-task-slot-{status}"
|
||||
- TaskCard has outer wrapper div with class "task-card-zone" and id="task-{task.ID}", inner .task-card with data-task-id="{task.ID}", drag handle div.task-drag-handle with ⠿ glyph and aria-hidden="true", title text, "Delete" button (soft-danger-md class directly), hx-get for delete-confirm
|
||||
- TaskCreateForm has title input (name="title", maxlength="255", required), hidden status input, CSRFField, Save button (solid default md), Discard button (soft neutral md)
|
||||
- TaskDeleteConfirmFragment has "Delete task?" heading, "This cannot be undone." body, "Yes, delete" form (POST .../delete with CSRFField), "Keep task" button (hx-get to show)
|
||||
- AddTaskTrigger renders the "+ Add task" button with hx-get and hx-target="#add-task-slot-{status}" hx-swap="innerHTML"
|
||||
- TabloDetailPage now calls @KanbanBoard(tablo.ID, csrfToken, tasks) after the existing tablo header section; requires tasks []sqlc.Task param added to TabloDetailPage signature
|
||||
- layout.templ has sortable.min.js script tag alongside htmx.min.js (both defer)
|
||||
</behavior>
|
||||
<action>
|
||||
Create `backend/templates/tasks.templ` in package templates. Imports needed: backend/internal/db/sqlc, backend/internal/web, backend/internal/web/ui, github.com/google/uuid, strconv (for Itoa in badge count), templ.
|
||||
|
||||
Implement these templ components following exact specs from UI-SPEC.md and RESEARCH.md Pattern 3:
|
||||
|
||||
groupTasksByStatus(tasks []sqlc.Task) map[sqlc.TaskStatus][]sqlc.Task — a plain Go helper function (not a templ component) in this file that groups the task slice by Status field.
|
||||
|
||||
KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task) — outer div#kanban-board, hidden reorder form with @ui.CSRFField(csrfToken), 4 KanbanColumn calls iterating web.TaskColumns. Use templ.SafeURL for action attribute: templ.SafeURL("/tablos/"+tabloID.String()+"/tasks/reorder").
|
||||
|
||||
KanbanColumn(tabloID uuid.UUID, status sqlc.TaskStatus, tasks []sqlc.Task, csrfToken string) — column header div with bg-slate-100 rounded px-3 py-2 mb-2, h3 text-sm font-semibold text-slate-700 with column label from web.TaskColumnLabels[status] + ui.Badge(ui.BadgeProps{Label: strconv.Itoa(len(tasks)), Variant: ui.BadgeVariantInfo}). Sortable list div with classes "sortable-column min-h-16 space-y-2", data-status={string(status)}, id={"column-"+string(status)}, aria-label={web.TaskColumnLabels[status]+" column"}. Empty state paragraph when len(tasks)==0. For each task: @TaskCard(tabloID, task, csrfToken). Add-task slot div#add-task-slot-{status} containing @AddTaskTrigger(tabloID, status, csrfToken).
|
||||
|
||||
TaskCard(tabloID uuid.UUID, task sqlc.Task, csrfToken string) — outer div with class "task-card-zone" and id={"task-"+task.ID.String()}. Inner div class="task-card" with data-task-id={task.ID.String()}. Include: drag handle div class="task-drag-handle ..." aria-hidden="true" with ⠿ glyph. Task title in p tag. Clickable area for edit (hx-get to .../edit, hx-target="closest .task-card-zone", hx-swap="outerHTML") with role="button" aria-label={"Edit task: "+task.Title}. Delete button using class "ui-button-soft-danger-md" directly (not ui.Button — this variant is raw CSS) with hx-get for delete-confirm, hx-target="closest .task-card-zone", hx-swap="outerHTML", aria-label={"Delete task: "+task.Title}.
|
||||
|
||||
TaskCreateForm(tabloID uuid.UUID, status sqlc.TaskStatus, form templates.TaskCreateForm, errs templates.TaskCreateErrors, csrfToken string) — form with method POST, action="/tablos/{tabloID}/tasks", hx-post, hx-target="#column-{status}", hx-swap="beforeend". Hidden status input (name="status"). Title input with name="title", value={form.Title}, maxlength="255", required, placeholder="Task title". @FieldError(errs.Title) if non-empty. CSRFField. Save button (ui.Button solid default md type="submit"). Discard button (ui.Button soft neutral md) with hx-get="/tablos/{tabloID}/tasks/cancel-new?status={status}" hx-target="#add-task-slot-{status}" hx-swap="innerHTML".
|
||||
|
||||
TaskDeleteConfirmFragment(tabloID uuid.UUID, task sqlc.Task, csrfToken string) — outer div class="task-card-zone" id={"task-"+task.ID.String()}. Heading "Delete task?", body "This cannot be undone." Delete form: method POST, action="/tablos/{tabloID}/tasks/{taskID}/delete", hx-post, hx-target="closest .task-card-zone", hx-swap="outerHTML", with CSRFField + "Yes, delete" submit button (ui.Button solid danger md). Keep button (ui.Button soft neutral md) with hx-get=".../show" hx-target="closest .task-card-zone" hx-swap="outerHTML" label="Keep task".
|
||||
|
||||
AddTaskTrigger(tabloID uuid.UUID, status sqlc.TaskStatus, csrfToken string) — a button with class "ui-button ui-button-soft-neutral-md w-full text-left text-sm mt-2", hx-get="/tablos/{tabloID}/tasks/new?status={status}", hx-target="#add-task-slot-{status}", hx-swap="innerHTML", label "+ Add task".
|
||||
|
||||
TaskCardOOB(status sqlc.TaskStatus, task sqlc.Task, tabloID uuid.UUID, csrfToken string) — a helper component used by TaskCreateHandler to append the new card AND return OOB reset of the add-task slot. Contains: @TaskCard(...) followed by a div with hx-swap-oob="innerHTML:#add-task-slot-{status}" containing @AddTaskTrigger(...).
|
||||
|
||||
Update `backend/templates/tablos.templ`: Change TabloDetailPage(user, csrfToken, tablo) to TabloDetailPage(user, csrfToken, tablo, tasks []sqlc.Task). Add @KanbanBoard(tablo.ID, csrfToken, tasks) below the existing tablo header/edit/delete sections with a div class="mt-8" wrapper. Also update the handler in handlers_tablos.go to fetch tasks before rendering: call deps.Queries.ListTasksByTablo(ctx, tablo.ID) and pass results to TabloDetailPage. If query fails, log and use empty slice (don't 500 — the tablo itself is valid). NOTE: TablosDeps must also reference Queries which has the tasks queries after sqlc generate; no struct change needed since both tablo and task queries are on the same *sqlc.Queries.
|
||||
|
||||
Update `backend/templates/layout.templ`: add `<script src="/static/sortable.min.js" defer></script>` after the htmx.min.js script tag. Also update the footer text from "Phase 3 · Tablos" to "Phase 4 · Tasks".
|
||||
|
||||
After editing .templ files, run: cd backend && just generate && go build ./...
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && just generate && go build ./... && go test ./internal/web/ -run "TestTasksKanbanRenders|TestTaskCreate|TestTaskDelete" -v -count=1</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`just generate` exits 0 (no templ errors). `go build ./...` exits 0. TestTasksKanbanRenders, TestTaskCreate, TestTaskDelete all pass. `grep -rn 'KanbanBoard' templates/tasks.templ` returns at least one match. `grep -c 'sortable.min.js' templates/layout.templ` returns 1.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Update handlers_tasks_test.go — turn RED stubs GREEN for TASK-01, TASK-02, TASK-06</name>
|
||||
<files>backend/internal/web/handlers_tasks_test.go</files>
|
||||
<read_first>
|
||||
- backend/internal/web/handlers_tasks_test.go (current state — the stubs from Plan 01 to replace)
|
||||
- backend/internal/web/handlers_tablos_test.go (full file — integration test patterns: loginUser, preInsertUser, getCSRFToken, HTMX header setting, cookie forwarding)
|
||||
- backend/internal/web/handlers_tasks.go (handler signatures — confirm route paths and response shapes)
|
||||
- backend/internal/db/sqlc/ (InsertTaskParams, TaskStatus constants for test setup)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestTasksKanbanRenders: authenticated GET /tablos/{id} response body contains "To do", "In progress", "In review", "Done" (4 column headers) and "kanban-board" id attribute
|
||||
- TestTaskCreate: authenticated HTMX POST /tablos/{id}/tasks with title="Finish tests", status="todo" returns 200; body contains "Finish tests"; task exists in DB after
|
||||
- TestTaskCreateValidation: HTMX POST with title="" returns 422; body contains "Title is required"
|
||||
- TestTaskDelete: pre-insert a task; HTMX POST /tablos/{id}/tasks/{task_id}/delete returns 200; body contains empty div with id="task-{task_id}"; GetTaskByID after delete returns pgx.ErrNoRows
|
||||
- TestTaskOwnership: userB attempts GET /tablos/{userA_tablo_id}/tasks/new returns 404; POST /tablos/{userA_tablo_id}/tasks returns 404
|
||||
- TestTasksKanbanRenders also verifies non-owner gets 404 (ownership guard from loadOwnedTablo)
|
||||
</behavior>
|
||||
<action>
|
||||
Replace the t.Skip() stubs in `backend/internal/web/handlers_tasks_test.go` for TestTasksKanbanRenders, TestTaskCreate, TestTaskCreateValidation, TestTaskDelete, and TestTaskOwnership with real integration test bodies.
|
||||
|
||||
Use the same test helper pattern as handlers_tablos_test.go: setupTestDB(t), preInsertUser, loginUser, getCSRFToken.
|
||||
|
||||
For each test that needs a tablo: use q.InsertTablo(ctx, InsertTabloParams{UserID: user.ID, Title: "Test tablo", ...}).
|
||||
|
||||
For TestTaskCreate and TestTaskDelete: pre-insert a tablo, then make the request.
|
||||
|
||||
For TestTaskDelete: pre-insert a task via q.InsertTask(ctx, InsertTaskParams{TabloID: tablo.ID, Title: "Delete me", Status: sqlc.TaskStatusTodo, Position: 100}).
|
||||
|
||||
HTMX requests: set req.Header.Set("HX-Request", "true") and req.Header.Set("Content-Type", "application/x-www-form-urlencoded").
|
||||
|
||||
For route param construction use tablo.ID.String() and task.ID.String().
|
||||
|
||||
newTaskTestRouter helper: mirrors newTabloTestRouter but passes TasksDeps alongside TablosDeps. Since NewRouter now takes TasksDeps, update this call. Also keep TestTaskReorderCrossColumn, TestTaskReorderSameColumn, TestTaskUpdate, TestTaskOrderPersists as t.Skip() — those are Plan 03.
|
||||
|
||||
Run the full task test suite after implementing: `go test ./internal/web/ -run TestTask -v -count=1`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/web/ -run TestTask -v -count=1 2>&1 | grep -E "^(--- PASS|--- SKIP|--- FAIL|FAIL|ok)"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`go test ./internal/web/ -run TestTask -v` exits 0. TestTasksKanbanRenders, TestTaskCreate, TestTaskCreateValidation, TestTaskDelete, TestTaskOwnership all show PASS. TestTaskReorder*, TestTaskUpdate, TestTaskOrderPersists show SKIP. No FAIL lines.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser → POST /tablos/{id}/tasks | title and status come from user form input |
|
||||
| POST /tablos/{id}/tasks/{task_id}/delete | task_id from URL param + tablo_id verified against user ownership |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-04-03 | Elevation of Privilege | loadOwnedTabloForTask | mitigate | GetTaskByID query uses WHERE id=$1 AND tablo_id=$2; tablo_id is already ownership-verified by loadOwnedTablo before task fetch |
|
||||
| T-04-04 | Tampering | POST /tablos/{id}/tasks title field | mitigate | title validated non-empty, max 255 chars; status validated against known TaskStatus constants before DB insert |
|
||||
| T-04-05 | Tampering | POST /tablos/{id}/tasks status field | mitigate | sqlc.TaskStatus(status) is passed to DB; Postgres ENUM rejects invalid values at DB layer |
|
||||
| T-04-06 | Spoofing | Unauthenticated task routes | accept | RequireAuth middleware (Phase 2) blocks all /tablos/* group routes before handlers run |
|
||||
| T-04-07 | Information Disclosure | GET /tablos/{id} with tasks | mitigate | TabloDetailPage only renders if loadOwnedTablo passes ownership check; tasks fetched with tablo_id filter (not user filter) but tablo ownership is already proven |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After all three tasks complete:
|
||||
- `cd backend && go build ./...` exits 0
|
||||
- `cd backend && go test ./internal/web/ -run TestTask -v` exits 0 with 5 PASS + 4 SKIP
|
||||
- `cd backend && go test ./...` exits 0 (full suite green)
|
||||
- `grep -c 'KanbanBoard' backend/templates/tasks.templ` returns at least 1
|
||||
- `grep -c 'TaskCreateHandler' backend/internal/web/router.go` returns at least 1
|
||||
- `grep -c 'sortable.min.js' backend/templates/layout.templ` returns 1
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Plan 02 complete when:
|
||||
1. GET /tablos/{id} renders 4 kanban columns with correct labels (verified by test)
|
||||
2. POST /tablos/{id}/tasks creates a task and returns TaskCard HTML fragment (verified by test)
|
||||
3. POST /tablos/{id}/tasks with empty title returns 422 with "Title is required" (verified by test)
|
||||
4. POST /tablos/{id}/tasks/{task_id}/delete removes the task and returns empty div (verified by test)
|
||||
5. Non-owner requests return 404 (verified by TestTaskOwnership)
|
||||
6. `go test ./...` is green (all tests in all packages pass)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-tasks-kanban/04-02-SUMMARY.md`
|
||||
</output>
|
||||
291
.planning/phases/04-tasks-kanban/04-03-PLAN.md
Normal file
291
.planning/phases/04-tasks-kanban/04-03-PLAN.md
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
---
|
||||
phase: 04-tasks-kanban
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on:
|
||||
- 04-02-PLAN.md
|
||||
files_modified:
|
||||
- backend/internal/web/handlers_tasks.go
|
||||
- backend/templates/tasks.templ
|
||||
- backend/internal/web/handlers_tasks_test.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- TASK-03
|
||||
- TASK-04
|
||||
- TASK-05
|
||||
- TASK-07
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Clicking a task card swaps it to an inline edit form showing current title and description"
|
||||
- "Saving the edit form updates title/description in the DB and swaps back to the display card"
|
||||
- "Cancelling edit restores the display card without a server POST"
|
||||
- "Dragging a task to a different column updates its status and position in the DB"
|
||||
- "Reordering tasks within a column updates their positions in the DB"
|
||||
- "GET /tablos/{id} after a reorder shows tasks in the saved position order"
|
||||
- "Reorder endpoint only modifies position and status — title and description are unchanged"
|
||||
artifacts:
|
||||
- path: "backend/internal/web/handlers_tasks.go"
|
||||
provides: "TaskEditHandler, TaskUpdateHandler, TaskReorderHandler — all fully implemented"
|
||||
exports: ["TaskEditHandler", "TaskUpdateHandler", "TaskReorderHandler"]
|
||||
- path: "backend/templates/tasks.templ"
|
||||
provides: "TaskEditFragment templ component"
|
||||
contains: "TaskEditFragment"
|
||||
- path: "backend/internal/web/handlers_tasks_test.go"
|
||||
provides: "TestTaskUpdate, TestTaskReorderCrossColumn, TestTaskReorderSameColumn, TestTaskOrderPersists — all green"
|
||||
contains: "TestTaskReorderCrossColumn"
|
||||
key_links:
|
||||
- from: "browser Sortable.js onEnd"
|
||||
to: "POST /tablos/{id}/tasks/reorder"
|
||||
via: "hidden form#reorder-form triggered via htmx.trigger(form, 'submit')"
|
||||
pattern: "task_id|task_col"
|
||||
- from: "POST /tablos/{id}/tasks/reorder"
|
||||
to: "sqlc.UpdateTask"
|
||||
via: "loop over r.Form[task_id] with position=(index+1)*100"
|
||||
pattern: "UpdateTaskParams"
|
||||
- from: "GET /tablos/{id}/tasks/{task_id}/edit"
|
||||
to: "templates.TaskEditFragment"
|
||||
via: "outerHTML swap on .task-card-zone"
|
||||
pattern: "task-card-zone"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Vertical slice 2: implement task inline editing (TASK-03), cross-column move via drag-and-drop (TASK-04), within-column reorder (TASK-05), and verify ordering persists (TASK-07). After this plan, the kanban board is fully functional — all 7 TASK requirements are delivered and all TestTask* integration tests pass.
|
||||
|
||||
Purpose: Completes the kanban board. Sortable.js wiring + reorder endpoint closes TASK-04/05/07; inline edit closes TASK-03. The phase is done when `go test ./...` is green.
|
||||
Output: TaskEditHandler, TaskUpdateHandler, TaskReorderHandler fully implemented; TaskEditFragment templ component; all 9 TestTask* tests passing.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-CONTEXT.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-RESEARCH.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-UI-SPEC.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-02-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key contracts the executor needs. -->
|
||||
|
||||
From backend/internal/web/handlers_tasks.go (Plan 02 output — stubs to replace):
|
||||
// TaskEditHandler — currently returns http.Error 501
|
||||
func TaskEditHandler(deps TasksDeps) http.HandlerFunc { ... }
|
||||
// TaskUpdateHandler — currently returns http.Error 501
|
||||
func TaskUpdateHandler(deps TasksDeps) http.HandlerFunc { ... }
|
||||
// TaskReorderHandler — currently returns http.Error 501
|
||||
func TaskReorderHandler(deps TasksDeps) http.HandlerFunc { ... }
|
||||
|
||||
From backend/internal/db/sqlc/ (generated after Plan 01):
|
||||
// UpdateTask signature (combined status+position+title+desc update):
|
||||
func (q *Queries) UpdateTask(ctx, params UpdateTaskParams) (Task, error)
|
||||
type UpdateTaskParams struct {
|
||||
ID uuid.UUID
|
||||
Title string
|
||||
Description pgtype.Text
|
||||
Status TaskStatus
|
||||
Position int32
|
||||
}
|
||||
// GetTaskByID:
|
||||
func (q *Queries) GetTaskByID(ctx, params GetTaskByIDParams) (Task, error)
|
||||
type GetTaskByIDParams struct { ID uuid.UUID; TabloID uuid.UUID }
|
||||
|
||||
Reorder payload format (RESEARCH.md Pattern 3 + D-07):
|
||||
r.Form["task_id"] — ordered array of task UUIDs (one entry per task, in new visual order)
|
||||
r.Form["task_col"] — parallel array of new column status strings (same length as task_id)
|
||||
Position computed as: (index+1) * 100 — e.g. first task in array gets position=100, second=200...
|
||||
gorilla/csrf reads _csrf from r.PostFormValue; call r.ParseForm() before accessing r.Form["task_id"]
|
||||
|
||||
UI-SPEC §3 edit interaction contracts:
|
||||
- GET /tablos/{id}/tasks/{task_id}/edit → returns TaskEditFragment (hx-target="closest .task-card-zone" hx-swap="outerHTML")
|
||||
- POST /tablos/{id}/tasks/{task_id} → on success returns TaskCard (same outerHTML swap target)
|
||||
- "Discard changes" button: hx-get=".../show" hx-target="closest .task-card-zone" hx-swap="outerHTML" — restores without POST
|
||||
- TaskEditFragment outer wrapper must have class="task-card-zone" id="task-{task_id}" (for outerHTML round-trips)
|
||||
|
||||
Reorder full-board refresh (RESEARCH.md Open Question 2 recommendation + D-07):
|
||||
- After reorder POST: re-fetch all tasks for the tablo, return updated KanbanBoard outerHTML
|
||||
- hx-target="#kanban-board" hx-swap="outerHTML" on the hidden reorder form
|
||||
- Sortable.js re-initialized via htmx.onLoad (Pitfall 2 from RESEARCH.md)
|
||||
|
||||
From backend/templates/tasks.templ (Plan 02 output):
|
||||
// TaskCard outer wrapper: class="task-card-zone" id={"task-"+task.ID.String()}
|
||||
// The edit trigger: hx-get="/tablos/{id}/tasks/{task_id}/edit"
|
||||
// hx-target="closest .task-card-zone"
|
||||
// hx-swap="outerHTML"
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: TaskEditHandler, TaskUpdateHandler — inline task editing</name>
|
||||
<files>backend/internal/web/handlers_tasks.go, backend/templates/tasks.templ</files>
|
||||
<read_first>
|
||||
- backend/internal/web/handlers_tasks.go (current state — TaskEditHandler and TaskUpdateHandler stubs to replace; also read loadOwnedTabloForTask implementation)
|
||||
- backend/internal/web/handlers_tablos.go (TabloUpdateHandler — exact pattern for validation + HTMX fragment response to mirror)
|
||||
- backend/templates/tasks.templ (current state — TaskCard component structure; TaskEditFragment must use same .task-card-zone outer wrapper + id)
|
||||
- backend/internal/db/sqlc/ (UpdateTaskParams fields — confirm Status and Position are required)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- GET /tablos/{id}/tasks/{task_id}/edit returns 200 + TaskEditFragment HTML with title input pre-filled with task.Title, description textarea pre-filled with task.Description.String (empty string if !Valid)
|
||||
- GET .../edit by non-owner (different user's tablo) returns 404
|
||||
- POST /tablos/{id}/tasks/{task_id} with HX-Request:true, title="Updated title", description="New desc" returns 200 + TaskCard HTML containing "Updated title" and "New desc"
|
||||
- POST /tablos/{id}/tasks/{task_id} with title="" returns 422 + TaskEditFragment HTML containing "Title is required."
|
||||
- POST /tablos/{id}/tasks/{task_id} without HX-Request header returns 303 redirect to /tablos/{id}
|
||||
- After successful POST, GetTaskByID from DB shows Title="Updated title" and Description.String="New desc"
|
||||
- UpdateTask preserves existing Status and Position values (does not reset them)
|
||||
</behavior>
|
||||
<action>
|
||||
In `backend/internal/web/handlers_tasks.go`, replace the 501-stub bodies of TaskEditHandler and TaskUpdateHandler:
|
||||
|
||||
TaskEditHandler(deps): call loadOwnedTabloForTask. Return 200 + TaskEditFragment(tabloID, task, TaskUpdateForm{Title: task.Title, Description: task.Description.String}, TaskUpdateErrors{}, csrfToken).
|
||||
|
||||
TaskUpdateHandler(deps): call loadOwnedTabloForTask. Read title = strings.TrimSpace(r.PostFormValue("title")) and description = r.PostFormValue("description"). Validate title non-empty and <=255 chars. On error: 422 + TaskEditFragment with errors (HTMX) or 422 + redirect (non-HTMX). On success: call UpdateTask with UpdateTaskParams{ID: task.ID, Title: title, Description: pgtype.Text{String: description, Valid: description != ""}, Status: task.Status, Position: task.Position} — preserves existing Status and Position (only title/description change). HTMX: return 200 + TaskCard(tabloID, updatedTask, csrfToken). Non-HTMX: 303 to /tablos/{tablo.ID.String()}.
|
||||
|
||||
Add TaskEditFragment to `backend/templates/tasks.templ`:
|
||||
Signature: TaskEditFragment(tabloID uuid.UUID, task sqlc.Task, form templates.TaskUpdateForm, errs templates.TaskUpdateErrors, csrfToken string).
|
||||
Outer div: class="task-card-zone" id={"task-"+task.ID.String()} (same as TaskCard wrapper — enables outerHTML round-trip).
|
||||
Form: method POST, action="/tablos/{tabloID}/tasks/{taskID}", hx-post same URL, hx-target="closest .task-card-zone", hx-swap="outerHTML".
|
||||
Fields: title input (name="title", value={form.Title}, maxlength="255", required, same styling as TaskCreateForm). Description textarea (name="description", rows="3", placeholder="Description (optional)", value={form.Description}). @FieldError(errs.Title) if non-empty. @FieldError(errs.General) if non-empty. @ui.CSRFField(csrfToken). "Save changes" button (ui.Button solid default md, type="submit"). "Discard changes" button (ui.Button soft neutral md) with hx-get="/tablos/{tabloID}/tasks/{taskID}/show" hx-target="closest .task-card-zone" hx-swap="outerHTML".
|
||||
|
||||
After editing, run: cd backend && just generate && go build ./...
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && just generate && go build ./... && go test ./internal/web/ -run TestTaskUpdate -v -count=1</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`just generate` exits 0. `go build ./...` exits 0. TestTaskUpdate passes. `grep -c 'TaskEditFragment' templates/tasks.templ` returns 1 or more. `grep -c 'TaskEditHandler' internal/web/handlers_tasks.go` returns 1 or more (non-stub implementation).
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: TaskReorderHandler + Sortable.js inline script</name>
|
||||
<files>backend/internal/web/handlers_tasks.go, backend/templates/tasks.templ</files>
|
||||
<read_first>
|
||||
- backend/internal/web/handlers_tasks.go (current state — TaskReorderHandler stub to replace; also read TaskColumns, TaskColumnLabels)
|
||||
- backend/templates/tasks.templ (current state — KanbanBoard hidden reorder form; location to add Sortable.js inline script)
|
||||
- backend/internal/db/sqlc/ (UpdateTaskParams, ListTasksByTablo — confirm signatures)
|
||||
- backend/internal/auth/csrf.go (how csrf.RequestHeader is configured — confirm gorilla/csrf reads _csrf from form field)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- POST /tablos/{id}/tasks/reorder with task_id[]=uuid1&task_col[]=in_progress&task_id[]=uuid2&task_col[]=todo returns 200 + KanbanBoard outerHTML with both tasks in their new columns
|
||||
- After reorder POST, uuid1 has status=in_progress position=100 in DB; uuid2 has status=todo position=100 in DB (positions renumbered from (index+1)*100)
|
||||
- POST /tablos/{id}/tasks/reorder by non-owner returns 404
|
||||
- POST /tablos/{id}/tasks/reorder with mismatched task_id and task_col array lengths returns 400
|
||||
- GET /tablos/{id} after a reorder POST shows tasks in the order returned by ListTasksByTablo (status ORDER BY, then position)
|
||||
- Sortable.js onEnd fires htmx.trigger(form, "submit") on the #reorder-form element
|
||||
- Invalid task UUIDs in the reorder payload are skipped silently (last-write-wins per D-05)
|
||||
</behavior>
|
||||
<action>
|
||||
In `backend/internal/web/handlers_tasks.go`, replace TaskReorderHandler stub:
|
||||
|
||||
TaskReorderHandler(deps): call loadOwnedTablo (not loadOwnedTabloForTask — reorder operates at the tablo level, not per-task). Call r.ParseForm() explicitly before accessing r.Form arrays. Read taskIDs := r.Form["task_id"] and taskCols := r.Form["task_col"]. If len(taskIDs) != len(taskCols), return 400. Loop: for i, rawID := range taskIDs { parse uuid, skip on error; newPos := int32((i+1) * 100); newStatus := sqlc.TaskStatus(taskCols[i]); call UpdateTask with UpdateTaskParams{ID: taskID, Title: existingTask.Title, Description: existingTask.Description, Status: newStatus, Position: newPos} }. Note: to preserve Title/Description during reorder, fetch each task first with GetTaskByID before updating, OR use a separate sqlc query that only updates status+position. Use the latter approach: add a comment noting this is intentional (RESEARCH.md anti-pattern: mass assignment — reorder only touches position/status). After loop, re-fetch with ListTasksByTablo and return KanbanBoard outerHTML. Set Content-Type: text/html.
|
||||
|
||||
Important: the UpdateTask sqlc query updates title, description, status, and position. To avoid mass assignment (T-04-MASS), fetch the task first using GetTaskByID, then pass back the existing title/desc alongside new status/position to UpdateTask. This is the safest pattern given the existing query shape.
|
||||
|
||||
Add the Sortable.js inline initialization script to `backend/templates/tasks.templ`, inside KanbanBoard, after the column divs. The script must:
|
||||
- Wrap all initialization in `htmx.onLoad(function(content) { ... })` to reinitialize after every HTMX swap (Pitfall 2 from RESEARCH.md)
|
||||
- For each .sortable-column element: new Sortable(col, { group: "kanban", animation: 150, handle: ".task-drag-handle", draggable: ".task-card", ghostClass: "bg-slate-100", chosenClass: "opacity-50", onEnd: function(evt) { ... } })
|
||||
- The onEnd callback: (1) get the reorder form: var form = document.getElementById("reorder-form"); (2) clear previous dynamic inputs: form.querySelectorAll("input[name=task_id],input[name=task_col]").forEach(function(el){el.remove();}); (3) iterate all .sortable-column elements, for each card (data-task-id) append hidden task_id input and task_col input (value=col.dataset.status); (4) trigger: htmx.trigger(form, "submit")
|
||||
- This is approximately 15-20 lines; the full script lives in the templ file as a <script> block (not a separate .js file — per D-07 "thin event bridge")
|
||||
|
||||
After editing, run: cd backend && just generate && go build ./...
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && just generate && go build ./... && go test ./internal/web/ -run "TestTaskReorder" -v -count=1</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`just generate` exits 0. `go build ./...` exits 0. TestTaskReorderCrossColumn and TestTaskReorderSameColumn both pass. `grep -c 'htmx.onLoad' templates/tasks.templ` returns 1 or more. `grep -c 'TaskReorderHandler' internal/web/handlers_tasks.go` returns 1 or more (non-stub body with r.ParseForm() call).
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Turn all remaining TestTask* stubs GREEN + full suite gate</name>
|
||||
<files>backend/internal/web/handlers_tasks_test.go</files>
|
||||
<read_first>
|
||||
- backend/internal/web/handlers_tasks_test.go (current state — TestTaskUpdate, TestTaskReorderCrossColumn, TestTaskReorderSameColumn, TestTaskOrderPersists still SKIP'd)
|
||||
- backend/internal/web/handlers_tasks.go (final implementation — confirm all routes, handler signatures, response shapes for reorder + edit)
|
||||
- backend/internal/web/handlers_tablos_test.go (full file — patterns for building form-encoded bodies with multiple values)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestTaskUpdate: edit a pre-inserted task via GET .../edit then POST with new title/desc; assert 200 + updated card in body; verify DB row updated
|
||||
- TestTaskReorderCrossColumn: insert 2 tasks in "todo", POST reorder with task2 moved to "in_progress"; assert task2.Status="in_progress" position=100 in DB
|
||||
- TestTaskReorderSameColumn: insert 3 tasks in "todo" (pos 100,200,300), POST reorder with reversed order; assert positions renumbered (pos 100,200,300 assigned to the reversed IDs)
|
||||
- TestTaskOrderPersists: after reorder POST, GET /tablos/{id} response body contains tasks in the new order (first task in new order appears before second task in HTML response)
|
||||
</behavior>
|
||||
<action>
|
||||
In `backend/internal/web/handlers_tasks_test.go`, replace t.Skip() bodies of TestTaskUpdate, TestTaskReorderCrossColumn, TestTaskReorderSameColumn, TestTaskOrderPersists with real integration assertions.
|
||||
|
||||
TestTaskUpdate: pre-insert tablo + task. GET /tablos/{id}/tasks/{task_id}/edit with HTMX header — assert 200 + body contains task title. Then POST /tablos/{id}/tasks/{task_id} with title="Updated" description="New desc" — assert 200 + body contains "Updated". Fetch task from DB with q.GetTaskByID and assert Title="Updated" Description.String="New desc".
|
||||
|
||||
TestTaskReorderCrossColumn: pre-insert tablo + 2 tasks in "todo" (positions 100, 200). Build form body: task_id={task2.ID}&task_col=in_progress&task_id={task1.ID}&task_col=todo (task2 moved to in_progress, task1 stays todo). POST to /tablos/{tablo.ID}/tasks/reorder with HTMX header. Assert 200 + body contains "kanban-board". Fetch task2 from DB: assert task2.Status="in_progress".
|
||||
|
||||
TestTaskReorderSameColumn: pre-insert tablo + 3 tasks in "todo" (pos 100,200,300). Build form body with reversed order: task3 first (task_col=todo), then task2 (task_col=todo), then task1 (task_col=todo). POST. Assert 200. Fetch all 3 tasks from DB. Assert tasks sorted by position have IDs in reversed order.
|
||||
|
||||
TestTaskOrderPersists: after a reorder POST (same as TestTaskReorderSameColumn), GET /tablos/{tablo.ID} and assert the response body contains task3.Title before task1.Title (positional ordering reflected in rendered HTML). Use strings.Index to compare positions.
|
||||
|
||||
Form encoding for multiple values of same key: use url.Values with .Add() multiple times for the same key, e.g.:
|
||||
form := url.Values{}
|
||||
form.Add("task_id", task1.ID.String())
|
||||
form.Add("task_col", "todo")
|
||||
form.Add("task_id", task2.ID.String())
|
||||
form.Add("task_col", "in_progress")
|
||||
strings.NewReader(form.Encode())
|
||||
|
||||
Run the full suite after: cd backend && go test ./... -v
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./... -count=1 2>&1 | grep -E "^(--- PASS|--- FAIL|FAIL|ok)" | head -30</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`go test ./...` exits 0. All TestTask* functions show PASS (none SKIP, none FAIL). Full suite (`go test ./...`) is green — no FAIL lines in output. `grep -c 'TestTaskOrderPersists' internal/web/handlers_tasks_test.go` returns 1 (not a stub).
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser → POST /tablos/{id}/tasks/reorder | task_id array and task_col array come from user-controlled Sortable.js DOM read |
|
||||
| Browser → POST /tablos/{id}/tasks/{task_id} (edit) | title and description from user input |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-04-08 | Tampering | TaskReorderHandler mass assignment | mitigate | Reorder only updates status+position; title/description preserved by fetching existing row first (GetTaskByID) before calling UpdateTask |
|
||||
| T-04-09 | Tampering | task_col array in reorder payload | mitigate | sqlc.TaskStatus(taskCols[i]) is passed to DB; Postgres ENUM rejects invalid column values at DB layer |
|
||||
| T-04-10 | Elevation of Privilege | TaskReorderHandler task_id injection | mitigate | UpdateTask query uses WHERE id=$1 AND tablo_id=$2 implicitly via loadOwnedTablo; tasks from other tablos are rejected at the DB query level |
|
||||
| T-04-11 | Denial of Service | Reorder POST with huge task_id array | accept | v1 acceptable; task counts are small; no explicit limit added (D-05 last-write-wins) |
|
||||
| T-04-12 | Tampering | TaskUpdateHandler preserves status/position | mitigate | TaskUpdateHandler reads existing task.Status and task.Position and passes them unchanged to UpdateTask; only title+description can change via this endpoint |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After all three tasks complete:
|
||||
- `cd backend && go test ./...` exits 0 — full suite green
|
||||
- `cd backend && go test ./internal/web/ -run TestTask -v` shows 9 PASS, 0 SKIP, 0 FAIL
|
||||
- `grep -c 'TaskEditFragment' backend/templates/tasks.templ` returns 1 or more
|
||||
- `grep -c 'htmx.onLoad' backend/templates/tasks.templ` returns 1 or more (Sortable.js init script)
|
||||
- `grep -c 'r.ParseForm' backend/internal/web/handlers_tasks.go` returns 1 or more (reorder handler)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Plan 03 complete when:
|
||||
1. All 9 TestTask* integration tests pass (TASK-01 through TASK-07 fully covered)
|
||||
2. `go test ./...` exits 0 — full suite green, no regressions
|
||||
3. Inline task edit: GET .../edit returns edit form, POST saves and returns updated card
|
||||
4. Reorder: POST /tablos/{id}/tasks/reorder updates status+position in DB, returns refreshed board
|
||||
5. Sortable.js htmx.onLoad init script present in KanbanBoard component (Pitfall 2 protection)
|
||||
6. Mass assignment guard: reorder fetches existing task before UpdateTask (T-04-08 mitigated)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-tasks-kanban/04-03-SUMMARY.md`
|
||||
</output>
|
||||
169
.planning/phases/04-tasks-kanban/04-04-PLAN.md
Normal file
169
.planning/phases/04-tasks-kanban/04-04-PLAN.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
---
|
||||
phase: 04-tasks-kanban
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on:
|
||||
- 04-03-PLAN.md
|
||||
files_modified: []
|
||||
autonomous: false
|
||||
requirements:
|
||||
- TASK-01
|
||||
- TASK-02
|
||||
- TASK-03
|
||||
- TASK-04
|
||||
- TASK-05
|
||||
- TASK-06
|
||||
- TASK-07
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Tablo detail page shows a kanban board with 4 labeled columns"
|
||||
- "Creating a task inserts it into the correct column without full page reload"
|
||||
- "Editing a task updates title and description inline"
|
||||
- "Dragging a task to a different column moves it and persists after reload"
|
||||
- "Reordering tasks within a column persists after reload"
|
||||
- "Deleting a task removes it from the board with a confirmation step"
|
||||
- "go test ./... is fully green before this checkpoint runs"
|
||||
artifacts:
|
||||
- path: "backend/internal/web/handlers_tasks.go"
|
||||
provides: "All task handlers fully implemented"
|
||||
- path: "backend/templates/tasks.templ"
|
||||
provides: "Full kanban board templ components"
|
||||
key_links:
|
||||
- from: "browser drag"
|
||||
to: "DB position column"
|
||||
via: "Sortable.js → htmx.trigger → POST /tasks/reorder → UpdateTask"
|
||||
pattern: "sortable-column"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Human verification checkpoint: confirm the full kanban board works end-to-end in a real browser session before closing Phase 4. All 7 TASK requirements must be visually verifiable.
|
||||
|
||||
Purpose: Automated tests prove correctness of individual handlers; this checkpoint proves the integrated experience works — drag-and-drop, inline edits, correct HTMX swap targets, Sortable.js re-initialization after swaps.
|
||||
Output: Human approval that Phase 4 is shippable. No code changes expected.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/04-tasks-kanban/04-03-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Final automated suite gate</name>
|
||||
<files></files>
|
||||
<read_first>
|
||||
- backend/internal/web/handlers_tasks_test.go (verify all 9 TestTask* are non-stub before running)
|
||||
</read_first>
|
||||
<action>
|
||||
Run the full test suite and confirm it is green. Then start the dev server for browser verification.
|
||||
|
||||
Run: cd backend && go test ./... -count=1 -v 2>&1 | grep -E "^(--- PASS|--- FAIL|FAIL|ok)"
|
||||
|
||||
If any FAIL lines appear, fix them before proceeding to the checkpoint. Do not open the browser until the full suite is green.
|
||||
|
||||
Then start the dev server: cd backend && just dev (leaves it running for the checkpoint).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./... -count=1 2>&1 | grep -v "^?" | grep -E "(FAIL|ok)"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
All output lines show "ok" — no "FAIL" lines. Dev server started on localhost.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<what-built>
|
||||
A fully functional kanban board inside every tablo. The board has 4 fixed columns (To do / In progress / In review / Done) with task count badges, inline task creation at the bottom of each column, inline task editing by clicking a card, drag-and-drop reordering and column moves via Sortable.js, and inline delete with confirmation. All mutations are HTMX-driven. All 9 TestTask* integration tests pass.
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
With `just dev` running (cd backend && just dev), open http://localhost:8080 in a browser.
|
||||
|
||||
**Setup:** Sign in (or sign up). Create a tablo if you don't have one. Click the tablo to open its detail page.
|
||||
|
||||
**TASK-01 — Kanban board renders:**
|
||||
- Confirm 4 column headers: "To do", "In progress", "In review", "Done"
|
||||
- Each column shows a count badge (e.g. "0")
|
||||
- Empty columns show "No tasks yet" in italic
|
||||
|
||||
**TASK-02 — Create a task:**
|
||||
- Click "+ Add task" at the bottom of the "To do" column
|
||||
- Confirm an inline form appears (no page reload)
|
||||
- Type a title "First task" and click "Add task"
|
||||
- Confirm the task card appears in the "To do" column and the count badge updates to 1
|
||||
- Verify the "+ Add task" button reappears after submit
|
||||
- Create 2 more tasks in different columns
|
||||
|
||||
**TASK-06 — Delete a task:**
|
||||
- Click the "Delete" button on a task card
|
||||
- Confirm an inline "Delete task? / This cannot be undone." confirmation appears
|
||||
- Click "Keep task" and confirm the card is restored
|
||||
- Click "Delete" again, then "Yes, delete" and confirm the card disappears
|
||||
|
||||
**TASK-03 — Edit a task:**
|
||||
- Click on a task card body (not the delete button or drag handle)
|
||||
- Confirm an edit form appears with the current title pre-filled
|
||||
- Change the title and click "Save changes"
|
||||
- Confirm the card shows the updated title without a full page reload
|
||||
- Click edit again, then "Discard changes" — confirm original state is restored
|
||||
|
||||
**TASK-04 and TASK-05 — Drag and drop:**
|
||||
- Create at least 3 tasks in "To do"
|
||||
- Drag a task (using the ⠿ grip handle) to the "In Progress" column — confirm it lands there
|
||||
- Reload the page — confirm the task is still in "In Progress"
|
||||
- Drag tasks within a column to reorder — reload and confirm order persists
|
||||
|
||||
**Degraded mode (optional):**
|
||||
- Disable JavaScript in browser settings
|
||||
- Confirm task creation form still submits via standard POST (page reload, 303 redirect flow)
|
||||
|
||||
Report any visual issues (wrong column order, badge not updating, drag not working after first swap, etc.)
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" if the board works end-to-end. Or describe any issues found.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Human tester → local dev server | No production data; local Postgres |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-04-13 | Information Disclosure | Human verify with real credentials | accept | Local dev only; no production data involved in this checkpoint |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Human approval received via "approved" signal.
|
||||
All 7 TASK requirements visually verified:
|
||||
- TASK-01: 4 columns render
|
||||
- TASK-02: task creation works inline
|
||||
- TASK-03: task editing works inline
|
||||
- TASK-04: cross-column drag persists
|
||||
- TASK-05: within-column reorder persists
|
||||
- TASK-06: delete with confirmation works
|
||||
- TASK-07: order survives page reload
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Plan 04 complete when:
|
||||
1. `go test ./...` exits 0 (pre-condition for checkpoint)
|
||||
2. Human confirms all 7 TASK behaviors work in the browser
|
||||
3. "approved" signal received
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-tasks-kanban/04-04-SUMMARY.md`
|
||||
</output>
|
||||
Loading…
Reference in a new issue