summaryrefslogtreecommitdiff
path: root/docs/superpowers
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers')
-rw-r--r--docs/superpowers/plans/2026-07-14-task-recurrence-and-detail-editing.md2691
1 files changed, 2691 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-14-task-recurrence-and-detail-editing.md b/docs/superpowers/plans/2026-07-14-task-recurrence-and-detail-editing.md
new file mode 100644
index 0000000..9e7a153
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-14-task-recurrence-and-detail-editing.md
@@ -0,0 +1,2691 @@
+# 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<TaskDetailResponse>`, `.updateTask(id, title, description): Result<Unit>`, `.setTaskRecurrence(id, freq, interval, weekdays): Result<Unit>`, `.setTaskNextDate(id, dateISO): Result<Unit>` — 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<Request>()
+ 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<Int> = 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<TaskDetailResponse> =
+ 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<TaskDetailResponse>(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<Unit> =
+ 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<Int>)
+
+ /** POSTs a recurrence pattern change to /api/widget/task/recurrence. freq="" clears it. */
+ suspend fun setTaskRecurrence(id: String, freq: String, interval: Int, weekdays: List<Int>): Result<Unit> =
+ 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<Unit> =
+ 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<TextLink>`, `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<TextLink> {
+ val links = mutableListOf<TextLink>()
+ 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<TextLayoutResult?>(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<Int>) -> 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<TaskRecurrence?>(null) }
+ var nextDate by remember { mutableStateOf<String?>(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<Int>) -> 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.