diff options
| -rw-r--r-- | docs/superpowers/plans/2026-07-14-task-recurrence-and-detail-editing.md | 2691 |
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. |
