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