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
30 changes: 30 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
9 changes: 7 additions & 2 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions internal/cli/entries.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
50 changes: 40 additions & 10 deletions internal/tui/screens/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand All @@ -1629,7 +1629,8 @@ type entryItem struct {
}

type entryMeta struct {
task storage.Task
task storage.Task
rates []storage.Rate
}

func (e entryItem) Title() string {
Expand Down Expand Up @@ -1699,19 +1700,21 @@ 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,
meta any,
) (*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] {
Expand Down Expand Up @@ -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)
Expand All @@ -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).
Expand All @@ -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(&note),
)).WithShowHelp(true)
)
form := huh.NewForm(huh.NewGroup(fields...)).WithShowHelp(true)

return components.NewForm[entryItem](
ctx,
Expand Down Expand Up @@ -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(
Expand All @@ -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
}
12 changes: 12 additions & 0 deletions internal/tui/screens/tasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}