Eric Bower
·
30 Nov 24
analytics.go
1package analytics
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 input "github.com/charmbracelet/bubbles/textinput"
9 "github.com/charmbracelet/bubbles/viewport"
10 tea "github.com/charmbracelet/bubbletea"
11 "github.com/charmbracelet/glamour"
12 "github.com/charmbracelet/lipgloss"
13 "github.com/picosh/pico/db"
14 "github.com/picosh/pico/tui/common"
15 "github.com/picosh/pico/tui/pages"
16 "github.com/picosh/utils"
17)
18
19type state int
20
21const (
22 stateLoading state = iota
23 stateReady
24)
25
26type errMsg error
27
28type SiteStatsLoaded struct {
29 Summary *db.SummaryVisits
30}
31
32type SiteListLoaded struct {
33 Sites []*db.VisitUrl
34}
35
36type PathStatsLoaded struct {
37 Summary *db.SummaryVisits
38}
39
40type HasAnalyticsFeature struct {
41 Has bool
42}
43
44type Model struct {
45 shared *common.SharedModel
46 state state
47 logData []map[string]any
48 viewport viewport.Model
49 input input.Model
50 sub chan map[string]any
51 ctx context.Context
52 done context.CancelFunc
53 errMsg error
54 statsBySite *db.SummaryVisits
55 statsByPath *db.SummaryVisits
56 siteList []*db.VisitUrl
57 repl string
58 analyticsEnabled bool
59}
60
61func headerHeight(shrd *common.SharedModel) int {
62 return shrd.HeaderHeight
63}
64
65func headerWidth(w int) int {
66 return w - 2
67}
68
69var helpMsg = `This view shows site usage analytics for prose, pages, and tuns.
70
71[Read our docs about site usage analytics](https://pico.sh/analytics)
72
73Shortcuts:
74
75- esc: leave page
76- tab: toggle between viewport and input box
77- ctrl+u: scroll viewport up a page
78- ctrl+d: scroll viewport down a page
79- j,k: scroll viewport
80
81Commands: [help, stats, site {domain}, post {slug}]
82
83**View usage stats for all sites for this month:**
84
85> stats
86
87**View usage stats for your site by month this year:**
88
89> site pico.sh
90
91**View usage stats for your site by day this month:**
92
93> site pico.sh day
94
95**View usage stats for your blog post by month this year:**
96
97> post my-post
98
99**View usage stats for blog posts by day this month:**
100
101> post my-post day
102
103`
104
105func NewModel(shrd *common.SharedModel) Model {
106 im := input.New()
107 im.Cursor.Style = shrd.Styles.Cursor
108 im.Placeholder = "type 'help' to learn how to use the repl"
109 im.PlaceholderStyle = shrd.Styles.InputPlaceholder
110 im.Prompt = shrd.Styles.FocusedPrompt.String()
111 im.CharLimit = 50
112 im.Focus()
113
114 hh := headerHeight(shrd)
115 ww := headerWidth(shrd.Width)
116 inputHeight := lipgloss.Height(im.View())
117 viewport := viewport.New(ww, shrd.Height-hh-inputHeight)
118 viewport.YPosition = hh
119
120 ctx, cancel := context.WithCancel(shrd.Session.Context())
121
122 return Model{
123 shared: shrd,
124 state: stateLoading,
125 viewport: viewport,
126 logData: []map[string]any{},
127 input: im,
128 sub: make(chan map[string]any),
129 ctx: ctx,
130 done: cancel,
131 }
132}
133
134func (m Model) Init() tea.Cmd {
135 return m.hasAnalyticsFeature()
136}
137
138func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
139 var cmds []tea.Cmd
140 var cmd tea.Cmd
141 updateViewport := true
142 switch msg := msg.(type) {
143 case tea.WindowSizeMsg:
144 m.viewport.Width = headerWidth(msg.Width)
145 inputHeight := lipgloss.Height(m.input.View())
146 hh := headerHeight(m.shared)
147 m.viewport.Height = msg.Height - hh - inputHeight
148 m.viewport.SetContent(m.renderViewport())
149
150 case errMsg:
151 m.errMsg = msg
152
153 case pages.NavigateMsg:
154 // cancel activity logger
155 m.done()
156 // reset model
157 next := NewModel(m.shared)
158 return next, nil
159
160 case tea.KeyMsg:
161 switch msg.String() {
162 case "q", "esc":
163 return m, pages.Navigate(pages.MenuPage)
164 // when typing in input, ignore viewport updates
165 case " ", "k", "j":
166 if m.input.Focused() {
167 updateViewport = false
168 }
169 case "tab":
170 if m.input.Focused() {
171 m.input.Blur()
172 } else {
173 cmds = append(cmds, m.input.Focus())
174 }
175 case "enter":
176 replCmd := m.input.Value()
177 m.repl = replCmd
178 if replCmd == "stats" {
179 m.state = stateLoading
180 cmds = append(cmds, m.fetchSiteList())
181 } else if strings.HasPrefix(replCmd, "site") {
182 name, by := splitReplCmd(replCmd)
183 m.state = stateLoading
184 cmds = append(cmds, m.fetchSiteStats(name, by))
185 } else if strings.HasPrefix(replCmd, "post") {
186 slug, by := splitReplCmd(replCmd)
187 m.state = stateLoading
188 cmds = append(cmds, m.fetchPostStats(slug, by))
189 }
190
191 m.viewport.SetContent(m.renderViewport())
192 m.input.SetValue("")
193 }
194
195 case SiteStatsLoaded:
196 m.state = stateReady
197 m.statsBySite = msg.Summary
198 m.viewport.SetContent(m.renderViewport())
199
200 case PathStatsLoaded:
201 m.state = stateReady
202 m.statsByPath = msg.Summary
203 m.viewport.SetContent(m.renderViewport())
204
205 case SiteListLoaded:
206 m.state = stateReady
207 m.siteList = msg.Sites
208 m.viewport.SetContent(m.renderViewport())
209
210 case HasAnalyticsFeature:
211 m.state = stateReady
212 m.analyticsEnabled = msg.Has
213 m.viewport.SetContent(m.renderViewport())
214 }
215
216 m.input, cmd = m.input.Update(msg)
217 cmds = append(cmds, cmd)
218 if updateViewport {
219 m.viewport, cmd = m.viewport.Update(msg)
220 cmds = append(cmds, cmd)
221 }
222 return m, tea.Batch(cmds...)
223}
224
225func (m Model) View() string {
226 if m.errMsg != nil {
227 return m.shared.Styles.Error.Render(m.errMsg.Error())
228 }
229 return m.viewport.View() + "\n" + m.input.View()
230}
231
232func (m Model) renderViewport() string {
233 if m.state == stateLoading {
234 return "Loading ..."
235 }
236
237 if m.shared.PlusFeatureFlag == nil || !m.shared.PlusFeatureFlag.IsValid() {
238 return m.renderMd(`**Analytics is only available for pico+ users.**
239
240[Read our docs about site usage analytics](https://pico.sh/analytics)`)
241 }
242
243 if !m.analyticsEnabled {
244 return m.renderMd(`**Analytics must be enabled in the Settings page.**
245
246[Read our docs about site usage analytics](https://pico.sh/analytics)`)
247 }
248
249 cmd := m.repl
250 if cmd == "help" {
251 return m.renderMd(helpMsg)
252 } else if cmd == "stats" {
253 return m.renderSiteList()
254 } else if strings.HasPrefix(cmd, "site") {
255 return m.renderSiteStats(m.statsBySite)
256 } else if strings.HasPrefix(cmd, "post") {
257 return m.renderSiteStats(m.statsByPath)
258 }
259
260 return m.renderMd(helpMsg)
261}
262
263func (m Model) renderMd(md string) string {
264 r, _ := glamour.NewTermRenderer(
265 // detect background color and pick either the default dark or light theme
266 glamour.WithAutoStyle(),
267 glamour.WithWordWrap(m.viewport.Width),
268 )
269 out, _ := r.Render(md)
270 return out
271}
272
273func (m Model) renderSiteStats(summary *db.SummaryVisits) string {
274 name, by := splitReplCmd(m.repl)
275 str := m.shared.Styles.Label.SetString(fmt.Sprintf("%s by %s\n", name, by)).String()
276
277 if !strings.HasPrefix(m.repl, "post") {
278 str += "\nTop URLs\n"
279 topUrlsTbl := common.VisitUrlsTbl(summary.TopUrls, m.shared.Styles.Renderer, m.viewport.Width)
280 str += topUrlsTbl.String()
281
282 str += "\nTop Not Found URLs\n"
283 notFoundUrlsTbl := common.VisitUrlsTbl(summary.NotFoundUrls, m.shared.Styles.Renderer, m.viewport.Width)
284 str += notFoundUrlsTbl.String()
285 }
286
287 str += "\nTop Referers\n"
288 topRefsTbl := common.VisitUrlsTbl(summary.TopReferers, m.shared.Styles.Renderer, m.viewport.Width)
289 str += topRefsTbl.String()
290
291 if by == "day" {
292 str += "\nUnique Visitors by Day this Month\n"
293 } else {
294 str += "\nUnique Visitors by Month this Year\n"
295 }
296 uniqueTbl := common.UniqueVisitorsTbl(summary.Intervals, m.shared.Styles.Renderer, m.viewport.Width)
297 str += uniqueTbl.String()
298 return str
299}
300
301func (m Model) fetchSiteStats(site string, interval string) tea.Cmd {
302 return func() tea.Msg {
303 opts := &db.SummaryOpts{
304 Host: site,
305
306 UserID: m.shared.User.ID,
307 Interval: interval,
308 }
309
310 if interval == "day" {
311 opts.Origin = utils.StartOfMonth()
312 } else {
313 opts.Origin = utils.StartOfYear()
314 }
315
316 summary, err := m.shared.Dbpool.VisitSummary(opts)
317 if err != nil {
318 return errMsg(err)
319 }
320
321 return SiteStatsLoaded{summary}
322 }
323}
324
325func (m Model) fetchPostStats(raw string, interval string) tea.Cmd {
326 return func() tea.Msg {
327 slug := raw
328 if !strings.HasPrefix(slug, "/") {
329 slug = "/" + raw
330 }
331
332 opts := &db.SummaryOpts{
333 Path: slug,
334
335 UserID: m.shared.User.ID,
336 Interval: interval,
337 }
338
339 if interval == "day" {
340 opts.Origin = utils.StartOfMonth()
341 } else {
342 opts.Origin = utils.StartOfYear()
343 }
344
345 summary, err := m.shared.Dbpool.VisitSummary(opts)
346 if err != nil {
347 return errMsg(err)
348 }
349
350 return PathStatsLoaded{summary}
351 }
352}
353
354func (m Model) renderSiteList() string {
355 tbl := common.VisitUrlsTbl(m.siteList, m.shared.Styles.Renderer, m.viewport.Width)
356 str := "Sites: Total Unique Visitors\n"
357 str += tbl.String()
358 return str
359}
360
361func (m Model) fetchSiteList() tea.Cmd {
362 return func() tea.Msg {
363 siteList, err := m.shared.Dbpool.FindVisitSiteList(&db.SummaryOpts{
364 UserID: m.shared.User.ID,
365 Origin: utils.StartOfMonth(),
366 })
367 if err != nil {
368 return errMsg(err)
369 }
370 return SiteListLoaded{siteList}
371 }
372}
373
374func (m Model) hasAnalyticsFeature() tea.Cmd {
375 return func() tea.Msg {
376 feature := m.shared.Dbpool.HasFeatureForUser(m.shared.User.ID, "analytics")
377 return HasAnalyticsFeature{feature}
378 }
379}
380
381func splitReplCmd(replCmd string) (string, string) {
382 replRaw := strings.SplitN(replCmd, " ", 3)
383 name := strings.TrimSpace(replRaw[1])
384 by := "month"
385 if len(replRaw) > 2 {
386 by = replRaw[2]
387 }
388 return name, by
389}