163 lines
5.5 KiB
TypeScript
163 lines
5.5 KiB
TypeScript
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"
|
|
intro="What viewers have been looking for, and what was searched just now."
|
|
/>
|
|
<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>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|