From d0f207704a406d4fbc65e89ef37f19bacfa0deef Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 13 Jul 2026 18:10:10 +0000 Subject: 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. --- internal/handlers/timeline.go | 84 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 9 deletions(-) (limited to 'internal/handlers/timeline.go') 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() -- cgit v1.2.3