--- phase: 16-tablo-detail plan: 03 type: execute wave: 3 depends_on: - 16-02 files_modified: - backend/templates/tasks.templ - backend/internal/web/handlers_tasks.go autonomous: true requirements: - DETAIL-02 - DETAIL-03 must_haves: truths: - "KanbanBoard templ signature accepts etapes []sqlc.Etape as 5th parameter" - "KanbanColumn renders tasks grouped by etape using groupTasksByEtape helper; unassigned tasks appear last" - "Each kanban column uses .kanban-column / .tasks-section / .tasks-section-header / .task-list CSS layout" - "TaskCard uses .task-row layout: .task-check + .task-body + trash @ui.IconButton" - "AddTaskTrigger uses .tasks-add-button class (not raw ui-button classes)" - "EtapeStrip OOB calls are removed from TaskCardGone and TaskCardOOB (etapes/counts params kept)" - "Both KanbanBoard call sites in handlers_tasks.go pass etapes as 5th argument" - "All 19 existing task handler tests pass unchanged" artifacts: - path: backend/templates/tasks.templ provides: groupTasksByEtape helper, EtapeGroup type, EtapeGroupHeader component, restyled KanbanBoard/Column/TaskCard contains: "groupTasksByEtape" - path: backend/internal/web/handlers_tasks.go provides: Updated KanbanBoard call sites (lines ~594, ~645) contains: "KanbanBoard.*etapes" key_links: - from: backend/templates/tasks.templ groupTasksByEtape to: backend/templates/tasks.templ KanbanColumn via: "groups := groupTasksByEtape(tasks, etapes)" expression inside KanbanColumn pattern: "groupTasksByEtape" - from: backend/internal/web/handlers_tasks.go to: backend/templates/tasks.templ KanbanBoard via: "templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter, etapes)" pattern: "KanbanBoard.*etapes" --- Restyle the task kanban board (DETAIL-02) and implement server-side etape grouping (DETAIL-03). Changes: add `groupTasksByEtape` helper and `EtapeGroup` type to `tasks.templ`; add `EtapeGroupHeader` templ component; update `KanbanBoard` and `KanbanColumn` signatures to accept `etapes []sqlc.Etape`; restyle `KanbanColumn` with `.tasks-section` layout; restyle `TaskCard` with `.task-row` layout; restyle `AddTaskTrigger` with `.tasks-add-button`; remove `@EtapeStrip` OOB calls from `TaskCardGone` and `TaskCardOOB`; update both `KanbanBoard` call sites in `handlers_tasks.go`. Purpose: Deliver DETAIL-02 (tasks-section kanban) and DETAIL-03 (etape grouping, EtapeStrip removal). Output: Restyled kanban with etape-grouped task rows; all 19 task handler tests pass. @/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 @.planning/ROADMAP.md @.planning/phases/16-tablo-detail/16-CONTEXT.md @.planning/phases/16-tablo-detail/16-RESEARCH.md @.planning/phases/16-tablo-detail/16-PATTERNS.md @.planning/phases/16-tablo-detail/16-UI-SPEC.md @.planning/phases/16-tablo-detail/16-02-SUMMARY.md From backend/templates/tasks.templ (current state — read the file before editing): Existing groupTasksByStatus (lines ~10-18) — model for groupTasksByEtape: func groupTasksByStatus(tasks []sqlc.Task) map[sqlc.TaskStatus][]sqlc.Task { result := make(map[sqlc.TaskStatus][]sqlc.Task, len(TaskColumns)) for _, t := range tasks { result[t.Status] = append(result[t.Status], t) } return result } Current KanbanBoard signature (line ~23): templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task, filter EtapeFilter) Target KanbanBoard signature: templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task, filter EtapeFilter, etapes []sqlc.Etape) KanbanColumn — receives tasks for one status. After change, also receives etapes: templ KanbanColumn(tabloID uuid.UUID, status sqlc.TaskStatus, tasks []sqlc.Task, csrfToken string, filter EtapeFilter, etapes []sqlc.Etape) EtapeGroup type to add (before groupTasksByEtape func): type EtapeGroup struct { EtapeID string EtapeTitle string EtapeColor string Tasks []sqlc.Task } groupTasksByEtape logic: - Build etapeID→Etape map from etapes slice for O(1) lookup - Use etapes slice ORDER to determine group order (not map iteration) - For each etape in order: collect tasks whose EtapeID matches; only add group if non-empty - sqlc.Task.EtapeID field: check the actual type (likely pgtype.UUID or *uuid.UUID) — use appropriate nil/zero check - At the end: collect tasks with nil/zero EtapeID → append as EtapeGroup{EtapeID: "", EtapeTitle: "No etape", EtapeColor: "", Tasks: unassigned} - If zero etapes are defined (etapes slice is empty), treat all tasks as unassigned TaskCardGone current signature (line ~384): has etapes []sqlc.Etape and counts EtapeTaskCounts params TaskCardOOB current signature (line ~396): same Action: remove the @EtapeStrip(..., true) OOB call from both; KEEP the etapes and counts params (to avoid handler signature changes); add TODO comment KanbanBoard call sites in handlers_tasks.go: Line ~594: templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter) → add etapes (from loadTasksTabData return) Line ~645: templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter) → add etapes (etapes is already available in scope) loadTasksTabData return signature (check actual): returns (tasks, etapes, counts, filter, ok bool) — etapes is the 2nd return value From backend/internal/web/ui/variants.go (for TaskCard IconButton): ui.IconButtonVariantDanger, ui.IconButtonToneGhost From backend/internal/web/ui/app.css (Section 23 added in Plan 01): .kanban-column { flex-shrink: 0; width: 18rem; } .tasks-section { border: 1px solid var(--color-border-subtle); border-radius: 1rem; overflow: hidden; } .tasks-section-header { display: flex; justify-content: space-between; align-items: center; padding: 1.2rem 1rem; } .task-list { display: flex; flex-direction: column; } .task-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.9rem 1rem; border-bottom: 1px solid var(--color-border-muted); } .tasks-add-button { display: inline-flex; align-items: center; gap: 0.5rem; ... } Task 1: Add groupTasksByEtape helper, EtapeGroup type, EtapeGroupHeader component; update KanbanBoard/Column signatures and restyled column/card/trigger backend/templates/tasks.templ - backend/templates/tasks.templ (read the FULL file before editing — ~420 lines; you need the current: groupTasksByStatus pattern, KanbanBoard body, KanbanColumn body, TaskCard body, AddTaskTrigger body, TaskCardGone and TaskCardOOB bodies, existing import block, sqlc.Task.EtapeID field type) - backend/internal/db/sqlc/querier.go or backend/internal/db/sqlc/models.go (grep for "EtapeID" field on Task struct to confirm the exact type — pgtype.UUID, *uuid.UUID, or sql.NullString) - .planning/phases/16-tablo-detail/16-PATTERNS.md (sections: groupTasksByEtape helper, KanbanBoard signature change, KanbanColumn restyled, TaskCard restyled, EtapeGroupHeader component, EtapeStrip OOB removal) Edit `backend/templates/tasks.templ` to make the following changes. Read the entire file first to understand current structure before starting any edits. 1. ADD EtapeGroup type and groupTasksByEtape function after the existing `groupTasksByStatus` function (before the `KanbanBoard` templ declaration): - `EtapeGroup` struct: fields EtapeID string, EtapeTitle string, EtapeColor string, Tasks []sqlc.Task - `groupTasksByEtape(tasks []sqlc.Task, etapes []sqlc.Etape) []EtapeGroup`: * Build index map from etape ID string to sqlc.Etape for O(1) lookup * Iterate etapes in slice order; for each etape, collect tasks matching that etape ID; only append a group if it has tasks * Append an unassigned group (EtapeID: "", EtapeTitle: "No etape", EtapeColor: "") for tasks with nil/zero EtapeID — check the actual EtapeID field type (pgtype.UUID: use !t.EtapeID.Valid; *uuid.UUID: use t.EtapeID == nil; uuid.UUID zero value: use t.EtapeID == uuid.Nil) * If all tasks are assigned (no unassigned), omit the unassigned group entirely 2. ADD EtapeGroupHeader templ component (after the groupTasksByEtape function): `templ EtapeGroupHeader(group EtapeGroup)` rendering a `div class="etape-group-header"`: - If group.EtapeColor != "": render `span class="etape-group-color-dot" style="background-color: {group.EtapeColor}"` - If group.EtapeID == "": render `span class="etape-group-label is-unassigned"` with group.EtapeTitle - Else: render `span class="etape-group-label"` with group.EtapeTitle 3. UPDATE KanbanBoard signature: add `etapes []sqlc.Etape` as 5th parameter. Inside KanbanBoard, pass `etapes` through to each `@KanbanColumn(...)` call. 4. UPDATE KanbanColumn signature: add `etapes []sqlc.Etape` as 6th parameter. Restyle the column structure: - Outer: `div class="kanban-column"` (replaces the current flex-shrink-0 w-72 div) - Inner: `div class="tasks-section"` containing: * `div class="tasks-section-header"`: left side has h3 with TaskColumnLabels[status] + span with task-count-badge `@ui.Badge(...BadgeVariantInfo...)`, right side has `div id="add-task-slot-{status}"` containing `@AddTaskTrigger(...)` * `div class="task-list sortable-column" data-status="{status}" id="column-{status}" aria-label="{label} column"`: - If len(tasks) == 0: render `p class="task-list-empty" "No tasks yet"` - Else: compute `groups := groupTasksByEtape(tasks, etapes)`, then `for _, group := range groups { @EtapeGroupHeader(group); for _, task := range group.Tasks { @TaskCard(tabloID, task, csrfToken) } }` - Preserve existing `data-status` and `id="column-..."` attributes for Sortable.js compatibility 5. RESTYLE TaskCard: replace the current `.task-card bg-white rounded border border-slate-200...` inner div with `.task-row task-card` div: - Outer zone wrapper: keep `div class="task-card-zone" id="task-{task.ID.String()}"` unchanged (HTMX swap target) - Inner: `div class="task-row task-card" data-task-id="{task.ID.String()}" hx-get=... hx-target="closest .task-card-zone" hx-swap="outerHTML" role="button" aria-label="Edit task: {task.Title}"` - Children: `div class="task-check"` (round checkbox, role="checkbox" aria-checked="false") + `div class="task-body"` (p with task.Title) + trash `@ui.IconButton(ui.IconButtonProps{Label: "Delete task: {task.Title}", Icon: "trash", Variant: ui.IconButtonVariantDanger, Tone: ui.IconButtonToneGhost, Type: "button", Attrs: templ.Attributes{"hx-get": "/tablos/{tabloID}/tasks/{task.ID}/delete-confirm", "hx-target": "closest .task-card-zone", "hx-swap": "outerHTML"}})` - Remove the old drag handle div (⠿) 6. RESTYLE AddTaskTrigger: change the button class from the current `ui-button ui-button-soft...` compound to simply `tasks-add-button`. Preserve all hx-* attributes. 7. REMOVE EtapeStrip OOB calls: in TaskCardGone and TaskCardOOB, delete the `@EtapeStrip(tabloID, etapes, counts, filter, csrfToken, true)` lines. Keep the `etapes []sqlc.Etape` and `counts EtapeTaskCounts` parameters on both component signatures. Add `// TODO: remove etapes and counts params after Phase 16 cleanup` comment. After editing, run: `just generate` to regenerate `tasks_templ.go`. grep -c "groupTasksByEtape" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tasks.templ && grep -c "EtapeGroupHeader" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tasks.templ && grep -c "kanban-column" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tasks.templ && grep -c "tasks-section" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tasks.templ && grep -c "task-row" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tasks.templ && grep -c "EtapeStrip" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/templates/tasks.templ - `tasks.templ` contains `func groupTasksByEtape(tasks []sqlc.Task, etapes []sqlc.Etape) []EtapeGroup` - `tasks.templ` contains `type EtapeGroup struct` - `tasks.templ` contains `templ EtapeGroupHeader(group EtapeGroup)` - `tasks.templ` contains `class="kanban-column"` (replacing w-72 Tailwind class) - `tasks.templ` contains `class="tasks-section"` inside kanban column - `tasks.templ` contains `class="task-row task-card"` inside TaskCard - `tasks.templ` contains `class="tasks-add-button"` in AddTaskTrigger - `grep -c "EtapeStrip" backend/templates/tasks.templ` returns 0 (OOB calls removed) - `grep "etapes \[\]sqlc.Etape" backend/templates/tasks.templ` shows parameter in both KanbanBoard and KanbanColumn signatures - `just generate` exits 0 (templ compiles cleanly) tasks.templ has groupTasksByEtape, EtapeGroupHeader, restyled KanbanColumn/TaskCard/AddTaskTrigger, and no EtapeStrip OOB calls. Task 2: Update KanbanBoard call sites in handlers_tasks.go and verify full test suite backend/internal/web/handlers_tasks.go - backend/internal/web/handlers_tasks.go (read lines 580–660 to see the two KanbanBoard call sites and the loadTasksTabData return variables in scope at each call site; confirm etapes is the 2nd return from loadTasksTabData: `tasks, etapes, counts, filter, ok := loadTasksTabData(...)`) - .planning/phases/16-tablo-detail/16-RESEARCH.md (Pattern: KanbanBoard call site count — three call sites must all be updated; tablos.templ was updated in Plan 02; handlers_tasks.go has two) Edit `backend/internal/web/handlers_tasks.go` at the two `KanbanBoard` call sites: Line ~594 (inside TaskReorderHandler or similar): change `templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter)` to `templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter, etapes)` Line ~645 (second KanbanBoard call): change the same pattern. At each call site, verify that `etapes` is already in scope from the `loadTasksTabData` return values. The function returns `(tasks, etapes, counts, filter, ok)` — use the `etapes` variable directly. After editing, run: `just generate && go build ./backend/...` to confirm all three KanbanBoard call sites match the updated signature. Then run the full task test suite: `go test ./backend/internal/web/... -run TestTask -count=1` And the full handler test suite (no DB required): `go test ./backend/internal/web/... -count=1` grep -c "KanbanBoard.*etapes" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/internal/web/handlers_tasks.go && cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && go build ./backend/... && go test ./backend/internal/web/... -run TestTask -count=1 - `grep "KanbanBoard" backend/internal/web/handlers_tasks.go` shows both call sites include `etapes` as 5th argument - `go build ./backend/...` exits 0 (no KanbanBoard argument count mismatch) - `go test ./backend/internal/web/... -run TestTask -count=1` exits 0 (all 19 task tests pass) - `go test ./backend/internal/web/... -count=1` exits 0 (full handler test suite passes with no regressions) Both KanbanBoard call sites in handlers_tasks.go pass etapes; all task tests pass; build is clean. ## Trust Boundaries | Boundary | Description | |----------|-------------| | Handler → KanbanBoard template | etapes []sqlc.Etape passed from handler; data comes from authenticated DB query | | EtapeGroup rendering | EtapeColor rendered as inline style value (background-color) — same pattern as project-avatar; no script injection via CSS values | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-16-03-01 | Information Disclosure | EtapeGroup server-side grouping | accept | groupTasksByEtape operates on data already fetched via authenticated query; no new data access introduced | | T-16-03-02 | Tampering | EtapeColor inline style value | accept | Etape color is stored in DB as user-supplied value but rendered only as CSS background-color; browsers do not execute CSS property values as code | | T-16-03-03 | Spoofing | EtapeStrip OOB removal | accept | Removing dead OOB calls has no security impact; HTMX ignores swaps for missing targets | | T-16-03-04 | Denial of Service | groupTasksByEtape O(n*m) | accept | n = tasks per column (bounded by UI; typically < 50), m = etapes per tablo (typically < 10); no external input controls loop bounds; not a real DoS surface | ```bash cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source go build ./backend/... go test ./backend/internal/web/... -count=1 ``` Build clean; full handler test suite passes (all 19 task tests + all etape tests + all file tests). - `groupTasksByEtape` helper groups tasks by etape in etape declaration order with unassigned last - `KanbanBoard` and `KanbanColumn` both accept `etapes []sqlc.Etape` - Both `handlers_tasks.go` call sites pass `etapes` - Kanban column uses `.kanban-column` / `.tasks-section` / `.tasks-section-header` / `.task-list` CSS classes - TaskCard uses `.task-row` with `.task-check` + `.task-body` + trash `@ui.IconButton` - EtapeStrip OOB calls removed from TaskCardGone and TaskCardOOB - `go test ./backend/internal/web/... -count=1` passes (all 19 task tests) After completion, create `.planning/phases/16-tablo-detail/16-03-SUMMARY.md` using the summary template.