From 46628e9febedcdf4ce87168318f5191f5160dc91 Mon Sep 17 00:00:00 2001 From: Dawid Zbinski Date: Mon, 1 Jun 2026 17:55:23 +0200 Subject: [PATCH] wip: round displayed durations to nearest 15 minutes Displayed durations are now rounded to the nearest 15-minute interval across status, history, report (table and PDF export), and log output. This keeps timesheets readable without altering the underlying data: stored time remains precise and only the presentation is rounded. Adds RoundMinutes(m, interval) and FormatMinutesRounded(m) in internal/entry/duration.go, with table-driven tests. Display-only call sites swap FormatMinutes for FormatMinutesRounded. Interactive input defaults that round-trip through ParseDuration (log_edit.go duration prompt, report_overlay.go form fields) deliberately keep the unrounded FormatMinutes, so editing an entry never silently mutates its stored duration. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 12 ++++++++ internal/cli/history.go | 2 +- internal/cli/log_add.go | 14 +++++----- internal/cli/log_edit.go | 6 ++-- internal/cli/log_remove.go | 2 +- internal/cli/report_export.go | 10 +++---- internal/cli/report_overlay.go | 4 +-- internal/cli/report_table_view.go | 14 +++++----- internal/cli/status.go | 4 +-- internal/entry/duration.go | 17 ++++++++++++ internal/entry/duration_test.go | 46 +++++++++++++++++++++++++++++++ internal/entry/find.go | 2 +- internal/timetrack/placement.go | 2 +- 13 files changed, 105 insertions(+), 30 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8dbaf7f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed + +- Displayed durations are now rounded to the nearest 15 minutes across `status`, `history`, `report` (table and PDF export), and `log` output. Stored time remains precise — rounding is display-only and does not affect logged data or interactive edit defaults. diff --git a/internal/cli/history.go b/internal/cli/history.go index 9540848..c84dfa9 100644 --- a/internal/cli/history.go +++ b/internal/cli/history.go @@ -72,7 +72,7 @@ func runHistory(cmd *cobra.Command, homeDir, projectFlag string, limit int) erro return err } for _, e := range logs { - detail := entry.FormatMinutes(e.Minutes) + detail := entry.FormatMinutesRounded(e.Minutes) if e.Task != "" { detail += " [" + e.Task + "]" } diff --git a/internal/cli/log_add.go b/internal/cli/log_add.go index 7d38426..8f2fe78 100644 --- a/internal/cli/log_add.go +++ b/internal/cli/log_add.go @@ -359,16 +359,16 @@ func checkBudgetWarning( 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(budget.ScheduledMinutes)), - Primary(entry.FormatMinutes(budget.LoggedMinutes)), + Primary(entry.FormatMinutesRounded(budget.ScheduledMinutes)), + Primary(entry.FormatMinutesRounded(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(budget.RemainingMinutes)), - Primary(entry.FormatMinutes(budget.ScheduledMinutes)), - Primary(entry.FormatMinutes(budget.LoggedMinutes)), + Primary(entry.FormatMinutesRounded(minutes)), + Primary(entry.FormatMinutesRounded(budget.RemainingMinutes)), + Primary(entry.FormatMinutesRounded(budget.ScheduledMinutes)), + Primary(entry.FormatMinutesRounded(budget.LoggedMinutes)), ) } @@ -434,7 +434,7 @@ func writeAndPrintEntry( } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "logged %s for project '%s' (%s)\n", - Primary(entry.FormatMinutes(e.Minutes)), + Primary(entry.FormatMinutesRounded(e.Minutes)), Primary(proj.Name), Silent(e.ID), ) diff --git a/internal/cli/log_edit.go b/internal/cli/log_edit.go index 142da4a..520eef3 100644 --- a/internal/cli/log_edit.go +++ b/internal/cli/log_edit.go @@ -247,7 +247,7 @@ func applyFlagEdits( computed := toMins - fromMins if computed != minutes { return e, fmt.Errorf("--duration (%s) does not match --from/%s to --to/%s (%s)", - durationFlag, fromFlag, toFlag, entry.FormatMinutes(computed)) + durationFlag, fromFlag, toFlag, entry.FormatMinutesRounded(computed)) } y, m, d := e.Start.Date() e.Start = time.Date(y, m, d, fromTOD.Hour, fromTOD.Minute, 0, 0, e.Start.Location()) @@ -407,8 +407,8 @@ func printEditDiff(cmd *cobra.Command, before, after entry.Entry) { if before.Minutes != after.Minutes { _, _ = fmt.Fprintf(w, " duration: %s → %s\n", - Silent(entry.FormatMinutes(before.Minutes)), - Primary(entry.FormatMinutes(after.Minutes)), + Silent(entry.FormatMinutesRounded(before.Minutes)), + Primary(entry.FormatMinutesRounded(after.Minutes)), ) } diff --git a/internal/cli/log_remove.go b/internal/cli/log_remove.go index 2d19fca..3187795 100644 --- a/internal/cli/log_remove.go +++ b/internal/cli/log_remove.go @@ -107,7 +107,7 @@ func locateAnyEntryInProject(homeDir, slug, hash string) (string, string, string // Try as log entry e, err := entry.ReadEntry(homeDir, slug, hash) if err == nil { - detail := fmt.Sprintf("%s — %s", entry.FormatMinutes(e.Minutes), e.Message) + detail := fmt.Sprintf("%s — %s", entry.FormatMinutesRounded(e.Minutes), e.Message) if e.Task != "" { detail = fmt.Sprintf("[%s] %s", e.Task, detail) } diff --git a/internal/cli/report_export.go b/internal/cli/report_export.go index 76f7199..d9a076a 100644 --- a/internal/cli/report_export.go +++ b/internal/cli/report_export.go @@ -55,7 +55,7 @@ func renderExportPDF(data timetrack.ExportData, outputPath string) error { weekday := day.Date.Weekday() dayLabel := fmt.Sprintf("%s %d, %s", day.Date.Month(), day.Date.Day(), weekday) - dayTotal := entry.FormatMinutes(day.TotalMinutes) + dayTotal := entry.FormatMinutesRounded(day.TotalMinutes) // Day header row m.AddRow(8, @@ -78,7 +78,7 @@ func renderExportPDF(data timetrack.ExportData, outputPath string) error { // Single-entry group where task == message: show as standalone row m.AddRow(6, text.NewCol(9, " "+group.Task, props.Text{Size: 9}), - text.NewCol(3, entry.FormatMinutes(group.TotalMinutes), props.Text{ + text.NewCol(3, entry.FormatMinutesRounded(group.TotalMinutes), props.Text{ Size: 9, Align: align.Right, }), @@ -90,7 +90,7 @@ func renderExportPDF(data timetrack.ExportData, outputPath string) error { Style: fontstyle.Bold, Size: 9, }), - text.NewCol(3, entry.FormatMinutes(group.TotalMinutes), props.Text{ + text.NewCol(3, entry.FormatMinutesRounded(group.TotalMinutes), props.Text{ Style: fontstyle.Bold, Size: 9, Align: align.Right, @@ -104,7 +104,7 @@ func renderExportPDF(data timetrack.ExportData, outputPath string) error { Size: 8, Color: &pdfMutedColor, }), - text.NewCol(3, entry.FormatMinutes(e.Minutes), props.Text{ + text.NewCol(3, entry.FormatMinutesRounded(e.Minutes), props.Text{ Size: 8, Align: align.Right, Color: &pdfMutedColor, @@ -126,7 +126,7 @@ func renderExportPDF(data timetrack.ExportData, outputPath string) error { Size: 12, Color: &pdfHeaderColor, }), - text.NewCol(3, entry.FormatMinutes(data.TotalMinutes), props.Text{ + text.NewCol(3, entry.FormatMinutesRounded(data.TotalMinutes), props.Text{ Style: fontstyle.Bold, Size: 12, Align: align.Right, diff --git a/internal/cli/report_overlay.go b/internal/cli/report_overlay.go index a2cfdde..3ab10da 100644 --- a/internal/cli/report_overlay.go +++ b/internal/cli/report_overlay.go @@ -190,7 +190,7 @@ func (o *entrySelectorOverlay) View() string { b.WriteString("\n\n") for i, e := range o.entries { - label := fmt.Sprintf("%s %s", entry.FormatMinutes(e.Minutes), e.Message) + label := fmt.Sprintf("%s %s", entry.FormatMinutesRounded(e.Minutes), e.Message) if !e.Persisted { label += " (generated)" } @@ -641,7 +641,7 @@ func (o *removeOverlay) View() string { b.WriteString(overlayTitleStyle.Render("Remove Entry")) b.WriteString("\n\n") - label := fmt.Sprintf("%s %s", entry.FormatMinutes(o.entry.Minutes), o.entry.Message) + label := fmt.Sprintf("%s %s", entry.FormatMinutesRounded(o.entry.Minutes), o.entry.Message) if !o.entry.Persisted { label += " (generated)" } diff --git a/internal/cli/report_table_view.go b/internal/cli/report_table_view.go index 72c5ac2..77382e6 100644 --- a/internal/cli/report_table_view.go +++ b/internal/cli/report_table_view.go @@ -47,7 +47,7 @@ func (m reportModel) renderDetailPanel() string { marker = "> " } - durStr := entry.FormatMinutes(ce.Minutes) + durStr := entry.FormatMinutesRounded(ce.Minutes) msg := ce.Message if msg == "" { msg = "(no message)" @@ -140,7 +140,7 @@ func renderDetailedTable(data timetrack.DetailedReportData, scrollX, scrollY, vi // Sum column b.WriteString(" | ") - b.WriteString(padCenter(entry.FormatMinutes(row.TotalMinutes), dayColWidth)) + b.WriteString(padCenter(entry.FormatMinutesRounded(row.TotalMinutes), dayColWidth)) for i := 0; i < visibleDays; i++ { day := scrollX + i + 1 @@ -153,7 +153,7 @@ func renderDetailedTable(data timetrack.DetailedReportData, scrollX, scrollY, vi cd := row.Days[day] cellText := "" if cd != nil && cd.TotalMinutes > 0 { - cellText = padCenter(entry.FormatMinutes(cd.TotalMinutes), dayColWidth) + cellText = padCenter(entry.FormatMinutesRounded(cd.TotalMinutes), dayColWidth) // Mark cells containing in-memory entries with an asterisk hasInMemory := false for _, ce := range cd.Entries { @@ -163,7 +163,7 @@ func renderDetailedTable(data timetrack.DetailedReportData, scrollX, scrollY, vi } } if hasInMemory { - cellText = padCenter(entry.FormatMinutes(cd.TotalMinutes)+"*", dayColWidth) + cellText = padCenter(entry.FormatMinutesRounded(cd.TotalMinutes)+"*", dayColWidth) } } else if !scheduled { cellText = padCenter("x", dayColWidth) @@ -207,7 +207,7 @@ func renderDetailedTable(data timetrack.DetailedReportData, scrollX, scrollY, vi // Grand total in Sum column b.WriteString(" | ") - b.WriteString(headerStyle.Render(padCenter(entry.FormatMinutes(totalMinutes), dayColWidth))) + b.WriteString(headerStyle.Render(padCenter(entry.FormatMinutesRounded(totalMinutes), dayColWidth))) for i := 0; i < visibleDays; i++ { day := scrollX + i + 1 @@ -225,9 +225,9 @@ func renderDetailedTable(data timetrack.DetailedReportData, scrollX, scrollY, vi } if dayTotal > 0 { if weekend { - b.WriteString(weekendStyle.Bold(true).Render(padCenter(entry.FormatMinutes(dayTotal), dayColWidth))) + b.WriteString(weekendStyle.Bold(true).Render(padCenter(entry.FormatMinutesRounded(dayTotal), dayColWidth))) } else { - b.WriteString(headerStyle.Render(padCenter(entry.FormatMinutes(dayTotal), dayColWidth))) + b.WriteString(headerStyle.Render(padCenter(entry.FormatMinutesRounded(dayTotal), dayColWidth))) } } else if !scheduled { b.WriteString(unscheduledStyle.Render(padCenter("x", dayColWidth))) diff --git a/internal/cli/status.go b/internal/cli/status.go index c843d39..5e09233 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -141,9 +141,9 @@ func runStatus( _, _ = fmt.Fprintln(w) _, _ = fmt.Fprintf(w, "%s %s %s %s\n", Silent("Today:"), - Primary(entry.FormatMinutes(budget.LoggedMinutes)+" logged"), + Primary(entry.FormatMinutesRounded(budget.LoggedMinutes)+" logged"), Silent("·"), - Text(entry.FormatMinutes(budget.RemainingMinutes)+" remaining"), + Text(entry.FormatMinutesRounded(budget.RemainingMinutes)+" remaining"), ) // Schedule line diff --git a/internal/entry/duration.go b/internal/entry/duration.go index cf6b876..00f3a5a 100644 --- a/internal/entry/duration.go +++ b/internal/entry/duration.go @@ -54,3 +54,20 @@ func FormatMinutes(m int) string { return strings.Join(parts, " ") } + +// RoundMinutes rounds m to the nearest multiple of interval. +// Midpoint rounds up (interval=15: 7→0, 8→15). Non-positive m or +// interval<=1 returns m unchanged. +func RoundMinutes(m, interval int) int { + if m <= 0 || interval <= 1 { + return m + } + return ((m + interval/2) / interval) * interval +} + +// FormatMinutesRounded formats minutes, rounded to the nearest 15 minutes. +// Use at display sites only — never to pre-fill interactive input defaults, +// which round-trip through ParseDuration. +func FormatMinutesRounded(m int) string { + return FormatMinutes(RoundMinutes(m, 15)) +} diff --git a/internal/entry/duration_test.go b/internal/entry/duration_test.go index 12aef7c..2ae88c5 100644 --- a/internal/entry/duration_test.go +++ b/internal/entry/duration_test.go @@ -1,6 +1,7 @@ package entry import ( + "strconv" "testing" "github.com/stretchr/testify/assert" @@ -65,3 +66,48 @@ func TestFormatMinutes(t *testing.T) { }) } } + +func TestRoundMinutes(t *testing.T) { + tests := []struct { + input int + interval int + want int + }{ + {0, 15, 0}, + {7, 15, 0}, + {8, 15, 15}, + {14, 15, 15}, + {15, 15, 15}, + {22, 15, 15}, + {23, 15, 30}, + {187, 15, 180}, + {188, 15, 195}, + {-5, 15, -5}, + {10, 1, 10}, // interval<=1 passes through unchanged + {10, 0, 10}, // degenerate interval passes through unchanged + } + + for _, tt := range tests { + t.Run(strconv.Itoa(tt.input)+"@"+strconv.Itoa(tt.interval), func(t *testing.T) { + assert.Equal(t, tt.want, RoundMinutes(tt.input, tt.interval)) + }) + } +} + +func TestFormatMinutesRounded(t *testing.T) { + tests := []struct { + input int + want string + }{ + {7, "0m"}, + {8, "15m"}, + {191, "3h 15m"}, + {187, "3h"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + assert.Equal(t, tt.want, FormatMinutesRounded(tt.input)) + }) + } +} diff --git a/internal/entry/find.go b/internal/entry/find.go index 90028d0..754f1c6 100644 --- a/internal/entry/find.go +++ b/internal/entry/find.go @@ -89,7 +89,7 @@ func FindAnyEntryAcrossProjects(homeDir, id string) (*FoundAnyEntry, error) { // Try as log entry e, err := ReadEntry(homeDir, slug, id) if err == nil { - detail := fmt.Sprintf("%s — %s", FormatMinutes(e.Minutes), e.Message) + detail := fmt.Sprintf("%s — %s", FormatMinutesRounded(e.Minutes), e.Message) if e.Task != "" { detail = fmt.Sprintf("[%s] %s", e.Task, detail) } diff --git a/internal/timetrack/placement.go b/internal/timetrack/placement.go index bdc51f0..4fadd9c 100644 --- a/internal/timetrack/placement.go +++ b/internal/timetrack/placement.go @@ -73,5 +73,5 @@ func FindAvailableSlot( } } - return time.Time{}, fmt.Errorf("no available slot for %s in today's schedule", entry.FormatMinutes(minutes)) + return time.Time{}, fmt.Errorf("no available slot for %s in today's schedule", entry.FormatMinutesRounded(minutes)) }