package api import ( "context" "fmt" "log" "sort" "strconv" "strings" "time" "task-dashboard/internal/models" "google.golang.org/api/calendar/v3" "google.golang.org/api/option" ) type GoogleCalendarClient struct { srv *calendar.Service calendarIDs []string displayTZ *time.Location } // parseEventTime extracts start/end times from a Google Calendar event func (c *GoogleCalendarClient) parseEventTime(item *calendar.Event) (start, end time.Time) { displayTZ := c.displayTZ if displayTZ == nil { displayTZ = time.UTC } if item.Start.DateTime == "" { // All-day event - parse in display timezone start, _ = time.ParseInLocation("2006-01-02", item.Start.Date, displayTZ) end, _ = time.ParseInLocation("2006-01-02", item.End.Date, displayTZ) } else { // Try RFC3339 first (includes timezone offset like "2006-01-02T15:04:05-10:00" or "Z") var err error start, err = time.Parse(time.RFC3339, item.Start.DateTime) if err != nil { // No timezone in string - use event's timezone or display timezone var loc *time.Location if item.Start.TimeZone != "" { loc, _ = time.LoadLocation(item.Start.TimeZone) } if loc == nil { loc = displayTZ } start, _ = time.ParseInLocation("2006-01-02T15:04:05", item.Start.DateTime, loc) } end, err = time.Parse(time.RFC3339, item.End.DateTime) if err != nil { var loc *time.Location if item.End.TimeZone != "" { loc, _ = time.LoadLocation(item.End.TimeZone) } if loc == nil { loc = displayTZ } end, _ = time.ParseInLocation("2006-01-02T15:04:05", item.End.DateTime, loc) } // Convert to display timezone start = start.In(displayTZ) end = end.In(displayTZ) } return } // deduplicateEvents removes duplicate events (same summary + start time) func deduplicateEvents(events []models.CalendarEvent) []models.CalendarEvent { seen := make(map[string]bool) var unique []models.CalendarEvent for _, event := range events { key := fmt.Sprintf("%s|%d", event.Summary, event.Start.Unix()) if !seen[key] { seen[key] = true unique = append(unique, event) } } sort.Slice(unique, func(i, j int) bool { return unique[i].Start.Before(unique[j].Start) }) 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") func NewGoogleCalendarClient(ctx context.Context, credentialsFile, calendarIDs, timezone string) (*GoogleCalendarClient, error) { srv, err := calendar.NewService(ctx, option.WithCredentialsFile(credentialsFile)) if err != nil { return nil, fmt.Errorf("unable to retrieve Calendar client: %v", err) } // Parse comma-separated calendar IDs ids := strings.Split(calendarIDs, ",") var trimmedIDs []string for _, id := range ids { if trimmed := strings.TrimSpace(id); trimmed != "" { trimmedIDs = append(trimmedIDs, trimmed) } } // Load display timezone displayTZ, err := time.LoadLocation(timezone) if err != nil { log.Printf("Warning: invalid timezone %q, using UTC: %v", timezone, err) displayTZ = time.UTC } return &GoogleCalendarClient{ srv: srv, calendarIDs: trimmedIDs, displayTZ: displayTZ, }, nil } func (c *GoogleCalendarClient) GetUpcomingEvents(ctx context.Context, maxResults int) ([]models.CalendarEvent, error) { t := time.Now().Format(time.RFC3339) var allEvents []models.CalendarEvent for _, calendarID := range c.calendarIDs { events, err := c.srv.Events.List(calendarID).ShowDeleted(false). SingleEvents(true).TimeMin(t).MaxResults(int64(maxResults)).OrderBy("startTime").Do() if err != nil { log.Printf("Warning: failed to fetch events from calendar %s: %v", calendarID, err) continue } 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, }) } } uniqueEvents := deduplicateEvents(allEvents) if len(uniqueEvents) > maxResults { uniqueEvents = uniqueEvents[:maxResults] } return uniqueEvents, nil } func (c *GoogleCalendarClient) GetEventsByDateRange(ctx context.Context, start, end time.Time) ([]models.CalendarEvent, error) { timeMin := start.Format(time.RFC3339) timeMax := end.Format(time.RFC3339) var allEvents []models.CalendarEvent for _, calendarID := range c.calendarIDs { events, err := c.srv.Events.List(calendarID).ShowDeleted(false). SingleEvents(true).TimeMin(timeMin).TimeMax(timeMax).OrderBy("startTime").Do() if err != nil { log.Printf("Warning: failed to fetch events from calendar %s: %v", calendarID, err) continue } 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, }) } } return deduplicateEvents(allEvents), nil } // SetCalendarIDs updates the calendar IDs used by the client func (c *GoogleCalendarClient) SetCalendarIDs(ids []string) { c.calendarIDs = ids } // GetCalendarList returns all calendars accessible to the user func (c *GoogleCalendarClient) GetCalendarList(ctx context.Context) ([]models.CalendarInfo, error) { list, err := c.srv.CalendarList.List().Do() if err != nil { return nil, fmt.Errorf("failed to fetch calendar list: %w", err) } var calendars []models.CalendarInfo for _, item := range list.Items { name := item.Summary if name == "" { name = item.Id } calendars = append(calendars, models.CalendarInfo{ ID: item.Id, Name: name, }) } 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) }