Add tasks multi-view dashboard implementation plan

This commit is contained in:
Arthur Belleville 2026-05-10 22:06:37 +02:00
parent c148ff9af3
commit 71a56b72f1
No known key found for this signature in database

View file

@ -0,0 +1,832 @@
# Go Backend Tasks Multi-View Dashboard Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the simple `go-backend` `/tasks` CRUD page with a server-rendered cross-tablo dashboard that supports `kanban`, `list`, and `roadmap` views, URL-driven filtering, and tablo-aware create/edit flows.
**Architecture:** Keep `/tasks` as a single owner-scoped route whose state is driven by query params. Parse page state once in the handler, load one shared task dataset across all owned tablos, then shape that dataset into dedicated `kanban`, `list`, and `roadmap` view models. Reuse the existing task persistence layer, and extend the create/edit flow so `tablo_id` and tablo-scoped étape selection are enforced on the server.
**Tech Stack:** Go, chi, templ components rendered from Go view models, HTMX, PostgreSQL, pgx, sqlc, Go standard `net/http` testing
---
## File Structure
**Existing files to modify**
- `go-backend/internal/tasks/model.go`
- Add owner-level dashboard page-state types, filter types, and shared dashboard record shapes.
- `go-backend/internal/web/handlers/tasks.go`
- Parse query params, normalize page state, load shared option data, preserve query state through mutations, and validate `tablo_id` plus tablo-scoped `parent_task_id`.
- `go-backend/internal/web/handlers/tasks_test.go`
- Add handler tests for view switching, filter normalization, roadmap mode behavior, and cross-tablo form validation.
- `go-backend/internal/web/handlers/in_memory_auth_repository.go`
- Add any list/filter support needed by handler tests, while keeping the repository owner-scoped.
- `go-backend/internal/web/views/tasks_view.go`
- Replace the simple grouped CRUD view model with a shared page shell and dedicated `kanban`, `list`, and `roadmap` view builders/renderers.
- `go-backend/router.go`
- Keep `/tasks` on one route, but ensure the new page behavior continues to work through the existing handlers.
- `go-backend/router_test.go`
- Add end-to-end route coverage for query-param-driven rendering and current-state-preserving mutation responses.
**Files that may be modified if needed**
- `go-backend/internal/db/repository.go`
- Only if the dashboard needs a small repository helper for owner-scoped task loading or deterministic ordering not already exposed.
- `go-backend/internal/db/queries.sql`
- Only if owner-scoped loading needs a dedicated query shape not already present.
- `go-backend/internal/db/sqlc/queries.sql.go`
- Regenerated if SQL changes are needed.
**No new persistence design files are required**
- This plan assumes the task and etape storage added earlier remains the source of truth.
**Verification commands**
- `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'`
- `cd go-backend && go test ./...`
- `cd go-backend && just build`
## Task 1: Add Query-State Parsing And Handler Coverage
**Files:**
- Modify: `go-backend/internal/web/handlers/tasks.go`
- Modify: `go-backend/internal/web/handlers/tasks_test.go`
- Modify: `go-backend/internal/tasks/model.go`
- [ ] **Step 1: Write the failing handler tests for view and filter state**
Add focused tests to `go-backend/internal/web/handlers/tasks_test.go`:
```go
func TestGetTasksPageDefaultsToKanbanView(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTasksPage().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, `href="/tasks?view=kanban"`) {
t.Fatalf("expected kanban tab in body, got %q", body)
}
if !strings.Contains(body, `data-current-view="kanban"`) {
t.Fatalf("expected default view to be kanban, got %q", body)
}
}
func TestGetTasksPageIgnoresInvalidQueryState(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/tasks?view=nope&roadmap_mode=nope&status=bad", nil)
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTasksPage().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, `data-current-view="kanban"`) {
t.Fatalf("expected normalized kanban view, got %q", body)
}
if strings.Contains(body, `value="bad" selected`) {
t.Fatalf("expected invalid status filter to be dropped, got %q", body)
}
}
```
- [ ] **Step 2: Run the focused tests to verify they fail**
Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'`
Expected: FAIL because the current `/tasks` page has no query-state model, no `data-current-view`, and no normalized view handling.
- [ ] **Step 3: Add the dashboard page-state types**
Extend `go-backend/internal/tasks/model.go` with a dedicated page-state model:
```go
type DashboardView string
const (
DashboardViewKanban DashboardView = "kanban"
DashboardViewList DashboardView = "list"
DashboardViewRoadmap DashboardView = "roadmap"
)
type RoadmapMode string
const (
RoadmapModeWeek RoadmapMode = "week"
RoadmapModeMonth RoadmapMode = "month"
)
type DashboardPageState struct {
View DashboardView
RoadmapMode RoadmapMode
TabloIDs []uuid.UUID
AssigneeIDs []uuid.UUID
Statuses []Status
}
```
Add parsing helpers with explicit normalization:
```go
func NormalizeDashboardView(raw string) DashboardView {
switch DashboardView(raw) {
case DashboardViewList:
return DashboardViewList
case DashboardViewRoadmap:
return DashboardViewRoadmap
default:
return DashboardViewKanban
}
}
func NormalizeRoadmapMode(raw string) RoadmapMode {
if RoadmapMode(raw) == RoadmapModeMonth {
return RoadmapModeMonth
}
return RoadmapModeWeek
}
```
- [ ] **Step 4: Parse query state in the handler**
In `go-backend/internal/web/handlers/tasks.go`, add a helper used by `renderTasksPage`:
```go
func parseDashboardPageState(r *http.Request) taskmodel.DashboardPageState {
query := r.URL.Query()
state := taskmodel.DashboardPageState{
View: taskmodel.NormalizeDashboardView(query.Get("view")),
RoadmapMode: taskmodel.NormalizeRoadmapMode(query.Get("roadmap_mode")),
TabloIDs: parseUUIDList(query["tablo"]),
AssigneeIDs: parseUUIDList(query["assignee"]),
Statuses: parseStatusList(query["status"]),
}
if state.View != taskmodel.DashboardViewRoadmap {
state.RoadmapMode = taskmodel.RoadmapModeWeek
}
return state
}
```
Add the parsing helpers in the same file:
```go
func parseUUIDList(values []string) []uuid.UUID {
parsed := make([]uuid.UUID, 0, len(values))
for _, value := range values {
id, err := uuid.Parse(strings.TrimSpace(value))
if err == nil {
parsed = append(parsed, id)
}
}
return parsed
}
func parseStatusList(values []string) []taskmodel.Status {
parsed := make([]taskmodel.Status, 0, len(values))
for _, value := range values {
status, err := taskmodel.ParseStatus(strings.TrimSpace(value))
if err == nil {
parsed = append(parsed, status)
}
}
return parsed
}
```
- [ ] **Step 5: Re-run the focused tests to verify the parsing and normalization contract**
Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'`
Expected: still FAIL, but now only on missing page rendering markers and missing multi-view content. Query parsing should compile cleanly.
- [ ] **Step 6: Commit**
```bash
git add go-backend/internal/tasks/model.go go-backend/internal/web/handlers/tasks.go go-backend/internal/web/handlers/tasks_test.go
git commit -m "feat: add tasks dashboard query state parsing"
```
## Task 2: Build Shared Dashboard Dataset And Grouping Logic
**Files:**
- Modify: `go-backend/internal/tasks/model.go`
- Modify: `go-backend/internal/web/views/tasks_view.go`
- Modify: `go-backend/internal/web/handlers/tasks_test.go`
- [ ] **Step 1: Write the failing view-model tests for cross-tablo grouping**
Add tests to `go-backend/internal/web/handlers/tasks_test.go` or a new focused view-model test section in the same file:
```go
func TestTasksDashboardListGroupsByStatusAcrossTablos(t *testing.T) {
now := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
tabloA := tablomodel.Record{ID: uuid.New(), Name: "Alpha", Color: "#111111"}
tabloB := tablomodel.Record{ID: uuid.New(), Name: "Beta", Color: "#222222"}
records := []taskmodel.Record{
{ID: uuid.New(), TabloID: tabloA.ID, Title: "Write copy", Status: taskmodel.StatusTodo, CreatedAt: now},
{ID: uuid.New(), TabloID: tabloB.ID, Title: "Ship docs", Status: taskmodel.StatusTodo, CreatedAt: now.Add(time.Minute)},
{ID: uuid.New(), TabloID: tabloB.ID, Title: "Review launch", Status: taskmodel.StatusInReview, CreatedAt: now.Add(2 * time.Minute)},
}
vm := views.NewTasksDashboardPageViewModel(
[]tablomodel.Record{tabloA, tabloB},
records,
nil,
taskmodel.DashboardPageState{View: taskmodel.DashboardViewList, RoadmapMode: taskmodel.RoadmapModeWeek},
now,
)
if len(vm.List.Groups) != 4 {
t.Fatalf("expected four status groups, got %d", len(vm.List.Groups))
}
if got := len(vm.List.Groups[0].Tasks); got != 2 {
t.Fatalf("expected two todo tasks across tablos, got %d", got)
}
}
```
Add a roadmap test:
```go
func TestTasksDashboardRoadmapCreatesPerTabloSansEtapeLanes(t *testing.T) {
now := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
tabloA := tablomodel.Record{ID: uuid.New(), Name: "Alpha"}
tabloB := tablomodel.Record{ID: uuid.New(), Name: "Beta"}
due := now.AddDate(0, 0, 3)
records := []taskmodel.Record{
{ID: uuid.New(), TabloID: tabloA.ID, Title: "A task", Status: taskmodel.StatusTodo, DueDate: &due, CreatedAt: now},
{ID: uuid.New(), TabloID: tabloB.ID, Title: "B task", Status: taskmodel.StatusTodo, DueDate: &due, CreatedAt: now},
}
vm := views.NewTasksDashboardPageViewModel(
[]tablomodel.Record{tabloA, tabloB},
records,
nil,
taskmodel.DashboardPageState{View: taskmodel.DashboardViewRoadmap, RoadmapMode: taskmodel.RoadmapModeWeek},
now,
)
if len(vm.Roadmap.Lanes) != 2 {
t.Fatalf("expected one Sans étape lane per tablo, got %d", len(vm.Roadmap.Lanes))
}
}
```
- [ ] **Step 2: Run the focused tests to verify they fail**
Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'`
Expected: FAIL because the current view model has no `List` or `Roadmap` structures and still groups by tablo sections.
- [ ] **Step 3: Replace the old simple page model with dashboard view models**
Reshape `go-backend/internal/web/views/tasks_view.go` around a new top-level model:
```go
type TasksDashboardPageViewModel struct {
State taskmodel.DashboardPageState
Filters TasksDashboardFiltersView
Kanban TasksKanbanView
List TasksListView
Roadmap TasksRoadmapView
Form TasksFormOptionsView
HasTasks bool
}
```
Add focused sub-models:
```go
type TasksKanbanView struct {
Columns []TasksKanbanColumnView
}
type TasksListView struct {
Groups []TasksStatusGroupView
}
type TasksRoadmapView struct {
Mode string
Buckets []TasksRoadmapBucketView
Lanes []TasksRoadmapLaneView
}
```
- [ ] **Step 4: Add pure grouping builders for kanban, list, and roadmap**
Implement these builders in `go-backend/internal/web/views/tasks_view.go`:
```go
func buildKanbanView(records []taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, assigneeLabels map[uuid.UUID]string) TasksKanbanView
func buildListView(records []taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, assigneeLabels map[uuid.UUID]string) TasksListView
func buildRoadmapView(records []taskmodel.Record, tabloByID map[uuid.UUID]tablomodel.Record, assigneeLabels map[uuid.UUID]string, mode taskmodel.RoadmapMode, now time.Time) TasksRoadmapView
```
Use deterministic ordering:
```go
slices.SortFunc(records, func(a, b taskmodel.Record) int {
if diff := strings.Compare(string(a.Status), string(b.Status)); diff != 0 {
return diff
}
return a.CreatedAt.Compare(b.CreatedAt)
})
```
For roadmap lanes, use stable lane IDs:
```go
func roadmapLaneID(tabloID uuid.UUID, parentTaskID *uuid.UUID) string {
if parentTaskID == nil {
return tabloID.String() + ":sans-etape"
}
return tabloID.String() + ":" + parentTaskID.String()
}
```
- [ ] **Step 5: Re-run the focused tests to verify grouping passes**
Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'`
Expected: PASS for the new grouping tests, while handler rendering tests may still fail until the page shell is updated.
- [ ] **Step 6: Commit**
```bash
git add go-backend/internal/tasks/model.go go-backend/internal/web/views/tasks_view.go go-backend/internal/web/handlers/tasks_test.go
git commit -m "feat: add tasks dashboard grouping models"
```
## Task 3: Extend Create/Edit Flow With Tablo-Aware Options And Validation
**Files:**
- Modify: `go-backend/internal/web/handlers/tasks.go`
- Modify: `go-backend/internal/web/handlers/in_memory_auth_repository.go`
- Modify: `go-backend/internal/web/handlers/tasks_test.go`
- [ ] **Step 1: Write the failing mutation tests for cross-tablo validation**
Add tests to `go-backend/internal/web/handlers/tasks_test.go`:
```go
func TestPatchTaskRejectsParentEtapeFromAnotherTablo(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
userID, ok := handler.currentUserID(req.Context(), req)
if !ok {
t.Fatal("expected authenticated user")
}
tabloA := mustCreateOwnedTablo(t, repo, userID)
tabloB := mustCreateOwnedTablo(t, repo, userID)
task := mustCreateTask(t, repo, userID, tabloA.ID, nil, "Alpha task")
foreignEtape := mustCreateEtape(t, repo, userID, tabloB.ID, "Beta stage")
form := url.Values{}
form.Set("tablo_id", tabloA.ID.String())
form.Set("title", task.Title)
form.Set("status", string(taskmodel.StatusTodo))
form.Set("parent_task_id", foreignEtape.ID.String())
patchReq := httptest.NewRequest(http.MethodPatch, "/tasks/"+task.ID.String(), strings.NewReader(form.Encode()))
patchReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
patchReq.SetPathValue("taskID", task.ID.String())
patchReq.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.PatchTask().ServeHTTP(rec, patchReq)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422 for cross-tablo parent, got %d", rec.Code)
}
}
```
Add a create-form scoping test:
```go
func TestGetEditTaskModalShowsOnlyEtapesFromSelectedTablo(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
userID, ok := handler.currentUserID(req.Context(), req)
if !ok {
t.Fatal("expected authenticated user")
}
tabloA := mustCreateOwnedTablo(t, repo, userID)
tabloB := mustCreateOwnedTablo(t, repo, userID)
task := mustCreateTask(t, repo, userID, tabloA.ID, nil, "Editable")
etapeA := mustCreateEtape(t, repo, userID, tabloA.ID, "Stage A")
_ = mustCreateEtape(t, repo, userID, tabloB.ID, "Stage B")
editReq := httptest.NewRequest(http.MethodGet, "/tasks/"+task.ID.String()+"/edit", nil)
editReq.SetPathValue("taskID", task.ID.String())
editReq.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetEditTaskModal().ServeHTTP(rec, editReq)
body := rec.Body.String()
if !strings.Contains(body, etapeA.ID.String()) {
t.Fatalf("expected same-tablo etape in edit form, got %q", body)
}
if strings.Contains(body, "Stage B") {
t.Fatalf("did not expect foreign tablo etape in edit form, got %q", body)
}
}
```
- [ ] **Step 2: Run the focused tests to verify they fail**
Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'`
Expected: FAIL because the current create/edit flow neither requires `tablo_id` on update nor renders tablo-scoped étape options.
- [ ] **Step 3: Require `tablo_id` and load owner-scoped option data for forms**
Update `go-backend/internal/web/handlers/tasks.go` so create and edit forms both build a consistent options model:
```go
type taskFormOptions struct {
Tablos []tablomodel.Record
EtapesByTablo map[uuid.UUID][]taskmodel.Record
Assignees map[uuid.UUID]string
}
func (h *AuthHandler) loadTaskFormOptions(ctx context.Context, ownerID uuid.UUID) (taskFormOptions, error) {
tablos, err := h.repo.ListTablos(ctx, ListTablosInput{OwnerID: ownerID})
if err != nil {
return taskFormOptions{}, err
}
records, err := h.repo.(taskPageRepository).ListTasksByOwner(ctx, ownerID)
if err != nil {
return taskFormOptions{}, err
}
return buildTaskFormOptions(tablos, records, h.repo), nil
}
```
Make `parseUpdateTaskInput` require `tablo_id`:
```go
tabloID, err := uuid.Parse(strings.TrimSpace(r.FormValue("tablo_id")))
if err != nil {
return UpdateTaskInput{}, errors.New("tablo_id is required")
}
```
- [ ] **Step 4: Enforce selected tablo and selected étape consistency**
Add a dedicated validator in `go-backend/internal/web/handlers/tasks.go`:
```go
func validateTaskTabloAndParent(records []TaskRecord, tabloID uuid.UUID, parentTaskID *uuid.UUID) error {
if parentTaskID == nil {
return nil
}
for _, record := range records {
if record.ID != *parentTaskID {
continue
}
if !record.IsEtape {
return errors.New("parent_task_id must reference an étape")
}
if record.TabloID != tabloID {
return errors.New("parent_task_id must belong to the selected tablo")
}
return nil
}
return errors.New("parent_task_id must reference an active étape")
}
```
Call this from both create and patch handlers after parsing input and before repository mutation.
- [ ] **Step 5: Re-run the focused tests to verify the mutation flow passes**
Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'`
Expected: PASS for the new cross-tablo validation tests and edit-form scoping test.
- [ ] **Step 6: Commit**
```bash
git add go-backend/internal/web/handlers/tasks.go go-backend/internal/web/handlers/in_memory_auth_repository.go go-backend/internal/web/handlers/tasks_test.go
git commit -m "feat: add tablo-aware task form validation"
```
## Task 4: Replace The `/tasks` Page Rendering With Multi-View Dashboard UI
**Files:**
- Modify: `go-backend/internal/web/views/tasks_view.go`
- Modify: `go-backend/internal/web/handlers/tasks.go`
- Modify: `go-backend/internal/web/handlers/tasks_test.go`
- [ ] **Step 1: Write the failing rendering tests for view-specific content**
Add tests to `go-backend/internal/web/handlers/tasks_test.go`:
```go
func TestGetTasksPageRendersListViewAcrossTablos(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
userID, ok := handler.currentUserID(req.Context(), req)
if !ok {
t.Fatal("expected authenticated user")
}
tabloA := mustCreateOwnedTablo(t, repo, userID)
tabloB := mustCreateOwnedTablo(t, repo, userID)
_ = mustCreateTask(t, repo, userID, tabloA.ID, nil, "Alpha task")
_ = mustCreateTask(t, repo, userID, tabloB.ID, nil, "Beta task")
pageReq := httptest.NewRequest(http.MethodGet, "/tasks?view=list", nil)
pageReq.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTasksPage().ServeHTTP(rec, pageReq)
body := rec.Body.String()
for _, want := range []string{"Alpha task", "Beta task", "Liste", "À faire"} {
if !strings.Contains(body, want) {
t.Fatalf("expected body to contain %q, got %q", want, body)
}
}
}
func TestGetTasksPageRendersRoadmapWeekMode(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
userID, ok := handler.currentUserID(req.Context(), req)
if !ok {
t.Fatal("expected authenticated user")
}
tablo := mustCreateOwnedTablo(t, repo, userID)
etape := mustCreateEtape(t, repo, userID, tablo.ID, "Préparation")
task := mustCreateTask(t, repo, userID, tablo.ID, &etape.ID, "Planifier")
due := time.Date(2026, 5, 12, 0, 0, 0, 0, time.UTC)
_, _ = repo.UpdateTask(context.Background(), UpdateTaskInput{
ID: task.ID, OwnerID: userID, TabloID: tablo.ID, Title: task.Title, Description: task.Description,
Status: task.Status, DueDate: &due, ParentTaskID: &etape.ID,
})
pageReq := httptest.NewRequest(http.MethodGet, "/tasks?view=roadmap&roadmap_mode=week", nil)
pageReq.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.GetTasksPage().ServeHTTP(rec, pageReq)
body := rec.Body.String()
for _, want := range []string{"Roadmap", "Préparation", "Planifier", "Semaine"} {
if !strings.Contains(body, want) {
t.Fatalf("expected body to contain %q, got %q", want, body)
}
}
}
```
- [ ] **Step 2: Run the focused tests to verify they fail**
Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'`
Expected: FAIL because the current page still renders simple tablo sections and has no list/roadmap UI.
- [ ] **Step 3: Build a shared dashboard page shell**
Replace `TasksPageContent` in `go-backend/internal/web/views/tasks_view.go` with a richer shell:
```go
func TasksPageContent(vm TasksDashboardPageViewModel) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
_, _ = io.WriteString(w, `<div class="app-section-page tasks-page" data-current-view="`+html.EscapeString(string(vm.State.View))+`">`)
_, _ = io.WriteString(w, `<div class="app-section-surface"><div class="tasks-page-header">`)
_, _ = io.WriteString(w, `<h2>Mes tâches</h2>`)
_, _ = io.WriteString(w, `<a class="button" href="/tasks?view=`+html.EscapeString(string(vm.State.View))+`">Nouvelle tâche</a>`)
_, _ = io.WriteString(w, `</div>`)
_, _ = io.WriteString(w, renderTasksViewTabs(vm.State))
_, _ = io.WriteString(w, renderTasksFilters(vm.Filters, vm.State))
_, _ = io.WriteString(w, `</div>`)
return renderCurrentTasksView(w, vm)
})
}
```
Add explicit renderers:
```go
func renderCurrentTasksView(w io.Writer, vm TasksDashboardPageViewModel) error
func renderTasksViewTabs(state taskmodel.DashboardPageState) string
func renderTasksFilters(filters TasksDashboardFiltersView, state taskmodel.DashboardPageState) string
```
- [ ] **Step 4: Implement the view-specific renderers**
Add one renderer per mode:
```go
func renderKanbanView(w io.Writer, view TasksKanbanView) error
func renderListView(w io.Writer, view TasksListView) error
func renderRoadmapView(w io.Writer, view TasksRoadmapView) error
```
Use obvious metadata in task cards/rows:
```go
type TaskCardView struct {
ID string
Title string
TabloName string
EtapeName string
Assignee string
DueDate string
StatusLabel string
}
```
For roadmap bucket labels, compute from `now`:
```go
func roadmapBucketLabelWeek(start time.Time) string {
return start.Format("02 Jan")
}
func roadmapBucketLabelMonth(start time.Time) string {
return start.Format("Jan 2006")
}
```
- [ ] **Step 5: Re-run the focused tests to verify all render modes pass**
Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'`
Expected: PASS for default, list, and roadmap rendering tests.
- [ ] **Step 6: Commit**
```bash
git add go-backend/internal/web/views/tasks_view.go go-backend/internal/web/handlers/tasks.go go-backend/internal/web/handlers/tasks_test.go
git commit -m "feat: render tasks multi-view dashboard"
```
## Task 5: Add Router-Level Coverage And Final Verification
**Files:**
- Modify: `go-backend/router_test.go`
- Modify: `go-backend/internal/web/handlers/tasks_test.go`
- [ ] **Step 1: Write the failing router tests for query-param-driven `/tasks`**
Add router-level tests to `go-backend/router_test.go`:
```go
func TestRouterServesTasksListView(t *testing.T) {
router := newTestRouter()
req := httptest.NewRequest(http.MethodGet, "/tasks?view=list", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther && rec.Code != http.StatusOK {
t.Fatalf("expected login redirect or page response, got %d", rec.Code)
}
}
```
If your existing router tests already log in first, use that pattern instead and assert the response contains `Liste`.
Add a mutation-state preservation test in handler tests:
```go
func TestPatchTaskRendersCurrentDashboardQueryState(t *testing.T) {
repo := NewInMemoryAuthRepository()
handler := NewAuthHandler(repo)
sessionCookie := loginTestUser(t, handler, "demo@xtablo.com", "xtablo-demo")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
userID, ok := handler.currentUserID(req.Context(), req)
if !ok {
t.Fatal("expected authenticated user")
}
tablo := mustCreateOwnedTablo(t, repo, userID)
task := mustCreateTask(t, repo, userID, tablo.ID, nil, "Stateful")
form := url.Values{}
form.Set("tablo_id", tablo.ID.String())
form.Set("title", "Stateful")
form.Set("status", string(taskmodel.StatusDone))
patchReq := httptest.NewRequest(http.MethodPatch, "/tasks/"+task.ID.String()+"?view=list&status=done", strings.NewReader(form.Encode()))
patchReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
patchReq.SetPathValue("taskID", task.ID.String())
patchReq.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
handler.PatchTask().ServeHTTP(rec, patchReq)
body := rec.Body.String()
if !strings.Contains(body, `data-current-view="list"`) {
t.Fatalf("expected response to preserve current list view, got %q", body)
}
}
```
- [ ] **Step 2: Run the focused tests to verify they fail**
Run: `cd go-backend && go test ./internal/web/handlers -run 'Tasks|Dashboard'`
Expected: FAIL if mutation responses still drop back to default state or router-level coverage is missing.
- [ ] **Step 3: Preserve query state through mutation responses**
Make `renderTasksPage` fully query-driven so post-mutation handlers reuse the request URL:
```go
func (h *AuthHandler) renderTasksPage(w http.ResponseWriter, r *http.Request) {
state := parseDashboardPageState(r)
// load tablos, tasks, assignees
vm := views.NewTasksDashboardPageViewModel(tablos, filteredTasks, assigneeLabels, state, time.Now().UTC())
// existing DashboardPage / DashboardContentSwap render path
}
```
Ensure `PostTasks`, `PatchTask`, and `DeleteTask` call `h.renderTasksPage(w, r)` without rebuilding a new request so the current query state survives.
- [ ] **Step 4: Run the full verification suite**
Run: `cd go-backend && go test ./...`
Expected: PASS across handlers, router, and existing task tests.
Run: `cd go-backend && just build`
Expected: PASS with a successful Go build for the full `go-backend`.
- [ ] **Step 5: Commit**
```bash
git add go-backend/internal/web/handlers/tasks.go go-backend/internal/web/handlers/tasks_test.go go-backend/router_test.go
git commit -m "test: verify tasks dashboard routing and state preservation"
```
## Self-Review
**Spec coverage**
- Cross-tablo aggregation is covered in Task 2 and Task 4.
- Query-param-driven `view` and `roadmap_mode` are covered in Task 1 and Task 5.
- `tablo`, `assignee`, and `status` filters are covered in Task 1, Task 2, and Task 4.
- `kanban`, `list`, and `roadmap` renderers are covered in Task 2 and Task 4.
- `week` and `month` roadmap modes are covered in Task 2 and Task 4.
- `tablo_id` plus tablo-scoped étape validation in create/edit flows are covered in Task 3.
- Current-state-preserving mutation responses are covered in Task 5.
**Placeholder scan**
- No `TODO`, `TBD`, or "implement later" placeholders remain.
- Every code-changing step includes explicit code targets and representative code blocks.
- Every verification step includes an exact command and expected result.
**Type consistency**
- View-state types use `DashboardView`, `RoadmapMode`, and `DashboardPageState` consistently.
- Renderer naming is consistent across `buildKanbanView`, `buildListView`, `buildRoadmapView`, and `renderKanbanView`, `renderListView`, `renderRoadmapView`.
- Mutation validation consistently uses `tablo_id` and `parent_task_id`.