diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-13 18:17:25 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-16 02:42:31 +0000 |
| commit | 5264453baab2b5810a98a49040cf18d771e13f23 (patch) | |
| tree | d12d98da2f9871c28a05cad49b9854afcf9557f8 /internal | |
| parent | 08ded9fc1114c86b7a748c589aa2b6e8d8202c1c (diff) | |
fix(web): account for Google Calendar's exclusive all-day end-date
A single-day all-day event's End.Date is one calendar day past its own
day (Google Calendar convention, already documented by
TestParseEventTime_AllDayEvent). isMultiDayEvent/multiDayVariant didn't
adjust for this, so every all-day event -- including single-day ones --
was misclassified as multi-day and duplicated across Today and
Tomorrow.
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/handlers/timeline.go | 21 | ||||
| -rw-r--r-- | internal/handlers/timeline_multiday_test.go | 46 |
2 files changed, 65 insertions, 2 deletions
diff --git a/internal/handlers/timeline.go b/internal/handlers/timeline.go index e8eadde..9656d4c 100644 --- a/internal/handlers/timeline.go +++ b/internal/handlers/timeline.go @@ -24,6 +24,21 @@ func dateOnly(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) } +// effectiveEndDay returns the last calendar day the item's occurrence +// actually covers. Google Calendar's all-day End.Date is EXCLUSIVE -- a +// single-day all-day event on July 13 has End = July 14 at midnight (see +// TestParseEventTime_AllDayEvent in internal/api/google_calendar_test.go) +// -- so for an all-day item this subtracts one day to get the real last +// day. Timed events' End is already the real end instant, so no +// adjustment is needed there. +func effectiveEndDay(item models.TimelineItem, tz *time.Location) time.Time { + endDay := dateOnly(item.EndTime.In(tz)) + if item.IsAllDay { + return endDay.AddDate(0, 0, -1) + } + return endDay +} + // isMultiDayEvent reports whether item is a calendar event whose Start and // End fall on different calendar days (in the display timezone). Only // events are ever considered multi-day -- tasks, cards, meals, and Google @@ -33,7 +48,9 @@ func isMultiDayEvent(item models.TimelineItem) bool { return false } tz := config.GetDisplayTimezone() - return !dateOnly(item.EndTime.In(tz)).Equal(dateOnly(item.Time.In(tz))) + startDay := dateOnly(item.Time.In(tz)) + endDay := effectiveEndDay(item, tz) + return endDay.After(startDay) } // multiDayVariant returns how a multi-day item should render on renderDay: @@ -44,7 +61,7 @@ func isMultiDayEvent(item models.TimelineItem) bool { func multiDayVariant(item models.TimelineItem, renderDay time.Time) string { tz := config.GetDisplayTimezone() startDay := dateOnly(item.Time.In(tz)) - endDay := dateOnly(item.EndTime.In(tz)) + endDay := effectiveEndDay(item, tz) day := dateOnly(renderDay.In(tz)) switch { diff --git a/internal/handlers/timeline_multiday_test.go b/internal/handlers/timeline_multiday_test.go index 3dc2005..7af2cbe 100644 --- a/internal/handlers/timeline_multiday_test.go +++ b/internal/handlers/timeline_multiday_test.go @@ -81,3 +81,49 @@ func TestMultiDayVariant_DayOutsideSpan_ReturnsEmpty(t *testing.T) { t.Errorf("variant = %q, want empty", got) } } + +func TestIsMultiDayEvent_SingleDayAllDayEvent_False(t *testing.T) { + // Exclusive end-date convention: a single-day all-day event on July 13 + // has Start=Jul13 00:00, End=Jul14 00:00 (one day past its own day). + start, _ := time.Parse(time.RFC3339, "2026-07-13T00:00:00Z") + end, _ := time.Parse(time.RFC3339, "2026-07-14T00:00:00Z") + item := models.TimelineItem{ + ID: "allday-1", Type: models.TimelineItemTypeEvent, + Time: start, EndTime: &end, IsAllDay: true, + } + if isMultiDayEvent(item) { + t.Error("expected a single-day all-day event (exclusive end-date) to not be multi-day") + } +} + +func TestIsMultiDayEvent_MultiDayAllDayEvent_True(t *testing.T) { + // A 3-day all-day event (Jul 13, 14, 15) has End=Jul16 00:00 (exclusive). + start, _ := time.Parse(time.RFC3339, "2026-07-13T00:00:00Z") + end, _ := time.Parse(time.RFC3339, "2026-07-16T00:00:00Z") + item := models.TimelineItem{ + ID: "allday-2", Type: models.TimelineItemTypeEvent, + Time: start, EndTime: &end, IsAllDay: true, + } + if !isMultiDayEvent(item) { + t.Error("expected a 3-day all-day event to be multi-day") + } +} + +func TestMultiDayVariant_MultiDayAllDayEvent_LastRealDayIsEnds(t *testing.T) { + // The 3-day all-day event above (Jul 13-15 inclusive, End=Jul16 exclusive) + // must report "ends" on Jul 15 (its real last day), not Jul 16. + start, _ := time.Parse(time.RFC3339, "2026-07-13T00:00:00Z") + end, _ := time.Parse(time.RFC3339, "2026-07-16T00:00:00Z") + item := models.TimelineItem{ + ID: "allday-2", Type: models.TimelineItemTypeEvent, + Time: start, EndTime: &end, IsAllDay: true, + } + renderDay, _ := time.Parse(time.RFC3339, "2026-07-15T00:00:00Z") + if got := multiDayVariant(item, renderDay); got != "ends" { + t.Errorf("variant on last real day = %q, want %q", got, "ends") + } + exclusiveEndDay, _ := time.Parse(time.RFC3339, "2026-07-16T00:00:00Z") + if got := multiDayVariant(item, exclusiveEndDay); got != "" { + t.Errorf("variant on the exclusive (non-real) end day = %q, want empty", got) + } +} |
