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

func TestCLITaskRateOverrideCanBeChangedAndCleared(t *testing.T) {
stor := cliStorage(t)
for _, args := range [][]string{
{"rates", "create", "--name", "First", "--amount-minor", "10000", "--currency", "USD"},
{"rates", "create", "--name", "Second", "--amount-minor", "20000", "--currency", "USD"},
{"rates", "create", "--name", "Override", "--amount-minor", "30000", "--currency", "USD"},
{"projects", "create", "--name", "One", "--rate", "1"},
{"projects", "create", "--name", "Two", "--rate", "2"},
{"tasks", "create", "--name", "Task", "--project", "1", "--rate", "3"},
} {
runCLI(t, stor, args...)
}

var task taskOutput
decodeCLI(t, stor, &task, "--json", "tasks", "get", "1")
if task.RateID != 3 || task.RateAmountMinor != 30_000 ||
!task.RateOverridden {
t.Fatalf("task override output = %#v", task)
}

runCLI(t, stor, "tasks", "update", "1", "--project", "2", "--rate", "0")
decodeCLI(t, stor, &task, "--json", "tasks", "get", "1")
if task.ProjectID != 2 || task.RateID != 2 ||
task.RateAmountMinor != 20_000 || task.RateOverridden {
t.Fatalf("inherited task output = %#v", task)
}
}

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 @@ -113,22 +113,24 @@ var commandSpecs = map[string]resourceSpec{
usage: "chankat tasks get ID", positional: completeTaskID,
},
"create": {
usage: "chankat tasks create --name NAME --project ID [--start | --started-at TIME [--ended-at TIME]] [--note TEXT]",
usage: "chankat tasks create --name NAME --project ID [--rate ID] [--start | --started-at TIME [--ended-at TIME]] [--note TEXT]",
options: []optionSpec{
{name: "name"},
{name: "project", value: completeProjectID},
{name: "rate", value: completeRateID},
{name: "start", boolean: true},
{name: "started-at"},
{name: "ended-at"},
{name: "note"},
},
},
"update": {
usage: "chankat tasks update ID [--name NAME] [--project ID]",
usage: "chankat tasks update ID [--name NAME] [--project ID] [--rate ID|0]",
positional: completeTaskID,
options: []optionSpec{
{name: "name"},
{name: "project", value: completeProjectID},
{name: "rate", value: completeRateID},
},
},
"delete": {
Expand Down
26 changes: 17 additions & 9 deletions internal/cli/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,19 @@ type projectOutput struct {
}

type taskOutput struct {
ID int `json:"id"`
Name string `json:"name"`
ProjectID int `json:"project_id"`
ProjectName string `json:"project_name"`
Active bool `json:"active"`
LastEndedAt *string `json:"last_ended_at"`
TrackedSeconds int64 `json:"tracked_seconds"`
EarnedMinor map[string]int64 `json:"earned_minor"`
ID int `json:"id"`
Name string `json:"name"`
ProjectID int `json:"project_id"`
ProjectName string `json:"project_name"`
RateID int `json:"rate_id"`
RateName string `json:"rate_name"`
RateAmountMinor int `json:"rate_amount_minor"`
RateCurrency string `json:"rate_currency"`
RateOverridden bool `json:"rate_overridden"`
Active bool `json:"active"`
LastEndedAt *string `json:"last_ended_at"`
TrackedSeconds int64 `json:"tracked_seconds"`
EarnedMinor map[string]int64 `json:"earned_minor"`
}

type entryOutput struct {
Expand Down Expand Up @@ -130,7 +135,10 @@ func taskOutputs(items []storage.TaskSummary) []taskOutput {
}
result[i] = taskOutput{
ID: item.ID, Name: item.Name, ProjectID: item.ProjectID,
ProjectName: item.Project.Name, Active: item.Active,
ProjectName: item.Project.Name, RateID: item.Rate.ID,
RateName: item.Rate.Name, RateAmountMinor: item.Rate.AmountMinor,
RateCurrency: item.Rate.Currency, RateOverridden: item.RateOverridden,
Active: item.Active,
LastEndedAt: lastEndedAt,
TrackedSeconds: int64(item.Tracked / time.Second),
EarnedMinor: item.EarnedMinor,
Expand Down
34 changes: 26 additions & 8 deletions internal/cli/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,13 @@ func (r runner) listTasks(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%t\t%s\t%s", item.ID,
item.Name, item.ProjectID, item.ProjectName, item.Active,
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\tACTIVE\tTRACKED\tEARNED_MINOR",
"ID\tNAME\tPROJECT_ID\tPROJECT\tRATE_ID\tRATE\tRATE_AMOUNT_MINOR\tRATE_CURRENCY\tRATE_OVERRIDDEN\tACTIVE\tTRACKED\tEARNED_MINOR",
rows,
)
}
Expand All @@ -112,10 +113,11 @@ func (r runner) getTask(args []string) error {
return r.writeJSON(output)
}
return r.table(
"ID\tNAME\tPROJECT_ID\tPROJECT\tACTIVE\tTRACKED\tEARNED_MINOR",
[]string{fmt.Sprintf("%d\t%s\t%d\t%s\t%t\t%s\t%s", output.ID,
output.Name, output.ProjectID, output.ProjectName,
output.Active, formatTracked(output.TrackedSeconds),
"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))},
)
}
Expand All @@ -127,6 +129,7 @@ func (r runner) createTask(args []string) error {
flags := r.flags("tasks", "create")
name := flags.String("name", "", "task name")
projectID := flags.Int("project", 0, "project ID")
rateID := flags.Int("rate", 0, "task rate override ID")
start := flags.Bool("start", false, "start tracking now")
startedAt := flags.String("started-at", "", "RFC3339 or YYYY-MM-DD HH:MM")
endedAt := flags.String("ended-at", "", "RFC3339 or YYYY-MM-DD HH:MM")
Expand All @@ -140,7 +143,9 @@ func (r runner) createTask(args []string) error {
if err := required(flags, "name", "project"); err != nil {
return err
}
task := storage.Task{Name: *name, ProjectID: *projectID}
task := storage.Task{
Name: *name, ProjectID: *projectID, RateID: optionalID(*rateID),
}
hasEntry := *start || changed(flags, "started-at") || changed(flags, "ended-at")
if !hasEntry {
if changed(flags, "note") {
Expand Down Expand Up @@ -198,6 +203,11 @@ func (r runner) updateTask(args []string) error {
flags := r.flags("tasks", "update")
name := flags.String("name", task.Name, "task name")
projectID := flags.Int("project", task.ProjectID, "project ID")
currentRateID := 0
if task.RateID != nil {
currentRateID = *task.RateID
}
rateID := flags.Int("rate", currentRateID, "task rate override ID; 0 inherits project")
if err := flags.Parse(args[1:]); err != nil {
return err
}
Expand All @@ -208,12 +218,20 @@ func (r runner) updateTask(args []string) error {
return fmt.Errorf("expected at least one update option")
}
task.Name, task.ProjectID = *name, *projectID
task.RateID = optionalID(*rateID)
if err := r.stor.UpdateTask(r.ctx, task); err != nil {
return err
}
return r.status("updated", "task", id)
}

func optionalID(id int) *int {
if id == 0 {
return nil
}
return &id
}

func (r runner) deleteTask(args []string) error {
if len(args) != 1 {
return fmt.Errorf("usage: chankat tasks delete ID")
Expand Down
6 changes: 6 additions & 0 deletions internal/storage/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,14 @@ UPDATE PAYMENT
SET PAID_FOR_DATE = PAID_AT
`

const addTaskRate = `
ALTER TABLE TASK
ADD COLUMN RATE_ID INTEGER REFERENCES RATE(ID)
`

var migrations = []string{
initial,
addPaidForDate,
normalizePaymentDates,
addTaskRate,
}
58 changes: 58 additions & 0 deletions internal/storage/storage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ func TestMigrationBackfillsLegacyPaymentDate(t *testing.T) {
legacyPaidAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.Local).Unix()
paidAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).Unix()
if _, err := db.Exec(`
CREATE TABLE RATE (ID INTEGER PRIMARY KEY);
CREATE TABLE TASK (ID INTEGER PRIMARY KEY);
CREATE TABLE PAYMENT (
ID INTEGER PRIMARY KEY,
PROJECT_ID INTEGER NOT NULL,
Expand Down Expand Up @@ -71,6 +73,8 @@ func TestMigrationReplacesExistingPaymentAccountingDate(t *testing.T) {
legacyPaidAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.Local).Unix()
paidAt := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).Unix()
if _, err := db.Exec(`
CREATE TABLE RATE (ID INTEGER PRIMARY KEY);
CREATE TABLE TASK (ID INTEGER PRIMARY KEY);
CREATE TABLE PAYMENT (
ID INTEGER PRIMARY KEY,
PROJECT_ID INTEGER NOT NULL,
Expand Down Expand Up @@ -112,6 +116,47 @@ func TestMigrationReplacesExistingPaymentAccountingDate(t *testing.T) {
}
}

func TestMigrationAddsNullableTaskRate(t *testing.T) {
path := filepath.Join(t.TempDir(), "version-three.sqlite")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`
CREATE TABLE RATE (ID INTEGER PRIMARY KEY);
CREATE TABLE TASK (
ID INTEGER PRIMARY KEY,
NAME TEXT NOT NULL,
PROJECT_ID INTEGER NOT NULL
);
INSERT INTO TASK (ID, NAME, PROJECT_ID) VALUES (1, 'existing', 7);
PRAGMA user_version = 3;
`); err != nil {
db.Close()
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}

t.Setenv("CHANKAT_DATA_PATH", path)
stor, err := storage.Open()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { stor.Close() })
if err := stor.Migrate(); err != nil {
t.Fatal(err)
}
task, err := stor.GetTask(t.Context(), 1)
if err != nil {
t.Fatal(err)
}
if task.Name != "existing" || task.ProjectID != 7 || task.RateID != nil {
t.Fatalf("migrated task = %#v", task)
}
}

func TestOpenAndMigrate(t *testing.T) {
t.Setenv("CHANKAT_DATA_PATH", "")
t.Setenv("XDG_DATA_HOME", t.TempDir())
Expand Down Expand Up @@ -160,6 +205,19 @@ func TestOpenAndMigrate(t *testing.T) {
t.Fatalf("got %d PAID_FOR_DATE columns, want 1", paidForColumn)
}

var taskRateColumn int
err = stor.QueryRow(`
SELECT count(*)
FROM pragma_table_info('TASK')
WHERE name = 'RATE_ID'
`).Scan(&taskRateColumn)
if err != nil {
t.Fatal(err)
}
if taskRateColumn != 1 {
t.Fatalf("got %d TASK.RATE_ID columns, want 1", taskRateColumn)
}

var taskProjectRequired int
err = stor.QueryRow(`
SELECT "notnull"
Expand Down
23 changes: 16 additions & 7 deletions internal/storage/summaries.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ type ProjectSummary struct {

type TaskSummary struct {
Task
Project Project
Active bool
LastEndedAt *time.Time
LastEntryID int
Tracked time.Duration
EarnedMinor map[string]int64
Project Project
Rate Rate
RateOverridden bool
Active bool
LastEndedAt *time.Time
LastEntryID int
Tracked time.Duration
EarnedMinor map[string]int64
}

func SummarizeRates(rates []Rate, projects []Project) []RateSummary {
Expand Down Expand Up @@ -123,6 +125,12 @@ func SummarizeTasks(
result := make([]TaskSummary, len(tasks))
for i, task := range tasks {
tracked, earned := TaskTotals(entries, ratesByID, task.ID, now)
project := projectsByID[task.ProjectID]
rateID := project.RateID
rateOverridden := task.RateID != nil
if rateOverridden {
rateID = *task.RateID
}
var lastEndedAt *time.Time
lastEntryID := 0
active := false
Expand All @@ -143,7 +151,8 @@ func SummarizeTasks(
}
}
result[i] = TaskSummary{
Task: task, Project: projectsByID[task.ProjectID], Active: active,
Task: task, Project: project, Rate: ratesByID[rateID],
RateOverridden: rateOverridden, Active: active,
LastEndedAt: lastEndedAt, LastEntryID: lastEntryID,
Tracked: tracked, EarnedMinor: earned,
}
Expand Down
Loading