Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions internal/cli/entries.go
Original file line number Diff line number Diff line change
@@ -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
}
92 changes: 92 additions & 0 deletions internal/cli/entries_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
49 changes: 20 additions & 29 deletions internal/cli/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)),
)
}

Expand Down
32 changes: 6 additions & 26 deletions internal/cli/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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,
Expand Down
52 changes: 14 additions & 38 deletions internal/cli/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
Expand Down Expand Up @@ -121,53 +123,27 @@ 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)
if err != nil {
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
Expand Down
Loading
Loading