217 lines
9.1 KiB
TypeScript
217 lines
9.1 KiB
TypeScript
import { Link } from 'react-router-dom';
|
|
import { useQuery } from '../lib/hooks';
|
|
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;
|
|
}
|
|
|
|
interface Account {
|
|
id: string;
|
|
username: string;
|
|
initials: string;
|
|
shortName: string;
|
|
lastSeen: string;
|
|
devices: KnownClient[] | null;
|
|
recommendations?: { prompted?: boolean; completed?: boolean };
|
|
watchTime?: WatchTime;
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
export function AccountsPage() {
|
|
const { data, error, loading } = useQuery<AccountsResponse>('/admin/api/accounts', {
|
|
pollMs: 60_000,
|
|
});
|
|
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 tracked = accounts.filter((account) => account.watchTime?.matched);
|
|
const weekMs = tracked.reduce((total, account) => total + (account.watchTime?.weekMs ?? 0), 0);
|
|
|
|
/* 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),
|
|
);
|
|
|
|
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>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** The account page reads the same list to find the person it is about. */
|
|
export type { Account, AccountsResponse };
|