Eric Bower
·
2025-04-01
analytics.go
1package tui
2
3import (
4 "fmt"
5 "math"
6 "time"
7
8 "git.sr.ht/~rockorager/vaxis"
9 "git.sr.ht/~rockorager/vaxis/vxfw"
10 "git.sr.ht/~rockorager/vaxis/vxfw/list"
11 "git.sr.ht/~rockorager/vaxis/vxfw/richtext"
12 "git.sr.ht/~rockorager/vaxis/vxfw/text"
13 "github.com/picosh/pico/pkg/db"
14 "github.com/picosh/utils"
15)
16
17type SitesLoaded struct{}
18type SiteStatsLoaded struct{}
19
20type AnalyticsPage struct {
21 shared *SharedModel
22
23 loadingSites bool
24 loadingDetails bool
25 sites []*db.VisitUrl
26 features []*db.FeatureFlag
27 err error
28 stats map[string]*db.SummaryVisits
29 selected string
30 interval string
31 focus string
32 leftPane list.Dynamic
33 rightPane *Pager
34}
35
36func NewAnalyticsPage(shrd *SharedModel) *AnalyticsPage {
37 page := &AnalyticsPage{
38 shared: shrd,
39 stats: map[string]*db.SummaryVisits{},
40 interval: "month",
41 focus: "sites",
42 }
43
44 page.leftPane = list.Dynamic{DrawCursor: true, Builder: page.getLeftWidget}
45 page.rightPane = NewPager()
46 return page
47}
48
49func (m *AnalyticsPage) Footer() []Shortcut {
50 ff := findAnalyticsFeature(m.features)
51 toggle := "enable analytics"
52 if ff != nil && ff.IsValid() {
53 toggle = "disable analytics"
54 }
55 short := []Shortcut{
56 {Shortcut: "j/k", Text: "choose"},
57 {Shortcut: "tab", Text: "focus"},
58 {Shortcut: "f", Text: "toggle filter (month/day)"},
59 }
60 if m.shared.PlusFeatureFlag != nil {
61 short = append(short, Shortcut{Shortcut: "t", Text: toggle})
62 }
63 return short
64}
65
66func (m *AnalyticsPage) getLeftWidget(i uint, cursor uint) vxfw.Widget {
67 if int(i) >= len(m.sites) {
68 return nil
69 }
70
71 site := m.sites[i]
72 txt := text.New(site.Url)
73 return txt
74}
75
76func (m *AnalyticsPage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
77 switch msg := ev.(type) {
78 case PageIn:
79 m.loadingSites = true
80 go m.fetchSites()
81 _ = m.fetchFeatures()
82 m.focus = "page"
83 return vxfw.FocusWidgetCmd(m), nil
84 case SitesLoaded:
85 if findAnalyticsFeature(m.features) == nil {
86 return vxfw.RedrawCmd{}, nil
87 }
88 m.focus = "sites"
89 return vxfw.BatchCmd([]vxfw.Command{
90 vxfw.FocusWidgetCmd(&m.leftPane),
91 vxfw.RedrawCmd{},
92 }), nil
93 case SiteStatsLoaded:
94 return vxfw.RedrawCmd{}, nil
95 case vaxis.Key:
96 if msg.Matches('f') {
97 if m.interval == "day" {
98 m.interval = "month"
99 } else {
100 m.interval = "day"
101 }
102 m.loadingDetails = true
103 go m.fetchSiteStats(m.selected, m.interval)
104 return vxfw.RedrawCmd{}, nil
105 }
106 if msg.Matches('t') {
107 enabled, err := m.toggleAnalytics()
108 if err != nil {
109 m.err = err
110 }
111 var wdgt vxfw.Widget = m
112 if enabled {
113 m.focus = "sites"
114 wdgt = &m.leftPane
115 } else {
116 m.focus = "page"
117 }
118 return vxfw.BatchCmd([]vxfw.Command{
119 vxfw.FocusWidgetCmd(wdgt),
120 vxfw.RedrawCmd{},
121 }), nil
122 }
123 if msg.Matches(vaxis.KeyEnter) {
124 m.selected = m.sites[m.leftPane.Cursor()].Url
125 m.loadingDetails = true
126 go m.fetchSiteStats(m.selected, m.interval)
127 return vxfw.RedrawCmd{}, nil
128 }
129 if msg.Matches(vaxis.KeyTab) {
130 var cmd vxfw.Widget
131 if findAnalyticsFeature(m.features) == nil {
132 return nil, nil
133 }
134 if m.focus == "sites" && m.selected != "" {
135 m.focus = "details"
136 cmd = m.rightPane
137 } else if m.focus == "details" {
138 m.focus = "sites"
139 cmd = &m.leftPane
140 } else if m.focus == "page" {
141 m.focus = "sites"
142 cmd = &m.leftPane
143 }
144 return vxfw.BatchCmd([]vxfw.Command{
145 vxfw.FocusWidgetCmd(cmd),
146 vxfw.RedrawCmd{},
147 }), nil
148 }
149 }
150 return nil, nil
151}
152
153func (m *AnalyticsPage) focusBorder(brd *Border) {
154 focus := m.focus
155 if focus == brd.Label {
156 brd.Style = vaxis.Style{Foreground: oj}
157 } else {
158 brd.Style = vaxis.Style{Foreground: purp}
159 }
160}
161
162func (m *AnalyticsPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
163 root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
164 ff := findAnalyticsFeature(m.features)
165 if ff == nil || !ff.IsValid() {
166 surf := m.banner(ctx)
167 root.AddChild(0, 0, surf)
168 return root, nil
169 }
170
171 leftPaneW := float32(ctx.Max.Width) * 0.35
172
173 var wdgt vxfw.Widget = text.New("No sites found")
174 if len(m.sites) > 0 {
175 wdgt = &m.leftPane
176 }
177
178 if m.loadingSites {
179 wdgt = text.New("Loading ...")
180 }
181
182 leftPane := NewBorder(wdgt)
183 leftPane.Label = "sites"
184 m.focusBorder(leftPane)
185 leftSurf, _ := leftPane.Draw(vxfw.DrawContext{
186 Characters: ctx.Characters,
187 Max: vxfw.Size{
188 Width: uint16(leftPaneW),
189 Height: ctx.Max.Height,
190 },
191 })
192
193 root.AddChild(0, 0, leftSurf)
194
195 rightPaneW := float32(ctx.Max.Width) * 0.65
196 if m.selected == "" {
197 rightWdgt := text.New("Select a site on the left to view its stats")
198 rightSurf, _ := rightWdgt.Draw(vxfw.DrawContext{
199 Characters: ctx.Characters,
200 Max: vxfw.Size{
201 Width: uint16(rightPaneW),
202 Height: ctx.Max.Height,
203 },
204 })
205 root.AddChild(int(leftPaneW), 0, rightSurf)
206 } else {
207 rightSurf := vxfw.NewSurface(uint16(rightPaneW), math.MaxUint16, m)
208
209 ah := 0
210
211 data, err := m.getSiteData()
212 if err != nil {
213 var txt vxfw.Surface
214 if m.loadingDetails {
215 txt, _ = text.New("Loading ...").Draw(ctx)
216 } else {
217 txt, _ = text.New("No data found").Draw(ctx)
218 }
219 m.rightPane.Surface = txt
220 rightPane := NewBorder(m.rightPane)
221 rightPane.Label = "details"
222 m.focusBorder(rightPane)
223 pagerSurf, _ := rightPane.Draw(vxfw.DrawContext{
224 Characters: ctx.Characters,
225 Max: vxfw.Size{Width: uint16(rightPaneW), Height: ctx.Max.Height},
226 })
227 rightSurf.AddChild(0, 0, pagerSurf)
228 root.AddChild(int(leftPaneW), 0, rightSurf)
229 return root, nil
230 }
231
232 rightCtx := vxfw.DrawContext{
233 Characters: vaxis.Characters,
234 Max: vxfw.Size{
235 Width: uint16(rightPaneW) - 2,
236 Height: ctx.Max.Height,
237 },
238 }
239
240 detailSurf, _ := m.detail(rightCtx, data.Intervals).Draw(rightCtx)
241 rightSurf.AddChild(0, ah, detailSurf)
242 ah += int(detailSurf.Size.Height)
243
244 urlSurf, _ := m.urls(rightCtx, data.TopUrls, "urls").Draw(rightCtx)
245 rightSurf.AddChild(0, ah, urlSurf)
246 ah += int(urlSurf.Size.Height)
247
248 urlSurf, _ = m.urls(rightCtx, data.NotFoundUrls, "not found").Draw(rightCtx)
249 rightSurf.AddChild(0, ah, urlSurf)
250 ah += int(urlSurf.Size.Height)
251
252 urlSurf, _ = m.urls(rightCtx, data.TopReferers, "referers").Draw(rightCtx)
253 rightSurf.AddChild(0, ah, urlSurf)
254 ah += int(urlSurf.Size.Height)
255
256 surf, _ := m.visits(rightCtx, data.Intervals).Draw(rightCtx)
257 rightSurf.AddChild(0, ah, surf)
258
259 m.rightPane.Surface = rightSurf
260 rightPane := NewBorder(m.rightPane)
261 rightPane.Label = "details"
262 m.focusBorder(rightPane)
263 pagerSurf, _ := rightPane.Draw(rightCtx)
264
265 root.AddChild(int(leftPaneW), 0, pagerSurf)
266 }
267
268 return root, nil
269}
270
271func (m *AnalyticsPage) getSiteData() (*db.SummaryVisits, error) {
272 val, ok := m.stats[m.selected+":"+m.interval]
273 if !ok {
274 return nil, fmt.Errorf("site data not found")
275 }
276 return val, nil
277}
278
279func (m *AnalyticsPage) detail(ctx vxfw.DrawContext, visits []*db.VisitInterval) vxfw.Widget {
280 datestr := ""
281 now := time.Now()
282 if m.interval == "day" {
283 datestr += now.Format("2006 Jan") + " by day"
284 } else {
285 datestr += now.Format("2006") + " by month"
286 }
287 kv := []Kv{
288 {Key: "date range", Value: datestr, Style: vaxis.Style{Foreground: green}},
289 }
290 sum := 0
291 for _, data := range visits {
292 sum += data.Visitors
293 }
294 avg := 0
295 if len(visits) > 0 {
296 avg = sum / len(visits)
297 }
298
299 kv = append(kv, Kv{Key: "avg req/period", Value: fmt.Sprintf("%d", avg)})
300
301 rightPane := NewBorder(NewKv(kv))
302 rightPane.Width = ctx.Max.Width
303 rightPane.Label = m.selected
304 m.focusBorder(rightPane)
305 return rightPane
306}
307
308func (m *AnalyticsPage) urls(ctx vxfw.DrawContext, urls []*db.VisitUrl, label string) vxfw.Widget {
309 kv := []Kv{}
310 w := 15
311 for _, url := range urls {
312 if len(url.Url) > w {
313 w = len(url.Url)
314 }
315 kv = append(kv, Kv{Key: url.Url, Value: fmt.Sprintf("%d", url.Count)})
316 }
317 wdgt := NewKv(kv)
318 wdgt.KeyColWidth = w + 1
319 rightPane := NewBorder(wdgt)
320 rightPane.Width = ctx.Max.Width
321 rightPane.Label = label
322 m.focusBorder(rightPane)
323 return rightPane
324}
325
326func (m *AnalyticsPage) visits(ctx vxfw.DrawContext, intervals []*db.VisitInterval) vxfw.Widget {
327 kv := []Kv{}
328 w := 0
329 for _, visit := range intervals {
330 key := visit.Interval.Format(time.DateOnly)
331 if len(key) > w {
332 w = len(key)
333 }
334 kv = append(
335 kv,
336 Kv{
337 Key: key,
338 Value: fmt.Sprintf("%d", visit.Visitors),
339 },
340 )
341 }
342 wdgt := NewKv(kv)
343 wdgt.KeyColWidth = w + 1
344 rightPane := NewBorder(wdgt)
345 rightPane.Width = ctx.Max.Width
346 rightPane.Label = "visits"
347 m.focusBorder(rightPane)
348 return rightPane
349}
350
351func (m *AnalyticsPage) fetchSites() {
352 siteList, err := m.shared.Dbpool.FindVisitSiteList(&db.SummaryOpts{
353 UserID: m.shared.User.ID,
354 Origin: utils.StartOfMonth(),
355 })
356 if err != nil {
357 m.loadingSites = false
358 m.err = err
359 return
360 }
361 m.sites = siteList
362 m.loadingSites = false
363 m.shared.App.PostEvent(SitesLoaded{})
364}
365
366func (m *AnalyticsPage) fetchSiteStats(site string, interval string) {
367 opts := &db.SummaryOpts{
368 Host: site,
369
370 UserID: m.shared.User.ID,
371 Interval: interval,
372 }
373
374 if interval == "day" {
375 opts.Origin = utils.StartOfMonth()
376 } else {
377 opts.Origin = utils.StartOfYear()
378 }
379
380 summary, err := m.shared.Dbpool.VisitSummary(opts)
381 if err != nil {
382 m.err = err
383 m.loadingDetails = false
384 return
385 }
386 m.stats[site+":"+interval] = summary
387 m.loadingDetails = false
388 m.shared.App.PostEvent(SiteStatsLoaded{})
389}
390
391func (m *AnalyticsPage) fetchFeatures() error {
392 features, err := m.shared.Dbpool.FindFeaturesForUser(m.shared.User.ID)
393 m.features = features
394 return err
395}
396
397func (m *AnalyticsPage) banner(ctx vxfw.DrawContext) vxfw.Surface {
398 segs := []vaxis.Segment{
399 {
400 Text: "Get usage statistics on your blog, blog posts, and pages sites. For example, see unique visitors, most popular URLs, and top referers.\n\n",
401 },
402 {
403 Text: "We do not collect usage statistic unless analytics is enabled. Further, when analytics are disabled we do not purge usage statistics.\n\n",
404 },
405 {
406 Text: "We will only store usage statistics for 1 year from when the event was created.\n\n",
407 },
408 }
409
410 if m.shared.PlusFeatureFlag == nil {
411 style := vaxis.Style{Foreground: red}
412 segs = append(segs,
413 vaxis.Segment{
414 Text: "Analytics is only available to pico+ users.\n\n",
415 Style: style,
416 })
417 } else {
418 style := vaxis.Style{Foreground: green}
419 segs = append(segs,
420 vaxis.Segment{
421 Text: "Press 't' to enable analytics\n\n",
422 Style: style,
423 })
424 }
425
426 analytics := richtext.New(segs)
427 brd := NewBorder(analytics)
428 brd.Label = "alert"
429 surf, _ := brd.Draw(ctx)
430 return surf
431}
432
433func (m *AnalyticsPage) toggleAnalytics() (bool, error) {
434 enabled := false
435 if findAnalyticsFeature(m.features) == nil {
436 now := time.Now()
437 expiresAt := now.AddDate(100, 0, 0)
438 _, err := m.shared.Dbpool.InsertFeature(m.shared.User.ID, "analytics", expiresAt)
439 if err != nil {
440 return false, err
441 }
442 enabled = true
443 } else {
444 err := m.shared.Dbpool.RemoveFeature(m.shared.User.ID, "analytics")
445 if err != nil {
446 return true, err
447 }
448 }
449
450 return enabled, m.fetchFeatures()
451}
452
453func findAnalyticsFeature(features []*db.FeatureFlag) *db.FeatureFlag {
454 for _, feature := range features {
455 if feature.Name == "analytics" {
456 return feature
457 }
458 }
459 return nil
460}