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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ hourgit edit <hash> [--duration <dur>] [--from <time>] [--to <time>] [--date <da
| `-m`, `--message` | — | New message |
| `-y`, `--yes` | `false` | Skip confirmation prompt |

> `--duration` and `--from`/`--to` are mutually exclusive. `--from` only: keeps existing end time, recalculates duration. `--to` only: keeps existing start time, recalculates duration. Entry ID and creation timestamp are preserved. If the entry is not found in the current repo's project, all projects are searched.
> Any combination of `--duration`, `--from`, and `--to` is allowed. With two flags, the third is computed. With all three, they must be consistent (to - from = duration). `--from` only: keeps duration, shifts the time window. `--to` only: keeps start, recalculates duration. `--duration` only: keeps start, shifts end. Entry ID and creation timestamp are preserved. If the entry is not found in the current repo's project, all projects are searched.

**Examples**

Expand Down
122 changes: 96 additions & 26 deletions internal/cli/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/Flyrell/hourgit/internal/entry"
"github.com/Flyrell/hourgit/internal/project"
"github.com/Flyrell/hourgit/internal/schedule"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -211,11 +212,6 @@ func applyFlagEdits(
hasTo := flagsChanged["to"]
hasDate := flagsChanged["date"]

// Mutual exclusivity
if hasDuration && (hasFrom || hasTo) {
return e, fmt.Errorf("--duration and --from/--to are mutually exclusive")
}

// Handle date shift
if hasDate {
newDate, err := resolveBaseDate(dateFlag, nowFn())
Expand All @@ -227,36 +223,106 @@ func applyFlagEdits(
e.Start = time.Date(y, m, d, e.Start.Hour(), e.Start.Minute(), 0, 0, e.Start.Location())
}

// Handle time changes
if hasDuration {
// Handle time changes — interdependent from/to/duration
switch {
case hasDuration && hasFrom && hasTo:
// All three specified — validate consistency: to - from must equal duration
fromTOD, err := schedule.ParseTimeOfDay(fromFlag)
if err != nil {
return e, fmt.Errorf("invalid --from time: %w", err)
}
toTOD, err := schedule.ParseTimeOfDay(toFlag)
if err != nil {
return e, fmt.Errorf("invalid --to time: %w", err)
}
minutes, err := entry.ParseDuration(durationFlag)
if err != nil {
return e, err
}
fromMins := fromTOD.Hour*60 + fromTOD.Minute
toMins := toTOD.Hour*60 + toTOD.Minute
if toMins <= fromMins {
return e, fmt.Errorf("--to (%s) must be after --from (%s)", toFlag, fromFlag)
}
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))
}
y, m, d := e.Start.Date()
e.Start = time.Date(y, m, d, fromTOD.Hour, fromTOD.Minute, 0, 0, e.Start.Location())
e.Minutes = minutes

case hasDuration && hasFrom:
// --duration --from → set from, set minutes (to = from + duration)
fromTOD, err := schedule.ParseTimeOfDay(fromFlag)
if err != nil {
return e, fmt.Errorf("invalid --from time: %w", err)
}
minutes, err := entry.ParseDuration(durationFlag)
if err != nil {
return e, err
}
y, m, d := e.Start.Date()
e.Start = time.Date(y, m, d, fromTOD.Hour, fromTOD.Minute, 0, 0, e.Start.Location())
e.Minutes = minutes
} else if hasFrom || hasTo {
// Compute current end time
oldEnd := e.Start.Add(time.Duration(e.Minutes) * time.Minute)
oldFromStr := e.Start.Format("15:04")
oldToStr := oldEnd.Format("15:04")

fromStr := oldFromStr
if hasFrom {
fromStr = fromFlag

case hasDuration && hasTo:
// --duration --to → compute from = to - duration, set minutes
toTOD, err := schedule.ParseTimeOfDay(toFlag)
if err != nil {
return e, fmt.Errorf("invalid --to time: %w", err)
}
minutes, err := entry.ParseDuration(durationFlag)
if err != nil {
return e, err
}
toStr := oldToStr
if hasTo {
toStr = toFlag
y, m, d := e.Start.Date()
endTime := time.Date(y, m, d, toTOD.Hour, toTOD.Minute, 0, 0, e.Start.Location())
e.Start = endTime.Add(-time.Duration(minutes) * time.Minute)
e.Minutes = minutes

case hasDuration:
// --duration alone → keep from, update minutes (to shifts)
minutes, err := entry.ParseDuration(durationFlag)
if err != nil {
return e, err
}
e.Minutes = minutes

case hasFrom && hasTo:
// --from --to → compute minutes = to - from
y, m, d := e.Start.Date()
baseDate := time.Date(y, m, d, 0, 0, 0, 0, e.Start.Location())
start, minutes, err := parseFromTo(fromStr, toStr, baseDate)
start, minutes, err := parseFromTo(fromFlag, toFlag, baseDate)
if err != nil {
return e, err
}
e.Start = start
e.Minutes = minutes

case hasFrom:
// --from alone → keep minutes, update from (to shifts)
fromTOD, err := schedule.ParseTimeOfDay(fromFlag)
if err != nil {
return e, fmt.Errorf("invalid --from time: %w", err)
}
y, m, d := e.Start.Date()
e.Start = time.Date(y, m, d, fromTOD.Hour, fromTOD.Minute, 0, 0, e.Start.Location())

case hasTo:
// --to alone → keep from, compute minutes = to - from
toTOD, err := schedule.ParseTimeOfDay(toFlag)
if err != nil {
return e, fmt.Errorf("invalid --to time: %w", err)
}
y, m, d := e.Start.Date()
endTime := time.Date(y, m, d, toTOD.Hour, toTOD.Minute, 0, 0, e.Start.Location())
newMinutes := int(endTime.Sub(e.Start).Minutes())
if newMinutes <= 0 {
return e, fmt.Errorf("--to (%s) must be after entry start (%s)", toTOD, e.Start.Format("15:04"))
}
e.Minutes = newMinutes
}

if flagsChanged["task"] {
Expand Down Expand Up @@ -293,19 +359,23 @@ func applyInteractiveEdits(e entry.Entry, pk PromptKit) (entry.Entry, error) {
if err != nil {
return e, err
}
fromTOD, err := schedule.ParseTimeOfDay(fromStr)
if err != nil {
return e, fmt.Errorf("invalid from time: %w", err)
}

// To
endTime := e.Start.Add(time.Duration(e.Minutes) * time.Minute)
toStr, err := pk.PromptWithDefault("To (e.g. 5pm, 17:00)", endTime.Format("15:04"))
// Duration
durationStr, err := pk.PromptWithDefault("Duration (e.g. 30m, 3h, 3h30m)", entry.FormatMinutes(e.Minutes))
if err != nil {
return e, err
}

start, minutes, err := parseFromTo(fromStr, toStr, newDate)
minutes, err := entry.ParseDuration(durationStr)
if err != nil {
return e, err
}
e.Start = start

y, m, d := newDate.Date()
e.Start = time.Date(y, m, d, fromTOD.Hour, fromTOD.Minute, 0, 0, e.Start.Location())
e.Minutes = minutes

// Task
Expand Down
102 changes: 91 additions & 11 deletions internal/cli/edit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ func TestEditFromToMode(t *testing.T) {
func TestEditFromOnly(t *testing.T) {
homeDir, repoDir, proj, _ := setupEditTest(t)

// Original: 9:00 - 12:00 (180 min). Change from to 10:00, keep end at 12:00.
// Original: 9:00 - 12:00 (180 min). Change from to 10:00, keep duration (3h) → to shifts to 13:00.
flags := map[string]bool{"from": true}
stdout, err := execEdit(homeDir, repoDir, "", "ed01234", flags, "", "10am", "", "", "", "")

Expand All @@ -116,7 +116,7 @@ func TestEditFromOnly(t *testing.T) {
e, err := entry.ReadEntry(homeDir, proj.Slug, "ed01234")
require.NoError(t, err)
assert.Equal(t, 10, e.Start.Hour())
assert.Equal(t, 120, e.Minutes) // 10:00 - 12:00 = 2h
assert.Equal(t, 180, e.Minutes) // duration preserved: 3h
}

func TestEditToOnly(t *testing.T) {
Expand Down Expand Up @@ -211,14 +211,94 @@ func TestEditEmptyMessageError(t *testing.T) {
assert.Contains(t, err.Error(), "message is required")
}

func TestEditDurationFromToMutuallyExclusive(t *testing.T) {
homeDir, repoDir, _, _ := setupEditTest(t)
func TestEditDurationAndFrom(t *testing.T) {
homeDir, repoDir, proj, _ := setupEditTest(t)

// Original: 9:00-12:00 (180 min). --duration 2h --from 10am → 10:00-12:00
flags := map[string]bool{"duration": true, "from": true}
_, err := execEdit(homeDir, repoDir, "", "ed01234", flags, "2h", "9am", "", "", "", "")
stdout, err := execEdit(homeDir, repoDir, "", "ed01234", flags, "2h", "10am", "", "", "", "")

require.NoError(t, err)
assert.Contains(t, stdout, "updated entry")

e, err := entry.ReadEntry(homeDir, proj.Slug, "ed01234")
require.NoError(t, err)
assert.Equal(t, 10, e.Start.Hour())
assert.Equal(t, 120, e.Minutes)
}

func TestEditDurationAndTo(t *testing.T) {
homeDir, repoDir, proj, _ := setupEditTest(t)

// Original: 9:00-12:00 (180 min). --duration 2h --to 2pm → from = 12:00, 2h
flags := map[string]bool{"duration": true, "to": true}
stdout, err := execEdit(homeDir, repoDir, "", "ed01234", flags, "2h", "", "2pm", "", "", "")

require.NoError(t, err)
assert.Contains(t, stdout, "updated entry")

e, err := entry.ReadEntry(homeDir, proj.Slug, "ed01234")
require.NoError(t, err)
assert.Equal(t, 12, e.Start.Hour())
assert.Equal(t, 120, e.Minutes)
}

func TestEditAllThreeConsistent(t *testing.T) {
homeDir, repoDir, proj, _ := setupEditTest(t)

// --from 10am --to 12pm --duration 2h → consistent, should succeed
flags := map[string]bool{"duration": true, "from": true, "to": true}
stdout, err := execEdit(homeDir, repoDir, "", "ed01234", flags, "2h", "10am", "12pm", "", "", "")

require.NoError(t, err)
assert.Contains(t, stdout, "updated entry")

e, err := entry.ReadEntry(homeDir, proj.Slug, "ed01234")
require.NoError(t, err)
assert.Equal(t, 10, e.Start.Hour())
assert.Equal(t, 120, e.Minutes)
}

func TestEditAllThreeInconsistent(t *testing.T) {
homeDir, repoDir, _, _ := setupEditTest(t)

// --from 10am --to 12pm --duration 3h → inconsistent (2h ≠ 3h)
flags := map[string]bool{"duration": true, "from": true, "to": true}
_, err := execEdit(homeDir, repoDir, "", "ed01234", flags, "3h", "10am", "12pm", "", "", "")

assert.Error(t, err)
assert.Contains(t, err.Error(), "does not match")
}

func TestEditToBeforeStart(t *testing.T) {
homeDir, repoDir, _, _ := setupEditTest(t)

// Original: 9:00-12:00. Set --to to 8am → error (before 9:00 start)
flags := map[string]bool{"to": true}
_, err := execEdit(homeDir, repoDir, "", "ed01234", flags, "", "", "8am", "", "", "")

assert.Error(t, err)
assert.Contains(t, err.Error(), "must be after")
}

func TestEditInvalidFromFlag(t *testing.T) {
homeDir, repoDir, _, _ := setupEditTest(t)

flags := map[string]bool{"from": true}
_, err := execEdit(homeDir, repoDir, "", "ed01234", flags, "", "invalid", "", "", "", "")

assert.Error(t, err)
assert.Contains(t, err.Error(), "mutually exclusive")
assert.Contains(t, err.Error(), "invalid --from time")
}

func TestEditInvalidToFlag(t *testing.T) {
homeDir, repoDir, _, _ := setupEditTest(t)

flags := map[string]bool{"to": true}
_, err := execEdit(homeDir, repoDir, "", "ed01234", flags, "", "", "invalid", "", "", "")

assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid --to time")
}

func TestEditExceeds24Hours(t *testing.T) {
Expand Down Expand Up @@ -327,8 +407,8 @@ func TestEditInteractiveMode(t *testing.T) {
return "2025-06-16", nil // same date
case "From (e.g. 9am, 14:00)":
return "10:00", nil // change from 9:00 to 10:00
case "To (e.g. 5pm, 17:00)":
return "12:00", nil // same end
case "Duration (e.g. 30m, 3h, 3h30m)":
return "2h", nil // 2h duration → to = 12:00
case "Task":
return "coding", nil // same task
case "Message":
Expand All @@ -349,7 +429,7 @@ func TestEditInteractiveMode(t *testing.T) {
e, err := entry.ReadEntry(homeDir, proj.Slug, "ed01234")
require.NoError(t, err)
assert.Equal(t, 10, e.Start.Hour())
assert.Equal(t, 120, e.Minutes) // 10:00 - 12:00
assert.Equal(t, 120, e.Minutes) // from 10:00, duration 2h
}

func TestEditRegisteredAsSubcommand(t *testing.T) {
Expand All @@ -376,8 +456,8 @@ func TestEditDateAndFrom(t *testing.T) {
assert.Equal(t, time.August, e.Start.Month())
assert.Equal(t, 1, e.Start.Day())
assert.Equal(t, 10, e.Start.Hour())
// Original end was 12:00, new from is 10:00 → 2h
assert.Equal(t, 120, e.Minutes)
// --from keeps duration (3h), to shifts
assert.Equal(t, 180, e.Minutes)
}

func TestEditScheduleWarningOutsideHours(t *testing.T) {
Expand Down
30 changes: 27 additions & 3 deletions internal/cli/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,13 @@ func runLog(
if err != nil {
return err
}
y, m, d := baseDate.Date()
start = time.Date(y, m, d, now.Hour(), now.Minute(), 0, 0, now.Location()).
Add(-time.Duration(minutes) * time.Minute)
start, err = findDurationSlot(homeDir, proj, baseDate, minutes, now)
if err != nil {
// No schedule or no room — place at beginning of day;
// the schedule warning system will inform the user
y, m, d := baseDate.Date()
start = time.Date(y, m, d, defaultStartHour, 0, 0, 0, now.Location())
}
} else {
if fromFlag == "" {
fromFlag, err = pk.Prompt("From (e.g. 9am, 14:00)")
Expand Down Expand Up @@ -378,6 +382,26 @@ func checkBudgetWarning(
return true, nil
}

// findDurationSlot attempts to find the first available schedule slot for a
// duration-only log entry. Returns an error if no schedule is configured or
// no slot is available.
func findDurationSlot(homeDir string, proj *project.ProjectEntry, baseDate time.Time, minutes int, now time.Time) (time.Time, error) {
windows, _, _, err := getDayScheduleWindows(homeDir, proj, baseDate)
if err != nil {
return time.Time{}, err
}
if len(windows) == 0 {
return time.Time{}, fmt.Errorf("no schedule windows")
}

logs, err := entry.ReadAllEntries(homeDir, proj.Slug)
if err != nil {
return time.Time{}, err
}

return timetrack.FindAvailableSlot(logs, windows, baseDate, minutes, now.Location())
}

// formatWindowsSummary formats schedule windows as a comma-separated summary.
func formatWindowsSummary(windows []schedule.TimeWindow) string {
parts := make([]string, len(windows))
Expand Down
Loading
Loading