xtablo-source/.planning/phases/16-tablo-detail/16-03-PLAN.md
Arthur Belleville 965ec5e5ce
docs(16): create phase 16 tablo detail plan — 4 plans, 4 waves
Phase 16 delivers DETAIL-01/02/03/04: header restyling, kanban
tasks-section layout with server-side etape grouping, and files
table component. Ends with a browser verify checkpoint.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-16 23:20:49 +02:00

18 KiB
Raw Blame History


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.

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

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

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

<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>
After completion, create `.planning/phases/16-tablo-detail/16-03-SUMMARY.md` using the summary template.