test(20-01): add failing tests for TabloDetailViewModel + GetTabloDetailPage handler

- TestComputeTabloProgress_{Empty,AllDone,Half,EtapesIgnored}
- TestNewTabloDetailViewModel_{GroupsTasksByStatus,EtapesExcludedFromColumns,EtapesPopulated}
- TestGetTabloDetailPage_{Returns200,Returns404,Returns400,Unauthenticated}
- TestTabloDetailKanbanColumns
- TestGetTabloDetailPage_ContainsSortableScript
This commit is contained in:
Arthur Belleville 2026-05-18 15:44:53 +02:00
parent 20e0a02edc
commit f24e1c4d35
No known key found for this signature in database
7 changed files with 719 additions and 74 deletions

View file

@ -2,14 +2,15 @@
gsd_state_version: 1.0
milestone: v4.0
milestone_name: Figma Design Parity
status: Executing
last_updated: "2026-05-17T16:05:00.000Z"
last_activity: 2026-05-17 — Phase 18 complete (sidebar + header restyle approved)
status: executing
last_updated: "2026-05-18T13:40:28.221Z"
last_activity: 2026-05-18 -- Phase 20 planning complete
progress:
total_phases: 5
completed_phases: 1
total_plans: 3
completed_plans: 3
completed_phases: 2
total_plans: 9
completed_plans: 6
percent: 40
---
# STATE
@ -29,8 +30,8 @@ See: `.planning/PROJECT.md` (updated 2026-05-17)
Phase: 19 — Tablo List Revamp (next)
Plan: —
Status: Executing
Last activity: 2026-05-17 — Phase 18 complete
Status: Ready to execute
Last activity: 2026-05-18 -- Phase 20 planning complete
## Previous Milestone Status

View file

