summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-03 22:36:46 +0200
committerPaul Buetow <paul@buetow.org>2026-03-03 22:36:46 +0200
commitc3c347d6faed97d9cc02bf326e2a74786b0bde99 (patch)
tree38a4ad0d4ccd964d5e59d42e6916e540fa92c6d4 /internal
parentb2cf337e0a4182dba6d0cbe18c0606e607d143c7 (diff)
Task 354: add worktime entry operations
Diffstat (limited to 'internal')
-rw-r--r--internal/worktime/entries.go265
-rw-r--r--internal/worktime/entries_test.go209
2 files changed, 474 insertions, 0 deletions
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")
+ }
+}