summaryrefslogtreecommitdiff
path: root/docs/superpowers/plans
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans')
-rw-r--r--docs/superpowers/plans/2026-07-12-widget-recurrence.md1103
1 files changed, 1103 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-12-widget-recurrence.md b/docs/superpowers/plans/2026-07-12-widget-recurrence.md
new file mode 100644
index 0000000..6c306a5
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-12-widget-recurrence.md
@@ -0,0 +1,1103 @@
+# Widget Recurrence Display Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Show a human-readable recurrence schedule (e.g. "Repeats weekly on Monday") in a new event-detail popup, replacing calendar events' current behavior of jumping straight out to the Google Calendar app on tap.
+
+**Architecture:** Capture `RecurringEventId` from Google's API (available on event instances even though the RRULE itself is master-event-only) and thread it from `CalendarEvent` through `TimelineItem`/`WidgetItem` to the Android client. A new endpoint does the actual RRULE lookup+formatting lazily, on-demand, when a user opens a recurring event's new detail popup — not during the bulk timeline fetch.
+
+**Tech Stack:** Go (`internal/api`, `internal/models`, `internal/store`, `internal/handlers`, SQL migration), Kotlin/Jetpack Glance + Compose (Android widget).
+
+## Global Constraints
+
+- `RecurringEventID` is empty string for non-recurring events — never nil-vs-empty ambiguity in Go (it's a plain `string`, not `*string`); the Android side treats an empty/missing JSON value as "not recurring" via a nullable `String?`.
+- `formatRecurrence` is intentionally NOT a full RFC 5545 parser — cover `FREQ` (DAILY/WEEKLY/MONTHLY/YEARLY), `INTERVAL`, `BYDAY` only; anything else falls back to the literal string `"Recurring event"`.
+- The existing "open in Google Calendar" behavior must still be reachable (as a button in the new popup), not removed.
+- `EventDetailActivity` is a new, separate Activity class — do not fold this into `TaskDetailActivity` or `QuickAddActivity`.
+
+---
+
+### Task 1: Server — capture and forward RecurringEventID
+
+**Files:**
+- Create: `migrations/022_calendar_events_recurring_id.sql`
+- Modify: `internal/models/types.go`
+- Modify: `internal/models/timeline.go`
+- Modify: `internal/models/widget.go`
+- Modify: `internal/store/sqlite.go`
+- Modify: `internal/api/google_calendar.go`
+- Modify: `internal/handlers/timeline_logic.go`
+- Modify: `internal/handlers/widget.go`
+- Test: `internal/handlers/widget_test.go`
+
+**Interfaces:**
+- Produces: `models.CalendarEvent.RecurringEventID string`
+- Produces: `models.TimelineItem.RecurringEventID string`
+- Produces: `models.WidgetItem.RecurringEventID string `json:"recurring_event_id,omitempty"``
+
+- [ ] **Step 1: Create the migration**
+
+Create `migrations/022_calendar_events_recurring_id.sql`:
+
+```sql
+ALTER TABLE calendar_events ADD COLUMN recurring_event_id TEXT DEFAULT '';
+```
+
+- [ ] **Step 2: Add `RecurringEventID` to `models.CalendarEvent`**
+
+In `internal/models/types.go`, find:
+
+```go
+type CalendarEvent struct {
+ ID string `json:"id"`
+ Summary string `json:"summary"`
+ Description string `json:"description"`
+ Start time.Time `json:"start"`
+ End time.Time `json:"end"`
+ HTMLLink string `json:"html_link"`
+}
+```
+
+Replace with:
+
+```go
+type CalendarEvent struct {
+ ID string `json:"id"`
+ Summary string `json:"summary"`
+ Description string `json:"description"`
+ Start time.Time `json:"start"`
+ End time.Time `json:"end"`
+ HTMLLink string `json:"html_link"`
+ RecurringEventID string `json:"recurring_event_id,omitempty"` // empty = not a recurring instance
+}
+```
+
+- [ ] **Step 3: Capture `RecurringEventId` in `GoogleCalendarClient`**
+
+In `internal/api/google_calendar.go`, find (in `GetUpcomingEvents`):
+
+```go
+ for _, item := range events.Items {
+ start, end := c.parseEventTime(item)
+ allEvents = append(allEvents, models.CalendarEvent{
+ ID: item.Id,
+ Summary: item.Summary,
+ Description: item.Description,
+ Start: start,
+ End: end,
+ HTMLLink: item.HtmlLink,
+ })
+ }
+```
+
+Replace with:
+
+```go
+ for _, item := range events.Items {
+ start, end := c.parseEventTime(item)
+ allEvents = append(allEvents, models.CalendarEvent{
+ ID: item.Id,
+ Summary: item.Summary,
+ Description: item.Description,
+ Start: start,
+ End: end,
+ HTMLLink: item.HtmlLink,
+ RecurringEventID: item.RecurringEventId,
+ })
+ }
+```
+
+Then find the near-identical block in `GetEventsByDateRange`:
+
+```go
+ for _, item := range events.Items {
+ evtStart, evtEnd := c.parseEventTime(item)
+ allEvents = append(allEvents, models.CalendarEvent{
+ ID: item.Id,
+ Summary: item.Summary,
+ Description: item.Description,
+ Start: evtStart,
+ End: evtEnd,
+ HTMLLink: item.HtmlLink,
+ })
+ }
+```
+
+Replace with:
+
+```go
+ for _, item := range events.Items {
+ evtStart, evtEnd := c.parseEventTime(item)
+ allEvents = append(allEvents, models.CalendarEvent{
+ ID: item.Id,
+ Summary: item.Summary,
+ Description: item.Description,
+ Start: evtStart,
+ End: evtEnd,
+ HTMLLink: item.HtmlLink,
+ RecurringEventID: item.RecurringEventId,
+ })
+ }
+```
+
+Run `gofmt -w internal/api/google_calendar.go` after this step — the alignment in the two blocks above is deliberately loose (gofmt will fix column alignment automatically; don't hand-align it yourself).
+
+- [ ] **Step 4: Thread the column through the store**
+
+In `internal/store/sqlite.go`, find `SaveCalendarEvents`'s insert:
+
+```go
+ stmt, err := tx.Prepare(`
+ INSERT INTO calendar_events (id, summary, description, start_time, end_time, html_link)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = stmt.Close() }()
+
+ for _, e := range events {
+ _, err = stmt.Exec(e.ID, e.Summary, e.Description, e.Start, e.End, e.HTMLLink)
+ if err != nil {
+ return err
+ }
+ }
+```
+
+Replace with:
+
+```go
+ stmt, err := tx.Prepare(`
+ INSERT INTO calendar_events (id, summary, description, start_time, end_time, html_link, recurring_event_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = stmt.Close() }()
+
+ for _, e := range events {
+ _, err = stmt.Exec(e.ID, e.Summary, e.Description, e.Start, e.End, e.HTMLLink, e.RecurringEventID)
+ if err != nil {
+ return err
+ }
+ }
+```
+
+Then find `GetCalendarEventsByDateRange`:
+
+```go
+func (s *Store) GetCalendarEventsByDateRange(start, end time.Time) ([]models.CalendarEvent, error) {
+ rows, err := s.db.Query(`
+ SELECT id, summary, description, start_time, end_time, html_link
+ FROM calendar_events
+ WHERE start_time >= ? AND start_time <= ?
+ ORDER BY start_time ASC
+ `, start, end)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ var events []models.CalendarEvent
+ for rows.Next() {
+ var e models.CalendarEvent
+ if err := rows.Scan(&e.ID, &e.Summary, &e.Description, &e.Start, &e.End, &e.HTMLLink); err != nil {
+ return nil, err
+ }
+ events = append(events, e)
+ }
+ return events, rows.Err()
+}
+```
+
+Replace with:
+
+```go
+func (s *Store) GetCalendarEventsByDateRange(start, end time.Time) ([]models.CalendarEvent, error) {
+ rows, err := s.db.Query(`
+ SELECT id, summary, description, start_time, end_time, html_link, recurring_event_id
+ FROM calendar_events
+ WHERE start_time >= ? AND start_time <= ?
+ ORDER BY start_time ASC
+ `, start, end)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ var events []models.CalendarEvent
+ for rows.Next() {
+ var e models.CalendarEvent
+ if err := rows.Scan(&e.ID, &e.Summary, &e.Description, &e.Start, &e.End, &e.HTMLLink, &e.RecurringEventID); err != nil {
+ return nil, err
+ }
+ events = append(events, e)
+ }
+ return events, rows.Err()
+}
+```
+
+- [ ] **Step 5: Add `RecurringEventID` to `TimelineItem` and forward it in `BuildTimeline`**
+
+In `internal/models/timeline.go`, find:
+
+```go
+ // Source-specific metadata
+ ListID string `json:"list_id,omitempty"` // For Google Tasks
+}
+```
+
+Replace with:
+
+```go
+ // Source-specific metadata
+ ListID string `json:"list_id,omitempty"` // For Google Tasks
+ RecurringEventID string `json:"recurring_event_id,omitempty"` // For calendar events
+}
+```
+
+In `internal/handlers/timeline_logic.go`, find the calendar-events block inside `BuildTimeline`:
+
+```go
+ for _, event := range events {
+ endTime := event.End
+ item := models.TimelineItem{
+ ID: event.ID,
+ Type: models.TimelineItemTypeEvent,
+ Title: event.Summary,
+ Time: event.Start,
+ EndTime: &endTime,
+ Description: event.Description,
+ URL: event.HTMLLink,
+ OriginalItem: event,
+ IsCompleted: false,
+ Source: "calendar",
+ }
+ item.ComputeDaySection(now)
+ items = append(items, item)
+ }
+```
+
+Replace with:
+
+```go
+ for _, event := range events {
+ endTime := event.End
+ item := models.TimelineItem{
+ ID: event.ID,
+ Type: models.TimelineItemTypeEvent,
+ Title: event.Summary,
+ Time: event.Start,
+ EndTime: &endTime,
+ Description: event.Description,
+ URL: event.HTMLLink,
+ OriginalItem: event,
+ IsCompleted: false,
+ Source: "calendar",
+ RecurringEventID: event.RecurringEventID,
+ }
+ item.ComputeDaySection(now)
+ items = append(items, item)
+ }
+```
+
+- [ ] **Step 6: Add `RecurringEventID` to `WidgetItem` and forward it**
+
+In `internal/models/widget.go`, add `RecurringEventID` to the struct (position among the other fields doesn't matter — add it after `DueDate`, before `URL`, or wherever the struct currently ends up after prior tasks land):
+
+```go
+ RecurringEventID string `json:"recurring_event_id,omitempty"`
+```
+
+In `internal/handlers/widget.go`'s `TimelineItemToWidgetItem`, add this forwarding line to the initial `wi := models.WidgetItem{...}` struct literal (alongside `IsAllDay`, `URL`, etc. — wherever that literal currently is after prior tasks land):
+
+```go
+ RecurringEventID: item.RecurringEventID,
+```
+
+(This is a straight passthrough regardless of item type — a task's `RecurringEventID` is always empty since only the calendar-event branch in `BuildTimeline` ever sets it, so there's no need for a type check here.)
+
+- [ ] **Step 7: Write and run tests**
+
+Add to `internal/handlers/widget_test.go`:
+
+```go
+// TestTimelineItemToWidgetItem_ForwardsRecurringEventID proves the
+// 2026-07-12 recurrence-display fix's data plumbing: a calendar event's
+// RecurringEventId (captured from Google's API, which only puts the RRULE
+// itself on the master event, not on expanded instances) must reach the
+// client so it can look up the human-readable schedule on demand.
+func TestTimelineItemToWidgetItem_ForwardsRecurringEventID(t *testing.T) {
+ item := models.TimelineItem{
+ ID: "cal-1",
+ Title: "Team sync",
+ Source: "calendar",
+ Type: models.TimelineItemTypeEvent,
+ Time: time.Now(),
+ RecurringEventID: "master-123",
+ }
+
+ wi := TimelineItemToWidgetItem(item)
+
+ if wi.RecurringEventID != "master-123" {
+ t.Errorf("RecurringEventID = %q, want %q", wi.RecurringEventID, "master-123")
+ }
+}
+
+func TestTimelineItemToWidgetItem_NonRecurring_EmptyRecurringEventID(t *testing.T) {
+ item := models.TimelineItem{
+ ID: "cal-2",
+ Title: "One-off meeting",
+ Source: "calendar",
+ Type: models.TimelineItemTypeEvent,
+ Time: time.Now(),
+ }
+
+ wi := TimelineItemToWidgetItem(item)
+
+ if wi.RecurringEventID != "" {
+ t.Errorf("expected empty RecurringEventID for a non-recurring event, got %q", wi.RecurringEventID)
+ }
+}
+```
+
+Run: `go test ./internal/handlers/ -run TestTimelineItemToWidgetItem -v`
+Expected: PASS — all tests in this group, including the two new ones.
+
+Run: `go build ./... && go test ./... 2>&1 | tail -20`
+Expected: only the two pre-existing unrelated failures (`TestHandleAgentTaskWriteOperations`, `TestHandleAgentCreateOperations`) plus the pre-existing `internal/models` vet failure.
+
+Run: `gofmt -l internal/models/types.go internal/models/timeline.go internal/models/widget.go internal/store/sqlite.go internal/api/google_calendar.go internal/handlers/timeline_logic.go internal/handlers/widget.go internal/handlers/widget_test.go`
+Expected: no output.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add migrations/022_calendar_events_recurring_id.sql \
+ internal/models/types.go internal/models/timeline.go internal/models/widget.go \
+ internal/store/sqlite.go internal/api/google_calendar.go \
+ internal/handlers/timeline_logic.go internal/handlers/widget.go internal/handlers/widget_test.go
+git commit -m "feat(widget): capture and forward RecurringEventID for calendar events"
+```
+
+---
+
+### Task 2: Server — recurrence lookup endpoint
+
+**Depends on:** Task 1 (uses `RecurringEventID`, which Task 1 introduces).
+
+**Files:**
+- Modify: `internal/api/google_calendar.go`
+- Modify: `internal/api/interfaces.go`
+- Modify: `internal/handlers/widget.go`
+- Modify: `cmd/dashboard/main.go`
+- Test: `internal/api/google_calendar_test.go` (create if it doesn't exist)
+- Test: `internal/handlers/widget_test.go`
+- Test: `internal/handlers/timeline_logic_test.go` (adds a mock method)
+
+**Interfaces:**
+- Produces: `formatRecurrence(rrules []string) string` (unexported, `internal/api` package)
+- Produces: `GoogleCalendarClient.GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error)`
+- Produces: `GoogleCalendarAPI.GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error)` (interface method)
+- Produces: `Handler.HandleWidgetRecurrence(w http.ResponseWriter, r *http.Request)`
+
+- [ ] **Step 1: Write the failing tests for `formatRecurrence`**
+
+Check whether `internal/api/google_calendar_test.go` already exists (`ls internal/api/`). If it doesn't, create it with this content; if it does, add the test function to it:
+
+```go
+package api
+
+import "testing"
+
+func TestFormatRecurrence(t *testing.T) {
+ tests := []struct {
+ name string
+ rules []string
+ want string
+ }{
+ {"weekly single day", []string{"RRULE:FREQ=WEEKLY;BYDAY=MO"}, "Repeats weekly on Monday"},
+ {"weekly multiple days", []string{"RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR"}, "Repeats weekly on Monday, Wednesday, Friday"},
+ {"daily", []string{"RRULE:FREQ=DAILY"}, "Repeats daily"},
+ {"monthly", []string{"RRULE:FREQ=MONTHLY"}, "Repeats monthly"},
+ {"yearly", []string{"RRULE:FREQ=YEARLY"}, "Repeats yearly"},
+ {"interval weekly", []string{"RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=SU"}, "Repeats every 2 weeks on Sunday"},
+ {"interval daily", []string{"RRULE:FREQ=DAILY;INTERVAL=3"}, "Repeats every 3 days"},
+ {"unrecognized frequency", []string{"RRULE:FREQ=HOURLY"}, "Recurring event"},
+ {"empty", []string{}, "Recurring event"},
+ {"unparseable", []string{"not a valid rule"}, "Recurring event"},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := formatRecurrence(tc.rules)
+ if got != tc.want {
+ t.Errorf("formatRecurrence(%v) = %q, want %q", tc.rules, got, tc.want)
+ }
+ })
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/api/ -run TestFormatRecurrence -v`
+Expected: compile error — `undefined: formatRecurrence`.
+
+- [ ] **Step 3: Implement `formatRecurrence`**
+
+Add to `internal/api/google_calendar.go` (anywhere at package level, e.g. after `deduplicateEvents`):
+
+```go
+var recurrenceFreqNames = map[string]string{
+ "DAILY": "daily",
+ "WEEKLY": "weekly",
+ "MONTHLY": "monthly",
+ "YEARLY": "yearly",
+}
+
+var recurrenceFreqUnits = map[string]string{
+ "DAILY": "day",
+ "WEEKLY": "week",
+ "MONTHLY": "month",
+ "YEARLY": "year",
+}
+
+var recurrenceWeekdayNames = map[string]string{
+ "SU": "Sunday",
+ "MO": "Monday",
+ "TU": "Tuesday",
+ "WE": "Wednesday",
+ "TH": "Thursday",
+ "FR": "Friday",
+ "SA": "Saturday",
+}
+
+// formatRecurrence turns Google Calendar RRULE strings into short English.
+// This intentionally covers only the common cases (FREQ, INTERVAL, BYDAY) --
+// not a full RFC 5545 parser. Anything it can't confidently describe falls
+// back to "Recurring event" rather than showing nothing or an error.
+func formatRecurrence(rrules []string) string {
+ for _, rule := range rrules {
+ rule = strings.TrimPrefix(rule, "RRULE:")
+ parts := make(map[string]string)
+ for _, kv := range strings.Split(rule, ";") {
+ pieces := strings.SplitN(kv, "=", 2)
+ if len(pieces) == 2 {
+ parts[pieces[0]] = pieces[1]
+ }
+ }
+
+ freqKey := parts["FREQ"]
+ unit, ok := recurrenceFreqUnits[freqKey]
+ if !ok {
+ continue
+ }
+
+ interval := 1
+ if iv := parts["INTERVAL"]; iv != "" {
+ if n, err := strconv.Atoi(iv); err == nil && n > 0 {
+ interval = n
+ }
+ }
+
+ var phrase string
+ if interval == 1 {
+ phrase = "Repeats " + recurrenceFreqNames[freqKey]
+ } else {
+ phrase = fmt.Sprintf("Repeats every %d %ss", interval, unit)
+ }
+
+ if byday := parts["BYDAY"]; byday != "" {
+ var days []string
+ for _, code := range strings.Split(byday, ",") {
+ code = strings.TrimSpace(code)
+ if len(code) >= 2 {
+ code = code[len(code)-2:]
+ }
+ if name, ok := recurrenceWeekdayNames[code]; ok {
+ days = append(days, name)
+ }
+ }
+ if len(days) > 0 {
+ phrase += " on " + strings.Join(days, ", ")
+ }
+ }
+
+ return phrase
+ }
+ return "Recurring event"
+}
+```
+
+Add `"strconv"` to this file's import block (it currently imports `"context"`, `"fmt"`, `"log"`, `"sort"`, `"strings"`, `"time"`, plus the two `google.golang.org/api/...` packages — `strconv` is new, `fmt`/`strings` already exist).
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/api/ -run TestFormatRecurrence -v`
+Expected: PASS — all 10 subtests.
+
+- [ ] **Step 5: Implement `GetRecurrenceRule` and add it to the interface + mock**
+
+In `internal/api/google_calendar.go`, add this method after `GetCalendarList`:
+
+```go
+// GetRecurrenceRule looks up a recurring event's master record and returns
+// its formatted recurrence schedule. Google's API only puts the RRULE on
+// the master event, not on expanded instances (see parseEventTime's
+// SingleEvents(true) callers), so this does a live lookup by the instance's
+// RecurringEventId. There's no per-event calendar attribution stored today
+// (events from all configured calendars are merged without recording which
+// one they came from), so this tries each configured calendar in turn.
+func (c *GoogleCalendarClient) GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error) {
+ for _, calendarID := range c.calendarIDs {
+ event, err := c.srv.Events.Get(calendarID, recurringEventID).Do()
+ if err != nil {
+ continue
+ }
+ return formatRecurrence(event.Recurrence), nil
+ }
+ return "", fmt.Errorf("recurring event %s not found on any configured calendar", recurringEventID)
+}
+```
+
+In `internal/api/interfaces.go`, find:
+
+```go
+type GoogleCalendarAPI interface {
+ GetUpcomingEvents(ctx context.Context, maxResults int) ([]models.CalendarEvent, error)
+ GetEventsByDateRange(ctx context.Context, start, end time.Time) ([]models.CalendarEvent, error)
+ GetCalendarList(ctx context.Context) ([]models.CalendarInfo, error)
+ SetCalendarIDs(ids []string)
+}
+```
+
+Replace with:
+
+```go
+type GoogleCalendarAPI interface {
+ GetUpcomingEvents(ctx context.Context, maxResults int) ([]models.CalendarEvent, error)
+ GetEventsByDateRange(ctx context.Context, start, end time.Time) ([]models.CalendarEvent, error)
+ GetCalendarList(ctx context.Context) ([]models.CalendarInfo, error)
+ SetCalendarIDs(ids []string)
+ GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error)
+}
+```
+
+In `internal/handlers/timeline_logic_test.go`, find `MockCalendarClient`'s method set (`GetUpcomingEvents`, `GetEventsByDateRange`, `GetCalendarList`, `SetCalendarIDs`) and add a new field plus mock method so the mock still satisfies the interface:
+
+```go
+// MockCalendarClient implements GoogleCalendarAPI interface for testing
+type MockCalendarClient struct {
+ Events []models.CalendarEvent
+ Err error
+ // SetCalendarIDsCalls records every ids slice SetCalendarIDs was called
+ // with, for tests that need to assert on how the caller resolved its
+ // calendar ID list (e.g. fetchCalendarEvents' comma-split fallback).
+ SetCalendarIDsCalls [][]string
+ // RecurrenceRule is returned by GetRecurrenceRule for any id when RecurrenceErr is nil.
+ RecurrenceRule string
+ RecurrenceErr error
+}
+```
+
+Add the method (anywhere among the other `MockCalendarClient` methods):
+
+```go
+func (m *MockCalendarClient) GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error) {
+ return m.RecurrenceRule, m.RecurrenceErr
+}
+```
+
+- [ ] **Step 6: Write the failing handler test**
+
+Add to `internal/handlers/widget_test.go`:
+
+```go
+// TestHandleWidgetRecurrence_ReturnsFormattedSchedule proves the recurrence
+// lookup endpoint: given a recurring_event_id query param, it calls the
+// calendar client's GetRecurrenceRule and returns the formatted text.
+func TestHandleWidgetRecurrence_ReturnsFormattedSchedule(t *testing.T) {
+ mock := &MockCalendarClient{RecurrenceRule: "Repeats weekly on Monday"}
+ h := &Handler{googleCalendarClient: mock}
+ req := httptest.NewRequest("GET", "/api/widget/recurrence?recurring_event_id=master-1", nil)
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetRecurrence).ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+ var resp struct {
+ Recurrence string `json:"recurrence"`
+ }
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+ if resp.Recurrence != "Repeats weekly on Monday" {
+ t.Errorf("recurrence = %q, want %q", resp.Recurrence, "Repeats weekly on Monday")
+ }
+}
+
+func TestHandleWidgetRecurrence_NotFound_Returns404(t *testing.T) {
+ mock := &MockCalendarClient{RecurrenceErr: fmt.Errorf("not found")}
+ h := &Handler{googleCalendarClient: mock}
+ req := httptest.NewRequest("GET", "/api/widget/recurrence?recurring_event_id=missing", nil)
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetRecurrence).ServeHTTP(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d", w.Code)
+ }
+}
+
+func TestHandleWidgetRecurrence_MissingParam_Returns400(t *testing.T) {
+ h := &Handler{}
+ req := httptest.NewRequest("GET", "/api/widget/recurrence", nil)
+ w := httptest.NewRecorder()
+ http.HandlerFunc(h.HandleWidgetRecurrence).ServeHTTP(w, req)
+
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", w.Code)
+ }
+}
+```
+
+Add `"fmt"` to `widget_test.go`'s imports if not already present (check the existing import block first — if `fmt` is already imported, don't add a duplicate).
+
+- [ ] **Step 7: Run tests to verify they fail**
+
+Run: `go test ./internal/handlers/ -run TestHandleWidgetRecurrence -v`
+Expected: compile error — `h.HandleWidgetRecurrence undefined`.
+
+- [ ] **Step 8: Implement `HandleWidgetRecurrence`**
+
+Add to `internal/handlers/widget.go`, after `HandleWidgetComplete`:
+
+```go
+// HandleWidgetRecurrence looks up and formats a recurring calendar event's schedule.
+func (h *Handler) HandleWidgetRecurrence(w http.ResponseWriter, r *http.Request) {
+ recurringEventID := r.URL.Query().Get("recurring_event_id")
+ if recurringEventID == "" {
+ http.Error(w, "recurring_event_id is required", http.StatusBadRequest)
+ return
+ }
+
+ recurrence, err := h.googleCalendarClient.GetRecurrenceRule(r.Context(), recurringEventID)
+ if err != nil {
+ http.Error(w, "recurring event not found", http.StatusNotFound)
+ return
+ }
+
+ resp := struct {
+ Recurrence string `json:"recurrence"`
+ }{Recurrence: recurrence}
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+}
+```
+
+Check `internal/handlers/handlers.go` (or wherever `Handler` is defined) for the exact field name of the calendar client on `Handler` — the code above assumes `h.googleCalendarClient` based on the existing `googleCalendarClient` field name already used in `timeline_logic_test.go`'s `Handler{googleCalendarClient: failingCal}` construction (see `TestFetchCalendarEvents_CacheFallbackOnAPIError`). If the actual field name differs, use the real one instead.
+
+- [ ] **Step 9: Register the route**
+
+In `cmd/dashboard/main.go`, find (after Task 1 of the quick-add feature, if that's landed, the block will have 4 lines instead of 3 — either way, find the `/api/widget/reschedule` line and add after it):
+
+```go
+ r.With(widgetAuth).Post("/api/widget/reschedule", h.HandleWidgetReschedule)
+```
+
+Add immediately after it (order relative to `/api/widget/add`, if present, doesn't matter):
+
+```go
+ r.With(widgetAuth).Get("/api/widget/recurrence", h.HandleWidgetRecurrence)
+```
+
+- [ ] **Step 10: Run tests to verify they pass, then full suite + gofmt**
+
+Run: `go test ./internal/handlers/ -run TestHandleWidgetRecurrence -v`
+Expected: PASS — all three new tests.
+
+Run: `go build ./... && go test ./... 2>&1 | tail -20`
+Expected: only the two pre-existing unrelated failures plus the pre-existing vet failure.
+
+Run: `gofmt -l internal/api/google_calendar.go internal/api/interfaces.go internal/api/google_calendar_test.go internal/handlers/widget.go internal/handlers/widget_test.go internal/handlers/timeline_logic_test.go cmd/dashboard/main.go`
+Expected: no output.
+
+- [ ] **Step 11: Commit**
+
+```bash
+git add internal/api/google_calendar.go internal/api/interfaces.go internal/api/google_calendar_test.go \
+ internal/handlers/widget.go internal/handlers/widget_test.go internal/handlers/timeline_logic_test.go \
+ cmd/dashboard/main.go
+git commit -m "feat(widget): add recurrence lookup endpoint (GET /api/widget/recurrence)"
+```
+
+---
+
+### Task 3: Android — event detail popup with recurrence
+
+**Depends on:** Task 2 (for a meaningful on-device test; not a compile dependency).
+
+**Files:**
+- Modify: `android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt`
+- Modify: `android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt`
+- Create: `android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt`
+- Modify: `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt`
+- Modify: `android/app/src/main/AndroidManifest.xml`
+
+**Interfaces:**
+- Produces: `WidgetItem.recurringEventId: String?`
+- Produces: `WidgetRepository.getRecurrence(recurringEventId: String): Result<String>`
+- Produces: `EventDetailActivity` (new Activity)
+
+- [ ] **Step 1: Add `recurringEventId` to the Android `WidgetItem`**
+
+In `android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt`, add the field (position among the other fields doesn't matter):
+
+```kotlin
+ @SerialName("recurring_event_id") val recurringEventId: String? = null,
+```
+
+- [ ] **Step 2: Add `getRecurrence` to `WidgetRepository`**
+
+In `android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt`, add this method to the class (after whichever methods prior features already added — `addTask` if quick-add landed first, or after `complete` otherwise):
+
+```kotlin
+ /** GETs the formatted recurrence schedule for a recurring event. */
+ suspend fun getRecurrence(recurringEventId: String): Result<String> =
+ withContext(Dispatchers.IO) {
+ val encodedId = java.net.URLEncoder.encode(recurringEventId, "UTF-8")
+ val request = Request.Builder()
+ .url("$serverUrl/api/widget/recurrence?recurring_event_id=$encodedId")
+ .header("Authorization", "Bearer $token")
+ .build()
+ runCatching {
+ val response = client.newCall(request).execute()
+ check(response.isSuccessful) { "HTTP ${response.code}" }
+ val body = checkNotNull(response.body?.string()) { "Empty body" }
+ val parsed = json.decodeFromString<RecurrenceResponse>(body)
+ parsed.recurrence
+ }
+ }
+```
+
+Add this small response data class at file scope (alongside any existing file-scope classes like `WidgetAddRequest`, if quick-add landed first — otherwise just above the `WidgetRepository` class):
+
+```kotlin
+@Serializable
+private data class RecurrenceResponse(val recurrence: String)
+```
+
+- [ ] **Step 3: Create `EventDetailActivity.kt`**
+
+Create `android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt`:
+
+```kotlin
+package org.terst.doot.widget.ui
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import okhttp3.OkHttpClient
+import org.terst.doot.widget.data.Keys
+import org.terst.doot.widget.data.WidgetRepository
+import org.terst.doot.widget.data.dataStore
+
+class EventDetailActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val title = intent.getStringExtra(EXTRA_TITLE) ?: ""
+ val source = intent.getStringExtra(EXTRA_SOURCE) ?: "calendar"
+ val url = intent.getStringExtra(EXTRA_URL) ?: ""
+ val recurringEventId = intent.getStringExtra(EXTRA_RECURRING_EVENT_ID)
+
+ setContent {
+ MaterialTheme(colorScheme = darkColorScheme()) {
+ EventDetailSheet(
+ title = title,
+ source = source,
+ recurringEventId = recurringEventId,
+ onOpenCalendar = {
+ startActivity(
+ Intent(Intent.ACTION_VIEW, Uri.parse(url.ifEmpty { "https://calendar.google.com" }))
+ )
+ finish()
+ },
+ onDismiss = ::finish
+ )
+ }
+ }
+ }
+
+ companion object {
+ const val EXTRA_TITLE = "event_title"
+ const val EXTRA_SOURCE = "event_source"
+ const val EXTRA_URL = "event_url"
+ const val EXTRA_RECURRING_EVENT_ID = "event_recurring_id"
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun EventDetailSheet(
+ title: String,
+ source: String,
+ recurringEventId: String?,
+ onOpenCalendar: () -> Unit,
+ onDismiss: () -> Unit
+) {
+ val context = LocalContext.current
+ var recurrenceText by remember { mutableStateOf<String?>(null) }
+
+ LaunchedEffect(recurringEventId) {
+ if (recurringEventId == null) return@LaunchedEffect
+ recurrenceText = "Loading…"
+ val prefs = context.dataStore.data.first()
+ val url = prefs[Keys.SERVER_URL]?.trimEnd('/') ?: return@LaunchedEffect
+ val token = prefs[Keys.TOKEN] ?: return@LaunchedEffect
+ val repo = WidgetRepository(OkHttpClient(), url, token)
+ repo.getRecurrence(recurringEventId).onSuccess { text ->
+ recurrenceText = text
+ }.onFailure {
+ recurrenceText = null
+ }
+ }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ containerColor = Color(0xFF1E293B),
+ tonalElevation = 0.dp,
+ dragHandle = {
+ Box(
+ modifier = Modifier
+ .padding(vertical = 12.dp)
+ .width(36.dp)
+ .height(4.dp)
+ .background(Color.White.copy(alpha = 0.25f), RoundedCornerShape(2.dp))
+ )
+ }
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp)
+ .navigationBarsPadding()
+ ) {
+ Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
+ Box(
+ modifier = Modifier
+ .size(10.dp)
+ .background(sourceColor(source), RoundedCornerShape(5.dp))
+ )
+ Spacer(Modifier.width(10.dp))
+ Text(
+ text = title,
+ fontSize = 17.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = Color.White,
+ modifier = Modifier.weight(1f)
+ )
+ }
+ recurrenceText?.let { text ->
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = text,
+ fontSize = 13.sp,
+ color = Color.White.copy(alpha = 0.6f)
+ )
+ }
+ Spacer(Modifier.height(20.dp))
+ OutlinedButton(
+ onClick = onOpenCalendar,
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White),
+ border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.3f))
+ ) {
+ Text("Open in Calendar", fontSize = 15.sp)
+ }
+ Spacer(Modifier.height(20.dp))
+ }
+ }
+}
+```
+
+Note: `recurrenceText` starts `null` (renders no recurrence line at all) and only becomes non-null if `recurringEventId != null` — a non-recurring event never shows "Loading…" or any recurrence text, per the design's requirement that a non-recurring event show no recurrence line, not an empty one. If the fetch fails, it falls back to `null` (silently hides the line) rather than showing an error state, since recurrence info is supplementary, not the primary purpose of the popup — the title and "Open in Calendar" button remain fully functional either way.
+
+- [ ] **Step 4: Wire `AllDayRow`, `EventBlock`, and `TomorrowEventRow` to open `EventDetailActivity`**
+
+In `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt`, all three composables currently build their `clickable` modifier inline as `.clickable(actionStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse(event.url.ifEmpty { "https://calendar.google.com" }))))`. Replace each with a two-step: build an `Intent` for `EventDetailActivity` carrying the event's data, then use that in `clickable`.
+
+For `AllDayRow` (find the composable, note it currently takes `event: WidgetItem` as its only parameter):
+
+```kotlin
+@Composable
+fun AllDayRow(event: WidgetItem) {
+ val context = LocalContext.current
+ val detailIntent = Intent(context, EventDetailActivity::class.java).apply {
+ putExtra(EventDetailActivity.EXTRA_TITLE, event.title)
+ putExtra(EventDetailActivity.EXTRA_SOURCE, event.source)
+ putExtra(EventDetailActivity.EXTRA_URL, event.url)
+ putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ val color = sourceColor(event.source)
+ Row(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(vertical = 3.dp)
+ .clickable(actionStartActivity(detailIntent)),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(modifier = GlanceModifier.width(3.dp).height(16.dp).background(color)) {}
+ Text(
+ text = event.title,
+ style = TextStyle(color = ColorProvider(Color.White.copy(alpha = 0.9f)), fontSize = 13.sp, fontWeight = FontWeight.Medium),
+ modifier = GlanceModifier.padding(start = 8.dp),
+ maxLines = 1
+ )
+ }
+}
+```
+
+For `EventBlock` (takes `event: WidgetItem, isPast: Boolean`):
+
+```kotlin
+@Composable
+fun EventBlock(event: WidgetItem, isPast: Boolean) {
+ val context = LocalContext.current
+ val detailIntent = Intent(context, EventDetailActivity::class.java).apply {
+ putExtra(EventDetailActivity.EXTRA_TITLE, event.title)
+ putExtra(EventDetailActivity.EXTRA_SOURCE, event.source)
+ putExtra(EventDetailActivity.EXTRA_URL, event.url)
+ putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ val color = sourceColor(event.source)
+ val alpha = if (isPast) 0.5f else 1f
+ Row(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ .clickable(actionStartActivity(detailIntent)),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = GlanceModifier
+ .width(3.dp)
+ .height(20.dp)
+ .background(color.copy(alpha = alpha))
+ ) {}
+ Text(
+ text = event.title,
+ style = TextStyle(
+ color = ColorProvider(Color.White.copy(alpha = alpha)),
+ fontSize = 14.sp
+ ),
+ modifier = GlanceModifier.padding(start = 8.dp),
+ maxLines = 1
+ )
+ }
+}
+```
+
+For `TomorrowEventRow` (takes `event: WidgetItem, zone: ZoneId`):
+
+```kotlin
+@Composable
+fun TomorrowEventRow(event: WidgetItem, zone: ZoneId) {
+ val context = LocalContext.current
+ val detailIntent = Intent(context, EventDetailActivity::class.java).apply {
+ putExtra(EventDetailActivity.EXTRA_TITLE, event.title)
+ putExtra(EventDetailActivity.EXTRA_SOURCE, event.source)
+ putExtra(EventDetailActivity.EXTRA_URL, event.url)
+ putExtra(EventDetailActivity.EXTRA_RECURRING_EVENT_ID, event.recurringEventId)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ val color = sourceColor(event.source)
+ val timeLabel = event.start?.let {
+ val t = Instant.parse(it).atZone(zone)
+ hourLabel(t.hour) + if (t.minute > 0) t.minute.toString().padStart(2, '0') else ""
+ } ?: ""
+ Row(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(vertical = 3.dp)
+ .clickable(actionStartActivity(detailIntent)),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = timeLabel,
+ style = TextStyle(color = ColorProvider(Color(0x4DFFFFFF)), fontSize = 10.sp),
+ modifier = GlanceModifier.width(32.dp)
+ )
+ Box(modifier = GlanceModifier.width(3.dp).height(16.dp).background(color)) {}
+ Text(
+ text = event.title,
+ style = TextStyle(color = ColorProvider(Color.White.copy(alpha = 0.75f)), fontSize = 13.sp),
+ modifier = GlanceModifier.padding(start = 8.dp),
+ maxLines = 1
+ )
+ }
+}
+```
+
+`Intent` and `Uri` imports already exist in this file (used by the code being replaced); `LocalContext` is already imported (used by `TaskRow`).
+
+- [ ] **Step 5: Declare `EventDetailActivity` in the manifest**
+
+In `android/app/src/main/AndroidManifest.xml`, add an entry for `EventDetailActivity` copying `TaskDetailActivity`'s (or `QuickAddActivity`'s, if quick-add landed first) exact attributes — same approach as the quick-add feature's Step 5:
+
+```xml
+ <activity
+ android:name=".widget.ui.EventDetailActivity"
+ android:exported="false"
+ android:theme="@style/Theme.Doot.Transparent" />
+```
+
+- [ ] **Step 6: Build and confirm no compile errors**
+
+```bash
+cd android && ./gradlew assembleDebug
+```
+
+Expected: `BUILD SUCCESSFUL`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add android/app/src/main/java/org/terst/doot/widget/data/WidgetItem.kt \
+ android/app/src/main/java/org/terst/doot/widget/data/WidgetRepository.kt \
+ android/app/src/main/java/org/terst/doot/widget/ui/EventDetailActivity.kt \
+ android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt \
+ android/app/src/main/AndroidManifest.xml
+git commit -m "feat(widget): add event detail popup showing recurrence schedule"
+```
+
+Manual on-device verification (release APK build, deploy, tap a recurring event and a one-off event, confirm the recurrence line appears only for the recurring one and the wording is sensible, confirm "Open in Calendar" still works) is deferred to the controller.
+
+---
+
+## Out of scope
+
+This is the last of the five widget features in this batch.