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
2 changes: 1 addition & 1 deletion .task/checksum/docs
Original file line number Diff line number Diff line change
@@ -1 +1 @@
b47ffd14ec64cbc5fff8aa93bffea04d
ceffd55971d1be23b048cc38aa1c5d51
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ hourgit version

5. **Export a PDF** for sharing:
```bash
hourgit report --output timesheet.pdf
hourgit report --export pdf
```

## Table of Contents
Expand Down Expand Up @@ -221,7 +221,7 @@ hourgit sync [--project <name>]
Interactive time report with inline editing. Shows tasks (rows) × days (columns) with time attributed from branch checkouts and manual log entries.

```bash
hourgit report [--month <1-12>] [--week <1-53>] [--year <YYYY>] [--project <name>] [--output <path>]
hourgit report [--month <1-12>] [--week <1-53>] [--year <YYYY>] [--project <name>] [--export <format>]
```

| Flag | Default | Description |
Expand All @@ -230,7 +230,7 @@ hourgit report [--month <1-12>] [--week <1-53>] [--year <YYYY>] [--project <name
| `--week` | — | ISO week number 1-53 |
| `--year` | current year | Year (complementary to `--month` or `--week`) |
| `--project` | auto-detect | Project name or ID |
| `--output` | — | Export report as a PDF timesheet to the given path (auto-named if empty) |
| `--export` | — | Export format (`pdf`); auto-generates filename based on period |

> `--month` and `--week` cannot be used together. `--year` alone is not valid — it must be paired with `--month` or `--week`. Neither flag defaults to the current month.

Expand All @@ -254,9 +254,9 @@ Previously submitted periods show a warning banner and can be re-edited and re-s
```bash
hourgit report # current month, interactive
hourgit report --week 8 # ISO week 8
hourgit report --output timesheet.pdf # export PDF
hourgit report --output # auto-named PDF (<project>-<YYYY>-<MM>.pdf)
hourgit report --output report.pdf --month 1 --year 2025
hourgit report --export pdf # export PDF (<project>-<YYYY>-month-<MM>.pdf)
hourgit report --export pdf --week 8 # export PDF (<project>-<YYYY>-week-<WW>.pdf)
hourgit report --export pdf --month 1 --year 2025
```

#### `hourgit history`
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/project_remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ func runProjectRemove(cmd *cobra.Command, homeDir, identifier string, confirm Co
_ = project.RemoveHookFromRepo(repoDir)
}

// Best-effort cleanup: delete the project's time entry directory
_ = os.RemoveAll(project.LogDir(homeDir, entry.Slug))

// Remove project from registry
_, err = project.RemoveProject(homeDir, identifier)
if err != nil {
Expand Down
25 changes: 25 additions & 0 deletions internal/cli/project_remove_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,31 @@ func TestProjectRemoveMissingRepo(t *testing.T) {
assert.Contains(t, stdout, "project 'My Project' removed")
}

func TestProjectRemoveDeletesEntryDirectory(t *testing.T) {
home := t.TempDir()

entry, err := project.CreateProject(home, "My Project")
require.NoError(t, err)

// Create the project's entry directory with a fake entry file
entryDir := project.LogDir(home, entry.Slug)
require.NoError(t, os.MkdirAll(entryDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(entryDir, "abc1234"), []byte(`{"id":"abc1234"}`), 0644))

// Verify directory exists before removal
_, err = os.Stat(entryDir)
require.NoError(t, err)

stdout, err := execProjectRemove(home, "My Project", AlwaysYes())

assert.NoError(t, err)
assert.Contains(t, stdout, "project 'My Project' removed")

// Verify entry directory was deleted
_, err = os.Stat(entryDir)
assert.True(t, os.IsNotExist(err), "entry directory should be deleted")
}

func TestProjectRemoveRegisteredAsSubcommand(t *testing.T) {
commands := projectCmd.Commands()
names := make([]string, len(commands))
Expand Down
30 changes: 22 additions & 8 deletions internal/cli/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type reportInputs struct {
to time.Time
year int
month time.Month
weekNum int // >0 when using --week view
}

var reportCmd = LeafCommand{
Expand All @@ -34,7 +35,7 @@ var reportCmd = LeafCommand{
{Name: "week", Usage: "ISO week number 1-53 (default: current week)"},
{Name: "year", Usage: "year (complementary to --month or --week)"},
{Name: "project", Usage: "project name or ID (auto-detected from repo if omitted)"},
{Name: "output", Usage: "export report as PDF to the given path (auto-named if empty)"},
{Name: "export", Usage: "export format (pdf)"},
},
RunE: func(cmd *cobra.Command, args []string) error {
homeDir, repoDir, err := getContextPaths()
Expand All @@ -46,19 +47,19 @@ var reportCmd = LeafCommand{
monthFlag, _ := cmd.Flags().GetString("month")
weekFlag, _ := cmd.Flags().GetString("week")
yearFlag, _ := cmd.Flags().GetString("year")
outputFlag, _ := cmd.Flags().GetString("output")
exportFlag, _ := cmd.Flags().GetString("export")

monthChanged := cmd.Flags().Changed("month")
weekChanged := cmd.Flags().Changed("week")
yearChanged := cmd.Flags().Changed("year")

return runReport(cmd, homeDir, repoDir, projectFlag, monthFlag, weekFlag, yearFlag, outputFlag, monthChanged, weekChanged, yearChanged, time.Now)
return runReport(cmd, homeDir, repoDir, projectFlag, monthFlag, weekFlag, yearFlag, exportFlag, monthChanged, weekChanged, yearChanged, time.Now)
},
}.Build()

func runReport(
cmd *cobra.Command,
homeDir, repoDir, projectFlag, monthFlag, weekFlag, yearFlag, outputFlag string,
homeDir, repoDir, projectFlag, monthFlag, weekFlag, yearFlag, exportFlag string,
monthChanged, weekChanged, yearChanged bool,
nowFn func() time.Time,
) error {
Expand All @@ -70,7 +71,11 @@ func runReport(
}

// PDF export path
if cmd.Flags().Changed("output") {
if exportFlag != "" {
if exportFlag != "pdf" {
return fmt.Errorf("unsupported export format %q (supported: pdf)", exportFlag)
}

exportData := timetrack.BuildExportData(
inputs.checkouts, inputs.logs, inputs.schedules,
inputs.year, inputs.month, now, nil,
Expand All @@ -82,9 +87,11 @@ func runReport(
return nil
}

outputPath := outputFlag
if outputPath == "" {
outputPath = fmt.Sprintf("%s-%d-%02d.pdf", inputs.proj.Slug, inputs.year, inputs.month)
var outputPath string
if inputs.weekNum > 0 {
outputPath = fmt.Sprintf("%s-%d-week-%02d.pdf", inputs.proj.Slug, inputs.year, inputs.weekNum)
} else {
outputPath = fmt.Sprintf("%s-%d-month-%02d.pdf", inputs.proj.Slug, inputs.year, inputs.month)
}

if err := renderExportPDF(exportData, outputPath); err != nil {
Expand Down Expand Up @@ -273,6 +280,12 @@ func loadReportInputs(homeDir, repoDir, projectFlag, monthFlag, weekFlag, yearFl
return nil, err
}

var weekNum int
if weekChanged {
// Derive week number from the resolved Monday date
_, weekNum = from.ISOWeek()
}

return &reportInputs{
proj: proj,
checkouts: checkouts,
Expand All @@ -283,5 +296,6 @@ func loadReportInputs(homeDir, repoDir, projectFlag, monthFlag, weekFlag, yearFl
to: to,
year: year,
month: month,
weekNum: weekNum,
}, nil
}
63 changes: 38 additions & 25 deletions internal/cli/report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ func TestIsSubmitted(t *testing.T) {
})
}

func execReportWithOutput(t *testing.T, homeDir, repoDir, monthFlag, yearFlag, outputFlag string) (string, error) {
func execReportWithExport(t *testing.T, homeDir, repoDir, monthFlag, weekFlag, yearFlag, exportFlag string) (string, error) {
t.Helper()
stdout := new(bytes.Buffer)

Expand All @@ -285,15 +285,17 @@ func execReportWithOutput(t *testing.T, homeDir, repoDir, monthFlag, yearFlag, o
{Name: "week", Usage: "ISO week number"},
{Name: "year", Usage: "year"},
{Name: "project", Usage: "project name or ID"},
{Name: "output", Usage: "export report as PDF"},
{Name: "export", Usage: "export format (pdf)"},
},
RunE: func(c *cobra.Command, args []string) error {
of, _ := c.Flags().GetString("output")
ef, _ := c.Flags().GetString("export")
mf, _ := c.Flags().GetString("month")
wf, _ := c.Flags().GetString("week")
yf, _ := c.Flags().GetString("year")
mc := c.Flags().Changed("month")
wc := c.Flags().Changed("week")
yc := c.Flags().Changed("year")
return runReport(c, homeDir, repoDir, "", mf, "", yf, of, mc, false, yc, fixedNow)
return runReport(c, homeDir, repoDir, "", mf, wf, yf, ef, mc, wc, yc, fixedNow)
},
}.Build()

Expand All @@ -303,21 +305,20 @@ func execReportWithOutput(t *testing.T, homeDir, repoDir, monthFlag, yearFlag, o
if monthFlag != "" {
cmdArgs = append(cmdArgs, "--month", monthFlag)
}
if weekFlag != "" {
cmdArgs = append(cmdArgs, "--week", weekFlag)
}
if yearFlag != "" {
cmdArgs = append(cmdArgs, "--year", yearFlag)
}
if outputFlag != "" {
cmdArgs = append(cmdArgs, "--output", outputFlag)
} else {
cmdArgs = append(cmdArgs, "--output=")
}
cmdArgs = append(cmdArgs, "--export", exportFlag)
cmd.SetArgs(cmdArgs)

err := cmd.Execute()
return stdout.String(), err
}

func TestReportOutputFlag_GeneratesPDF(t *testing.T) {
func TestReportExportFlag_GeneratesPDF(t *testing.T) {
homeDir, repoDir, proj := setupReportTest(t)

e := entry.Entry{
Expand All @@ -330,35 +331,39 @@ func TestReportOutputFlag_GeneratesPDF(t *testing.T) {
}
require.NoError(t, entry.WriteEntry(homeDir, proj.Slug, e))

outDir := t.TempDir()
outPath := filepath.Join(outDir, "test-output.pdf")
origDir, _ := os.Getwd()
tmpDir := t.TempDir()
require.NoError(t, os.Chdir(tmpDir))
t.Cleanup(func() { _ = os.Chdir(origDir) })

stdout, err := execReportWithOutput(t, homeDir, repoDir, "6", "2025", outPath)
stdout, err := execReportWithExport(t, homeDir, repoDir, "6", "", "2025", "pdf")
require.NoError(t, err)
assert.Contains(t, stdout, "Exported report to")

info, sErr := os.Stat(outPath)
expectedName := fmt.Sprintf("%s-2025-month-06.pdf", proj.Slug)
assert.Contains(t, stdout, "Exported report to "+expectedName)

info, sErr := os.Stat(filepath.Join(tmpDir, expectedName))
require.NoError(t, sErr)
assert.True(t, info.Size() > 0)
}

func TestReportOutputFlag_EmptyMonth(t *testing.T) {
func TestReportExportFlag_EmptyMonth(t *testing.T) {
homeDir, repoDir, _ := setupReportTest(t)

outDir := t.TempDir()
outPath := filepath.Join(outDir, "empty.pdf")
origDir, _ := os.Getwd()
tmpDir := t.TempDir()
require.NoError(t, os.Chdir(tmpDir))
t.Cleanup(func() { _ = os.Chdir(origDir) })

stdout, err := execReportWithOutput(t, homeDir, repoDir, "1", "2025", outPath)
stdout, err := execReportWithExport(t, homeDir, repoDir, "1", "", "2025", "pdf")
require.NoError(t, err)
assert.Contains(t, stdout, "No time entries")

_, sErr := os.Stat(outPath)
assert.True(t, os.IsNotExist(sErr))
}

func TestReportOutputFlag_AutoName(t *testing.T) {
func TestReportExportFlag_WeekAutoName(t *testing.T) {
homeDir, repoDir, proj := setupReportTest(t)

// Week 23 of 2025 starts Mon Jun 2
e := entry.Entry{
ID: "a010010",
Start: time.Date(2025, 6, 2, 10, 0, 0, 0, time.UTC),
Expand All @@ -374,17 +379,25 @@ func TestReportOutputFlag_AutoName(t *testing.T) {
require.NoError(t, os.Chdir(tmpDir))
t.Cleanup(func() { _ = os.Chdir(origDir) })

stdout, err := execReportWithOutput(t, homeDir, repoDir, "6", "2025", "")
stdout, err := execReportWithExport(t, homeDir, repoDir, "", "23", "2025", "pdf")
require.NoError(t, err)

expectedName := fmt.Sprintf("%s-2025-06.pdf", proj.Slug)
expectedName := fmt.Sprintf("%s-2025-week-23.pdf", proj.Slug)
assert.Contains(t, stdout, expectedName)

info, sErr := os.Stat(filepath.Join(tmpDir, expectedName))
require.NoError(t, sErr)
assert.True(t, info.Size() > 0)
}

func TestReportExportFlag_UnsupportedFormat(t *testing.T) {
homeDir, repoDir, _ := setupReportTest(t)

_, err := execReportWithExport(t, homeDir, repoDir, "6", "", "2025", "csv")
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported export format")
}

func TestReportRegisteredAsSubcommand(t *testing.T) {
root := newRootCmd()
names := make([]string, len(root.Commands()))
Expand Down
20 changes: 18 additions & 2 deletions internal/timetrack/timetrack.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package timetrack

import (
"math"
"sort"
"strings"
"time"
Expand Down Expand Up @@ -223,12 +224,15 @@ func buildCheckoutBucket(
if now.Before(lastEnd) {
lastEnd = now
}
lastEnd = lastEnd.Truncate(time.Minute)
for i := range pairs {
if i+1 < len(pairs) {
pairs[i].to = pairs[i+1].from
} else {
pairs[i].to = lastEnd
}
pairs[i].from = pairs[i].from.Truncate(time.Minute)
pairs[i].to = pairs[i].to.Truncate(time.Minute)
}

checkoutBucket := make(map[string]map[int]int)
Expand Down Expand Up @@ -278,10 +282,22 @@ func deductScheduleOverrun(checkoutBucket map[string]map[int]int, logMinsByDay,

if totalCheckoutMins > availableForCheckouts && totalCheckoutMins > 0 {
ratio := float64(availableForCheckouts) / float64(totalCheckoutMins)
roundedSum := 0
largestBranch := ""
largestMins := 0
for branch, dayMap := range checkoutBucket {
dayMap[day] = int(float64(dayMap[day]) * ratio)
dayMap[day] = int(math.Round(float64(dayMap[day]) * ratio))
roundedSum += dayMap[day]
if dayMap[day] > largestMins {
largestMins = dayMap[day]
largestBranch = branch
}
checkoutBucket[branch] = dayMap
}
// Clamp: if rounding pushed the total over available, subtract excess from the largest branch
if excess := roundedSum - availableForCheckouts; excess > 0 && largestBranch != "" {
checkoutBucket[largestBranch][day] -= excess
}
}
}
}
Expand Down Expand Up @@ -532,7 +548,7 @@ func overlapMinutes(from, to time.Time, year int, month time.Month, day int, win
}

if overlapEnd.After(overlapStart) {
total += int(overlapEnd.Sub(overlapStart).Minutes())
total += int(math.Round(overlapEnd.Sub(overlapStart).Minutes()))
}
}
return total
Expand Down
Loading