# 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}}
{{range .TodayItems}} {{if or .IsOverdue .IsAllDay}}
{{if or (eq .Type "task") (eq .Type "card") (eq .Type "gtask")}} {{end}} {{.Title}} {{if .IsOverdue}}overdue{{end}} {{if .URL}} {{end}}
{{end}} {{end}}
{{end}} ``` with: ```html {{$hasUntimed := false}}{{range .TodayItems}}{{if or .IsOverdue .IsAllDay (ne .MultiDayVariant "")}}{{$hasUntimed = true}}{{end}}{{end}} {{if $hasUntimed}}
{{range .TodayItems}} {{if or .IsOverdue .IsAllDay (ne .MultiDayVariant "")}}
{{if or (eq .Type "task") (eq .Type "card") (eq .Type "gtask")}} {{end}} {{.Title}}{{multiDayLabel .}} {{if .IsOverdue}}overdue{{end}} {{if .URL}} {{end}}
{{end}} {{end}}
{{end}} ``` - [ ] **Step 2: Exclude multi-day rows from the hourly grid** Replace: ```html {{range .TodayItems}} {{if and (not .IsOverdue) (not .IsAllDay)}}
{{if and (not .IsAllDay) (or (ne .Time.Hour 0) (ne .Time.Minute 0))}} {{.Time.Format "3:04 PM"}} {{end}}
{{if or (eq .Type "task") (eq .Type "card") (eq .Type "gtask")}} {{end}} {{.Title}} {{if .EndTime}}– {{.EndTime.Format "3:04 PM"}}{{end}} {{if .URL}} {{end}}
{{end}} ``` with: ```html {{range .TomorrowItems}}
{{if eq .MultiDayVariant ""}} {{if and (not .IsAllDay) (or (ne .Time.Hour 0) (ne .Time.Minute 0))}} {{.Time.Format "3:04 PM"}} {{end}} {{end}}
{{if or (eq .Type "task") (eq .Type "card") (eq .Type "gtask")}} {{end}} {{.Title}}{{multiDayLabel .}} {{if and (eq .MultiDayVariant "") .EndTime}}– {{.EndTime.Format "3:04 PM"}}{{end}} {{if .URL}} {{end}}
{{end}} ``` - [ ] **Step 4: Verify the project still builds and the handlers test suite still passes** Run: `cd /workspace/doot && go build ./... && go test ./internal/handlers/...` Expected: same result as Task 2 Step 9 (template changes don't affect Go tests, but a broken template would fail `HTMLResponse` at runtime — this build/test run is a sanity check, not new coverage for the template itself). - [ ] **Step 5: Commit** ```bash cd /workspace/doot git add web/templates/partials/timeline-tab.html git commit -m "feat(web): render multi-day event labels in the timeline template" ``` --- ### Task 4: Android widget — multi-day event detection and rendering **Files:** - Modify: `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt` - Test: `android/app/src/test/java/org/terst/doot/widget/ui/DootWidgetMultiDayTest.kt` (new) **Interfaces:** - Consumes: `WidgetItem.start`/`.end`/`.type` (existing, no model changes needed — `end` is now reliably populated for multi-day all-day events thanks to Task 1's server fix). - Produces: `enum class MultiDayVariant { NONE, STARTS, ENDS, SPANS }`, `internal fun isMultiDayEvent(item: WidgetItem, zone: ZoneId): Boolean`, `internal fun multiDayVariant(item: WidgetItem, renderDay: LocalDate, zone: ZoneId): MultiDayVariant` — no later task consumes these (this is the last Android task), but they're `internal` (not `private`) for the same testability reason `calcGridStart`/`calcGridEnd` are. **Context:** `WidgetRoot` currently splits `items` into `allDayEvents` (`isAllDay && type=="event"`) and `rest` (everything else). A multi-day event that is NOT flagged `isAllDay` (e.g. a real 17-day timed event) falls into `rest` → `scheduledEvents`, and only ever renders in the hour-grid row matching its `start`'s exact hour on the exact day it starts — it silently disappears on every later day, including "today" once it's actually ongoing. A multi-day event that IS flagged `isAllDay` was, before Task 1, missing its `end` entirely, so it couldn't be detected as multi-day at all. This task pulls every multi-day event (`type=="event"` with `start`/`end` calendar days differing) out of the normal pipeline entirely, computes which of Today/Tomorrow it touches and which case applies, and renders it via `AllDayRow` extended with an optional label. - [ ] **Step 1: Write the failing tests for the pure helper functions** Create `android/app/src/test/java/org/terst/doot/widget/ui/DootWidgetMultiDayTest.kt`: ```kotlin package org.terst.doot.widget.ui import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import org.terst.doot.widget.data.WidgetItem import java.time.LocalDate import java.time.ZoneId class DootWidgetMultiDayTest { private val zone: ZoneId = ZoneId.of("UTC") private fun event(start: String, end: String, isAllDay: Boolean = false): WidgetItem = WidgetItem( id = "e1", title = "Event", source = "calendar", type = "event", start = start, end = end, isAllDay = isAllDay ) @Test fun `isMultiDayEvent is false for a same-day timed event`() { assertFalse(isMultiDayEvent(event("2026-07-13T10:00:00Z", "2026-07-13T11:00:00Z"), zone)) } @Test fun `isMultiDayEvent is true when start and end fall on different calendar days`() { assertTrue(isMultiDayEvent(event("2026-07-13T17:35:00Z", "2026-07-30T22:30:00Z"), zone)) } @Test fun `isMultiDayEvent is true for a genuine all-day multi-day event`() { assertTrue(isMultiDayEvent(event("2026-07-13T00:00:00Z", "2026-07-15T00:00:00Z", isAllDay = true), zone)) } @Test fun `isMultiDayEvent is false for non-event types`() { val task = WidgetItem( id = "t1", title = "Task", source = "doot", type = "task", start = "2026-07-13T10:00:00Z", end = "2026-07-30T22:30:00Z" ) assertFalse(isMultiDayEvent(task, zone)) } @Test fun `multiDayVariant returns STARTS on the event's start day`() { val item = event("2026-07-13T17:35:00Z", "2026-07-30T22:30:00Z") assertEquals(MultiDayVariant.STARTS, multiDayVariant(item, LocalDate.of(2026, 7, 13), zone)) } @Test fun `multiDayVariant returns ENDS on the event's end day`() { val item = event("2026-07-13T17:35:00Z", "2026-07-30T22:30:00Z") assertEquals(MultiDayVariant.ENDS, multiDayVariant(item, LocalDate.of(2026, 7, 30), zone)) } @Test fun `multiDayVariant returns SPANS on a day strictly between start and end`() { val item = event("2026-07-13T17:35:00Z", "2026-07-30T22:30:00Z") assertEquals(MultiDayVariant.SPANS, multiDayVariant(item, LocalDate.of(2026, 7, 20), zone)) } @Test fun `multiDayVariant returns NONE for a day outside the event's span`() { val item = event("2026-07-13T17:35:00Z", "2026-07-30T22:30:00Z") assertEquals(MultiDayVariant.NONE, multiDayVariant(item, LocalDate.of(2026, 8, 1), zone)) } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cd /workspace/doot/android && ./gradlew testDebugUnitTest --tests "org.terst.doot.widget.ui.DootWidgetMultiDayTest"` Expected: FAILS to compile — `MultiDayVariant`/`isMultiDayEvent`/`multiDayVariant` don't exist yet. - [ ] **Step 3: Add the `LocalDate` import and the multi-day helpers** In `android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt`, add the import: ```kotlin import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.ZonedDateTime import java.time.temporal.ChronoUnit ``` (replacing the existing four-line block that's missing `LocalDate`.) Then add, right after the `calendarViewIntent` helper near the top of the file: ```kotlin enum class MultiDayVariant { NONE, STARTS, ENDS, SPANS } /** True for a calendar event whose start and end fall on different calendar days. */ internal fun isMultiDayEvent(item: WidgetItem, zone: ZoneId): Boolean { if (item.type != "event") return false val start = item.start?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return false val end = item.end?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return false return start.atZone(zone).toLocalDate() != end.atZone(zone).toLocalDate() } /** * How a multi-day item should render on renderDay. Caller must have already * confirmed isMultiDayEvent(item) so item.start/item.end are non-null. */ internal fun multiDayVariant(item: WidgetItem, renderDay: LocalDate, zone: ZoneId): MultiDayVariant { val start = Instant.parse(item.start!!).atZone(zone).toLocalDate() val end = Instant.parse(item.end!!).atZone(zone).toLocalDate() return when { renderDay.isEqual(start) -> MultiDayVariant.STARTS renderDay.isEqual(end) -> MultiDayVariant.ENDS renderDay.isAfter(start) && renderDay.isBefore(end) -> MultiDayVariant.SPANS else -> MultiDayVariant.NONE } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `cd /workspace/doot/android && ./gradlew testDebugUnitTest --tests "org.terst.doot.widget.ui.DootWidgetMultiDayTest"` Expected: PASS (8 tests). - [ ] **Step 5: Wire multi-day events into `WidgetRoot`, `AllDayRow`, and `TomorrowSection`** Replace `WidgetRoot` in full: ```kotlin @Composable fun WidgetRoot(items: List, now: Instant, isRefreshing: Boolean, textSize: WidgetTextSize) { val zone = ZoneId.systemDefault() val nowZoned: ZonedDateTime = now.atZone(zone) val todayDate: LocalDate = nowZoned.toLocalDate() val tomorrowDate: LocalDate = todayDate.plusDays(1) val todayStart = todayDate.atStartOfDay(zone).toInstant() val tomorrowStart = tomorrowDate.atStartOfDay(zone).toInstant() val tomorrowEnd = tomorrowStart.plus(1, ChronoUnit.DAYS) // Multi-day events (Start and End fall on different calendar days) are pulled out of // the normal all-day/grid pipeline entirely and rendered as an all-day-style row on // EVERY day they touch, labeled "starts"/"ends"/plain depending on which day is being // rendered -- see isMultiDayEvent/multiDayVariant. val multiDayEvents = items.filter { it.type == "event" && isMultiDayEvent(it, zone) } val singleDayItems = items.filter { !(it.type == "event" && isMultiDayEvent(it, zone)) } val todayMultiDay = multiDayEvents.mapNotNull { item -> multiDayVariant(item, todayDate, zone).takeIf { it != MultiDayVariant.NONE }?.let { item to it } } val tomorrowMultiDay = multiDayEvents.mapNotNull { item -> multiDayVariant(item, tomorrowDate, zone).takeIf { it != MultiDayVariant.NONE }?.let { item to it } } // All-day CALENDAR EVENTS (isAllDay && type == "event" -- see // TimelineItemToWidgetItem's doc comment for why undated doot/gtask // tasks, which are also flagged isAllDay, are deliberately excluded // here) are pinned to the top of their day's section and never compete // for hourly grid slots or floating-task packing. Previously they had // no Start at all and fell into the same floating-task queue as // ordinary untimed tasks, where enough tasks ahead of them in the queue // could push their assigned slot past the visible grid range entirely // -- not merely unpinned, actually invisible. val allDayEvents = singleDayItems.filter { it.isAllDay && it.type == "event" } val rest = singleDayItems.filter { !(it.isAllDay && it.type == "event") } val todayAllDay = allDayEvents.filter { item -> val s = item.start?.let { runCatching { Instant.parse(it) }.getOrNull() } s == null || (s >= todayStart && s < tomorrowStart) } val tomorrowAllDay = allDayEvents.filter { item -> val s = item.start?.let { runCatching { Instant.parse(it) }.getOrNull() } s != null && s >= tomorrowStart && s < tomorrowEnd } val allScheduled = rest.filter { it.start != null }.sortedBy { Instant.parse(it.start!!) } // Past tasks float at now (before untimed tasks); past events stay in the grid at 50% alpha val pastTasks = allScheduled.filter { it.type == "task" && Instant.parse(it.start!!) < now } val scheduledEvents = allScheduled.filter { it.type != "task" || Instant.parse(it.start!!) >= now } val floating = rest.filter { it.start == null } val fragments = SlotPacker.pack(pastTasks + floating, scheduledEvents, now) // Grid bounds must only reflect TODAY's events -- a tomorrow event's hour-of-day // would otherwise stretch today's grid (see calcGridStart/calcGridEnd), manufacturing // empty hour rows that push the non-scrolling TomorrowSection below the widget's // visible area. val todayScheduledEvents = scheduledEvents.filter { Instant.parse(it.start!!) < tomorrowStart } val gridStart = calcGridStart(todayScheduledEvents, nowZoned.hour) val gridEnd = calcGridEnd(todayScheduledEvents, nowZoned.hour) val tomorrowItems = scheduledEvents .filter { Instant.parse(it.start!!) >= tomorrowStart && Instant.parse(it.start!!) < tomorrowEnd } val tomorrowFrags = fragments .filter { it.startTime >= tomorrowStart && it.startTime < tomorrowEnd } Column( modifier = GlanceModifier .fillMaxSize() .background(Color.Transparent) .padding(horizontal = 8.dp, vertical = 4.dp) ) { Row( modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp), verticalAlignment = Alignment.CenterVertically ) { Text( "TODAY", style = TextStyle( color = ColorProvider(Color(0x66FFFFFF)), fontSize = textSize.scaledHeaderSize(11), fontWeight = textSize.scaledHeaderWeight(FontWeight.Bold) ), modifier = GlanceModifier.defaultWeight() ) QuickAddButton() Spacer(modifier = GlanceModifier.width(4.dp)) RefreshButton(isRefreshing) } todayAllDay.forEach { AllDayRow(it, textSize) } todayMultiDay.forEach { (item, variant) -> AllDayRow(item, textSize, variant) } for (hour in gridStart..gridEnd) { HourRow(hour, nowZoned, scheduledEvents, fragments, zone, textSize) } if (tomorrowItems.isNotEmpty() || tomorrowFrags.any { it.slots.isNotEmpty() } || tomorrowAllDay.isNotEmpty() || tomorrowMultiDay.isNotEmpty()) { TomorrowSection(tomorrowItems, tomorrowFrags, tomorrowAllDay, tomorrowMultiDay, zone, textSize) } } } ``` Replace `AllDayRow` in full: ```kotlin @Composable fun AllDayRow(event: WidgetItem, textSize: WidgetTextSize, variant: MultiDayVariant = MultiDayVariant.NONE) { val color = sourceColor(event.source) Row( modifier = GlanceModifier .fillMaxWidth() .padding(vertical = 3.dp) .clickable(actionStartActivity(calendarViewIntent(event.url))), verticalAlignment = Alignment.CenterVertically ) { Box(modifier = GlanceModifier.width(3.dp).height(16.dp).background(color)) {} Text( text = event.title + multiDayLabel(event, variant), style = TextStyle( color = ColorProvider(Color.White.copy(alpha = 0.9f)), fontSize = textSize.scaledContentSize(13), fontWeight = textSize.scaledContentWeight(FontWeight.Medium) ), modifier = GlanceModifier.padding(start = 8.dp), maxLines = 1 ) } } /** "" for a plain all-day row or a "spans"-through day; " (starts/ends HH:MM)" otherwise. */ private fun multiDayLabel(event: WidgetItem, variant: MultiDayVariant): String = when (variant) { MultiDayVariant.STARTS -> event.start?.let { timeSuffix("starts", it) } ?: "" MultiDayVariant.ENDS -> event.end?.let { timeSuffix("ends", it) } ?: "" else -> "" } private fun timeSuffix(verb: String, iso: String): String { val t = Instant.parse(iso).atZone(ZoneId.systemDefault()) if (t.hour == 0 && t.minute == 0) return "" val minutePart = if (t.minute > 0) t.minute.toString().padStart(2, '0') else "" return " ($verb ${hourLabel(t.hour)}$minutePart)" } ``` Replace `TomorrowSection` in full: ```kotlin @Composable fun TomorrowSection( items: List, fragments: List, allDayEvents: List, multiDayEvents: List>, zone: ZoneId, textSize: WidgetTextSize ) { Box(modifier = GlanceModifier.fillMaxWidth().height(1.dp).padding(vertical = 4.dp).background(Color(0x1AFFFFFF))) {} Row(modifier = GlanceModifier.fillMaxWidth().padding(top = 6.dp, bottom = 2.dp)) { Text( "TOMORROW", style = TextStyle( color = ColorProvider(Color(0x66FFFFFF)), fontSize = textSize.scaledHeaderSize(11), fontWeight = textSize.scaledHeaderWeight(FontWeight.Bold) ) ) } allDayEvents.forEach { AllDayRow(it, textSize) } multiDayEvents.forEach { (item, variant) -> AllDayRow(item, textSize, variant) } items.forEach { item -> val isPast = false if (item.type == "event") { TomorrowEventRow(item, zone, textSize) } else { TaskRow(item, textSize) } } fragments.forEach { frag -> frag.slots.forEach { slot -> TaskRow(slot.task, textSize) } } } ``` - [ ] **Step 6: Run the full unit test suite** Run: `cd /workspace/doot/android && ./gradlew testDebugUnitTest` Expected: BUILD SUCCESSFUL, all tests pass (including this task's 8 new tests and every previously-existing test). - [ ] **Step 7: Commit** ```bash cd /workspace/doot git add android/app/src/main/java/org/terst/doot/widget/ui/DootWidget.kt android/app/src/test/java/org/terst/doot/widget/ui/DootWidgetMultiDayTest.kt git commit -m "feat(widget): show multi-day calendar events on every day they span Multi-day events (Start and End on different calendar days) are pulled out of the normal grid/all-day pipeline and rendered as an all-day-style row on every day they touch (Today and/or Tomorrow), labeled starts/ends/plain per the day being rendered. Previously such an event either only appeared in the single hourly grid slot matching its start time (never again on later days) or, if genuinely flagged all-day, never had its End forwarded at all." ``` --- ### Task 5: Build, test, and deploy both the Go server and the Android widget **Files:** none (build/deploy only). - [ ] **Step 1: Run the full Go test suite** Run: `cd /workspace/doot && go build ./... && go test ./internal/... ./cmd/...` Expected: `go build` succeeds. `go test` passes except the two pre-existing unrelated failures (`TestHandleAgentTaskWriteOperations`, `TestHandleAgentCreateOperations` in `internal/handlers`) and the pre-existing unrelated `internal/models` vet/test breakage (`MealToAtom` undefined in `atom_test.go`) — all three confirmed present before this plan's changes and out of scope. - [ ] **Step 2: Run the full Android unit test suite** Run: `cd /workspace/doot/android && ./gradlew testDebugUnitTest` Expected: BUILD SUCCESSFUL. - [ ] **Step 3: Deploy the Go server** Run: `cd /workspace/doot && ./scripts/deploy` Expected: script completes through "Deploy complete!" — builds the binary from the current working tree, syncs `web/static`/`web/templates`/migrations to `/site/doot.terst.org`, restarts the `task-dashboard@doot.terst.org` service. - [ ] **Step 4: Build and deploy the Android APK** Run: `cd /workspace/doot/android && ./gradlew assembleRelease` Expected: BUILD SUCCESSFUL. Run: `md5sum /workspace/doot/android/app/build/outputs/apk/release/app-release.apk /site/static.terst.org/public/files/doot-widget.apk` Expected: checksums differ (proves this is a new build). Run: `cp /workspace/doot/android/app/build/outputs/apk/release/app-release.apk /site/static.terst.org/public/files/doot-widget.apk` - [ ] **Step 5: Update the project worklog** Per `.agent/config.md`'s Worklog Integrity mandate, append a short entry to `/workspace/doot/.agent/worklog.md`'s "Recently Completed" section describing the multi-day event fix (web + widget) and its root cause (single-day-only bucketing, and the widget's missing `End` for all-day events). - [ ] **Step 6: Manual verification** No `adb`/emulator or way to hit the live web UI from this environment. For the user, after the deploy in Steps 3-4: - Web: confirm a multi-day calendar event now appears on every day it spans (Today and Tomorrow sections), with "(starts HH:MM)" / "(ends HH:MM)" labels on its boundary days and no label on pass-through days. - Widget: reinstall `doot-widget.apk`, confirm the same three cases render correctly in both the Today and Tomorrow sections.