diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index ed3070c..6ead794 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -85,12 +85,18 @@ Plans: ### Phase 20: Tablo Detail & Kanban Restyle **Goal:** Restyle the tablo detail page and kanban board to match Figma. **Requirements:** DETAIL-01, TASK-01 +**Plans:** 3 plans **Success criteria:** 1. Tablo detail header shows tablo name, status, and progress matching Figma 2. Kanban columns and task cards are restyled to match Figma 3. Drag-and-drop reorder continues to work after restyle 4. Etapes section and files table match Figma layout +Plans: +- [ ] 20-01-PLAN.md — Handler + view model + route: GET /tablos/{tabloID}, TabloDetailViewModel, test scaffold +- [ ] 20-02-PLAN.md — TabloDetailPage templ components: header, tab bar, kanban board, task cards +- [ ] 20-03-PLAN.md — CSS restyle: tablo detail header, kanban board layout, task card, progress bar, files table + ### Phase 21: Task Grid & Roadmap Views **Goal:** Add grid/table and roadmap/timeline views to the task section of a tablo. **Requirements:** TASK-02, TASK-03 @@ -132,6 +138,6 @@ Plans: | 17. Chat & Planning | v3.0 | 2/2 | Complete | 2026-05-17 | | 18. App Shell & Navigation | v4.0 | 0/3 | Pending | — | | 19. Tablo List Revamp | v4.0 | 0/3 | Pending | — | -| 20. Tablo Detail & Kanban | v4.0 | — | Pending | — | +| 20. Tablo Detail & Kanban | v4.0 | 0/3 | Pending | — | | 21. Task Grid & Roadmap Views | v4.0 | — | Pending | — | | 22. Calendar Rework | v4.0 | — | Pending | — | diff --git a/.planning/phases/20-tablo-detail-kanban-restyle/20-01-PLAN.md b/.planning/phases/20-tablo-detail-kanban-restyle/20-01-PLAN.md new file mode 100644 index 0000000..1601c81 --- /dev/null +++ b/.planning/phases/20-tablo-detail-kanban-restyle/20-01-PLAN.md @@ -0,0 +1,305 @@ +--- +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" + 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"] + - 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" +--- + + +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. + + + +@/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 + + + +@/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: +```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, ...} +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: +```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) +``` + + + + + + + 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, "
%s
", 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. +
+ +
+ + +## 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 | + + + +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 + + + +- 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 + + + +Create `.planning/phases/20-tablo-detail-kanban-restyle/20-01-SUMMARY.md` when done. + diff --git a/.planning/phases/20-tablo-detail-kanban-restyle/20-02-PLAN.md b/.planning/phases/20-tablo-detail-kanban-restyle/20-02-PLAN.md new file mode 100644 index 0000000..33871e4 --- /dev/null +++ b/.planning/phases/20-tablo-detail-kanban-restyle/20-02-PLAN.md @@ -0,0 +1,326 @@ +--- +phase: 20-tablo-detail-kanban-restyle +plan: 02 +type: execute +wave: 2 +depends_on: + - 20-01 +files_modified: + - go-backend/internal/web/views/tablo_detail.templ + - go-backend/internal/web/views/tablo_detail_view.go +autonomous: true +requirements: + - DETAIL-01 + - TASK-01 + +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" + - "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 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" + artifacts: + - path: "go-backend/internal/web/views/tablo_detail.templ" + provides: "TabloDetailPage + all sub-components (header, tab bar, kanban board, task card)" + 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" + key_links: + - from: "go-backend/internal/web/views/tablo_detail.templ" + to: "go-backend/internal/web/views/tablo_detail_view.go" + via: "TabloDetailViewModel struct fields" + pattern: "TabloDetailViewModel" + - from: ".tablo-kanban-board .sortable-column" + to: "POST /tablos/{id}/tasks/reorder" + via: "Sortable.js onEnd -> #reorder-form submit" + pattern: "sortable-column" +--- + + +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. + +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. + + + +@/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 + + + +@/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-UI-SPEC.md +@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/20-tablo-detail-kanban-restyle/20-RESEARCH.md + + + + +From go-backend/internal/web/views/tablo_detail_view.go (created in Plan 01): +```go +type TabloDetailTaskView struct { + ID string + Title string + DeleteHref string + EditHref string +} + +type TabloDetailColumnView struct { + ID string // "todo" | "in_progress" | "in_review" | "done" + Label string // "À faire" | "En cours" | "En révision" | "Terminé" + Tasks []TabloDetailTaskView + CreateHref string // "/tablos/{id}/tasks/create?status={colID}" +} + +type TabloDetailViewModel struct { + TabloID string + TabloName string + Color string + Initial string + OwnerName string + DueDate string // empty string → show "—" in template + StatusLabel string + StatusTone string // "warning" | "success" | "info" + Progress int // 0-100 + ProgressLabel string // "{N}%" + Columns []TabloDetailColumnView // always 4 columns +} +``` + +From go-backend/internal/web/ui/badge.templ: +```go +// ui.Badge(ui.BadgeProps{Label: string, Variant: string}) templ.Component +// badge variants: use badgeVariantForTone(tone string) -> string (function in views package) +// tone strings: "success", "warning", "info", "danger", "default" +``` + +From go-backend/internal/web/ui/icon_button.templ: +```go +// ui.IconButton(ui.IconButtonProps{Label, Icon, Variant, Tone, Type, Attrs}) templ.Component +// IconButtonVariantNeutral / IconButtonToneGhost — for ghost action buttons +// IconButtonVariantDanger + IconButtonToneGhost — for delete button +``` + +From go-backend/internal/web/views/tablos.templ (for ActionIcon usage): +```go +// ActionIcon(kind string) templ.Component — available in views package +// Icons used: "calendar", "message-circle", "user" +``` + +From go-backend/internal/web/views/tasks.templ (for drag-and-drop reorder pattern): +``` +// 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: .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) +// Guard: if (el._sortable) return; prevents double-init after HTMX swap (Pitfall 4) +``` + +CSS class contract (defined in Plan 03, used here): +``` +.tablo-detail-page — outer container, px-6 pt-6 +.tablo-detail-header — header element +.tablo-detail-title-row — flex row with avatar + h1 +.tablo-detail-avatar — 48×48 colored circle with initial +.tablo-detail-title — h1, font-size 1.75rem, weight 600 +.tablo-metadata-row — flex row with gap:24px, padding-block:16px +.tablo-meta-segment — each metadata segment in the row +.tablo-meta-progress — the progress segment (bar + label) +.tablo-progress-bar — progress fill (uses brand-primary, NOT project-color) +.tablo-tab-bar — tab navigation below header +.tab-nav-item — each tab item (existing class) +.tab-nav-item--active — active tab (existing class) +.tablo-kanban-board — flex container for columns +.tablo-kanban-column — each 18rem column +.tablo-kanban-column-header — column header area +.tablo-kanban-column-title — h3 inside column header +.tablo-kanban-task-count — count pill +.tablo-kanban-add-link — "+ Ajouter" ghost link +.task-list.sortable-column — the sortable task list container +.task-card — individual task card (replaces .task-row) +.task-card-top-row — row with drag handle, title, delete icon +.task-drag-handle — drag handle (opacity 0 at rest, 1 on card hover) +.task-card-title — task title text +.task-card-delete — delete icon button (opacity 0 at rest) +.tablo-kanban-empty — empty column message +``` + + + + + + + Task 1: TabloDetailPage templ component — header, tab bar, kanban board + go-backend/internal/web/views/tablo_detail.templ + + - 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/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) + + + Create go-backend/internal/web/views/tablo_detail.templ in package views. Import "xtablo-backend/internal/web/ui". + + Define the following templ components in this order: + + 1. TabloDetailPage(vm TabloDetailViewModel) — outer wrapper: + - Outer div class="tablo-detail-page" + - @TabloDetailHeader(vm) + - @TabloDetailTabBar(vm.TabloID) + - div class="tablo-kanban-board" containing: @TabloDetailKanbanBoard(vm.Columns, vm.TabloID) + - @TabloDetailSortableScript(vm.TabloID) + + 2. TabloDetailHeader(vm TabloDetailViewModel) — header section: + - header element class="tablo-detail-header" + - Inner div class="tablo-detail-title-row": + * div class="tablo-detail-avatar" style={ "background:" + vm.Color } containing { vm.Initial } + * h1 class="tablo-detail-title" containing { vm.TabloName } + - div class="tablo-metadata-row": + * span class="tablo-meta-segment" containing: 24×24 avatar div with owner initial + " " + { vm.OwnerName } + * span class="tablo-meta-segment" containing: @ActionIcon("calendar") + if vm.DueDate != "" { vm.DueDate } else { "—" } + * span class="tablo-meta-segment" containing: @ui.Badge(ui.BadgeProps{Label: vm.StatusLabel, Variant: badgeVariantForTone(vm.StatusTone)}) + * span class="tablo-meta-segment tablo-meta-progress" containing: + - 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: + - 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 } + - "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 + + 4. TabloDetailKanbanBoard(columns []TabloDetailColumnView, tabloID string) — kanban board: + - For each column: @TabloDetailKanbanColumn(col, tabloID) + + 5. TabloDetailKanbanColumn(col TabloDetailColumnView, tabloID string) — single column: + - div class="tablo-kanban-column" data-status={ col.ID } + - 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" + - 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) } + - div id={ "create-zone-" + col.ID } (empty create zone for HTMX task create swap) + + 6. TabloDetailTaskCard(task TabloDetailTaskView, tabloID string) — task card: + - article element: class="task-card" data-task-id={ task.ID } + - div class="task-card-top-row": + * span class="task-drag-handle" aria-hidden="true" { "⠿" } + * span class="task-card-title" { task.Title } + * @ui.IconButton(ui.IconButtonProps{ + Label: "Supprimer la tâche", Icon: "trash", + Variant: ui.IconButtonVariantDanger, Tone: ui.IconButtonToneGhost, + Type: "button", + Attrs: templ.Attributes{ + "class": "task-card-delete", + "hx-delete": task.DeleteHref, + "hx-target": "#app-main-content", + "hx-swap": "outerHTML", + "hx-confirm": "Supprimer cette tâche ?", + }, + }) + + 7. TabloDetailSortableScript(tabloID string) — JavaScript block: + Use a templ @raw block (or 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.addEventListener('DOMContentLoaded', initTabloDetailSortable) + - Calls document.addEventListener('htmx:afterSettle', initTabloDetailSortable) + Wrap in