summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-17 22:22:37 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-17 22:22:37 +0000
commitb007fee8fb5b39a5f9b369c59af71ac9e795ceaf (patch)
treea5b999b8f4c23a52ae9249675753a92a77993008 /internal
parent70e6dd75130e70f2db83096c23eaa75326b183a2 (diff)
Implement linear task chains and recurring maintenance buckets
Backend, web timeline, and Android widget wiring for the last two unimplemented items from doot-future-task-scheduling-ideas. Chains: task_chains table + chain_id/chain_position/chain_unlocked on native_tasks (migration 026), WIP-limit-1 advancement hooked into CompleteNativeTask, locked tasks excluded from all date-based queries, 5 new /api/widget/chains* endpoints, an N/M position badge on web and Android widget rows. 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, 5 new endpoints including the distinct Defer action, a Defer button on web and Android widget rows. Also corrected stale "not yet approved" status headers on the two already-shipped specs this work depended on (labels/projects, budgets/ availability) -- their headers were never updated after implementation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'internal')
-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
18 files changed, 1594 insertions, 8 deletions
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 {