import { useEffect, useState, type ReactElement } from 'react'
import { Mark } from './Funnel'
import {
doingText,
elapsedText,
finished,
MAX_CRITERIA,
slug,
stageOf,
verdictTickers,
VERDICTS,
type Criterion,
type Progress,
type RowState,
type Run,
type RunSnapshot,
type Screen,
type Verdict,
} from './screener'
// The qualitative stage, in two parts. Its bar: the criteria in words, and the button that has every
// company on the list read against them. A run costs model calls for every company, so it starts on
// the button (or on the orchestrator's word, which is the same write), never on a criterion alone.
// And where a run is, which is the funnel's last row (RunProgress): the list goes on being narrowed
// there, by reading instead of by a filter.
interface Props {
criteria: Criterion[]
onCriteria: (next: Criterion[]) => void
run: Run | null
snapshot: RunSnapshot | null
screen: Screen | null
/** The server's session holds the list on screen and no screen call is out, so a run would freeze the right list. */
settled: boolean
verdicts: Verdict[]
onVerdicts: (next: Verdict[]) => void
onRun: () => void
onRetry: () => void
onClear: () => void
}
export function QualitativeBar(props: Props): ReactElement {
const { criteria, onCriteria, run, snapshot, screen, settled, verdicts, onVerdicts, onRun, onRetry, onClear } = props
const [question, setQuestion] = useState('')
const [problem, setProblem] = useState('')
// The criteria stand while a run is starting or going on. A run asked for with none is waiting for one, so the editor stays.
const stage = stageOf(criteria, run)
const busy = stage === 'starting' || stage === 'running'
function add(): void {
const typed = question.trim()
// The server's own bounds on a criterion, said before it has to refuse.
if (typed.length < 5 || typed.length > 500) {
setProblem('A criterion is a question of 5 to 500 characters.')
return
}
onCriteria([...criteria, { id: slug(typed, criteria.map((c) => c.id)), question: typed }])
setQuestion('')
setProblem('')
}
return (
)
}
interface StatusProps extends Pick {
/** There is at least one criterion. */
ready: boolean
}
/** What can be done about a run: start one, try a failed one again, take one off the panel. Where a run is, the funnel says. */
function RunStatus({ run, screen, settled, ready, onRun, onRetry, onClear }: StatusProps): ReactElement | null {
if (run === null) {
if (!screen) return null
if (!screen.ready) {
const over = screen.count - screen.max
return (
{screen.count === 0 ? 'Nothing to read' : `${over.toLocaleString('en-US')} over the ${screen.max} the qualitative stage takes`}
)
}
return (
)
}
if (run.status === 'error') {
return (
<>
{run.error ?? 'The run failed.'}
>
)
}
// There is no stopping a run: clearing takes it off this panel, and the server goes on reading.
if (!run.id) return null
return (
)
}
interface ProgressProps {
criteria: Criterion[]
run: Run | null
snapshot: RunSnapshot | null
}
/**
* The qualitative stage as a row of the funnel: still to come, at work, or in. While a run goes on it
* says what the run is doing and for how long, draws how far it is, and names who has passed and who
* has failed as each company's verdict lands. Why a run failed is said in the bar, beside the button
* that tries it again.
*/
export function RunProgress({ criteria, run, snapshot }: ProgressProps): ReactElement {
const stage = stageOf(criteria, run)
const state: RowState = stage === 'done' ? 'done' : stage === 'starting' || stage === 'running' ? 'working' : 'pending'
const now = useNow(stage === 'running' && snapshot?.status !== 'done')
const clock = snapshot && elapsedText(snapshot, now)
const fetching = snapshot?.status !== 'done' && snapshot?.phase?.status === 'fetching' ? snapshot.phase : null
return (
)
}
/** The order a run's companies are drawn in along its meter: what was found, then what is still to be. */
const SEGMENTS = ['pass', 'unverified', 'unclear', 'fail', 'error', 'running', 'queued'] as const satisfies readonly (keyof Progress)[]
/** How far something is, as one bar cut into its parts, each as wide as its count. */
function Meter({ label, done, parts }: { label: string; done: number; parts: [key: string, count: number][] }): ReactElement {
const total = parts.reduce((sum, [, count]) => sum + count, 0)
return (
{parts.map(([key, count]) => count > 0 && )}
)
}
/** How many companies have one verdict, and who: the tickers in the verdict's colour, as many as there is room for. Nothing until one has it. */
function Names({ verdict, count, tickers }: { verdict: Verdict; count: number; tickers: string[] }): ReactElement | null {
if (count === 0) return null
return (
{count} {verdict}
{tickers.length > 0 && {tickers.join(' · ')}}
)
}
/** The time now, once a second while something is going on. */
function useNow(ticking: boolean): number {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (!ticking) return
setNow(Date.now())
const timer = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(timer)
}, [ticking])
return now
}