summaryrefslogtreecommitdiff
path: root/internal/handlers/budget_logic.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/handlers/budget_logic.go')
-rw-r--r--internal/handlers/budget_logic.go72
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