summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-13 07:11:04 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-13 07:11:04 +0000
commit44abf42ed45aa8f285e7ce031cbb9ef1ade667ea (patch)
treedbc21cadc2750c6e39e7f7613be0b3f73790c445 /internal
parent8310f802dd9fc6ef5dff0be7f640f79c5b39987f (diff)
parent4126fe4f56a6eb9703a084d4793a597f37bf2867 (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.go125
-rw-r--r--internal/api/google_calendar_test.go30
-rw-r--r--internal/api/interfaces.go1
-rw-r--r--internal/handlers/handlers.go25
-rw-r--r--internal/handlers/timeline_logic.go49
-rw-r--r--internal/handlers/timeline_logic_test.go110
-rw-r--r--internal/handlers/widget.go112
-rw-r--r--internal/handlers/widget_test.go338
-rw-r--r--internal/models/timeline.go13
-rw-r--r--internal/models/types.go25
-rw-r--r--internal/models/widget.go21
-rw-r--r--internal/store/native_tasks.go88
-rw-r--r--internal/store/native_tasks_test.go96
-rw-r--r--internal/store/sqlite.go10
-rw-r--r--internal/store/sqlite_test.go45
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.