Files
memby/admin-ui/src/pages/Journeys.tsx
T
2026-08-14 09:40:03 +12:00

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>
)}
</>
);
}