summaryrefslogtreecommitdiff
path: root/docs/superpowers/plans/2026-07-17-linear-task-chains.md
blob: ebb576a55454fabb288191db6caaafc023426bf3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# Linear Task Chains — Implementation Plan

> Implements `docs/superpowers/specs/2026-07-15-linear-task-chains-design.md`.
> Backend (Go), web timeline indicator, and Android widget chain view — full stack, per user decision 2026-07-17.

**Goal:** doot-native tasks can belong to a `task_chains` sequence with a WIP limit of exactly 1 — only one task in the chain is ever unlocked/actionable, and completing it unlocks the next. Locked tasks are hidden from date-based views; a dedicated checklist view shows the whole chain.

## Global Constraints

- Doot-native tasks only (matches every prior feature this session). Chains reuse the `projects` table (one project per chain) rather than a parallel grouping mechanism.
- Strictly linear, fixed at creation: no branching, no reordering, WIP limit not configurable (always 1), no looping.
- Locked tasks have no `due_date` and are excluded from `GetNativeTasksByDateRange`/`GetOverdueNativeTasks`/`GetUndatedNativeTasks`/the widget's `/api/widget` response.

---

### Task 1: Migration

**File:** `migrations/026_task_chains.sql`

```sql
CREATE TABLE task_chains (
    id TEXT PRIMARY KEY,
    project_id TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'active',   -- active, paused, abandoned, completed
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

ALTER TABLE native_tasks ADD COLUMN chain_id TEXT DEFAULT '';
ALTER TABLE native_tasks ADD COLUMN chain_position INTEGER DEFAULT 0;
ALTER TABLE native_tasks ADD COLUMN chain_unlocked BOOLEAN DEFAULT 0;

CREATE INDEX IF NOT EXISTS idx_native_tasks_chain ON native_tasks(chain_id);
```

