summaryrefslogtreecommitdiff
path: root/docs/superpowers/specs/2026-07-14-task-recurrence-and-detail-editing-design.md
blob: 174e970797d1342b26ff5aed38843902b4982e48 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
# Task Recurrence + Detail Popup Editing — Design

## Context

The Android widget's task-detail popup (`TaskDetailActivity`/`TaskDetailSheet`) currently shows only a title and, for doot-native tasks, a due-date reschedule button plus a "Mark Complete" button. There's no description display, no editing, and no recurrence — `models.Task.IsRecurring` exists but is never set anywhere; it's dead weight left over from a removed Todoist integration. The web app already has a separate, simpler task-detail page (`HandleUpdateTask`/`task-detail-page.html`) that edits description only (and has a latent bug: it always passes an empty `content` argument to `UpdateNativeTask`, silently blanking the task's title on every save — pre-existing, out of scope here, not touched by this work).

This spec adds real recurrence support to doot-native tasks (frequency, interval, specific weekdays), and redesigns the widget's task-detail popup: an editable title/description, a linkified description (clickable URLs/phone numbers), and three independently-tappable chips (date, recurrence, next-occurrence) for a task's schedule.

## Scope

**doot-native tasks only.** Trello cards and Google Tasks keep today's behavior exactly (title + Complete button, no description, no editing, no recurrence) — neither has an API-level recurrence concept, and pushing title/description edits back through their APIs is a separate, larger integration this spec doesn't attempt.

## Data Model

New migration `023_native_task_recurrence.sql`, adding four columns to `native_tasks`:

```sql
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);
```

- `recurrence_freq`: `""` (not recurring), `"daily"`, `"weekly"`, `"monthly"`, or `"yearly"`.
- `recurrence_interval`: "every N" units of `recurrence_freq`. Default 1.
- `recurrence_weekdays`: comma-separated integers 0–6 (Sunday=0), only meaningful when `recurrence_freq == "weekly"`. Empty means "same weekday as the due date, every N weeks."
- `recurrence_series_id`: a random ID shared by every row generated from the same recurring task. Generated the first time recurrence is set on a task (via the new recurrence-editing endpoint); empty means non-recurring. **This is the only way a task is "recurring" — `IsRecurring bool` is retired from display logic** (the field can stay on the struct for now since removing it touches unrelated code in `atoms.go`; this spec just stops relying on it and introduces the real mechanism).
- `next_occurrence_override`: one-shot ISO date. When set, the *next* iteration's `due_date` uses this value instead of a computed one. Not copied onto the new row when the iteration is created — the new row always starts with `next_occurrence_override = ""`.

`models.Task` gains matching fields: `RecurrenceFreq string`, `RecurrenceInterval int`, `RecurrenceWeekdays []int`, `RecurrenceSeriesID string`, `NextOccurrenceOverride *time.Time`.

