diff options
Diffstat (limited to 'internal/handlers/budget_logic.go')
| -rw-r--r-- | internal/handlers/budget_logic.go | 72 |
1 files changed, 56 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 |
