summaryrefslogtreecommitdiff
path: root/docs/superpowers/specs/2026-07-12-widget-recurrence-design.md
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-13 07:11:04 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-13 07:11:04 +0000
commit44abf42ed45aa8f285e7ce031cbb9ef1ade667ea (patch)
treedbc21cadc2750c6e39e7f7613be0b3f73790c445 /docs/superpowers/specs/2026-07-12-widget-recurrence-design.md
parent8310f802dd9fc6ef5dff0be7f640f79c5b39987f (diff)
parent4126fe4f56a6eb9703a084d4793a597f37bf2867 (diff)
Merge github/master: reconcile with parallel widget work
Another session pushed 27 commits in parallel covering quick-add, event detail popups, recurrence display, overdue badges, a manual refresh button, and its own fix for the same overdue-tasks bug (via a separate GetOverdueNativeTasks fetch folded into BuildTimeline, rather than widening GetNativeTasksByDateRange's bound directly). Reconciled rather than blindly taking one side: - Reverted GetNativeTasksByDateRange to its original bounded query and kept upstream's GetOverdueNativeTasks + BuildTimeline fold-in as the sole overdue mechanism for native tasks, to avoid double-counting overdue items (my widened query + their separate fetch would have both returned them). Re-pointed the regression test at the now-correct contract and added a store-level test for GetOverdueNativeTasks directly. - Kept my GetGoogleTasksByDateRange fix as-is (single unbounded query) -- upstream never touched Google Tasks overdue handling, so there's no duplication risk there. - Rewove WidgetRoot's LazyColumn structure (added for scrolling) around upstream's new header buttons, pinned all-day event rows, and the enhanced TomorrowSection, none of which were written LazyColumn-aware since that work landed on this side only. - Combined both sides' additions to TaskDetailActivity/TaskDetailSheet (description-edit detail popup + due-date reschedule label) and WidgetRepository/Actions (optimistic local removal + refresh button wiring) -- these were independent, non-overlapping features that both needed to survive. - Renumbered the migration collision: both sides independently added a migration numbered 022. Card-description was already applied to the live production DB under that filename earlier this session (migrations are tracked by filename), so it keeps 022; the recurring-event-id migration, never deployed under any name here, moves to 023. Verified: go build clean, full test suite passes (only the two pre-existing agent-handler failures and the pre-existing models package build error remain, both confirmed unrelated via git stash before this session began), and a dry run against a copy of the live production database applies both migrations cleanly with no re-run conflicts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'docs/superpowers/specs/2026-07-12-widget-recurrence-design.md')
-rw-r--r--docs/superpowers/specs/2026-07-12-widget-recurrence-design.md51
1 files changed, 51 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-07-12-widget-recurrence-design.md b/docs/superpowers/specs/2026-07-12-widget-recurrence-design.md
new file mode 100644
index 0000000..0f241cf
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-12-widget-recurrence-design.md
@@ -0,0 +1,51 @@
+# Widget Recurrence Display — Design
+
+## Context
+
+Fifth and last of five widget features. Decided without a user Q&A round (per explicit instruction to proceed autonomously) — this one has more judgment calls than the other four, documented below with reasoning.
+
+## Key findings from grounding
+
+1. **No recurrence data exists anywhere in this codebase today.** `models.CalendarEvent` has no recurrence field, `GoogleCalendarClient` never reads `item.Recurrence` or `item.RecurringEventId` from the Google API response, and there's no DB column for it. This is new plumbing end-to-end, not a forward of an existing value (unlike the overdue-badge and due-date features).
+
+2. **`GoogleCalendarClient` calls `.SingleEvents(true)`** (`internal/api/google_calendar.go:124,157`), which makes Google's API expand recurring series into individual instances. Each instance has `RecurringEventId` (pointing at the master event) but does **not** carry the `Recurrence` field (the RRULE strings) — that only lives on the master event. Getting the actual schedule therefore requires a second API call: `Events.Get(calendarID, recurringEventId)`.
+
+3. **No per-event calendar attribution exists.** Events from all 3 configured calendars are merged into one deduplicated list with no record of which calendar each came from (`calendar_events` table has no `calendar_id` column). Looking up a master event by ID needs to know which calendar to query. Rather than add calendar attribution (a bigger change touching the DB schema, the dedup logic, and every caller), this design tries each configured calendar ID in turn — there are only ~3, and this only happens on-demand (see point 5), not during the bulk fetch.
+
+4. **Calendar events currently never open an in-app popup at all.** Tapping a calendar event (`EventBlock`, `TomorrowEventRow`, or `AllDayRow` in `DootWidget.kt`) launches `ACTION_VIEW` directly to the Google Calendar app/website. Only tasks (`TaskRow`) open the in-app `TaskDetailActivity` bottom sheet. Since the user asked for recurrence to show "in the popup," and there is no existing popup for events, this design adds one — a new `EventDetailActivity`, structurally a sibling of `TaskDetailActivity`/`QuickAddActivity` (same one-Activity-per-purpose pattern used by quick add). The existing "jump straight to Google Calendar" behavior is preserved as a button inside the new popup, not removed.
+
+5. **Recurrence lookup is lazy (on-demand), not part of the bulk `/api/widget` fetch.** Pre-fetching every recurring event's master record during each timeline build would mean N extra Google API calls per fetch cycle (every 15 minutes, or on-demand) even when nobody looks at any of them. Instead, a new endpoint is queried only when the user actually opens a recurring event's popup — matching how reschedule already does its own live network call from `TaskDetailActivity` rather than being pre-computed into the widget JSON blob.
+
+## Design
+
+**Server — data layer (Go):**
+- `models.CalendarEvent` gains `RecurringEventID string` (empty = not a recurring instance).
+- Migration `022_calendar_events_recurring_id.sql`: `ALTER TABLE calendar_events ADD COLUMN recurring_event_id TEXT DEFAULT ''`.
+- `internal/store/sqlite.go`'s `SaveCalendarEvents`/`GetCalendarEventsByDateRange` thread the new column through (the table is fully replaced on every save — `DELETE FROM calendar_events` then re-insert — so no backfill logic is needed, the next fetch cycle repopulates it).
+- `GoogleCalendarClient.parseEventTime`'s callers (`GetUpcomingEvents`, `GetEventsByDateRange`) capture `item.RecurringEventId` into `models.CalendarEvent.RecurringEventID`.
+- `TimelineItem` gains `RecurringEventID string`; `BuildTimeline`'s event-mapping section forwards it from `models.CalendarEvent`.
+- `models.WidgetItem` gains `RecurringEventID string `json:"recurring_event_id,omitempty"``; `TimelineItemToWidgetItem` forwards it only for `Type == "event"`.
+
+**Server — recurrence lookup (Go):**
+- `GoogleCalendarAPI` interface gains `GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error)`.
+- `GoogleCalendarClient.GetRecurrenceRule` tries `Events.Get(calendarID, recurringEventID)` against each configured calendar ID in turn, returns the first success's formatted recurrence text; returns an error if the event isn't found on any configured calendar.
+- New pure function `formatRecurrence(rrules []string) string` turns Google's RRULE strings into short English (e.g. `RRULE:FREQ=WEEKLY;BYDAY=MO` → `"Repeats weekly on Monday"`). Covers `FREQ` (DAILY/WEEKLY/MONTHLY/YEARLY), `INTERVAL` (e.g. "every 2 weeks"), and `BYDAY` (weekday names, comma-joined for multiple days). Anything it can't parse falls back to the generic `"Recurring event"` rather than showing nothing or crashing — this is intentionally NOT a full RFC 5545 parser, just common-case coverage (every recurring event actually seen in this calendar during grounding was a simple weekly repeat).
+- New handler `HandleWidgetRecurrence` (`GET /api/widget/recurrence?recurring_event_id=X`), bearer-token protected like the other widget endpoints: calls `GetRecurrenceRule`, returns `{"recurrence": "..."}` on success or 404 if not found on any calendar.
+- New route: `r.With(widgetAuth).Get("/api/widget/recurrence", h.HandleWidgetRecurrence)`.
+
+**Android — data layer:**
+- `WidgetItem.kt` gains `@SerialName("recurring_event_id") val recurringEventId: String? = null`.
+- `WidgetRepository` gains `suspend fun getRecurrence(recurringEventId: String): Result<String>` — a GET request with the id as a query parameter, parsing `{"recurrence": "..."}` from the response.
+
+**Android — UI:**
+- New `EventDetailActivity.kt` (separate class, not a mode flag on `TaskDetailActivity` — different trigger, different data, same reasoning as keeping `QuickAddActivity` separate): a `ModalBottomSheet` showing the event title, formatted start time, a recurrence line that shows "Loading…" then the fetched text (only rendered at all if `recurringEventId != null` — a non-recurring event shows no recurrence line, not an empty one), and an "Open in Calendar" button that does the existing `ACTION_VIEW` behavior.
+- `EventBlock`, `TomorrowEventRow`, and `AllDayRow` (`DootWidget.kt`) change their `clickable` action from directly launching `ACTION_VIEW` to instead launching `EventDetailActivity` with the event's id/title/start/url/recurringEventId as extras.
+
+## Testing
+
+- Server: unit tests for `formatRecurrence` (the pure function) covering the cases in the design above — this is the highest-value test in this feature since it's the one piece of real logic; table-driven, matching this codebase's existing style where a table is natural (see `TestCalcCalendarBounds` in `timeline_logic_test.go` for the established table-driven pattern in this repo). Unit test for `TimelineItemToWidgetItem` confirming `RecurringEventID` forwards only for events. `HandleWidgetRecurrence` tested via the mock calendar client (`MockCalendarClient` in `timeline_logic_test.go`, which needs a new `GetRecurrenceRule` mock method added alongside its existing mocked methods).
+- Android: no unit-test surface (consistent with the rest of this session's widget UI work) — verified by build + manual on-device check.
+
+## Out of scope
+
+Editing/creating recurrence rules (this is read-only display). Showing recurrence for doot tasks (doot tasks have no recurrence concept at all — confirmed during earlier grounding that `models.Task.IsRecurring` exists but is never set or used anywhere in the Go codebase; wiring that up is a different, unscoped feature).