# 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.