summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/handlers/timeline_logic.go8
-rw-r--r--internal/handlers/widget.go36
-rw-r--r--internal/handlers/widget_test.go110
-rw-r--r--internal/models/timeline.go17
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