repos / pico

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

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