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/integrity.go | |
| parent | c25c9002f3214e07b041aefa26d5d13c26757839 (diff) | |
Rename project to timesamurai and release v0.5.0v0.5.0
Diffstat (limited to 'internal/worktime/integrity.go')
| -rw-r--r-- | internal/worktime/integrity.go | 144 |
1 files changed, 144 insertions, 0 deletions
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 +} |
