summaryrefslogtreecommitdiff
path: root/internal/worktime
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/worktime
parentc25c9002f3214e07b041aefa26d5d13c26757839 (diff)
Rename project to timesamurai and release v0.5.0v0.5.0
Diffstat (limited to 'internal/worktime')
-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
9 files changed, 269 insertions, 5 deletions
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) {