diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 995be66..0ae12bb 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -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*
diff --git a/.planning/phases/04-tasks-kanban/04-01-PLAN.md b/.planning/phases/04-tasks-kanban/04-01-PLAN.md
new file mode 100644
index 0000000..b057d48
--- /dev/null
+++ b/.planning/phases/04-tasks-kanban/04-01-PLAN.md
@@ -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"
+---
+
+
+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.
+
+
+
+@/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
+
+
+
+@/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
+
+
+
+
+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
+
+
+
+
+
+
+ Task 1: Migration 0004_tasks.sql + sqlc queries tasks.sql
+ backend/migrations/0004_tasks.sql, backend/internal/db/queries/tasks.sql
+
+ - 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)
+
+
+ - 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
+
+
+ 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 ./...
+
+
+ cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && just migrate up && just generate && go build ./...
+
+
+ `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.
+
+
+
+
+ Task 2: RED test scaffold handlers_tasks_test.go + form structs tasks_forms.go
+ backend/internal/web/handlers_tasks_test.go, backend/templates/tasks_forms.go
+
+ - 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)
+
+
+ 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.
+
+
+ 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
+
+
+ `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.
+
+
+
+
+ Task 3: Sortable.js bootstrap + soft-danger button CSS
+ backend/justfile, backend/internal/web/ui/button.css
+
+ - 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)
+
+
+ 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).
+
+
+ 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
+
+
+ `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.
+
+
+
+
+
+
+## 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 |
+
+
+
+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
+
+
+
+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
+
+
+
diff --git a/.planning/phases/04-tasks-kanban/04-02-PLAN.md b/.planning/phases/04-tasks-kanban/04-02-PLAN.md
new file mode 100644
index 0000000..311c1ec
--- /dev/null
+++ b/.planning/phases/04-tasks-kanban/04-02-PLAN.md
@@ -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"
+---
+
+
+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.
+
+
+
+@/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
+
+
+
+@/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
+
+
+
+
+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):
+
+ // Add after htmx:
+
+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
+ - 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)
+
+
+
+
+
+
+ Task 1: handlers_tasks.go — TasksDeps + create/delete/show handlers
+ backend/internal/web/handlers_tasks.go, backend/internal/web/router.go, backend/cmd/web/main.go
+
+ - 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)
+
+
+ - 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
+
+
+ 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 ``. 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.
+
+
+ cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./... && go test ./internal/web/ -run "TestTaskCreate|TestTaskDelete|TestTaskOwnership" -v -count=1
+
+
+ `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.
+
+
+
+
+ Task 2: tasks.templ — KanbanBoard, KanbanColumn, TaskCard, TaskCreateForm, TaskDeleteConfirmFragment, AddTaskTrigger
+ backend/templates/tasks.templ, backend/templates/tablos.templ, backend/templates/layout.templ
+
+ - 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)
+
+
+ - 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)
+
+
+ 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 `` 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 ./...
+
+
+ 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
+
+
+ `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.
+
+
+
+
+ Task 3: Update handlers_tasks_test.go — turn RED stubs GREEN for TASK-01, TASK-02, TASK-06
+ backend/internal/web/handlers_tasks_test.go
+
+ - 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)
+
+
+ - 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)
+
+
+ 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`
+
+
+ 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)"
+
+
+ `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.
+
+
+
+
+
+
+## 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 |
+
+
+
+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
+
+
+
+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)
+
+
+
diff --git a/.planning/phases/04-tasks-kanban/04-03-PLAN.md b/.planning/phases/04-tasks-kanban/04-03-PLAN.md
new file mode 100644
index 0000000..3771a3b
--- /dev/null
+++ b/.planning/phases/04-tasks-kanban/04-03-PLAN.md
@@ -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"
+---
+
+
+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.
+
+
+
+@/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
+
+
+
+@/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
+
+
+
+
+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"
+
+
+
+
+
+
+ Task 1: TaskEditHandler, TaskUpdateHandler — inline task editing
+ backend/internal/web/handlers_tasks.go, backend/templates/tasks.templ
+
+ - 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)
+
+
+ - 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)
+
+
+ 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 ./...
+
+
+ cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && just generate && go build ./... && go test ./internal/web/ -run TestTaskUpdate -v -count=1
+
+
+ `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).
+
+
+
+
+ Task 2: TaskReorderHandler + Sortable.js inline script
+ backend/internal/web/handlers_tasks.go, backend/templates/tasks.templ
+
+ - 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)
+
+
+ - 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)
+
+
+ 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