diff --git a/CHANGELOG.md b/CHANGELOG.md index f92ced0..9a7c383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.2.45 - 2026-08-10 +- Bug fixes + +## Server 0.1.31 — 2026-08-10 +- Added: The admin console now has a Journeys page for visit health, feature use, significant actions, common paths and per-profile event history. + ## 0.2.43 — 2026-08-10 - Fixed: App no longer crashes. - Fixed: Manual surround-sound choices now reliably override the automatically detected audio output. diff --git a/server/README.md b/server/README.md index 3ce6f3f..fab7808 100644 --- a/server/README.md +++ b/server/README.md @@ -319,8 +319,8 @@ leaves the server. `MEMBY_RADARR_TTL` controls the shared calendar cache lifetim ## Admin interface -`https://mserver.sublogue.com/admin/` — a single self-contained page for library imports, the -maintenance switch and row engagement. Set `MEMBY_ADMIN_TOKEN` to enable it; unset, every +`https://mserver.sublogue.com/admin/` — a self-contained console for library imports, +maintenance, engagement and journeys. Set `MEMBY_ADMIN_TOKEN` to enable it; unset, every `/admin` route 404s so it cannot be left exposed by accident. The page first uses the same discreet Emby login gate as the private installer. After successful verification it establishes the HttpOnly admin cookie, but browser API requests require both that cookie @@ -349,6 +349,7 @@ the sign-in form with `next` pointing back at the page rather than as an error b | POST | `/admin/api/features` | Publish feature overrides, enter safe mode, reset defaults, or roll back one revision | | POST | `/admin/api/mdblist-settings` | Enable MDBList, replace/clear its API key, and select visible rating sources | | GET | `/admin/api/analytics?days=7` | Row engagement | +| GET | `/admin/api/journeys?days=30&userId=…` | Journey health, feature use, paths and event history | The **Features** admin page is the recovery surface for optional TV behaviour. Flags are registered in the server catalogue and persist only explicit overrides; clearing an @@ -567,6 +568,13 @@ Raw events are pruned after `MEMBY_ANALYTICS_RETENTION` (90 days) and aggregates computed at read time, so nothing survives the prune. This is tuning telemetry, not a record of what anyone watched. +Journey events are ingested separately at `POST /v1/analytics/events` and have their own +**Journeys** admin page. The gateway derives completion, abandonment, active visits, +feature use, significant actions and common paths from the retained events. An unfinished +visit remains active for 30 minutes before it counts as abandoned. Selecting one profile +reveals its ordered event history; search text, content titles and setting values are never +accepted into this stream. + ## Caching Redis holds everything user-scoped under `u::*`, plus session lookups under @@ -648,5 +656,3 @@ can review and revoke signed-in TVs from the app's Settings screen. - **Cache warming.** Rows go cold after `MEMBY_HOME_TTL`; the first TV to ask pays for the refresh. A background refresher per active session would hide that. - **Rate limiting** on `/v1/auth/login`. -- **Per-user analytics breakdown.** Events carry a user id, but the admin page only shows - totals per row. diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go index fdaa332..7916f85 100644 --- a/server/internal/api/admin.go +++ b/server/internal/api/admin.go @@ -26,8 +26,8 @@ const adminCookieName = "memby_admin" // a valid session, so the only thing it can extend is its own sign-in. const adminActivityHeader = "X-Memby-Admin-Active" -// adminRoutes is the operator interface: library imports, the maintenance switch, and -// row engagement. Disabled entirely when MEMBY_ADMIN_TOKEN is unset, so it cannot be +// adminRoutes is the operator interface: library imports, maintenance and analytics. +// Disabled entirely when MEMBY_ADMIN_TOKEN is unset, so it cannot be // left exposed by accident. func (s *Server) adminRoutes() http.Handler { mux := http.NewServeMux() @@ -57,6 +57,7 @@ func (s *Server) adminRoutes() http.Handler { mux.Handle("PUT /admin/api/accounts/{userID}/themes", s.adminAuth(s.handleAdminUserThemes)) mux.Handle("GET /admin/api/recommendations", s.adminAuth(s.handleAdminRecommendations)) mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics)) + mux.Handle("GET /admin/api/journeys", s.adminAuth(s.handleAdminJourneys)) mux.Handle("GET /admin/api/searches", s.adminAuth(s.handleAdminSearches)) mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents)) mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime)) @@ -720,6 +721,8 @@ func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "could not read analytics") return } + // Keep the journey fields on this older endpoint for scripts written before journeys + // gained their own page and endpoint. The console itself only reads rows from here. users, err := s.store.AnalyticsUsers(r.Context(), since) if err != nil { s.loggerFor(r.Context()).Error("user analytics failed", "error", err) @@ -733,7 +736,7 @@ func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) { paths, pathErr := s.store.UserPaths(r.Context(), userID, since) events, eventErr := s.store.UserJourneyEvents(r.Context(), userID, since, 1000) if featureErr != nil || pathErr != nil || eventErr != nil { - s.loggerFor(r.Context()).Error("user journey read failed", "user_id", userID) + s.loggerFor(r.Context()).Error("legacy user journey read failed", "user_id", userID) writeError(w, http.StatusInternalServerError, "could not read user journey") return } @@ -745,6 +748,42 @@ func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, payload) } +func (s *Server) handleAdminJourneys(w http.ResponseWriter, r *http.Request) { + days := queryInt(r, "days", 30, 90) + since := time.Now().UTC().AddDate(0, 0, -days) + userID := strings.TrimSpace(r.URL.Query().Get("userId")) + users, err := s.store.AnalyticsUsers(r.Context(), since) + if err != nil { + s.loggerFor(r.Context()).Error("journey users failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not read journeys") + return + } + stats, statsErr := s.store.JourneyStats(r.Context(), userID, since) + features, featureErr := s.store.UserFeatureStats(r.Context(), userID, since) + paths, pathErr := s.store.UserPaths(r.Context(), userID, since) + actions, actionErr := s.store.JourneyActionStats(r.Context(), userID, since) + if statsErr != nil || featureErr != nil || pathErr != nil || actionErr != nil { + s.loggerFor(r.Context()).Error("journey analytics read failed", "user_id", userID) + writeError(w, http.StatusInternalServerError, "could not read journeys") + return + } + payload := map[string]any{ + "days": days, "retentionDays": int(s.cfg.AnalyticsRetention / (24 * time.Hour)), + "users": users, "stats": stats, "features": features, "paths": paths, "actions": actions, + } + if userID != "" { + events, eventErr := s.store.UserJourneyEvents(r.Context(), userID, since, 1000) + if eventErr != nil { + s.loggerFor(r.Context()).Error("user journey read failed", "user_id", userID) + writeError(w, http.StatusInternalServerError, "could not read user journey") + return + } + payload["userId"] = userID + payload["events"] = events + } + writeJSON(w, http.StatusOK, payload) +} + // syncerHandle is the slice of the syncer the API needs, so api does not depend on the // concrete type for testing. type syncerHandle interface { diff --git a/server/internal/api/admin/pages/engagement.html b/server/internal/api/admin/pages/engagement.html index 532e397..0d636c0 100644 --- a/server/internal/api/admin/pages/engagement.html +++ b/server/internal/api/admin/pages/engagement.html @@ -25,53 +25,3 @@ - -
-
-
-

