repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / tui
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}