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)