summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-12 06:21:40 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-12 06:21:40 +0000
commit84252756c687044d73a0ea5cebf8088c7c9ed3e8 (patch)
tree0f76a627ee9fb4037d83a57270a3426694b69c2f
parenta43cc7b8f007a86742756bd4b67f9ba1c204bbd0 (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.kt60
-rw-r--r--internal/handlers/widget.go25
-rw-r--r--internal/handlers/widget_test.go61
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"}`