xtablo-source/.planning/phases/20-tablo-detail-kanban-restyle/20-01-PLAN.md

326 lines
22 KiB
Markdown
Raw Normal View History

---
phase: 20-tablo-detail-kanban-restyle
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- go-backend/internal/web/handlers/tablo_detail.go
- go-backend/internal/web/views/tablo_detail_view.go
- go-backend/internal/web/handlers/tablo_detail_test.go
- go-backend/router.go
autonomous: true
requirements:
- DETAIL-01
- TASK-01
must_haves:
truths:
- "GET /tablos/{tabloID} returns 200 with the tablo name in the HTML response body"
- "Accessing a tablo owned by a different user returns 404"
- "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 + 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"
provides: "GET /tablos/{tabloID} route registered"
contains: "mux.Get(\"/tablos/{tabloID}\""
key_links:
- from: "go-backend/router.go"
to: "go-backend/internal/web/handlers/tablo_detail.go"
via: "authHandler.GetTabloDetailPage()"
pattern: "GetTabloDetailPage"
- from: "go-backend/internal/web/handlers/tablo_detail.go"
to: "h.repo.(tabloDetailRepository)"
via: "type assertion for ListTasksByTablo"
pattern: "tabloDetailRepository"
---
<objective>
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, etape grouping — and registers the route.
Output: Handler file, view model file, test scaffold, updated router.go.
</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>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/20-tablo-detail-kanban-restyle/20-RESEARCH.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/20-tablo-detail-kanban-restyle/20-UI-SPEC.md
<interfaces>
<!-- Key types the executor needs. Extracted from codebase. -->
From go-backend/internal/web/handlers/auth.go:
```go
// AuthRepository interface — does NOT include ListTasksByTablo
type AuthRepository interface {
CreateAuthUser(ctx context.Context, input CreateAuthUserInput) (uuid.UUID, error)
GetAuthUserByEmail(ctx context.Context, email string) (AuthUser, error)
GetPublicUserByID(ctx context.Context, id uuid.UUID) (PublicUser, error)
CreateSession(ctx context.Context, token string, userID uuid.UUID, expiresAt time.Time) error
GetSessionByToken(ctx context.Context, token string) (Session, error)
DeleteSessionByToken(ctx context.Context, token string) error
CreateTablo(ctx context.Context, input CreateTabloInput) (TabloRecord, error)
UpdateTablo(ctx context.Context, input UpdateTabloInput) error
ListTablos(ctx context.Context, input ListTablosInput) ([]TabloRecord, error)
SoftDeleteTablo(ctx context.Context, tabloID uuid.UUID, ownerID uuid.UUID) error
}
type AuthHandler struct { repo AuthRepository }
func (h *AuthHandler) authenticatedUser(ctx context.Context, r *http.Request) (PublicUser, bool)
```
From go-backend/internal/web/handlers/tasks.go:
```go
// taskPageRepository — separate interface for task reads; ListTasksByTablo NOT in AuthRepository
type taskPageRepository interface {
ListTasksByOwner(ctx context.Context, ownerID uuid.UUID) ([]TaskRecord, error)
}
// taskMutationRepository — separate interface for task writes
type taskMutationRepository interface {
CreateTask(ctx context.Context, input CreateTaskInput) (TaskRecord, error)
GetTaskByID(ctx context.Context, taskID uuid.UUID, ownerID uuid.UUID) (TaskRecord, error)
UpdateTask(ctx context.Context, input UpdateTaskInput) (TaskRecord, error)
SoftDeleteTask(ctx context.Context, taskID uuid.UUID, ownerID uuid.UUID) error
}
type ListTasksByTabloInput = taskmodel.ListByTabloInput // {OwnerID uuid.UUID, TabloID uuid.UUID}
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
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
func findTabloByID(tablos []TabloRecord, targetID uuid.UUID) (TabloRecord, bool)
func tabloStatusPresentation(status TabloStatus) (string, string, int, string)
// returns: (statusLabel, statusClass, progress, statusTone)
type TabloRecord = tablomodel.Record // {ID, OwnerID, Name, Color, Status, CreatedAt, UpdatedAt}
type ListTablosInput = tablomodel.ListInput // {OwnerID, Status*}
```
From go-backend/internal/web/handlers/in_memory_auth_repository.go (line 168):
```go
func (r *InMemoryAuthRepository) ListTasksByTablo(_ context.Context, input ListTasksByTabloInput) ([]TaskRecord, error)
// InMemoryAuthRepository already implements this — safe to use in tests
```
From go-backend/router.go:
```go
func newRouterWithHandler(authHandler *handlers.AuthHandler) http.Handler {
mux := chi.NewRouter()
// ... existing routes ...
mux.Get("/tablos/{tabloID}/edit", authHandler.GetEditTabloModal())
mux.Post("/tablos/{tabloID}", authHandler.PostTabloUpdate())
mux.Delete("/tablos/{tabloID}", authHandler.DeleteTablo())
// ADD: mux.Get("/tablos/{tabloID}", authHandler.GetTabloDetailPage())
// Must be BEFORE the edit/post/delete routes to avoid conflicts — chi uses first-match
}
```
From go-backend/internal/web/views (render pattern from tablos.go renderTablosResponse):
```go
// DashboardPageWithMainClass and DashboardContentSwapWithMainClass are used for full-width content
views.DashboardContentSwapWithMainClass(activePath, tablos, "flex-1 overflow-auto", content).Render(r.Context(), w)
views.DashboardPageWithMainClass(activePath, tablos, "flex-1 overflow-auto", content).Render(r.Context(), w)
// activePath for tablo detail: "/tablos" (parent, so sidebar Tablos item stays highlighted per Pitfall 3)
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<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; 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, Etapes []TabloDetailEtapeView
- TabloDetailColumnView has fields: ID string, Label string, Tasks []TabloDetailTaskView, CreateHref string
- TabloDetailTaskView has fields: ID string, Title string, DeleteHref string, EditHref string
- 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: 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. 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 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/views/tablo_detail_view_test.go in package views for view model unit tests. Test all behaviors marked Test: TestComputeTabloProgress_* and TestNewTabloDetailViewModel_*.
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 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">
<name>Task 2: GetTabloDetailPage handler + router registration</name>
<files>go-backend/internal/web/handlers/tablo_detail.go, go-backend/router.go</files>
<read_first>
- go-backend/internal/web/handlers/tablos.go (findTabloByID, renderTablosResponse pattern, tabloStatusPresentation, authenticatedUser)
- go-backend/internal/web/handlers/tasks.go (type assertion pattern h.repo.(taskPageRepository), ListTasksByTabloInput)
- go-backend/internal/web/handlers/auth.go (AuthHandler struct, AuthRepository interface)
- go-backend/router.go (current route list — locate where to insert GET /tablos/{tabloID} before edit/post/delete)
- go-backend/internal/web/views/tablo_detail_view.go (just created — NewTabloDetailViewModel signature)
</read_first>
<behavior>
- tabloDetailRepository interface exposes ListTasksByTablo(ctx, ListTasksByTabloInput) ([]TaskRecord, error)
- GetTabloDetailPage returns http.HandlerFunc that: (a) checks auth, (b) parses tabloID as UUID, (c) ListTablos+findTabloByID, (d) type-asserts h.repo to tabloDetailRepository, (e) ListTasksByTablo, (f) fetches owner via GetPublicUserByID (falls back to empty string on error), (g) builds TabloDetailViewModel, (h) renders DashboardPageWithMainClass or DashboardContentSwapWithMainClass with activePath="/tablos" and a placeholder views.TabloDetailPage(vm) stub
- On auth failure: 302 /login
- On invalid UUID: 400 "invalid tablo id"
- On tablo not found: 404 "tablo not found"
- On type assertion failure (should never happen in production): 500 "tasks repository not configured"
- activePath passed to Dashboard helpers is "/tablos" (not "/tablos/{id}") so sidebar "Tablos" nav item stays highlighted (Pitfall 3 from RESEARCH)
- router.go adds: mux.Get("/tablos/{tabloID}", authHandler.GetTabloDetailPage()) — positioned before the existing mux.Get("/tablos/{tabloID}/edit", ...) line
</behavior>
<action>
Create go-backend/internal/web/handlers/tablo_detail.go in package handlers. Import context, net/http, github.com/google/uuid, xtablo-backend/internal/web/views.
Define tabloDetailRepository interface:
type tabloDetailRepository interface {
ListTasksByTablo(ctx context.Context, input ListTasksByTabloInput) ([]TaskRecord, error)
}
Define GetTabloDetailPage() http.HandlerFunc on *AuthHandler:
- Call h.authenticatedUser; on failure redirect /login
- uuid.Parse(r.PathValue("tabloID")); on error http.Error 400 "invalid tablo id"
- h.repo.ListTablos(ctx, ListTablosInput{OwnerID: user.ID}); on error 500
- findTabloByID(tablos, tabloID); on not-found http.Error 404 "tablo not found"
- taskRepo, ok := h.repo.(tabloDetailRepository); if !ok http.Error 500 "tasks repository not configured"
- taskRepo.ListTasksByTablo(ctx, ListTasksByTabloInput{OwnerID: user.ID, TabloID: tabloID}); on error 500
- ownerName: call h.repo.GetPublicUserByID(ctx, user.ID); use owner.DisplayName; on error ownerName=""
- vm := views.NewTabloDetailViewModel(tablo, tasks, ownerName)
- content := views.TabloDetailPage(vm) [this templ function will be a stub until Plan 02]
- Render using DashboardPageWithMainClass (non-HX) or DashboardContentSwapWithMainClass (HX) with activePath="/tablos", mainClass="flex-1 overflow-auto"
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<script>function initTabloDetailSortable(){}</script></div>", vm.TabloName)
return err
})
}
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.
Verify handler tests from tablo_detail_test.go pass.
</action>
<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 including TestGetTabloDetailPage_ContainsSortableScript; go build ./... succeeds; GET /tablos/{validID} integration test returns 200 with tablo name in body.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client→GET /tablos/{tabloID} | Unauthenticated or cross-user path value |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-20-01 | Elevation of Privilege | GetTabloDetailPage — IDOR via guessed UUID | mitigate | findTabloByID filters by OwnerID from authenticated session; returns 404 for any tablo not owned by authenticated user |
| T-20-02 | Spoofing | Cookie-based session auth | mitigate | h.authenticatedUser validates session cookie via GetSessionByToken + expiry check — existing pattern |
| T-20-03 | Tampering | ListTasksByTablo input | mitigate | OwnerID comes from authenticated session, not from request params; tabloID validated via uuid.Parse before use |
| T-20-SC | Tampering | npm/pip/cargo installs | accept | No package installs in this plan — pure Go code using existing dependencies |
</threat_model>
<verification>
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 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, TabloDetailEtapeView, NewTabloDetailViewModel
- TabloDetailViewModel.Etapes is populated from tasks where IsEtape==true
</verification>
<success_criteria>
- GET /tablos/{validUUID} (authenticated, owned) returns 200 containing tablo name
- 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 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>
<output>
Create `.planning/phases/20-tablo-detail-kanban-restyle/20-01-SUMMARY.md` when done.
</output>