Also add the same tables/columns to the store test-schema helpers (`internal/store/native_tasks_test.go`'s inline `CREATE TABLE native_tasks`, plus a new `task_chains` table) and `internal/store/sqlite_test.go`'s `native_tasks` schema.

### Task 2: Models

**File:** `internal/models/types.go`

- Add to `Task`: `ChainID string`, `ChainPosition int`, `ChainUnlocked bool` (json `chain_id,omitempty` / `chain_position,omitempty` / `chain_unlocked,omitempty`).
- New `Chain` struct: `ID, ProjectID, Status string`, `CreatedAt time.Time`.

### Task 3: Store — chain CRUD and advancement

**File:** `internal/store/chains.go` (new)

- `CreateChain(name string, taskTitles []string) (*models.Chain, error)` — creates a backing project (reuse `CreateProject`), inserts a `task_chains` row, then inserts one `native_tasks` row per title: position 0 gets `chain_unlocked=1, due_date=now`; the rest get `chain_unlocked=0, due_date=NULL`. All in one transaction.
- `GetChain(id string) (*models.Chain, error)` — `ErrNativeTaskNotFound` if missing (reuse the shared sentinel).
- `GetChainTasks(chainID string) ([]models.Task, error)` — every task in the chain, ordered by `chain_position`, locked and unlocked both (backs the checklist view; the "visible in the tasks list" requirement from the spec).
- `SetChainStatus(id, status string) error` — used by pause/resume/abandon.
- `advanceChain(tx, chainID string, completedPosition int) error` — unexported, called from `CompleteNativeTask`: if chain status is `paused`, no-op (per spec: paused chains don't auto-advance). Otherwise find the row at `chain_position = completedPosition + 1`; if found, set it `chain_unlocked=1, due_date=now`; if not found (completed task was last), set the chain's `status='completed'`.

**File:** `internal/store/native_tasks.go`

- Add `chain_id, chain_position, chain_unlocked` to all 5 SELECT queries and `scanNativeTasks`.
- `GetNativeTasksByDateRange`, `GetOverdueNativeTasks`, `GetUndatedNativeTasks`: add `AND (chain_id = '' OR chain_unlocked = 1)` to the WHERE clause — locked chain tasks never appear in date-based views regardless of (null) due date.
- `CompleteNativeTask`: after the existing recurrence-advancement block, add: if `task.ChainID != ""`, call `s.advanceChain(task.ChainID, task.ChainPosition)`.
- `CreateNativeTask`: no chain fields needed (chain creation goes through `CreateChain`, not this path).

**Tests:** `internal/store/chains_test.go` (new) — creating a chain seeds positions correctly (only position 0 unlocked/dated, rest locked/undated); completing an unlocked task advances the next position (unlocked + due_date set); completing the last position marks the chain `completed` instead of advancing; pausing prevents auto-advance on completion; resuming re-enables advancement on the *next* completion (not retroactively); `GetNativeTasksByDateRange`/`GetOverdueNativeTasks`/`GetUndatedNativeTasks` all exclude locked tasks.

### Task 4: HTTP handlers

**File:** `internal/handlers/widget.go`

- `HandleWidgetChainsCreate` (`POST /api/widget/chains`) — body `{name string, tasks []string}`; calls `store.CreateChain`; returns `{id string}`.
- `HandleWidgetChainsPause` / `HandleWidgetChainsResume` / `HandleWidgetChainsAbandon` (`POST /api/widget/chains/{id}/pause|resume|abandon`) — call `SetChainStatus`; 404 via `ErrNativeTaskNotFound` if the chain id doesn't exist.
- `HandleWidgetChainGet` (`GET /api/widget/chains/{id}`) — returns `{chain: Chain, tasks: []Task}` ordered by position, for the checklist view.

**File:** `cmd/dashboard/main.go` — register the 5 routes under the existing `widgetAuth`-gated block, next to the availability routes.

**Tests:** `internal/handlers/chains_test.go` (new) mirroring `widget_test.go`'s style — create, pause/resume/abandon (including 404 on unknown id), get.

### Task 5: Web timeline wiring

A task's chain membership is visible as a small "N/M" position badge (e.g. "3/12") next to the existing due-date/project chip on the web Today/Tomorrow timeline rows, same visual slot pattern as the budget "scheduled/available" badge from `2026-07-16-task-budgets-and-availability.md` Task 11. Locked tasks never reach the timeline template (excluded at the store layer), so no "locked" state needs rendering there — only the currently-unlocked task ever shows, with its position.

**Files:** the timeline template(s) already touched by the budget-indicator work (search for that commit's template diff — `6651dc1`) and the corresponding `TimelineItem`/Go template-data struct, adding `ChainPosition`/chain total count (compute total via `len(GetChainTasks)` or store it as `chain_total` if simpler — implementation-time call).

No standalone web checklist page — per spec, "web UI specifics for the chain/checklist view" are out of scope; the Android widget gets the dedicated checklist surface (Task 6).

### Task 6: Android widget — chain checklist view

**Files:**
- `android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt` — add `chainId`, `chainPosition`, `chainUnlocked` (or equivalent) fields, mirroring how `budget_status` was added.
- `android/app/src/main/java/org/terst/doot/widget/data/DataStore.kt` — new repository methods hitting `GET /api/widget/chains/{id}`, `POST /api/widget/chains/{id}/pause|resume|abandon`.
- `android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt` — small chain-position badge on rows for tasks with `chainId` set (same slot as the budget badge).
- New Compose screen/dialog for the chain checklist (locked + unlocked items in order) — reachable by tapping the chain badge. No automated Compose UI test harness in this project (established convention); verify by building and a manual on-device/emulator check.

### Task 7: Docs

- Flip this spec's status line from "Backlog idea... Not yet approved" to reflect implementation, once Tasks 1-6 are done and tests pass.
- Update `.agent/worklog.md`.

## Testing

`go test ./...` after Tasks 1-4. Android: `./gradlew assembleDebug` after Task 6 (debug build only, per existing widget-build convention — do not build/publish the release APK without a separate explicit go-ahead, since that touches `/site/static.terst.org`).