summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/dashboard/main.go1
-rw-r--r--internal/api/google_calendar.go99
-rw-r--r--internal/api/google_calendar_test.go30
-rw-r--r--internal/api/interfaces.go1
-rw-r--r--internal/handlers/timeline_logic_test.go7
-rw-r--r--internal/handlers/widget.go22
-rw-r--r--internal/handlers/widget_test.go49
7 files changed, 209 insertions, 0 deletions
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index 5414a9d..d4e4dcb 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -371,6 +371,7 @@ func main() {
r.With(widgetAuth).Get("/api/widget", h.HandleWidgetGet)
r.With(widgetAuth).Post("/api/widget/complete", h.HandleWidgetComplete)
r.With(widgetAuth).Post("/api/widget/reschedule", h.HandleWidgetReschedule)
+ r.With(widgetAuth).Get("/api/widget/recurrence", h.HandleWidgetRecurrence)
r.With(widgetAuth).Post("/api/widget/add", h.HandleWidgetAdd)
} else {
log.Println("WIDGET_TOKEN not set — /api/widget disabled")
diff --git a/internal/api/google_calendar.go b/internal/api/google_calendar.go
index 7381527..bb17812 100644
--- a/internal/api/google_calendar.go
+++ b/internal/api/google_calendar.go
@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"sort"
+ "strconv"
"strings"
"time"
@@ -83,6 +84,86 @@ func deduplicateEvents(events []models.CalendarEvent) []models.CalendarEvent {
return unique
}
+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"
+}
+
// NewGoogleCalendarClient creates a client that fetches from multiple calendars.
// calendarIDs can be comma-separated (e.g., "cal1@group.calendar.google.com,cal2@group.calendar.google.com")
// timezone is the IANA timezone name for display (e.g., "Pacific/Honolulu")
@@ -203,3 +284,21 @@ func (c *GoogleCalendarClient) GetCalendarList(ctx context.Context) ([]models.Ca
}
return calendars, nil
}
+
+// 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)
+}
diff --git a/internal/api/google_calendar_test.go b/internal/api/google_calendar_test.go
index 3cf0dbd..efbab0d 100644
--- a/internal/api/google_calendar_test.go
+++ b/internal/api/google_calendar_test.go
@@ -272,3 +272,33 @@ func TestGetUpcomingEvents_APIError_ReturnsEmptyNotError(t *testing.T) {
t.Errorf("expected 0 events on API error, got %d", len(events))
}
}
+
+// --- formatRecurrence ---
+
+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)
+ }
+ })
+ }
+}
diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go
index c5e154d..9a607bb 100644
--- a/internal/api/interfaces.go
+++ b/internal/api/interfaces.go
@@ -30,6 +30,7 @@ type GoogleCalendarAPI interface {
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)
}
// GoogleTasksAPI defines the interface for Google Tasks operations
diff --git a/internal/handlers/timeline_logic_test.go b/internal/handlers/timeline_logic_test.go
index 9ddaeda..5e7cb27 100644
--- a/internal/handlers/timeline_logic_test.go
+++ b/internal/handlers/timeline_logic_test.go
@@ -23,6 +23,9 @@ type MockCalendarClient struct {
// 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
}
func (m *MockCalendarClient) GetUpcomingEvents(ctx context.Context, maxResults int) ([]models.CalendarEvent, error) {
@@ -41,6 +44,10 @@ func (m *MockCalendarClient) SetCalendarIDs(ids []string) {
m.SetCalendarIDsCalls = append(m.SetCalendarIDsCalls, ids)
}
+func (m *MockCalendarClient) GetRecurrenceRule(ctx context.Context, recurringEventID string) (string, error) {
+ return m.RecurrenceRule, m.RecurrenceErr
+}
+
func setupTestStore(t *testing.T) *store.Store {
t.Helper()
tempDir := t.TempDir()
diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go
index 29124fb..6b1e774 100644
--- a/internal/handlers/widget.go
+++ b/internal/handlers/widget.go
@@ -195,6 +195,28 @@ func (h *Handler) HandleWidgetComplete(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
+// 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)
+}
+
// HandleWidgetAdd creates a new undated native task from the widget's quick-add sheet.
func (h *Handler) HandleWidgetAdd(w http.ResponseWriter, r *http.Request) {
var req widgetAddRequest
diff --git a/internal/handlers/widget_test.go b/internal/handlers/widget_test.go
index efe6858..610a55c 100644
--- a/internal/handlers/widget_test.go
+++ b/internal/handlers/widget_test.go
@@ -1,6 +1,8 @@
package handlers
import (
+ "encoding/json"
+ "fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -424,3 +426,50 @@ func TestTimelineItemToWidgetItem_NonRecurring_EmptyRecurringEventID(t *testing.
t.Errorf("expected empty RecurringEventID for a non-recurring event, got %q", wi.RecurringEventID)
}
}
+
+// 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)
+ }
+}