`scanNativeTasks` and all four existing `SELECT` queries in `internal/store/native_tasks.go` (`GetNativeTasks`, `GetNativeTasksByDateRange`, `GetOverdueNativeTasks`, `GetUndatedNativeTasks`) add the five new columns to their column list and populate the new struct fields (weekdays parsed from the comma-separated string, same pattern as `labels`' JSON parsing already does for a different serialization).

## Recurrence Computation

New file `internal/models/recurrence.go`:

```go
package models

import "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."
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))
}
```

(`sort` added to the import block.)

**Worked examples** (due = Monday):
- `freq=weekly, interval=1, weekdays=[1,3,5]` (Mon/Wed/Fri), due=Mon → next=Wed (2 days later, same week).
- Same pattern, due=Fri → next=Mon the following week (wraps: `daysToNextWeekStart=7-5=2`, `+sorted[0]=1` → 3 days later = next Monday).
- `freq=weekly, interval=2, weekdays=[1]` (Mon only), due=Mon → next = Mon two weeks later (`daysToNextWeekStart=7-1=6`, `+1` (sorted[0]) `+7*(2-1)=7` → 14 days later).
- `freq=monthly, interval=1`, due=Jan 31 → next=Mar 3 (Go's standard `AddDate` month-overflow rollover; not clamped to end-of-month — accepted simplification, noted so it isn't mistaken for a bug later). This drift is **permanent, not self-correcting**: each computation rebases on the previous row's *actual* due date, not an original anchor day, so Mar 3 → Apr 3 → May 3 → ... locks onto day-3 forever. Only anchor days 29–31 are affected at all (some month always lacks them); any day ≤ 28 recurs on the same day-of-month indefinitely with zero drift, since every month has at least 28 days.

## Iteration Creation

New store method in `internal/store/native_tasks.go`:

```go
// GetNativeTaskByID returns a single native task by id, or ErrNativeTaskNotFound.
func (s *Store) GetNativeTaskByID(id string) (*models.Task, error)

// isLatestInSeries reports whether id 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 (only possible if a next_occurrence_override duplicates
// an existing date) are broken by created_at: the more-recently-created row
// wins, so CreateNextIteration's freshly-inserted row (later created_at)
// always displaces the row it was generated from, never the reverse.
func (s *Store) isLatestInSeries(seriesID, id string) (bool, error)

// 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
```

**Trigger 1 — completion.** `CompleteNativeTask(id)` becomes:

```go
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.RecurrenceSeriesID, id)
	if err != nil || !isLatest {
		return err
	}
	return s.CreateNextIteration(*task)
}
```

Completing a non-recurring task, or a recurring task that's already been superseded by an auto-created successor (Trigger 2 got there first), behaves exactly as today: just marks it completed.

**Trigger 2 — due date passed, independent of completion.** New store method:

```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)
	...
}
```

New package `internal/scheduler` with a single exported function:

```go
package scheduler

// RunRecurrenceCheck ticks every interval, calling AdvanceDueRecurringTasks
// (a new Store method: GetSeriesNeedingNextIteration(now), then
// CreateNextIteration for each) until ctx is cancelled. Errors are logged,
// not fatal -- one bad tick shouldn't kill the loop.
func RunRecurrenceCheck(ctx context.Context, s *store.Store, interval time.Duration)
```

Started in `main.go` alongside the existing HTTP-server goroutine: `go scheduler.RunRecurrenceCheck(ctx, db, 15*time.Minute)` (15 minutes matches this codebase's existing cache-TTL cadence). No transactions wrap the two-step complete-then-create-iteration sequence — consistent with this store's existing single-`Exec`-per-method style, and the periodic job's `NOT EXISTS`-based self-healing check is the safety net for the rare case a partial failure leaves a series without its successor.

## API Endpoints

- `GET /api/widget/task?id=&source=` — doot-only; returns full detail:
  ```json
  {
    "id": "...", "title": "...", "description": "...",
    "due_date": "2026-07-13T00:00:00-10:00", "completed": false,
    "recurrence": {"freq": "weekly", "interval": 1, "weekdays": [1,3,5]},
    "next_date": "2026-07-20T00:00:00-10:00"
  }
  ```
  `recurrence` is `null` when `recurrence_freq == ""`; `next_date` is `null` when not recurring, else the override if set, else `ComputeNextOccurrence` on the current due date.
- `POST /api/widget/task/update` — `{id, title, description}`. Calls `UpdateNativeTask` (existing method — this is the doot-only, correctly-both-fields-together caller; it does not touch or reuse the web's buggy `HandleUpdateTask`).
- `POST /api/widget/task/recurrence` — `{id, freq, interval, weekdays}`. `freq: ""` clears recurrence (sets all four recurrence columns back to empty/default, keeps `recurrence_series_id` as-is so history stays linkable — a cleared task just stops generating new iterations). Setting a freq for the first time on a task with `recurrence_series_id == ""` generates a new series ID.
- `POST /api/widget/task/next-date` — `{id, date}`. Sets `next_occurrence_override`. Doot-only; 400 if the task has no active recurrence (an override with nothing to apply to is a no-op the UI shouldn't offer, but the server checks too).
- Existing `/api/widget/reschedule` (current due date) and `/api/widget/complete` (now recurrence-aware via the store change above) are unchanged at the HTTP layer.

## Android UI

`TaskDetailActivity` keeps its existing Intent extras (`id`, `source`, `title`, `completable`, `due_date`) for immediate display and for non-doot sources, which render exactly as today — no changes to their path at all.

For `source == "doot"` only, `TaskDetailSheet` additionally fires a `GET /api/widget/task` call on open and progressively enhances the sheet once it resolves (no blocking spinner — title/date show immediately from the Intent, description/recurrence/next-date populate in as they arrive):

- **View mode:** title; a row with three independently-tappable chips: date (existing `DatePickerDialog` → `/api/widget/reschedule`, unchanged), recurrence (`🔄 weekly` or "Set recurrence" if none → new dialog: frequency segmented control + interval stepper +, if weekly, a day-of-week multi-select row → `/api/widget/task/recurrence`; a "Clear" option removes recurrence), next-date (only shown when recurring; `(Jul 20)` → `DatePickerDialog` → `/api/widget/task/next-date`); then the linkified description paragraph; then `(Edit)(Complete)`.
- **Edit mode:** tapping Edit turns title into a single-line `TextField` and description into a multi-line `TextField`, buttons become `(Cancel)(Save)`. The three chips stay tappable throughout — they're independent of this toggle. Save calls `/api/widget/task/update`; Cancel discards the in-progress edits and reverts to view mode.
- **Description linkification:** build an `AnnotatedString` from the raw description text using two regexes (a URL pattern and a phone-number pattern), recording each match's character range and target action (open URL / dial number). Render with `Text(annotatedString)` plus a `Modifier.pointerInput` tap handler that maps the tap's character offset (via `TextLayoutResult.getOffsetForPosition`) to a matching range and fires the corresponding intent (`ACTION_VIEW` for URLs, `ACTION_DIAL` with a `tel:` URI for phone numbers). This avoids `LinkAnnotation` (unconfirmed whether it's fully wired into `Text`'s click handling at this project's resolved Compose UI version, 1.6.1) in favor of a manual approach that works on any Compose version.
- After every successful action (save, recurrence change, date/next-date change, complete), call the existing `WidgetRepository.fetchAndPersist` + `DootWidget().updateAll()` so the home-screen widget's cached view refreshes too — same pattern the reschedule flow already uses.

## Testing

- `ComputeNextOccurrence`: table-driven Go tests covering all four frequencies, the weekly-with-weekdays same-week and wrap-to-next-cycle cases (including `interval > 1`), and the weekly-no-weekdays fallback.
- `CompleteNativeTask`: recurrence-aware behavior — completing the latest row of a series creates the next iteration with the right due date (computed and override cases); completing a non-recurring task behaves as today; completing an already-superseded row doesn't double-create.
- `GetSeriesNeedingNextIteration` / the scheduler's `AdvanceDueRecurringTasks`: a due-but-uncompleted recurring task gets its successor created; a series that already has a newer row is left alone; a non-recurring task is never touched.
- New HTTP handlers: standard table-driven handler tests matching this codebase's existing style in `widget_test.go` (bad request, not-found, success cases) for all four new/changed endpoints.
- Android: no new automated test surface beyond what's practical for Compose/Glance in this project already (established convention — verified by building and manual on-device check, same as every other widget feature this session).

## Out of Scope

- Editing Trello/Google Tasks title, description, or any recurrence concept for those sources.
- Fixing the pre-existing `HandleUpdateTask` (web) bug that blanks a task's title on every description save — noted, not touched.
- Clamping month/year rollover to the last valid day of the target month (Go's standard `AddDate` overflow behavior is accepted as-is).
- Any change to the compact widget row (`TaskRow` on the home screen) — no recurrence icon added there; this is a detail-popup-only feature.
- Removing the dead `IsRecurring bool` field from `models.Task`/`Atom` — left in place, just no longer relied upon.