docs(04): research phase tasks-kanban domain

This commit is contained in:
Arthur Belleville 2026-05-15 08:51:06 +02:00
parent 338e7e6e92
commit 1c7b9d632c
No known key found for this signature in database

View file

@ -0,0 +1,775 @@
# Phase 4: Tasks (Kanban) - Research
**Researched:** 2026-05-15
**Domain:** Go HTMX Kanban — Postgres ENUM ordering, Sortable.js drag-drop, templ fragments
**Confidence:** HIGH
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
**Column Architecture**
- D-01: Fixed 4 columns: `todo`, `in_progress`, `in_review`, `done` — hardcoded as Go constants, no `task_columns` table. No column CRUD routes in this phase.
- D-02: Column stored on each task row as a Postgres ENUM type: `CREATE TYPE task_status AS ENUM ('todo','in_progress','in_review','done')`. sqlc generates a Go type; invalid values rejected at DB layer.
- D-03: Hard-delete only — no `deleted_at` column on tasks. Inline confirmation before firing (reuse D-07 pattern from Phase 3).
**Task Ordering**
- D-04: `position INTEGER NOT NULL` on each task row. First task = 100, gap of 100. Midpoint insertion between two tasks. Rebalance when adjacent gap < 2 (reassign multiples of 100 for that column). O(n) rebalance acceptable.
- D-05: Last-write-wins for concurrent edits — documented in code, no optimistic locking.
**Move/Reorder Interaction**
- D-06: Drag-and-drop via Sortable.js (standalone ~30KB, not a JS framework).
- D-07: Sortable fires `end` event. Inline `<script>` (~5 lines) reads new task order from DOM and fires `hx-post` to `/tablos/{id}/tasks/reorder`. Server reorders affected tasks, returns updated column fragment(s).
**Task Detail UX**
- D-08: Task editing uses inline expand in the kanban card. Clicking swaps card to editable form via HTMX. On save (`POST /tablos/{tablo_id}/tasks/{task_id}`), form swaps back to display card. On cancel, restores original without server round-trip.
- D-09: Task creation uses inline form at the bottom of each column. `+ Add task` renders a small form (title only) via HTMX. On submit, new card appends to column and form resets.
- D-10: Kanban board embedded directly in tablo detail page (`GET /tablos/{id}`). No tabs yet.
### Claude's Discretion
- Exact Tailwind styling for task cards (compact vs spacious, shadow depth) — consistent with Phase 3's Card component aesthetic.
- Column header visual treatment (background color, count badge).
- Whether Sortable.js drag handle is full card or dedicated grip icon.
- HTTP verb for task create/edit: `POST /tablos/{id}/tasks` and `POST /tablos/{id}/tasks/{task_id}`.
- Whether rebalancing happens synchronously in same transaction as reorder (sync is simplest).
- Exact threshold for triggering rebalance (e.g. when any adjacent gap < 2).
### Deferred Ideas (OUT OF SCOPE)
- Configurable columns — user-defined column names, `task_columns` table with CRUD.
- Tabs on tablo detail — tab navigation deferred until Phase 5 adds the files section.
- Task due dates, assignees, labels, comments.
- Rich-text task descriptions — plain text only in v1.
</user_constraints>
---
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| TASK-01 | Each tablo has a kanban board with named columns | D-01/D-02 locked: 4 fixed columns as Postgres ENUM. sqlc generates `TaskStatus` type. |
| TASK-02 | User can create a task inside a column with a title | D-09: inline form at bottom of column, `POST /tablos/{id}/tasks`. sqlc `InsertTask` query needed. |
| TASK-03 | User can edit a task's title and description | D-08: inline expand edit in card, `POST /tablos/{id}/tasks/{task_id}`. Same HTMX outerHTML swap pattern as Phase 3. |
| TASK-04 | User can move a task between columns | D-06/D-07: Sortable.js group="kanban" enables cross-column drag. `POST /tablos/{id}/tasks/reorder` handles both column change + position. |
| TASK-05 | User can reorder tasks within a column | D-06/D-07: same Sortable + reorder endpoint, within-column reorder. |
| TASK-06 | User can delete a task | D-03: hard-delete, `POST /tablos/{id}/tasks/{task_id}/delete` with inline confirmation fragment (mirrors Phase 3 D-07). |
| TASK-07 | Task ordering persists across refreshes | D-04: `position` written to DB on every reorder — no client-side-only state. |
</phase_requirements>
---
## Summary
Phase 4 adds a kanban board to the tablo detail page. All key architectural decisions are locked: four fixed columns stored as a Postgres ENUM (`task_status`), integer position ordering with gap-of-100 and midpoint insertion, and Sortable.js as the drag-and-drop engine with HTMX form submission on the `end` event.
The implementation closely mirrors Phase 3's handler and template patterns. The `tasks` domain follows the same structure as `tablos`: a `TasksDeps` struct, sqlc queries in `internal/db/queries/tasks.sql`, templ components in `backend/templates/tasks.templ`, and routes nested under `/tablos/{id}/tasks*` inside the existing `RequireAuth` chi group. The `backend/internal/tasks/` package is a placeholder from Phase 1 waiting for implementation.
The most novel piece is the reorder endpoint. Sortable.js in group mode handles both within-column reorder and cross-column moves via a single `POST /tablos/{id}/tasks/reorder` that accepts a form-encoded list of `task_id` + `new_status` + `new_position` tuples. CSRF is handled by including the `_csrf` hidden field in each column's hidden form element — Sortable's `end` event triggers HTMX, which POSTs the form including the CSRF token naturally.
**Primary recommendation:** Follow the Phase 3 handler/template/sqlc pattern exactly. Add Sortable.js as a local static asset (matching the HTMX no-CDN policy D-10). Use form-encoded arrays for the reorder payload to avoid JSON body + gorilla/csrf header complexity.
---
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Task CRUD (create/edit/delete) | API/Backend (Go handler) | Browser (HTMX forms) | Server validates ownership, writes DB, returns fragments |
| Task ordering / rebalance | API/Backend (Go handler + Postgres) | — | Position integers must be written atomically; rebalance is a DB concern |
| Column assignment | API/Backend (Go handler) | — | `task_status` ENUM validated at DB layer, owned by server |
| Drag-and-drop UI | Browser (Sortable.js) | Frontend Server (HTMX swap) | Sortable reorders DOM; HTMX sends new order to server; server persists and returns updated fragment |
| Kanban board rendering | Frontend Server (templ/Go) | — | Full column HTML rendered server-side, injected into tablo detail page |
| Authorization (ownership) | API/Backend | — | `loadOwnedTablo` guard + `user_id` filter on all task queries |
---
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Go (stdlib) | 1.26.1 | HTTP handlers, JSON decoding | Already locked by project |
| templ | v0.3.1020 | HTML templates with type safety | Established in Phase 1 [VERIFIED: go.mod] |
| sqlc | v1.31.1 | Type-safe SQL query generation | Established in Phase 1 [VERIFIED: justfile] |
| pgx/v5 | v5.9.2 | Postgres driver | Established in Phase 1 [VERIFIED: go.mod] |
| chi v5 | v5.2.5 | HTTP router | Established in Phase 1 [VERIFIED: go.mod] |
| gorilla/csrf | v1.7.3 | CSRF protection | Established in Phase 2 [VERIFIED: go.mod] |
| Sortable.js | 1.15.7 | Drag-and-drop reordering | Standalone, no framework dependency [VERIFIED: npm registry] |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| goose v3 | v3.27.1 | DB migrations | Add `0004_tasks.sql` migration [VERIFIED: justfile] |
| google/uuid | v1.6.0 | UUID parsing for route params | Already used in tablo handlers [VERIFIED: go.mod] |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Sortable.js form-encoded array | JSON body with `hx-vals` | Form encoding is simpler: hidden inputs reorder with DOM, no JS serialization needed; JSON body requires `X-CSRF-Token` header dance [VERIFIED: htmx.org/examples/sortable/] |
| INTEGER position (gap-of-100) | Fractional index (string/float) | Integers are simpler in sqlc + Postgres; rebalance is O(n) but acceptable at v1 task counts [ASSUMED] |
**Installation (Sortable.js — add to justfile `bootstrap` recipe):**
```bash
# Add sortable_version variable
sortable_version := "1.15.7"
# Add to bootstrap recipe:
curl -sSL -o static/sortable.min.js \
"https://cdn.jsdelivr.net/npm/sortablejs@{{ sortable_version }}/Sortable.min.js"
```
**Version verification:** Sortable.js 1.15.7 confirmed current as of 2026-05-15 [VERIFIED: npm registry].
---
## Architecture Patterns
### System Architecture Diagram
```
Browser
├─ GET /tablos/{id}
│ └─> Go handler: TabloDetailHandler
│ ├─ loadOwnedTablo (auth + ownership)
│ ├─ ListTasksByTablo (all 4 columns in one query, sorted by position)
│ └─> TabloDetailPage(tablo, tasks, csrfToken)
│ └─> KanbanBoard(columns, tasks grouped by status, csrfToken)
│ └─> KanbanColumn × 4 (todo / in_progress / in_review / done)
│ ├─ TaskCard × N (display state)
│ └─ AddTaskForm (inline at bottom)
├─ POST /tablos/{id}/tasks (create)
│ └─> TaskCreateHandler → INSERT task → append TaskCard to column + clear form
├─ GET /tablos/{id}/tasks/{tid}/edit (swap card to edit form)
├─ POST /tablos/{id}/tasks/{tid} (save edit → swap back to display card)
├─ GET /tablos/{id}/tasks/{tid}/delete-confirm (inline confirmation fragment)
├─ POST /tablos/{id}/tasks/{tid}/delete (hard delete → remove card via OOB/swap)
└─ POST /tablos/{id}/tasks/reorder (Sortable.js end event → update positions + column)
└─> bulk UPDATE tasks SET position=?, status=? WHERE id=? AND tablo_id=?
└─> returns updated KanbanColumn fragment(s) as OOB swaps
```
### Recommended Project Structure
```
backend/
├── migrations/
│ └── 0004_tasks.sql # task_status ENUM + tasks table
├── internal/
│ ├── db/
│ │ ├── queries/
│ │ │ └── tasks.sql # sqlc query source
│ │ └── sqlc/ # generated: task.go, task_sql.go
│ ├── tasks/
│ │ └── doc.go # placeholder → Phase 4 lands here (optional: move logic here)
│ └── web/
│ ├── handlers_tasks.go # TasksDeps, all task HTTP handlers
│ ├── handlers_tasks_test.go # integration tests (Wave 0 scaffold)
│ └── router.go # add task routes inside RequireAuth group
├── templates/
│ ├── tasks.templ # KanbanBoard, KanbanColumn, TaskCard, TaskEditFragment,
│ │ # TaskCreateForm, TaskDeleteConfirmFragment
│ └── tasks_forms.go # TaskCreateForm, TaskCreateErrors, TaskUpdateErrors structs
└── static/
└── sortable.min.js # pinned 1.15.7 — add to bootstrap + clean recipes
```
### Pattern 1: Postgres ENUM + sqlc Go Type Generation
**What:** `CREATE TYPE task_status AS ENUM (...)` in migration. sqlc generates a Go string-alias type with constants.
**When to use:** All column assignment — DB rejects invalid status values at insertion.
Migration:
```sql
-- 0004_tasks.sql
-- +goose Up
CREATE TYPE task_status AS ENUM ('todo', 'in_progress', 'in_review', 'done');
CREATE TABLE tasks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tablo_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE,
title text NOT NULL,
description text,
status task_status NOT NULL DEFAULT 'todo',
position integer NOT NULL DEFAULT 100,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX tasks_tablo_id_status_idx ON tasks(tablo_id, status, position);
-- +goose Down
DROP TABLE IF EXISTS tasks;
DROP TYPE IF EXISTS task_status;
```
sqlc generates (after `sqlc generate`) [VERIFIED: docs.sqlc.dev]:
```go
// backend/internal/db/sqlc/models.go (generated)
type TaskStatus string
const (
TaskStatusTodo TaskStatus = "todo"
TaskStatusInProgress TaskStatus = "in_progress"
TaskStatusInReview TaskStatus = "in_review"
TaskStatusDone TaskStatus = "done"
)
type Task struct {
ID uuid.UUID
TabloID uuid.UUID
Title string
Description pgtype.Text
Status TaskStatus
Position int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
```
### Pattern 2: Gap-of-100 Position Ordering with Midpoint Insertion
**What:** Tasks are given `position` values 100, 200, 300, ... Inserting between position 100 and 200 assigns position 150. When gap falls below threshold (< 2), rebalance the entire column.
**When to use:** All task create and reorder operations.
```go
// Source: D-04 decision + standard gap-list pattern [ASSUMED: implementation shape]
// nextPosition returns the position for a new task appended to a column.
// If the column is empty, returns 100. Otherwise returns (max_position + 100).
func nextPosition(tasks []sqlc.Task) int32 {
if len(tasks) == 0 {
return 100
}
return tasks[len(tasks)-1].Position + 100
}
// midpoint returns the integer midpoint between two adjacent positions.
// Returns (a+b)/2. Caller should check if result == a or == b (gap < 2)
// and trigger rebalance if so.
func midpoint(a, b int32) int32 {
return (a + b) / 2
}
// needsRebalance returns true when any two adjacent positions in the sorted
// list differ by less than the threshold.
const rebalanceThreshold = 2
func needsRebalance(positions []int32) bool {
for i := 1; i < len(positions); i++ {
if positions[i]-positions[i-1] < rebalanceThreshold {
return true
}
}
return false
}
```
### Pattern 3: Sortable.js + HTMX Form-Encoded Reorder
**What:** Each kanban column is a `<form>` with hidden inputs for task IDs and a `hx-trigger="end"` that fires when Sortable drops. The form also carries the column status so the server knows the new column for moved tasks.
**When to use:** Both within-column reorder (TASK-05) and cross-column move (TASK-04).
```html
<!-- Source: htmx.org/examples/sortable/ pattern adapted for multi-column kanban [CITED: https://htmx.org/examples/sortable/] -->
<!-- Each column is a Sortable container -->
<div
class="kanban-column sortable-column"
data-status="in_progress"
id="column-in_progress"
>
<!-- Task cards with hidden inputs -->
<div class="task-card" data-task-id="{{ task.ID }}">
<input type="hidden" name="task_id" value="{{ task.ID }}"/>
<input type="hidden" name="task_status" value="{{ task.Status }}"/>
<!-- card content -->
</div>
</div>
```
```html
<!-- Single hidden reorder form — shared across all columns -->
<form
id="reorder-form"
method="POST"
action="/tablos/{{ tablo.ID }}/tasks/reorder"
hx-post="/tablos/{{ tablo.ID }}/tasks/reorder"
hx-target="#kanban-board"
hx-swap="outerHTML"
>
@ui.CSRFField(csrfToken)
<!-- Hidden inputs populated by inline script on Sortable end event -->
</form>
```
```html
<!-- Inline script (~5 lines) that bridges Sortable end event to HTMX [CITED: htmx.org/examples/sortable/] -->
<script>
htmx.onLoad(function(content) {
document.querySelectorAll(".sortable-column").forEach(function(col) {
new Sortable(col, {
group: "kanban",
animation: 150,
handle: ".task-drag-handle",
ghostClass: "bg-slate-100",
onEnd: function(evt) {
// Collect all task IDs and their new column from current DOM order
var form = document.getElementById("reorder-form");
// Clear previous hidden inputs
form.querySelectorAll("input[name=task_id],input[name=task_col]").forEach(function(el) { el.remove(); });
document.querySelectorAll(".sortable-column").forEach(function(col) {
var status = col.dataset.status;
col.querySelectorAll("[data-task-id]").forEach(function(card, idx) {
var id = document.createElement("input");
id.type = "hidden"; id.name = "task_id"; id.value = card.dataset.taskId;
form.appendChild(id);
var st = document.createElement("input");
st.type = "hidden"; st.name = "task_col"; st.value = status;
form.appendChild(st);
});
});
htmx.trigger(form, "submit");
}
});
});
});
</script>
```
**Server-side:** Handler reads `r.Form["task_id"]` (ordered array) and `r.Form["task_col"]` (same length). Position is computed as index * 100 for simplicity; a rebalance pass runs if needed.
**CSRF note:** The shared form includes `@ui.CSRFField(csrfToken)`. Because the script triggers a normal form `submit` (not a custom HTMX event), gorilla/csrf reads the `_csrf` field from `r.PostFormValue("_csrf")` as usual. No `X-CSRF-Token` header gymnastics needed.
### Pattern 4: Task Handler Structure (mirrors TablosDeps)
**What:** `TasksDeps` struct, handler constructor functions returning `http.HandlerFunc`.
**When to use:** All task handlers.
```go
// Source: handlers_tablos.go pattern [VERIFIED: codebase]
type TasksDeps struct {
Queries *sqlc.Queries
}
// loadOwnedTabloForTask is a shared preamble: parses tablo UUID, verifies
// ownership via user_id filter (same as loadOwnedTablo in handlers_tablos.go),
// parses task UUID from URL, fetches task verifying tablo_id match.
func loadOwnedTabloForTask(w http.ResponseWriter, r *http.Request, deps TasksDeps) (sqlc.Tablo, sqlc.Task, *auth.User, bool) {
// ... mirrors loadOwnedTablo with extra task fetch
}
```
### Pattern 5: HTMX Fragment Response for Task Operations
**What:** Reuse Phase 3's `HX-Request` detection. HTMX path returns 200 + HTML fragment; non-HTMX path returns 303 redirect.
```go
// Source: handlers_tablos.go — verbatim pattern [VERIFIED: codebase]
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.TaskCard(task, csrfToken).Render(ctx, w)
return
}
http.Redirect(w, r, "/tablos/"+tabloID.String(), http.StatusSeeOther)
```
**Delete success (HTMX):** Use `HX-Reswap: delete` with the card as target, OR use OOB swap to empty the card element. Simplest: return a 200 with an OOB swap that removes the card `<div>`.
### Anti-Patterns to Avoid
- **CDN script tags in templates:** Sortable.js must be downloaded to `static/sortable.min.js` via `just bootstrap`, consistent with HTMX no-CDN rule (D-10). Never `<script src="https://cdn.jsdelivr.net/...">` in template HTML.
- **JSON body for reorder endpoint:** gorilla/csrf reads CSRF token from `r.PostFormValue("_csrf")`, which requires `application/x-www-form-urlencoded` or `multipart/form-data`. JSON body requests need the `X-CSRF-Token` header instead — more complex, not needed here. Use form-encoded arrays.
- **Parametric route before static:** `/tablos/{id}/tasks/reorder` MUST be declared before `/tablos/{id}/tasks/{task_id}` in chi router (Pitfall 1 from Phase 3 context).
- **Reading form body with `r.FormValue` after gorilla/csrf:** gorilla/csrf middleware consumes `r.Body`. Always call `r.ParseForm()` first (implicitly done by `r.PostFormValue`). Never manually call `io.ReadAll(r.Body)` after the CSRF middleware has run.
- **Forgetting `templ generate`:** After editing any `.templ` file, `templ generate` must run to regenerate `*_templ.go`. The `just generate` recipe does this.
- **DROP TYPE in wrong order:** In goose Down migration, `DROP TABLE tasks` must come before `DROP TYPE task_status` because the table depends on the type.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Drag-and-drop with touch support | Custom mouse/touch event handlers | Sortable.js 1.15.7 | Touch events are complex; Sortable handles iOS/Android, ghost rendering, animation, and multi-list group semantics [VERIFIED: github.com/SortableJS/Sortable] |
| CSRF token for AJAX | Custom token generation/validation | gorilla/csrf already wired (D-14) | `csrf.RequestHeader("X-CSRF-Token")` is already configured in `auth/csrf.go`; re-use the existing `_csrf` form field pattern |
| Position rebalancing algorithm | Custom tree/linked-list structures | Simple O(n) gap renumber | At v1 task counts (< 100 per column), a full-column renumber is fast and simple; fractional indices add complexity with no benefit |
| UUID parsing in routes | Manual regex | `github.com/google/uuid` + `chi.URLParam` | Already used in tablo handlers; consistent [VERIFIED: codebase] |
**Key insight:** Sortable.js's group mode handles the hardest part of kanban (cross-list drag with animation) in ~3 lines of JavaScript configuration. The server only needs to persist the resulting order — it never needs to reason about drag physics.
---
## Common Pitfalls
### Pitfall 1: Chi Route Registration Order
**What goes wrong:** `GET /tablos/{id}/tasks/{task_id}` catches requests to `/tablos/{id}/tasks/reorder` and `/tablos/{id}/tasks/new`, returning 404 or wrong handler.
**Why it happens:** chi v5 matches parametric segments greedily when static routes are declared after them.
**How to avoid:** Declare static sub-segments BEFORE parametric: `r.Post("/tablos/{id}/tasks/reorder", ...)` before `r.Get("/tablos/{id}/tasks/{task_id}", ...)`.
**Warning signs:** 404 on `/tasks/reorder` or handler receives `task_id = "reorder"`.
### Pitfall 2: Sortable.js Re-initialization After HTMX Swap
**What goes wrong:** After the first reorder triggers an HTMX swap that replaces the kanban board, Sortable.js is no longer attached to the new DOM elements. Subsequent drags do nothing.
**Why it happens:** HTMX replaces the DOM node. The Sortable instance was bound to the old node.
**How to avoid:** Use `htmx.onLoad(function(content) { ... })` to initialize Sortable — this fires both on initial page load AND after every HTMX swap. Alternatively, listen for `htmx:afterSwap` on the board container. [CITED: https://htmx.org/examples/sortable/]
**Warning signs:** Drag works on first use but fails after any HTMX response.
### Pitfall 3: goose Migration — DROP TYPE Order
**What goes wrong:** `0004_tasks.sql` Down migration fails with `cannot drop type task_status because other objects depend on it`.
**Why it happens:** `tasks` table has a column of type `task_status`. You must drop the table before the type.
**How to avoid:** Always order Down migrations: `DROP TABLE IF EXISTS tasks;` then `DROP TYPE IF EXISTS task_status;`.
**Warning signs:** `just migrate down` fails on phase 4 migration.
### Pitfall 4: CSRF Failure on Reorder Endpoint
**What goes wrong:** `POST /tablos/{id}/tasks/reorder` returns 403 Forbidden from gorilla/csrf.
**Why it happens:** The reorder form's hidden `_csrf` field is populated from the page's CSRF token but may be empty if the script doesn't find the field, or the form is submitted before the CSRF token renders.
**How to avoid:** Include `@ui.CSRFField(csrfToken)` in the shared reorder form (rendered server-side, so token is always present). Verify the `_csrf` input is present in the form before triggering submit in the Sortable `onEnd` handler.
**Warning signs:** Console shows 403; gorilla/csrf logs "CSRF token not found in form".
### Pitfall 5: Position Collision on Concurrent Create
**What goes wrong:** Two users create tasks in the same column at the same time, both get `position = max+100` — identical positions. Ordering is non-deterministic on next fetch.
**Why it happens:** `max(position) + 100` is computed in application code (not a DB sequence), so two concurrent readers get the same max.
**How to avoid:** For v1 (D-05 last-write-wins), this is acceptable. Document it. Use `ORDER BY position, created_at` as a tiebreaker in the sqlc query so ordering is stable even when two tasks share a position.
**Warning signs:** Two tasks in the same column appear to swap positions on refresh.
### Pitfall 6: `task_status` ENUM Ordering in Query Results
**What goes wrong:** Fetching all tasks for a tablo with `ORDER BY status` produces lexicographic ENUM order (`done`, `in_progress`, `in_review`, `todo`) rather than the visual column order.
**Why it happens:** Postgres ENUMs sort in declaration order when compared directly, but sqlc generates a string alias — verify the migration ENUM declaration order matches the visual left-to-right order.
**How to avoid:** Declare the ENUM in visual order: `('todo','in_progress','in_review','done')`. Then `ORDER BY status, position` will produce the correct column grouping. Group tasks by status in the Go template, not by relying on DB sort.
**Warning signs:** Column order appears scrambled on first render.
### Pitfall 7: Sortable.js and Non-Draggable Children
**What goes wrong:** The inline `+ Add task` form at the bottom of each column gets dragged around with the tasks.
**Why it happens:** Sortable makes all direct children draggable by default.
**How to avoid:** Use `draggable: ".task-card"` in Sortable options to restrict which children are sortable. The `+ Add task` form should not have the `.task-card` class.
**Warning signs:** Users can accidentally drag the create form between columns.
---
## Code Examples
### sqlc Query: List Tasks for a Tablo
```sql
-- Source: codebase pattern from tablos.sql [VERIFIED: codebase]
-- name: ListTasksByTablo :many
SELECT id, tablo_id, title, description, status, position, created_at, updated_at
FROM tasks
WHERE tablo_id = $1
ORDER BY status, position, created_at;
-- name: InsertTask :one
INSERT INTO tasks (tablo_id, title, description, status, position)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, tablo_id, title, description, status, position, created_at, updated_at;
-- name: GetTaskByID :one
SELECT id, tablo_id, title, description, status, position, created_at, updated_at
FROM tasks
WHERE id = $1 AND tablo_id = $2;
-- name: UpdateTask :one
UPDATE tasks
SET title = $2, description = $3, status = $4, position = $5, updated_at = now()
WHERE id = $1
RETURNING id, tablo_id, title, description, status, position, created_at, updated_at;
-- name: DeleteTask :exec
DELETE FROM tasks WHERE id = $1 AND tablo_id = $2;
-- name: ListTasksByTabloAndStatus :many
SELECT id, tablo_id, title, description, status, position, created_at, updated_at
FROM tasks
WHERE tablo_id = $1 AND status = $2
ORDER BY position, created_at;
-- name: MaxPositionByTabloAndStatus :one
SELECT COALESCE(MAX(position), 0)::integer AS max_position
FROM tasks
WHERE tablo_id = $1 AND status = $2;
```
### Go: Task Status Constants (D-01 hardcoded columns)
```go
// Source: D-01 decision — hardcoded in Go alongside the sqlc-generated type [ASSUMED: file location]
// backend/internal/web/handlers_tasks.go
// TaskColumns is the ordered list of columns for the kanban board.
// Order determines left-to-right visual rendering.
var TaskColumns = []sqlc.TaskStatus{
sqlc.TaskStatusTodo,
sqlc.TaskStatusInProgress,
sqlc.TaskStatusInReview,
sqlc.TaskStatusDone,
}
// TaskColumnLabels maps status to human-readable column header labels.
var TaskColumnLabels = map[sqlc.TaskStatus]string{
sqlc.TaskStatusTodo: "To do",
sqlc.TaskStatusInProgress: "In progress",
sqlc.TaskStatusInReview: "In review",
sqlc.TaskStatusDone: "Done",
}
```
### Go: Reorder Handler Form Parsing
```go
// Source: D-07 decision + Go standard form parsing [ASSUMED: exact implementation shape]
// Reads form-encoded arrays: task_id[]=uuid1&task_col[]=todo&task_id[]=uuid2&task_col[]=in_progress
func TaskReorderHandler(deps TasksDeps) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tablo, _, ok := loadOwnedTablo(w, r, deps.TabloQueries)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
taskIDs := r.Form["task_id"] // ordered array from form
taskCols := r.Form["task_col"] // parallel array of new statuses
if len(taskIDs) != len(taskCols) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Build update params: position = (index+1) * 100
ctx := r.Context()
for i, rawID := range taskIDs {
taskID, err := uuid.Parse(rawID)
if err != nil {
continue // skip invalid UUIDs silently (last-write-wins)
}
newPos := int32((i + 1) * 100)
newStatus := sqlc.TaskStatus(taskCols[i]) // DB rejects invalid values
if err := deps.Queries.UpdateTask(ctx, sqlc.UpdateTaskParams{
ID: taskID,
TabloID: tablo.ID,
Status: newStatus,
Position: newPos,
}); err != nil {
slog.Default().Error("tasks reorder: update failed", "task_id", taskID, "err", err)
}
}
// Return updated kanban board fragment
tasks, _ := deps.Queries.ListTasksByTablo(ctx, tablo.ID)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.KanbanBoard(tablo.ID, csrf.Token(r), tasks).Render(ctx, w)
}
}
```
### templ: KanbanBoard and KanbanColumn Structure
```go
// Source: D-10 + Phase 3 templ patterns [ASSUMED: exact templ syntax]
templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task) {
// Group tasks by status
{{ tasksByStatus := groupTasksByStatus(tasks) }}
<div id="kanban-board" class="flex gap-4 overflow-x-auto pb-4">
// Hidden form for reorder — CSRF token always present
<form id="reorder-form" method="POST"
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/tasks/reorder") }
hx-post={ "/tablos/" + tabloID.String() + "/tasks/reorder" }
hx-target="#kanban-board"
hx-swap="outerHTML"
class="hidden">
@ui.CSRFField(csrfToken)
</form>
for _, status := range TaskColumns {
@KanbanColumn(tabloID, status, tasksByStatus[status], csrfToken)
}
</div>
}
templ KanbanColumn(tabloID uuid.UUID, status sqlc.TaskStatus, tasks []sqlc.Task, csrfToken string) {
<div class="kanban-column-wrapper flex-shrink-0 w-72">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-semibold text-slate-700">
{ TaskColumnLabels[status] }
@ui.Badge(ui.BadgeProps{
Label: strconv.Itoa(len(tasks)),
Variant: ui.BadgeVariantInfo,
})
</h3>
</div>
<div
class="sortable-column min-h-16 space-y-2"
data-status={ string(status) }
id={ "column-" + string(status) }
>
for _, task := range tasks {
@TaskCard(task, csrfToken)
}
</div>
// Inline add task form at bottom (D-09)
<div id={ "add-task-slot-" + string(status) } class="mt-2">
@AddTaskTrigger(tabloID, status, csrfToken)
</div>
</div>
}
```
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Custom drag event handlers | Sortable.js group mode | Sortable.js has been standard since ~2015 | Handles touch, animation, multi-list; no custom event code needed |
| Float/string fractional indices (LexoRank) | Integer gap-of-100 with rebalance | LexoRank popularized by JIRA; overkill for v1 | Integer positions simpler in SQL + Go; rebalance is sufficient at small scale |
| Separate "move" and "reorder" endpoints | Single `/tasks/reorder` endpoint | Pattern from HTMX sortable example | Sortable.js `group` mode fires one `end` event for both cases |
**Deprecated/outdated:**
- jQuery UI Sortable: Replaced by SortableJS — no jQuery dependency.
- `hx-trigger="end from:body"`: Old pattern; prefer `htmx.onLoad` initialization with `onEnd` callback as per official HTMX example.
---
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Position rebalance runs synchronously in the same DB transaction as the reorder | Architecture Patterns / Reorder handler | Could rebalance and reorder in separate transactions — if DB fails between, positions stay inconsistent. Mitigation: wrap both in a single pgx transaction. |
| A2 | Go file structure: `handlers_tasks.go` in `internal/web/`, templ in `backend/templates/tasks.templ` | Recommended Project Structure | Templates could be in a `tablos/` sub-directory if that's adopted; either works as long as `templ generate` runs. |
| A3 | Inline Sortable.js script is placed in `tasks.templ` (not in `layout.templ`) so it is only loaded on the tablo detail page | Code Examples | If placed in layout.templ, Sortable initializes on every page; no functional harm, minor overhead. |
| A4 | `UpdateTask` sqlc query accepts both status + position (combined update for reorder) | sqlc queries | Could require separate `UpdateTaskStatus` and `UpdateTaskPosition` queries; combined is simpler. |
| A5 | `groupTasksByStatus` is a helper function in `tasks_forms.go` or a template helper | templ example | Could also be computed in the handler before passing to template; either is valid. |
---
## Open Questions
1. **Reorder payload: per-column or full-board?**
- What we know: Sortable.js `end` event fires on each column individually when dragging within a column, or fires on the destination column when moving between columns.
- What's unclear: Should the reorder endpoint receive positions for just the affected columns (source + destination), or all four columns at once?
- Recommendation: Send all task IDs across all columns in a single POST (full-board snapshot). This is simpler to parse and eliminates partial-update bugs where the source column gets stale positions.
2. **HTMX swap target after reorder: full board or per-column?**
- What we know: Returning the full `#kanban-board` outerHTML is simple but re-renders all columns. Returning only the two affected columns as OOB swaps is more surgical but adds complexity.
- What's unclear: At v1 task counts, the performance difference is negligible. Does the UI team prefer the simpler or more granular approach?
- Recommendation: Return the full `#kanban-board` outerHTML for v1. Use OOB per-column swaps only if performance becomes an issue.
3. **Delete success response in HTMX**
- What we know: Phase 3 delete uses `HX-Redirect: /` after deleting a tablo. For tasks, we want to remove just the card element from the column without redirecting.
- What's unclear: Exact HTMX swap strategy — `hx-swap="delete"` removes the element; `hx-swap="outerHTML"` with an empty response also works.
- Recommendation: Use `hx-swap="outerHTML"` with the task card as target, and return an empty `<div>` with the same ID so the DOM element is replaced with nothing. This is the cleanest approach with gorilla/csrf (no response-header gymnastics).
---
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Go 1.26+ | All handlers | ✓ | 1.26.1 | — |
| Postgres | tasks table + ENUM | ✓ | (local via compose.yaml) | — |
| templ CLI | Template generation | ✓ | v0.3.1020 (in justfile) | — |
| sqlc CLI | Query generation | ✓ | v1.31.1 (in justfile) | — |
| goose CLI | Migration | ✓ | v3.27.1 (in justfile) | — |
| Sortable.js | Drag-and-drop | ✗ (not yet downloaded) | 1.15.7 | — (must be added to `just bootstrap`) |
**Missing dependencies with no fallback:**
- Sortable.js `static/sortable.min.js` — not yet downloaded. Must add to `justfile bootstrap` recipe and `clean` recipe. Without it, drag-and-drop silently fails (script tag 404).
---
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Go `testing` package + httptest |
| Config file | none — standard `go test ./...` |
| Quick run command | `go test ./internal/web/ -run TestTask -v` |
| Full suite command | `go test ./...` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| TASK-01 | GET /tablos/{id} renders 4 column headers | integration | `go test ./internal/web/ -run TestTasksKanbanRenders -v` | ❌ Wave 0 |
| TASK-02 | POST /tablos/{id}/tasks creates task, returns card fragment | integration | `go test ./internal/web/ -run TestTaskCreate -v` | ❌ Wave 0 |
| TASK-03 | POST /tablos/{id}/tasks/{id} updates title/desc, returns card | integration | `go test ./internal/web/ -run TestTaskUpdate -v` | ❌ Wave 0 |
| TASK-04 | POST /tablos/{id}/tasks/reorder moves task to new column | integration | `go test ./internal/web/ -run TestTaskReorderCrossColumn -v` | ❌ Wave 0 |
| TASK-05 | POST /tablos/{id}/tasks/reorder changes position within column | integration | `go test ./internal/web/ -run TestTaskReorderSameColumn -v` | ❌ Wave 0 |
| TASK-06 | POST /tablos/{id}/tasks/{id}/delete removes task | integration | `go test ./internal/web/ -run TestTaskDelete -v` | ❌ Wave 0 |
| TASK-07 | GET /tablos/{id} after reorder shows persisted position order | integration | `go test ./internal/web/ -run TestTaskOrderPersists -v` | ❌ Wave 0 |
### Sampling Rate
- **Per task commit:** `go test ./internal/web/ -run TestTask -v`
- **Per wave merge:** `go test ./...`
- **Phase gate:** Full suite green before `/gsd-verify-work`
### Wave 0 Gaps
- [ ] `backend/internal/web/handlers_tasks_test.go` — covers TASK-01 through TASK-07
- [ ] `backend/internal/db/queries/tasks.sql` — required before sqlc generate
- [ ] `backend/migrations/0004_tasks.sql` — required before setupTestDB runs migrations
- [ ] Sortable.js download in `just bootstrap` — required before dev run
---
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | no | RequireAuth middleware (already in Phase 2) |
| V3 Session Management | no | Signed HTTP-only cookies (already in Phase 2) |
| V4 Access Control | yes | `loadOwnedTablo` guard + `tablo_id` filter on all task queries |
| V5 Input Validation | yes | Title: non-empty, <= 255 chars; status: ENUM rejects invalid values at DB |
| V6 Cryptography | no | No new crypto in this phase |
### Known Threat Patterns for This Stack
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| IDOR on task operations | Elevation of Privilege | All task queries include `WHERE tablo_id = $1` where tablo is already ownership-verified |
| Mass assignment via reorder | Tampering | Reorder handler only updates `position` and `status` from the request; title/description cannot be changed via reorder endpoint |
| CSRF on reorder POST | Tampering | gorilla/csrf `_csrf` field in hidden reorder form; already wired (D-14) |
| Status injection (invalid ENUM) | Tampering | Postgres ENUM rejects invalid values at DB layer before any application code runs |
---
## Sources
### Primary (HIGH confidence)
- [VERIFIED: go.mod] — all Go module versions confirmed
- [VERIFIED: backend/justfile] — CLI tool versions, asset download pattern, no-CDN rule
- [VERIFIED: codebase — handlers_tablos.go] — handler pattern, `loadOwnedTablo`, HTMX fragment response
- [VERIFIED: codebase — router.go] — chi route ordering convention, RequireAuth group
- [VERIFIED: codebase — auth/csrf.go] — gorilla/csrf configuration, `csrf.RequestHeader("X-CSRF-Token")`
- [VERIFIED: codebase — sqlc.yaml] — sqlc configuration, pgx/v5 sql_package
- [VERIFIED: npm registry] — Sortable.js 1.15.7 current version
- [CITED: https://htmx.org/examples/sortable/] — official HTMX Sortable.js integration pattern
- [CITED: https://docs.sqlc.dev/en/latest/reference/datatypes.html] — ENUM → Go type generation
### Secondary (MEDIUM confidence)
- [CITED: https://github.com/SortableJS/Sortable] — Sortable.js group option for cross-list Kanban
- Multiple sources agree on form-encoded array pattern for HTMX reorder
### Tertiary (LOW confidence)
- Exact Sortable.js initialization script shape (canonical pattern from htmx.org but adapted for multi-column kanban with a shared hidden form — not directly verified against a published Go/HTMX kanban example)
---
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — all library versions verified against codebase and npm registry
- Architecture: HIGH — directly derived from Phase 3 codebase patterns + official HTMX docs
- Pitfalls: HIGH for chi routing + gorilla/csrf; MEDIUM for Sortable.js re-initialization (verified via htmx.org example); MEDIUM for ENUM migration order (verified via Postgres docs behavior)
- Reorder payload design: MEDIUM — recommended approach (full-board form-encoded array) is derived from HTMX sortable pattern but the exact multi-column adaptation is ASSUMED
**Research date:** 2026-05-15
**Valid until:** 2026-06-15 (stable stack — Go, HTMX 2, Sortable.js 1.x are all in maintenance mode)