summaryrefslogtreecommitdiff
path: root/internal/handlers/budget_logic.go
blob: 9d8b6fedbbb76ae9142ded9059749df07d633df4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
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
}