From 9a3ece4fef431c2c35af239f3df6eeaa10ddeaf4 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 10 Aug 2026 03:16:39 +0000 Subject: Fix widget showing tomorrow's dated tasks under today Root cause: doot-native and Google Tasks due dates are always midnight-anchored (even when a task genuinely has a due date), which trips TimelineItem.ComputeDaySection's "midnight means no specific time" heuristic and sets IsAllDay=true on them. TimelineItemToWidgetItem then left wi.Start nil for any Task/GTask with IsAllDay=true, and the Android client's undated/floating pool (DootWidget.kt's `floating` list) has zero per-day awareness -- it just packs items forward from "now" -- so a task due tomorrow rendered as if due today. This was previously diagnosed and deliberately set aside (see project_doot_isallday_midnight_bug memory) with a simpler proposed fix (gate ComputeDaySection's heuristic to Event/Meal types); re-verifying that plan against the actual code before implementing it surfaced a real problem with it: it would also flip IsAllDay to false for same-day dated tasks, moving them from the web's untimed-item strip into the hourly grid with a fabricated "12:00 AM" time label -- fixing the widget by breaking the web's currently-correct rendering. Actual fix: added TimelineItem.Undated, a signal genuinely independent of IsAllDay -- true only for the caller's two genuinely-dateless constructions (nativeUndated tasks, gtasks with no due date), both of which already use Time=now as a layout placeholder rather than a real date. ComputeDaySection and IsAllDay are untouched, so web rendering is unaffected. This gate.go change routes on that new signal instead: dated Task/GTask items now get wi.Start populated (so the Android client's existing, already-correct Start-based day bucketing runs for them) while genuinely undated ones keep today's nil-Start floating treatment exactly as before. Also fixed the same latent bug in wi.DueDate's guard while in the same code path: it only checked !Time.IsZero(), but nativeUndated's Time=now placeholder is non-zero, so an undated task's Android detail popup would have shown a fabricated due date of whatever moment the request happened to run. Now guards on !Undated too. Verified: added TestTimelineItemToWidgetItem_DatedGTaskGetsStart and TestTimelineItemToWidgetItem_UndatedTaskWithPlaceholderTime_NilDueDate, and updated the three existing tests that had encoded the bug's old "Start stays nil for any IsAllDay task" assumption as expected behavior (TestTimelineItemToWidgetItem_Task, _DootTaskGetsDueDate, _AllDayTask_KeepsFloatingBehavior -- the last one needed Undated: true added since it no longer implies undated on its own). Proved the fix by reverting internal/handlers/widget.go to the pre-fix condition against a real backup and confirming exactly the three tests exercising the new behavior fail, then restored from that backup and confirmed byte-identical. go build/vet/test all clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL --- internal/handlers/timeline_logic.go | 8 ++- internal/handlers/widget.go | 36 +++++++++--- internal/handlers/widget_test.go | 110 +++++++++++++++++++++++++++++------- internal/models/timeline.go | 17 ++++++ 4 files changed, 140 insertions(+), 31 deletions(-) diff --git a/internal/handlers/timeline_logic.go b/internal/handlers/timeline_logic.go index b008221..b25be1f 100644 --- a/internal/handlers/timeline_logic.go +++ b/internal/handlers/timeline_logic.go @@ -151,8 +151,13 @@ func BuildTimeline(ctx context.Context, s *store.Store, start, end time.Time) ([ // 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. + // detail sheet. Undated (not just IsAllDay) also has to be set + // here: a gtask WITH a due date is midnight-anchored too (see + // reference_google_tasks_due_date_utc_quirk), which sets IsAllDay + // true regardless -- Undated is what actually tells + // TimelineItemToWidgetItem "no date at all" vs "has a date". IsAllDay: gTask.DueDate == nil, + Undated: gTask.DueDate == nil, } item.ComputeDaySection(now) items = append(items, item) @@ -230,6 +235,7 @@ func BuildTimeline(ctx context.Context, s *store.Store, start, end time.Time) ([ IsCompleted: task.Completed, Source: "doot", IsAllDay: true, + Undated: true, ProjectColor: projectColors[task.ProjectID], } setChainBadge(s, &item, task) diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go index 915909e..1f6911b 100644 --- a/internal/handlers/widget.go +++ b/internal/handlers/widget.go @@ -70,10 +70,24 @@ func TimelineItemToWidgetItem(item models.TimelineItem) models.WidgetItem { // 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) { + // entirely. + // + // Dated Task/GTask items (item.Undated == false) get the same Start + // treatment as Events, for the same reason -- their client-side day + // bucketing (Android's WidgetRoot todayScheduledEvents/tomorrowItems + // split) is keyed entirely off Start, so a nil Start drops them into the + // undated/floating pool regardless of their actual due date. That pool + // has zero day-awareness (see DootWidget.kt's `floating` list), so a + // task due tomorrow rendered as if due today (2026-08-10 report). This + // is deliberately gated on Undated, not IsAllDay -- IsAllDay stays true + // for these tasks (doot-native/Google Tasks due dates are ALWAYS + // midnight-anchored even with a real due date, see + // reference_google_tasks_due_date_utc_quirk), which is why the simpler + // "just check IsAllDay" fix doesn't work here: Card and truly-undated + // Task/GTask items must keep the exact opposite behavior (nil Start), + // and only Undated tells them apart. + isDatedTask := (item.Type == models.TimelineItemTypeTask || item.Type == models.TimelineItemTypeGTask) && !item.Undated + if !item.Time.IsZero() && (!item.IsAllDay || item.Type == models.TimelineItemTypeEvent || isDatedTask) { t := item.Time wi.Start = &t if item.Type == models.TimelineItemTypeEvent { @@ -94,11 +108,15 @@ func TimelineItemToWidgetItem(item models.TimelineItem) models.WidgetItem { } } - // 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() { + // DueDate is a separate signal from Start/IsAllDay -- populated whenever + // a doot task genuinely has a due date (Undated == false), regardless of + // whether Start also got set above, so the Android detail popup can + // display/reschedule it. Guarding on !item.Undated (not just + // !item.Time.IsZero()) matters because nativeUndated tasks use Time=now + // as a layout placeholder, not a zero value -- without this guard, + // an undated task's detail popup would show a fabricated "due" date of + // whatever moment the request happened to run. + if item.Type == models.TimelineItemTypeTask && item.Source == "doot" && !item.Time.IsZero() && !item.Undated { due := item.Time wi.DueDate = &due } diff --git a/internal/handlers/widget_test.go b/internal/handlers/widget_test.go index 749af3a..2bb92bc 100644 --- a/internal/handlers/widget_test.go +++ b/internal/handlers/widget_test.go @@ -123,7 +123,7 @@ func TestTimelineItemToWidgetItem_Task(t *testing.T) { Source: "doot", Type: models.TimelineItemTypeTask, Time: now, - IsAllDay: true, + IsAllDay: true, // dated task: doot due dates are always midnight-anchored URL: "https://example.com/task/abc", } @@ -138,8 +138,13 @@ func TestTimelineItemToWidgetItem_Task(t *testing.T) { if !wi.Completable { t.Error("doot task should be completable") } - if wi.Start != nil { - t.Error("all-day item should have nil Start") + // A dated task (Undated defaults false) must get Start populated even + // though IsAllDay is true -- see the 2026-08-10 fix in + // TimelineItemToWidgetItem's doc comment. Only genuinely undated tasks + // (Undated: true, see TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior) + // keep nil Start. + if wi.Start == nil { + t.Error("dated task should have Start populated, not nil") } } @@ -235,12 +240,13 @@ func TestTimelineItemToWidgetItem_MultiDayAllDayEvent_ForwardsEnd(t *testing.T) } } -// 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. +// TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior proves a +// genuinely undated doot/gtask task (Undated: true, matching how +// timeline_logic.go's nativeUndated/gtask-no-due-date branches actually +// construct it) keeps the existing nil-Start "floating" treatment so the +// hourly-slot packer still places them -- unlike a dated-but-midnight- +// anchored task, which now gets Start populated (see +// TestTimelineItemToWidgetItem_DootTaskGetsDueDate). func TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior(t *testing.T) { item := models.TimelineItem{ ID: "undated-task", @@ -249,12 +255,13 @@ func TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior(t *testing.T) Type: models.TimelineItemTypeTask, Time: time.Now(), IsAllDay: true, + Undated: 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") + t.Error("a genuinely undated task should still have nil Start") } } @@ -300,12 +307,17 @@ func TestTimelineItemToWidgetItem_NotOverdueByDefault(t *testing.T) { // 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. +// client via DueDate, independent of Start, so the Android detail popup can +// display/reschedule it. +// +// Start assertion updated 2026-08-10: this used to assert Start stayed +// nil "so the client's floating-task SlotPacker can position it" -- that +// was the bug. A dated task (Undated: false, the default -- this item +// isn't undated, it has a real due date) dumped into the same undated/ +// floating pool as genuinely dateless tasks, which has zero day-awareness +// on the Android client (DootWidget.kt's `floating` list), so a task due +// tomorrow rendered as if due today. Start must now be populated so the +// client's existing day-bucketing (keyed off Start) actually runs for it. func TestTimelineItemToWidgetItem_DootTaskGetsDueDate(t *testing.T) { due := time.Date(2026, 7, 15, 0, 0, 0, 0, time.Local) item := models.TimelineItem{ @@ -314,7 +326,7 @@ func TestTimelineItemToWidgetItem_DootTaskGetsDueDate(t *testing.T) { Source: "doot", Type: models.TimelineItemTypeTask, Time: due, - IsAllDay: true, + IsAllDay: true, // doot due dates are always midnight-anchored, dated or not } wi := TimelineItemToWidgetItem(item) @@ -325,10 +337,63 @@ func TestTimelineItemToWidgetItem_DootTaskGetsDueDate(t *testing.T) { 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.Fatal("expected Start to be set for a dated task, so Android can day-bucket it correctly") + } + if !wi.Start.Equal(due) { + t.Errorf("Start = %v, want %v", *wi.Start, due) + } +} + +// TestTimelineItemToWidgetItem_DatedGTaskGetsStart proves the same fix for +// Google Tasks, which have the identical always-midnight-anchored due date +// quirk (see reference_google_tasks_due_date_utc_quirk) -- a dated gtask +// (Undated: false) must also get Start populated, not just doot tasks. +func TestTimelineItemToWidgetItem_DatedGTaskGetsStart(t *testing.T) { + due := time.Date(2026, 7, 15, 0, 0, 0, 0, time.Local) + item := models.TimelineItem{ + ID: "gtask-1", + Title: "Renew passport", + Source: "gtasks", + Type: models.TimelineItemTypeGTask, + Time: due, + IsAllDay: true, + } + + wi := TimelineItemToWidgetItem(item) + + if wi.Start == nil { + t.Fatal("expected Start to be set for a dated gtask") + } + if !wi.Start.Equal(due) { + t.Errorf("Start = %v, want %v", *wi.Start, due) + } +} + +// TestTimelineItemToWidgetItem_UndatedTaskWithPlaceholderTime_NilDueDate +// proves the 2026-08-10 companion fix: nativeUndated tasks construct their +// TimelineItem with Time=now (a layout placeholder, not a real due date -- +// see timeline_logic.go), not a zero Time. DueDate must guard on Undated, +// not just !Time.IsZero(), or an undated task's detail popup would show a +// fabricated "due" date of whatever moment the request happened to run. +func TestTimelineItemToWidgetItem_UndatedTaskWithPlaceholderTime_NilDueDate(t *testing.T) { + item := models.TimelineItem{ + ID: "doot-undated-2", + Title: "Someday task", + Source: "doot", + Type: models.TimelineItemTypeTask, + Time: time.Now(), // the real placeholder value nativeUndated actually uses + IsAllDay: true, + Undated: true, + } + + wi := TimelineItemToWidgetItem(item) + + if wi.DueDate != nil { + t.Error("expected DueDate to be nil for an undated task, even though Time is non-zero") + } if wi.Start != nil { - t.Error("Start must remain nil for a doot task -- DueDate is a separate field, not a replacement") + t.Error("expected Start to be nil for an undated task") } } @@ -340,9 +405,12 @@ func TestTimelineItemToWidgetItem_UndatedDootTask_NilDueDate(t *testing.T) { Type: models.TimelineItemTypeTask, Time: time.Now(), IsAllDay: true, + Undated: true, } // Zero Time simulates the "no real due date" case at the field level; - // TimelineItemToWidgetItem's DueDate logic must guard on !Time.IsZero(). + // TimelineItemToWidgetItem's DueDate logic must guard on !Time.IsZero() + // (see TestTimelineItemToWidgetItem_UndatedTaskWithPlaceholderTime_NilDueDate + // for the realistic non-zero-placeholder-Time case). item.Time = time.Time{} wi := TimelineItemToWidgetItem(item) diff --git a/internal/models/timeline.go b/internal/models/timeline.go index 5224b72..b740c42 100644 --- a/internal/models/timeline.go +++ b/internal/models/timeline.go @@ -41,6 +41,23 @@ type TimelineItem struct { DaySection DaySection `json:"day_section"` Source string `json:"source"` // "trello", "plantoeat", "calendar", "gtasks" + // Undated is true only for genuinely dateless Task/GTask items (the + // caller uses Time=now as a layout placeholder for them -- see + // timeline_logic.go's nativeUndated/gtask-no-due-date branches). + // Deliberately NOT the same thing as IsAllDay: doot-native and Google + // Tasks due dates are ALWAYS midnight-anchored even when a task has a + // real due date (see reference_google_tasks_due_date_utc_quirk), so + // IsAllDay ends up true for both "no due date" and "has a due date, but + // it's date-only" -- a distinction the WEB UI doesn't need (it buckets + // by DaySection, computed from Time regardless of IsAllDay, and only + // uses IsAllDay for a same-day visual choice that's fine either way for + // tasks) but the ANDROID WIDGET does: TimelineItemToWidgetItem uses + // Undated, not IsAllDay, to decide whether to populate wi.Start, because + // the widget's undated/floating pool has no per-day awareness at all -- + // conflating "no date" with "dated but time-only-midnight" there means + // a task due tomorrow renders as if due today (2026-08-10 report). + Undated bool `json:"-"` + // Source-specific metadata ListID string `json:"list_id,omitempty"` // For Google Tasks RecurringEventID string `json:"recurring_event_id,omitempty"` // For calendar events -- cgit v1.2.3