main pico / pkg / tui / analytics.go
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}