Files
memby/admin-ui/src/pages/Searches.tsx
T

163 lines
5.5 KiB
TypeScript
Raw Normal View History

2026-08-14 09:40:03 +12:00
import { useState } from 'react';
import { useQuery } from '../lib/hooks';
import { num, when } from '../lib/format';
import {
Banner,
Card,
EmptyRow,
Field,
Loading,
PageHead,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
interface SearchTerm {
query: string;
searches: number;
viewers: number;
lastAt: string;
}
interface SearchEvent {
occurredAt: string;
userId: string;
username: string;
query: string;
}
interface SearchesResponse {
days: number;
retentionDays: number;
totals: { searches: number; queries: number; viewers: number };
terms: SearchTerm[] | null;
recent: SearchEvent[] | null;
}
/* Two tables of the same rows on purpose: the summary groups by query and answers "what
does this house look for", which is what a library is organised against; the log is
uncollapsed and newest-first and answers "what happened just now", which is the one to
read when somebody reports that search is not finding something. */
export function SearchesPage() {
const [days, setDays] = useState(7);
const { data, error, loading } = useQuery<SearchesResponse>(`/admin/api/searches?days=${days}`);
const terms = data?.terms ?? [];
const recent = data?.recent ?? [];
const totals = data?.totals;
return (
<>
<PageHead
title="Searches"
2026-08-19 06:57:59 +12:00
intro="What viewers have been looking for, and what was searched just now."
2026-08-14 09:40:03 +12:00
/>
<Banner message={error} />
<Tiles
tiles={[
{ label: 'searches', value: num(totals?.searches ?? 0), icon: 'search', tone: 'info' },
{ label: 'distinct queries', value: num(totals?.queries ?? 0), icon: 'list', tone: 'data' },
{ label: 'viewers searching', value: num(totals?.viewers ?? 0), icon: 'people', tone: 'note' },
// Stated rather than assumed: every figure on this page is bounded by how long
// the table keeps a row, and an operator reading a quiet week has no other way to
// tell a household that stopped searching from one whose history has aged out.
{
label: 'history kept',
value: `${data?.retentionDays ?? 30} days`,
small: true,
icon: 'clock',
},
]}
/>
{loading ? (
<Loading />
) : (
<>
<Card
title="What the house looks for"
intro="Queries the search tab ran, grouped without regard to case and labelled with the most recent spelling. Instant search asks from the second character, so a title typed slowly leaves its prefixes here too."
icon="search"
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>
</select>
</Field>
}
>
<TableWrap>
<table>
<thead>
<tr>
<th>Query</th>
<th className="num">Searches</th>
<th className="num">Viewers</th>
<th>Last searched</th>
</tr>
</thead>
<tbody>
{terms.length === 0 ? (
<EmptyRow columns={4}>Nothing searched in this window.</EmptyRow>
) : (
terms.map((term) => (
<tr key={term.query}>
<td>{term.query}</td>
<td className="num">{num(term.searches)}</td>
<td className="num">{num(term.viewers)}</td>
<td className="muted nowrap">{when(term.lastAt)}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
<Card
title="As it happened"
intro="The log, newest first — the query exactly as it was typed, and who typed it. This is the one to read when somebody says search is not finding something."
icon="history"
tone="note"
>
<TableWrap>
<table>
<thead>
<tr>
<th>When</th>
<th>Viewer</th>
<th>Query</th>
</tr>
</thead>
<tbody>
{recent.length === 0 ? (
<EmptyRow columns={3}>No searches in this window.</EmptyRow>
) : (
recent.map((event, index) => (
<tr key={`${event.occurredAt}:${index}`}>
<td className="muted nowrap">{when(event.occurredAt)}</td>
{/* An unattributed search keeps its row and shows the id: the query
is the point, and a viewer whose sessions have all expired is
still one searcher rather than nobody. */}
<td>
{event.username || <Tag tone="warn">{event.userId || 'unknown'}</Tag>}
</td>
<td>{event.query}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
)}
</>
);
}