diff --git a/.gitignore b/.gitignore index 77268fd..4ddb03b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ build/ other/ +.dev-data/ diff --git a/Makefile b/Makefile index 7a8de5b..46b44c6 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,17 @@ -.PHONY: build run test fmt clean +.PHONY: build run seed test fmt clean VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +DEV_DATA_PATH ?= $(CURDIR)/.dev-data/data.sqlite build: mkdir -p build go build -ldflags "-X main.version=$(VERSION)" -o build/chankat ./cmd run: - go run ./cmd + CHANKAT_DATA_PATH="$(DEV_DATA_PATH)" go run ./cmd + +seed: + CHANKAT_DATA_PATH="$(DEV_DATA_PATH)" go run ./cmd/seed --reset test: go test ./... diff --git a/README.md b/README.md index a7b176f..7c0b5b6 100644 --- a/README.md +++ b/README.md @@ -48,3 +48,38 @@ Release archives are built for Linux, macOS and Windows. | ![Tasks screen](assets/sc-tasks.png) | ![Payments screen](assets/sc-payments.png) | | Project editor | Payment editor | | ![Project editor](assets/sc-editor-projects.png) | ![Payment editor](assets/sc-editor-payments.png) | + +## Contributing + +Build and test changes with the Makefile: + +```sh +make build +make test +``` + +Repository structure: + +```text +cmd/ application and development command entry points +internal/cli/ CLI parsing and output +internal/storage/ SQLite access, schema, validation and aggregation +internal/tui/ terminal application and tab navigation +internal/tui/components/ reusable TUI controls and formatting +internal/tui/screens/ TUI screens and forms +assets/ screenshots and other static files +``` + +Keep command entry points limited to startup and dependency wiring. Put database +operations and data aggregation in `internal/storage`. CLI behavior belongs in +`internal/cli`; TUI screens belong in `internal/tui/screens`, with reusable UI +code in `internal/tui/components`. Keep tests beside the code they cover. + +Set `CHANKAT_DATA_PATH` to override the SQLite database file. Development +commands use `.dev-data/data.sqlite`, leaving the installed application's user +database untouched: + +```sh +make seed +make run +``` diff --git a/cmd/seed/main.go b/cmd/seed/main.go new file mode 100644 index 0000000..6568401 --- /dev/null +++ b/cmd/seed/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "time" + + "chankat/internal/storage" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + reset := flag.Bool("reset", false, "replace all existing application data") + nowValue := flag.String("now", "", "seed time in RFC3339 format") + flag.Parse() + if flag.NArg() != 0 { + return fmt.Errorf("unexpected argument %q", flag.Arg(0)) + } + + now := time.Now() + if *nowValue != "" { + parsed, err := time.Parse(time.RFC3339, *nowValue) + if err != nil { + return fmt.Errorf("parse --now: %w", err) + } + now = parsed + } + + stor, err := storage.Open() + if err != nil { + return err + } + defer stor.Close() + if err := stor.Migrate(); err != nil { + return err + } + if err := stor.SeedDevelopment(context.Background(), now, *reset); err != nil { + return err + } + fmt.Println("seeded development data") + return nil +} diff --git a/go.mod b/go.mod index f503925..c26e710 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module chankat go 1.25.0 require ( + github.com/NimbleMarkets/ntcharts v0.5.1 github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 github.com/charmbracelet/bubbletea v1.3.10 @@ -23,6 +24,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/uuid v1.6.0 // indirect + github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect diff --git a/go.sum b/go.sum index 7cd7b03..e282766 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/NimbleMarkets/ntcharts v0.5.1 h1:HWtekubEXfESwi24pyFynwGo2Hulbb9fPh7INMUc1dg= +github.com/NimbleMarkets/ntcharts v0.5.1/go.mod h1:zVeRqYkh2n59YPe1bflaSL4O2aD2ZemNmrbdEqZ70hk= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -58,6 +60,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e h1:OLwZ8xVaeVrru0xyeuOX+fne0gQTFEGlzfNjipCbxlU= +github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e/go.mod h1:NQ34EGeu8FAYGBMDzwhfNJL8YQYoWZP5xYJPRDAwN3E= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index cd83cfc..c0f5f87 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -110,6 +110,8 @@ func (r runner) run(args []string) error { return err case "completion": return r.runCompletion(args[1:]) + case "dashboard": + return r.runDashboard(args[1:]) case "rates": return r.runRates(args[1:]) case "projects": @@ -152,6 +154,10 @@ func (r runner) help(args []string) { fmt.Fprintln(r.out, "Usage: chankat completion bash") return } + if command[0] == "dashboard" { + fmt.Fprintln(r.out, "Usage: "+commandSpecs["dashboard"].commands["show"].usage) + return + } if len(command) == 1 { if resource, ok := commandSpecs[command[0]]; ok { fmt.Fprintf( @@ -177,6 +183,7 @@ func (r runner) help(args []string) { func (r runner) usage() { fmt.Fprint(r.out, `Usage: chankat launch the terminal interface + chankat [--json] dashboard [options] chankat [--json] [options] chankat completion bash chankat version diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 4619ecd..ad15205 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -171,6 +171,46 @@ func TestCLIStopsAllTasks(t *testing.T) { } } +func TestCLIDashboardCustomPeriod(t *testing.T) { + stor := cliStorage(t) + runCLI(t, stor, "rates", "create", "--name", "Rate", + "--amount-minor", "10000", "--currency", "USD") + runCLI(t, stor, "projects", "create", "--name", "Acme", "--rate", "1") + runCLI(t, stor, "tasks", "create", "--name", "Build", "--project", "1", + "--started-at", "2026-08-02T23:00:00Z", + "--ended-at", "2026-08-03T01:00:00Z") + runCLI(t, stor, "payments", "create", "--project", "1", + "--amount-minor", "5000", "--currency", "USD", + "--paid-at", "2026-08-09", "--paid-for", "2026-08-03") + + output := runCLIAt(t, stor, time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC), + "--json", "dashboard", "--from", "2026-08-03", "--to", "2026-08-03") + var got dashboardOutput + if err := json.Unmarshal([]byte(output), &got); err != nil { + t.Fatal(err) + } + if got.Period != "custom" || got.TrackedSeconds != 3600 || + got.EarnedMinor["USD"] != 10_000 || got.PaidMinor["USD"] != 5_000 || + len(got.Projects) != 1 { + t.Fatalf("dashboard = %#v", got) + } +} + +func TestCLIDashboardRejectsInvalidRanges(t *testing.T) { + stor := cliStorage(t) + for _, args := range [][]string{ + {"dashboard", "--from", "2026-08-01"}, + {"dashboard", "--period", "week", "--from", "2026-08-01", "--to", "2026-08-02"}, + {"dashboard", "--from", "2026-08-02", "--to", "2026-08-01"}, + {"dashboard", "--period", "quarter"}, + } { + var out bytes.Buffer + if err := RunIO(t.Context(), args, "test", stor, &out, &out); err == nil { + t.Fatalf("%v succeeded", args) + } + } +} + func TestCLIRejectsInvalidArguments(t *testing.T) { stor := cliStorage(t) for _, args := range [][]string{ @@ -216,6 +256,7 @@ func TestFormatTracked(t *testing.T) { func cliStorage(t *testing.T) *storage.Storage { t.Helper() + t.Setenv("CHANKAT_DATA_PATH", "") t.Setenv("XDG_DATA_HOME", t.TempDir()) stor, err := storage.Open() if err != nil { diff --git a/internal/cli/commands.go b/internal/cli/commands.go index f99cf26..e7893ce 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -39,6 +39,19 @@ var resourceOrder = []string{ } var commandSpecs = map[string]resourceSpec{ + "dashboard": { + commands: map[string]commandSpec{ + "show": { + usage: "chankat dashboard [--period day|week|month|all] [--from DATE --to DATE] [--project ID]", + options: []optionSpec{ + {name: "period"}, + {name: "from"}, + {name: "to"}, + {name: "project", value: completeProjectID}, + }, + }, + }, + }, "rates": { actions: []string{"list", "get", "create", "update", "delete"}, commands: map[string]commandSpec{ diff --git a/internal/cli/completion.go b/internal/cli/completion.go index ef5072c..87d64d8 100644 --- a/internal/cli/completion.go +++ b/internal/cli/completion.go @@ -76,7 +76,7 @@ func analyzeCompletion(args []string) completionRequest { prefix: current, candidates: append( append([]string{}, resourceOrder...), - "version", "help", "completion", "--json", + "dashboard", "version", "help", "completion", "--json", ), } } @@ -88,6 +88,10 @@ func analyzeCompletion(args []string) completionRequest { } return completionRequest{prefix: current} } + if args[0] == "dashboard" { + spec := commandSpecs["dashboard"].commands["show"] + return analyzeCommandOptions(spec, args, 1) + } resource, ok := commandSpecs[args[0]] if !ok { @@ -128,6 +132,39 @@ func analyzeCompletion(args []string) completionRequest { return completionRequest{prefix: current, candidates: options} } +func analyzeCommandOptions( + spec commandSpec, + args []string, + optionStart int, +) completionRequest { + current := args[len(args)-1] + if option, value, ok := optionWithValue(spec, current); ok { + return completionRequest{ + prefix: value, value: option.completionValue(), + valueFlag: "--" + option.name, + } + } + if len(args) > optionStart+1 { + if option, ok := findOption(spec, args[len(args)-2]); ok && + option.completionValue() != completeNone { + return completionRequest{prefix: current, value: option.completionValue()} + } + } + used := make(map[string]bool) + for _, arg := range args[optionStart : len(args)-1] { + name := strings.TrimPrefix(strings.SplitN(arg, "=", 2)[0], "--") + used[name] = true + } + candidates := make([]string, 0, len(spec.options)+1) + for _, option := range spec.options { + if !used[option.name] { + candidates = append(candidates, "--"+option.name) + } + } + candidates = append(candidates, "--help") + return completionRequest{prefix: current, candidates: candidates} +} + func commandOptions(spec commandSpec, args []string) []string { used := make(map[string]bool) for _, arg := range args[2 : len(args)-1] { diff --git a/internal/cli/completion_test.go b/internal/cli/completion_test.go index 1e67850..c68aeb1 100644 --- a/internal/cli/completion_test.go +++ b/internal/cli/completion_test.go @@ -32,7 +32,7 @@ func TestStaticCompletion(t *testing.T) { args []string want []string }{ - {args: []string{""}, want: []string{"rates", "completion", "--json"}}, + {args: []string{""}, want: []string{"dashboard", "rates", "completion", "--json"}}, {args: []string{"--json", "pro"}, want: []string{"projects"}}, {args: []string{"tasks", "cr"}, want: []string{"create"}}, {args: []string{"completion", ""}, want: []string{"bash"}}, @@ -44,6 +44,10 @@ func TestStaticCompletion(t *testing.T) { args: []string{"tasks", "stop", "--"}, want: []string{"--all", "--help"}, }, + { + args: []string{"dashboard", "--"}, + want: []string{"--period", "--from", "--to", "--project", "--help"}, + }, } { request := analyzeCompletion(test.args) for _, want := range test.want { diff --git a/internal/cli/dashboard.go b/internal/cli/dashboard.go new file mode 100644 index 0000000..44eb22c --- /dev/null +++ b/internal/cli/dashboard.go @@ -0,0 +1,197 @@ +package cli + +import ( + "fmt" + "time" + + "chankat/internal/storage" +) + +type dashboardOutput struct { + Period string `json:"period"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + TrackedSeconds int64 `json:"tracked_seconds"` + EarnedMinor map[string]int64 `json:"earned_minor"` + PaidMinor map[string]int64 `json:"paid_minor"` + NetMinor map[string]int64 `json:"net_minor"` + Projects []dashboardProjectOutput `json:"projects"` +} + +type dashboardProjectOutput struct { + ProjectID *int `json:"project_id"` + ProjectName string `json:"project_name"` + TrackedSeconds int64 `json:"tracked_seconds"` + EarnedMinor map[string]int64 `json:"earned_minor"` + PaidMinor map[string]int64 `json:"paid_minor"` + NetMinor map[string]int64 `json:"net_minor"` + Tasks []dashboardTaskOutput `json:"tasks"` +} + +type dashboardTaskOutput struct { + TaskID *int `json:"task_id"` + TaskName string `json:"task_name"` + TrackedSeconds int64 `json:"tracked_seconds"` + EarnedMinor map[string]int64 `json:"earned_minor"` +} + +func (r runner) runDashboard(args []string) error { + flags := r.flags("dashboard", "show") + periodName := flags.String("period", "day", "day, week, month, or all") + from := flags.String("from", "", "custom range start date (YYYY-MM-DD)") + to := flags.String("to", "", "custom range end date (YYYY-MM-DD, inclusive)") + projectID := flags.Int("project", 0, "filter by project ID") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected argument %q", flags.Arg(0)) + } + + customFrom := changed(flags, "from") + customTo := changed(flags, "to") + if customFrom != customTo { + return fmt.Errorf("--from and --to must be supplied together") + } + if customFrom && changed(flags, "period") { + return fmt.Errorf("--period cannot be combined with --from and --to") + } + + now := r.now() + var period storage.Period + var err error + if customFrom { + start, parseErr := parseDate(*from) + if parseErr != nil { + return fmt.Errorf("invalid --from: %w", parseErr) + } + end, parseErr := parseDate(*to) + if parseErr != nil { + return fmt.Errorf("invalid --to: %w", parseErr) + } + period, err = storage.CustomPeriod(start, end) + } else { + period, err = storage.CurrentPeriod(storage.PeriodKind(*periodName), now) + } + if err != nil { + return err + } + + projects, err := r.stor.GetProjects(r.ctx) + if err != nil { + return fmt.Errorf("load dashboard projects: %w", err) + } + if changed(flags, "project") { + if *projectID <= 0 { + return fmt.Errorf("invalid project ID %q", fmt.Sprint(*projectID)) + } + project, getErr := r.stor.GetProject(r.ctx, *projectID) + if getErr != nil { + return getErr + } + projects = []storage.Project{project} + } + tasks, err := r.stor.GetTasks(r.ctx) + if err != nil { + return fmt.Errorf("load dashboard tasks: %w", err) + } + rates, err := r.stor.GetRates(r.ctx) + if err != nil { + return fmt.Errorf("load dashboard rates: %w", err) + } + entries, err := r.stor.GetEntries(r.ctx) + if err != nil { + return fmt.Errorf("load dashboard entries: %w", err) + } + payments, err := r.stor.GetPayments(r.ctx) + if err != nil { + return fmt.Errorf("load dashboard payments: %w", err) + } + if changed(flags, "project") { + entries = filterDashboardEntries(entries, *projectID) + payments = filterDashboardPayments(payments, *projectID) + } + + summary := storage.SummarizeDashboard( + projects, tasks, rates, entries, payments, + period.Start, period.End, now, + ) + output := makeDashboardOutput(period, summary) + if r.json { + return r.writeJSON(output) + } + if _, err := fmt.Fprintf(r.out, "%s\nTracked: %s\n\n", period.Label(), + formatTracked(output.TrackedSeconds)); err != nil { + return err + } + rows := make([]string, len(output.Projects)+1) + for i, project := range output.Projects { + rows[i] = fmt.Sprintf("%s\t%s\t%s\t%s\t%s", + project.ProjectName, formatTracked(project.TrackedSeconds), + formatMinorMap(project.EarnedMinor), formatMinorMap(project.PaidMinor), + formatMinorMap(project.NetMinor)) + } + rows[len(rows)-1] = fmt.Sprintf("TOTAL\t%s\t%s\t%s\t%s", + formatTracked(output.TrackedSeconds), formatMinorMap(output.EarnedMinor), + formatMinorMap(output.PaidMinor), formatMinorMap(output.NetMinor)) + return r.table("PROJECT\tTRACKED\tEARNED_MINOR\tPAID_MINOR\tNET_MINOR", rows) +} + +func filterDashboardEntries(entries []storage.Entry, projectID int) []storage.Entry { + result := make([]storage.Entry, 0, len(entries)) + for _, entry := range entries { + if entry.ProjectID != nil && *entry.ProjectID == projectID { + result = append(result, entry) + } + } + return result +} + +func filterDashboardPayments(payments []storage.Payment, projectID int) []storage.Payment { + result := make([]storage.Payment, 0, len(payments)) + for _, payment := range payments { + if payment.ProjectID == projectID { + result = append(result, payment) + } + } + return result +} + +func makeDashboardOutput( + period storage.Period, + summary storage.DashboardSummary, +) dashboardOutput { + output := dashboardOutput{ + Period: string(period.Kind), TrackedSeconds: int64(summary.Tracked / time.Second), + EarnedMinor: summary.EarnedMinor, PaidMinor: summary.PaidMinor, + NetMinor: summary.NetMinor, + Projects: make([]dashboardProjectOutput, 0, len(summary.Projects)), + } + if period.Kind == "" { + output.Period = "custom" + } + if !period.Start.IsZero() { + output.From = period.Start.Format(dateLayout) + } + if !period.End.IsZero() { + output.To = period.End.AddDate(0, 0, -1).Format(dateLayout) + } + for _, project := range summary.Projects { + item := dashboardProjectOutput{ + ProjectID: project.ProjectID, ProjectName: project.ProjectName, + TrackedSeconds: int64(project.Tracked / time.Second), + EarnedMinor: project.EarnedMinor, PaidMinor: project.PaidMinor, + NetMinor: project.NetMinor, + Tasks: make([]dashboardTaskOutput, 0, len(project.Tasks)), + } + for _, task := range project.Tasks { + item.Tasks = append(item.Tasks, dashboardTaskOutput{ + TaskID: task.TaskID, TaskName: task.TaskName, + TrackedSeconds: int64(task.Tracked / time.Second), + EarnedMinor: task.EarnedMinor, + }) + } + output.Projects = append(output.Projects, item) + } + return output +} diff --git a/internal/storage/dashboard.go b/internal/storage/dashboard.go new file mode 100644 index 0000000..b0c8123 --- /dev/null +++ b/internal/storage/dashboard.go @@ -0,0 +1,356 @@ +package storage + +import ( + "sort" + "time" +) + +type DashboardSummary struct { + Tracked time.Duration + EarnedMinor map[string]int64 + PaidMinor map[string]int64 + NetMinor map[string]int64 + Projects []DashboardProjectSummary +} + +type DashboardProjectSummary struct { + ProjectID *int + ProjectName string + Tracked time.Duration + EarnedMinor map[string]int64 + PaidMinor map[string]int64 + NetMinor map[string]int64 + Tasks []DashboardTaskSummary +} + +type DashboardTaskSummary struct { + TaskID *int + TaskName string + Tracked time.Duration + EarnedMinor map[string]int64 +} + +type DashboardTimeBucket struct { + Start time.Time + End time.Time + Tracked time.Duration +} + +type dashboardTaskTotals struct { + tracked time.Duration + minorSeconds map[string]int64 +} + +type dashboardProjectTotals struct { + name string + tracked time.Duration + minorSeconds map[string]int64 + paid map[string]int64 + tasks map[int]*dashboardTaskTotals +} + +// SummarizeDashboard reports activity in the half-open interval [start, end). +// A zero start means there is no lower bound. Active entries stop at now. +func SummarizeDashboard( + projects []Project, + tasks []Task, + rates []Rate, + entries []Entry, + payments []Payment, + start time.Time, + end time.Time, + now time.Time, +) DashboardSummary { + projectNames := make(map[int]string, len(projects)) + for _, project := range projects { + projectNames[project.ID] = project.Name + } + taskNames := make(map[int]string, len(tasks)) + for _, task := range tasks { + taskNames[task.ID] = task.Name + } + ratesByID := RatesByID(rates) + totals := make(map[int]*dashboardProjectTotals) + + projectTotals := func(projectID int) *dashboardProjectTotals { + if item, ok := totals[projectID]; ok { + return item + } + name := projectNames[projectID] + if projectID == 0 { + name = "Unassigned" + } else if name == "" { + name = "Unknown project" + } + item := &dashboardProjectTotals{ + name: name, minorSeconds: make(map[string]int64), + paid: make(map[string]int64), tasks: make(map[int]*dashboardTaskTotals), + } + totals[projectID] = item + return item + } + + for _, entry := range entries { + entryEnd := now + if entry.EndedAt != nil && entry.EndedAt.Before(entryEnd) { + entryEnd = *entry.EndedAt + } + if !end.IsZero() && end.Before(entryEnd) { + entryEnd = end + } + entryStart := entry.StartedAt + if !start.IsZero() && start.After(entryStart) { + entryStart = start + } + if !entryEnd.After(entryStart) { + continue + } + + projectID := 0 + if entry.ProjectID != nil { + projectID = *entry.ProjectID + } + taskID := 0 + if entry.TaskID != nil { + taskID = *entry.TaskID + } + elapsed := entryEnd.Sub(entryStart) + project := projectTotals(projectID) + project.tracked += elapsed + task := project.tasks[taskID] + if task == nil { + task = &dashboardTaskTotals{minorSeconds: make(map[string]int64)} + project.tasks[taskID] = task + } + task.tracked += elapsed + + if entry.RateID == nil { + continue + } + rate, ok := ratesByID[*entry.RateID] + if !ok { + continue + } + minorSeconds := int64(rate.AmountMinor) * int64(elapsed/time.Second) + project.minorSeconds[rate.Currency] += minorSeconds + task.minorSeconds[rate.Currency] += minorSeconds + } + + for _, payment := range payments { + if !start.IsZero() && payment.PaidForDate.Before(start) { + continue + } + if !end.IsZero() && !payment.PaidForDate.Before(end) { + continue + } + projectTotals(payment.ProjectID).paid[payment.Currency] += + int64(payment.AmountMinor) + } + + result := DashboardSummary{ + EarnedMinor: make(map[string]int64), + PaidMinor: make(map[string]int64), + NetMinor: make(map[string]int64), + } + for projectID, totals := range totals { + project := DashboardProjectSummary{ + ProjectName: totals.name, + Tracked: totals.tracked, + EarnedMinor: minorSecondsToAmounts(totals.minorSeconds), + PaidMinor: cloneAmounts(totals.paid), + } + if projectID != 0 { + id := projectID + project.ProjectID = &id + } + project.NetMinor = subtractAmounts(project.EarnedMinor, project.PaidMinor) + + for taskID, totals := range totals.tasks { + taskName := taskNames[taskID] + if taskID == 0 { + taskName = "Unassigned" + } else if taskName == "" { + taskName = "Unknown task" + } + task := DashboardTaskSummary{ + TaskName: taskName, Tracked: totals.tracked, + EarnedMinor: minorSecondsToAmounts(totals.minorSeconds), + } + if taskID != 0 { + id := taskID + task.TaskID = &id + } + project.Tasks = append(project.Tasks, task) + } + sort.Slice(project.Tasks, func(i, j int) bool { + if project.Tasks[i].Tracked != project.Tasks[j].Tracked { + return project.Tasks[i].Tracked > project.Tasks[j].Tracked + } + return project.Tasks[i].TaskName < project.Tasks[j].TaskName + }) + + result.Tracked += project.Tracked + addAmounts(result.EarnedMinor, project.EarnedMinor) + addAmounts(result.PaidMinor, project.PaidMinor) + result.Projects = append(result.Projects, project) + } + result.NetMinor = subtractAmounts(result.EarnedMinor, result.PaidMinor) + sort.Slice(result.Projects, func(i, j int) bool { + if result.Projects[i].Tracked != result.Projects[j].Tracked { + return result.Projects[i].Tracked > result.Projects[j].Tracked + } + return result.Projects[i].ProjectName < result.Projects[j].ProjectName + }) + return result +} + +// SummarizeDashboardTimeline splits tracked time into calendar-aware buckets. +func SummarizeDashboardTimeline( + entries []Entry, + period Period, + now time.Time, +) []DashboardTimeBucket { + start, end := period.Start, period.End + if end.IsZero() { + end = now + } + if start.IsZero() { + start = end + for _, entry := range entries { + if entry.StartedAt.Before(start) { + start = entry.StartedAt + } + } + } + if !end.After(start) { + return nil + } + + unit := timelineBucketUnit(period.Kind, end.Sub(start)) + start = alignTimelineStart(start, unit) + buckets := make([]DashboardTimeBucket, 0) + for bucketStart := start; bucketStart.Before(end); { + bucketEnd := nextTimelineStart(bucketStart, unit) + buckets = append(buckets, DashboardTimeBucket{ + Start: bucketStart, + End: bucketEnd, + }) + bucketStart = bucketEnd + } + + for _, entry := range entries { + entryEnd := now + if entry.EndedAt != nil && entry.EndedAt.Before(entryEnd) { + entryEnd = *entry.EndedAt + } + if entryEnd.After(end) { + entryEnd = end + } + entryStart := entry.StartedAt + if entryStart.Before(start) { + entryStart = start + } + if !entryEnd.After(entryStart) { + continue + } + for i := range buckets { + overlapStart := entryStart + if buckets[i].Start.After(overlapStart) { + overlapStart = buckets[i].Start + } + overlapEnd := entryEnd + if buckets[i].End.Before(overlapEnd) { + overlapEnd = buckets[i].End + } + if overlapEnd.After(overlapStart) { + buckets[i].Tracked += overlapEnd.Sub(overlapStart) + } + } + } + return buckets +} + +type timelineUnit int + +const ( + timelineHour timelineUnit = iota + timelineDay + timelineMonth + timelineYear +) + +func timelineBucketUnit(kind PeriodKind, span time.Duration) timelineUnit { + switch kind { + case Day: + return timelineHour + case Week, Month: + return timelineDay + } + switch { + case span <= 48*time.Hour: + return timelineHour + case span <= 90*24*time.Hour: + return timelineDay + case span <= 2*365*24*time.Hour: + return timelineMonth + default: + return timelineYear + } +} + +func alignTimelineStart(value time.Time, unit timelineUnit) time.Time { + switch unit { + case timelineHour: + return time.Date( + value.Year(), value.Month(), value.Day(), value.Hour(), + 0, 0, 0, value.Location(), + ) + case timelineDay: + return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, value.Location()) + case timelineMonth: + return time.Date(value.Year(), value.Month(), 1, 0, 0, 0, 0, value.Location()) + default: + return time.Date(value.Year(), 1, 1, 0, 0, 0, 0, value.Location()) + } +} + +func nextTimelineStart(value time.Time, unit timelineUnit) time.Time { + switch unit { + case timelineHour: + return value.Add(time.Hour) + case timelineDay: + return value.AddDate(0, 0, 1) + case timelineMonth: + return value.AddDate(0, 1, 0) + default: + return value.AddDate(1, 0, 0) + } +} + +func minorSecondsToAmounts(values map[string]int64) map[string]int64 { + result := make(map[string]int64, len(values)) + for currency, value := range values { + result[currency] = value / 3600 + } + return result +} + +func cloneAmounts(values map[string]int64) map[string]int64 { + result := make(map[string]int64, len(values)) + addAmounts(result, values) + return result +} + +func addAmounts(target, values map[string]int64) { + for currency, value := range values { + target[currency] += value + } +} + +func subtractAmounts(left, right map[string]int64) map[string]int64 { + result := cloneAmounts(left) + for currency, value := range right { + result[currency] -= value + } + return result +} diff --git a/internal/storage/dashboard_test.go b/internal/storage/dashboard_test.go new file mode 100644 index 0000000..62370b5 --- /dev/null +++ b/internal/storage/dashboard_test.go @@ -0,0 +1,119 @@ +package storage_test + +import ( + "testing" + "time" + + "chankat/internal/storage" +) + +func TestSummarizeDashboardClipsEntriesAndGroupsProjects(t *testing.T) { + start := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC) + end := start.Add(7 * 24 * time.Hour) + now := end.Add(-12 * time.Hour) + rate := storage.Rate{ID: 1, AmountMinor: 10_000, Currency: "USD"} + projects := []storage.Project{ + {ID: 1, Name: "Acme", RateID: 1}, + {ID: 2, Name: "Beta", RateID: 1}, + } + tasks := []storage.Task{ + {ID: 1, Name: "Build", ProjectID: 1}, + {ID: 2, Name: "Review", ProjectID: 2}, + } + project1, project2, task1, task2, rateID := 1, 2, 1, 2, 1 + beforeEnd := start.Add(time.Hour) + activeStart := now.Add(-2 * time.Hour) + entries := []storage.Entry{ + { + TaskID: &task1, ProjectID: &project1, RateID: &rateID, + StartedAt: start.Add(-time.Hour), EndedAt: &beforeEnd, + }, + { + TaskID: &task2, ProjectID: &project2, RateID: &rateID, + StartedAt: activeStart, + }, + { + TaskID: &task1, ProjectID: &project1, RateID: &rateID, + StartedAt: end.Add(time.Hour), + }, + } + payments := []storage.Payment{ + {ProjectID: 1, AmountMinor: 5_000, Currency: "USD", PaidForDate: start}, + {ProjectID: 2, AmountMinor: 9_000, Currency: "USD", PaidForDate: end}, + } + + got := storage.SummarizeDashboard( + projects, tasks, []storage.Rate{rate}, entries, payments, + start, end, now, + ) + + if got.Tracked != 3*time.Hour { + t.Fatalf("tracked = %s, want 3h", got.Tracked) + } + if got.EarnedMinor["USD"] != 30_000 || + got.PaidMinor["USD"] != 5_000 || got.NetMinor["USD"] != 25_000 { + t.Fatalf("amounts = earned %v paid %v net %v", + got.EarnedMinor, got.PaidMinor, got.NetMinor) + } + if len(got.Projects) != 2 || got.Projects[0].ProjectName != "Beta" { + t.Fatalf("projects = %#v", got.Projects) + } + if len(got.Projects[0].Tasks) != 1 || + got.Projects[0].Tasks[0].TaskName != "Review" { + t.Fatalf("tasks = %#v", got.Projects[0].Tasks) + } +} + +func TestSummarizeDashboardKeepsCurrenciesSeparate(t *testing.T) { + start := time.Date(2026, 8, 9, 0, 0, 0, 0, time.UTC) + end := start.Add(24 * time.Hour) + projectID, taskID, usdRateID, eurRateID := 1, 1, 1, 2 + firstEnd := start.Add(time.Hour) + secondEnd := firstEnd.Add(time.Hour) + got := storage.SummarizeDashboard( + []storage.Project{{ID: 1, Name: "Acme", RateID: 1}}, + []storage.Task{{ID: 1, Name: "Build", ProjectID: 1}}, + []storage.Rate{ + {ID: 1, AmountMinor: 10_000, Currency: "USD"}, + {ID: 2, AmountMinor: 8_000, Currency: "EUR"}, + }, + []storage.Entry{ + {TaskID: &taskID, ProjectID: &projectID, RateID: &usdRateID, + StartedAt: start, EndedAt: &firstEnd}, + {TaskID: &taskID, ProjectID: &projectID, RateID: &eurRateID, + StartedAt: firstEnd, EndedAt: &secondEnd}, + }, + nil, start, end, end, + ) + + if got.EarnedMinor["USD"] != 10_000 || got.EarnedMinor["EUR"] != 8_000 { + t.Fatalf("earned = %v", got.EarnedMinor) + } +} + +func TestSummarizeDashboardTimelineSplitsEntriesAcrossHours(t *testing.T) { + start := time.Date(2026, 8, 9, 0, 0, 0, 0, time.UTC) + period, err := storage.CurrentPeriod(storage.Day, start.Add(12*time.Hour)) + if err != nil { + t.Fatal(err) + } + firstEnd := start.Add(2*time.Hour + 30*time.Minute) + entries := []storage.Entry{ + {StartedAt: start.Add(30 * time.Minute), EndedAt: &firstEnd}, + {StartedAt: start.Add(3 * time.Hour)}, + } + + buckets := storage.SummarizeDashboardTimeline( + entries, period, start.Add(4*time.Hour), + ) + + if len(buckets) != 24 { + t.Fatalf("bucket count = %d, want 24", len(buckets)) + } + want := []time.Duration{30 * time.Minute, time.Hour, 30 * time.Minute, time.Hour} + for i, duration := range want { + if buckets[i].Tracked != duration { + t.Errorf("bucket %d = %s, want %s", i, buckets[i].Tracked, duration) + } + } +} diff --git a/internal/storage/development_seed.go b/internal/storage/development_seed.go new file mode 100644 index 0000000..0839a50 --- /dev/null +++ b/internal/storage/development_seed.go @@ -0,0 +1,146 @@ +package storage + +import ( + "context" + "fmt" + "time" +) + +// SeedDevelopment replaces the database contents with representative data. +// It is intended for the development seed command, not application startup. +func (s *Storage) SeedDevelopment( + ctx context.Context, + now time.Time, + reset bool, +) error { + if now.IsZero() { + return fmt.Errorf("seed development data: current time is required") + } + + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("seed development data: begin transaction: %w", err) + } + defer tx.Rollback() + + var records int + if err := tx.GetContext(ctx, &records, ` + SELECT + (SELECT COUNT(*) FROM RATE) + + (SELECT COUNT(*) FROM PROJECT) + + (SELECT COUNT(*) FROM TASK) + + (SELECT COUNT(*) FROM ENTRY) + + (SELECT COUNT(*) FROM PAYMENT) + `); err != nil { + return fmt.Errorf("seed development data: count records: %w", err) + } + if records > 0 && !reset { + return fmt.Errorf( + "seed development data: database is not empty; pass --reset to replace it", + ) + } + + if reset { + for _, table := range []string{"PAYMENT", "ENTRY", "TASK", "PROJECT", "RATE"} { + if _, err := tx.ExecContext(ctx, "DELETE FROM "+table); err != nil { + return fmt.Errorf("seed development data: clear %s: %w", table, err) + } + } + } + + for _, rate := range []Rate{ + {ID: 1, Name: "Consulting", AmountMinor: 12_500, Currency: "USD"}, + {ID: 2, Name: "Retainer", AmountMinor: 9_000, Currency: "USD"}, + {ID: 3, Name: "European", AmountMinor: 11_000, Currency: "EUR"}, + } { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO RATE (ID, NAME, AMOUNT_MINOR, CURRENCY) + VALUES ($1, $2, $3, $4) + `, rate.ID, rate.Name, rate.AmountMinor, rate.Currency); err != nil { + return fmt.Errorf("seed development data: insert rate: %w", err) + } + } + + for _, project := range []Project{ + {ID: 1, Name: "Acme Website", RateID: 1}, + {ID: 2, Name: "Northstar API", RateID: 2}, + {ID: 3, Name: "Atelier Brand", RateID: 3}, + } { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO PROJECT (ID, NAME, RATE_ID) VALUES ($1, $2, $3) + `, project.ID, project.Name, project.RateID); err != nil { + return fmt.Errorf("seed development data: insert project: %w", err) + } + } + + for _, task := range []Task{ + {ID: 1, Name: "Landing page", ProjectID: 1}, + {ID: 2, Name: "Checkout integration", ProjectID: 1}, + {ID: 3, Name: "Authentication", ProjectID: 2}, + {ID: 4, Name: "API documentation", ProjectID: 2}, + {ID: 5, Name: "Visual identity", ProjectID: 3}, + {ID: 6, Name: "Brand guidelines", ProjectID: 3}, + } { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO TASK (ID, NAME, PROJECT_ID) VALUES ($1, $2, $3) + `, task.ID, task.Name, task.ProjectID); err != nil { + return fmt.Errorf("seed development data: insert task: %w", err) + } + } + + day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + daysSinceMonday := (int(day.Weekday()) + 6) % 7 + week := day.AddDate(0, 0, -daysSinceMonday) + month := time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, day.Location()) + entryTimes := []struct { + id, taskID, projectID, rateID int + start, end time.Time + note string + active bool + }{ + {1, 3, 2, 2, now.Add(-42 * time.Minute), time.Time{}, "Investigating token refresh", true}, + {2, 1, 1, 1, now.Add(-3 * time.Hour), now.Add(-time.Hour), "Responsive hero and navigation", false}, + {3, 5, 3, 3, day.AddDate(0, 0, -1).Add(13 * time.Hour), day.AddDate(0, 0, -1).Add(16 * time.Hour), "Logo directions", false}, + {4, 2, 1, 1, week.Add(9 * time.Hour), week.Add(12*time.Hour + 30*time.Minute), "Payment provider webhooks", false}, + {5, 4, 2, 2, week.AddDate(0, 0, 1).Add(10 * time.Hour), week.AddDate(0, 0, 1).Add(12 * time.Hour), "Quick-start examples", false}, + {6, 6, 3, 3, week.AddDate(0, 0, -3).Add(9 * time.Hour), week.AddDate(0, 0, -3).Add(14*time.Hour + 15*time.Minute), "Typography and color system", false}, + {7, 1, 1, 1, month.AddDate(0, -1, 5).Add(9 * time.Hour), month.AddDate(0, -1, 5).Add(12 * time.Hour), "Initial wireframes", false}, + {8, 3, 2, 2, month.AddDate(0, -1, 12).Add(13 * time.Hour), month.AddDate(0, -1, 12).Add(17 * time.Hour), "Session middleware", false}, + } + for _, entry := range entryTimes { + var endedAt any + if !entry.active { + endedAt = entry.end.Unix() + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO ENTRY ( + ID, TASK_ID, PROJECT_ID, RATE_ID, STARTED_AT, ENDED_AT, NOTES + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + `, entry.id, entry.taskID, entry.projectID, entry.rateID, + entry.start.Unix(), endedAt, entry.note); err != nil { + return fmt.Errorf("seed development data: insert entry: %w", err) + } + } + + payments := []Payment{ + {ID: 1, ProjectID: 1, AmountMinor: 45_000, Currency: "USD", PaidAt: day, PaidForDate: week, Note: "Weekly invoice"}, + {ID: 2, ProjectID: 2, AmountMinor: 72_000, Currency: "USD", PaidAt: day.AddDate(0, 0, -2), PaidForDate: month, Note: "Retainer installment"}, + {ID: 3, ProjectID: 3, AmountMinor: 33_000, Currency: "EUR", PaidAt: month.AddDate(0, 0, -1), PaidForDate: month.AddDate(0, -1, 1), Note: "Brand discovery"}, + } + for _, payment := range payments { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO PAYMENT ( + ID, PROJECT_ID, AMOUNT_MINOR, CURRENCY, + PAID_AT, PAID_FOR_DATE, NOTES + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + `, payment.ID, payment.ProjectID, payment.AmountMinor, payment.Currency, + payment.PaidAt.Unix(), payment.PaidForDate.Unix(), payment.Note); err != nil { + return fmt.Errorf("seed development data: insert payment: %w", err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("seed development data: commit: %w", err) + } + return nil +} diff --git a/internal/storage/development_seed_test.go b/internal/storage/development_seed_test.go new file mode 100644 index 0000000..43c7100 --- /dev/null +++ b/internal/storage/development_seed_test.go @@ -0,0 +1,63 @@ +package storage_test + +import ( + "strings" + "testing" + "time" + + "chankat/internal/storage" +) + +func TestSeedDevelopment(t *testing.T) { + stor := fixtureStorage(t) + now := time.Date(2026, 8, 9, 16, 0, 0, 0, time.UTC) + + if err := stor.SeedDevelopment(t.Context(), now, false); err != nil { + t.Fatal(err) + } + assertSeedCounts(t, stor, 3, 3, 6, 8, 3) + + if err := stor.SeedDevelopment(t.Context(), now, false); err == nil || + !strings.Contains(err.Error(), "database is not empty") { + t.Fatalf("second seed error = %v", err) + } + if err := stor.SeedDevelopment(t.Context(), now.AddDate(0, 0, 1), true); err != nil { + t.Fatal(err) + } + assertSeedCounts(t, stor, 3, 3, 6, 8, 3) + + active, err := stor.GetActiveEntries(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(active) != 1 || active[0].Note != "Investigating token refresh" { + t.Fatalf("active entries = %#v", active) + } +} + +func assertSeedCounts( + t *testing.T, + stor *storage.Storage, + rates, projects, tasks, entries, payments int, +) { + t.Helper() + queries := []struct { + table string + want int + }{ + {"RATE", rates}, + {"PROJECT", projects}, + {"TASK", tasks}, + {"ENTRY", entries}, + {"PAYMENT", payments}, + } + for _, query := range queries { + var got int + if err := stor.QueryRow("SELECT COUNT(*) FROM " + query.table).Scan(&got); err != nil { + t.Fatal(err) + } + if got != query.want { + t.Errorf("%s count = %d, want %d", query.table, got, query.want) + } + } +} diff --git a/internal/storage/period.go b/internal/storage/period.go new file mode 100644 index 0000000..5befdb2 --- /dev/null +++ b/internal/storage/period.go @@ -0,0 +1,144 @@ +package storage + +import ( + "fmt" + "time" +) + +type PeriodKind string + +const ( + Day PeriodKind = "day" + Week PeriodKind = "week" + Month PeriodKind = "month" + All PeriodKind = "all" +) + +type Period struct { + Kind PeriodKind + Start time.Time + End time.Time +} + +func CurrentPeriod(kind PeriodKind, now time.Time) (Period, error) { + day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + switch kind { + case Day: + return Period{Kind: kind, Start: day, End: day.AddDate(0, 0, 1)}, nil + case Week: + daysSinceMonday := (int(day.Weekday()) + 6) % 7 + start := day.AddDate(0, 0, -daysSinceMonday) + return Period{Kind: kind, Start: start, End: start.AddDate(0, 0, 7)}, nil + case Month: + start := time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, day.Location()) + return Period{Kind: kind, Start: start, End: start.AddDate(0, 1, 0)}, nil + case All: + return Period{Kind: kind}, nil + default: + return Period{}, fmt.Errorf("unknown period %q", kind) + } +} + +func CustomPeriod(start, end time.Time) (Period, error) { + if end.Before(start) { + return Period{}, fmt.Errorf("end date must not precede start date") + } + return Period{ + Start: start, + End: end.AddDate(0, 0, 1), + }, nil +} + +func MovePeriod(period Period, offset int) Period { + if offset == 0 || period.Kind == All || period.Kind == "" { + return period + } + switch period.Kind { + case Day: + period.Start = period.Start.AddDate(0, 0, offset) + period.End = period.End.AddDate(0, 0, offset) + case Week: + period.Start = period.Start.AddDate(0, 0, 7*offset) + period.End = period.End.AddDate(0, 0, 7*offset) + case Month: + period.Start = period.Start.AddDate(0, offset, 0) + period.End = period.End.AddDate(0, offset, 0) + } + return period +} + +func StepPeriodKind(period Period, offset int, now time.Time) Period { + if offset == 0 { + return period + } + kinds := [...]PeriodKind{Day, Week, Month, All} + index := len(kinds) - 1 + for i, kind := range kinds { + if period.Kind == kind { + index = i + break + } + } + if offset < 0 { + offset = -1 + } else { + offset = 1 + } + index += offset + if index < 0 { + index = 0 + } + if index >= len(kinds) { + index = len(kinds) - 1 + } + result, err := CurrentPeriod(kinds[index], now) + if err != nil { + return period + } + return result +} + +func (p Period) Label() string { + if p.Kind == All { + return "All time" + } + if p.Kind == Day { + return p.Start.Format("Mon, 02 Jan 2006") + } + return p.Start.Format("02 Jan 2006") + " – " + + p.End.AddDate(0, 0, -1).Format("02 Jan 2006") +} + +// EntriesInPeriod returns entries clipped to the half-open period interval. +// Active entries remain active when the period includes now. +func EntriesInPeriod(entries []Entry, period Period, now time.Time) []Entry { + result := make([]Entry, 0, len(entries)) + for _, entry := range entries { + endedAt := now + if entry.EndedAt != nil && entry.EndedAt.Before(endedAt) { + endedAt = *entry.EndedAt + } + if !period.End.IsZero() && period.End.Before(endedAt) { + endedAt = period.End + } + startedAt := entry.StartedAt + if !period.Start.IsZero() && period.Start.After(startedAt) { + startedAt = period.Start + } + if !endedAt.After(startedAt) { + continue + } + + clipped := entry + clipped.StartedAt = startedAt + periodIncludesNow := period.End.IsZero() || period.End.After(now) + if entry.EndedAt == nil && periodIncludesNow { + clipped.EndedAt = nil + } else { + end := endedAt + clipped.EndedAt = &end + } + result = append(result, clipped) + } + return result +} diff --git a/internal/storage/period_test.go b/internal/storage/period_test.go new file mode 100644 index 0000000..35aa54f --- /dev/null +++ b/internal/storage/period_test.go @@ -0,0 +1,122 @@ +package storage + +import ( + "testing" + "time" +) + +func TestCurrentWeekStartsMonday(t *testing.T) { + now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + period, err := CurrentPeriod(Week, now) + if err != nil { + t.Fatal(err) + } + if got := period.Start.Format("2006-01-02"); got != "2026-08-03" { + t.Fatalf("start = %s", got) + } + if got := period.End.Format("2006-01-02"); got != "2026-08-10" { + t.Fatalf("end = %s", got) + } +} + +func TestMoveMonthUsesCalendarMonths(t *testing.T) { + now := time.Date(2026, 3, 31, 12, 0, 0, 0, time.UTC) + period, err := CurrentPeriod(Month, now) + if err != nil { + t.Fatal(err) + } + period = MovePeriod(period, -1) + if got := period.Label(); got != "01 Feb 2026 – 28 Feb 2026" { + t.Fatalf("label = %q", got) + } +} + +func TestDayUsesLocalCalendarAcrossDST(t *testing.T) { + location, err := time.LoadLocation("America/New_York") + if err != nil { + t.Skip(err) + } + now := time.Date(2026, 3, 8, 12, 0, 0, 0, location) + period, err := CurrentPeriod(Day, now) + if err != nil { + t.Fatal(err) + } + if got := period.End.Sub(period.Start); got != 23*time.Hour { + t.Fatalf("duration = %s, want 23h", got) + } +} + +func TestEntriesInPeriodClipsBoundariesAndActiveEntries(t *testing.T) { + start := time.Date(2026, 8, 9, 0, 0, 0, 0, time.UTC) + now := start.Add(12 * time.Hour) + period, err := CurrentPeriod(Day, now) + if err != nil { + t.Fatal(err) + } + endedAt := start.Add(time.Hour) + entries := []Entry{ + {ID: 1, StartedAt: start.Add(-time.Hour), EndedAt: &endedAt}, + {ID: 2, StartedAt: start.Add(10 * time.Hour)}, + {ID: 3, StartedAt: period.End.Add(time.Hour)}, + } + + got := EntriesInPeriod(entries, period, now) + + if len(got) != 2 { + t.Fatalf("entries = %#v", got) + } + if !got[0].StartedAt.Equal(start) || got[0].EndedAt == nil || + !got[0].EndedAt.Equal(endedAt) { + t.Fatalf("clipped entry = %#v", got[0]) + } + if got[1].EndedAt != nil { + t.Fatalf("active entry became completed: %#v", got[1]) + } +} + +func TestStepPeriodKind(t *testing.T) { + now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + period, err := CurrentPeriod(Day, now) + if err != nil { + t.Fatal(err) + } + + period = StepPeriodKind(period, 1, now) + if period.Kind != Week || period.Start.Day() != 3 { + t.Fatalf("day to week = %#v", period) + } + period = StepPeriodKind(period, 1, now) + if period.Kind != Month || period.Start.Day() != 1 { + t.Fatalf("week to month = %#v", period) + } + period = StepPeriodKind(period, 1, now) + if period.Kind != All { + t.Fatalf("month to all = %#v", period) + } + period = StepPeriodKind(period, 1, now) + if period.Kind != All { + t.Fatalf("all did not remain at the upper bound: %#v", period) + } + period, err = CurrentPeriod(Day, now) + if err != nil { + t.Fatal(err) + } + period = StepPeriodKind(period, -1, now) + if period.Kind != Day { + t.Fatalf("day did not remain at the lower bound: %#v", period) + } +} + +func TestStepCustomPeriodReturnsToPreset(t *testing.T) { + now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + custom, err := CustomPeriod(now.AddDate(0, 0, -3), now) + if err != nil { + t.Fatal(err) + } + if got := StepPeriodKind(custom, 1, now).Kind; got != All { + t.Fatalf("custom down = %s, want all", got) + } + if got := StepPeriodKind(custom, -1, now).Kind; got != Month { + t.Fatalf("custom up = %s, want month", got) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 90fea94..f2fd3d7 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -14,17 +14,16 @@ type Storage struct { } func Open() (*Storage, error) { - dir, err := dataDir() + path, err := dataPath() if err != nil { return nil, err } + dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0700); err != nil { return nil, err } - path := filepath.Join(dir, "data.sqlite") - db, err := sqlx.Open("sqlite", path) if err != nil { return nil, err @@ -39,6 +38,17 @@ func Open() (*Storage, error) { return &Storage{db: db}, nil } +func dataPath() (string, error) { + if path := os.Getenv("CHANKAT_DATA_PATH"); path != "" { + return path, nil + } + dir, err := dataDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "data.sqlite"), nil +} + func dataDir() (string, error) { if dir := os.Getenv("XDG_DATA_HOME"); dir != "" { return filepath.Join(dir, "chankat"), nil diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index ed22200..ee21137 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -1,12 +1,15 @@ package storage_test import ( + "os" + "path/filepath" "testing" "chankat/internal/storage" ) func TestOpenAndMigrate(t *testing.T) { + t.Setenv("CHANKAT_DATA_PATH", "") t.Setenv("XDG_DATA_HOME", t.TempDir()) stor, err := storage.Open() @@ -67,9 +70,27 @@ func TestOpenAndMigrate(t *testing.T) { } } +func TestOpenUsesConfiguredDataPath(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "development.sqlite") + t.Setenv("CHANKAT_DATA_PATH", path) + t.Setenv("XDG_DATA_HOME", filepath.Join(t.TempDir(), "ignored")) + + stor, err := storage.Open() + if err != nil { + t.Fatal(err) + } + if err := stor.Close(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("configured database was not created: %v", err) + } +} + func fixtureStorage(t *testing.T) *storage.Storage { t.Helper() + t.Setenv("CHANKAT_DATA_PATH", "") t.Setenv("XDG_DATA_HOME", t.TempDir()) stor, err := storage.Open() diff --git a/internal/tui/components/crud.go b/internal/tui/components/crud.go index 69f70be..df65ff1 100644 --- a/internal/tui/components/crud.go +++ b/internal/tui/components/crud.go @@ -9,7 +9,6 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" ) type crudOperation int @@ -64,7 +63,7 @@ func NewPage[T list.Item](ctx context.Context, config Config[T]) Page[T] { delegate := NewListDelegate() items := list.New(nil, delegate, 0, 0) items.SetShowTitle(false) - items.Styles.FilterCursor = items.Styles.FilterCursor.Foreground(lipgloss.Color("9")) + items.Styles.FilterCursor = items.Styles.FilterCursor.Foreground(AccentColor) if config.Embedded { items.SetShowStatusBar(false) } @@ -76,13 +75,12 @@ func NewPage[T list.Item](ctx context.Context, config Config[T]) Page[T] { func NewListDelegate() list.DefaultDelegate { delegate := list.NewDefaultDelegate() delegate.SetSpacing(0) - red := lipgloss.Color("9") delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle. - Foreground(red). - BorderLeftForeground(red) + Foreground(AccentColor). + BorderLeftForeground(AccentColor) delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc. - Foreground(red). - BorderLeftForeground(red) + Foreground(AccentColor). + BorderLeftForeground(AccentColor) return delegate } diff --git a/internal/tui/components/period_menu.go b/internal/tui/components/period_menu.go new file mode 100644 index 0000000..388b1a6 --- /dev/null +++ b/internal/tui/components/period_menu.go @@ -0,0 +1,107 @@ +package components + +import ( + "fmt" + "time" + + "chankat/internal/storage" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" +) + +const customPeriod = "custom" + +type PeriodMenu struct { + form *huh.Form + kind string + from string + to string +} + +func NewPeriodMenu( + period storage.Period, + now time.Time, + width int, + fields ...huh.Field, +) *PeriodMenu { + start, end := now, now + if !period.Start.IsZero() { + start = period.Start + } + if !period.End.IsZero() { + end = period.End.AddDate(0, 0, -1) + } + kind := string(period.Kind) + if kind == "" { + kind = customPeriod + } + m := &PeriodMenu{ + kind: kind, + from: FormatDate(start), + to: FormatDate(end), + } + fields = append(fields, + huh.NewSelect[string]().Title("Period").Options( + huh.NewOption("All time", string(storage.All)), + huh.NewOption("Day", string(storage.Day)), + huh.NewOption("Week", string(storage.Week)), + huh.NewOption("Month", string(storage.Month)), + huh.NewOption("Custom", customPeriod), + ).Value(&m.kind), + ) + filters := huh.NewGroup(fields...) + dates := huh.NewGroup( + huh.NewInput().Title("From (YYYY-MM-DD)").Value(&m.from). + Validate(Date), + huh.NewInput().Title("To (YYYY-MM-DD, inclusive)").Value(&m.to). + Validate(func(value string) error { + if err := Date(value); err != nil { + return err + } + start, err := ParseDate(m.from) + if err != nil { + return err + } + end, err := ParseDate(value) + if err != nil { + return err + } + if end.Before(start) { + return fmt.Errorf("end date must not precede start date") + } + return nil + }), + ).WithHideFunc(func() bool { return m.kind != customPeriod }) + m.form = huh.NewForm(filters, dates).WithShowHelp(true).WithWidth(width) + return m +} + +func (m *PeriodMenu) Init() tea.Cmd { return m.form.Init() } + +func (m *PeriodMenu) Update(msg tea.Msg) tea.Cmd { + updated, cmd := m.form.Update(msg) + m.form = updated.(*huh.Form) + return cmd +} + +func (m *PeriodMenu) View() string { return m.form.View() } + +func (m *PeriodMenu) Completed() bool { return m.form.State == huh.StateCompleted } + +func (m *PeriodMenu) Aborted() bool { return m.form.State == huh.StateAborted } + +func (m *PeriodMenu) Period(now time.Time) (storage.Period, error) { + if m.kind != customPeriod { + return storage.CurrentPeriod(storage.PeriodKind(m.kind), now) + } + start, err := ParseDate(m.from) + if err != nil { + return storage.Period{}, err + } + end, err := ParseDate(m.to) + if err != nil { + return storage.Period{}, err + } + return storage.CustomPeriod(start, end) +} diff --git a/internal/tui/components/period_menu_test.go b/internal/tui/components/period_menu_test.go new file mode 100644 index 0000000..c81de1b --- /dev/null +++ b/internal/tui/components/period_menu_test.go @@ -0,0 +1,43 @@ +package components + +import ( + "testing" + "time" + + "chankat/internal/storage" +) + +func TestPeriodMenuSkipsDatesUnlessCustom(t *testing.T) { + now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + menu := NewPeriodMenu(storage.Period{Kind: storage.All}, now, 80) + menu.form.NextGroup() + if !menu.Completed() { + t.Fatal("all-time period did not skip the date range") + } + + menu = NewPeriodMenu(storage.Period{Kind: storage.All}, now, 80) + menu.kind = customPeriod + menu.form.NextGroup() + if menu.Completed() { + t.Fatal("custom period skipped the date range") + } +} + +func TestPeriodMenuReturnsCustomPeriod(t *testing.T) { + now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + menu := NewPeriodMenu(storage.Period{Kind: storage.All}, now, 80) + menu.kind = customPeriod + menu.from = "2026-08-01" + menu.to = "2026-08-03" + + period, err := menu.Period(now) + if err != nil { + t.Fatal(err) + } + if got := period.Start.Format("2006-01-02"); got != "2026-08-01" { + t.Fatalf("period start = %s", got) + } + if got := period.End.Format("2006-01-02"); got != "2026-08-04" { + t.Fatalf("period end = %s", got) + } +} diff --git a/internal/tui/components/style.go b/internal/tui/components/style.go new file mode 100644 index 0000000..b6037fe --- /dev/null +++ b/internal/tui/components/style.go @@ -0,0 +1,18 @@ +package components + +import "github.com/charmbracelet/lipgloss" + +const AccentColor lipgloss.Color = "208" + +var chartColors = [...]lipgloss.Color{ + AccentColor, + "39", + "42", + "141", + "220", + "81", +} + +func ChartColor(index int) lipgloss.Color { + return chartColors[index%len(chartColors)] +} diff --git a/internal/tui/screens/stats.go b/internal/tui/screens/stats.go new file mode 100644 index 0000000..47dbd48 --- /dev/null +++ b/internal/tui/screens/stats.go @@ -0,0 +1,564 @@ +package screens + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "chankat/internal/storage" + "chankat/internal/tui/components" + + "github.com/NimbleMarkets/ntcharts/linechart/timeserieslinechart" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type Stats struct { + ctx context.Context + stor *storage.Storage + period storage.Period + now time.Time + projects []storage.Project + tasks []storage.Task + rates []storage.Rate + entries []storage.Entry + payments []storage.Payment + summary storage.DashboardSummary + list list.Model + loading bool + err error + spinner spinner.Model + width int + height int + periodMenu *components.PeriodMenu +} + +type statsLoadedMsg struct { + projects []storage.Project + tasks []storage.Task + rates []storage.Rate + entries []storage.Entry + payments []storage.Payment +} + +type statsFailedMsg struct{ err error } +type statsTickMsg time.Time + +type OpenTasksMsg struct { + ProjectID int + Period storage.Period +} + +type statsProjectItem struct { + project storage.DashboardProjectSummary +} + +func (i statsProjectItem) Title() string { return i.project.ProjectName } +func (i statsProjectItem) Description() string { + description := components.FormatDuration(i.project.Tracked) + " tracked" + if amounts := formatStatsAmounts(i.project.EarnedMinor); amounts != "" { + description += " · " + amounts + " earned" + } + if amounts := formatStatsAmounts(i.project.PaidMinor); amounts != "" { + description += " · " + amounts + " paid" + } + return description +} +func (i statsProjectItem) FilterValue() string { return i.project.ProjectName } + +func NewStats(ctx context.Context, stor *storage.Storage) Stats { + now := time.Now() + period, _ := storage.CurrentPeriod(storage.Day, now) + return Stats{ + ctx: ctx, stor: stor, period: period, now: now, loading: true, + spinner: spinner.New(), list: newStatsList(), + } +} + +func newStatsList() list.Model { + model := list.New(nil, components.NewListDelegate(), 0, 0) + model.SetShowTitle(false) + model.Styles.FilterCursor = model.Styles.FilterCursor. + Foreground(components.AccentColor) + return model +} + +func (m Stats) Init() tea.Cmd { + return tea.Batch(loadStats(m.ctx, m.stor), tickStats(), m.spinner.Tick) +} + +func (m Stats) Update(msg tea.Msg) (Stats, tea.Cmd) { + if m.periodMenu != nil { + if tick, ok := msg.(statsTickMsg); ok { + m.updateNow(time.Time(tick)) + return m, tea.Batch(m.refresh(), tickStats()) + } + if key, ok := msg.(tea.KeyMsg); ok && key.String() == "esc" { + m.periodMenu = nil + return m, nil + } + cmd := m.periodMenu.Update(msg) + if m.periodMenu.Aborted() { + m.periodMenu = nil + return m, nil + } + if m.periodMenu.Completed() { + var refreshCmd tea.Cmd + period, err := m.periodMenu.Period(m.now) + if err == nil { + m.period = period + refreshCmd = m.refresh() + } + m.periodMenu = nil + return m, tea.Batch(cmd, refreshCmd) + } + return m, cmd + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.resizeList() + case statsLoadedMsg: + m.projects, m.tasks, m.rates = msg.projects, msg.tasks, msg.rates + m.entries, m.payments = msg.entries, msg.payments + m.loading = false + m.err = nil + m.updateNow(time.Now()) + return m, m.refresh() + case statsFailedMsg: + m.loading = false + m.err = msg.err + case statsTickMsg: + m.updateNow(time.Time(msg)) + return m, tea.Batch(m.refresh(), tickStats()) + case spinner.TickMsg: + if m.loading { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + case tea.KeyMsg: + if m.list.FilterState() == list.Filtering { + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + m.resizeList() + return m, cmd + } + switch msg.String() { + case "f": + return m.openPeriodMenu() + case "F": + return m, m.setPeriod(storage.All) + case "shift+left", "H": + m.period = storage.MovePeriod(m.period, -1) + return m, m.refresh() + case "shift+right", "L": + return m, m.moveForward() + case "shift+up", "K": + m.period = storage.StepPeriodKind(m.period, -1, m.now) + return m, m.refresh() + case "shift+down", "J": + m.period = storage.StepPeriodKind(m.period, 1, m.now) + return m, m.refresh() + case "enter": + if item, ok := m.list.SelectedItem().(statsProjectItem); ok { + message := OpenTasksMsg{ + ProjectID: dashboardProjectID(item.project), + Period: m.period, + } + return m, func() tea.Msg { return message } + } + return m, nil + case "r": + m.loading = true + return m, loadStats(m.ctx, m.stor) + } + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + m.resizeList() + return m, cmd + case tea.MouseMsg: + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + m.resizeList() + return m, cmd + default: + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + m.resizeList() + return m, cmd + } + return m, nil +} + +func (m Stats) View() string { + if m.periodMenu != nil { + return "dashboard / filters\n\n" + m.periodMenu.View() + "\n\n[esc] back" + } + if m.loading { + return m.spinner.View() + " Loading dashboard..." + } + if m.err != nil { + return "Error: " + m.err.Error() + ". Press 'r' to retry." + } + return m.headerView() + "\n\n" + m.list.View() +} + +func (m Stats) headerView() string { + var b strings.Builder + b.WriteString(m.period.Label()) + b.WriteString("\n") + b.WriteString(components.FormatDuration(m.summary.Tracked)) + b.WriteString(" tracked") + for _, currency := range dashboardCurrencies( + m.summary.EarnedMinor, m.summary.PaidMinor, m.summary.NetMinor, + ) { + fmt.Fprintf(&b, "\n%s earned · %s paid · %s net", + components.FormatMoney(m.summary.EarnedMinor[currency], currency), + components.FormatMoney(m.summary.PaidMinor[currency], currency), + components.FormatMoney(m.summary.NetMinor[currency], currency)) + } + if chart := m.timelineChartView(lipgloss.Height(b.String())); chart != "" { + b.WriteString("\n\nTracked over time by project (hours)\n" + chart) + } + return b.String() +} + +func (m Stats) timelineChartView(headerHeight int) string { + if m.width < 45 { + return "" + } + projects := m.visibleChartProjects() + if len(projects) == 0 { + return "" + } + legend := chartLegend(projects, m.width) + chartHeight := m.height - headerHeight - lipgloss.Height(legend) - 8 + if chartHeight < 6 { + return "" + } + if chartHeight > 9 { + chartHeight = 9 + } + + allBuckets := storage.SummarizeDashboardTimeline( + m.visibleEntries(), m.period, m.now, + ) + if len(allBuckets) < 2 { + return "" + } + chartPeriod := storage.Period{ + Kind: m.period.Kind, + Start: allBuckets[0].Start, + End: allBuckets[len(allBuckets)-1].End, + } + maxHours := 0.0 + series := make([][]storage.DashboardTimeBucket, len(projects)) + for i, project := range projects { + series[i] = storage.SummarizeDashboardTimeline( + m.entriesForProject(dashboardProjectID(project)), + chartPeriod, + m.now, + ) + for _, bucket := range series[i] { + hours := bucket.Tracked.Hours() + if hours > maxHours { + maxHours = hours + } + } + } + if maxHours == 0 { + return "" + } + maxHours *= 1.1 + if maxHours < 1 { + maxHours = 1 + } + + muted := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + chart := timeserieslinechart.New( + m.width, + chartHeight, + timeserieslinechart.WithTimeRange( + allBuckets[0].Start, + allBuckets[len(allBuckets)-1].End, + ), + timeserieslinechart.WithYRange(0, maxHours), + timeserieslinechart.WithXYSteps(4, 2), + timeserieslinechart.WithXLabelFormatter(timelineLabelFormatter(allBuckets)), + timeserieslinechart.WithYLabelFormatter(func(_ int, value float64) string { + return fmt.Sprintf("%.1fh", value) + }), + timeserieslinechart.WithAxesStyles(muted, muted), + ) + for i, project := range projects { + name := fmt.Sprintf("project-%d", dashboardProjectID(project)) + chart.SetDataSetStyle( + name, + lipgloss.NewStyle().Foreground(components.ChartColor(i)), + ) + for _, bucket := range series[i] { + chart.PushDataSet(name, timeserieslinechart.TimePoint{ + Time: bucket.Start, Value: bucket.Tracked.Hours(), + }) + } + } + chart.DrawBrailleAll() + return chart.View() + "\n" + legend +} + +func (m Stats) visibleChartProjects() []storage.DashboardProjectSummary { + result := make([]storage.DashboardProjectSummary, 0) + for _, item := range m.list.VisibleItems() { + project, ok := item.(statsProjectItem) + if ok && project.project.Tracked > 0 { + result = append(result, project.project) + } + } + return result +} + +func (m Stats) visibleEntries() []storage.Entry { + projectIDs := make(map[int]bool) + for _, item := range m.list.VisibleItems() { + project, ok := item.(statsProjectItem) + if ok { + projectIDs[dashboardProjectID(project.project)] = true + } + } + result := make([]storage.Entry, 0, len(m.entries)) + for _, entry := range m.entries { + projectID := 0 + if entry.ProjectID != nil { + projectID = *entry.ProjectID + } + if projectIDs[projectID] { + result = append(result, entry) + } + } + return result +} + +func (m Stats) entriesForProject(projectID int) []storage.Entry { + result := make([]storage.Entry, 0) + for _, entry := range m.entries { + entryProjectID := 0 + if entry.ProjectID != nil { + entryProjectID = *entry.ProjectID + } + if entryProjectID == projectID { + result = append(result, entry) + } + } + return result +} + +func chartLegend( + projects []storage.DashboardProjectSummary, + width int, +) string { + lines := make([]string, 0, 1) + line := "" + for i, project := range projects { + item := lipgloss.NewStyle().Foreground(components.ChartColor(i)). + Render("●") + " " + project.ProjectName + candidate := item + if line != "" { + candidate = line + " " + item + } + if line != "" && lipgloss.Width(candidate) > width { + lines = append(lines, line) + line = item + } else { + line = candidate + } + } + if line != "" { + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} + +func timelineLabelFormatter( + buckets []storage.DashboardTimeBucket, +) func(int, float64) string { + location := buckets[0].Start.Location() + layout := "Jan 02" + step := buckets[1].Start.Sub(buckets[0].Start) + if step <= 2*time.Hour { + layout = "15:04" + } else if step > 300*24*time.Hour { + layout = "2006" + } else if step > 20*24*time.Hour { + layout = "Jan 06" + } + return func(_ int, value float64) string { + return time.Unix(int64(value), 0).In(location).Format(layout) + } +} + +func (m *Stats) refresh() tea.Cmd { + m.summary = storage.SummarizeDashboard( + m.projects, m.tasks, m.rates, m.entries, m.payments, + m.period.Start, m.period.End, m.now, + ) + return m.refreshItems() +} + +func (m *Stats) refreshItems() tea.Cmd { + items := make([]list.Item, len(m.summary.Projects)) + for i, project := range m.summary.Projects { + items[i] = statsProjectItem{project} + } + cmd := m.setListItems(items) + m.resizeList() + return cmd +} + +func (m *Stats) setListItems(items []list.Item) tea.Cmd { + selected := m.list.Index() + cmd := m.list.SetItems(items) + if len(items) == 0 { + return cmd + } + if selected >= len(items) { + selected = len(items) - 1 + } + if selected < 0 { + selected = 0 + } + m.list.Select(selected) + return cmd +} + +func (m *Stats) resizeList() { + height := m.height - lipgloss.Height(m.headerView()) - 2 + if height < 1 { + height = 1 + } + m.list.SetSize(m.width, height) +} + +func dashboardProjectID(project storage.DashboardProjectSummary) int { + if project.ProjectID == nil { + return 0 + } + return *project.ProjectID +} + +func (m *Stats) updateNow(now time.Time) { + followsCurrent := false + if m.period.Kind != "" && m.period.Kind != storage.All { + current, err := storage.CurrentPeriod(m.period.Kind, m.now) + followsCurrent = err == nil && + m.period.Start.Equal(current.Start) && m.period.End.Equal(current.End) + } + m.now = now + if followsCurrent { + current, err := storage.CurrentPeriod(m.period.Kind, now) + if err == nil { + m.period = current + } + } +} + +func (m *Stats) setPeriod(kind storage.PeriodKind) tea.Cmd { + period, err := storage.CurrentPeriod(kind, m.now) + if err == nil { + m.period = period + return m.refresh() + } + return nil +} + +func (m *Stats) moveForward() tea.Cmd { + if m.period.Kind == storage.All || m.period.Kind == "" { + return nil + } + current, err := storage.CurrentPeriod(m.period.Kind, m.now) + if err != nil || !m.period.Start.Before(current.Start) { + return nil + } + m.period = storage.MovePeriod(m.period, 1) + return m.refresh() +} + +func (m Stats) openPeriodMenu() (Stats, tea.Cmd) { + m.periodMenu = components.NewPeriodMenu(m.period, m.now, m.width) + return m, m.periodMenu.Init() +} + +func (m Stats) FormActive() bool { return m.periodMenu != nil } +func (m Stats) GlobalKeysEnabled() bool { + return m.periodMenu == nil && m.list.FilterState() != list.Filtering +} +func (m Stats) Actions() string { + return "[/] search [f] filters [F] reset filters " + + "[shift+up/down or K/J] period " + + "[shift+left/right or H/L] move " + + "[j/k] select [enter] open tasks [r] reload" +} + +func (m *Stats) Reload() tea.Cmd { + m.loading = true + return loadStats(m.ctx, m.stor) +} + +func loadStats(ctx context.Context, stor *storage.Storage) tea.Cmd { + return func() tea.Msg { + projects, err := stor.GetProjects(ctx) + if err != nil { + return statsFailedMsg{err} + } + tasks, err := stor.GetTasks(ctx) + if err != nil { + return statsFailedMsg{err} + } + rates, err := stor.GetRates(ctx) + if err != nil { + return statsFailedMsg{err} + } + entries, err := stor.GetEntries(ctx) + if err != nil { + return statsFailedMsg{err} + } + payments, err := stor.GetPayments(ctx) + if err != nil { + return statsFailedMsg{err} + } + return statsLoadedMsg{projects, tasks, rates, entries, payments} + } +} + +func tickStats() tea.Cmd { + return tea.Tick(time.Second, func(now time.Time) tea.Msg { return statsTickMsg(now) }) +} + +func dashboardCurrencies(amountMaps ...map[string]int64) []string { + seen := make(map[string]bool) + for _, amounts := range amountMaps { + for currency := range amounts { + seen[currency] = true + } + } + result := make([]string, 0, len(seen)) + for currency := range seen { + result = append(result, currency) + } + sort.Strings(result) + return result +} + +func formatStatsAmounts(amounts map[string]int64) string { + currencies := storage.SortedCurrencies(amounts) + parts := make([]string, len(currencies)) + for i, currency := range currencies { + parts[i] = components.FormatMoney(amounts[currency], currency) + } + return strings.Join(parts, ", ") +} diff --git a/internal/tui/screens/stats_test.go b/internal/tui/screens/stats_test.go new file mode 100644 index 0000000..d2bf209 --- /dev/null +++ b/internal/tui/screens/stats_test.go @@ -0,0 +1,192 @@ +package screens + +import ( + "strings" + "testing" + "time" + + "chankat/internal/storage" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestStatsPeriodKeysAndProjectNavigation(t *testing.T) { + now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + period, err := storage.CurrentPeriod(storage.Day, now) + if err != nil { + t.Fatal(err) + } + project1, project2, taskID, rateID := 1, 2, 1, 1 + end := now.Add(-time.Hour) + m := NewStats(t.Context(), nil) + m.now, m.period, m.loading = now, period, false + m.width, m.height = 80, 20 + m.projects = []storage.Project{ + {ID: 1, Name: "Acme", RateID: 1}, + {ID: 2, Name: "Beta", RateID: 1}, + } + m.tasks = []storage.Task{{ID: 1, Name: "Build", ProjectID: 1}} + m.rates = []storage.Rate{{ID: 1, AmountMinor: 10_000, Currency: "USD"}} + m.entries = []storage.Entry{ + {TaskID: &taskID, ProjectID: &project1, RateID: &rateID, + StartedAt: now.Add(-3 * time.Hour), EndedAt: &end}, + {TaskID: &taskID, ProjectID: &project2, RateID: &rateID, + StartedAt: now.Add(-time.Hour)}, + } + m.refresh() + + m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) + if m.list.Index() != 1 { + t.Fatalf("selected = %d", m.list.Index()) + } + m, _ = m.Update(tea.KeyMsg{Type: tea.KeyShiftLeft}) + if got := m.period.Start.Format("2006-01-02"); got != "2026-08-08" { + t.Fatalf("period start = %s", got) + } + m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'L'}}) + if got := m.period.Start.Format("2006-01-02"); got != "2026-08-09" { + t.Fatalf("period start after L = %s", got) + } + m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'H'}}) + if got := m.period.Start.Format("2006-01-02"); got != "2026-08-08" { + t.Fatalf("period start after H = %s", got) + } + m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")}) + if m.periodMenu == nil { + t.Fatal("filter key did not open the period menu") + } + m, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m.setPeriod(storage.Week) + if m.period.Kind != storage.Week { + t.Fatalf("period = %s", m.period.Kind) + } + m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'J'}}) + if m.period.Kind != storage.Month { + t.Fatalf("period after J = %s", m.period.Kind) + } + m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'K'}}) + if m.period.Kind != storage.Week { + t.Fatalf("period after K = %s", m.period.Kind) + } + if !strings.Contains(m.View(), "03 Aug 2026") { + t.Fatalf("view = %q", m.View()) + } + if !strings.Contains(m.View(), "Tracked over time by project (hours)") { + t.Fatalf("view has no timeline chart: %q", m.View()) + } + if !strings.Contains(m.View(), "●") { + t.Fatalf("view has no chart legend: %q", m.View()) + } + if got := strings.Count(m.View(), "●"); got != 2 { + t.Fatalf("chart legend entries = %d, want 2", got) + } + m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) + + var cmd tea.Cmd + m, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + message, ok := cmd().(OpenTasksMsg) + if !ok || message.ProjectID != 2 || message.Period.Kind != storage.Week { + t.Fatalf("open tasks message = %#v", message) + } + m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + if m.GlobalKeysEnabled() { + t.Fatal("global keys remain enabled while filtering") + } +} + +func TestStatsCurrentPeriodAdvancesAtCalendarBoundary(t *testing.T) { + tests := []struct { + name string + kind storage.PeriodKind + before time.Time + after time.Time + wantStart string + }{ + { + name: "day", kind: storage.Day, + before: time.Date(2026, 8, 9, 23, 59, 59, 0, time.UTC), + after: time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC), + wantStart: "2026-08-10", + }, + { + name: "week", kind: storage.Week, + before: time.Date(2026, 8, 9, 23, 59, 59, 0, time.UTC), + after: time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC), + wantStart: "2026-08-10", + }, + { + name: "month", kind: storage.Month, + before: time.Date(2026, 8, 31, 23, 59, 59, 0, time.UTC), + after: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), + wantStart: "2026-09-01", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + period, err := storage.CurrentPeriod(test.kind, test.before) + if err != nil { + t.Fatal(err) + } + m := NewStats(t.Context(), nil) + m.now, m.period = test.before, period + m, _ = m.Update(statsTickMsg(test.after)) + if got := m.period.Start.Format("2006-01-02"); got != test.wantStart { + t.Fatalf("period start = %s, want %s", got, test.wantStart) + } + }) + } +} + +func TestStatsHistoricalPeriodDoesNotAdvanceAtCalendarBoundary(t *testing.T) { + before := time.Date(2026, 8, 9, 23, 59, 59, 0, time.UTC) + period, err := storage.CurrentPeriod(storage.Day, before) + if err != nil { + t.Fatal(err) + } + period = storage.MovePeriod(period, -1) + m := NewStats(t.Context(), nil) + m.now, m.period = before, period + + m, _ = m.Update(statsTickMsg(time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC))) + + if got := m.period.Start.Format("2006-01-02"); got != "2026-08-08" { + t.Fatalf("period start = %s, want 2026-08-08", got) + } +} + +func TestStatsAppliesFilterResultsAfterRefresh(t *testing.T) { + now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + period, err := storage.CurrentPeriod(storage.Day, now) + if err != nil { + t.Fatal(err) + } + project1, project2, taskID, rateID := 1, 2, 1, 1 + m := NewStats(t.Context(), nil) + m.now, m.period, m.loading = now, period, false + m.projects = []storage.Project{ + {ID: 1, Name: "Acme", RateID: 1}, + {ID: 2, Name: "Beta", RateID: 1}, + } + m.tasks = []storage.Task{{ID: 1, Name: "Build", ProjectID: 1}} + m.rates = []storage.Rate{{ID: 1, AmountMinor: 10_000, Currency: "USD"}} + m.entries = []storage.Entry{ + {TaskID: &taskID, ProjectID: &project1, RateID: &rateID, StartedAt: now.Add(-time.Hour)}, + {TaskID: &taskID, ProjectID: &project2, RateID: &rateID, StartedAt: now.Add(-time.Hour)}, + } + m.refresh() + m.list.SetFilterText("Beta") + + cmd := m.refresh() + if cmd == nil { + t.Fatal("filtered refresh returned no command") + } + if got := len(m.list.VisibleItems()); got != 0 { + t.Fatalf("visible items before filter result = %d, want 0", got) + } + m, _ = m.Update(cmd()) + + items := m.list.VisibleItems() + if len(items) != 1 || items[0].FilterValue() != "Beta" { + t.Fatalf("visible items = %#v, want Beta", items) + } +} diff --git a/internal/tui/screens/tasks.go b/internal/tui/screens/tasks.go index 9f700c9..a7774ce 100644 --- a/internal/tui/screens/tasks.go +++ b/internal/tui/screens/tasks.go @@ -20,27 +20,37 @@ import ( ) type Dashboard struct { - ctx context.Context - stor *storage.Storage - entries []storage.Entry - projectList []storage.Project - taskList []storage.Task - projects map[int]string - tasks map[int]string - rates map[int]storage.Rate - active list.Model - activeItems []storage.Entry - taskPage components.Page[taskItem] - entryPage *components.Page[entryItem] - detailTask *storage.Task - focus dashboardRow - spinner spinner.Model - now time.Time - err error - loading bool - viewport viewport.Model + ctx context.Context + stor *storage.Storage + entries []storage.Entry + projectList []storage.Project + taskList []storage.Task + projects map[int]string + tasks map[int]string + rates map[int]storage.Rate + active list.Model + activeItems []storage.Entry + taskPage components.Page[taskItem] + entryPage *components.Page[entryItem] + detailTask *storage.Task + focus dashboardRow + spinner spinner.Model + now time.Time + err error + loading bool + viewport viewport.Model + filter *TaskListFilter + filterMenu *components.PeriodMenu + filterProjectID *int } +type TaskListFilter struct { + ProjectID int + Period storage.Period +} + +const allProjectsFilter = -1 + type dashboardLoadedMsg struct { entries []storage.Entry projects []storage.Project @@ -69,7 +79,7 @@ type entryStartFailedMsg struct { var ( dashboardSectionStyle = list.DefaultStyles().StatusBar dashboardMutedStyle = list.DefaultStyles().NoItems.PaddingLeft(2) - dashboardErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + dashboardErrorStyle = lipgloss.NewStyle().Foreground(components.AccentColor) ) type dashboardItem struct { @@ -82,6 +92,10 @@ func (i dashboardItem) Description() string { return i.description } func (i dashboardItem) FilterValue() string { return i.title + " " + i.description } func NewDashboard(ctx context.Context, stor *storage.Storage) Dashboard { + filter := &TaskListFilter{ + ProjectID: allProjectsFilter, + Period: storage.Period{Kind: storage.All}, + } m := Dashboard{ ctx: ctx, stor: stor, @@ -91,8 +105,9 @@ func NewDashboard(ctx context.Context, stor *storage.Storage) Dashboard { viewport: viewport.New(0, 0), now: time.Now(), loading: true, + filter: filter, } - m.taskPage = newTaskPage(ctx, stor, func() tea.Cmd { + m.taskPage = newTaskPage(ctx, stor, filter, func() tea.Cmd { return loadDashboard(ctx, stor) }) return m @@ -108,10 +123,40 @@ func (m Dashboard) Init() tea.Cmd { } func (m Dashboard) Update(msg tea.Msg) (Dashboard, tea.Cmd) { + if m.filterMenu != nil { + if tick, ok := msg.(dashboardTickMsg); ok { + periodChanged := m.updateFilterNow(time.Time(tick)) + m.refreshTables() + var filterCmd tea.Cmd + if periodChanged { + filterCmd = m.refreshTaskPage() + } + return m, tea.Batch(tickDashboard(), filterCmd) + } + if key, ok := msg.(tea.KeyMsg); ok && key.String() == "esc" { + m.closeFilterMenu() + return m, nil + } + cmd := m.filterMenu.Update(msg) + if m.filterMenu.Aborted() { + m.closeFilterMenu() + return m, nil + } + if m.filterMenu.Completed() { + filterCmd := m.applyFilterDraft() + m.closeFilterMenu() + return m, tea.Batch(cmd, filterCmd) + } + return m, cmd + } if _, ok := msg.(dashboardTickMsg); ok { - m.now = time.Time(msg.(dashboardTickMsg)) + periodChanged := m.updateFilterNow(time.Time(msg.(dashboardTickMsg))) m.refreshTables() - return m, tickDashboard() + var filterCmd tea.Cmd + if periodChanged { + filterCmd = m.refreshTaskPage() + } + return m, tea.Batch(tickDashboard(), filterCmd) } if m.entryPage != nil && m.entryPage.FormActive() { var cmd tea.Cmd @@ -158,6 +203,7 @@ func (m Dashboard) Update(msg tea.Msg) (Dashboard, tea.Cmd) { m.loading = false m.err = nil m.refreshTables() + taskCmd = tea.Batch(taskCmd, m.refreshTaskPage()) case dashboardFailedMsg: m.loading = false m.err = msg.err @@ -194,6 +240,24 @@ func (m Dashboard) Update(msg tea.Msg) (Dashboard, tea.Cmd) { return m, taskCmd } switch msg.String() { + case "f": + return m.openFilterMenu() + case "F": + return m, m.resetFilter() + case "shift+left", "H": + m.filter.Period = storage.MovePeriod(m.filter.Period, -1) + m.refreshTables() + return m, m.refreshTaskPage() + case "shift+right", "L": + return m, m.moveFilterForward() + case "shift+up", "K": + m.filter.Period = storage.StepPeriodKind(m.filter.Period, -1, m.now) + m.refreshTables() + return m, m.refreshTaskPage() + case "shift+down", "J": + m.filter.Period = storage.StepPeriodKind(m.filter.Period, 1, m.now) + m.refreshTables() + return m, m.refreshTaskPage() case "/", "n": m.setFocus(dashboardTaskRow) m.taskPage, taskCmd = m.taskPage.Update(msg) @@ -306,6 +370,9 @@ func (m Dashboard) Update(msg tea.Msg) (Dashboard, tea.Cmd) { } func (m Dashboard) View() string { + if m.filterMenu != nil { + return "tasks / filters\n\n" + m.filterMenu.View() + "\n\n[esc] back" + } if m.entryPage != nil && m.entryPage.FormActive() { return m.entryPage.View() } @@ -333,6 +400,7 @@ func (m Dashboard) View() string { func (m Dashboard) content() string { var sections []string + sections = append(sections, dashboardMutedStyle.Render(m.filterLabel())) var active strings.Builder active.WriteString(dashboardSectionStyle.Render( countLabel(len(m.activeItems), "active task"), @@ -367,9 +435,10 @@ func countLabel(count int, singular string) string { } func (m *Dashboard) refreshTables() { + entries := m.filteredEntries() active := make([]list.Item, 0) m.activeItems = m.activeItems[:0] - for _, entry := range m.entries { + for _, entry := range entries { if entry.EndedAt == nil { m.activeItems = append(m.activeItems, entry) duration := m.now.Sub(entry.StartedAt) @@ -379,7 +448,7 @@ func (m *Dashboard) refreshTables() { amounts := make(map[string]int64) if entry.TaskID != nil { duration, amounts = taskTotals( - m.entries, + entries, m.rates, *entry.TaskID, m.now, @@ -420,6 +489,185 @@ func (m *Dashboard) refreshTables() { m.setFocus(m.focus) } +func (m Dashboard) filteredEntries() []storage.Entry { + entries := m.entries + if m.filter == nil { + return entries + } + periodFiltered := m.filter.Period.Kind != storage.All || + !m.filter.Period.Start.IsZero() || !m.filter.Period.End.IsZero() + if periodFiltered { + entries = storage.EntriesInPeriod(entries, m.filter.Period, m.now) + } + if m.filter.ProjectID != allProjectsFilter { + entries = entriesForProject(entries, m.filter.ProjectID) + } + return entries +} + +func entriesForProject(entries []storage.Entry, projectID int) []storage.Entry { + result := make([]storage.Entry, 0, len(entries)) + for _, entry := range entries { + entryProjectID := 0 + if entry.ProjectID != nil { + entryProjectID = *entry.ProjectID + } + if entryProjectID == projectID { + result = append(result, entry) + } + } + return result +} + +func (m *Dashboard) refreshTaskPage() tea.Cmd { + if m.filter == nil { + return nil + } + return m.taskPage.SetItems(taskItemsForFilter( + m.taskList, + m.projectList, + m.entries, + mapRates(m.rates), + *m.filter, + m.now, + )) +} + +func mapRates(rates map[int]storage.Rate) []storage.Rate { + result := make([]storage.Rate, 0, len(rates)) + for _, rate := range rates { + result = append(result, rate) + } + return result +} + +func (m *Dashboard) ApplyFilter(projectID int, period storage.Period) tea.Cmd { + if m.filter == nil { + m.filter = &TaskListFilter{} + } + m.filter.ProjectID = projectID + m.filter.Period = period + m.detailTask = nil + m.entryPage = nil + m.taskPage.ResetFilter() + m.viewport.SetYOffset(0) + m.refreshTables() + return tea.Batch(m.refreshTaskPage(), loadDashboard(m.ctx, m.stor)) +} + +func (m *Dashboard) resetFilter() tea.Cmd { + return m.ApplyFilter( + allProjectsFilter, + storage.Period{Kind: storage.All}, + ) +} + +func (m Dashboard) openFilterMenu() (Dashboard, tea.Cmd) { + projectID := allProjectsFilter + if m.filter != nil { + projectID = m.filter.ProjectID + } + m.filterProjectID = &projectID + projectOptions := []huh.Option[int]{ + huh.NewOption("All projects", allProjectsFilter), + huh.NewOption("No project", 0), + } + for _, project := range m.projectList { + projectOptions = append( + projectOptions, huh.NewOption(project.Name, project.ID), + ) + } + projectField := huh.NewSelect[int](). + Title("Project"). + Options(projectOptions...). + Value(m.filterProjectID) + period := storage.Period{Kind: storage.All} + if m.filter != nil { + period = m.filter.Period + } + m.filterMenu = components.NewPeriodMenu( + period, + m.now, + m.viewport.Width, + projectField, + ) + return m, m.filterMenu.Init() +} + +func (m *Dashboard) applyFilterDraft() tea.Cmd { + if m.filterMenu == nil || m.filterProjectID == nil { + return nil + } + period, err := m.filterMenu.Period(m.now) + if err != nil { + m.err = err + return nil + } + m.err = nil + return m.ApplyFilter(*m.filterProjectID, period) +} + +func (m *Dashboard) closeFilterMenu() { + m.filterMenu = nil + m.filterProjectID = nil +} + +func (m *Dashboard) moveFilterForward() tea.Cmd { + if m.filter == nil || m.filter.Period.Kind == storage.All || + m.filter.Period.Kind == "" { + return nil + } + current, err := storage.CurrentPeriod(m.filter.Period.Kind, m.now) + if err != nil || !m.filter.Period.Start.Before(current.Start) { + return nil + } + m.filter.Period = storage.MovePeriod(m.filter.Period, 1) + m.refreshTables() + return m.refreshTaskPage() +} + +func (m *Dashboard) updateFilterNow(now time.Time) bool { + if m.filter == nil { + m.now = now + return false + } + previous := m.filter.Period + followsCurrent := false + if previous.Kind != "" && previous.Kind != storage.All { + current, err := storage.CurrentPeriod(previous.Kind, m.now) + followsCurrent = err == nil && previous.Start.Equal(current.Start) && + previous.End.Equal(current.End) + } + m.now = now + if followsCurrent { + current, err := storage.CurrentPeriod(previous.Kind, now) + if err == nil { + m.filter.Period = current + } + } + return !previous.Start.Equal(m.filter.Period.Start) || + !previous.End.Equal(m.filter.Period.End) +} + +func (m Dashboard) filterLabel() string { + project := "All projects" + period := storage.Period{Kind: storage.All} + if m.filter != nil { + period = m.filter.Period + switch m.filter.ProjectID { + case allProjectsFilter: + case 0: + project = "No project" + default: + project = m.projects[m.filter.ProjectID] + if project == "" { + project = fmt.Sprintf("Project %d", m.filter.ProjectID) + } + } + } + return "Filter: " + project + " · " + period.Label() +} + func newDashboardList(focused bool) list.Model { delegate := components.NewInactiveListDelegate() if focused { @@ -470,13 +718,14 @@ func (m *Dashboard) setFocus(focus dashboardRow) { } func (m *Dashboard) ensureFocusVisible() { + const filterHeight = 2 var y int switch m.focus { case dashboardActiveRow: if len(m.activeItems) == 0 { return } - y = 2 + m.active.Index()*2 + y = filterHeight + 2 + m.active.Index()*2 case dashboardTaskRow: if m.taskPage.VisibleCount() == 0 { return @@ -485,7 +734,7 @@ func (m *Dashboard) ensureFocusVisible() { if len(m.activeItems) > 0 { activeBodyHeight = len(m.activeItems) * 2 } - y = 3 + activeBodyHeight + 2 + m.taskPage.Index()*2 + y = filterHeight + 3 + activeBodyHeight + 2 + m.taskPage.Index()*2 default: return } @@ -506,17 +755,18 @@ const ( ) func (m Dashboard) rowAt(y int) (dashboardRow, int) { + const filterHeight = 2 activeBodyHeight := 1 if len(m.activeItems) > 0 { activeBodyHeight = len(m.activeItems) * 2 - if offset := y - 2; offset >= 0 && offset%2 == 0 { + if offset := y - filterHeight - 2; offset >= 0 && offset%2 == 0 { if index := offset / 2; index < len(m.activeItems) { return dashboardActiveRow, index } } } - taskTitle := 3 + activeBodyHeight + taskTitle := filterHeight + 3 + activeBodyHeight if m.taskPage.VisibleCount() > 0 { if offset := y - taskTitle - 2; offset >= 0 && offset%2 == 0 { if index := offset / 2; index < m.taskPage.VisibleCount() { @@ -658,11 +908,14 @@ func ratesByID(rates []storage.Rate) map[int]storage.Rate { } func (m Dashboard) FormActive() bool { - return m.taskPage.FormActive() || + return m.filterMenu != nil || m.taskPage.FormActive() || m.entryPage != nil && m.entryPage.FormActive() } func (m Dashboard) GlobalKeysEnabled() bool { + if m.filterMenu != nil { + return false + } if m.entryPage != nil && m.entryPage.FormActive() { return false } @@ -688,7 +941,9 @@ func (m Dashboard) Actions() string { } return "[/] search [n] new & track [a] add past task " + "[enter] details [e] edit task [x/delete] delete " + - "[space] start/pause" + "[space] start/pause [f] filters [F] reset filters " + + "[shift+up/down or K/J] period " + + "[shift+left/right or H/L] move" } func (m *Dashboard) resizeTaskPage() { @@ -696,7 +951,7 @@ func (m *Dashboard) resizeTaskPage() { if activeBodyHeight < 1 { activeBodyHeight = 1 } - height := m.viewport.Height - activeBodyHeight - 6 + height := m.viewport.Height - activeBodyHeight - 8 if height < 4 { height = 4 } @@ -756,7 +1011,7 @@ func (m Dashboard) openSelectedTask() (Dashboard, tea.Cmd) { if !ok { return m, nil } - page := newEntryPage(m.ctx, m.stor, task.ID, func() tea.Cmd { + page := newEntryPage(m.ctx, m.stor, task.ID, m.filter, func() tea.Cmd { return tea.Batch( loadDashboard(m.ctx, m.stor), m.taskPage.Reload(), @@ -860,7 +1115,7 @@ func (m *Dashboard) refreshDetailTask() bool { func (m Dashboard) detailView() string { task := *m.detailTask project := m.projects[task.ProjectID] - duration, amounts := taskTotals(m.entries, m.rates, task.ID, m.now) + duration, amounts := taskTotals(m.filteredEntries(), m.rates, task.ID, m.now) summary := project + " · total " + components.FormatDuration(duration) if amount := formatTaskAmounts(amounts); amount != "" { summary += " · " + amount + " earned" @@ -898,6 +1153,7 @@ type taskFormValues struct { func newTaskPage( ctx context.Context, stor *storage.Storage, + filter *TaskListFilter, afterSave func() tea.Cmd, ) components.Page[taskItem] { config := components.Config[taskItem]{ @@ -920,7 +1176,9 @@ func newTaskPage( if err != nil { return nil, nil, err } - return taskItems(tasks, projects, entries, rates), projects, nil + return taskItemsForFilter( + tasks, projects, entries, rates, *filter, time.Now(), + ), projects, nil }, Create: func(meta any) (*components.Form[taskItem], error) { return taskForm(ctx, stor, nil, meta.([]storage.Project)) @@ -947,9 +1205,69 @@ func taskItems( projects []storage.Project, entries []storage.Entry, rates []storage.Rate, +) []taskItem { + return summarizedTaskItems( + tasks, projects, entries, rates, time.Time{}, false, + ) +} + +func taskItemsForFilter( + tasks []storage.Task, + projects []storage.Project, + entries []storage.Entry, + rates []storage.Rate, + filter TaskListFilter, + now time.Time, +) []taskItem { + periodFiltered := filter.Period.Kind != storage.All || + !filter.Period.Start.IsZero() || !filter.Period.End.IsZero() + if periodFiltered { + entries = storage.EntriesInPeriod(entries, filter.Period, now) + } + if filter.ProjectID != allProjectsFilter { + filteredTasks := make([]storage.Task, 0, len(tasks)) + for _, task := range tasks { + if task.ProjectID == filter.ProjectID { + filteredTasks = append(filteredTasks, task) + } + } + tasks = filteredTasks + entries = entriesForProject(entries, filter.ProjectID) + } + if periodFiltered { + taskIDs := make(map[int]bool) + for _, entry := range entries { + if entry.TaskID != nil { + taskIDs[*entry.TaskID] = true + } + } + filteredTasks := make([]storage.Task, 0, len(tasks)) + for _, task := range tasks { + if taskIDs[task.ID] { + filteredTasks = append(filteredTasks, task) + } + } + tasks = filteredTasks + } + totalsNow := time.Time{} + if periodFiltered { + totalsNow = now + } + return summarizedTaskItems( + tasks, projects, entries, rates, totalsNow, periodFiltered, + ) +} + +func summarizedTaskItems( + tasks []storage.Task, + projects []storage.Project, + entries []storage.Entry, + rates []storage.Rate, + now time.Time, + periodFiltered bool, ) []taskItem { summaries := storage.SummarizeTasks( - tasks, projects, entries, rates, time.Time{}, + tasks, projects, entries, rates, now, ) ordered := make([]storage.TaskSummary, 0, len(summaries)) for _, summary := range summaries { @@ -977,7 +1295,11 @@ func taskItems( for _, summary := range ordered { description := summary.Project.Name if summary.LastEndedAt != nil { - description += " · total " + + totalLabel := "total " + if periodFiltered { + totalLabel = "period " + } + description += " · " + totalLabel + components.FormatDuration(summary.Tracked) if amount := formatTaskAmounts(summary.EarnedMinor); amount != "" { description += " · " + amount + " earned" @@ -1186,6 +1508,7 @@ func newEntryPage( ctx context.Context, stor *storage.Storage, taskID int, + filter *TaskListFilter, afterSave func() tea.Cmd, ) components.Page[entryItem] { config := components.Config[entryItem]{ @@ -1205,6 +1528,10 @@ func newEntryPage( 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 @@ -1238,6 +1565,25 @@ func newEntryPage( return components.NewPage(ctx, config) } +func entriesOverlappingPeriod( + entries []storage.Entry, + period storage.Period, + now time.Time, +) []storage.Entry { + clipped := storage.EntriesInPeriod(entries, period, now) + ids := make(map[int]bool, len(clipped)) + for _, entry := range clipped { + ids[entry.ID] = true + } + result := make([]storage.Entry, 0, len(clipped)) + for _, entry := range entries { + if ids[entry.ID] { + result = append(result, entry) + } + } + return result +} + func entryItems( entries []storage.Entry, taskID int, diff --git a/internal/tui/screens/tasks_test.go b/internal/tui/screens/tasks_test.go index 2e85ee1..663624e 100644 --- a/internal/tui/screens/tasks_test.go +++ b/internal/tui/screens/tasks_test.go @@ -105,6 +105,147 @@ func TestDashboardHistoricalEntryDoesNotBecomeLatest(t *testing.T) { } } +func TestTaskItemsForProjectAndPeriod(t *testing.T) { + now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + period, err := storage.CurrentPeriod(storage.Day, now) + if err != nil { + t.Fatal(err) + } + projectOne, projectTwo := 1, 2 + taskOne, taskTwo := 10, 20 + firstEnd := period.Start.Add(2 * time.Hour) + secondEnd := period.Start.Add(3 * time.Hour) + items := taskItemsForFilter( + []storage.Task{ + {ID: taskOne, Name: "one", ProjectID: projectOne}, + {ID: taskTwo, Name: "two", ProjectID: projectTwo}, + }, + []storage.Project{ + {ID: projectOne, Name: "first"}, + {ID: projectTwo, Name: "second"}, + }, + []storage.Entry{ + {ID: 1, TaskID: &taskOne, ProjectID: &projectOne, + StartedAt: period.Start.Add(-time.Hour), EndedAt: &firstEnd}, + {ID: 2, TaskID: &taskTwo, ProjectID: &projectTwo, + StartedAt: period.Start.Add(time.Hour), EndedAt: &secondEnd}, + }, + nil, + TaskListFilter{ProjectID: projectOne, Period: period}, + now, + ) + + if len(items) != 1 || items[0].task.ID != taskOne { + t.Fatalf("unexpected filtered tasks: %#v", items) + } + if got := items[0].Description(); !strings.Contains(got, "period 2h 00m") { + t.Fatalf("got description %q, want clipped period total", got) + } +} + +func TestDashboardFilterAdvancesWithCurrentPeriod(t *testing.T) { + before := time.Date(2026, 8, 9, 23, 59, 59, 0, time.UTC) + after := before.Add(2 * time.Second) + period, err := storage.CurrentPeriod(storage.Day, before) + if err != nil { + t.Fatal(err) + } + m := NewDashboard(t.Context(), nil) + m.now = before + m.filter.Period = period + + if changed := m.updateFilterNow(after); !changed { + t.Fatal("current filter did not advance") + } + if !m.filter.Period.Start.Equal(time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC)) { + t.Fatalf("period start = %s", m.filter.Period.Start) + } +} + +func TestEntriesForUnassignedProject(t *testing.T) { + projectID := 1 + entries := []storage.Entry{{ID: 1}, {ID: 2, ProjectID: &projectID}} + filtered := entriesForProject(entries, 0) + if len(filtered) != 1 || filtered[0].ID != 1 { + t.Fatalf("unexpected unassigned entries: %#v", filtered) + } +} + +func TestDashboardApplyFilterUpdatesTaskRows(t *testing.T) { + now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + period, err := storage.CurrentPeriod(storage.Day, now) + if err != nil { + t.Fatal(err) + } + projectOne, projectTwo := 1, 2 + taskOne, taskTwo := 10, 20 + end := now.Add(-time.Hour) + m := NewDashboard(t.Context(), nil) + m.now = now + m.loading = false + m.projectList = []storage.Project{ + {ID: projectOne, Name: "first"}, + {ID: projectTwo, Name: "second"}, + } + m.taskList = []storage.Task{ + {ID: taskOne, Name: "one", ProjectID: projectOne}, + {ID: taskTwo, Name: "two", ProjectID: projectTwo}, + } + m.projects = projectNames(m.projectList) + m.entries = []storage.Entry{ + {ID: 1, TaskID: &taskOne, ProjectID: &projectOne, + StartedAt: end.Add(-time.Hour), EndedAt: &end}, + {ID: 2, TaskID: &taskTwo, ProjectID: &projectTwo, + StartedAt: end.Add(-time.Hour), EndedAt: &end}, + } + + m.ApplyFilter(projectTwo, period) + selected, ok := m.taskPage.Selected() + if !ok || m.taskPage.VisibleCount() != 1 || selected.task.ID != taskTwo { + t.Fatalf("unexpected filtered task: %#v", selected) + } + if got := m.filterLabel(); !strings.Contains(got, "second") || + !strings.Contains(got, period.Label()) { + t.Fatalf("filter label = %q", got) + } +} + +func TestTaskFilterMenuKeepsProjectSelection(t *testing.T) { + m := NewDashboard(t.Context(), nil) + m.projectList = []storage.Project{{ID: 7, Name: "project"}} + m, _ = m.openFilterMenu() + *m.filterProjectID = 7 + m.applyFilterDraft() + if m.filter.ProjectID != 7 { + t.Fatalf("project filter = %d", m.filter.ProjectID) + } +} + +func TestDashboardResetFilter(t *testing.T) { + m := NewDashboard(t.Context(), nil) + period, err := storage.CurrentPeriod( + storage.Week, + time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC), + ) + if err != nil { + t.Fatal(err) + } + m.filter.ProjectID = 7 + m.filter.Period = period + + updated, cmd := m.Update(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune{'F'}, + }) + if cmd == nil { + t.Fatal("reset did not refresh tasks") + } + if updated.filter.ProjectID != allProjectsFilter || + updated.filter.Period.Kind != storage.All { + t.Fatalf("filter was not reset: %#v", updated.filter) + } +} + func TestDashboardResumedTaskTotals(t *testing.T) { now := time.Unix(1_700_000_000, 0) previousEnd := now.Add(-30 * time.Minute) @@ -155,14 +296,14 @@ func TestDashboardRowAt(t *testing.T) { m.refreshTables() t.Run("active entry", func(t *testing.T) { - kind, index := m.rowAt(2) + kind, index := m.rowAt(4) if kind != dashboardActiveRow || index != 0 { t.Fatalf("got (%d, %d), want active row 0", kind, index) } }) t.Run("available task", func(t *testing.T) { - kind, index := m.rowAt(7) + kind, index := m.rowAt(9) if kind != dashboardTaskRow || index != 0 { t.Fatalf("got (%d, %d), want task row 0", kind, index) } @@ -227,6 +368,7 @@ func TestDashboardKeyboardNavigation(t *testing.T) { } func TestPauseTaskStopsAllTaskEntries(t *testing.T) { + t.Setenv("CHANKAT_DATA_PATH", "") t.Setenv("XDG_DATA_HOME", t.TempDir()) stor, err := storage.Open() if err != nil { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 8352327..3e07137 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -15,7 +15,8 @@ import ( type screen int const ( - tasksScreen screen = iota + dashboardScreen screen = iota + tasksScreen projectsScreen ratesScreen paymentsScreen @@ -27,6 +28,7 @@ type tab struct { } var tabs = []tab{ + {label: "Dashboard", screen: dashboardScreen}, {label: "Tasks", screen: tasksScreen}, {label: "Projects", screen: projectsScreen}, {label: "Rates", screen: ratesScreen}, @@ -40,6 +42,7 @@ var ( type model struct { active screen + stats screens.Stats dashboard screens.Dashboard projects screens.Projects rates screens.Rates @@ -49,7 +52,8 @@ type model struct { func newModel(ctx context.Context, stor *storage.Storage) model { return model{ - active: tasksScreen, + active: dashboardScreen, + stats: screens.NewStats(ctx, stor), dashboard: screens.NewDashboard(ctx, stor), projects: screens.NewProjects(ctx, stor), rates: screens.NewRates(ctx, stor), @@ -59,6 +63,7 @@ func newModel(ctx context.Context, stor *storage.Storage) model { func (m model) Init() tea.Cmd { return tea.Batch( + m.stats.Init(), m.dashboard.Init(), m.projects.Init(), m.rates.Init(), @@ -68,6 +73,9 @@ func (m model) Init() tea.Cmd { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case screens.OpenTasksMsg: + m.active = tasksScreen + return m, m.dashboard.ApplyFilter(msg.ProjectID, msg.Period) case tea.WindowSizeMsg: m.height = msg.Height msg.Height -= 5 @@ -98,12 +106,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "right", "l": return m.activate(adjacentTab(m.active, 1)) case "1": - return m.activate(tasksScreen) + return m.activate(dashboardScreen) case "2": - return m.activate(projectsScreen) + return m.activate(tasksScreen) case "3": - return m.activate(ratesScreen) + return m.activate(projectsScreen) case "4": + return m.activate(ratesScreen) + case "5": return m.activate(paymentsScreen) } } @@ -116,6 +126,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) updateActive(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch m.active { + case dashboardScreen: + m.stats, cmd = m.stats.Update(msg) case tasksScreen: m.dashboard, cmd = m.dashboard.Update(msg) case projectsScreen: @@ -129,17 +141,20 @@ func (m model) updateActive(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m model) updateAll(msg tea.Msg) (tea.Model, tea.Cmd) { - var commands [4]tea.Cmd - m.dashboard, commands[0] = m.dashboard.Update(msg) - m.projects, commands[1] = m.projects.Update(msg) - m.rates, commands[2] = m.rates.Update(msg) - m.payments, commands[3] = m.payments.Update(msg) + var commands [5]tea.Cmd + m.stats, commands[0] = m.stats.Update(msg) + m.dashboard, commands[1] = m.dashboard.Update(msg) + m.projects, commands[2] = m.projects.Update(msg) + m.rates, commands[3] = m.rates.Update(msg) + m.payments, commands[4] = m.payments.Update(msg) return m, tea.Batch(commands[:]...) } func (m model) View() string { var content string switch m.active { + case dashboardScreen: + content = m.stats.View() case tasksScreen: content = m.dashboard.View() case projectsScreen: @@ -160,7 +175,7 @@ func (m model) View() string { } content = lipgloss.NewStyle().Height(contentHeight).Render(content) return fmt.Sprintf( - "%s\n\n%s\n\n[1-4] tabs [h/l] cycle\n%s", + "%s\n\n%s\n\n[1-5] tabs [h/l] cycle\n%s", renderTabs(m.active), content, m.actions(), @@ -178,6 +193,8 @@ func (m model) activate(target screen) (tea.Model, tea.Cmd) { m.active = target var cmd tea.Cmd switch target { + case dashboardScreen: + cmd = m.stats.Reload() case tasksScreen: cmd = m.dashboard.Reload() case projectsScreen: @@ -192,6 +209,8 @@ func (m model) activate(target screen) (tea.Model, tea.Cmd) { func (m model) formActive() bool { switch m.active { + case dashboardScreen: + return m.stats.FormActive() case tasksScreen: return m.dashboard.FormActive() case projectsScreen: @@ -207,6 +226,8 @@ func (m model) formActive() bool { func (m model) globalKeysEnabled() bool { switch m.active { + case dashboardScreen: + return m.stats.GlobalKeysEnabled() case tasksScreen: return m.dashboard.GlobalKeysEnabled() case projectsScreen: @@ -222,6 +243,8 @@ func (m model) globalKeysEnabled() bool { func (m model) actions() string { switch m.active { + case dashboardScreen: + return m.stats.Actions() case tasksScreen: return m.dashboard.Actions() case projectsScreen: @@ -266,7 +289,7 @@ func adjacentTab(active screen, offset int) screen { return tabs[next].screen } } - return tasksScreen + return dashboardScreen } func Run(ctx context.Context, stor *storage.Storage) error { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 9ed875f..cec63fb 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -3,6 +3,10 @@ package tui import ( "strings" "testing" + "time" + + "chankat/internal/storage" + "chankat/internal/tui/screens" ) func TestAdjacentTab(t *testing.T) { @@ -12,8 +16,14 @@ func TestAdjacentTab(t *testing.T) { } }) - t.Run("previous wraps", func(t *testing.T) { - if got := adjacentTab(tasksScreen, -1); got != paymentsScreen { + t.Run("previous", func(t *testing.T) { + if got := adjacentTab(tasksScreen, -1); got != dashboardScreen { + t.Fatalf("got screen %d, want dashboard", got) + } + }) + + t.Run("dashboard previous wraps", func(t *testing.T) { + if got := adjacentTab(dashboardScreen, -1); got != paymentsScreen { t.Fatalf("got screen %d, want payments", got) } }) @@ -31,7 +41,29 @@ 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] delete [space] start/pause "+ + "[f] filters [F] reset filters "+ + "[shift+up/down or K/J] period "+ + "[shift+left/right or H/L] move" { t.Fatalf("got final line %q", got) } } + +func TestDashboardProjectOpensFilteredTasksTab(t *testing.T) { + period, err := storage.CurrentPeriod( + storage.Week, + time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC), + ) + if err != nil { + t.Fatal(err) + } + m := newModel(t.Context(), nil) + updated, cmd := m.Update(screens.OpenTasksMsg{ProjectID: 7, Period: period}) + result := updated.(model) + if result.active != tasksScreen { + t.Fatalf("active screen = %d, want tasks", result.active) + } + if cmd == nil { + t.Fatal("task filter did not trigger a refresh") + } +}