@ -21,13 +21,15 @@ must_haves:
- "Accessing with an invalid UUID returns 400"
- "Progress is computed as doneTasks/totalTasks*100 (integer), not from tablo.Status"
- "TabloDetailViewModel groups tasks into 4 slices: Todo, InProgress, InReview, Done"
- "TabloDetailViewModel.Etapes lists etapes (tasks with IsEtape=true) for the tablo, with task count per etape"
- "Rendered HTML from GET /tablos/{id} contains substring `initTabloDetailSortable`"
artifacts:
- path: "go-backend/internal/web/handlers/tablo_detail.go"
provides: "GetTabloDetailPage handler + tabloDetailRepository interface + computeTabloProgress"
exports: ["GetTabloDetailPage"]
- path: "go-backend/internal/web/views/tablo_detail_view.go"
provides: "TabloDetailViewModel + TabloDetailColumnView + NewTabloDetailViewModel"
exports: ["TabloDetailViewModel", "TabloDetailColumnView", "NewTabloDetailViewModel"]
provides: "TabloDetailViewModel + TabloDetailColumnView + TabloDetailEtapeView + NewTabloDetailViewModel"
exports: ["TabloDetailViewModel", "TabloDetailColumnView", "TabloDetailEtapeView", "NewTabloDetailViewModel"]
- path: "go-backend/internal/web/handlers/tablo_detail_test.go"
provides: "Test scaffold for DETAIL-01 + TASK-01 handler tests"
- path: "go-backend/router.go"
@ -45,9 +47,9 @@ must_haves:
---
<objective>
Create the GET /tablos/{tabloID} handler, view model, and route registration. This is the foundation plan: it creates the data contracts (TabloDetailViewModel, TabloDetailColumnView) that the templ plan will build against, and the test scaffold that covers DETAIL-01 + TASK-01 handler behavior.
Create the GET /tablos/{tabloID} handler, view model, and route registration. This is the foundation plan: it creates the data contracts (TabloDetailViewModel, TabloDetailColumnView, TabloDetailEtapeView) that the templ plan will build against, and the test scaffold that covers DETAIL-01 + TASK-01 handler behavior.
Purpose: The tablo detail page does not exist. This plan wires the server-side data layer — authentication, ownership check, task fetch, progress computation — and registers the route.
Purpose: The tablo detail page does not exist. This plan wires the server-side data layer — authentication, ownership check, task fetch, progress computation, etape grouping — and registers the route.
Output: Handler file, view model file, test scaffold, updated router.go.
</objective>
@ -101,7 +103,7 @@ type taskMutationRepository interface {
}
type ListTasksByTabloInput = taskmodel.ListByTabloInput // {OwnerID uuid.UUID, TabloID uuid.UUID}
type TaskRecord = taskmodel.Record // {ID, TabloID, OwnerID, Title, Status, ...}
type TaskRecord = taskmodel.Record // {ID, TabloID, OwnerID, Title, Status, IsEtape bool, ParentTaskID *uuid.UUID, ...}
type TaskStatus = taskmodel.Status
const TaskStatusDone = taskmodel.StatusDone // "done"
const TaskStatusTodo = taskmodel.StatusTodo
@ -109,6 +111,13 @@ const TaskStatusInProgress = taskmodel.StatusInProgress
const TaskStatusInReview = taskmodel.StatusInReview
```
From go-backend/internal/tasks/model.go:
```go
// TaskRecord.IsEtape bool — true for etapes (stage-level tasks that group regular tasks)
// TaskRecord.ParentTaskID *uuid.UUID — regular tasks may have an etape as their parent
// Etapes: tasks where IsEtape==true; child tasks: IsEtape==false with ParentTaskID pointing to an etape
```
From go-backend/internal/web/handlers/tablos.go:
```go
// findTabloByID — existing helper, available within handlers package
@ -152,54 +161,62 @@ views.DashboardPageWithMainClass(activePath, tablos, "flex-1 overflow-auto", con
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Define TabloDetailViewModel and test scaffold</name>
<name>Task 1: Define TabloDetailViewModel (with etapes) and test scaffold</name>
<files>go-backend/internal/web/views/tablo_detail_view.go, go-backend/internal/web/handlers/tablo_detail_test.go</files>
<read_first>
- go-backend/internal/web/views/tablos_view.go (TabloCardView struct pattern, normalizedView helper)
- go-backend/internal/web/views/tasks_view.go (TaskCardView, TasksKanbanColumnView pattern)
- go-backend/internal/web/views/tasks_view.go (TaskCardView, TasksKanbanColumnView pattern; etape grouping in buildKanbanView and etapeMeta struct)
- go-backend/internal/web/handlers/tablos_test.go (test scaffold pattern for handlers)
- go-backend/internal/web/handlers/in_memory_auth_repository.go (InMemoryAuthRepository method list, lines 168+)
</read_first>
<behavior>
- TabloDetailViewModel has fields: TabloID string, TabloName string, Color string, Initial string, OwnerName string, DueDate string, StatusLabel string, StatusTone string, Progress int, ProgressLabel string, Columns []TabloDetailColumnView
- TabloDetailViewModel has fields: TabloID string, TabloName string, Color string, Initial string, OwnerName string, DueDate string, StatusLabel string, StatusTone string, Progress int, ProgressLabel string, Columns []TabloDetailColumnView, Etapes []TabloDetailEtapeView
- TabloDetailColumnView has fields: ID string, Label string, Tasks []TabloDetailTaskView, CreateHref string
- TabloDetailTaskView has fields: ID string, Title string, DeleteHref string, EditHref string
- NewTabloDetailViewModel(tablo TabloRecord, tasks []TaskRecord, ownerName string) builds 4 columns in order: "todo", "in_progress", "in_review", "done"
- computeTabloProgress(tasks []TaskRecord) int returns 0 when total==0, else (doneCount*100)/total
- TabloDetailEtapeView has fields: ID string, Name string, TaskCount int
- NewTabloDetailViewModel(tablo TabloRecord, tasks []TaskRecord, ownerName string) builds 4 columns in order: "todo", "in_progress", "in_review", "done". It also builds Etapes slice by filtering tasks where IsEtape==true; each etape's TaskCount is the number of tasks (IsEtape==false) whose ParentTaskID equals the etape's ID.
- computeTabloProgress(tasks []TaskRecord) int returns 0 when total of non-etape tasks==0, else (doneCount*100)/total where doneCount counts non-etape tasks with Status==TaskStatusDone
- Test: TestComputeTabloProgress_Empty — computeTabloProgress([]) == 0
- Test: TestComputeTabloProgress_AllDone — computeTabloProgress([done, done]) == 100
- Test: TestComputeTabloProgress_Half — computeTabloProgress([done, todo]) == 50
- Test: TestNewTabloDetailViewModel_Groups tasks into correct columns by status
- Test: TestComputeTabloProgress_EtapesIgnored — etape tasks (IsEtape=true) are excluded from progress computation
- Test: TestNewTabloDetailViewModel_Groups tasks into correct columns by status (excludes etapes from columns)
- Test: TestNewTabloDetailViewModel_EtapesPopulated — Etapes slice contains etape tasks with correct TaskCount
- Test: TestGetTabloDetailPage_Returns200 — GET /tablos/{validTabloID} with session cookie returns 200 and tablo name in body
- Test: TestGetTabloDetailPage_Returns404 — GET /tablos/{unknownID} with session cookie returns 404
- Test: TestGetTabloDetailPage_Returns400 — GET /tablos/not-a-uuid returns 400
- Test: TestGetTabloDetailPage_Unauthenticated — GET /tablos/{validID} without session cookie returns 302 to /login
- Test: TestTabloDetailKanbanColumns — response body contains the 4 kanban column status values (todo, in_progress, in_review, done) per Pitfall 3
- Test: TestGetTabloDetailPage_ContainsSortableScript — response body contains substring "initTabloDetailSortable"
</behavior>
<action>
Create go-backend/internal/web/views/tablo_detail_view.go in package views. Define:
1. TabloDetailTaskView struct with ID, Title, DeleteHref, EditHref string fields.
2. TabloDetailColumnView struct with ID, Label, Tasks []TabloDetailTaskView, CreateHref string fields.
3. TabloDetailViewModel struct with TabloID, TabloName, Color, Initial, OwnerName, DueDate, StatusLabel, StatusTone string, Progress int, ProgressLabel string, Columns []TabloDetailColumnView fields.
4. computeTabloProgress(tasks []TaskRecord) int function (package-private, used by handler and tested directly in tablo_detail_test.go via same package). Returns 0 if len==0; returns (doneCount*100)/len(tasks) where doneCount counts tasks with Status==TaskStatusDone.
5. NewTabloDetailViewModel(tablo TabloRecord, tasks []TaskRecord, ownerName string) TabloDetailViewModel that:
3. TabloDetailEtapeView struct with ID, Name string and TaskCount int fields.
4. TabloDetailViewModel struct with TabloID, TabloName, Color, Initial, OwnerName, DueDate, StatusLabel, StatusTone string, Progress int, ProgressLabel string, Columns []TabloDetailColumnView, Etapes []TabloDetailEtapeView fields.
5. computeTabloProgress(tasks []TaskRecord) int function (package-private). Excludes tasks with IsEtape==true from computation. Returns 0 if no non-etape tasks; returns (doneCount*100)/nonEtapeTotal where doneCount counts non-etape tasks with Status==TaskStatusDone.
6. NewTabloDetailViewModel(tablo TabloRecord, tasks []TaskRecord, ownerName string) TabloDetailViewModel that:
- Sets TabloID=tablo.ID.String(), TabloName=tablo.Name, Color=tablo.Color, Initial=projectInitial(tablo.Name)
- Sets OwnerName=ownerName
- Calls tabloStatusPresentation(tablo.Status) for StatusLabel, _, _, StatusTone (ignores the static progress int from tabloStatusPresentation — computes Progress via computeTabloProgress instead)
- Sets Progress=computeTabloProgress(tasks), ProgressLabel=fmt.Sprintf("%d%%", progress)
- Builds Columns slice of 4 TabloDetailColumnView in order: {ID:"todo", Label:"À faire"}, {ID:"in_progress", Label:"En cours"}, {ID:"in_review", Label:"En révision"}, {ID:"done", Label:"Terminé"}
- Populates each column's Tasks by filtering the tasks slice by Status; for each task: DeleteHref="/tasks/"+task.ID.String(), EditHref="/tasks/"+task.ID.String()+"/edit"
- Populates each column's Tasks by filtering non-etape tasks (IsEtape==false) by Status; for each task: DeleteHref="/tasks/"+task.ID.String(), EditHref="/tasks/"+task.ID.String()+"/edit"
- Sets CreateHref="/tablos/"+tablo.ID.String()+"/tasks/create?status="+colID for each column
- Builds Etapes slice: for each task where IsEtape==true, create TabloDetailEtapeView{ID: task.ID.String(), Name: task.Title, TaskCount: count of tasks where IsEtape==false && ParentTaskID==&task.ID}
- DueDate: tablo.UpdatedAt is not a due date — set DueDate="" (no due_date field on tablo model yet; template shows "—" fallback)
Create go-backend/internal/web/handlers/tablo_detail_test.go in package handlers. Import testing, net/http, net/http/httptest, strings, context, github.com/google/uuid, and xtablo-backend/internal/web/views. Use newTestRouter() from router_test.go test setup pattern: create a helper that builds an InMemoryAuthRepository, seeds a user, tablo, and tasks, and sets a session cookie. Test all 5 behaviors listed above with require/assert stdlib patterns (t.Fatal on unexpected).
Create go-backend/internal/web/views/tablo_detail_view_test.go in package views for view model unit tests. Test all behaviors marked Test: TestComputeTabloProgress_* and TestNewTabloDetailViewModel_*.
NOTE: computeTabloProgress and NewTabloDetailViewModel are in the views package; the test file for them should be go-backend/internal/web/views/tablo_detail_view_test.go (not in handlers). Create a second test file go-backend/internal/web/views/tablo_detail_view_test.go in package views for the view model unit tests (TestComputeTabloProgress_*, TestNewTabloDetailViewModel_Groups). The handler tests go in go-backend/internal/web/handlers/tablo_detail_test.go in package handlers.
Create go-backend/internal/web/handlers/tablo_detail_test.go in package handlers for handler integration tests. Use the router/test helper pattern from existing handlers tests. Test all behaviors marked Test: TestGetTabloDetailPage_* and TestTabloDetailKanbanColumns and TestGetTabloDetailPage_ContainsSortableScript.
NOTE: The TestGetTabloDetailPage_ContainsSortableScript test checks that the response body from GET /tablos/{id} contains the string "initTabloDetailSortable". This is the verifiable proxy for Sortable.js initialization — `go build` cannot verify runtime JS behavior, but it CAN verify the script tag is present in the rendered HTML.
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/go-backend && go test ./internal/web/views/... -run "TestCompute|TestNewTabloDetail" -count=1 -v 2>&1 | tail -20</automated>
</verify>
<done>go test ./internal/web/views/... -run "TestCompute|TestNewTabloDetail" passes; all 4 view-layer tests are green; tablo_detail_view.go compiles with TabloDetailViewModel exported.</done>
<done>go test ./internal/web/views/... -run "TestCompute|TestNewTabloDetail" passes; all view-layer tests are green; tablo_detail_view.go compiles with TabloDetailViewModel, TabloDetailEtapeView exported; etapes filtering logic verified by TestNewTabloDetailViewModel_EtapesPopulated.</done>
</task>
<task type="auto" tdd="true">
@ -245,11 +262,11 @@ views.DashboardPageWithMainClass(activePath, tablos, "flex-1 overflow-auto", con
IMPORTANT: views.TabloDetailPage does not exist yet. Add a temporary stub in go-backend/internal/web/views/tablo_detail_view.go:
func TabloDetailPage(vm TabloDetailViewModel) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
_, err := fmt.Fprintf(w, "<div>%s</div>", vm.TabloName)
_, err := fmt.Fprintf(w, "<div>%s<script>function initTabloDetailSortable(){}</script></div>", vm.TabloName)
return err
})
}
This stub will be replaced in Plan 02 with the real templ component. Import "context", "fmt", "io", "github.com/a-h/templ" for the stub.
The stub emits the tablo name AND the initTabloDetailSortable function name so that TestGetTabloDetailPage_ContainsSortableScript passes. This stub will be replaced in Plan 02 with the real templ component. Import "context", "fmt", "io", "github.com/a-h/templ" for the stub.
Update go-backend/router.go: add mux.Get("/tablos/{tabloID}", authHandler.GetTabloDetailPage()) immediately before the existing mux.Get("/tablos/{tabloID}/edit", ...) line.
@ -258,7 +275,7 @@ views.DashboardPageWithMainClass(activePath, tablos, "flex-1 overflow-auto", con
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/go-backend && go test ./... -run "TestGetTabloDetailPage" -count=1 -v 2>&1 | tail -30</automated>
</verify>
<done>All TestGetTabloDetailPage_* tests pass; go build ./... succeeds; GET /tablos/{validID} integration test returns 200 with tablo name in body.</done>
<done>All TestGetTabloDetailPage_* tests pass including TestGetTabloDetailPage_ContainsSortableScript; go build ./... succeeds; GET /tablos/{validID} integration test returns 200 with tablo name in body.</done>
</task>
</tasks>
@ -284,11 +301,12 @@ views.DashboardPageWithMainClass(activePath, tablos, "flex-1 overflow-auto", con
After Plan 01:
- `cd go-backend && go build ./...` exits 0
- `go test ./internal/web/views/... -run "TestCompute|TestNewTabloDetail" -count=1` all pass
- `go test ./... -run "TestGetTabloDetailPage" -count=1` all pass
- `go test ./... -run "TestGetTabloDetailPage" -count=1` all pass including TestGetTabloDetailPage_ContainsSortableScript
- `go test ./... -count=1` full suite still green (no regressions)
- router.go contains `mux.Get("/tablos/{tabloID}", authHandler.GetTabloDetailPage())`
- tablo_detail.go defines tabloDetailRepository interface and GetTabloDetailPage method
- tablo_detail_view.go exports TabloDetailViewModel, TabloDetailColumnView, NewTabloDetailViewModel
- tablo_detail_view.go exports TabloDetailViewModel, TabloDetailColumnView, TabloDetailEtapeView, NewTabloDetailViewModel
- TabloDetailViewModel.Etapes is populated from tasks where IsEtape==true
</verification>
<success_criteria>
@ -296,7 +314,9 @@ After Plan 01:
- GET /tablos/{validUUID} (authenticated, not owned) returns 404
- GET /tablos/not-a-uuid returns 400
- GET /tablos/{validUUID} (unauthenticated) returns 302 to /login
- Progress computed from task statuses, not tablo.Status field
- Progress computed from non-etape task statuses, not tablo.Status field
- Etapes slice populated with etape tasks and their child task counts
- Response body contains "initTabloDetailSortable" (verifiable via go test)
- Full test suite (`go test ./... -count=1`) remains green
</success_criteria>

View file

@ -8,6 +8,7 @@ depends_on:
files_modified:
- go-backend/internal/web/views/tablo_detail.templ
- go-backend/internal/web/views/tablo_detail_view.go
- go-backend/internal/web/handlers/tablo_detail_tab.go
autonomous: true
requirements:
- DETAIL-01
@ -17,19 +18,25 @@ must_haves:
truths:
- "Tablo detail page renders a header with tablo name as h1 (font-size 1.75rem) and a metadata row"
- "Tab bar renders Overview, Tasks, Files, Discussion, Events tabs; Tasks tab is active with class tab-nav-item--active"
- "Tab links use hx-get and hx-push-url=true targeting #tab-content — not plain href anchors"
- "Kanban board renders exactly 4 columns in a .tablo-kanban-board flex container"
- "Each column uses class tablo-kanban-column with data-status attribute set to the column ID"
- "Each column contains a hidden reorder form with id reorder-form-{status} for Sortable.js onEnd"
- "Each task card uses class task-card and carries data-task-id"
- "Drag handle element uses class task-drag-handle and is a child of .task-card"
- "Empty column renders a .tablo-kanban-empty element with text 'Aucune tâche'"
- "Sortable.js init fires on DOMContentLoaded and htmx:afterSettle; task list containers use class sortable-column"
- "Rendered HTML from GET /tablos/{id} contains substring `initTabloDetailSortable`"
- "Etapes section renders below the kanban board listing each etape name and its task count"
artifacts:
- path: "go-backend/internal/web/views/tablo_detail.templ"
provides: "TabloDetailPage + all sub-components (header, tab bar, kanban board, task card)"
provides: "TabloDetailPage + all sub-components (header, tab bar, kanban board, task card, etapes section)"
exports: ["TabloDetailPage"]
- path: "go-backend/internal/web/views/tablo_detail_view.go"
provides: "TabloDetailPage real templ component replaces stub from Plan 01"
contains: "func TabloDetailPage"
- path: "go-backend/internal/web/handlers/tablo_detail_tab.go"
provides: "GET /tablos/{tabloID}/{tab} handler for tab content swaps"
exports: ["GetTabloDetailTab"]
key_links:
- from: "go-backend/internal/web/views/tablo_detail.templ"
to: "go-backend/internal/web/views/tablo_detail_view.go"
@ -37,16 +44,20 @@ must_haves:
pattern: "TabloDetailViewModel"
- from: ".tablo-kanban-board .sortable-column"
to: "POST /tablos/{id}/tasks/reorder"
via: "Sortable.js onEnd -> #reorder-form submit"
pattern: "sortable-column"
via: "Sortable.js onEnd -> #reorder-form-{status} submit"
pattern: "reorder-form-"
- from: ".tablo-tab-bar a[hx-get]"
to: "GET /tablos/{tabloID}/{tab}"
via: "HTMX hx-get + hx-push-url=true"
pattern: "hx-get"
---
<objective>
Build the tablo_detail.templ components that render the tablo detail page: header section, tab bar, kanban board with 4 columns, task cards, empty state, and Sortable.js initialization script. Replace the stub TabloDetailPage function in tablo_detail_view.go with the real templ component.
Build the tablo_detail.templ components that render the tablo detail page: header section, HTMX tab bar, kanban board with 4 columns (each with a hidden reorder form), task cards, empty state, etapes section, and Sortable.js initialization script. Replace the stub TabloDetailPage function in tablo_detail_view.go with the real templ component. Add a minimal tab handler for the tasks tab.
Purpose: This plan produces the full HTML surface for DETAIL-01 and TASK-01. All CSS class names used here are defined in Plan 03's CSS work, so the visual output is unstyled at first but structurally correct.
Output: tablo_detail.templ with all sub-components; tablo_detail_view.go stub removed and replaced by templ-generated component.
Output: tablo_detail.templ with all sub-components; tablo_detail_view.go stub removed and replaced by templ-generated component; tablo_detail_tab.go with GetTabloDetailTab handler.
</objective>
<execution_context>
@ -78,6 +89,12 @@ type TabloDetailColumnView struct {
CreateHref string // "/tablos/{id}/tasks/create?status={colID}"
}
type TabloDetailEtapeView struct {
ID string
Name string
TaskCount int
}
type TabloDetailViewModel struct {
TabloID string
TabloName string
@ -90,6 +107,7 @@ type TabloDetailViewModel struct {
Progress int // 0-100
ProgressLabel string // "{N}%"
Columns []TabloDetailColumnView // always 4 columns
Etapes []TabloDetailEtapeView // may be empty
}
```
@ -117,10 +135,12 @@ From go-backend/internal/web/views/tasks.templ (for drag-and-drop reorder patter
```
// Sortable.js init fires on DOMContentLoaded AND htmx:afterSettle
// Uses: .sortable-column class on task list containers
// Uses: data-status attribute on column containers
// Uses: data-status attribute on column containers
// Uses: .task-drag-handle handle class
// Uses: .task-card draggable class
// Reorder form: #reorder-form (hidden form that submits task order to POST /tablos/{id}/tasks/reorder)
// Reorder form: hidden form that submits task order to POST /tablos/{id}/tasks/reorder
// IMPORTANT: form id must be "reorder-form-{status}" — Sortable.js onEnd looks up
// getElementById('reorder-form-' + el.dataset.status)
// Guard: if (el._sortable) return; prevents double-init after HTMX swap (Pitfall 4)
```
@ -151,6 +171,8 @@ CSS class contract (defined in Plan 03, used here):
.task-card-title — task title text
.task-card-delete — delete icon button (opacity 0 at rest)
.tablo-kanban-empty — empty column message
.tablo-etapes-section — etapes section below kanban board
.tablo-etape-row — each etape row (name + task count)
```
</interfaces>
</context>
@ -158,19 +180,20 @@ CSS class contract (defined in Plan 03, used here):
<tasks>
<task type="auto">
<name>Task 1: TabloDetailPage templ component — header, tab bar, kanban board</name>
<files>go-backend/internal/web/views/tablo_detail.templ</files>
<name>Task 1: TabloDetailPage templ component — header, HTMX tab bar, kanban board, etapes section</name>
<files>go-backend/internal/web/views/tablo_detail.templ, go-backend/internal/web/handlers/tablo_detail_tab.go</files>
<read_first>
- go-backend/internal/web/views/tablo_detail_view.go (TabloDetailViewModel fields — just created in Plan 01)
- go-backend/internal/web/views/tablos.templ (TabloGridCard pattern, ActionIcon usage, badgeVariantForTone function name)
- go-backend/internal/web/views/tasks.templ (TasksKanbanLayout — for contrast; tablo-detail kanban is SEPARATE and must NOT reuse TasksKanbanLayout)
- go-backend/internal/web/views/tasks.templ (TasksKanbanLayout — for contrast; tablo-detail kanban is SEPARATE and must NOT reuse TasksKanbanLayout; also read the Sortable.js init script and reorder form pattern)
- go-backend/internal/web/ui/badge.templ (BadgeProps fields)
- go-backend/internal/web/ui/icon_button.templ (IconButtonProps fields)
- go-backend/internal/web/ui/app.css lines 882-900 (existing .tab-nav, .tab-nav-item classes)
- go-backend/internal/web/views/discussion_view.go (for badgeVariantForTone function — confirm it's in views package)
- go-backend/internal/web/handlers/tablos.go (renderTablosResponse pattern — for how DashboardContentSwapWithMainClass is called, to replicate in tab handler)
</read_first>
<action>
Create go-backend/internal/web/views/tablo_detail.templ in package views. Import "xtablo-backend/internal/web/ui".
Create go-backend/internal/web/views/tablo_detail.templ in package views. Import "xtablo-backend/internal/web/ui" and "strconv".
Define the following templ components in this order:
@ -178,7 +201,9 @@ CSS class contract (defined in Plan 03, used here):
- Outer div class="tablo-detail-page"
- @TabloDetailHeader(vm)
- @TabloDetailTabBar(vm.TabloID)
- div class="tablo-kanban-board" containing: @TabloDetailKanbanBoard(vm.Columns, vm.TabloID)
- div id="tab-content":
* @TabloDetailKanbanBoard(vm.Columns, vm.TabloID)
* if len(vm.Etapes) > 0: @TabloDetailEtapesSection(vm.Etapes)
- @TabloDetailSortableScript(vm.TabloID)
2. TabloDetailHeader(vm TabloDetailViewModel) — header section:
@ -194,15 +219,21 @@ CSS class contract (defined in Plan 03, used here):
- div class="project-progress-track" style="min-width:120px" with inner div class="tablo-progress-bar" style={ "width:" + vm.ProgressLabel }
- strong { vm.ProgressLabel }
3. TabloDetailTabBar(tabloID string) — tab navigation:
3. TabloDetailTabBar(tabloID string) — HTMX tab navigation:
- nav element class="tablo-tab-bar"
- 5 anchor elements for: "Vue d'ensemble", "Tâches", "Fichiers", "Discussion", "Événements"
- Tab IDs/slugs: "overview", "tasks", "files", "discussion", "events"
- Each anchor: class="tab-nav-item" with href={ "/tablos/" + tabloID + "#" + slug }
- Tab slugs: "overview", "tasks", "files", "discussion", "events"
- Each anchor MUST use HTMX attributes (per UI-SPEC locked interaction contract):
hx-get="/tablos/{tabloID}/{slug}"
hx-target="#tab-content"
hx-push-url="true"
class="tab-nav-item"
- "Tâches" tab (tasks slug): class="tab-nav-item tab-nav-item--active" (Phase 20 always shows tasks tab active)
- No hx-get on tabs in Phase 20 — tab switching is Phase 21 scope; use plain href anchors
- Do NOT use plain href="#slug" anchors — the UI-SPEC requires HTMX push-url navigation for all tabs.
- Use templ.SafeURL for the hx-get value to satisfy templ's URL safety requirements.
4. TabloDetailKanbanBoard(columns []TabloDetailColumnView, tabloID string) — kanban board:
4. TabloDetailKanbanBoard(columns []TabloDetailColumnView, tabloID string) — renders the kanban board:
- Outer div class="tablo-kanban-board"
- For each column: @TabloDetailKanbanColumn(col, tabloID)
5. TabloDetailKanbanColumn(col TabloDetailColumnView, tabloID string) — single column:
@ -210,10 +241,18 @@ CSS class contract (defined in Plan 03, used here):
- div class="tablo-kanban-column-header":
* span class="tablo-kanban-column-title" containing { col.Label }
* span class="tablo-kanban-task-count" containing { strconv.Itoa(len(col.Tasks)) }
* a class="tablo-kanban-add-link" href={ col.CreateHref } containing "+ Ajouter"
* a class="tablo-kanban-add-link" href={ templ.SafeURL(col.CreateHref) } containing "+ Ajouter"
- div id={ "task-list-" + col.ID } class="task-list sortable-column" data-status={ col.ID }:
* if len(col.Tasks) == 0: div class="tablo-kanban-empty" { "Aucune tâche" }
* else: for _, task := range col.Tasks { @TabloDetailTaskCard(task, tabloID) }
- Hidden reorder form (REQUIRED for Sortable.js onEnd):
<form id={ "reorder-form-" + col.ID }
style="display:none"
hx-post={ "/tablos/" + tabloID + "/tasks/reorder?status=" + col.ID }
hx-target={ "#task-list-" + col.ID }
hx-swap="innerHTML">
<!-- Sortable.js onEnd will populate this form's inputs before calling requestSubmit() -->
</form>
- div id={ "create-zone-" + col.ID } (empty create zone for HTMX task create swap)
6. TabloDetailTaskCard(task TabloDetailTaskView, tabloID string) — task card:
@ -234,15 +273,33 @@ CSS class contract (defined in Plan 03, used here):
},
})
7. TabloDetailSortableScript(tabloID string) — JavaScript block:
Use a templ @raw block (or templ.Raw) to emit the Sortable.js init script that:
7. TabloDetailEtapesSection(etapes []TabloDetailEtapeView) — etapes list below kanban:
- section element class="tablo-etapes-section"
- h2 or h3 heading: "Étapes" (French)
- ul element: for each etape, render a li class="tablo-etape-row" containing:
span class="tablo-etape-name" { etape.Name }
span class="tablo-etape-count" { strconv.Itoa(etape.TaskCount) + " tâche(s)" }
8. TabloDetailSortableScript(tabloID string) — JavaScript block:
Use templ.Raw to emit the Sortable.js init script that:
- Defines function initTabloDetailSortable()
- Calls document.querySelectorAll('.sortable-column').forEach(function(el) { if (el._sortable) return; el._sortable = Sortable.create(el, { group: 'tablo-tasks', animation: 150, handle: '.task-drag-handle', draggable: '.task-card', onEnd: function(evt) { /* submit hidden reorder form */ var form = document.getElementById('reorder-form-' + el.dataset.status); if (form) form.requestSubmit(); } }); })
- Calls document.querySelectorAll('.sortable-column').forEach(function(el) { if (el._sortable) return; el._sortable = Sortable.create(el, { group: 'tablo-tasks', animation: 150, handle: '.task-drag-handle', draggable: '.task-card', onEnd: function(evt) { var form = document.getElementById('reorder-form-' + el.dataset.status); if (form) form.requestSubmit(); } }); })
- Calls document.addEventListener('DOMContentLoaded', initTabloDetailSortable)
- Calls document.addEventListener('htmx:afterSettle', initTabloDetailSortable)
Wrap in <script> tag.
Create go-backend/internal/web/handlers/tablo_detail_tab.go in package handlers. This handles GET /tablos/{tabloID}/{tab} for HTMX tab swaps:
- Define GetTabloDetailTab() http.HandlerFunc on *AuthHandler
- Auth check; redirect /login on failure
- Parse tabloID from path; 400 on invalid UUID
- Parse tab slug from path (chi: r.PathValue("tab"))
- For tab == "tasks": fetch tablo + tasks, build TabloDetailViewModel, return views.TabloDetailKanbanBoard(vm.Columns, tabloID.String()) rendered as HTMX partial (no dashboard layout wrapper — just the fragment)
- For other tabs ("overview", "files", "discussion", "events"): return a simple HTML fragment: `<div class="tab-coming-soon">Cette section arrive bientôt.</div>`
- Register route in router.go: mux.Get("/tablos/{tabloID}/{tab}", authHandler.GetTabloDetailTab()) — add immediately after the mux.Get("/tablos/{tabloID}", ...) line
CRITICAL rules:
- Tab links MUST use hx-get + hx-target="#tab-content" + hx-push-url="true" — plain href="#slug" anchors are FORBIDDEN per the UI-SPEC interaction contract
- Each kanban column MUST contain a hidden form with id="reorder-form-{col.ID}" — Sortable.js onEnd calls getElementById('reorder-form-' + el.dataset.status)
- Do NOT import or call TasksKanbanLayout — tablo-detail kanban is a separate surface (RESEARCH Anti-Patterns)
- Do NOT add a task view switcher (Board/List/Gantt) — UI-SPEC says this is Phase 21
- All strings in French per UI-SPEC Copywriting Contract
@ -253,17 +310,18 @@ CSS class contract (defined in Plan 03, used here):
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/go-backend && just generate 2>&1 | tail -5 && go build ./... 2>&1</automated>
</verify>
<done>just generate succeeds (tablo_detail_templ.go generated); go build ./... exits 0; tablo_detail.templ defines TabloDetailPage, TabloDetailHeader, TabloDetailTabBar, TabloDetailKanbanBoard, TabloDetailKanbanColumn, TabloDetailTaskCard, TabloDetailSortableScript.</done>
<done>just generate succeeds (tablo_detail_templ.go generated); go build ./... exits 0; tablo_detail.templ defines TabloDetailPage, TabloDetailHeader, TabloDetailTabBar, TabloDetailKanbanBoard, TabloDetailKanbanColumn, TabloDetailTaskCard, TabloDetailEtapesSection, TabloDetailSortableScript; each column contains a hidden reorder form; tab links use hx-get.</done>
</task>
<task type="auto">
<name>Task 2: Replace TabloDetailPage stub + wire templ component + run handler tests</name>
<files>go-backend/internal/web/views/tablo_detail_view.go</files>
<files>go-backend/internal/web/views/tablo_detail_view.go, go-backend/router.go</files>
<read_first>
- go-backend/internal/web/views/tablo_detail_view.go (current stub — remove the ComponentFunc stub)
- go-backend/internal/web/views/tablo_detail.templ (just created — TabloDetailPage is now a real templ component)
- go-backend/internal/web/views/tablo_detail_templ.go (generated by just generate — confirm TabloDetailPage signature)
- go-backend/internal/web/handlers/tablo_detail_test.go (handler tests that verify 200 + tablo name in body)
- go-backend/internal/web/handlers/tablo_detail_test.go (handler tests that verify 200 + tablo name in body + "initTabloDetailSortable" in body)
- go-backend/router.go (add GET /tablos/{tabloID}/{tab} route for GetTabloDetailTab)
</read_first>
<action>
Edit go-backend/internal/web/views/tablo_detail_view.go:
@ -273,14 +331,18 @@ CSS class contract (defined in Plan 03, used here):
After removing the stub, the real TabloDetailPage comes from tablo_detail_templ.go (generated by just generate). Verify with go build.
Edit go-backend/router.go:
- Add mux.Get("/tablos/{tabloID}/{tab}", authHandler.GetTabloDetailTab()) immediately after the mux.Get("/tablos/{tabloID}", ...) line added in Plan 01
Then run the full handler tests to confirm the integration is correct:
- TestGetTabloDetailPage_Returns200 should now render real HTML with the tablo name inside the TabloDetailHeader h1
- Update the test assertion if needed: the response body should contain the tablo name string
- TestGetTabloDetailPage_ContainsSortableScript should find "initTabloDetailSortable" in the rendered HTML (now from the real templ component's script block)
- Update any test assertions if needed: the response body should contain the tablo name string
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/go-backend && go test ./... -run "TestGetTabloDetailPage|TestComputeTabloProgress|TestNewTabloDetail|TestTabloDetail" -count=1 -v 2>&1 | tail -30</automated>
</verify>
<done>All TestGetTabloDetailPage_* and view model tests pass; go build ./... exits 0; no stub function remains in tablo_detail_view.go; generated tablo_detail_templ.go exports TabloDetailPage(TabloDetailViewModel) templ.Component.</done>
<done>All TestGetTabloDetailPage_* and view model tests pass; go build ./... exits 0; no stub function remains in tablo_detail_view.go; generated tablo_detail_templ.go exports TabloDetailPage(TabloDetailViewModel) templ.Component; router.go contains both /tablos/{tabloID} and /tablos/{tabloID}/{tab} routes.</done>
</task>
</tasks>
@ -291,6 +353,7 @@ CSS class contract (defined in Plan 03, used here):
| Boundary | Description |
|----------|-------------|
| Template rendering | TabloDetailViewModel data flows from handler into templ — ensure no raw HTML injection |
| Tab slug routing | {tab} path value used to select partial response — must not allow arbitrary content injection |
## STRIDE Threat Register
@ -298,6 +361,7 @@ CSS class contract (defined in Plan 03, used here):
|-----------|----------|-----------|-------------|-----------------|
| T-20-04 | Tampering | TabloDetailTaskCard — task.Title rendered in HTML | mitigate | templ auto-escapes all { expr } interpolations — no raw HTML emission for user data |
| T-20-05 | Tampering | Sortable.js onEnd — data-status attribute used in form ID | mitigate | data-status comes from hardcoded column IDs ("todo", "in_progress", "in_review", "done") — not user input |
| T-20-07 | Tampering | GetTabloDetailTab — {tab} slug used in switch, not reflected into HTML | mitigate | Tab slug used only as switch key; "coming soon" text is hardcoded; tab slug never interpolated into HTML |
| T-20-SC | Tampering | npm/pip/cargo installs | accept | No package installs — pure templ + CSS |
</threat_model>
@ -307,17 +371,22 @@ After Plan 02:
- `go build ./...` exits 0
- `go test ./... -run "TestGetTabloDetailPage|TestComputeTabloProgress|TestNewTabloDetail|TestTabloDetail" -count=1` all pass
- `go test ./... -count=1` full suite green
- tablo_detail.templ defines 7 templ components including TabloDetailPage and TabloDetailTaskCard
- tablo_detail.templ defines 8 templ components including TabloDetailPage, TabloDetailTaskCard, TabloDetailEtapesSection
- Task cards use class="task-card" and data-task-id attribute
- Task list containers use class="task-list sortable-column"
- Drag handle uses class="task-drag-handle"
- Empty column uses class="tablo-kanban-empty" with text "Aucune tâche"
- Each column has a hidden form with id="reorder-form-{status}"
- Tab links use hx-get + hx-target="#tab-content" + hx-push-url="true"
- Etapes section renders below kanban board when vm.Etapes is non-empty
</verification>
<success_criteria>
- GET /tablos/{validID} (authenticated) returns 200 with h1 containing tablo name, .tablo-kanban-board div, and 4 .tablo-kanban-column elements in the response body
- GET /tablos/{validID} (authenticated) returns 200 with h1 containing tablo name, .tablo-kanban-board div, 4 .tablo-kanban-column elements, hidden reorder forms, and etapes section in the response body
- Full test suite green
- Sortable.js init script present in rendered HTML with DOMContentLoaded and htmx:afterSettle listeners
- Tab links use hx-get targeting #tab-content (not plain href anchors)
- Hidden reorder forms present per column: id="reorder-form-todo", id="reorder-form-in_progress", etc.
- No TasksKanbanLayout referenced in tablo_detail.templ
</success_criteria>

View file

@ -25,9 +25,10 @@ must_haves:
- "app.css contains a .tablo-progress-bar rule with background var(--color-brand-primary) — NOT var(--project-color)"
- "app.css contains a .tablo-files-table-wrapper rule with border-radius 12px"
- "app.css .task-list rule is updated to include gap 8px and padding 8px"
- "app.css contains a .tablo-etapes-section rule and a .tablo-etape-row rule"
artifacts:
- path: "go-backend/internal/web/ui/app.css"
provides: "All tablo detail + kanban restyle CSS rules"
provides: "All tablo detail + kanban restyle + etapes section CSS rules"
contains: ".tablo-detail-header"
key_links:
- from: "go-backend/internal/web/views/tablo_detail.templ"
@ -37,7 +38,7 @@ must_haves:
---
<objective>
Apply all CSS changes from the UI-SPEC to app.css: new tablo detail header styles, kanban board layout, task card restyle (column-flex, hover shadow, opacity-based drag handle), progress bar fill fix, files table wrapper, and updated task-list gap. This plan runs in parallel with Plan 02 since CSS class names are already locked in the UI-SPEC.
Apply all CSS changes from the UI-SPEC to app.css: new tablo detail header styles, kanban board layout, task card restyle (column-flex, hover shadow, opacity-based drag handle), progress bar fill fix, files table wrapper, etapes section, and updated task-list gap. This plan runs in parallel with Plan 02 since CSS class names are already locked in the UI-SPEC.
Purpose: Without these CSS rules, the templ components from Plan 02 render unstyled. This plan is the visual half of the restyle.
@ -151,7 +152,7 @@ Output: Updated app.css with all new and modified CSS blocks from UI-SPEC.
</task>
<task type="auto">
<name>Task 2: Kanban board, task card, files table, and task-list gap CSS</name>
<name>Task 2: Kanban board, task card, etapes section, files table, and task-list gap CSS</name>
<files>go-backend/internal/web/ui/app.css</files>
<read_first>
- go-backend/internal/web/ui/app.css (read lines 1283-1350 — existing .task-list, .task-row, .task-body, .task-check rules; understand what to modify vs what to add)
@ -277,39 +278,79 @@ Output: Updated app.css with all new and modified CSS blocks from UI-SPEC.
}
This change affects both the global tasks page and the tablo detail page equally — gap between cards is desired everywhere.
Add etapes section CSS:
BLOCK 27: .tablo-etapes-section — etapes list below kanban board
border-top: 1px solid var(--color-border-muted);
margin-top: 24px;
padding-top: 24px;
BLOCK 28: .tablo-etapes-section h2, .tablo-etapes-section h3 — section heading
color: var(--color-text-primary);
font-size: 1rem;
font-weight: 600;
margin: 0 0 12px 0;
BLOCK 29: .tablo-etapes-section ul — list reset
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
BLOCK 30: .tablo-etape-row — each etape row
align-items: center;
background: var(--color-surface-default);
border: 1px solid var(--color-border-default);
border-radius: 8px;
display: flex;
gap: 12px;
justify-content: space-between;
padding: 10px 16px;
BLOCK 31: .tablo-etape-name — etape stage name
color: var(--color-text-primary);
font-size: 0.875rem;
font-weight: 500;
BLOCK 32: .tablo-etape-count — task count label
color: var(--color-text-secondary);
font-size: 0.75rem;
Add files table wrapper CSS:
BLOCK 27: .tablo-files-table-wrapper — wrapper for the files table
BLOCK 33: .tablo-files-table-wrapper — wrapper for the files table
border: 1px solid var(--color-border-default);
border-radius: 12px;
overflow: hidden;
BLOCK 28: .tablo-files-table-wrapper thead tr
BLOCK 34: .tablo-files-table-wrapper thead tr
background: var(--color-surface-muted);
BLOCK 29: .tablo-files-table-wrapper thead th
BLOCK 35: .tablo-files-table-wrapper thead th
color: var(--color-text-muted);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
BLOCK 30: .tablo-files-table-wrapper tbody tr
BLOCK 36: .tablo-files-table-wrapper tbody tr
border-bottom: 1px solid var(--color-border-default);
BLOCK 31: .tablo-files-table-wrapper tbody tr:hover
BLOCK 37: .tablo-files-table-wrapper tbody tr:hover
background: var(--color-surface-subtle);
CRITICAL checks before saving:
- .tablo-progress-bar uses "background: var(--color-brand-primary)" — grep to verify
- .tablo-progress-bar uses "background: var(--color-brand-primary)" — verify with grep after writing
- .task-card and .task-row are SEPARATE rules — .task-row must NOT be removed (it is used by the global tasks page)
- .tasks-section border-radius (1rem) must NOT be changed — only .tablo-kanban-column gets 0.75rem
- All opacity transitions use "0.12s ease" consistent with design system
</action>
<verify>
<automated>grep -v "^[[:space:]]*//" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/go-backend/internal/web/ui/app.css | grep -c "tablo-kanban-board\|tablo-kanban-column\|task-card\b\|task-drag-handle\|tablo-files-table-wrapper\|tablo-kanban-empty"</automated>
<automated>grep "background: var(--color-brand-primary)" /Users/arthur.belleville/Documents/perso/projects/xtablo-source/go-backend/internal/web/ui/app.css</automated>
</verify>
<done>grep count returns 6 or more; .task-card block exists with flex-direction: column; .task-drag-handle exists with opacity: 0; .task-card:hover .task-drag-handle exists with opacity: 1; .tablo-kanban-board exists with overflow-x: auto; .task-list updated to include gap: 8px; .task-row unchanged.</done>
<done>grep returns at least one line containing "background: var(--color-brand-primary)" (the .tablo-progress-bar rule); .task-card block exists with flex-direction: column; .task-drag-handle exists with opacity: 0; .task-card:hover .task-drag-handle exists with opacity: 1; .tablo-kanban-board exists with overflow-x: auto; .task-list updated to include gap: 8px; .task-row unchanged; .tablo-etapes-section and .tablo-etape-row rules present.</done>
</task>
</tasks>
@ -332,14 +373,16 @@ Output: Updated app.css with all new and modified CSS blocks from UI-SPEC.
<verification>
After Plan 03:
- grep for "tablo-kanban-board" in app.css returns at least one match with "overflow-x: auto"
- grep for "tablo-progress-bar" in app.css returns a match with "background: var(--color-brand-primary)"
- grep for ".tablo-progress-bar" in app.css returns a match with "background: var(--color-brand-primary)"
- grep for "tablo-progress-bar" does NOT return "var(--project-color)"
- grep for ".task-card\b" in app.css returns the column-flex card rule
- grep for ".task-card" in app.css returns the column-flex card rule (flex-direction: column)
- grep for ".task-drag-handle" returns opacity: 0 rule
- grep for ".task-card:hover .task-drag-handle" returns opacity: 1 rule
- grep for ".task-row" still returns the original horizontal rule (not removed)
- grep for ".tasks-section" still has border-radius: 1rem (not changed to 0.75rem)
- grep for ".task-list" returns updated rule with gap: 8px
- grep for ".tablo-etapes-section" returns a CSS rule
- grep for ".tablo-etape-row" returns a CSS rule
- go build ./... still exits 0 (CSS is static — no compilation risk, but verify Go still compiles cleanly)
</verification>
@ -350,6 +393,7 @@ After Plan 03:
- Visual: drag handle appears on card hover (opacity transition, not display toggle)
- Visual: delete icon appears on card hover
- Visual: empty columns show "Aucune tâche" centered
- Visual: etapes section renders below kanban board with stage names and task counts
- CSS regression: global /tasks kanban page still renders correctly (task-row unchanged)
</success_criteria>

View file

@ -0,0 +1,80 @@
---
phase: 20
slug: tablo-detail-kanban-restyle
status: draft
nyquist_compliant: true
wave_0_complete: false
created: 2026-05-18
---
# Phase 20 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | go test |
| **Config file** | none — existing test infrastructure |
| **Quick run command** | `go build ./...` |
| **Full suite command** | `TEST_DATABASE_URL='postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable' go test ./... -count=1` |
| **Estimated runtime** | ~20 seconds |
---
## Sampling Rate
- **After every task commit:** Run `go build ./...`
- **After every plan wave:** Run full suite with TEST_DATABASE_URL
- **Before `/gsd:verify-work`:** Full suite must be green
- **Max feedback latency:** 20 seconds
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 20-01-01 | 01 | 1 | DETAIL-01 | — | handler returns 404 for tablo not owned by current user | unit | `go build ./...` | ✅ | ⬜ pending |
| 20-01-02 | 01 | 1 | DETAIL-01 | — | N/A | unit | `go test ./backend/... -run TestTabloDetail -count=1` | ❌ W0 | ⬜ pending |
| 20-02-01 | 02 | 2 | TASK-01 | — | N/A | build | `just generate && go build ./...` | ✅ | ⬜ pending |
| 20-02-02 | 02 | 2 | TASK-01 | — | N/A | build | `just generate && go build ./...` | ✅ | ⬜ pending |
| 20-03-01 | 03 | 2 | DETAIL-01 | — | N/A | source | `grep -c "tablo-detail-header\|tablo-detail-title\|tablo-metadata-row\|tablo-progress-bar" backend/internal/web/ui/app.css` | ✅ | ⬜ pending |
| 20-03-02 | 03 | 2 | TASK-01 | — | N/A | source | `grep "background: var(--color-brand-primary)" backend/internal/web/ui/app.css` | ✅ | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `backend/cmd/web/tablo_detail_test.go` — stubs for DETAIL-01, TASK-01 handler tests
- [ ] Test must compile with `go test ./... -run TestTabloDetail -count=1` returning 0 (skip OK)
*Existing infrastructure covers all other phase requirements (go build, just generate).*
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Kanban drag-and-drop reorder persists after restyle | TASK-01 | Browser interaction required (Sortable.js) | Open /tablos/{id}, drag a task card to a different position in the same column, reload page and verify new position persists |
| Tablo detail header matches Figma visual | DETAIL-01 | Visual layout requires browser render | Open /tablos/{id}, compare header row (name, metadata, avatar stack, progress bar) against Board-1.png screenshot |
| Etapes section renders correctly | DETAIL-01 | Visual layout | Open /tablos/{id} that has etapes, verify etapes section is visible below kanban with correct French labels |
---
## Validation Sign-Off
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [x] Wave 0 covers all MISSING references
- [x] No watch-mode flags
- [x] Feedback latency < 20s
- [x] `nyquist_compliant: true` set in frontmatter
**Approval:** pending

View file

@ -0,0 +1,187 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestGetTabloDetailPage_Returns200(t *testing.T) {
handler := newTestAuthHandler(t)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
// Create a tablo owned by demo user
user, err := handler.repo.(interface {
GetAuthUserByEmail(ctx context.Context, email string) (AuthUser, error)
}).GetAuthUserByEmail(context.Background(), "demo@xtablo.com")
if err != nil {
t.Fatalf("get demo user: %v", err)
}
tablo, err := handler.repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: user.ID,
Name: "Test Project",
Status: TabloStatusTodo,
Color: "#3B82F6",
})
if err != nil {
t.Fatalf("create tablo: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String(), nil)
req.SetPathValue("tabloID", tablo.ID.String())
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTabloDetailPage().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d\nbody: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Test Project") {
t.Errorf("expected body to contain tablo name 'Test Project', got: %s", body[:min(len(body), 500)])
}
}
func TestGetTabloDetailPage_Returns404(t *testing.T) {
handler := newTestAuthHandler(t)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
// Use a random UUID that doesn't belong to the user
unknownID := "00000000-0000-0000-0000-000000000099"
req := httptest.NewRequest(http.MethodGet, "/tablos/"+unknownID, nil)
req.SetPathValue("tabloID", unknownID)
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTabloDetailPage().ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}
func TestGetTabloDetailPage_Returns400(t *testing.T) {
handler := newTestAuthHandler(t)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/tablos/not-a-uuid", nil)
req.SetPathValue("tabloID", "not-a-uuid")
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTabloDetailPage().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}
func TestGetTabloDetailPage_Unauthenticated(t *testing.T) {
handler := newTestAuthHandler(t)
req := httptest.NewRequest(http.MethodGet, "/tablos/00000000-0000-0000-0000-000000000001", nil)
req.SetPathValue("tabloID", "00000000-0000-0000-0000-000000000001")
rec := httptest.NewRecorder()
handler.GetTabloDetailPage().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("expected 302 redirect to /login, got %d", rec.Code)
}
location := rec.Header().Get("Location")
if location != "/login" {
t.Errorf("expected redirect to /login, got %q", location)
}
}
func TestTabloDetailKanbanColumns(t *testing.T) {
handler := newTestAuthHandler(t)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
user, err := handler.repo.(interface {
GetAuthUserByEmail(ctx context.Context, email string) (AuthUser, error)
}).GetAuthUserByEmail(context.Background(), "demo@xtablo.com")
if err != nil {
t.Fatalf("get demo user: %v", err)
}
tablo, err := handler.repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: user.ID,
Name: "Kanban Test",
Status: TabloStatusTodo,
Color: "#3B82F6",
})
if err != nil {
t.Fatalf("create tablo: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String(), nil)
req.SetPathValue("tabloID", tablo.ID.String())
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTabloDetailPage().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, status := range []string{"todo", "in_progress", "in_review", "done"} {
if !strings.Contains(body, status) {
t.Errorf("expected body to contain kanban column status %q", status)
}
}
}
func TestGetTabloDetailPage_ContainsSortableScript(t *testing.T) {
handler := newTestAuthHandler(t)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
user, err := handler.repo.(interface {
GetAuthUserByEmail(ctx context.Context, email string) (AuthUser, error)
}).GetAuthUserByEmail(context.Background(), "demo@xtablo.com")
if err != nil {
t.Fatalf("get demo user: %v", err)
}
tablo, err := handler.repo.CreateTablo(context.Background(), CreateTabloInput{
OwnerID: user.ID,
Name: "Sortable Test",
Status: TabloStatusTodo,
Color: "#3B82F6",
})
if err != nil {
t.Fatalf("create tablo: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String(), nil)
req.SetPathValue("tabloID", tablo.ID.String())
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTabloDetailPage().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "initTabloDetailSortable") {
t.Errorf("expected body to contain 'initTabloDetailSortable', got body[:500]: %s", body[:min(len(body), 500)])
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View file

@ -0,0 +1,244 @@
package views
import (
"testing"
"time"
"github.com/google/uuid"
tablomodel "xtablo-backend/internal/tablos"
taskmodel "xtablo-backend/internal/tasks"
)
func makeTask(status taskmodel.Status, isEtape bool) taskmodel.Record {
return taskmodel.Record{
ID: uuid.New(),
TabloID: uuid.New(),
OwnerID: uuid.New(),
Title: "Task " + string(status),
Status: status,
IsEtape: isEtape,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
}
func makeTablo() tablomodel.Record {
return tablomodel.Record{
ID: uuid.New(),
OwnerID: uuid.New(),
Name: "Test Tablo",
Color: "#3B82F6",
Status: tablomodel.StatusTodo,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
}
func TestComputeTabloProgress_Empty(t *testing.T) {
got := computeTabloProgress([]taskmodel.Record{})
if got != 0 {
t.Fatalf("expected 0, got %d", got)
}
}
func TestComputeTabloProgress_AllDone(t *testing.T) {
tasks := []taskmodel.Record{
makeTask(taskmodel.StatusDone, false),
makeTask(taskmodel.StatusDone, false),
}
got := computeTabloProgress(tasks)
if got != 100 {
t.Fatalf("expected 100, got %d", got)
}
}
func TestComputeTabloProgress_Half(t *testing.T) {
tasks := []taskmodel.Record{
makeTask(taskmodel.StatusDone, false),
makeTask(taskmodel.StatusTodo, false),
}
got := computeTabloProgress(tasks)
if got != 50 {
t.Fatalf("expected 50, got %d", got)
}
}
func TestComputeTabloProgress_EtapesIgnored(t *testing.T) {
tasks := []taskmodel.Record{
makeTask(taskmodel.StatusDone, false),
// etape task — should be excluded from denominator and numerator
makeTask(taskmodel.StatusTodo, true),
}
got := computeTabloProgress(tasks)
if got != 100 {
t.Fatalf("expected 100 (etape ignored), got %d", got)
}
}
func TestNewTabloDetailViewModel_GroupsTasksByStatus(t *testing.T) {
tablo := makeTablo()
todoTask := makeTask(taskmodel.StatusTodo, false)
todoTask.TabloID = tablo.ID
inProgressTask := makeTask(taskmodel.StatusInProgress, false)
inProgressTask.TabloID = tablo.ID
doneTask := makeTask(taskmodel.StatusDone, false)
doneTask.TabloID = tablo.ID
tasks := []taskmodel.Record{todoTask, inProgressTask, doneTask}
vm := NewTabloDetailViewModel(tablo, tasks, "Test User")
if len(vm.Columns) != 4 {
t.Fatalf("expected 4 columns, got %d", len(vm.Columns))
}
columnIDs := []string{"todo", "in_progress", "in_review", "done"}
for i, col := range vm.Columns {
if col.ID != columnIDs[i] {
t.Errorf("column[%d]: expected ID %q, got %q", i, columnIDs[i], col.ID)
}
}
// Column 0 = todo — 1 task
if len(vm.Columns[0].Tasks) != 1 {
t.Errorf("todo column: expected 1 task, got %d", len(vm.Columns[0].Tasks))
}
// Column 1 = in_progress — 1 task
if len(vm.Columns[1].Tasks) != 1 {
t.Errorf("in_progress column: expected 1 task, got %d", len(vm.Columns[1].Tasks))
}
// Column 2 = in_review — 0 tasks
if len(vm.Columns[2].Tasks) != 0 {
t.Errorf("in_review column: expected 0 tasks, got %d", len(vm.Columns[2].Tasks))
}
// Column 3 = done — 1 task
if len(vm.Columns[3].Tasks) != 1 {
t.Errorf("done column: expected 1 task, got %d", len(vm.Columns[3].Tasks))
}
}
func TestNewTabloDetailViewModel_EtapesExcludedFromColumns(t *testing.T) {
tablo := makeTablo()
etapeTask := makeTask(taskmodel.StatusTodo, true)
etapeTask.TabloID = tablo.ID
regularTask := makeTask(taskmodel.StatusTodo, false)
regularTask.TabloID = tablo.ID
tasks := []taskmodel.Record{etapeTask, regularTask}
vm := NewTabloDetailViewModel(tablo, tasks, "")
// Only regular task in todo column
if len(vm.Columns[0].Tasks) != 1 {
t.Errorf("todo column: expected 1 task (etape excluded), got %d", len(vm.Columns[0].Tasks))
}
}
func TestNewTabloDetailViewModel_EtapesPopulated(t *testing.T) {
tablo := makeTablo()
etape1 := taskmodel.Record{
ID: uuid.New(),
TabloID: tablo.ID,
OwnerID: tablo.OwnerID,
Title: "Etape One",
Status: taskmodel.StatusTodo,
IsEtape: true,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
etape2 := taskmodel.Record{
ID: uuid.New(),
TabloID: tablo.ID,
OwnerID: tablo.OwnerID,
Title: "Etape Two",
Status: taskmodel.StatusTodo,
IsEtape: true,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
child1 := taskmodel.Record{
ID: uuid.New(),
TabloID: tablo.ID,
OwnerID: tablo.OwnerID,
Title: "Child A",
Status: taskmodel.StatusTodo,
IsEtape: false,
ParentTaskID: &etape1.ID,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
child2 := taskmodel.Record{
ID: uuid.New(),
TabloID: tablo.ID,
OwnerID: tablo.OwnerID,
Title: "Child B",
Status: taskmodel.StatusInProgress,
IsEtape: false,
ParentTaskID: &etape1.ID,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
orphan := taskmodel.Record{
ID: uuid.New(),
TabloID: tablo.ID,
OwnerID: tablo.OwnerID,
Title: "No Etape",
Status: taskmodel.StatusTodo,
IsEtape: false,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
tasks := []taskmodel.Record{etape1, etape2, child1, child2, orphan}
vm := NewTabloDetailViewModel(tablo, tasks, "")
if len(vm.Etapes) != 2 {
t.Fatalf("expected 2 etapes, got %d", len(vm.Etapes))
}
// Find etape1 in the Etapes slice
var foundEtape1 *TabloDetailEtapeView
for i := range vm.Etapes {
if vm.Etapes[i].ID == etape1.ID.String() {
foundEtape1 = &vm.Etapes[i]
break
}
}
if foundEtape1 == nil {
t.Fatal("etape1 not found in vm.Etapes")
}
if foundEtape1.TaskCount != 2 {
t.Errorf("etape1: expected TaskCount=2, got %d", foundEtape1.TaskCount)
}
// Find etape2 — should have TaskCount=0
var foundEtape2 *TabloDetailEtapeView
for i := range vm.Etapes {
if vm.Etapes[i].ID == etape2.ID.String() {
foundEtape2 = &vm.Etapes[i]
break
}
}
if foundEtape2 == nil {
t.Fatal("etape2 not found in vm.Etapes")
}
if foundEtape2.TaskCount != 0 {
t.Errorf("etape2: expected TaskCount=0, got %d", foundEtape2.TaskCount)
}
}