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 (
—
);
}
export function AccountsPage() {
const { data, error, loading } = useQuery('/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 (
<>
{loading ? (
) : (
<>
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,
},
]
: []),
]}
/>
User
Short name
Devices
This week
This month
Recommendations
Last seen
{rows.length === 0 ? (
No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here.
) : (
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 (
{/* 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. */}
{account.shortName || (
account name
)}
{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 ? {num(active)} active now : null}
{/* 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. */}