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/widget.go | |
| 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/widget.go')
| -rw-r--r-- | internal/handlers/widget.go | 199 |
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) +} |
