summaryrefslogtreecommitdiff
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
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.
-rw-r--r--internal/handlers/handlers.go20
-rw-r--r--internal/handlers/timeline.go84
-rw-r--r--internal/handlers/timeline_logic_test.go41
-rw-r--r--internal/handlers/timeline_multiday_test.go83
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)
+ }
+}