summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.agent/worklog.md1
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt30
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt76
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt22
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt31
-rw-r--r--android/app/src/main/java/org/terst/doot/widget/work/DeferWorker.kt50
-rw-r--r--cmd/dashboard/main.go12
-rw-r--r--docs/superpowers/plans/2026-07-17-linear-task-chains.md98
-rw-r--r--docs/superpowers/plans/2026-07-17-recurring-maintenance-buckets.md106
-rw-r--r--docs/superpowers/specs/2026-07-15-linear-task-chains-design.md2
-rw-r--r--docs/superpowers/specs/2026-07-15-recurring-maintenance-buckets-design.md2
-rw-r--r--docs/superpowers/specs/2026-07-15-task-budgets-and-availability-design.md2
-rw-r--r--docs/superpowers/specs/2026-07-15-task-labels-and-projects-design.md2
-rw-r--r--internal/handlers/buckets_test.go188
-rw-r--r--internal/handlers/chains_test.go161
-rw-r--r--internal/handlers/chains_timeline_test.go43
-rw-r--r--internal/handlers/defer_atom_test.go94
-rw-r--r--internal/handlers/handlers.go25
-rw-r--r--internal/handlers/timeline_logic.go22
-rw-r--r--internal/handlers/widget.go199
-rw-r--r--internal/models/timeline.go3
-rw-r--r--internal/models/types.go33
-rw-r--r--internal/models/widget.go3
-rw-r--r--internal/scheduler/buckets.go33
-rw-r--r--internal/store/buckets.go192
-rw-r--r--internal/store/buckets_test.go211
-rw-r--r--internal/store/chains.go142
-rw-r--r--internal/store/chains_test.go165
-rw-r--r--internal/store/native_tasks.go50
-rw-r--r--internal/store/native_tasks_test.go30
-rw-r--r--internal/store/sqlite_test.go8
-rw-r--r--migrations/026_task_chains.sql16
-rw-r--r--migrations/027_maintenance_buckets.sql18
-rw-r--r--web/templates/partials/timeline-tab.html13
34 files changed, 2070 insertions, 13 deletions
diff --git a/.agent/worklog.md b/.agent/worklog.md
index 18456cf..932cad8 100644
--- a/.agent/worklog.md
+++ b/.agent/worklog.md
@@ -4,6 +4,7 @@
Cleaned Backlog
## Recently Completed
+- **Linear task chains + recurring maintenance buckets** — implemented the last two items from `[[doot-future-task-scheduling-ideas]]` (items 1 and 2, budgets/availability and labels/projects, turned out to already be shipped -- their spec status headers and the budgets plan's checkboxes had just never been updated to say so; corrected both). Chains: `task_chains` table + `chain_id`/`chain_position`/`chain_unlocked` on `native_tasks` (migration 026), WIP-limit-1 sequencing wired into `CompleteNativeTask`'s existing recurrence-hook pattern, locked tasks excluded from all date-based queries, 5 new `/api/widget/chains*` endpoints, a position badge ("N/M") on web timeline rows and the Android widget row. Buckets: `maintenance_buckets` table + `bucket_id`/`bucket_state`/`bucket_last_active_at` on `native_tasks` (migration 027), staleness-then-priority selection scoring, a new `RunBucketCycleCheck` scheduler loop mirroring `RunRecurrenceCheck`, 5 new endpoints including the distinct Defer action (returns to pool without crediting completion, unlike Complete), a Defer button on both web timeline rows and the Android widget row. Both fully covered by store+handler tests (`go test ./...` green). Deferred (backend/API exists, UI doesn't): a dedicated Android chain-checklist screen, and bucket-management create/add-item screens on web or Android -- both explicitly out of scope in their specs' own interviews. Not yet deployed or built into the Android APK.
- **Task labels and projects** — doot-native tasks get a lightweight Projects concept (name + user-assignable color, exactly one per task) and the previously-dormant `Labels` field is finally wired up end-to-end (free-text tags, each assigned a deterministic color on first use). Both are inherited automatically across recurring task series, just like content/description already are. Editable in the task-detail popup (project picker with inline "create new" + color swatches, a label chip editor); the widget's time-grid rows show a small project-color accent. Trello/Google Tasks cards are unaffected. Built via a 9-task subagent-driven plan, all tasks reviewed clean; deployed server + widget APK.
- **Widget polish: task/event font parity, instant task completion, recurrence dialog redesign** — TaskRow's title color (a flat hardcoded gray) now matches EventBlock's (Color.White, dimmed only when past) at the same size/weight — the color gap was reading as a font mismatch. CompleteTaskAction now optimistically removes the completed item from the cached list and re-renders immediately (matching RefreshTaskAction's existing pattern) instead of waiting for the full complete->fetch->render round trip. Redesigned RecurrenceEditDialog: the 4 frequency chips and 7 weekday chips were each in a non-wrapping Row (overflowing/cut off on real phone widths) — now FlowRow-based so they wrap; single-letter weekday chips; a narrow fixed-width interval field with a correctly-pluralized unit label (was "Every N dailys"); muted section labels for visual structure. Deployed new APK.
- **Widget periodic-refresh self-healing fix** — root-caused a report of "all-day events and tasks gone, now-line stuck on this morning" to the widget's periodic background refresh (`RefreshWorker`, every 15 min) having silently stopped ticking, likely because reinstalling the APK (as happened repeatedly this session, outside the Play Store update path) can clear WorkManager's persisted schedule without the widget itself being removed/re-added — the only place that re-establishes the periodic job was `onEnabled`, which only fires on first-widget-add. Manual refresh always worked (confirmed data/rendering were fine); `onUpdate` (fires far more often: reboot, periodic OS ticks) now also re-arms the schedule via `KEEP` (a no-op if already running), making it self-healing. Deployed new APK.
diff --git a/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt b/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt
index ed1bceb..dfaa8b6 100644
--- a/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt
+++ b/android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt
@@ -17,7 +17,10 @@ data class WidgetItem(
val url: String = "",
val completable: Boolean = false,
@SerialName("recurring_event_id") val recurringEventId: String? = null,
- @SerialName("project_color") val projectColor: String? = null
+ @SerialName("project_color") val projectColor: String? = null,
+ @SerialName("chain_position") val chainPosition: Int = 0, // 1-indexed; 0 = not a chain task
+ @SerialName("chain_total") val chainTotal: Int = 0,
+ @SerialName("bucket_state") val bucketState: String? = null // "active" when it's a maintenance-bucket item; null otherwise
)
@Serializable
@@ -45,3 +48,28 @@ data class TaskDetail(
val description: String,
val editable: Boolean
)
+
+@Serializable
+data class Chain(
+ val id: String,
+ @SerialName("project_id") val projectId: String,
+ val status: String, // "active" | "paused" | "abandoned" | "completed"
+ @SerialName("created_at") val createdAt: String
+)
+
+/** Matches models.Task's JSON shape (GET /api/widget/chains/{id}), not WidgetItem's. */
+@Serializable
+data class ChainTask(
+ val id: String,
+ val content: String,
+ val completed: Boolean = false,
+ @SerialName("due_date") val dueDate: String? = null,
+ @SerialName("chain_position") val chainPosition: Int = 0,
+ @SerialName("chain_unlocked") val chainUnlocked: Boolean = false
+)
+
+@Serializable
+data class ChainDetail(
+ val chain: Chain,
+ val tasks: List<ChainTask> // locked + unlocked, ordered by chain_position
+)
diff --git a/android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt b/android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt
index 3283ea8..d630247 100644
--- a/android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt
+++ b/android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt
@@ -133,6 +133,21 @@ class WidgetRepository(
}
}
+ /** POSTs a defer (return to bucket pool without crediting completion) to /api/widget/task/defer. */
+ suspend fun defer(id: String): Result<Unit> =
+ withContext(Dispatchers.IO) {
+ val body = """{"id":"$id"}""".toRequestBody("application/json".toMediaType())
+ val request = Request.Builder()
+ .url("$serverUrl/api/widget/task/defer")
+ .header("Authorization", "Bearer $token")
+ .post(body)
+ .build()
+ runCatching {
+ val response = client.newCall(request).execute()
+ check(response.isSuccessful) { "HTTP ${response.code}" }
+ }
+ }
+
/**
* POSTs a new task title to /api/widget/add and returns its id, so the
* caller can follow up with the same project/labels/recurrence/due-date
@@ -339,4 +354,65 @@ class WidgetRepository(
check(response.isSuccessful) { "HTTP ${response.code}" }
}
}
+
+ @Serializable
+ private data class ChainCreateRequest(val name: String, val tasks: List<String>)
+
+ @Serializable
+ private data class ChainCreateResponse(val id: String)
+
+ /** POSTs a new chain (name + ordered task titles) to /api/widget/chains and returns its id. */
+ suspend fun createChain(name: String, taskTitles: List<String>): Result<String> =
+ withContext(Dispatchers.IO) {
+ val body = json.encodeToString(ChainCreateRequest(name, taskTitles))
+ .toRequestBody("application/json".toMediaType())
+ val request = Request.Builder()
+ .url("$serverUrl/api/widget/chains")
+ .header("Authorization", "Bearer $token")
+ .post(body)
+ .build()
+ runCatching {
+ val response = client.newCall(request).execute()
+ check(response.isSuccessful) { "HTTP ${response.code}" }
+ val respBody = checkNotNull(response.body?.string()) { "Empty body" }
+ json.decodeFromString<ChainCreateResponse>(respBody).id
+ }
+ }
+
+ /** GETs the full ordered checklist (locked + unlocked) for a chain. */
+ suspend fun fetchChain(id: String): Result<ChainDetail> =
+ withContext(Dispatchers.IO) {
+ val request = Request.Builder()
+ .url("$serverUrl/api/widget/chains/$id")
+ .header("Authorization", "Bearer $token")
+ .build()
+ runCatching {
+ val response = client.newCall(request).execute()
+ check(response.isSuccessful) { "HTTP ${response.code}" }
+ val body = checkNotNull(response.body?.string()) { "Empty body" }
+ json.decodeFromString<ChainDetail>(body)
+ }
+ }
+
+ private suspend fun setChainStatus(id: String, action: String): Result<Unit> =
+ withContext(Dispatchers.IO) {
+ val request = Request.Builder()
+ .url("$serverUrl/api/widget/chains/$id/$action")
+ .header("Authorization", "Bearer $token")
+ .post("".toRequestBody("application/json".toMediaType()))
+ .build()
+ runCatching {
+ val response = client.newCall(request).execute()
+ check(response.isSuccessful) { "HTTP ${response.code}" }
+ }
+ }
+
+ /** Pauses a chain: the unlocked task stays actionable but won't auto-advance on completion. */
+ suspend fun pauseChain(id: String): Result<Unit> = setChainStatus(id, "pause")
+
+ /** Resumes a paused chain, re-enabling auto-advance on the next completion. */
+ suspend fun resumeChain(id: String): Result<Unit> = setChainStatus(id, "resume")
+
+ /** Abandons a chain -- a terminal state distinct from "completed". */
+ suspend fun abandonChain(id: String): Result<Unit> = setChainStatus(id, "abandon")
}
diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt b/android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt
index c6dcae9..78fb29c 100644
--- a/android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt
+++ b/android/app/src/main/java/org/terst/doot/widget/ui/Actions.kt
@@ -10,6 +10,7 @@ import org.terst.doot.widget.data.Keys
import org.terst.doot.widget.data.dataStore
import org.terst.doot.widget.data.removeWidgetItemLocally
import org.terst.doot.widget.work.CompleteWorker
+import org.terst.doot.widget.work.DeferWorker
import org.terst.doot.widget.work.RefreshWorker
class CompleteTaskAction : ActionCallback {
@@ -33,6 +34,27 @@ class CompleteTaskAction : ActionCallback {
}
}
+// Defer is only ever shown for doot-native active bucket items (see
+// TaskRow), so unlike CompleteTaskAction this doesn't need a source param.
+class DeferTaskAction : ActionCallback {
+ override suspend fun onAction(
+ context: Context,
+ glanceId: GlanceId,
+ parameters: ActionParameters
+ ) {
+ val id = parameters[idKey] ?: return
+ // Optimistically drop it from the visible list right away, same as
+ // CompleteTaskAction -- DeferWorker does the real call + refresh.
+ removeWidgetItemLocally(context, id, "doot")
+ DootWidget().updateAll(context)
+ DeferWorker.enqueue(context, id)
+ }
+
+ companion object {
+ val idKey = ActionParameters.Key<String>("item_id")
+ }
+}
+
// Sets IS_REFRESHING synchronously (before the network round trip) so the
// widget's icon flips to the loading state on the spot -- RefreshWorker
// clears the flag when it finishes, regardless of outcome, so the icon
diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt b/android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt
index 1c7e454..6515131 100644
--- a/android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt
+++ b/android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt
@@ -307,6 +307,37 @@ fun TaskRow(task: WidgetItem, textSize: WidgetTextSize) {
maxLines = 1
)
}
+
+ if (task.chainTotal > 0) {
+ Text(
+ text = "${task.chainPosition}/${task.chainTotal}",
+ style = TextStyle(
+ color = ColorProvider(Color(0xFFC4B5FD)),
+ fontSize = textSize.scaledContentSize(11)
+ ),
+ modifier = GlanceModifier.padding(start = 4.dp)
+ )
+ }
+
+ if (task.bucketState == "active") {
+ Box(
+ modifier = GlanceModifier
+ .padding(start = 4.dp)
+ .clickable(
+ actionRunCallback<DeferTaskAction>(
+ actionParametersOf(DeferTaskAction.idKey to task.id)
+ )
+ )
+ ) {
+ Text(
+ text = "defer",
+ style = TextStyle(
+ color = ColorProvider(Color(0x99FFFFFF)),
+ fontSize = textSize.scaledContentSize(11)
+ )
+ )
+ }
+ }
}
}
diff --git a/android/app/src/main/java/org/terst/doot/widget/work/DeferWorker.kt b/android/app/src/main/java/org/terst/doot/widget/work/DeferWorker.kt
new file mode 100644
index 0000000..03ed1ab
--- /dev/null
+++ b/android/app/src/main/java/org/terst/doot/widget/work/DeferWorker.kt
@@ -0,0 +1,50 @@
+package org.terst.doot.widget.work
+
+import android.content.Context
+import androidx.glance.appwidget.updateAll
+import androidx.work.*
+import kotlinx.coroutines.flow.first
+import org.terst.doot.widget.ui.DootWidget
+import org.terst.doot.widget.data.Keys
+import org.terst.doot.widget.data.WidgetRepository
+import org.terst.doot.widget.data.dataStore
+
+// Mirrors CompleteWorker's shape -- see that file for the enqueueUniqueWork
+// rationale (same rapid-double-tap race applies here).
+class DeferWorker(context: Context, params: WorkerParameters) :
+ CoroutineWorker(context, params) {
+
+ override suspend fun doWork(): Result {
+ val id = inputData.getString(KEY_ID) ?: return Result.failure()
+
+ val prefs = applicationContext.dataStore.data.first()
+ val url = prefs[Keys.SERVER_URL]?.takeIf { it.isNotBlank() } ?: return Result.failure()
+ val token = prefs[Keys.TOKEN]?.takeIf { it.isNotBlank() } ?: return Result.failure()
+
+ val repo = WidgetRepository(okhttp3.OkHttpClient(), url, token)
+ return repo.defer(id).fold(
+ onSuccess = {
+ repo.fetchAndPersist(applicationContext)
+ DootWidget().updateAll(applicationContext)
+ Result.success()
+ },
+ onFailure = { Result.retry() }
+ )
+ }
+
+ companion object {
+ const val KEY_ID = "item_id"
+
+ fun enqueue(context: Context, id: String) {
+ val data = workDataOf(KEY_ID to id)
+ val request = OneTimeWorkRequestBuilder<DeferWorker>()
+ .setInputData(data)
+ .build()
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ "defer_$id",
+ ExistingWorkPolicy.KEEP,
+ request
+ )
+ }
+ }
+}
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index 9691d5b..893a46f 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -328,6 +328,7 @@ func main() {
// Unified task completion (for Tasks tab Atoms)
r.Post("/complete-atom", h.HandleCompleteAtom)
r.Post("/uncomplete-atom", h.HandleUncompleteAtom)
+ r.Post("/defer-atom", h.HandleDeferAtom)
// Unified Quick Add (for Tasks tab)
r.Post("/unified-add", h.HandleUnifiedAdd)
@@ -395,6 +396,16 @@ func main() {
r.With(widgetAuth).Post("/api/widget/task/estimate", h.HandleWidgetTaskEstimate)
r.With(widgetAuth).Post("/api/widget/projects/budget-tracked", h.HandleWidgetProjectsBudgetTracked)
r.With(widgetAuth).Post("/api/widget/labels/budget-tracked", h.HandleWidgetLabelsBudgetTracked)
+ r.With(widgetAuth).Post("/api/widget/chains", h.HandleWidgetChainsCreate)
+ r.With(widgetAuth).Get("/api/widget/chains/{id}", h.HandleWidgetChainGet)
+ r.With(widgetAuth).Post("/api/widget/chains/{id}/pause", h.HandleWidgetChainsPause)
+ r.With(widgetAuth).Post("/api/widget/chains/{id}/resume", h.HandleWidgetChainsResume)
+ r.With(widgetAuth).Post("/api/widget/chains/{id}/abandon", h.HandleWidgetChainsAbandon)
+ r.With(widgetAuth).Get("/api/widget/buckets", h.HandleWidgetBucketsGet)
+ r.With(widgetAuth).Post("/api/widget/buckets", h.HandleWidgetBucketsCreate)
+ r.With(widgetAuth).Post("/api/widget/buckets/{id}/items", h.HandleWidgetBucketItemsAdd)
+ r.With(widgetAuth).Post("/api/widget/buckets/{id}/items/remove", h.HandleWidgetBucketItemsRemove)
+ r.With(widgetAuth).Post("/api/widget/task/defer", h.HandleWidgetTaskDefer)
} else {
log.Println("WIDGET_TOKEN not set — /api/widget disabled")
}
@@ -413,6 +424,7 @@ func main() {
// server's own lifecycle, cancelled alongside it on shutdown below).
schedulerCtx, cancelScheduler := context.WithCancel(context.Background())
go scheduler.RunRecurrenceCheck(schedulerCtx, db, 15*time.Minute)
+ go scheduler.RunBucketCycleCheck(schedulerCtx, db, 15*time.Minute)
// Graceful shutdown
go func() {
diff --git a/docs/superpowers/plans/2026-07-17-linear-task-chains.md b/docs/superpowers/plans/2026-07-17-linear-task-chains.md
new file mode 100644
index 0000000..ebb576a
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-17-linear-task-chains.md
@@ -0,0 +1,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`).
diff --git a/docs/superpowers/plans/2026-07-17-recurring-maintenance-buckets.md b/docs/superpowers/plans/2026-07-17-recurring-maintenance-buckets.md
new file mode 100644
index 0000000..c78783e
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-17-recurring-maintenance-buckets.md
@@ -0,0 +1,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).
diff --git a/docs/superpowers/specs/2026-07-15-linear-task-chains-design.md b/docs/superpowers/specs/2026-07-15-linear-task-chains-design.md
index a66d828..98198b2 100644
--- a/docs/superpowers/specs/2026-07-15-linear-task-chains-design.md
+++ b/docs/superpowers/specs/2026-07-15-linear-task-chains-design.md
@@ -1,6 +1,6 @@
# Linear Task Chains with WIP Limits — Design
-**Status:** Backlog idea, refined via interview 2026-07-15. Not yet approved for implementation.
+**Status:** Implemented 2026-07-17. Backend (migration 026, store, HTTP API), web timeline position badge, and Android widget data layer + row badge all shipped with tests (`go test ./...` green). See `docs/superpowers/plans/2026-07-17-linear-task-chains.md` for the task breakdown. Not built: a dedicated Android checklist screen for browsing a full chain (locked + unlocked) -- the row-level badge and `GET /api/widget/chains/{id}` endpoint exist, but the standalone browsable view described in "Visibility" below is deferred, consistent with this spec's own "Web UI specifics for the chain/checklist view" being out of scope.
## Context
diff --git a/docs/superpowers/specs/2026-07-15-recurring-maintenance-buckets-design.md b/docs/superpowers/specs/2026-07-15-recurring-maintenance-buckets-design.md
index 5072643..2bfb5ba 100644
--- a/docs/superpowers/specs/2026-07-15-recurring-maintenance-buckets-design.md
+++ b/docs/superpowers/specs/2026-07-15-recurring-maintenance-buckets-design.md
@@ -1,6 +1,6 @@
# Recurring Maintenance Buckets — Design
-**Status:** Backlog idea, refined via interview 2026-07-15. Not yet approved for implementation.
+**Status:** Implemented 2026-07-17. Backend (migration 027, store, scheduler, HTTP API), web timeline Defer action, and Android widget data layer + row Defer button all shipped with tests (`go test ./...` green). See `docs/superpowers/plans/2026-07-17-recurring-maintenance-buckets.md` for the task breakdown. Not built: a dedicated bucket-management UI (create bucket / add-remove pool items screen) on either web or Android -- the CRUD API exists and is tested, but per this spec's own "Not designed in detail here" on UI, the management surface itself is deferred.
## Context
diff --git a/docs/superpowers/specs/2026-07-15-task-budgets-and-availability-design.md b/docs/superpowers/specs/2026-07-15-task-budgets-and-availability-design.md
index 923ef54..5cf2c04 100644
--- a/docs/superpowers/specs/2026-07-15-task-budgets-and-availability-design.md
+++ b/docs/superpowers/specs/2026-07-15-task-budgets-and-availability-design.md
@@ -1,6 +1,6 @@
# Task Budgets and Availability Times — Design
-**Status:** Backlog idea, refined via interview 2026-07-15. Not yet approved for implementation.
+**Status:** Implemented (migration 025, shipped 2026-07-17). Status header and the implementation plan's checkboxes were never updated after implementation -- corrected 2026-07-17.
## Context
diff --git a/docs/superpowers/specs/2026-07-15-task-labels-and-projects-design.md b/docs/superpowers/specs/2026-07-15-task-labels-and-projects-design.md
index 4144d5b..9c13094 100644
--- a/docs/superpowers/specs/2026-07-15-task-labels-and-projects-design.md
+++ b/docs/superpowers/specs/2026-07-15-task-labels-and-projects-design.md
@@ -1,6 +1,6 @@
# Task Labels and Projects — Design
-**Status:** Backlog idea, refined via interview 2026-07-15. Not yet approved for implementation.
+**Status:** Implemented (migration 024, shipped 2026-07-15). Status header was never updated after implementation -- corrected 2026-07-17 during the linear-task-chains/maintenance-buckets work, which depends on this being done.
## Context
diff --git a/internal/handlers/buckets_test.go b/internal/handlers/buckets_test.go
new file mode 100644
index 0000000..822083b
--- /dev/null
+++ b/internal/handlers/buckets_test.go
@@ -0,0 +1,188 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "task-dashboard/internal/models"
+)
+
+func TestHandleWidgetBucketsCreate_CreatesBucket(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ body := `{"name":"Gutters","cycle_days":30,"pick_n":2}`
+ req := httptest.NewRequest("POST", "/api/widget/buckets", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ h.HandleWidgetBucketsCreate(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+ var bucket models.MaintenanceBucket
+ if err := json.NewDecoder(w.Body).Decode(&bucket); err != nil {
+ t.Fatal(err)
+ }
+ if bucket.Name != "Gutters" || bucket.CycleDays != 30 || bucket.PickN != 2 {
+ t.Errorf("bucket = %+v", bucket)
+ }
+}
+
+func TestHandleWidgetBucketsCreate_InvalidPickN_Returns400(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ body := `{"name":"Gutters","cycle_days":30,"pick_n":0}`
+ req := httptest.NewRequest("POST", "/api/widget/buckets", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ h.HandleWidgetBucketsCreate(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandleWidgetBucketsGet_ReturnsBuckets(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+ if _, err := h.store.CreateBucket("Gutters", 30, 2); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/widget/buckets", nil)
+ w := httptest.NewRecorder()
+ h.HandleWidgetBucketsGet(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ var buckets []models.MaintenanceBucket
+ if err := json.NewDecoder(w.Body).Decode(&buckets); err != nil {
+ t.Fatal(err)
+ }
+ if len(buckets) != 1 || buckets[0].Name != "Gutters" {
+ t.Errorf("buckets = %+v", buckets)
+ }
+}
+
+func TestHandleWidgetBucketItemsAdd_AssignsTask(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+ bucket, err := h.store.CreateBucket("Gutters", 30, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ task := models.Task{ID: "task-1", Content: "Clean gutters", Priority: 1}
+ if err := h.store.CreateNativeTask(task); err != nil {
+ t.Fatal(err)
+ }
+
+ body := `{"task_id":"task-1"}`
+ req := withURLParam(httptest.NewRequest("POST", "/api/widget/buckets/"+bucket.ID+"/items", strings.NewReader(body)), "id", bucket.ID)
+ w := httptest.NewRecorder()
+ h.HandleWidgetBucketItemsAdd(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+ updated, err := h.store.GetNativeTaskByID("task-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updated.BucketID != bucket.ID || updated.BucketState != "dormant" {
+ t.Errorf("task = %+v", updated)
+ }
+}
+
+func TestHandleWidgetBucketItemsAdd_UnknownTask_Returns404(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+ bucket, err := h.store.CreateBucket("Gutters", 30, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ body := `{"task_id":"nope"}`
+ req := withURLParam(httptest.NewRequest("POST", "/api/widget/buckets/"+bucket.ID+"/items", strings.NewReader(body)), "id", bucket.ID)
+ w := httptest.NewRecorder()
+ h.HandleWidgetBucketItemsAdd(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", w.Code)
+ }
+}
+
+func TestHandleWidgetTaskDefer_ReturnsToDormant(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+ // pick_n=1 with a spare reserve item in the pool: activate one, then
+ // defer it -- the reserve is what backfill should pick, so the
+ // just-deferred item (now the pool's only dormant item at the moment
+ // selectBucketCycle would otherwise look) isn't immediately re-picked.
+ bucket, err := h.store.CreateBucket("Gutters", 30, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.CreateNativeTask(models.Task{ID: "task-1", Content: "task-1", Priority: 1}); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.AddBucketItem(bucket.ID, "task-1"); err != nil {
+ t.Fatal(err)
+ }
+ // Activate task-1 via a real cycle run so it's a legit active bucket item.
+ if _, err := h.store.RunBucketCycles(time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ // Add the reserve item AFTER the cycle runs, so it's still dormant when task-1 is deferred.
+ if err := h.store.CreateNativeTask(models.Task{ID: "reserve", Content: "reserve", Priority: 1}); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.AddBucketItem(bucket.ID, "reserve"); err != nil {
+ t.Fatal(err)
+ }
+
+ body := `{"id":"task-1"}`
+ req := httptest.NewRequest("POST", "/api/widget/task/defer", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ h.HandleWidgetTaskDefer(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+ updated, err := h.store.GetNativeTaskByID("task-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updated.BucketState != "dormant" {
+ t.Errorf("BucketState = %q, want dormant", updated.BucketState)
+ }
+}
+
+func TestHandleWidgetTaskDefer_NotABucketItem_Returns404(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+ task := models.Task{ID: "task-1", Content: "Plain task", Priority: 1}
+ if err := h.store.CreateNativeTask(task); err != nil {
+ t.Fatal(err)
+ }
+
+ body := `{"id":"task-1"}`
+ req := httptest.NewRequest("POST", "/api/widget/task/defer", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ h.HandleWidgetTaskDefer(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", w.Code)
+ }
+}
diff --git a/internal/handlers/chains_test.go b/internal/handlers/chains_test.go
new file mode 100644
index 0000000..0ebcc7a
--- /dev/null
+++ b/internal/handlers/chains_test.go
@@ -0,0 +1,161 @@
+package handlers
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+)
+
+func withURLParam(req *http.Request, key, value string) *http.Request {
+ rctx := chi.NewRouteContext()
+ rctx.URLParams.Add(key, value)
+ return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
+}
+
+func TestHandleWidgetChainsCreate_CreatesChain(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ body := `{"name":"Ham Radio Track","tasks":["Study Technician","Pass exam"]}`
+ req := httptest.NewRequest("POST", "/api/widget/chains", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ h.HandleWidgetChainsCreate(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+ var resp chainCreateResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatal(err)
+ }
+ if resp.ID == "" {
+ t.Fatal("expected a generated chain id")
+ }
+
+ tasks, err := h.store.GetChainTasks(resp.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(tasks) != 2 {
+ t.Fatalf("len(tasks) = %d, want 2", len(tasks))
+ }
+}
+
+func TestHandleWidgetChainsCreate_EmptyTasks_Returns400(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ body := `{"name":"Empty","tasks":[]}`
+ req := httptest.NewRequest("POST", "/api/widget/chains", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ h.HandleWidgetChainsCreate(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandleWidgetChainGet_ReturnsChainAndTasks(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ chain, err := h.store.CreateChain("Track", []string{"Step 1", "Step 2"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ req := withURLParam(httptest.NewRequest("GET", "/api/widget/chains/"+chain.ID, nil), "id", chain.ID)
+ w := httptest.NewRecorder()
+ h.HandleWidgetChainGet(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+ var resp chainGetResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatal(err)
+ }
+ if len(resp.Tasks) != 2 || resp.Chain.ID != chain.ID {
+ t.Errorf("resp = %+v", resp)
+ }
+}
+
+func TestHandleWidgetChainGet_UnknownID_Returns404(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ req := withURLParam(httptest.NewRequest("GET", "/api/widget/chains/nope", nil), "id", "nope")
+ w := httptest.NewRecorder()
+ h.HandleWidgetChainGet(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", w.Code)
+ }
+}
+
+func TestHandleWidgetChainsPauseResumeAbandon(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ chain, err := h.store.CreateChain("Track", []string{"Step 1", "Step 2"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ pauseReq := withURLParam(httptest.NewRequest("POST", "/api/widget/chains/"+chain.ID+"/pause", nil), "id", chain.ID)
+ w := httptest.NewRecorder()
+ h.HandleWidgetChainsPause(w, pauseReq)
+ if w.Code != http.StatusOK {
+ t.Fatalf("pause status = %d, want 200", w.Code)
+ }
+ paused, err := h.store.GetChain(chain.ID)
+ if err != nil || paused.Status != "paused" {
+ t.Fatalf("chain after pause = %+v, err=%v", paused, err)
+ }
+
+ resumeReq := withURLParam(httptest.NewRequest("POST", "/api/widget/chains/"+chain.ID+"/resume", nil), "id", chain.ID)
+ w = httptest.NewRecorder()
+ h.HandleWidgetChainsResume(w, resumeReq)
+ if w.Code != http.StatusOK {
+ t.Fatalf("resume status = %d, want 200", w.Code)
+ }
+ resumed, err := h.store.GetChain(chain.ID)
+ if err != nil || resumed.Status != "active" {
+ t.Fatalf("chain after resume = %+v, err=%v", resumed, err)
+ }
+
+ abandonReq := withURLParam(httptest.NewRequest("POST", "/api/widget/chains/"+chain.ID+"/abandon", nil), "id", chain.ID)
+ w = httptest.NewRecorder()
+ h.HandleWidgetChainsAbandon(w, abandonReq)
+ if w.Code != http.StatusOK {
+ t.Fatalf("abandon status = %d, want 200", w.Code)
+ }
+ abandoned, err := h.store.GetChain(chain.ID)
+ if err != nil || abandoned.Status != "abandoned" {
+ t.Fatalf("chain after abandon = %+v, err=%v", abandoned, err)
+ }
+}
+
+func TestHandleWidgetChainsPause_UnknownID_Returns404(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db}
+
+ req := withURLParam(httptest.NewRequest("POST", "/api/widget/chains/nope/pause", nil), "id", "nope")
+ w := httptest.NewRecorder()
+ h.HandleWidgetChainsPause(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", w.Code)
+ }
+}
diff --git a/internal/handlers/chains_timeline_test.go b/internal/handlers/chains_timeline_test.go
new file mode 100644
index 0000000..76ceb68
--- /dev/null
+++ b/internal/handlers/chains_timeline_test.go
@@ -0,0 +1,43 @@
+package handlers
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestBuildTimeline_PopulatesChainBadgeForUnlockedTask(t *testing.T) {
+ s, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ chain, err := s.CreateChain("Ham Radio Track", []string{"Study Technician", "Pass exam", "Study General"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ tasks, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ now := time.Now()
+ items, err := BuildTimeline(context.Background(), s, now.Add(-time.Hour), now.Add(24*time.Hour))
+ if err != nil {
+ t.Fatalf("BuildTimeline: %v", err)
+ }
+
+ var found bool
+ for _, item := range items {
+ if item.ID == tasks[0].ID {
+ found = true
+ if item.ChainPosition != 1 || item.ChainTotal != 3 {
+ t.Errorf("ChainPosition/ChainTotal = %d/%d, want 1/3", item.ChainPosition, item.ChainTotal)
+ }
+ }
+ if item.ID == tasks[1].ID || item.ID == tasks[2].ID {
+ t.Errorf("locked chain task %q should not appear in the timeline", item.ID)
+ }
+ }
+ if !found {
+ t.Fatal("expected the unlocked chain task (position 0) to appear in the timeline")
+ }
+}
diff --git a/internal/handlers/defer_atom_test.go b/internal/handlers/defer_atom_test.go
new file mode 100644
index 0000000..1a626d5
--- /dev/null
+++ b/internal/handlers/defer_atom_test.go
@@ -0,0 +1,94 @@
+package handlers
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "task-dashboard/internal/config"
+ "task-dashboard/internal/models"
+)
+
+func TestHandleDeferAtom_ReturnsActiveBucketItemToDormant(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db, config: &config.Config{}}
+
+ // pick_n=1 plus a reserve item added after the cycle runs, so deferring
+ // task-1 backfills with the reserve rather than immediately re-picking
+ // task-1 itself (the only-item-in-pool case is a degenerate edge case
+ // covered at the store layer).
+ bucket, err := h.store.CreateBucket("Gutters", 30, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.CreateNativeTask(models.Task{ID: "task-1", Content: "Clean gutters", Priority: 1}); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.AddBucketItem(bucket.ID, "task-1"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := h.store.RunBucketCycles(time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.CreateNativeTask(models.Task{ID: "reserve", Content: "reserve", Priority: 1}); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.store.AddBucketItem(bucket.ID, "reserve"); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("POST", "/defer-atom", nil)
+ req.Form = map[string][]string{"id": {"task-1"}}
+ w := httptest.NewRecorder()
+ h.HandleDeferAtom(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
+ }
+ if w.Header().Get("HX-Trigger") != "refresh-tasks" {
+ t.Errorf("HX-Trigger = %q, want refresh-tasks", w.Header().Get("HX-Trigger"))
+ }
+ updated, err := h.store.GetNativeTaskByID("task-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updated.BucketState != "dormant" {
+ t.Errorf("BucketState = %q, want dormant", updated.BucketState)
+ }
+}
+
+func TestHandleDeferAtom_MissingID_Returns400(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db, config: &config.Config{}}
+
+ req := httptest.NewRequest("POST", "/defer-atom", nil)
+ req.Form = map[string][]string{"id": {""}}
+ w := httptest.NewRecorder()
+ h.HandleDeferAtom(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", w.Code)
+ }
+}
+
+func TestHandleDeferAtom_NotABucketItem_Returns500(t *testing.T) {
+ db, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: db, config: &config.Config{}}
+
+ if err := h.store.CreateNativeTask(models.Task{ID: "task-1", Content: "Plain task", Priority: 1}); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("POST", "/defer-atom", nil)
+ req.Form = map[string][]string{"id": {"task-1"}}
+ w := httptest.NewRecorder()
+ h.HandleDeferAtom(w, req)
+
+ if w.Code != http.StatusInternalServerError {
+ t.Fatalf("status = %d, want 500", w.Code)
+ }
+}
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index e427e40..343d0b1 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -488,6 +488,31 @@ func (h *Handler) HandleCompleteCard(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
+// HandleDeferAtom returns an active maintenance-bucket item to its pool
+// without crediting it as done -- distinct from complete/uncomplete.
+// Doot-native tasks only (bucket items don't exist for other sources).
+// No special swap needed: a successful defer removes the task's due date,
+// so the same timeline refresh that follows completion just makes it
+// disappear from the current view like any other now-undated task would.
+func (h *Handler) HandleDeferAtom(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ JSONError(w, http.StatusBadRequest, "Failed to parse form", err)
+ return
+ }
+ id := r.FormValue("id")
+ if id == "" {
+ JSONError(w, http.StatusBadRequest, "Missing id", nil)
+ return
+ }
+ if err := h.store.DeferNativeTask(id); err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to defer task", err)
+ return
+ }
+ w.Header().Set("HX-Reswap", "none")
+ w.Header().Set("HX-Trigger", "refresh-tasks")
+ w.WriteHeader(http.StatusOK)
+}
+
// HandleCompleteAtom handles completion of a unified task (Atom)
func (h *Handler) HandleCompleteAtom(w http.ResponseWriter, r *http.Request) {
h.handleAtomToggle(w, r, true)
diff --git a/internal/handlers/timeline_logic.go b/internal/handlers/timeline_logic.go
index b90b61e..e3da760 100644
--- a/internal/handlers/timeline_logic.go
+++ b/internal/handlers/timeline_logic.go
@@ -12,6 +12,22 @@ import (
"task-dashboard/internal/store"
)
+// setChainBadge populates item.ChainPosition/ChainTotal (1-indexed) when
+// task belongs to a chain. Only the currently-unlocked task in a chain ever
+// reaches BuildTimeline (locked tasks are excluded at the store layer), so
+// this runs at most once per chain per call -- no memoization needed.
+func setChainBadge(s *store.Store, item *models.TimelineItem, task models.Task) {
+ if task.ChainID == "" {
+ return
+ }
+ tasks, err := s.GetChainTasks(task.ChainID)
+ if err != nil {
+ return
+ }
+ item.ChainPosition = task.ChainPosition + 1
+ item.ChainTotal = len(tasks)
+}
+
// BuildTimeline aggregates and normalizes data into a timeline structure
func BuildTimeline(ctx context.Context, s *store.Store, start, end time.Time) ([]models.TimelineItem, error) {
var items []models.TimelineItem
@@ -159,6 +175,8 @@ func BuildTimeline(ctx context.Context, s *store.Store, start, end time.Time) ([
Source: "doot",
ProjectColor: projectColors[task.ProjectID],
}
+ setChainBadge(s, &item, task)
+ item.BucketState = task.BucketState
item.ComputeDaySection(now)
items = append(items, item)
}
@@ -183,6 +201,8 @@ func BuildTimeline(ctx context.Context, s *store.Store, start, end time.Time) ([
Source: "doot",
ProjectColor: projectColors[task.ProjectID],
}
+ setChainBadge(s, &item, task)
+ item.BucketState = task.BucketState
item.ComputeDaySection(now)
items = append(items, item)
}
@@ -205,6 +225,8 @@ func BuildTimeline(ctx context.Context, s *store.Store, start, end time.Time) ([
IsAllDay: true,
ProjectColor: projectColors[task.ProjectID],
}
+ setChainBadge(s, &item, task)
+ item.BucketState = task.BucketState
item.ComputeDaySection(now)
items = append(items, item)
}
diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go
index a2a0837..de2e856 100644
--- a/internal/handlers/widget.go
+++ b/internal/handlers/widget.go
@@ -8,6 +8,8 @@ import (
"strings"
"time"
+ "github.com/go-chi/chi/v5"
+
"task-dashboard/internal/config"
"task-dashboard/internal/models"
"task-dashboard/internal/store"
@@ -40,6 +42,9 @@ func TimelineItemToWidgetItem(item models.TimelineItem) models.WidgetItem {
IsOverdue: item.IsOverdue,
URL: item.URL,
RecurringEventID: item.RecurringEventID,
+ ChainPosition: item.ChainPosition,
+ ChainTotal: item.ChainTotal,
+ BucketState: item.BucketState,
}
switch item.Type {
@@ -956,3 +961,197 @@ func (h *Handler) HandleWidgetLabelsBudgetTracked(w http.ResponseWriter, r *http
}
w.WriteHeader(http.StatusOK)
}
+
+type chainCreateRequest struct {
+ Name string `json:"name"`
+ Tasks []string `json:"tasks"`
+}
+
+type chainCreateResponse struct {
+ ID string `json:"id"`
+}
+
+// HandleWidgetChainsCreate creates a linear task chain: a backing project
+// plus one native_tasks row per title in req.Tasks, position 0 unlocked and
+// due now, the rest locked with no due date.
+func (h *Handler) HandleWidgetChainsCreate(w http.ResponseWriter, r *http.Request) {
+ var req chainCreateRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if strings.TrimSpace(req.Name) == "" || len(req.Tasks) == 0 {
+ http.Error(w, "name and at least one task are required", http.StatusBadRequest)
+ return
+ }
+ chain, err := h.store.CreateChain(req.Name, req.Tasks)
+ if err != nil {
+ http.Error(w, "failed to create chain", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(chainCreateResponse{ID: chain.ID})
+}
+
+// handleWidgetChainSetStatus is the shared body for pause/resume/abandon --
+// each just sets a different status string on the chain named by the {id}
+// URL param.
+func (h *Handler) handleWidgetChainSetStatus(w http.ResponseWriter, r *http.Request, status string) {
+ id := chi.URLParam(r, "id")
+ if err := h.store.SetChainStatus(id, status); err != nil {
+ if errors.Is(err, store.ErrNativeTaskNotFound) {
+ http.Error(w, "chain not found", http.StatusNotFound)
+ return
+ }
+ http.Error(w, "failed to update chain", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// HandleWidgetChainsPause pauses a chain: the currently-unlocked task stays
+// actionable, but completing it will not auto-advance until resumed.
+func (h *Handler) HandleWidgetChainsPause(w http.ResponseWriter, r *http.Request) {
+ h.handleWidgetChainSetStatus(w, r, "paused")
+}
+
+// HandleWidgetChainsResume reactivates a paused chain.
+func (h *Handler) HandleWidgetChainsResume(w http.ResponseWriter, r *http.Request) {
+ h.handleWidgetChainSetStatus(w, r, "active")
+}
+
+// HandleWidgetChainsAbandon marks a chain abandoned -- a terminal state
+// distinguishable from "completed" in queries/reporting.
+func (h *Handler) HandleWidgetChainsAbandon(w http.ResponseWriter, r *http.Request) {
+ h.handleWidgetChainSetStatus(w, r, "abandoned")
+}
+
+type chainGetResponse struct {
+ Chain models.Chain `json:"chain"`
+ Tasks []models.Task `json:"tasks"`
+}
+
+// HandleWidgetChainGet returns the full ordered checklist for a chain --
+// locked and unlocked tasks both, per the design's "visible in the tasks
+// list" requirement met via this dedicated surface.
+func (h *Handler) HandleWidgetChainGet(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ chain, err := h.store.GetChain(id)
+ if err != nil {
+ if errors.Is(err, store.ErrNativeTaskNotFound) {
+ http.Error(w, "chain not found", http.StatusNotFound)
+ return
+ }
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ tasks, err := h.store.GetChainTasks(id)
+ if err != nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(chainGetResponse{Chain: *chain, Tasks: tasks})
+}
+
+type bucketCreateRequest struct {
+ Name string `json:"name"`
+ CycleDays int `json:"cycle_days"`
+ PickN int `json:"pick_n"`
+}
+
+// HandleWidgetBucketsGet returns every maintenance bucket.
+func (h *Handler) HandleWidgetBucketsGet(w http.ResponseWriter, r *http.Request) {
+ buckets, err := h.store.GetBuckets()
+ if err != nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(buckets)
+}
+
+// HandleWidgetBucketsCreate creates a new maintenance bucket.
+func (h *Handler) HandleWidgetBucketsCreate(w http.ResponseWriter, r *http.Request) {
+ var req bucketCreateRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if strings.TrimSpace(req.Name) == "" || req.CycleDays <= 0 || req.PickN <= 0 {
+ http.Error(w, "name, a positive cycle_days, and a positive pick_n are required", http.StatusBadRequest)
+ return
+ }
+ bucket, err := h.store.CreateBucket(req.Name, req.CycleDays, req.PickN)
+ if err != nil {
+ http.Error(w, "failed to create bucket", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(bucket)
+}
+
+type bucketItemRequest struct {
+ TaskID string `json:"task_id"`
+}
+
+// HandleWidgetBucketItemsAdd assigns an existing task to a bucket's pool.
+func (h *Handler) HandleWidgetBucketItemsAdd(w http.ResponseWriter, r *http.Request) {
+ bucketID := chi.URLParam(r, "id")
+ var req bucketItemRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if err := h.store.AddBucketItem(bucketID, req.TaskID); err != nil {
+ if errors.Is(err, store.ErrNativeTaskNotFound) {
+ http.Error(w, "task not found", http.StatusNotFound)
+ return
+ }
+ http.Error(w, "failed to add bucket item", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// HandleWidgetBucketItemsRemove clears a task's bucket membership.
+func (h *Handler) HandleWidgetBucketItemsRemove(w http.ResponseWriter, r *http.Request) {
+ var req bucketItemRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if err := h.store.RemoveBucketItem(req.TaskID); err != nil {
+ if errors.Is(err, store.ErrNativeTaskNotFound) {
+ http.Error(w, "task not found", http.StatusNotFound)
+ return
+ }
+ http.Error(w, "failed to remove bucket item", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+type taskDeferRequest struct {
+ ID string `json:"id"`
+}
+
+// HandleWidgetTaskDefer returns an active bucket item to its pool without
+// crediting it as done -- distinct from Complete -- and triggers a fresh
+// selection to backfill the freed slot.
+func (h *Handler) HandleWidgetTaskDefer(w http.ResponseWriter, r *http.Request) {
+ var req taskDeferRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if err := h.store.DeferNativeTask(req.ID); err != nil {
+ if errors.Is(err, store.ErrNativeTaskNotFound) {
+ http.Error(w, "task not found or not an active bucket item", http.StatusNotFound)
+ return
+ }
+ http.Error(w, "failed to defer task", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
diff --git a/internal/models/timeline.go b/internal/models/timeline.go
index 1313712..4b90856 100644
--- a/internal/models/timeline.go
+++ b/internal/models/timeline.go
@@ -45,6 +45,9 @@ type TimelineItem struct {
ListID string `json:"list_id,omitempty"` // For Google Tasks
RecurringEventID string `json:"recurring_event_id,omitempty"` // For calendar events
ProjectColor string `json:"project_color,omitempty"` // For doot-native tasks with a project assigned
+ ChainPosition int `json:"chain_position,omitempty"` // For doot-native tasks that belong to a linear chain (1-indexed for display)
+ ChainTotal int `json:"chain_total,omitempty"` // Total tasks in the chain, paired with ChainPosition
+ BucketState string `json:"bucket_state,omitempty"` // "active" for doot-native tasks that are a maintenance-bucket item (dormant items never reach the timeline)
}
// ComputeDaySection sets the DaySection, IsOverdue, and IsAllDay based on the item's time
diff --git a/internal/models/types.go b/internal/models/types.go
index e3164de..8eda33b 100644
--- a/internal/models/types.go
+++ b/internal/models/types.go
@@ -28,6 +28,39 @@ type Task struct {
RecurrenceWeekdays []int `json:"recurrence_weekdays,omitempty"`
RecurrenceSeriesID string `json:"recurrence_series_id,omitempty"`
NextOccurrenceOverride *time.Time `json:"next_occurrence_override,omitempty"`
+
+ // Chain membership (doot-native tasks only). ChainID != "" is the
+ // indicator that a task belongs to a linear task chain.
+ ChainID string `json:"chain_id,omitempty"`
+ ChainPosition int `json:"chain_position,omitempty"`
+ ChainUnlocked bool `json:"chain_unlocked,omitempty"`
+
+ // Maintenance bucket membership (doot-native tasks only). BucketID != ""
+ // is the indicator that a task is part of a bucket's pool.
+ BucketID string `json:"bucket_id,omitempty"`
+ BucketState string `json:"bucket_state,omitempty"` // "dormant" | "active"
+ BucketLastActiveAt *time.Time `json:"bucket_last_active_at,omitempty"`
+}
+
+// Chain is a fixed, ordered sequence of native tasks with a WIP limit of
+// exactly 1 -- only one task in the chain is ever unlocked/actionable.
+type Chain struct {
+ ID string `json:"id"`
+ ProjectID string `json:"project_id"`
+ Status string `json:"status"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// MaintenanceBucket is a finite pool of native tasks that get repeatedly
+// activated/deactivated: every CycleDays, PickN dormant items are selected
+// and flipped active with a computed due date.
+type MaintenanceBucket struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ CycleDays int `json:"cycle_days"`
+ PickN int `json:"pick_n"`
+ LastCycleAt *time.Time `json:"last_cycle_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
}
// Project is a lightweight grouping for doot-native tasks. Exactly one
diff --git a/internal/models/widget.go b/internal/models/widget.go
index bf148fd..1ac082e 100644
--- a/internal/models/widget.go
+++ b/internal/models/widget.go
@@ -19,6 +19,9 @@ type WidgetItem struct {
Completable bool `json:"completable"` // true = doot task (checkbox shown)
RecurringEventID string `json:"recurring_event_id,omitempty"`
ProjectColor *string `json:"project_color,omitempty"`
+ ChainPosition int `json:"chain_position,omitempty"` // 1-indexed; 0 = not a chain task
+ ChainTotal int `json:"chain_total,omitempty"`
+ BucketState string `json:"bucket_state,omitempty"` // "active" when this is a maintenance-bucket item
}
// WidgetResponse is the full /api/widget response body.
diff --git a/internal/scheduler/buckets.go b/internal/scheduler/buckets.go
new file mode 100644
index 0000000..5fa242d
--- /dev/null
+++ b/internal/scheduler/buckets.go
@@ -0,0 +1,33 @@
+package scheduler
+
+import (
+ "context"
+ "log"
+ "time"
+
+ "task-dashboard/internal/config"
+ "task-dashboard/internal/store"
+)
+
+// RunBucketCycleCheck ticks every interval, calling RunBucketCycles until
+// ctx is cancelled. Mirrors RunRecurrenceCheck's shape -- errors are
+// logged, not fatal, so one bad tick doesn't kill the loop.
+func RunBucketCycleCheck(ctx context.Context, s *store.Store, interval time.Duration) {
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ n, err := s.RunBucketCycles(config.Now())
+ if err != nil {
+ log.Printf("ERROR [BucketCycleCheck]: %v", err)
+ continue
+ }
+ if n > 0 {
+ log.Printf("BucketCycleCheck: activated %d item(s)", n)
+ }
+ }
+ }
+}
diff --git a/internal/store/buckets.go b/internal/store/buckets.go
new file mode 100644
index 0000000..8bbcca9
--- /dev/null
+++ b/internal/store/buckets.go
@@ -0,0 +1,192 @@
+package store
+
+import (
+ "database/sql"
+ "time"
+
+ "task-dashboard/internal/config"
+ "task-dashboard/internal/models"
+)
+
+// CreateBucket inserts a new maintenance bucket and returns it.
+func (s *Store) CreateBucket(name string, cycleDays, pickN int) (*models.MaintenanceBucket, error) {
+ id := newTaskID()
+ if _, err := s.db.Exec(`
+ INSERT INTO maintenance_buckets (id, name, cycle_days, pick_n) VALUES (?, ?, ?, ?)
+ `, id, name, cycleDays, pickN); err != nil {
+ return nil, err
+ }
+ return &models.MaintenanceBucket{ID: id, Name: name, CycleDays: cycleDays, PickN: pickN}, nil
+}
+
+// GetBuckets returns every maintenance bucket, alphabetically by name.
+func (s *Store) GetBuckets() ([]models.MaintenanceBucket, error) {
+ rows, err := s.db.Query(`
+ SELECT id, name, cycle_days, pick_n, last_cycle_at, created_at FROM maintenance_buckets ORDER BY name ASC
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ var buckets []models.MaintenanceBucket
+ for rows.Next() {
+ var b models.MaintenanceBucket
+ var lastCycleAt sql.NullTime
+ if err := rows.Scan(&b.ID, &b.Name, &b.CycleDays, &b.PickN, &lastCycleAt, &b.CreatedAt); err != nil {
+ return nil, err
+ }
+ if lastCycleAt.Valid {
+ b.LastCycleAt = &lastCycleAt.Time
+ }
+ buckets = append(buckets, b)
+ }
+ return buckets, rows.Err()
+}
+
+// GetBucketByID returns a single bucket by id, or ErrNativeTaskNotFound.
+func (s *Store) GetBucketByID(id string) (*models.MaintenanceBucket, error) {
+ var b models.MaintenanceBucket
+ var lastCycleAt sql.NullTime
+ err := s.db.QueryRow(`
+ SELECT id, name, cycle_days, pick_n, last_cycle_at, created_at FROM maintenance_buckets WHERE id = ?
+ `, id).Scan(&b.ID, &b.Name, &b.CycleDays, &b.PickN, &lastCycleAt, &b.CreatedAt)
+ if err == sql.ErrNoRows {
+ return nil, ErrNativeTaskNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ if lastCycleAt.Valid {
+ b.LastCycleAt = &lastCycleAt.Time
+ }
+ return &b, nil
+}
+
+// AddBucketItem assigns an existing task to a bucket's pool: sets bucket_id
+// and bucket_state = 'dormant', clearing any due date (dormant items are
+// invisible to date-based views, per the design). Returns
+// ErrNativeTaskNotFound if taskID doesn't match any row.
+func (s *Store) AddBucketItem(bucketID, taskID string) error {
+ result, err := s.db.Exec(`
+ UPDATE native_tasks SET bucket_id = ?, bucket_state = 'dormant', due_date = NULL, updated_at = ? WHERE id = ?
+ `, bucketID, config.Now(), taskID)
+ if err != nil {
+ return err
+ }
+ return checkRowsAffected(result)
+}
+
+// RemoveBucketItem clears a task's bucket membership entirely. Returns
+// ErrNativeTaskNotFound if taskID doesn't match any row.
+func (s *Store) RemoveBucketItem(taskID string) error {
+ result, err := s.db.Exec(`
+ UPDATE native_tasks SET bucket_id = '', bucket_state = '', bucket_last_active_at = NULL, updated_at = ? WHERE id = ?
+ `, config.Now(), taskID)
+ if err != nil {
+ return err
+ }
+ return checkRowsAffected(result)
+}
+
+// selectBucketCycle activates the top pick_n dormant items in the bucket's
+// pool, scored by staleness (never-activated items first, then oldest
+// bucket_last_active_at) with task priority as a tiebreaker. Activated items
+// get due_date = now + cycle_days and bucket_last_active_at = now. Always
+// updates the bucket's last_cycle_at, even if nothing was activated (an
+// empty pool shouldn't cause every subsequent tick to re-scan it). Returns
+// the number of items activated.
+func (s *Store) selectBucketCycle(bucketID string, now time.Time) (int, error) {
+ bucket, err := s.GetBucketByID(bucketID)
+ if err != nil {
+ return 0, err
+ }
+
+ rows, err := s.db.Query(`
+ SELECT id FROM native_tasks
+ WHERE bucket_id = ? AND bucket_state = 'dormant'
+ ORDER BY (bucket_last_active_at IS NULL) DESC, bucket_last_active_at ASC, priority DESC
+ LIMIT ?
+ `, bucketID, bucket.PickN)
+ if err != nil {
+ return 0, err
+ }
+ var ids []string
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ _ = rows.Close()
+ return 0, err
+ }
+ ids = append(ids, id)
+ }
+ if err := rows.Err(); err != nil {
+ return 0, err
+ }
+ _ = rows.Close()
+
+ dueDate := now.AddDate(0, 0, bucket.CycleDays)
+ for _, id := range ids {
+ if _, err := s.db.Exec(`
+ UPDATE native_tasks SET bucket_state = 'active', due_date = ?, bucket_last_active_at = ?, updated_at = ? WHERE id = ?
+ `, dueDate, now, now, id); err != nil {
+ return 0, err
+ }
+ }
+
+ if _, err := s.db.Exec(`UPDATE maintenance_buckets SET last_cycle_at = ? WHERE id = ?`, now, bucketID); err != nil {
+ return 0, err
+ }
+ return len(ids), nil
+}
+
+// RunBucketCycles runs selectBucketCycle for every bucket whose cycle is
+// due (last_cycle_at is unset, or at least cycle_days old). Mirrors
+// AdvanceDueRecurringTasks's shape as the scheduler's entry point. Returns
+// the total number of items activated across all due buckets.
+func (s *Store) RunBucketCycles(now time.Time) (int, error) {
+ buckets, err := s.GetBuckets()
+ if err != nil {
+ return 0, err
+ }
+ total := 0
+ for _, b := range buckets {
+ due := b.LastCycleAt == nil || !b.LastCycleAt.AddDate(0, 0, b.CycleDays).After(now)
+ if !due {
+ continue
+ }
+ n, err := s.selectBucketCycle(b.ID, now)
+ if err != nil {
+ return total, err
+ }
+ total += n
+ }
+ return total, nil
+}
+
+// DeferNativeTask returns an active bucket item to the pool without
+// crediting it as done: bucket_last_active_at is left at its prior value
+// (still relatively stale, likely to be reselected soon) -- unlike
+// completing it, which stamps bucket_last_active_at = now via
+// CompleteNativeTask. Immediately triggers a fresh selection for the
+// task's bucket to backfill the freed slot. Returns ErrNativeTaskNotFound
+// if id doesn't match any row, or if the task isn't an active bucket item.
+func (s *Store) DeferNativeTask(id string) error {
+ task, err := s.GetNativeTaskByID(id)
+ if err != nil {
+ return err
+ }
+ if task.BucketID == "" || task.BucketState != "active" {
+ return ErrNativeTaskNotFound
+ }
+
+ now := config.Now()
+ if _, err := s.db.Exec(`
+ UPDATE native_tasks SET bucket_state = 'dormant', due_date = NULL, updated_at = ? WHERE id = ?
+ `, now, id); err != nil {
+ return err
+ }
+
+ _, err = s.selectBucketCycle(task.BucketID, now)
+ return err
+}
diff --git a/internal/store/buckets_test.go b/internal/store/buckets_test.go
new file mode 100644
index 0000000..936c384
--- /dev/null
+++ b/internal/store/buckets_test.go
@@ -0,0 +1,211 @@
+package store
+
+import (
+ "testing"
+ "time"
+)
+
+func createDormantTask(t *testing.T, s *Store, id, bucketID string, priority int, lastActive *time.Time) {
+ t.Helper()
+ if _, err := s.db.Exec(`
+ INSERT INTO native_tasks (id, content, priority, bucket_id, bucket_state, bucket_last_active_at)
+ VALUES (?, ?, ?, ?, 'dormant', ?)
+ `, id, id, priority, bucketID, lastActive); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestSelectBucketCycle_PicksTopNByStalenessThenPriority(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ bucket, err := s.CreateBucket("Gutters", 30, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ recent := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
+ createDormantTask(t, s, "never-activated", bucket.ID, 1, nil)
+ createDormantTask(t, s, "stale", bucket.ID, 1, &old)
+ createDormantTask(t, s, "recent", bucket.ID, 1, &recent)
+
+ now := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
+ n, err := s.selectBucketCycle(bucket.ID, now)
+ if err != nil {
+ t.Fatalf("selectBucketCycle: %v", err)
+ }
+ if n != 2 {
+ t.Fatalf("activated = %d, want 2", n)
+ }
+
+ neverTask, err := s.GetNativeTaskByID("never-activated")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if neverTask.BucketState != "active" {
+ t.Errorf("never-activated should be picked first (never activated outranks any timestamp), got state=%q", neverTask.BucketState)
+ }
+ staleTask, err := s.GetNativeTaskByID("stale")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if staleTask.BucketState != "active" {
+ t.Errorf("stale should be picked second, got state=%q", staleTask.BucketState)
+ }
+ recentTask, err := s.GetNativeTaskByID("recent")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if recentTask.BucketState != "dormant" {
+ t.Errorf("recent should NOT be picked (only pick_n=2 slots), got state=%q", recentTask.BucketState)
+ }
+ if neverTask.DueDate == nil || !neverTask.DueDate.Equal(now.AddDate(0, 0, 30)) {
+ t.Errorf("DueDate = %v, want now + cycle_days", neverTask.DueDate)
+ }
+}
+
+func TestRunBucketCycles_RespectsCycleDays(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ bucket, err := s.CreateBucket("Gutters", 30, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ createDormantTask(t, s, "item-1", bucket.ID, 1, nil)
+
+ firstRun := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ n, err := s.RunBucketCycles(firstRun)
+ if err != nil {
+ t.Fatalf("RunBucketCycles: %v", err)
+ }
+ if n != 1 {
+ t.Fatalf("first run activated = %d, want 1", n)
+ }
+
+ // Complete it so it's dormant again, then check a too-soon second run doesn't reactivate it.
+ if err := s.CompleteNativeTask("item-1"); err != nil {
+ t.Fatal(err)
+ }
+ tooSoon := firstRun.AddDate(0, 0, 10)
+ n, err = s.RunBucketCycles(tooSoon)
+ if err != nil {
+ t.Fatalf("RunBucketCycles (too soon): %v", err)
+ }
+ if n != 0 {
+ t.Fatalf("too-soon run activated = %d, want 0 (cycle not due yet)", n)
+ }
+
+ dueRun := firstRun.AddDate(0, 0, 31)
+ n, err = s.RunBucketCycles(dueRun)
+ if err != nil {
+ t.Fatalf("RunBucketCycles (due): %v", err)
+ }
+ if n != 1 {
+ t.Fatalf("due run activated = %d, want 1", n)
+ }
+}
+
+func TestCompleteNativeTask_BucketItem_ReturnsToDormantWithNowTimestamp(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ bucket, err := s.CreateBucket("Gutters", 30, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ createDormantTask(t, s, "item-1", bucket.ID, 1, nil)
+ if _, err := s.selectBucketCycle(bucket.ID, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.CompleteNativeTask("item-1"); err != nil {
+ t.Fatalf("CompleteNativeTask: %v", err)
+ }
+
+ task, err := s.GetNativeTaskByID("item-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if task.BucketState != "dormant" {
+ t.Errorf("BucketState = %q, want dormant", task.BucketState)
+ }
+ if task.DueDate != nil {
+ t.Errorf("DueDate = %v, want nil after completing", task.DueDate)
+ }
+ if task.BucketLastActiveAt == nil {
+ t.Fatal("expected BucketLastActiveAt to be set to now on completion")
+ }
+ if task.BucketLastActiveAt.Before(time.Now().Add(-time.Minute)) {
+ t.Errorf("BucketLastActiveAt = %v, expected close to now (completion, not defer)", *task.BucketLastActiveAt)
+ }
+}
+
+func TestDeferNativeTask_ReturnsToDormantWithPriorTimestampAndBackfills(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ bucket, err := s.CreateBucket("Gutters", 30, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ createDormantTask(t, s, "item-1", bucket.ID, 1, &old)
+ createDormantTask(t, s, "item-2", bucket.ID, 1, nil)
+ if _, err := s.selectBucketCycle(bucket.ID, time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)); err != nil {
+ t.Fatal(err)
+ }
+ // pick_n=1: item-2 (never activated) should have been picked, item-1 still dormant.
+ activeBefore, err := s.GetNativeTaskByID("item-2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if activeBefore.BucketState != "active" {
+ t.Fatalf("setup: expected item-2 active, got %q", activeBefore.BucketState)
+ }
+
+ if err := s.DeferNativeTask("item-2"); err != nil {
+ t.Fatalf("DeferNativeTask: %v", err)
+ }
+
+ deferred, err := s.GetNativeTaskByID("item-2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if deferred.BucketState != "dormant" {
+ t.Errorf("BucketState = %q, want dormant", deferred.BucketState)
+ }
+ activationTime := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
+ if deferred.BucketLastActiveAt == nil || !deferred.BucketLastActiveAt.Equal(activationTime) {
+ t.Errorf("BucketLastActiveAt = %v, want unchanged from activation time %v (defer must not touch it)", deferred.BucketLastActiveAt, activationTime)
+ }
+
+ // Backfill: item-1 (the only other dormant item) should now be active.
+ backfilled, err := s.GetNativeTaskByID("item-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if backfilled.BucketState != "active" {
+ t.Errorf("expected defer to backfill item-1 into the freed slot, got state=%q", backfilled.BucketState)
+ }
+}
+
+func TestDeferNativeTask_NotAnActiveBucketItem_ReturnsErrNotFound(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ if err := s.DeferNativeTask("real-1"); err != ErrNativeTaskNotFound {
+ t.Errorf("err = %v, want ErrNativeTaskNotFound", err)
+ }
+}
+
+func TestGetUndatedNativeTasks_ExcludesDormantBucketItems(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ bucket, err := s.CreateBucket("Gutters", 30, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ createDormantTask(t, s, "item-1", bucket.ID, 1, nil)
+
+ undated, err := s.GetUndatedNativeTasks()
+ if err != nil {
+ t.Fatalf("GetUndatedNativeTasks: %v", err)
+ }
+ for _, task := range undated {
+ if task.ID == "item-1" {
+ t.Error("dormant bucket item leaked into GetUndatedNativeTasks")
+ }
+ }
+}
diff --git a/internal/store/chains.go b/internal/store/chains.go
new file mode 100644
index 0000000..4f51b3c
--- /dev/null
+++ b/internal/store/chains.go
@@ -0,0 +1,142 @@
+package store
+
+import (
+ "database/sql"
+ "fmt"
+ "time"
+
+ "task-dashboard/internal/config"
+ "task-dashboard/internal/models"
+)
+
+// defaultChainProjectColor is used for the project a chain auto-creates for
+// itself, since the chain-creation API takes no color -- unlike
+// HandleWidgetProjectsCreate, where the client always supplies one.
+const defaultChainProjectColor = "#8B5CF6"
+
+// CreateChain creates a backing project (per the design's "a chain is
+// effectively a project with strict sequential unlocking"), a task_chains
+// row, and one native_tasks row per title in taskTitles. Position 0 is
+// created unlocked with due_date = now; the rest are locked with no due
+// date. All in one transaction so a partial chain never exists.
+func (s *Store) CreateChain(name string, taskTitles []string) (*models.Chain, error) {
+ if len(taskTitles) == 0 {
+ return nil, fmt.Errorf("chain must have at least one task")
+ }
+
+ project, err := s.CreateProject(name, defaultChainProjectColor)
+ if err != nil {
+ return nil, err
+ }
+
+ chainID := newTaskID()
+ now := config.Now()
+
+ tx, err := s.db.Begin()
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ if _, err := tx.Exec(`
+ INSERT INTO task_chains (id, project_id, status, created_at) VALUES (?, ?, 'active', ?)
+ `, chainID, project.ID, now); err != nil {
+ return nil, err
+ }
+
+ for i, title := range taskTitles {
+ unlocked := i == 0
+ var dueDate *time.Time
+ if unlocked {
+ dueDate = &now
+ }
+ if _, err := tx.Exec(`
+ INSERT INTO native_tasks (id, content, project_name, project_id, priority, due_date, chain_id, chain_position, chain_unlocked, created_at, updated_at)
+ VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)
+ `, newTaskID(), title, project.Name, project.ID, dueDate, chainID, i, unlocked, now, now); err != nil {
+ return nil, err
+ }
+ }
+
+ if err := tx.Commit(); err != nil {
+ return nil, err
+ }
+
+ return &models.Chain{ID: chainID, ProjectID: project.ID, Status: "active", CreatedAt: now}, nil
+}
+
+// GetChain returns a single chain by id, or ErrNativeTaskNotFound.
+func (s *Store) GetChain(id string) (*models.Chain, error) {
+ var c models.Chain
+ err := s.db.QueryRow(`
+ SELECT id, project_id, status, created_at FROM task_chains WHERE id = ?
+ `, id).Scan(&c.ID, &c.ProjectID, &c.Status, &c.CreatedAt)
+ if err == sql.ErrNoRows {
+ return nil, ErrNativeTaskNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &c, nil
+}
+
+// GetChainTasks returns every task in the chain, locked and unlocked both,
+// ordered by chain_position -- the full checklist view.
+func (s *Store) GetChainTasks(chainID string) ([]models.Task, error) {
+ rows, err := s.db.Query(`
+ SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at,
+ recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes,
+ chain_id, chain_position, chain_unlocked,
+ bucket_id, bucket_state, bucket_last_active_at
+ FROM native_tasks
+ WHERE chain_id = ?
+ ORDER BY chain_position ASC
+ `, chainID)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+ return scanNativeTasks(rows)
+}
+
+// SetChainStatus updates a chain's status (active/paused/abandoned/completed).
+// Returns ErrNativeTaskNotFound if id doesn't match any row.
+func (s *Store) SetChainStatus(id, status string) error {
+ result, err := s.db.Exec(`UPDATE task_chains SET status = ? WHERE id = ?`, status, id)
+ if err != nil {
+ return err
+ }
+ return checkRowsAffected(result)
+}
+
+// advanceChain is called from CompleteNativeTask when the just-completed
+// task has a chain_id set. Per the design, a paused chain does not
+// auto-advance -- it must be explicitly resumed first. Completing the last
+// position marks the chain completed instead of advancing.
+func (s *Store) advanceChain(chainID string, completedPosition int) error {
+ chain, err := s.GetChain(chainID)
+ if err != nil {
+ return err
+ }
+ if chain.Status == "paused" {
+ return nil
+ }
+
+ now := config.Now()
+ result, err := s.db.Exec(`
+ UPDATE native_tasks SET chain_unlocked = 1, due_date = ?, updated_at = ?
+ WHERE chain_id = ? AND chain_position = ?
+ `, now, now, chainID, completedPosition+1)
+ if err != nil {
+ return err
+ }
+ affected, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if affected == 0 {
+ // No next position -- the completed task was the last in the chain.
+ return s.SetChainStatus(chainID, "completed")
+ }
+ return nil
+}
diff --git a/internal/store/chains_test.go b/internal/store/chains_test.go
new file mode 100644
index 0000000..6d3e450
--- /dev/null
+++ b/internal/store/chains_test.go
@@ -0,0 +1,165 @@
+package store
+
+import (
+ "testing"
+)
+
+func TestCreateChain_SeedsPositionsCorrectly(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ chain, err := s.CreateChain("Ham Radio Track", []string{"Study Technician", "Pass Technician exam", "Study General"})
+ if err != nil {
+ t.Fatalf("CreateChain: %v", err)
+ }
+ if chain.Status != "active" {
+ t.Errorf("chain.Status = %q, want active", chain.Status)
+ }
+
+ tasks, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatalf("GetChainTasks: %v", err)
+ }
+ if len(tasks) != 3 {
+ t.Fatalf("len(tasks) = %d, want 3", len(tasks))
+ }
+ if !tasks[0].ChainUnlocked || tasks[0].DueDate == nil {
+ t.Errorf("position 0: ChainUnlocked=%v DueDate=%v, want unlocked with a due date", tasks[0].ChainUnlocked, tasks[0].DueDate)
+ }
+ for i := 1; i < 3; i++ {
+ if tasks[i].ChainUnlocked || tasks[i].DueDate != nil {
+ t.Errorf("position %d: ChainUnlocked=%v DueDate=%v, want locked with no due date", i, tasks[i].ChainUnlocked, tasks[i].DueDate)
+ }
+ }
+ if tasks[0].Content != "Study Technician" || tasks[1].Content != "Pass Technician exam" || tasks[2].Content != "Study General" {
+ t.Errorf("unexpected content order: %q, %q, %q", tasks[0].Content, tasks[1].Content, tasks[2].Content)
+ }
+}
+
+func TestCompleteNativeTask_AdvancesChain(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ chain, err := s.CreateChain("Track", []string{"Step 1", "Step 2"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ tasks, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.CompleteNativeTask(tasks[0].ID); err != nil {
+ t.Fatalf("CompleteNativeTask: %v", err)
+ }
+
+ after, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !after[1].ChainUnlocked || after[1].DueDate == nil {
+ t.Errorf("position 1 after completing position 0: ChainUnlocked=%v DueDate=%v, want unlocked with a due date", after[1].ChainUnlocked, after[1].DueDate)
+ }
+
+ updatedChain, err := s.GetChain(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updatedChain.Status != "active" {
+ t.Errorf("chain.Status = %q, want active (not yet done)", updatedChain.Status)
+ }
+}
+
+func TestCompleteNativeTask_LastPosition_MarksChainCompleted(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ chain, err := s.CreateChain("Track", []string{"Only step"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ tasks, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.CompleteNativeTask(tasks[0].ID); err != nil {
+ t.Fatalf("CompleteNativeTask: %v", err)
+ }
+
+ updatedChain, err := s.GetChain(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updatedChain.Status != "completed" {
+ t.Errorf("chain.Status = %q, want completed", updatedChain.Status)
+ }
+}
+
+func TestCompleteNativeTask_PausedChain_DoesNotAdvance(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ chain, err := s.CreateChain("Track", []string{"Step 1", "Step 2"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetChainStatus(chain.ID, "paused"); err != nil {
+ t.Fatal(err)
+ }
+ tasks, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.CompleteNativeTask(tasks[0].ID); err != nil {
+ t.Fatalf("CompleteNativeTask: %v", err)
+ }
+
+ after, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if after[1].ChainUnlocked {
+ t.Error("position 1 should still be locked while chain is paused")
+ }
+
+ // Resuming re-enables advancement on the *next* completion.
+ if err := s.SetChainStatus(chain.ID, "active"); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.CompleteNativeTask(tasks[1].ID); err != nil {
+ t.Fatalf("CompleteNativeTask after resume: %v", err)
+ }
+ updatedChain, err := s.GetChain(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if updatedChain.Status != "completed" {
+ t.Errorf("chain.Status = %q, want completed after resuming and finishing the last step", updatedChain.Status)
+ }
+}
+
+func TestGetUndatedNativeTasks_ExcludesLockedChainTasks(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ chain, err := s.CreateChain("Track", []string{"Step 1", "Step 2", "Step 3"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = chain
+
+ undated, err := s.GetUndatedNativeTasks()
+ if err != nil {
+ t.Fatalf("GetUndatedNativeTasks: %v", err)
+ }
+ for _, task := range undated {
+ if task.ChainID != "" && !task.ChainUnlocked {
+ t.Errorf("locked chain task %q leaked into GetUndatedNativeTasks", task.ID)
+ }
+ }
+}
+
+func TestGetChain_UnknownID_ReturnsErrNotFound(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ if _, err := s.GetChain("does-not-exist"); err != ErrNativeTaskNotFound {
+ t.Errorf("err = %v, want ErrNativeTaskNotFound", err)
+ }
+}
diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go
index 5d03bde..6f8ba7a 100644
--- a/internal/store/native_tasks.go
+++ b/internal/store/native_tasks.go
@@ -10,6 +10,7 @@ import (
"strings"
"time"
+ "task-dashboard/internal/config"
"task-dashboard/internal/models"
)
@@ -24,7 +25,9 @@ var ErrNativeTaskNotFound = errors.New("native task not found")
func (s *Store) GetNativeTasks() ([]models.Task, error) {
rows, err := s.db.Query(`
SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at,
- recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes
+ recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes,
+ chain_id, chain_position, chain_unlocked,
+ bucket_id, bucket_state, bucket_last_active_at
FROM native_tasks
WHERE completed = 0
ORDER BY CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC, priority DESC
@@ -43,9 +46,12 @@ func (s *Store) GetNativeTasks() ([]models.Task, error) {
func (s *Store) GetNativeTasksByDateRange(start, end time.Time) ([]models.Task, error) {
rows, err := s.db.Query(`
SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at,
- recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes
+ recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes,
+ chain_id, chain_position, chain_unlocked,
+ bucket_id, bucket_state, bucket_last_active_at
FROM native_tasks
WHERE completed = 0 AND due_date IS NOT NULL AND due_date >= ? AND due_date < ?
+ AND (chain_id = '' OR chain_unlocked = 1)
ORDER BY due_date ASC, priority DESC
`, start, end)
if err != nil {
@@ -64,9 +70,12 @@ func (s *Store) GetNativeTasksByDateRange(start, end time.Time) ([]models.Task,
func (s *Store) GetOverdueNativeTasks(before time.Time) ([]models.Task, error) {
rows, err := s.db.Query(`
SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at,
- recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes
+ recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes,
+ chain_id, chain_position, chain_unlocked,
+ bucket_id, bucket_state, bucket_last_active_at
FROM native_tasks
WHERE completed = 0 AND due_date IS NOT NULL AND due_date < ?
+ AND (chain_id = '' OR chain_unlocked = 1)
ORDER BY due_date ASC, priority DESC
`, before)
if err != nil {
@@ -80,9 +89,13 @@ func (s *Store) GetOverdueNativeTasks(before time.Time) ([]models.Task, error) {
func (s *Store) GetUndatedNativeTasks() ([]models.Task, error) {
rows, err := s.db.Query(`
SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at,
- recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes
+ recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes,
+ chain_id, chain_position, chain_unlocked,
+ bucket_id, bucket_state, bucket_last_active_at
FROM native_tasks
WHERE completed = 0 AND due_date IS NULL
+ AND (chain_id = '' OR chain_unlocked = 1)
+ AND bucket_state != 'dormant'
ORDER BY priority DESC, created_at ASC
`)
if err != nil {
@@ -96,7 +109,9 @@ func (s *Store) GetUndatedNativeTasks() ([]models.Task, error) {
func (s *Store) GetNativeTaskByID(id string) (*models.Task, error) {
rows, err := s.db.Query(`
SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at,
- recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes
+ recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes,
+ chain_id, chain_position, chain_unlocked,
+ bucket_id, bucket_state, bucket_last_active_at
FROM native_tasks
WHERE id = ?
`, id)
@@ -166,6 +181,21 @@ func (s *Store) CompleteNativeTask(id string) error {
return err
}
+ if task.ChainID != "" {
+ if err := s.advanceChain(task.ChainID, task.ChainPosition); err != nil {
+ return err
+ }
+ }
+
+ if task.BucketID != "" {
+ now := config.Now()
+ if _, err := s.db.Exec(`
+ UPDATE native_tasks SET bucket_state = 'dormant', due_date = NULL, bucket_last_active_at = ?, updated_at = ? WHERE id = ?
+ `, now, now, id); err != nil {
+ return err
+ }
+ }
+
if task.RecurrenceSeriesID == "" {
return nil
}
@@ -334,7 +364,9 @@ func (s *Store) SetNextOccurrenceOverride(id string, date time.Time) error {
func (s *Store) GetSeriesNeedingNextIteration(now time.Time) ([]models.Task, error) {
rows, err := s.db.Query(`
SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at,
- recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes
+ recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes,
+ chain_id, chain_position, chain_unlocked,
+ bucket_id, bucket_state, bucket_last_active_at
FROM native_tasks t1
WHERE recurrence_series_id != ''
AND due_date IS NOT NULL AND due_date <= ?
@@ -395,12 +427,18 @@ func scanNativeTasks(rows interface {
var dueDateStr *string
var weekdaysStr string
var nextOverrideStr string
+ var bucketLastActiveAt sql.NullTime
if err := rows.Scan(
&t.ID, &t.Content, &t.Description, &t.ProjectName, &t.ProjectID, &dueDateStr, &t.Priority, &t.Completed, &labelsJSON, &t.CreatedAt,
&t.RecurrenceFreq, &t.RecurrenceInterval, &weekdaysStr, &t.RecurrenceSeriesID, &nextOverrideStr, &t.EstimatedMinutes,
+ &t.ChainID, &t.ChainPosition, &t.ChainUnlocked,
+ &t.BucketID, &t.BucketState, &bucketLastActiveAt,
); err != nil {
return nil, err
}
+ if bucketLastActiveAt.Valid {
+ t.BucketLastActiveAt = &bucketLastActiveAt.Time
+ }
if dueDateStr != nil {
if parsed, err := time.Parse(time.RFC3339, *dueDateStr); err == nil {
t.DueDate = &parsed
diff --git a/internal/store/native_tasks_test.go b/internal/store/native_tasks_test.go
index 00785c2..d82576e 100644
--- a/internal/store/native_tasks_test.go
+++ b/internal/store/native_tasks_test.go
@@ -42,7 +42,13 @@ func newNativeTasksTestStore(t *testing.T) *Store {
recurrence_weekdays TEXT DEFAULT '',
recurrence_series_id TEXT DEFAULT '',
next_occurrence_override TEXT DEFAULT '',
- estimated_minutes INTEGER DEFAULT 0
+ estimated_minutes INTEGER DEFAULT 0,
+ chain_id TEXT DEFAULT '',
+ chain_position INTEGER DEFAULT 0,
+ chain_unlocked BOOLEAN DEFAULT 0,
+ bucket_id TEXT DEFAULT '',
+ bucket_state TEXT DEFAULT '',
+ bucket_last_active_at DATETIME
)
`); err != nil {
t.Fatal(err)
@@ -68,6 +74,28 @@ func newNativeTasksTestStore(t *testing.T) *Store {
`); err != nil {
t.Fatal(err)
}
+ if _, err := db.Exec(`
+ CREATE TABLE task_chains (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'active',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ `); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`
+ 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
+ )
+ `); err != nil {
+ t.Fatal(err)
+ }
if _, err := db.Exec(`INSERT INTO native_tasks (id, content) VALUES ('real-1', 'Real task')`); err != nil {
t.Fatal(err)
}
diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go
index 345b40e..7f7e56d 100644
--- a/internal/store/sqlite_test.go
+++ b/internal/store/sqlite_test.go
@@ -185,7 +185,13 @@ func setupTestStoreWithNativeTasks(t *testing.T) *Store {
recurrence_weekdays TEXT DEFAULT '',
recurrence_series_id TEXT DEFAULT '',
next_occurrence_override TEXT DEFAULT '',
- estimated_minutes INTEGER DEFAULT 0
+ estimated_minutes INTEGER DEFAULT 0,
+ chain_id TEXT DEFAULT '',
+ chain_position INTEGER DEFAULT 0,
+ chain_unlocked BOOLEAN DEFAULT 0,
+ bucket_id TEXT DEFAULT '',
+ bucket_state TEXT DEFAULT '',
+ bucket_last_active_at DATETIME
);
`
if _, err := db.Exec(schema); err != nil {
diff --git a/migrations/026_task_chains.sql b/migrations/026_task_chains.sql
new file mode 100644
index 0000000..5ed14d7
--- /dev/null
+++ b/migrations/026_task_chains.sql
@@ -0,0 +1,16 @@
+-- Linear task chains: a fixed, ordered sequence of native tasks with a WIP
+-- limit of exactly 1 -- only the task at the current position is ever
+-- unlocked/actionable. Completing it unlocks the next position; completing
+-- the last position marks the chain completed instead.
+CREATE TABLE task_chains (
+ id TEXT PRIMARY KEY,
+ project_id TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'active',
+ 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);
diff --git a/migrations/027_maintenance_buckets.sql b/migrations/027_maintenance_buckets.sql
new file mode 100644
index 0000000..50c6d7e
--- /dev/null
+++ b/migrations/027_maintenance_buckets.sql
@@ -0,0 +1,18 @@
+-- Recurring maintenance buckets: a finite pool of native_tasks that get
+-- repeatedly activated/deactivated (state flip, not a new row per cycle,
+-- unlike due-date recurrence) every cycle_days, picking pick_n dormant
+-- items per cycle.
+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);
diff --git a/web/templates/partials/timeline-tab.html b/web/templates/partials/timeline-tab.html
index d3b3fb6..d6f410e 100644
--- a/web/templates/partials/timeline-tab.html
+++ b/web/templates/partials/timeline-tab.html
@@ -103,6 +103,13 @@
padding: 1px 4px;
border-radius: 3px;
}
+ .chain-badge {
+ background: rgba(139,92,246,0.25);
+ color: #c4b5fd;
+ font-size: 0.65em;
+ padding: 1px 4px;
+ border-radius: 3px;
+ }
</style>
<div class="space-y-6 text-shadow-sm"
@@ -142,6 +149,8 @@
{{end}}
<span class="{{if .IsCompleted}}line-through text-white/50{{end}}">{{.Title}}{{multiDayLabel .}}</span>
{{if .IsOverdue}}<span class="overdue-badge">overdue</span>{{end}}
+ {{if .ChainTotal}}<span class="chain-badge">{{.ChainPosition}}/{{.ChainTotal}}</span>{{end}}
+ {{if eq .BucketState "active"}}<span class="chain-badge" style="cursor:pointer" hx-post="/defer-atom" hx-vals='{"id": "{{.ID}}"}' hx-swap="none">defer</span>{{end}}
{{if .URL}}
<a href="{{.URL}}" target="_blank" class="text-white/50 hover:text-white">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -195,6 +204,8 @@
class="h-4 w-4 rounded bg-black/40 border-white/30 text-white/80 focus:ring-white/30 cursor-pointer flex-shrink-0">
{{end}}
<span class="calendar-event-title {{if .IsCompleted}}line-through text-white/50{{end}}">{{.Title}}</span>
+ {{if .ChainTotal}}<span class="chain-badge">{{.ChainPosition}}/{{.ChainTotal}}</span>{{end}}
+ {{if eq .BucketState "active"}}<span class="chain-badge" style="cursor:pointer" hx-post="/defer-atom" hx-vals='{"id": "{{.ID}}"}' hx-swap="none">defer</span>{{end}}
</div>
<div class="calendar-event-time">{{.Time.Format "3:04 PM"}}{{if .EndTime}} - {{.EndTime.Format "3:04 PM"}}{{end}}</div>
</div>
@@ -245,6 +256,8 @@
<span class="flex-1 text-sm text-white/80 {{if .IsCompleted}}line-through text-white/40{{end}} truncate">
{{.Title}}{{multiDayLabel .}}
{{if and (eq .MultiDayVariant "") .EndTime}}<span class="text-white/30 text-xs ml-1">– {{.EndTime.Format "3:04 PM"}}</span>{{end}}
+ {{if .ChainTotal}}<span class="chain-badge">{{.ChainPosition}}/{{.ChainTotal}}</span>{{end}}
+ {{if eq .BucketState "active"}}<span class="chain-badge" style="cursor:pointer" hx-post="/defer-atom" hx-vals='{"id": "{{.ID}}"}' hx-swap="none">defer</span>{{end}}
</span>
{{if .URL}}
<a href="{{.URL}}" target="_blank" class="text-white/30 hover:text-white shrink-0">