# Task Recurrence + Detail Popup Editing 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 real recurrence (frequency/interval/weekdays), a server-owned mechanism that generates the next occurrence as a new row (on completion or once due, whichever comes first), and the Android widget's task-detail popup gets editable title/description, a linkified description, and independently-tappable date/recurrence/next-date chips. **Architecture:** Go server: new `native_tasks` columns + a pure `ComputeNextOccurrence` function + a store-owned iteration-creation mechanism triggered from two places (completion, and a new periodic ticker) + four new/reused HTTP endpoints. Android: `TaskDetailActivity` does a live fetch on open (doot-only) instead of trusting cached widget data, with a toggleable edit mode and a small recurrence-editing dialog. **Tech Stack:** Go (`database/sql`, `chi`), Kotlin (Jetpack Compose, OkHttp, kotlinx.serialization). ## Global Constraints - Scope is doot-native tasks only. Trello/Google Tasks cards are completely unchanged — no edit UI, no recurrence, no live-fetch-on-open. - `recurrence_series_id != ""` is the only recurring indicator. `models.Task.IsRecurring` stays in the struct (unrelated code in `atoms.go` reads it) but this feature does not set or read it. - Iteration creation is server-owned: a new row is created either when the current occurrence is completed, or once its due date passes — independent triggers, whichever fires first wins, and "latest row in a series" (no newer `due_date`, ties broken by `created_at`) is how a trigger knows whether it should act. - No transactions wrap the two-step complete-then-create-iteration sequence (matches this store's existing single-`Exec`-per-method style); the periodic ticker's `NOT EXISTS` check is the self-healing backstop for a partial failure. - Monthly/yearly rollover uses Go's standard `AddDate` overflow behavior (accepted, documented drift for anchor days 29–31; days ≤28 never drift). - No new automated UI test harness for Android Compose — verified by building and a manual on-device check, matching every other widget feature this session. --- ### Task 1: Recurrence columns, model fields, and store scan/query layer **Files:** - Create: `migrations/023_native_task_recurrence.sql` - Modify: `internal/models/types.go:10-23` (`Task` struct) - Modify: `internal/store/native_tasks.go` (imports, `scanNativeTasks`, all four `SELECT` queries) - Modify: `internal/store/native_tasks_test.go` (`newNativeTasksTestStore`'s schema) - Test: `internal/store/native_tasks_test.go` (new tests) **Interfaces:** - Consumes: nothing new. - Produces: `models.Task` gains `RecurrenceFreq string`, `RecurrenceInterval int`, `RecurrenceWeekdays []int`, `RecurrenceSeriesID string`, `NextOccurrenceOverride *time.Time`. `parseWeekdays(s string) []int` and `formatWeekdays(weekdays []int) string` (both unexported, `internal/store` package) — later tasks in this package use `formatWeekdays`. - [ ] **Step 1: Write the failing tests** Add to `internal/store/native_tasks_test.go` (add `"task-dashboard/internal/models"` to the import block): ```go func TestGetNativeTasks_ParsesRecurrenceFields(t *testing.T) { s := newNativeTasksTestStore(t) if _, err := s.db.Exec(` INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override) VALUES ('rec-1', 'Recurring task', '2026-07-13', 'weekly', 2, '1,3,5', 'series-abc', '2026-07-27') `); 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 == "rec-1" { found = &tasks[i] } } if found == nil { t.Fatal("expected to find rec-1") } if found.RecurrenceFreq != "weekly" { t.Errorf("RecurrenceFreq = %q, want %q", found.RecurrenceFreq, "weekly") } if found.RecurrenceInterval != 2 { t.Errorf("RecurrenceInterval = %d, want 2", found.RecurrenceInterval) } if len(found.RecurrenceWeekdays) != 3 || found.RecurrenceWeekdays[0] != 1 || found.RecurrenceWeekdays[1] != 3 || found.RecurrenceWeekdays[2] != 5 { t.Errorf("RecurrenceWeekdays = %v, want [1 3 5]", found.RecurrenceWeekdays) } if found.RecurrenceSeriesID != "series-abc" { t.Errorf("RecurrenceSeriesID = %q, want %q", found.RecurrenceSeriesID, "series-abc") } if found.NextOccurrenceOverride == nil || found.NextOccurrenceOverride.Format("2006-01-02") != "2026-07-27" { t.Errorf("NextOccurrenceOverride = %v, want 2026-07-27", found.NextOccurrenceOverride) } } func TestGetNativeTasks_NonRecurringTask_HasEmptyRecurrenceFields(t *testing.T) { s := newNativeTasksTestStore(t) // "real-1" (inserted by newNativeTasksTestStore) has no recurrence columns set. tasks, err := s.GetNativeTasks() if err != nil { t.Fatalf("GetNativeTasks: %v", err) } var found *models.Task for i := range tasks { if tasks[i].ID == "real-1" { found = &tasks[i] } } if found == nil { t.Fatal("expected to find real-1") } if found.RecurrenceSeriesID != "" { t.Errorf("expected empty RecurrenceSeriesID for non-recurring task, got %q", found.RecurrenceSeriesID) } if found.RecurrenceWeekdays != nil { t.Errorf("expected nil RecurrenceWeekdays for non-recurring task, got %v", found.RecurrenceWeekdays) } if found.NextOccurrenceOverride != nil { t.Errorf("expected nil NextOccurrenceOverride for non-recurring task, got %v", found.NextOccurrenceOverride) } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cd /workspace/doot && go test ./internal/store/... -run TestGetNativeTasks_ -v` Expected: FAIL to compile — `recurrence_freq` etc. don't exist in the test schema yet, and `models.Task` has no `RecurrenceFreq` field. - [ ] **Step 3: Add the migration** Create `migrations/023_native_task_recurrence.sql`: ```sql -- Recurrence support for doot-native tasks: a recurring task generates a -- brand-new row per iteration (see Store.CreateNextIteration), linked by -- recurrence_series_id. recurrence_freq empty means "not recurring." ALTER TABLE native_tasks ADD COLUMN recurrence_freq TEXT DEFAULT ''; ALTER TABLE native_tasks ADD COLUMN recurrence_interval INTEGER DEFAULT 1; ALTER TABLE native_tasks ADD COLUMN recurrence_weekdays TEXT DEFAULT ''; ALTER TABLE native_tasks ADD COLUMN recurrence_series_id TEXT DEFAULT ''; ALTER TABLE native_tasks ADD COLUMN next_occurrence_override TEXT DEFAULT ''; CREATE INDEX IF NOT EXISTS idx_native_tasks_recurrence_series ON native_tasks(recurrence_series_id); ``` - [ ] **Step 4: Add the `Task` struct fields** In `internal/models/types.go`, replace: ```go // Task represents a native task type Task struct { ID string `json:"id"` Content string `json:"content"` Description string `json:"description"` ProjectID string `json:"project_id"` ProjectName string `json:"project_name"` DueDate *time.Time `json:"due_date,omitempty"` Priority int `json:"priority"` Completed bool `json:"completed"` Labels []string `json:"labels"` URL string `json:"url"` CreatedAt time.Time `json:"created_at"` IsRecurring bool `json:"is_recurring"` } ``` with: ```go // Task represents a native task type Task struct { ID string `json:"id"` Content string `json:"content"` Description string `json:"description"` ProjectID string `json:"project_id"` ProjectName string `json:"project_name"` DueDate *time.Time `json:"due_date,omitempty"` Priority int `json:"priority"` Completed bool `json:"completed"` Labels []string `json:"labels"` URL string `json:"url"` CreatedAt time.Time `json:"created_at"` IsRecurring bool `json:"is_recurring"` // Recurrence (doot-native tasks only). RecurrenceSeriesID != "" is the // real "is this task recurring" indicator -- IsRecurring above predates // this feature and is never set by it. RecurrenceFreq string `json:"recurrence_freq,omitempty"` RecurrenceInterval int `json:"recurrence_interval,omitempty"` RecurrenceWeekdays []int `json:"recurrence_weekdays,omitempty"` RecurrenceSeriesID string `json:"recurrence_series_id,omitempty"` NextOccurrenceOverride *time.Time `json:"next_occurrence_override,omitempty"` } ``` - [ ] **Step 5: Update `scanNativeTasks` and the four `SELECT` queries** In `internal/store/native_tasks.go`, replace the import block: ```go import ( "database/sql" "encoding/json" "errors" "time" "task-dashboard/internal/models" ) ``` with: ```go import ( "database/sql" "encoding/json" "errors" "strconv" "strings" "time" "task-dashboard/internal/models" ) ``` Replace each of the four `SELECT` column lists (in `GetNativeTasks`, `GetNativeTasksByDateRange`, `GetOverdueNativeTasks`, `GetUndatedNativeTasks`) from: ```go SELECT id, content, description, project_name, due_date, priority, completed, labels, created_at ``` to: ```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 ``` (Four occurrences — one per function; the rest of each query, e.g. the `WHERE`/`ORDER BY` clauses, is unchanged.) Replace `scanNativeTasks` in full: ```go func scanNativeTasks(rows interface { Next() bool Scan(...interface{}) error Err() error }) ([]models.Task, error) { var tasks []models.Task for rows.Next() { var t models.Task var labelsJSON string var dueDateStr *string var weekdaysStr string var nextOverrideStr string if err := rows.Scan( &t.ID, &t.Content, &t.Description, &t.ProjectName, &dueDateStr, &t.Priority, &t.Completed, &labelsJSON, &t.CreatedAt, &t.RecurrenceFreq, &t.RecurrenceInterval, &weekdaysStr, &t.RecurrenceSeriesID, &nextOverrideStr, ); err != nil { return nil, err } if dueDateStr != nil { if parsed, err := time.Parse(time.RFC3339, *dueDateStr); err == nil { t.DueDate = &parsed } else if parsed, err := time.Parse("2006-01-02 15:04:05", *dueDateStr); err == nil { t.DueDate = &parsed } else if parsed, err := time.Parse("2006-01-02", *dueDateStr); err == nil { t.DueDate = &parsed } } if err := json.Unmarshal([]byte(labelsJSON), &t.Labels); err != nil { t.Labels = nil } t.RecurrenceWeekdays = parseWeekdays(weekdaysStr) if nextOverrideStr != "" { if parsed, err := time.Parse("2006-01-02", nextOverrideStr); err == nil { t.NextOccurrenceOverride = &parsed } } tasks = append(tasks, t) } return tasks, rows.Err() } // parseWeekdays parses a comma-separated list of 0-6 ints (e.g. "1,3,5"), // returning nil for an empty string. func parseWeekdays(s string) []int { if s == "" { return nil } parts := strings.Split(s, ",") weekdays := make([]int, 0, len(parts)) for _, p := range parts { if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil { weekdays = append(weekdays, n) } } return weekdays } // formatWeekdays is the inverse of parseWeekdays. func formatWeekdays(weekdays []int) string { if len(weekdays) == 0 { return "" } strs := make([]string, len(weekdays)) for i, d := range weekdays { strs[i] = strconv.Itoa(d) } return strings.Join(strs, ",") } ``` - [ ] **Step 6: Update the test schema helper** In `internal/store/native_tasks_test.go`, add the import and update the schema. Replace: ```go import ( "database/sql" "errors" "path/filepath" "testing" "time" _ "github.com/mattn/go-sqlite3" ) ``` with: ```go import ( "database/sql" "errors" "path/filepath" "testing" "time" "task-dashboard/internal/models" _ "github.com/mattn/go-sqlite3" ) ``` Replace the `CREATE TABLE` statement inside `newNativeTasksTestStore`: ```go if _, err := db.Exec(` CREATE TABLE native_tasks ( id TEXT PRIMARY KEY, content TEXT NOT NULL, description TEXT DEFAULT '', project_name 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 ) `); err != nil { t.Fatal(err) } ``` with: ```go if _, err := db.Exec(` CREATE TABLE native_tasks ( id TEXT PRIMARY KEY, content TEXT NOT NULL, description TEXT DEFAULT '', project_name 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 all tests in the package, including the two new ones and the pre-existing `TestCompleteNativeTask_*`/`TestUncompleteNativeTask_*`/`TestRescheduleNativeTask_*` tests (unaffected by this change). - [ ] **Step 8: Run the full Go build and handlers package test** Run: `cd /workspace/doot && go build ./... && go test ./internal/handlers/...` Expected: `go build` succeeds (the four `SELECT` column-list changes are internal to `store`, no caller signature changed). `go test` passes except the two pre-existing, unrelated failures (`TestHandleAgentTaskWriteOperations`, `TestHandleAgentCreateOperations` — confirmed broken before this entire feature, not yours to fix). - [ ] **Step 9: Commit** ```bash cd /workspace/doot git add migrations/023_native_task_recurrence.sql internal/models/types.go internal/store/native_tasks.go internal/store/native_tasks_test.go git commit -m "feat(tasks): add recurrence columns and model fields for native tasks recurrence_series_id != \"\" is the real recurring indicator (IsRecurring predates this feature and is never set). Adds parseWeekdays/formatWeekdays for the comma-separated weekday-list column, and threads the five new columns through scanNativeTasks and all four existing SELECT queries." ``` --- ### Task 2: `ComputeNextOccurrence` **Files:** - Create: `internal/models/recurrence.go` - Test: `internal/models/recurrence_test.go` **Interfaces:** - Consumes: nothing new. - Produces: `ComputeNextOccurrence(due time.Time, freq string, interval int, weekdays []int) time.Time` — used by Task 3's `CreateNextIteration` and Task 5's task-detail endpoint. - [ ] **Step 1: Write the failing tests** Create `internal/models/recurrence_test.go`: ```go package models import ( "testing" "time" ) func TestComputeNextOccurrence(t *testing.T) { mustParse := func(t *testing.T, s string) time.Time { t.Helper() d, err := time.Parse("2006-01-02", s) if err != nil { t.Fatalf("bad fixture date %q: %v", s, err) } return d } tests := []struct { name string due string freq string interval int weekdays []int want string }{ {"daily interval 1", "2026-07-13", "daily", 1, nil, "2026-07-14"}, {"daily interval 3", "2026-07-13", "daily", 3, nil, "2026-07-16"}, {"weekly no weekdays interval 1", "2026-07-13", "weekly", 1, nil, "2026-07-20"}, {"weekly no weekdays interval 2", "2026-07-13", "weekly", 2, nil, "2026-07-27"}, // 2026-07-13 is a Monday (weekday=1). {"weekly with weekdays same week", "2026-07-13", "weekly", 1, []int{1, 3, 5}, "2026-07-15"}, {"weekly with weekdays wraps to next week", "2026-07-17", "weekly", 1, []int{1, 3, 5}, "2026-07-20"}, // due=Fri(5), wraps to Mon {"weekly with weekdays interval 2 wraps", "2026-07-13", "weekly", 2, []int{1}, "2026-07-27"}, // due=Mon, only Mon active, skip a week {"monthly interval 1", "2026-06-13", "monthly", 1, nil, "2026-07-13"}, {"monthly rollover", "2026-01-31", "monthly", 1, nil, "2026-03-03"}, {"monthly rollover locks in on the drifted day", "2026-03-03", "monthly", 1, nil, "2026-04-03"}, {"yearly interval 1", "2026-07-13", "yearly", 1, nil, "2027-07-13"}, {"unknown freq returns due unchanged", "2026-07-13", "bogus", 1, nil, "2026-07-13"}, {"interval below 1 is treated as 1", "2026-07-13", "daily", 0, nil, "2026-07-14"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := ComputeNextOccurrence(mustParse(t, tc.due), tc.freq, tc.interval, tc.weekdays) want := mustParse(t, tc.want) if !got.Equal(want) { t.Errorf("ComputeNextOccurrence(%s, %s, %d, %v) = %s, want %s", tc.due, tc.freq, tc.interval, tc.weekdays, got.Format("2006-01-02"), tc.want) } }) } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cd /workspace/doot && go test ./internal/models/... -run TestComputeNextOccurrence -v` Expected: FAIL to compile — `ComputeNextOccurrence` doesn't exist yet. - [ ] **Step 3: Implement `ComputeNextOccurrence`** Create `internal/models/recurrence.go`: ```go package models import ( "sort" "time" ) // ComputeNextOccurrence returns the next occurrence date after due, given a // recurrence pattern. weekdays is only consulted when freq == "weekly"; nil // or empty means "same weekday as due, every interval weeks." Unknown freq // values return due unchanged. interval < 1 is treated as 1. // // Monthly/yearly rollover uses Go's standard AddDate overflow behavior (a // due date of Jan 31 + 1 month becomes Mar 3, not clamped to Feb's last // day) -- this is an accepted simplification, and the drift is permanent: // each call computes from the previous call's actual result, not an // original anchor day, so a drifted date locks onto its new day-of-month // going forward. Only anchor days 29-31 are ever affected; every month has // at least 28 days, so any anchor day <= 28 never drifts. func ComputeNextOccurrence(due time.Time, freq string, interval int, weekdays []int) time.Time { if interval < 1 { interval = 1 } switch freq { case "daily": return due.AddDate(0, 0, interval) case "weekly": return nextWeeklyOccurrence(due, interval, weekdays) case "monthly": return due.AddDate(0, interval, 0) case "yearly": return due.AddDate(interval, 0, 0) default: return due } } func nextWeeklyOccurrence(due time.Time, interval int, weekdays []int) time.Time { if len(weekdays) == 0 { return due.AddDate(0, 0, 7*interval) } sorted := append([]int(nil), weekdays...) sort.Ints(sorted) dueWeekday := int(due.Weekday()) for _, wd := range sorted { if wd > dueWeekday { return due.AddDate(0, 0, wd-dueWeekday) } } // Wrapped past the last active weekday this week: land on the first // active weekday, (interval-1) whole weeks further out than the // immediate next week (interval=1 means "next week", interval=2 means // "skip a week", etc). daysToNextWeekStart := 7 - dueWeekday return due.AddDate(0, 0, daysToNextWeekStart+sorted[0]+7*(interval-1)) } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `cd /workspace/doot && go test ./internal/models/... -run TestComputeNextOccurrence -v` Expected: PASS (13 test cases). - [ ] **Step 5: Commit** ```bash cd /workspace/doot git add internal/models/recurrence.go internal/models/recurrence_test.go git commit -m "feat(tasks): add ComputeNextOccurrence for recurring task scheduling" ``` --- ### Task 3: Iteration creation (`CreateNextIteration`, completion trigger) **Files:** - Modify: `internal/store/native_tasks.go` - Test: `internal/store/native_tasks_test.go` **Interfaces:** - Consumes: `models.ComputeNextOccurrence` (Task 2), `formatWeekdays`/`parseWeekdays` (Task 1). - Produces: `(s *Store) GetNativeTaskByID(id string) (*models.Task, error)`, `(s *Store) CreateNextIteration(old models.Task) error`, `(s *Store) SetTaskRecurrence(id, freq string, interval int, weekdays []int) error`, `(s *Store) SetNextOccurrenceOverride(id string, date time.Time) error` — Task 4 and Task 5 call these. `CompleteNativeTask`'s existing signature/behavior for non-recurring tasks is unchanged (only recurring tasks get new behavior). - [ ] **Step 1: Write the failing tests** Add to `internal/store/native_tasks_test.go`: ```go func TestGetNativeTaskByID_UnknownID_ReturnsErrNotFound(t *testing.T) { s := newNativeTasksTestStore(t) _, err := s.GetNativeTaskByID("does-not-exist") if !errors.Is(err, ErrNativeTaskNotFound) { t.Fatalf("expected ErrNativeTaskNotFound, got %v", err) } } func TestGetNativeTaskByID_RealID_ReturnsTask(t *testing.T) { s := newNativeTasksTestStore(t) task, err := s.GetNativeTaskByID("real-1") if err != nil { t.Fatalf("GetNativeTaskByID: %v", err) } if task.Content != "Real task" { t.Errorf("Content = %q, want %q", task.Content, "Real task") } } func TestCompleteNativeTask_NonRecurring_JustCompletes(t *testing.T) { s := newNativeTasksTestStore(t) if err := s.CompleteNativeTask("real-1"); err != nil { t.Fatalf("CompleteNativeTask: %v", err) } tasks, err := s.db.Query(`SELECT id FROM native_tasks`) if err != nil { t.Fatal(err) } defer tasks.Close() count := 0 for tasks.Next() { count++ } if count != 1 { t.Errorf("expected exactly 1 row (no iteration created for a non-recurring task), got %d", count) } } func TestCompleteNativeTask_LatestInSeries_CreatesNextIteration(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) VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 1, 'series-1') `); err != nil { t.Fatal(err) } if err := s.CompleteNativeTask("rec-1"); err != nil { t.Fatalf("CompleteNativeTask: %v", err) } var completed bool if err := s.db.QueryRow(`SELECT completed FROM native_tasks WHERE id = 'rec-1'`).Scan(&completed); err != nil { t.Fatal(err) } if !completed { t.Error("expected rec-1 to be marked completed") } var nextCount int var nextDue string if err := s.db.QueryRow(` SELECT COUNT(*), COALESCE(MAX(due_date), '') FROM native_tasks WHERE recurrence_series_id = 'series-1' AND id != 'rec-1' `).Scan(&nextCount, &nextDue); err != nil { t.Fatal(err) } if nextCount != 1 { t.Fatalf("expected exactly 1 new iteration, got %d", nextCount) } if nextDue[:10] != "2026-07-20" { t.Errorf("next iteration due_date = %q, want 2026-07-20", nextDue) } } func TestCompleteNativeTask_UsesNextOccurrenceOverride(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, next_occurrence_override) VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 1, 'series-1', '2026-08-01') `); err != nil { t.Fatal(err) } if err := s.CompleteNativeTask("rec-1"); err != nil { t.Fatalf("CompleteNativeTask: %v", err) } var nextDue string if err := s.db.QueryRow(` SELECT due_date FROM native_tasks WHERE recurrence_series_id = 'series-1' AND id != 'rec-1' `).Scan(&nextDue); err != nil { t.Fatal(err) } if nextDue[:10] != "2026-08-01" { t.Errorf("next iteration due_date = %q, want 2026-08-01 (the override)", nextDue) } } func TestCompleteNativeTask_AlreadySuperseded_DoesNotDoubleCreate(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) VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 1, 'series-1') `); err != nil { t.Fatal(err) } // Simulate the periodic due-check having already created the successor // before the user got around to completing rec-1. if _, err := s.db.Exec(` INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_series_id) VALUES ('rec-2', 'Water plants', '2026-07-20', 'weekly', 1, 'series-1') `); err != nil { t.Fatal(err) } if err := s.CompleteNativeTask("rec-1"); err != nil { t.Fatalf("CompleteNativeTask: %v", err) } var count int if err := s.db.QueryRow(`SELECT COUNT(*) FROM native_tasks WHERE recurrence_series_id = 'series-1'`).Scan(&count); err != nil { t.Fatal(err) } if count != 2 { t.Errorf("expected still exactly 2 rows in the series (no double-create), got %d", count) } } func TestSetTaskRecurrence_FirstTimeGeneratesSeriesID(t *testing.T) { s := newNativeTasksTestStore(t) if err := s.SetTaskRecurrence("real-1", "weekly", 1, []int{1, 3}); err != nil { t.Fatalf("SetTaskRecurrence: %v", err) } task, err := s.GetNativeTaskByID("real-1") if err != nil { t.Fatal(err) } if task.RecurrenceFreq != "weekly" { t.Errorf("RecurrenceFreq = %q, want weekly", task.RecurrenceFreq) } if task.RecurrenceSeriesID == "" { t.Error("expected a generated RecurrenceSeriesID, got empty string") } if len(task.RecurrenceWeekdays) != 2 || task.RecurrenceWeekdays[0] != 1 || task.RecurrenceWeekdays[1] != 3 { t.Errorf("RecurrenceWeekdays = %v, want [1 3]", task.RecurrenceWeekdays) } } func TestSetTaskRecurrence_ClearingKeepsSeriesID(t *testing.T) { s := newNativeTasksTestStore(t) if err := s.SetTaskRecurrence("real-1", "weekly", 1, nil); err != nil { t.Fatal(err) } task, err := s.GetNativeTaskByID("real-1") if err != nil { t.Fatal(err) } seriesID := task.RecurrenceSeriesID if err := s.SetTaskRecurrence("real-1", "", 1, nil); err != nil { t.Fatalf("SetTaskRecurrence (clear): %v", err) } task, err = s.GetNativeTaskByID("real-1") if err != nil { t.Fatal(err) } if task.RecurrenceFreq != "" { t.Errorf("RecurrenceFreq = %q, want empty after clearing", task.RecurrenceFreq) } if task.RecurrenceSeriesID != seriesID { t.Errorf("RecurrenceSeriesID = %q, want unchanged %q after clearing", task.RecurrenceSeriesID, seriesID) } } func TestSetNextOccurrenceOverride_UnknownID_ReturnsErrNotFound(t *testing.T) { s := newNativeTasksTestStore(t) err := s.SetNextOccurrenceOverride("does-not-exist", time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)) 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 "TestGetNativeTaskByID|TestCompleteNativeTask_|TestSetTaskRecurrence|TestSetNextOccurrenceOverride" -v` Expected: FAIL to compile — `GetNativeTaskByID`, `CreateNextIteration`, `SetTaskRecurrence`, `SetNextOccurrenceOverride` don't exist yet, and `CompleteNativeTask`'s recurrence-aware tests fail against the old implementation. - [ ] **Step 3: Implement the new store methods and update `CompleteNativeTask`** In `internal/store/native_tasks.go`, add these imports: ```go import ( "crypto/rand" "database/sql" "encoding/json" "errors" "fmt" "strconv" "strings" "time" "task-dashboard/internal/models" ) ``` Add `GetNativeTaskByID` (place it right after `GetUndatedNativeTasks`): ```go // GetNativeTaskByID returns a single native task by id, or ErrNativeTaskNotFound. func (s *Store) GetNativeTaskByID(id string) (*models.Task, error) { rows, err := s.db.Query(` 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 FROM native_tasks WHERE id = ? `, id) if err != nil { return nil, err } defer func() { _ = rows.Close() }() tasks, err := scanNativeTasks(rows) if err != nil { return nil, err } if len(tasks) == 0 { return nil, ErrNativeTaskNotFound } return &tasks[0], nil } ``` Replace `CompleteNativeTask` in full: ```go // CompleteNativeTask marks a task as completed. If it's the latest // occurrence of a recurring series (no newer row exists yet), it also // creates the next iteration. Returns ErrNativeTaskNotFound if id doesn't // match any row. func (s *Store) CompleteNativeTask(id string) error { task, err := s.GetNativeTaskByID(id) if err != nil { return err } result, err := s.db.Exec(` UPDATE native_tasks SET completed = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? `, id) if err != nil { return err } if err := checkRowsAffected(result); err != nil { return err } if task.RecurrenceSeriesID == "" { return nil } isLatest, err := s.isLatestInSeries(*task) if err != nil { return err } if !isLatest { return nil } return s.CreateNextIteration(*task) } // isLatestInSeries reports whether task is the row with the latest // due_date in its recurrence series (i.e., no newer iteration has been // created yet). Ties on due_date are broken by created_at: the // more-recently-created row wins, so CreateNextIteration's freshly-inserted // row always displaces the row it was generated from, never the reverse. func (s *Store) isLatestInSeries(task models.Task) (bool, error) { var exists bool err := s.db.QueryRow(` SELECT EXISTS ( SELECT 1 FROM native_tasks WHERE recurrence_series_id = ? AND (due_date > ? OR (due_date = ? AND created_at > ?)) ) `, task.RecurrenceSeriesID, task.DueDate, task.DueDate, task.CreatedAt).Scan(&exists) if err != nil { return false, err } return !exists, nil } // CreateNextIteration copies old's content, description, project_name, // priority, labels, and recurrence fields onto a brand-new row (new id, // same recurrence_series_id, completed=false, next_occurrence_override=""), // with due_date set to old.NextOccurrenceOverride if present, else // ComputeNextOccurrence(old.DueDate, ...). old itself is left untouched. 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, due_date, priority, labels, recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) `, newTaskID(), old.Content, old.Description, old.ProjectName, nextDue, old.Priority, string(labelsJSON), old.RecurrenceFreq, old.RecurrenceInterval, formatWeekdays(old.RecurrenceWeekdays), old.RecurrenceSeriesID) return err } // newTaskID generates a random hex id for a new native_tasks row -- the // same format as handlers.newID(), duplicated here since store must not // import handlers. func newTaskID() string { b := make([]byte, 12) _, _ = rand.Read(b) return fmt.Sprintf("%x", b) } // SetTaskRecurrence sets or clears a task's recurrence pattern. freq == "" // clears the pattern (recurrence_series_id is left untouched so history // stays linkable -- a cleared task just stops generating new iterations). // Setting a freq for the first time (existing recurrence_series_id is // empty) generates a new series id. Returns ErrNativeTaskNotFound if id // doesn't match any row. func (s *Store) SetTaskRecurrence(id, freq string, interval int, weekdays []int) error { task, err := s.GetNativeTaskByID(id) if err != nil { return err } seriesID := task.RecurrenceSeriesID if freq != "" && seriesID == "" { seriesID = newTaskID() } result, err := s.db.Exec(` UPDATE native_tasks SET recurrence_freq = ?, recurrence_interval = ?, recurrence_weekdays = ?, recurrence_series_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `, freq, interval, formatWeekdays(weekdays), seriesID, id) if err != nil { return err } return checkRowsAffected(result) } // SetNextOccurrenceOverride sets a one-shot override for a task's next // occurrence, consumed (read, but not explicitly cleared -- the override // column simply isn't copied onto the new row) the next time // CreateNextIteration runs for its series. Returns ErrNativeTaskNotFound if // id doesn't match any row. func (s *Store) SetNextOccurrenceOverride(id string, date time.Time) error { result, err := s.db.Exec(` UPDATE native_tasks SET next_occurrence_override = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `, date.Format("2006-01-02"), id) if err != nil { return err } return checkRowsAffected(result) } ``` - [ ] **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 Go build and handlers package test** Run: `cd /workspace/doot && go build ./... && go test ./internal/handlers/...` Expected: same result as Task 1 Step 8 (build succeeds; only the two pre-existing unrelated failures remain). - [ ] **Step 6: Commit** ```bash cd /workspace/doot git add internal/store/native_tasks.go internal/store/native_tasks_test.go git commit -m "feat(tasks): create the next recurring iteration on completion CompleteNativeTask now creates a new row for the next occurrence when completing the latest iteration of a recurring series (using the one-shot next_occurrence_override if set, else ComputeNextOccurrence). Completing an already-superseded row (the periodic due-check beat it to creating the successor) just marks it completed, no double-create." ``` --- ### Task 4: Periodic due-date trigger **Files:** - Modify: `internal/store/native_tasks.go` - Test: `internal/store/native_tasks_test.go` - Create: `internal/scheduler/recurrence.go` - Modify: `cmd/dashboard/main.go` **Interfaces:** - Consumes: `Store.CreateNextIteration` (Task 3). - Produces: `(s *Store) GetSeriesNeedingNextIteration(now time.Time) ([]models.Task, error)`, `(s *Store) AdvanceDueRecurringTasks(now time.Time) (int, error)`, `scheduler.RunRecurrenceCheck(ctx context.Context, s *store.Store, interval time.Duration)`. - [ ] **Step 1: Write the failing tests** Add to `internal/store/native_tasks_test.go`: ```go func TestAdvanceDueRecurringTasks_DueUncompleted_CreatesSuccessor(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) VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 1, 'series-1') `); err != nil { t.Fatal(err) } now := time.Date(2026, 7, 14, 0, 0, 0, 0, time.UTC) // one day after due, still uncompleted n, err := s.AdvanceDueRecurringTasks(now) if err != nil { t.Fatalf("AdvanceDueRecurringTasks: %v", err) } if n != 1 { t.Fatalf("expected 1 iteration created, got %d", n) } var completed bool if err := s.db.QueryRow(`SELECT completed FROM native_tasks WHERE id = 'rec-1'`).Scan(&completed); err != nil { t.Fatal(err) } if completed { t.Error("expected rec-1 to remain uncompleted -- due-date passing doesn't complete it, just spawns the successor") } var count int if err := s.db.QueryRow(`SELECT COUNT(*) FROM native_tasks WHERE recurrence_series_id = 'series-1'`).Scan(&count); err != nil { t.Fatal(err) } if count != 2 { t.Fatalf("expected 2 rows in the series (original + successor), got %d", count) } } func TestAdvanceDueRecurringTasks_NotYetDue_LeavesAlone(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) VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 1, 'series-1') `); err != nil { t.Fatal(err) } now := time.Date(2026, 7, 10, 0, 0, 0, 0, time.UTC) // before the due date n, err := s.AdvanceDueRecurringTasks(now) if err != nil { t.Fatalf("AdvanceDueRecurringTasks: %v", err) } if n != 0 { t.Errorf("expected 0 iterations created for a not-yet-due task, got %d", n) } } func TestAdvanceDueRecurringTasks_AlreadyHasSuccessor_SkipsIt(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) VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 1, 'series-1') `); err != nil { t.Fatal(err) } if _, err := s.db.Exec(` INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_series_id) VALUES ('rec-2', 'Water plants', '2026-07-20', 'weekly', 1, 'series-1') `); err != nil { t.Fatal(err) } now := time.Date(2026, 7, 21, 0, 0, 0, 0, time.UTC) n, err := s.AdvanceDueRecurringTasks(now) if err != nil { t.Fatalf("AdvanceDueRecurringTasks: %v", err) } if n != 1 { t.Fatalf("expected only rec-2 (the latest, now also due) to spawn a successor, got n=%d", n) } var count int if err := s.db.QueryRow(`SELECT COUNT(*) FROM native_tasks WHERE recurrence_series_id = 'series-1'`).Scan(&count); err != nil { t.Fatal(err) } if count != 3 { t.Fatalf("expected 3 rows total (rec-1, rec-2, and rec-2's new successor), got %d", count) } } func TestAdvanceDueRecurringTasks_NonRecurringTask_NeverTouched(t *testing.T) { s := newNativeTasksTestStore(t) // "real-1" (from newNativeTasksTestStore) has no due_date and no recurrence. now := time.Date(2026, 7, 14, 0, 0, 0, 0, time.UTC) n, err := s.AdvanceDueRecurringTasks(now) if err != nil { t.Fatalf("AdvanceDueRecurringTasks: %v", err) } if n != 0 { t.Errorf("expected 0 iterations created (no recurring tasks in fixture), got %d", n) } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cd /workspace/doot && go test ./internal/store/... -run TestAdvanceDueRecurringTasks -v` Expected: FAIL to compile — `AdvanceDueRecurringTasks` doesn't exist yet. - [ ] **Step 3: Implement `GetSeriesNeedingNextIteration` and `AdvanceDueRecurringTasks`** In `internal/store/native_tasks.go`, add (after `SetNextOccurrenceOverride`): ```go // GetSeriesNeedingNextIteration returns the latest row of every recurring // series whose due_date has arrived (<= now) and which has no newer row // yet in its series -- regardless of completed state, so a series whose // synchronous CreateNextIteration call (from CompleteNativeTask) somehow // failed still gets healed on the next tick, and so an uncompleted, // ignored recurring task doesn't block its successor from appearing. func (s *Store) GetSeriesNeedingNextIteration(now time.Time) ([]models.Task, error) { rows, err := s.db.Query(` 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 FROM native_tasks t1 WHERE recurrence_series_id != '' AND due_date IS NOT NULL AND due_date <= ? AND NOT EXISTS ( SELECT 1 FROM native_tasks t2 WHERE t2.recurrence_series_id = t1.recurrence_series_id AND (t2.due_date > t1.due_date OR (t2.due_date = t1.due_date AND t2.created_at > t1.created_at)) ) `, now) if err != nil { return nil, err } defer func() { _ = rows.Close() }() return scanNativeTasks(rows) } // AdvanceDueRecurringTasks creates the next iteration for every recurring // series whose latest row is due (or overdue) and has no successor yet. // Returns the number of iterations created. func (s *Store) AdvanceDueRecurringTasks(now time.Time) (int, error) { series, err := s.GetSeriesNeedingNextIteration(now) if err != nil { return 0, err } for _, task := range series { if err := s.CreateNextIteration(task); err != nil { return 0, err } } return len(series), nil } ``` - [ ] **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: Add the scheduler package** Create `internal/scheduler/recurrence.go`: ```go package scheduler import ( "context" "log" "time" "task-dashboard/internal/config" "task-dashboard/internal/store" ) // RunRecurrenceCheck ticks every interval, calling AdvanceDueRecurringTasks // until ctx is cancelled. Errors are logged, not fatal -- one bad tick // shouldn't kill the loop; the next tick tries again. func RunRecurrenceCheck(ctx context.Context, s *store.Store, interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: n, err := s.AdvanceDueRecurringTasks(config.Now()) if err != nil { log.Printf("ERROR [RecurrenceCheck]: %v", err) continue } if n > 0 { log.Printf("RecurrenceCheck: created %d next iteration(s)", n) } } } } ``` - [ ] **Step 6: Wire the scheduler into `main.go`** In `cmd/dashboard/main.go`, find the graceful-shutdown section: ```go addr := ":" + cfg.Port srv := &http.Server{ Addr: addr, Handler: r, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, } // Graceful shutdown go func() { log.Printf("Starting server on http://localhost%s", addr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Server failed: %v", err) } }() // Wait for interrupt signal quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit log.Println("Shutting down server...") ``` Replace with: ```go addr := ":" + cfg.Port srv := &http.Server{ Addr: addr, Handler: r, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, } // Periodic recurring-task due-date check (independent of the HTTP // server's own lifecycle, cancelled alongside it on shutdown below). schedulerCtx, cancelScheduler := context.WithCancel(context.Background()) go scheduler.RunRecurrenceCheck(schedulerCtx, db, 15*time.Minute) // Graceful shutdown go func() { log.Printf("Starting server on http://localhost%s", addr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Server failed: %v", err) } }() // Wait for interrupt signal quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit log.Println("Shutting down server...") cancelScheduler() ``` Add the import (`"task-dashboard/internal/scheduler"`) to `main.go`'s import block, alongside the other `task-dashboard/internal/...` imports. - [ ] **Step 7: Run the full build** Run: `cd /workspace/doot && go build ./...` Expected: BUILD SUCCESSFUL. - [ ] **Step 8: Commit** ```bash cd /workspace/doot git add internal/store/native_tasks.go internal/store/native_tasks_test.go internal/scheduler/recurrence.go cmd/dashboard/main.go git commit -m "feat(tasks): add periodic due-date check for recurring tasks A recurring task's successor now also gets created once its due date passes, independent of completion -- an ignored/overdue recurring task no longer blocks the next occurrence from appearing. Runs every 15 minutes via a new goroutine in main.go, cancelled on shutdown." ``` --- ### Task 5: HTTP endpoints **Files:** - Modify: `internal/handlers/widget.go` - Modify: `cmd/dashboard/main.go` - Test: `internal/handlers/widget_test.go` **Interfaces:** - Consumes: `Store.GetNativeTaskByID`, `Store.SetTaskRecurrence`, `Store.SetNextOccurrenceOverride` (Task 3), `models.ComputeNextOccurrence` (Task 2), existing `Store.UpdateNativeTask`. - Produces: `GET /api/widget/task?id=&source=`, `POST /api/widget/task/update`, `POST /api/widget/task/recurrence`, `POST /api/widget/task/next-date` — consumed by Task 6's Android `WidgetRepository`. - [ ] **Step 1: Write the failing tests** Add to `internal/handlers/widget_test.go`: ```go func TestHandleWidgetTaskDetail_ReturnsFullDetail(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} if _, err := s.DB().Exec(` INSERT INTO native_tasks (id, content, description, due_date, recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id) VALUES ('rec-1', 'Water plants', 'Use the blue can', '2026-07-13', 'weekly', 1, '1,3,5', 'series-1') `); 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.Title != "Water plants" { t.Errorf("Title = %q, want %q", resp.Title, "Water plants") } if resp.Description != "Use the blue can" { t.Errorf("Description = %q, want %q", resp.Description, "Use the blue can") } if resp.Recurrence == nil { t.Fatal("expected non-nil Recurrence") } if resp.Recurrence.Freq != "weekly" || len(resp.Recurrence.Weekdays) != 3 { t.Errorf("Recurrence = %+v, want freq=weekly with 3 weekdays", resp.Recurrence) } if resp.NextDate == nil { t.Fatal("expected computed NextDate for a recurring task") } } func TestHandleWidgetTaskDetail_NonRecurring_NilRecurrenceAndNextDate(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 milk')`); err != nil { t.Fatal(err) } req := httptest.NewRequest("GET", "/api/widget/task?id=plain-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", w.Code) } var resp taskDetailResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp.Recurrence != nil { t.Errorf("expected nil Recurrence for a non-recurring task, got %+v", resp.Recurrence) } if resp.NextDate != nil { t.Errorf("expected nil NextDate for a non-recurring task, got %v", resp.NextDate) } } func TestHandleWidgetTaskDetail_UnknownID_Returns404(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} req := httptest.NewRequest("GET", "/api/widget/task?id=does-not-exist&source=doot", nil) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetTaskDetail).ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Errorf("expected 404, got %d", w.Code) } } func TestHandleWidgetTaskUpdate_UpdatesTitleAndDescription(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', 'Old title')`); err != nil { t.Fatal(err) } body := `{"id":"plain-1","title":"New title","description":"New description"}` req := httptest.NewRequest("POST", "/api/widget/task/update", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetTaskUpdate).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.Content != "New title" || task.Description != "New description" { t.Errorf("task = %+v, want title/description updated", task) } } func TestHandleWidgetTaskRecurrence_SetsPattern(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', 'Water plants')`); err != nil { t.Fatal(err) } body := `{"id":"plain-1","freq":"weekly","interval":2,"weekdays":[1,3]}` req := httptest.NewRequest("POST", "/api/widget/task/recurrence", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetTaskRecurrence).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.RecurrenceFreq != "weekly" || task.RecurrenceInterval != 2 || task.RecurrenceSeriesID == "" { t.Errorf("task = %+v, want recurrence set with a generated series id", task) } } func TestHandleWidgetTaskRecurrence_InvalidFreq_Returns400(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} body := `{"id":"plain-1","freq":"bogus","interval":1}` req := httptest.NewRequest("POST", "/api/widget/task/recurrence", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetTaskRecurrence).ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for an invalid freq, got %d", w.Code) } } func TestHandleWidgetTaskNextDate_SetsOverride(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} if _, err := s.DB().Exec(` INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_series_id) VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 'series-1') `); err != nil { t.Fatal(err) } body := `{"id":"rec-1","date":"2026-08-01"}` req := httptest.NewRequest("POST", "/api/widget/task/next-date", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetTaskNextDate).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } task, err := s.GetNativeTaskByID("rec-1") if err != nil { t.Fatal(err) } if task.NextOccurrenceOverride == nil || task.NextOccurrenceOverride.Format("2006-01-02") != "2026-08-01" { t.Errorf("NextOccurrenceOverride = %v, want 2026-08-01", task.NextOccurrenceOverride) } } func TestHandleWidgetTaskNextDate_NonRecurringTask_Returns400(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 milk')`); err != nil { t.Fatal(err) } body := `{"id":"plain-1","date":"2026-08-01"}` req := httptest.NewRequest("POST", "/api/widget/task/next-date", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetTaskNextDate).ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for a non-recurring task, got %d", w.Code) } } ``` Check whether `setupTestDB` exposes the underlying `*sql.DB` (needed for the raw `INSERT` fixtures above) — search for `func (s *Store) DB()`: ```bash grep -n "func (s \*Store) DB()" /workspace/doot/internal/store/sqlite.go ``` If it doesn't exist, add it to `internal/store/sqlite.go` (near the `Store` struct definition): ```go // DB returns the underlying *sql.DB, for callers (tests, session store // wiring) that need direct access. func (s *Store) DB() *sql.DB { return s.db } ``` (Check first — this accessor may already exist for the `sessionManager.Store = sqlite3store.New(db.DB())` call already present in `cmd/dashboard/main.go`; if so, skip this addition.) - [ ] **Step 2: Run tests to verify they fail** Run: `cd /workspace/doot && go test ./internal/handlers/... -run "TestHandleWidgetTaskDetail|TestHandleWidgetTaskUpdate|TestHandleWidgetTaskRecurrence|TestHandleWidgetTaskNextDate" -v` Expected: FAIL to compile — none of the four handlers or the `taskDetailResponse`/`recurrenceResponse` types exist yet. - [ ] **Step 3: Implement the four handlers** In `internal/handlers/widget.go`, add (after `HandleWidgetAdd`, or any convenient point after the existing widget handlers): ```go type recurrenceResponse struct { Freq string `json:"freq"` Interval int `json:"interval"` Weekdays []int `json:"weekdays,omitempty"` } 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"` } // HandleWidgetTaskDetail returns full detail for a single doot-native task, // including its recurrence pattern and computed next-occurrence date. func (h *Handler) HandleWidgetTaskDetail(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("id") source := r.URL.Query().Get("source") if id == "" || source != "doot" { http.Error(w, "id required and source must be doot", http.StatusBadRequest) return } task, err := h.store.GetNativeTaskByID(id) if err != nil { if errors.Is(err, store.ErrNativeTaskNotFound) { http.Error(w, "task not found", http.StatusNotFound) return } http.Error(w, "failed to load task", http.StatusInternalServerError) return } resp := taskDetailResponse{ ID: task.ID, Title: task.Content, Description: task.Description, DueDate: task.DueDate, Completed: task.Completed, } if task.RecurrenceSeriesID != "" { resp.Recurrence = &recurrenceResponse{ Freq: task.RecurrenceFreq, Interval: task.RecurrenceInterval, Weekdays: task.RecurrenceWeekdays, } if task.NextOccurrenceOverride != nil { resp.NextDate = task.NextOccurrenceOverride } else if task.DueDate != nil { next := models.ComputeNextOccurrence(*task.DueDate, task.RecurrenceFreq, task.RecurrenceInterval, task.RecurrenceWeekdays) resp.NextDate = &next } } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } type taskUpdateRequest struct { ID string `json:"id"` Title string `json:"title"` Description string `json:"description"` } // HandleWidgetTaskUpdate updates a doot-native task's title and description. func (h *Handler) HandleWidgetTaskUpdate(w http.ResponseWriter, r *http.Request) { var req taskUpdateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if req.ID == "" || req.Title == "" { http.Error(w, "id and title are required", http.StatusBadRequest) return } if err := h.store.UpdateNativeTask(req.ID, req.Title, req.Description); err != nil { http.Error(w, "failed to update task", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } type taskRecurrenceRequest struct { ID string `json:"id"` Freq string `json:"freq"` Interval int `json:"interval"` Weekdays []int `json:"weekdays"` } // HandleWidgetTaskRecurrence sets or clears a doot-native task's recurrence // pattern. freq == "" clears recurrence. func (h *Handler) HandleWidgetTaskRecurrence(w http.ResponseWriter, r *http.Request) { var req taskRecurrenceRequest 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 req.Freq != "" && req.Freq != "daily" && req.Freq != "weekly" && req.Freq != "monthly" && req.Freq != "yearly" { http.Error(w, "invalid freq", http.StatusBadRequest) return } if err := h.store.SetTaskRecurrence(req.ID, req.Freq, req.Interval, req.Weekdays); err != nil { if errors.Is(err, store.ErrNativeTaskNotFound) { http.Error(w, "task not found", http.StatusNotFound) return } http.Error(w, "failed to update recurrence", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } type taskNextDateRequest struct { ID string `json:"id"` Date string `json:"date"` // YYYY-MM-DD } // HandleWidgetTaskNextDate sets a one-shot override for a recurring task's // next occurrence. 400 if the task has no active recurrence. func (h *Handler) HandleWidgetTaskNextDate(w http.ResponseWriter, r *http.Request) { var req taskNextDateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } parsed, err := time.Parse("2006-01-02", req.Date) if err != nil { http.Error(w, "invalid date", http.StatusBadRequest) return } task, err := h.store.GetNativeTaskByID(req.ID) if err != nil { if errors.Is(err, store.ErrNativeTaskNotFound) { http.Error(w, "task not found", http.StatusNotFound) return } http.Error(w, "failed to load task", http.StatusInternalServerError) return } if task.RecurrenceSeriesID == "" { http.Error(w, "task has no active recurrence", http.StatusBadRequest) return } tz := config.GetDisplayTimezone() dueDate := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, tz) if err := h.store.SetNextOccurrenceOverride(req.ID, dueDate); err != nil { http.Error(w, "failed to set next date", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } ``` Add `"task-dashboard/internal/models"` to `widget.go`'s import block if not already present (check first — `TimelineItemToWidgetItem` in the same file already references `models.WidgetItem`/`models.TimelineItem`, so this import should already exist). - [ ] **Step 4: Register the four routes** In `cmd/dashboard/main.go`, find: ```go r.With(widgetAuth).Get("/api/widget", h.HandleWidgetGet) r.With(widgetAuth).Post("/api/widget/complete", h.HandleWidgetComplete) r.With(widgetAuth).Post("/api/widget/reschedule", h.HandleWidgetReschedule) r.With(widgetAuth).Post("/api/widget/add", h.HandleWidgetAdd) ``` Replace with: ```go r.With(widgetAuth).Get("/api/widget", h.HandleWidgetGet) r.With(widgetAuth).Post("/api/widget/complete", h.HandleWidgetComplete) r.With(widgetAuth).Post("/api/widget/reschedule", h.HandleWidgetReschedule) r.With(widgetAuth).Post("/api/widget/add", h.HandleWidgetAdd) 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) ``` - [ ] **Step 5: Run tests to verify they pass** Run: `cd /workspace/doot && go test ./internal/handlers/... -run "TestHandleWidgetTaskDetail|TestHandleWidgetTaskUpdate|TestHandleWidgetTaskRecurrence|TestHandleWidgetTaskNextDate" -v` Expected: PASS for all 8 new tests. - [ ] **Step 6: Run the full Go build and test suite** Run: `cd /workspace/doot && go build ./... && go test ./internal/...` Expected: `go build` succeeds. `go test` passes except the two pre-existing, unrelated `internal/handlers` failures and the pre-existing `internal/models` vet/build failure (`MealToAtom` undefined in `atom_test.go`) — all confirmed present before this entire body of work. - [ ] **Step 7: Commit** ```bash cd /workspace/doot git add internal/handlers/widget.go cmd/dashboard/main.go internal/store/sqlite.go git commit -m "feat(tasks): add HTTP endpoints for task detail, update, and recurrence GET /api/widget/task, POST /api/widget/task/update, POST /api/widget/task/recurrence, POST /api/widget/task/next-date. Doot-only; the recurrence/next-date fields are null in the detail response for a non-recurring task." ``` --- ### Task 6: Android `WidgetRepository` data layer **Files:** - Create: `android/app/src/main/java/org/terst/doot/widget/data/TaskDetail.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 /api/widget/task`, `POST /api/widget/task/update`, `POST /api/widget/task/recurrence`, `POST /api/widget/task/next-date` (Task 5). - Produces: `TaskDetailResponse`, `TaskRecurrence` data classes; `WidgetRepository.fetchTaskDetail(id: String): Result`, `.updateTask(id, title, description): Result`, `.setTaskRecurrence(id, freq, interval, weekdays): Result`, `.setTaskNextDate(id, dateISO): Result` — consumed by Task 8's `TaskDetailActivity`. - [ ] **Step 1: Write the failing test** Add to `android/app/src/test/java/org/terst/doot/widget/WidgetRepositoryTest.kt` (append inside the `WidgetRepositoryTest` class, before the closing brace): ```kotlin @Test fun `fetchTaskDetail returns success with valid JSON`() = runTest { val json = """{"id":"rec-1","title":"Water plants","description":"Use the blue can","due_date":"2026-07-13T00:00:00-10:00","completed":false,"recurrence":{"freq":"weekly","interval":1,"weekdays":[1,3,5]},"next_date":"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.fetchTaskDetail("rec-1") assertTrue(result.isSuccess) val detail = result.getOrThrow() assertEquals("Water plants", detail.title) assertEquals("weekly", detail.recurrence?.freq) assertEquals(listOf(1, 3, 5), detail.recurrence?.weekdays) assertEquals("2026-07-15T00:00:00-10:00", detail.nextDate) } @Test fun `fetchTaskDetail returns failure on non-200`() = runTest { every { mockClient.newCall(any()) } returns mockCall every { mockCall.execute() } returns responseOf(404, "not found") val repo = WidgetRepository(mockClient, "https://doot.example.com", "token") val result = repo.fetchTaskDetail("does-not-exist") assertTrue(result.isFailure) } @Test fun `fetchTaskDetail requests the given id with source=doot`() = runTest { val json = """{"id":"rec-1","title":"Water plants","description":"","completed":false}""" val capturedRequest = slot() every { mockClient.newCall(capture(capturedRequest)) } returns mockCall every { mockCall.execute() } returns responseOf(200, json) val repo = WidgetRepository(mockClient, "https://doot.example.com", "token") repo.fetchTaskDetail("rec-1") val url = capturedRequest.captured.url.toString() assertTrue("expected id=rec-1 in URL, got $url", url.contains("id=rec-1")) assertTrue("expected source=doot in URL, got $url", url.contains("source=doot")) } ``` - [ ] **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 — `fetchTaskDetail`/`TaskDetailResponse`/`TaskRecurrence` don't exist yet. - [ ] **Step 3: Add the data classes** Create `android/app/src/main/java/org/terst/doot/widget/data/TaskDetail.kt`: ```kotlin package org.terst.doot.widget.data import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @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 ) @Serializable data class TaskRecurrence( val freq: String, val interval: Int, val weekdays: List = emptyList() ) ``` - [ ] **Step 4: Add the four `WidgetRepository` methods** In `android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt`, add after `addTask` (before the closing `}` of the class): ```kotlin /** GETs full detail for a single doot-native task from /api/widget/task. */ suspend fun fetchTaskDetail(id: String): Result = withContext(Dispatchers.IO) { val encodedId = java.net.URLEncoder.encode(id, "UTF-8") val request = Request.Builder() .url("$serverUrl/api/widget/task?id=$encodedId&source=doot") .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 TaskUpdateRequest(val id: String, val title: String, val description: String) /** POSTs a title/description update to /api/widget/task/update. */ suspend fun updateTask(id: String, title: String, description: String): Result = withContext(Dispatchers.IO) { val body = json.encodeToString(TaskUpdateRequest(id, title, description)) .toRequestBody("application/json".toMediaType()) val request = Request.Builder() .url("$serverUrl/api/widget/task/update") .header("Authorization", "Bearer $token") .post(body) .build() runCatching { val response = client.newCall(request).execute() check(response.isSuccessful) { "HTTP ${response.code}" } } } @Serializable private data class TaskRecurrenceRequest(val id: String, val freq: String, val interval: Int, val weekdays: List) /** POSTs a recurrence pattern change to /api/widget/task/recurrence. freq="" clears it. */ suspend fun setTaskRecurrence(id: String, freq: String, interval: Int, weekdays: List): Result = withContext(Dispatchers.IO) { val body = json.encodeToString(TaskRecurrenceRequest(id, freq, interval, weekdays)) .toRequestBody("application/json".toMediaType()) val request = Request.Builder() .url("$serverUrl/api/widget/task/recurrence") .header("Authorization", "Bearer $token") .post(body) .build() runCatching { val response = client.newCall(request).execute() check(response.isSuccessful) { "HTTP ${response.code}" } } } @Serializable private data class TaskNextDateRequest(val id: String, val date: String) /** POSTs a one-shot next-occurrence override to /api/widget/task/next-date. */ suspend fun setTaskNextDate(id: String, dateISO: String): Result = withContext(Dispatchers.IO) { val body = json.encodeToString(TaskNextDateRequest(id, dateISO)) .toRequestBody("application/json".toMediaType()) val request = Request.Builder() .url("$serverUrl/api/widget/task/next-date") .header("Authorization", "Bearer $token") .post(body) .build() runCatching { val response = client.newCall(request).execute() check(response.isSuccessful) { "HTTP ${response.code}" } } } ``` - [ ] **Step 5: 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 6: Run the full Android unit test suite** Run: `cd /workspace/doot/android && ./gradlew testDebugUnitTest` Expected: BUILD SUCCESSFUL, all tests pass. - [ ] **Step 7: Commit** ```bash cd /workspace/doot git add android/app/src/main/java/org/terst/doot/widget/data/TaskDetail.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 WidgetRepository methods for task detail/recurrence" ``` --- ### Task 7: Description linkification **Files:** - Create: `android/app/src/main/java/org/terst/doot/widget/ui/LinkifiedText.kt` - Test: `android/app/src/test/java/org/terst/doot/widget/ui/LinkifiedTextTest.kt` **Interfaces:** - Consumes: nothing new. - Produces: `internal fun findLinks(text: String): List`, `enum class LinkKind { URL, PHONE }`, `data class TextLink(val range: IntRange, val kind: LinkKind, val target: String)`, `@Composable fun LinkifiedText(text: String, modifier: Modifier, onOpenUrl: (String) -> Unit, onDialPhone: (String) -> Unit)` — the Composable is consumed by Task 8. - [ ] **Step 1: Write the failing tests** Create `android/app/src/test/java/org/terst/doot/widget/ui/LinkifiedTextTest.kt`: ```kotlin package org.terst.doot.widget.ui import org.junit.Assert.assertEquals import org.junit.Test class LinkifiedTextTest { @Test fun `findLinks finds a plain URL`() { val links = findLinks("Check out https://example.com/path for details") assertEquals(1, links.size) assertEquals(LinkKind.URL, links[0].kind) assertEquals("https://example.com/path", links[0].target) } @Test fun `findLinks finds a phone number`() { val links = findLinks("Call me at 555-123-4567 tomorrow") assertEquals(1, links.size) assertEquals(LinkKind.PHONE, links[0].kind) assertEquals("555-123-4567", links[0].target) } @Test fun `findLinks finds both a URL and a phone number in the same text`() { val links = findLinks("See https://example.com or call 555-123-4567") assertEquals(2, links.size) assertEquals(LinkKind.URL, links[0].kind) assertEquals(LinkKind.PHONE, links[1].kind) } @Test fun `findLinks returns empty list for plain text`() { val links = findLinks("Just a regular description with no links.") assertEquals(0, links.size) } @Test fun `findLinks does not double-count phone-like digits inside a URL`() { val links = findLinks("https://example.com/555-123-4567") assertEquals(1, links.size) assertEquals(LinkKind.URL, links[0].kind) } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cd /workspace/doot/android && ./gradlew testDebugUnitTest --tests "org.terst.doot.widget.ui.LinkifiedTextTest"` Expected: FAILS to compile — `findLinks`/`LinkKind`/`TextLink` don't exist yet. - [ ] **Step 3: Implement `LinkifiedText.kt`** Create `android/app/src/main/java/org/terst/doot/widget/ui/LinkifiedText.kt`: ```kotlin package org.terst.doot.widget.ui import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.text.BasicText 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.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.sp enum class LinkKind { URL, PHONE } data class TextLink(val range: IntRange, val kind: LinkKind, val target: String) private val URL_REGEX = Regex("""https?://[^\s<>"']+""") private val PHONE_REGEX = Regex("""\+?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}""") /** * Finds URL and phone-number spans in text. Phone matches that overlap an * already-found URL span are dropped (a URL's digits could coincidentally * match the phone pattern; the URL match wins). Intentionally simple -- * US-style phone numbers, not a full i18n phone-number parser. */ internal fun findLinks(text: String): List { val links = mutableListOf() for (match in URL_REGEX.findAll(text)) { links.add(TextLink(match.range, LinkKind.URL, match.value)) } for (match in PHONE_REGEX.findAll(text)) { val overlaps = links.any { it.range.first <= match.range.last && match.range.first <= it.range.last } if (!overlaps) { links.add(TextLink(match.range, LinkKind.PHONE, match.value)) } } return links.sortedBy { it.range.first } } /** * Renders text with URLs and phone numbers underlined and tappable. * Doesn't use Compose's LinkAnnotation API (unconfirmed whether it's fully * wired into Text's click handling at this project's resolved Compose UI * version, 1.6.1) -- instead resolves taps manually via * TextLayoutResult.getOffsetForPosition, which works on any Compose version. */ @Composable fun LinkifiedText( text: String, modifier: Modifier = Modifier, onOpenUrl: (String) -> Unit, onDialPhone: (String) -> Unit ) { val links = remember(text) { findLinks(text) } val annotated = remember(text, links) { buildAnnotatedString { append(text) for (link in links) { addStyle( SpanStyle(color = Color(0xFF60A5FA), textDecoration = TextDecoration.Underline), link.range.first, link.range.last + 1 ) } } } var layoutResult by remember { mutableStateOf(null) } BasicText( text = annotated, modifier = modifier.pointerInput(links) { detectTapGestures { offset -> val layout = layoutResult ?: return@detectTapGestures val charOffset = layout.getOffsetForPosition(offset) links.firstOrNull { charOffset in it.range }?.let { link -> when (link.kind) { LinkKind.URL -> onOpenUrl(link.target) LinkKind.PHONE -> onDialPhone(link.target) } } } }, style = TextStyle(color = Color.White.copy(alpha = 0.85f), fontSize = 14.sp), onTextLayout = { layoutResult = it } ) } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `cd /workspace/doot/android && ./gradlew testDebugUnitTest --tests "org.terst.doot.widget.ui.LinkifiedTextTest"` Expected: PASS (5 tests). - [ ] **Step 5: Run the full Android unit test suite** Run: `cd /workspace/doot/android && ./gradlew testDebugUnitTest` Expected: BUILD SUCCESSFUL, all tests pass. - [ ] **Step 6: Commit** ```bash cd /workspace/doot git add android/app/src/main/java/org/terst/doot/widget/ui/LinkifiedText.kt android/app/src/test/java/org/terst/doot/widget/ui/LinkifiedTextTest.kt git commit -m "feat(widget): add description linkification for URLs and phone numbers" ``` --- ### Task 8: `TaskDetailActivity` redesign **Files:** - Modify: `android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt` - Create: `android/app/src/main/java/org/terst/doot/widget/ui/RecurrenceEditDialog.kt` **Interfaces:** - Consumes: `WidgetRepository.fetchTaskDetail`/`.updateTask`/`.setTaskRecurrence`/`.setTaskNextDate` (Task 6), `LinkifiedText` (Task 7), `TaskRecurrence`/`TaskDetailResponse` (Task 6). - Produces: nothing consumed by later tasks (this is the last Android task). No dedicated test for this task — no Compose UI test harness in this project (established convention every prior widget UI task this session has followed). Verified by `./gradlew testDebugUnitTest` (compiles, no regressions) and a manual on-device check after Task 9's deploy. - [ ] **Step 1: Create the recurrence-editing dialog** Create `android/app/src/main/java/org/terst/doot/widget/ui/RecurrenceEditDialog.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.Row import androidx.compose.foundation.layout.height 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.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import org.terst.doot.widget.data.TaskRecurrence internal fun weekdayAbbrev(day: Int): String = when (day) { 0 -> "Sun"; 1 -> "Mon"; 2 -> "Tue"; 3 -> "Wed"; 4 -> "Thu"; 5 -> "Fri"; 6 -> "Sat" else -> "?" } @OptIn(ExperimentalMaterial3Api::class) @Composable fun RecurrenceEditDialog( initial: TaskRecurrence?, onDismiss: () -> Unit, onSave: (freq: String, interval: Int, weekdays: List) -> Unit, onClear: () -> Unit ) { var freq by remember { mutableStateOf(initial?.freq ?: "weekly") } var interval by remember { mutableStateOf((initial?.interval ?: 1).toString()) } var weekdays by remember { mutableStateOf(initial?.weekdays?.toSet() ?: emptySet()) } AlertDialog( onDismissRequest = onDismiss, title = { Text("Recurrence") }, text = { Column { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { listOf("daily", "weekly", "monthly", "yearly").forEach { option -> FilterChip( selected = freq == option, onClick = { freq = option }, label = { Text(option.replaceFirstChar { it.uppercase() }) } ) } } OutlinedTextField( value = interval, onValueChange = { if (it.all(Char::isDigit)) interval = it }, label = { Text("Every N ${freq}${if (interval != "1") "s" else ""}") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true ) if (freq == "weekly") { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { (0..6).forEach { day -> FilterChip( selected = weekdays.contains(day), onClick = { weekdays = if (weekdays.contains(day)) weekdays - day else weekdays + day }, label = { Text(weekdayAbbrev(day)) } ) } } } } }, confirmButton = { TextButton(onClick = { onSave(freq, interval.toIntOrNull()?.coerceAtLeast(1) ?: 1, weekdays.sorted()) }) { Text("Save") } }, dismissButton = { Row { if (initial != null) { TextButton(onClick = onClear) { Text("Clear") } } TextButton(onClick = onDismiss) { Text("Cancel") } } } ) } ``` - [ ] **Step 2: Replace `TaskDetailActivity.kt` in full** Replace the entire contents of `android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt` with: ```kotlin package org.terst.doot.widget.ui import android.content.Intent import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.glance.appwidget.updateAll import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import okhttp3.OkHttpClient import org.terst.doot.widget.data.Keys import org.terst.doot.widget.data.TaskDetailResponse import org.terst.doot.widget.data.TaskRecurrence import org.terst.doot.widget.data.WidgetRepository import org.terst.doot.widget.data.dataStore import org.terst.doot.widget.work.CompleteWorker import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Calendar import java.util.TimeZone class TaskDetailActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val id = intent.getStringExtra(EXTRA_ID) ?: return finish() val source = intent.getStringExtra(EXTRA_SOURCE) ?: return finish() val initialTitle = intent.getStringExtra(EXTRA_TITLE) ?: "" val completable = intent.getBooleanExtra(EXTRA_COMPLETABLE, false) val initialDueDate = intent.getStringExtra(EXTRA_DUE_DATE) val isDoot = source == "doot" setContent { MaterialTheme(colorScheme = darkColorScheme()) { var title by remember { mutableStateOf(initialTitle) } var description by remember { mutableStateOf("") } var dueDate by remember { mutableStateOf(initialDueDate) } var recurrence by remember { mutableStateOf(null) } var nextDate by remember { mutableStateOf(null) } suspend fun repo(): WidgetRepository? { val prefs = this@TaskDetailActivity.dataStore.data.first() val url = prefs[Keys.SERVER_URL]?.trimEnd('/') ?: return null val token = prefs[Keys.TOKEN] ?: return null return WidgetRepository(OkHttpClient(), url, token) } suspend fun refreshWidget() { val r = repo() ?: return r.fetchAndPersist(this@TaskDetailActivity) DootWidget().updateAll(this@TaskDetailActivity) } 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 } } } TaskDetailSheet( title = title, source = source, completable = completable, dueDate = dueDate, isDoot = isDoot, description = description, recurrence = recurrence, nextDate = nextDate, onComplete = { CompleteWorker.enqueue(this, id, source) finish() }, onReschedule = { dateISO -> lifecycleScope.launch { repo()?.reschedule(id, source, dateISO)?.onSuccess { refreshWidget() finish() } } }, onSaveEdit = { newTitle, newDescription -> lifecycleScope.launch { repo()?.updateTask(id, newTitle, newDescription)?.onSuccess { title = newTitle description = newDescription refreshWidget() } } }, onSaveRecurrence = { freq, interval, weekdays -> lifecycleScope.launch { repo()?.setTaskRecurrence(id, freq, interval, weekdays)?.onSuccess { repo()?.fetchTaskDetail(id)?.onSuccess { detail -> recurrence = detail.recurrence nextDate = detail.nextDate } refreshWidget() } } }, onSaveNextDate = { dateISO -> lifecycleScope.launch { repo()?.setTaskNextDate(id, dateISO)?.onSuccess { nextDate = dateISO refreshWidget() } } }, onOpenUrl = { url -> startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }, onDialPhone = { phone -> startActivity(Intent(Intent.ACTION_DIAL, Uri.parse("tel:$phone"))) }, onDismiss = ::finish ) } } } companion object { const val EXTRA_ID = "task_id" const val EXTRA_SOURCE = "task_source" const val EXTRA_TITLE = "task_title" const val EXTRA_COMPLETABLE = "task_completable" const val EXTRA_DUE_DATE = "task_due_date" } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun TaskDetailSheet( title: String, source: String, completable: Boolean, dueDate: String?, isDoot: Boolean, description: String, recurrence: TaskRecurrence?, nextDate: String?, onComplete: () -> Unit, onReschedule: (String) -> Unit, onSaveEdit: (title: String, description: String) -> Unit, onSaveRecurrence: (freq: String, interval: Int, weekdays: List) -> Unit, onSaveNextDate: (String) -> Unit, onOpenUrl: (String) -> Unit, onDialPhone: (String) -> Unit, onDismiss: () -> Unit ) { var isEditing by remember { mutableStateOf(false) } var editTitle by remember(title) { mutableStateOf(title) } var editDescription by remember(description) { mutableStateOf(description) } var showDatePicker by remember { mutableStateOf(false) } var showRecurrenceDialog by remember { mutableStateOf(false) } var showNextDatePicker by remember { mutableStateOf(false) } val datePickerState = rememberDatePickerState(initialSelectedDateMillis = System.currentTimeMillis()) val nextDatePickerState = rememberDatePickerState(initialSelectedDateMillis = System.currentTimeMillis()) if (showDatePicker) { DatePickerDialog( onDismissRequest = { showDatePicker = false }, confirmButton = { TextButton(onClick = { datePickerState.selectedDateMillis?.let { millis -> onReschedule(isoDateFromMillis(millis)) } showDatePicker = false }) { Text("Set date") } }, dismissButton = { TextButton(onClick = { showDatePicker = false }) { Text("Cancel") } } ) { DatePicker(state = datePickerState) } } if (showNextDatePicker) { DatePickerDialog( onDismissRequest = { showNextDatePicker = false }, confirmButton = { TextButton(onClick = { nextDatePickerState.selectedDateMillis?.let { millis -> onSaveNextDate(isoDateFromMillis(millis)) } showNextDatePicker = false }) { Text("Set next date") } }, dismissButton = { TextButton(onClick = { showNextDatePicker = false }) { Text("Cancel") } } ) { DatePicker(state = nextDatePickerState) } } if (showRecurrenceDialog) { RecurrenceEditDialog( initial = recurrence, onDismiss = { showRecurrenceDialog = false }, onSave = { freq, interval, weekdays -> onSaveRecurrence(freq, interval, weekdays) showRecurrenceDialog = false }, onClear = { onSaveRecurrence("", 1, emptyList()) showRecurrenceDialog = false } ) } ModalBottomSheet( onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = Color(0xFF1E293B), tonalElevation = 0.dp, dragHandle = { Box( modifier = Modifier .padding(vertical = 12.dp) .width(36.dp) .height(4.dp) .background(Color.White.copy(alpha = 0.25f), RoundedCornerShape(2.dp)) ) } ) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 20.dp) .navigationBarsPadding() ) { Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { Box( modifier = Modifier .size(10.dp) .background(sourceColor(source), RoundedCornerShape(5.dp)) ) Spacer(Modifier.width(10.dp)) if (isEditing) { OutlinedTextField( value = editTitle, onValueChange = { editTitle = it }, modifier = Modifier.weight(1f), singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) ) } else { Text( text = title, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, color = Color.White, modifier = Modifier.weight(1f) ) } } if (isDoot) { Spacer(Modifier.height(12.dp)) 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.height(16.dp)) if (isEditing) { OutlinedTextField( value = editDescription, onValueChange = { editDescription = it }, modifier = Modifier .fillMaxWidth() .heightIn(min = 100.dp), placeholder = { Text("Description") } ) } else if (description.isNotEmpty()) { LinkifiedText( text = description, modifier = Modifier.fillMaxWidth(), onOpenUrl = onOpenUrl, onDialPhone = onDialPhone ) } } Spacer(Modifier.height(20.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (isDoot && isEditing) { OutlinedButton( onClick = { editTitle = title editDescription = description isEditing = false }, modifier = Modifier.weight(1f), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) ) { Text("Cancel") } Button( onClick = { onSaveEdit(editTitle, editDescription) isEditing = false }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3B82F6)), enabled = editTitle.isNotBlank() ) { Text("Save") } } else { if (isDoot) { OutlinedButton( onClick = { isEditing = true }, modifier = Modifier.weight(1f), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) ) { Text("Edit") } } if (completable) { Button( onClick = onComplete, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3B82F6)) ) { Text("Complete") } } } } Spacer(Modifier.height(20.dp)) } } } private fun formatDueDateLabel(dueDate: String?): String { if (dueDate == null) return "No due date · tap to schedule" return runCatching { val date = LocalDate.parse(dueDate.substring(0, 10)) date.format(DateTimeFormatter.ofPattern("MMM d")) }.getOrDefault("No due date · tap to schedule") } private fun formatRecurrenceLabel(recurrence: TaskRecurrence?): String { if (recurrence == null) return "Set recurrence" val intervalPrefix = if (recurrence.interval > 1) "every ${recurrence.interval} " else "" val unit = when (recurrence.freq) { "daily" -> if (recurrence.interval > 1) "days" else "daily" "weekly" -> if (recurrence.interval > 1) "weeks" else "weekly" "monthly" -> if (recurrence.interval > 1) "months" else "monthly" "yearly" -> if (recurrence.interval > 1) "years" else "yearly" else -> recurrence.freq } val weekdaysSuffix = if (recurrence.freq == "weekly" && recurrence.weekdays.isNotEmpty()) { " on " + recurrence.weekdays.sorted().joinToString(", ") { weekdayAbbrev(it) } } else "" return "🔄 $intervalPrefix$unit$weekdaysSuffix" } private fun formatNextDateLabel(nextDate: String?): String { if (nextDate == null) return "Next: —" return runCatching { val date = LocalDate.parse(nextDate.substring(0, 10)) "Next: " + date.format(DateTimeFormatter.ofPattern("MMM d")) }.getOrDefault("Next: —") } private fun isoDateFromMillis(millis: Long): String { val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) cal.timeInMillis = millis return "%04d-%02d-%02d".format(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH)) } ``` - [ ] **Step 3: Run the full Android unit test suite** Run: `cd /workspace/doot/android && ./gradlew testDebugUnitTest` Expected: BUILD SUCCESSFUL, all tests pass (this task adds no new tests, but must not break any existing one — `formatDueDateLabel`/`isoDateFromMillis` are carried over unchanged from the original file). - [ ] **Step 4: Commit** ```bash cd /workspace/doot git add android/app/src/main/java/org/terst/doot/widget/ui/TaskDetailActivity.kt android/app/src/main/java/org/terst/doot/widget/ui/RecurrenceEditDialog.kt git commit -m "feat(widget): redesign task detail popup with editing and recurrence Editable title/description (Edit -> Cancel/Save toggle), linkified description, and independently-tappable date/recurrence/next-date chips. Non-doot sources (Trello, Google Tasks) are unaffected -- same title + Complete button as before, no live fetch, no edit UI." ``` --- ### 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 except the two pre-existing, unrelated `internal/handlers` failures and the pre-existing `internal/models` build failure — all confirmed present before this feature. - [ ] **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 the new migration (`023_native_task_recurrence.sql`) against the live database via the existing migration-runner startup path, and starts the new 15-minute recurrence-check goroutine. - [ ] **Step 4: Build and deploy the Android APK** Run: `cd /workspace/doot/android && ./gradlew assembleRelease` Expected: BUILD SUCCESSFUL. Confirm the build is actually fresh before deploying (this project's Gradle setup has intermittently served a stale cached APK this session without `--rerun-tasks`): Run: `ls -la /workspace/doot/android/app/build/outputs/apk/release/app-release.apk` and note the timestamp is from *this* build, not an earlier one this session. If it looks stale, re-run with `./gradlew assembleRelease --rerun-tasks`. 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** Per `.agent/config.md`'s Worklog Integrity mandate, append a short entry to `/workspace/doot/.agent/worklog.md`'s "Recently Completed" section describing the recurrence feature (server-owned iteration creation, two triggers) and the task-detail popup redesign (editable title/description, linkified URLs/phone numbers, tappable date/recurrence/next-date chips). - [ ] **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 the date/recurrence/next-date chips appear and are independently tappable. - Set a weekly recurrence with specific weekdays (e.g. Mon/Wed/Fri). Confirm the chip updates to show it and a next-date chip appears. - Tap Edit, change the title and description (include a URL and a phone number in the description), Save. Confirm the popup reflects the change and the underlined URL/phone number are tappable (URL opens a browser, phone number opens the dialer). - Complete the recurring task. Confirm a new task appears with the due date advanced to the next occurrence, and the completed one stays completed in history. - Confirm a Trello or Google Tasks card's popup is completely unchanged (no Edit button, no chips, no description) — this scope should never have been touched by this work.