Skip to content
Open
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion internal/cli/history.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 + "]"
}
Expand Down
14 changes: 7 additions & 7 deletions internal/cli/log_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
)
}

Expand Down Expand Up @@ -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),
)
Expand Down
6 changes: 3 additions & 3 deletions internal/cli/log_edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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)),
)
}

Expand Down
2 changes: 1 addition & 1 deletion internal/cli/log_remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
10 changes: 5 additions & 5 deletions internal/cli/report_export.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
}),
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/report_overlay.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
Expand Down Expand Up @@ -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)"
}
Expand Down
14 changes: 7 additions & 7 deletions internal/cli/report_table_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)))
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions internal/entry/duration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
46 changes: 46 additions & 0 deletions internal/entry/duration_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package entry

import (
"strconv"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -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))
})
}
}
2 changes: 1 addition & 1 deletion internal/entry/find.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/timetrack/placement.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}