300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { query } from '../api/client';
|
|
import { useQuery } from '../lib/hooks';
|
|
import { duration, num, percent, when } from '../lib/format';
|
|
import {
|
|
Banner,
|
|
Card,
|
|
EmptyRow,
|
|
Field,
|
|
Grid,
|
|
Loading,
|
|
Meter,
|
|
PageHead,
|
|
TableWrap,
|
|
Tag,
|
|
Tiles,
|
|
} from '../components/ui';
|
|
|
|
/* Memby's major feature catalogue, in the order it is presented.
|
|
*
|
|
* It is a list here rather than derived from what has been used, and that is the whole
|
|
* point of the table it feeds: a feature nobody has touched has no row in the analytics
|
|
* and would simply be absent, which is indistinguishable from a feature that does not
|
|
* exist. Listing it and showing a zero is what makes "not used" an answer. */
|
|
const FEATURE_CATALOGUE = [
|
|
'home', 'movies', 'shows', 'favorites', 'search', 'recent_searches',
|
|
'genre_browse', 'for_you', 'for_you_time', 'recommendation', 'continue',
|
|
'latest', 'my_shows', 'details', 'playback', 'notifications', 'profiles', 'settings',
|
|
];
|
|
|
|
/** The wire values are ids; this is the render, so it is spelled — the same boundary the
|
|
* launcher's own row titles observe. */
|
|
function label(value: string | undefined | null): string {
|
|
const text = String(value || '—').replaceAll('_', ' ');
|
|
if (text === 'favorites') return 'Favourites';
|
|
if (text === 'abandoned') return 'Abandoned / interrupted';
|
|
return text;
|
|
}
|
|
|
|
interface JourneysResponse {
|
|
days: number;
|
|
retentionDays: number;
|
|
stats: {
|
|
events: number;
|
|
journeys: number;
|
|
viewers: number;
|
|
completed: number;
|
|
abandoned: number;
|
|
active: number;
|
|
averageSteps: number;
|
|
averageTimeMs: number;
|
|
completionRate: number;
|
|
};
|
|
users: { userId: string; username: string }[] | null;
|
|
features: { feature: string; uses: number; lastUsedAt: string }[] | null;
|
|
actions: { category: string; action: string; events: number; journeys: number }[] | null;
|
|
paths: { from: string; to: string; count: number }[] | null;
|
|
}
|
|
|
|
export function JourneysPage() {
|
|
const [days, setDays] = useState(30);
|
|
const [userId, setUserId] = useState('');
|
|
const navigate = useNavigate();
|
|
const path = useMemo(() => `/admin/api/journeys${query({ days, userId })}`, [days, userId]);
|
|
const { data, error, loading } = useQuery<JourneysResponse>(path);
|
|
|
|
const stats = data?.stats;
|
|
const users = data?.users ?? [];
|
|
const actions = data?.actions ?? [];
|
|
const paths = data?.paths ?? [];
|
|
const topPath = paths[0];
|
|
|
|
const features = useMemo(() => {
|
|
const used = new Map((data?.features ?? []).map((feature) => [feature.feature, feature]));
|
|
const position = new Map(FEATURE_CATALOGUE.map((name, index) => [name, index]));
|
|
return [...new Set([...FEATURE_CATALOGUE, ...used.keys()])]
|
|
.map((name) => ({ name, stat: used.get(name) }))
|
|
.sort((left, right) => {
|
|
const byUse = (right.stat?.uses ?? 0) - (left.stat?.uses ?? 0);
|
|
if (byUse) return byUse;
|
|
return (
|
|
(position.get(left.name) ?? Number.MAX_SAFE_INTEGER) -
|
|
(position.get(right.name) ?? Number.MAX_SAFE_INTEGER)
|
|
);
|
|
});
|
|
}, [data?.features]);
|
|
|
|
// Individual visits are shown only for one person: across a household they are a wall
|
|
// of cards with nothing to compare against, and the question they answer is always
|
|
// "what did *they* do".
|
|
return (
|
|
<>
|
|
<PageHead
|
|
title="User journeys"
|
|
intro="How viewers move through Memby, use features and complete flows."
|
|
/>
|
|
<Banner message={error} />
|
|
|
|
<Card
|
|
title="Journey health"
|
|
intro="Server-derived foreground visits, completion and interruption. Search text, content titles and setting values are never stored."
|
|
icon="people"
|
|
tone="info"
|
|
actions={
|
|
<>
|
|
<Field label="Window">
|
|
<select value={days} onChange={(event) => setDays(Number(event.target.value))}>
|
|
<option value={1}>24 hours</option>
|
|
<option value={7}>7 days</option>
|
|
<option value={30}>30 days</option>
|
|
<option value={90}>90 days</option>
|
|
</select>
|
|
</Field>
|
|
<Field label="User">
|
|
<select
|
|
value={userId}
|
|
onChange={(event) => {
|
|
const next = event.target.value;
|
|
setUserId(next);
|
|
if (next) navigate(`/admin/journeys/${encodeURIComponent(next)}`);
|
|
}}
|
|
>
|
|
<option value="">All users</option>
|
|
{users.map((user) => (
|
|
<option key={user.userId} value={user.userId}>
|
|
{user.username || user.userId}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
</>
|
|
}
|
|
>
|
|
{loading ? (
|
|
<Loading rows={1} />
|
|
) : (
|
|
<>
|
|
<Tiles
|
|
tiles={[
|
|
{ label: 'journeys', value: num(stats?.journeys), icon: 'list', tone: 'data' },
|
|
{ label: 'viewers', value: num(stats?.viewers), icon: 'people', tone: 'info' },
|
|
{ label: 'completion', value: percent(stats?.completionRate), icon: 'check', tone: 'ok' },
|
|
{ label: 'abandoned', value: num(stats?.abandoned), icon: 'alert', tone: 'note' },
|
|
{ label: 'active now', value: num(stats?.active), icon: 'pulse', tone: 'info' },
|
|
{ label: 'average steps', value: (stats?.averageSteps ?? 0).toFixed(1), icon: 'chart' },
|
|
{ label: 'average visit', value: duration(stats?.averageTimeMs), small: true, icon: 'clock' },
|
|
{ label: 'history kept', value: `${data?.retentionDays ?? 90} days`, small: true, icon: 'clock' },
|
|
]}
|
|
/>
|
|
|
|
<div className="summary-grid">
|
|
<div className="summary">
|
|
<div className="summary-head">
|
|
<b>Visit completion</b>
|
|
<strong>{percent(stats?.completionRate)}</strong>
|
|
</div>
|
|
<Meter value={stats?.completed ?? 0} total={stats?.journeys ?? 0} />
|
|
<p>
|
|
{num(stats?.completed)} completed · {num(stats?.abandoned)} abandoned ·{' '}
|
|
{num(stats?.active)} active
|
|
</p>
|
|
</div>
|
|
<div className="summary">
|
|
<div className="summary-head">
|
|
<b>Most common route</b>
|
|
</div>
|
|
<strong style={undefined}>
|
|
{topPath ? `${label(topPath.from)} → ${label(topPath.to)}` : 'Not enough data'}
|
|
</strong>
|
|
<p>
|
|
{topPath
|
|
? `${num(topPath.count)} times in this window`
|
|
: 'Journeys will appear here as viewers move through Memby.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Card>
|
|
|
|
<Grid cols="2">
|
|
<Card
|
|
title="What people do"
|
|
intro="Actions show total use and how many separate visits included them."
|
|
icon="chart"
|
|
tone="info"
|
|
>
|
|
<TableWrap>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Action</th>
|
|
<th className="num">Uses</th>
|
|
<th className="num">Visits</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{actions.length === 0 ? (
|
|
<EmptyRow columns={3}>No significant actions in this window.</EmptyRow>
|
|
) : (
|
|
actions.map((action) => (
|
|
<tr key={`${action.category}:${action.action}`}>
|
|
<td>
|
|
<b>{label(action.action)}</b>
|
|
<span className="table-sub">{label(action.category)}</span>
|
|
</td>
|
|
<td className="num">{num(action.events)}</td>
|
|
<td className="num">{num(action.journeys)}</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</TableWrap>
|
|
</Card>
|
|
|
|
<Card
|
|
title="Where people go"
|
|
intro="The most common steps between screens, including where quiet visits ended."
|
|
icon="list"
|
|
tone="note"
|
|
>
|
|
<TableWrap>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Route</th>
|
|
<th className="num">Times</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{paths.length === 0 ? (
|
|
<EmptyRow columns={2}>No repeated paths in this window.</EmptyRow>
|
|
) : (
|
|
paths.map((entry, index) => (
|
|
<tr key={`${entry.from}:${entry.to}:${index}`}>
|
|
<td>
|
|
{label(entry.from)} <span className="route-arrow">→</span> {label(entry.to)}
|
|
</td>
|
|
<td className="num">{num(entry.count)}</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</TableWrap>
|
|
</Card>
|
|
</Grid>
|
|
|
|
<Card
|
|
title="Feature use"
|
|
intro="Rare and unused features are shown against Memby's major feature catalogue."
|
|
icon="pulse"
|
|
tone="data"
|
|
>
|
|
<TableWrap>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Feature</th>
|
|
<th className="num">Uses</th>
|
|
<th>Last used</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{features.map(({ name, stat }) => {
|
|
const uses = stat?.uses ?? 0;
|
|
return (
|
|
<tr key={name}>
|
|
<td>{label(name)}</td>
|
|
<td className="num">{num(uses)}</td>
|
|
<td className="muted nowrap">{stat ? when(stat.lastUsedAt) : '—'}</td>
|
|
<td>
|
|
{uses === 0 ? (
|
|
<Tag tone="warn">not used</Tag>
|
|
) : uses < 3 ? (
|
|
<Tag tone="note">rare</Tag>
|
|
) : (
|
|
<Tag tone="ok">used</Tag>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</TableWrap>
|
|
</Card>
|
|
|
|
{userId ? null : (
|
|
<Card title="Inspect a viewer" intro="Choose a person above to open their dedicated session and viewing-journey timeline." icon="journey" tone="info">
|
|
<p className="empty">A viewing journey follows one intent through to playback, so two films watched in a single app session appear as two separate journeys.</p>
|
|
</Card>
|
|
)}
|
|
</>
|
|
);
|
|
}
|