diff options
Diffstat (limited to 'docs/superpowers/specs')
| -rw-r--r-- | docs/superpowers/specs/2026-07-12-widget-recurrence-design.md | 51 |
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). |
