diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-04 10:50:07 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-04 10:50:07 +0200 |
| commit | 97aa8a6f666f5f40859c8a9aa4948bde435cf18f (patch) | |
| tree | 0cb5928cd6a1220607dbf64e234a2522acac2848 /internal/worktime | |
| parent | c25c9002f3214e07b041aefa26d5d13c26757839 (diff) | |
Rename project to timesamurai and release v0.5.0v0.5.0
Diffstat (limited to 'internal/worktime')
| -rw-r--r-- | internal/worktime/blackbox_test.go | 2 | ||||
| -rw-r--r-- | internal/worktime/comprehensive_test.go | 2 | ||||
| -rw-r--r-- | internal/worktime/entries.go | 21 | ||||
| -rw-r--r-- | internal/worktime/entries_test.go | 22 | ||||
| -rw-r--r-- | internal/worktime/import.go | 2 | ||||
| -rw-r--r-- | internal/worktime/integrity.go | 144 | ||||
| -rw-r--r-- | internal/worktime/integrity_test.go | 77 | ||||
| -rw-r--r-- | internal/worktime/report.go | 2 | ||||
| -rw-r--r-- | internal/worktime/report_test.go | 2 |
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) { |
