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 }