summaryrefslogtreecommitdiff
path: root/docs/superpowers/plans/2026-07-15-task-labels-and-projects.md
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-15 18:03:07 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-16 02:54:17 +0000
commit194c1aa3ff6adc086dad8b2257c29b6e1c374977 (patch)
tree145f9a6d4b7954193d3d3d4506d4ff263e6a26bd /docs/superpowers/plans/2026-07-15-task-labels-and-projects.md
parent56cd7195060e7ac304cba946087d5f56d5a5f187 (diff)
Add implementation plan for task labels and projects
Nine tasks: schema/model + project_id wiring, Projects CRUD, label-color CRUD, HTTP endpoints, TimelineItem/WidgetItem project-color threading, Android data layer, task-detail popup UI (project picker + label editor), widget grid color accent, build/deploy.
Diffstat (limited to 'docs/superpowers/plans/2026-07-15-task-labels-and-projects.md')
-rw-r--r--docs/superpowers/plans/2026-07-15-task-labels-and-projects.md2136
1 files changed, 2136 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-15-task-labels-and-projects.md b/docs/superpowers/plans/2026-07-15-task-labels-and-projects.md
new file mode 100644
index 0000000..e929352
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-15-task-labels-and-projects.md
@@ -0,0 +1,2136 @@
+# Task Labels and Projects Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** doot-native tasks get a lightweight Projects concept (exactly one project per task, user-assignable color) and finally wire up the existing-but-dormant `Labels []string` field (free-text tags, also color-assignable), both inherited automatically across recurring task series, both editable in the widget's task-detail popup, and both surfaced as a small color accent on the widget's time-grid rows.
+
+**Architecture:** Go server: two new small tables (`projects`, `labels`) plus a `project_id` column added to `native_tasks` (the `Labels` column already exists). New store methods, new HTTP endpoints, and a project-color lookup threaded through the existing `TimelineItem`→`WidgetItem` conversion pipeline (the same pattern already used for `RecurringEventID`). Android: new data classes + repository methods, a project picker + label editor in the task-detail popup, and a small color accent added to the existing widget row composables.
+
+**Tech Stack:** Go (`database/sql`, `chi`), Kotlin (Jetpack Compose, OkHttp, kotlinx.serialization).
+
+## Global Constraints
+
+- Scope is doot-native tasks only, matching every other feature this session. Trello/Google Tasks cards are completely unaffected.
+- Exactly one project per task (`project_id` is a single string, empty = unassigned). Labels are the many-to-many cross-cutting mechanism instead.
+- Both `project_id` and `Labels` are series-level metadata: `CreateNextIteration` must copy them onto every new recurring occurrence, the same way it already copies `content`/`description`/`priority`.
+- The widget grid gets only a small color accent (project color) — no filtering UI on the widget itself. Full display and editing happens in the task-detail popup.
+- No automated Compose UI test harness in this project (established convention) — Android UI tasks are verified by building and a manual on-device check, matching every prior widget UI task this session.
+
+---
+
+### Task 1: Migration, models, and project_id wired through the native_tasks store layer
+
+**Files:**
+- Create: `migrations/024_labels_and_projects.sql`
+- Modify: `internal/models/types.go` (add `Project`/`LabelColor` structs; `Task.ProjectID` already exists, no change needed there)
+- Modify: `internal/store/native_tasks.go` (all 5 SELECT queries, `scanNativeTasks`, `CreateNativeTask`, `CreateNextIteration`)
+- Modify: `internal/store/native_tasks_test.go` (test schema helper)
+- Test: `internal/store/native_tasks_test.go` (new tests)
+
+**Interfaces:**
+- Consumes: nothing new.
+- Produces: `models.Project`, `models.LabelColor` structs; `native_tasks.project_id` column populated end-to-end through the existing task-reading/writing pipeline, including recurrence inheritance. Task 2 builds the Projects CRUD store methods on top of the `projects` table this task creates.
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `internal/store/native_tasks_test.go`:
+
+```go
+func TestGetNativeTasks_ParsesProjectID(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ if _, err := s.db.Exec(`INSERT INTO native_tasks (id, content, project_id) VALUES ('proj-1', 'Task with project', 'project-abc')`); err != nil {
+ t.Fatal(err)
+ }
+
+ tasks, err := s.GetNativeTasks()
+ if err != nil {
+ t.Fatalf("GetNativeTasks: %v", err)
+ }
+ var found *models.Task
+ for i := range tasks {
+ if tasks[i].ID == "proj-1" {
+ found = &tasks[i]
+ }
+ }
+ if found == nil {
+ t.Fatal("expected to find proj-1")
+ }
+ if found.ProjectID != "project-abc" {
+ t.Errorf("ProjectID = %q, want %q", found.ProjectID, "project-abc")
+ }
+}
+
+func TestCreateNativeTask_PersistsProjectID(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ task := models.Task{ID: "new-1", Content: "New task", ProjectID: "project-xyz"}
+ if err := s.CreateNativeTask(task); err != nil {
+ t.Fatalf("CreateNativeTask: %v", err)
+ }
+
+ got, err := s.GetNativeTaskByID("new-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.ProjectID != "project-xyz" {
+ t.Errorf("ProjectID = %q, want %q", got.ProjectID, "project-xyz")
+ }
+}
+
+func TestCreateNextIteration_InheritsProjectIDAndLabels(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ if _, err := s.db.Exec(`
+ INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_series_id, project_id, labels)
+ VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 1, 'series-1', 'project-abc', '["chore","garden"]')
+ `); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.CompleteNativeTask("rec-1"); err != nil {
+ t.Fatalf("CompleteNativeTask: %v", err)
+ }
+
+ var nextID string
+ if err := s.db.QueryRow(`SELECT id FROM native_tasks WHERE recurrence_series_id = 'series-1' AND id != 'rec-1'`).Scan(&nextID); err != nil {
+ t.Fatal(err)
+ }
+ next, err := s.GetNativeTaskByID(nextID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if next.ProjectID != "project-abc" {
+ t.Errorf("next iteration ProjectID = %q, want %q", next.ProjectID, "project-abc")
+ }
+ if len(next.Labels) != 2 || next.Labels[0] != "chore" || next.Labels[1] != "garden" {
+ t.Errorf("next iteration Labels = %v, want [chore garden]", next.Labels)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd /workspace/doot && go test ./internal/store/... -run "TestGetNativeTasks_ParsesProjectID|TestCreateNativeTask_PersistsProjectID|TestCreateNextIteration_InheritsProjectIDAndLabels" -v`
+Expected: FAIL to compile — the test schema has no `project_id` column yet, and `ProjectID` isn't read/written by the store layer for native tasks yet (it's currently silently dropped even though the Go struct field already exists).
+
+- [ ] **Step 3: Add the migration**
+
+Create `migrations/024_labels_and_projects.sql`:
+
+```sql
+-- Lightweight projects (exactly one per task) and label colors for
+-- doot-native tasks. Labels themselves already exist as a JSON column on
+-- native_tasks; this only adds color metadata for them, plus the new
+-- projects concept.
+CREATE TABLE projects (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ color TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ archived BOOLEAN DEFAULT 0
+);
+
+CREATE TABLE labels (
+ name TEXT PRIMARY KEY,
+ color TEXT NOT NULL
+);
+
+ALTER TABLE native_tasks ADD COLUMN project_id TEXT DEFAULT '';
+
+CREATE INDEX IF NOT EXISTS idx_native_tasks_project ON native_tasks(project_id);
+```
+
+- [ ] **Step 4: Add the `Project` and `LabelColor` structs**
+
+In `internal/models/types.go`, add after the `Task` struct:
+
+```go
+// Project is a lightweight grouping for doot-native tasks. Exactly one
+// project per task (Task.ProjectID); labels handle cross-cutting tagging.
+type Project struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Color string `json:"color"`
+ CreatedAt time.Time `json:"created_at"`
+ Archived bool `json:"archived"`
+}
+
+// LabelColor maps a label's free-text name to its display color. A label
+// with no matching row here just has no color assigned yet.
+type LabelColor struct {
+ Name string `json:"name"`
+ Color string `json:"color"`
+}
+```
+
+- [ ] **Step 5: Wire `project_id` through the store layer**
+
+In `internal/store/native_tasks.go`, update all 5 occurrences of the SELECT column list from:
+
+```go
+ SELECT id, content, description, project_name, due_date, priority, completed, labels, created_at,
+ recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override
+```
+
+to:
+
+```go
+ SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at,
+ recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override
+```
+
+(This appears in `GetNativeTasks`, `GetNativeTasksByDateRange`, `GetOverdueNativeTasks`, `GetUndatedNativeTasks`, and `GetNativeTaskByID` — five occurrences total.)
+
+Update `scanNativeTasks`'s `Scan(...)` call to match the new column order:
+
+```go
+ if err := rows.Scan(
+ &t.ID, &t.Content, &t.Description, &t.ProjectName, &t.ProjectID, &dueDateStr, &t.Priority, &t.Completed, &labelsJSON, &t.CreatedAt,
+ &t.RecurrenceFreq, &t.RecurrenceInterval, &weekdaysStr, &t.RecurrenceSeriesID, &nextOverrideStr,
+ ); err != nil {
+ return nil, err
+ }
+```
+
+Update `CreateNativeTask`:
+
+```go
+// CreateNativeTask inserts a new native task.
+func (s *Store) CreateNativeTask(task models.Task) error {
+ labelsJSON, _ := json.Marshal(task.Labels)
+ _, err := s.db.Exec(`
+ INSERT INTO native_tasks (id, content, description, project_name, project_id, due_date, priority, labels, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ `, task.ID, task.Content, task.Description, task.ProjectName, task.ProjectID, task.DueDate, task.Priority, string(labelsJSON))
+ return err
+}
+```
+
+Update `CreateNextIteration`'s INSERT to also copy `project_id` (labels are already copied via `labelsJSON`, no change needed there):
+
+```go
+func (s *Store) CreateNextIteration(old models.Task) error {
+ var nextDue *time.Time
+ switch {
+ case old.NextOccurrenceOverride != nil:
+ nextDue = old.NextOccurrenceOverride
+ case old.DueDate != nil:
+ computed := models.ComputeNextOccurrence(*old.DueDate, old.RecurrenceFreq, old.RecurrenceInterval, old.RecurrenceWeekdays)
+ nextDue = &computed
+ }
+
+ labelsJSON, _ := json.Marshal(old.Labels)
+ _, err := s.db.Exec(`
+ INSERT INTO native_tasks (
+ id, content, description, project_name, project_id, due_date, priority, labels,
+ recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override,
+ created_at, updated_at
+ )
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+ WHERE NOT EXISTS (
+ SELECT 1 FROM native_tasks
+ WHERE recurrence_series_id = ?
+ AND (due_date > ? OR (due_date = ? AND created_at > ?))
+ )
+ `, newTaskID(), old.Content, old.Description, old.ProjectName, old.ProjectID, nextDue, old.Priority, string(labelsJSON),
+ old.RecurrenceFreq, old.RecurrenceInterval, formatWeekdays(old.RecurrenceWeekdays), old.RecurrenceSeriesID,
+ old.RecurrenceSeriesID, old.DueDate, old.DueDate, old.CreatedAt)
+ return err
+}
+```
+
+- [ ] **Step 6: Update the test schema helper**
+
+In `internal/store/native_tasks_test.go`, add `project_id TEXT DEFAULT '',` to the `CREATE TABLE native_tasks` statement inside `newNativeTasksTestStore`, right after the existing `project_name TEXT DEFAULT '',` line:
+
+```go
+ if _, err := db.Exec(`
+ CREATE TABLE native_tasks (
+ id TEXT PRIMARY KEY,
+ content TEXT NOT NULL,
+ description TEXT DEFAULT '',
+ project_name TEXT DEFAULT '',
+ project_id TEXT DEFAULT '',
+ due_date DATETIME,
+ priority INTEGER DEFAULT 1,
+ completed BOOLEAN DEFAULT 0,
+ labels TEXT DEFAULT '[]',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ recurrence_freq TEXT DEFAULT '',
+ recurrence_interval INTEGER DEFAULT 1,
+ recurrence_weekdays TEXT DEFAULT '',
+ recurrence_series_id TEXT DEFAULT '',
+ next_occurrence_override TEXT DEFAULT ''
+ )
+ `); err != nil {
+ t.Fatal(err)
+ }
+```
+
+- [ ] **Step 7: Run tests to verify they pass**
+
+Run: `cd /workspace/doot && go test ./internal/store/... -v`
+Expected: PASS for every test in the package, including the 3 new ones.
+
+- [ ] **Step 8: Run the full Go build and test suite**
+
+Run: `cd /workspace/doot && go build ./... && go test ./internal/... ./cmd/...`
+Expected: `go build` succeeds. `go test` passes for every package (as of this session, `internal/handlers` and `internal/models` both run clean with zero pre-existing failures — any failure here is new and must be investigated, not assumed pre-existing).
+
+- [ ] **Step 9: Commit**
+
+```bash
+cd /workspace/doot
+git add migrations/024_labels_and_projects.sql internal/models/types.go internal/store/native_tasks.go internal/store/native_tasks_test.go
+git commit -m "feat(tasks): add projects/labels schema, wire project_id through native tasks
+
+project_id joins the existing labels JSON column as series-level
+metadata: CreateNextIteration copies both onto every new recurring
+occurrence, matching content/description/priority's existing behavior."
+```
+
+---
+
+### Task 2: Store layer — Projects CRUD
+
+**Files:**
+- Create: `internal/store/projects.go`
+- Test: `internal/store/projects_test.go`
+
+**Interfaces:**
+- Consumes: the `projects` table and `native_tasks.project_id` column from Task 1.
+- Produces: `(s *Store) CreateProject(name, color string) (*models.Project, error)`, `(s *Store) GetProjects() ([]models.Project, error)`, `(s *Store) GetProjectByID(id string) (*models.Project, error)`, `(s *Store) SetTaskProject(id, projectID string) error` — Task 4's HTTP handlers and Task 5's project-color lookup both call these.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/store/projects_test.go`:
+
+```go
+package store
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestCreateProject_ReturnsCreatedProject(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ project, err := s.CreateProject("Sailing prep", "#3B82F6")
+ if err != nil {
+ t.Fatalf("CreateProject: %v", err)
+ }
+ if project.ID == "" {
+ t.Error("expected a generated ID")
+ }
+ if project.Name != "Sailing prep" {
+ t.Errorf("Name = %q, want %q", project.Name, "Sailing prep")
+ }
+ if project.Color != "#3B82F6" {
+ t.Errorf("Color = %q, want %q", project.Color, "#3B82F6")
+ }
+ if project.Archived {
+ t.Error("expected a newly created project to not be archived")
+ }
+}
+
+func TestGetProjects_ExcludesArchived(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ active, err := s.CreateProject("Active project", "#111111")
+ if err != nil {
+ t.Fatal(err)
+ }
+ archived, err := s.CreateProject("Archived project", "#222222")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.db.Exec(`UPDATE projects SET archived = 1 WHERE id = ?`, archived.ID); err != nil {
+ t.Fatal(err)
+ }
+
+ projects, err := s.GetProjects()
+ if err != nil {
+ t.Fatalf("GetProjects: %v", err)
+ }
+ if len(projects) != 1 || projects[0].ID != active.ID {
+ t.Fatalf("expected only the active project, got %+v", projects)
+ }
+}
+
+func TestGetProjectByID_UnknownID_ReturnsErrNotFound(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ _, err := s.GetProjectByID("does-not-exist")
+ if !errors.Is(err, ErrNativeTaskNotFound) {
+ t.Fatalf("expected ErrNativeTaskNotFound, got %v", err)
+ }
+}
+
+func TestSetTaskProject_UpdatesProjectID(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ project, err := s.CreateProject("Test project", "#333333")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.SetTaskProject("real-1", project.ID); err != nil {
+ t.Fatalf("SetTaskProject: %v", err)
+ }
+
+ task, err := s.GetNativeTaskByID("real-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if task.ProjectID != project.ID {
+ t.Errorf("ProjectID = %q, want %q", task.ProjectID, project.ID)
+ }
+}
+
+func TestSetTaskProject_UnknownID_ReturnsErrNotFound(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ err := s.SetTaskProject("does-not-exist", "")
+ if !errors.Is(err, ErrNativeTaskNotFound) {
+ t.Fatalf("expected ErrNativeTaskNotFound, got %v", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd /workspace/doot && go test ./internal/store/... -run "TestCreateProject|TestGetProjects|TestGetProjectByID|TestSetTaskProject" -v`
+Expected: FAIL to compile — none of these methods exist yet.
+
+- [ ] **Step 3: Implement the Projects store methods**
+
+Create `internal/store/projects.go`:
+
+```go
+package store
+
+import (
+ "task-dashboard/internal/models"
+)
+
+// CreateProject inserts a new project and returns it.
+func (s *Store) CreateProject(name, color string) (*models.Project, error) {
+ id := newTaskID()
+ if _, err := s.db.Exec(`INSERT INTO projects (id, name, color) VALUES (?, ?, ?)`, id, name, color); err != nil {
+ return nil, err
+ }
+ return s.GetProjectByID(id)
+}
+
+// GetProjects returns all non-archived projects, alphabetically by name.
+func (s *Store) GetProjects() ([]models.Project, error) {
+ rows, err := s.db.Query(`
+ SELECT id, name, color, created_at, archived FROM projects WHERE archived = 0 ORDER BY name ASC
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ var projects []models.Project
+ for rows.Next() {
+ var p models.Project
+ if err := rows.Scan(&p.ID, &p.Name, &p.Color, &p.CreatedAt, &p.Archived); err != nil {
+ return nil, err
+ }
+ projects = append(projects, p)
+ }
+ return projects, rows.Err()
+}
+
+// GetProjectByID returns a single project by id, or ErrNativeTaskNotFound.
+func (s *Store) GetProjectByID(id string) (*models.Project, error) {
+ var p models.Project
+ err := s.db.QueryRow(`
+ SELECT id, name, color, created_at, archived FROM projects WHERE id = ?
+ `, id).Scan(&p.ID, &p.Name, &p.Color, &p.CreatedAt, &p.Archived)
+ if err == sql.ErrNoRows {
+ return nil, ErrNativeTaskNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &p, nil
+}
+
+// SetTaskProject sets or clears a task's project (projectID = "" clears
+// it). Returns ErrNativeTaskNotFound if id doesn't match any task row --
+// deliberately does NOT validate that projectID itself exists, mirroring
+// this codebase's existing tolerance for a stale/empty foreign reference
+// (e.g. RecurrenceSeriesID is never validated against another table either).
+func (s *Store) SetTaskProject(id, projectID string) error {
+ result, err := s.db.Exec(`
+ UPDATE native_tasks SET project_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
+ `, projectID, id)
+ if err != nil {
+ return err
+ }
+ return checkRowsAffected(result)
+}
+```
+
+Add `"database/sql"` to the import block (needed for `sql.ErrNoRows`):
+
+```go
+package store
+
+import (
+ "database/sql"
+
+ "task-dashboard/internal/models"
+)
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd /workspace/doot && go test ./internal/store/... -v`
+Expected: PASS for every test in the package.
+
+- [ ] **Step 5: Run the full build**
+
+Run: `cd /workspace/doot && go build ./...`
+Expected: BUILD SUCCESSFUL.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /workspace/doot
+git add internal/store/projects.go internal/store/projects_test.go
+git commit -m "feat(tasks): add Projects CRUD store methods"
+```
+
+---
+
+### Task 3: Store layer — Label color CRUD
+
+**Files:**
+- Create: `internal/store/labels.go`
+- Test: `internal/store/labels_test.go`
+
+**Interfaces:**
+- Consumes: the `labels` table from Task 1, the existing `Labels []string` column (already wired via `scanNativeTasks`/`CreateNativeTask`/`CreateNextIteration`).
+- Produces: `(s *Store) SetTaskLabels(id string, labels []string) error`, `(s *Store) GetLabelColors() ([]models.LabelColor, error)`, `(s *Store) SetLabelColor(name, color string) error` — Task 4's HTTP handlers call these.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/store/labels_test.go`:
+
+```go
+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)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd /workspace/doot && go test ./internal/store/... -run "TestSetTaskLabels|TestSetLabelColor" -v`
+Expected: FAIL to compile — none of these methods exist yet.
+
+- [ ] **Step 3: Implement the label store methods**
+
+Create `internal/store/labels.go`:
+
+```go
+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
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd /workspace/doot && go test ./internal/store/... -v`
+Expected: PASS for every test in the package.
+
+- [ ] **Step 5: Run the full build**
+
+Run: `cd /workspace/doot && go build ./...`
+Expected: BUILD SUCCESSFUL.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /workspace/doot
+git add internal/store/labels.go internal/store/labels_test.go
+git commit -m "feat(tasks): add label color CRUD store methods"
+```
+
+---
+
+### Task 4: HTTP endpoints
+
+**Files:**
+- Modify: `internal/handlers/widget.go`
+- Modify: `cmd/dashboard/main.go`
+- Test: `internal/handlers/widget_test.go`
+
+**Interfaces:**
+- Consumes: `Store.CreateProject`/`GetProjects`/`GetProjectByID`/`SetTaskProject` (Task 2), `Store.SetTaskLabels`/`GetLabelColors`/`SetLabelColor` (Task 3).
+- Produces: `GET /api/widget/projects`, `POST /api/widget/projects`, `POST /api/widget/task/project`, `POST /api/widget/task/labels`, `GET /api/widget/labels`, `POST /api/widget/labels/color` — consumed by Task 6's Android `WidgetRepository`. Also extends `taskDetailResponse` with `project`/`labels` fields.
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `internal/handlers/widget_test.go`:
+
+```go
+func TestHandleWidgetProjectsGet_ReturnsNonArchivedProjects(t *testing.T) {
+ s, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: s}
+ if _, err := s.CreateProject("Sailing prep", "#3B82F6"); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/widget/projects", nil)
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetProjectsGet).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var projects []projectResponse
+ if err := json.NewDecoder(w.Body).Decode(&projects); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+ if len(projects) != 1 || projects[0].Name != "Sailing prep" {
+ t.Fatalf("projects = %+v, want 1 entry named 'Sailing prep'", projects)
+ }
+}
+
+func TestHandleWidgetProjectsCreate_CreatesProject(t *testing.T) {
+ s, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: s}
+
+ body := `{"name":"Sailing prep","color":"#3B82F6"}`
+ req := httptest.NewRequest("POST", "/api/widget/projects", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetProjectsCreate).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var resp projectResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+ if resp.Name != "Sailing prep" || resp.Color != "#3B82F6" || resp.ID == "" {
+ t.Errorf("resp = %+v, want a created project with name/color/id set", resp)
+ }
+}
+
+func TestHandleWidgetTaskProject_SetsProjectID(t *testing.T) {
+ s, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: s}
+ project, err := s.CreateProject("Sailing prep", "#3B82F6")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.DB().Exec(`INSERT INTO native_tasks (id, content) VALUES ('plain-1', 'Buy line')`); err != nil {
+ t.Fatal(err)
+ }
+
+ body := `{"id":"plain-1","project_id":"` + project.ID + `"}`
+ req := httptest.NewRequest("POST", "/api/widget/task/project", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetTaskProject).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ task, err := s.GetNativeTaskByID("plain-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if task.ProjectID != project.ID {
+ t.Errorf("ProjectID = %q, want %q", task.ProjectID, project.ID)
+ }
+}
+
+func TestHandleWidgetTaskLabels_SetsLabels(t *testing.T) {
+ s, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: s}
+ if _, err := s.DB().Exec(`INSERT INTO native_tasks (id, content) VALUES ('plain-1', 'Buy line')`); err != nil {
+ t.Fatal(err)
+ }
+
+ body := `{"id":"plain-1","labels":["chore","urgent"]}`
+ req := httptest.NewRequest("POST", "/api/widget/task/labels", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetTaskLabels).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ task, err := s.GetNativeTaskByID("plain-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 TestHandleWidgetLabelsColorSet_ThenGet_ReturnsIt(t *testing.T) {
+ s, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: s}
+
+ body := `{"name":"chore","color":"#F59E0B"}`
+ setReq := httptest.NewRequest("POST", "/api/widget/labels/color", strings.NewReader(body))
+ setW := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetLabelsColorSet).ServeHTTP(setW, setReq)
+ if setW.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", setW.Code, setW.Body.String())
+ }
+
+ getReq := httptest.NewRequest("GET", "/api/widget/labels", nil)
+ getW := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetLabelsGet).ServeHTTP(getW, getReq)
+ if getW.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", getW.Code, getW.Body.String())
+ }
+ var colors []labelColorResponse
+ if err := json.NewDecoder(getW.Body).Decode(&colors); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+ if len(colors) != 1 || colors[0].Name != "chore" || colors[0].Color != "#F59E0B" {
+ t.Fatalf("colors = %+v, want [{chore #F59E0B}]", colors)
+ }
+}
+
+func TestHandleWidgetTaskDetail_IncludesProjectAndLabels(t *testing.T) {
+ s, cleanup := setupTestDB(t)
+ defer cleanup()
+ h := &Handler{store: s}
+ project, err := s.CreateProject("Sailing prep", "#3B82F6")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.DB().Exec(`
+ INSERT INTO native_tasks (id, content, project_id, labels) VALUES ('rec-1', 'Water plants', ?, '["chore"]')
+ `, project.ID); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/widget/task?id=rec-1&source=doot", nil)
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetTaskDetail).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var resp taskDetailResponse
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+ if resp.Project == nil || resp.Project.ID != project.ID || resp.Project.Name != "Sailing prep" {
+ t.Fatalf("Project = %+v, want the created project", resp.Project)
+ }
+ if len(resp.Labels) != 1 || resp.Labels[0] != "chore" {
+ t.Errorf("Labels = %v, want [chore]", resp.Labels)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd /workspace/doot && go test ./internal/handlers/... -run "TestHandleWidgetProjects|TestHandleWidgetTaskProject|TestHandleWidgetTaskLabels|TestHandleWidgetLabelsColor|TestHandleWidgetTaskDetail_IncludesProjectAndLabels" -v`
+Expected: FAIL to compile — none of these handlers or response types exist yet.
+
+- [ ] **Step 3: Implement the handlers**
+
+In `internal/handlers/widget.go`, add (near the existing task-detail/update handlers):
+
+```go
+type projectResponse struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Color string `json:"color"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+func projectToResponse(p models.Project) projectResponse {
+ return projectResponse{ID: p.ID, Name: p.Name, Color: p.Color, CreatedAt: p.CreatedAt}
+}
+
+// HandleWidgetProjectsGet returns all non-archived projects.
+func (h *Handler) HandleWidgetProjectsGet(w http.ResponseWriter, r *http.Request) {
+ projects, err := h.store.GetProjects()
+ if err != nil {
+ http.Error(w, "failed to load projects", http.StatusInternalServerError)
+ return
+ }
+ resp := make([]projectResponse, 0, len(projects))
+ for _, p := range projects {
+ resp = append(resp, projectToResponse(p))
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+}
+
+type projectCreateRequest struct {
+ Name string `json:"name"`
+ Color string `json:"color"`
+}
+
+// HandleWidgetProjectsCreate creates a new project.
+func (h *Handler) HandleWidgetProjectsCreate(w http.ResponseWriter, r *http.Request) {
+ var req projectCreateRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if req.Name == "" || req.Color == "" {
+ http.Error(w, "name and color are required", http.StatusBadRequest)
+ return
+ }
+ project, err := h.store.CreateProject(req.Name, req.Color)
+ if err != nil {
+ http.Error(w, "failed to create project", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(projectToResponse(*project))
+}
+
+type taskProjectRequest struct {
+ ID string `json:"id"`
+ ProjectID string `json:"project_id"`
+}
+
+// HandleWidgetTaskProject sets or clears a task's project.
+func (h *Handler) HandleWidgetTaskProject(w http.ResponseWriter, r *http.Request) {
+ var req taskProjectRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if req.ID == "" {
+ http.Error(w, "id is required", http.StatusBadRequest)
+ return
+ }
+ if err := h.store.SetTaskProject(req.ID, req.ProjectID); err != nil {
+ if errors.Is(err, store.ErrNativeTaskNotFound) {
+ http.Error(w, "task not found", http.StatusNotFound)
+ return
+ }
+ http.Error(w, "failed to set project", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+type taskLabelsRequest struct {
+ ID string `json:"id"`
+ Labels []string `json:"labels"`
+}
+
+// HandleWidgetTaskLabels replaces a task's label set.
+func (h *Handler) HandleWidgetTaskLabels(w http.ResponseWriter, r *http.Request) {
+ var req taskLabelsRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if req.ID == "" {
+ http.Error(w, "id is required", http.StatusBadRequest)
+ return
+ }
+ if err := h.store.SetTaskLabels(req.ID, req.Labels); err != nil {
+ if errors.Is(err, store.ErrNativeTaskNotFound) {
+ http.Error(w, "task not found", http.StatusNotFound)
+ return
+ }
+ http.Error(w, "failed to set labels", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+type labelColorResponse struct {
+ Name string `json:"name"`
+ Color string `json:"color"`
+}
+
+// HandleWidgetLabelsGet returns every label that has an assigned color.
+func (h *Handler) HandleWidgetLabelsGet(w http.ResponseWriter, r *http.Request) {
+ colors, err := h.store.GetLabelColors()
+ if err != nil {
+ http.Error(w, "failed to load label colors", http.StatusInternalServerError)
+ return
+ }
+ resp := make([]labelColorResponse, 0, len(colors))
+ for _, c := range colors {
+ resp = append(resp, labelColorResponse{Name: c.Name, Color: c.Color})
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+}
+
+type labelColorSetRequest struct {
+ Name string `json:"name"`
+ Color string `json:"color"`
+}
+
+// HandleWidgetLabelsColorSet assigns a label's display color.
+func (h *Handler) HandleWidgetLabelsColorSet(w http.ResponseWriter, r *http.Request) {
+ var req labelColorSetRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ if req.Name == "" || req.Color == "" {
+ http.Error(w, "name and color are required", http.StatusBadRequest)
+ return
+ }
+ if err := h.store.SetLabelColor(req.Name, req.Color); err != nil {
+ http.Error(w, "failed to set label color", http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+```
+
+Now extend `taskDetailResponse` and `HandleWidgetTaskDetail`. Find:
+
+```go
+type taskDetailResponse struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ DueDate *time.Time `json:"due_date,omitempty"`
+ Completed bool `json:"completed"`
+ Recurrence *recurrenceResponse `json:"recurrence,omitempty"`
+ NextDate *time.Time `json:"next_date,omitempty"`
+}
+```
+
+Replace with:
+
+```go
+type taskDetailResponse struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ DueDate *time.Time `json:"due_date,omitempty"`
+ Completed bool `json:"completed"`
+ Recurrence *recurrenceResponse `json:"recurrence,omitempty"`
+ NextDate *time.Time `json:"next_date,omitempty"`
+ Project *projectResponse `json:"project,omitempty"`
+ Labels []string `json:"labels,omitempty"`
+}
+```
+
+In `HandleWidgetTaskDetail`, find where `resp := taskDetailResponse{...}` is built and, after it, add the project lookup (project lookup failure is non-fatal — a stale/missing project id just means no project is shown, matching `SetTaskProject`'s deliberate non-validation):
+
+```go
+ resp := taskDetailResponse{
+ ID: task.ID,
+ Title: task.Content,
+ Description: task.Description,
+ DueDate: task.DueDate,
+ Completed: task.Completed,
+ Labels: task.Labels,
+ }
+ if task.ProjectID != "" {
+ if project, err := h.store.GetProjectByID(task.ProjectID); err == nil {
+ resp.Project = &projectResponse{ID: project.ID, Name: project.Name, Color: project.Color}
+ }
+ }
+```
+
+(Keep the existing `if task.RecurrenceSeriesID != "" { ... }` block that follows unchanged.)
+
+- [ ] **Step 4: Register the new routes**
+
+In `cmd/dashboard/main.go`, find:
+
+```go
+ r.With(widgetAuth).Get("/api/widget/task", h.HandleWidgetTaskDetail)
+ r.With(widgetAuth).Post("/api/widget/task/update", h.HandleWidgetTaskUpdate)
+ r.With(widgetAuth).Post("/api/widget/task/recurrence", h.HandleWidgetTaskRecurrence)
+ r.With(widgetAuth).Post("/api/widget/task/next-date", h.HandleWidgetTaskNextDate)
+```
+
+Replace with:
+
+```go
+ r.With(widgetAuth).Get("/api/widget/task", h.HandleWidgetTaskDetail)
+ r.With(widgetAuth).Post("/api/widget/task/update", h.HandleWidgetTaskUpdate)
+ r.With(widgetAuth).Post("/api/widget/task/recurrence", h.HandleWidgetTaskRecurrence)
+ r.With(widgetAuth).Post("/api/widget/task/next-date", h.HandleWidgetTaskNextDate)
+ r.With(widgetAuth).Get("/api/widget/projects", h.HandleWidgetProjectsGet)
+ r.With(widgetAuth).Post("/api/widget/projects", h.HandleWidgetProjectsCreate)
+ r.With(widgetAuth).Post("/api/widget/task/project", h.HandleWidgetTaskProject)
+ r.With(widgetAuth).Post("/api/widget/task/labels", h.HandleWidgetTaskLabels)
+ r.With(widgetAuth).Get("/api/widget/labels", h.HandleWidgetLabelsGet)
+ r.With(widgetAuth).Post("/api/widget/labels/color", h.HandleWidgetLabelsColorSet)
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `cd /workspace/doot && go test ./internal/handlers/... -run "TestHandleWidgetProjects|TestHandleWidgetTaskProject|TestHandleWidgetTaskLabels|TestHandleWidgetLabelsColor|TestHandleWidgetTaskDetail" -v`
+Expected: PASS for all new tests.
+
+- [ ] **Step 6: Run the full Go build and test suite**
+
+Run: `cd /workspace/doot && go build ./... && go test ./internal/... ./cmd/...`
+Expected: `go build` succeeds, `go test` passes with zero failures across every package.
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd /workspace/doot
+git add internal/handlers/widget.go cmd/dashboard/main.go
+git commit -m "feat(tasks): add HTTP endpoints for projects and label colors
+
+Extends the task-detail response with project/labels so the popup can
+display them without a second round trip."
+```
+
+---
+
+### Task 5: Thread project color through TimelineItem → WidgetItem
+
+**Files:**
+- Modify: `internal/models/timeline.go` (add `ProjectColor` field)
+- Modify: `internal/models/widget.go` (add `ProjectColor` field)
+- Modify: `internal/handlers/timeline_logic.go` (populate `ProjectColor` on native-task `TimelineItem`s)
+- Modify: `internal/handlers/widget.go` (`TimelineItemToWidgetItem` copies it across)
+- Test: `internal/handlers/timeline_logic_test.go`, `internal/handlers/widget_test.go`
+
+**Interfaces:**
+- Consumes: `Store.GetProjects` (Task 2).
+- Produces: `WidgetItem.ProjectColor *string` (JSON `project_color`), populated end-to-end from a task's `ProjectID` — consumed by Task 6's Kotlin `WidgetItem` and Task 8's widget row rendering.
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `internal/handlers/timeline_logic_test.go` (check the file's existing `BuildTimeline` test setup first to match its exact fixture/assertion style before writing this):
+
+```go
+func TestBuildTimeline_PopulatesProjectColorForNativeTasks(t *testing.T) {
+ s, cleanup := setupTestDB(t)
+ defer cleanup()
+ project, err := s.CreateProject("Sailing prep", "#3B82F6")
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now()
+ if _, err := s.DB().Exec(`
+ INSERT INTO native_tasks (id, content, due_date, project_id) VALUES ('rec-1', 'Water plants', ?, ?)
+ `, now, project.ID); err != nil {
+ t.Fatal(err)
+ }
+
+ items, err := BuildTimeline(context.Background(), s, now.Add(-time.Hour), now.Add(24*time.Hour))
+ if err != nil {
+ t.Fatalf("BuildTimeline: %v", err)
+ }
+
+ var found *models.TimelineItem
+ for i := range items {
+ if items[i].ID == "rec-1" {
+ found = &items[i]
+ }
+ }
+ if found == nil {
+ t.Fatal("expected to find rec-1 in the timeline")
+ }
+ if found.ProjectColor != "#3B82F6" {
+ t.Errorf("ProjectColor = %q, want %q", found.ProjectColor, "#3B82F6")
+ }
+}
+```
+
+Add to `internal/handlers/widget_test.go`:
+
+```go
+func TestTimelineItemToWidgetItem_CopiesProjectColor(t *testing.T) {
+ item := models.TimelineItem{
+ ID: "rec-1",
+ Type: models.TimelineItemTypeTask,
+ Source: "doot",
+ ProjectColor: "#3B82F6",
+ }
+
+ wi := TimelineItemToWidgetItem(item)
+
+ if wi.ProjectColor == nil || *wi.ProjectColor != "#3B82F6" {
+ t.Errorf("ProjectColor = %v, want #3B82F6", wi.ProjectColor)
+ }
+}
+
+func TestTimelineItemToWidgetItem_NoProject_NilProjectColor(t *testing.T) {
+ item := models.TimelineItem{ID: "rec-1", Type: models.TimelineItemTypeTask, Source: "doot"}
+
+ wi := TimelineItemToWidgetItem(item)
+
+ if wi.ProjectColor != nil {
+ t.Errorf("ProjectColor = %v, want nil", wi.ProjectColor)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd /workspace/doot && go test ./internal/handlers/... -run "TestBuildTimeline_PopulatesProjectColorForNativeTasks|TestTimelineItemToWidgetItem_.*ProjectColor" -v`
+Expected: FAIL to compile — `ProjectColor` doesn't exist on `TimelineItem`/`WidgetItem` yet.
+
+- [ ] **Step 3: Add the `ProjectColor` field to both models**
+
+In `internal/models/timeline.go`, add to `TimelineItem` (near the other source-specific metadata fields):
+
+```go
+ // Source-specific metadata
+ ListID string `json:"list_id,omitempty"` // For Google Tasks
+ RecurringEventID string `json:"recurring_event_id,omitempty"` // For calendar events
+ ProjectColor string `json:"project_color,omitempty"` // For doot-native tasks with a project assigned
+```
+
+In `internal/models/widget.go`, add to `WidgetItem`:
+
+```go
+type WidgetItem struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Source string `json:"source"`
+ Type string `json:"type"`
+ Start *time.Time `json:"start,omitempty"` // nil = no specific time (floating task)
+ End *time.Time `json:"end,omitempty"`
+ IsAllDay bool `json:"is_all_day"`
+ IsOverdue bool `json:"is_overdue"`
+ DueDate *time.Time `json:"due_date,omitempty"` // doot tasks only -- see TimelineItemToWidgetItem
+ URL string `json:"url,omitempty"`
+ Completable bool `json:"completable"` // true = doot task (checkbox shown)
+ RecurringEventID string `json:"recurring_event_id,omitempty"`
+ ProjectColor *string `json:"project_color,omitempty"`
+}
+```
+
+- [ ] **Step 4: Populate `ProjectColor` in `BuildTimeline`**
+
+In `internal/handlers/timeline_logic.go`, near the top of `BuildTimeline` (before the native-task sections), fetch all projects once into a color lookup map:
+
+```go
+ projectColors := make(map[string]string)
+ if projects, err := s.GetProjects(); err != nil {
+ log.Printf("Warning: failed to read projects: %v", err)
+ } else {
+ for _, p := range projects {
+ projectColors[p.ID] = p.Color
+ }
+ }
+```
+
+Then in each of the 3 native-task-building blocks (`nativeDated`, `nativeOverdue`, `nativeUndated`), add `ProjectColor: projectColors[task.ProjectID],` to the `models.TimelineItem{...}` literal. For example, the `nativeDated` block becomes:
+
+```go
+ for _, task := range nativeDated {
+ if task.DueDate == nil {
+ continue
+ }
+ item := models.TimelineItem{
+ ID: task.ID,
+ Type: models.TimelineItemTypeTask,
+ Title: task.Content,
+ Time: *task.DueDate,
+ Description: task.Description,
+ OriginalItem: task,
+ IsCompleted: task.Completed,
+ Source: "doot",
+ ProjectColor: projectColors[task.ProjectID],
+ }
+ item.ComputeDaySection(now)
+ items = append(items, item)
+ }
+```
+
+(Apply the same `ProjectColor: projectColors[task.ProjectID],` addition to the `nativeOverdue` and `nativeUndated` blocks' `models.TimelineItem{...}` literals.) Note `projectColors[""]` correctly returns `""` (Go's zero value for a missing map key), so an unassigned task naturally gets an empty `ProjectColor` with no special-casing needed.
+
+- [ ] **Step 5: Copy it across in `TimelineItemToWidgetItem`**
+
+In `internal/handlers/widget.go`, at the end of `TimelineItemToWidgetItem` (before `return wi`), add:
+
+```go
+ if item.ProjectColor != "" {
+ wi.ProjectColor = &item.ProjectColor
+ }
+
+ return wi
+```
+
+(This replaces the bare `return wi` at the function's end.)
+
+- [ ] **Step 6: Run tests to verify they pass**
+
+Run: `cd /workspace/doot && go test ./internal/handlers/... -v`
+Expected: PASS for every test in the package, including the new ones.
+
+- [ ] **Step 7: Run the full Go build and test suite**
+
+Run: `cd /workspace/doot && go build ./... && go test ./internal/... ./cmd/...`
+Expected: `go build` succeeds, `go test` passes with zero failures.
+
+- [ ] **Step 8: Commit**
+
+```bash
+cd /workspace/doot
+git add internal/models/timeline.go internal/models/widget.go internal/handlers/timeline_logic.go internal/handlers/widget.go
+git commit -m "feat(tasks): thread project color from BuildTimeline to WidgetItem
+
+Projects are fetched once per BuildTimeline call into a color lookup
+map, not queried per-task -- same pattern as RecurringEventID's
+existing TimelineItem->WidgetItem threading."
+```
+
+---
+
+### Task 6: Android data layer
+
+**Files:**
+- Create: `android/app/src/main/java/org/terst/doot/widget/data/Project.kt`
+- Modify: `android/app/src/main/java/org/terst/doot/widget/data/TaskDetail.kt`
+- Modify: `android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt`
+- Modify: `android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt`
+- Test: `android/app/src/test/java/org/terst/doot/widget/WidgetRepositoryTest.kt`
+
+**Interfaces:**
+- Consumes: `GET/POST /api/widget/projects`, `POST /api/widget/task/project`, `POST /api/widget/task/labels`, `GET /api/widget/labels`, `POST /api/widget/labels/color` (Task 4); `WidgetItem.project_color` (Task 5).
+- Produces: `Project`, `LabelColor` data classes; extended `TaskDetailResponse` with `project`/`labels`; extended `WidgetItem` with `projectColor`; `WidgetRepository.fetchProjects()`, `.createProject(name, color)`, `.setTaskProject(id, projectId)`, `.setTaskLabels(id, labels)`, `.fetchLabelColors()`, `.setLabelColor(name, color)` — consumed by Task 7's UI and Task 8's widget row rendering.
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `android/app/src/test/java/org/terst/doot/widget/WidgetRepositoryTest.kt` (append inside the `WidgetRepositoryTest` class):
+
+```kotlin
+ @Test
+ fun `fetchProjects returns success with valid JSON`() = runTest {
+ val json = """[{"id":"proj-1","name":"Sailing prep","color":"#3B82F6","created_at":"2026-07-15T00:00:00-10:00"}]"""
+ every { mockClient.newCall(any()) } returns mockCall
+ every { mockCall.execute() } returns responseOf(200, json)
+
+ val repo = WidgetRepository(mockClient, "https://doot.example.com", "token")
+ val result = repo.fetchProjects()
+
+ assertTrue(result.isSuccess)
+ val projects = result.getOrThrow()
+ assertEquals(1, projects.size)
+ assertEquals("Sailing prep", projects[0].name)
+ assertEquals("#3B82F6", projects[0].color)
+ }
+
+ @Test
+ fun `createProject returns success with valid JSON`() = runTest {
+ val json = """{"id":"proj-1","name":"Sailing prep","color":"#3B82F6","created_at":"2026-07-15T00:00:00-10:00"}"""
+ every { mockClient.newCall(any()) } returns mockCall
+ every { mockCall.execute() } returns responseOf(200, json)
+
+ val repo = WidgetRepository(mockClient, "https://doot.example.com", "token")
+ val result = repo.createProject("Sailing prep", "#3B82F6")
+
+ assertTrue(result.isSuccess)
+ assertEquals("Sailing prep", result.getOrThrow().name)
+ }
+
+ @Test
+ fun `fetchLabelColors returns success with valid JSON`() = runTest {
+ val json = """[{"name":"chore","color":"#F59E0B"}]"""
+ every { mockClient.newCall(any()) } returns mockCall
+ every { mockCall.execute() } returns responseOf(200, json)
+
+ val repo = WidgetRepository(mockClient, "https://doot.example.com", "token")
+ val result = repo.fetchLabelColors()
+
+ assertTrue(result.isSuccess)
+ val colors = result.getOrThrow()
+ assertEquals(1, colors.size)
+ assertEquals("chore", colors[0].name)
+ }
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd /workspace/doot/android && ./gradlew testDebugUnitTest --tests "org.terst.doot.widget.WidgetRepositoryTest"`
+Expected: FAILS to compile — `fetchProjects`/`createProject`/`fetchLabelColors`/`Project`/`LabelColor` don't exist yet.
+
+- [ ] **Step 3: Add the `Project`/`LabelColor` data classes**
+
+Create `android/app/src/main/java/org/terst/doot/widget/data/Project.kt`:
+
+```kotlin
+package org.terst.doot.widget.data
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class Project(
+ val id: String,
+ val name: String,
+ val color: String,
+ @kotlinx.serialization.SerialName("created_at") val createdAt: String
+)
+
+@Serializable
+data class LabelColor(
+ val name: String,
+ val color: String
+)
+```
+
+- [ ] **Step 4: Extend `TaskDetailResponse` with project/labels**
+
+In `android/app/src/main/java/org/terst/doot/widget/data/TaskDetail.kt`, replace:
+
+```kotlin
+@Serializable
+data class TaskDetailResponse(
+ val id: String,
+ val title: String,
+ val description: String,
+ @SerialName("due_date") val dueDate: String? = null,
+ val completed: Boolean = false,
+ val recurrence: TaskRecurrence? = null,
+ @SerialName("next_date") val nextDate: String? = null
+)
+```
+
+with:
+
+```kotlin
+@Serializable
+data class TaskDetailResponse(
+ val id: String,
+ val title: String,
+ val description: String,
+ @SerialName("due_date") val dueDate: String? = null,
+ val completed: Boolean = false,
+ val recurrence: TaskRecurrence? = null,
+ @SerialName("next_date") val nextDate: String? = null,
+ val project: Project? = null,
+ val labels: List<String> = emptyList()
+)
+```
+
+- [ ] **Step 5: Extend `WidgetItem` with `projectColor`**
+
+In `android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt`, replace:
+
+```kotlin
+@Serializable
+data class WidgetItem(
+ val id: String,
+ val title: String,
+ val source: String, // "todoist" | "trello" | "calendar" | "plantoeat" | "gtasks"
+ val type: String, // "task" | "event"
+ val start: String? = null, // ISO-8601 or null (floating task)
+ val end: String? = null, // ISO-8601 or null
+ @SerialName("is_all_day") val isAllDay: Boolean = false,
+ @SerialName("is_overdue") val isOverdue: Boolean = false,
+ @SerialName("due_date") val dueDate: String? = null,
+ val url: String = "",
+ val completable: Boolean = false,
+ @SerialName("recurring_event_id") val recurringEventId: String? = null
+)
+```
+
+with:
+
+```kotlin
+@Serializable
+data class WidgetItem(
+ val id: String,
+ val title: String,
+ val source: String, // "todoist" | "trello" | "calendar" | "plantoeat" | "gtasks"
+ val type: String, // "task" | "event"
+ val start: String? = null, // ISO-8601 or null (floating task)
+ val end: String? = null, // ISO-8601 or null
+ @SerialName("is_all_day") val isAllDay: Boolean = false,
+ @SerialName("is_overdue") val isOverdue: Boolean = false,
+ @SerialName("due_date") val dueDate: String? = null,
+ val url: String = "",
+ val completable: Boolean = false,
+ @SerialName("recurring_event_id") val recurringEventId: String? = null,
+ @SerialName("project_color") val projectColor: String? = null
+)
+```
+
+- [ ] **Step 6: Add the six `WidgetRepository` methods**
+
+In `android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt`, add after the existing `setTaskNextDate` method (before the closing `}` of the class):
+
+```kotlin
+ /** GETs the non-archived project list from /api/widget/projects. */
+ suspend fun fetchProjects(): Result<List<Project>> =
+ withContext(Dispatchers.IO) {
+ val request = Request.Builder()
+ .url("$serverUrl/api/widget/projects")
+ .header("Authorization", "Bearer $token")
+ .build()
+ runCatching {
+ val response = client.newCall(request).execute()
+ check(response.isSuccessful) { "HTTP ${response.code}" }
+ val body = checkNotNull(response.body?.string()) { "Empty body" }
+ json.decodeFromString<List<Project>>(body)
+ }
+ }
+
+ @Serializable
+ private data class ProjectCreateRequest(val name: String, val color: String)
+
+ /** POSTs a new project to /api/widget/projects. */
+ suspend fun createProject(name: String, color: String): Result<Project> =
+ withContext(Dispatchers.IO) {
+ val body = json.encodeToString(ProjectCreateRequest(name, color))
+ .toRequestBody("application/json".toMediaType())
+ val request = Request.Builder()
+ .url("$serverUrl/api/widget/projects")
+ .header("Authorization", "Bearer $token")
+ .post(body)
+ .build()
+ runCatching {
+ val response = client.newCall(request).execute()
+ check(response.isSuccessful) { "HTTP ${response.code}" }
+ val responseBody = checkNotNull(response.body?.string()) { "Empty body" }
+ json.decodeFromString<Project>(responseBody)
+ }
+ }
+
+ @Serializable
+ private data class TaskProjectRequest(val id: String, @SerialName("project_id") val projectId: String)
+
+ /** POSTs a project assignment to /api/widget/task/project. projectId="" clears it. */
+ suspend fun setTaskProject(id: String, projectId: String): Result<Unit> =
+ withContext(Dispatchers.IO) {
+ val body = json.encodeToString(TaskProjectRequest(id, projectId))
+ .toRequestBody("application/json".toMediaType())
+ val request = Request.Builder()
+ .url("$serverUrl/api/widget/task/project")
+ .header("Authorization", "Bearer $token")
+ .post(body)
+ .build()
+ runCatching {
+ val response = client.newCall(request).execute()
+ check(response.isSuccessful) { "HTTP ${response.code}" }
+ }
+ }
+
+ @Serializable
+ private data class TaskLabelsRequest(val id: String, val labels: List<String>)
+
+ /** POSTs a full label-set replacement to /api/widget/task/labels. */
+ suspend fun setTaskLabels(id: String, labels: List<String>): Result<Unit> =
+ withContext(Dispatchers.IO) {
+ val body = json.encodeToString(TaskLabelsRequest(id, labels))
+ .toRequestBody("application/json".toMediaType())
+ val request = Request.Builder()
+ .url("$serverUrl/api/widget/task/labels")
+ .header("Authorization", "Bearer $token")
+ .post(body)
+ .build()
+ runCatching {
+ val response = client.newCall(request).execute()
+ check(response.isSuccessful) { "HTTP ${response.code}" }
+ }
+ }
+
+ /** GETs every colored label from /api/widget/labels. */
+ suspend fun fetchLabelColors(): Result<List<LabelColor>> =
+ withContext(Dispatchers.IO) {
+ val request = Request.Builder()
+ .url("$serverUrl/api/widget/labels")
+ .header("Authorization", "Bearer $token")
+ .build()
+ runCatching {
+ val response = client.newCall(request).execute()
+ check(response.isSuccessful) { "HTTP ${response.code}" }
+ val body = checkNotNull(response.body?.string()) { "Empty body" }
+ json.decodeFromString<List<LabelColor>>(body)
+ }
+ }
+
+ @Serializable
+ private data class LabelColorSetRequest(val name: String, val color: String)
+
+ /** POSTs a label color assignment to /api/widget/labels/color. */
+ suspend fun setLabelColor(name: String, color: String): Result<Unit> =
+ withContext(Dispatchers.IO) {
+ val body = json.encodeToString(LabelColorSetRequest(name, color))
+ .toRequestBody("application/json".toMediaType())
+ val request = Request.Builder()
+ .url("$serverUrl/api/widget/labels/color")
+ .header("Authorization", "Bearer $token")
+ .post(body)
+ .build()
+ runCatching {
+ val response = client.newCall(request).execute()
+ check(response.isSuccessful) { "HTTP ${response.code}" }
+ }
+ }
+```
+
+Add `import org.terst.doot.widget.data.Project` and `import org.terst.doot.widget.data.LabelColor` if they're not already implicitly resolved (both are in the same package `org.terst.doot.widget.data` as `WidgetRepository`, so no import is actually needed — skip this if the file compiles without it).
+
+- [ ] **Step 7: Run test to verify it passes**
+
+Run: `cd /workspace/doot/android && ./gradlew testDebugUnitTest --tests "org.terst.doot.widget.WidgetRepositoryTest"`
+Expected: PASS (all tests in the file, including the 3 new ones).
+
+- [ ] **Step 8: Run the full Android unit test suite**
+
+Run: `cd /workspace/doot/android && ./gradlew testDebugUnitTest`
+Expected: BUILD SUCCESSFUL, all tests pass.
+
+- [ ] **Step 9: Commit**
+
+```bash
+cd /workspace/doot
+git add android/app/src/main/java/org/terst/doot/widget/data/Project.kt android/app/src/main/java/org/terst/doot/widget/data/TaskDetail.kt android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt android/app/src/test/java/org/terst/doot/widget/WidgetRepositoryTest.kt
+git commit -m "feat(widget): add Android data layer for projects and label colors"
+```
+
+---
+
+### Task 7: Android UI — project picker and label editor in the task-detail popup
+
+**Files:**
+- Create: `android/app/src/main/java/org/terst/doot/widget/ui/ProjectPickerDialog.kt`
+- Create: `android/app/src/main/java/org/terst/doot/widget/ui/LabelEditorDialog.kt`
+- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt`
+
+**Interfaces:**
+- Consumes: `WidgetRepository.fetchProjects/createProject/setTaskProject/fetchLabelColors/setLabelColor/setTaskLabels` (Task 6), `Project`/`LabelColor` (Task 6).
+- Produces: nothing consumed by later tasks (last Android task besides the widget-row rendering).
+
+No dedicated test for this task — no Compose UI test harness in this project (established convention). Verified by `./gradlew testDebugUnitTest` (compiles, no regressions) and a manual on-device check after Task 9's deploy.
+
+- [ ] **Step 1: Create the project picker dialog**
+
+Create `android/app/src/main/java/org/terst/doot/widget/ui/ProjectPickerDialog.kt`:
+
+```kotlin
+package org.terst.doot.widget.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import org.terst.doot.widget.data.Project
+
+private val PROJECT_COLOR_CHOICES = listOf(
+ "#3B82F6", "#EF4444", "#F59E0B", "#10B981", "#8B5CF6", "#EC4899", "#6B7280"
+)
+
+/** Parses a "#RRGGBB" string into a Compose Color, defaulting to gray on failure. */
+internal fun parseHexColor(hex: String): Color = runCatching {
+ Color(android.graphics.Color.parseColor(hex))
+}.getOrDefault(Color.Gray)
+
+@Composable
+fun ProjectPickerDialog(
+ projects: List<Project>,
+ onDismiss: () -> Unit,
+ onSelect: (projectId: String) -> Unit,
+ onClear: () -> Unit,
+ onCreate: (name: String, color: String) -> Unit
+) {
+ var newName by remember { mutableStateOf("") }
+ var newColor by remember { mutableStateOf(PROJECT_COLOR_CHOICES.first()) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Project") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ projects.forEach { project ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onSelect(project.id) }
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(14.dp)
+ .background(parseHexColor(project.color), CircleShape)
+ )
+ Text(project.name, modifier = Modifier.padding(start = 10.dp))
+ }
+ }
+ Text("New project", modifier = Modifier.padding(top = 12.dp, bottom = 4.dp))
+ OutlinedTextField(
+ value = newName,
+ onValueChange = { newName = it },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ modifier = Modifier.fillMaxWidth()
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(top = 8.dp)) {
+ PROJECT_COLOR_CHOICES.forEach { hex ->
+ val isSelected = hex == newColor
+ Box(
+ modifier = Modifier
+ .size(24.dp)
+ .background(parseHexColor(hex), CircleShape)
+ .then(
+ if (isSelected) Modifier.border(2.dp, Color.White, CircleShape)
+ else Modifier
+ )
+ .clickable { newColor = hex }
+ )
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { onCreate(newName, newColor) },
+ enabled = newName.isNotBlank()
+ ) { Text("Create") }
+ },
+ dismissButton = {
+ Row {
+ TextButton(onClick = onClear) { Text("Clear") }
+ TextButton(onClick = onDismiss) { Text("Cancel") }
+ }
+ }
+ )
+}
+```
+
+- [ ] **Step 2: Create the label editor dialog**
+
+Create `android/app/src/main/java/org/terst/doot/widget/ui/LabelEditorDialog.kt`:
+
+```kotlin
+package org.terst.doot.widget.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@Composable
+fun LabelEditorDialog(
+ initial: List<String>,
+ knownLabels: List<String>,
+ onDismiss: () -> Unit,
+ onSave: (labels: List<String>) -> Unit
+) {
+ var labels by remember { mutableStateOf(initial.toMutableList()) }
+ var newLabel by remember { mutableStateOf("") }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Labels") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ if (labels.isNotEmpty()) {
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ labels.forEach { label ->
+ FilterChip(
+ selected = true,
+ onClick = { labels = (labels - label).toMutableList() },
+ label = { Text(label) }
+ )
+ }
+ }
+ }
+ OutlinedTextField(
+ value = newLabel,
+ onValueChange = { newLabel = it },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ label = { Text("Add a label") }
+ )
+ if (knownLabels.isNotEmpty()) {
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ knownLabels.filter { it !in labels }.forEach { label ->
+ FilterChip(
+ selected = false,
+ onClick = { labels = (labels + label).toMutableList() },
+ label = { Text(label) }
+ )
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ val finalLabels = if (newLabel.isNotBlank()) labels + newLabel else labels
+ onSave(finalLabels)
+ }) { Text("Save") }
+ },
+ dismissButton = {
+ Row { TextButton(onClick = onDismiss) { Text("Cancel") } }
+ }
+ )
+}
+```
+
+- [ ] **Step 3: Wire both dialogs into `TaskDetailActivity`**
+
+In `android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt`, add imports:
+
+```kotlin
+import org.terst.doot.widget.data.Project
+```
+
+In the `setContent { MaterialTheme { ... } }` block, add new state variables alongside the existing ones:
+
+```kotlin
+ var project by remember { mutableStateOf<Project?>(null) }
+ var labels by remember { mutableStateOf<List<String>>(emptyList()) }
+ var availableProjects by remember { mutableStateOf<List<Project>>(emptyList()) }
+ var knownLabels by remember { mutableStateOf<List<String>>(emptyList()) }
+```
+
+In the existing `LaunchedEffect(id) { repo()?.fetchTaskDetail(id)?.onSuccess { detail -> ... } }` block, add two lines to the `onSuccess` lambda:
+
+```kotlin
+ if (isDoot) {
+ LaunchedEffect(id) {
+ repo()?.fetchTaskDetail(id)?.onSuccess { detail: TaskDetailResponse ->
+ title = detail.title
+ description = detail.description
+ dueDate = detail.dueDate
+ recurrence = detail.recurrence
+ nextDate = detail.nextDate
+ project = detail.project
+ labels = detail.labels
+ }
+ repo()?.fetchProjects()?.onSuccess { availableProjects = it }
+ repo()?.fetchLabelColors()?.onSuccess { colors -> knownLabels = colors.map { it.name } }
+ }
+ }
+```
+
+Add three new callback parameters to the `TaskDetailSheet(...)` call and its function signature: `onSaveProject: (projectId: String) -> Unit`, `onCreateProject: (name: String, color: String) -> Unit`, and `onSaveLabels: (labels: List<String>) -> Unit`. In `TaskDetailActivity`'s call site:
+
+```kotlin
+ onSaveProject = { projectId ->
+ lifecycleScope.launch {
+ repo()?.setTaskProject(id, projectId)?.onSuccess {
+ project = availableProjects.find { it.id == projectId }
+ refreshWidget()
+ }
+ }
+ },
+ onCreateProject = { name, color ->
+ lifecycleScope.launch {
+ repo()?.createProject(name, color)?.onSuccess { newProject ->
+ repo()?.setTaskProject(id, newProject.id)?.onSuccess {
+ project = newProject
+ availableProjects = availableProjects + newProject
+ refreshWidget()
+ }
+ }
+ }
+ },
+ onSaveLabels = { newLabels ->
+ lifecycleScope.launch {
+ // Assign every genuinely new label (not already in knownLabels) a
+ // deterministic color from a fixed palette, per the spec's
+ // "gets a default color... the first time it's colored" -- without
+ // this, SetLabelColor/GetLabelColors (already built server-side)
+ // would never actually get called from the UI, and every label
+ // would silently stay uncolored forever.
+ val newlyAdded = newLabels.filter { it !in knownLabels }
+ newlyAdded.forEach { label -> repo()?.setLabelColor(label, colorForNewLabel(label)) }
+ if (newlyAdded.isNotEmpty()) {
+ knownLabels = knownLabels + newlyAdded
+ }
+ repo()?.setTaskLabels(id, newLabels)?.onSuccess {
+ labels = newLabels
+ refreshWidget()
+ }
+ }
+ },
+```
+
+(Add these three right after the existing `onSaveNextDate = { ... },` block, before `onOpenUrl = { ... },`.)
+
+Add the deterministic color-picker helper as a private top-level function in `TaskDetailActivity.kt` (near the other private formatting helpers at the bottom of the file, e.g. next to `isoDateFromMillis`):
+
+```kotlin
+private val LABEL_COLOR_PALETTE = listOf(
+ "#3B82F6", "#EF4444", "#F59E0B", "#10B981", "#8B5CF6", "#EC4899", "#6B7280"
+)
+
+/** Deterministic so the same label name always lands on the same color, even
+ * across sessions, without needing to remember a prior assignment. */
+private fun colorForNewLabel(name: String): String =
+ LABEL_COLOR_PALETTE[Math.floorMod(name.hashCode(), LABEL_COLOR_PALETTE.size)]
+```
+
+**Scope note:** this makes label colors real, persisted, and retrievable via `GET /api/widget/labels` (fulfilling the spec's "user-assignable colors" requirement for labels), but the label chips in `LabelEditorDialog` and the task-detail popup's label chip still render as plain default-colored `FilterChip`s in this plan -- adding a colored leading dot to those chips (mirroring `ProjectPickerDialog`'s color swatches) is a reasonable follow-up polish pass, not included here to keep this already-large plan bounded.
+
+In `TaskDetailSheet`'s signature, add the three new callback parameters and the new plain-value parameters:
+
+```kotlin
+fun TaskDetailSheet(
+ title: String,
+ source: String,
+ completable: Boolean,
+ dueDate: String?,
+ isDoot: Boolean,
+ description: String,
+ recurrence: TaskRecurrence?,
+ nextDate: String?,
+ project: Project?,
+ labels: List<String>,
+ availableProjects: List<Project>,
+ knownLabels: List<String>,
+ onComplete: () -> Unit,
+ onReschedule: (String) -> Unit,
+ onSaveEdit: (title: String, description: String) -> Unit,
+ onSaveRecurrence: (freq: String, interval: Int, weekdays: List<Int>) -> Unit,
+ onSaveNextDate: (String) -> Unit,
+ onSaveProject: (projectId: String) -> Unit,
+ onCreateProject: (name: String, color: String) -> Unit,
+ onSaveLabels: (labels: List<String>) -> Unit,
+ onOpenUrl: (String) -> Unit,
+ onDialPhone: (String) -> Unit,
+ onDismiss: () -> Unit
+) {
+```
+
+Update the `TaskDetailSheet(...)` call in `TaskDetailActivity` to pass the four new plain values too: `project = project, labels = labels, availableProjects = availableProjects, knownLabels = knownLabels,` (add these alongside the existing `recurrence = recurrence, nextDate = nextDate,` arguments).
+
+Inside `TaskDetailSheet`'s body, add two new dialog-visibility flags alongside the existing ones:
+
+```kotlin
+ var showProjectPicker by remember { mutableStateOf(false) }
+ var showLabelEditor by remember { mutableStateOf(false) }
+```
+
+Add the two dialogs alongside the existing `if (showRecurrenceDialog) { ... }` block:
+
+```kotlin
+ if (showProjectPicker) {
+ ProjectPickerDialog(
+ projects = availableProjects,
+ onDismiss = { showProjectPicker = false },
+ onSelect = { projectId -> onSaveProject(projectId); showProjectPicker = false },
+ onClear = { onSaveProject(""); showProjectPicker = false },
+ onCreate = { name, color -> onCreateProject(name, color); showProjectPicker = false }
+ )
+ }
+
+ if (showLabelEditor) {
+ LabelEditorDialog(
+ initial = labels,
+ knownLabels = knownLabels,
+ onDismiss = { showLabelEditor = false },
+ onSave = { newLabels -> onSaveLabels(newLabels); showLabelEditor = false }
+ )
+ }
+```
+
+In the chips `Row` (the one with the date/recurrence/next-date `AssistChip`s), add two more chips after the existing ones:
+
+```kotlin
+ Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
+ AssistChip(
+ onClick = { showDatePicker = true },
+ label = { Text(formatDueDateLabel(dueDate), fontSize = 13.sp) }
+ )
+ Spacer(Modifier.width(8.dp))
+ AssistChip(
+ onClick = { showRecurrenceDialog = true },
+ label = { Text(formatRecurrenceLabel(recurrence), fontSize = 13.sp) }
+ )
+ if (recurrence != null) {
+ Spacer(Modifier.width(8.dp))
+ AssistChip(
+ onClick = { showNextDatePicker = true },
+ label = { Text(formatNextDateLabel(nextDate), fontSize = 13.sp) }
+ )
+ }
+ Spacer(Modifier.width(8.dp))
+ AssistChip(
+ onClick = { showProjectPicker = true },
+ label = { Text(project?.name ?: "Set project", fontSize = 13.sp) }
+ )
+ Spacer(Modifier.width(8.dp))
+ AssistChip(
+ onClick = { showLabelEditor = true },
+ label = { Text(if (labels.isEmpty()) "Add labels" else labels.joinToString(", "), fontSize = 13.sp) }
+ )
+ }
+```
+
+- [ ] **Step 4: Run the full Android unit test suite**
+
+Run: `cd /workspace/doot/android && ./gradlew testDebugUnitTest`
+Expected: BUILD SUCCESSFUL, all tests pass (no new tests added by this task, must not break existing ones).
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /workspace/doot
+git add android/app/src/main/java/org/terst/doot/widget/ui/ProjectPickerDialog.kt android/app/src/main/java/org/terst/doot/widget/ui/LabelEditorDialog.kt android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt
+git commit -m "feat(widget): add project picker and label editor to task detail popup"
+```
+
+---
+
+### Task 8: Widget grid color mark
+
+**Files:**
+- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt`
+
+**Interfaces:**
+- Consumes: `WidgetItem.projectColor` (Task 6), `parseHexColor` (Task 7).
+- Produces: nothing consumed by later tasks.
+
+No dedicated test — Glance/RemoteViews rendering, same established convention as every other widget-row task this session. Verified by building and a manual on-device check.
+
+- [ ] **Step 1: Add a project-color accent to `TaskRow`**
+
+In `android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt`, find `TaskRow`'s existing source-color indicator (the `Box`/`Image` showing `sourceColor(task.source)`, right before the title `Box`). Add a second small color box for the project color, only when present, right after that existing indicator and before the title `Box`:
+
+```kotlin
+ if (task.projectColor != null) {
+ Box(
+ modifier = GlanceModifier
+ .size(6.dp)
+ .background(parseGlanceHexColor(task.projectColor))
+ ) {}
+ Spacer(modifier = GlanceModifier.width(4.dp))
+ }
+```
+
+Add a small color-parsing helper near the top of the file (Glance's `Color` doesn't have the same `android.graphics.Color.parseColor` convenience path used in Task 7's Compose-side `parseHexColor` — this is a separate, Glance-compatible parser):
+
+```kotlin
+/** Parses a "#RRGGBB" string into a Glance-compatible Color, defaulting to gray on failure. */
+internal fun parseGlanceHexColor(hex: String): Color = runCatching {
+ Color(android.graphics.Color.parseColor(hex))
+}.getOrDefault(Color.Gray)
+```
+
+(`androidx.compose.ui.graphics.Color` is used both by Glance's `.background()` modifier and regular Compose -- this helper works identically to Task 7's `parseHexColor`; it's duplicated rather than shared because `WidgetRows.kt` is Glance-only code and `ProjectPickerDialog.kt` is regular-Compose-only code, and this project doesn't currently share a components file between the two rendering systems.)
+
+- [ ] **Step 2: Run the full Android unit test suite**
+
+Run: `cd /workspace/doot/android && ./gradlew testDebugUnitTest`
+Expected: BUILD SUCCESSFUL, all tests pass.
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd /workspace/doot
+git add android/app/src/main/java/org/terst/doot/widget/ui/WidgetRows.kt
+git commit -m "feat(widget): show project color as a small accent on task rows"
+```
+
+---
+
+### Task 9: Build, test, deploy
+
+**Files:** none (build/deploy/docs only).
+
+- [ ] **Step 1: Run the full Go test suite**
+
+Run: `cd /workspace/doot && go build ./... && go test ./internal/... ./cmd/...`
+Expected: `go build` succeeds. `go test` passes with zero failures across every package (as of this session, `internal/handlers` and `internal/models` both run clean).
+
+- [ ] **Step 2: Run the full Android unit test suite**
+
+Run: `cd /workspace/doot/android && ./gradlew testDebugUnitTest`
+Expected: BUILD SUCCESSFUL.
+
+- [ ] **Step 3: Deploy the Go server**
+
+Run: `cd /workspace/doot && ./scripts/deploy`
+Expected: script completes through "Deploy complete!" — this also runs migration `024_labels_and_projects.sql` against the live database.
+
+- [ ] **Step 4: Build and deploy the Android APK**
+
+Run: `cd /workspace/doot/android && ./gradlew --stop && pkill -f KotlinCompileDaemon; ./gradlew assembleRelease --rerun-tasks --console=plain`
+Expected: BUILD SUCCESSFUL. This project's Gradle setup has intermittently served a stale cached APK this session without `--rerun-tasks` and a fresh daemon -- confirm via checksum before deploying:
+
+Run: `md5sum /workspace/doot/android/app/build/outputs/apk/release/app-release.apk /site/static.terst.org/public/files/doot-widget.apk`
+Expected: checksums differ (proves this is a new build).
+
+Run: `cp /workspace/doot/android/app/build/outputs/apk/release/app-release.apk /site/static.terst.org/public/files/doot-widget.apk`
+
+- [ ] **Step 5: Update the project worklog**
+
+Append a short entry to `/workspace/doot/.agent/worklog.md`'s "Recently Completed" section describing the labels/projects feature (lightweight projects with color, dormant Labels field finally wired up, both inherited across recurrence, editable in the task-detail popup, shown as a small accent on widget rows).
+
+- [ ] **Step 6: Manual verification**
+
+No `adb`/emulator in this environment. For the user, after Steps 3-4:
+- Open a doot-native task's detail popup. Confirm "Set project" and "Add labels" chips appear.
+- Create a new project with a color, assign it to the task. Confirm the chip updates to show the project name, and a small colored accent appears on that task's row in the widget grid.
+- Add a couple of labels to the task. Confirm the label chip shows them, and re-opening the popup later still shows them (persisted).
+- Complete a recurring task that has a project and labels assigned. Confirm the next occurrence inherits both (open its detail popup and check).
+- Confirm a Trello or Google Tasks card's popup is completely unaffected (no project/label chips) -- this scope should never have been touched by this work.