diff options
42 files changed, 1854 insertions, 248 deletions
@@ -1,3 +1,3 @@ -/timr +/timesamurai /main -/cmd/timr/timr +/cmd/timesamurai/timesamurai diff --git a/Magefile.go b/Magefile.go index 4b150c0..3b00b47 100644 --- a/Magefile.go +++ b/Magefile.go @@ -9,11 +9,11 @@ import ( var Default = Build func Build() error { - return sh.RunV("go", "build", "-o", "timr", "./cmd/timr") + return sh.RunV("go", "build", "-o", "timesamurai", "./cmd/timesamurai") } func Run() error { - return sh.RunV("go", "run", "./cmd/timr") + return sh.RunV("go", "run", "./cmd/timesamurai") } func Test() error { @@ -25,5 +25,5 @@ func Lint() error { } func Install() error { - return sh.RunV("go", "install", "./cmd/timr") + return sh.RunV("go", "install", "./cmd/timesamurai") } @@ -1,13 +1,13 @@ -# timr +# timesamurai -`timr` is a terminal time-tracking tool that combines: +`timesamurai` is a terminal time-tracking tool that combines: - a stopwatch timer, - worktime-style work log tracking, - weekly reporting, - and a Bubble Tea TUI. -Current version: `v0.4.0`. +Current version: `v0.5.0`. ## Installation @@ -26,49 +26,50 @@ mage build Or build directly: ```bash -go build -o timr ./cmd/timr +go build -o timesamurai ./cmd/timesamurai ``` ## CLI Overview -`timr` now uses Cobra and supports global flags: +`timesamurai` now uses Cobra and supports global flags: ```bash -timr --version -timr --config /path/to/config.json +timesamurai --version +timesamurai --config /path/to/config.json ``` Top-level command groups: -- `timr timer ...` -- `timr work ...` -- `timr tui` +- `timesamurai timer ...` +- `timesamurai work ...` +- `timesamurai tui` ### `timer` Commands ```bash -timr timer start -timr timer stop -timr timer continue -timr timer reset -timr timer status [--raw|--raw-minutes] -timr timer prompt -timr timer track <description> -timr timer live [-f|--font <font>] +timesamurai timer start +timesamurai timer stop +timesamurai timer continue +timesamurai timer reset +timesamurai timer status [--raw|--raw-minutes] +timesamurai timer prompt +timesamurai timer track <description> +timesamurai timer live [-f|--font <font>] ``` ### `work` Commands ```bash -timr work login [-c|--category <cat>] [--at <time>] [-d|--descr <text>] [--start-timer] -timr work logout [-c|--category <cat>] [--at <time>] [-d|--descr <text>] [--stop-timer] -timr work add <duration> [-c|--category <cat>] [--at <time>] [-d|--descr <text>] -timr work sub <duration> [-c|--category <cat>] [--at <time>] [-d|--descr <text>] -timr work use-buffer <duration> [--at <time>] [-d|--descr <text>] -timr work status -timr work report [--verbose] [--no-color] -timr work edit -timr work import <file> +timesamurai work login [-c|--category <cat>] [--at <time>] [-d|--descr <text>] [--start-timer] +timesamurai work logout [-c|--category <cat>] [--at <time>] [-d|--descr <text>] [--stop-timer] +timesamurai work add <duration> [-c|--category <cat>] [--at <time>] [-d|--descr <text>] +timesamurai work sub <duration> [-c|--category <cat>] [--at <time>] [-d|--descr <text>] +timesamurai work day-off [--at <time>] [-d|--descr <text>] +timesamurai work use-buffer <duration> [--at <time>] [-d|--descr <text>] +timesamurai work status +timesamurai work report [--verbose] [--no-color] +timesamurai work edit +timesamurai work import <file> ``` ## TUI @@ -76,20 +77,23 @@ timr work import <file> Launch the TUI: ```bash -timr tui +timesamurai tui +timesamurai tui --disco ``` -The scaffold currently includes: +The TUI includes: - tab-based navigation (`Entries`, `Report`, `Timer`), -- vi-style global keys (`Tab`, `gt`, `gT`, `1/2/3`, `?`, `q`, `ZQ`), -- entries browsing/editing flows, +- TaskSamurai-inspired table styling and status bars, +- vi-style global keys (`Tab`, `gt`, `gT`, `1/2/3`, `?`/`H`, `q`, `ZQ`), +- theme controls (`c` randomize theme, `C` reset, `x` toggle disco mode), +- entries timeline table flows (navigate columns with `h/l` and press `Enter` to edit selected field, including date/time/value/description; `D` opens day-off datepicker), - report browsing, - timer screen with work login/logout toggle (`l`). ## TUI Screenshots -Screenshots section (`v0.4.0` baseline): +Screenshots section (`v0.5.0` baseline): - Entries screen: _to be captured_ - Report screen: _to be captured_ @@ -103,4 +107,4 @@ Install fish prompt helper: ./install-fish-integration.fish ``` -Then add `timr_prompt` to your `fish_prompt` or `fish_right_prompt`. +Then add `timesamurai_prompt` to your `fish_prompt` or `fish_right_prompt`. diff --git a/cmd/timr/main.go b/cmd/timesamurai/main.go index eea7d78..f1a8ab8 100644 --- a/cmd/timr/main.go +++ b/cmd/timesamurai/main.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - "codeberg.org/snonux/timr/internal/cli" + "codeberg.org/snonux/timesamurai/internal/cli" ) func main() { @@ -1,4 +1,4 @@ -module codeberg.org/snonux/timr +module codeberg.org/snonux/timesamurai go 1.24.3 diff --git a/install-fish-integration.fish b/install-fish-integration.fish index b90be24..90024d8 100755 --- a/install-fish-integration.fish +++ b/install-fish-integration.fish @@ -2,9 +2,9 @@ set -l fish_config_dir (set -q XDG_CONFIG_HOME; and echo "$XDG_CONFIG_HOME/fish"; or echo "$HOME/.config/fish") set -l fish_conf_d "$fish_config_dir/conf.d" -set -l integration_file "$fish_conf_d/timr.fish" +set -l integration_file "$fish_conf_d/timesamurai.fish" -echo "Installing timr fish shell integration..." +echo "Installing timesamurai fish shell integration..." # Create fish config directory if it doesn't exist if not test -d "$fish_conf_d" @@ -14,12 +14,12 @@ end # Write the integration file echo "\ -function timr_prompt -d \"Display timr timr_status in the prompt\" - if command -v timr >/dev/null - set -l timr_status (timr prompt) - if test -n \"\$timr_status\" - set -l icon (string sub -l 1 -- \"\$timr_status\") - set -l time (string sub -s 2 -- \"\$timr_status\") +function timesamurai_prompt -d \"Display timesamurai timesamurai_status in the prompt\" + if command -v timesamurai >/dev/null + set -l timesamurai_status (timesamurai prompt) + if test -n \"\$timesamurai_status\" + set -l icon (string sub -l 1 -- \"\$timesamurai_status\") + set -l time (string sub -s 2 -- \"\$timesamurai_status\") if test \"\$icon\" = \"▶\" set_color green else @@ -32,30 +32,30 @@ function timr_prompt -d \"Display timr timr_status in the prompt\" end end -complete -c timr -n __fish_use_subcommand -a start -d \"Start the timer\" -complete -c timr -n __fish_use_subcommand -a stop -d \"Stop the timer\" -complete -c timr -n __fish_use_subcommand -a pause -d \"Pause the timer\" -complete -c timr -n __fish_use_subcommand -a continue -d \"Continue the timer\" -complete -c timr -n __fish_use_subcommand -a cont -d \"Continue the timer\" -complete -c timr -n __fish_use_subcommand -a status -d \"Show the timer status\" -complete -c timr -n __fish_use_subcommand -a reset -d \"Reset the timer\" -complete -c timr -n __fish_use_subcommand -a track -d \"Save time with description\" -complete -c timr -n __fish_use_subcommand -a live -d \"Show the live timer\" -complete -c timr -n __fish_use_subcommand -a prompt -d \"Show the prompt status\" +complete -c timesamurai -n __fish_use_subcommand -a start -d \"Start the timer\" +complete -c timesamurai -n __fish_use_subcommand -a stop -d \"Stop the timer\" +complete -c timesamurai -n __fish_use_subcommand -a pause -d \"Pause the timer\" +complete -c timesamurai -n __fish_use_subcommand -a continue -d \"Continue the timer\" +complete -c timesamurai -n __fish_use_subcommand -a cont -d \"Continue the timer\" +complete -c timesamurai -n __fish_use_subcommand -a status -d \"Show the timer status\" +complete -c timesamurai -n __fish_use_subcommand -a reset -d \"Reset the timer\" +complete -c timesamurai -n __fish_use_subcommand -a track -d \"Save time with description\" +complete -c timesamurai -n __fish_use_subcommand -a live -d \"Show the live timer\" +complete -c timesamurai -n __fish_use_subcommand -a prompt -d \"Show the prompt status\" # Font completions for live mode -complete -c timr -n \"__fish_seen_subcommand_from live\" -s f -l font -d \"Font style\" -a \"doom mono12 rebel ansi ansiShadow\" +complete -c timesamurai -n \"__fish_seen_subcommand_from live\" -s f -l font -d \"Font style\" -a \"doom mono12 rebel ansi ansiShadow\" " > "$integration_file" echo "Created: $integration_file" echo "" echo "Installation complete!" echo "" -echo "To display timr in your prompt, add this to your fish_prompt or fish_right_prompt:" +echo "To display timesamurai in your prompt, add this to your fish_prompt or fish_right_prompt:" echo "" echo " function fish_prompt" echo " # ... your existing prompt ..." -echo " printf '%s ' (timr_prompt)" +echo " printf '%s ' (timesamurai_prompt)" echo " end" echo "" echo "Restart your fish shell or run 'source $integration_file' to apply changes." diff --git a/internal/cli/root.go b/internal/cli/root.go index faba592..8b5ad09 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -2,10 +2,14 @@ package cli import ( "context" + "errors" "fmt" + "strings" + "time" - timr "codeberg.org/snonux/timr/internal" - "codeberg.org/snonux/timr/internal/config" + timesamurai "codeberg.org/snonux/timesamurai/internal" + "codeberg.org/snonux/timesamurai/internal/config" + "codeberg.org/snonux/timesamurai/internal/worktime" "github.com/spf13/cobra" ) @@ -20,9 +24,10 @@ func Execute() error { func NewRootCmd() *cobra.Command { var configPath string var showVersion bool + var checkDBIntegrity bool cmd := &cobra.Command{ - Use: "timr", + Use: "timesamurai", Short: "Track time from your terminal", SilenceUsage: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { @@ -40,15 +45,19 @@ func NewRootCmd() *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { if showVersion { - _, err := fmt.Fprintln(cmd.OutOrStdout(), timr.Version) + _, err := fmt.Fprintln(cmd.OutOrStdout(), timesamurai.Version) return err } + if checkDBIntegrity { + return runDBIntegrityCheck(cmd) + } return cmd.Help() }, } cmd.Flags().BoolVar(&showVersion, "version", false, "Print version and exit") + cmd.Flags().BoolVar(&checkDBIntegrity, "check-db-integrity", false, "Validate worktime database integrity and exit") cmd.PersistentFlags().StringVar(&configPath, "config", "", "Path to config file") cmd.AddCommand(newTimerCmd()) cmd.AddCommand(newWorkCmd()) @@ -81,3 +90,45 @@ func currentConfig(cmd *cobra.Command) config.Config { } return cfg } + +func runDBIntegrityCheck(cmd *cobra.Command) error { + cfg := currentConfig(cmd) + entries, err := worktime.LoadAll(cfg.WorktimeDBDir) + if err != nil { + return err + } + + issues := worktime.CheckEntriesIntegrity(entries, worktime.DefaultMaxSessionSpan) + openSessions := worktime.OpenSessions(entries) + + lines := make([]string, 0, len(issues)+len(openSessions)+2) + if len(issues) == 0 { + lines = append(lines, "Database integrity check passed.") + } else { + lines = append(lines, fmt.Sprintf("Database integrity check found %d issue(s):", len(issues))) + for idx, issue := range issues { + lines = append(lines, fmt.Sprintf("%d. %s", idx+1, issue.String())) + } + } + + if len(openSessions) > 0 { + lines = append(lines, fmt.Sprintf("Warning: currently logged in (%d open session(s)):", len(openSessions))) + for _, session := range openSessions { + lines = append(lines, fmt.Sprintf( + "- category=%s source=%s since=%s", + session.Category, + session.Login.Source, + time.Unix(session.Login.Epoch, 0).Format("2006-01-02 15:04:05"), + )) + } + } + + _, printErr := fmt.Fprintln(cmd.OutOrStdout(), strings.Join(lines, "\n")) + if printErr != nil { + return printErr + } + if len(issues) > 0 { + return errors.New("database integrity check failed") + } + return nil +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 2b12514..e1f74b1 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -7,8 +7,10 @@ import ( "path/filepath" "strings" "testing" + "time" - timr "codeberg.org/snonux/timr/internal" + timesamurai "codeberg.org/snonux/timesamurai/internal" + "codeberg.org/snonux/timesamurai/internal/worktime" ) func TestRootVersionFlag(t *testing.T) { @@ -22,8 +24,8 @@ func TestRootVersionFlag(t *testing.T) { t.Fatalf("Execute() error = %v", err) } - if strings.TrimSpace(out.String()) != timr.Version { - t.Fatalf("output = %q, want %q", strings.TrimSpace(out.String()), timr.Version) + if strings.TrimSpace(out.String()) != timesamurai.Version { + t.Fatalf("output = %q, want %q", strings.TrimSpace(out.String()), timesamurai.Version) } } @@ -96,8 +98,8 @@ func TestVersionSkipsConfigLoading(t *testing.T) { t.Fatalf("Execute() error = %v", err) } - if strings.TrimSpace(out.String()) != timr.Version { - t.Fatalf("output = %q, want %q", strings.TrimSpace(out.String()), timr.Version) + if strings.TrimSpace(out.String()) != timesamurai.Version { + t.Fatalf("output = %q, want %q", strings.TrimSpace(out.String()), timesamurai.Version) } } @@ -124,3 +126,113 @@ func TestRootUsesDefaultConfigWhenNoFileExists(t *testing.T) { t.Fatalf("WorktimeDBDir = %q, want %q", cfg.WorktimeDBDir, wantDir) } } + +func TestRootCheckDBIntegrityPasses(t *testing.T) { + dbDir := t.TempDir() + host := "host-a" + + if _, err := worktime.Login(dbDir, host, "work", time.Unix(100, 0), ""); err != nil { + t.Fatalf("Login() error = %v", err) + } + if _, err := worktime.Logout(dbDir, host, "work", time.Unix(200, 0), ""); err != nil { + t.Fatalf("Logout() error = %v", err) + } + + cfgPath := writeRootConfig(t, dbDir, host) + + var out bytes.Buffer + cmd := NewRootCmd() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--config", cfgPath, "--check-db-integrity"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v (output: %q)", err, out.String()) + } + if !strings.Contains(out.String(), "Database integrity check passed.") { + t.Fatalf("unexpected output: %q", out.String()) + } +} + +func TestRootCheckDBIntegrityFailsOnIssues(t *testing.T) { + dbDir := t.TempDir() + host := "host-a" + + db := worktime.Database{ + Entries: map[string][]worktime.Entry{ + host: { + { + Action: "logout", + What: "work", + Epoch: 100, + Source: host, + Human: time.Unix(100, 0).Format("Mon 02.01.2006 15:04:05"), + }, + }, + }, + } + if err := worktime.SaveHost(dbDir, host, db); err != nil { + t.Fatalf("SaveHost() error = %v", err) + } + + cfgPath := writeRootConfig(t, dbDir, host) + + var out bytes.Buffer + cmd := NewRootCmd() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--config", cfgPath, "--check-db-integrity"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("Execute() error = nil, want integrity failure") + } + if !strings.Contains(err.Error(), "database integrity check failed") { + t.Fatalf("Execute() error = %v, want integrity failure", err) + } + if !strings.Contains(out.String(), "Database integrity check found") { + t.Fatalf("unexpected output: %q", out.String()) + } +} + +func TestRootCheckDBIntegrityWarnsForOpenSession(t *testing.T) { + dbDir := t.TempDir() + host := "host-a" + + if _, err := worktime.Login(dbDir, host, "work", time.Unix(100, 0), ""); err != nil { + t.Fatalf("Login() error = %v", err) + } + + cfgPath := writeRootConfig(t, dbDir, host) + + var out bytes.Buffer + cmd := NewRootCmd() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--config", cfgPath, "--check-db-integrity"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v (output: %q)", err, out.String()) + } + if !strings.Contains(out.String(), "Database integrity check passed.") { + t.Fatalf("unexpected output: %q", out.String()) + } + if !strings.Contains(out.String(), "Warning: currently logged in") { + t.Fatalf("expected open-session warning in output: %q", out.String()) + } +} + +func writeRootConfig(t *testing.T, dbDir, host string) string { + t.Helper() + + content := `{ + "worktime_db_dir": "` + dbDir + `", + "hostname": "` + host + `" +} +` + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write config file: %v", err) + } + return path +} diff --git a/internal/cli/timer.go b/internal/cli/timer.go index 37e50d0..e9889c4 100644 --- a/internal/cli/timer.go +++ b/internal/cli/timer.go @@ -8,10 +8,10 @@ import ( "strings" "time" - "codeberg.org/snonux/timr/internal/ascii" - timrTimer "codeberg.org/snonux/timr/internal/timer" - tuiapp "codeberg.org/snonux/timr/internal/tui" - "codeberg.org/snonux/timr/internal/worktime" + "codeberg.org/snonux/timesamurai/internal/ascii" + timesamuraiTimer "codeberg.org/snonux/timesamurai/internal/timer" + tuiapp "codeberg.org/snonux/timesamurai/internal/tui" + "codeberg.org/snonux/timesamurai/internal/worktime" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" ) @@ -44,7 +44,7 @@ func newTimerStartCmd() *cobra.Command { return err } - output, err := timrTimer.StartTimer(hasElapsed) + output, err := timesamuraiTimer.StartTimer(hasElapsed) if err != nil { return err } @@ -61,7 +61,7 @@ func newTimerStopCmd() *cobra.Command { Use: "stop", Short: "Stop the timer", RunE: func(cmd *cobra.Command, args []string) error { - output, err := timrTimer.StopTimer() + output, err := timesamuraiTimer.StopTimer() if err != nil { return fmt.Errorf("stop timer: %w", err) } @@ -85,7 +85,7 @@ func newTimerContinueCmd() *cobra.Command { output := "Timer is at 0, cannot continue." if hasElapsed { - output, err = timrTimer.StartTimer(true) + output, err = timesamuraiTimer.StartTimer(true) if err != nil { return err } @@ -101,7 +101,7 @@ func newTimerResetCmd() *cobra.Command { Use: "reset", Short: "Reset the timer", RunE: func(cmd *cobra.Command, args []string) error { - output, err := timrTimer.ResetTimer() + output, err := timesamuraiTimer.ResetTimer() if err != nil { return fmt.Errorf("reset timer: %w", err) } @@ -129,11 +129,11 @@ func newTimerStatusCmd() *cobra.Command { switch { case raw: - output, err = timrTimer.GetRawStatus() + output, err = timesamuraiTimer.GetRawStatus() case rawMinutes: - output, err = timrTimer.GetRawMinutesStatus() + output, err = timesamuraiTimer.GetRawMinutesStatus() default: - output, err = timrTimer.GetStatus() + output, err = timesamuraiTimer.GetStatus() } if err != nil { return err @@ -153,7 +153,7 @@ func newTimerPromptCmd() *cobra.Command { Use: "prompt", Short: "Show prompt-friendly timer status", RunE: func(cmd *cobra.Command, args []string) error { - output, err := timrTimer.GetPromptStatus() + output, err := timesamuraiTimer.GetPromptStatus() if err != nil { return fmt.Errorf("get prompt timer status: %w", err) } @@ -169,7 +169,7 @@ func newTimerTrackCmd() *cobra.Command { Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { description := strings.Join(args, " ") - output, err := timrTimer.TrackTime(description) + output, err := timesamuraiTimer.TrackTime(description) if err != nil { return fmt.Errorf("track timer entry %q: %w", description, err) } @@ -242,7 +242,7 @@ func syncWorktimeWithTimer(cmd *cobra.Command, start bool) error { } func timerHasElapsed() (bool, error) { - rawStatus, err := timrTimer.GetRawStatus() + rawStatus, err := timesamuraiTimer.GetRawStatus() if err != nil { return false, fmt.Errorf("get raw timer status: %w", err) } diff --git a/internal/cli/timer_test.go b/internal/cli/timer_test.go index 572d5a0..be4391e 100644 --- a/internal/cli/timer_test.go +++ b/internal/cli/timer_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "codeberg.org/snonux/timr/internal/worktime" + "codeberg.org/snonux/timesamurai/internal/worktime" ) func TestTimerStartAndStopCommands(t *testing.T) { diff --git a/internal/cli/tui.go b/internal/cli/tui.go index ff4f2aa..474ffc2 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -1,17 +1,19 @@ package cli import ( - tuiapp "codeberg.org/snonux/timr/internal/tui" + tuiapp "codeberg.org/snonux/timesamurai/internal/tui" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" ) func newTUICmd() *cobra.Command { - return &cobra.Command{ + var disco bool + + cmd := &cobra.Command{ Use: "tui", Short: "Launch full-screen TUI", RunE: func(cmd *cobra.Command, args []string) error { - model, err := tuiapp.NewModelWithConfig(currentConfig(cmd)) + model, err := tuiapp.NewModelWithConfigAndDisco(currentConfig(cmd), disco) if err != nil { return err } @@ -19,4 +21,7 @@ func newTUICmd() *cobra.Command { return program.Start() }, } + + cmd.Flags().BoolVar(&disco, "disco", false, "Enable disco mode (random theme changes)") + return cmd } diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go index 9d8f481..8ec4afd 100644 --- a/internal/cli/tui_test.go +++ b/internal/cli/tui_test.go @@ -25,4 +25,9 @@ func TestNewTUICmdMetadata(t *testing.T) { if cmd.Short == "" { t.Fatal("Short description should not be empty") } + + flag := cmd.Flags().Lookup("disco") + if flag == nil { + t.Fatal("expected --disco flag to be defined") + } } diff --git a/internal/cli/work.go b/internal/cli/work.go index 22f0406..d6b989e 100644 --- a/internal/cli/work.go +++ b/internal/cli/work.go @@ -10,10 +10,10 @@ import ( "strings" "time" - "codeberg.org/snonux/timr/internal/duration" - "codeberg.org/snonux/timr/internal/timefmt" - timrTimer "codeberg.org/snonux/timr/internal/timer" - "codeberg.org/snonux/timr/internal/worktime" + "codeberg.org/snonux/timesamurai/internal/duration" + "codeberg.org/snonux/timesamurai/internal/timefmt" + timesamuraiTimer "codeberg.org/snonux/timesamurai/internal/timer" + "codeberg.org/snonux/timesamurai/internal/worktime" "github.com/spf13/cobra" ) @@ -32,6 +32,7 @@ func newWorkCmd() *cobra.Command { cmd.AddCommand(newWorkLogoutCmd()) cmd.AddCommand(newWorkAddCmd()) cmd.AddCommand(newWorkSubCmd()) + cmd.AddCommand(newWorkDayOffCmd()) cmd.AddCommand(newWorkUseBufferCmd()) cmd.AddCommand(newWorkReportCmd()) cmd.AddCommand(newWorkStatusCmd()) @@ -211,6 +212,39 @@ func newWorkSubCmd() *cobra.Command { return cmd } +func newWorkDayOffCmd() *cobra.Command { + var at string + var descr string + + cmd := &cobra.Command{ + Use: "day-off", + Short: "Add an 8-hour day-off entry", + RunE: func(cmd *cobra.Command, args []string) error { + ctx, err := resolveWorkContext(cmd) + if err != nil { + return err + } + + day, err := parseAtOrNow(at) + if err != nil { + return err + } + + entry, err := worktime.AddDayOff(ctx.dbDir, ctx.host, day, descr) + if err != nil { + return err + } + + dayLabel := time.Unix(entry.Epoch, 0).Format("2006-01-02") + return printOutput(cmd, fmt.Sprintf("Added day off: 8h on %s", dayLabel)) + }, + } + + cmd.Flags().StringVar(&at, "at", "", "Date/time override (unix, ISO, today, yesterday)") + cmd.Flags().StringVarP(&descr, "descr", "d", "", "Description") + return cmd +} + func newWorkUseBufferCmd() *cobra.Command { var at string var descr string @@ -407,9 +441,9 @@ func startTimerFromWorkCommand() (string, error) { if err != nil { return "", err } - return timrTimer.StartTimer(hasElapsed) + return timesamuraiTimer.StartTimer(hasElapsed) } func stopTimerFromWorkCommand() (string, error) { - return timrTimer.StopTimer() + return timesamuraiTimer.StopTimer() } diff --git a/internal/cli/work_test.go b/internal/cli/work_test.go index 9621bfb..04a1643 100644 --- a/internal/cli/work_test.go +++ b/internal/cli/work_test.go @@ -7,8 +7,10 @@ import ( "strconv" "strings" "testing" + "time" - timrTimer "codeberg.org/snonux/timr/internal/timer" + timesamuraiTimer "codeberg.org/snonux/timesamurai/internal/timer" + "codeberg.org/snonux/timesamurai/internal/worktime" ) func TestWorkLoginStatusLogoutFlow(t *testing.T) { @@ -87,7 +89,7 @@ func TestWorkLoginLogoutWithTimerFlags(t *testing.T) { if err != nil { t.Fatalf("work login --start-timer error = %v (output: %q)", err, out) } - state, err := timrTimer.LoadState() + state, err := timesamuraiTimer.LoadState() if err != nil { t.Fatalf("LoadState() error = %v", err) } @@ -99,7 +101,7 @@ func TestWorkLoginLogoutWithTimerFlags(t *testing.T) { if err != nil { t.Fatalf("work logout --stop-timer error = %v (output: %q)", err, out) } - state, err = timrTimer.LoadState() + state, err = timesamuraiTimer.LoadState() if err != nil { t.Fatalf("LoadState() error = %v", err) } @@ -108,6 +110,42 @@ func TestWorkLoginLogoutWithTimerFlags(t *testing.T) { } } +func TestWorkDayOffCommand(t *testing.T) { + dbDir := t.TempDir() + host := "host-day-off" + cfgPath := writeWorkConfig(t, dbDir, host) + + out, err := runRootCommand("--config", cfgPath, "work", "day-off", "--at", "2026-02-17", "--descr", "vacation") + if err != nil { + t.Fatalf("work day-off error = %v (output: %q)", err, out) + } + if !strings.Contains(out, "Added day off: 8h on 2026-02-17") { + t.Fatalf("unexpected day-off output: %q", out) + } + + db, err := worktime.LoadHost(dbDir, host) + if err != nil { + t.Fatalf("LoadHost() error = %v", err) + } + + entries := db.Entries[host] + if len(entries) != 1 { + t.Fatalf("entries len = %d, want 1", len(entries)) + } + + entry := entries[0] + wantEpoch := time.Date(2026, 2, 17, 0, 0, 0, 0, time.Local).Unix() + if entry.Action != "add" || entry.What != "off" { + t.Fatalf("unexpected day-off entry: %+v", entry) + } + if entry.Value != 8*3600 { + t.Fatalf("day-off value = %d, want 28800", entry.Value) + } + if entry.Epoch != wantEpoch { + t.Fatalf("day-off epoch = %d, want %d", entry.Epoch, wantEpoch) + } +} + func writeWorkConfig(t *testing.T, dbDir, host string) string { return writeWorkConfigWithAuto(t, dbDir, host, false) } diff --git a/internal/config/blackbox_test.go b/internal/config/blackbox_test.go index 0d97660..2303a74 100644 --- a/internal/config/blackbox_test.go +++ b/internal/config/blackbox_test.go @@ -3,7 +3,7 @@ package config_test import ( "testing" - "codeberg.org/snonux/timr/internal/config" + "codeberg.org/snonux/timesamurai/internal/config" ) func TestDefaultPublicAPI(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go index 812cca2..5dae114 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,7 +11,7 @@ import ( const ( defaultWeekWorkHours = 40.0 defaultWorktimeDBDir = "~/git/worktime" - configDirName = "timr" + configDirName = "timesamurai" configFileName = "config.json" ) diff --git a/internal/config/doc.go b/internal/config/doc.go index 5d58f69..4e34d6b 100644 --- a/internal/config/doc.go +++ b/internal/config/doc.go @@ -1,2 +1,2 @@ -// Package config loads, saves, and defaults timr configuration values. +// Package config loads, saves, and defaults timesamurai configuration values. package config diff --git a/internal/duration/blackbox_test.go b/internal/duration/blackbox_test.go index 87fa9dc..3553cdc 100644 --- a/internal/duration/blackbox_test.go +++ b/internal/duration/blackbox_test.go @@ -4,7 +4,7 @@ import ( "testing" "time" - "codeberg.org/snonux/timr/internal/duration" + "codeberg.org/snonux/timesamurai/internal/duration" ) func TestParsePublicAPI(t *testing.T) { diff --git a/internal/duration/doc.go b/internal/duration/doc.go index 9a99242..ad267c5 100644 --- a/internal/duration/doc.go +++ b/internal/duration/doc.go @@ -1,2 +1,2 @@ -// Package duration parses CLI duration values used by timr commands. +// Package duration parses CLI duration values used by timesamurai commands. package duration diff --git a/internal/timefmt/doc.go b/internal/timefmt/doc.go index 63afde5..747c1cc 100644 --- a/internal/timefmt/doc.go +++ b/internal/timefmt/doc.go @@ -1,2 +1,2 @@ -// Package timefmt parses timestamp inputs accepted by timr commands. +// Package timefmt parses timestamp inputs accepted by timesamurai commands. package timefmt diff --git a/internal/timer/timer.go b/internal/timer/timer.go index 2a5dbde..dec9421 100644 --- a/internal/timer/timer.go +++ b/internal/timer/timer.go @@ -9,7 +9,7 @@ import ( ) const ( - stateFile = ".timr_state" + stateFile = ".timesamurai_state" ) // State stores persisted timer progress. @@ -29,7 +29,7 @@ func resolveStateFilePath(path string) (string, error) { return "", err } - return filepath.Join(configDir, "timr", stateFile), nil + return filepath.Join(configDir, "timesamurai", stateFile), nil } // GetStateFile returns the default state file path. diff --git a/internal/tui/doc.go b/internal/tui/doc.go index 624b708..0621595 100644 --- a/internal/tui/doc.go +++ b/internal/tui/doc.go @@ -1,2 +1,2 @@ -// Package tui provides Bubble Tea models used by timr terminal interfaces. +// Package tui provides Bubble Tea models used by timesamurai terminal interfaces. package tui diff --git a/internal/tui/entries.go b/internal/tui/entries.go index e189846..2d35708 100644 --- a/internal/tui/entries.go +++ b/internal/tui/entries.go @@ -4,13 +4,15 @@ import ( "errors" "fmt" "slices" + "sort" "strconv" "strings" "time" "unicode/utf8" - "codeberg.org/snonux/timr/internal/duration" - "codeberg.org/snonux/timr/internal/worktime" + "codeberg.org/snonux/timesamurai/internal/duration" + "codeberg.org/snonux/timesamurai/internal/timefmt" + "codeberg.org/snonux/timesamurai/internal/worktime" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -20,26 +22,35 @@ type entryEditField int const ( entryEditFieldDescription entryEditField = iota entryEditFieldValue + entryEditFieldDate + entryEditFieldTime + entryEditFieldAction + entryEditFieldCategory ) -var categoryColors = map[string]string{ - "work": "#8BD3DD", - "lunch": "#F6BD60", - "off": "#CDB4DB", - "bank": "#A8DADC", - "sick": "#FFB4A2", - "selfdevelopment": "#B8E1A9", -} +type entriesColumn int + +const ( + entriesColumnDate entriesColumn = iota + entriesColumnTime + entriesColumnAction + entriesColumnCategory + entriesColumnValue + entriesColumnSource + entriesColumnDescription + entriesColumnCount +) // EntriesModel is a chronological worktime entry browser. type EntriesModel struct { allEntries []worktime.Entry visible []worktime.Entry - cursor int - offset int - width int - height int + cursor int + offset int + selectedColumn entriesColumn + width int + height int pendingG bool pendingD bool @@ -49,6 +60,8 @@ type EntriesModel struct { filterMode bool filterQuery string + dayOffMode bool + dayOffDate time.Time editMode bool confirmDelete bool @@ -57,22 +70,31 @@ type EntriesModel struct { editField entryEditField dbDir string + dbHost string statusMessage string statusError bool + mutationCount int + dirty bool + changedHosts map[string]struct{} } // NewEntriesModel creates an entry browser model. func NewEntriesModel(entries []worktime.Entry) EntriesModel { model := EntriesModel{ - height: 16, + height: 16, + selectedColumn: entriesColumnDescription, } model.SetEntries(entries) return model } -// SetPersistence enables edit/delete persistence against dbDir. -func (m *EntriesModel) SetPersistence(dbDir string) { +// SetPersistence enables edit/delete/create persistence against dbDir and host. +func (m *EntriesModel) SetPersistence(dbDir, host string) { m.dbDir = strings.TrimSpace(dbDir) + m.dbHost = strings.TrimSpace(host) + if m.changedHosts == nil { + m.changedHosts = map[string]struct{}{} + } } // SetSize updates viewport size used for scrolling. @@ -96,6 +118,8 @@ func (m *EntriesModel) SetEntries(entries []worktime.Entry) { } return 1 }) + m.dirty = false + m.changedHosts = map[string]struct{}{} m.applyFilters() } @@ -110,6 +134,10 @@ func (m *EntriesModel) Update(msg tea.Msg) (EntriesModel, tea.Cmd) { return *m, nil } + if m.updateDayOffMode(keyMsg) { + return *m, nil + } + if m.updateEditMode(keyMsg) { return *m, nil } @@ -170,6 +198,45 @@ func (m *EntriesModel) updateEditMode(keyMsg tea.KeyMsg) bool { return true } +func (m *EntriesModel) updateDayOffMode(keyMsg tea.KeyMsg) bool { + if !m.dayOffMode { + return false + } + + switch keyMsg.String() { + case "enter": + if addErr := m.addDayOff(m.dayOffDate); addErr != nil { + m.setStatusError("Add day off failed: " + addErr.Error()) + } else { + m.setStatusInfo("Day off added.") + } + + m.dayOffMode = false + case "esc": + m.dayOffMode = false + case "left", "h": + m.dayOffDate = m.dayOffDate.AddDate(0, 0, -1) + case "right", "l": + m.dayOffDate = m.dayOffDate.AddDate(0, 0, 1) + case "up", "k": + m.dayOffDate = m.dayOffDate.AddDate(0, 0, -7) + case "down", "j": + m.dayOffDate = m.dayOffDate.AddDate(0, 0, 7) + case "pgup": + m.dayOffDate = m.dayOffDate.AddDate(0, -1, 0) + case "pgdown": + m.dayOffDate = m.dayOffDate.AddDate(0, 1, 0) + case "home": + m.dayOffDate = time.Date(m.dayOffDate.Year(), m.dayOffDate.Month(), 1, 0, 0, 0, 0, m.dayOffDate.Location()) + case "end": + m.dayOffDate = time.Date(m.dayOffDate.Year(), m.dayOffDate.Month()+1, 0, 0, 0, 0, 0, m.dayOffDate.Location()) + default: + return true + } + + return true +} + func (m *EntriesModel) updateSearchFilterMode(keyMsg tea.KeyMsg) bool { if !m.searchMode && !m.filterMode { return false @@ -213,7 +280,11 @@ func (m *EntriesModel) updateNormalMode(keyMsg tea.KeyMsg) { m.input = m.filterQuery m.pendingG = false m.pendingD = false - case "e", "enter": + case "enter": + m.beginEditSelectedField() + m.pendingG = false + m.pendingD = false + case "e": m.beginEditDescription() m.pendingG = false m.pendingD = false @@ -221,6 +292,14 @@ func (m *EntriesModel) updateNormalMode(keyMsg tea.KeyMsg) { m.beginEditValue() m.pendingG = false m.pendingD = false + case "h", "left": + m.moveColumn(-1) + m.pendingG = false + m.pendingD = false + case "l", "right": + m.moveColumn(1) + m.pendingG = false + m.pendingD = false case "o": m.insertEntry(false) m.pendingG = false @@ -229,6 +308,17 @@ func (m *EntriesModel) updateNormalMode(keyMsg tea.KeyMsg) { m.insertEntry(true) m.pendingG = false m.pendingD = false + case "D": + m.dayOffMode = true + m.dayOffDate = time.Now() + m.pendingG = false + m.pendingD = false + case "s": + if err := m.savePendingChanges(); err != nil { + m.setStatusError("Save failed: " + err.Error()) + } + m.pendingG = false + m.pendingD = false case "d": if m.pendingD { m.confirmDelete = true @@ -282,7 +372,7 @@ func (m *EntriesModel) updateNormalMode(keyMsg tea.KeyMsg) { } } -// View renders the entries list. +// View renders the entries timeline table. func (m *EntriesModel) View(styles Styles) string { title := fmt.Sprintf("Entries [%d/%d]", minInt(m.cursor+1, len(m.visible)), len(m.visible)) if m.filterQuery != "" { @@ -298,11 +388,11 @@ func (m *EntriesModel) View(styles Styles) string { if m.filterMode { return styles.Body.Render(title + "\n\nf " + m.input) } + if m.dayOffMode { + return styles.Body.Render(m.renderDayOffPicker(title, styles)) + } if m.editMode { - prompt := "Edit description: " - if m.editField == entryEditFieldValue { - prompt = "Edit value (e.g. 90m, 3600, -600): " - } + prompt := m.editPrompt() return styles.Body.Render(title + "\n\n" + prompt + m.input) } if m.confirmDelete { @@ -314,32 +404,9 @@ func (m *EntriesModel) View(styles Styles) string { return styles.Body.Render(body + m.renderStatus(styles)) } - maxRows := m.listRows() - end := minInt(len(m.visible), m.offset+maxRows) - lines := make([]string, 0, end-m.offset) - selectedStyle := lipgloss.NewStyle(). - Background(lipgloss.Color("#28323F")). - Bold(true) - - for idx := m.offset; idx < end; idx++ { - entry := m.visible[idx] - cursor := " " - if idx == m.cursor { - cursor = ">" - } - - timestamp := time.Unix(entry.Epoch, 0).Format("2006-01-02 15:04") - category := colorizeCategory(entry.What) - value := formatEntryValue(entry) - line := fmt.Sprintf("%s %s %-7s %-18s %-8s %s", cursor, timestamp, entry.Action, category, value, entry.Descr) - if idx == m.cursor { - line = selectedStyle.Render(line) - } - lines = append(lines, line) - } - - body := title + "\n\n" + strings.Join(lines, "\n") - body += "\n\n" + styles.Hint.Render("j/k move, e edit description, v edit value, dd delete") + rows := m.renderTimelineTable(styles) + body := title + "\n\n" + rows + body += "\n\n" + styles.Hint.Render("j/k rows, h/l columns, Enter edit cell, s save, / search, D day-off datepicker, dd delete") return styles.Body.Render(body + m.renderStatus(styles)) } @@ -386,11 +453,56 @@ func (m *EntriesModel) moveCursor(delta int) { m.ensureCursorVisible() } +func (m *EntriesModel) moveColumn(delta int) { + m.selectedColumn += entriesColumn(delta) + if m.selectedColumn < entriesColumnDate { + m.selectedColumn = entriesColumnDate + } + if m.selectedColumn >= entriesColumnCount { + m.selectedColumn = entriesColumnCount - 1 + } +} + +func (m *EntriesModel) beginEditSelectedField() { + if len(m.visible) == 0 || m.cursor >= len(m.visible) { + return + } + + entry := m.visible[m.cursor] + entryTime := time.Unix(entry.Epoch, 0) + + switch m.selectedColumn { + case entriesColumnDate: + m.editMode = true + m.editField = entryEditFieldDate + m.input = entryTime.Format("2006-01-02") + case entriesColumnTime: + m.editMode = true + m.editField = entryEditFieldTime + m.input = entryTime.Format("15:04") + case entriesColumnAction: + m.editMode = true + m.editField = entryEditFieldAction + m.input = entry.Action + case entriesColumnCategory: + m.editMode = true + m.editField = entryEditFieldCategory + m.input = entry.What + case entriesColumnValue: + m.beginEditValue() + case entriesColumnDescription: + m.beginEditDescription() + case entriesColumnSource: + m.setStatusInfo("Source column is read-only.") + } +} + func (m *EntriesModel) beginEditDescription() { if len(m.visible) == 0 || m.cursor >= len(m.visible) { return } + m.selectedColumn = entriesColumnDescription m.editMode = true m.editField = entryEditFieldDescription m.input = m.visible[m.cursor].Descr @@ -407,6 +519,7 @@ func (m *EntriesModel) beginEditValue() { return } + m.selectedColumn = entriesColumnValue m.editMode = true m.editField = entryEditFieldValue m.input = strconv.FormatInt(entry.Value, 10) @@ -421,6 +534,24 @@ func (m *EntriesModel) saveEdit() error { newEntry := oldEntry switch m.editField { + case entryEditFieldDate: + updatedTime, err := parseEditedDate(m.input, time.Unix(newEntry.Epoch, 0)) + if err != nil { + return err + } + newEntry.Epoch = updatedTime.Unix() + newEntry.Human = updatedTime.Format("Mon 02.01.2006 15:04:05") + case entryEditFieldTime: + updatedTime, err := parseEditedTime(m.input, time.Unix(newEntry.Epoch, 0)) + if err != nil { + return err + } + newEntry.Epoch = updatedTime.Unix() + newEntry.Human = updatedTime.Format("Mon 02.01.2006 15:04:05") + case entryEditFieldAction: + newEntry.Action = strings.TrimSpace(m.input) + case entryEditFieldCategory: + newEntry.What = strings.TrimSpace(m.input) case entryEditFieldValue: if strings.ToLower(strings.TrimSpace(newEntry.Action)) != "add" { return errors.New("only 'add' entries have an editable value") @@ -434,6 +565,23 @@ func (m *EntriesModel) saveEdit() error { newEntry.Descr = strings.TrimSpace(m.input) } + host := strings.TrimSpace(newEntry.Source) + if host == "" { + host = strings.TrimSpace(oldEntry.Source) + } + if host == "" { + host = strings.TrimSpace(m.dbHost) + } + if host == "" { + host = "local" + } + + normalized, err := worktime.NormalizeEditedEntry(newEntry, host) + if err != nil { + return err + } + newEntry = normalized + if err := m.persistReplacement(oldEntry, newEntry); err != nil { return err } @@ -441,6 +589,23 @@ func (m *EntriesModel) saveEdit() error { return nil } +func (m *EntriesModel) editPrompt() string { + switch m.editField { + case entryEditFieldDate: + return "Edit date (e.g. 2026-03-04, today, yesterday): " + case entryEditFieldTime: + return "Edit time (HH:MM or HH:MM:SS): " + case entryEditFieldAction: + return "Edit action (login/logout/add): " + case entryEditFieldCategory: + return "Edit category: " + case entryEditFieldValue: + return "Edit value (e.g. 90m, 3600, -600): " + default: + return "Edit description: " + } +} + func (m *EntriesModel) deleteSelected() error { if len(m.visible) == 0 || m.cursor >= len(m.visible) { return nil @@ -458,15 +623,21 @@ func (m *EntriesModel) deleteSelected() error { m.allEntries = append(m.allEntries[:idx], m.allEntries[idx+1:]...) m.applyFilters() + m.mutationCount++ return nil } func (m *EntriesModel) insertEntry(above bool) { + sourceHost := "local" + if configuredHost := strings.TrimSpace(m.dbHost); configuredHost != "" { + sourceHost = configuredHost + } + newEntry := worktime.Entry{ Action: "add", What: "work", Epoch: time.Now().Unix(), - Source: "local", + Source: sourceHost, Human: time.Now().Format("Mon 02.01.2006 15:04:05"), Value: int64(time.Hour / time.Second), } @@ -484,6 +655,8 @@ func (m *EntriesModel) insertEntry(above bool) { m.allEntries = insertEntryAt(m.allEntries, insertAt, newEntry) m.applyFilters() + m.mutationCount++ + m.markHostChanged(newEntry.Source) if idx := findEntryIndex(m.visible, newEntry); idx >= 0 { m.cursor = idx @@ -494,6 +667,131 @@ func (m *EntriesModel) insertEntry(above bool) { m.input = "" } +func (m *EntriesModel) addDayOff(day time.Time) error { + dayStart := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, day.Location()) + + if m.dbDir != "" { + host := strings.TrimSpace(m.dbHost) + if host == "" { + return errors.New("persistence host is not configured") + } + + entry := worktime.Entry{ + Action: "add", + What: "off", + Epoch: dayStart.Unix(), + Source: host, + Human: dayStart.Format("Mon 02.01.2006 15:04:05"), + Value: int64((8 * time.Hour) / time.Second), + } + m.appendEntry(entry) + return nil + } + + entry := worktime.Entry{ + Action: "add", + What: "off", + Epoch: dayStart.Unix(), + Source: "local", + Human: dayStart.Format("Mon 02.01.2006 15:04:05"), + Value: int64((8 * time.Hour) / time.Second), + } + m.appendEntry(entry) + return nil +} + +func (m *EntriesModel) renderDayOffPicker(title string, styles Styles) string { + selected := m.dayOffDate + if selected.IsZero() { + selected = time.Now() + } + selected = dayAtMidnight(selected) + + lines := []string{ + title, + "", + styles.Hint.Render("Day Off Datepicker"), + selected.Format("2006-01-02 (Mon)"), + "", + renderCalendarMonth(selected), + "", + styles.Hint.Render("h/l or ←/→ day, j/k or ↑/↓ week, PgUp/PgDn month, Enter confirm, Esc cancel"), + } + return strings.Join(lines, "\n") +} + +func renderCalendarMonth(selected time.Time) string { + firstOfMonth := time.Date(selected.Year(), selected.Month(), 1, 0, 0, 0, 0, selected.Location()) + lastOfMonth := time.Date(selected.Year(), selected.Month()+1, 0, 0, 0, 0, 0, selected.Location()) + + weekdayOffset := int(firstOfMonth.Weekday()) + if weekdayOffset == 0 { + weekdayOffset = 7 + } + weekdayOffset-- + + var builder strings.Builder + builder.WriteString(selected.Format("January 2006")) + builder.WriteString("\nMo Tu We Th Fr Sa Su\n") + + for idx := 0; idx < weekdayOffset; idx++ { + builder.WriteString(" ") + } + + for day := 1; day <= lastOfMonth.Day(); day++ { + current := time.Date(selected.Year(), selected.Month(), day, 0, 0, 0, 0, selected.Location()) + cell := fmt.Sprintf("%2d", day) + if sameDay(current, selected) { + cell = "[" + cell + "]" + } else { + cell = " " + cell + " " + } + builder.WriteString(cell) + + column := (weekdayOffset + day) % 7 + if column == 0 { + builder.WriteByte('\n') + } else { + builder.WriteByte(' ') + } + } + + output := strings.TrimRight(builder.String(), " \n") + return output +} + +func dayAtMidnight(value time.Time) time.Time { + year, month, day := value.Date() + return time.Date(year, month, day, 0, 0, 0, 0, value.Location()) +} + +func sameDay(a, b time.Time) bool { + a = dayAtMidnight(a) + b = dayAtMidnight(b) + return a.Equal(b) +} + +func (m *EntriesModel) appendEntry(entry worktime.Entry) { + m.allEntries = append(m.allEntries, entry) + slices.SortFunc(m.allEntries, func(a, b worktime.Entry) int { + if a.Epoch == b.Epoch { + return strings.Compare(a.Action, b.Action) + } + if a.Epoch > b.Epoch { + return -1 + } + return 1 + }) + m.applyFilters() + m.mutationCount++ + m.markHostChanged(entry.Source) + + if idx := findEntryIndex(m.visible, entry); idx >= 0 { + m.cursor = idx + m.ensureCursorVisible() + } +} + func (m *EntriesModel) replaceEntry(oldEntry, newEntry worktime.Entry) { idx := findEntryIndex(m.allEntries, oldEntry) if idx < 0 { @@ -502,6 +800,9 @@ func (m *EntriesModel) replaceEntry(oldEntry, newEntry worktime.Entry) { m.allEntries[idx] = newEntry m.applyFilters() + m.mutationCount++ + m.markHostChanged(oldEntry.Source) + m.markHostChanged(newEntry.Source) if newIdx := findEntryIndex(m.visible, newEntry); newIdx >= 0 { m.cursor = newIdx @@ -519,14 +820,14 @@ func (m *EntriesModel) persistReplacement(oldEntry, newEntry worktime.Entry) err return errors.New("selected entry has no source host") } - index, err := findHostEntryIndex(m.dbDir, host, oldEntry) - if err != nil { - return err + m.markHostChanged(host) + + newHost := strings.TrimSpace(newEntry.Source) + if newHost != "" && newHost != host { + m.markHostChanged(newHost) } - newEntry.Source = host - _, err = worktime.EditEntry(m.dbDir, host, index, newEntry) - return err + return nil } func (m *EntriesModel) persistDelete(target worktime.Entry) error { @@ -539,13 +840,64 @@ func (m *EntriesModel) persistDelete(target worktime.Entry) error { return errors.New("selected entry has no source host") } - index, err := findHostEntryIndex(m.dbDir, host, target) - if err != nil { - return err + m.markHostChanged(host) + return nil +} + +func (m *EntriesModel) markHostChanged(host string) { + normalized := strings.TrimSpace(host) + if normalized == "" { + return } - _, err = worktime.DeleteEntry(m.dbDir, host, index) - return err + if m.changedHosts == nil { + m.changedHosts = map[string]struct{}{} + } + m.changedHosts[normalized] = struct{}{} + m.dirty = true +} + +func (m *EntriesModel) savePendingChanges() error { + if m.dbDir == "" { + return errors.New("persistence is not configured") + } + if !m.dirty || len(m.changedHosts) == 0 { + m.setStatusInfo("No unsaved changes.") + return nil + } + + hosts := make([]string, 0, len(m.changedHosts)) + for host := range m.changedHosts { + hosts = append(hosts, host) + } + sort.Strings(hosts) + + for _, host := range hosts { + hostEntries := make([]worktime.Entry, 0) + for _, entry := range m.allEntries { + if strings.TrimSpace(entry.Source) == host { + hostEntries = append(hostEntries, entry) + } + } + + db := worktime.Database{ + Entries: map[string][]worktime.Entry{ + host: hostEntries, + }, + } + if err := worktime.SaveHost(m.dbDir, host, db); err != nil { + return err + } + } + + m.changedHosts = map[string]struct{}{} + m.dirty = false + m.setStatusInfo(fmt.Sprintf("Saved %d host database(s).", len(hosts))) + return nil +} + +func (m EntriesModel) hasUnsavedChanges() bool { + return m.dirty && len(m.changedHosts) > 0 } func (m *EntriesModel) setStatusInfo(message string) { @@ -564,7 +916,7 @@ func (m *EntriesModel) renderStatus(styles Styles) string { } if m.statusError { - return "\n\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#FF8B8B")).Render(m.statusMessage) + return "\n\n" + styles.Error.Render(m.statusMessage) } return "\n\n" + styles.Hint.Render(m.statusMessage) @@ -594,7 +946,7 @@ func (m *EntriesModel) ensureCursorVisible() { } func (m *EntriesModel) listRows() int { - rows := m.height - 4 + rows := m.height - 6 if rows < 1 { return 1 } @@ -626,18 +978,6 @@ func formatEntryValue(entry worktime.Entry) string { return fmt.Sprintf("%+.2fh", hours) } -func colorizeCategory(category string) string { - if strings.TrimSpace(category) == "" { - category = "work" - } - - color, ok := categoryColors[category] - if !ok { - color = "#D9D9D9" - } - return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(category) -} - func minInt(a, b int) int { if a < b { return a @@ -654,22 +994,6 @@ func findEntryIndex(entries []worktime.Entry, target worktime.Entry) int { return -1 } -func findHostEntryIndex(dbDir, host string, target worktime.Entry) (int, error) { - db, err := worktime.LoadHost(dbDir, host) - if err != nil { - return -1, err - } - - entries := db.Entries[host] - for idx, entry := range entries { - if sameEntryIdentity(entry, target) { - return idx, nil - } - } - - return -1, fmt.Errorf("entry not found in host db %q", host) -} - func insertEntryAt(entries []worktime.Entry, idx int, entry worktime.Entry) []worktime.Entry { if idx < 0 { idx = 0 @@ -705,6 +1029,138 @@ func entryMatchesSearch(entry worktime.Entry, search string) bool { strings.Contains(strings.ToLower(entry.Descr), search) } +func (m *EntriesModel) renderTimelineTable(styles Styles) string { + widths := m.timelineColumnWidths() + headers := []string{"Date", "Time", "Action", "Category", "Value", "Source", "Description"} + + headerStyles := make([]lipgloss.Style, len(headers)) + for idx := range headers { + headerStyles[idx] = styles.TableHeader + if entriesColumn(idx) == m.selectedColumn { + headerStyles[idx] = styles.TableSelected + } + } + + lines := []string{ + renderTimelineRow(headers, widths, headerStyles), + } + + maxRows := m.listRows() + end := minInt(len(m.visible), m.offset+maxRows) + for idx := m.offset; idx < end; idx++ { + entry := m.visible[idx] + moment := time.Unix(entry.Epoch, 0) + row := []string{ + moment.Format("2006-01-02"), + moment.Format("15:04"), + entry.Action, + entry.What, + formatEntryValue(entry), + entry.Source, + entry.Descr, + } + + cellStyles := make([]lipgloss.Style, len(row)) + for colIdx := range row { + cellStyles[colIdx] = styles.TableCell + if idx == m.cursor { + cellStyles[colIdx] = styles.TableCell.Copy().Bold(true) + if entriesColumn(colIdx) == m.selectedColumn { + cellStyles[colIdx] = styles.TableSelected + } + } + } + lines = append(lines, renderTimelineRow(row, widths, cellStyles)) + } + + return strings.Join(lines, "\n") +} + +func (m *EntriesModel) timelineColumnWidths() []int { + total := m.width + if total <= 0 { + total = 120 + } + + dateWidth := 10 + timeWidth := 5 + actionWidth := 8 + categoryWidth := 12 + valueWidth := 9 + sourceWidth := 12 + + fixed := dateWidth + timeWidth + actionWidth + categoryWidth + valueWidth + sourceWidth + 6 + descriptionWidth := total - fixed + if descriptionWidth < 16 { + descriptionWidth = 16 + } + + return []int{ + dateWidth, + timeWidth, + actionWidth, + categoryWidth, + valueWidth, + sourceWidth, + descriptionWidth, + } +} + +func renderTimelineRow(values []string, widths []int, styles []lipgloss.Style) string { + cells := make([]string, 0, len(values)) + for idx, raw := range values { + width := widths[idx] + cell := lipgloss.NewStyle().Width(width).MaxWidth(width).Render(trimToWidth(raw, width)) + cells = append(cells, styles[idx].Render(cell)) + } + return strings.Join(cells, " ") +} + +func trimToWidth(value string, width int) string { + if width <= 0 { + return "" + } + + runes := []rune(value) + if len(runes) <= width { + return value + } + if width <= 1 { + return string(runes[:width]) + } + return string(runes[:width-1]) + "…" +} + +func parseEditedDate(input string, current time.Time) (time.Time, error) { + parsed, err := timefmt.Parse(strings.TrimSpace(input)) + if err != nil { + return time.Time{}, err + } + + year, month, day := parsed.Date() + hour, minute, second := current.Clock() + return time.Date(year, month, day, hour, minute, second, 0, current.Location()), nil +} + +func parseEditedTime(input string, current time.Time) (time.Time, error) { + candidate := strings.TrimSpace(input) + if candidate == "" { + return time.Time{}, errors.New("time value must not be empty") + } + + layouts := []string{"15:04", "15:04:05"} + for _, layout := range layouts { + parsed, err := time.ParseInLocation(layout, candidate, current.Location()) + if err == nil { + year, month, day := current.Date() + hour, minute, second := parsed.Clock() + return time.Date(year, month, day, hour, minute, second, 0, current.Location()), nil + } + } + + return time.Time{}, fmt.Errorf("unsupported time format %q", input) +} + func sameEntryIdentity(a, b worktime.Entry) bool { return a.Epoch == b.Epoch && a.Action == b.Action && a.Source == b.Source } diff --git a/internal/tui/entries_test.go b/internal/tui/entries_test.go index da5a5c4..f8f9378 100644 --- a/internal/tui/entries_test.go +++ b/internal/tui/entries_test.go @@ -2,10 +2,11 @@ package tui import ( "fmt" + "strings" "testing" "time" - "codeberg.org/snonux/timr/internal/worktime" + "codeberg.org/snonux/timesamurai/internal/worktime" tea "github.com/charmbracelet/bubbletea" ) @@ -25,6 +26,85 @@ func TestEntriesModelSortsChronologically(t *testing.T) { } } +func TestEntriesViewRendersTimelineTableHeaders(t *testing.T) { + model := NewEntriesModel(sampleEntries(2)) + model.SetSize(140, 12) + + view := model.View(DefaultStyles()) + if !strings.Contains(view, "Date") || !strings.Contains(view, "Time") || !strings.Contains(view, "Description") { + t.Fatalf("timeline table headers missing from view: %q", view) + } +} + +func TestEntriesColumnNavigationKeys(t *testing.T) { + model := NewEntriesModel(sampleEntries(1)) + model.SetSize(120, 12) + + if model.selectedColumn != entriesColumnDescription { + t.Fatalf("selectedColumn = %d, want %d", model.selectedColumn, entriesColumnDescription) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + if model.selectedColumn != entriesColumnSource { + t.Fatalf("selectedColumn after left = %d, want %d", model.selectedColumn, entriesColumnSource) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}) + if model.selectedColumn != entriesColumnValue { + t.Fatalf("selectedColumn after h = %d, want %d", model.selectedColumn, entriesColumnValue) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) + if model.selectedColumn != entriesColumnSource { + t.Fatalf("selectedColumn after l = %d, want %d", model.selectedColumn, entriesColumnSource) + } +} + +func TestEntriesEnterEditsSelectedColumnValue(t *testing.T) { + model := NewEntriesModel(sampleEntries(1)) + model.SetSize(120, 12) + model.selectedColumn = entriesColumnValue + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if !model.editMode { + t.Fatal("editMode = false, want true after Enter on value column") + } + if model.editField != entryEditFieldValue { + t.Fatalf("editField = %d, want %d", model.editField, entryEditFieldValue) + } +} + +func TestEntriesEnterEditsSelectedColumnDateAndTime(t *testing.T) { + model := NewEntriesModel(sampleEntries(1)) + model.SetSize(120, 12) + + original := model.visible[0].Epoch + + model.selectedColumn = entriesColumnDate + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if !model.editMode || model.editField != entryEditFieldDate { + t.Fatalf("date edit not entered: editMode=%t editField=%d", model.editMode, model.editField) + } + model.input = "2026-02-02" + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if model.visible[0].Epoch == original { + t.Fatal("epoch did not change after date edit") + } + + model.selectedColumn = entriesColumnTime + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if !model.editMode || model.editField != entryEditFieldTime { + t.Fatalf("time edit not entered: editMode=%t editField=%d", model.editMode, model.editField) + } + model.input = "13:45" + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + updatedTime := time.Unix(model.visible[0].Epoch, 0) + if updatedTime.Hour() != 13 || updatedTime.Minute() != 45 { + t.Fatalf("time after edit = %s, want 13:45", updatedTime.Format("15:04")) + } +} + func TestEntriesNavigationKeys(t *testing.T) { model := NewEntriesModel(sampleEntries(20)) model.SetSize(120, 12) @@ -228,7 +308,7 @@ func TestEntriesDeletePersistsToDB(t *testing.T) { } model := NewEntriesModel(entries) - model.SetPersistence(dbDir) + model.SetPersistence(dbDir, host) model.SetSize(120, 12) model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) @@ -238,13 +318,124 @@ func TestEntriesDeletePersistsToDB(t *testing.T) { if len(model.visible) != 2 { t.Fatalf("entries len = %d, want 2 after persisted delete", len(model.visible)) } + if !model.hasUnsavedChanges() { + t.Fatal("hasUnsavedChanges = false, want true after delete before save") + } reloaded, err := worktime.LoadHost(dbDir, host) if err != nil { t.Fatalf("LoadHost() error = %v", err) } + if len(reloaded.Entries[host]) != 3 { + t.Fatalf("host entries len before save = %d, want 3", len(reloaded.Entries[host])) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) + if model.hasUnsavedChanges() { + t.Fatal("hasUnsavedChanges = true, want false after save") + } + + reloaded, err = worktime.LoadHost(dbDir, host) + if err != nil { + t.Fatalf("LoadHost() error = %v", err) + } if len(reloaded.Entries[host]) != 2 { - t.Fatalf("host entries len = %d, want 2", len(reloaded.Entries[host])) + t.Fatalf("host entries len after save = %d, want 2", len(reloaded.Entries[host])) + } +} + +func TestEntriesDayOffPromptPersistsToDB(t *testing.T) { + dbDir := t.TempDir() + host := "host-a" + + db := worktime.Database{ + Entries: map[string][]worktime.Entry{ + host: {}, + }, + } + if err := worktime.SaveHost(dbDir, host, db); err != nil { + t.Fatalf("SaveHost() error = %v", err) + } + + model := NewEntriesModel(nil) + model.SetPersistence(dbDir, host) + model.SetSize(120, 12) + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}}) + if !model.dayOffMode { + t.Fatal("dayOffMode = false, want true after D") + } + + model.dayOffDate = time.Date(2026, 1, 22, 12, 34, 0, 0, time.Local) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if model.dayOffMode { + t.Fatal("dayOffMode = true, want false after Enter") + } + if !model.hasUnsavedChanges() { + t.Fatal("hasUnsavedChanges = false, want true after day off before save") + } + + db, err := worktime.LoadHost(dbDir, host) + if err != nil { + t.Fatalf("LoadHost() error = %v", err) + } + + entries := db.Entries[host] + if len(entries) != 0 { + t.Fatalf("entries len before save = %d, want 0", len(entries)) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) + if model.hasUnsavedChanges() { + t.Fatal("hasUnsavedChanges = true, want false after save") + } + + db, err = worktime.LoadHost(dbDir, host) + if err != nil { + t.Fatalf("LoadHost() error = %v", err) + } + + entries = db.Entries[host] + if len(entries) != 1 { + t.Fatalf("entries len after save = %d, want 1", len(entries)) + } + + entry := entries[0] + wantEpoch := time.Date(2026, 1, 22, 0, 0, 0, 0, time.Local).Unix() + if entry.What != "off" || entry.Action != "add" { + t.Fatalf("unexpected day-off entry: %+v", entry) + } + if entry.Value != 8*3600 { + t.Fatalf("entry.Value = %d, want 28800", entry.Value) + } + if entry.Epoch != wantEpoch { + t.Fatalf("entry.Epoch = %d, want %d", entry.Epoch, wantEpoch) + } +} + +func TestEntriesDayOffDatepickerNavigation(t *testing.T) { + model := NewEntriesModel(sampleEntries(1)) + model.SetSize(120, 12) + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}}) + if !model.dayOffMode { + t.Fatal("dayOffMode = false, want true after D") + } + + model.dayOffDate = time.Date(2026, 2, 12, 0, 0, 0, 0, time.Local) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + if !sameDay(model.dayOffDate, time.Date(2026, 2, 13, 0, 0, 0, 0, time.Local)) { + t.Fatalf("dayOffDate after right = %v, want 2026-02-13", model.dayOffDate) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown}) + if !sameDay(model.dayOffDate, time.Date(2026, 2, 20, 0, 0, 0, 0, time.Local)) { + t.Fatalf("dayOffDate after down = %v, want 2026-02-20", model.dayOffDate) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyPgUp}) + if !sameDay(model.dayOffDate, time.Date(2026, 1, 20, 0, 0, 0, 0, time.Local)) { + t.Fatalf("dayOffDate after pgup = %v, want 2026-01-20", model.dayOffDate) } } diff --git a/internal/tui/report.go b/internal/tui/report.go index 205e3e9..3db4ab0 100644 --- a/internal/tui/report.go +++ b/internal/tui/report.go @@ -4,13 +4,14 @@ import ( "fmt" "strings" - "codeberg.org/snonux/timr/internal/worktime" + "codeberg.org/snonux/timesamurai/internal/worktime" tea "github.com/charmbracelet/bubbletea" ) // ReportModel is a weekly report browser screen. type ReportModel struct { weeks []worktime.WeekReport + warn string weekIndex int cursor int @@ -58,6 +59,11 @@ func (m *ReportModel) SetWeeks(weeks []worktime.WeekReport) { m.offset = 0 } +// SetWarning sets a status warning shown at the bottom of the report view. +func (m *ReportModel) SetWarning(warning string) { + m.warn = strings.TrimSpace(warning) +} + // Update handles keyboard interaction. func (m *ReportModel) Update(msg tea.Msg) (ReportModel, tea.Cmd) { keyMsg, ok := msg.(tea.KeyMsg) @@ -150,6 +156,9 @@ func (m *ReportModel) View(styles Styles) string { hint := "j/k scroll, gg/G top/bottom, ]w/[w week nav, v verbose" body := header + "\n\n" + strings.Join(lines, "\n") + "\n\n" + summary + "\n" + hint + if m.warn != "" { + body += "\n" + styles.Warning.Render("Warning: "+m.warn) + } return styles.Body.Render(body) } diff --git a/internal/tui/report_test.go b/internal/tui/report_test.go index cf9a7f9..2bde9ff 100644 --- a/internal/tui/report_test.go +++ b/internal/tui/report_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "codeberg.org/snonux/timr/internal/worktime" + "codeberg.org/snonux/timesamurai/internal/worktime" tea "github.com/charmbracelet/bubbletea" ) @@ -82,6 +82,17 @@ func TestReportSummaryBarInView(t *testing.T) { } } +func TestReportWarningInView(t *testing.T) { + model := NewReportModel(sampleWeeks()) + model.SetWarning("currently logged in: work") + model.SetSize(120, 12) + + view := model.View(DefaultStyles()) + if !strings.Contains(view, "Warning: currently logged in: work") { + t.Fatalf("view missing warning: %q", view) + } +} + func sampleWeeks() []worktime.WeekReport { return []worktime.WeekReport{ { diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 247411c..c131c1f 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -4,38 +4,79 @@ import "github.com/charmbracelet/lipgloss" // Styles groups visual styles for the root TUI scaffold. type Styles struct { - App lipgloss.Style - Header lipgloss.Style - Tab lipgloss.Style - ActiveTab lipgloss.Style - Body lipgloss.Style - Help lipgloss.Style - Hint lipgloss.Style + App lipgloss.Style + Header lipgloss.Style + Tab lipgloss.Style + ActiveTab lipgloss.Style + Body lipgloss.Style + Help lipgloss.Style + Hint lipgloss.Style + Status lipgloss.Style + TableHeader lipgloss.Style + TableCell lipgloss.Style + TableSelected lipgloss.Style + SearchMatch lipgloss.Style + Error lipgloss.Style + Warning lipgloss.Style } // DefaultStyles returns the default style set. func DefaultStyles() Styles { + return StylesFromTheme(DefaultTheme()) +} + +// StylesFromTheme builds styles from a theme palette. +func StylesFromTheme(theme Theme) Styles { return Styles{ App: lipgloss.NewStyle(). - Padding(1, 2), + Padding(0, 1), Header: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#8A8A8A")). + Foreground(lipgloss.Color(theme.HeaderFG)). + Background(lipgloss.Color(theme.StatusBG)). + Padding(0, 1). Bold(true), Tab: lipgloss.NewStyle(). Padding(0, 1). - Foreground(lipgloss.Color("#B5B5B5")), + Foreground(lipgloss.Color(theme.StatusFG)), ActiveTab: lipgloss.NewStyle(). Padding(0, 1). - Foreground(lipgloss.Color("#101010")). - Background(lipgloss.Color("#8BD3DD")). + Foreground(lipgloss.Color(theme.SelectedFG)). + Background(lipgloss.Color(theme.SelectedBG)). Bold(true), Body: lipgloss.NewStyle(). PaddingTop(1), Help: lipgloss.NewStyle(). PaddingTop(1). - Foreground(lipgloss.Color("#A0D568")), + Foreground(lipgloss.Color(theme.HeaderFG)), Hint: lipgloss.NewStyle(). PaddingTop(1). - Foreground(lipgloss.Color("#777777")), + Foreground(lipgloss.Color("245")), + Status: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.StatusFG)). + Background(lipgloss.Color(theme.StatusBG)). + Padding(0, 1), + TableHeader: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(theme.HeaderFG)). + Background(lipgloss.Color(theme.SelectedBG)). + Padding(0, 1), + TableCell: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.RowFG)). + Background(lipgloss.Color(theme.RowBG)). + Padding(0, 1), + TableSelected: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(theme.SelectedFG)). + Background(lipgloss.Color(theme.SelectedBG)). + Padding(0, 1), + SearchMatch: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.SearchFG)). + Background(lipgloss.Color(theme.SearchBG)), + Error: lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Bold(true), + Warning: lipgloss.NewStyle(). + Foreground(lipgloss.Color("214")). + Bold(true), } } diff --git a/internal/tui/theme.go b/internal/tui/theme.go new file mode 100644 index 0000000..862f193 --- /dev/null +++ b/internal/tui/theme.go @@ -0,0 +1,103 @@ +package tui + +import ( + "math/rand/v2" + "strconv" +) + +// Theme defines the shared color palette used across the TUI. +type Theme struct { + HeaderFG string + SelectedFG string + SelectedBG string + RowFG string + RowBG string + StatusFG string + StatusBG string + SearchFG string + SearchBG string +} + +// DefaultTheme returns the baseline theme inspired by Task Samurai. +func DefaultTheme() Theme { + return Theme{ + HeaderFG: "205", + SelectedFG: "229", + SelectedBG: "57", + RowFG: "15", + RowBG: "236", + StatusFG: "229", + StatusBG: "57", + SearchFG: "21", + SearchBG: "226", + } +} + +// RandomTheme returns a randomized high-contrast palette for disco mode. +func RandomTheme() Theme { + theme := Theme{ + HeaderFG: randomColor(), + SelectedBG: randomColor(), + RowBG: randomColor(), + StatusBG: randomColor(), + SearchBG: randomColor(), + } + + theme.SelectedFG = contrastColor(theme.SelectedBG) + theme.RowFG = contrastColor(theme.RowBG) + theme.StatusFG = contrastColor(theme.StatusBG) + theme.SearchFG = contrastColor(theme.SearchBG) + return theme +} + +func randomColor() string { + return strconv.Itoa(rand.IntN(256)) +} + +func contrastColor(background string) string { + value, err := strconv.Atoi(background) + if err != nil { + return "15" + } + + if xtermBrightness(value) > 128 { + return "0" + } + return "15" +} + +func xtermBrightness(index int) float64 { + red, green, blue := xtermRGB(index) + return 0.299*float64(red) + 0.587*float64(green) + 0.114*float64(blue) +} + +func xtermRGB(index int) (int, int, int) { + if index < 0 { + index = 0 + } + if index > 255 { + index = 255 + } + + if index < 16 { + table := [16][3]int{ + {0, 0, 0}, {205, 0, 0}, {0, 205, 0}, {205, 205, 0}, + {0, 0, 238}, {205, 0, 205}, {0, 205, 205}, {229, 229, 229}, + {127, 127, 127}, {255, 0, 0}, {0, 255, 0}, {255, 255, 0}, + {92, 92, 255}, {255, 0, 255}, {0, 255, 255}, {255, 255, 255}, + } + rgb := table[index] + return rgb[0], rgb[1], rgb[2] + } + + if index <= 231 { + index -= 16 + red := (index / 36) * 51 + green := (index % 36 / 6) * 51 + blue := (index % 6) * 51 + return red, green, blue + } + + gray := (index-232)*10 + 8 + return gray, gray, gray +} diff --git a/internal/tui/timer.go b/internal/tui/timer.go index 4a1af3c..980390a 100644 --- a/internal/tui/timer.go +++ b/internal/tui/timer.go @@ -5,10 +5,10 @@ import ( "strings" "time" - "codeberg.org/snonux/timr/internal/ascii" - "codeberg.org/snonux/timr/internal/config" - timrTimer "codeberg.org/snonux/timr/internal/timer" - "codeberg.org/snonux/timr/internal/worktime" + "codeberg.org/snonux/timesamurai/internal/ascii" + "codeberg.org/snonux/timesamurai/internal/config" + timesamuraiTimer "codeberg.org/snonux/timesamurai/internal/timer" + "codeberg.org/snonux/timesamurai/internal/worktime" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/common-nighthawk/go-figure" @@ -24,7 +24,7 @@ func timerTick() tea.Cmd { // TimerModel is the timer screen model used inside the TUI. type TimerModel struct { - state timrTimer.State + state timesamuraiTimer.State quitting bool helpStyle lipgloss.Style timerStyle lipgloss.Style @@ -47,7 +47,7 @@ type workIntegration struct { // NewTimerModel builds the timer screen model. func NewTimerModel(font string, cfg config.Config) (TimerModel, error) { - state, err := timrTimer.LoadState() + state, err := timesamuraiTimer.LoadState() if err != nil { return TimerModel{}, err } @@ -146,8 +146,8 @@ func (m TimerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case "r": - m.state = timrTimer.State{} - if _, err := timrTimer.ResetTimer(); err != nil { + m.state = timesamuraiTimer.State{} + if _, err := timesamuraiTimer.ResetTimer(); err != nil { m.work.status = "reset error: " + err.Error() } return m, nil diff --git a/internal/tui/timer_test.go b/internal/tui/timer_test.go index 3eb39b2..8bc9432 100644 --- a/internal/tui/timer_test.go +++ b/internal/tui/timer_test.go @@ -3,8 +3,8 @@ package tui import ( "testing" - "codeberg.org/snonux/timr/internal/config" - "codeberg.org/snonux/timr/internal/worktime" + "codeberg.org/snonux/timesamurai/internal/config" + "codeberg.org/snonux/timesamurai/internal/worktime" tea "github.com/charmbracelet/bubbletea" ) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index eaa1dd4..926c331 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,11 +1,13 @@ package tui import ( + "fmt" "strings" "time" - "codeberg.org/snonux/timr/internal/config" - "codeberg.org/snonux/timr/internal/worktime" + timesamurai "codeberg.org/snonux/timesamurai/internal" + "codeberg.org/snonux/timesamurai/internal/config" + "codeberg.org/snonux/timesamurai/internal/worktime" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -35,11 +37,14 @@ type Model struct { width int height int - showHelp bool - pendingG bool - pendingZ bool + showHelp bool + confirmQuit bool + pendingG bool + pendingZ bool styles Styles + theme Theme + disco bool entries EntriesModel report ReportModel @@ -59,9 +64,17 @@ func NewModel() *Model { // NewModelWithConfig creates a data-backed root model from config. func NewModelWithConfig(cfg config.Config) (*Model, error) { + return NewModelWithConfigAndDisco(cfg, false) +} + +// NewModelWithConfigAndDisco creates a data-backed root model and optionally enables disco mode. +func NewModelWithConfigAndDisco(cfg config.Config, disco bool) (*Model, error) { + theme := DefaultTheme() model := &Model{ activeTab: tabEntries, - styles: DefaultStyles(), + styles: StylesFromTheme(theme), + theme: theme, + disco: disco, entries: NewEntriesModel(nil), report: NewReportModel(nil), } @@ -70,14 +83,21 @@ func NewModelWithConfig(cfg config.Config) (*Model, error) { if err != nil { model.entriesErr = err.Error() } else { + host, hostErr := cfg.EffectiveHostname() + if hostErr != nil { + host = strings.TrimSpace(cfg.Hostname) + } model.entries.SetEntries(entries) - model.entries.SetPersistence(cfg.WorktimeDBDir) + model.entries.SetPersistence(cfg.WorktimeDBDir, host) weeks, reportErr := worktime.BuildReport(entries, cfg) if reportErr != nil { model.reportErr = reportErr.Error() } else { model.report.SetWeeks(weeks) } + if warning := reportOpenSessionWarning(entries); warning != "" { + model.report.SetWarning(warning) + } } timerModel, timerErr := NewTimerModel("doom", cfg) @@ -87,6 +107,10 @@ func NewModelWithConfig(cfg config.Config) (*Model, error) { model.timer = timerModel } + if model.disco { + model.randomizeTheme() + } + return model, nil } @@ -114,10 +138,31 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: key := msg.String() + if m.confirmQuit { + switch key { + case "s": + if err := m.entries.savePendingChanges(); err != nil { + m.entries.setStatusError("Save failed: " + err.Error()) + m.confirmQuit = false + return m, nil + } + m.confirmQuit = false + return m, tea.Quit + case "d", "n": + m.confirmQuit = false + return m, tea.Quit + case "esc": + m.confirmQuit = false + return m, nil + default: + return m, nil + } + } + if m.pendingZ { m.pendingZ = false if key == "Q" { - return m, tea.Quit + return m.requestQuit() } } @@ -140,9 +185,26 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.switchTab(tabReport) case "3": return m, m.switchTab(tabTimer) - case "?": + case "?", "H": m.showHelp = !m.showHelp return m, nil + case "esc": + if m.showHelp { + m.showHelp = false + return m, nil + } + case "c": + m.randomizeTheme() + return m, nil + case "C": + m.resetTheme() + return m, nil + case "x": + m.disco = !m.disco + if m.disco { + m.randomizeTheme() + } + return m, nil case "g": m.pendingG = true return m, nil @@ -150,7 +212,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pendingZ = true return m, nil case "q", "ctrl+c": - return m, tea.Quit + return m.requestQuit() } } @@ -161,13 +223,39 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) View() string { header := m.renderTabs() body := m.renderBody() + status := m.renderStatusLine() + + if m.confirmQuit { + body = m.styles.Help.Render(strings.Join([]string{ + "Unsaved entry changes detected.", + "", + "Save before quitting?", + "", + "s : save and quit", + "d : discard changes and quit", + "Esc : cancel", + }, "\n")) + } - help := m.styles.Hint.Render("Press ? for help") - if m.showHelp { - help = m.styles.Help.Render("Tab/gt/gT/1/2/3 switch tabs, ? toggles help, q or ZQ quits") + if !m.confirmQuit && m.showHelp { + body = m.styles.Help.Render(strings.Join([]string{ + "Global keys", + "", + "Tab / gt / gT / 1 / 2 / 3 : switch tabs", + "? / H : toggle help", + "c / C : random/reset theme", + "x : toggle disco mode", + "q / ZQ : quit", + "", + "Entries", + "", + "j/k rows, h/l columns, Enter edit selected cell", + "/ search, f category filter, e/v quick edit, s save, dd delete entry", + "D day-off datepicker (8h off entry)", + }, "\n")) } - content := lipgloss.JoinVertical(lipgloss.Left, header, body, help) + content := lipgloss.JoinVertical(lipgloss.Left, header, body, status) rendered := m.styles.App.Render(content) if m.width > 0 && m.height > 0 { @@ -190,6 +278,7 @@ func (m *Model) prevTab() tea.Cmd { func (m *Model) renderTabs() string { parts := make([]string, 0, len(tabLabels)) + parts = append(parts, lipgloss.NewStyle().Bold(true).Render("timesamurai "+timesamurai.Version)) for idx, label := range tabLabels { if tab(idx) == m.activeTab { parts = append(parts, m.styles.ActiveTab.Render(label)) @@ -197,7 +286,14 @@ func (m *Model) renderTabs() string { } parts = append(parts, m.styles.Tab.Render(label)) } - return m.styles.Header.Render(strings.Join(parts, " ")) + if m.disco { + parts = append(parts, m.styles.ActiveTab.Render("DISCO")) + } + if m.entries.hasUnsavedChanges() { + parts = append(parts, m.styles.ActiveTab.Render("UNSAVED")) + } + line := strings.Join(parts, " ") + return m.styles.Header.Width(m.statusWidth()).Render(line) } func (m *Model) renderBody() string { @@ -261,8 +357,12 @@ func (m *Model) bodySize() (width int, height int) { func (m *Model) updateActiveTab(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.activeTab { case tabEntries: + beforeMutations := m.entries.mutationCount updated, cmd := m.entries.Update(msg) m.entries = updated + if m.disco && m.entries.mutationCount != beforeMutations { + m.randomizeTheme() + } return m, cmd case tabReport: updated, cmd := m.report.Update(msg) @@ -290,3 +390,64 @@ func (m *Model) startRootTimerTicker() tea.Cmd { m.timerTickScheduled = true return rootTimerTick() } + +func (m *Model) randomizeTheme() { + m.theme = RandomTheme() + m.styles = StylesFromTheme(m.theme) +} + +func (m *Model) resetTheme() { + m.theme = DefaultTheme() + m.styles = StylesFromTheme(m.theme) +} + +func (m *Model) renderStatusLine() string { + status := fmt.Sprintf( + "Entries timeline table | unsaved:%t | disco:%t | H help | c/C theme | x disco | q quit", + m.entries.hasUnsavedChanges(), + m.disco, + ) + if m.showHelp { + status = "Help mode active (press H or ? to close)" + } + if m.confirmQuit { + status = "Unsaved changes: s save+quit, d discard+quit, Esc cancel" + } + + return m.styles.Status.Width(m.statusWidth()).Render(status) +} + +func (m *Model) statusWidth() int { + if m.width <= 0 { + return 80 + } + if m.width <= 2 { + return m.width + } + return m.width - 2 +} + +func (m *Model) requestQuit() (tea.Model, tea.Cmd) { + if m.entries.hasUnsavedChanges() { + m.confirmQuit = true + return m, nil + } + return m, tea.Quit +} + +func reportOpenSessionWarning(entries []worktime.Entry) string { + openSessions := worktime.OpenSessions(entries) + if len(openSessions) == 0 { + return "" + } + + items := make([]string, 0, len(openSessions)) + for _, session := range openSessions { + items = append(items, fmt.Sprintf( + "%s (since %s)", + session.Category, + time.Unix(session.Login.Epoch, 0).Format("2006-01-02 15:04"), + )) + } + return "currently logged in: " + strings.Join(items, ", ") +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 26fe216..eb64f14 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -3,8 +3,10 @@ package tui import ( "strings" "testing" + "time" - "codeberg.org/snonux/timr/internal/config" + "codeberg.org/snonux/timesamurai/internal/config" + "codeberg.org/snonux/timesamurai/internal/worktime" tea "github.com/charmbracelet/bubbletea" ) @@ -46,6 +48,18 @@ func TestHelpToggle(t *testing.T) { if model.showHelp { t.Fatal("showHelp = true, want false after second ?") } + + modelAny, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'H'}}) + model = modelAny.(*Model) + if !model.showHelp { + t.Fatal("showHelp = false, want true after H") + } + + modelAny, _ = model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + model = modelAny.(*Model) + if model.showHelp { + t.Fatal("showHelp = true, want false after Esc") + } } func TestQuitKeys(t *testing.T) { @@ -72,6 +86,70 @@ func TestQuitKeys(t *testing.T) { } } +func TestQuitWithUnsavedChangesPromptsConfirmation(t *testing.T) { + model := newRootModelForTest(t) + + modelAny, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}}) + model = modelAny.(*Model) + if !model.entries.hasUnsavedChanges() { + t.Fatal("entries.hasUnsavedChanges() = false, want true after insertion") + } + + modelAny, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + model = modelAny.(*Model) + if cmd != nil { + t.Fatal("quit command should be deferred until quit confirmation") + } + if !model.confirmQuit { + t.Fatal("confirmQuit = false, want true after q with unsaved changes") + } + + modelAny, cmd = model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + model = modelAny.(*Model) + if cmd != nil { + t.Fatal("Esc in quit confirmation should not quit") + } + if model.confirmQuit { + t.Fatal("confirmQuit = true, want false after Esc") + } +} + +func TestQuitConfirmationSaveAndQuitPersistsEntries(t *testing.T) { + model := newRootModelForTest(t) + + modelAny, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}}) + model = modelAny.(*Model) + if !model.entries.hasUnsavedChanges() { + t.Fatal("entries.hasUnsavedChanges() = false, want true after insertion") + } + + modelAny, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + model = modelAny.(*Model) + if !model.confirmQuit { + t.Fatal("confirmQuit = false, want true after q with unsaved changes") + } + + modelAny, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) + model = modelAny.(*Model) + if cmd == nil { + t.Fatal("quit cmd is nil for save-and-quit") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatal("save-and-quit did not return tea.Quit command") + } + if model.entries.hasUnsavedChanges() { + t.Fatal("entries.hasUnsavedChanges() = true, want false after save-and-quit") + } + + db, err := worktime.LoadHost(model.entries.dbDir, model.entries.dbHost) + if err != nil { + t.Fatalf("LoadHost() error = %v", err) + } + if got := len(db.Entries[model.entries.dbHost]); got != 1 { + t.Fatalf("saved entries len = %d, want 1", got) + } +} + func TestViewContainsTabLabels(t *testing.T) { model := newRootModelForTest(t) view := model.View() @@ -88,6 +166,49 @@ func TestEntriesTabUsesEntriesModelView(t *testing.T) { } } +func TestDiscoToggleAndThemeResetKeys(t *testing.T) { + model := newRootModelForTest(t) + + modelAny, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) + model = modelAny.(*Model) + if !model.disco { + t.Fatal("disco = false, want true after x") + } + + modelAny, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'C'}}) + model = modelAny.(*Model) + if model.theme != DefaultTheme() { + t.Fatalf("theme after C = %+v, want default theme", model.theme) + } +} + +func TestModelSetsReportWarningForOpenSession(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", tempDir) + + cfg := config.Default() + cfg.WorktimeDBDir = tempDir + cfg.Hostname = "host-a" + + if _, err := worktime.Login(cfg.WorktimeDBDir, cfg.Hostname, "work", localTime(2026, 3, 4, 10, 0), ""); err != nil { + t.Fatalf("Login() error = %v", err) + } + + model, err := NewModelWithConfig(cfg) + if err != nil { + t.Fatalf("NewModelWithConfig() error = %v", err) + } + + if !strings.Contains(model.report.warn, "currently logged in") { + t.Fatalf("report warning = %q, want currently logged in warning", model.report.warn) + } +} + +func localTime(year int, month time.Month, day, hour, minute int) time.Time { + return time.Date(year, month, day, hour, minute, 0, 0, time.Local) +} + func newRootModelForTest(t *testing.T) *Model { t.Helper() diff --git a/internal/version.go b/internal/version.go index 95fdbad..6b86152 100644 --- a/internal/version.go +++ b/internal/version.go @@ -1,3 +1,3 @@ package internal -const Version = "v0.4.0" +const Version = "v0.5.0" diff --git a/internal/worktime/blackbox_test.go b/internal/worktime/blackbox_test.go index 51ae635..130e192 100644 --- a/internal/worktime/blackbox_test.go +++ b/internal/worktime/blackbox_test.go @@ -4,7 +4,7 @@ import ( "testing" "time" - "codeberg.org/snonux/timr/internal/worktime" + "codeberg.org/snonux/timesamurai/internal/worktime" ) func TestAddAndLoadAllPublicAPI(t *testing.T) { diff --git a/internal/worktime/comprehensive_test.go b/internal/worktime/comprehensive_test.go index 0f68813..b148982 100644 --- a/internal/worktime/comprehensive_test.go +++ b/internal/worktime/comprehensive_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "codeberg.org/snonux/timr/internal/config" + "codeberg.org/snonux/timesamurai/internal/config" ) func TestComprehensiveDBRoundTripAndReportFixture(t *testing.T) { diff --git a/internal/worktime/entries.go b/internal/worktime/entries.go index fd116ba..2a6dc24 100644 --- a/internal/worktime/entries.go +++ b/internal/worktime/entries.go @@ -12,6 +12,7 @@ const ( actionLogin = "login" actionLogout = "logout" actionAdd = "add" + dayOffHours = 8 ) var ( @@ -76,6 +77,11 @@ func Add(dbDir, hostname, category string, duration time.Duration, at time.Time, return appendHostEntry(dbDir, host, entry) } +// AddDayOff creates an 8-hour day-off entry for the provided day. +func AddDayOff(dbDir, hostname string, day time.Time, descr string) (Entry, error) { + return Add(dbDir, hostname, "off", time.Duration(dayOffHours)*time.Hour, startOfDay(day), descr) +} + // Sub creates an add entry with a negative duration value. func Sub(dbDir, hostname, category string, duration time.Duration, at time.Time, descr string) (Entry, error) { host, err := normalizeHostname(hostname) @@ -141,6 +147,15 @@ func EditEntry(dbDir, hostname string, index int, replacement Entry) (Entry, err return normalized, nil } +// NormalizeEditedEntry validates and normalizes an edited entry for a host without persisting it. +func NormalizeEditedEntry(entry Entry, hostname string) (Entry, error) { + host, err := normalizeHostname(hostname) + if err != nil { + return Entry{}, err + } + return normalizeEditedEntry(entry, host) +} + // DeleteEntry removes an entry by index from the host database. func DeleteEntry(dbDir, hostname string, index int) (Entry, error) { host, err := normalizeHostname(hostname) @@ -230,6 +245,12 @@ func durationToSeconds(duration time.Duration) int64 { return int64(duration / time.Second) } +func startOfDay(value time.Time) time.Time { + effective := effectiveTime(value) + year, month, day := effective.Date() + return time.Date(year, month, day, 0, 0, 0, 0, effective.Location()) +} + func effectiveTime(at time.Time) time.Time { if at.IsZero() { return time.Now() diff --git a/internal/worktime/entries_test.go b/internal/worktime/entries_test.go index 3b3296e..0a605f9 100644 --- a/internal/worktime/entries_test.go +++ b/internal/worktime/entries_test.go @@ -115,6 +115,28 @@ func TestAddSubAndUseBuffer(t *testing.T) { } } +func TestAddDayOff(t *testing.T) { + dbDir := t.TempDir() + host := "host-a" + + day := time.Date(2026, time.January, 12, 16, 30, 0, 0, time.Local) + entry, err := AddDayOff(dbDir, host, day, "vacation") + if err != nil { + t.Fatalf("AddDayOff() error = %v", err) + } + + wantDayStart := time.Date(2026, time.January, 12, 0, 0, 0, 0, time.Local) + if entry.What != "off" { + t.Fatalf("entry.What = %q, want off", entry.What) + } + if entry.Value != 8*3600 { + t.Fatalf("entry.Value = %d, want 28800", entry.Value) + } + if entry.Epoch != wantDayStart.Unix() { + t.Fatalf("entry.Epoch = %d, want %d", entry.Epoch, wantDayStart.Unix()) + } +} + func TestDurationValidation(t *testing.T) { dbDir := t.TempDir() host := "host-a" diff --git a/internal/worktime/import.go b/internal/worktime/import.go index d84becd..08d43c7 100644 --- a/internal/worktime/import.go +++ b/internal/worktime/import.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "codeberg.org/snonux/timr/internal/timefmt" + "codeberg.org/snonux/timesamurai/internal/timefmt" ) const importSecondsPerHour = 3600 diff --git a/internal/worktime/integrity.go b/internal/worktime/integrity.go new file mode 100644 index 0000000..2676bf3 --- /dev/null +++ b/internal/worktime/integrity.go @@ -0,0 +1,144 @@ +package worktime + +import ( + "errors" + "fmt" + "slices" + "strings" + "time" +) + +// DefaultMaxSessionSpan is the standard maximum allowed login/logout span. +const DefaultMaxSessionSpan = 15 * time.Hour + +// IntegrityIssue represents a database consistency violation. +type IntegrityIssue struct { + Kind string + Category string + Entry Entry + Message string +} + +// OpenSession represents a category that is currently logged in. +type OpenSession struct { + Category string + Login Entry +} + +// String returns a human-readable description of the issue. +func (i IntegrityIssue) String() string { + when := time.Unix(i.Entry.Epoch, 0).Format("2006-01-02 15:04:05") + return fmt.Sprintf("[%s] category=%s source=%s at=%s: %s", i.Kind, i.Category, i.Entry.Source, when, i.Message) +} + +// CheckIntegrity loads all databases and validates login/logout consistency. +func CheckIntegrity(dbDir string, maxSessionSpan time.Duration) ([]IntegrityIssue, error) { + if maxSessionSpan <= 0 { + return nil, errors.New("max session span must be positive") + } + + entries, err := LoadAll(dbDir) + if err != nil { + return nil, err + } + + return CheckEntriesIntegrity(entries, maxSessionSpan), nil +} + +// CheckEntriesIntegrity validates entry consistency for login/logout flows. +func CheckEntriesIntegrity(entries []Entry, maxSessionSpan time.Duration) []IntegrityIssue { + activeByCategory := map[string]Entry{} + issues := make([]IntegrityIssue, 0) + + for _, entry := range entries { + category := normalizeCategory(entry.What) + action := strings.ToLower(strings.TrimSpace(entry.Action)) + + switch action { + case actionLogin: + if previous, active := activeByCategory[category]; active { + issues = append(issues, IntegrityIssue{ + Kind: "double-login", + Category: category, + Entry: entry, + Message: fmt.Sprintf( + "login while already logged in (previous login at %s from %s)", + time.Unix(previous.Epoch, 0).Format("2006-01-02 15:04:05"), + previous.Source, + ), + }) + continue + } + + activeByCategory[category] = entry + + case actionLogout: + login, active := activeByCategory[category] + if !active { + issues = append(issues, IntegrityIssue{ + Kind: "logout-without-login", + Category: category, + Entry: entry, + Message: "logout without a matching login", + }) + continue + } + + span := time.Duration(entry.Epoch-login.Epoch) * time.Second + if span <= 0 { + issues = append(issues, IntegrityIssue{ + Kind: "non-positive-session", + Category: category, + Entry: entry, + Message: fmt.Sprintf( + "logout timestamp is not after login (login at %s)", + time.Unix(login.Epoch, 0).Format("2006-01-02 15:04:05"), + ), + }) + } + if span > maxSessionSpan { + issues = append(issues, IntegrityIssue{ + Kind: "session-too-long", + Category: category, + Entry: entry, + Message: fmt.Sprintf("session spans %.2fh (max %.2fh)", span.Hours(), maxSessionSpan.Hours()), + }) + } + + delete(activeByCategory, category) + } + } + + return issues +} + +// OpenSessions returns categories with an active login that has no corresponding logout yet. +func OpenSessions(entries []Entry) []OpenSession { + activeByCategory := map[string]Entry{} + + for _, entry := range entries { + category := normalizeCategory(entry.What) + action := strings.ToLower(strings.TrimSpace(entry.Action)) + switch action { + case actionLogin: + activeByCategory[category] = entry + case actionLogout: + delete(activeByCategory, category) + } + } + + categories := make([]string, 0, len(activeByCategory)) + for category := range activeByCategory { + categories = append(categories, category) + } + slices.Sort(categories) + + sessions := make([]OpenSession, 0, len(categories)) + for _, category := range categories { + sessions = append(sessions, OpenSession{ + Category: category, + Login: activeByCategory[category], + }) + } + return sessions +} diff --git a/internal/worktime/integrity_test.go b/internal/worktime/integrity_test.go new file mode 100644 index 0000000..8239d49 --- /dev/null +++ b/internal/worktime/integrity_test.go @@ -0,0 +1,77 @@ +package worktime + +import ( + "testing" + "time" +) + +func TestCheckEntriesIntegrityDetectsIssues(t *testing.T) { + entries := []Entry{ + {Action: "login", What: "work", Epoch: 100, Source: "host-a"}, + {Action: "login", What: "work", Epoch: 110, Source: "host-a"}, // double login + {Action: "logout", What: "work", Epoch: 120, Source: "host-a"}, // matches first login + {Action: "logout", What: "work", Epoch: 130, Source: "host-a"}, // logout without login + {Action: "login", What: "off", Epoch: 200, Source: "host-b"}, // open session warning + {Action: "login", What: "bank", Epoch: 300, Source: "host-c"}, + {Action: "logout", What: "bank", Epoch: 300 + int64((16*time.Hour)/time.Second), Source: "host-c"}, // too long + } + + issues := CheckEntriesIntegrity(entries, DefaultMaxSessionSpan) + if len(issues) != 3 { + t.Fatalf("issues len = %d, want 3", len(issues)) + } + + kinds := map[string]bool{} + for _, issue := range issues { + kinds[issue.Kind] = true + } + for _, kind := range []string{ + "double-login", + "logout-without-login", + "session-too-long", + } { + if !kinds[kind] { + t.Fatalf("missing expected issue kind %q", kind) + } + } +} + +func TestOpenSessionsReturnsActiveLogins(t *testing.T) { + entries := []Entry{ + {Action: "login", What: "work", Epoch: 100, Source: "host-a"}, + {Action: "logout", What: "work", Epoch: 200, Source: "host-a"}, + {Action: "login", What: "off", Epoch: 300, Source: "host-b"}, + {Action: "login", What: "bank", Epoch: 400, Source: "host-c"}, + } + + sessions := OpenSessions(entries) + if len(sessions) != 2 { + t.Fatalf("sessions len = %d, want 2", len(sessions)) + } + + if sessions[0].Category != "bank" || sessions[0].Login.Source != "host-c" { + t.Fatalf("unexpected first session: %+v", sessions[0]) + } + if sessions[1].Category != "off" || sessions[1].Login.Source != "host-b" { + t.Fatalf("unexpected second session: %+v", sessions[1]) + } +} + +func TestCheckEntriesIntegrityPassesValidFlow(t *testing.T) { + entries := []Entry{ + {Action: "login", What: "work", Epoch: 100, Source: "host-a"}, + {Action: "logout", What: "work", Epoch: 200, Source: "host-a"}, + {Action: "add", What: "off", Epoch: 300, Source: "host-a", Value: 8 * 3600}, + } + + issues := CheckEntriesIntegrity(entries, DefaultMaxSessionSpan) + if len(issues) != 0 { + t.Fatalf("issues len = %d, want 0 (%v)", len(issues), issues) + } +} + +func TestCheckIntegrityValidation(t *testing.T) { + if _, err := CheckIntegrity(t.TempDir(), 0); err == nil { + t.Fatal("CheckIntegrity() with non-positive max span should fail") + } +} diff --git a/internal/worktime/report.go b/internal/worktime/report.go index 3ffc983..11890a9 100644 --- a/internal/worktime/report.go +++ b/internal/worktime/report.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "codeberg.org/snonux/timr/internal/config" + "codeberg.org/snonux/timesamurai/internal/config" ) const secondsPerHour = int64(3600) diff --git a/internal/worktime/report_test.go b/internal/worktime/report_test.go index 6bed630..bbfeeb3 100644 --- a/internal/worktime/report_test.go +++ b/internal/worktime/report_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "codeberg.org/snonux/timr/internal/config" + "codeberg.org/snonux/timesamurai/internal/config" ) func TestBuildReportBalanceAndMarkers(t *testing.T) { |
