Skip to content

Commit d11abd9

Browse files
committed
feat: project removal and report export
1 parent 85cf0ba commit d11abd9

File tree

11 files changed

+201
-58
lines changed

11 files changed

+201
-58
lines changed

.task/checksum/docs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
b47ffd14ec64cbc5fff8aa93bffea04d
1+
ceffd55971d1be23b048cc38aa1c5d51

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ hourgit version
8383

8484
5. **Export a PDF** for sharing:
8585
```bash
86-
hourgit report --output timesheet.pdf
86+
hourgit report --export pdf
8787
```
8888

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

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

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

235235
> `--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.
236236
@@ -254,9 +254,9 @@ Previously submitted periods show a warning banner and can be re-edited and re-s
254254
```bash
255255
hourgit report # current month, interactive
256256
hourgit report --week 8 # ISO week 8
257-
hourgit report --output timesheet.pdf # export PDF
258-
hourgit report --output # auto-named PDF (<project>-<YYYY>-<MM>.pdf)
259-
hourgit report --output report.pdf --month 1 --year 2025
257+
hourgit report --export pdf # export PDF (<project>-<YYYY>-month-<MM>.pdf)
258+
hourgit report --export pdf --week 8 # export PDF (<project>-<YYYY>-week-<WW>.pdf)
259+
hourgit report --export pdf --month 1 --year 2025
260260
```
261261

262262
#### `hourgit history`

internal/cli/project_remove.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ func runProjectRemove(cmd *cobra.Command, homeDir, identifier string, confirm Co
6565
_ = project.RemoveHookFromRepo(repoDir)
6666
}
6767

68+
// Best-effort cleanup: delete the project's time entry directory
69+
_ = os.RemoveAll(project.LogDir(homeDir, entry.Slug))
70+
6871
// Remove project from registry
6972
_, err = project.RemoveProject(homeDir, identifier)
7073
if err != nil {

internal/cli/project_remove_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,31 @@ func TestProjectRemoveMissingRepo(t *testing.T) {
127127
assert.Contains(t, stdout, "project 'My Project' removed")
128128
}
129129

130+
func TestProjectRemoveDeletesEntryDirectory(t *testing.T) {
131+
home := t.TempDir()
132+
133+
entry, err := project.CreateProject(home, "My Project")
134+
require.NoError(t, err)
135+
136+
// Create the project's entry directory with a fake entry file
137+
entryDir := project.LogDir(home, entry.Slug)
138+
require.NoError(t, os.MkdirAll(entryDir, 0755))
139+
require.NoError(t, os.WriteFile(filepath.Join(entryDir, "abc1234"), []byte(`{"id":"abc1234"}`), 0644))
140+
141+
// Verify directory exists before removal
142+
_, err = os.Stat(entryDir)
143+
require.NoError(t, err)
144+
145+
stdout, err := execProjectRemove(home, "My Project", AlwaysYes())
146+
147+
assert.NoError(t, err)
148+
assert.Contains(t, stdout, "project 'My Project' removed")
149+
150+
// Verify entry directory was deleted
151+
_, err = os.Stat(entryDir)
152+
assert.True(t, os.IsNotExist(err), "entry directory should be deleted")
153+
}
154+
130155
func TestProjectRemoveRegisteredAsSubcommand(t *testing.T) {
131156
commands := projectCmd.Commands()
132157
names := make([]string, len(commands))

internal/cli/report.go

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type reportInputs struct {
2424
to time.Time
2525
year int
2626
month time.Month
27+
weekNum int // >0 when using --week view
2728
}
2829

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

5152
monthChanged := cmd.Flags().Changed("month")
5253
weekChanged := cmd.Flags().Changed("week")
5354
yearChanged := cmd.Flags().Changed("year")
5455

55-
return runReport(cmd, homeDir, repoDir, projectFlag, monthFlag, weekFlag, yearFlag, outputFlag, monthChanged, weekChanged, yearChanged, time.Now)
56+
return runReport(cmd, homeDir, repoDir, projectFlag, monthFlag, weekFlag, yearFlag, exportFlag, monthChanged, weekChanged, yearChanged, time.Now)
5657
},
5758
}.Build()
5859

5960
func runReport(
6061
cmd *cobra.Command,
61-
homeDir, repoDir, projectFlag, monthFlag, weekFlag, yearFlag, outputFlag string,
62+
homeDir, repoDir, projectFlag, monthFlag, weekFlag, yearFlag, exportFlag string,
6263
monthChanged, weekChanged, yearChanged bool,
6364
nowFn func() time.Time,
6465
) error {
@@ -70,7 +71,11 @@ func runReport(
7071
}
7172

7273
// PDF export path
73-
if cmd.Flags().Changed("output") {
74+
if exportFlag != "" {
75+
if exportFlag != "pdf" {
76+
return fmt.Errorf("unsupported export format %q (supported: pdf)", exportFlag)
77+
}
78+
7479
exportData := timetrack.BuildExportData(
7580
inputs.checkouts, inputs.logs, inputs.schedules,
7681
inputs.year, inputs.month, now, nil,
@@ -82,9 +87,11 @@ func runReport(
8287
return nil
8388
}
8489

85-
outputPath := outputFlag
86-
if outputPath == "" {
87-
outputPath = fmt.Sprintf("%s-%d-%02d.pdf", inputs.proj.Slug, inputs.year, inputs.month)
90+
var outputPath string
91+
if inputs.weekNum > 0 {
92+
outputPath = fmt.Sprintf("%s-%d-week-%02d.pdf", inputs.proj.Slug, inputs.year, inputs.weekNum)
93+
} else {
94+
outputPath = fmt.Sprintf("%s-%d-month-%02d.pdf", inputs.proj.Slug, inputs.year, inputs.month)
8895
}
8996

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

283+
var weekNum int
284+
if weekChanged {
285+
// Derive week number from the resolved Monday date
286+
_, weekNum = from.ISOWeek()
287+
}
288+
276289
return &reportInputs{
277290
proj: proj,
278291
checkouts: checkouts,
@@ -283,5 +296,6 @@ func loadReportInputs(homeDir, repoDir, projectFlag, monthFlag, weekFlag, yearFl
283296
to: to,
284297
year: year,
285298
month: month,
299+
weekNum: weekNum,
286300
}, nil
287301
}

internal/cli/report_test.go

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ func TestIsSubmitted(t *testing.T) {
273273
})
274274
}
275275

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

@@ -285,15 +285,17 @@ func execReportWithOutput(t *testing.T, homeDir, repoDir, monthFlag, yearFlag, o
285285
{Name: "week", Usage: "ISO week number"},
286286
{Name: "year", Usage: "year"},
287287
{Name: "project", Usage: "project name or ID"},
288-
{Name: "output", Usage: "export report as PDF"},
288+
{Name: "export", Usage: "export format (pdf)"},
289289
},
290290
RunE: func(c *cobra.Command, args []string) error {
291-
of, _ := c.Flags().GetString("output")
291+
ef, _ := c.Flags().GetString("export")
292292
mf, _ := c.Flags().GetString("month")
293+
wf, _ := c.Flags().GetString("week")
293294
yf, _ := c.Flags().GetString("year")
294295
mc := c.Flags().Changed("month")
296+
wc := c.Flags().Changed("week")
295297
yc := c.Flags().Changed("year")
296-
return runReport(c, homeDir, repoDir, "", mf, "", yf, of, mc, false, yc, fixedNow)
298+
return runReport(c, homeDir, repoDir, "", mf, wf, yf, ef, mc, wc, yc, fixedNow)
297299
},
298300
}.Build()
299301

@@ -303,21 +305,20 @@ func execReportWithOutput(t *testing.T, homeDir, repoDir, monthFlag, yearFlag, o
303305
if monthFlag != "" {
304306
cmdArgs = append(cmdArgs, "--month", monthFlag)
305307
}
308+
if weekFlag != "" {
309+
cmdArgs = append(cmdArgs, "--week", weekFlag)
310+
}
306311
if yearFlag != "" {
307312
cmdArgs = append(cmdArgs, "--year", yearFlag)
308313
}
309-
if outputFlag != "" {
310-
cmdArgs = append(cmdArgs, "--output", outputFlag)
311-
} else {
312-
cmdArgs = append(cmdArgs, "--output=")
313-
}
314+
cmdArgs = append(cmdArgs, "--export", exportFlag)
314315
cmd.SetArgs(cmdArgs)
315316

316317
err := cmd.Execute()
317318
return stdout.String(), err
318319
}
319320

320-
func TestReportOutputFlag_GeneratesPDF(t *testing.T) {
321+
func TestReportExportFlag_GeneratesPDF(t *testing.T) {
321322
homeDir, repoDir, proj := setupReportTest(t)
322323

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

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

336-
stdout, err := execReportWithOutput(t, homeDir, repoDir, "6", "2025", outPath)
339+
stdout, err := execReportWithExport(t, homeDir, repoDir, "6", "", "2025", "pdf")
337340
require.NoError(t, err)
338-
assert.Contains(t, stdout, "Exported report to")
339341

340-
info, sErr := os.Stat(outPath)
342+
expectedName := fmt.Sprintf("%s-2025-month-06.pdf", proj.Slug)
343+
assert.Contains(t, stdout, "Exported report to "+expectedName)
344+
345+
info, sErr := os.Stat(filepath.Join(tmpDir, expectedName))
341346
require.NoError(t, sErr)
342347
assert.True(t, info.Size() > 0)
343348
}
344349

345-
func TestReportOutputFlag_EmptyMonth(t *testing.T) {
350+
func TestReportExportFlag_EmptyMonth(t *testing.T) {
346351
homeDir, repoDir, _ := setupReportTest(t)
347352

348-
outDir := t.TempDir()
349-
outPath := filepath.Join(outDir, "empty.pdf")
353+
origDir, _ := os.Getwd()
354+
tmpDir := t.TempDir()
355+
require.NoError(t, os.Chdir(tmpDir))
356+
t.Cleanup(func() { _ = os.Chdir(origDir) })
350357

351-
stdout, err := execReportWithOutput(t, homeDir, repoDir, "1", "2025", outPath)
358+
stdout, err := execReportWithExport(t, homeDir, repoDir, "1", "", "2025", "pdf")
352359
require.NoError(t, err)
353360
assert.Contains(t, stdout, "No time entries")
354-
355-
_, sErr := os.Stat(outPath)
356-
assert.True(t, os.IsNotExist(sErr))
357361
}
358362

359-
func TestReportOutputFlag_AutoName(t *testing.T) {
363+
func TestReportExportFlag_WeekAutoName(t *testing.T) {
360364
homeDir, repoDir, proj := setupReportTest(t)
361365

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

377-
stdout, err := execReportWithOutput(t, homeDir, repoDir, "6", "2025", "")
382+
stdout, err := execReportWithExport(t, homeDir, repoDir, "", "23", "2025", "pdf")
378383
require.NoError(t, err)
379384

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

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

393+
func TestReportExportFlag_UnsupportedFormat(t *testing.T) {
394+
homeDir, repoDir, _ := setupReportTest(t)
395+
396+
_, err := execReportWithExport(t, homeDir, repoDir, "6", "", "2025", "csv")
397+
assert.Error(t, err)
398+
assert.Contains(t, err.Error(), "unsupported export format")
399+
}
400+
388401
func TestReportRegisteredAsSubcommand(t *testing.T) {
389402
root := newRootCmd()
390403
names := make([]string, len(root.Commands()))

internal/timetrack/timetrack.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package timetrack
22

33
import (
4+
"math"
45
"sort"
56
"strings"
67
"time"
@@ -223,12 +224,15 @@ func buildCheckoutBucket(
223224
if now.Before(lastEnd) {
224225
lastEnd = now
225226
}
227+
lastEnd = lastEnd.Truncate(time.Minute)
226228
for i := range pairs {
227229
if i+1 < len(pairs) {
228230
pairs[i].to = pairs[i+1].from
229231
} else {
230232
pairs[i].to = lastEnd
231233
}
234+
pairs[i].from = pairs[i].from.Truncate(time.Minute)
235+
pairs[i].to = pairs[i].to.Truncate(time.Minute)
232236
}
233237

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

279283
if totalCheckoutMins > availableForCheckouts && totalCheckoutMins > 0 {
280284
ratio := float64(availableForCheckouts) / float64(totalCheckoutMins)
285+
roundedSum := 0
286+
largestBranch := ""
287+
largestMins := 0
281288
for branch, dayMap := range checkoutBucket {
282-
dayMap[day] = int(float64(dayMap[day]) * ratio)
289+
dayMap[day] = int(math.Round(float64(dayMap[day]) * ratio))
290+
roundedSum += dayMap[day]
291+
if dayMap[day] > largestMins {
292+
largestMins = dayMap[day]
293+
largestBranch = branch
294+
}
283295
checkoutBucket[branch] = dayMap
284296
}
297+
// Clamp: if rounding pushed the total over available, subtract excess from the largest branch
298+
if excess := roundedSum - availableForCheckouts; excess > 0 && largestBranch != "" {
299+
checkoutBucket[largestBranch][day] -= excess
300+
}
285301
}
286302
}
287303
}
@@ -532,7 +548,7 @@ func overlapMinutes(from, to time.Time, year int, month time.Month, day int, win
532548
}
533549

534550
if overlapEnd.After(overlapStart) {
535-
total += int(overlapEnd.Sub(overlapStart).Minutes())
551+
total += int(math.Round(overlapEnd.Sub(overlapStart).Minutes()))
536552
}
537553
}
538554
return total

0 commit comments

Comments
 (0)