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 >
</>
)}
</>
);
}