diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index fdee8a1..b6d50fa 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -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", diff --git a/internal/cli/commands.go b/internal/cli/commands.go index e7893ce..08913fd 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -113,10 +113,11 @@ 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"}, @@ -124,11 +125,12 @@ var commandSpecs = map[string]resourceSpec{ }, }, "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": { diff --git a/internal/cli/output.go b/internal/cli/output.go index c2274a0..b39ba6a 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -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 { @@ -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, diff --git a/internal/cli/tasks.go b/internal/cli/tasks.go index 80a7050..9364e24 100644 --- a/internal/cli/tasks.go +++ b/internal/cli/tasks.go @@ -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, ) } @@ -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))}, ) } @@ -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") @@ -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") { @@ -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 } @@ -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") diff --git a/internal/storage/schema.go b/internal/storage/schema.go index 05273f0..c35c410 100644 --- a/internal/storage/schema.go +++ b/internal/storage/schema.go @@ -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, } diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 77b6626..10228d1 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -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, @@ -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, @@ -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()) @@ -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" diff --git a/internal/storage/summaries.go b/internal/storage/summaries.go index 1d1fcfe..b6ffe47 100644 --- a/internal/storage/summaries.go +++ b/internal/storage/summaries.go @@ -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 { @@ -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 @@ -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, } diff --git a/internal/storage/tasks.go b/internal/storage/tasks.go index 894bf4a..1053af4 100644 --- a/internal/storage/tasks.go +++ b/internal/storage/tasks.go @@ -12,11 +12,13 @@ type Task struct { ID int `db:"id"` Name string `db:"name"` ProjectID int `db:"project_id"` + RateID *int `db:"rate_id"` } func (s *Storage) GetTasks(ctx context.Context) ([]Task, error) { const query = ` - SELECT ID AS id, NAME AS name, PROJECT_ID AS project_id + SELECT ID AS id, NAME AS name, PROJECT_ID AS project_id, + RATE_ID AS rate_id FROM TASK ORDER BY ID ` @@ -30,7 +32,8 @@ func (s *Storage) GetTasks(ctx context.Context) ([]Task, error) { func (s *Storage) GetTask(ctx context.Context, id int) (Task, error) { const query = ` - SELECT ID AS id, NAME AS name, PROJECT_ID AS project_id + SELECT ID AS id, NAME AS name, PROJECT_ID AS project_id, + RATE_ID AS rate_id FROM TASK WHERE ID = $1 ` @@ -56,11 +59,11 @@ func (s *Storage) CreateTaskID(ctx context.Context, task Task) (int, error) { return 0, fmt.Errorf("create task: %w", err) } const query = ` - INSERT INTO TASK (NAME, PROJECT_ID) - VALUES ($1, $2) + INSERT INTO TASK (NAME, PROJECT_ID, RATE_ID) + VALUES ($1, $2, $3) ` - result, err := s.db.ExecContext(ctx, query, name, task.ProjectID) + result, err := s.db.ExecContext(ctx, query, name, task.ProjectID, task.RateID) if err != nil { return 0, fmt.Errorf("create task: %w", err) } @@ -107,10 +110,10 @@ func (s *Storage) CreateTaskAndEntryID( } defer tx.Rollback() - var rateID int + var projectRateID int if err := tx.GetContext( ctx, - &rateID, + &projectRateID, `SELECT RATE_ID FROM PROJECT WHERE ID = $1`, task.ProjectID, ); err != nil { @@ -119,12 +122,17 @@ func (s *Storage) CreateTaskAndEntryID( } return 0, fmt.Errorf("get project rate: %w", err) } + rateID := projectRateID + if task.RateID != nil { + rateID = *task.RateID + } result, err := tx.ExecContext( ctx, - `INSERT INTO TASK (NAME, PROJECT_ID) VALUES ($1, $2)`, + `INSERT INTO TASK (NAME, PROJECT_ID, RATE_ID) VALUES ($1, $2, $3)`, name, task.ProjectID, + task.RateID, ) if err != nil { return 0, fmt.Errorf("create task: %w", err) @@ -188,7 +196,8 @@ func (s *Storage) CreateEntryForTaskID( RateID int `db:"rate_id"` } if err := tx.GetContext(ctx, &task, ` - SELECT TASK.PROJECT_ID AS project_id, PROJECT.RATE_ID AS rate_id + SELECT TASK.PROJECT_ID AS project_id, + COALESCE(TASK.RATE_ID, PROJECT.RATE_ID) AS rate_id FROM TASK JOIN PROJECT ON PROJECT.ID = TASK.PROJECT_ID WHERE TASK.ID = $1 @@ -298,11 +307,13 @@ func (s *Storage) UpdateTask(ctx context.Context, task Task) error { } const query = ` UPDATE TASK - SET NAME = $1, PROJECT_ID = $2 - WHERE ID = $3 + SET NAME = $1, PROJECT_ID = $2, RATE_ID = $3 + WHERE ID = $4 ` - result, err := s.db.ExecContext(ctx, query, name, task.ProjectID, task.ID) + result, err := s.db.ExecContext( + ctx, query, name, task.ProjectID, task.RateID, task.ID, + ) if err != nil { return fmt.Errorf("update task: %w", err) } diff --git a/internal/storage/tasks_test.go b/internal/storage/tasks_test.go index f6b8a09..d9e69e9 100644 --- a/internal/storage/tasks_test.go +++ b/internal/storage/tasks_test.go @@ -60,6 +60,35 @@ func TestCreateTaskAndStart(t *testing.T) { } }) + t.Run("uses task rate override", func(t *testing.T) { + stor := fixtureStorage(t) + ctx := t.Context() + project := fixtureProject(t, stor) + if err := stor.CreateRate(ctx, storage.Rate{ + Name: "override", AmountMinor: 20_000, Currency: "USD", + }); err != nil { + t.Fatal(err) + } + rates, err := stor.GetRates(ctx) + if err != nil { + t.Fatal(err) + } + overrideRateID := rates[1].ID + if err := stor.CreateTaskAndStart(ctx, storage.Task{ + Name: "custom", ProjectID: project.ID, RateID: &overrideRateID, + }, time.Unix(1_700_000_000, 0)); err != nil { + t.Fatal(err) + } + entries, err := stor.GetEntries(ctx) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].RateID == nil || + *entries[0].RateID != overrideRateID { + t.Fatalf("override was not captured: %#v", entries) + } + }) + t.Run("rolls back for missing project", func(t *testing.T) { stor := fixtureStorage(t) err := stor.CreateTaskAndStart(t.Context(), storage.Task{ @@ -170,6 +199,92 @@ func TestTaskEntriesCaptureProjectRateChanges(t *testing.T) { } } +func TestTaskRateOverrideAndProjectChangesAffectFutureEntries(t *testing.T) { + stor := fixtureStorage(t) + ctx := t.Context() + firstProject := fixtureProject(t, stor) + if err := stor.CreateRate(ctx, storage.Rate{ + Name: "second project", AmountMinor: 20_000, Currency: "USD", + }); err != nil { + t.Fatal(err) + } + if err := stor.CreateRate(ctx, storage.Rate{ + Name: "task override", AmountMinor: 30_000, Currency: "USD", + }); err != nil { + t.Fatal(err) + } + rates, err := stor.GetRates(ctx) + if err != nil { + t.Fatal(err) + } + if err := stor.CreateProject(ctx, storage.Project{ + Name: "second", RateID: rates[1].ID, + }); err != nil { + t.Fatal(err) + } + projects, err := stor.GetProjects(ctx) + if err != nil { + t.Fatal(err) + } + if err := stor.CreateTask(ctx, storage.Task{ + Name: "movable", ProjectID: firstProject.ID, + }); err != nil { + t.Fatal(err) + } + + startedAt := time.Unix(1_700_000_000, 0) + startAndStop := func(offset time.Duration) { + t.Helper() + start := startedAt.Add(offset) + if err := stor.StartTask(ctx, 1, start); err != nil { + t.Fatal(err) + } + if err := stor.PauseTask(ctx, 1, start.Add(time.Hour)); err != nil { + t.Fatal(err) + } + } + startAndStop(0) + + overrideRateID := rates[2].ID + if err := stor.UpdateTask(ctx, storage.Task{ + ID: 1, Name: "movable", ProjectID: projects[1].ID, + RateID: &overrideRateID, + }); err != nil { + t.Fatal(err) + } + startAndStop(2 * time.Hour) + + if err := stor.UpdateTask(ctx, storage.Task{ + ID: 1, Name: "movable", ProjectID: projects[1].ID, + }); err != nil { + t.Fatal(err) + } + startAndStop(4 * time.Hour) + + task, err := stor.GetTask(ctx, 1) + if err != nil { + t.Fatal(err) + } + if task.ProjectID != projects[1].ID || task.RateID != nil { + t.Fatalf("unexpected updated task: %#v", task) + } + entries, err := stor.GetEntries(ctx) + if err != nil { + t.Fatal(err) + } + wantProjects := []int{firstProject.ID, projects[1].ID, projects[1].ID} + wantRates := []int{firstProject.RateID, overrideRateID, rates[1].ID} + if len(entries) != len(wantRates) { + t.Fatalf("got %d entries, want %d", len(entries), len(wantRates)) + } + for i, entry := range entries { + if entry.ProjectID == nil || *entry.ProjectID != wantProjects[i] || + entry.RateID == nil || *entry.RateID != wantRates[i] { + t.Fatalf("entry %d = %#v", i, entry) + } + } +} + func TestPauseTask(t *testing.T) { stor := fixtureStorage(t) ctx := t.Context() diff --git a/internal/tui/screens/tasks.go b/internal/tui/screens/tasks.go index a7774ce..1bba9ba 100644 --- a/internal/tui/screens/tasks.go +++ b/internal/tui/screens/tasks.go @@ -466,9 +466,23 @@ func (m *Dashboard) refreshTables() { } description := []string{ m.entryProject(entry), - "session " + components.FormatDuration(session), - "total " + components.FormatDuration(duration), } + if entry.RateID != nil { + if rate, ok := m.rates[*entry.RateID]; ok { + description = append(description, fmt.Sprintf( + "%s · %s/hour", + rate.Name, + components.FormatMoney( + int64(rate.AmountMinor), rate.Currency, + ), + )) + } + } + description = append( + description, + "session "+components.FormatDuration(session), + "total "+components.FormatDuration(duration), + ) if amount := formatTaskAmounts(amounts); amount != "" { description = append(description, amount+" earned") } @@ -538,6 +552,7 @@ func mapRates(rates map[int]storage.Rate) []storage.Rate { for _, rate := range rates { result = append(result, rate) } + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) return result } @@ -992,7 +1007,9 @@ func (m Dashboard) updateDetail( if !ok { return m, nil } - form, err := taskForm(m.ctx, m.stor, &item.task, m.projectList) + form, err := taskForm( + m.ctx, m.stor, &item.task, m.projectList, mapRates(m.rates), + ) if err != nil { m.err = err return m, nil @@ -1040,7 +1057,9 @@ func (m Dashboard) openSelectedTaskForm(deleteTask bool) (Dashboard, tea.Cmd) { ) } else { var err error - form, err = taskForm(m.ctx, m.stor, &task, m.projectList) + form, err = taskForm( + m.ctx, m.stor, &task, m.projectList, mapRates(m.rates), + ) if err != nil { m.err = err return m, nil @@ -1052,7 +1071,9 @@ func (m Dashboard) openSelectedTaskForm(deleteTask bool) (Dashboard, tea.Cmd) { } func (m Dashboard) openHistoricalTask() (Dashboard, tea.Cmd) { - form, err := historicalTaskForm(m.ctx, m.stor, m.projectList, m.now) + form, err := historicalTaskForm( + m.ctx, m.stor, m.projectList, mapRates(m.rates), m.now, + ) if err != nil { m.err = err return m, nil @@ -1104,7 +1125,7 @@ func (m *Dashboard) refreshDetailTask() bool { if task.ID == id { copy := task m.detailTask = © - return task != previous + return !sameTask(task, previous) } } m.detailTask = nil @@ -1112,11 +1133,27 @@ func (m *Dashboard) refreshDetailTask() bool { return true } +func sameTask(left, right storage.Task) bool { + if left.ID != right.ID || left.Name != right.Name || + left.ProjectID != right.ProjectID { + return false + } + if left.RateID == nil || right.RateID == nil { + return left.RateID == nil && right.RateID == nil + } + return *left.RateID == *right.RateID +} + func (m Dashboard) detailView() string { task := *m.detailTask project := m.projects[task.ProjectID] duration, amounts := taskTotals(m.filteredEntries(), m.rates, task.ID, m.now) - summary := project + " · total " + components.FormatDuration(duration) + rate, overridden := effectiveTaskRate(task, m.projectList, m.rates) + summary := project + if rate.ID != 0 { + summary += " · " + formatTaskRate(rate, overridden) + } + summary += " · total " + components.FormatDuration(duration) if amount := formatTaskAmounts(amounts); amount != "" { summary += " · " + amount + " earned" } @@ -1136,18 +1173,25 @@ func (m Dashboard) detailView() string { type taskItem struct { task storage.Task project storage.Project + rate storage.Rate description string } func (t taskItem) Title() string { return "[>] " + t.task.Name } func (t taskItem) Description() string { return t.description } func (t taskItem) FilterValue() string { - return t.task.Name + " " + t.project.Name + return t.task.Name + " " + t.project.Name + " " + t.rate.Name } type taskFormValues struct { name string projectID int + rateID int +} + +type taskFormMeta struct { + projects []storage.Project + rates []storage.Rate } func newTaskPage( @@ -1178,13 +1222,15 @@ func newTaskPage( } return taskItemsForFilter( tasks, projects, entries, rates, *filter, time.Now(), - ), projects, nil + ), taskFormMeta{projects: projects, rates: rates}, nil }, Create: func(meta any) (*components.Form[taskItem], error) { - return taskForm(ctx, stor, nil, meta.([]storage.Project)) + values := meta.(taskFormMeta) + return taskForm(ctx, stor, nil, values.projects, values.rates) }, Update: func(item taskItem, meta any) (*components.Form[taskItem], error) { - return taskForm(ctx, stor, &item.task, meta.([]storage.Project)) + values := meta.(taskFormMeta) + return taskForm(ctx, stor, &item.task, values.projects, values.rates) }, Delete: func(item taskItem) *components.Form[taskItem] { return components.NewDeleteForm[taskItem]( @@ -1294,6 +1340,11 @@ func summarizedTaskItems( items := make([]taskItem, 0, len(ordered)) for _, summary := range ordered { description := summary.Project.Name + if summary.Rate.ID != 0 { + description += " · " + formatTaskRate( + summary.Rate, summary.RateOverridden, + ) + } if summary.LastEndedAt != nil { totalLabel := "total " if periodFiltered { @@ -1310,6 +1361,7 @@ func summarizedTaskItems( items = append(items, taskItem{ task: summary.Task, project: summary.Project, + rate: summary.Rate, description: description, }) } @@ -1341,13 +1393,43 @@ func formatTaskAmounts(amounts map[string]int64) string { return strings.Join(formatted, ", ") } +func formatTaskRate(rate storage.Rate, overridden bool) string { + source := "project rate" + if overridden { + source = "task rate" + } + return fmt.Sprintf( + "%s · %s/hour (%s)", + rate.Name, + components.FormatMoney(int64(rate.AmountMinor), rate.Currency), + source, + ) +} + +func effectiveTaskRate( + task storage.Task, + projects []storage.Project, + rates map[int]storage.Rate, +) (storage.Rate, bool) { + if task.RateID != nil { + return rates[*task.RateID], true + } + for _, project := range projects { + if project.ID == task.ProjectID { + return rates[project.RateID], false + } + } + return storage.Rate{}, false +} + func taskForm( ctx context.Context, stor *storage.Storage, task *storage.Task, projects []storage.Project, + rates []storage.Rate, ) (*components.Form[taskItem], error) { - form, values, err := taskFields(task, projects) + form, values, err := taskFields(task, projects, rates) if err != nil { return nil, err } @@ -1362,6 +1444,7 @@ func taskForm( func(ctx context.Context) error { value := storage.Task{ Name: strings.TrimSpace(values.name), ProjectID: values.projectID, + RateID: optionalTaskRateID(values.rateID), } if task == nil { return stor.CreateTaskAndStart(ctx, value, time.Now()) @@ -1376,6 +1459,7 @@ func historicalTaskForm( ctx context.Context, stor *storage.Storage, projects []storage.Project, + rates []storage.Rate, now time.Time, ) (*components.Form[taskItem], error) { if len(projects) == 0 { @@ -1384,6 +1468,7 @@ func historicalTaskForm( now = now.Truncate(time.Minute) name := "" projectID := projects[0].ID + rateID := 0 startedAt := components.FormatDateTime(now.Add(-time.Hour)) endedAt := components.FormatDateTime(now) note := "" @@ -1400,6 +1485,10 @@ func historicalTaskForm( Title("Project"). Options(options...). Value(&projectID), + huh.NewSelect[int](). + Title("Rate"). + Options(taskRateOptions(rates)...). + Value(&rateID), huh.NewInput(). Title("Started at (YYYY-MM-DD HH:MM)"). Value(&startedAt). @@ -1430,6 +1519,7 @@ func historicalTaskForm( ctx, storage.Task{ Name: strings.TrimSpace(name), ProjectID: projectID, + RateID: optionalTaskRateID(rateID), }, storage.Entry{ StartedAt: started, @@ -1444,6 +1534,7 @@ func historicalTaskForm( func taskFields( task *storage.Task, projects []storage.Project, + rates []storage.Rate, ) (*huh.Form, *taskFormValues, error) { if len(projects) == 0 { return nil, nil, errors.New("no projects available; create a project first") @@ -1452,6 +1543,9 @@ func taskFields( if task != nil { values.name = task.Name values.projectID = task.ProjectID + if task.RateID != nil { + values.rateID = *task.RateID + } } options := make([]huh.Option[int], len(projects)) for i, project := range projects { @@ -1466,18 +1560,41 @@ func taskFields( Title("Project"). Options(options...). Value(&values.projectID), + huh.NewSelect[int](). + Title("Rate"). + Options(taskRateOptions(rates)...). + Value(&values.rateID), )).WithShowHelp(true) return form, values, nil } +func taskRateOptions(rates []storage.Rate) []huh.Option[int] { + options := []huh.Option[int]{huh.NewOption("Inherit project rate", 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 +} + +func optionalTaskRateID(rateID int) *int { + if rateID == 0 { + return nil + } + return &rateID +} + type entryItem struct { entry storage.Entry now time.Time } type entryMeta struct { - task storage.Task - project storage.Project + task storage.Task } func (e entryItem) Title() string { @@ -1523,24 +1640,16 @@ func newEntryPage( if err != nil { return nil, nil, err } - project, err := stor.GetProject(ctx, task.ProjectID) - if err != nil { - return nil, nil, err - } now := time.Now() if filter != nil && (filter.Period.Kind != storage.All || !filter.Period.Start.IsZero() || !filter.Period.End.IsZero()) { entries = entriesOverlappingPeriod(entries, filter.Period, now) } - return entryItems(entries, task.ID, now), entryMeta{ - task: task, project: project, - }, nil + return entryItems(entries, task.ID, now), entryMeta{task: task}, nil }, Create: func(meta any) (*components.Form[entryItem], error) { values := meta.(entryMeta) - return entryForm( - ctx, stor, values.task, values.project, nil, time.Now(), - ), nil + return entryForm(ctx, stor, values.task, nil, time.Now()), nil }, Update: func( item entryItem, @@ -1548,7 +1657,7 @@ func newEntryPage( ) (*components.Form[entryItem], error) { values := meta.(entryMeta) return entryForm( - ctx, stor, values.task, values.project, &item.entry, time.Now(), + ctx, stor, values.task, &item.entry, time.Now(), ), nil }, Delete: func(item entryItem) *components.Form[entryItem] { @@ -1605,7 +1714,6 @@ func entryForm( ctx context.Context, stor *storage.Storage, task storage.Task, - project storage.Project, entry *storage.Entry, now time.Time, ) *components.Form[entryItem] { @@ -1666,10 +1774,9 @@ func entryForm( value.RateID = entry.RateID return stor.UpdateEntry(ctx, value) } - value.TaskID = &task.ID - value.ProjectID = &project.ID - value.RateID = &project.RateID - return stor.CreateEntry(ctx, value) + return stor.CreateEntryForTask( + ctx, task.ID, value.StartedAt, value.EndedAt, value.Note, + ) }, ) } diff --git a/internal/tui/screens/tasks_test.go b/internal/tui/screens/tasks_test.go index 663624e..41cbfee 100644 --- a/internal/tui/screens/tasks_test.go +++ b/internal/tui/screens/tasks_test.go @@ -73,6 +73,27 @@ func TestDashboardTasksByRecentEntry(t *testing.T) { } } +func TestTaskItemsDisplayEffectiveRate(t *testing.T) { + overrideID := 2 + items := taskItems( + []storage.Task{ + {ID: 1, Name: "inherited", ProjectID: 1}, + {ID: 2, Name: "overridden", ProjectID: 1, RateID: &overrideID}, + }, + []storage.Project{{ID: 1, Name: "project", RateID: 1}}, + nil, + []storage.Rate{ + {ID: 1, Name: "standard", AmountMinor: 10_000, Currency: "USD"}, + {ID: 2, Name: "special", AmountMinor: 15_000, Currency: "USD"}, + }, + ) + if len(items) != 2 || + !strings.Contains(items[0].Description(), "$100.00/hour (project rate)") || + !strings.Contains(items[1].Description(), "$150.00/hour (task rate)") { + t.Fatalf("task rates not displayed: %#v", items) + } +} + func TestDashboardHistoricalEntryDoesNotBecomeLatest(t *testing.T) { taskOne := 1 taskTwo := 2 @@ -280,6 +301,9 @@ func TestDashboardResumedTaskTotals(t *testing.T) { if !strings.Contains(view, "$75.00 earned") { t.Fatalf("dashboard does not show cumulative amount:\n%s", view) } + if !strings.Contains(view, "$50.00/hour") { + t.Fatalf("dashboard does not show active rate:\n%s", view) + } } func TestDashboardRowAt(t *testing.T) { @@ -503,6 +527,26 @@ func TestDashboardTaskDetail(t *testing.T) { } } +func TestRefreshDetailTaskComparesRateValues(t *testing.T) { + firstRateID, reloadedRateID := 3, 3 + m := NewDashboard(t.Context(), nil) + m.detailTask = &storage.Task{ + ID: 1, Name: "task", ProjectID: 2, RateID: &firstRateID, + } + m.taskList = []storage.Task{{ + ID: 1, Name: "task", ProjectID: 2, RateID: &reloadedRateID, + }} + if changed := m.refreshDetailTask(); changed { + t.Fatal("equal rate values were treated as a task change") + } + + changedRateID := 4 + m.taskList[0].RateID = &changedRateID + if changed := m.refreshDetailTask(); !changed { + t.Fatal("changed rate was not detected") + } +} + func TestDashboardEditsActiveTask(t *testing.T) { task := storage.Task{ID: 1, Name: "active", ProjectID: 2} taskID := task.ID