From e2a20706caa5cfb10897db84e246e6bdb4992470 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Thu, 16 Jul 2026 06:35:07 +0000 Subject: Add pure availability-minus-events and scheduled-load computation ComputeBudgetPeriod is a standalone, dependency-free function over in-memory availability blocks, calendar events, and tasks: sums weekly availability minus overlapping calendar events for [start, end), and sums estimated minutes for tracked, incomplete tasks due in that window. --- internal/handlers/budget_logic.go | 106 +++++++++++++++++++++++++++++++++ internal/handlers/budget_logic_test.go | 98 ++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 internal/handlers/budget_logic.go create mode 100644 internal/handlers/budget_logic_test.go (limited to 'internal/handlers') 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) + } +} -- cgit v1.2.3