From cef79fc49fd8d6e607c71432755812adca847ce2 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Thu, 16 Jul 2026 06:41:40 +0000 Subject: Fix overlapping-event double-subtraction in ComputeBudgetPeriod; clarify task-filter doc comment; add multi-day test Overlapping calendar events within the same availability block were each subtracted independently, double-counting their intersection and making availability under-counted (a 120-min block with two 60-min-overlap events that overlap each other 18:30-19:00 came out to 0 min instead of the correct 30 min). Replace the per-event overlapMinutes subtraction with busyMinutesInBlock, which clips each event to the block, merges the clipped intervals, and subtracts the union's total length. Also clarify ComputeBudgetPeriod's doc comment: task filtering only enforces an upper bound (due < end); start is intentionally not used to filter tasks, since callers are expected to pre-fetch and pass in already-overdue tasks. Add a multi-day-window test verifying availability sums correctly across days, a block only contributes on its matching weekday, and the day-iteration loop excludes the day at `end`. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PQaPGQVSfmKUiHXB87qRTC --- internal/handlers/budget_logic_test.go | 61 ++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) (limited to 'internal/handlers/budget_logic_test.go') diff --git a/internal/handlers/budget_logic_test.go b/internal/handlers/budget_logic_test.go index 1e16595..dad8079 100644 --- a/internal/handlers/budget_logic_test.go +++ b/internal/handlers/budget_logic_test.go @@ -74,6 +74,67 @@ func TestComputeBudgetPeriod_AvailableNeverGoesNegative(t *testing.T) { } } +func TestComputeBudgetPeriod_OverlappingEventsUnionNotSummed(t *testing.T) { + loc := time.UTC + // A Wednesday: 2026-07-15 is a Wednesday. + day := mustParseInLoc(t, "2006-01-02", "2026-07-15", loc) + start := day + end := day.AddDate(0, 0, 1) + + blocks := []models.AvailabilityBlock{ + {ID: "b1", Weekday: int(day.Weekday()), StartTime: "18:00", EndTime: "20:00"}, // 120 min + } + events := []models.CalendarEvent{ + // Event A: 18:00-19:00 (60 min overlap with block) + {ID: "e1", Start: mustParseInLoc(t, "2006-01-02 15:04", "2026-07-15 18:00", loc), End: mustParseInLoc(t, "2006-01-02 15:04", "2026-07-15 19:00", loc)}, + // Event B: 18:30-19:30 (60 min overlap with block), overlapping event A from 18:30-19:00. + {ID: "e2", Start: mustParseInLoc(t, "2006-01-02 15:04", "2026-07-15 18:30", loc), End: mustParseInLoc(t, "2006-01-02 15:04", "2026-07-15 19:30", loc)}, + } + + // Union of busy time is 18:00-19:30 = 90 min, so available should be + // 120 - 90 = 30. A sum-based (rather than union-based) implementation + // would incorrectly compute 120 - 60 - 60 = 0. + status := ComputeBudgetPeriod(blocks, events, nil, nil, nil, start, end) + if status.AvailableMinutes != 30 { + t.Errorf("AvailableMinutes = %d, want 30 (120 - 90 union of overlapping events, not 120 - 60 - 60)", status.AvailableMinutes) + } +} + +func TestComputeBudgetPeriod_MultiDayWindowSumsPerMatchingWeekdayAndExcludesEnd(t *testing.T) { + loc := time.UTC + // 2026-07-13 is a Monday, 2026-07-14 Tuesday, 2026-07-15 Wednesday, + // 2026-07-16 Thursday, 2026-07-17 Friday. + start := mustParseInLoc(t, "2006-01-02", "2026-07-13", loc) // Monday + end := mustParseInLoc(t, "2006-01-02", "2026-07-17", loc) // Friday (exclusive) + + monday := int(time.Monday) + wednesday := int(time.Wednesday) + thursday := int(time.Thursday) + friday := int(time.Friday) + + blocks := []models.AvailabilityBlock{ + // Occurs once, on 07-13 (Monday) -- 60 min. + {ID: "mon", Weekday: monday, StartTime: "09:00", EndTime: "10:00"}, + // Occurs once, on 07-15 (Wednesday) -- 90 min. + {ID: "wed", Weekday: wednesday, StartTime: "08:00", EndTime: "09:30"}, + // Occurs once, on 07-16 (Thursday) -- 60 min. + {ID: "thu", Weekday: thursday, StartTime: "12:00", EndTime: "13:00"}, + // Weekday matches `end` (07-17, Friday) itself, which is one day + // PAST the last iterated day (07-16). If the loop incorrectly + // iterated through (or including) `end`, this block would wrongly + // contribute 60 more minutes. + {ID: "fri-at-end", Weekday: friday, StartTime: "09:00", EndTime: "10:00"}, + } + + // No events, so no overlap to subtract. + status := ComputeBudgetPeriod(blocks, nil, nil, nil, nil, start, end) + + want := 60 + 90 + 60 // Monday + Wednesday + Thursday; Friday-at-end excluded. + if status.AvailableMinutes != want { + t.Errorf("AvailableMinutes = %d, want %d (60 Mon + 90 Wed + 60 Thu; the Friday block at `end` must not be counted)", status.AvailableMinutes, want) + } +} + func TestComputeBudgetPeriod_OnlySumsTrackedIncompleteTasksDueInWindow(t *testing.T) { loc := time.UTC start := mustParseInLoc(t, "2006-01-02", "2026-07-15", loc) -- cgit v1.2.3