summaryrefslogtreecommitdiff
path: root/internal/handlers
diff options
context:
space:
mode:
Diffstat (limited to 'internal/handlers')
-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
5 files changed, 604 insertions, 30 deletions
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)
+ }
+}