diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-03 22:58:28 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-03 22:58:28 +0200 |
| commit | 0a2ad3650208a6cd7221974ed0c0acf38eb22f79 (patch) | |
| tree | 410aa7e048c07876bde288a8796e0ef49d17648b | |
| parent | 6437f0a5f6a4a114540092efe50f62270070afb0 (diff) | |
Task 353: add TUI entries browser model
| -rw-r--r-- | internal/tui/entries.go | 324 | ||||
| -rw-r--r-- | internal/tui/entries_test.go | 119 |
2 files changed, 443 insertions, 0 deletions
diff --git a/internal/tui/entries.go b/internal/tui/entries.go new file mode 100644 index 0000000..450748f --- /dev/null +++ b/internal/tui/entries.go @@ -0,0 +1,324 @@ +package tui + +import ( + "fmt" + "slices" + "strings" + "time" + + "codeberg.org/snonux/timr/internal/worktime" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// EntriesModel is a chronological worktime entry browser. +type EntriesModel struct { + allEntries []worktime.Entry + visible []worktime.Entry + + cursor int + offset int + width int + height int + + pendingG bool + + searchMode bool + searchQuery string + + filterMode bool + filterQuery string + + input string +} + +// NewEntriesModel creates an entry browser model. +func NewEntriesModel(entries []worktime.Entry) EntriesModel { + model := EntriesModel{ + height: 16, + } + model.SetEntries(entries) + return model +} + +// SetSize updates viewport size used for scrolling. +func (m *EntriesModel) SetSize(width, height int) { + m.width = width + if height > 0 { + m.height = height + } + m.ensureCursorVisible() +} + +// SetEntries replaces entry data (most recent first). +func (m *EntriesModel) SetEntries(entries []worktime.Entry) { + m.allEntries = append([]worktime.Entry(nil), entries...) + slices.SortFunc(m.allEntries, func(a, b worktime.Entry) int { + if a.Epoch == b.Epoch { + return strings.Compare(a.Action, b.Action) + } + if a.Epoch > b.Epoch { + return -1 + } + return 1 + }) + m.applyFilters() +} + +// Update handles keyboard navigation and search/filter interaction. +func (m EntriesModel) Update(msg tea.Msg) (EntriesModel, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + if m.searchMode || m.filterMode { + switch keyMsg.String() { + case "enter": + if m.searchMode { + m.searchQuery = strings.TrimSpace(m.input) + m.searchMode = false + } else { + m.filterQuery = strings.TrimSpace(m.input) + m.filterMode = false + } + m.input = "" + m.applyFilters() + case "esc": + m.searchMode = false + m.filterMode = false + m.input = "" + case "backspace": + if len(m.input) > 0 { + m.input = m.input[:len(m.input)-1] + } + default: + if keyMsg.Type == tea.KeyRunes { + m.input += string(keyMsg.Runes) + } + } + return m, nil + } + + switch keyMsg.String() { + case "/": + m.searchMode = true + m.input = "" + m.pendingG = false + case "f": + m.filterMode = true + m.input = m.filterQuery + m.pendingG = false + case "j", "down": + m.moveCursor(1) + m.pendingG = false + case "k", "up": + m.moveCursor(-1) + m.pendingG = false + case "G": + m.cursor = len(m.visible) - 1 + m.ensureCursorVisible() + m.pendingG = false + case "g": + if m.pendingG { + m.cursor = 0 + m.ensureCursorVisible() + m.pendingG = false + return m, nil + } + m.pendingG = true + case "ctrl+d": + m.moveCursor(m.halfPage()) + m.pendingG = false + case "ctrl+u": + m.moveCursor(-m.halfPage()) + m.pendingG = false + case "ctrl+f": + m.moveCursor(m.pageSize()) + m.pendingG = false + case "ctrl+b": + m.moveCursor(-m.pageSize()) + m.pendingG = false + default: + m.pendingG = false + } + + return m, nil +} + +// View renders the entries list. +func (m EntriesModel) View(styles Styles) string { + title := fmt.Sprintf("Entries [%d/%d]", minInt(m.cursor+1, len(m.visible)), len(m.visible)) + if m.filterQuery != "" { + title += " filter:" + m.filterQuery + } + if m.searchQuery != "" { + title += " search:" + m.searchQuery + } + + if m.searchMode { + return styles.Body.Render(title + "\n\n/" + m.input) + } + if m.filterMode { + return styles.Body.Render(title + "\n\nf " + m.input) + } + + if len(m.visible) == 0 { + return styles.Body.Render(title + "\n\nNo entries match current search/filter.") + } + + maxRows := m.listRows() + end := minInt(len(m.visible), m.offset+maxRows) + lines := make([]string, 0, end-m.offset) + for idx := m.offset; idx < end; idx++ { + entry := m.visible[idx] + cursor := " " + if idx == m.cursor { + cursor = ">" + } + + timestamp := time.Unix(entry.Epoch, 0).Format("2006-01-02 15:04") + category := colorizeCategory(entry.What) + value := formatEntryValue(entry) + line := fmt.Sprintf("%s %s %-7s %-18s %-8s %s", cursor, timestamp, entry.Action, category, value, entry.Descr) + lines = append(lines, line) + } + + return styles.Body.Render(title + "\n\n" + strings.Join(lines, "\n")) +} + +func (m *EntriesModel) applyFilters() { + search := strings.ToLower(m.searchQuery) + filter := strings.ToLower(m.filterQuery) + + m.visible = m.visible[:0] + for _, entry := range m.allEntries { + if filter != "" && !strings.Contains(strings.ToLower(entry.What), filter) { + continue + } + if search != "" { + joined := strings.ToLower(strings.Join([]string{ + entry.Action, + entry.What, + entry.Source, + entry.Human, + entry.Descr, + }, " ")) + if !strings.Contains(joined, search) { + continue + } + } + m.visible = append(m.visible, entry) + } + + if len(m.visible) == 0 { + m.cursor = 0 + m.offset = 0 + return + } + if m.cursor >= len(m.visible) { + m.cursor = len(m.visible) - 1 + } + m.ensureCursorVisible() +} + +func (m *EntriesModel) moveCursor(delta int) { + if len(m.visible) == 0 { + m.cursor = 0 + m.offset = 0 + return + } + + m.cursor += delta + if m.cursor < 0 { + m.cursor = 0 + } + if m.cursor >= len(m.visible) { + m.cursor = len(m.visible) - 1 + } + m.ensureCursorVisible() +} + +func (m *EntriesModel) ensureCursorVisible() { + if len(m.visible) == 0 { + m.cursor = 0 + m.offset = 0 + return + } + + if m.cursor < m.offset { + m.offset = m.cursor + } + + maxRows := m.listRows() + if maxRows <= 0 { + return + } + if m.cursor >= m.offset+maxRows { + m.offset = m.cursor - maxRows + 1 + } + if m.offset < 0 { + m.offset = 0 + } +} + +func (m EntriesModel) listRows() int { + rows := m.height - 4 + if rows < 1 { + return 1 + } + return rows +} + +func (m EntriesModel) halfPage() int { + page := m.listRows() / 2 + if page < 1 { + return 1 + } + return page +} + +func (m EntriesModel) pageSize() int { + page := m.listRows() + if page < 1 { + return 1 + } + return page +} + +func formatEntryValue(entry worktime.Entry) string { + if entry.Action != "add" { + return "-" + } + + hours := float64(entry.Value) / 3600 + return fmt.Sprintf("%+.2fh", hours) +} + +func colorizeCategory(category string) string { + if strings.TrimSpace(category) == "" { + category = "work" + } + + colors := map[string]string{ + "work": "#8BD3DD", + "lunch": "#F6BD60", + "off": "#CDB4DB", + "bank": "#A8DADC", + "sick": "#FFB4A2", + "selfdevelopment": "#B8E1A9", + } + + color, ok := colors[category] + if !ok { + color = "#D9D9D9" + } + return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(category) +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/tui/entries_test.go b/internal/tui/entries_test.go new file mode 100644 index 0000000..5f6d5f5 --- /dev/null +++ b/internal/tui/entries_test.go @@ -0,0 +1,119 @@ +package tui + +import ( + "fmt" + "testing" + "time" + + "codeberg.org/snonux/timr/internal/worktime" + tea "github.com/charmbracelet/bubbletea" +) + +func TestEntriesModelSortsChronologically(t *testing.T) { + entries := []worktime.Entry{ + {Action: "add", What: "work", Epoch: 10, Descr: "old"}, + {Action: "add", What: "work", Epoch: 30, Descr: "new"}, + {Action: "add", What: "work", Epoch: 20, Descr: "mid"}, + } + + model := NewEntriesModel(entries) + if len(model.visible) != 3 { + t.Fatalf("visible len = %d, want 3", len(model.visible)) + } + if model.visible[0].Epoch != 30 || model.visible[1].Epoch != 20 || model.visible[2].Epoch != 10 { + t.Fatalf("entries are not sorted descending by epoch: %+v", model.visible) + } +} + +func TestEntriesNavigationKeys(t *testing.T) { + model := NewEntriesModel(sampleEntries(20)) + model.SetSize(120, 12) + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + if model.cursor != 1 { + t.Fatalf("cursor after j = %d, want 1", model.cursor) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + if model.cursor != 0 { + t.Fatalf("cursor after k = %d, want 0", model.cursor) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}) + if model.cursor != len(model.visible)-1 { + t.Fatalf("cursor after G = %d, want %d", model.cursor, len(model.visible)-1) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + if model.cursor != 0 { + t.Fatalf("cursor after gg = %d, want 0", model.cursor) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) + if model.cursor == 0 { + t.Fatal("cursor did not move after ctrl+d") + } + + before := model.cursor + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyCtrlU}) + if model.cursor >= before { + t.Fatalf("cursor after ctrl+u = %d, want less than %d", model.cursor, before) + } +} + +func TestEntriesSearchAndFilter(t *testing.T) { + entries := []worktime.Entry{ + {Action: "add", What: "work", Epoch: localEpoch(2026, 1, 5, 10), Descr: "meeting"}, + {Action: "add", What: "off", Epoch: localEpoch(2026, 1, 4, 10), Descr: "vacation"}, + {Action: "add", What: "work", Epoch: localEpoch(2026, 1, 3, 10), Descr: "coding"}, + } + + model := NewEntriesModel(entries) + model.SetSize(120, 12) + + // Search for "meeting". + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'t'}}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + if len(model.visible) != 1 || model.visible[0].Descr != "meeting" { + t.Fatalf("search results mismatch: %+v", model.visible) + } + + // Apply filter by category "work" on top of search. + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + if len(model.visible) != 1 || model.visible[0].What != "work" { + t.Fatalf("filter results mismatch: %+v", model.visible) + } +} + +func sampleEntries(count int) []worktime.Entry { + entries := make([]worktime.Entry, 0, count) + for idx := 0; idx < count; idx++ { + entries = append(entries, worktime.Entry{ + Action: "add", + What: "work", + Epoch: int64(1000 + idx), + Descr: fmt.Sprintf("entry-%d", idx), + Value: int64(time.Hour / time.Second), + }) + } + return entries +} + +func localEpoch(year int, month time.Month, day int, hour int) int64 { + return time.Date(year, month, day, hour, 0, 0, 0, time.Local).Unix() +} |
