diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-14 17:07:57 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-16 02:44:02 +0000 |
| commit | 7cf03d3559d25377699c78085c6d722ba0aebae5 (patch) | |
| tree | 9ad827e8d96de3d45df4b3e0785efe324d8b8179 /docs | |
| parent | 6d2d7c83f35376dc7bce7492f882a2f5a1a96d5e (diff) | |
docs: add design for task recurrence + detail popup editing
New recurring-series model for doot-native tasks (server-owned, not
consumer-driven): new rows are created either on completion or once a
due date passes, whichever comes first. Redesigns the widget's task
detail popup with editable title/description, linkified URLs/phone
numbers, and independently-tappable date/recurrence/next-date chips.
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/specs/2026-07-14-task-recurrence-and-detail-editing-design.md | 234 |
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. |
