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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
|
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
}
|