From 6fdd09bfe030ce91ee188f18c198fd6a57c7b42f Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 12 Jul 2026 11:12:55 +0000 Subject: feat(widget): add recurrence lookup endpoint (GET /api/widget/recurrence) --- internal/api/google_calendar.go | 99 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) (limited to 'internal/api/google_calendar.go') 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) +} -- cgit v1.2.3