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:
Arthur Belleville 2026-05-15 09:16:17 +02:00
parent cfd30eb277
commit 7f58588f5a
No known key found for this signature in database
5 changed files with 1109 additions and 0 deletions

View file

@ -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*

View 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>

View 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>

View 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>

View 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>