diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/handlers/buckets_test.go | 188 | ||||
| -rw-r--r-- | internal/handlers/chains_test.go | 161 | ||||
| -rw-r--r-- | internal/handlers/chains_timeline_test.go | 43 | ||||
| -rw-r--r-- | internal/handlers/defer_atom_test.go | 94 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 25 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic.go | 22 | ||||
| -rw-r--r-- | internal/handlers/widget.go | 199 | ||||
| -rw-r--r-- | internal/models/timeline.go | 3 | ||||
| -rw-r--r-- | internal/models/types.go | 33 | ||||
| -rw-r--r-- | internal/models/widget.go | 3 | ||||
| -rw-r--r-- | internal/scheduler/buckets.go | 33 | ||||
| -rw-r--r-- | internal/store/buckets.go | 192 | ||||
| -rw-r--r-- | internal/store/buckets_test.go | 211 | ||||
| -rw-r--r-- | internal/store/chains.go | 142 | ||||
| -rw-r--r-- | internal/store/chains_test.go | 165 | ||||
| -rw-r--r-- | internal/store/native_tasks.go | 50 | ||||
| -rw-r--r-- | internal/store/native_tasks_test.go | 30 | ||||
| -rw-r--r-- | internal/store/sqlite_test.go | 8 |
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 { |
