Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
build/
other/
.dev-data/
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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 ./...
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
50 changes: 50 additions & 0 deletions cmd/seed/main.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
7 changes: 7 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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(
Expand All @@ -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] <resource> <command> [options]
chankat completion bash
chankat version
Expand Down
41 changes: 41 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
39 changes: 38 additions & 1 deletion internal/cli/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
}
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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] {
Expand Down
6 changes: 5 additions & 1 deletion internal/cli/completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}},
Expand All @@ -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 {
Expand Down
Loading