From c3c347d6faed97d9cc02bf326e2a74786b0bde99 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 3 Mar 2026 22:36:46 +0200 Subject: Task 354: add worktime entry operations --- internal/worktime/entries.go | 265 ++++++++++++++++++++++++++++++++++++++ internal/worktime/entries_test.go | 209 ++++++++++++++++++++++++++++++ 2 files changed, 474 insertions(+) create mode 100644 internal/worktime/entries.go create mode 100644 internal/worktime/entries_test.go (limited to 'internal') diff --git a/internal/worktime/entries.go b/internal/worktime/entries.go new file mode 100644 index 0000000..f9226ce --- /dev/null +++ b/internal/worktime/entries.go @@ -0,0 +1,265 @@ +package worktime + +import ( + "errors" + "fmt" + "strings" + "time" +) + +const ( + actionLogin = "login" + actionLogout = "logout" + actionAdd = "add" +) + +// Login creates a login entry after validating the category is not already logged in. +func Login(dbDir, hostname, category string, at time.Time, descr string) (Entry, error) { + host, err := normalizeHostname(hostname) + if err != nil { + return Entry{}, err + } + + cat := normalizeCategory(category) + loggedIn, err := isLoggedIn(dbDir, cat) + if err != nil { + return Entry{}, err + } + if loggedIn { + return Entry{}, fmt.Errorf("already logged in for %q", cat) + } + + entry := newEntry(actionLogin, host, cat, at, 0, descr) + return appendHostEntry(dbDir, host, entry) +} + +// Logout creates a logout entry after validating the category is currently logged in. +func Logout(dbDir, hostname, category string, at time.Time, descr string) (Entry, error) { + host, err := normalizeHostname(hostname) + if err != nil { + return Entry{}, err + } + + cat := normalizeCategory(category) + loggedIn, err := isLoggedIn(dbDir, cat) + if err != nil { + return Entry{}, err + } + if !loggedIn { + return Entry{}, fmt.Errorf("not logged in for %q", cat) + } + + entry := newEntry(actionLogout, host, cat, at, 0, descr) + return appendHostEntry(dbDir, host, entry) +} + +// Add creates an add entry with a positive duration. +func Add(dbDir, hostname, category string, duration time.Duration, at time.Time, descr string) (Entry, error) { + host, err := normalizeHostname(hostname) + if err != nil { + return Entry{}, err + } + if duration <= 0 { + return Entry{}, errors.New("duration must be positive") + } + + cat := normalizeCategory(category) + entry := newEntry(actionAdd, host, cat, at, durationToSeconds(duration), descr) + return appendHostEntry(dbDir, host, entry) +} + +// 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) + if err != nil { + return Entry{}, err + } + if duration <= 0 { + return Entry{}, errors.New("duration must be positive") + } + + cat := normalizeCategory(category) + entry := newEntry(actionAdd, host, cat, at, -durationToSeconds(duration), descr) + return appendHostEntry(dbDir, host, entry) +} + +// UseBuffer transfers duration from selfdevelopment to work. +func UseBuffer(dbDir, hostname string, duration time.Duration, at time.Time, descr string) ([]Entry, error) { + if duration <= 0 { + return nil, errors.New("duration must be positive") + } + + removed, err := Sub(dbDir, hostname, "selfdevelopment", duration, at, descr) + if err != nil { + return nil, err + } + + added, err := Add(dbDir, hostname, "work", duration, at, descr) + if err != nil { + return nil, err + } + + return []Entry{removed, added}, nil +} + +// EditEntry replaces an entry by index in the host database after validation. +func EditEntry(dbDir, hostname string, index int, replacement Entry) (Entry, error) { + host, err := normalizeHostname(hostname) + if err != nil { + return Entry{}, err + } + + db, err := LoadHost(dbDir, host) + if err != nil { + return Entry{}, err + } + + entries := db.Entries[host] + if index < 0 || index >= len(entries) { + return Entry{}, fmt.Errorf("entry index %d out of range", index) + } + + normalized, err := normalizeEditedEntry(replacement, host) + if err != nil { + return Entry{}, err + } + + entries[index] = normalized + db.Entries[host] = entries + if err := SaveHost(dbDir, host, db); err != nil { + return Entry{}, err + } + + return normalized, nil +} + +// DeleteEntry removes an entry by index from the host database. +func DeleteEntry(dbDir, hostname string, index int) (Entry, error) { + host, err := normalizeHostname(hostname) + if err != nil { + return Entry{}, err + } + + db, err := LoadHost(dbDir, host) + if err != nil { + return Entry{}, err + } + + entries := db.Entries[host] + if index < 0 || index >= len(entries) { + return Entry{}, fmt.Errorf("entry index %d out of range", index) + } + + removed := entries[index] + db.Entries[host] = append(entries[:index], entries[index+1:]...) + if err := SaveHost(dbDir, host, db); err != nil { + return Entry{}, err + } + + return removed, nil +} + +func appendHostEntry(dbDir, host string, entry Entry) (Entry, error) { + db, err := LoadHost(dbDir, host) + if err != nil { + return Entry{}, err + } + + db.Entries[host] = append(db.Entries[host], entry) + if err := SaveHost(dbDir, host, db); err != nil { + return Entry{}, err + } + + return entry, nil +} + +func isLoggedIn(dbDir, category string) (bool, error) { + entries, err := LoadAll(dbDir) + if err != nil { + return false, err + } + + status := map[string]bool{} + for _, entry := range entries { + cat := normalizeCategory(entry.What) + switch strings.ToLower(strings.TrimSpace(entry.Action)) { + case actionLogin: + status[cat] = true + case actionLogout: + status[cat] = false + } + } + + return status[category], nil +} + +func normalizeCategory(category string) string { + cat := strings.TrimSpace(category) + if cat == "" { + return "work" + } + return cat +} + +func durationToSeconds(duration time.Duration) int64 { + return int64(duration / time.Second) +} + +func effectiveTime(at time.Time) time.Time { + if at.IsZero() { + return time.Now() + } + return at +} + +func newEntry(action, host, category string, at time.Time, value int64, descr string) Entry { + eventTime := effectiveTime(at) + + entry := Entry{ + Action: action, + What: category, + Epoch: eventTime.Unix(), + Source: host, + Human: eventTime.Format("Mon 02.01.2006 15:04:05"), + Value: value, + } + if trimmedDescr := strings.TrimSpace(descr); trimmedDescr != "" { + entry.Descr = trimmedDescr + } + + return entry +} + +func normalizeEditedEntry(entry Entry, host string) (Entry, error) { + action := strings.ToLower(strings.TrimSpace(entry.Action)) + switch action { + case actionLogin, actionLogout, actionAdd: + default: + return Entry{}, fmt.Errorf("unsupported action %q", entry.Action) + } + + if entry.Epoch <= 0 { + return Entry{}, errors.New("epoch must be greater than zero") + } + + entry.Action = action + entry.What = normalizeCategory(entry.What) + entry.Source = strings.TrimSpace(entry.Source) + if entry.Source == "" { + entry.Source = host + } + if entry.Source != host { + return Entry{}, fmt.Errorf("entry source %q does not match host %q", entry.Source, host) + } + + if action != actionAdd { + entry.Value = 0 + } + + if strings.TrimSpace(entry.Human) == "" { + entry.Human = time.Unix(entry.Epoch, 0).Format("Mon 02.01.2006 15:04:05") + } + entry.Descr = strings.TrimSpace(entry.Descr) + + return entry, nil +} diff --git a/internal/worktime/entries_test.go b/internal/worktime/entries_test.go new file mode 100644 index 0000000..1327a1a --- /dev/null +++ b/internal/worktime/entries_test.go @@ -0,0 +1,209 @@ +package worktime + +import ( + "testing" + "time" +) + +func TestLoginLogoutValidation(t *testing.T) { + dbDir := t.TempDir() + host := "host-a" + + loginEntry, err := Login(dbDir, host, "work", time.Unix(100, 0), "start") + if err != nil { + t.Fatalf("Login() error = %v", err) + } + if loginEntry.Action != "login" || loginEntry.What != "work" { + t.Fatalf("unexpected login entry: %+v", loginEntry) + } + + if _, err := Login(dbDir, host, "work", time.Unix(110, 0), "start again"); err == nil { + t.Fatal("Login() error = nil, want already logged in error") + } + + logoutEntry, err := Logout(dbDir, host, "work", time.Unix(120, 0), "stop") + if err != nil { + t.Fatalf("Logout() error = %v", err) + } + if logoutEntry.Action != "logout" || logoutEntry.What != "work" { + t.Fatalf("unexpected logout entry: %+v", logoutEntry) + } + + if _, err := Logout(dbDir, host, "work", time.Unix(130, 0), "stop again"); err == nil { + t.Fatal("Logout() error = nil, want not logged in error") + } +} + +func TestLoginValidationIsCategoryScoped(t *testing.T) { + dbDir := t.TempDir() + host := "host-a" + + if _, err := Login(dbDir, host, "work", time.Unix(100, 0), ""); err != nil { + t.Fatalf("Login(work) error = %v", err) + } + if _, err := Login(dbDir, host, "lunch", time.Unix(110, 0), ""); err != nil { + t.Fatalf("Login(lunch) error = %v", err) + } +} + +func TestAddSubAndUseBuffer(t *testing.T) { + dbDir := t.TempDir() + host := "host-a" + + added, err := Add(dbDir, host, "", 30*time.Minute, time.Unix(100, 0), "manual add") + if err != nil { + t.Fatalf("Add() error = %v", err) + } + if added.Action != "add" || added.What != "work" || added.Value != 1800 { + t.Fatalf("unexpected Add() entry: %+v", added) + } + + subbed, err := Sub(dbDir, host, "work", 15*time.Minute, time.Unix(200, 0), "manual sub") + if err != nil { + t.Fatalf("Sub() error = %v", err) + } + if subbed.Value != -900 { + t.Fatalf("Sub() value = %d, want -900", subbed.Value) + } + + bufferEntries, err := UseBuffer(dbDir, host, 10*time.Minute, time.Unix(300, 0), "buffer transfer") + if err != nil { + t.Fatalf("UseBuffer() error = %v", err) + } + if len(bufferEntries) != 2 { + t.Fatalf("UseBuffer() len = %d, want 2", len(bufferEntries)) + } + if bufferEntries[0].What != "selfdevelopment" || bufferEntries[0].Value != -600 { + t.Fatalf("unexpected buffer remove entry: %+v", bufferEntries[0]) + } + if bufferEntries[1].What != "work" || bufferEntries[1].Value != 600 { + t.Fatalf("unexpected buffer add entry: %+v", bufferEntries[1]) + } + + db, err := LoadHost(dbDir, host) + if err != nil { + t.Fatalf("LoadHost() error = %v", err) + } + + entries := db.Entries[host] + if len(entries) != 4 { + t.Fatalf("entries len = %d, want 4", len(entries)) + } + if entries[0].Epoch != 100 || entries[1].Epoch != 200 || entries[2].Epoch != 300 || entries[3].Epoch != 300 { + t.Fatalf("entries not sorted by epoch: %+v", entries) + } +} + +func TestDurationValidation(t *testing.T) { + dbDir := t.TempDir() + host := "host-a" + + if _, err := Add(dbDir, host, "work", 0, time.Unix(100, 0), ""); err == nil { + t.Fatal("Add() accepted zero duration") + } + if _, err := Add(dbDir, host, "work", -time.Minute, time.Unix(100, 0), ""); err == nil { + t.Fatal("Add() accepted negative duration") + } + if _, err := Sub(dbDir, host, "work", 0, time.Unix(100, 0), ""); err == nil { + t.Fatal("Sub() accepted zero duration") + } + if _, err := UseBuffer(dbDir, host, 0, time.Unix(100, 0), ""); err == nil { + t.Fatal("UseBuffer() accepted zero duration") + } +} + +func TestEditAndDeleteEntry(t *testing.T) { + dbDir := t.TempDir() + host := "host-a" + + if _, err := Add(dbDir, host, "work", 5*time.Minute, time.Unix(100, 0), "first"); err != nil { + t.Fatalf("Add(first) error = %v", err) + } + if _, err := Add(dbDir, host, "work", 6*time.Minute, time.Unix(200, 0), "second"); err != nil { + t.Fatalf("Add(second) error = %v", err) + } + + edited, err := EditEntry(dbDir, host, 0, Entry{ + Action: "ADD", + What: "off", + Epoch: 100, + Value: 120, + Descr: "updated", + }) + if err != nil { + t.Fatalf("EditEntry() error = %v", err) + } + if edited.Action != "add" || edited.What != "off" || edited.Source != host || edited.Value != 120 { + t.Fatalf("unexpected edited entry: %+v", edited) + } + + dbAfterEdit, err := LoadHost(dbDir, host) + if err != nil { + t.Fatalf("LoadHost() after edit error = %v", err) + } + if dbAfterEdit.Entries[host][0].What != "off" { + t.Fatalf("entry was not edited: %+v", dbAfterEdit.Entries[host][0]) + } + + removed, err := DeleteEntry(dbDir, host, 1) + if err != nil { + t.Fatalf("DeleteEntry() error = %v", err) + } + if removed.Descr != "second" { + t.Fatalf("unexpected removed entry: %+v", removed) + } + + dbAfterDelete, err := LoadHost(dbDir, host) + if err != nil { + t.Fatalf("LoadHost() after delete error = %v", err) + } + if len(dbAfterDelete.Entries[host]) != 1 { + t.Fatalf("entries len after delete = %d, want 1", len(dbAfterDelete.Entries[host])) + } + + if _, err := EditEntry(dbDir, host, 5, Entry{Action: "add", Epoch: 1}); err == nil { + t.Fatal("EditEntry() accepted out-of-range index") + } + if _, err := DeleteEntry(dbDir, host, 5); err == nil { + t.Fatal("DeleteEntry() accepted out-of-range index") + } +} + +func TestEditEntryValidation(t *testing.T) { + dbDir := t.TempDir() + host := "host-a" + + if _, err := Add(dbDir, host, "work", time.Minute, time.Unix(100, 0), "seed"); err != nil { + t.Fatalf("Add(seed) error = %v", err) + } + + if _, err := EditEntry(dbDir, host, 0, Entry{Action: "bad", Epoch: 1}); err == nil { + t.Fatal("EditEntry() accepted unsupported action") + } + if _, err := EditEntry(dbDir, host, 0, Entry{Action: "add", Epoch: 0}); err == nil { + t.Fatal("EditEntry() accepted non-positive epoch") + } + if _, err := EditEntry(dbDir, host, 0, Entry{Action: "add", Epoch: 1, Source: "other-host"}); err == nil { + t.Fatal("EditEntry() accepted mismatched source") + } +} + +func TestGlobalLoginValidationAcrossHosts(t *testing.T) { + dbDir := t.TempDir() + + if _, err := Login(dbDir, "host-a", "work", time.Unix(100, 0), ""); err != nil { + t.Fatalf("Login(host-a) error = %v", err) + } + + if _, err := Login(dbDir, "host-b", "work", time.Unix(110, 0), ""); err == nil { + t.Fatal("Login(host-b) should fail while work is already logged in") + } + + if _, err := Logout(dbDir, "host-b", "work", time.Unix(120, 0), ""); err != nil { + t.Fatalf("Logout(host-b) error = %v", err) + } + + if _, err := Logout(dbDir, "host-a", "work", time.Unix(130, 0), ""); err == nil { + t.Fatal("Logout(host-a) should fail because work is already logged out") + } +} -- cgit v1.2.3