summaryrefslogtreecommitdiff
path: root/internal/handlers
diff options
context:
space:
mode:
Diffstat (limited to 'internal/handlers')
-rw-r--r--internal/handlers/buckets_test.go188
-rw-r--r--internal/handlers/chains_test.go161
-rw-r--r--internal/handlers/chains_timeline_test.go43
-rw-r--r--internal/handlers/defer_atom_test.go94
-rw-r--r--internal/handlers/handlers.go25
-rw-r--r--internal/handlers/timeline_logic.go22
-rw-r--r--internal/handlers/widget.go199
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)
+}