summaryrefslogtreecommitdiff
path: root/internal/store
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
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')
-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
7 files changed, 790 insertions, 8 deletions
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 {