---
phase: 16-tablo-detail
plan: 03
type: execute
wave: 3
depends_on:
  - 16-02
files_modified:
  - backend/templates/tasks.templ
  - backend/templates/tablos.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)"
    - "All three KanbanBoard call sites pass etapes as 5th argument: tablos.templ (1 site) + handlers_tasks.go (2 sites)"
    - "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"
    - from: backend/templates/tablos.templ TasksTabFragment
      to: backend/templates/tasks.templ KanbanBoard
      via: "@KanbanBoard(tablo.ID, csrfToken, tasks, filter, etapes)"
      pattern: "KanbanBoard.*etapes"
---

<objective>
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 all three `KanbanBoard` call sites: both in `handlers_tasks.go` and the one in `tablos.templ` (deferred from Plan 02 so all call site updates happen atomically with the signature change).

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.
</objective>

<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.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

<interfaces>
<!-- Key contracts the executor needs. -->

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; ... }
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add groupTasksByEtape helper, EtapeGroup type, EtapeGroupHeader component; update KanbanBoard/Column signatures and restyled column/card/trigger</name>
  <files>backend/templates/tasks.templ</files>
  <read_first>
    - 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)
  </read_first>
  <action>
    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`.
  </action>
  <verify>
    <automated>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</automated>
  </verify>
  <acceptance_criteria>
    - `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)
  </acceptance_criteria>
  <done>tasks.templ has groupTasksByEtape, EtapeGroupHeader, restyled KanbanColumn/TaskCard/AddTaskTrigger, and no EtapeStrip OOB calls.</done>
</task>

<task type="auto">
  <name>Task 2: Update all three KanbanBoard call sites (tablos.templ + handlers_tasks.go) and verify full test suite</name>
  <files>backend/internal/web/handlers_tasks.go, backend/templates/tablos.templ</files>
  <read_first>
    - 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)
  </read_first>
  <action>
    Edit three `KanbanBoard` call sites simultaneously — all three must be updated in this task so the build passes cleanly at every step:

    1. `backend/templates/tablos.templ` TasksTabFragment (deferred from Plan 02): change
         `@KanbanBoard(tablo.ID, csrfToken, tasks, filter)`
       to
         `@KanbanBoard(tablo.ID, csrfToken, tasks, filter, etapes)`
       Note: `etapes` is already a parameter of `TasksTabFragment` (unchanged from before Plan 02).

    2. `backend/internal/web/handlers_tasks.go` line ~594: change
         `templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter)`
       to
         `templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks, filter, etapes)`

    3. `backend/internal/web/handlers_tasks.go` line ~645: change the same pattern.

    At each handler 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 all three sites, run: `just generate && go build ./backend/...` to confirm all 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`
  </action>
  <verify>
    <automated>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</automated>
  </verify>
  <acceptance_criteria>
    - `grep "KanbanBoard" backend/internal/web/handlers_tasks.go` shows both call sites include `etapes` as 5th argument
    - `grep "KanbanBoard" backend/templates/tablos.templ` shows the call includes `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)
  </acceptance_criteria>
  <done>Both KanbanBoard call sites in handlers_tasks.go pass etapes; all task tests pass; build is clean.</done>
</task>

</tasks>

<threat_model>
## 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 |
</threat_model>

<verification>
```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).
</verification>

<success_criteria>
- `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)
</success_criteria>

<output>
After completion, create `.planning/phases/16-tablo-detail/16-03-SUMMARY.md` using the summary template.
</output>
