Eric Bower
·
2025-05-05
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 cursor := int(m.leftPane.Cursor())
125 if cursor >= len(m.sites) {
126 return nil, nil
127 }
128 m.selected = m.sites[m.leftPane.Cursor()].Url
129 m.loadingDetails = true
130 go m.fetchSiteStats(m.selected, m.interval)
131 return vxfw.RedrawCmd{}, nil
132 }
133 if msg.Matches(vaxis.KeyTab) {
134 var cmd vxfw.Widget
135 if findAnalyticsFeature(m.features) == nil {
136 return nil, nil
137 }
138 if m.focus == "sites" && m.selected != "" {
139 m.focus = "details"
140 cmd = m.rightPane
141 } else if m.focus == "details" {
142 m.focus = "sites"
143 cmd = &m.leftPane
144 } else if m.focus == "page" {
145 m.focus = "sites"
146 cmd = &m.leftPane
147 }
148 return vxfw.BatchCmd([]vxfw.Command{
149 vxfw.FocusWidgetCmd(cmd),
150 vxfw.RedrawCmd{},
151 }), nil
152 }
153 }
154 return nil, nil
155}
156
157func (m *AnalyticsPage) focusBorder(brd *Border) {
158 focus := m.focus
159 if focus == brd.Label {
160 brd.Style = vaxis.Style{Foreground: oj}
161 } else {
162 brd.Style = vaxis.Style{Foreground: purp}
163 }
164}
165
166func (m *AnalyticsPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
167 root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
168 ff := findAnalyticsFeature(m.features)
169 if ff == nil || !ff.IsValid() {
170 surf := m.banner(ctx)
171 root.AddChild(0, 0, surf)
172 return root, nil
173 }
174
175 leftPaneW := float32(ctx.Max.Width) * 0.35
176
177 var wdgt vxfw.Widget = text.New("No sites found")
178 if len(m.sites) > 0 {
179 wdgt = &m.leftPane
180 }
181
182 if m.loadingSites {
183 wdgt = text.New("Loading ...")
184 }
185
186 leftPane := NewBorder(wdgt)
187 leftPane.Label = "sites"
188 m.focusBorder(leftPane)
189 leftSurf, _ := leftPane.Draw(vxfw.DrawContext{
190 Characters: ctx.Characters,
191 Max: vxfw.Size{
192 Width: uint16(leftPaneW),
193 Height: ctx.Max.Height,
194 },
195 })
196
197 root.AddChild(0, 0, leftSurf)
198
199 rightPaneW := float32(ctx.Max.Width) * 0.65
200 if m.selected == "" {
201 rightWdgt := text.New("Select a site on the left to view its stats")
202 rightSurf, _ := rightWdgt.Draw(vxfw.DrawContext{
203 Characters: ctx.Characters,
204 Max: vxfw.Size{
205 Width: uint16(rightPaneW),
206 Height: ctx.Max.Height,
207 },
208 })
209 root.AddChild(int(leftPaneW), 0, rightSurf)
210 } else {
211 rightSurf := vxfw.NewSurface(uint16(rightPaneW), math.MaxUint16, m)
212
213 ah := 0
214
215 data, err := m.getSiteData()
216 if err != nil {
217 var txt vxfw.Surface
218 if m.loadingDetails {
219 txt, _ = text.New("Loading ...").Draw(ctx)
220 } else {
221 txt, _ = text.New("No data found").Draw(ctx)
222 }
223 m.rightPane.Surface = txt
224 rightPane := NewBorder(m.rightPane)
225 rightPane.Label = "details"
226 m.focusBorder(rightPane)
227 pagerSurf, _ := rightPane.Draw(vxfw.DrawContext{
228 Characters: ctx.Characters,
229 Max: vxfw.Size{Width: uint16(rightPaneW), Height: ctx.Max.Height},
230 })
231 rightSurf.AddChild(0, 0, pagerSurf)
232 root.AddChild(int(leftPaneW), 0, rightSurf)
233 return root, nil
234 }
235
236 rightCtx := vxfw.DrawContext{
237 Characters: vaxis.Characters,
238 Max: vxfw.Size{
239 Width: uint16(rightPaneW) - 2,
240 Height: ctx.Max.Height,
241 },
242 }
243
244 detailSurf, _ := m.detail(rightCtx, data.Intervals).Draw(rightCtx)
245 rightSurf.AddChild(0, ah, detailSurf)
246 ah += int(detailSurf.Size.Height)
247
248 urlSurf, _ := m.urls(rightCtx, data.TopUrls, "urls").Draw(rightCtx)
249 rightSurf.AddChild(0, ah, urlSurf)
250 ah += int(urlSurf.Size.Height)
251
252 urlSurf, _ = m.urls(rightCtx, data.NotFoundUrls, "not found").Draw(rightCtx)
253 rightSurf.AddChild(0, ah, urlSurf)
254 ah += int(urlSurf.Size.Height)
255
256 urlSurf, _ = m.urls(rightCtx, data.TopReferers, "referers").Draw(rightCtx)
257 rightSurf.AddChild(0, ah, urlSurf)
258 ah += int(urlSurf.Size.Height)
259
260 surf, _ := m.visits(rightCtx, data.Intervals).Draw(rightCtx)
261 rightSurf.AddChild(0, ah, surf)
262
263 m.rightPane.Surface = rightSurf
264 rightPane := NewBorder(m.rightPane)
265 rightPane.Label = "details"
266 m.focusBorder(rightPane)
267 pagerSurf, _ := rightPane.Draw(rightCtx)
268
269 root.AddChild(int(leftPaneW), 0, pagerSurf)
270 }
271
272 return root, nil
273}
274
275func (m *AnalyticsPage) getSiteData() (*db.SummaryVisits, error) {
276 val, ok := m.stats[m.selected+":"+m.interval]
277 if !ok {
278 return nil, fmt.Errorf("site data not found")
279 }
280 return val, nil
281}
282
283func (m *AnalyticsPage) detail(ctx vxfw.DrawContext, visits []*db.VisitInterval) vxfw.Widget {
284 datestr := ""
285 now := time.Now()
286 if m.interval == "day" {
287 datestr += now.Format("2006 Jan") + " by day"
288 } else {
289 datestr += now.Format("2006") + " by month"
290 }
291 kv := []Kv{
292 {Key: "date range", Value: datestr, Style: vaxis.Style{Foreground: green}},
293 }
294 sum := 0
295 for _, data := range visits {
296 sum += data.Visitors
297 }
298 avg := 0
299 if len(visits) > 0 {
300 avg = sum / len(visits)
301 }
302
303 kv = append(kv, Kv{Key: "avg req/period", Value: fmt.Sprintf("%d", avg)})
304
305 rightPane := NewBorder(NewKv(kv))
306 rightPane.Width = ctx.Max.Width
307 rightPane.Label = m.selected
308 m.focusBorder(rightPane)
309 return rightPane
310}
311
312func (m *AnalyticsPage) urls(ctx vxfw.DrawContext, urls []*db.VisitUrl, label string) vxfw.Widget {
313 kv := []Kv{}
314 w := 15
315 for _, url := range urls {
316 if len(url.Url) > w {
317 w = len(url.Url)
318 }
319 kv = append(kv, Kv{Key: url.Url, Value: fmt.Sprintf("%d", url.Count)})
320 }
321 wdgt := NewKv(kv)
322 wdgt.KeyColWidth = w + 1
323 rightPane := NewBorder(wdgt)
324 rightPane.Width = ctx.Max.Width
325 rightPane.Label = label
326 m.focusBorder(rightPane)
327 return rightPane
328}
329
330func (m *AnalyticsPage) visits(ctx vxfw.DrawContext, intervals []*db.VisitInterval) vxfw.Widget {
331 kv := []Kv{}
332 w := 0
333 for _, visit := range intervals {
334 key := visit.Interval.Format(time.DateOnly)
335 if len(key) > w {
336 w = len(key)
337 }
338 kv = append(
339 kv,
340 Kv{
341 Key: key,
342 Value: fmt.Sprintf("%d", visit.Visitors),
343 },
344 )
345 }
346 wdgt := NewKv(kv)
347 wdgt.KeyColWidth = w + 1
348 rightPane := NewBorder(wdgt)
349 rightPane.Width = ctx.Max.Width
350 rightPane.Label = "visits"
351 m.focusBorder(rightPane)
352 return rightPane
353}
354
355func (m *AnalyticsPage) fetchSites() {
356 siteList, err := m.shared.Dbpool.FindVisitSiteList(&db.SummaryOpts{
357 UserID: m.shared.User.ID,
358 Origin: utils.StartOfMonth(),
359 })
360 if err != nil {
361 m.loadingSites = false
362 m.err = err
363 return
364 }
365 m.sites = siteList
366 m.loadingSites = false
367 m.shared.App.PostEvent(SitesLoaded{})
368}
369
370func (m *AnalyticsPage) fetchSiteStats(site string, interval string) {
371 opts := &db.SummaryOpts{
372 Host: site,
373
374 UserID: m.shared.User.ID,
375 Interval: interval,
376 }
377
378 if interval == "day" {
379 opts.Origin = utils.StartOfMonth()
380 } else {
381 opts.Origin = utils.StartOfYear()
382 }
383
384 summary, err := m.shared.Dbpool.VisitSummary(opts)
385 if err != nil {
386 m.err = err
387 m.loadingDetails = false
388 return
389 }
390 m.stats[site+":"+interval] = summary
391 m.loadingDetails = false
392 m.shared.App.PostEvent(SiteStatsLoaded{})
393}
394
395func (m *AnalyticsPage) fetchFeatures() error {
396 features, err := m.shared.Dbpool.FindFeaturesForUser(m.shared.User.ID)
397 m.features = features
398 return err
399}
400
401func (m *AnalyticsPage) banner(ctx vxfw.DrawContext) vxfw.Surface {
402 segs := []vaxis.Segment{
403 {
404 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",
405 },
406 {
407 Text: "We do not collect usage statistic unless analytics is enabled. Further, when analytics are disabled we do not purge usage statistics.\n\n",
408 },
409 {
410 Text: "We will only store usage statistics for 1 year from when the event was created.\n\n",
411 },
412 }
413
414 if m.shared.PlusFeatureFlag == nil {
415 style := vaxis.Style{Foreground: red}
416 segs = append(segs,
417 vaxis.Segment{
418 Text: "Analytics is only available to pico+ users.\n\n",
419 Style: style,
420 })
421 } else {
422 style := vaxis.Style{Foreground: green}
423 segs = append(segs,
424 vaxis.Segment{
425 Text: "Press 't' to enable analytics\n\n",
426 Style: style,
427 })
428 }
429
430 analytics := richtext.New(segs)
431 brd := NewBorder(analytics)
432 brd.Label = "alert"
433 surf, _ := brd.Draw(ctx)
434 return surf
435}
436
437func (m *AnalyticsPage) toggleAnalytics() (bool, error) {
438 enabled := false
439 if findAnalyticsFeature(m.features) == nil {
440 now := time.Now()
441 expiresAt := now.AddDate(100, 0, 0)
442 _, err := m.shared.Dbpool.InsertFeature(m.shared.User.ID, "analytics", expiresAt)
443 if err != nil {
444 return false, err
445 }
446 enabled = true
447 } else {
448 err := m.shared.Dbpool.RemoveFeature(m.shared.User.ID, "analytics")
449 if err != nil {
450 return true, err
451 }
452 }
453
454 return enabled, m.fetchFeatures()
455}
456
457func findAnalyticsFeature(features []*db.FeatureFlag) *db.FeatureFlag {
458 for _, feature := range features {
459 if feature.Name == "analytics" {
460 return feature
461 }
462 }
463 return nil
464}