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