repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

pico / tui / analytics
Eric Bower · 30 Nov 24

analytics.go

  1package analytics
  2
  3import (
  4	"context"
  5	"fmt"
  6	"strings"
  7
  8	input "github.com/charmbracelet/bubbles/textinput"
  9	"github.com/charmbracelet/bubbles/viewport"
 10	tea "github.com/charmbracelet/bubbletea"
 11	"github.com/charmbracelet/glamour"
 12	"github.com/charmbracelet/lipgloss"
 13	"github.com/picosh/pico/db"
 14	"github.com/picosh/pico/tui/common"
 15	"github.com/picosh/pico/tui/pages"
 16	"github.com/picosh/utils"
 17)
 18
 19type state int
 20
 21const (
 22	stateLoading state = iota
 23	stateReady
 24)
 25
 26type errMsg error
 27
 28type SiteStatsLoaded struct {
 29	Summary *db.SummaryVisits
 30}
 31
 32type SiteListLoaded struct {
 33	Sites []*db.VisitUrl
 34}
 35
 36type PathStatsLoaded struct {
 37	Summary *db.SummaryVisits
 38}
 39
 40type HasAnalyticsFeature struct {
 41	Has bool
 42}
 43
 44type Model struct {
 45	shared           *common.SharedModel
 46	state            state
 47	logData          []map[string]any
 48	viewport         viewport.Model
 49	input            input.Model
 50	sub              chan map[string]any
 51	ctx              context.Context
 52	done             context.CancelFunc
 53	errMsg           error
 54	statsBySite      *db.SummaryVisits
 55	statsByPath      *db.SummaryVisits
 56	siteList         []*db.VisitUrl
 57	repl             string
 58	analyticsEnabled bool
 59}
 60
 61func headerHeight(shrd *common.SharedModel) int {
 62	return shrd.HeaderHeight
 63}
 64
 65func headerWidth(w int) int {
 66	return w - 2
 67}
 68
 69var helpMsg = `This view shows site usage analytics for prose, pages, and tuns.
 70
 71[Read our docs about site usage analytics](https://pico.sh/analytics)
 72
 73Shortcuts:
 74
 75- esc: leave page
 76- tab: toggle between viewport and input box
 77- ctrl+u: scroll viewport up a page
 78- ctrl+d: scroll viewport down a page
 79- j,k: scroll viewport
 80
 81Commands: [help, stats, site {domain}, post {slug}]
 82
 83**View usage stats for all sites for this month:**
 84
 85> stats
 86
 87**View usage stats for your site by month this year:**
 88
 89> site pico.sh
 90
 91**View usage stats for your site by day this month:**
 92
 93> site pico.sh day
 94
 95**View usage stats for your blog post by month this year:**
 96
 97> post my-post
 98
 99**View usage stats for blog posts by day this month:**
100
101> post my-post day
102
103`
104
105func NewModel(shrd *common.SharedModel) Model {
106	im := input.New()
107	im.Cursor.Style = shrd.Styles.Cursor
108	im.Placeholder = "type 'help' to learn how to use the repl"
109	im.PlaceholderStyle = shrd.Styles.InputPlaceholder
110	im.Prompt = shrd.Styles.FocusedPrompt.String()
111	im.CharLimit = 50
112	im.Focus()
113
114	hh := headerHeight(shrd)
115	ww := headerWidth(shrd.Width)
116	inputHeight := lipgloss.Height(im.View())
117	viewport := viewport.New(ww, shrd.Height-hh-inputHeight)
118	viewport.YPosition = hh
119
120	ctx, cancel := context.WithCancel(shrd.Session.Context())
121
122	return Model{
123		shared:   shrd,
124		state:    stateLoading,
125		viewport: viewport,
126		logData:  []map[string]any{},
127		input:    im,
128		sub:      make(chan map[string]any),
129		ctx:      ctx,
130		done:     cancel,
131	}
132}
133
134func (m Model) Init() tea.Cmd {
135	return m.hasAnalyticsFeature()
136}
137
138func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
139	var cmds []tea.Cmd
140	var cmd tea.Cmd
141	updateViewport := true
142	switch msg := msg.(type) {
143	case tea.WindowSizeMsg:
144		m.viewport.Width = headerWidth(msg.Width)
145		inputHeight := lipgloss.Height(m.input.View())
146		hh := headerHeight(m.shared)
147		m.viewport.Height = msg.Height - hh - inputHeight
148		m.viewport.SetContent(m.renderViewport())
149
150	case errMsg:
151		m.errMsg = msg
152
153	case pages.NavigateMsg:
154		// cancel activity logger
155		m.done()
156		// reset model
157		next := NewModel(m.shared)
158		return next, nil
159
160	case tea.KeyMsg:
161		switch msg.String() {
162		case "q", "esc":
163			return m, pages.Navigate(pages.MenuPage)
164		// when typing in input, ignore viewport updates
165		case " ", "k", "j":
166			if m.input.Focused() {
167				updateViewport = false
168			}
169		case "tab":
170			if m.input.Focused() {
171				m.input.Blur()
172			} else {
173				cmds = append(cmds, m.input.Focus())
174			}
175		case "enter":
176			replCmd := m.input.Value()
177			m.repl = replCmd
178			if replCmd == "stats" {
179				m.state = stateLoading
180				cmds = append(cmds, m.fetchSiteList())
181			} else if strings.HasPrefix(replCmd, "site") {
182				name, by := splitReplCmd(replCmd)
183				m.state = stateLoading
184				cmds = append(cmds, m.fetchSiteStats(name, by))
185			} else if strings.HasPrefix(replCmd, "post") {
186				slug, by := splitReplCmd(replCmd)
187				m.state = stateLoading
188				cmds = append(cmds, m.fetchPostStats(slug, by))
189			}
190
191			m.viewport.SetContent(m.renderViewport())
192			m.input.SetValue("")
193		}
194
195	case SiteStatsLoaded:
196		m.state = stateReady
197		m.statsBySite = msg.Summary
198		m.viewport.SetContent(m.renderViewport())
199
200	case PathStatsLoaded:
201		m.state = stateReady
202		m.statsByPath = msg.Summary
203		m.viewport.SetContent(m.renderViewport())
204
205	case SiteListLoaded:
206		m.state = stateReady
207		m.siteList = msg.Sites
208		m.viewport.SetContent(m.renderViewport())
209
210	case HasAnalyticsFeature:
211		m.state = stateReady
212		m.analyticsEnabled = msg.Has
213		m.viewport.SetContent(m.renderViewport())
214	}
215
216	m.input, cmd = m.input.Update(msg)
217	cmds = append(cmds, cmd)
218	if updateViewport {
219		m.viewport, cmd = m.viewport.Update(msg)
220		cmds = append(cmds, cmd)
221	}
222	return m, tea.Batch(cmds...)
223}
224
225func (m Model) View() string {
226	if m.errMsg != nil {
227		return m.shared.Styles.Error.Render(m.errMsg.Error())
228	}
229	return m.viewport.View() + "\n" + m.input.View()
230}
231
232func (m Model) renderViewport() string {
233	if m.state == stateLoading {
234		return "Loading ..."
235	}
236
237	if m.shared.PlusFeatureFlag == nil || !m.shared.PlusFeatureFlag.IsValid() {
238		return m.renderMd(`**Analytics is only available for pico+ users.**
239
240[Read our docs about site usage analytics](https://pico.sh/analytics)`)
241	}
242
243	if !m.analyticsEnabled {
244		return m.renderMd(`**Analytics must be enabled in the Settings page.**
245
246[Read our docs about site usage analytics](https://pico.sh/analytics)`)
247	}
248
249	cmd := m.repl
250	if cmd == "help" {
251		return m.renderMd(helpMsg)
252	} else if cmd == "stats" {
253		return m.renderSiteList()
254	} else if strings.HasPrefix(cmd, "site") {
255		return m.renderSiteStats(m.statsBySite)
256	} else if strings.HasPrefix(cmd, "post") {
257		return m.renderSiteStats(m.statsByPath)
258	}
259
260	return m.renderMd(helpMsg)
261}
262
263func (m Model) renderMd(md string) string {
264	r, _ := glamour.NewTermRenderer(
265		// detect background color and pick either the default dark or light theme
266		glamour.WithAutoStyle(),
267		glamour.WithWordWrap(m.viewport.Width),
268	)
269	out, _ := r.Render(md)
270	return out
271}
272
273func (m Model) renderSiteStats(summary *db.SummaryVisits) string {
274	name, by := splitReplCmd(m.repl)
275	str := m.shared.Styles.Label.SetString(fmt.Sprintf("%s by %s\n", name, by)).String()
276
277	if !strings.HasPrefix(m.repl, "post") {
278		str += "\nTop URLs\n"
279		topUrlsTbl := common.VisitUrlsTbl(summary.TopUrls, m.shared.Styles.Renderer, m.viewport.Width)
280		str += topUrlsTbl.String()
281
282		str += "\nTop Not Found URLs\n"
283		notFoundUrlsTbl := common.VisitUrlsTbl(summary.NotFoundUrls, m.shared.Styles.Renderer, m.viewport.Width)
284		str += notFoundUrlsTbl.String()
285	}
286
287	str += "\nTop Referers\n"
288	topRefsTbl := common.VisitUrlsTbl(summary.TopReferers, m.shared.Styles.Renderer, m.viewport.Width)
289	str += topRefsTbl.String()
290
291	if by == "day" {
292		str += "\nUnique Visitors by Day this Month\n"
293	} else {
294		str += "\nUnique Visitors by Month this Year\n"
295	}
296	uniqueTbl := common.UniqueVisitorsTbl(summary.Intervals, m.shared.Styles.Renderer, m.viewport.Width)
297	str += uniqueTbl.String()
298	return str
299}
300
301func (m Model) fetchSiteStats(site string, interval string) tea.Cmd {
302	return func() tea.Msg {
303		opts := &db.SummaryOpts{
304			Host: site,
305
306			UserID:   m.shared.User.ID,
307			Interval: interval,
308		}
309
310		if interval == "day" {
311			opts.Origin = utils.StartOfMonth()
312		} else {
313			opts.Origin = utils.StartOfYear()
314		}
315
316		summary, err := m.shared.Dbpool.VisitSummary(opts)
317		if err != nil {
318			return errMsg(err)
319		}
320
321		return SiteStatsLoaded{summary}
322	}
323}
324
325func (m Model) fetchPostStats(raw string, interval string) tea.Cmd {
326	return func() tea.Msg {
327		slug := raw
328		if !strings.HasPrefix(slug, "/") {
329			slug = "/" + raw
330		}
331
332		opts := &db.SummaryOpts{
333			Path: slug,
334
335			UserID:   m.shared.User.ID,
336			Interval: interval,
337		}
338
339		if interval == "day" {
340			opts.Origin = utils.StartOfMonth()
341		} else {
342			opts.Origin = utils.StartOfYear()
343		}
344
345		summary, err := m.shared.Dbpool.VisitSummary(opts)
346		if err != nil {
347			return errMsg(err)
348		}
349
350		return PathStatsLoaded{summary}
351	}
352}
353
354func (m Model) renderSiteList() string {
355	tbl := common.VisitUrlsTbl(m.siteList, m.shared.Styles.Renderer, m.viewport.Width)
356	str := "Sites: Total Unique Visitors\n"
357	str += tbl.String()
358	return str
359}
360
361func (m Model) fetchSiteList() tea.Cmd {
362	return func() tea.Msg {
363		siteList, err := m.shared.Dbpool.FindVisitSiteList(&db.SummaryOpts{
364			UserID: m.shared.User.ID,
365			Origin: utils.StartOfMonth(),
366		})
367		if err != nil {
368			return errMsg(err)
369		}
370		return SiteListLoaded{siteList}
371	}
372}
373
374func (m Model) hasAnalyticsFeature() tea.Cmd {
375	return func() tea.Msg {
376		feature := m.shared.Dbpool.HasFeatureForUser(m.shared.User.ID, "analytics")
377		return HasAnalyticsFeature{feature}
378	}
379}
380
381func splitReplCmd(replCmd string) (string, string) {
382	replRaw := strings.SplitN(replCmd, " ", 3)
383	name := strings.TrimSpace(replRaw[1])
384	by := "month"
385	if len(replRaw) > 2 {
386		by = replRaw[2]
387	}
388	return name, by
389}