diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-17 22:22:37 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-17 22:22:37 +0000 |
| commit | b007fee8fb5b39a5f9b369c59af71ac9e795ceaf (patch) | |
| tree | a5b999b8f4c23a52ae9249675753a92a77993008 /internal/handlers | |
| parent | 70e6dd75130e70f2db83096c23eaa75326b183a2 (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/handlers')
| -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 |
7 files changed, 732 insertions, 0 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) +} |
