From 7f8cd6bfb894385b9ae310fd000cb921fef71cea Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sat, 25 Jul 2026 20:09:42 +0000 Subject: fix: undated tasks were landing in the widget's scheduled-events grid ComputeDaySection unconditionally recomputed IsAllDay from a midnight-time heuristic, clobbering the IsAllDay:true flag callers set to mark floating (no-due-date) tasks -- since undated tasks use Time = now, this silently reset IsAllDay to false and gave them a real Start, so the widget could render them via EventBlock/HourRow instead of TaskRow. Their click handler always opens the calendar/source URL, so tapping a no-due-date task (e.g. a Google Task) opened Google Calendar instead of the task detail sheet. ComputeDaySection now only ever sets IsAllDay true, never clears an already-true value. Also flag IsAllDay for undated Google Tasks, which never got it set at all (only native undated doot tasks did). --- internal/handlers/timeline_logic.go | 7 ++++++ internal/handlers/timeline_logic_test.go | 42 ++++++++++++++++++++++++++++++++ internal/models/timeline.go | 11 +++++++-- 3 files changed, 58 insertions(+), 2 deletions(-) (limited to 'internal') diff --git a/internal/handlers/timeline_logic.go b/internal/handlers/timeline_logic.go index e3da760..b008221 100644 --- a/internal/handlers/timeline_logic.go +++ b/internal/handlers/timeline_logic.go @@ -146,6 +146,13 @@ func BuildTimeline(ctx context.Context, s *store.Store, start, end time.Time) ([ IsCompleted: gTask.Completed, Source: "gtasks", ListID: gTask.ListID, + // No due date -- flag IsAllDay like nativeUndated tasks below so + // TimelineItemToWidgetItem leaves Start nil instead of pinning it + // to "now", which otherwise lands it among scheduledEvents and + // renders it via EventBlock/HourRow -- rows whose click handler + // always opens the source URL/Google Calendar, not the task's + // detail sheet. + IsAllDay: gTask.DueDate == nil, } item.ComputeDaySection(now) items = append(items, item) diff --git a/internal/handlers/timeline_logic_test.go b/internal/handlers/timeline_logic_test.go index 7b5c47e..1a6ea6c 100644 --- a/internal/handlers/timeline_logic_test.go +++ b/internal/handlers/timeline_logic_test.go @@ -169,6 +169,48 @@ func TestBuildTimeline(t *testing.T) { } } +// TestBuildTimeline_UndatedGoogleTaskIsAllDay guards against a regression +// where a Google Task with no due date got Time set to "now" but IsAllDay +// left false (unlike nativeUndated doot tasks, which set IsAllDay: true). +// TimelineItemToWidgetItem then treated the near-"now" Time as a real +// scheduled Start, so the task could land among scheduledEvents and render +// via the widget's EventBlock/HourRow -- rows whose click handler always +// opens the source URL / Google Calendar instead of the task detail sheet. +func TestBuildTimeline_UndatedGoogleTaskIsAllDay(t *testing.T) { + s := setupTestStore(t) + + if err := s.SaveGoogleTasks([]models.GoogleTask{ + {ID: "g1", Title: "Hardware store", Status: "needsAction", ListID: "l1"}, + }); err != nil { + t.Fatalf("SaveGoogleTasks failed: %v", err) + } + + start := time.Now().Add(-24 * time.Hour) + end := time.Now().Add(24 * time.Hour) + items, err := BuildTimeline(context.Background(), s, start, end) + if err != nil { + t.Fatalf("BuildTimeline failed: %v", err) + } + + var found *models.TimelineItem + for i := range items { + if items[i].ID == "g1" { + found = &items[i] + } + } + if found == nil { + t.Fatal("expected undated google task g1 to appear in timeline") + } + if !found.IsAllDay { + t.Error("expected undated google task to be flagged IsAllDay so it stays a floating task on the widget, not a scheduled event") + } + + wi := TimelineItemToWidgetItem(*found) + if wi.Start != nil { + t.Error("expected undated google task to have nil Start so the widget renders it via TaskRow, not EventBlock") + } +} + func TestCalcCalendarBounds(t *testing.T) { tests := []struct { name string diff --git a/internal/models/timeline.go b/internal/models/timeline.go index 4b90856..5224b72 100644 --- a/internal/models/timeline.go +++ b/internal/models/timeline.go @@ -66,8 +66,15 @@ func (item *TimelineItem) ComputeDaySection(now time.Time) { // Check if item is overdue (before today) item.IsOverdue = itemDay.Before(today) - // Check if item is all-day (midnight time means no specific time) - item.IsAllDay = localItemTime.Hour() == 0 && localItemTime.Minute() == 0 + // Check if item is all-day (midnight time means no specific time). + // Only ever sets IsAllDay to true here -- never clears a true a caller + // already set (e.g. nativeUndated/gtask "no due date" tasks, which use + // IsAllDay as a floating-task marker with Time set to "now", not + // midnight -- clobbering it back to false made them look like real + // scheduled items to the widget client). + if localItemTime.Hour() == 0 && localItemTime.Minute() == 0 { + item.IsAllDay = true + } if itemDay.Before(tomorrow) { item.DaySection = DaySectionToday -- cgit v1.2.3