summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/root.go59
-rw-r--r--internal/cli/root_test.go122
-rw-r--r--internal/cli/timer.go28
-rw-r--r--internal/cli/timer_test.go2
-rw-r--r--internal/cli/tui.go11
-rw-r--r--internal/cli/tui_test.go5
-rw-r--r--internal/cli/work.go46
-rw-r--r--internal/cli/work_test.go44
-rw-r--r--internal/config/blackbox_test.go2
-rw-r--r--internal/config/config.go2
-rw-r--r--internal/config/doc.go2
-rw-r--r--internal/duration/blackbox_test.go2
-rw-r--r--internal/duration/doc.go2
-rw-r--r--internal/timefmt/doc.go2
-rw-r--r--internal/timer/timer.go4
-rw-r--r--internal/tui/doc.go2
-rw-r--r--internal/tui/entries.go638
-rw-r--r--internal/tui/entries_test.go197
-rw-r--r--internal/tui/report.go11
-rw-r--r--internal/tui/report_test.go13
-rw-r--r--internal/tui/styles.go69
-rw-r--r--internal/tui/theme.go103
-rw-r--r--internal/tui/timer.go16
-rw-r--r--internal/tui/timer_test.go4
-rw-r--r--internal/tui/tui.go191
-rw-r--r--internal/tui/tui_test.go123
-rw-r--r--internal/version.go2
-rw-r--r--internal/worktime/blackbox_test.go2
-rw-r--r--internal/worktime/comprehensive_test.go2
-rw-r--r--internal/worktime/entries.go21
-rw-r--r--internal/worktime/entries_test.go22
-rw-r--r--internal/worktime/import.go2
-rw-r--r--internal/worktime/integrity.go144
-rw-r--r--internal/worktime/integrity_test.go77
-rw-r--r--internal/worktime/report.go2
-rw-r--r--internal/worktime/report_test.go2
36 files changed, 1789 insertions, 187 deletions
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,