open-navigator / web_app /src /pages /PolicyQuestionPage.tsx
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
10.2 kB
import { useParams, Link, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import {
fetchPolicyQuestion,
type CanonicalArgument,
type PolicyQuestionDetail,
} from '../api/policyQuestions'
import { expandStateName, titleCaseCity } from '../utils/formatters'
const OUTCOME_COLORS: Record<string, string> = {
approved: 'bg-green-500',
denied: 'bg-red-500',
deferred: 'bg-amber-500',
other: 'bg-gray-400',
}
function RollupBar({ q }: { q: PolicyQuestionDetail }) {
const r = q.rollup
const total = r.approved_count + r.denied_count + r.deferred_count + r.other_count
const seg = (n: number) => (total > 0 ? `${(n / total) * 100}%` : '0%')
const pct =
r.jurisdictions_total > 0
? Math.round((r.jurisdictions_approved / r.jurisdictions_total) * 100)
: 0
return (
<div className="bg-white rounded-lg border border-gray-200 p-5">
{r.instances_total === 0 ? (
<p className="text-gray-500">No mapped instances yet.</p>
) : (
<>
<p className="text-lg font-semibold text-gray-900">
{r.jurisdictions_approved} of {r.jurisdictions_total} jurisdictions approved
<span className="text-gray-500 font-normal"> ({pct}%)</span>
</p>
<p className="text-sm text-gray-500 mb-3">
{r.decisions_total} local decision{r.decisions_total === 1 ? '' : 's'}
{r.bills_total > 0 ? ` · ${r.bills_total} state bills` : ''} ·{' '}
{r.states_total} state{r.states_total === 1 ? '' : 's'}
</p>
<div className="flex h-3 w-full overflow-hidden rounded-full">
{(['approved', 'denied', 'deferred', 'other'] as const).map((k) => {
const n = r[`${k}_count` as keyof typeof r] as number
return n > 0 ? (
<div
key={k}
className={OUTCOME_COLORS[k]}
style={{ width: seg(n) }}
title={`${k}: ${n}`}
/>
) : null
})}
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
<span>● approved {r.approved_count}</span>
<span>● denied {r.denied_count}</span>
<span>● deferred {r.deferred_count}</span>
<span>● other {r.other_count}</span>
</div>
</>
)}
</div>
)
}
function ArgumentCard({ a }: { a: CanonicalArgument }) {
return (
<div className="rounded-md border border-gray-200 bg-white p-3">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-gray-900 text-sm">{a.label}</span>
<span className="shrink-0 text-[11px] text-gray-400">×{a.member_count}</span>
</div>
{a.summary && <p className="mt-1 text-sm text-gray-600">{a.summary}</p>}
<div className="mt-2 flex flex-wrap gap-1 text-[11px]">
{a.frame_label && (
<span className="rounded bg-indigo-50 px-1.5 py-0.5 text-indigo-700">
{a.frame_label}
</span>
)}
{a.source_role && (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-gray-600">{a.source_role}</span>
)}
</div>
</div>
)
}
// "← Back" affordance: returns to the previous page in history (e.g. the home
// "Big questions" section or search), falling back to the questions list when
// the page was opened directly (no in-app history).
function BackLink() {
const navigate = useNavigate()
return (
<button
type="button"
onClick={() => (window.history.length > 1 ? navigate(-1) : navigate('/policy-questions'))}
className="inline-flex items-center gap-1.5 text-sm font-medium text-gray-500 hover:text-gray-900"
>
<span aria-hidden="true"></span> Back
</button>
)
}
export default function PolicyQuestionPage() {
const { questionId } = useParams<{ questionId: string }>()
const { data: q, isLoading, error } = useQuery<PolicyQuestionDetail>({
queryKey: ['policy-question', questionId],
queryFn: () => fetchPolicyQuestion(questionId as string),
enabled: !!questionId,
})
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600" />
</div>
)
}
if (error || !q) {
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="max-w-3xl mx-auto px-4 text-center">
<p className="text-red-700">Unable to load this policy question.</p>
<Link to="/policy-questions" className="text-blue-600 underline">
Back to policy questions
</Link>
</div>
</div>
)
}
const pros = q.arguments.filter((a) => a.stance === 'pro')
const cons = q.arguments.filter((a) => a.stance === 'con')
const other = q.arguments.filter((a) => a.stance !== 'pro' && a.stance !== 'con')
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-5xl mx-auto px-4 space-y-6">
<BackLink />
<div>
<p className="text-xs uppercase tracking-wide text-indigo-600 font-semibold">
This question keeps coming up
</p>
<h1 className="text-2xl font-bold text-gray-900 mt-1">{q.canonical_text}</h1>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500">
{q.primary_theme && q.primary_theme !== '__unthemed__' && (
<span className="rounded bg-gray-100 px-2 py-0.5">{q.primary_theme}</span>
)}
{q.scope && <span className="rounded bg-gray-100 px-2 py-0.5">scope: {q.scope}</span>}
{q.topic_code && (
<span className="rounded bg-gray-100 px-2 py-0.5">CAP {q.topic_code}</span>
)}
</div>
</div>
<RollupBar q={q} />
{q.relations && q.relations.length > 0 && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4">
<h2 className="text-sm font-semibold text-amber-900 mb-2">Across levels of government</h2>
<ul className="space-y-1.5">
{q.relations.map((r) => (
<li key={`${r.relation_type}-${r.question_id}`} className="text-sm">
<span className="mr-2 rounded bg-amber-200 px-1.5 py-0.5 text-[11px] font-medium text-amber-900">
{r.relation_type === 'preempts'
? r.direction === 'incoming'
? 'may be preempted by'
: 'may preempt'
: r.relation_type === 'implements'
? r.direction === 'incoming'
? 'may be enabled by'
: 'may enable'
: 'related to'}
</span>
<Link to={`/policy-question/${r.question_id}`} className="text-blue-700 hover:underline">
{r.canonical_text}
</Link>
{r.scope && <span className="ml-1 text-gray-400">({r.scope})</span>}
</li>
))}
</ul>
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<div>
<h2 className="mb-2 text-sm font-semibold text-green-700">
Arguments for ({pros.length})
</h2>
<div className="space-y-2">
{pros.length ? pros.map((a) => <ArgumentCard key={a.argument_id} a={a} />) : (
<p className="text-sm text-gray-400">None extracted.</p>
)}
</div>
</div>
<div>
<h2 className="mb-2 text-sm font-semibold text-red-700">
Arguments against ({cons.length})
</h2>
<div className="space-y-2">
{cons.length ? cons.map((a) => <ArgumentCard key={a.argument_id} a={a} />) : (
<p className="text-sm text-gray-400">None extracted.</p>
)}
</div>
</div>
</div>
{other.length > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-semibold text-gray-600">Other arguments</h2>
{other.map((a) => <ArgumentCard key={a.argument_id} a={a} />)}
</div>
)}
<div>
<h2 className="mb-2 text-sm font-semibold text-gray-700">
Where it came up ({q.rollup.instances_total})
</h2>
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
<table className="w-full text-sm">
<tbody className="divide-y divide-gray-100">
{q.sample_instances.map((i) => (
<tr key={i.instance_id}>
<td className="px-3 py-2">
{i.source_type === 'state_bill' ? (
<Link to={`/bills/${i.source_id}`} className="text-blue-600 hover:underline">
{i.jurisdiction_name || expandStateName(i.state_code)} (bill)
</Link>
) : (
<Link
to={`/decisions/${i.source_id}`}
className="text-blue-600 hover:underline"
>
{i.jurisdiction_name || titleCaseCity(i.city) || expandStateName(i.state_code)}
</Link>
)}
</td>
<td className="px-3 py-2 text-gray-600">{i.outcome_raw}</td>
<td className="px-3 py-2">
<span
className={`rounded px-1.5 py-0.5 text-xs text-white ${
OUTCOME_COLORS[i.outcome_normalized || 'other']
}`}
>
{i.outcome_normalized}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}