diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-15 18:03:07 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-16 02:54:17 +0000 |
| commit | 194c1aa3ff6adc086dad8b2257c29b6e1c374977 (patch) | |
| tree | 145f9a6d4b7954193d3d3d4506d4ff263e6a26bd | |
| parent | 56cd7195060e7ac304cba946087d5f56d5a5f187 (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.
| -rw-r--r-- | docs/superpowers/plans/2026-07-15-task-labels-and-projects.md | 2136 |
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. |
