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
41 changes: 41 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,47 @@ func TestCLITaskRateOverrideCanBeChangedAndCleared(t *testing.T) {
}
}

func TestCLIProjectMoveAndArchiveKeepHistoricalEarnings(t *testing.T) {
stor := cliStorage(t)
for _, args := range [][]string{
{"rates", "create", "--name", "Old", "--amount-minor", "10000", "--currency", "USD"},
{"rates", "create", "--name", "New", "--amount-minor", "20000", "--currency", "USD"},
{"projects", "create", "--name", "One", "--rate", "1"},
{"projects", "create", "--name", "Two", "--rate", "2"},
{"tasks", "create", "--name", "History", "--project", "1", "--started-at", "2026-01-01 09:00", "--ended-at", "2026-01-01 10:00"},
{"tasks", "update", "1", "--project", "2"},
{"payments", "create", "--project", "2", "--amount-minor", "4000", "--currency", "USD", "--paid-at", "2026-01-01"},
{"tasks", "delete", "1"},
} {
runCLI(t, stor, args...)
}
var projects []projectOutput
decodeCLI(t, stor, &projects, "--json", "projects", "list")
if projects[0].TrackedSeconds != 0 || projects[0].BalanceMinor["USD"] != 0 ||
projects[1].EarnedMinor["USD"] != 10000 || projects[1].PaidMinor["USD"] != 4000 || projects[1].BalanceMinor["USD"] != 6000 {
t.Fatalf("projects = %#v", projects)
}
var tasks []taskOutput
decodeCLI(t, stor, &tasks, "--json", "tasks", "list")
if len(tasks) != 0 {
t.Fatal("archived task visible")
}
decodeCLI(t, stor, &tasks, "--json", "tasks", "list", "--archived")
if len(tasks) != 1 || !tasks[0].Archived || tasks[0].RateID != 2 ||
len(tasks[0].HistoricalRates) != 1 || tasks[0].HistoricalRates[0].ID != 1 || tasks[0].EarnedMinor["USD"] != 10000 {
t.Fatalf("archived history = %#v", tasks)
}
text := runCLI(t, stor, "tasks", "get", "1")
if !strings.Contains(text, "NEXT_RATE") || !strings.Contains(text, "1:USD:10000") {
t.Fatalf("task output: %s", text)
}
runCLI(t, stor, "tasks", "restore", "1")
decodeCLI(t, stor, &tasks, "--json", "tasks", "list")
if len(tasks) != 1 || tasks[0].Archived || tasks[0].Active {
t.Fatalf("restored task = %#v", tasks)
}
}

