docs(04): research phase tasks-kanban domain
This commit is contained in:
parent
338e7e6e92
commit
1c7b9d632c
1 changed files with 775 additions and 0 deletions
775
.planning/phases/04-tasks-kanban/04-RESEARCH.md
Normal file
775
.planning/phases/04-tasks-kanban/04-RESEARCH.md
Normal 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)
|
||||||
Loading…
Reference in a new issue