diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-03 23:05:32 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-03 23:05:32 +0200 |
| commit | 93783dc090cb5f5452e893b55a9f50f500f1e8a3 (patch) | |
| tree | 9e070783fa77bb3b88db2b658323d5d2bb319aeb | |
| parent | fd29c03621e226ed756f5abc78009825e924a545 (diff) | |
Task 352: add TUI report screen model
| -rw-r--r-- | internal/tui/report.go | 258 | ||||
| -rw-r--r-- | internal/tui/report_test.go | 113 |
2 files changed, 371 insertions, 0 deletions
diff --git a/internal/tui/report.go b/internal/tui/report.go new file mode 100644 index 0000000..ca680c3 --- /dev/null +++ b/internal/tui/report.go @@ -0,0 +1,258 @@ +package tui + +import ( + "fmt" + "strings" + + "codeberg.org/snonux/timr/internal/worktime" + tea "github.com/charmbracelet/bubbletea" +) + +// ReportModel is a weekly report browser screen. +type ReportModel struct { + weeks []worktime.WeekReport + + weekIndex int + cursor int + offset int + width int + height int + + verbose bool + + pendingG bool + pendingBracket int +} + +// NewReportModel creates a report screen model. +func NewReportModel(weeks []worktime.WeekReport) ReportModel { + model := ReportModel{ + height: 16, + } + model.SetWeeks(weeks) + return model +} + +// SetSize updates viewport dimensions. +func (m *ReportModel) SetSize(width, height int) { + m.width = width + if height > 0 { + m.height = height + } + m.ensureCursorVisible() +} + +// SetWeeks replaces report data. +func (m *ReportModel) SetWeeks(weeks []worktime.WeekReport) { + m.weeks = append([]worktime.WeekReport(nil), weeks...) + if len(m.weeks) == 0 { + m.weekIndex = 0 + m.cursor = 0 + m.offset = 0 + return + } + if m.weekIndex >= len(m.weeks) { + m.weekIndex = len(m.weeks) - 1 + } + m.cursor = 0 + m.offset = 0 +} + +// Update handles keyboard interaction. +func (m ReportModel) Update(msg tea.Msg) (ReportModel, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + if m.pendingBracket != 0 { + if keyMsg.String() == "w" { + if m.pendingBracket > 0 { + m.nextWeek() + } else { + m.prevWeek() + } + m.pendingBracket = 0 + return m, nil + } + m.pendingBracket = 0 + } + + switch keyMsg.String() { + case "j", "down": + m.moveCursor(1) + m.pendingG = false + case "k", "up": + m.moveCursor(-1) + m.pendingG = false + case "G": + m.cursor = m.rowCount() - 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 "]": + m.pendingBracket = 1 + m.pendingG = false + case "[": + m.pendingBracket = -1 + m.pendingG = false + case "v": + m.verbose = !m.verbose + m.pendingG = false + default: + m.pendingG = false + } + + return m, nil +} + +// View renders the report screen. +func (m ReportModel) View(styles Styles) string { + if len(m.weeks) == 0 { + return styles.Body.Render("Report\n\nNo report data.") + } + + week := m.weeks[m.weekIndex] + header := fmt.Sprintf( + "Report Week %s [%d/%d] verbose:%t", + week.WeekLabel, + m.weekIndex+1, + len(m.weeks), + m.verbose, + ) + + rows := m.dayRows(week) + maxRows := m.listRows() + end := minInt(len(rows), m.offset+maxRows) + + lines := make([]string, 0, end-m.offset) + for idx := m.offset; idx < end; idx++ { + cursor := " " + if idx == m.cursor { + cursor = ">" + } + lines = append(lines, cursor+" "+rows[idx]) + } + + summary := fmt.Sprintf( + "Balance:%+.2fh Work:%+.2fh Buffer:%+.2fh", + toHours(week.CumulativeBalanceSeconds), + toHours(week.Values["work"]), + toHours(week.BufferSeconds), + ) + hint := "j/k scroll, gg/G top/bottom, ]w/[w week nav, v verbose" + + body := header + "\n\n" + strings.Join(lines, "\n") + "\n\n" + summary + "\n" + hint + return styles.Body.Render(body) +} + +func (m *ReportModel) dayRows(week worktime.WeekReport) []string { + rows := make([]string, 0, len(week.Days)) + for _, day := range week.Days { + row := fmt.Sprintf( + "%-1s %-18s work:%+6.2fh lunch:%+6.2fh off:%+6.2fh bank:%+6.2fh", + day.Marker, + day.DayLabel, + toHours(day.Values["work"]), + toHours(day.Values["lunch"]), + toHours(day.Values["off"]), + toHours(day.Values["bank"]), + ) + if m.verbose { + row += fmt.Sprintf(" epoch:%d", day.Epoch) + } + rows = append(rows, row) + } + if len(rows) == 0 { + rows = append(rows, "No days in this week.") + } + return rows +} + +func (m *ReportModel) moveCursor(delta int) { + if m.rowCount() == 0 { + m.cursor = 0 + m.offset = 0 + return + } + + m.cursor += delta + if m.cursor < 0 { + m.cursor = 0 + } + if m.cursor >= m.rowCount() { + m.cursor = m.rowCount() - 1 + } + m.ensureCursorVisible() +} + +func (m *ReportModel) ensureCursorVisible() { + if m.rowCount() == 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 *ReportModel) nextWeek() { + if len(m.weeks) == 0 { + return + } + if m.weekIndex < len(m.weeks)-1 { + m.weekIndex++ + } + m.cursor = 0 + m.offset = 0 +} + +func (m *ReportModel) prevWeek() { + if len(m.weeks) == 0 { + return + } + if m.weekIndex > 0 { + m.weekIndex-- + } + m.cursor = 0 + m.offset = 0 +} + +func (m ReportModel) listRows() int { + rows := m.height - 7 + if rows < 1 { + return 1 + } + return rows +} + +func (m ReportModel) rowCount() int { + if len(m.weeks) == 0 { + return 0 + } + return len(m.dayRows(m.weeks[m.weekIndex])) +} + +func toHours(seconds int64) float64 { + return float64(seconds) / 3600 +} diff --git a/internal/tui/report_test.go b/internal/tui/report_test.go new file mode 100644 index 0000000..cf9a7f9 --- /dev/null +++ b/internal/tui/report_test.go @@ -0,0 +1,113 @@ +package tui + +import ( + "strings" + "testing" + + "codeberg.org/snonux/timr/internal/worktime" + tea "github.com/charmbracelet/bubbletea" +) + +func TestReportWeekNavigation(t *testing.T) { + model := NewReportModel(sampleWeeks()) + model.SetSize(120, 12) + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{']'}}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) + if model.weekIndex != 1 { + t.Fatalf("weekIndex after ]w = %d, want 1", model.weekIndex) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'['}}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) + if model.weekIndex != 0 { + t.Fatalf("weekIndex after [w = %d, want 0", model.weekIndex) + } +} + +func TestReportScrollingAndTopBottom(t *testing.T) { + model := NewReportModel(sampleWeeks()) + 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 != model.rowCount()-1 { + t.Fatalf("cursor after G = %d, want %d", model.cursor, model.rowCount()-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) + } +} + +func TestReportVerboseToggle(t *testing.T) { + model := NewReportModel(sampleWeeks()) + model.SetSize(120, 12) + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'v'}}) + if !model.verbose { + t.Fatal("verbose = false, want true after v") + } + + view := model.View(DefaultStyles()) + if !strings.Contains(view, "epoch:") { + t.Fatalf("verbose view missing epoch details: %q", view) + } +} + +func TestReportSummaryBarInView(t *testing.T) { + model := NewReportModel(sampleWeeks()) + model.SetSize(120, 12) + + view := model.View(DefaultStyles()) + if !strings.Contains(view, "Balance:") { + t.Fatalf("view missing summary balance: %q", view) + } + if !strings.Contains(view, "Work:") { + t.Fatalf("view missing summary work: %q", view) + } + if !strings.Contains(view, "Buffer:") { + t.Fatalf("view missing summary buffer: %q", view) + } +} + +func sampleWeeks() []worktime.WeekReport { + return []worktime.WeekReport{ + { + WeekLabel: "10", + CumulativeBalanceSeconds: 2 * 3600, + BufferSeconds: 1 * 3600, + Values: map[string]int64{ + "work": 20 * 3600, + }, + Days: []worktime.DayReport{ + {DayLabel: "Mon 20260302 10", Marker: " ", Epoch: 1, Values: map[string]int64{"work": 8 * 3600}}, + {DayLabel: "Tue 20260303 10", Marker: " ", Epoch: 2, Values: map[string]int64{"work": 7 * 3600, "lunch": 3600}}, + {DayLabel: "Wed 20260304 10", Marker: "*", Epoch: 3, Values: map[string]int64{"off": 8 * 3600}}, + }, + }, + { + WeekLabel: "11", + CumulativeBalanceSeconds: 3 * 3600, + BufferSeconds: 2 * 3600, + Values: map[string]int64{ + "work": 18 * 3600, + }, + Days: []worktime.DayReport{ + {DayLabel: "Mon 20260309 11", Marker: " ", Epoch: 4, Values: map[string]int64{"work": 9 * 3600}}, + {DayLabel: "Tue 20260310 11", Marker: " ", Epoch: 5, Values: map[string]int64{"work": 9 * 3600}}, + }, + }, + } +} |
