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