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
99
100
101
102
103
104
105
106
|
# Recurring Maintenance Buckets — Implementation Plan
> Implements `docs/superpowers/specs/2026-07-15-recurring-maintenance-buckets-design.md`.
> Backend (Go), web wiring, and Android widget wiring — full stack, per user decision 2026-07-17.
**Goal:** a bucket holds a pool of doot-native tasks that don't each deserve their own due date. Every `cycle_days`, the top `pick_n` (scored by staleness/priority/availability-fit) flip from dormant to active with a computed due date. Completing or deferring an active item returns it to the pool; deferring immediately triggers a backfill selection for that bucket.
## Global Constraints
- Doot-native tasks only. Exactly one bucket per item.
- Reuses the periodic-scheduler shape from `internal/scheduler/recurrence.go`, not its new-row-per-iteration mechanism — bucket items are state-flipped in place, never duplicated.
- Scoring formula is an implementation-time call (spec leaves it open) — implement staleness (days since `bucket_last_active_at`, nulls score as +infinity/highest) as primary key, task `priority` as tiebreaker. Availability-budget fit (item 1) is a nice-to-have third factor if `estimated_minutes`/budget-tracked project data is easily available at selection time; skip it if it adds real complexity rather than blocking on it.
---
### Task 1: Migration
**File:** `migrations/027_maintenance_buckets.sql`
```sql
CREATE TABLE maintenance_buckets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
cycle_days INTEGER NOT NULL,
pick_n INTEGER NOT NULL,
last_cycle_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE native_tasks ADD COLUMN bucket_id TEXT DEFAULT '';
ALTER TABLE native_tasks ADD COLUMN bucket_state TEXT DEFAULT '';
ALTER TABLE native_tasks ADD COLUMN bucket_last_active_at DATETIME;
CREATE INDEX IF NOT EXISTS idx_native_tasks_bucket ON native_tasks(bucket_id);
```
Add matching columns/table to the store test-schema helpers (`internal/store/native_tasks_test.go`, `internal/store/sqlite_test.go`), same as the chains plan's Task 1.
### Task 2: Models
**File:** `internal/models/types.go`
- Add to `Task`: `BucketID string`, `BucketState string`, `BucketLastActiveAt *time.Time` (all `omitempty`).
- New `MaintenanceBucket` struct: `ID, Name string`, `CycleDays, PickN int`, `LastCycleAt *time.Time`, `CreatedAt time.Time`.
### Task 3: Store — bucket CRUD, selection, defer/complete hooks
**File:** `internal/store/buckets.go` (new)
- `CreateBucket(name string, cycleDays, pickN int) (*models.MaintenanceBucket, error)`.
- `GetBuckets() ([]models.MaintenanceBucket, error)`.
- `AddBucketItem(bucketID, taskID string) error` — sets `bucket_id`, `bucket_state='dormant'` on an existing task row (task must already exist — created via the normal `CreateNativeTask`/widget-add path first, matching "add an existing or new task" from the spec's API section: creating-then-adding covers both cases without a separate code path).
- `RemoveBucketItem(taskID string) error` — clears `bucket_id`/`bucket_state`/`bucket_last_active_at`.
- `selectBucketCycle(bucketID string, now time.Time) (int, error)` — unexported: loads dormant items for the bucket, scores (staleness DESC — NULL `bucket_last_active_at` first — then `priority` DESC as tiebreaker), flips the top `pick_n` to `active` with `due_date = now + cycle_days` and `bucket_last_active_at = now`, updates the bucket's `last_cycle_at = now`. Returns count activated.
- `RunBucketCycles(now time.Time) (int, error)` — exported, scheduler entry point: for every bucket where `last_cycle_at IS NULL OR last_cycle_at <= now - cycle_days`, calls `selectBucketCycle`. Mirrors `AdvanceDueRecurringTasks`'s shape.
- `DeferNativeTask(id string) error` — sets the task `bucket_state='dormant'`, due_date=NULL, leaves `bucket_last_active_at` untouched, then synchronously calls `selectBucketCycle` for its bucket to backfill the freed slot. Returns `ErrNativeTaskNotFound` if the task isn't an active bucket item.
**File:** `internal/store/native_tasks.go`
- Add `bucket_id, bucket_state, bucket_last_active_at` to all 5 SELECT queries and `scanNativeTasks`.
- `GetNativeTasksByDateRange`/`GetOverdueNativeTasks`/`GetUndatedNativeTasks`: dormant bucket items (`bucket_state = 'dormant'`) have no due date already, so the existing `due_date IS NOT NULL` filters naturally exclude them from the dated queries — no WHERE-clause change needed there. `GetUndatedNativeTasks` (`due_date IS NULL`), however, would otherwise surface dormant bucket items as if they were ordinary undated tasks; add `AND bucket_state != 'dormant'` to its WHERE clause so the pool stays invisible until activated, per spec.
- `CompleteNativeTask`: after the chain-advancement block (or recurrence block if chains plan hasn't landed yet — check `chain_id`/order doesn't matter, they're independent per both specs), add: if `task.BucketID != ""`, flip it back to `dormant` with `bucket_last_active_at = now` (distinct from Defer, which leaves the timestamp alone) and clear `due_date`.
**Tests:** `internal/store/buckets_test.go` (new) — selection picks the top-N by staleness then priority; never-activated (`NULL` timestamp) items outrank ever-activated ones; `RunBucketCycles` respects `cycle_days` (no-op if not due yet); completing an active item returns it to dormant with `bucket_last_active_at = now`; deferring returns it to dormant with the *prior* timestamp and triggers a fresh selection that backfills the slot; dormant items excluded from `GetUndatedNativeTasks`.
### Task 4: Scheduler
**File:** `internal/scheduler/buckets.go` (new) — `RunBucketCycleCheck(ctx, s, interval)`, same ticker shape as `RunRecurrenceCheck`, calling `s.RunBucketCycles(config.Now())`.
**File:** `cmd/dashboard/main.go` — `go scheduler.RunBucketCycleCheck(schedulerCtx, db, 15*time.Minute)` next to the existing recurrence-check goroutine.
### Task 5: HTTP handlers
**File:** `internal/handlers/widget.go`
- `HandleWidgetBucketsGet` (`GET /api/widget/buckets`).
- `HandleWidgetBucketsCreate` (`POST /api/widget/buckets` — `{name, cycle_days, pick_n}`).
- `HandleWidgetBucketItemsAdd` (`POST /api/widget/buckets/{id}/items` — `{task_id}`).
- `HandleWidgetBucketItemsRemove` (`POST /api/widget/buckets/{id}/items/remove` — `{task_id}`).
- `HandleWidgetTaskDefer` (`POST /api/widget/task/defer` — `{id}`) — the new action distinct from complete/reschedule.
**File:** `cmd/dashboard/main.go` — register under the `widgetAuth` block.
**Tests:** `internal/handlers/buckets_test.go` (new), mirroring `widget_test.go` style.
### Task 6: Web + task-detail popup wiring
- Task-detail popup (the same one that already handles project/labels/recurrence, per `2026-07-15-task-labels-and-projects.md`) gets a bucket assignment control and, for active bucket items, a "Defer" button next to Complete.
- Bucket management view: minimal list (name, cycle, pick_n, pool contents) — reuses the existing projects-list page pattern if one exists, otherwise a simple new template under the settings area.
### Task 7: Android widget wiring
**Files:**
- `WidgetItem.kt` — add `bucketId`, `bucketState` fields.
- `DataStore.kt` — repository methods for the 5 new endpoints, plus defer.
- `WidgetRows.kt` — a "Defer" swipe/button action alongside the existing Complete action for rows where `bucketState == "active"`.
- No dedicated bucket-management screen on Android (per spec, UI design is explicitly out of scope beyond the minimum) — management stays a web-only surface; the widget only needs to complete/defer active items it already displays.
### Task 8: Docs
- Flip the spec's status line once Tasks 1-7 are done and tests pass.
- Update `.agent/worklog.md`.
## Testing
`go test ./...` after Tasks 1-5. Android: `./gradlew assembleDebug` after Task 7 (debug build only — do not build/publish the release APK without a separate explicit go-ahead).
|