From b547dc4372175bc54ca3bfdeb215b9734987c669 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 4 Mar 2026 13:58:36 +0200 Subject: tui: fix entry editing keys and show session durations Bump version to v0.6.0. --- README.md | 2 +- internal/tui/entries.go | 97 +++++++++++++++++++++++++++++++++++++++----- internal/tui/entries_test.go | 38 +++++++++++++++++ internal/tui/tui.go | 10 ++++- internal/tui/tui_test.go | 22 ++++++++++ internal/version.go | 2 +- 6 files changed, 157 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index e907c64..bb6128e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - weekly reporting, - and a Bubble Tea TUI. -Current version: `v0.5.1`. +Current version: `v0.6.0`. ## Installation 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" -- cgit v1.2.3