From 29d096771a01ceee1d74b365c7cf7052c55a1219 Mon Sep 17 00:00:00 2001 From: Dawid Zbinski Date: Sat, 14 Mar 2026 17:19:55 +0100 Subject: [PATCH] refactor: centralize day budget computation and fix status idle trimming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status command computed "today's logged time" via BuildReport without passing activity entries, so idle gaps from precise mode were never trimmed — causing inflated numbers compared to the report command. Extract ComputeDayBudget() and ComputeManualLogBudget() into internal/timetrack/budget.go, and LoadProjectEntries() into internal/cli/entries.go. Rewire status, log, and report commands to use these shared functions, ensuring consistent time attribution everywhere. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cli/entries.go | 50 ++++++ internal/cli/entries_test.go | 92 +++++++++++ internal/cli/log.go | 49 +++--- internal/cli/report.go | 32 +--- internal/cli/status.go | 52 ++----- internal/timetrack/budget.go | 109 +++++++++++++ internal/timetrack/budget_test.go | 246 ++++++++++++++++++++++++++++++ 7 files changed, 537 insertions(+), 93 deletions(-) create mode 100644 internal/cli/entries.go create mode 100644 internal/cli/entries_test.go create mode 100644 internal/timetrack/budget.go create mode 100644 internal/timetrack/budget_test.go diff --git a/internal/cli/entries.go b/internal/cli/entries.go new file mode 100644 index 0000000..91b8fbc --- /dev/null +++ b/internal/cli/entries.go @@ -0,0 +1,50 @@ +package cli + +import ( + "github.com/Flyrell/hourgit/internal/entry" +) + +// ProjectEntries holds all entry types for a project. +type ProjectEntries struct { + Checkouts []entry.CheckoutEntry + Logs []entry.Entry + Commits []entry.CommitEntry + ActivityStops []entry.ActivityStopEntry + ActivityStarts []entry.ActivityStartEntry +} + +// LoadProjectEntries reads all 5 entry types for a project in one call. +func LoadProjectEntries(homeDir, slug string) (ProjectEntries, error) { + checkouts, err := entry.ReadAllCheckoutEntries(homeDir, slug) + if err != nil { + return ProjectEntries{}, err + } + + logs, err := entry.ReadAllEntries(homeDir, slug) + if err != nil { + return ProjectEntries{}, err + } + + commits, err := entry.ReadAllCommitEntries(homeDir, slug) + if err != nil { + return ProjectEntries{}, err + } + + activityStops, err := entry.ReadAllActivityStopEntries(homeDir, slug) + if err != nil { + return ProjectEntries{}, err + } + + activityStarts, err := entry.ReadAllActivityStartEntries(homeDir, slug) + if err != nil { + return ProjectEntries{}, err + } + + return ProjectEntries{ + Checkouts: checkouts, + Logs: logs, + Commits: commits, + ActivityStops: activityStops, + ActivityStarts: activityStarts, + }, nil +} diff --git a/internal/cli/entries_test.go b/internal/cli/entries_test.go new file mode 100644 index 0000000..fff3407 --- /dev/null +++ b/internal/cli/entries_test.go @@ -0,0 +1,92 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/Flyrell/hourgit/internal/entry" + "github.com/Flyrell/hourgit/internal/project" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupEntriesTest(t *testing.T) (homeDir string, proj *project.ProjectEntry) { + t.Helper() + homeDir = t.TempDir() + repoDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(repoDir, ".git"), 0755)) + + proj, err := project.CreateProject(homeDir, "Entries Test") + require.NoError(t, err) + require.NoError(t, project.AssignProject(homeDir, repoDir, proj)) + + cfg, err := project.ReadConfig(homeDir) + require.NoError(t, err) + proj = project.FindProjectByID(cfg, proj.ID) + + return homeDir, proj +} + +func TestLoadProjectEntriesEmpty(t *testing.T) { + homeDir, proj := setupEntriesTest(t) + + entries, err := LoadProjectEntries(homeDir, proj.Slug) + + require.NoError(t, err) + assert.Empty(t, entries.Checkouts) + assert.Empty(t, entries.Logs) + assert.Empty(t, entries.Commits) + assert.Empty(t, entries.ActivityStops) + assert.Empty(t, entries.ActivityStarts) +} + +func TestLoadProjectEntriesWithData(t *testing.T) { + homeDir, proj := setupEntriesTest(t) + + now := time.Date(2025, 6, 11, 10, 0, 0, 0, time.UTC) + + require.NoError(t, entry.WriteEntry(homeDir, proj.Slug, entry.Entry{ + ID: "aaa1111", Start: now, Minutes: 60, Message: "work", + })) + + require.NoError(t, entry.WriteCheckoutEntry(homeDir, proj.Slug, entry.CheckoutEntry{ + ID: "bbb2222", Timestamp: now, Previous: "main", Next: "feature", + })) + + require.NoError(t, entry.WriteCommitEntry(homeDir, proj.Slug, entry.CommitEntry{ + ID: "ccc3333", Timestamp: now, Message: "commit", Branch: "feature", + })) + + require.NoError(t, entry.WriteActivityStopEntry(homeDir, proj.Slug, entry.ActivityStopEntry{ + ID: "ddd4444", Timestamp: now, + })) + + require.NoError(t, entry.WriteActivityStartEntry(homeDir, proj.Slug, entry.ActivityStartEntry{ + ID: "eee5555", Timestamp: now.Add(30 * time.Minute), + })) + + entries, err := LoadProjectEntries(homeDir, proj.Slug) + + require.NoError(t, err) + assert.Len(t, entries.Logs, 1) + assert.Len(t, entries.Checkouts, 1) + assert.Len(t, entries.Commits, 1) + assert.Len(t, entries.ActivityStops, 1) + assert.Len(t, entries.ActivityStarts, 1) +} + +func TestLoadProjectEntriesInvalidSlug(t *testing.T) { + homeDir := t.TempDir() + + // Non-existent project dir should return empty entries, not error + entries, err := LoadProjectEntries(homeDir, "nonexistent") + + require.NoError(t, err) + assert.Empty(t, entries.Logs) + assert.Empty(t, entries.Checkouts) + assert.Empty(t, entries.Commits) + assert.Empty(t, entries.ActivityStops) + assert.Empty(t, entries.ActivityStarts) +} diff --git a/internal/cli/log.go b/internal/cli/log.go index 46aa644..c7102d5 100644 --- a/internal/cli/log.go +++ b/internal/cli/log.go @@ -9,6 +9,7 @@ import ( "github.com/Flyrell/hourgit/internal/hashutil" "github.com/Flyrell/hourgit/internal/project" "github.com/Flyrell/hourgit/internal/schedule" + "github.com/Flyrell/hourgit/internal/timetrack" "github.com/spf13/cobra" ) @@ -218,7 +219,7 @@ func checkScheduleWarnings( return true, nil } - windows, scheduledMinutes, err := getDayScheduleWindows(homeDir, proj, entryStart) + windows, scheduledMinutes, daySchedules, err := getDayScheduleWindows(homeDir, proj, entryStart) if err != nil { return false, err } @@ -245,15 +246,15 @@ func checkScheduleWarnings( } // 3. Check budget overrun - return checkBudgetWarning(cmd, confirm, homeDir, proj, entryStart, minutes, scheduledMinutes, excludeID) + return checkBudgetWarning(cmd, confirm, homeDir, proj, entryStart, minutes, daySchedules, excludeID) } -// getDayScheduleWindows returns the schedule windows and total scheduled minutes -// for the day containing entryStart. -func getDayScheduleWindows(homeDir string, proj *project.ProjectEntry, entryStart time.Time) ([]schedule.TimeWindow, int, error) { +// getDayScheduleWindows returns the schedule windows, total scheduled minutes, +// and expanded day schedules for the day containing entryStart. +func getDayScheduleWindows(homeDir string, proj *project.ProjectEntry, entryStart time.Time) ([]schedule.TimeWindow, int, []schedule.DaySchedule, error) { cfg, err := project.ReadConfig(homeDir) if err != nil { - return nil, 0, err + return nil, 0, nil, err } schedules := project.GetSchedules(cfg, proj.ID) @@ -264,7 +265,7 @@ func getDayScheduleWindows(homeDir string, proj *project.ProjectEntry, entryStar daySchedules, err := schedule.ExpandSchedules(schedules, dayStart, dayEnd) if err != nil { - return nil, 0, err + return nil, 0, nil, err } var dayWindows []schedule.TimeWindow @@ -283,7 +284,7 @@ func getDayScheduleWindows(homeDir string, proj *project.ProjectEntry, entryStar scheduledMinutes += toMins - fromMins } - return dayWindows, scheduledMinutes, nil + return dayWindows, scheduledMinutes, daySchedules, nil } // checkBoundsWarning warns if the entry falls fully or partially outside @@ -339,41 +340,31 @@ func checkBudgetWarning( homeDir string, proj *project.ProjectEntry, entryStart time.Time, - minutes, scheduledMinutes int, + minutes int, + daySchedules []schedule.DaySchedule, excludeID string, ) (bool, error) { - entries, err := entry.ReadAllEntries(homeDir, proj.Slug) + logs, err := entry.ReadAllEntries(homeDir, proj.Slug) if err != nil { return false, err } - y, m, d := entryStart.Date() - loggedMinutes := 0 - for _, e := range entries { - if excludeID != "" && e.ID == excludeID { - continue - } - ey, em, ed := e.Start.Date() - if ey == y && em == m && ed == d { - loggedMinutes += e.Minutes - } - } + budget := timetrack.ComputeManualLogBudget(logs, daySchedules, entryStart, excludeID) - remaining := scheduledMinutes - loggedMinutes - if minutes > remaining { - if remaining <= 0 { + if minutes > budget.RemainingMinutes { + if budget.RemainingMinutes <= 0 { _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s you have already logged your full schedule for this day (%s scheduled, %s logged).\n", Warning("Warning:"), - Primary(entry.FormatMinutes(scheduledMinutes)), - Primary(entry.FormatMinutes(loggedMinutes)), + Primary(entry.FormatMinutes(budget.ScheduledMinutes)), + Primary(entry.FormatMinutes(budget.LoggedMinutes)), ) } else { _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s you are about to log %s, but only %s remains in today's schedule (%s scheduled, %s already logged).\n", Warning("Warning:"), Primary(entry.FormatMinutes(minutes)), - Primary(entry.FormatMinutes(remaining)), - Primary(entry.FormatMinutes(scheduledMinutes)), - Primary(entry.FormatMinutes(loggedMinutes)), + Primary(entry.FormatMinutes(budget.RemainingMinutes)), + Primary(entry.FormatMinutes(budget.ScheduledMinutes)), + Primary(entry.FormatMinutes(budget.LoggedMinutes)), ) } diff --git a/internal/cli/report.go b/internal/cli/report.go index 1fc6e1f..178157f 100644 --- a/internal/cli/report.go +++ b/internal/cli/report.go @@ -277,17 +277,7 @@ func loadReportInputs(homeDir, repoDir, projectFlag, monthFlag, weekFlag, yearFl return nil, err } - logs, err := entry.ReadAllEntries(homeDir, proj.Slug) - if err != nil { - return nil, err - } - - checkouts, err := entry.ReadAllCheckoutEntries(homeDir, proj.Slug) - if err != nil { - return nil, err - } - - commits, err := entry.ReadAllCommitEntries(homeDir, proj.Slug) + entries, err := LoadProjectEntries(homeDir, proj.Slug) if err != nil { return nil, err } @@ -297,16 +287,6 @@ func loadReportInputs(homeDir, repoDir, projectFlag, monthFlag, weekFlag, yearFl return nil, err } - activityStops, err := entry.ReadAllActivityStopEntries(homeDir, proj.Slug) - if err != nil { - return nil, err - } - - activityStarts, err := entry.ReadAllActivityStartEntries(homeDir, proj.Slug) - if err != nil { - return nil, err - } - var weekNum int if weekChanged { // Derive week number from the resolved Monday date @@ -315,13 +295,13 @@ func loadReportInputs(homeDir, repoDir, projectFlag, monthFlag, weekFlag, yearFl return &reportInputs{ proj: proj, - checkouts: checkouts, - logs: logs, - commits: commits, + checkouts: entries.Checkouts, + logs: entries.Logs, + commits: entries.Commits, schedules: daySchedules, submits: submits, - activityStops: activityStops, - activityStarts: activityStarts, + activityStops: entries.ActivityStops, + activityStarts: entries.ActivityStarts, from: from, to: to, year: year, diff --git a/internal/cli/status.go b/internal/cli/status.go index 12bbc6a..c843d39 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -85,12 +85,14 @@ func runStatus( } } - // Last checkout - checkouts, err := entry.ReadAllCheckoutEntries(homeDir, proj.Slug) + // Load all entries once + entries, err := LoadProjectEntries(homeDir, proj.Slug) if err != nil { return err } - if last := findLastCheckout(checkouts); last != nil { + + // Last checkout + if last := findLastCheckout(entries.Checkouts); last != nil { ago := formatDurationAgo(now.Sub(last.Timestamp)) _, _ = fmt.Fprintf(w, "%s %s\n", Silent("Checked out:"), Text(ago+" ago")) } @@ -121,13 +123,8 @@ func runStatus( return nil } - // Compute today's logged time - logs, err := entry.ReadAllEntries(homeDir, proj.Slug) - if err != nil { - return err - } - - // Expand schedules for the whole month (needed by BuildReport) + // Compute today's logged time (with activity-aware idle trimming) + // Expand schedules for the whole month (needed by ComputeDayBudget) monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) monthEnd := time.Date(now.Year(), now.Month()+1, 0, 23, 59, 59, 0, time.UTC) monthSchedules, err := schedule.ExpandSchedules(schedules, monthStart, monthEnd) @@ -135,39 +132,18 @@ func runStatus( return err } - commits, err := entry.ReadAllCommitEntries(homeDir, proj.Slug) - if err != nil { - return err - } - - report := timetrack.BuildReport(checkouts, logs, commits, monthSchedules, now.Year(), now.Month(), now, nil) - - todayMinutes := 0 - for _, row := range report.Rows { - if mins, ok := row.Days[now.Day()]; ok { - todayMinutes += mins - } - } - - // Total scheduled minutes for today - totalScheduled := 0 - for _, win := range todaySchedule.Windows { - fromMins := win.From.Hour*60 + win.From.Minute - toMins := win.To.Hour*60 + win.To.Minute - totalScheduled += toMins - fromMins - } - - remaining := totalScheduled - todayMinutes - if remaining < 0 { - remaining = 0 - } + budget := timetrack.ComputeDayBudget( + entries.Checkouts, entries.Logs, entries.Commits, + monthSchedules, now, now, + timetrack.ActivityEntries{Stops: entries.ActivityStops, Starts: entries.ActivityStarts}, + ) _, _ = fmt.Fprintln(w) _, _ = fmt.Fprintf(w, "%s %s %s %s\n", Silent("Today:"), - Primary(entry.FormatMinutes(todayMinutes)+" logged"), + Primary(entry.FormatMinutes(budget.LoggedMinutes)+" logged"), Silent("·"), - Text(entry.FormatMinutes(remaining)+" remaining"), + Text(entry.FormatMinutes(budget.RemainingMinutes)+" remaining"), ) // Schedule line diff --git a/internal/timetrack/budget.go b/internal/timetrack/budget.go new file mode 100644 index 0000000..af45b9a --- /dev/null +++ b/internal/timetrack/budget.go @@ -0,0 +1,109 @@ +package timetrack + +import ( + "time" + + "github.com/Flyrell/hourgit/internal/entry" + "github.com/Flyrell/hourgit/internal/schedule" +) + +// DayBudget holds the time budget for a single day. +type DayBudget struct { + LoggedMinutes int // total attributed minutes + ScheduledMinutes int // total scheduled working minutes + RemainingMinutes int // max(0, scheduled - logged) +} + +// ComputeDayBudget computes the full time attribution for a day including +// checkout-attributed time (with idle trimming) and manual logs. +// Used by: status command. +func ComputeDayBudget( + checkouts []entry.CheckoutEntry, + logs []entry.Entry, + commits []entry.CommitEntry, + daySchedules []schedule.DaySchedule, + targetDate time.Time, + now time.Time, + activity ...ActivityEntries, +) DayBudget { + year := targetDate.Year() + month := targetDate.Month() + + report := BuildReport(checkouts, logs, commits, daySchedules, year, month, now, nil, activity...) + + day := targetDate.Day() + loggedMinutes := 0 + for _, row := range report.Rows { + if mins, ok := row.Days[day]; ok { + loggedMinutes += mins + } + } + + // Get scheduled minutes for the target day + scheduledMinutes := 0 + for _, ds := range daySchedules { + if ds.Date.Day() == day && ds.Date.Month() == month && ds.Date.Year() == year { + for _, w := range ds.Windows { + scheduledMinutes += windowMinutes(w) + } + break + } + } + + remaining := scheduledMinutes - loggedMinutes + if remaining < 0 { + remaining = 0 + } + + return DayBudget{ + LoggedMinutes: loggedMinutes, + ScheduledMinutes: scheduledMinutes, + RemainingMinutes: remaining, + } +} + +// ComputeManualLogBudget computes the manual log budget for a day, +// counting only manual log entries and submitted/generated entries. +// excludeID, if non-empty, skips the entry with that ID (for edit). +// Used by: log and edit budget warnings. +func ComputeManualLogBudget( + logs []entry.Entry, + daySchedules []schedule.DaySchedule, + targetDate time.Time, + excludeID string, +) DayBudget { + y, m, d := targetDate.Date() + + loggedMinutes := 0 + for _, e := range logs { + if excludeID != "" && e.ID == excludeID { + continue + } + ey, em, ed := e.Start.Date() + if ey == y && em == m && ed == d { + loggedMinutes += e.Minutes + } + } + + // Get scheduled minutes for the target day + scheduledMinutes := 0 + for _, ds := range daySchedules { + if ds.Date.Day() == d && ds.Date.Month() == m && ds.Date.Year() == y { + for _, w := range ds.Windows { + scheduledMinutes += windowMinutes(w) + } + break + } + } + + remaining := scheduledMinutes - loggedMinutes + if remaining < 0 { + remaining = 0 + } + + return DayBudget{ + LoggedMinutes: loggedMinutes, + ScheduledMinutes: scheduledMinutes, + RemainingMinutes: remaining, + } +} diff --git a/internal/timetrack/budget_test.go b/internal/timetrack/budget_test.go new file mode 100644 index 0000000..3b51efc --- /dev/null +++ b/internal/timetrack/budget_test.go @@ -0,0 +1,246 @@ +package timetrack + +import ( + "testing" + "time" + + "github.com/Flyrell/hourgit/internal/entry" + "github.com/Flyrell/hourgit/internal/schedule" + "github.com/stretchr/testify/assert" +) + +func weekdaySchedule(fromH, fromM, toH, toM int) []schedule.DaySchedule { + // Return a week of weekday schedules for June 2025 (Mon=9, Tue=10, Wed=11, Thu=12, Fri=13) + var ds []schedule.DaySchedule + for day := 1; day <= 30; day++ { + d := time.Date(2025, 6, day, 0, 0, 0, 0, time.UTC) + wd := d.Weekday() + if wd == time.Saturday || wd == time.Sunday { + continue + } + ds = append(ds, schedule.DaySchedule{ + Date: d, + Windows: []schedule.TimeWindow{ + { + From: schedule.TimeOfDay{Hour: fromH, Minute: fromM}, + To: schedule.TimeOfDay{Hour: toH, Minute: toM}, + }, + }, + }) + } + return ds +} + +func TestComputeDayBudgetWithCheckouts(t *testing.T) { + now := time.Date(2025, 6, 11, 14, 0, 0, 0, time.UTC) // Wednesday 2pm + daySchedules := weekdaySchedule(9, 0, 17, 0) + + checkouts := []entry.CheckoutEntry{ + { + ID: "abc1234", + Timestamp: time.Date(2025, 6, 11, 9, 0, 0, 0, time.UTC), + Previous: "main", + Next: "feature/auth", + }, + } + + budget := ComputeDayBudget(checkouts, nil, nil, daySchedules, now, now) + + // Checked out at 9am, now is 2pm = 5h = 300 minutes of checkout time + assert.Equal(t, 300, budget.LoggedMinutes) + assert.Equal(t, 480, budget.ScheduledMinutes) + assert.Equal(t, 180, budget.RemainingMinutes) +} + +func TestComputeDayBudgetWithLogs(t *testing.T) { + now := time.Date(2025, 6, 11, 14, 0, 0, 0, time.UTC) + daySchedules := weekdaySchedule(9, 0, 17, 0) + + logs := []entry.Entry{ + { + ID: "abc1234", + Start: time.Date(2025, 6, 11, 9, 0, 0, 0, time.UTC), + Minutes: 150, + Message: "morning work", + }, + } + + budget := ComputeDayBudget(nil, logs, nil, daySchedules, now, now) + + assert.Equal(t, 150, budget.LoggedMinutes) + assert.Equal(t, 480, budget.ScheduledMinutes) + assert.Equal(t, 330, budget.RemainingMinutes) +} + +func TestComputeDayBudgetNonWorkingDay(t *testing.T) { + now := time.Date(2025, 6, 14, 10, 0, 0, 0, time.UTC) // Saturday + daySchedules := weekdaySchedule(9, 0, 17, 0) + + budget := ComputeDayBudget(nil, nil, nil, daySchedules, now, now) + + assert.Equal(t, 0, budget.LoggedMinutes) + assert.Equal(t, 0, budget.ScheduledMinutes) + assert.Equal(t, 0, budget.RemainingMinutes) +} + +func TestComputeDayBudgetWithIdleTrimming(t *testing.T) { + now := time.Date(2025, 6, 11, 14, 0, 0, 0, time.UTC) + daySchedules := weekdaySchedule(9, 0, 17, 0) + + checkouts := []entry.CheckoutEntry{ + { + ID: "abc1234", + Timestamp: time.Date(2025, 6, 11, 9, 0, 0, 0, time.UTC), + Previous: "main", + Next: "feature/auth", + }, + } + + commits := []entry.CommitEntry{ + { + ID: "com1234", + Timestamp: time.Date(2025, 6, 11, 12, 0, 0, 0, time.UTC), + Message: "commit 1", + Branch: "feature/auth", + }, + } + + // Idle from 10:00 to 12:00 (2 hours) + stops := []entry.ActivityStopEntry{ + {ID: "stp1234", Timestamp: time.Date(2025, 6, 11, 10, 0, 0, 0, time.UTC)}, + } + starts := []entry.ActivityStartEntry{ + {ID: "sta1234", Timestamp: time.Date(2025, 6, 11, 12, 0, 0, 0, time.UTC)}, + } + + budgetWithIdle := ComputeDayBudget( + checkouts, nil, commits, daySchedules, now, now, + ActivityEntries{Stops: stops, Starts: starts}, + ) + + budgetWithoutIdle := ComputeDayBudget( + checkouts, nil, commits, daySchedules, now, now, + ) + + // With idle trimming, 2h idle gap should reduce logged time + assert.Less(t, budgetWithIdle.LoggedMinutes, budgetWithoutIdle.LoggedMinutes) + assert.Greater(t, budgetWithIdle.RemainingMinutes, budgetWithoutIdle.RemainingMinutes) +} + +func TestComputeManualLogBudgetBasic(t *testing.T) { + daySchedules := []schedule.DaySchedule{ + { + Date: time.Date(2025, 6, 11, 0, 0, 0, 0, time.UTC), + Windows: []schedule.TimeWindow{ + { + From: schedule.TimeOfDay{Hour: 9, Minute: 0}, + To: schedule.TimeOfDay{Hour: 17, Minute: 0}, + }, + }, + }, + } + + logs := []entry.Entry{ + {ID: "aaa1111", Start: time.Date(2025, 6, 11, 9, 0, 0, 0, time.UTC), Minutes: 240, Message: "work"}, + } + + targetDate := time.Date(2025, 6, 11, 12, 0, 0, 0, time.UTC) + + budget := ComputeManualLogBudget(logs, daySchedules, targetDate, "") + + assert.Equal(t, 240, budget.LoggedMinutes) + assert.Equal(t, 480, budget.ScheduledMinutes) + assert.Equal(t, 240, budget.RemainingMinutes) +} + +func TestComputeManualLogBudgetExcludeID(t *testing.T) { + daySchedules := []schedule.DaySchedule{ + { + Date: time.Date(2025, 6, 11, 0, 0, 0, 0, time.UTC), + Windows: []schedule.TimeWindow{ + { + From: schedule.TimeOfDay{Hour: 9, Minute: 0}, + To: schedule.TimeOfDay{Hour: 17, Minute: 0}, + }, + }, + }, + } + + logs := []entry.Entry{ + {ID: "aaa1111", Start: time.Date(2025, 6, 11, 9, 0, 0, 0, time.UTC), Minutes: 240, Message: "work1"}, + {ID: "bbb2222", Start: time.Date(2025, 6, 11, 13, 0, 0, 0, time.UTC), Minutes: 120, Message: "work2"}, + } + + targetDate := time.Date(2025, 6, 11, 12, 0, 0, 0, time.UTC) + + budget := ComputeManualLogBudget(logs, daySchedules, targetDate, "aaa1111") + + // Only bbb2222 counted (120 minutes) + assert.Equal(t, 120, budget.LoggedMinutes) + assert.Equal(t, 480, budget.ScheduledMinutes) + assert.Equal(t, 360, budget.RemainingMinutes) +} + +func TestComputeManualLogBudgetNoSchedule(t *testing.T) { + // Saturday — no schedule + targetDate := time.Date(2025, 6, 14, 12, 0, 0, 0, time.UTC) + + budget := ComputeManualLogBudget(nil, nil, targetDate, "") + + assert.Equal(t, 0, budget.LoggedMinutes) + assert.Equal(t, 0, budget.ScheduledMinutes) + assert.Equal(t, 0, budget.RemainingMinutes) +} + +func TestComputeManualLogBudgetOverSchedule(t *testing.T) { + daySchedules := []schedule.DaySchedule{ + { + Date: time.Date(2025, 6, 11, 0, 0, 0, 0, time.UTC), + Windows: []schedule.TimeWindow{ + { + From: schedule.TimeOfDay{Hour: 9, Minute: 0}, + To: schedule.TimeOfDay{Hour: 17, Minute: 0}, + }, + }, + }, + } + + logs := []entry.Entry{ + {ID: "aaa1111", Start: time.Date(2025, 6, 11, 9, 0, 0, 0, time.UTC), Minutes: 600, Message: "long day"}, + } + + targetDate := time.Date(2025, 6, 11, 12, 0, 0, 0, time.UTC) + + budget := ComputeManualLogBudget(logs, daySchedules, targetDate, "") + + assert.Equal(t, 600, budget.LoggedMinutes) + assert.Equal(t, 480, budget.ScheduledMinutes) + assert.Equal(t, 0, budget.RemainingMinutes) // clamped to 0 +} + +func TestComputeManualLogBudgetDifferentDay(t *testing.T) { + daySchedules := []schedule.DaySchedule{ + { + Date: time.Date(2025, 6, 11, 0, 0, 0, 0, time.UTC), + Windows: []schedule.TimeWindow{ + { + From: schedule.TimeOfDay{Hour: 9, Minute: 0}, + To: schedule.TimeOfDay{Hour: 17, Minute: 0}, + }, + }, + }, + } + + // Log on a different day + logs := []entry.Entry{ + {ID: "aaa1111", Start: time.Date(2025, 6, 10, 9, 0, 0, 0, time.UTC), Minutes: 240, Message: "yesterday"}, + } + + targetDate := time.Date(2025, 6, 11, 12, 0, 0, 0, time.UTC) + + budget := ComputeManualLogBudget(logs, daySchedules, targetDate, "") + + assert.Equal(t, 0, budget.LoggedMinutes) + assert.Equal(t, 480, budget.ScheduledMinutes) + assert.Equal(t, 480, budget.RemainingMinutes) +}