summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/specs/2026-07-14-task-recurrence-and-detail-editing-design.md234
1 files changed, 234 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-07-14-task-recurrence-and-detail-editing-design.md b/docs/superpowers/specs/2026-07-14-task-recurrence-and-detail-editing-design.md
new file mode 100644
index 0000000..2cb6098
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-14-task-recurrence-and-detail-editing-design.md
@@ -0,0 +1,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).
+
+## 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.