xtablo-source/.planning/phases/20-tablo-detail-kanban-restyle/20-01-PLAN.md
Arthur Belleville f24e1c4d35
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
2026-05-18 15:44:53 +02:00

22 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
20-tablo-detail-kanban-restyle 01 execute 1
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
true
DETAIL-01
TASK-01
truths artifacts key_links
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`
path provides exports
go-backend/internal/web/handlers/tablo_detail.go GetTabloDetailPage handler + tabloDetailRepository interface + computeTabloProgress
GetTabloDetailPage
path provides exports
go-backend/internal/web/views/tablo_detail_view.go TabloDetailViewModel + TabloDetailColumnView + TabloDetailEtapeView + NewTabloDetailViewModel
TabloDetailViewModel
TabloDetailColumnView
TabloDetailEtapeView
NewTabloDetailViewModel
path provides
go-backend/internal/web/handlers/tablo_detail_test.go Test scaffold for DETAIL-01 + TASK-01 handler tests
path provides contains
go-backend/router.go GET /tablos/{tabloID} route registered mux.Get("/tablos/{tabloID}"
from to via pattern
go-backend/router.go go-backend/internal/web/handlers/tablo_detail.go authHandler.GetTabloDetailPage() GetTabloDetailPage
from to via pattern
go-backend/internal/web/handlers/tablo_detail.go h.repo.(tabloDetailRepository) type assertion for ListTasksByTablo tabloDetailRepository
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.

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

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

From go-backend/internal/web/handlers/auth.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:

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

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

// 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):

func (r *InMemoryAuthRepository) ListTasksByTablo(_ context.Context, input ListTasksByTabloInput) ([]TaskRecord, error)
// InMemoryAuthRepository already implements this — safe to use in tests

From go-backend/router.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):

// 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)
Task 1: Define TabloDetailViewModel (with etapes) and test scaffold go-backend/internal/web/views/tablo_detail_view.go, go-backend/internal/web/handlers/tablo_detail_test.go - 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+) - 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" 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.
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 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. Task 2: GetTabloDetailPage handler + router registration go-backend/internal/web/handlers/tablo_detail.go, go-backend/router.go - 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) - 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 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.
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/go-backend && go test ./... -run "TestGetTabloDetailPage" -count=1 -v 2>&1 | tail -30 All TestGetTabloDetailPage_* tests pass including TestGetTabloDetailPage_ContainsSortableScript; go build ./... succeeds; GET /tablos/{validID} integration test returns 200 with tablo name in body.

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

<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>
Create `.planning/phases/20-tablo-detail-kanban-restyle/20-01-SUMMARY.md` when done.