summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/handlers/budget_logic.go106
-rw-r--r--internal/handlers/budget_logic_test.go98
2 files changed, 204 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
+}
diff --git a/internal/handlers/budget_logic_test.go b/internal/handlers/budget_logic_test.go
new file mode 100644
index 0000000..1e16595
--- /dev/null
+++ b/internal/handlers/budget_logic_test.go
@@ -0,0 +1,98 @@
+package handlers
+
+import (
+ "testing"
+ "time"
+
+ "task-dashboard/internal/models"
+)
+
+func mustParseInLoc(t *testing.T, layout, value string, loc *time.Location) time.Time {
+ t.Helper()
+ parsed, err := time.ParseInLocation(layout, value, loc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return parsed
+}
+
+func TestComputeBudgetPeriod_SumsAvailabilityMinusOverlappingEvent(t *testing.T) {
+ loc := time.UTC
+ // A Wednesday: 2026-07-15 is a Wednesday.
+ day := mustParseInLoc(t, "2006-01-02", "2026-07-15", loc)
+ start := day
+ end := day.AddDate(0, 0, 1)
+
+ blocks := []models.AvailabilityBlock{
+ {ID: "b1", Weekday: int(day.Weekday()), StartTime: "18:00", EndTime: "20:00"}, // 120 min
+ }
+ events := []models.CalendarEvent{
+ {ID: "e1", Start: mustParseInLoc(t, "2006-01-02 15:04", "2026-07-15 18:30", loc), End: mustParseInLoc(t, "2006-01-02 15:04", "2026-07-15 19:00", loc)}, // 30 min overlap
+ }
+
+ status := ComputeBudgetPeriod(blocks, events, nil, nil, nil, start, end)
+ if status.AvailableMinutes != 90 {
+ t.Errorf("AvailableMinutes = %d, want 90 (120 - 30 overlap)", status.AvailableMinutes)
+ }
+}
+
+func TestComputeBudgetPeriod_EventFullyOutsideBlockDoesNotReduceIt(t *testing.T) {
+ loc := time.UTC
+ day := mustParseInLoc(t, "2006-01-02", "2026-07-15", loc)
+ start := day
+ end := day.AddDate(0, 0, 1)
+
+ blocks := []models.AvailabilityBlock{
+ {ID: "b1", Weekday: int(day.Weekday()), StartTime: "18:00", EndTime: "20:00"},
+ }
+ events := []models.CalendarEvent{
+ {ID: "e1", Start: mustParseInLoc(t, "2006-01-02 15:04", "2026-07-15 09:00", loc), End: mustParseInLoc(t, "2006-01-02 15:04", "2026-07-15 10:00", loc)},
+ }
+
+ status := ComputeBudgetPeriod(blocks, events, nil, nil, nil, start, end)
+ if status.AvailableMinutes != 120 {
+ t.Errorf("AvailableMinutes = %d, want 120 (event doesn't overlap the block)", status.AvailableMinutes)
+ }
+}
+
+func TestComputeBudgetPeriod_AvailableNeverGoesNegative(t *testing.T) {
+ loc := time.UTC
+ day := mustParseInLoc(t, "2006-01-02", "2026-07-15", loc)
+ start := day
+ end := day.AddDate(0, 0, 1)
+
+ blocks := []models.AvailabilityBlock{
+ {ID: "b1", Weekday: int(day.Weekday()), StartTime: "18:00", EndTime: "20:00"},
+ }
+ events := []models.CalendarEvent{
+ {ID: "e1", Start: mustParseInLoc(t, "2006-01-02 15:04", "2026-07-15 17:00", loc), End: mustParseInLoc(t, "2006-01-02 15:04", "2026-07-15 21:00", loc)},
+ }
+
+ status := ComputeBudgetPeriod(blocks, events, nil, nil, nil, start, end)
+ if status.AvailableMinutes != 0 {
+ t.Errorf("AvailableMinutes = %d, want 0 (event fully covers the block)", status.AvailableMinutes)
+ }
+}
+
+func TestComputeBudgetPeriod_OnlySumsTrackedIncompleteTasksDueInWindow(t *testing.T) {
+ loc := time.UTC
+ start := mustParseInLoc(t, "2006-01-02", "2026-07-15", loc)
+ end := start.AddDate(0, 0, 1)
+ due := mustParseInLoc(t, "2006-01-02 15:04", "2026-07-15 12:00", loc)
+ outsideWindow := end.AddDate(0, 0, 5)
+
+ tasks := []models.Task{
+ {ID: "t-tracked-project", ProjectID: "p1", EstimatedMinutes: 30, DueDate: &due},
+ {ID: "t-tracked-label", Labels: []string{"errands"}, EstimatedMinutes: 20, DueDate: &due},
+ {ID: "t-untracked", ProjectID: "p2", EstimatedMinutes: 999, DueDate: &due},
+ {ID: "t-completed", ProjectID: "p1", EstimatedMinutes: 999, DueDate: &due, Completed: true},
+ {ID: "t-outside-window", ProjectID: "p1", EstimatedMinutes: 999, DueDate: &outsideWindow},
+ }
+ trackedProjects := map[string]bool{"p1": true}
+ trackedLabels := map[string]bool{"errands": true}
+
+ status := ComputeBudgetPeriod(nil, nil, tasks, trackedProjects, trackedLabels, start, end)
+ if status.ScheduledMinutes != 50 {
+ t.Errorf("ScheduledMinutes = %d, want 50 (30 + 20, excluding untracked/completed/out-of-window)", status.ScheduledMinutes)
+ }
+}