diff options
Diffstat (limited to 'internal/handlers')
| -rw-r--r-- | internal/handlers/budget_logic.go | 72 | ||||
| -rw-r--r-- | internal/handlers/budget_logic_test.go | 61 |
2 files changed, 117 insertions, 16 deletions
diff --git a/internal/handlers/budget_logic.go b/internal/handlers/budget_logic.go index 9d8b6fe..f4529d4 100644 --- a/internal/handlers/budget_logic.go +++ b/internal/handlers/budget_logic.go @@ -1,6 +1,7 @@ package handlers import ( + "sort" "time" "task-dashboard/internal/models" @@ -8,8 +9,18 @@ import ( // ComputeBudgetPeriod is a pure function: given the weekly availability // template, calendar events, and candidate tasks, it returns the scheduled -// (tracked, incomplete, due-in-window) load versus the available minutes -// in [start, end) -- availability minus any overlapping calendar events. +// load versus the available minutes in [start, end) -- availability minus +// any overlapping calendar events (overlapping events are merged so busy +// time is never double-subtracted). +// +// Task filtering only enforces an upper bound: a task counts toward +// ScheduledMinutes if it is tracked, incomplete, and task.DueDate.Before(end). +// There is no lower bound tied to start -- start is intentionally not used +// to filter tasks. Callers are responsible for passing in the right task +// set, including any tasks already overdue relative to start, so that +// overdue load always counts regardless of which window is being +// evaluated. Here, start only bounds the day-by-day availability iteration. +// // Never mutates its inputs and never drives scheduling decisions; it only // answers "does this fit." func ComputeBudgetPeriod( @@ -32,9 +43,7 @@ func ComputeBudgetPeriod( continue } minutes := int(blockEnd.Sub(blockStart).Minutes()) - for _, event := range events { - minutes -= overlapMinutes(blockStart, blockEnd, event.Start, event.End) - } + minutes -= busyMinutesInBlock(blockStart, blockEnd, events) if minutes > 0 { available += minutes } @@ -75,20 +84,51 @@ func blockTimesOnDay(block models.AvailabilityBlock, day time.Time) (time.Time, time.Date(y, m, d, end.Hour(), end.Minute(), 0, 0, day.Location()), true } -// overlapMinutes returns how many minutes [bStart, bEnd) and [eStart, eEnd) overlap. -func overlapMinutes(bStart, bEnd, eStart, eEnd time.Time) int { - lo := bStart - if eStart.After(lo) { - lo = eStart - } - hi := bEnd - if eEnd.Before(hi) { - hi = eEnd +// busyMinutesInBlock returns how many minutes of [blockStart, blockEnd) are +// covered by the union of the given events. Each event is clipped to the +// block first; the clipped intervals are then merged so that overlapping +// events are not double-counted (an event ∩ block interval that overlaps +// another event's clipped interval contributes its union length once, not +// once per event). +func busyMinutesInBlock(blockStart, blockEnd time.Time, events []models.CalendarEvent) int { + clipped := make([]struct{ start, end time.Time }, 0, len(events)) + for _, event := range events { + lo := blockStart + if event.Start.After(lo) { + lo = event.Start + } + hi := blockEnd + if event.End.Before(hi) { + hi = event.End + } + if !hi.After(lo) { + continue // event doesn't overlap the block at all + } + clipped = append(clipped, struct{ start, end time.Time }{lo, hi}) } - if hi.Before(lo) || hi.Equal(lo) { + if len(clipped) == 0 { return 0 } - return int(hi.Sub(lo).Minutes()) + + sort.Slice(clipped, func(i, j int) bool { + return clipped[i].start.Before(clipped[j].start) + }) + + total := 0 + curStart, curEnd := clipped[0].start, clipped[0].end + for _, iv := range clipped[1:] { + if iv.start.After(curEnd) { + // Gap between merged interval and this one -- close it out. + total += int(curEnd.Sub(curStart).Minutes()) + curStart, curEnd = iv.start, iv.end + continue + } + if iv.end.After(curEnd) { + curEnd = iv.end + } + } + total += int(curEnd.Sub(curStart).Minutes()) + return total } // isBudgetTracked reports whether task counts against any budget 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) |
