diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-12 06:21:40 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-12 06:21:40 +0000 |
| commit | 84252756c687044d73a0ea5cebf8088c7c9ed3e8 (patch) | |
| tree | 0f76a627ee9fb4037d83a57270a3426694b69c2f | |
| parent | a43cc7b8f007a86742756bd4b67f9ba1c204bbd0 (diff) | |
fix(widget): pin all-day calendar events to the top instead of losing them
WidgetItem.isAllDay was carried by the client's data model but nothing
ever read it. All-day events had no Start at all, so they fell into the
same floating-task queue as ordinary untimed tasks; if enough tasks were
ahead of one in the queue, SlotPacker could assign it an hour slot past
the visible grid range entirely -- not merely unpinned, actually
invisible.
Server: TimelineItemToWidgetItem now populates Start for all-day
CALENDAR EVENTS specifically (their real event date), while leaving
undated doot/gtask tasks -- also flagged IsAllDay as a "no specific
time" fallback, a different concept -- on the existing nil-Start
floating behavior.
Client: all-day events are filtered out of the hourly grid/floating-task
pipeline entirely, bucketed by day using the new Start date, and
rendered in a new pinned AllDayRow section right after the TODAY/TOMORROW
headers.
| -rw-r--r-- | android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt | 60 | ||||
| -rw-r--r-- | internal/handlers/widget.go | 25 | ||||
| -rw-r--r-- | internal/handlers/widget_test.go | 61 |
3 files changed, 132 insertions, 14 deletions
diff --git a/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt index d8f1140..7674ddb 100644 --- a/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt +++ b/android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt @@ -63,17 +63,39 @@ class DootWidget : GlanceAppWidget() { fun WidgetRoot(items: List<WidgetItem>, now: Instant) { val zone = ZoneId.systemDefault() val nowZoned: ZonedDateTime = now.atZone(zone) - val allScheduled = items.filter { it.start != null }.sortedBy { Instant.parse(it.start!!) } + val todayStart = nowZoned.toLocalDate().atStartOfDay(zone).toInstant() + val tomorrowStart = nowZoned.toLocalDate().plusDays(1).atStartOfDay(zone).toInstant() + val tomorrowEnd = tomorrowStart.plus(1, ChronoUnit.DAYS) + + // All-day CALENDAR EVENTS (isAllDay && type == "event" -- see + // TimelineItemToWidgetItem's doc comment for why undated doot/gtask + // tasks, which are also flagged isAllDay, are deliberately excluded + // here) are pinned to the top of their day's section and never compete + // for hourly grid slots or floating-task packing. Previously they had + // no Start at all and fell into the same floating-task queue as + // ordinary untimed tasks, where enough tasks ahead of them in the queue + // could push their assigned slot past the visible grid range entirely + // -- not merely unpinned, actually invisible. + val allDayEvents = items.filter { it.isAllDay && it.type == "event" } + val rest = items.filter { !(it.isAllDay && it.type == "event") } + val todayAllDay = allDayEvents.filter { item -> + val s = item.start?.let { runCatching { Instant.parse(it) }.getOrNull() } + s == null || (s >= todayStart && s < tomorrowStart) + } + val tomorrowAllDay = allDayEvents.filter { item -> + val s = item.start?.let { runCatching { Instant.parse(it) }.getOrNull() } + s != null && s >= tomorrowStart && s < tomorrowEnd + } + + val allScheduled = rest.filter { it.start != null }.sortedBy { Instant.parse(it.start!!) } // Past tasks float at now (before untimed tasks); past events stay in the grid at 50% alpha val pastTasks = allScheduled.filter { it.type == "task" && Instant.parse(it.start!!) < now } val scheduledEvents = allScheduled.filter { it.type != "task" || Instant.parse(it.start!!) >= now } - val floating = items.filter { it.start == null } + val floating = rest.filter { it.start == null } val fragments = SlotPacker.pack(pastTasks + floating, scheduledEvents, now) val gridStart = calcGridStart(scheduledEvents, nowZoned.hour) val gridEnd = calcGridEnd(scheduledEvents, nowZoned.hour) - val tomorrowStart = nowZoned.toLocalDate().plusDays(1).atStartOfDay(zone).toInstant() - val tomorrowEnd = tomorrowStart.plus(1, ChronoUnit.DAYS) val tomorrowItems = scheduledEvents .filter { Instant.parse(it.start!!) >= tomorrowStart && Instant.parse(it.start!!) < tomorrowEnd } val tomorrowFrags = fragments @@ -96,17 +118,39 @@ fun WidgetRoot(items: List<WidgetItem>, now: Instant) { ) } + todayAllDay.forEach { AllDayRow(it) } + for (hour in gridStart..gridEnd) { HourRow(hour, nowZoned, scheduledEvents, fragments, zone) } - if (tomorrowItems.isNotEmpty() || tomorrowFrags.any { it.slots.isNotEmpty() }) { - TomorrowSection(tomorrowItems, tomorrowFrags, zone) + if (tomorrowItems.isNotEmpty() || tomorrowFrags.any { it.slots.isNotEmpty() } || tomorrowAllDay.isNotEmpty()) { + TomorrowSection(tomorrowItems, tomorrowFrags, tomorrowAllDay, zone) } } } @Composable +fun AllDayRow(event: WidgetItem) { + val color = sourceColor(event.source) + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(vertical = 3.dp) + .clickable(actionStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse(event.url.ifEmpty { "https://calendar.google.com" })))), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = GlanceModifier.width(3.dp).height(16.dp).background(color)) {} + Text( + text = event.title, + style = TextStyle(color = ColorProvider(Color.White.copy(alpha = 0.9f)), fontSize = 13.sp, fontWeight = FontWeight.Medium), + modifier = GlanceModifier.padding(start = 8.dp), + maxLines = 1 + ) + } +} + +@Composable fun HourRow( hour: Int, nowZoned: ZonedDateTime, @@ -284,7 +328,7 @@ fun TaskRow(task: WidgetItem) { } @Composable -fun TomorrowSection(items: List<WidgetItem>, fragments: List<TaskFragment>, zone: ZoneId) { +fun TomorrowSection(items: List<WidgetItem>, fragments: List<TaskFragment>, allDayEvents: List<WidgetItem>, zone: ZoneId) { Box(modifier = GlanceModifier.fillMaxWidth().height(1.dp).padding(vertical = 4.dp).background(Color(0x1AFFFFFF))) {} Row(modifier = GlanceModifier.fillMaxWidth().padding(top = 6.dp, bottom = 2.dp)) { @@ -298,6 +342,8 @@ fun TomorrowSection(items: List<WidgetItem>, fragments: List<TaskFragment>, zone ) } + allDayEvents.forEach { AllDayRow(it) } + items.forEach { item -> val isPast = false if (item.type == "event") { diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go index 6c3b455..dc10db6 100644 --- a/internal/handlers/widget.go +++ b/internal/handlers/widget.go @@ -52,15 +52,26 @@ 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 + } } } diff --git a/internal/handlers/widget_test.go b/internal/handlers/widget_test.go index 850dc07..6d741d2 100644 --- a/internal/handlers/widget_test.go +++ b/internal/handlers/widget_test.go @@ -109,6 +109,67 @@ 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") + } +} + func TestHandleWidgetComplete_NonCompletable(t *testing.T) { h := &Handler{} body := `{"id":"x","source":"calendar"}` |