User journeys

-

Choose an Emby profile to review feature use, common paths and - significant actions in time order. Search text, content titles and setting values - are not stored in journey analytics.

-
- -
-
-
- - - - - - diff --git a/server/internal/api/admin/pages/engagement.js b/server/internal/api/admin/pages/engagement.js index 8b05684..77bc072 100644 --- a/server/internal/api/admin/pages/engagement.js +++ b/server/internal/api/admin/pages/engagement.js @@ -1,11 +1,5 @@ const { fmt, ui, $ } = Admin; -const featureCatalogue = [ - 'home', 'movies', 'shows', 'favorites', 'search', 'recent_searches', - 'genre_browse', 'for_you', 'for_you_time', 'recommendation', 'continue', - 'latest', 'my_shows', 'details', 'playback', 'notifications', 'profiles', 'settings', -]; - const label = (value) => { const text = String(value || '—').replaceAll('_', ' '); if (text === 'favorites') return 'Favourites'; @@ -13,65 +7,8 @@ const label = (value) => { return text; }; -function renderUser(payload) { - const selected = $('engagement-user').value; - const users = payload.users || []; - const current = users.find((user) => user.userId === selected); - $('engagement-user-tiles').innerHTML = current ? ui.tiles([ - ['events', fmt.number(current.events), { icon: 'pulse', tone: 'info' }], - ['journeys', fmt.number(current.journeys), { icon: 'list', tone: 'data' }], - ['last active', fmt.when(current.lastActiveAt), { icon: 'clock', tone: 'note', small: true }], - ['history kept', payload.retentionDays + ' days', { icon: 'clock', small: true }], - ]) : ''; - - ['feature', 'path', 'journey'].forEach((name) => { - $('engagement-' + name + '-card').hidden = !current; - }); - if (!current) return; - - const used = new Map((payload.features || []).map((feature) => [feature.feature, feature])); - const features = [...new Set([...featureCatalogue, ...used.keys()])]; - $('engagement-features').innerHTML = features.map((name) => { - const stat = used.get(name); - const uses = stat?.uses || 0; - const status = uses === 0 ? ui.tag('not used', 'warn') : uses < 3 ? ui.tag('rare', 'note') : ui.tag('used', 'ok'); - return '' + fmt.escape(label(name)) + '' + fmt.number(uses) + - '' + (stat ? fmt.when(stat.lastUsedAt) : '—') + '' + status + ''; - }).join(''); - - const paths = payload.paths || []; - $('engagement-paths').innerHTML = paths.length ? paths.map((path) => - '' + fmt.escape(label(path.from)) + '' + fmt.escape(label(path.to)) + - '' + fmt.number(path.count) + '').join('') - : ui.emptyRow(3, 'No repeated paths in this window.'); - - const grouped = new Map(); - (payload.events || []).forEach((event) => { - if (!grouped.has(event.journeyId)) grouped.set(event.journeyId, []); - grouped.get(event.journeyId).push(event); - }); - let journeyNumber = grouped.size; - const rows = []; - grouped.forEach((events) => { - events.sort((a, b) => a.sequence - b.sequence); - const number = journeyNumber--; - events.forEach((event) => { - const path = event.source && event.target ? label(event.source) + ' → ' + label(event.target) - : label(event.target || event.screen); - const content = event.itemId ? label(event.itemType) + ' · ' + event.itemId : '—'; - rows.push('' + fmt.when(event.occurredAt) + '' + - '' + number + '' + fmt.escape(label(event.action)) + '' + - '' + fmt.escape(path) + '' + fmt.escape(label(event.feature)) + '' + - '' + fmt.escape(content) + '' + fmt.escape(label(event.outcome)) + ''); - }); - }); - $('engagement-events').innerHTML = rows.length ? rows.join('') : ui.emptyRow(7, 'No journey events in this window.'); -} - Admin.onRefresh(async () => { - const selected = $('engagement-user').value; - const payload = await Admin.api('/admin/api/analytics?days=' + $('engagement-days').value + - (selected ? '&userId=' + encodeURIComponent(selected) : '')); + const payload = await Admin.api('/admin/api/analytics?days=' + $('engagement-days').value); const rows = payload.rows || []; $('engagement-rows').innerHTML = rows.length ? rows.map((row) => '' + fmt.escape(label(row.rowId)) + '' + @@ -84,13 +21,6 @@ Admin.onRefresh(async () => { '' + fmt.number(row.viewers) + '').join('') : ui.emptyRow(8, 'No events in this window.'); - const users = payload.users || []; - const existing = $('engagement-user').value; - $('engagement-user').innerHTML = '' + users.map((user) => - '').join(''); - if (users.some((user) => user.userId === existing)) $('engagement-user').value = existing; - renderUser(payload); }); Admin.ready(() => $('engagement-days').addEventListener('change', Admin.refresh)); -Admin.ready(() => $('engagement-user').addEventListener('change', Admin.refresh)); diff --git a/server/internal/api/admin/pages/journeys.html b/server/internal/api/admin/pages/journeys.html new file mode 100644 index 0000000..b7079f2 --- /dev/null +++ b/server/internal/api/admin/pages/journeys.html @@ -0,0 +1,66 @@ +
+
+
+

Journey health

+

Server-derived foreground visits, completion and interruption. + Search text, content titles and setting values are never stored.

+
+
+ + +
+
+
+
+ +
+
+

Feature use

+

Rare and unused features are shown against Memby's major feature catalogue.

+
+
+ + +
FeatureUsesLast usedStatus
+
+ +
+
+

Significant actions

+

Actions are counted by event and by distinct journey, so repeated use + inside one visit remains visible without looking like more visits.

+
+
+ + +
CategoryActionEventsJourneys
+
+ +
+
+

Common paths

+

Repeated transitions reveal routes into playback and places viewers + commonly leave a flow. A quiet unfinished journey becomes abandoned after 30 minutes.

+
+
+ + +
FromToTimes
+
+ + diff --git a/server/internal/api/admin/pages/journeys.js b/server/internal/api/admin/pages/journeys.js new file mode 100644 index 0000000..26d38a3 --- /dev/null +++ b/server/internal/api/admin/pages/journeys.js @@ -0,0 +1,89 @@ +const { fmt, ui, $ } = Admin; + +const featureCatalogue = [ + 'home', 'movies', 'shows', 'favorites', 'search', 'recent_searches', + 'genre_browse', 'for_you', 'for_you_time', 'recommendation', 'continue', + 'latest', 'my_shows', 'details', 'playback', 'notifications', 'profiles', 'settings', +]; + +const label = (value) => { + const text = String(value || '—').replaceAll('_', ' '); + if (text === 'favorites') return 'Favourites'; + if (text === 'abandoned') return 'Abandoned / interrupted'; + return text; +}; + +function renderEvents(events) { + const grouped = new Map(); + events.forEach((event) => { + if (!grouped.has(event.journeyId)) grouped.set(event.journeyId, []); + grouped.get(event.journeyId).push(event); + }); + let journeyNumber = grouped.size; + const rows = []; + grouped.forEach((steps) => { + steps.sort((a, b) => a.sequence - b.sequence); + const number = journeyNumber--; + steps.forEach((event) => { + const path = event.source && event.target ? label(event.source) + ' → ' + label(event.target) + : label(event.target || event.screen); + const content = event.itemId ? label(event.itemType) + ' · ' + event.itemId : '—'; + rows.push('' + fmt.when(event.occurredAt) + '' + + '' + number + '' + fmt.escape(label(event.action)) + '' + + '' + fmt.escape(path) + '' + fmt.escape(label(event.feature)) + '' + + '' + fmt.escape(content) + '' + fmt.escape(label(event.outcome)) + ''); + }); + }); + $('journeys-events').innerHTML = rows.length ? rows.join('') : ui.emptyRow(7, 'No journey events in this window.'); +} + +Admin.onRefresh(async () => { + const selected = $('journeys-user').value; + const payload = await Admin.api('/admin/api/journeys?days=' + $('journeys-days').value + + (selected ? '&userId=' + encodeURIComponent(selected) : '')); + const users = payload.users || []; + const existing = $('journeys-user').value; + $('journeys-user').innerHTML = '' + users.map((user) => + '').join(''); + if (users.some((user) => user.userId === existing)) $('journeys-user').value = existing; + + const stats = payload.stats || {}; + $('journeys-tiles').innerHTML = ui.tiles([ + ['journeys', fmt.number(stats.journeys || 0), { icon: 'list', tone: 'data' }], + ['viewers', fmt.number(stats.viewers || 0), { icon: 'people', tone: 'info' }], + ['completion', Math.round((stats.completionRate || 0) * 100) + '%', { icon: 'check', tone: 'ok' }], + ['abandoned', fmt.number(stats.abandoned || 0), { icon: 'alert', tone: 'note' }], + ['active now', fmt.number(stats.active || 0), { icon: 'pulse', tone: 'info' }], + ['average steps', Number(stats.averageSteps || 0).toFixed(1), { icon: 'chart' }], + ['average visit', fmt.duration(stats.averageTimeMs || 0), { icon: 'clock', small: true }], + ['history kept', payload.retentionDays + ' days', { icon: 'clock', small: true }], + ]); + + const used = new Map((payload.features || []).map((feature) => [feature.feature, feature])); + const features = [...new Set([...featureCatalogue, ...used.keys()])]; + $('journeys-features').innerHTML = features.map((name) => { + const stat = used.get(name); + const uses = stat?.uses || 0; + const status = uses === 0 ? ui.tag('not used', 'warn') : uses < 3 ? ui.tag('rare', 'note') : ui.tag('used', 'ok'); + return '' + fmt.escape(label(name)) + '' + fmt.number(uses) + + '' + (stat ? fmt.when(stat.lastUsedAt) : '—') + '' + status + ''; + }).join(''); + + const actions = payload.actions || []; + $('journeys-actions').innerHTML = actions.length ? actions.map((action) => + '' + fmt.escape(label(action.category)) + '' + fmt.escape(label(action.action)) + + '' + fmt.number(action.events) + '' + + fmt.number(action.journeys) + '').join('') : ui.emptyRow(4, 'No significant actions in this window.'); + + const paths = payload.paths || []; + $('journeys-paths').innerHTML = paths.length ? paths.map((path) => + '' + fmt.escape(label(path.from)) + '' + fmt.escape(label(path.to)) + + '' + fmt.number(path.count) + '').join('') + : ui.emptyRow(3, 'No repeated paths in this window.'); + + $('journeys-events-card').hidden = !selected; + if (selected) renderEvents(payload.events || []); +}); + +Admin.ready(() => $('journeys-days').addEventListener('change', Admin.refresh)); +Admin.ready(() => $('journeys-user').addEventListener('change', Admin.refresh)); diff --git a/server/internal/api/admin_console.go b/server/internal/api/admin_console.go index bc7855e..cf33577 100644 --- a/server/internal/api/admin_console.go +++ b/server/internal/api/admin_console.go @@ -137,6 +137,11 @@ var adminNav = []adminNavGroup{ { Label: "Operations", Items: []adminNavItem{ + { + ID: "journeys", Label: "Journeys", Title: "User journeys", + Intro: "How viewers move through Memby, use features and complete flows.", + Icon: "M4 6h5v5h6v7h5M7 3 4 6l3 3m10 6 3 3-3 3", + }, { ID: "maintenance", Label: "Maintenance", Title: "Maintenance", Intro: "Take Memby offline for every television.", diff --git a/server/internal/api/admin_preview_test.go b/server/internal/api/admin_preview_test.go index 7cb8978..bfdb106 100644 --- a/server/internal/api/admin_preview_test.go +++ b/server/internal/api/admin_preview_test.go @@ -166,6 +166,28 @@ func adminPreviewData() map[string]any { map[string]any{"rowId": "favorites", "title": "Favourites", "impressions": 1610, "focuses": 300, "selections": 74, "averageDwellMs": 1800}, }}, + "/admin/api/journeys": map[string]any{ + "days": 30, "retentionDays": 90, + "stats": map[string]any{"events": 184, "journeys": 28, "viewers": 2, + "completed": 21, "abandoned": 5, "active": 2, "averageSteps": 6.6, + "averageTimeMs": 1140000, "completionRate": 0.81}, + "users": []any{ + map[string]any{"userId": "u-1", "username": "matt", "events": 120, "journeys": 18, "lastActiveAt": stamp(9 * time.Minute)}, + map[string]any{"userId": "u-2", "username": "sam", "events": 64, "journeys": 10, "lastActiveAt": stamp(3 * time.Hour)}, + }, + "features": []any{ + map[string]any{"feature": "playback", "uses": 31, "lastUsedAt": stamp(9 * time.Minute)}, + map[string]any{"feature": "search", "uses": 12, "lastUsedAt": stamp(3 * time.Hour)}, + }, + "actions": []any{ + map[string]any{"category": "content", "action": "open", "events": 44, "journeys": 22}, + map[string]any{"category": "playback", "action": "start", "events": 24, "journeys": 18}, + }, + "paths": []any{ + map[string]any{"from": "home", "to": "details", "count": 31}, + map[string]any{"from": "details", "to": "playback", "count": 18}, + }, + }, "/admin/api/requests": map[string]any{"requests": []any{}}, // A prefix among the terms and an unattributed row in the log, because both are // ordinary here and a preview showing neither would not be a preview of this page. diff --git a/server/internal/api/admin_test.go b/server/internal/api/admin_test.go index b09e1ee..9073f31 100644 --- a/server/internal/api/admin_test.go +++ b/server/internal/api/admin_test.go @@ -576,7 +576,7 @@ func TestAdminPagesUseRealRoutes(t *testing.T) { for _, page := range []string{ "overview", "accounts", "clients", "library", "recommendations", "inspector", "requests", "ratings", "features", "playback", "maintenance", "updates", - "engagement", "imports", "logs", + "journeys", "engagement", "imports", "logs", } { req := httptest.NewRequest(http.MethodGet, "/admin/"+page, nil) addInstallerSession(t, server, req) diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index 013adb7..db7a480 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.30 +0.1.31 diff --git a/server/internal/store/analytics.go b/server/internal/store/analytics.go index 1e52a64..d6a6080 100644 --- a/server/internal/store/analytics.go +++ b/server/internal/store/analytics.go @@ -97,6 +97,28 @@ type PathStat struct { Count int64 `json:"count"` } +// JourneyStats is the server-derived health of foreground visits in a reporting window. +// An unfinished visit is only abandoned once it has been quiet for thirty minutes; until +// then it is active, so an open television does not immediately look like a failed flow. +type JourneyStats struct { + Events int64 `json:"events"` + Journeys int64 `json:"journeys"` + Viewers int64 `json:"viewers"` + Completed int64 `json:"completed"` + Abandoned int64 `json:"abandoned"` + Active int64 `json:"active"` + AverageSteps float64 `json:"averageSteps"` + AverageTimeMs int64 `json:"averageTimeMs"` + CompletionRate float64 `json:"completionRate"` +} + +type JourneyActionStat struct { + Category string `json:"category"` + Action string `json:"action"` + Events int64 `json:"events"` + Journeys int64 `json:"journeys"` +} + // Event kinds. Impressions say a row was drawn; focus says the remote actually landed // on it and for how long; select says something was opened from it. const ( @@ -231,10 +253,64 @@ func (s *Store) AnalyticsUsers(ctx context.Context, since time.Time) ([]Analytic return out, rows.Err() } +func (s *Store) JourneyStats(ctx context.Context, userID string, since time.Time) (JourneyStats, error) { + var value JourneyStats + err := s.pool.QueryRow(ctx, ` + WITH visits AS ( + SELECT emby_user_id, journey_id, count(*) AS steps, + min(occurred_at) AS started_at, max(occurred_at) AS last_at, + bool_or(action = 'journey_end') AS completed + FROM journey_events + WHERE occurred_at >= $1 AND ($2 = '' OR emby_user_id = $2) + GROUP BY emby_user_id, journey_id + ) + SELECT coalesce(sum(steps), 0), count(*), count(DISTINCT emby_user_id), + count(*) FILTER (WHERE completed), + count(*) FILTER (WHERE NOT completed AND last_at < now() - interval '30 minutes'), + count(*) FILTER (WHERE NOT completed AND last_at >= now() - interval '30 minutes'), + coalesce(avg(steps), 0)::double precision, + round(coalesce(avg(extract(epoch FROM (last_at - started_at)) * 1000) + FILTER (WHERE completed), 0))::bigint + FROM visits`, since, userID).Scan( + &value.Events, &value.Journeys, &value.Viewers, &value.Completed, + &value.Abandoned, &value.Active, &value.AverageSteps, &value.AverageTimeMs, + ) + if err != nil { + return JourneyStats{}, fmt.Errorf("store: journey stats: %w", err) + } + finished := value.Completed + value.Abandoned + if finished > 0 { + value.CompletionRate = float64(value.Completed) / float64(finished) + } + return value, nil +} + +func (s *Store) JourneyActionStats(ctx context.Context, userID string, since time.Time) ([]JourneyActionStat, error) { + rows, err := s.pool.Query(ctx, ` + SELECT category, action, count(*), count(DISTINCT journey_id) + FROM journey_events + WHERE occurred_at >= $1 AND ($2 = '' OR emby_user_id = $2) + AND action NOT IN ('journey_start', 'journey_end') + GROUP BY category, action ORDER BY count(*) DESC, category, action`, since, userID) + if err != nil { + return nil, fmt.Errorf("store: journey action stats: %w", err) + } + defer rows.Close() + out := []JourneyActionStat{} + for rows.Next() { + var value JourneyActionStat + if err := rows.Scan(&value.Category, &value.Action, &value.Events, &value.Journeys); err != nil { + return nil, err + } + out = append(out, value) + } + return out, rows.Err() +} + func (s *Store) UserFeatureStats(ctx context.Context, userID string, since time.Time) ([]FeatureStat, error) { rows, err := s.pool.Query(ctx, ` SELECT feature, count(*), max(occurred_at) FROM journey_events - WHERE emby_user_id=$1 AND occurred_at >= $2 AND feature <> '' + WHERE ($1 = '' OR emby_user_id=$1) AND occurred_at >= $2 AND feature <> '' AND action NOT IN ('screen_view', 'journey_start', 'journey_end') GROUP BY feature ORDER BY count(*) DESC, feature`, userID, since) if err != nil { @@ -259,7 +335,7 @@ func (s *Store) UserPaths(ctx context.Context, userID string, since time.Time) ( coalesce(nullif(target,''), nullif(screen,''), feature) AS node, lag(coalesce(nullif(target,''), nullif(screen,''), feature)) OVER (PARTITION BY journey_id ORDER BY sequence, occurred_at, id) AS previous - FROM journey_events WHERE emby_user_id=$1 AND occurred_at >= $2 + FROM journey_events WHERE ($1 = '' OR emby_user_id=$1) AND occurred_at >= $2 ), path_steps AS ( SELECT previous AS from_node, node AS to_node FROM ordered WHERE previous IS NOT NULL AND node IS NOT NULL AND previous <> node