# 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 = 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> = 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>(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 = 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(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 = 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) /** POSTs a full label-set replacement to /api/widget/task/labels. */ suspend fun setTaskLabels(id: String, labels: List): Result = 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> = 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>(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 = 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, 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, knownLabels: List, onDismiss: () -> Unit, onSave: (labels: List) -> 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(null) } var labels by remember { mutableStateOf>(emptyList()) } var availableProjects by remember { mutableStateOf>(emptyList()) } var knownLabels by remember { mutableStateOf>(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) -> 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, availableProjects: List, knownLabels: List, onComplete: () -> Unit, onReschedule: (String) -> Unit, onSaveEdit: (title: String, description: String) -> Unit, onSaveRecurrence: (freq: String, interval: Int, weekdays: List) -> Unit, onSaveNextDate: (String) -> Unit, onSaveProject: (projectId: String) -> Unit, onCreateProject: (name: String, color: String) -> Unit, onSaveLabels: (labels: List) -> 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.