# 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` - 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 = 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(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(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 ``` - [ ] **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.