xtablo-source/.planning/phases/04-tasks-kanban/04-02-PLAN.md
Arthur Belleville 7f58588f5a
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>
2026-05-15 09:16:17 +02:00

27 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
04-tasks-kanban 02 execute 2
04-01-PLAN.md
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
true
TASK-01
TASK-02
TASK-06
truths artifacts key_links
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
path provides exports
backend/internal/web/handlers_tasks.go TasksDeps, TaskCreateHandler, TaskDeleteConfirmHandler, TaskDeleteHandler, TaskShowHandler, TaskNewFormHandler, TaskCancelNewHandler
TasksDeps
TaskCreateHandler
path provides contains
backend/templates/tasks.templ KanbanBoard, KanbanColumn, TaskCard, TaskCreateForm, TaskDeleteConfirmFragment, AddTaskTrigger KanbanBoard
path provides contains
backend/internal/web/router.go task routes wired inside RequireAuth group tasks/reorder
path provides contains
backend/templates/tablos.templ KanbanBoard embedded below tablo detail header KanbanBoard
from to via pattern
backend/templates/tasks.templ backend/internal/web/handlers_tasks.go templ components rendered by handler functions templates.KanbanBoard|templates.TaskCard
from to via pattern
backend/internal/web/router.go backend/internal/web/handlers_tasks.go chi route group inside RequireAuth TaskCreateHandler|TaskDeleteHandler
from to via pattern
backend/templates/tablos.templ backend/templates/tasks.templ TabloDetailPage calls KanbanBoard @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.

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

@/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 `<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.
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 `<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 ./...
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.

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

<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>
After completion, create `.planning/phases/04-tasks-kanban/04-02-SUMMARY.md`