"use client"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { POPULATIONS, SCOPES, SCOPE_CONFIDENCE, VALUE_KINDS, rosterLine, } from "umtool-report-to-video/ledger-totals"; // Ruling on one claim. // // The layout puts the CONTEXT first and the controls second, on purpose. The // temptation this page exists to resist is adjudicating from the quote -- the // quote is what produced the four hazards in the first place, and every one of // them is invisible inside it. So the cited cue is highlighted inside a // ninety-second paragraph, and the audio starts a few seconds early. export type ClaimBenchData = { project: string; claim: { id: string; date: string; company: string | null; value: number | null; display: string | null; label: string | null; quote: string | null; src: string | null; video: string | null; channel: string | null; cite: number | null; scope: string | null; scopeBasis: string | null; scopeConfidence: string | null; population: string | null; valueKind: string | null; flags: string[] | null; roles: Array<{ role: string; count: number; verbatim: string }> | null; }; at: number | null; view: { from: number; to: number }; pad: number; cues: Array<{ start: number; end: number; text: string; cited: boolean }>; source: { title: string | null; uploadDate: string | null; duration: number | null; webpageUrl: string | null } | null; noCues: boolean; windows: Array<{ name: string; from: number; to: number }>; gaps: string[]; fired: Array<{ rule: string; text: string }>; prev: string | null; next: string | null; token: string | null; }; const hms = (t: number) => { const s = Math.max(0, Math.floor(t)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const r = s % 60; return h ? `${h}:${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}` : `${m}:${String(r).padStart(2, "0")}`; }; /** What each vocabulary term means, so nobody has to guess at the radio. */ const HELP: Record = { media: "The Quartering — the media team", coffee: "Coffee Brand Coffee", publica: "The Publica", all: "spans everything he owns", clear: "the quote itself settles it", read: "the context settles it; the quote alone would not", unresolved: "genuinely ambiguous — feeds NEITHER total", uttered: "he says this number, as one number, for this scope", derived: "our arithmetic over his per-company claims", synthetic: "our midpoint of a range he gave", employees: "“employees”", "full-time": "“full-time”", salaried: "“salaried”", contractor: "“contractors”", "1099": "“1099”", people: "“people”", }; function Choice({ label, options, value, onChange, name, }: { label: string; options: readonly string[]; value: string | null; onChange: (v: string) => void; name: string; }) { return (
{label}
{options.map((o) => ( ))}
{value && HELP[value] ? (
{HELP[value]}
) : null}
); } type Role = { role: string; count: number; verbatim: string }; const rolesToText = (roles: ClaimBenchData["claim"]["roles"]) => (roles ?? []).map((r) => `${r.count} | ${r.role} | ${r.verbatim}`).join("\n"); /** * `2 | video editor | two video editors` per line. * * `verbatim` is required and is the point of the field: the count and the role * name are OUR reading, and without his own words beside them nobody can check * the reading against the audio. */ function parseRoles(text: string): { ok: true; list: Role[] } | { ok: false; error: string } { const lines = text.split("\n").map((l) => l.trim()).filter(Boolean); const list: Role[] = []; for (const [i, line] of lines.entries()) { const parts = line.split("|").map((s) => s.trim()); if (parts.length !== 3) return { ok: false, error: `line ${i + 1} needs count | role | his words` }; const count = Number(parts[0]); if (!Number.isFinite(count) || count < 0) return { ok: false, error: `line ${i + 1}: count is not a number` }; if (!parts[1]) return { ok: false, error: `line ${i + 1}: no role` }; if (!parts[2]) return { ok: false, error: `line ${i + 1}: quote his words for it` }; list.push({ count, role: parts[1], verbatim: parts[2] }); } return { ok: true, list }; } export default function ClaimBench({ data }: { data: ClaimBenchData }) { const { claim, at, view, cues } = data; const video = useRef(null); const [scope, setScope] = useState(claim.scope); const [confidence, setConfidence] = useState(claim.scopeConfidence); const [population, setPopulation] = useState(claim.population); const [valueKind, setValueKind] = useState(claim.valueKind); const [basis, setBasis] = useState(claim.scopeBasis ?? ""); const [flagText, setFlagText] = useState((claim.flags ?? []).join("\n")); // The roster, as one line per role: `2 | video editor | two video editors`. // A textarea rather than a row of inputs because most claims have none and // the handful that do are a two-line list -- a repeater widget would be more // chrome than the field it edits. const [roleText, setRoleText] = useState(rolesToText(claim.roles)); const [token, setToken] = useState(data.token ?? ""); const [busy, setBusy] = useState(null); const [note, setNote] = useState(null); const [fetching, setFetching] = useState(false); // The widest cached window containing the cite. When there is one the moment // is playable with no download at all -- which is most of them once a build // has run, because the build already over-fetched around every clip. const cached = data.windows[0] ?? null; const roles = useMemo(() => parseRoles(roleText), [roleText]); const rosterPreview = roles.ok ? rosterLine(roles.list) : null; const dirty = roleText !== rolesToText(claim.roles) || scope !== claim.scope || confidence !== claim.scopeConfidence || population !== claim.population || valueKind !== claim.valueKind || basis !== (claim.scopeBasis ?? "") || flagText !== (claim.flags ?? []).join("\n"); const complete = !!(scope && confidence && population && valueKind && basis.trim()); // Start a few seconds EARLY. Landing exactly on the cited word is landing // mid-sentence, which is the position this page exists to get out of. const seekTo = useCallback( (t: number) => { const el = video.current; if (!el || !cached) return; el.currentTime = Math.max(0, t - cached.from); void el.play().catch(() => {}); }, [cached], ); useEffect(() => { if (at != null && cached) { const el = video.current; if (el) el.currentTime = Math.max(0, at - cached.from - 4); } }, [at, cached]); const save = useCallback(async () => { setBusy("saving…"); setNote(null); const r = await fetch("/api/report/claim", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ project: data.project, claim: claim.id, token, scope, scopeConfidence: confidence, population, valueKind, scopeBasis: basis, flags: flagText.split("\n").map((s) => s.trim()).filter(Boolean), roles: roles.ok ? roles.list : undefined, }), }); const j = (await r.json()) as Record; setBusy(null); if (!r.ok) { setNote( j.stale ? "the manifest changed since you opened this — reload before saving, or your ruling would overwrite whatever was written" : `could not save: ${String(j.error ?? r.status)}`, ); return; } setToken(String(j.token ?? "")); const gaps = (j.gaps as string[]) ?? []; setNote(gaps.length ? `saved — still missing ${gaps.join(", ")}` : "saved — adjudicated"); }, [data.project, claim.id, token, scope, confidence, population, valueKind, basis, flagText, roles]); const fetchWindow = useCallback(async () => { setFetching(true); setNote(null); const r = await fetch("/api/report/fetch", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ project: data.project, clip: claim.id, pad: data.pad + 5 }), }); const j = (await r.json()) as Record; if (!r.ok) { setFetching(false); setNote(`could not fetch: ${String(j.error ?? r.status)}`); return; } // The job runs on the server; poll it rather than guess how long it takes. const job = j.job as { id: string } | undefined; for (let i = 0; i < 300 && job; i += 1) { await new Promise((res) => setTimeout(res, 1000)); const s = await fetch(`/api/report/fetch?job=${job.id}`, { cache: "no-store" }); const sj = (await s.json()) as { job?: { state?: string } }; if (!sj.job || sj.job.state === "done" || sj.job.state === "failed") break; } setFetching(false); window.location.reload(); }, [data.project, claim.id, data.pad]); const paragraph = useMemo( () => cues.map((c, i) => ( seekTo(c.start)} className={ "cursor-pointer " + (c.cited ? "bg-[var(--color-sel)]/25 text-[var(--color-fg)]" : "text-[var(--color-dim)] hover:text-[var(--color-fg)]") } > {c.text}{" "} )), [cues, seekTo], ); return (
{claim.id}
{claim.date}
{claim.display ?? (claim.value == null ? "—" : String(claim.value))}
{data.prev ? ( ← {data.prev} ) : null} {data.next ? ( {data.next} → ) : null}
{cached ? (
{data.gaps.length ? (
unadjudicated — missing {data.gaps.join(", ")}
) : (
adjudicated
)}
scope basis — the phrase that settles it