0.3.21
This commit is contained in:
+63
-200
@@ -1,216 +1,79 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '../lib/hooks';
|
||||
import { api } from '../api/client';
|
||||
import { useAction, useQuery } from '../lib/hooks';
|
||||
import { useToast } from '../lib/toast';
|
||||
import { ago, initials, num, presence, recent, watchTime, when } from '../lib/format';
|
||||
import {
|
||||
Banner,
|
||||
Card,
|
||||
EmptyRow,
|
||||
Loading,
|
||||
PageHead,
|
||||
TableWrap,
|
||||
Tag,
|
||||
Tiles,
|
||||
} from '../components/ui';
|
||||
import type { KnownClient } from '../api/types';
|
||||
|
||||
/* A directory, and only a directory. Everything you can *do* to a person lives on their own
|
||||
page: this list used to render a full seventeen-control settings editor for every account
|
||||
at once, which meant the page grew with the household and an operator scrolled past four
|
||||
other people's preferences to reach the one they came for. */
|
||||
|
||||
/* Watch time comes from Tracearr, and `matched` is the field that matters: a household
|
||||
running no Tracearr, and a person Tracearr has never seen, both arrive as zeroes. Drawing
|
||||
those as "0 min this week" would have an operator asking why somebody stopped watching
|
||||
when the real answer is that nothing was asked. */
|
||||
interface WatchTime {
|
||||
matched: boolean;
|
||||
tracearrUsername?: string;
|
||||
weekMs: number;
|
||||
monthMs: number;
|
||||
totalMs: number;
|
||||
weekSessions: number;
|
||||
monthSessions: number;
|
||||
lastWatchedAt?: string;
|
||||
}
|
||||
import { Banner, Button, Card, Confirm, EmptyRow, Loading, PageHead, TableWrap, Tag, Tiles, Toggle } from '../components/ui';
|
||||
|
||||
interface Device { id: string; name: string; version: string; lastSeen: string; lastIp?: string; }
|
||||
interface WatchTime { matched: boolean; weekMs: number; monthMs: number; }
|
||||
interface Account {
|
||||
id: string;
|
||||
username: string;
|
||||
initials: string;
|
||||
shortName: string;
|
||||
lastSeen: string;
|
||||
devices: KnownClient[] | null;
|
||||
recommendations?: { prompted?: boolean; completed?: boolean };
|
||||
watchTime?: WatchTime;
|
||||
id: string; username: string; initials: string; shortName: string; lastSeen: string; devices: Device[] | null;
|
||||
enabled: boolean; lastIp?: string; notifications: { enabled: boolean; [key: string]: unknown }; watchTime?: WatchTime;
|
||||
}
|
||||
interface AccountsResponse { accounts: Account[] | null; }
|
||||
interface StatusResponse { updatePolicy?: { latestVersion?: string }; }
|
||||
|
||||
interface AccountsResponse {
|
||||
accounts: Account[] | null;
|
||||
}
|
||||
|
||||
/** seenAt is a timestamp as a number, with anything unreadable sorting last rather than
|
||||
* first — an invalid date yields NaN, and NaN comparisons would scatter those rows. */
|
||||
function seenAt(value: string | undefined): number {
|
||||
const at = value ? new Date(value).getTime() : 0;
|
||||
return Number.isFinite(at) ? at : 0;
|
||||
}
|
||||
|
||||
/** A dash, and it says why on hover. Watch time comes from Tracearr; a household running
|
||||
* none and a person it has never matched are both "not measured", never "none". */
|
||||
function NotMeasured() {
|
||||
return (
|
||||
<span className="muted" title="No Tracearr sessions matched to this person">
|
||||
—
|
||||
</span>
|
||||
);
|
||||
}
|
||||
function seenAt(value: string | undefined): number { const at = value ? new Date(value).getTime() : 0; return Number.isFinite(at) ? at : 0; }
|
||||
function NotMeasured() { return <span className="muted" title="No Tracearr sessions matched to this person">—</span>; }
|
||||
|
||||
export function AccountsPage() {
|
||||
const { data, error, loading } = useQuery<AccountsResponse>('/admin/api/accounts', {
|
||||
pollMs: 60_000,
|
||||
});
|
||||
const { data, error, loading, reload } = useQuery<AccountsResponse>('/admin/api/accounts', { pollMs: 60_000 });
|
||||
const { data: status } = useQuery<StatusResponse>('/admin/api/status', { pollMs: 60_000 });
|
||||
const { busy, run } = useAction();
|
||||
const { wrap } = useToast();
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [confirm, setConfirm] = useState<Account | null>(null);
|
||||
const accounts = data?.accounts ?? [];
|
||||
const devices = accounts.flatMap((account) => account.devices ?? []);
|
||||
const completed = accounts.filter((account) => account.recommendations?.completed).length;
|
||||
const queued = accounts.filter(
|
||||
(account) => account.recommendations?.prompted && !account.recommendations?.completed,
|
||||
).length;
|
||||
/* Summed from the same rows the list draws, so the tile and the column underneath it can
|
||||
never disagree — a separate total query is how those two come apart. */
|
||||
const latest = status?.updatePolicy?.latestVersion ?? '';
|
||||
const current = (version: string) => Boolean(version && latest && version === latest);
|
||||
const needsUpdate = devices.filter((device) => device.version && latest && device.version !== latest).length;
|
||||
const tracked = accounts.filter((account) => account.watchTime?.matched);
|
||||
const weekMs = tracked.reduce((total, account) => total + (account.watchTime?.weekMs ?? 0), 0);
|
||||
const rows = [...accounts].sort((a, b) => seenAt(b.lastSeen) - seenAt(a.lastSeen));
|
||||
const path = (id: string, suffix: string) => `/admin/api/accounts/${encodeURIComponent(id)}${suffix}`;
|
||||
|
||||
/* Most recently seen first, so the table means something without being sorted. Whoever
|
||||
is using Memby right now is the row an operator opening this page is looking for, and a
|
||||
person who has never signed in from a device sorts to the bottom rather than the top. */
|
||||
const rows = [...accounts].sort(
|
||||
(a, b) => seenAt(b.lastSeen) - seenAt(a.lastSeen),
|
||||
);
|
||||
const saveNotifications = async (account: Account, enabled: boolean) => run(`notifications-${account.id}`, () => wrap(() => api.put(path(account.id, '/notifications'), { ...account.notifications, enabled }), `Notifications ${enabled ? 'enabled' : 'disabled'} for ${account.username}`).then(() => reload()));
|
||||
const saveEnabled = async (account: Account) => { const enabled = !account.enabled; await run(`enabled-${account.id}`, () => wrap(() => api.put(path(account.id, '/enabled'), { enabled }), `${account.username} ${enabled ? 'enabled' : 'disabled'}`).then(() => { setConfirm(null); return reload(); })); };
|
||||
const forceUpdate = async (account: Account) => run(`update-${account.id}`, () => wrap(() => api.post(path(account.id, '/force-update')), `Update request queued for ${account.username}`));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Users" intro="Who uses Memby, and which devices they are signed in to." />
|
||||
<Banner message={error} />
|
||||
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<Tiles
|
||||
tiles={[
|
||||
{ label: 'Memby users', value: num(accounts.length), icon: 'people', tone: 'note' },
|
||||
{ label: 'signed-in devices', value: num(devices.length), icon: 'tv', tone: 'info' },
|
||||
{
|
||||
label: 'active in the last 15 mins',
|
||||
value: num(devices.filter((device) => recent(device.lastSeen)).length),
|
||||
icon: 'pulse',
|
||||
tone: 'ok',
|
||||
},
|
||||
{ label: 'recommendation setups completed', value: num(completed), icon: 'check', tone: 'ok' },
|
||||
{ label: 'setup prompts queued', value: num(queued), icon: 'sparkle', tone: 'note' },
|
||||
...(tracked.length
|
||||
? [
|
||||
{
|
||||
label: 'watch time this week',
|
||||
value: watchTime(weekMs),
|
||||
icon: 'pulse' as const,
|
||||
tone: 'data' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card title="Users" icon="people" tone="note">
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Short name</th>
|
||||
<th className="num">Devices</th>
|
||||
<th className="num">This week</th>
|
||||
<th className="num">This month</th>
|
||||
<th>Recommendations</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyRow columns={7}>
|
||||
No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here.
|
||||
</EmptyRow>
|
||||
) : (
|
||||
rows.map((account) => {
|
||||
const list = account.devices ?? [];
|
||||
const active = list.filter((device) => recent(device.lastSeen)).length;
|
||||
const state = account.recommendations?.completed
|
||||
? { label: 'personalised', tone: 'ok' as const }
|
||||
: account.recommendations?.prompted
|
||||
? { label: 'prompt queued', tone: 'warn' as const }
|
||||
: { label: 'not invited', tone: undefined };
|
||||
const seen = presence(account.lastSeen);
|
||||
const watched = account.watchTime;
|
||||
return (
|
||||
<tr key={account.id}>
|
||||
<td>
|
||||
<span className="row tight">
|
||||
<span className="dot-state" data-tone={seen.tone} title={seen.label} />
|
||||
<span className="avatar">{account.initials || initials(account.username)}</span>
|
||||
<Link
|
||||
className="table-row-link"
|
||||
to={`/admin/accounts/${encodeURIComponent(account.id)}`}
|
||||
>
|
||||
{account.username || 'Unnamed user'}
|
||||
</Link>
|
||||
</span>
|
||||
</td>
|
||||
{/* Blank is the ordinary state and not a gap: the launcher greets
|
||||
somebody by their account name unless an operator has given
|
||||
Memby a friendlier one, and saying so beats a bare dash. */}
|
||||
<td>
|
||||
{account.shortName || (
|
||||
<span className="muted" title="Memby greets them by their account name">
|
||||
account name
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="num">
|
||||
{num(list.length)}
|
||||
{/* Only where there is something to say. A sub-line under every
|
||||
row reading "0 active now" is a column of noise. */}
|
||||
{active ? <span className="table-sub">{num(active)} active now</span> : null}
|
||||
</td>
|
||||
{/* The month sits beside the week because a quiet week only means
|
||||
something next to the month around it. Both are a dash rather
|
||||
than a zero for somebody Tracearr has never seen: "0 min" would
|
||||
have an operator investigating a person when the real answer is
|
||||
that nothing was ever asked. */}
|
||||
<td className="num">
|
||||
{watched?.matched ? watchTime(watched.weekMs) : <NotMeasured />}
|
||||
</td>
|
||||
<td className="num muted">
|
||||
{watched?.matched ? watchTime(watched.monthMs) : <NotMeasured />}
|
||||
</td>
|
||||
<td>
|
||||
<Tag tone={state.tone}>{state.label}</Tag>
|
||||
</td>
|
||||
<td className="nowrap muted" title={when(account.lastSeen)}>
|
||||
{ago(account.lastSeen)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return <>
|
||||
<PageHead title="Users" intro="Manage Memby users and signed-in devices at a glance." />
|
||||
<Banner message={error} />
|
||||
{loading ? <Loading /> : <>
|
||||
<Tiles tiles={[
|
||||
{ label: 'Memby users', value: num(accounts.length), icon: 'people', tone: 'note' },
|
||||
{ label: 'signed-in devices', value: num(devices.length), icon: 'tv', tone: 'info' },
|
||||
{ label: 'active in the last 15 mins', value: num(devices.filter((device) => recent(device.lastSeen)).length), icon: 'pulse', tone: 'ok' },
|
||||
{ label: 'devices up to date', value: num(devices.filter((device) => current(device.version)).length), icon: 'check', tone: 'ok' },
|
||||
{ label: 'devices requiring update', value: num(needsUpdate), icon: 'alert', tone: needsUpdate ? 'warn' : 'note' },
|
||||
...(tracked.length ? [{ label: 'watch time this week', value: watchTime(weekMs), icon: 'pulse' as const, tone: 'data' as const }] : []),
|
||||
]} />
|
||||
<Card title="Users" icon="people" tone="note"><TableWrap><table><thead><tr>
|
||||
<th>User</th><th className="num">Devices</th><th>Version</th><th>Remote IP</th><th className="num">This week</th><th className="num">This month</th><th>Last seen</th><th>Notifications</th><th>Enabled</th><th>Actions</th>
|
||||
</tr></thead><tbody>
|
||||
{rows.length === 0 ? <EmptyRow columns={10}>No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here.</EmptyRow> : rows.map((account) => {
|
||||
const list = account.devices ?? []; const seen = presence(account.lastSeen); const watched = account.watchTime; const open = expanded === account.id; const first = list[0];
|
||||
return <>
|
||||
<tr key={account.id} className={!account.enabled ? 'is-disabled' : undefined}>
|
||||
<td><span className="row tight"><span className="dot-state" data-tone={seen.tone} title={seen.label} /><span className="avatar">{account.initials || initials(account.username)}</span><Link className="table-row-link" to={`/admin/accounts/${encodeURIComponent(account.id)}`}>{account.username || 'Unnamed user'}</Link></span></td>
|
||||
<td className="num"><button type="button" className="link-button" onClick={() => setExpanded(open ? null : account.id)}>{num(list.length)}</button></td>
|
||||
<td><span title={first?.version ? `Latest recorded version on ${first.name}` : 'Version not reported'}>{first?.version || <span className="muted">Unknown</span>}</span>{first?.version && current(first.version) ? <Tag tone="ok">current</Tag> : first?.version && latest ? <Tag tone="warn">update</Tag> : null}{list.length > 1 ? <span className="table-sub">{list.length - 1} more device{list.length === 2 ? '' : 's'}</span> : null}</td>
|
||||
<td className="mono" title="Last recorded IP address">{first?.lastIp || account.lastIp || <span className="muted">—</span>}</td>
|
||||
<td className="num">{watched?.matched ? watchTime(watched.weekMs) : <NotMeasured />}</td><td className="num muted">{watched?.matched ? watchTime(watched.monthMs) : <NotMeasured />}</td>
|
||||
<td className="nowrap muted" title={when(account.lastSeen)}>{ago(account.lastSeen)}</td>
|
||||
<td><Toggle label={account.notifications?.enabled ? 'ON' : 'OFF'} checked={Boolean(account.notifications?.enabled)} disabled={busy === `notifications-${account.id}`} onChange={(next) => void saveNotifications(account, next)} /></td>
|
||||
<td><Toggle label={account.enabled ? 'Enabled' : 'Disabled'} checked={account.enabled} disabled={busy === `enabled-${account.id}`} onChange={() => setConfirm(account)} /></td>
|
||||
<td><Button size="sm" variant="quiet" busy={busy === `update-${account.id}`} onClick={() => void forceUpdate(account)}>Force update</Button></td>
|
||||
</tr>
|
||||
{open ? <tr key={`${account.id}-devices`} className="account-device-detail"><td colSpan={10}><div className="device-breakdown">{list.map((device) => <div className="device-breakdown-row" key={device.id}><b>{device.name || 'Unnamed device'}</b><span>{device.version || 'Version unknown'}</span><span className="mono" title="Last recorded IP address">{device.lastIp || 'IP not recorded'}</span><span className="muted">last seen {ago(device.lastSeen)}</span></div>)}</div></td></tr> : null}
|
||||
</>;
|
||||
})}
|
||||
</tbody></table></TableWrap></Card>
|
||||
</>}
|
||||
{confirm ? <Confirm title={`${confirm.enabled ? 'Disable' : 'Enable'} ${confirm.username}?`} body={confirm.enabled ? 'This will stop their Memby sessions from accessing the gateway. Their Emby account is not changed.' : 'This will restore their access to Memby.'} confirmLabel={confirm.enabled ? 'Disable user' : 'Enable user'} destructive={confirm.enabled} busy={busy === `enabled-${confirm.id}`} onCancel={() => setConfirm(null)} onConfirm={() => void saveEnabled(confirm)} /> : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
/** The account page reads the same list to find the person it is about. */
|
||||
export type { Account, AccountsResponse };
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useMemo } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { query } from '../api/client';
|
||||
import { useQuery } from '../lib/hooks';
|
||||
import { when } from '../lib/format';
|
||||
import { num, when } from '../lib/format';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { Banner, Card, Loading, PageHead, Tag } from '../components/ui';
|
||||
import { Banner, Card, Loading, PageHead, TableWrap, Tag } from '../components/ui';
|
||||
|
||||
interface JourneyEvent {
|
||||
journeyId: string;
|
||||
@@ -25,12 +25,20 @@ interface JourneyEvent {
|
||||
|
||||
interface JourneyResponse {
|
||||
users: { userId: string; username: string }[] | null;
|
||||
featureUsage: { feature: string; users: number; uses: number; journeys: number; activeUserRate: number; lastUsedAt: string }[] | null;
|
||||
featureBreakdown: { feature: string; subFeature: string; action: string; users: number; uses: number; journeys: number; lastUsedAt: string }[] | null;
|
||||
events: JourneyEvent[] | null;
|
||||
}
|
||||
|
||||
const label = (value: string | undefined | null) => {
|
||||
const text = String(value || '—').replaceAll('_', ' ');
|
||||
return text === 'favorites' ? 'Favourites' : text;
|
||||
const labels: Record<string, string> = {
|
||||
favorites: 'Favourites', continue_watching: 'Continue Watching', recommendation: 'Recommendations',
|
||||
for_you: 'Recommendations', screen_view: 'Viewed', open: 'Opened', close: 'Closed', select: 'Selected',
|
||||
request: 'Requested', start: 'Started', stop: 'Stopped', complete: 'Completed', text: 'Text Search',
|
||||
voice: 'Voice Search', journey_start: 'Started Session', journey_end: 'Ended Session',
|
||||
};
|
||||
return labels[String(value || '')] ?? text.replace(/\b\w/g, (character) => character.toUpperCase());
|
||||
};
|
||||
|
||||
const place = (event: JourneyEvent | undefined) => label(event?.target || event?.screen || event?.source || event?.feature);
|
||||
@@ -104,10 +112,18 @@ export function JourneyViewerPage() {
|
||||
.sort((left, right) => (right[0]?.occurredAt ?? '').localeCompare(left[0]?.occurredAt ?? ''));
|
||||
}, [data?.events]);
|
||||
const journeys = sessions.flatMap((session) => splitViewingJourneys(session).map((events, index) => ({ events, key: `${session[0]?.journeyId}:${index}` })));
|
||||
const featureUsage = data?.featureUsage ?? [];
|
||||
const featureBreakdown = data?.featureBreakdown ?? [];
|
||||
|
||||
return <>
|
||||
<PageHead title={`${username}'s journeys`} intro="Each app session is shown as the viewing journeys it contains: entry, selection, playback outcome." icon="journey" crumbs={<Link className="crumb" to="/admin/journeys">Journeys</Link>} />
|
||||
<Banner message={error} />
|
||||
{!loading && <Card title="Feature usage summary" intro="This viewer’s feature use before the detailed journeys below." icon="chart" tone="data">
|
||||
<TableWrap><table><thead><tr><th>Feature</th><th className="num">Uses</th><th className="num">Visits</th><th>Last used</th></tr></thead><tbody>
|
||||
{featureUsage.length === 0 ? <tr><td colSpan={4} className="empty">No feature usage recorded for this viewer.</td></tr> : featureUsage.map((feature) => <tr key={feature.feature}><td><b>{label(feature.feature)}</b><span className="table-sub">{num(feature.users)} viewer</span></td><td className="num">{num(feature.uses)}</td><td className="num">{num(feature.journeys)}</td><td>{when(feature.lastUsedAt)}</td></tr>)}
|
||||
</tbody></table></TableWrap>
|
||||
{featureBreakdown.length > 0 && <p className="table-sub">Breakdown: {featureBreakdown.slice(0, 8).map((entry) => `${label(entry.feature)} · ${label(entry.subFeature)} · ${label(entry.action)} (${num(entry.uses)})`).join(' · ')}</p>}
|
||||
</Card>}
|
||||
{loading ? <Loading /> : <Card title="Viewing journeys" intro={`${sessions.length} app session${sessions.length === 1 ? '' : 's'} · ${journeys.length} viewing journey${journeys.length === 1 ? '' : 's'} in the last 90 days.`} icon="journey" tone="info">
|
||||
<div className="visits">
|
||||
{journeys.length === 0 ? <p className="empty">No journeys recorded for this viewer.</p> : journeys.map((journey, index) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { duration, num, percent } from '../lib/format';
|
||||
import {
|
||||
Banner,
|
||||
Card,
|
||||
@@ -13,30 +13,24 @@ import {
|
||||
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', 'magic_movie', '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;
|
||||
const labels: Record<string, string> = {
|
||||
favorites: 'Favourites', continue: 'Continue Watching', continue_watching: 'Continue Watching',
|
||||
recent_searches: 'Recent Searches', genre_browse: 'Genre Browse', for_you: 'Recommendations',
|
||||
recommendation: 'Recommendations', for_you_time: 'Recommendation Timing', my_shows: 'My Shows',
|
||||
magic_movie: 'Magic Movie', detail_page: 'Details', home_hero: 'Home Hero',
|
||||
screen_view: 'Viewed', open: 'Opened', close: 'Closed', select: 'Selected', request: 'Requested',
|
||||
start: 'Started', stop: 'Stopped', complete: 'Completed', change: 'Changed', toggle: 'Toggled',
|
||||
submit: 'Submitted', journey_start: 'Started Session', journey_end: 'Ended Session',
|
||||
abandoned: 'Abandoned / interrupted', text: 'Text Search', voice: 'Voice Search',
|
||||
};
|
||||
return labels[String(value || '')] ?? text.replace(/\b\w/g, (character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
interface JourneysResponse {
|
||||
@@ -55,6 +49,8 @@ interface JourneysResponse {
|
||||
};
|
||||
users: { userId: string; username: string }[] | null;
|
||||
features: { feature: string; uses: number; lastUsedAt: string }[] | null;
|
||||
featureUsage: { feature: string; users: number; uses: number; journeys: number; activeUserRate: number; lastUsedAt: string }[] | null;
|
||||
featureBreakdown: { feature: string; subFeature: string; action: string; users: number; uses: number; journeys: number; lastUsedAt: string }[] | null;
|
||||
actions: { category: string; action: string; events: number; journeys: number }[] | null;
|
||||
paths: { from: string; to: string; count: number }[] | null;
|
||||
}
|
||||
@@ -68,25 +64,11 @@ export function JourneysPage() {
|
||||
|
||||
const stats = data?.stats;
|
||||
const users = data?.users ?? [];
|
||||
const actions = data?.actions ?? [];
|
||||
const featureUsage = data?.featureUsage ?? [];
|
||||
const featureBreakdown = data?.featureBreakdown ?? [];
|
||||
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".
|
||||
@@ -182,8 +164,8 @@ export function JourneysPage() {
|
||||
|
||||
<Grid cols="2">
|
||||
<Card
|
||||
title="What people do"
|
||||
intro="Actions show total use and how many separate visits included them."
|
||||
title="Feature usage"
|
||||
intro="Distinct viewers, total uses and visits containing each feature."
|
||||
icon="chart"
|
||||
tone="info"
|
||||
>
|
||||
@@ -191,23 +173,24 @@ export function JourneysPage() {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Action</th>
|
||||
<th>Feature</th>
|
||||
<th className="num">Users</th>
|
||||
<th className="num">Uses</th>
|
||||
<th className="num">Visits</th>
|
||||
<th className="num">Active users</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{actions.length === 0 ? (
|
||||
<EmptyRow columns={3}>No significant actions in this window.</EmptyRow>
|
||||
{featureUsage.length === 0 ? (
|
||||
<EmptyRow columns={5}>No feature usage 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>
|
||||
featureUsage.map((feature) => (
|
||||
<tr key={feature.feature}>
|
||||
<td><b>{label(feature.feature)}</b></td>
|
||||
<td className="num">{num(feature.users)}</td>
|
||||
<td className="num">{num(feature.uses)}</td>
|
||||
<td className="num">{num(feature.journeys)}</td>
|
||||
<td className="num">{percent(feature.activeUserRate)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
@@ -217,8 +200,8 @@ export function JourneysPage() {
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Where people go"
|
||||
intro="The most common steps between screens, including where quiet visits ended."
|
||||
title="Feature and action detail"
|
||||
intro="Structured sub-features show which parts of a feature were used."
|
||||
icon="list"
|
||||
tone="note"
|
||||
>
|
||||
@@ -226,20 +209,24 @@ export function JourneysPage() {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Route</th>
|
||||
<th>Feature / sub-feature</th>
|
||||
<th>Action</th>
|
||||
<th className="num">Users</th>
|
||||
<th className="num">Times</th>
|
||||
<th className="num">Visits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paths.length === 0 ? (
|
||||
<EmptyRow columns={2}>No repeated paths in this window.</EmptyRow>
|
||||
{featureBreakdown.length === 0 ? (
|
||||
<EmptyRow columns={5}>No feature details 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>
|
||||
featureBreakdown.slice(0, 30).map((entry, index) => (
|
||||
<tr key={`${entry.feature}:${entry.subFeature}:${entry.action}:${index}`}>
|
||||
<td><b>{label(entry.feature)}</b><span className="table-sub">{label(entry.subFeature)}</span></td>
|
||||
<td>{label(entry.action)}</td>
|
||||
<td className="num">{num(entry.users)}</td>
|
||||
<td className="num">{num(entry.uses)}</td>
|
||||
<td className="num">{num(entry.journeys)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
@@ -250,8 +237,8 @@ export function JourneysPage() {
|
||||
</Grid>
|
||||
|
||||
<Card
|
||||
title="Feature use"
|
||||
intro="Rare and unused features are shown against Memby's major feature catalogue."
|
||||
title="Most common routes"
|
||||
intro="The most common steps between screens, including where quiet visits ended."
|
||||
icon="pulse"
|
||||
tone="data"
|
||||
>
|
||||
@@ -259,32 +246,14 @@ export function JourneysPage() {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Feature</th>
|
||||
<th className="num">Uses</th>
|
||||
<th>Last used</th>
|
||||
<th>Status</th>
|
||||
<th>Route</th>
|
||||
<th className="num">Times</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>
|
||||
);
|
||||
})}
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user