summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-12 11:06:32 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-12 11:06:32 +0000
commitf31b4722243b8b69564c94e47e07678153c31815 (patch)
tree9b66a2132b7c40c3ca189aa151327aeec33cc18b /internal
parentb1c76cdf30b7d491f3791588c8aa8c66ee5a0950 (diff)
feat(widget): capture and forward RecurringEventID for calendar events
Diffstat (limited to 'internal')
-rw-r--r--internal/api/google_calendar.go26
-rw-r--r--internal/handlers/timeline_logic.go21
-rw-r--r--internal/handlers/timeline_logic_test.go1
-rw-r--r--internal/handlers/widget.go13
-rw-r--r--internal/handlers/widget_test.go38
-rw-r--r--internal/models/timeline.go13
-rw-r--r--internal/models/types.go25
-rw-r--r--internal/models/widget.go23
-rw-r--r--internal/store/sqlite.go10
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)