diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-18 00:14:45 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-18 00:14:45 +0000 |
| commit | f08f06bef47aac2c9effb4cec650d99c2deb2dd7 (patch) | |
| tree | ae06bd2e140c678e67f2879f21b07a4114a41f73 /internal/handlers | |
| parent | be4d606e9a1f5b068abcc21bbac58d1e4705ea1f (diff) | |
Rework Tasks tab: Chains section, checklist modal, project visibility
The flat Tasks-tab atom list was silently dumping every chain step
(locked and unlocked) and dormant bucket-pool items in as ordinary
undated cards, with no chain/project context and no protection against
completing a locked step out of order.
- CompleteNativeTask now rejects completing a locked chain task
(ErrChainTaskLocked), mapped to 400 in both the widget and web
complete-atom handlers.
- Chain tasks and dormant bucket items are excluded from the flat atom
list; a new "Chains" section shows one card per active/paused chain
with the current step and N/M progress.
- New chain checklist modal (GET /chains/{id}) lists every position in
order with pause/resume/abandon -- the web view originally deferred
as Android-only.
- Fixed a real bug this surfaced: resuming a paused chain only flipped
the status flag, never unlocking the deferred successor, so a chain
paused right after a completion stayed stuck forever. SetChainStatus
now catches up the deferred advancement on resume, idempotently.
- Atom cards gained a project-name chip for general visibility.
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/atoms.go | 16 | ||||
| -rw-r--r-- | internal/handlers/atoms_test.go | 53 | ||||
| -rw-r--r-- | internal/handlers/chains_web.go | 109 | ||||
| -rw-r--r-- | internal/handlers/chains_web_test.go | 213 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 13 | ||||
| -rw-r--r-- | internal/handlers/widget.go | 4 |
6 files changed, 407 insertions, 1 deletions
diff --git a/internal/handlers/atoms.go b/internal/handlers/atoms.go index 9474150..16cd003 100644 --- a/internal/handlers/atoms.go +++ b/internal/handlers/atoms.go @@ -30,8 +30,22 @@ func BuildUnifiedAtomList(s *store.Store, claudomator api.ClaudomatorClient) ([] atoms := make([]models.Atom, 0, len(gTasks)+len(nativeTasks)) - // Add native doot tasks (GetNativeTasks already filters completed=0) + // Add native doot tasks (GetNativeTasks already filters completed=0). + // Chain tasks (locked or unlocked) and dormant bucket-pool items are + // deliberately excluded here. Chains get their own "Chains" section + // (see BuildChainSummaries) that owns the whole lifecycle -- viewing + // every step, completing the current one, pause/resume/abandon -- in + // one place, rather than the current step also duplicating into this + // flat list and the locked steps cluttering it with non-actionable + // cards. Dormant bucket items aren't actionable until picked, per the + // bucket design. for _, task := range nativeTasks { + if task.ChainID != "" { + continue + } + if task.BucketState == "dormant" { + continue + } atoms = append(atoms, models.NativeTaskToAtom(task)) } diff --git a/internal/handlers/atoms_test.go b/internal/handlers/atoms_test.go index 521b147..7cb94a7 100644 --- a/internal/handlers/atoms_test.go +++ b/internal/handlers/atoms_test.go @@ -44,6 +44,59 @@ func TestBuildUnifiedAtomList_WithNativeTasks(t *testing.T) { } } +func TestBuildUnifiedAtomList_ExcludesLockedChainTasks(t *testing.T) { + s, err := store.New(":memory:", "../../migrations") + if err != nil { + t.Fatalf("failed to create in-memory store: %v", err) + } + defer s.Close() + + if _, err := s.CreateChain("Track", []models.ChainTaskInput{ + {Content: "Step 1"}, {Content: "Step 2"}, {Content: "Step 3"}, + }); err != nil { + t.Fatal(err) + } + + atoms, _, err := BuildUnifiedAtomList(s, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, a := range atoms { + if a.Title == "Step 1" || a.Title == "Step 2" || a.Title == "Step 3" { + t.Errorf("chain task %q should not appear in the flat atom list (chains get their own Chains section)", a.Title) + } + } +} + +func TestBuildUnifiedAtomList_ExcludesDormantBucketItems(t *testing.T) { + s, err := store.New(":memory:", "../../migrations") + if err != nil { + t.Fatalf("failed to create in-memory store: %v", err) + } + defer s.Close() + + bucket, err := s.CreateBucket("Gutters", 30, 1) + if err != nil { + t.Fatal(err) + } + if err := s.CreateNativeTask(models.Task{ID: "pool-item", Content: "Clean gutters", Priority: 1}); err != nil { + t.Fatal(err) + } + if err := s.AddBucketItem(bucket.ID, "pool-item"); err != nil { + t.Fatal(err) + } + + atoms, _, err := BuildUnifiedAtomList(s, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, a := range atoms { + if a.Title == "Clean gutters" { + t.Error("dormant bucket-pool item should not appear in the flat atom list") + } + } +} + func TestBuildUnifiedAtomList_WithClaudomator(t *testing.T) { s, err := store.New(":memory:", "../../migrations") if err != nil { diff --git a/internal/handlers/chains_web.go b/internal/handlers/chains_web.go new file mode 100644 index 0000000..862bec3 --- /dev/null +++ b/internal/handlers/chains_web.go @@ -0,0 +1,109 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "task-dashboard/internal/models" + "task-dashboard/internal/store" +) + +// BuildChainSummaries returns one summary per active/paused chain -- +// project name, the currently-unlocked task (nil if none, e.g. a chain +// that just completed and hasn't been re-fetched into the active/paused +// set yet), and 1-indexed position/total for a "N/M" progress display. +// Backs the Tasks tab's Chains section. +func BuildChainSummaries(s *store.Store) ([]models.ChainSummary, error) { + chains, err := s.GetChains() + if err != nil { + return nil, err + } + summaries := make([]models.ChainSummary, 0, len(chains)) + for _, chain := range chains { + project, err := s.GetProjectByID(chain.ProjectID) + if err != nil { + continue // orphaned project reference -- skip rather than fail the whole tab + } + tasks, err := s.GetChainTasks(chain.ID) + if err != nil { + return nil, err + } + summary := models.ChainSummary{Chain: chain, ProjectName: project.Name, Total: len(tasks)} + for i, t := range tasks { + if t.ChainUnlocked { + task := t + summary.CurrentTask = &task + summary.Position = i + 1 + break + } + } + summaries = append(summaries, summary) + } + return summaries, nil +} + +// HandleChainDetailView renders the full ordered checklist for a chain -- +// locked and unlocked tasks both -- for the web Tasks tab's chain modal. +// This is the "visible in the tasks list" requirement from the chains +// design spec, met on web (the spec originally scoped this to Android +// only; extended to web per user request). +func (h *Handler) HandleChainDetailView(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 + } + project, err := h.store.GetProjectByID(chain.ProjectID) + if err != nil { + 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 + } + data := struct { + Chain models.Chain + ProjectName string + Tasks []models.Task + }{Chain: *chain, ProjectName: project.Name, Tasks: tasks} + HTMLResponse(w, h.renderer, "chain-detail", data) +} + +// HandleChainPause, HandleChainResume, and HandleChainAbandon are the +// session-authed web equivalents of the widget API's chain-status +// endpoints, for the Tasks tab's chain modal. +func (h *Handler) HandleChainPause(w http.ResponseWriter, r *http.Request) { + h.setChainStatusWeb(w, r, "paused") +} + +func (h *Handler) HandleChainResume(w http.ResponseWriter, r *http.Request) { + h.setChainStatusWeb(w, r, "active") +} + +func (h *Handler) HandleChainAbandon(w http.ResponseWriter, r *http.Request) { + h.setChainStatusWeb(w, r, "abandoned") +} + +func (h *Handler) setChainStatusWeb(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.Header().Set("HX-Reswap", "none") + w.Header().Set("HX-Trigger", "refresh-tasks") + w.WriteHeader(http.StatusOK) +} diff --git a/internal/handlers/chains_web_test.go b/internal/handlers/chains_web_test.go new file mode 100644 index 0000000..d27dab9 --- /dev/null +++ b/internal/handlers/chains_web_test.go @@ -0,0 +1,213 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "task-dashboard/internal/models" +) + +func TestBuildChainSummaries_ReturnsPositionAndCurrentTask(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + chain, err := db.CreateChain("Track", []models.ChainTaskInput{ + {Content: "Step 1"}, {Content: "Step 2"}, {Content: "Step 3"}, + }) + if err != nil { + t.Fatal(err) + } + + summaries, err := BuildChainSummaries(db) + if err != nil { + t.Fatalf("BuildChainSummaries: %v", err) + } + if len(summaries) != 1 { + t.Fatalf("len(summaries) = %d, want 1", len(summaries)) + } + s := summaries[0] + if s.Chain.ID != chain.ID || s.ProjectName != "Track" { + t.Errorf("summary = %+v", s) + } + if s.Total != 3 || s.Position != 1 { + t.Errorf("Position/Total = %d/%d, want 1/3", s.Position, s.Total) + } + if s.CurrentTask == nil || s.CurrentTask.Content != "Step 1" { + t.Errorf("CurrentTask = %+v, want Step 1", s.CurrentTask) + } +} + +func TestBuildChainSummaries_ExcludesAbandonedAndCompleted(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + chain, err := db.CreateChain("Track", []models.ChainTaskInput{{Content: "Only step"}}) + if err != nil { + t.Fatal(err) + } + if err := db.SetChainStatus(chain.ID, "abandoned"); err != nil { + t.Fatal(err) + } + + summaries, err := BuildChainSummaries(db) + if err != nil { + t.Fatal(err) + } + if len(summaries) != 0 { + t.Errorf("summaries = %+v, want none (abandoned chain excluded)", summaries) + } +} + +func TestHandleChainDetailView_RendersFullOrderedList(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + chain, err := h.store.CreateChain("Track", []models.ChainTaskInput{{Content: "Step 1"}, {Content: "Step 2"}}) + if err != nil { + t.Fatal(err) + } + + req := withURLParam(httptest.NewRequest("GET", "/chains/"+chain.ID, nil), "id", chain.ID) + w := httptest.NewRecorder() + h.HandleChainDetailView(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String()) + } + + mock := h.renderer.(*MockRenderer) + if len(mock.Calls) == 0 { + t.Fatal("expected renderer to be called") + } + lastCall := mock.Calls[len(mock.Calls)-1] + if lastCall.Name != "chain-detail" { + t.Errorf("template = %q, want chain-detail", lastCall.Name) + } + data, ok := lastCall.Data.(struct { + Chain models.Chain + ProjectName string + Tasks []models.Task + }) + if !ok { + t.Fatalf("unexpected data type %T", lastCall.Data) + } + if data.ProjectName != "Track" || len(data.Tasks) != 2 { + t.Errorf("data = %+v", data) + } + if data.Tasks[0].Content != "Step 1" || data.Tasks[1].Content != "Step 2" { + t.Errorf("tasks out of order: %+v", data.Tasks) + } +} + +func TestHandleChainDetailView_UnknownID_Returns404(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: db} + + req := withURLParam(httptest.NewRequest("GET", "/chains/nope", nil), "id", "nope") + w := httptest.NewRecorder() + h.HandleChainDetailView(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", w.Code) + } +} + +func TestHandleChainPauseResumeAbandon_Web(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: db} + + chain, err := db.CreateChain("Track", []models.ChainTaskInput{{Content: "Step 1"}, {Content: "Step 2"}}) + if err != nil { + t.Fatal(err) + } + + req := withURLParam(httptest.NewRequest("POST", "/chains/"+chain.ID+"/pause", nil), "id", chain.ID) + w := httptest.NewRecorder() + h.HandleChainPause(w, req) + if w.Code != http.StatusOK { + t.Fatalf("pause status = %d, want 200", w.Code) + } + if w.Header().Get("HX-Trigger") != "refresh-tasks" { + t.Errorf("HX-Trigger = %q, want refresh-tasks", w.Header().Get("HX-Trigger")) + } + paused, err := db.GetChain(chain.ID) + if err != nil || paused.Status != "paused" { + t.Fatalf("chain after pause = %+v, err=%v", paused, err) + } + + req = withURLParam(httptest.NewRequest("POST", "/chains/"+chain.ID+"/resume", nil), "id", chain.ID) + w = httptest.NewRecorder() + h.HandleChainResume(w, req) + if w.Code != http.StatusOK { + t.Fatalf("resume status = %d, want 200", w.Code) + } + + req = withURLParam(httptest.NewRequest("POST", "/chains/"+chain.ID+"/abandon", nil), "id", chain.ID) + w = httptest.NewRecorder() + h.HandleChainAbandon(w, req) + if w.Code != http.StatusOK { + t.Fatalf("abandon status = %d, want 200", w.Code) + } + abandoned, err := db.GetChain(chain.ID) + if err != nil || abandoned.Status != "abandoned" { + t.Fatalf("chain after abandon = %+v, err=%v", abandoned, err) + } +} + +func TestHandleChainPause_UnknownID_Returns404(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: db} + + req := withURLParam(httptest.NewRequest("POST", "/chains/nope/pause", nil), "id", "nope") + w := httptest.NewRecorder() + h.HandleChainPause(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", w.Code) + } +} + +func TestHandleTabTasks_IncludesChainSummaries(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + if _, err := h.store.CreateChain("Track", []models.ChainTaskInput{{Content: "Step 1"}}); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/tabs/tasks", nil) + w := httptest.NewRecorder() + h.HandleTabTasks(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String()) + } + + mock := h.renderer.(*MockRenderer) + lastCall := mock.Calls[len(mock.Calls)-1] + if lastCall.Name != "tasks-tab" { + t.Errorf("template = %q, want tasks-tab", lastCall.Name) + } + data, ok := lastCall.Data.(struct { + Atoms []models.Atom + FutureAtoms []models.Atom + Boards []models.Board + Chains []models.ChainSummary + Today string + }) + if !ok { + t.Fatalf("unexpected data type %T", lastCall.Data) + } + if len(data.Chains) != 1 || data.Chains[0].CurrentTask == nil || data.Chains[0].CurrentTask.Content != "Step 1" { + t.Errorf("Chains = %+v", data.Chains) + } + for _, a := range data.Atoms { + if a.Title == "Step 1" { + t.Error("chain task should not also appear in the flat Atoms list") + } + } +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 343d0b1..54c6a70 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -3,6 +3,7 @@ package handlers import ( "context" "crypto/rand" + "errors" "fmt" "html/template" "log" @@ -46,6 +47,7 @@ func New(s *store.Store, trello api.TrelloAPI, planToEat api.PlanToEatAPI, googl // Template functions funcMap := template.FuncMap{ "subtract": func(a, b int) int { return a - b }, + "add": func(a, b int) int { return a + b }, // multiDayLabel returns the "(starts/ends HH:MM)" suffix for a // multi-day calendar event's row on the day it's rendered, or "" // for a normal item or a "spans" (pass-through) day, which shows @@ -580,6 +582,10 @@ func (h *Handler) handleAtomToggle(w http.ResponseWriter, r *http.Request, compl } if err != nil { + if errors.Is(err, store.ErrChainTaskLocked) { + JSONError(w, http.StatusBadRequest, "Task is locked in its chain", err) + return + } action := "complete" if !complete { action = "reopen" @@ -858,6 +864,11 @@ func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) { return } + chainSummaries, err := BuildChainSummaries(h.store) + if err != nil { + log.Printf("Warning: failed to build chain summaries: %v", err) + } + SortAtomsByUrgency(atoms) currentAtoms, futureAtoms := PartitionAtomsByTime(atoms) @@ -865,11 +876,13 @@ func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) { Atoms []models.Atom FutureAtoms []models.Atom Boards []models.Board + Chains []models.ChainSummary Today string }{ Atoms: currentAtoms, FutureAtoms: futureAtoms, Boards: boards, + Chains: chainSummaries, Today: config.Now().Format("2006-01-02"), } diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go index a7f7b66..915909e 100644 --- a/internal/handlers/widget.go +++ b/internal/handlers/widget.go @@ -391,6 +391,10 @@ func (h *Handler) HandleWidgetComplete(w http.ResponseWriter, r *http.Request) { http.Error(w, "task not found", http.StatusNotFound) return } + if errors.Is(err, store.ErrChainTaskLocked) { + http.Error(w, "task is locked in its chain", http.StatusBadRequest) + return + } http.Error(w, "failed to complete task", http.StatusInternalServerError) return } |
