Eric Bower
·
2026-05-10
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/pico/pkg/shared"
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 deviceSurf, _ := m.devices(rightCtx, data.Intervals).Draw(rightCtx)
261 rightSurf.AddChild(0, ah, deviceSurf)
262 ah += int(deviceSurf.Size.Height)
263
264 surf, _ := m.visits(rightCtx, data.Intervals).Draw(rightCtx)
265 rightSurf.AddChild(0, ah, surf)
266
267 m.rightPane.Surface = rightSurf
268 rightPane := NewBorder(m.rightPane)
269 rightPane.Label = "details"
270 m.focusBorder(rightPane)
271 pagerSurf, _ := rightPane.Draw(rightCtx)
272
273 root.AddChild(int(leftPaneW), 0, pagerSurf)
274 }
275
276 return root, nil
277}
278
279func (m *AnalyticsPage) getSiteData() (*db.SummaryVisits, error) {
280 val, ok := m.stats[m.selected+":"+m.interval]
281 if !ok {
282 return nil, fmt.Errorf("site data not found")
283 }
284 return val, nil
285}
286
287func (m *AnalyticsPage) detail(ctx vxfw.DrawContext, visits []*db.VisitInterval) vxfw.Widget {
288 datestr := ""
289 now := time.Now()
290 if m.interval == "day" {
291 datestr += now.Format("2006 Jan") + " by day"
292 } else {
293 datestr += now.Format("2006") + " by month"
294 }
295 kv := []Kv{
296 {Key: "date range", Value: datestr, Style: vaxis.Style{Foreground: green}},
297 }
298 sum := 0
299 for _, data := range visits {
300 sum += data.Visitors
301 }
302 avg := 0
303 if len(visits) > 0 {
304 avg = sum / len(visits)
305 }
306
307 kv = append(kv, Kv{Key: "avg req/period", Value: fmt.Sprintf("%d", avg)})
308
309 rightPane := NewBorder(NewKv(kv))
310 rightPane.Width = ctx.Max.Width
311 rightPane.Label = m.selected
312 m.focusBorder(rightPane)
313 return rightPane
314}
315
316func (m *AnalyticsPage) urls(ctx vxfw.DrawContext, urls []*db.VisitUrl, label string) vxfw.Widget {
317 kv := []Kv{}
318 w := 15
319 for _, url := range urls {
320 if len(url.Url) > w {
321 w = len(url.Url)
322 }
323 kv = append(kv, Kv{Key: url.Url, Value: fmt.Sprintf("%d", url.Count)})
324 }
325 wdgt := NewKv(kv)
326 wdgt.KeyColWidth = w + 1
327 rightPane := NewBorder(wdgt)
328 rightPane.Width = ctx.Max.Width
329 rightPane.Label = label
330 m.focusBorder(rightPane)
331 return rightPane
332}
333
334func (m *AnalyticsPage) devices(ctx vxfw.DrawContext, intervals []*db.VisitInterval) vxfw.Widget {
335 mobile, desktop := 0, 0
336 for _, visit := range intervals {
337 mobile += visit.MobileVisitors
338 desktop += visit.DesktopVisitors
339 }
340 total := mobile + desktop
341 mobilePct := 0.0
342 desktopPct := 0.0
343 if total > 0 {
344 mobilePct = float64(mobile) / float64(total) * 100
345 desktopPct = float64(desktop) / float64(total) * 100
346 }
347 kv := []Kv{
348 {Key: "mobile", Value: fmt.Sprintf("%d (%.1f%%)", mobile, mobilePct)},
349 {Key: "desktop", Value: fmt.Sprintf("%d (%.1f%%)", desktop, desktopPct)},
350 }
351 wdgt := NewKv(kv)
352 rightPane := NewBorder(wdgt)
353 rightPane.Width = ctx.Max.Width
354 rightPane.Label = "devices"
355 m.focusBorder(rightPane)
356 return rightPane
357}
358
359func (m *AnalyticsPage) visits(ctx vxfw.DrawContext, intervals []*db.VisitInterval) vxfw.Widget {
360 kv := []Kv{}
361 w := 0
362 for _, visit := range intervals {
363 key := visit.Interval.Format(time.DateOnly)
364 if len(key) > w {
365 w = len(key)
366 }
367 kv = append(
368 kv,
369 Kv{
370 Key: key,
371 Value: fmt.Sprintf("%d", visit.Visitors),
372 },
373 )
374 }
375 wdgt := NewKv(kv)
376 wdgt.KeyColWidth = w + 1
377 rightPane := NewBorder(wdgt)
378 rightPane.Width = ctx.Max.Width
379 rightPane.Label = "visits"
380 m.focusBorder(rightPane)
381 return rightPane
382}
383
384func (m *AnalyticsPage) fetchSites() {
385 siteList, err := m.shared.Dbpool.FindVisitSiteList(&db.SummaryOpts{
386 UserID: m.shared.User.ID,
387 Origin: shared.StartOfMonth(),
388 })
389 if err != nil {
390 m.loadingSites = false
391 m.err = err
392 return
393 }
394 m.sites = siteList
395 m.loadingSites = false
396 m.shared.App.PostEvent(SitesLoaded{})
397}
398
399func (m *AnalyticsPage) fetchSiteStats(site string, interval string) {
400 opts := &db.SummaryOpts{
401 Host: site,
402
403 UserID: m.shared.User.ID,
404 Interval: interval,
405 }
406
407 if interval == "day" {
408 opts.Origin = shared.StartOfMonth()
409 } else {
410 opts.Origin = shared.StartOfYear()
411 }
412
413 summary, err := m.shared.Dbpool.VisitSummary(opts)
414 if err != nil {
415 m.err = err
416 m.loadingDetails = false
417 return
418 }
419 m.stats[site+":"+interval] = summary
420 m.loadingDetails = false
421 m.shared.App.PostEvent(SiteStatsLoaded{})
422}
423
424func (m *AnalyticsPage) fetchFeatures() error {
425 features, err := m.shared.Dbpool.FindFeaturesByUser(m.shared.User.ID)
426 m.features = features
427 return err
428}
429
430func (m *AnalyticsPage) banner(ctx vxfw.DrawContext) vxfw.Surface {
431 segs := []vaxis.Segment{
432 {
433 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",
434 },
435 {
436 Text: "We do not collect usage statistic unless analytics is enabled. Further, when analytics are disabled we do not purge usage statistics.\n\n",
437 },
438 {
439 Text: "We will only store usage statistics for 1 year from when the event was created.\n\n",
440 },
441 }
442
443 if m.shared.PlusFeatureFlag == nil {
444 style := vaxis.Style{Foreground: red}
445 segs = append(segs,
446 vaxis.Segment{
447 Text: "Analytics is only available to pico+ users.\n\n",
448 Style: style,
449 })
450 } else {
451 style := vaxis.Style{Foreground: green}
452 segs = append(segs,
453 vaxis.Segment{
454 Text: "Press 't' to enable analytics\n\n",
455 Style: style,
456 })
457 }
458
459 analytics := richtext.New(segs)
460 brd := NewBorder(analytics)
461 brd.Label = "alert"
462 surf, _ := brd.Draw(ctx)
463 return surf
464}
465
466func (m *AnalyticsPage) toggleAnalytics() (bool, error) {
467 enabled := false
468 if findAnalyticsFeature(m.features) == nil {
469 now := time.Now()
470 expiresAt := now.AddDate(100, 0, 0)
471 _, err := m.shared.Dbpool.InsertFeature(m.shared.User.ID, "analytics", expiresAt)
472 if err != nil {
473 return false, err
474 }
475 enabled = true
476 } else {
477 err := m.shared.Dbpool.RemoveFeature(m.shared.User.ID, "analytics")
478 if err != nil {
479 return true, err
480 }
481 }
482
483 return enabled, m.fetchFeatures()
484}
485
486func findAnalyticsFeature(features []*db.FeatureFlag) *db.FeatureFlag {
487 for _, feature := range features {
488 if feature.Name == "analytics" {
489 return feature
490 }
491 }
492 return nil
493}