package handlers import ( "sort" "time" "task-dashboard/internal/models" ) // ComputeBudgetPeriod is a pure function: given the weekly availability // template, calendar events, and candidate tasks, it returns the scheduled // 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( 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()) minutes -= busyMinutesInBlock(blockStart, blockEnd, events) 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 } // 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 len(clipped) == 0 { return 0 } 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 // 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 }