---
phase: 09
slug: etapes
status: complete
created: 2026-05-15
---

# Phase 9: Etapes - Research

## RESEARCH COMPLETE

This research answers what the planner needs to know to implement Phase 9 without breaking the existing task kanban.

## Phase Summary

Phase 9 adds one-level etapes inside a tablo. An etape is a parent wrapper for tasks, not a nested hierarchy. Tasks may be unassigned or assigned to one etape. The first UI is a compact top strip of etape chips with counts, including `Unassigned`; selecting a chip filters the existing four-column kanban.

The core implementation constraint is preserving the current task reorder model: task order remains scoped by tablo and status, not by etape.

## Existing System Facts

### Task Schema

Current task schema is in `backend/migrations/0004_tasks.sql`.

- `task_status` enum order is `todo`, `in_progress`, `in_review`, `done`; this order matches the visual kanban columns.
- `tasks` has `tablo_id`, `title`, `description`, `status`, `position`, timestamps.
- `tasks_tablo_id_status_idx` indexes `(tablo_id, status, position)`.

### Task Query Surface

Current task queries are in `backend/internal/db/queries/tasks.sql`.

- `ListTasksByTablo` returns all tasks in a tablo ordered by `status, position, created_at`.
- `InsertTask` inserts title, description, status, and position.
- `GetTaskByID` verifies task-to-tablo binding with `WHERE id = $1 AND tablo_id = $2`.
- `UpdateTask` overwrites title, description, status, and position.
- `MaxPositionByTabloAndStatus` scopes new task position to `(tablo_id, status)`.

### Task Handler Surface

Task handlers live in `backend/internal/web/handlers_tasks.go`.

- `loadOwnedTablo` verifies authenticated ownership for tablo-scoped routes.
- `loadOwnedTabloForTask` verifies both tablo ownership and task membership.
- `TaskCreateHandler` computes `MaxPositionByTabloAndStatus + 100`, then inserts.
- `TaskUpdateHandler` preserves existing status and position when updating title/description.
- `TaskReorderHandler` fetches each task by ID and tablo before updating status/position.
- Reorder currently calls `UpdateTaskParams` with only title, description, status, and position. After Phase 9 adds `etape_id`, this path must preserve the existing task's `etape_id` or drag/drop will silently unassign tasks.

### Template Surface

Task templates live in `backend/templates/tasks.templ`.

- `KanbanBoard(tabloID, csrfToken, tasks []sqlc.Task)` accepts a flat task slice and groups by status.
- `TaskCreateFormFragment` and `TaskEditFragment` are the right places for an etape selector.
- `TaskCardOOB` returns a new task card and resets the add-task slot through an out-of-band swap.
- `TasksTabFragment` in `backend/templates/tablos.templ` currently wraps only `KanbanBoard`.

### Test Surface

Task tests live in `backend/internal/web/handlers_tasks_test.go`.

- Existing tests cover rendering, create, validation, update, cross-column reorder, same-column reorder, delete, order persistence, and ownership.
- DB-backed tests use `setupTestDB` and will skip when `TEST_DATABASE_URL` is absent.
- These tests are the closest template for etape handler tests.

## Recommended Data Model

Add a migration after `0006_social_identities.sql`, likely `0007_etapes.sql`.

Recommended shape:

- `etapes`
  - `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`
  - `position integer not null default 100`
  - `created_at timestamptz not null default now()`
  - `updated_at timestamptz not null default now()`
- `tasks.etape_id uuid null references etapes(id) on delete set null`

Recommended indexes:

- `etapes_tablo_id_position_idx on etapes(tablo_id, position)`
- `tasks_tablo_id_etape_id_idx on tasks(tablo_id, etape_id)`
- Keep the existing `(tablo_id, status, position)` index because task ordering remains status-scoped.

Nested etapes are prevented by structure: `etapes` has no `parent_id`. Do not add an etape-to-etape relationship.

Use `ON DELETE SET NULL` for `tasks.etape_id`; this directly implements ETAPE-04 and avoids a manual cleanup race. The down migration must drop the task foreign key/column before dropping `etapes`.

## Recommended Query Changes

Create `backend/internal/db/queries/etapes.sql` with:

- `ListEtapesByTablo`
- `InsertEtape`
- `GetEtapeByID`
- `UpdateEtape`
- `DeleteEtape`
- `MaxEtapePositionByTablo`
- `UpdateEtapePosition`

Extend task queries to include `etape_id` everywhere a task is returned or updated:

- `ListTasksByTablo` should select `etape_id`.
- `InsertTask` should accept nullable `etape_id`.
- `GetTaskByID` should select `etape_id`.
- `UpdateTask` should accept and preserve nullable `etape_id`.
- Add `ListTasksByTabloAndEtape` for a specific etape.
- Add `ListUnassignedTasksByTablo` for the `Unassigned` chip.

For counts, either:

- add `CountTasksByEtapeForTablo` plus `CountUnassignedTasksByTablo`, or
- fetch all tasks and count in Go for the first version.

Counting in Go is acceptable for Phase 9 because the current Tasks tab already loads all tasks and the first UI only needs task counts in the chip strip. If filtered views should avoid loading all tasks later, the count queries can be added separately.

## Recommended Route And Handler Shape

Follow existing `/tablos/{id}/...` patterns in `backend/internal/web/router.go`.

Candidate routes:

