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)