From d5750f2268be5164159dcd81901a10b0496525b7 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 15 Jul 2026 18:17:47 +0000 Subject: feat(tasks): add label color CRUD store methods --- internal/store/labels.go | 46 ++++++++++++++++++++ internal/store/labels_test.go | 84 +++++++++++++++++++++++++++++++++++++ internal/store/native_tasks_test.go | 8 ++++ 3 files changed, 138 insertions(+) create mode 100644 internal/store/labels.go create mode 100644 internal/store/labels_test.go diff --git a/internal/store/labels.go b/internal/store/labels.go new file mode 100644 index 0000000..3c5f5eb --- /dev/null +++ b/internal/store/labels.go @@ -0,0 +1,46 @@ +package store + +import ( + "encoding/json" + + "task-dashboard/internal/models" +) + +// SetTaskLabels replaces a task's label set entirely (not an add/remove +// single-label API), matching UpdateNativeTask's replace-whole-value style. +// Returns ErrNativeTaskNotFound if id doesn't match any row. +func (s *Store) SetTaskLabels(id string, labels []string) error { + labelsJSON, _ := json.Marshal(labels) + result, err := s.db.Exec(` + UPDATE native_tasks SET labels = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? + `, string(labelsJSON), id) + if err != nil { + return err + } + return checkRowsAffected(result) +} + +// GetLabelColors returns every label that has been assigned a color. +func (s *Store) GetLabelColors() ([]models.LabelColor, error) { + rows, err := s.db.Query(`SELECT name, color FROM labels ORDER BY name ASC`) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var colors []models.LabelColor + for rows.Next() { + var c models.LabelColor + if err := rows.Scan(&c.Name, &c.Color); err != nil { + return nil, err + } + colors = append(colors, c) + } + return colors, rows.Err() +} + +// SetLabelColor assigns (or reassigns) a label's display color. +func (s *Store) SetLabelColor(name, color string) error { + _, err := s.db.Exec(`INSERT OR REPLACE INTO labels (name, color) VALUES (?, ?)`, name, color) + return err +} diff --git a/internal/store/labels_test.go b/internal/store/labels_test.go new file mode 100644 index 0000000..4e97084 --- /dev/null +++ b/internal/store/labels_test.go @@ -0,0 +1,84 @@ +package store + +import ( + "errors" + "testing" +) + +func TestSetTaskLabels_UpdatesLabels(t *testing.T) { + s := newNativeTasksTestStore(t) + + if err := s.SetTaskLabels("real-1", []string{"chore", "urgent"}); err != nil { + t.Fatalf("SetTaskLabels: %v", err) + } + + task, err := s.GetNativeTaskByID("real-1") + if err != nil { + t.Fatal(err) + } + if len(task.Labels) != 2 || task.Labels[0] != "chore" || task.Labels[1] != "urgent" { + t.Errorf("Labels = %v, want [chore urgent]", task.Labels) + } +} + +func TestSetTaskLabels_EmptyList_ClearsLabels(t *testing.T) { + s := newNativeTasksTestStore(t) + if err := s.SetTaskLabels("real-1", []string{"chore"}); err != nil { + t.Fatal(err) + } + + if err := s.SetTaskLabels("real-1", []string{}); err != nil { + t.Fatalf("SetTaskLabels (clear): %v", err) + } + + task, err := s.GetNativeTaskByID("real-1") + if err != nil { + t.Fatal(err) + } + if len(task.Labels) != 0 { + t.Errorf("Labels = %v, want empty", task.Labels) + } +} + +func TestSetTaskLabels_UnknownID_ReturnsErrNotFound(t *testing.T) { + s := newNativeTasksTestStore(t) + err := s.SetTaskLabels("does-not-exist", []string{"chore"}) + if !errors.Is(err, ErrNativeTaskNotFound) { + t.Fatalf("expected ErrNativeTaskNotFound, got %v", err) + } +} + +func TestSetLabelColor_ThenGetLabelColors_ReturnsIt(t *testing.T) { + s := newNativeTasksTestStore(t) + + if err := s.SetLabelColor("chore", "#F59E0B"); err != nil { + t.Fatalf("SetLabelColor: %v", err) + } + + colors, err := s.GetLabelColors() + if err != nil { + t.Fatalf("GetLabelColors: %v", err) + } + if len(colors) != 1 || colors[0].Name != "chore" || colors[0].Color != "#F59E0B" { + t.Fatalf("colors = %+v, want [{chore #F59E0B}]", colors) + } +} + +func TestSetLabelColor_SameName_Overwrites(t *testing.T) { + s := newNativeTasksTestStore(t) + if err := s.SetLabelColor("chore", "#F59E0B"); err != nil { + t.Fatal(err) + } + + if err := s.SetLabelColor("chore", "#EF4444"); err != nil { + t.Fatalf("SetLabelColor (overwrite): %v", err) + } + + colors, err := s.GetLabelColors() + if err != nil { + t.Fatal(err) + } + if len(colors) != 1 || colors[0].Color != "#EF4444" { + t.Fatalf("colors = %+v, want a single entry with the overwritten color", colors) + } +} diff --git a/internal/store/native_tasks_test.go b/internal/store/native_tasks_test.go index 0117bf5..cf9ea49 100644 --- a/internal/store/native_tasks_test.go +++ b/internal/store/native_tasks_test.go @@ -57,6 +57,14 @@ func newNativeTasksTestStore(t *testing.T) *Store { `); err != nil { t.Fatal(err) } + if _, err := db.Exec(` + CREATE TABLE labels ( + name TEXT PRIMARY KEY, + color TEXT NOT NULL + ) + `); err != nil { + t.Fatal(err) + } if _, err := db.Exec(`INSERT INTO native_tasks (id, content) VALUES ('real-1', 'Real task')`); err != nil { t.Fatal(err) } -- cgit v1.2.3