- `GET /tablos/{id}/tasks?etape={uuid|unassigned|all}` renders the Tasks tab or fragment with etape filter context.
- `GET /tablos/{id}/etapes/new` renders an inline create form for the top strip.
- `POST /tablos/{id}/etapes` creates an etape.
- `GET /tablos/{id}/etapes/{etape_id}/edit` renders edit form.
- `POST /tablos/{id}/etapes/{etape_id}` updates title/description.
- `GET /tablos/{id}/etapes/{etape_id}/delete-confirm` renders confirmation.
- `POST /tablos/{id}/etapes/{etape_id}/delete` deletes the etape; DB `ON DELETE SET NULL` unassigns tasks.
- `POST /tablos/{id}/etapes/reorder` updates etape positions.

Add `EtapesDeps` or extend `TasksDeps`. Prefer a dedicated `EtapesDeps` if handlers become large; both should hold `*sqlc.Queries` and use the same ownership helpers.

Important authorization invariant: every etape route must first load the owning tablo with `loadOwnedTablo`, then fetch etape with `WHERE id = $1 AND tablo_id = $2`.

## Recommended Template Shape

Extend `TasksTabFragment` to accept:

- `tasks []sqlc.Task`
- `etapes []sqlc.Etape`
- current filter: all, unassigned, or etape UUID
- task counts per etape

Add an `EtapeStrip` component above `KanbanBoard`.

The strip should include:

- an `All` chip if needed for returning to the full board
- an `Unassigned` chip with count
- one chip per etape with count
- minimal create/edit/delete/reorder controls

Use HTMX links/buttons that target the Tasks tab container or `#kanban-board`, depending on the selected fragment boundary. The least risky boundary is the whole Tasks tab fragment because filtering changes both active chip state and task cards.

Keep cards and columns stable:

- Do not split kanban columns by etape.
- Do not add etape lanes.
- Do not change `TaskColumns`.

## Reorder Pitfalls

### Pitfall 1: Filtered Reorder Can Overwrite Hidden Task Positions

The current Sortable.js code posts only tasks visible in `.sortable-column`. If the board is filtered by etape and the handler recalculates positions from visible tasks, only those visible tasks will be renumbered.

That is acceptable for Phase 9 if task order remains status-scoped and the handler only updates submitted tasks. Hidden tasks keep their previous positions. This may create position collisions across filtered/unfiltered views, but the current model already tolerates sparse positions and does not require uniqueness.

Planner should require a regression test that:

- creates two tasks in the same status with different etapes,
- filters to one etape,
- reorders the visible task or moves it to another status,
- verifies the hidden task still exists and keeps its etape assignment.

### Pitfall 2: Reorder Must Preserve Etape Assignment

Once `tasks.etape_id` exists, every call to `UpdateTask` must include the current `etape_id` unless the handler intentionally changes assignment.

This affects:

- `TaskUpdateHandler`
- `TaskReorderHandler` array path
- `TaskReorderHandler` single-task path

Missing this will satisfy compile checks but break ETAPE-03 during drag/drop.

### Pitfall 3: Task Creation Under A Filter

When the current filter is an etape, the add-task form should include the active `etape_id` so new tasks land in the filtered etape. When the filter is `Unassigned`, the form should submit no etape. When the filter is `All`, default to no etape unless the user selects one in the form.

This keeps the visible result intuitive without changing task position semantics.

## Security Notes

Threats to cover in PLAN.md:

- Cross-tablo etape assignment: a user must not assign a task in tablo A to an etape from tablo B.
- Cross-user etape access: non-owners must receive 404, matching existing task behavior.
- CSRF on create/update/delete/reorder routes: all mutation forms must use `@ui.CSRFField(csrfToken)` and live under existing CSRF middleware.
- XSS through titles/descriptions: templ escaped text output should be used; do not render etape titles or descriptions through `templ.Raw`.
- Delete behavior: deleting an etape must not delete tasks.

## Suggested Plan Breakdown

Because the phase is MVP mode, prefer vertical slices. However, this feature has a schema dependency that all UI slices need. A pragmatic four-plan sequence is:

1. Schema, SQLC, and RED tests for etapes and optional task assignment.
2. Etape CRUD and top-strip management in the Tasks tab.
3. Task assignment in create/edit forms plus filtered kanban rendering.
4. Reorder preservation and final browser/UAT checkpoint.

Plan 1 should be wave 1 and blocking. Plans 2 and 3 can be sequential because the strip and forms share template signatures. Plan 4 should depend on the previous plans and focus on regression coverage plus human verification.

## Validation Architecture

### Automated Coverage

Use `go test ./... -count=1` from `backend` as the full command.

Add or extend tests in `backend/internal/web/handlers_tasks_test.go` or a new `handlers_etapes_test.go`:

- ETAPE-01: create etape inside owned tablo and render chip with count.
- ETAPE-02: edit, delete, and reorder etapes inside a tablo.
- ETAPE-03: create/update a task with zero or one etape.
- ETAPE-04: delete etape and verify assigned tasks still exist with null etape.
- ETAPE-05: filter by etape and unassigned while preserving status columns.
- ETAPE-06: schema has no parent field; assignment rejects etape IDs outside the task's tablo.
- Regression: drag/drop reorder preserves `etape_id`.
- Regression: current task tests still pass.

### Manual Verification

Manual browser verification should confirm:

- top strip appears above the kanban,
- chip counts update after create/assign/delete,
- selecting an etape filters the existing kanban,
- selecting `Unassigned` shows only unassigned tasks,
- task drag/drop still works after filtering,
- deleting an etape unassigns tasks rather than deleting cards permanently.

### Commands

- Generate code: `cd backend && just generate`
- Full test suite: `cd backend && go test ./... -count=1`
- Whitespace sanity: `git diff --check`

## Open Planning Decisions

No user-facing product decisions remain open. The planner may choose exact route names and fragment boundaries as long as the context decisions are preserved.
