summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-04 13:58:36 +0200
committerPaul Buetow <paul@buetow.org>2026-03-04 13:58:36 +0200
commitb547dc4372175bc54ca3bfdeb215b9734987c669 (patch)
tree758a4c302ec1eca8502e6f02280c64941e0fd635 /internal
parent0df060769b7f43fa0cd7c5ed81992f122d7af8a9 (diff)
tui: fix entry editing keys and show session durationsv0.6.0
Bump version to v0.6.0.
Diffstat (limited to 'internal')
-rw-r--r--internal/tui/entries.go97
-rw-r--r--internal/tui/entries_test.go38
-rw-r--r--internal/tui/tui.go10
-rw-r--r--internal/tui/tui_test.go22
-rw-r--r--internal/version.go2
5 files changed, 156 insertions, 13 deletions
diff --git a/internal/tui/entries.go b/internal/tui/entries.go
index e0e76bf..e2f7e54 100644
--- a/internal/tui/entries.go
+++ b/internal/tui/entries.go
@@ -150,6 +150,10 @@ func (m *EntriesModel) Update(msg tea.Msg) (EntriesModel, tea.Cmd) {
return *m, nil
}
+func (m EntriesModel) capturesTextInput() bool {
+ return m.editMode || m.searchMode || m.filterMode
+}
+
func (m *EntriesModel) updateConfirmDelete(key string) bool {
if !m.confirmDelete {
return false
@@ -190,9 +194,7 @@ func (m *EntriesModel) updateEditMode(keyMsg tea.KeyMsg) bool {
case "backspace":
m.input = trimLastRune(m.input)
default:
- if keyMsg.Type == tea.KeyRunes {
- m.input += string(keyMsg.Runes)
- }
+ m.input = appendInputKey(m.input, keyMsg)
}
return true
@@ -260,9 +262,7 @@ func (m *EntriesModel) updateSearchFilterMode(keyMsg tea.KeyMsg) bool {
case "backspace":
m.input = trimLastRune(m.input)
default:
- if keyMsg.Type == tea.KeyRunes {
- m.input += string(keyMsg.Runes)
- }
+ m.input = appendInputKey(m.input, keyMsg)
}
return true
@@ -969,13 +969,76 @@ func (m *EntriesModel) pageSize() int {
return page
}
-func formatEntryValue(entry worktime.Entry) string {
- if entry.Action != "add" {
+func formatEntryValue(entry worktime.Entry, sessionDurations map[worktime.Entry]int64) string {
+ action := strings.ToLower(strings.TrimSpace(entry.Action))
+ switch action {
+ case "add":
+ hours := float64(entry.Value) / 3600
+ return fmt.Sprintf("%+.2fh", hours)
+ case "login", "logout":
+ span, ok := sessionDurations[entry]
+ if !ok || span <= 0 {
+ return "-"
+ }
+ hours := float64(span) / 3600
+ return fmt.Sprintf("+%.2fh", hours)
+ default:
return "-"
}
+}
+
+func buildSessionDurations(entries []worktime.Entry) map[worktime.Entry]int64 {
+ if len(entries) == 0 {
+ return map[worktime.Entry]int64{}
+ }
+
+ sorted := append([]worktime.Entry(nil), entries...)
+ slices.SortFunc(sorted, func(a, b worktime.Entry) int {
+ if a.Epoch != b.Epoch {
+ if a.Epoch < b.Epoch {
+ return -1
+ }
+ return 1
+ }
+ if a.Source != b.Source {
+ return strings.Compare(a.Source, b.Source)
+ }
+ return strings.Compare(a.Action, b.Action)
+ })
- hours := float64(entry.Value) / 3600
- return fmt.Sprintf("%+.2fh", hours)
+ activeByCategory := map[string]worktime.Entry{}
+ durations := map[worktime.Entry]int64{}
+ for _, entry := range sorted {
+ action := strings.ToLower(strings.TrimSpace(entry.Action))
+ category := normalizeEntryCategory(entry.What)
+
+ switch action {
+ case "login":
+ activeByCategory[category] = entry
+ case "logout":
+ loginEntry, ok := activeByCategory[category]
+ if !ok {
+ continue
+ }
+
+ span := entry.Epoch - loginEntry.Epoch
+ if span > 0 {
+ durations[loginEntry] = span
+ durations[entry] = span
+ }
+ delete(activeByCategory, category)
+ }
+ }
+
+ return durations
+}
+
+func normalizeEntryCategory(raw string) string {
+ category := strings.TrimSpace(raw)
+ if category == "" {
+ return "work"
+ }
+ return category
}
func minInt(a, b int) int {
@@ -1021,6 +1084,17 @@ func trimLastRune(value string) string {
return value[:len(value)-size]
}
+func appendInputKey(input string, keyMsg tea.KeyMsg) string {
+ switch keyMsg.Type {
+ case tea.KeyRunes:
+ return input + string(keyMsg.Runes)
+ case tea.KeySpace:
+ return input + " "
+ default:
+ return input
+ }
+}
+
func entryMatchesSearch(entry worktime.Entry, search string) bool {
return strings.Contains(strings.ToLower(entry.Action), search) ||
strings.Contains(strings.ToLower(entry.What), search) ||
@@ -1032,6 +1106,7 @@ func entryMatchesSearch(entry worktime.Entry, search string) bool {
func (m *EntriesModel) renderTimelineTable(styles Styles) string {
widths := m.timelineColumnWidths()
headers := []string{"Date", "Time", "Action", "Category", "Value", "Source", "Description"}
+ sessionDurations := buildSessionDurations(m.allEntries)
headerStyles := make([]lipgloss.Style, len(headers))
for idx := range headers {
@@ -1055,7 +1130,7 @@ func (m *EntriesModel) renderTimelineTable(styles Styles) string {
moment.Format("15:04"),
entry.Action,
entry.What,
- formatEntryValue(entry),
+ formatEntryValue(entry, sessionDurations),
entry.Source,
entry.Descr,
}
diff --git a/internal/tui/entries_test.go b/internal/tui/entries_test.go
index f8f9378..e1541a0 100644
--- a/internal/tui/entries_test.go
+++ b/internal/tui/entries_test.go
@@ -202,6 +202,44 @@ func TestEntriesEditFlow(t *testing.T) {
}
}
+func TestEntriesEditModeAcceptsSpaceKey(t *testing.T) {
+ model := NewEntriesModel(sampleEntries(1))
+ model.editMode = true
+ model.input = "hello"
+
+ model, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace})
+ if model.input != "hello " {
+ t.Fatalf("input after space = %q, want %q", model.input, "hello ")
+ }
+}
+
+func TestEntriesValueShowsSessionDurationForLoginLogoutPairs(t *testing.T) {
+ entries := []worktime.Entry{
+ {
+ Action: "login",
+ What: "work",
+ Epoch: localEpoch(2026, 1, 5, 9),
+ Source: "host-a",
+ Human: time.Unix(localEpoch(2026, 1, 5, 9), 0).Format("Mon 02.01.2006 15:04:05"),
+ },
+ {
+ Action: "logout",
+ What: "work",
+ Epoch: localEpoch(2026, 1, 5, 11),
+ Source: "host-a",
+ Human: time.Unix(localEpoch(2026, 1, 5, 11), 0).Format("Mon 02.01.2006 15:04:05"),
+ },
+ }
+
+ model := NewEntriesModel(entries)
+ model.SetSize(120, 12)
+
+ table := model.renderTimelineTable(DefaultStyles())
+ if count := strings.Count(table, "+2.00h"); count != 2 {
+ t.Fatalf("rendered session duration count = %d, want 2 in table:\n%s", count, table)
+ }
+}
+
func TestEntriesValueEditFlow(t *testing.T) {
model := NewEntriesModel(sampleEntries(3))
model.SetSize(120, 12)
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 926c331..3c4feb6 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -137,6 +137,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
key := msg.String()
+ entriesCapturingText := m.activeTab == tabEntries && m.entries.capturesTextInput()
if m.confirmQuit {
switch key {
@@ -159,6 +160,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
+ if entriesCapturingText {
+ m.pendingG = false
+ }
+
if m.pendingZ {
m.pendingZ = false
if key == "Q" {
@@ -166,7 +171,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
- if m.pendingG {
+ if m.pendingG && !entriesCapturingText {
m.pendingG = false
switch key {
case "t":
@@ -206,6 +211,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case "g":
+ if entriesCapturingText {
+ break
+ }
m.pendingG = true
return m, nil
case "Z":
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index e41317d..16d3ca2 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -34,6 +34,28 @@ func TestTabNavigation(t *testing.T) {
}
}
+func TestEntriesTextEditingIgnoresRootGlobalShortcuts(t *testing.T) {
+ model := newRootModelForTest(t)
+
+ modelAny, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}})
+ model = modelAny.(*Model)
+ if !model.entries.editMode {
+ t.Fatal("entries.editMode = false, want true after o")
+ }
+
+ modelAny, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})
+ model = modelAny.(*Model)
+ modelAny, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace})
+ model = modelAny.(*Model)
+
+ if model.entries.input != "g " {
+ t.Fatalf("entries.input = %q, want %q", model.entries.input, "g ")
+ }
+ if model.activeTab != tabEntries {
+ t.Fatalf("activeTab = %v, want %v", model.activeTab, tabEntries)
+ }
+}
+
func TestHelpToggle(t *testing.T) {
model := newRootModelForTest(t)
diff --git a/internal/version.go b/internal/version.go
index 07ca8f7..18e6b9e 100644
--- a/internal/version.go
+++ b/internal/version.go
@@ -1,3 +1,3 @@
package internal
-const Version = "v0.5.1"
+const Version = "v0.6.0"