summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-03 23:05:32 +0200
committerPaul Buetow <paul@buetow.org>2026-03-03 23:05:32 +0200
commit93783dc090cb5f5452e893b55a9f50f500f1e8a3 (patch)
tree9e070783fa77bb3b88db2b658323d5d2bb319aeb
parentfd29c03621e226ed756f5abc78009825e924a545 (diff)
Task 352: add TUI report screen model
-rw-r--r--internal/tui/report.go258
-rw-r--r--internal/tui/report_test.go113
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}},
+ },
+ },
+ }
+}