0.2.45 - Server side git commits

This commit is contained in:
ponzischeme89
2026-08-10 20:39:26 +12:00
parent 56c1167382
commit 4c47a5f8a0
12 changed files with 321 additions and 132 deletions
+42 -3
View File
@@ -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 {
@@ -25,53 +25,3 @@
</table>
</div>
</section>
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="people" data-icon-tone="info">User journeys</h2>
<p class="card-note">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.</p>
</div>
<label class="field narrow"><span>User</span>
<select id="engagement-user"><option value="">Choose a user</option></select></label>
</div>
<div class="tiles" id="engagement-user-tiles"></div>
</section>
<section class="card" id="engagement-feature-card" hidden>
<div class="card-head"><div>
<h2 class="card-title" data-icon="pulse" data-icon-tone="data">Feature use</h2>
<p class="card-note">Rare and unused features are shown explicitly against Memby's
major feature catalogue.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>Feature</th><th class="num">Uses</th><th>Last used</th><th>Status</th></tr></thead>
<tbody id="engagement-features"></tbody>
</table></div>
</section>
<section class="card" id="engagement-path-card" hidden>
<div class="card-head"><div>
<h2 class="card-title" data-icon="list" data-icon-tone="note">Common paths</h2>
<p class="card-note">Repeated transitions reveal routes into playback and places a
viewer commonly leaves a flow.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>From</th><th>To</th><th class="num">Times</th></tr></thead>
<tbody id="engagement-paths"></tbody>
</table></div>
</section>
<section class="card" id="engagement-journey-card" hidden>
<div class="card-head"><div>
<h2 class="card-title" data-icon="clock" data-icon-tone="info">Chronological journey</h2>
<p class="card-note">Newest journeys first; actions within each journey run from start
to finish. A journey without an end event indicates an interruption or abandonment.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>Time</th><th>Journey</th><th>Action</th><th>Screen / path</th><th>Feature</th><th>Content reference</th><th>Outcome</th></tr></thead>
<tbody id="engagement-events"></tbody>
</table></div>
</section>
+1 -71
View File
@@ -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 '<tr><td>' + fmt.escape(label(name)) + '</td><td class="num">' + fmt.number(uses) +
'</td><td class="muted">' + (stat ? fmt.when(stat.lastUsedAt) : '—') + '</td><td>' + status + '</td></tr>';
}).join('');
const paths = payload.paths || [];
$('engagement-paths').innerHTML = paths.length ? paths.map((path) =>
'<tr><td>' + fmt.escape(label(path.from)) + '</td><td>' + fmt.escape(label(path.to)) +
'</td><td class="num">' + fmt.number(path.count) + '</td></tr>').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('<tr><td class="muted">' + fmt.when(event.occurredAt) + '</td>' +
'<td class="num">' + number + '</td><td>' + fmt.escape(label(event.action)) + '</td>' +
'<td>' + fmt.escape(path) + '</td><td>' + fmt.escape(label(event.feature)) + '</td>' +
'<td class="muted">' + fmt.escape(content) + '</td><td>' + fmt.escape(label(event.outcome)) + '</td></tr>');
});
});
$('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) =>
'<tr><td>' + fmt.escape(label(row.rowId)) + '</td>' +
@@ -84,13 +21,6 @@ Admin.onRefresh(async () => {
'<td class="num">' + fmt.number(row.viewers) + '</td></tr>').join('')
: ui.emptyRow(8, 'No events in this window.');
const users = payload.users || [];
const existing = $('engagement-user').value;
$('engagement-user').innerHTML = '<option value="">Choose a user</option>' + users.map((user) =>
'<option value="' + fmt.escape(user.userId) + '">' + fmt.escape(user.username || user.userId) + '</option>').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));
@@ -0,0 +1,66 @@
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="people" data-icon-tone="info">Journey health</h2>
<p class="card-note">Server-derived foreground visits, completion and interruption.
Search text, content titles and setting values are never stored.</p>
</div>
<div class="row bottom">
<label class="field narrow"><span>Window</span>
<select id="journeys-days">
<option value="1">24 hours</option><option value="7">7 days</option>
<option value="30" selected>30 days</option><option value="90">90 days</option>
</select></label>
<label class="field narrow"><span>User</span>
<select id="journeys-user"><option value="">All users</option></select></label>
</div>
</div>
<div class="tiles" id="journeys-tiles"></div>
</section>
<section class="card">
<div class="card-head"><div>
<h2 class="card-title" data-icon="pulse" data-icon-tone="data">Feature use</h2>
<p class="card-note">Rare and unused features are shown against Memby's major feature catalogue.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>Feature</th><th class="num">Uses</th><th>Last used</th><th>Status</th></tr></thead>
<tbody id="journeys-features"></tbody>
</table></div>
</section>
<section class="card">
<div class="card-head"><div>
<h2 class="card-title" data-icon="chart" data-icon-tone="info">Significant actions</h2>
<p class="card-note">Actions are counted by event and by distinct journey, so repeated use
inside one visit remains visible without looking like more visits.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>Category</th><th>Action</th><th class="num">Events</th><th class="num">Journeys</th></tr></thead>
<tbody id="journeys-actions"></tbody>
</table></div>
</section>
<section class="card">
<div class="card-head"><div>
<h2 class="card-title" data-icon="list" data-icon-tone="note">Common paths</h2>
<p class="card-note">Repeated transitions reveal routes into playback and places viewers
commonly leave a flow. A quiet unfinished journey becomes abandoned after 30 minutes.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>From</th><th>To</th><th class="num">Times</th></tr></thead>
<tbody id="journeys-paths"></tbody>
</table></div>
</section>
<section class="card" id="journeys-events-card" hidden>
<div class="card-head"><div>
<h2 class="card-title" data-icon="clock" data-icon-tone="info">Chronological journeys</h2>
<p class="card-note">Choose one user to inspect visits. Newest visits appear first;
actions within each visit run from start to finish.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>Time</th><th>Journey</th><th>Action</th><th>Screen / path</th><th>Feature</th><th>Content reference</th><th>Outcome</th></tr></thead>
<tbody id="journeys-events"></tbody>
</table></div>
</section>
@@ -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('<tr><td class="muted">' + fmt.when(event.occurredAt) + '</td>' +
'<td class="num">' + number + '</td><td>' + fmt.escape(label(event.action)) + '</td>' +
'<td>' + fmt.escape(path) + '</td><td>' + fmt.escape(label(event.feature)) + '</td>' +
'<td class="muted">' + fmt.escape(content) + '</td><td>' + fmt.escape(label(event.outcome)) + '</td></tr>');
});
});
$('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 = '<option value="">All users</option>' + users.map((user) =>
'<option value="' + fmt.escape(user.userId) + '">' + fmt.escape(user.username || user.userId) + '</option>').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 '<tr><td>' + fmt.escape(label(name)) + '</td><td class="num">' + fmt.number(uses) +
'</td><td class="muted">' + (stat ? fmt.when(stat.lastUsedAt) : '—') + '</td><td>' + status + '</td></tr>';
}).join('');
const actions = payload.actions || [];
$('journeys-actions').innerHTML = actions.length ? actions.map((action) =>
'<tr><td>' + fmt.escape(label(action.category)) + '</td><td>' + fmt.escape(label(action.action)) +
'</td><td class="num">' + fmt.number(action.events) + '</td><td class="num">' +
fmt.number(action.journeys) + '</td></tr>').join('') : ui.emptyRow(4, 'No significant actions in this window.');
const paths = payload.paths || [];
$('journeys-paths').innerHTML = paths.length ? paths.map((path) =>
'<tr><td>' + fmt.escape(label(path.from)) + '</td><td>' + fmt.escape(label(path.to)) +
'</td><td class="num">' + fmt.number(path.count) + '</td></tr>').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));
+5
View File
@@ -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.",
+22
View File
@@ -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.
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -1 +1 @@
0.1.30
0.1.31
+78 -2
View File
@@ -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