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.go106
1 files changed, 106 insertions, 0 deletions
diff --git a/internal/handlers/budget_logic.go b/internal/handlers/budget_logic.go
new file mode 100644
index 0000000..9d8b6fe
--- /dev/null
+++ b/internal/handlers/budget_logic.go
@@ -0,0 +1,106 @@
+package handlers
+
+import (
+ "time"
+
+ "task-dashboard/internal/models"
+)
+
+// 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.
+// Never mutates its inputs and never drives scheduling decisions; it only
+// answers "does this fit."
+func ComputeBudgetPeriod(
+ blocks []models.AvailabilityBlock,
+ events []models.CalendarEvent,
+ tasks []models.Task,
+ trackedProjects map[string]bool,
+ trackedLabels map[string]bool,
+ start, end time.Time,
+) models.BudgetPeriod {
+ available := 0
+ for day := start; day.Before(end); day = day.AddDate(0, 0, 1) {
+ weekday := int(day.Weekday())
+ for _, block := range blocks {
+ if block.Weekday != weekday {
+ continue
+ }
+ blockStart, blockEnd, ok := blockTimesOnDay(block, day)
+ if !ok {
+ continue
+ }
+ minutes := int(blockEnd.Sub(blockStart).Minutes())
+ for _, event := range events {
+ minutes -= overlapMinutes(blockStart, blockEnd, event.Start, event.End)
+ }
+ if minutes > 0 {
+ available += minutes
+ }
+ }
+ }
+
+ scheduled := 0
+ for _, task := range tasks {
+ if task.Completed || task.DueDate == nil {
+ continue
+ }
+ if !task.DueDate.Before(end) {
+ continue
+ }
+ if !isBudgetTracked(task, trackedProjects, trackedLabels) {
+ continue
+ }
+ scheduled += task.EstimatedMinutes
+ }
+
+ return models.BudgetPeriod{ScheduledMinutes: scheduled, AvailableMinutes: available}
+}
+
+// blockTimesOnDay resolves an availability block's "HH:MM" start/end
+// strings to concrete times on the given day. ok is false if either time
+// fails to parse (a malformed block is skipped rather than panicking).
+func blockTimesOnDay(block models.AvailabilityBlock, day time.Time) (time.Time, time.Time, bool) {
+ start, err := time.ParseInLocation("15:04", block.StartTime, day.Location())
+ if err != nil {
+ return time.Time{}, time.Time{}, false
+ }
+ end, err := time.ParseInLocation("15:04", block.EndTime, day.Location())
+ if err != nil {
+ return time.Time{}, time.Time{}, false
+ }
+ y, m, d := day.Date()
+ return time.Date(y, m, d, start.Hour(), start.Minute(), 0, 0, day.Location()),
+ 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
+ }
+ if hi.Before(lo) || hi.Equal(lo) {
+ return 0
+ }
+ return int(hi.Sub(lo).Minutes())
+}
+
+// isBudgetTracked reports whether task counts against any budget
+// calculation -- true if its project or any of its labels is opted in.
+func isBudgetTracked(task models.Task, trackedProjects, trackedLabels map[string]bool) bool {
+ if trackedProjects[task.ProjectID] {
+ return true
+ }
+ for _, label := range task.Labels {
+ if trackedLabels[label] {
+ return true
+ }
+ }
+ return false
+}