diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-17 22:46:18 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-17 22:46:18 +0000 |
| commit | be4d606e9a1f5b068abcc21bbac58d1e4705ea1f (patch) | |
| tree | 220aea277125aee4aa35a10201ab0a8a6c1caf89 | |
| parent | 9460b768335dcaf7f89020caf00f1a125de14b20 (diff) | |
Extend chain creation to accept per-task description and priority
CreateChain and POST /api/widget/chains previously only took bare
title strings, with priority hardcoded to 1 -- narrower than the
spec's "an ordered list of task titles/descriptions" API line. Now
accepts []models.ChainTaskInput{Content, Description, Priority} per
position, priority defaulting to 1 when omitted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
| -rw-r--r-- | docs/superpowers/plans/2026-07-17-linear-task-chains.md | 2 | ||||
| -rw-r--r-- | internal/handlers/chains_test.go | 8 | ||||
| -rw-r--r-- | internal/handlers/chains_timeline_test.go | 6 | ||||
| -rw-r--r-- | internal/handlers/widget.go | 15 | ||||
| -rw-r--r-- | internal/models/types.go | 9 | ||||
| -rw-r--r-- | internal/store/chains.go | 22 | ||||
| -rw-r--r-- | internal/store/chains_test.go | 44 |
7 files changed, 84 insertions, 22 deletions
diff --git a/docs/superpowers/plans/2026-07-17-linear-task-chains.md b/docs/superpowers/plans/2026-07-17-linear-task-chains.md index ebb576a..c0e2095 100644 --- a/docs/superpowers/plans/2026-07-17-linear-task-chains.md +++ b/docs/superpowers/plans/2026-07-17-linear-task-chains.md @@ -2,6 +2,8 @@ > Implements `docs/superpowers/specs/2026-07-15-linear-task-chains-design.md`. > Backend (Go), web timeline indicator, and Android widget chain view — full stack, per user decision 2026-07-17. +> +> **Correction (2026-07-17, later same day):** Task 3/4 below originally under-implemented the spec's "an ordered list of task titles/descriptions" API line — `CreateChain`/`POST /api/widget/chains` only accepted bare title strings, with priority hardcoded to 1. Fixed to accept `[]models.ChainTaskInput{Content, Description, Priority}` per position, matching the spec. **Goal:** doot-native tasks can belong to a `task_chains` sequence with a WIP limit of exactly 1 — only one task in the chain is ever unlocked/actionable, and completing it unlocks the next. Locked tasks are hidden from date-based views; a dedicated checklist view shows the whole chain. diff --git a/internal/handlers/chains_test.go b/internal/handlers/chains_test.go index 0ebcc7a..bf657d1 100644 --- a/internal/handlers/chains_test.go +++ b/internal/handlers/chains_test.go @@ -9,6 +9,8 @@ import ( "testing" "github.com/go-chi/chi/v5" + + "task-dashboard/internal/models" ) func withURLParam(req *http.Request, key, value string) *http.Request { @@ -22,7 +24,7 @@ func TestHandleWidgetChainsCreate_CreatesChain(t *testing.T) { defer cleanup() h := &Handler{store: db} - body := `{"name":"Ham Radio Track","tasks":["Study Technician","Pass exam"]}` + body := `{"name":"Ham Radio Track","tasks":[{"content":"Study Technician"},{"content":"Pass exam","description":"Pick a date","priority":3}]}` req := httptest.NewRequest("POST", "/api/widget/chains", strings.NewReader(body)) w := httptest.NewRecorder() h.HandleWidgetChainsCreate(w, req) @@ -67,7 +69,7 @@ func TestHandleWidgetChainGet_ReturnsChainAndTasks(t *testing.T) { defer cleanup() h := &Handler{store: db} - chain, err := h.store.CreateChain("Track", []string{"Step 1", "Step 2"}) + chain, err := h.store.CreateChain("Track", []models.ChainTaskInput{{Content: "Step 1"}, {Content: "Step 2"}}) if err != nil { t.Fatal(err) } @@ -107,7 +109,7 @@ func TestHandleWidgetChainsPauseResumeAbandon(t *testing.T) { defer cleanup() h := &Handler{store: db} - chain, err := h.store.CreateChain("Track", []string{"Step 1", "Step 2"}) + chain, err := h.store.CreateChain("Track", []models.ChainTaskInput{{Content: "Step 1"}, {Content: "Step 2"}}) if err != nil { t.Fatal(err) } diff --git a/internal/handlers/chains_timeline_test.go b/internal/handlers/chains_timeline_test.go index 76ceb68..e9c8a3a 100644 --- a/internal/handlers/chains_timeline_test.go +++ b/internal/handlers/chains_timeline_test.go @@ -4,13 +4,17 @@ import ( "context" "testing" "time" + + "task-dashboard/internal/models" ) 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"}) + chain, err := s.CreateChain("Ham Radio Track", []models.ChainTaskInput{ + {Content: "Study Technician"}, {Content: "Pass exam"}, {Content: "Study General"}, + }) if err != nil { t.Fatal(err) } diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go index de2e856..a7f7b66 100644 --- a/internal/handlers/widget.go +++ b/internal/handlers/widget.go @@ -963,8 +963,8 @@ func (h *Handler) HandleWidgetLabelsBudgetTracked(w http.ResponseWriter, r *http } type chainCreateRequest struct { - Name string `json:"name"` - Tasks []string `json:"tasks"` + Name string `json:"name"` + Tasks []models.ChainTaskInput `json:"tasks"` } type chainCreateResponse struct { @@ -972,8 +972,9 @@ type chainCreateResponse struct { } // 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. +// plus one native_tasks row per entry in req.Tasks (content required; +// description and priority optional, priority defaulting to 1), 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 { @@ -984,6 +985,12 @@ func (h *Handler) HandleWidgetChainsCreate(w http.ResponseWriter, r *http.Reques http.Error(w, "name and at least one task are required", http.StatusBadRequest) return } + for _, t := range req.Tasks { + if strings.TrimSpace(t.Content) == "" { + http.Error(w, "every task requires non-empty content", http.StatusBadRequest) + return + } + } chain, err := h.store.CreateChain(req.Name, req.Tasks) if err != nil { http.Error(w, "failed to create chain", http.StatusInternalServerError) diff --git a/internal/models/types.go b/internal/models/types.go index 8eda33b..aa65e18 100644 --- a/internal/models/types.go +++ b/internal/models/types.go @@ -51,6 +51,15 @@ type Chain struct { CreatedAt time.Time `json:"created_at"` } +// ChainTaskInput seeds one position of a chain at creation time. Priority +// of 0 (the zero value, JSON field omitted) defaults to 1, matching +// CreateNativeTask's default. +type ChainTaskInput struct { + Content string `json:"content"` + Description string `json:"description,omitempty"` + Priority int `json:"priority,omitempty"` +} + // MaintenanceBucket is a finite pool of native tasks that get repeatedly // activated/deactivated: every CycleDays, PickN dormant items are selected // and flipped active with a computed due date. diff --git a/internal/store/chains.go b/internal/store/chains.go index 4f51b3c..f3bd0d7 100644 --- a/internal/store/chains.go +++ b/internal/store/chains.go @@ -16,11 +16,11 @@ const defaultChainProjectColor = "#8B5CF6" // CreateChain creates a backing project (per the design's "a chain is // effectively a project with strict sequential unlocking"), a task_chains -// row, and one native_tasks row per title in taskTitles. Position 0 is -// created unlocked with due_date = now; the rest are locked with no due -// date. All in one transaction so a partial chain never exists. -func (s *Store) CreateChain(name string, taskTitles []string) (*models.Chain, error) { - if len(taskTitles) == 0 { +// row, and one native_tasks row per entry in tasks. Position 0 is created +// unlocked with due_date = now; the rest are locked with no due date. All +// in one transaction so a partial chain never exists. +func (s *Store) CreateChain(name string, tasks []models.ChainTaskInput) (*models.Chain, error) { + if len(tasks) == 0 { return nil, fmt.Errorf("chain must have at least one task") } @@ -44,16 +44,20 @@ func (s *Store) CreateChain(name string, taskTitles []string) (*models.Chain, er return nil, err } - for i, title := range taskTitles { + for i, t := range tasks { unlocked := i == 0 var dueDate *time.Time if unlocked { dueDate = &now } + priority := t.Priority + if priority == 0 { + priority = 1 + } if _, err := tx.Exec(` - INSERT INTO native_tasks (id, content, project_name, project_id, priority, due_date, chain_id, chain_position, chain_unlocked, created_at, updated_at) - VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?) - `, newTaskID(), title, project.Name, project.ID, dueDate, chainID, i, unlocked, now, now); err != nil { + INSERT INTO native_tasks (id, content, description, project_name, project_id, priority, due_date, chain_id, chain_position, chain_unlocked, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, newTaskID(), t.Content, t.Description, project.Name, project.ID, priority, dueDate, chainID, i, unlocked, now, now); err != nil { return nil, err } } diff --git a/internal/store/chains_test.go b/internal/store/chains_test.go index 6d3e450..1d16508 100644 --- a/internal/store/chains_test.go +++ b/internal/store/chains_test.go @@ -2,12 +2,24 @@ package store import ( "testing" + + "task-dashboard/internal/models" ) +// chainTasks builds bare-content ChainTaskInputs (no description/priority) +// for tests that only care about title ordering and lock state. +func chainTasks(titles ...string) []models.ChainTaskInput { + tasks := make([]models.ChainTaskInput, len(titles)) + for i, t := range titles { + tasks[i] = models.ChainTaskInput{Content: t} + } + return tasks +} + func TestCreateChain_SeedsPositionsCorrectly(t *testing.T) { s := newNativeTasksTestStore(t) - chain, err := s.CreateChain("Ham Radio Track", []string{"Study Technician", "Pass Technician exam", "Study General"}) + chain, err := s.CreateChain("Ham Radio Track", chainTasks("Study Technician", "Pass Technician exam", "Study General")) if err != nil { t.Fatalf("CreateChain: %v", err) } @@ -38,7 +50,7 @@ func TestCreateChain_SeedsPositionsCorrectly(t *testing.T) { func TestCompleteNativeTask_AdvancesChain(t *testing.T) { s := newNativeTasksTestStore(t) - chain, err := s.CreateChain("Track", []string{"Step 1", "Step 2"}) + chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2")) if err != nil { t.Fatal(err) } @@ -71,7 +83,7 @@ func TestCompleteNativeTask_AdvancesChain(t *testing.T) { func TestCompleteNativeTask_LastPosition_MarksChainCompleted(t *testing.T) { s := newNativeTasksTestStore(t) - chain, err := s.CreateChain("Track", []string{"Only step"}) + chain, err := s.CreateChain("Track", chainTasks("Only step")) if err != nil { t.Fatal(err) } @@ -96,7 +108,7 @@ func TestCompleteNativeTask_LastPosition_MarksChainCompleted(t *testing.T) { func TestCompleteNativeTask_PausedChain_DoesNotAdvance(t *testing.T) { s := newNativeTasksTestStore(t) - chain, err := s.CreateChain("Track", []string{"Step 1", "Step 2"}) + chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2")) if err != nil { t.Fatal(err) } @@ -139,7 +151,7 @@ func TestCompleteNativeTask_PausedChain_DoesNotAdvance(t *testing.T) { func TestGetUndatedNativeTasks_ExcludesLockedChainTasks(t *testing.T) { s := newNativeTasksTestStore(t) - chain, err := s.CreateChain("Track", []string{"Step 1", "Step 2", "Step 3"}) + chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2", "Step 3")) if err != nil { t.Fatal(err) } @@ -156,6 +168,28 @@ func TestGetUndatedNativeTasks_ExcludesLockedChainTasks(t *testing.T) { } } +func TestCreateChain_PersistsDescriptionAndPriority(t *testing.T) { + s := newNativeTasksTestStore(t) + + chain, err := s.CreateChain("Track", []models.ChainTaskInput{ + {Content: "Step 1", Description: "do the thing", Priority: 4}, + {Content: "Step 2"}, // no priority given -- should default to 1 + }) + if err != nil { + t.Fatal(err) + } + tasks, err := s.GetChainTasks(chain.ID) + if err != nil { + t.Fatal(err) + } + if tasks[0].Description != "do the thing" || tasks[0].Priority != 4 { + t.Errorf("tasks[0] = %+v, want description=%q priority=4", tasks[0], "do the thing") + } + if tasks[1].Priority != 1 { + t.Errorf("tasks[1].Priority = %d, want default of 1", tasks[1].Priority) + } +} + func TestGetChain_UnknownID_ReturnsErrNotFound(t *testing.T) { s := newNativeTasksTestStore(t) |
