diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index b6d50fa..44dde95 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -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", diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 08913fd..591f2cd 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -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": { @@ -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, diff --git a/internal/cli/dashboard.go b/internal/cli/dashboard.go index a8a72cd..d9dab70 100644 --- a/internal/cli/dashboard.go +++ b/internal/cli/dashboard.go @@ -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) } diff --git a/internal/cli/output.go b/internal/cli/output.go index b39ba6a..d74c421 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -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 { @@ -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"` @@ -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 @@ -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 } diff --git a/internal/cli/projects.go b/internal/cli/projects.go index 1389cfd..78977cb 100644 --- a/internal/cli/projects.go +++ b/internal/cli/projects.go @@ -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 { @@ -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) diff --git a/internal/cli/tasks.go b/internal/cli/tasks.go index 9364e24..1c09615 100644 --- a/internal/cli/tasks.go +++ b/internal/cli/tasks.go @@ -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": @@ -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 } @@ -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 } @@ -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 { @@ -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") @@ -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 { diff --git a/internal/storage/bulk.go b/internal/storage/bulk.go index 860c4e7..556d940 100644 --- a/internal/storage/bulk.go +++ b/internal/storage/bulk.go @@ -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), diff --git a/internal/storage/entries.go b/internal/storage/entries.go index f184c61..e631234 100644 --- a/internal/storage/entries.go +++ b/internal/storage/entries.go @@ -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, diff --git a/internal/storage/ownership_test.go b/internal/storage/ownership_test.go new file mode 100644 index 0000000..181833e --- /dev/null +++ b/internal/storage/ownership_test.go @@ -0,0 +1,239 @@ +package storage_test + +import ( + "database/sql" + "path/filepath" + "testing" + "time" + + "chankat/internal/storage" +) + +func TestOwnershipMigrationRepairsOnlyAttachedProjects(t *testing.T) { + path := filepath.Join(t.TempDir(), "v4.sqlite") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + _, err = db.Exec(` + CREATE TABLE TASK (ID INTEGER PRIMARY KEY, NAME TEXT, PROJECT_ID INTEGER, RATE_ID INTEGER); + CREATE TABLE ENTRY (ID INTEGER PRIMARY KEY, TASK_ID INTEGER, PROJECT_ID INTEGER, + RATE_ID INTEGER, STARTED_AT INTEGER, ENDED_AT INTEGER, NOTES TEXT); + INSERT INTO TASK VALUES (1, 'moved', 2, NULL); + INSERT INTO ENTRY VALUES (1, 1, 1, 7, 100, 3700, 'historical'); + INSERT INTO ENTRY VALUES (2, NULL, 1, 8, 100, 3700, 'detached'); + INSERT INTO ENTRY VALUES (3, 1, NULL, 7, 200, NULL, 'active'); + PRAGMA user_version = 4; + `) + if err != nil { + 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() }) + for range 2 { + if err := stor.Migrate(); err != nil { + t.Fatal(err) + } + } + entries, err := stor.GetEntries(t.Context()) + if err != nil { + t.Fatal(err) + } + for i, want := range []int{2, 1, 2} { + if entries[i].ProjectID == nil || *entries[i].ProjectID != want { + t.Fatalf("entry %d: %#v", i, entries[i]) + } + var mirror int + if err := stor.QueryRow(`SELECT PROJECT_ID FROM ENTRY WHERE ID = ?`, i+1).Scan(&mirror); err != nil { + t.Fatal(err) + } + if mirror != want { + t.Fatalf("mirror = %d, want %d", mirror, want) + } + } + if *entries[0].RateID != 7 || entries[0].StartedAt.Unix() != 100 || + entries[0].EndedAt.Unix() != 3700 || entries[0].Note != "historical" || + *entries[1].RateID != 8 || entries[1].TaskID != nil || entries[2].EndedAt != nil { + t.Fatalf("migration rewrote history: %#v", entries) + } +} + +func TestBulkTaskMoveAndArchivePreserveAccounting(t *testing.T) { + stor := fixtureStorage(t) + ctx := t.Context() + from := fixtureProject(t, stor) + to := fixtureProject(t, stor) + start := time.Now().Add(-time.Hour).Truncate(time.Second) + end := start.Add(30 * time.Minute) + for _, name := range []string{"one", "two"} { + if err := stor.CreateTaskAndEntry(ctx, storage.Task{Name: name, ProjectID: from.ID}, + storage.Entry{StartedAt: start, EndedAt: &end}); err != nil { + t.Fatal(err) + } + } + if err := stor.StartTask(ctx, 1, end); err != nil { + t.Fatal(err) + } + if err := stor.AssignTasksToProject(ctx, []int{1, 999}, to.ID); err == nil { + t.Fatal("missing task accepted") + } + if err := stor.AssignTasksToProject(ctx, []int{1, 2}, 999); err == nil { + t.Fatal("missing project accepted") + } + entries, err := stor.GetEntries(ctx) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if *entry.ProjectID != from.ID { + t.Fatal("failed move changed attribution") + } + } + if err := stor.AssignTasksToProject(ctx, []int{1, 2}, to.ID); err != nil { + t.Fatal(err) + } + if err := stor.AssignEntriesToProject(ctx, []int{1}, &from.ID); err == nil { + t.Fatal("attached entry allowed an independent project assignment") + } + if err := stor.DeleteTask(ctx, 1); err != nil { + t.Fatal(err) + } + task, err := stor.GetTask(ctx, 1) + if err != nil { + t.Fatal(err) + } + if !task.Archived { + t.Fatal("task not archived") + } + visible, err := stor.GetTasks(ctx) + if err != nil { + t.Fatal(err) + } + if len(visible) != 1 || visible[0].ID != 2 { + t.Fatalf("visible tasks = %#v", visible) + } + if err := stor.StartTask(ctx, 1, time.Now()); err == nil { + t.Fatal("archived task restarted") + } + entries, err = stor.GetEntries(ctx) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if entry.TaskID == nil || *entry.ProjectID != to.ID || *entry.RateID != from.RateID || entry.EndedAt == nil { + t.Fatalf("lost archived history: %#v", entry) + } + } + rates, err := stor.GetRates(ctx) + if err != nil { + t.Fatal(err) + } + projects := storage.SummarizeProjects([]storage.Project{from, to}, rates, entries, nil, time.Now()) + if projects[0].BalanceMinor["USD"] != 0 || projects[1].BalanceMinor["USD"] < 11250 { + t.Fatalf("wrong moved balances: %#v", projects) + } + // A stale entry editor must not put the work back on the previous project. + entry := entries[0] + entry.ProjectID = &from.ID + entry.Note = "edited" + if err := stor.UpdateEntry(ctx, entry); err != nil { + t.Fatal(err) + } + entry, err = stor.GetEntry(ctx, entry.ID) + if err != nil { + t.Fatal(err) + } + if *entry.ProjectID != to.ID || entry.Note != "edited" { + t.Fatalf("stale edit: %#v", entry) + } +} + +func TestOwnershipMigrationFailureRollsBack(t *testing.T) { + path := filepath.Join(t.TempDir(), "broken-v4.sqlite") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + // Simulate an incomplete old database: ALTER TASK succeeds, backfill fails. + if _, err := db.Exec(`CREATE TABLE TASK (ID INTEGER PRIMARY KEY, PROJECT_ID INTEGER); PRAGMA user_version = 4;`); err != nil { + 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("invalid migration succeeded") + } + var version, archivedColumns int + if err := stor.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil { + t.Fatal(err) + } + if err := stor.QueryRow(`SELECT count(*) FROM pragma_table_info('TASK') WHERE name = 'ARCHIVED'`).Scan(&archivedColumns); err != nil { + t.Fatal(err) + } + if version != 4 || archivedColumns != 0 { + t.Fatalf("partial migration committed: version=%d, archived=%d", version, archivedColumns) + } +} + +func TestLedgerBreakdownKeepsCreditsCurrenciesAndRounding(t *testing.T) { + projectID, usdID, eurID := 1, 1, 2 + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + end := start.Add(time.Hour) + entries := []storage.Entry{ + {ProjectID: &projectID, RateID: &usdID, StartedAt: start, EndedAt: &end}, + {ProjectID: &projectID, RateID: &eurID, StartedAt: start, EndedAt: &end}, + } + payments := []storage.Payment{ + {ProjectID: projectID, Currency: "USD", AmountMinor: 50, PaidAt: start}, + {ProjectID: projectID, Currency: "EUR", AmountMinor: 100, PaidAt: start}, + {ProjectID: projectID, Currency: "USD", AmountMinor: 1000, PaidAt: start.AddDate(0, 0, 1)}, + } + got := storage.SummarizeProjects([]storage.Project{{ID: projectID, RateID: usdID}}, + []storage.Rate{{ID: usdID, AmountMinor: 101, Currency: "USD"}, {ID: eurID, AmountMinor: 3, Currency: "EUR"}}, + entries, payments, start.Add(30*time.Minute))[0] + if got.EarnedMinor["USD"] != 50 || got.PaidMinor["USD"] != 50 || got.BalanceMinor["USD"] != 0 || + got.EarnedMinor["EUR"] != 1 || got.PaidMinor["EUR"] != 100 || got.BalanceMinor["EUR"] != -99 { + t.Fatalf("ledger = %#v", got) + } + for currency, balance := range got.BalanceMinor { + if got.EarnedMinor[currency]-got.PaidMinor[currency] != balance { + t.Fatalf("unreconciled %s", currency) + } + } +} + +func TestProjectLedgerUsesHistoricalRatesAndCountsUnratedTime(t *testing.T) { + projectID, oldRate, newRate, taskID := 1, 1, 2, 1 + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + first, second, third := start.Add(time.Hour), start.Add(2*time.Hour), start.Add(3*time.Hour) + entries := []storage.Entry{ + {TaskID: &taskID, ProjectID: &projectID, RateID: &oldRate, StartedAt: start, EndedAt: &first}, + {TaskID: &taskID, ProjectID: &projectID, RateID: &newRate, StartedAt: first, EndedAt: &second}, + {TaskID: &taskID, ProjectID: &projectID, StartedAt: second, EndedAt: &third}, + } + rates := []storage.Rate{{ID: oldRate, AmountMinor: 10000, Currency: "USD"}, {ID: newRate, AmountMinor: 20000, Currency: "USD"}} + projects := []storage.Project{{ID: projectID, RateID: newRate}} + payments := []storage.Payment{{ProjectID: projectID, Currency: "USD", AmountMinor: 15000, PaidAt: start}} + got := storage.SummarizeProjects(projects, rates, entries, payments, third)[0] + if got.Tracked != 3*time.Hour || got.EarnedMinor["USD"] != 30000 || got.PaidMinor["USD"] != 15000 || got.BalanceMinor["USD"] != 15000 { + t.Fatalf("ledger = %#v", got) + } + task := storage.SummarizeTasks([]storage.Task{{ID: taskID, ProjectID: projectID}}, projects, entries, rates, third)[0] + if task.Rate.ID != newRate || len(task.HistoricalRates) != 2 || task.EarnedMinor["USD"] != 30000 { + t.Fatalf("task = %#v", task) + } +} diff --git a/internal/storage/projects.go b/internal/storage/projects.go index 22af540..46ef001 100644 --- a/internal/storage/projects.go +++ b/internal/storage/projects.go @@ -95,7 +95,21 @@ func (s *Storage) UpdateProject(ctx context.Context, project Project) error { } func (s *Storage) DeleteProject(ctx context.Context, id int) error { - result, err := s.db.ExecContext(ctx, `DELETE FROM PROJECT WHERE ID = $1`, id) + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin delete project: %w", err) + } + defer tx.Rollback() + + // Empty archived tasks have no accounting history to retain. + if _, err := tx.ExecContext(ctx, ` + DELETE FROM TASK + WHERE PROJECT_ID = $1 AND ARCHIVED = 1 + AND NOT EXISTS (SELECT 1 FROM ENTRY WHERE TASK_ID = TASK.ID) + `, id); err != nil { + return fmt.Errorf("remove empty archived tasks: %w", err) + } + result, err := tx.ExecContext(ctx, `DELETE FROM PROJECT WHERE ID = $1`, id) if err != nil { return fmt.Errorf("delete project: %w", err) } @@ -106,5 +120,8 @@ func (s *Storage) DeleteProject(ctx context.Context, id int) error { if deleted == 0 { return fmt.Errorf("project %d not found", id) } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit delete project: %w", err) + } return nil } diff --git a/internal/storage/schema.go b/internal/storage/schema.go index c35c410..2ecdd8d 100644 --- a/internal/storage/schema.go +++ b/internal/storage/schema.go @@ -66,9 +66,43 @@ ALTER TABLE TASK ADD COLUMN RATE_ID INTEGER REFERENCES RATE(ID) ` +// ENTRY.PROJECT_ID remains a mirror for older readers and a fallback for +// legacy detached entries. Attached entries always belong to their task. +const taskProjectOwnership = ` +ALTER TABLE TASK + ADD COLUMN ARCHIVED INTEGER NOT NULL DEFAULT 0; + +UPDATE ENTRY +SET PROJECT_ID = (SELECT PROJECT_ID FROM TASK WHERE ID = ENTRY.TASK_ID) +WHERE TASK_ID IN (SELECT ID FROM TASK); + +CREATE TRIGGER task_project_changed AFTER UPDATE OF PROJECT_ID ON TASK +BEGIN + UPDATE ENTRY SET PROJECT_ID = NEW.PROJECT_ID WHERE TASK_ID = NEW.ID; +END; + +CREATE TRIGGER entry_project_inserted AFTER INSERT ON ENTRY +WHEN NEW.TASK_ID IS NOT NULL +BEGIN + UPDATE ENTRY + SET PROJECT_ID = (SELECT PROJECT_ID FROM TASK WHERE ID = NEW.TASK_ID) + WHERE ID = NEW.ID; +END; + +CREATE TRIGGER entry_project_changed AFTER UPDATE OF TASK_ID, PROJECT_ID ON ENTRY +WHEN NEW.TASK_ID IS NOT NULL AND NEW.PROJECT_ID IS NOT + (SELECT PROJECT_ID FROM TASK WHERE ID = NEW.TASK_ID) +BEGIN + UPDATE ENTRY + SET PROJECT_ID = (SELECT PROJECT_ID FROM TASK WHERE ID = NEW.TASK_ID) + WHERE ID = NEW.ID; +END; +` + var migrations = []string{ initial, addPaidForDate, normalizePaymentDates, addTaskRate, + taskProjectOwnership, } diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 10228d1..efa8836 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -20,7 +20,8 @@ func TestMigrationBackfillsLegacyPaymentDate(t *testing.T) { 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 TASK (ID INTEGER PRIMARY KEY, PROJECT_ID INTEGER); + CREATE TABLE ENTRY (ID INTEGER PRIMARY KEY, TASK_ID INTEGER, PROJECT_ID INTEGER); CREATE TABLE PAYMENT ( ID INTEGER PRIMARY KEY, PROJECT_ID INTEGER NOT NULL, @@ -74,7 +75,8 @@ func TestMigrationReplacesExistingPaymentAccountingDate(t *testing.T) { 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 TASK (ID INTEGER PRIMARY KEY, PROJECT_ID INTEGER); + CREATE TABLE ENTRY (ID INTEGER PRIMARY KEY, TASK_ID INTEGER, PROJECT_ID INTEGER); CREATE TABLE PAYMENT ( ID INTEGER PRIMARY KEY, PROJECT_ID INTEGER NOT NULL, @@ -130,6 +132,7 @@ func TestMigrationAddsNullableTaskRate(t *testing.T) { PROJECT_ID INTEGER NOT NULL ); INSERT INTO TASK (ID, NAME, PROJECT_ID) VALUES (1, 'existing', 7); + CREATE TABLE ENTRY (ID INTEGER PRIMARY KEY, TASK_ID INTEGER, PROJECT_ID INTEGER); PRAGMA user_version = 3; `); err != nil { db.Close() diff --git a/internal/storage/summaries.go b/internal/storage/summaries.go index b6ffe47..a9540ed 100644 --- a/internal/storage/summaries.go +++ b/internal/storage/summaries.go @@ -14,19 +14,22 @@ type ProjectSummary struct { Project Rate Rate BalanceMinor map[string]int64 + EarnedMinor map[string]int64 + PaidMinor map[string]int64 Tracked time.Duration } type TaskSummary struct { Task - Project Project - Rate Rate - RateOverridden bool - 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 + HistoricalRates []Rate } func SummarizeRates(rates []Rate, projects []Project) []RateSummary { @@ -52,20 +55,17 @@ func SummarizeProjects( balances := make(map[int]map[string]int64, len(projects)) minorSeconds := make(map[int]map[string]int64, len(projects)) tracked := make(map[int]time.Duration, len(projects)) + paid := make(map[int]map[string]int64, len(projects)) for _, project := range projects { balances[project.ID] = make(map[string]int64) if rate, ok := ratesByID[project.RateID]; ok { balances[project.ID][rate.Currency] = 0 } minorSeconds[project.ID] = make(map[string]int64) + paid[project.ID] = make(map[string]int64) } for _, entry := range entries { - if entry.ProjectID == nil || entry.RateID == nil { - continue - } - rate, rateOK := ratesByID[*entry.RateID] - _, projectOK := balances[*entry.ProjectID] - if !rateOK || !projectOK { + if entry.ProjectID == nil || balances[*entry.ProjectID] == nil { continue } endedAt := now @@ -77,6 +77,13 @@ func SummarizeProjects( } elapsed := nonNegativeDuration(entry.StartedAt, endedAt) tracked[*entry.ProjectID] += elapsed + if entry.RateID == nil { + continue + } + rate, rateOK := ratesByID[*entry.RateID] + if !rateOK { + continue + } minorSeconds[*entry.ProjectID][rate.Currency] += int64(rate.AmountMinor) * int64(elapsed/time.Second) } @@ -94,6 +101,7 @@ func SummarizeProjects( continue } if balances[payment.ProjectID] != nil { + paid[payment.ProjectID][payment.Currency] += int64(payment.AmountMinor) balances[payment.ProjectID][payment.Currency] -= int64(payment.AmountMinor) } } @@ -104,6 +112,8 @@ func SummarizeProjects( Project: project, Rate: ratesByID[project.RateID], BalanceMinor: balances[project.ID], + EarnedMinor: minorSecondsToAmounts(minorSeconds[project.ID]), + PaidMinor: paid[project.ID], Tracked: tracked[project.ID], } } @@ -134,10 +144,16 @@ func SummarizeTasks( var lastEndedAt *time.Time lastEntryID := 0 active := false + historicalRates := make(map[int]Rate) for _, entry := range entries { if entry.TaskID == nil || *entry.TaskID != task.ID { continue } + if entry.RateID != nil { + if rate, ok := ratesByID[*entry.RateID]; ok { + historicalRates[rate.ID] = rate + } + } if entry.EndedAt == nil { active = true continue @@ -156,6 +172,12 @@ func SummarizeTasks( LastEndedAt: lastEndedAt, LastEntryID: lastEntryID, Tracked: tracked, EarnedMinor: earned, } + for _, rate := range historicalRates { + result[i].HistoricalRates = append(result[i].HistoricalRates, rate) + } + sort.Slice(result[i].HistoricalRates, func(a, b int) bool { + return result[i].HistoricalRates[a].ID < result[i].HistoricalRates[b].ID + }) } return result } @@ -173,7 +195,7 @@ func TaskTotals( continue } endedAt := now - if entry.EndedAt != nil { + if entry.EndedAt != nil && (now.IsZero() || entry.EndedAt.Before(now)) { endedAt = *entry.EndedAt } else if now.IsZero() { continue diff --git a/internal/storage/tasks.go b/internal/storage/tasks.go index 1053af4..041d1a4 100644 --- a/internal/storage/tasks.go +++ b/internal/storage/tasks.go @@ -13,18 +13,29 @@ type Task struct { Name string `db:"name"` ProjectID int `db:"project_id"` RateID *int `db:"rate_id"` + Archived bool `db:"archived"` } func (s *Storage) GetTasks(ctx context.Context) ([]Task, error) { + return s.getTasks(ctx, false) +} + +// GetTasksIncludingArchived keeps historical task names available to reports. +func (s *Storage) GetTasksIncludingArchived(ctx context.Context) ([]Task, error) { + return s.getTasks(ctx, true) +} + +func (s *Storage) getTasks(ctx context.Context, includeArchived bool) ([]Task, error) { const query = ` SELECT ID AS id, NAME AS name, PROJECT_ID AS project_id, - RATE_ID AS rate_id + RATE_ID AS rate_id, ARCHIVED AS archived FROM TASK + WHERE ARCHIVED = 0 OR $1 ORDER BY ID ` tasks := make([]Task, 0) - if err := s.db.SelectContext(ctx, &tasks, query); err != nil { + if err := s.db.SelectContext(ctx, &tasks, query, includeArchived); err != nil { return nil, fmt.Errorf("query tasks: %w", err) } return tasks, nil @@ -33,7 +44,7 @@ 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, - RATE_ID AS rate_id + RATE_ID AS rate_id, ARCHIVED AS archived FROM TASK WHERE ID = $1 ` @@ -200,7 +211,7 @@ func (s *Storage) CreateEntryForTaskID( COALESCE(TASK.RATE_ID, PROJECT.RATE_ID) AS rate_id FROM TASK JOIN PROJECT ON PROJECT.ID = TASK.PROJECT_ID - WHERE TASK.ID = $1 + WHERE TASK.ID = $1 AND TASK.ARCHIVED = 0 `, taskID); err != nil { if err == sql.ErrNoRows { return 0, fmt.Errorf("task %d not found", taskID) @@ -300,6 +311,8 @@ func (s *Storage) PauseAllTasks( return len(taskIDs), nil } +// UpdateTask moves attached work with the task via task_project_changed. +// The trigger runs inside this statement's transaction and leaves rates intact. func (s *Storage) UpdateTask(ctx context.Context, task Task) error { name, err := NormalizeName(task.Name, "name") if err != nil { @@ -327,6 +340,8 @@ func (s *Storage) UpdateTask(ctx context.Context, task Task) error { return nil } +// DeleteTask retains its legacy name for callers; it archives the task and +// stops its timers while preserving the task/entry relationship. func (s *Storage) DeleteTask(ctx context.Context, id int) error { tx, err := s.db.BeginTxx(ctx, nil) if err != nil { @@ -335,12 +350,18 @@ func (s *Storage) DeleteTask(ctx context.Context, id int) error { defer tx.Rollback() if _, err := tx.ExecContext( ctx, - `UPDATE ENTRY SET TASK_ID = NULL WHERE TASK_ID = $1`, - id, + ` + UPDATE ENTRY + SET ENDED_AT = MAX(STARTED_AT + 1, $2) + WHERE TASK_ID = $1 AND ENDED_AT IS NULL + `, + id, time.Now().Unix(), ); err != nil { - return fmt.Errorf("detach task entries: %w", err) + return fmt.Errorf("stop archived task entries: %w", err) } - result, err := tx.ExecContext(ctx, `DELETE FROM TASK WHERE ID = $1`, id) + result, err := tx.ExecContext(ctx, ` + UPDATE TASK SET ARCHIVED = 1 WHERE ID = $1 AND ARCHIVED = 0 + `, id) if err != nil { return fmt.Errorf("delete task: %w", err) } @@ -364,3 +385,24 @@ func (s *Storage) AssignTasksToProject( ) error { return s.bulkAssign(ctx, "TASK", "PROJECT_ID", projectID, taskIDs) } + +func (s *Storage) RestoreTask(ctx context.Context, id int) error { + const query = ` + UPDATE TASK + SET ARCHIVED = 0 + WHERE ID = $1 AND ARCHIVED = 1 + ` + + result, err := s.db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("restore task: %w", err) + } + restored, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("get restored task count: %w", err) + } + if restored == 0 { + return fmt.Errorf("archived task %d not found", id) + } + return nil +} diff --git a/internal/storage/tasks_test.go b/internal/storage/tasks_test.go index d9e69e9..9e2c04a 100644 --- a/internal/storage/tasks_test.go +++ b/internal/storage/tasks_test.go @@ -199,7 +199,7 @@ func TestTaskEntriesCaptureProjectRateChanges(t *testing.T) { } } -func TestTaskRateOverrideAndProjectChangesAffectFutureEntries(t *testing.T) { +func TestTaskProjectMovesPreserveHistoricalRates(t *testing.T) { stor := fixtureStorage(t) ctx := t.Context() firstProject := fixtureProject(t, stor) @@ -272,7 +272,7 @@ func TestTaskRateOverrideAndProjectChangesAffectFutureEntries(t *testing.T) { if err != nil { t.Fatal(err) } - wantProjects := []int{firstProject.ID, projects[1].ID, projects[1].ID} + wantProjects := []int{projects[1].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)) @@ -442,7 +442,7 @@ func TestGetTask(t *testing.T) { if err != nil { t.Fatal(err) } - if len(entries) != 1 || entries[0].TaskID != nil || + if len(entries) != 1 || entries[0].TaskID == nil || *entries[0].TaskID != 1 || entries[0].ProjectID == nil || *entries[0].ProjectID != project.ID || entries[0].RateID == nil || *entries[0].RateID != project.RateID { t.Fatalf("task deletion lost accounting history: %#v", entries) diff --git a/internal/tui/screens/projects.go b/internal/tui/screens/projects.go index e3894b1..6ef23df 100644 --- a/internal/tui/screens/projects.go +++ b/internal/tui/screens/projects.go @@ -23,6 +23,8 @@ type projectItem struct { project storage.Project rate storage.Rate balance map[string]int64 + earned map[string]int64 + paid map[string]int64 tracked time.Duration } @@ -41,6 +43,12 @@ func (p projectItem) Description() string { balances[i] = components.FormatBalance(p.balance[currency], currency) } parts := make([]string, 0, 4) + for _, currency := range dashboardCurrencies(p.earned, p.paid, p.balance) { + parts = append(parts, + components.FormatMoney(p.earned[currency], currency)+" earned", + components.FormatMoney(p.paid[currency], currency)+" paid", + ) + } if len(balances) > 0 { parts = append(parts, strings.Join(balances, ", ")) } @@ -48,7 +56,7 @@ func (p projectItem) Description() string { parts, components.FormatDuration(p.tracked)+" tracked", fmt.Sprintf( - "%s Rate · %s/h", + "Current rate: %s · %s/h", p.rate.Name, components.FormatMoney(int64(p.rate.AmountMinor), p.rate.Currency), ), @@ -75,6 +83,8 @@ func projectItems( project: summary.Project, rate: summary.Rate, balance: summary.BalanceMinor, + earned: summary.EarnedMinor, + paid: summary.PaidMinor, tracked: summary.Tracked, } } diff --git a/internal/tui/screens/projects_test.go b/internal/tui/screens/projects_test.go index 706799b..1c62f84 100644 --- a/internal/tui/screens/projects_test.go +++ b/internal/tui/screens/projects_test.go @@ -32,7 +32,8 @@ func TestProjectItems(t *testing.T) { for _, expected := range []string{ "$30.00 outstanding", "1h 00m tracked", - "Standard Rate · $50.00/h", + "Current rate: Standard · $50.00/h", + "$50.00 earned", "$20.00 paid", } { if !strings.Contains(description, expected) { t.Fatalf("description %q does not contain %q", description, expected) diff --git a/internal/tui/screens/stats.go b/internal/tui/screens/stats.go index 18fad71..3f96afb 100644 --- a/internal/tui/screens/stats.go +++ b/internal/tui/screens/stats.go @@ -520,7 +520,7 @@ func loadStats(ctx context.Context, stor *storage.Storage) tea.Cmd { if err != nil { return statsFailedMsg{err} } - tasks, err := stor.GetTasks(ctx) + tasks, err := stor.GetTasksIncludingArchived(ctx) if err != nil { return statsFailedMsg{err} } diff --git a/internal/tui/screens/tasks.go b/internal/tui/screens/tasks.go index 1bba9ba..0ac26e1 100644 --- a/internal/tui/screens/tasks.go +++ b/internal/tui/screens/tasks.go @@ -955,7 +955,7 @@ func (m Dashboard) Actions() string { "[x/delete] delete time [t] edit task [esc] back" } return "[/] search [n] new & track [a] add past task " + - "[enter] details [e] edit task [x/delete] delete " + + "[enter] details [e] edit task [x/delete] archive " + "[space] start/pause [f] filters [F] reset filters " + "[shift+up/down or K/J] period " + "[shift+left/right or H/L] move" @@ -1048,13 +1048,7 @@ func (m Dashboard) openSelectedTaskForm(deleteTask bool) (Dashboard, tea.Cmd) { } var form *components.Form[taskItem] if deleteTask { - form = components.NewDeleteForm[taskItem]( - m.ctx, - task.Name, - func(ctx context.Context) error { - return m.stor.DeleteTask(ctx, task.ID) - }, - ) + form = archiveTaskForm(m.ctx, m.stor, task) } else { var err error form, err = taskForm( @@ -1150,6 +1144,10 @@ func (m Dashboard) detailView() string { duration, amounts := taskTotals(m.filteredEntries(), m.rates, task.ID, m.now) rate, overridden := effectiveTaskRate(task, m.projectList, m.rates) summary := project + for _, item := range storage.SummarizeTasks([]storage.Task{task}, m.projectList, + m.filteredEntries(), mapRates(m.rates), m.now) { + summary += formatHistoricalRates(item.HistoricalRates) + } if rate.ID != 0 { summary += " · " + formatTaskRate(rate, overridden) } @@ -1233,13 +1231,7 @@ func newTaskPage( return taskForm(ctx, stor, &item.task, values.projects, values.rates) }, Delete: func(item taskItem) *components.Form[taskItem] { - return components.NewDeleteForm[taskItem]( - ctx, - item.task.Name, - func(ctx context.Context) error { - return stor.DeleteTask(ctx, item.task.ID) - }, - ) + return archiveTaskForm(ctx, stor, item.task) }, AfterSave: afterSave, } @@ -1340,6 +1332,7 @@ func summarizedTaskItems( items := make([]taskItem, 0, len(ordered)) for _, summary := range ordered { description := summary.Project.Name + description += formatHistoricalRates(summary.HistoricalRates) if summary.Rate.ID != 0 { description += " · " + formatTaskRate( summary.Rate, summary.RateOverridden, @@ -1399,13 +1392,27 @@ func formatTaskRate(rate storage.Rate, overridden bool) string { source = "task rate" } return fmt.Sprintf( - "%s · %s/hour (%s)", + "Next rate: %s · %s/hour (%s)", rate.Name, components.FormatMoney(int64(rate.AmountMinor), rate.Currency), source, ) } +func formatHistoricalRates(rates []storage.Rate) string { + if len(rates) == 0 { + return "" + } + parts := make([]string, len(rates)) + for i, rate := range rates { + parts[i] = fmt.Sprintf( + "%s %s/hour", rate.Name, + components.FormatMoney(int64(rate.AmountMinor), rate.Currency), + ) + } + return " · Used: " + strings.Join(parts, ", ") +} + func effectiveTaskRate( task storage.Task, projects []storage.Project, @@ -1455,6 +1462,33 @@ func taskForm( ), nil } +func archiveTaskForm( + ctx context.Context, + stor *storage.Storage, + task storage.Task, +) *components.Form[taskItem] { + confirmed := false + form := huh.NewForm(huh.NewGroup( + huh.NewConfirm(). + Title("Archive " + task.Name + "?"). + Description("Stops running timers and preserves time entries and earnings."). + Affirmative("Archive"). + Negative("Cancel"). + Value(&confirmed), + )) + return components.NewForm[taskItem]( + ctx, + "archive task", + form, + func(ctx context.Context) error { + if !confirmed { + return nil + } + return stor.DeleteTask(ctx, task.ID) + }, + ) +} + func historicalTaskForm( ctx context.Context, stor *storage.Storage, @@ -1591,6 +1625,7 @@ func optionalTaskRateID(rateID int) *int { type entryItem struct { entry storage.Entry now time.Time + rate storage.Rate } type entryMeta struct { @@ -1611,6 +1646,14 @@ func (e entryItem) Description() string { endedAt = *e.entry.EndedAt } description := components.FormatDuration(endedAt.Sub(e.entry.StartedAt)) + if e.rate.ID != 0 { + description += fmt.Sprintf( + " · %s %s/hour", e.rate.Name, + components.FormatMoney(int64(e.rate.AmountMinor), e.rate.Currency), + ) + } else { + description += " · Rate unavailable" + } if e.entry.Note != "" { description += " · " + e.entry.Note } @@ -1645,7 +1688,18 @@ func newEntryPage( !filter.Period.Start.IsZero() || !filter.Period.End.IsZero()) { entries = entriesOverlappingPeriod(entries, filter.Period, now) } - return entryItems(entries, task.ID, now), entryMeta{task: task}, nil + rates, err := stor.GetRates(ctx) + if err != nil { + return nil, nil, err + } + items := entryItems(entries, task.ID, now) + byID := storage.RatesByID(rates) + for i := range items { + if items[i].entry.RateID != nil { + items[i].rate = byID[*items[i].entry.RateID] + } + } + return items, entryMeta{task: task}, nil }, Create: func(meta any) (*components.Form[entryItem], error) { values := meta.(entryMeta) diff --git a/internal/tui/screens/tasks_test.go b/internal/tui/screens/tasks_test.go index 41cbfee..1b17fae 100644 --- a/internal/tui/screens/tasks_test.go +++ b/internal/tui/screens/tasks_test.go @@ -94,6 +94,28 @@ func TestTaskItemsDisplayEffectiveRate(t *testing.T) { } } +func TestHistoricalTaskDisplaysUsedRateSeparatelyFromNextRate(t *testing.T) { + taskID, projectID, oldID := 1, 1, 1 + end := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + rates := []storage.Rate{ + {ID: oldID, Name: "Old", AmountMinor: 10000, Currency: "USD"}, + {ID: 2, Name: "New", AmountMinor: 20000, Currency: "USD"}, + } + entry := storage.Entry{TaskID: &taskID, ProjectID: &projectID, RateID: &oldID, + StartedAt: end.Add(-time.Hour), EndedAt: &end} + items := taskItems([]storage.Task{{ID: taskID, Name: "Historical", ProjectID: projectID}}, + []storage.Project{{ID: projectID, Name: "Client", RateID: 2}}, []storage.Entry{entry}, rates) + for _, want := range []string{"Used: Old $100.00/hour", "Next rate: New", "$100.00 earned"} { + if !strings.Contains(items[0].Description(), want) { + t.Fatalf("missing %q: %s", want, items[0].Description()) + } + } + row := entryItem{entry: entry, now: end, rate: rates[0]} + if !strings.Contains(row.Description(), "Old $100.00/hour") { + t.Fatalf("entry rate = %s", row.Description()) + } +} + func TestDashboardHistoricalEntryDoesNotBecomeLatest(t *testing.T) { taskOne := 1 taskTwo := 2 diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index cec63fb..0ed7344 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -41,7 +41,7 @@ func TestDashboardFillsContentHeight(t *testing.T) { } if got := lines[len(lines)-1]; got != "[/] search [n] new & track [a] add past task [enter] details "+ - "[e] edit task [x/delete] delete [space] start/pause "+ + "[e] edit task [x/delete] archive [space] start/pause "+ "[f] filters [F] reset filters "+ "[shift+up/down or K/J] period "+ "[shift+left/right or H/L] move" {