main 5041353

stats.go RawHistoryFolder

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