diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 44dde95..4afc59f 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -165,6 +165,36 @@ func TestCLITaskRateOverrideCanBeChangedAndCleared(t *testing.T) { } } +func TestCLIEntryRateCanBeChangedAndCleared(t *testing.T) { + stor := cliStorage(t) + for _, args := range [][]string{ + {"rates", "create", "--name", "Old", "--amount-minor", "10000", "--currency", "USD"}, + {"rates", "create", "--name", "Corrected", "--amount-minor", "25000", "--currency", "USD"}, + {"projects", "create", "--name", "Project", "--rate", "1"}, + {"tasks", "create", "--name", "Task", "--project", "1", "--started-at", "2026-01-01 09:00", "--ended-at", "2026-01-01 10:00"}, + } { + runCLI(t, stor, args...) + } + + runCLI(t, stor, "entries", "update", "1", "--rate", "2") + var entry entryOutput + decodeCLI(t, stor, &entry, "--json", "entries", "get", "1") + if entry.RateID == nil || *entry.RateID != 2 { + t.Fatalf("updated entry rate = %#v", entry.RateID) + } + var projects []projectOutput + decodeCLI(t, stor, &projects, "--json", "projects", "list") + if projects[0].EarnedMinor["USD"] != 25_000 { + t.Fatalf("earnings after rate correction = %#v", projects[0].EarnedMinor) + } + + runCLI(t, stor, "entries", "update", "1", "--rate", "0") + decodeCLI(t, stor, &entry, "--json", "entries", "get", "1") + if entry.RateID != nil { + t.Fatalf("cleared entry rate = %#v", entry.RateID) + } +} + func TestCLIProjectMoveAndArchiveKeepHistoricalEarnings(t *testing.T) { stor := cliStorage(t) for _, args := range [][]string{ diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 591f2cd..e17a3e5 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -175,9 +175,14 @@ var commandSpecs = map[string]resourceSpec{ }, }, "update": { - usage: "chankat entries update ID [--started-at TIME] [--ended-at TIME] [--note TEXT]", + usage: "chankat entries update ID [--rate ID|0] [--started-at TIME] [--ended-at TIME] [--note TEXT]", positional: completeEntryID, - options: options("started-at", "ended-at", "note"), + options: []optionSpec{ + {name: "rate", value: completeRateID}, + {name: "started-at"}, + {name: "ended-at"}, + {name: "note"}, + }, }, "delete": { usage: "chankat entries delete ID", positional: completeEntryID, diff --git a/internal/cli/entries.go b/internal/cli/entries.go index 0ab8bc3..d923eb9 100644 --- a/internal/cli/entries.go +++ b/internal/cli/entries.go @@ -149,7 +149,12 @@ func (r runner) updateEntry(args []string) error { if entry.EndedAt != nil { endedDefault = entry.EndedAt.Format(time.RFC3339) } + currentRateID := 0 + if entry.RateID != nil { + currentRateID = *entry.RateID + } flags := r.flags("entries", "update") + rateID := flags.Int("rate", currentRateID, "rate ID; 0 clears the rate") startedAt := flags.String("started-at", entry.StartedAt.Format(time.RFC3339), "RFC3339 or YYYY-MM-DD HH:MM") endedAt := flags.String("ended-at", endedDefault, @@ -177,6 +182,7 @@ func (r runner) updateEntry(args []string) error { } entry.EndedAt = &value } + entry.RateID = optionalID(*rateID) entry.Note = *note if err := r.stor.UpdateEntry(r.ctx, entry); err != nil { return err diff --git a/internal/tui/screens/tasks.go b/internal/tui/screens/tasks.go index 0ac26e1..ceede96 100644 --- a/internal/tui/screens/tasks.go +++ b/internal/tui/screens/tasks.go @@ -1451,7 +1451,7 @@ func taskForm( func(ctx context.Context) error { value := storage.Task{ Name: strings.TrimSpace(values.name), ProjectID: values.projectID, - RateID: optionalTaskRateID(values.rateID), + RateID: optionalRateID(values.rateID), } if task == nil { return stor.CreateTaskAndStart(ctx, value, time.Now()) @@ -1553,7 +1553,7 @@ func historicalTaskForm( ctx, storage.Task{ Name: strings.TrimSpace(name), ProjectID: projectID, - RateID: optionalTaskRateID(rateID), + RateID: optionalRateID(rateID), }, storage.Entry{ StartedAt: started, @@ -1615,7 +1615,7 @@ func taskRateOptions(rates []storage.Rate) []huh.Option[int] { return options } -func optionalTaskRateID(rateID int) *int { +func optionalRateID(rateID int) *int { if rateID == 0 { return nil } @@ -1629,7 +1629,8 @@ type entryItem struct { } type entryMeta struct { - task storage.Task + task storage.Task + rates []storage.Rate } func (e entryItem) Title() string { @@ -1699,11 +1700,13 @@ func newEntryPage( items[i].rate = byID[*items[i].entry.RateID] } } - return items, entryMeta{task: task}, nil + return items, entryMeta{task: task, rates: rates}, nil }, Create: func(meta any) (*components.Form[entryItem], error) { values := meta.(entryMeta) - return entryForm(ctx, stor, values.task, nil, time.Now()), nil + return entryForm( + ctx, stor, values.task, nil, values.rates, time.Now(), + ), nil }, Update: func( item entryItem, @@ -1711,7 +1714,7 @@ func newEntryPage( ) (*components.Form[entryItem], error) { values := meta.(entryMeta) return entryForm( - ctx, stor, values.task, &item.entry, time.Now(), + ctx, stor, values.task, &item.entry, values.rates, time.Now(), ), nil }, Delete: func(item entryItem) *components.Form[entryItem] { @@ -1769,12 +1772,14 @@ func entryForm( stor *storage.Storage, task storage.Task, entry *storage.Entry, + rates []storage.Rate, now time.Time, ) *components.Form[entryItem] { now = now.Truncate(time.Minute) startedAt := components.FormatDateTime(now.Add(-time.Hour)) endedAt := components.FormatDateTime(now) note := "" + rateID := 0 action := "add" if entry != nil { startedAt = components.FormatDateTime(entry.StartedAt) @@ -1783,9 +1788,12 @@ func entryForm( endedAt = components.FormatDateTime(*entry.EndedAt) } note = entry.Note + if entry.RateID != nil { + rateID = *entry.RateID + } action = "edit" } - form := huh.NewForm(huh.NewGroup( + fields := []huh.Field{ huh.NewInput(). Title("Started at (YYYY-MM-DD HH:MM)"). Value(&startedAt). @@ -1794,10 +1802,19 @@ func entryForm( Title("Ended at (blank means active)"). Value(&endedAt). Validate(components.EntryEndTime(&startedAt, entry != nil)), + } + if entry != nil { + fields = append(fields, huh.NewSelect[int](). + Title("Rate"). + Options(entryRateOptions(rates)...). + Value(&rateID)) + } + fields = append(fields, huh.NewInput(). Title("Note"). Value(¬e), - )).WithShowHelp(true) + ) + form := huh.NewForm(huh.NewGroup(fields...)).WithShowHelp(true) return components.NewForm[entryItem]( ctx, @@ -1825,7 +1842,7 @@ func entryForm( value.ID = entry.ID value.TaskID = entry.TaskID value.ProjectID = entry.ProjectID - value.RateID = entry.RateID + value.RateID = optionalRateID(rateID) return stor.UpdateEntry(ctx, value) } return stor.CreateEntryForTask( @@ -1834,3 +1851,16 @@ func entryForm( }, ) } + +func entryRateOptions(rates []storage.Rate) []huh.Option[int] { + options := []huh.Option[int]{huh.NewOption("No rate (unbilled)", 0)} + for _, rate := range rates { + label := fmt.Sprintf( + "%s ยท %s/hour", + rate.Name, + components.FormatMoney(int64(rate.AmountMinor), rate.Currency), + ) + options = append(options, huh.NewOption(label, rate.ID)) + } + return options +} diff --git a/internal/tui/screens/tasks_test.go b/internal/tui/screens/tasks_test.go index 1b17fae..5b947e0 100644 --- a/internal/tui/screens/tasks_test.go +++ b/internal/tui/screens/tasks_test.go @@ -619,3 +619,15 @@ func TestEntryItems(t *testing.T) { t.Fatalf("entry description missing note: %q", items[1].Description()) } } + +func TestEntryRateOptions(t *testing.T) { + options := entryRateOptions([]storage.Rate{ + {ID: 1, Name: "Old", AmountMinor: 10_000, Currency: "USD"}, + {ID: 2, Name: "Corrected", AmountMinor: 20_000, Currency: "USD"}, + }) + if len(options) != 3 || options[0].Value != 0 || + options[1].Value != 1 || options[2].Value != 2 || + !strings.Contains(options[2].Key, "$200.00/hour") { + t.Fatalf("entry rate options = %#v", options) + } +}