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, }, ] : []), ]} /> {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. */} {/* 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. */} ); }) )}
User Short name Devices This week This month Recommendations Last seen
{account.initials || initials(account.username)} {account.username || 'Unnamed user'} {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} {watched?.matched ? watchTime(watched.weekMs) : } {watched?.matched ? watchTime(watched.monthMs) : } {state.label} {ago(account.lastSeen)}
)} ); } /** The account page reads the same list to find the person it is about. */ export type { Account, AccountsResponse };