From 1157ceed1d877cd233ac1417e17e65a37b3bece3 Mon Sep 17 00:00:00 2001 From: Dawid Zbinski Date: Fri, 27 Feb 2026 09:45:05 +0100 Subject: [PATCH] feat: project removal and report export --- .task/checksum/docs | 2 +- README.md | 12 ++--- internal/cli/project_remove.go | 3 ++ internal/cli/project_remove_test.go | 25 ++++++++++ internal/cli/report.go | 30 ++++++++---- internal/cli/report_test.go | 63 ++++++++++++++---------- internal/timetrack/timetrack.go | 20 +++++++- internal/timetrack/timetrack_test.go | 72 ++++++++++++++++++++++++++++ tools/docgen/main.go | 20 ++++---- web/docs/commands/time-tracking.md | 10 ++-- web/docs/quick-start.md | 2 +- 11 files changed, 201 insertions(+), 58 deletions(-) diff --git a/.task/checksum/docs b/.task/checksum/docs index db37458..fe623b9 100644 --- a/.task/checksum/docs +++ b/.task/checksum/docs @@ -1 +1 @@ -b47ffd14ec64cbc5fff8aa93bffea04d +ceffd55971d1be23b048cc38aa1c5d51 diff --git a/README.md b/README.md index 0389cc8..7702b08 100644 --- a/README.md +++ b/README.md @@ -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 @@ -221,7 +221,7 @@ hourgit sync [--project ] 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 ] [--project ] [--output ] +hourgit report [--month <1-12>] [--week <1-53>] [--year ] [--project ] [--export ] ``` | Flag | Default | Description | @@ -230,7 +230,7 @@ hourgit report [--month <1-12>] [--week <1-53>] [--year ] [--project `--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. @@ -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 (--.pdf) -hourgit report --output report.pdf --month 1 --year 2025 +hourgit report --export pdf # export PDF (--month-.pdf) +hourgit report --export pdf --week 8 # export PDF (--week-.pdf) +hourgit report --export pdf --month 1 --year 2025 ``` #### `hourgit history` diff --git a/internal/cli/project_remove.go b/internal/cli/project_remove.go index 5a8a32a..363470a 100644 --- a/internal/cli/project_remove.go +++ b/internal/cli/project_remove.go @@ -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 { diff --git a/internal/cli/project_remove_test.go b/internal/cli/project_remove_test.go index 6a1d1c5..f0038f0 100644 --- a/internal/cli/project_remove_test.go +++ b/internal/cli/project_remove_test.go @@ -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)) diff --git a/internal/cli/report.go b/internal/cli/report.go index c47ce9f..6fd1c82 100644 --- a/internal/cli/report.go +++ b/internal/cli/report.go @@ -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{ @@ -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() @@ -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 { @@ -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, @@ -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 { @@ -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, @@ -283,5 +296,6 @@ func loadReportInputs(homeDir, repoDir, projectFlag, monthFlag, weekFlag, yearFl to: to, year: year, month: month, + weekNum: weekNum, }, nil } diff --git a/internal/cli/report_test.go b/internal/cli/report_test.go index 4b33166..aa8cb77 100644 --- a/internal/cli/report_test.go +++ b/internal/cli/report_test.go @@ -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) @@ -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() @@ -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{ @@ -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), @@ -374,10 +379,10 @@ 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)) @@ -385,6 +390,14 @@ func TestReportOutputFlag_AutoName(t *testing.T) { 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())) diff --git a/internal/timetrack/timetrack.go b/internal/timetrack/timetrack.go index d0db858..bdf68d0 100644 --- a/internal/timetrack/timetrack.go +++ b/internal/timetrack/timetrack.go @@ -1,6 +1,7 @@ package timetrack import ( + "math" "sort" "strings" "time" @@ -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) @@ -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 + } } } } @@ -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 diff --git a/internal/timetrack/timetrack_test.go b/internal/timetrack/timetrack_test.go index 6b2abbc..45673eb 100644 --- a/internal/timetrack/timetrack_test.go +++ b/internal/timetrack/timetrack_test.go @@ -406,6 +406,78 @@ func TestBuildDetailedReport_SortedByTotalDescending(t *testing.T) { assert.Equal(t, "small", report.Rows[1].Name) } +func TestOverlapMinutes_RoundsInsteadOfTruncating(t *testing.T) { + year, month := 2025, time.January + loc := time.UTC + + windows := []schedule.TimeWindow{ + {From: schedule.TimeOfDay{Hour: 9, Minute: 0}, To: schedule.TimeOfDay{Hour: 17, Minute: 0}}, + } + + // Checkout from before day start to now with 59 seconds into the last minute + // This creates an overlap of 479min + 59sec = 479.983... min → should round to 480 + from := time.Date(year, month, 2, 8, 0, 0, 0, loc) + to := time.Date(year, month, 2, 16, 59, 59, 0, loc) + + mins := overlapMinutes(from, to, year, month, 2, windows, loc) + assert.Equal(t, 480, mins, "should round 479.98 to 480, not truncate to 479") +} + +func TestOverlapMinutes_RoundsDownSmallFraction(t *testing.T) { + year, month := 2025, time.January + loc := time.UTC + + windows := []schedule.TimeWindow{ + {From: schedule.TimeOfDay{Hour: 9, Minute: 0}, To: schedule.TimeOfDay{Hour: 17, Minute: 0}}, + } + + // Overlap of exactly 0min 15sec = 0.25 min → should round to 0 + from := time.Date(year, month, 2, 9, 0, 0, 0, loc) + to := time.Date(year, month, 2, 9, 0, 15, 0, loc) + + mins := overlapMinutes(from, to, year, month, 2, windows, loc) + assert.Equal(t, 0, mins, "should round 0.25 to 0") +} + +func TestBuildReport_RoundedCheckoutNeverExceedsSchedule(t *testing.T) { + year, month := 2025, time.January + + days := []schedule.DaySchedule{workday(year, month, 2)} // 480 min schedule + + // Checkout active for the full day + checkouts := []entry.CheckoutEntry{ + {ID: "c1", Timestamp: time.Date(2024, 12, 31, 10, 0, 0, 0, time.UTC), Previous: "main", Next: "feature-x"}, + } + + // now with seconds past the schedule end — truncation to 17:00 aligns with window + now := time.Date(2025, 1, 2, 17, 0, 35, 0, time.UTC) + report := BuildReport(checkouts, nil, days, year, month, now, nil) + + assert.Equal(t, 1, len(report.Rows)) + // Should be exactly 480 (rounded), not 479 (truncated), and not >480 + assert.LessOrEqual(t, report.Rows[0].Days[2], 480) + assert.Equal(t, 480, report.Rows[0].Days[2]) +} + +func TestBuildReport_CheckoutSecondsAreTruncated(t *testing.T) { + year, month := 2025, time.January + + days := []schedule.DaySchedule{workday(year, month, 2)} // 480 min schedule (9-17) + + // Checkout at 9:00:35 — 35 seconds into the first minute. + // Without truncation: overlap = 7h59m25s = 479.417 min → math.Round → 479 (wrong) + // With truncation: from becomes 9:00:00 → overlap = exactly 480 min + checkouts := []entry.CheckoutEntry{ + {ID: "c1", Timestamp: time.Date(2025, 1, 2, 9, 0, 35, 0, time.UTC), Previous: "main", Next: "feature-x"}, + } + + now := afterMonth(year, month) + report := BuildReport(checkouts, nil, days, year, month, now, nil) + + assert.Equal(t, 1, len(report.Rows)) + assert.Equal(t, 480, report.Rows[0].Days[2], "checkout seconds should be truncated, giving exactly 8h") +} + func TestBuildReport_ConsecutiveSameBranchCheckoutsDeduped(t *testing.T) { year, month := 2025, time.January diff --git a/tools/docgen/main.go b/tools/docgen/main.go index e9ac127..2f31be0 100644 --- a/tools/docgen/main.go +++ b/tools/docgen/main.go @@ -10,8 +10,8 @@ import ( "regexp" "strings" - highlighting "github.com/yuin/goldmark-highlighting/v2" "github.com/yuin/goldmark" + highlighting "github.com/yuin/goldmark-highlighting/v2" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer/html" @@ -31,10 +31,10 @@ type NavItem struct { // PageData is the template data for rendering a docs page. type PageData struct { - Title string - Sidebar template.HTML - Content template.HTML - CSSPath string + Title string + Sidebar template.HTML + Content template.HTML + CSSPath string RootPath string } @@ -125,10 +125,10 @@ func main() { // Render full page data := PageData{ - Title: title, - Sidebar: template.HTML(sidebarHTML), - Content: template.HTML(contentHTML), - CSSPath: cssPath, + Title: title, + Sidebar: template.HTML(sidebarHTML), + Content: template.HTML(contentHTML), + CSSPath: cssPath, RootPath: rootPath, } @@ -259,7 +259,7 @@ func mdToLinkPath(mdPath string) string { if filepath.Base(htmlPath) == "index.html" { dir := filepath.Dir(htmlPath) if dir == "." { - return "" + return "." } return dir + "/" } diff --git a/web/docs/commands/time-tracking.md b/web/docs/commands/time-tracking.md index 9fbf94f..2655d50 100644 --- a/web/docs/commands/time-tracking.md +++ b/web/docs/commands/time-tracking.md @@ -115,7 +115,7 @@ hourgit sync [--project ] 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 ] [--project ] [--output ] +hourgit report [--month <1-12>] [--week <1-53>] [--year ] [--project ] [--export ] ``` | Flag | Default | Description | @@ -124,7 +124,7 @@ hourgit report [--month <1-12>] [--week <1-53>] [--year ] [--project `--month` and `--week` cannot be used together. @@ -144,9 +144,9 @@ hourgit report [--month <1-12>] [--week <1-53>] [--year ] [--project