diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/google_calendar.go | 26 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic.go | 21 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic_test.go | 1 | ||||
| -rw-r--r-- | internal/handlers/widget.go | 13 | ||||
| -rw-r--r-- | internal/handlers/widget_test.go | 38 | ||||
| -rw-r--r-- | internal/models/timeline.go | 13 | ||||
| -rw-r--r-- | internal/models/types.go | 25 | ||||
| -rw-r--r-- | internal/models/widget.go | 23 | ||||
| -rw-r--r-- | internal/store/sqlite.go | 10 |
9 files changed, 108 insertions, 62 deletions
diff --git a/internal/api/google_calendar.go b/internal/api/google_calendar.go index 8bb3143..7381527 100644 --- a/internal/api/google_calendar.go +++ b/internal/api/google_calendar.go @@ -130,12 +130,13 @@ func (c *GoogleCalendarClient) GetUpcomingEvents(ctx context.Context, maxResults 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, + ID: item.Id, + Summary: item.Summary, + Description: item.Description, + Start: start, + End: end, + HTMLLink: item.HtmlLink, + RecurringEventID: item.RecurringEventId, }) } } @@ -163,12 +164,13 @@ func (c *GoogleCalendarClient) GetEventsByDateRange(ctx context.Context, start, 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, + ID: item.Id, + Summary: item.Summary, + Description: item.Description, + Start: evtStart, + End: evtEnd, + HTMLLink: item.HtmlLink, + RecurringEventID: item.RecurringEventId, }) } } diff --git a/internal/handlers/timeline_logic.go b/internal/handlers/timeline_logic.go index e7c1279..6fddd1d 100644 --- a/internal/handlers/timeline_logic.go +++ b/internal/handlers/timeline_logic.go @@ -81,16 +81,17 @@ func BuildTimeline(ctx context.Context, s *store.Store, start, end time.Time) ([ 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", + 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) diff --git a/internal/handlers/timeline_logic_test.go b/internal/handlers/timeline_logic_test.go index 7d3f6b5..9ddaeda 100644 --- a/internal/handlers/timeline_logic_test.go +++ b/internal/handlers/timeline_logic_test.go @@ -82,6 +82,7 @@ func setupTestStore(t *testing.T) *store.Store { start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, html_link TEXT, + recurring_event_id TEXT DEFAULT '', updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS google_tasks ( diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go index 0c88c16..29124fb 100644 --- a/internal/handlers/widget.go +++ b/internal/handlers/widget.go @@ -32,12 +32,13 @@ func WidgetAuthMiddleware(token string, next http.Handler) http.Handler { // Exported for testability. func TimelineItemToWidgetItem(item models.TimelineItem) models.WidgetItem { wi := models.WidgetItem{ - ID: item.ID, - Title: item.Title, - Source: item.Source, - IsAllDay: item.IsAllDay, - IsOverdue: item.IsOverdue, - URL: item.URL, + ID: item.ID, + Title: item.Title, + Source: item.Source, + IsAllDay: item.IsAllDay, + IsOverdue: item.IsOverdue, + URL: item.URL, + RecurringEventID: item.RecurringEventID, } switch item.Type { diff --git a/internal/handlers/widget_test.go b/internal/handlers/widget_test.go index e4a1d16..efe6858 100644 --- a/internal/handlers/widget_test.go +++ b/internal/handlers/widget_test.go @@ -386,3 +386,41 @@ func TestWidgetAuthMiddleware_EmptyToken(t *testing.T) { t.Fatalf("expected 401 with empty token, got %d", w.Code) } } + +// 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) + } +} diff --git a/internal/models/timeline.go b/internal/models/timeline.go index ab486a8..6e48f91 100644 --- a/internal/models/timeline.go +++ b/internal/models/timeline.go @@ -9,11 +9,11 @@ import ( type TimelineItemType string const ( - TimelineItemTypeTask TimelineItemType = "task" - TimelineItemTypeMeal TimelineItemType = "meal" - TimelineItemTypeCard TimelineItemType = "card" - TimelineItemTypeEvent TimelineItemType = "event" - TimelineItemTypeGTask TimelineItemType = "gtask" // Google Tasks + TimelineItemTypeTask TimelineItemType = "task" + TimelineItemTypeMeal TimelineItemType = "meal" + TimelineItemTypeCard TimelineItemType = "card" + TimelineItemTypeEvent TimelineItemType = "event" + TimelineItemTypeGTask TimelineItemType = "gtask" // Google Tasks ) type DaySection string @@ -42,7 +42,8 @@ type TimelineItem struct { Source string `json:"source"` // "trello", "plantoeat", "calendar", "gtasks" // Source-specific metadata - ListID string `json:"list_id,omitempty"` // For Google Tasks + ListID string `json:"list_id,omitempty"` // For Google Tasks + RecurringEventID string `json:"recurring_event_id,omitempty"` // For calendar events } // ComputeDaySection sets the DaySection, IsOverdue, and IsAllDay based on the item's time diff --git a/internal/models/types.go b/internal/models/types.go index d9dd515..b3f3a22 100644 --- a/internal/models/types.go +++ b/internal/models/types.go @@ -126,12 +126,13 @@ type Project struct { // CalendarEvent represents a Google Calendar event 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"` + 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 } // GoogleTask represents a task from Google Tasks @@ -248,12 +249,12 @@ type CompletedTask struct { // SourceConfig represents a configurable item from a data source type SourceConfig struct { - ID int64 `json:"id"` - Source string `json:"source"` // trello, gcal, gtasks - ItemType string `json:"item_type"` // board, project, calendar, tasklist - ItemID string `json:"item_id"` - ItemName string `json:"item_name"` - Enabled bool `json:"enabled"` + ID int64 `json:"id"` + Source string `json:"source"` // trello, gcal, gtasks + ItemType string `json:"item_type"` // board, project, calendar, tasklist + ItemID string `json:"item_id"` + ItemName string `json:"item_name"` + Enabled bool `json:"enabled"` } // FeatureToggle represents a feature flag diff --git a/internal/models/widget.go b/internal/models/widget.go index 54a6eee..cbaf19c 100644 --- a/internal/models/widget.go +++ b/internal/models/widget.go @@ -6,17 +6,18 @@ import "time" // Source values: "doot", "trello", "plantoeat", "calendar", "gtasks" // Type values: "task", "event" type WidgetItem struct { - ID string `json:"id"` - Title string `json:"title"` - Source string `json:"source"` - Type string `json:"type"` - Start *time.Time `json:"start,omitempty"` // nil = no specific time (floating task) - End *time.Time `json:"end,omitempty"` - IsAllDay bool `json:"is_all_day"` - IsOverdue bool `json:"is_overdue"` - DueDate *time.Time `json:"due_date,omitempty"` // doot tasks only -- see TimelineItemToWidgetItem - URL string `json:"url,omitempty"` - Completable bool `json:"completable"` // true = doot task (checkbox shown) + ID string `json:"id"` + Title string `json:"title"` + Source string `json:"source"` + Type string `json:"type"` + Start *time.Time `json:"start,omitempty"` // nil = no specific time (floating task) + End *time.Time `json:"end,omitempty"` + IsAllDay bool `json:"is_all_day"` + IsOverdue bool `json:"is_overdue"` + DueDate *time.Time `json:"due_date,omitempty"` // doot tasks only -- see TimelineItemToWidgetItem + URL string `json:"url,omitempty"` + Completable bool `json:"completable"` // true = doot task (checkbox shown) + RecurringEventID string `json:"recurring_event_id,omitempty"` } // WidgetResponse is the full /api/widget response body. diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index a6e9fd4..f08e9bd 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -598,8 +598,8 @@ func (s *Store) SaveCalendarEvents(events []models.CalendarEvent) error { } stmt, err := tx.Prepare(` - INSERT INTO calendar_events (id, summary, description, start_time, end_time, html_link) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO calendar_events (id, summary, description, start_time, end_time, html_link, recurring_event_id) + VALUES (?, ?, ?, ?, ?, ?, ?) `) if err != nil { return err @@ -607,7 +607,7 @@ func (s *Store) SaveCalendarEvents(events []models.CalendarEvent) error { defer func() { _ = stmt.Close() }() for _, e := range events { - _, err = stmt.Exec(e.ID, e.Summary, e.Description, e.Start, e.End, e.HTMLLink) + _, err = stmt.Exec(e.ID, e.Summary, e.Description, e.Start, e.End, e.HTMLLink, e.RecurringEventID) if err != nil { return err } @@ -642,7 +642,7 @@ func (s *Store) GetCalendarEvents() ([]models.CalendarEvent, error) { // GetCalendarEventsByDateRange retrieves cached calendar events within a date range 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 + 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 @@ -655,7 +655,7 @@ func (s *Store) GetCalendarEventsByDateRange(start, end time.Time) ([]models.Cal 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 { + 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) |
