v0.26.0 ae3a77a

stats.go RawHistoryFolder

1package model
2
3import (
4	"context"
5	"fmt"
6
7	"github.com/charmbracelet/bubbles/table"
8	tea "github.com/charmbracelet/bubbletea"
9	"github.com/charmbracelet/lipgloss"
10
11	"github.com/bnema/dumber/internal/application/usecase"
12	"github.com/bnema/dumber/internal/cli/styles"
13	"github.com/bnema/dumber/internal/domain/entity"
14)
15
16// StatsModel displays history analytics.
17type StatsModel struct {
18	analytics *entity.HistoryAnalytics
19	table     table.Model
20	loading   bool
21	err       error
22	width     int
23	height    int
24
25	historyUC *usecase.SearchHistoryUseCase
26	theme     *styles.Theme
27}
28
29// NewStatsModel creates a new stats display model.
30func NewStatsModel(theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) StatsModel {
31	return StatsModel{
32		historyUC: historyUC,
33		theme:     theme,
34		loading:   true,
35		width:     80,
36		height:    24,
37	}
38}
39
40// analyticsLoadedMsg is sent when analytics are loaded.
41type analyticsLoadedMsg struct {
42	analytics *entity.HistoryAnalytics
43	err       error
44}
45
46// Init implements tea.Model.
47func (m StatsModel) Init() tea.Cmd {
48	return m.loadAnalytics
49}
50
51// loadAnalytics loads history analytics.
52func (m StatsModel) loadAnalytics() tea.Msg {
53	ctx := context.Background()
54	analytics, err := m.historyUC.GetAnalytics(ctx)
55	return analyticsLoadedMsg{analytics: analytics, err: err}
56}
57
58// Update implements tea.Model.
59func (m StatsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
60	switch msg := msg.(type) {
61	case tea.WindowSizeMsg:
62		m.width = msg.Width
63		m.height = msg.Height
64		m.updateTable()
65
66	case tea.KeyMsg:
67		switch msg.String() {
68		case "q", "ctrl+c", "esc":
69			return m, tea.Quit
70		default:
71			var cmd tea.Cmd
72			m.table, cmd = m.table.Update(msg)
73			return m, cmd
74		}
75
76	case analyticsLoadedMsg:
77		m.loading = false
78		if msg.err != nil {
79			m.err = msg.err
80		} else {
81			m.analytics = msg.analytics
82			m.updateTable()
83		}
84	}
85
86	return m, nil
87}
88
89// updateTable builds the domain stats table.
90func (m *StatsModel) updateTable() {
91	if m.analytics == nil || len(m.analytics.TopDomains) == 0 {
92		return
93	}
94
95	columns := styles.StatsTableColumns()
96
97	rows := make([]table.Row, len(m.analytics.TopDomains))
98	for i, d := range m.analytics.TopDomains {
99		rows[i] = table.Row{
100			d.Domain,
101			formatNumber(d.TotalVisits),
102			formatNumber(d.PageCount),
103			styles.RelativeTime(d.LastVisit),
104		}
105	}
106
107	tableHeight := len(rows)
108	if tableHeight > m.height-10 {
109		tableHeight = m.height - 10
110	}
111	if tableHeight < 3 {
112		tableHeight = 3
113	}
114
115	m.table = styles.NewStyledTable(m.theme, columns, rows, m.width-4, tableHeight)
116}
117
118// View implements tea.Model.
119func (m StatsModel) View() string {
120	t := m.theme
121
122	if m.loading {
123		spinner := styles.NewLoading(t, "Loading analytics...")
124		return t.Box.Render(spinner.View())
125	}
126
127	if m.err != nil {
128		return t.Box.Render(t.ErrorStyle.Render("Error: " + m.err.Error()))
129	}
130
131	if m.analytics == nil {
132		return t.Box.Render(t.Subtle.Render("No data available"))
133	}
134
135	// Build header with summary stats
136	header := lipgloss.JoinVertical(
137		lipgloss.Left,
138		t.Title.Render("History Analytics"),
139		"",
140		lipgloss.JoinHorizontal(
141			lipgloss.Top,
142			t.Badge.Render(fmt.Sprintf("%d entries", m.analytics.TotalEntries)),
143			" ",
144			t.Badge.Render(fmt.Sprintf("%d visits", m.analytics.TotalVisits)),
145			" ",
146			t.BadgeMuted.Render(fmt.Sprintf("%d days", m.analytics.UniqueDays)),
147		),
148	)
149
150	// Domain table
151	tableHeader := t.Subtitle.Render("Top Domains")
152	tableView := m.table.View()
153
154	// Daily activity (simple bar chart)
155	var activityView string
156	if len(m.analytics.DailyVisits) > 0 {
157		activityView = m.renderDailyActivity()
158	}
159
160	// Help
161	helpView := t.Subtle.Render("q to quit")
162
163	// Combine
164	content := lipgloss.JoinVertical(
165		lipgloss.Left,
166		header,
167		"",
168		tableHeader,
169		tableView,
170	)
171
172	if activityView != "" {
173		content = lipgloss.JoinVertical(
174			lipgloss.Left,
175			content,
176			"",
177			t.Subtitle.Render("Daily Activity (Last 30 Days)"),
178			activityView,
179		)
180	}
181
182	content = lipgloss.JoinVertical(
183		lipgloss.Left,
184		content,
185		"",
186		helpView,
187	)
188
189	return content
190}
191
192// renderDailyActivity renders a simple sparkline-style bar chart.
193func (m StatsModel) renderDailyActivity() string {
194	if m.analytics == nil || len(m.analytics.DailyVisits) == 0 {
195		return ""
196	}
197
198	// Find max for scaling
199	var maxVisits int64
200	for _, d := range m.analytics.DailyVisits {
201		if d.Visits > maxVisits {
202			maxVisits = d.Visits
203		}
204	}
205
206	if maxVisits == 0 {
207		return m.theme.Subtle.Render("No activity")
208	}
209
210	// Build sparkline using block characters
211	blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
212	const (
213		sparklineMaxDays  = 30
214		sparklineMaxIndex = 7
215	)
216	var result string
217
218	// Show last 30 days or available data
219	days := m.analytics.DailyVisits
220	if len(days) > sparklineMaxDays {
221		days = days[len(days)-sparklineMaxDays:]
222	}
223
224	for _, d := range days {
225		// Scale to block index (0-7)
226		idx := int(float64(d.Visits) / float64(maxVisits) * sparklineMaxIndex)
227		if idx > sparklineMaxIndex {
228			idx = sparklineMaxIndex
229		}
230		result += string(blocks[idx])
231	}
232
233	return lipgloss.NewStyle().Foreground(m.theme.Accent).Render(result)
234}
235
236// formatNumber formats a number for display.
237func formatNumber(n int64) string {
238	const (
239		formatMillion  = 1_000_000
240		formatThousand = 1_000
241	)
242	switch {
243	case n >= formatMillion:
244		return fmt.Sprintf("%.1fM", float64(n)/formatMillion)
245	case n >= formatThousand:
246		return fmt.Sprintf("%.1fK", float64(n)/formatThousand)
247	default:
248		return fmt.Sprintf("%d", n)
249	}
250}
251
252// Ensure interface compliance.
253var _ tea.Model = (*StatsModel)(nil)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close