summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-04 10:50:07 +0200
committerPaul Buetow <paul@buetow.org>2026-03-04 10:50:07 +0200
commit97aa8a6f666f5f40859c8a9aa4948bde435cf18f (patch)
tree0cb5928cd6a1220607dbf64e234a2522acac2848 /internal
parentc25c9002f3214e07b041aefa26d5d13c26757839 (diff)
Rename project to timesamurai and release v0.5.0v0.5.0
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, 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) {