summaryrefslogtreecommitdiff
path: root/internal/handlers/widget.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-17 22:22:37 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-17 22:22:37 +0000
commitb007fee8fb5b39a5f9b369c59af71ac9e795ceaf (patch)
treea5b999b8f4c23a52ae9249675753a92a77993008 /internal/handlers/widget.go
parent70e6dd75130e70f2db83096c23eaa75326b183a2 (diff)
Implement linear task chains and recurring maintenance buckets
Backend, web timeline, and Android widget wiring for the last two unimplemented items from doot-future-task-scheduling-ideas. Chains: task_chains table + chain_id/chain_position/chain_unlocked on native_tasks (migration 026), WIP-limit-1 advancement hooked into CompleteNativeTask, locked tasks excluded from all date-based queries, 5 new /api/widget/chains* endpoints, an N/M position badge on web and Android widget rows. Buckets: maintenance_buckets table + bucket_id/bucket_state/ bucket_last_active_at on native_tasks (migration 027), staleness-then-priority selection scoring, a new RunBucketCycleCheck scheduler loop, 5 new endpoints including the distinct Defer action, a Defer button on web and Android widget rows. Also corrected stale "not yet approved" status headers on the two already-shipped specs this work depended on (labels/projects, budgets/ availability) -- their headers were never updated after implementation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'internal/handlers/widget.go')
-rw-r--r--internal/handlers/widget.go199
1 files changed, 199 insertions, 0 deletions
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)
+}