xtablo-source/.planning/phases/20-tablo-detail-kanban-restyle/20-01-PLAN.md
Arthur Belleville 20e0a02edc
docs(20): create phase 20 plan — tablo detail page + kanban restyle
3 plans in 2 waves: handler+viewmodel (wave 1), templ components + CSS
restyle in parallel (wave 2). Covers DETAIL-01 and TASK-01.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-18 15:29:41 +02:00

19 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
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 + NewTabloDetailViewModel
TabloDetailViewModel
TabloDetailColumnView
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) 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.

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, ...}
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/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 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) - 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 - 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 - 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: 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 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: - 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" - Sets CreateHref="/tablos/"+tablo.ID.String()+"/tasks/create?status="+colID for each column - 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).

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.
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 4 view-layer tests are green; tablo_detail_view.go compiles with TabloDetailViewModel exported. 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</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.

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

<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 task statuses, not tablo.Status field
  • 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.