diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-13 18:10:10 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-16 02:42:31 +0000 |
| commit | d0f207704a406d4fbc65e89ef37f19bacfa0deef (patch) | |
| tree | 2bafd1ee3e0759dff015df0c9bae184aa2bbc913 | |
| parent | 8e56555adb7d3c77f2da62288cbf484f7102a74a (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.
| -rw-r--r-- | internal/handlers/handlers.go | 20 | ||||
| -rw-r--r-- | internal/handlers/timeline.go | 84 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic_test.go | 41 | ||||
| -rw-r--r-- | internal/handlers/timeline_multiday_test.go | 83 |
4 files changed, 203 insertions, 25 deletions
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 408006d..e427e40 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -46,6 +46,26 @@ func New(s *store.Store, trello api.TrelloAPI, planToEat api.PlanToEatAPI, googl // Template functions funcMap := template.FuncMap{ "subtract": func(a, b int) int { return a - b }, + // multiDayLabel returns the "(starts/ends HH:MM)" suffix for a + // multi-day calendar event's row on the day it's rendered, or "" + // for a normal item or a "spans" (pass-through) day, which shows + // as a plain all-day row with no time label. + "multiDayLabel": func(item TimelineItemView) string { + switch item.MultiDayVariant { + case "starts": + if item.Time.Hour() == 0 && item.Time.Minute() == 0 { + return "" + } + return " (starts " + item.Time.Format("3:04 PM") + ")" + case "ends": + if item.EndTime == nil || (item.EndTime.Hour() == 0 && item.EndTime.Minute() == 0) { + return "" + } + return " (ends " + item.EndTime.Format("3:04 PM") + ")" + default: + return "" + } + }, } // Parse templates including partials 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() diff --git a/internal/handlers/timeline_logic_test.go b/internal/handlers/timeline_logic_test.go index 01ad814..89581a3 100644 --- a/internal/handlers/timeline_logic_test.go +++ b/internal/handlers/timeline_logic_test.go @@ -172,22 +172,22 @@ func TestBuildTimeline(t *testing.T) { func TestCalcCalendarBounds(t *testing.T) { tests := []struct { name string - items []models.TimelineItem + items []TimelineItemView currentHour int wantStart int wantEnd int }{ { name: "no timed events returns default", - items: []models.TimelineItem{}, + items: []TimelineItemView{}, currentHour: -1, wantStart: 8, wantEnd: 18, }, { name: "single event at 10am", - items: []models.TimelineItem{ - {Time: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC)}, + items: []TimelineItemView{ + {TimelineItem: models.TimelineItem{Time: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC)}}, }, currentHour: -1, wantStart: 9, // 1 hour buffer before @@ -195,8 +195,8 @@ func TestCalcCalendarBounds(t *testing.T) { }, { name: "includes current hour", - items: []models.TimelineItem{ - {Time: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC)}, + items: []TimelineItemView{ + {TimelineItem: models.TimelineItem{Time: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC)}}, }, currentHour: 8, wantStart: 7, // 1 hour before 8am @@ -204,11 +204,11 @@ func TestCalcCalendarBounds(t *testing.T) { }, { name: "event with end time extends range", - items: []models.TimelineItem{ - { + items: []TimelineItemView{ + {TimelineItem: models.TimelineItem{ Time: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), EndTime: timePtr(time.Date(2023, 1, 1, 14, 0, 0, 0, time.UTC)), - }, + }}, }, currentHour: -1, wantStart: 9, // 1 hour before 10am @@ -216,8 +216,8 @@ func TestCalcCalendarBounds(t *testing.T) { }, { name: "all-day events are skipped", - items: []models.TimelineItem{ - {Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), IsAllDay: true}, + items: []TimelineItemView{ + {TimelineItem: models.TimelineItem{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), IsAllDay: true}}, }, currentHour: -1, wantStart: 8, @@ -225,8 +225,8 @@ func TestCalcCalendarBounds(t *testing.T) { }, { name: "overdue events are skipped", - items: []models.TimelineItem{ - {Time: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), IsOverdue: true}, + items: []TimelineItemView{ + {TimelineItem: models.TimelineItem{Time: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), IsOverdue: true}}, }, currentHour: -1, wantStart: 8, @@ -234,14 +234,23 @@ func TestCalcCalendarBounds(t *testing.T) { }, { name: "clamps to 0-23 range", - items: []models.TimelineItem{ - {Time: time.Date(2023, 1, 1, 0, 30, 0, 0, time.UTC)}, - {Time: time.Date(2023, 1, 1, 23, 0, 0, 0, time.UTC)}, + items: []TimelineItemView{ + {TimelineItem: models.TimelineItem{Time: time.Date(2023, 1, 1, 0, 30, 0, 0, time.UTC)}}, + {TimelineItem: models.TimelineItem{Time: time.Date(2023, 1, 1, 23, 0, 0, 0, time.UTC)}}, }, currentHour: -1, wantStart: 0, // Can't go below 0 wantEnd: 23, // Can't go above 23 }, + { + name: "multi-day rows are skipped like all-day events", + items: []TimelineItemView{ + {TimelineItem: models.TimelineItem{Time: time.Date(2023, 1, 1, 17, 0, 0, 0, time.UTC)}, MultiDayVariant: "starts"}, + }, + currentHour: -1, + wantStart: 8, + wantEnd: 18, + }, } for _, tc := range tests { diff --git a/internal/handlers/timeline_multiday_test.go b/internal/handlers/timeline_multiday_test.go new file mode 100644 index 0000000..3dc2005 --- /dev/null +++ b/internal/handlers/timeline_multiday_test.go @@ -0,0 +1,83 @@ +package handlers + +import ( + "testing" + "time" + + "task-dashboard/internal/models" +) + +func multiDayEvent(startISO, endISO string) models.TimelineItem { + start, _ := time.Parse(time.RFC3339, startISO) + end, _ := time.Parse(time.RFC3339, endISO) + return models.TimelineItem{ + ID: "evt-1", + Title: "Offsite", + Type: models.TimelineItemTypeEvent, + Time: start, + EndTime: &end, + } +} + +func TestIsMultiDayEvent_SameDayTimedEvent_False(t *testing.T) { + item := multiDayEvent("2026-07-13T10:00:00Z", "2026-07-13T11:00:00Z") + if isMultiDayEvent(item) { + t.Error("expected same-day timed event to not be multi-day") + } +} + +func TestIsMultiDayEvent_SpansMultipleDays_True(t *testing.T) { + item := multiDayEvent("2026-07-13T17:35:00Z", "2026-07-30T22:30:00Z") + if !isMultiDayEvent(item) { + t.Error("expected event spanning multiple days to be multi-day") + } +} + +func TestIsMultiDayEvent_NonEventType_AlwaysFalse(t *testing.T) { + start, _ := time.Parse(time.RFC3339, "2026-07-13T10:00:00Z") + end, _ := time.Parse(time.RFC3339, "2026-07-30T22:30:00Z") + item := models.TimelineItem{ID: "t1", Type: models.TimelineItemTypeTask, Time: start, EndTime: &end} + if isMultiDayEvent(item) { + t.Error("expected non-event type to never be considered multi-day, regardless of span") + } +} + +func TestIsMultiDayEvent_NoEndTime_False(t *testing.T) { + start, _ := time.Parse(time.RFC3339, "2026-07-13T10:00:00Z") + item := models.TimelineItem{ID: "e1", Type: models.TimelineItemTypeEvent, Time: start} + if isMultiDayEvent(item) { + t.Error("expected event with nil EndTime to not be multi-day") + } +} + +func TestMultiDayVariant_StartDay_ReturnsStarts(t *testing.T) { + item := multiDayEvent("2026-07-13T17:35:00Z", "2026-07-30T22:30:00Z") + renderDay, _ := time.Parse(time.RFC3339, "2026-07-13T00:00:00Z") + if got := multiDayVariant(item, renderDay); got != "starts" { + t.Errorf("variant = %q, want %q", got, "starts") + } +} + +func TestMultiDayVariant_EndDay_ReturnsEnds(t *testing.T) { + item := multiDayEvent("2026-07-13T17:35:00Z", "2026-07-30T22:30:00Z") + renderDay, _ := time.Parse(time.RFC3339, "2026-07-30T00:00:00Z") + if got := multiDayVariant(item, renderDay); got != "ends" { + t.Errorf("variant = %q, want %q", got, "ends") + } +} + +func TestMultiDayVariant_MiddleDay_ReturnsSpans(t *testing.T) { + item := multiDayEvent("2026-07-13T17:35:00Z", "2026-07-30T22:30:00Z") + renderDay, _ := time.Parse(time.RFC3339, "2026-07-20T00:00:00Z") + if got := multiDayVariant(item, renderDay); got != "spans" { + t.Errorf("variant = %q, want %q", got, "spans") + } +} + +func TestMultiDayVariant_DayOutsideSpan_ReturnsEmpty(t *testing.T) { + item := multiDayEvent("2026-07-13T17:35:00Z", "2026-07-30T22:30:00Z") + renderDay, _ := time.Parse(time.RFC3339, "2026-08-01T00:00:00Z") + if got := multiDayVariant(item, renderDay); got != "" { + t.Errorf("variant = %q, want empty", got) + } +} |
