summaryrefslogtreecommitdiff
path: root/internal/store/buckets_test.go
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/store/buckets_test.go
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/store/buckets_test.go')
-rw-r--r--internal/store/buckets_test.go211
1 files changed, 211 insertions, 0 deletions
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")
+ }
+ }
+}