func TestCLIStopsAllTasks(t *testing.T) {
stor := cliStorage(t)
runCLI(t, stor, "rates", "create", "--name", "Rate",
Expand Down
6 changes: 4 additions & 2 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,13 @@ var commandSpecs = map[string]resourceSpec{
},
},
"tasks": {
actions: []string{"list", "get", "create", "update", "delete", "start", "stop"},
actions: []string{"list", "get", "create", "update", "delete", "restore", "start", "stop"},
commands: map[string]commandSpec{
"list": {
usage: "chankat tasks list [--active]",
usage: "chankat tasks list [--active] [--archived]",
options: []optionSpec{
{name: "active", boolean: true},
{name: "archived", boolean: true},
},
},
"get": {
Expand Down Expand Up @@ -136,6 +137,7 @@ var commandSpecs = map[string]resourceSpec{
"delete": {
usage: "chankat tasks delete ID", positional: completeTaskID,
},
"restore": {usage: "chankat tasks restore ID"},
"start": {
usage: "chankat tasks start ID [--at TIME]",
positional: completeTaskID,
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ func (r runner) runDashboard(args []string) error {
}
projects = []storage.Project{project}
}
tasks, err := r.stor.GetTasks(r.ctx)
tasks, err := r.stor.GetTasksIncludingArchived(r.ctx)
if err != nil {
return fmt.Errorf("load dashboard tasks: %w", err)
}
Expand Down
13 changes: 13 additions & 0 deletions internal/cli/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ type projectOutput struct {
RateName string `json:"rate_name"`
TrackedSeconds int64 `json:"tracked_seconds"`
BalanceMinor map[string]int64 `json:"balance_minor"`
EarnedMinor map[string]int64 `json:"earned_minor"`
PaidMinor map[string]int64 `json:"paid_minor"`
}

type taskOutput struct {
Expand All @@ -36,6 +38,8 @@ type taskOutput struct {
RateAmountMinor int `json:"rate_amount_minor"`
RateCurrency string `json:"rate_currency"`
RateOverridden bool `json:"rate_overridden"`
Archived bool `json:"archived"`
HistoricalRates []rateOutput `json:"historical_rates"`
Active bool `json:"active"`
LastEndedAt *string `json:"last_ended_at"`
TrackedSeconds int64 `json:"tracked_seconds"`
Expand Down Expand Up @@ -120,6 +124,8 @@ func projectOutputs(items []storage.ProjectSummary) []projectOutput {
RateName: item.Rate.Name,
TrackedSeconds: int64(item.Tracked / time.Second),
BalanceMinor: item.BalanceMinor,
EarnedMinor: item.EarnedMinor,
PaidMinor: item.PaidMinor,
}
}
return result
Expand All @@ -138,11 +144,18 @@ func taskOutputs(items []storage.TaskSummary) []taskOutput {
ProjectName: item.Project.Name, RateID: item.Rate.ID,
RateName: item.Rate.Name, RateAmountMinor: item.Rate.AmountMinor,
RateCurrency: item.Rate.Currency, RateOverridden: item.RateOverridden,
Archived: item.Archived, HistoricalRates: make([]rateOutput, 0),
Active: item.Active,
LastEndedAt: lastEndedAt,
TrackedSeconds: int64(item.Tracked / time.Second),
EarnedMinor: item.EarnedMinor,
}
for _, rate := range item.HistoricalRates {
result[i].HistoricalRates = append(result[i].HistoricalRates, rateOutput{
ID: rate.ID, Name: rate.Name,
AmountMinor: rate.AmountMinor, Currency: rate.Currency,
})
}
}
return result
}
Expand Down
17 changes: 11 additions & 6 deletions internal/cli/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,15 @@ func (r runner) listProjects(args []string) error {
}
rows := make([]string, len(output))
for i, item := range output {
rows[i] = fmt.Sprintf("%d\t%s\t%d\t%s\t%s\t%v", item.ID, item.Name,
rows[i] = fmt.Sprintf("%d\t%s\t%d\t%s\t%s\t%s\t%s\t%s", item.ID, item.Name,
item.RateID, item.RateName, formatTracked(item.TrackedSeconds),
item.BalanceMinor)
formatMinorMap(item.EarnedMinor), formatMinorMap(item.PaidMinor),
formatMinorMap(item.BalanceMinor))
}
return r.table("ID\tNAME\tRATE_ID\tRATE\tTRACKED\tBALANCE_MINOR", rows)
return r.table(
"ID\tNAME\tCURRENT_RATE_ID\tCURRENT_RATE\tTRACKED\tEARNED_MINOR\tPAID_MINOR\tBALANCE_MINOR",
rows,
)
}

func (r runner) getProject(args []string) error {
Expand All @@ -90,10 +94,11 @@ func (r runner) getProject(args []string) error {
if r.json {
return r.writeJSON(output)
}
return r.table("ID\tNAME\tRATE_ID\tRATE\tTRACKED\tBALANCE_MINOR",
[]string{fmt.Sprintf("%d\t%s\t%d\t%s\t%s\t%v", output.ID,
return r.table("ID\tNAME\tCURRENT_RATE_ID\tCURRENT_RATE\tTRACKED\tEARNED_MINOR\tPAID_MINOR\tBALANCE_MINOR",
[]string{fmt.Sprintf("%d\t%s\t%d\t%s\t%s\t%s\t%s\t%s", output.ID,
output.Name, output.RateID, output.RateName,
formatTracked(output.TrackedSeconds), output.BalanceMinor)})
formatTracked(output.TrackedSeconds), formatMinorMap(output.EarnedMinor),
formatMinorMap(output.PaidMinor), formatMinorMap(output.BalanceMinor))})
}
}
return fmt.Errorf("project %d not found", id)
Expand Down
67 changes: 48 additions & 19 deletions internal/cli/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ func (r runner) runTasks(args []string) error {
return r.updateTask(args[1:])
case "delete":
return r.deleteTask(args[1:])
case "restore":
return r.restoreTask(args[1:])
case "start":
return r.startTask(args[1:])
case "stop":
Expand All @@ -36,7 +38,7 @@ func (r runner) runTasks(args []string) error {
}

func (r runner) loadTasks() ([]storage.TaskSummary, error) {
tasks, err := r.stor.GetTasks(r.ctx)
tasks, err := r.stor.GetTasksIncludingArchived(r.ctx)
if err != nil {
return nil, err
}
Expand All @@ -58,6 +60,7 @@ func (r runner) loadTasks() ([]storage.TaskSummary, error) {
func (r runner) listTasks(args []string) error {
flags := r.flags("tasks", "list")
activeOnly := flags.Bool("active", false, "show only actively tracked tasks")
includeArchived := flags.Bool("archived", false, "include archived tasks")
if err := flags.Parse(args); err != nil {
return err
}
Expand All @@ -77,21 +80,24 @@ func (r runner) listTasks(args []string) error {
}
items = filtered
}
if !*includeArchived {
filtered := items[:0]
for _, item := range items {
if !item.Archived {
filtered = append(filtered, item)
}
}
items = filtered
}
output := taskOutputs(items)
if r.json {
return r.writeJSON(output)
}
rows := make([]string, len(output))
for i, item := range output {
rows[i] = fmt.Sprintf("%d\t%s\t%d\t%s\t%d\t%s\t%d\t%s\t%t\t%t\t%s\t%s", item.ID,
item.Name, item.ProjectID, item.ProjectName, item.RateID, item.RateName,
item.RateAmountMinor, item.RateCurrency, item.RateOverridden, item.Active,
formatTracked(item.TrackedSeconds), formatMinorMap(item.EarnedMinor))
}
return r.table(
"ID\tNAME\tPROJECT_ID\tPROJECT\tRATE_ID\tRATE\tRATE_AMOUNT_MINOR\tRATE_CURRENCY\tRATE_OVERRIDDEN\tACTIVE\tTRACKED\tEARNED_MINOR",
rows,
)
rows[i] = taskTableRow(item)
}
return r.table(taskTableHeader, rows)
}

func (r runner) getTask(args []string) error {
Expand All @@ -112,19 +118,28 @@ func (r runner) getTask(args []string) error {
if r.json {
return r.writeJSON(output)
}
return r.table(
"ID\tNAME\tPROJECT_ID\tPROJECT\tRATE_ID\tRATE\tRATE_AMOUNT_MINOR\tRATE_CURRENCY\tRATE_OVERRIDDEN\tACTIVE\tTRACKED\tEARNED_MINOR",
[]string{fmt.Sprintf("%d\t%s\t%d\t%s\t%d\t%s\t%d\t%s\t%t\t%t\t%s\t%s", output.ID,
output.Name, output.ProjectID, output.ProjectName, output.RateID,
output.RateName, output.RateAmountMinor, output.RateCurrency,
output.RateOverridden, output.Active, formatTracked(output.TrackedSeconds),
formatMinorMap(output.EarnedMinor))},
)
return r.table(taskTableHeader, []string{taskTableRow(output)})
}
}
return fmt.Errorf("task %d not found", id)
}

const taskTableHeader = "ID\tNAME\tPROJECT_ID\tPROJECT\tNEXT_RATE_ID\tNEXT_RATE\t" +
"NEXT_RATE_AMOUNT_MINOR\tNEXT_RATE_CURRENCY\tRATE_OVERRIDDEN\tACTIVE\t" +
"TRACKED\tEARNED_MINOR\tUSED_RATES_MINOR_PER_HOUR\tARCHIVED"

func taskTableRow(item taskOutput) string {
used := make([]string, len(item.HistoricalRates))
for i, rate := range item.HistoricalRates {
used[i] = fmt.Sprintf("%d:%s:%d", rate.ID, rate.Currency, rate.AmountMinor)
}
return fmt.Sprintf("%d\t%s\t%d\t%s\t%d\t%s\t%d\t%s\t%t\t%t\t%s\t%s\t%s\t%t", item.ID,
item.Name, item.ProjectID, item.ProjectName, item.RateID, item.RateName,
item.RateAmountMinor, item.RateCurrency, item.RateOverridden, item.Active,
formatTracked(item.TrackedSeconds), formatMinorMap(item.EarnedMinor),
strings.Join(used, ","), item.Archived)
}

func (r runner) createTask(args []string) error {
flags := r.flags("tasks", "create")
name := flags.String("name", "", "task name")
Expand Down Expand Up @@ -243,7 +258,21 @@ func (r runner) deleteTask(args []string) error {
if err := r.stor.DeleteTask(r.ctx, id); err != nil {
return err
}
return r.status("deleted", "task", id)
return r.status("archived", "task", id)
}

func (r runner) restoreTask(args []string) error {
if len(args) != 1 {
return fmt.Errorf("usage: chankat tasks restore ID")
}
id, err := parseID(args[0], "task")
if err != nil {
return err
}
if err := r.stor.RestoreTask(r.ctx, id); err != nil {
return err
}
return r.status("restored", "task", id)
}

func (r runner) startTask(args []string) error {
Expand Down
16 changes: 16 additions & 0 deletions internal/storage/bulk.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ func (s *Storage) bulkAssign(
if count != len(ids) {
return fmt.Errorf("found %d of %d requested %s records", count, len(ids), entity)
}
if entity == "ENTRY" && column == "PROJECT_ID" {
query, args, err := sqlx.In(
`SELECT count(*) FROM ENTRY WHERE TASK_ID IS NOT NULL AND ID IN (?)`,
ids,
)
if err != nil {
return fmt.Errorf("build attached entry count: %w", err)
}
var attached int
if err := tx.GetContext(ctx, &attached, query, args...); err != nil {
return fmt.Errorf("count attached entries: %w", err)
}
if attached != 0 {
return fmt.Errorf("attached entries inherit their task's project; move the task instead")
}
}

updateQuery, updateArgs, err := sqlx.In(
fmt.Sprintf(`UPDATE %s SET %s = ? WHERE ID IN (?)`, entity, column),
Expand Down
4 changes: 3 additions & 1 deletion internal/storage/entries.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ const selectEntries = `
SELECT
ID AS id,
TASK_ID AS task_id,
PROJECT_ID AS project_id,
CASE WHEN TASK_ID IS NOT NULL THEN
(SELECT PROJECT_ID FROM TASK WHERE TASK.ID = ENTRY.TASK_ID)
ELSE PROJECT_ID END AS project_id,
RATE_ID AS rate_id,
STARTED_AT AS started_at,
ENDED_AT AS ended_at,
Expand Down
Loading