diff options
Diffstat (limited to 'internal/handlers/budget_logic.go')
| -rw-r--r-- | internal/handlers/budget_logic.go | 106 |
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 +} |
