# Multi-Day Calendar Events Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** A calendar event that spans more than one calendar day (its Start and End fall on different days) currently either shows on its start day only (web) or vanishes entirely once it's no longer "today" (widget). Fix both so the event shows on every day it touches, as an all-day-style row labeled "starts HH:MM" / "ends HH:MM" / plain, per the day being rendered. **Architecture:** The server's `TimelineItem`/`WidgetItem` already carry both `Time` (start) and `EndTime`/`End` for calendar events — the fix is entirely about *how each renderer decides which day(s) an event belongs to and what label to show*, computed independently in Go (web) and Kotlin (widget) from the same Start/End data, mirroring how each already does its own today/tomorrow bucketing today. One exception: the widget's JSON conversion currently drops `End` for events flagged `is_all_day`, so a genuine multi-day *all-day* Google event never reaches the client with enough data to detect its span — that's Task 1. **Tech Stack:** Go (`html/template`), Kotlin (Jetpack Glance). ## Global Constraints - Multi-day handling applies **only** to calendar events (`Type`/`type == "event"`) — tasks, cards, meals, and Google Tasks never get this treatment, even if a bug elsewhere gave one a multi-day span. - Exactly three display cases, matching the exact rules given for this feature: 1. Event starts during the rendered day, ends on a later day → all-day-style row, labeled "starts HH:MM" (omit the label if the start time is exactly midnight — nothing meaningful to show). 2. Event starts before the rendered day and ends after it (rendered day is strictly in the middle) → plain all-day-style row, no label. 3. Event starts before the rendered day and ends on it → all-day-style row, labeled "ends HH:MM" (omit if end time is exactly midnight). - Single-day events and all non-event types are completely unaffected — their existing rendering (grid slot, floating task, etc.) does not change. - No new third-party dependencies. - Both the web timeline (Today + Tomorrow sections only — the collapsed "Later" section keeps its existing single-bucket behavior, out of scope) and the Android widget (Today + Tomorrow, its only two rendered days) need the fix. --- ### Task 1: Server — forward `End` for multi-day all-day calendar events **Files:** - Modify: `internal/handlers/widget.go:57-78` (`TimelineItemToWidgetItem`) - Test: `internal/handlers/widget_test.go` **Interfaces:** - Consumes: nothing new. - Produces: no signature changes — `TimelineItemToWidgetItem(item models.TimelineItem) models.WidgetItem` now also sets `wi.End` for a calendar event with `IsAllDay == true` when `item.EndTime != nil` (previously always left nil in that case). **Context:** `models.WidgetItem.End` already exists (`internal/models/widget.go:14`) and reaches the Android client already — this task only changes when it's populated. A genuine Google Calendar all-day event that spans multiple days (e.g. a 3-day conference, no specific time) is marked `IsAllDay: true`. Today's conversion logic only forwards `End` `if !item.IsAllDay`, so the widget never receives this event's end date at all and has no way to detect its span. Timed multi-day events (like a 17-day event with real start/end times) are unaffected by this task — they already get `End` forwarded today, since `IsAllDay` is false for them. - [ ] **Step 1: Write the failing test** Add to `internal/handlers/widget_test.go` (place after `TestTimelineItemToWidgetItem_AllDayEvent`): ```go // TestTimelineItemToWidgetItem_MultiDayAllDayEvent_ForwardsEnd proves that a // genuine all-day CALENDAR EVENT with a real EndTime (e.g. a 3-day // conference with no specific time) still gets End forwarded to the widget // client, even though IsAllDay is true -- needed so the client can detect // the multi-day span and render "starts"/"ends" labels per day. func TestTimelineItemToWidgetItem_MultiDayAllDayEvent_ForwardsEnd(t *testing.T) { start := time.Date(2026, 7, 12, 0, 0, 0, 0, time.Local) end := time.Date(2026, 7, 15, 0, 0, 0, 0, time.Local) item := models.TimelineItem{ ID: "cal-conference", Title: "Offsite", Source: "calendar", Type: models.TimelineItemTypeEvent, Time: start, EndTime: &end, IsAllDay: true, } wi := TimelineItemToWidgetItem(item) if wi.End == nil { t.Fatal("expected End to be forwarded for a multi-day all-day event") } if !wi.End.Equal(end) { t.Errorf("End = %v, want %v", *wi.End, end) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd /workspace/doot && go test ./internal/handlers/... -run TestTimelineItemToWidgetItem_MultiDayAllDayEvent_ForwardsEnd -v` Expected: FAIL — `wi.End` is nil (current code only sets `End` when `!item.IsAllDay`). - [ ] **Step 3: Fix `TimelineItemToWidgetItem`** In `internal/handlers/widget.go`, replace: ```go if !item.Time.IsZero() && (!item.IsAllDay || item.Type == models.TimelineItemTypeEvent) { t := item.Time wi.Start = &t if !item.IsAllDay { if item.EndTime != nil { wi.End = item.EndTime } else { end := item.Time.Add(time.Hour) wi.End = &end } } } ``` with: ```go if !item.Time.IsZero() && (!item.IsAllDay || item.Type == models.TimelineItemTypeEvent) { t := item.Time wi.Start = &t if item.Type == models.TimelineItemTypeEvent { // Events always forward End when known, even when IsAllDay -- // a multi-day all-day event (e.g. a 3-day conference) needs its // End date reaching the client so it can detect the span and // render "starts"/"ends"/spans-through labels per rendered day. if item.EndTime != nil { wi.End = item.EndTime } } else if !item.IsAllDay { if item.EndTime != nil { wi.End = item.EndTime } else { end := item.Time.Add(time.Hour) wi.End = &end } } } ``` - [ ] **Step 4: Update the stale comment on the existing AllDayEvent test** In `internal/handlers/widget_test.go`, the comment above `TestTimelineItemToWidgetItem_AllDayEvent` currently ends with: ```go // grid range instead. End stays nil since there's no meaningful end time to // show for an all-day item. func TestTimelineItemToWidgetItem_AllDayEvent(t *testing.T) { ``` Replace with: ```go // grid range instead. This fixture's End stays nil only because it has no // EndTime set -- see TestTimelineItemToWidgetItem_MultiDayAllDayEvent_ForwardsEnd // for the case where a multi-day all-day event has a real EndTime. func TestTimelineItemToWidgetItem_AllDayEvent(t *testing.T) { ``` - [ ] **Step 5: Run test to verify it passes** Run: `cd /workspace/doot && go test ./internal/handlers/... -run TestTimelineItemToWidgetItem -v` Expected: PASS for all `TestTimelineItemToWidgetItem_*` tests, including the new one. - [ ] **Step 6: Run the full handlers package test suite** Run: `cd /workspace/doot && go test ./internal/handlers/...` Expected: `ok` (pre-existing unrelated failures `TestHandleAgentTaskWriteOperations` and `TestHandleAgentCreateOperations` are known-broken before this change — confirmed via `git stash` comparison earlier this session — and are not caused by this task; do not attempt to fix them here). - [ ] **Step 7: Commit** ```bash cd /workspace/doot git add internal/handlers/widget.go internal/handlers/widget_test.go git commit -m "fix(widget): forward End for all-day multi-day calendar events A genuine all-day Google Calendar event spanning multiple days (e.g. a 3-day conference) never got its EndTime forwarded to the widget client, since the conversion only set End when !IsAllDay. The client has no way to detect a multi-day span without both Start and End." ``` --- ### Task 2: Server — multi-day day-bucketing for the web timeline **Files:** - Modify: `internal/handlers/timeline.go` - Modify: `internal/handlers/handlers.go:47-49` (funcMap) - Modify: `internal/handlers/timeline_logic_test.go` (existing `TestCalcCalendarBounds` fixture type change) - Test: `internal/handlers/timeline_multiday_test.go` (new) **Interfaces:** - Consumes: `models.TimelineItem` (unchanged), `config.GetDisplayTimezone()` (already used elsewhere in this package). - Produces (used by Task 3's template): - `type TimelineItemView struct { models.TimelineItem; MultiDayVariant string }` — `MultiDayVariant` is `""`, `"starts"`, `"ends"`, or `"spans"`. - `TimelineData.TodayItems`, `.TomorrowItems`, `.LaterItems` change type from `[]models.TimelineItem` to `[]TimelineItemView`. - `multiDayLabel` template function (registered in `handlers.go`'s `funcMap`), callable from templates as `{{multiDayLabel .}}`, taking a `TimelineItemView` and returning a display suffix string (e.g. `" (starts 3:04 PM)"`, `""`). **Context:** `HandleTimeline` (`internal/handlers/timeline.go:41-126`) currently buckets each `TimelineItem` into exactly one of `TodayItems`/`TomorrowItems`/`LaterItems` based on its single precomputed `DaySection` (which reflects only the item's *start* day). A multi-day event's `DaySection` only ever matches one of those three, so it's rendered on one day and never appears on the others it spans. This task adds a per-render-day computation so a multi-day event can appear in more than one of these lists, each time annotated with which case applies. - [ ] **Step 1: Write the failing tests for the pure helper functions** Create `internal/handlers/timeline_multiday_test.go`: ```go 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) } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cd /workspace/doot && go test ./internal/handlers/... -run "TestIsMultiDayEvent|TestMultiDayVariant" -v` Expected: FAIL to compile — `isMultiDayEvent`/`multiDayVariant` don't exist yet. - [ ] **Step 3: Implement the helpers and `TimelineItemView` in `timeline.go`** In `internal/handlers/timeline.go`, add near the top (after the `import` block, before `TimelineData`): ```go // 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 "" } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `cd /workspace/doot && go test ./internal/handlers/... -run "TestIsMultiDayEvent|TestMultiDayVariant" -v` Expected: PASS (8 tests). - [ ] **Step 5: Wire the bucketing loop and update `TimelineData`'s field types** In `internal/handlers/timeline.go`, replace the `TimelineData` struct's three fields: ```go type TimelineData struct { TodayItems []models.TimelineItem TomorrowItems []models.TimelineItem LaterItems []models.TimelineItem ``` with: ```go type TimelineData struct { TodayItems []TimelineItemView TomorrowItems []TimelineItemView LaterItems []TimelineItemView ``` Then replace the bucketing loop: ```go for _, item := range items { switch item.DaySection { case models.DaySectionToday: data.TodayItems = append(data.TodayItems, item) case models.DaySectionTomorrow: data.TomorrowItems = append(data.TomorrowItems, item) case models.DaySectionLater: data.LaterItems = append(data.LaterItems, item) } } ``` with: ```go 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, TimelineItemView{item, ""}) case models.DaySectionTomorrow: data.TomorrowItems = append(data.TomorrowItems, TimelineItemView{item, ""}) case models.DaySectionLater: data.LaterItems = append(data.LaterItems, TimelineItemView{item, ""}) } } ``` (`today` and `tomorrow` are already computed a few lines above this loop, as `today := config.Today()` and `tomorrow := today.AddDate(0, 0, 1)` — unchanged.) - [ ] **Step 6: Update `calcCalendarBounds` to accept `[]TimelineItemView` and skip multi-day rows** Replace: ```go func calcCalendarBounds(items []models.TimelineItem, 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 { continue } ``` with: ```go func calcCalendarBounds(items []TimelineItemView, currentHour int) (startHour, endHour int) { minHour := 23 maxHour := 0 hasTimedEvents := false for _, item := range items { // 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 } ``` - [ ] **Step 7: Update the existing `TestCalcCalendarBounds` fixtures for the new parameter type** In `internal/handlers/timeline_logic_test.go`, change the test struct's field type and every fixture literal. Replace: ```go func TestCalcCalendarBounds(t *testing.T) { tests := []struct { name string items []models.TimelineItem currentHour int wantStart int wantEnd int }{ { name: "no timed events returns default", items: []models.TimelineItem{}, 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)}, }, currentHour: -1, wantStart: 9, // 1 hour buffer before wantEnd: 11, // 1 hour buffer after }, { name: "includes current hour", items: []models.TimelineItem{ {Time: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC)}, }, currentHour: 8, wantStart: 7, // 1 hour before 8am wantEnd: 11, // 1 hour after 10am }, { name: "event with end time extends range", items: []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 wantEnd: 15, // 1 hour after 2pm end }, { name: "all-day events are skipped", items: []models.TimelineItem{ {Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), IsAllDay: true}, }, currentHour: -1, wantStart: 8, wantEnd: 18, }, { name: "overdue events are skipped", items: []models.TimelineItem{ {Time: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), IsOverdue: true}, }, currentHour: -1, wantStart: 8, wantEnd: 18, }, { 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)}, }, currentHour: -1, wantStart: 0, // Can't go below 0 wantEnd: 23, // Can't go above 23 }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { start, end := calcCalendarBounds(tc.items, tc.currentHour) if start != tc.wantStart { t.Errorf("Expected start %d, got %d", tc.wantStart, start) } if end != tc.wantEnd { t.Errorf("Expected end %d, got %d", tc.wantEnd, end) } }) } } ``` with: ```go func TestCalcCalendarBounds(t *testing.T) { tests := []struct { name string items []TimelineItemView currentHour int wantStart int wantEnd int }{ { name: "no timed events returns default", items: []TimelineItemView{}, currentHour: -1, wantStart: 8, wantEnd: 18, }, { name: "single event at 10am", 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 wantEnd: 11, // 1 hour buffer after }, { name: "includes current hour", 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 wantEnd: 11, // 1 hour after 10am }, { name: "event with end time extends range", 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 wantEnd: 15, // 1 hour after 2pm end }, { name: "all-day events are skipped", items: []TimelineItemView{ {TimelineItem: models.TimelineItem{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), IsAllDay: true}}, }, currentHour: -1, wantStart: 8, wantEnd: 18, }, { name: "overdue events are skipped", items: []TimelineItemView{ {TimelineItem: models.TimelineItem{Time: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), IsOverdue: true}}, }, currentHour: -1, wantStart: 8, wantEnd: 18, }, { name: "clamps to 0-23 range", 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 { t.Run(tc.name, func(t *testing.T) { start, end := calcCalendarBounds(tc.items, tc.currentHour) if start != tc.wantStart { t.Errorf("Expected start %d, got %d", tc.wantStart, start) } if end != tc.wantEnd { t.Errorf("Expected end %d, got %d", tc.wantEnd, end) } }) } } ``` - [ ] **Step 8: Add the `multiDayLabel` template function** In `internal/handlers/handlers.go`, replace: ```go funcMap := template.FuncMap{ "subtract": func(a, b int) int { return a - b }, } ``` with: ```go 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 "" } }, } ``` - [ ] **Step 9: Run the full handlers package test suite** Run: `cd /workspace/doot && go build ./... && go test ./internal/handlers/...` Expected: `go build` succeeds; `go test` passes except the two pre-existing unrelated failures noted in Task 1 Step 6. - [ ] **Step 10: Commit** ```bash cd /workspace/doot git add internal/handlers/timeline.go internal/handlers/handlers.go internal/handlers/timeline_logic_test.go internal/handlers/timeline_multiday_test.go git commit -m "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." ``` --- ### Task 3: Web template — render multi-day rows in `timeline-tab.html` **Files:** - Modify: `web/templates/partials/timeline-tab.html` **Interfaces:** - Consumes: `TimelineItemView.MultiDayVariant` (Task 2), `multiDayLabel` template func (Task 2). - Produces: nothing consumed by later tasks. No dedicated test for this task — this project has no template-rendering test harness beyond `TestHandleTimeline_RendersDataToTemplate`'s type assertion (already covered in Task 2). Verified by `go build` (fails loudly on template parse errors) and manual visual check after deploy in Task 5. - [ ] **Step 1: Extend the Today section's "untimed" bucket to include multi-day rows** In `web/templates/partials/timeline-tab.html`, replace: ```html {{$hasUntimed := false}}{{range .TodayItems}}{{if or .IsOverdue .IsAllDay}}{{$hasUntimed = true}}{{end}}{{end}} {{if $hasUntimed}}