summaryrefslogtreecommitdiff
path: root/internal/handlers/timeline.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-13 18:10:10 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-16 02:42:31 +0000
commitd0f207704a406d4fbc65e89ef37f19bacfa0deef (patch)
tree2bafd1ee3e0759dff015df0c9bae184aa2bbc913 /internal/handlers/timeline.go
parent8e56555adb7d3c77f2da62288cbf484f7102a74a (diff)
feat(web): show multi-day calendar events on every day they span
TimelineItemView wraps a TimelineItem with a per-render-day MultiDayVariant (starts/ends/spans/none). HandleTimeline's bucketing now places a multi-day event into both TodayItems and TomorrowItems when it touches both, instead of only the list matching its start day.
Diffstat (limited to 'internal/handlers/timeline.go')
-rw-r--r--internal/handlers/timeline.go84
1 files changed, 75 insertions, 9 deletions
diff --git a/internal/handlers/timeline.go b/internal/handlers/timeline.go
index 2c3c6b6..e8eadde 100644
--- a/internal/handlers/timeline.go
+++ b/internal/handlers/timeline.go
@@ -10,11 +10,60 @@ import (
"task-dashboard/internal/models"
)
+// TimelineItemView wraps a TimelineItem with how it should render for one
+// specific day (Today or Tomorrow). MultiDayVariant is "" for a normal
+// single-day item; "starts"/"ends"/"spans" for a calendar event whose
+// Start and End fall on different calendar days -- see isMultiDayEvent.
+type TimelineItemView struct {
+ models.TimelineItem
+ MultiDayVariant string
+}
+
+// dateOnly strips the time-of-day, keeping the calendar day in t's own location.
+func dateOnly(t time.Time) time.Time {
+ return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
+}
+
+// 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
+// Tasks keep their existing single-day rendering unconditionally.
+func isMultiDayEvent(item models.TimelineItem) bool {
+ if item.Type != models.TimelineItemTypeEvent || item.EndTime == nil {
+ return false
+ }
+ tz := config.GetDisplayTimezone()
+ return !dateOnly(item.EndTime.In(tz)).Equal(dateOnly(item.Time.In(tz)))
+}
+
+// multiDayVariant returns how a multi-day item should render on renderDay:
+// "starts" if renderDay is the item's start day, "ends" if renderDay is its
+// end day, "spans" if renderDay falls strictly between them, or "" if
+// renderDay doesn't fall within the item's span at all. Caller must have
+// already confirmed isMultiDayEvent(item) so item.EndTime is non-nil.
+func multiDayVariant(item models.TimelineItem, renderDay time.Time) string {
+ tz := config.GetDisplayTimezone()
+ startDay := dateOnly(item.Time.In(tz))
+ endDay := dateOnly(item.EndTime.In(tz))
+ day := dateOnly(renderDay.In(tz))
+
+ switch {
+ case day.Equal(startDay):
+ return "starts"
+ case day.Equal(endDay):
+ return "ends"
+ case day.After(startDay) && day.Before(endDay):
+ return "spans"
+ default:
+ return ""
+ }
+}
+
// TimelineData holds grouped timeline items for the template
type TimelineData struct {
- TodayItems []models.TimelineItem
- TomorrowItems []models.TimelineItem
- LaterItems []models.TimelineItem
+ TodayItems []TimelineItemView
+ TomorrowItems []TimelineItemView
+ LaterItems []TimelineItemView
Start time.Time
Days int
@@ -100,13 +149,29 @@ func (h *Handler) HandleTimeline(w http.ResponseWriter, r *http.Request) {
NowMinute: now.Minute(),
}
for _, item := range items {
+ if isMultiDayEvent(item) {
+ addedToday := false
+ addedTomorrow := false
+ if v := multiDayVariant(item, today); v != "" {
+ data.TodayItems = append(data.TodayItems, TimelineItemView{item, v})
+ addedToday = true
+ }
+ if v := multiDayVariant(item, tomorrow); v != "" {
+ data.TomorrowItems = append(data.TomorrowItems, TimelineItemView{item, v})
+ addedTomorrow = true
+ }
+ if !addedToday && !addedTomorrow {
+ data.LaterItems = append(data.LaterItems, TimelineItemView{item, ""})
+ }
+ continue
+ }
switch item.DaySection {
case models.DaySectionToday:
- data.TodayItems = append(data.TodayItems, item)
+ data.TodayItems = append(data.TodayItems, TimelineItemView{item, ""})
case models.DaySectionTomorrow:
- data.TomorrowItems = append(data.TomorrowItems, item)
+ data.TomorrowItems = append(data.TomorrowItems, TimelineItemView{item, ""})
case models.DaySectionLater:
- data.LaterItems = append(data.LaterItems, item)
+ data.LaterItems = append(data.LaterItems, TimelineItemView{item, ""})
}
}
@@ -128,14 +193,15 @@ func (h *Handler) HandleTimeline(w http.ResponseWriter, r *http.Request) {
// calcCalendarBounds returns start/end hours for calendar view based on timed events.
// If currentHour >= 0, it's included in the range (for "now" line visibility).
// Returns hours clamped to 0-23 with 1-hour buffer before/after events.
-func calcCalendarBounds(items []models.TimelineItem, currentHour int) (startHour, endHour int) {
+func calcCalendarBounds(items []TimelineItemView, currentHour int) (startHour, endHour int) {
minHour := 23
maxHour := 0
hasTimedEvents := false
for _, item := range items {
- // Skip all-day/overdue items (midnight with no real time)
- if item.IsAllDay || item.IsOverdue {
+ // Skip all-day/overdue/multi-day items (rendered as all-day-style
+ // rows, not grid slots -- see TimelineItemView.MultiDayVariant)
+ if item.IsAllDay || item.IsOverdue || item.MultiDayVariant != "" {
continue
}
h := item.Time.Hour()