diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-13 07:11:04 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-13 07:11:04 +0000 |
| commit | 44abf42ed45aa8f285e7ce031cbb9ef1ade667ea (patch) | |
| tree | dbc21cadc2750c6e39e7f7613be0b3f73790c445 /internal | |
| parent | 8310f802dd9fc6ef5dff0be7f640f79c5b39987f (diff) | |
| parent | 4126fe4f56a6eb9703a084d4793a597f37bf2867 (diff) | |
Merge github/master: reconcile with parallel widget work
Another session pushed 27 commits in parallel covering quick-add, event
detail popups, recurrence display, overdue badges, a manual refresh button,
and its own fix for the same overdue-tasks bug (via a separate
GetOverdueNativeTasks fetch folded into BuildTimeline, rather than widening
GetNativeTasksByDateRange's bound directly). Reconciled rather than blindly
taking one side:
- Reverted GetNativeTasksByDateRange to its original bounded query and kept
upstream's GetOverdueNativeTasks + BuildTimeline fold-in as the sole
overdue mechanism for native tasks, to avoid double-counting overdue
items (my widened query + their separate fetch would have both returned
them). Re-pointed the regression test at the now-correct contract and
added a store-level test for GetOverdueNativeTasks directly.
- Kept my GetGoogleTasksByDateRange fix as-is (single unbounded query) --
upstream never touched Google Tasks overdue handling, so there's no
duplication risk there.
- Rewove WidgetRoot's LazyColumn structure (added for scrolling) around
upstream's new header buttons, pinned all-day event rows, and the
enhanced TomorrowSection, none of which were written LazyColumn-aware
since that work landed on this side only.
- Combined both sides' additions to TaskDetailActivity/TaskDetailSheet
(description-edit detail popup + due-date reschedule label) and
WidgetRepository/Actions (optimistic local removal + refresh button
wiring) -- these were independent, non-overlapping features that both
needed to survive.
- Renumbered the migration collision: both sides independently added a
migration numbered 022. Card-description was already applied to the live
production DB under that filename earlier this session (migrations are
tracked by filename), so it keeps 022; the recurring-event-id migration,
never deployed under any name here, moves to 023.
Verified: go build clean, full test suite passes (only the two
pre-existing agent-handler failures and the pre-existing models package
build error remain, both confirmed unrelated via git stash before this
session began), and a dry run against a copy of the live production
database applies both migrations cleanly with no re-run conflicts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/google_calendar.go | 125 | ||||
| -rw-r--r-- | internal/api/google_calendar_test.go | 30 | ||||
| -rw-r--r-- | internal/api/interfaces.go | 1 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 25 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic.go | 49 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic_test.go | 110 | ||||
| -rw-r--r-- | internal/handlers/widget.go | 112 | ||||
| -rw-r--r-- | internal/handlers/widget_test.go | 338 | ||||
| -rw-r--r-- | internal/models/timeline.go | 13 | ||||
| -rw-r--r-- | internal/models/types.go | 25 | ||||
| -rw-r--r-- | internal/models/widget.go | 21 | ||||
| -rw-r--r-- | internal/store/native_tasks.go | 88 | ||||
| -rw-r--r-- | internal/store/native_tasks_test.go | 96 | ||||
| -rw-r--r-- | internal/store/sqlite.go | 10 | ||||
| -rw-r--r-- | internal/store/sqlite_test.go | 45 |
15 files changed, 995 insertions, 93 deletions
diff --git a/internal/api/google_calendar.go b/internal/api/google_calendar.go index 8bb3143..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") @@ -130,12 +211,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 +245,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, }) } } @@ -201,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 183f3f0..3c1e4e1 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/handlers.go b/internal/handlers/handlers.go index aaf1d0d..408006d 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -314,9 +314,20 @@ func (h *Handler) fetchCalendarEvents(ctx context.Context, forceRefresh bool) ([ } if len(enabledIDs) == 0 { - // No source_configs synced yet — fall back to the configured calendar ID + // No source_configs synced yet — fall back to the configured calendar + // ID(s). GoogleCalendarID is a single env var that itself holds a + // comma-separated list (see GOOGLE_CALENDAR_ID in .env) -- it must be + // split before use. Passing the raw joined string straight through + // as a single calendar ID (the previous behavior) sends Google's API + // a calendarId that matches nothing, failing every fetch with a 404 — + // this is a real production incident, not a hypothetical: it silently + // broke all calendar events (web and widget both) until fixed. if len(configs) == 0 && h.config.GoogleCalendarID != "" { - enabledIDs = []string{h.config.GoogleCalendarID} + for _, id := range strings.Split(h.config.GoogleCalendarID, ",") { + if trimmed := strings.TrimSpace(id); trimmed != "" { + enabledIDs = append(enabledIDs, trimmed) + } + } } else { // Configs exist but all disabled — respect that return nil, nil @@ -326,10 +337,12 @@ func (h *Handler) fetchCalendarEvents(ctx context.Context, forceRefresh bool) ([ h.googleCalendarClient.SetCalendarIDs(enabledIDs) fetcher := &CacheFetcher[models.CalendarEvent]{ - Store: h.store, - CacheKey: store.CacheKeyGoogleCalendar, - TTLMinutes: h.config.CacheTTLMinutes, - Fetch: func(ctx context.Context) ([]models.CalendarEvent, error) { return h.googleCalendarClient.GetUpcomingEvents(ctx, 50) }, + Store: h.store, + CacheKey: store.CacheKeyGoogleCalendar, + TTLMinutes: h.config.CacheTTLMinutes, + Fetch: func(ctx context.Context) ([]models.CalendarEvent, error) { + return h.googleCalendarClient.GetUpcomingEvents(ctx, 50) + }, GetFromCache: h.store.GetCalendarEvents, SaveToCache: h.store.SaveCalendarEvents, } diff --git a/internal/handlers/timeline_logic.go b/internal/handlers/timeline_logic.go index 67ba09d..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) @@ -125,7 +126,10 @@ func BuildTimeline(ctx context.Context, s *store.Store, start, end time.Time) ([ } } - // 6. Fetch native (doot-owned) tasks + // 6. Fetch native (doot-owned) tasks due within the range, plus any + // overdue tasks (due before the range's start) so they still surface -- + // GetNativeTasksByDateRange's lower bound would otherwise drop them + // before ComputeDaySection ever gets a chance to mark them IsOverdue. nativeDated, err := s.GetNativeTasksByDateRange(start, end) if err != nil { log.Printf("Warning: failed to read native tasks: %v", err) @@ -149,6 +153,29 @@ func BuildTimeline(ctx context.Context, s *store.Store, start, end time.Time) ([ } } + nativeOverdue, err := s.GetOverdueNativeTasks(start) + if err != nil { + log.Printf("Warning: failed to read overdue native tasks: %v", err) + } else { + for _, task := range nativeOverdue { + if task.DueDate == nil { + continue + } + item := models.TimelineItem{ + ID: task.ID, + Type: models.TimelineItemTypeTask, + Title: task.Content, + Time: *task.DueDate, + Description: task.Description, + OriginalItem: task, + IsCompleted: task.Completed, + Source: "doot", + } + item.ComputeDaySection(now) + items = append(items, item) + } + } + nativeUndated, err := s.GetUndatedNativeTasks() if err != nil { log.Printf("Warning: failed to read undated native tasks: %v", err) diff --git a/internal/handlers/timeline_logic_test.go b/internal/handlers/timeline_logic_test.go index 02dd5fb..1da2d8a 100644 --- a/internal/handlers/timeline_logic_test.go +++ b/internal/handlers/timeline_logic_test.go @@ -19,6 +19,13 @@ import ( type MockCalendarClient struct { Events []models.CalendarEvent Err error + // SetCalendarIDsCalls records every ids slice SetCalendarIDs was called + // 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) { @@ -33,7 +40,13 @@ func (m *MockCalendarClient) GetCalendarList(ctx context.Context) ([]models.Cale return nil, m.Err } -func (m *MockCalendarClient) SetCalendarIDs(ids []string) {} +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() @@ -77,6 +90,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 ( @@ -288,6 +302,56 @@ func TestBuildTimeline_ReadsCalendarEventsFromStore(t *testing.T) { } } +// TestBuildTimeline_IncludesOverdueNativeTasks proves the 2026-07-12 fix: a +// native task whose due_date is BEFORE the requested range's start must +// still appear in the timeline, marked IsOverdue, instead of being silently +// dropped. Root cause: GetNativeTasksByDateRange's SQL bound (due_date >= +// start) excluded overdue tasks from ever being fetched, so ComputeDaySection +// never got a chance to set IsOverdue -- both the web Timeline view and the +// widget API (which both call BuildTimeline with a start of "today") showed +// zero overdue tasks even though the Tasks tab (which calls the unbounded +// GetNativeTasks) showed them fine. +func TestBuildTimeline_IncludesOverdueNativeTasks(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + overdueDate := today.AddDate(0, 0, -12) // 12 days in the past + + if err := db.CreateNativeTask(models.Task{ + ID: "overdue-1", + Content: "Pay the water bill", + DueDate: &overdueDate, + }); err != nil { + t.Fatalf("Failed to create native task: %v", err) + } + + start := today + end := today.AddDate(0, 0, 2) + + items, err := BuildTimeline(context.Background(), db, start, end) + if err != nil { + t.Fatalf("BuildTimeline failed: %v", err) + } + + var found *models.TimelineItem + for i := range items { + if items[i].ID == "overdue-1" { + found = &items[i] + } + } + if found == nil { + t.Fatal("expected overdue native task to appear in timeline, but it was missing") + } + if !found.IsOverdue { + t.Error("expected overdue native task to have IsOverdue = true") + } + if found.Title != "Pay the water bill" { + t.Errorf("Title: got %q, want %q", found.Title, "Pay the water bill") + } +} + func TestFetchCalendarEvents_CacheFallbackOnAPIError(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() @@ -336,6 +400,50 @@ func TestFetchCalendarEvents_CacheFallbackOnAPIError(t *testing.T) { } } +// TestFetchCalendarEvents_ConfigFallback_SplitsCommaJoinedIDs proves the +// 2026-07-12 fix: when no source_configs rows exist yet for "gcal" (the +// normal state before any calendar-discovery sync has run), fetchCalendarEvents +// falls back to config.GoogleCalendarID -- but that's a single env var that +// itself holds a comma-separated list of calendar IDs (see GOOGLE_CALENDAR_ID +// in .env). Previously the whole joined string was passed to SetCalendarIDs +// as a single one-element slice, which Google's API rejected with a 404 +// ("Not Found") on every single fetch -- a real production incident that +// broke all calendar events, in both the web dashboard and the widget, until +// this fix. It must now be split into separate IDs. +func TestFetchCalendarEvents_ConfigFallback_SplitsCommaJoinedIDs(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + mock := &MockCalendarClient{Events: []models.CalendarEvent{{ID: "e1"}}} + h := &Handler{ + store: db, + googleCalendarClient: mock, + config: &config.Config{ + CacheTTLMinutes: 5, + GoogleCalendarID: "cal-a@group.calendar.google.com, cal-b@gmail.com,cal-c@group.calendar.google.com", + }, + renderer: newTestRenderer(), + } + + if _, err := h.fetchCalendarEvents(context.Background(), true); err != nil { + t.Fatalf("fetchCalendarEvents: %v", err) + } + + if len(mock.SetCalendarIDsCalls) == 0 { + t.Fatal("expected SetCalendarIDs to be called") + } + got := mock.SetCalendarIDsCalls[len(mock.SetCalendarIDsCalls)-1] + want := []string{"cal-a@group.calendar.google.com", "cal-b@gmail.com", "cal-c@group.calendar.google.com"} + if len(got) != len(want) { + t.Fatalf("SetCalendarIDs called with %d ids, want %d: got %v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("id[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + func TestSaveAndGetCalendarEvents(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go index 3b94bf1..f1f7452 100644 --- a/internal/handlers/widget.go +++ b/internal/handlers/widget.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "errors" "net/http" "strings" "time" @@ -31,11 +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, - 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 { @@ -54,18 +57,38 @@ func TimelineItemToWidgetItem(item models.TimelineItem) models.WidgetItem { wi.Completable = item.Source == "doot" } - // Only populate Start/End for items with a real time (non-all-day, non-zero) - if !item.IsAllDay && !item.Time.IsZero() { + // Only populate Start/End for items with a real time. All-day CALENDAR + // EVENTS (not undated doot/gtask tasks, which are also flagged IsAllDay + // as a "no specific time" fallback -- see TimelineItem.ComputeDaySection) + // get Start populated too, using their real event date, so the widget + // client can pin them to the top of the correct day's section instead of + // losing them in the floating-task hourly-slot packer, which has no + // concept of "all day" and can push a slot past the visible grid range + // entirely. Tasks keep the existing nil-Start "floating" treatment + // regardless of IsAllDay -- only Start is set for them (never End), and + // only when they have a real time. + if !item.Time.IsZero() && (!item.IsAllDay || item.Type == models.TimelineItemTypeEvent) { t := item.Time wi.Start = &t - if item.EndTime != nil { - wi.End = item.EndTime - } else { - end := item.Time.Add(time.Hour) - wi.End = &end + if !item.IsAllDay { + if item.EndTime != nil { + wi.End = item.EndTime + } else { + end := item.Time.Add(time.Hour) + wi.End = &end + } } } + // DueDate is independent of Start/IsAllDay -- doot tasks deliberately + // keep Start nil (see the "floating task" doc comment above) so the + // client's SlotPacker positions them, but the Android detail popup + // still needs to know the real due date to display and reschedule it. + if item.Type == models.TimelineItemTypeTask && item.Source == "doot" && !item.Time.IsZero() { + due := item.Time + wi.DueDate = &due + } + return wi } @@ -105,6 +128,10 @@ type widgetCompleteRequest struct { Source string `json:"source"` } +type widgetAddRequest struct { + Title string `json:"title"` +} + type widgetRescheduleRequest struct { ID string `json:"id"` Source string `json:"source"` @@ -130,6 +157,10 @@ func (h *Handler) HandleWidgetReschedule(w http.ResponseWriter, r *http.Request) tz := config.GetDisplayTimezone() dueDate := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, tz) if err := h.store.RescheduleNativeTask(req.ID, dueDate); err != nil { + if errors.Is(err, store.ErrNativeTaskNotFound) { + http.Error(w, "task not found", http.StatusNotFound) + return + } http.Error(w, "failed to reschedule", http.StatusInternalServerError) return } @@ -276,6 +307,14 @@ func (h *Handler) HandleWidgetComplete(w http.ResponseWriter, r *http.Request) { switch req.Source { case "doot": if err := h.store.CompleteNativeTask(req.ID); err != nil { + if errors.Is(err, store.ErrNativeTaskNotFound) { + // Surfaced as 404 (not a silent 200) so the caller -- the + // Android widget -- can tell "nothing changed" apart from + // "it worked", instead of the previous behavior where a + // stale/wrong id looked identical to a real completion. + http.Error(w, "task not found", http.StatusNotFound) + return + } http.Error(w, "failed to complete task", http.StatusInternalServerError) return } @@ -319,3 +358,52 @@ 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 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + title := strings.TrimSpace(req.Title) + if title == "" { + http.Error(w, "title is required", http.StatusBadRequest) + return + } + + task := models.Task{ + ID: newID(), + Content: title, + Priority: 1, + } + if err := h.store.CreateNativeTask(task); err != nil { + http.Error(w, "failed to create task", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/internal/handlers/widget_test.go b/internal/handlers/widget_test.go index 1d8dba9..3116918 100644 --- a/internal/handlers/widget_test.go +++ b/internal/handlers/widget_test.go @@ -3,6 +3,7 @@ package handlers import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -168,6 +169,178 @@ func TestTimelineItemToWidgetItem_Event(t *testing.T) { } } +// TestTimelineItemToWidgetItem_AllDayEvent proves the 2026-07-12 fix: an +// all-day CALENDAR EVENT (Type == event, IsAllDay == true) must get Start +// populated with its real date -- previously Start was nil for every +// IsAllDay item regardless of type, which meant the widget client's +// hourly-slot packer had no date information to pin all-day events to the +// top of the correct day and they could silently fall outside the visible +// grid range instead. End stays nil since there's no meaningful end time to +// show for an all-day item. +func TestTimelineItemToWidgetItem_AllDayEvent(t *testing.T) { + day := time.Date(2026, 7, 12, 0, 0, 0, 0, time.Local) + item := models.TimelineItem{ + ID: "cal-holiday", + Title: "Company Holiday", + Source: "calendar", + Type: models.TimelineItemTypeEvent, + Time: day, + IsAllDay: true, + } + + wi := TimelineItemToWidgetItem(item) + + if wi.Type != "event" { + t.Errorf("Type: got %q, want %q", wi.Type, "event") + } + if !wi.IsAllDay { + t.Error("expected IsAllDay to be true") + } + if wi.Start == nil { + t.Fatal("all-day event should have non-nil Start (needed to pin it to the correct day)") + } + if !wi.Start.Equal(day) { + t.Errorf("Start = %v, want %v", *wi.Start, day) + } + if wi.End != nil { + t.Error("all-day event should have nil End") + } +} + +// TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior proves the +// same fix does NOT change behavior for undated doot/gtask tasks, which are +// also flagged IsAllDay as a "no specific time" fallback (see +// TimelineItem.ComputeDaySection) but are a different concept from a real +// all-day calendar event -- they must keep the existing nil-Start +// "floating" treatment so the hourly-slot packer still places them. +func TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior(t *testing.T) { + item := models.TimelineItem{ + ID: "undated-task", + Title: "Someday task", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: time.Now(), + IsAllDay: true, + } + + wi := TimelineItemToWidgetItem(item) + + if wi.Start != nil { + t.Error("an undated task (IsAllDay as a fallback, not a real all-day event) should still have nil Start") + } +} + +// TestTimelineItemToWidgetItem_ForwardsIsOverdue proves the 2026-07-12 +// overdue-badge fix: TimelineItem.IsOverdue (already computed correctly by +// ComputeDaySection, confirmed by the earlier fix that made overdue tasks +// appear in the timeline at all) must be forwarded onto WidgetItem so the +// Android client can render it distinctly -- previously it was silently +// dropped, so an overdue task looked identical to a normal one on the +// widget. +func TestTimelineItemToWidgetItem_ForwardsIsOverdue(t *testing.T) { + item := models.TimelineItem{ + ID: "overdue-1", + Title: "Pay the water bill", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: time.Now(), + IsOverdue: true, + } + + wi := TimelineItemToWidgetItem(item) + + if !wi.IsOverdue { + t.Error("expected IsOverdue to be forwarded as true") + } +} + +func TestTimelineItemToWidgetItem_NotOverdueByDefault(t *testing.T) { + item := models.TimelineItem{ + ID: "today-1", + Title: "Water the plants", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: time.Now(), + } + + wi := TimelineItemToWidgetItem(item) + + if wi.IsOverdue { + t.Error("expected IsOverdue to be false when the source item isn't overdue") + } +} + +// TestTimelineItemToWidgetItem_DootTaskGetsDueDate proves the 2026-07-12 +// clickable-reschedule fix: a doot task's raw due date must reach the +// client via a NEW field (DueDate) that is independent of Start/IsAllDay -- +// Start is deliberately left nil for doot tasks (see +// TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior) so the +// client's floating-task SlotPacker can position it, and that must keep +// working unchanged. Before this fix there was no way for the Android +// detail popup to know a doot task's current due date at all. +func TestTimelineItemToWidgetItem_DootTaskGetsDueDate(t *testing.T) { + due := time.Date(2026, 7, 15, 0, 0, 0, 0, time.Local) + item := models.TimelineItem{ + ID: "doot-1", + Title: "Pay the water bill", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: due, + IsAllDay: true, + } + + wi := TimelineItemToWidgetItem(item) + + if wi.DueDate == nil { + t.Fatal("expected DueDate to be set for a doot task with a real due date") + } + if !wi.DueDate.Equal(due) { + t.Errorf("DueDate = %v, want %v", *wi.DueDate, due) + } + // Start must stay nil -- this is the pre-existing floating-task + // behavior and this feature must not change it. + if wi.Start != nil { + t.Error("Start must remain nil for a doot task -- DueDate is a separate field, not a replacement") + } +} + +func TestTimelineItemToWidgetItem_UndatedDootTask_NilDueDate(t *testing.T) { + item := models.TimelineItem{ + ID: "doot-undated", + Title: "Someday task", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: time.Now(), + IsAllDay: true, + } + // Zero Time simulates the "no real due date" case at the field level; + // TimelineItemToWidgetItem's DueDate logic must guard on !Time.IsZero(). + item.Time = time.Time{} + + wi := TimelineItemToWidgetItem(item) + + if wi.DueDate != nil { + t.Error("expected DueDate to be nil when the source item has a zero Time") + } +} + +func TestTimelineItemToWidgetItem_CalendarEvent_NilDueDate(t *testing.T) { + start := time.Now() + item := models.TimelineItem{ + ID: "cal-1", + Title: "Team sync", + Source: "calendar", + Type: models.TimelineItemTypeEvent, + Time: start, + } + + wi := TimelineItemToWidgetItem(item) + + if wi.DueDate != nil { + t.Error("expected DueDate to stay nil for a non-doot item (calendar event)") + } +} + func TestHandleWidgetComplete_NonCompletable(t *testing.T) { h := &Handler{} body := `{"id":"x","source":"calendar"}` @@ -323,6 +496,26 @@ func TestHandleWidgetComplete_GoogleTask(t *testing.T) { } } +// TestHandleWidgetComplete_UnknownID_Returns404 proves the 2026-07-12 fix at +// the handler layer: a "doot" completion for an id that doesn't exist must +// surface as 404, not the previous silent 200 (see +// store.ErrNativeTaskNotFound's doc comment for the underlying bug this +// closes -- a real production incident where the widget's completeTask tap +// intermittently looked like it worked but changed nothing). +func TestHandleWidgetComplete_UnknownID_Returns404(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + body := `{"id":"does-not-exist","source":"doot"}` + req := httptest.NewRequest("POST", "/api/widget/complete", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetComplete).ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + func TestHandleWidgetComplete_GoogleTask_NotConfigured(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() @@ -483,6 +676,66 @@ func TestHandleWidgetComplete_TrelloCard_Archives(t *testing.T) { } } +// TestHandleWidgetAdd_CreatesTask proves the quick-add feature: POSTing a +// title to /api/widget/add creates an undated native task the same way the +// web UI's HandleUnifiedAdd does, but via the widget's bearer-token JSON +// API instead of a session-authenticated HTML form. +func TestHandleWidgetAdd_CreatesTask(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + body := `{"title":"Buy milk"}` + req := httptest.NewRequest("POST", "/api/widget/add", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetAdd).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + tasks, err := s.GetUndatedNativeTasks() + if err != nil { + t.Fatalf("failed to read back tasks: %v", err) + } + found := false + for _, task := range tasks { + if task.Content == "Buy milk" { + found = true + } + } + if !found { + t.Error("expected a task with content 'Buy milk' to have been created") + } +} + +func TestHandleWidgetAdd_EmptyTitle_Returns400(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + body := `{"title":""}` + req := httptest.NewRequest("POST", "/api/widget/add", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetAdd).ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestHandleWidgetAdd_WhitespaceOnlyTitle_Returns400(t *testing.T) { + s, cleanup := setupTestDB(t) + defer cleanup() + h := &Handler{store: s} + body := `{"title":" "}` + req := httptest.NewRequest("POST", "/api/widget/add", strings.NewReader(body)) + w := httptest.NewRecorder() + http.HandlerFunc(h.HandleWidgetAdd).ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + func TestWidgetAuthMiddleware_EmptyToken(t *testing.T) { inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) h := WidgetAuthMiddleware("", inner) @@ -496,3 +749,88 @@ 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) + } +} + +// 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) + } +} 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 58d3888..6f8c405 100644 --- a/internal/models/types.go +++ b/internal/models/types.go @@ -127,12 +127,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 @@ -249,12 +250,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 8d0bdd6..cbaf19c 100644 --- a/internal/models/widget.go +++ b/internal/models/widget.go @@ -6,15 +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"` - 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/native_tasks.go b/internal/store/native_tasks.go index 218675e..7789a0d 100644 --- a/internal/store/native_tasks.go +++ b/internal/store/native_tasks.go @@ -1,12 +1,21 @@ package store import ( + "database/sql" "encoding/json" + "errors" "time" "task-dashboard/internal/models" ) +// ErrNativeTaskNotFound is returned by CompleteNativeTask, UncompleteNativeTask, +// and RescheduleNativeTask when no row matches the given id -- previously these +// three silently reported success on a 0-row UPDATE (Exec's err is nil even when +// no rows match), so a stale or wrong id from a caller looked identical to a real +// completion: the HTTP response was 200, but nothing in the database changed. +var ErrNativeTaskNotFound = errors.New("native task not found") + // GetNativeTasks returns all non-completed native tasks. func (s *Store) GetNativeTasks() ([]models.Task, error) { rows, err := s.db.Query(` @@ -22,15 +31,37 @@ func (s *Store) GetNativeTasks() ([]models.Task, error) { return scanNativeTasks(rows) } -// GetNativeTasksByDateRange returns non-completed native tasks due within the given range, -// including overdue tasks (due before start) so they keep appearing until completed. +// GetNativeTasksByDateRange returns non-completed native tasks due within the given range. +// Overdue tasks (due before start) are deliberately excluded here -- BuildTimeline fetches +// those separately via GetOverdueNativeTasks so callers that only want "in range" can use this +// without double-counting against that separate fetch. func (s *Store) GetNativeTasksByDateRange(start, end time.Time) ([]models.Task, error) { rows, err := s.db.Query(` SELECT id, content, description, project_name, due_date, priority, completed, labels, created_at FROM native_tasks + WHERE completed = 0 AND due_date IS NOT NULL AND due_date >= ? AND due_date < ? + ORDER BY due_date ASC, priority DESC + `, start, end) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + return scanNativeTasks(rows) +} + +// GetOverdueNativeTasks returns non-completed native tasks whose due date is +// before the given time. BuildTimeline calls this alongside +// GetNativeTasksByDateRange, whose lower bound excludes anything due before +// the requested range's start -- without this, a task overdue from a +// previous day never gets fetched at all, so it never reaches +// ComputeDaySection to be marked IsOverdue. +func (s *Store) GetOverdueNativeTasks(before time.Time) ([]models.Task, error) { + rows, err := s.db.Query(` + SELECT id, content, description, project_name, due_date, priority, completed, labels, created_at + FROM native_tasks WHERE completed = 0 AND due_date IS NOT NULL AND due_date < ? ORDER BY due_date ASC, priority DESC - `, end) + `, before) if err != nil { return nil, err } @@ -81,31 +112,62 @@ func (s *Store) UpdateNativeTaskDescription(id, description string) error { return err } -// CompleteNativeTask marks a task as completed. +// CompleteNativeTask marks a task as completed. Returns ErrNativeTaskNotFound +// if id doesn't match any row. func (s *Store) CompleteNativeTask(id string) error { - _, err := s.db.Exec(` + result, err := s.db.Exec(` UPDATE native_tasks SET completed = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? `, id) - return err + if err != nil { + return err + } + return checkRowsAffected(result) } -// RescheduleNativeTask sets a new due date on a task. +// RescheduleNativeTask sets a new due date on a task. Returns +// ErrNativeTaskNotFound if id doesn't match any row. func (s *Store) RescheduleNativeTask(id string, dueDate time.Time) error { - _, err := s.db.Exec(` + result, err := s.db.Exec(` UPDATE native_tasks SET due_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `, dueDate, id) - return err + if err != nil { + return err + } + return checkRowsAffected(result) } -// UncompleteNativeTask marks a task as not completed. +// UncompleteNativeTask marks a task as not completed. Returns +// ErrNativeTaskNotFound if id doesn't match any row. func (s *Store) UncompleteNativeTask(id string) error { - _, err := s.db.Exec(` + result, err := s.db.Exec(` UPDATE native_tasks SET completed = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ? `, id) - return err + if err != nil { + return err + } + return checkRowsAffected(result) +} + +// checkRowsAffected returns ErrNativeTaskNotFound if the update matched no +// rows -- mirrors the RowsAffected() check already used in sqlite.go's +// ApproveAgentSession/DenyAgentSession for the same "silent 0-row update" +// class of bug. +func checkRowsAffected(result sql.Result) error { + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrNativeTaskNotFound + } + return nil } -func scanNativeTasks(rows interface{ Next() bool; Scan(...interface{}) error; Err() error }) ([]models.Task, error) { +func scanNativeTasks(rows interface { + Next() bool + Scan(...interface{}) error + Err() error +}) ([]models.Task, error) { var tasks []models.Task for rows.Next() { var t models.Task diff --git a/internal/store/native_tasks_test.go b/internal/store/native_tasks_test.go new file mode 100644 index 0000000..5c11d3a --- /dev/null +++ b/internal/store/native_tasks_test.go @@ -0,0 +1,96 @@ +package store + +import ( + "database/sql" + "errors" + "path/filepath" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// newNativeTasksTestStore creates a Store backed by a fresh temp sqlite DB +// with just the native_tasks table -- enough to exercise +// CompleteNativeTask/UncompleteNativeTask/RescheduleNativeTask without +// running the full migration set. +func newNativeTasksTestStore(t *testing.T) *Store { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.db") + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + if _, err := db.Exec(` + CREATE TABLE native_tasks ( + id TEXT PRIMARY KEY, + content TEXT NOT NULL, + description TEXT DEFAULT '', + project_name TEXT DEFAULT '', + due_date DATETIME, + priority INTEGER DEFAULT 1, + completed BOOLEAN DEFAULT 0, + labels TEXT DEFAULT '[]', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO native_tasks (id, content) VALUES ('real-1', 'Real task')`); err != nil { + t.Fatal(err) + } + return &Store{db: db} +} + +// TestCompleteNativeTask_UnknownID_ReturnsErrNotFound proves the 2026-07-12 +// fix: a plain UPDATE ... WHERE id = ? silently "succeeds" with a nil error +// when 0 rows match (this is how database/sql's Exec behaves for an UPDATE +// that matches nothing -- no error, just RowsAffected() == 0). Before this +// fix, CompleteNativeTask returned that nil error straight through, so a +// stale/wrong id from a caller (the Android widget, in the real incident +// this was found from) looked identical to a real completion: HTTP 200, +// nothing changed in the database. +func TestCompleteNativeTask_UnknownID_ReturnsErrNotFound(t *testing.T) { + s := newNativeTasksTestStore(t) + + err := s.CompleteNativeTask("does-not-exist") + if !errors.Is(err, ErrNativeTaskNotFound) { + t.Fatalf("expected ErrNativeTaskNotFound, got %v", err) + } +} + +func TestCompleteNativeTask_RealID_Succeeds(t *testing.T) { + s := newNativeTasksTestStore(t) + + if err := s.CompleteNativeTask("real-1"); err != nil { + t.Fatalf("CompleteNativeTask: %v", err) + } + + var completed bool + if err := s.db.QueryRow(`SELECT completed FROM native_tasks WHERE id = 'real-1'`).Scan(&completed); err != nil { + t.Fatal(err) + } + if !completed { + t.Error("expected task to be marked completed") + } +} + +func TestUncompleteNativeTask_UnknownID_ReturnsErrNotFound(t *testing.T) { + s := newNativeTasksTestStore(t) + + err := s.UncompleteNativeTask("does-not-exist") + if !errors.Is(err, ErrNativeTaskNotFound) { + t.Fatalf("expected ErrNativeTaskNotFound, got %v", err) + } +} + +func TestRescheduleNativeTask_UnknownID_ReturnsErrNotFound(t *testing.T) { + s := newNativeTasksTestStore(t) + + err := s.RescheduleNativeTask("does-not-exist", time.Now()) + if !errors.Is(err, ErrNativeTaskNotFound) { + t.Fatalf("expected ErrNativeTaskNotFound, got %v", err) + } +} diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index f955f71..ad88166 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -600,8 +600,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 @@ -609,7 +609,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 } @@ -644,7 +644,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 @@ -657,7 +657,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) diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go index 4d3c8f8..e8af436 100644 --- a/internal/store/sqlite_test.go +++ b/internal/store/sqlite_test.go @@ -188,10 +188,12 @@ func setupTestStoreWithNativeTasks(t *testing.T) *Store { return store } -// TestGetNativeTasksByDateRange_IncludesOverdue guards against a regression where a native task -// due before the window's start (e.g. yesterday, still incomplete) silently dropped out of the -// widget/timeline the moment the day rolled over, because the query required due_date >= start. -func TestGetNativeTasksByDateRange_IncludesOverdue(t *testing.T) { +// TestGetNativeTasksByDateRange_ExcludesOverdue documents the deliberate contract after +// 2026-07-13's reconciliation: GetNativeTasksByDateRange is scoped to [start, end) only. +// Overdue tasks (due before start) are BuildTimeline's job to fetch separately via +// GetOverdueNativeTasks -- see that test below and timeline_logic.go's "6." section -- +// so this function must NOT also return them, or BuildTimeline would double them up. +func TestGetNativeTasksByDateRange_ExcludesOverdue(t *testing.T) { store := setupTestStoreWithNativeTasks(t) now := time.Now() @@ -221,8 +223,8 @@ func TestGetNativeTasksByDateRange_IncludesOverdue(t *testing.T) { for _, r := range results { ids[r.ID] = true } - if !ids["t-overdue"] { - t.Error("expected overdue task to be included, but it was excluded") + if ids["t-overdue"] { + t.Error("expected overdue task to be excluded from the ranged fetch") } if !ids["t-today"] { t.Error("expected today's task to be included") @@ -232,6 +234,37 @@ func TestGetNativeTasksByDateRange_IncludesOverdue(t *testing.T) { } } +// TestGetOverdueNativeTasks_IncludesOnlyPastDue is the store-level counterpart to +// TestGetNativeTasksByDateRange_ExcludesOverdue: this is the function BuildTimeline relies on +// to actually surface overdue tasks (see timeline_logic.go's "6." section and +// TestBuildTimeline_IncludesOverdueNativeTasks for the integration-level proof). +func TestGetOverdueNativeTasks_IncludesOnlyPastDue(t *testing.T) { + store := setupTestStoreWithNativeTasks(t) + + now := time.Now() + overdue := now.Add(-48 * time.Hour) + today := now + + for _, task := range []models.Task{ + {ID: "t-overdue", Content: "Overdue task", DueDate: &overdue}, + {ID: "t-today", Content: "Today task", DueDate: &today}, + } { + if err := store.CreateNativeTask(task); err != nil { + t.Fatalf("CreateNativeTask(%s) failed: %v", task.ID, err) + } + } + + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + results, err := store.GetOverdueNativeTasks(start) + if err != nil { + t.Fatalf("GetOverdueNativeTasks failed: %v", err) + } + if len(results) != 1 || results[0].ID != "t-overdue" { + t.Errorf("expected only the overdue task, got %+v", results) + } +} + // TestSaveAndGetGoogleTasks_RoundTripsTimestamps guards against a regression where // due_date/updated_at (TEXT columns, not DATETIME) failed to scan back into time.Time // via sql.NullTime whenever a row had a non-null timestamp. |
