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