summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-17 22:46:18 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-17 22:46:18 +0000
commitbe4d606e9a1f5b068abcc21bbac58d1e4705ea1f (patch)
tree220aea277125aee4aa35a10201ab0a8a6c1caf89 /internal
parent9460b768335dcaf7f89020caf00f1a125de14b20 (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
Diffstat (limited to 'internal')
-rw-r--r--internal/handlers/chains_test.go8
-rw-r--r--internal/handlers/chains_timeline_test.go6
-rw-r--r--internal/handlers/widget.go15
-rw-r--r--internal/models/types.go9
-rw-r--r--internal/store/chains.go22
-rw-r--r--internal/store/chains_test.go44
6 files changed, 82 insertions, 22 deletions
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)