diff --git a/AGENTS.md b/AGENTS.md index c2092d9..c0198f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,7 +169,7 @@ When contributing to this project, you must adhere strictly to these rules: ## 🚀 Next Steps & Outstanding Work - The active remaining roadmap is `docs/canonical_data_and_analytics_plan.md`. Phases 1 and 2 are implemented and the scraper identity resolver in Phase 3 is complete. Applied migrations - `0022` through `0037` establish deterministic identity, atomic source-profile writes, + `0022` through `0038` establish deterministic identity, atomic source-profile writes, provenance, person office terms, and private normalized House and Senate roll-call facts. Both official vote paths remain bounded, identifier-only, and isolated from legacy `voting_records`; the versioned `0031` read RPC combines presentation-safe @@ -182,7 +182,9 @@ When contributing to this project, you must adhere strictly to these rules: JSON, legacy vote writes, or a public read path. `0036` records the successful manual canary and private audit before the reviewed workflow enabled that same bounded path for schedules. `0037` adds a narrow measure-aware public voting-record RPC without opening those private - tables or changing the v2 vote contract; it is applied and live-validated. + tables or changing the v2 vote contract. `0038` adds bounded official participation summaries, + canonical same-scope alignment rankings, and inspectable pairwise evidence while keeping + state/historical legacy votes separate; both migrations are applied and live-validated. - Monitor and resolve quarantined identity candidates instead of weakening the pre-write boundary. A person can have federal, state, and local roles over time; those roles must not become separate canonical people or be flattened into one office field. @@ -207,7 +209,10 @@ When contributing to this project, you must adhere strictly to these rules: run confirmed that the legacy person-filtered GovTrack crawl had zero attempts or failures while the official House, Senate, and Congress.gov paths stayed healthy. Migration `0037` is now applied: v3/v2 base-row equivalence, all 43 exact measure links across 40 roll calls, browser - ACLs, pagination, and representative House/Senate profiles were validated live. The scraper + ACLs, pagination, and representative House/Senate profiles were validated live. Migration + `0038` is also applied: anonymous-role House/Senate summary, alignment, and pair-evidence + reconciliations passed with zero ACL or contract violations, and scraper preflight now requires + the complete analytics surface. The scraper cron is intentionally paused during active development. Use only the manual workflow, keep the state LittleSis fields blank and GovTrack profile mode disabled for routine runs, explicitly enable the reviewed House/Senate/Congress.gov modes when refreshing those facts, and never diff --git a/README.md b/README.md index 9b3e727..3839f0b 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,13 @@ then decorates only official rows with at most 100 exact linked measures. It exp titles, purposes, official Congress.gov links, and bounded source freshness without granting browser roles direct access to private provenance or legislative tables. +Migration `0038` builds the first canonical analytics surface from those same reviewed official +facts. The live profile summarizes coverage and participation by chamber and Congress, ranks +same-scope peers using only shared Yea/Nay votes with a ten-vote minimum, and provides paginated +pairwise evidence behind every comparison. State and historical legacy votes remain visibly +separate, and every federal comparison links back to its official roll call and any exact +Congress.gov measure metadata without opening the private fact tables. + Migration `0030_senate_roll_call_production_enablement.sql` verifies migration `0029`'s exact public barrier, owner-only helper, constraint, ACL, dependency, identity-index, zero-fact, and service-role read-only contracts. It replaces the same public function OID with a thin guarded @@ -394,7 +401,7 @@ only runs the ETL and `nextjs.yml` only builds. prevents half-applied data changes. Migration `0027` is the explicit exception: use `ON_ERROR_STOP=1` but omit `--single-transaction` so its committed cutover barrier can become visible before its second transaction drains old callers and atomically enables both gates. - Migrations `0028` through `0037` use the normal single-transaction rule. `0028` is the private + Migrations `0028` through `0038` use the normal single-transaction rule. `0028` is the private Senate source-review decision. `0029` installs the write-disabled Senate provenance contract and hard preflight-only barrier. `0030` verifies that exact disabled state, installs the guarded wrapper, and enables the database gates. `0031` installs the narrow public, @@ -412,7 +419,9 @@ only runs the ETL and `nextjs.yml` only builds. bounded writer. The current cron pause leaves runtime and manual-input defaults disabled, and unknown events fail closed. `0037` creates the measure-aware `get_canonical_voting_records_v3` read RPC without changing v2, legacy votes, or - scraper writers; it advances preflight so the new read contract cannot silently drift. + scraper writers. `0038` adds bounded official-vote summary, same-scope alignment, and + inspectable pair-comparison RPCs; it keeps legacy votes separate and advances preflight so the + complete analytics contract cannot silently drift. Do **not** replay the full historical migration directory against an upgraded database. Some migrations contain guarded data decisions and review-state transitions, not just diff --git a/docs/canonical_data_and_analytics_plan.md b/docs/canonical_data_and_analytics_plan.md index 8c8aef7..0f5c8d7 100644 --- a/docs/canonical_data_and_analytics_plan.md +++ b/docs/canonical_data_and_analytics_plan.md @@ -511,11 +511,19 @@ or identity-candidate queue. Build analytics only after identity-aware reads exist. +The first complete capability is implemented by +`0038_canonical_federal_voting_analytics.sql` and the profile/Connections clients. It summarizes +official participation by chamber and Congress, ranks peers only within that exact scope using +shared Yea/Nay casts and a ten-vote minimum, and exposes paginated roll-call evidence behind each +pair. Present and Not Voting remain visible in participation but do not enter the alignment +denominator. The federal lane never reads legacy `voting_records`; state and historical +co-voting remains a clearly labeled compatibility lane until it has its own canonical model. + Candidate RPCs/views: - shared donor overlap by person - donor bridge view across canonical people -- voting agreement/opposition by person and jurisdiction +- state/local voting agreement by person and jurisdiction (federal official alignment is complete) - two-hop organization/person relationship overlap - same-state, same-chamber, and same-office cohorts - media co-mention discovery signals @@ -712,6 +720,18 @@ access, pagination, and representative House and Senate profiles. Scraper prefli the 0037 marker, so deployment drift cannot remain silent. This slice added no source request, credential, writer, or legacy vote mutation. +The federal voting analytics slice added and applied +`0038_canonical_federal_voting_analytics.sql` and the live Voting & Analytics/Connections +integration. Three bounded RPCs provide per-scope participation, canonical same-chamber and +same-Congress rankings, and inspectable pairwise official roll calls with exact measure metadata. +The metric uses only shared Yea/Nay votes, requires at least ten, describes recorded alignment +rather than causation, and keeps state/historical legacy co-voting in a separate labeled lane. +The internal read model and all private provenance/fact tables remain closed to browser roles; +the scraper write contract and source request budget are unchanged. Live anonymous-role checks +reconciled summary categories, rates, ranking samples, and pair evidence for representative House +and Senate profiles with zero contract or ACL failures. Scraper preflight now requires the 0038 +marker and all three RPCs so this read surface cannot silently drift. + During active site development the cron trigger is intentionally paused. The ETL is dispatched manually, remains serialized, and keeps every write-mode input disabled by default. Routine full refreshes explicitly enable only the reviewed House, Senate, and Congress.gov paths, keep the diff --git a/frontend/src/app/[politician_id]/ConnectionsTab.tsx b/frontend/src/app/[politician_id]/ConnectionsTab.tsx index 977c36f..d627f74 100644 --- a/frontend/src/app/[politician_id]/ConnectionsTab.tsx +++ b/frontend/src/app/[politician_id]/ConnectionsTab.tsx @@ -9,7 +9,8 @@ import { type CoVoteConnection, } from '@/lib/connections'; import { isUuid } from '@/lib/ids'; -import { profilePath } from '@/lib/routes'; +import { profilePath, votingComparisonPath } from '@/lib/routes'; +import { formatDate } from './ProfileSpokeStates'; // A connection rendered as a node in the hub-and-spoke mini-graph. interface GraphNode { @@ -34,19 +35,26 @@ function firstName(name: string): string { /** Pick the strongest connections across all types for the at-a-glance graph. */ function buildGraphNodes(data: ConnectionsBundle): GraphNode[] { + const officialVoting = Boolean(data.federalVotingSummary); + const alignedVotes = officialVoting + ? [...data.coVotes] + .filter((connection) => (connection.aligned_rank ?? Number.POSITIVE_INFINITY) <= 3) + .sort((a, b) => (a.aligned_rank ?? 99) - (b.aligned_rank ?? 99)) + : data.coVotes.filter((connection) => connection.agreement_rate >= 0.5).slice(0, 3); + const differingVotes = officialVoting + ? [...data.coVotes] + .filter((connection) => connection.disagree_count > 0 && (connection.differing_rank ?? Number.POSITIVE_INFINITY) <= 2) + .sort((a, b) => (a.differing_rank ?? 99) - (b.differing_rank ?? 99)) + : data.coVotes.filter((connection) => connection.agreement_rate < 0.5).slice(0, 2); const donors: GraphNode[] = data.sharedDonors.slice(0, 3).map((d) => ({ id: d.politician_id, label: firstName(d.full_name), weight: d.shared_donor_count, kind: 'donor', })); - const allies: GraphNode[] = data.coVotes - .filter((c) => c.agreement_rate >= 0.5) - .slice(0, 3) + const allies: GraphNode[] = alignedVotes .map((c) => ({ id: c.politician_id, label: firstName(c.full_name), weight: c.agree_count, kind: 'ally' })); - const opponents: GraphNode[] = data.coVotes - .filter((c) => c.agreement_rate < 0.5) - .slice(0, 2) + const opponents: GraphNode[] = differingVotes .map((c) => ({ id: c.politician_id, label: firstName(c.full_name), weight: c.disagree_count, kind: 'opponent' })); const ties: GraphNode[] = data.networkTies.slice(0, 2).map((t) => ({ id: t.related_politician_id, @@ -120,13 +128,35 @@ function PersonCardLink({ id, children }: { id: string | null; children: React.R return
{children}
; } -function CoVoteCard({ c }: { c: CoVoteConnection }) { - const ally = c.agreement_rate >= 0.5; - return ( - +function CoVoteCard({ + c, + currentPoliticianId, + rankKind, +}: { + c: CoVoteConnection; + currentPoliticianId: string; + rankKind?: 'aligned' | 'differing'; +}) { + const official = c.record_origin === 'official'; + const ally = official + ? rankKind !== 'differing' + : c.agreement_rate >= 0.5; + const content = ( + <>
-
{c.full_name}
+ {official && rankKind && ( +
+ #{rankKind === 'aligned' ? c.aligned_rank : c.differing_rank}{' '} + {rankKind === 'aligned' ? 'most aligned' : 'most often differed'} +
+ )} + + {c.full_name} +
{c.current_office}
- {Math.round(c.agreement_rate * 100)}% agree + {Math.round(c.agreement_rate * 100)}% {official ? 'aligned' : 'agree'}
- {c.agree_count} together · {c.disagree_count} opposed · {c.shared_total} shared roll calls + {c.agree_count} same · {c.disagree_count} different · {c.shared_total} shared {official ? 'Yea/Nay votes' : 'roll calls'}
-
+ {official && c.chamber && c.congress && ( +
+ {c.chamber === 'house' ? 'U.S. House' : 'U.S. Senate'} · Congress {c.congress} + {c.first_shared_vote_date && c.last_shared_vote_date + ? ` · ${formatDate(c.first_shared_vote_date)}–${formatDate(c.last_shared_vote_date)}` + : ''} +
+ )} + ); + + if (official) { + return ( +
+ {content} +
+ + Inspect shared votes + + + Official · {c.source_name || 'Federal roll call'} + +
+
+ ); + } + + return
{content}
; } function SectionHeading({ children }: { children: React.ReactNode }) { @@ -178,7 +237,13 @@ function PartialConnectionsNotice({ ); } -const EMPTY_BUNDLE: ConnectionsBundle = { sharedDonors: [], coVotes: [], networkTies: [], failures: [] }; +const EMPTY_BUNDLE: ConnectionsBundle = { + sharedDonors: [], + coVotes: [], + federalVotingSummary: null, + networkTies: [], + failures: [], +}; export default function ConnectionsTab({ politicianId, politicianName }: { politicianId: string; politicianName: string }) { // Mock/non-UUID profiles have no DB row, so the RPCs would error — start them in a @@ -239,18 +304,34 @@ export default function ConnectionsTab({ politicianId, politicianName }: { polit // non-null assertion and is a safe no-op render if it somehow isn't. if (!data) return null; const bundle = data; - const allies = bundle.coVotes.filter((c) => c.agreement_rate >= 0.5).sort((a, b) => b.agree_count - a.agree_count); - const opponents = bundle.coVotes.filter((c) => c.agreement_rate < 0.5).sort((a, b) => b.disagree_count - a.disagree_count); + const officialVoting = Boolean(bundle.federalVotingSummary); + const allies = officialVoting + ? [...bundle.coVotes] + .filter((connection) => (connection.aligned_rank ?? Number.POSITIVE_INFINITY) <= 6) + .sort((a, b) => (a.aligned_rank ?? 99) - (b.aligned_rank ?? 99)) + : bundle.coVotes + .filter((connection) => connection.agreement_rate >= 0.5) + .sort((a, b) => b.agree_count - a.agree_count); + const opponents = officialVoting + ? [...bundle.coVotes] + .filter((connection) => connection.disagree_count > 0 && (connection.differing_rank ?? Number.POSITIVE_INFINITY) <= 6) + .sort((a, b) => (a.differing_rank ?? 99) - (b.differing_rank ?? 99)) + : bundle.coVotes + .filter((connection) => connection.agreement_rate < 0.5) + .sort((a, b) => b.disagree_count - a.disagree_count); const graphNodes = buildGraphNodes(bundle); - const isEmpty = !bundle.sharedDonors.length && !bundle.coVotes.length && !bundle.networkTies.length; + const isEmpty = !bundle.sharedDonors.length + && !bundle.coVotes.length + && !bundle.federalVotingSummary + && !bundle.networkTies.length; if (isEmpty) { return (
- No cross-referenced connections found yet. Co-voting connections appear once roll-call data has been - re-ingested with stable roll-call ids; shared-donor links require overlapping FEC donors. + No cross-referenced connections found yet. Voting comparisons require shared roll calls; + shared-donor links require overlapping FEC donors.
); @@ -266,8 +347,8 @@ export default function ConnectionsTab({ politicianId, politicianName }: { polit Connection Map
- Shared donors / voting allies - Opponents / network ties + Shared donors / voting alignment + Voting differences / network ties
)} @@ -293,14 +374,41 @@ export default function ConnectionsTab({ politicianId, politicianName }: { polit )} - {/* Co-voting (verified) */} - {(allies.length > 0 || opponents.length > 0) && ( + {/* Official federal alignment, or a clearly separate legacy state/historical fallback. */} + {(bundle.federalVotingSummary || allies.length > 0 || opponents.length > 0) && (
- Voting Allies & Opponents -
- {allies.slice(0, 6).map((c) => )} - {opponents.slice(0, 6).map((c) => )} -
+ + {officialVoting ? 'Official Federal Voting Alignment' : 'State & Historical Co-voting'} + +

+ {officialVoting + ? `Exact shared Yea/Nay votes from ${bundle.federalVotingSummary?.chamber === 'house' ? 'the U.S. House' : 'the U.S. Senate'}, Congress ${bundle.federalVotingSummary?.congress}. Present and Not Voting are excluded from alignment.` + : 'This compatibility view uses retained state and historical vote feeds. It is not blended into the official federal alignment metric.'} +

+ {allies.length === 0 && opponents.length === 0 ? ( +
+ No peers meet the minimum shared-vote sample in the current official window. +
+ ) : ( +
+ {allies.slice(0, 6).map((c) => ( + + ))} + {opponents.slice(0, 6).map((c) => ( + + ))} +
+ )}
)} diff --git a/frontend/src/app/[politician_id]/PoliticianClient.tsx b/frontend/src/app/[politician_id]/PoliticianClient.tsx index 178c2c6..bfbf705 100644 --- a/frontend/src/app/[politician_id]/PoliticianClient.tsx +++ b/frontend/src/app/[politician_id]/PoliticianClient.tsx @@ -21,7 +21,7 @@ export interface PoliticianData extends ProfileHeader { const TABS: { id: TabId; label: string }[] = [ { id: 'financial', label: 'Financial Disclosures' }, { id: 'donors', label: 'Campaign Donors' }, - { id: 'votes', label: 'Voting Record' }, + { id: 'votes', label: 'Voting & Analytics' }, { id: 'connections', label: 'Connections' }, { id: 'media', label: 'Media' }, ]; @@ -113,7 +113,9 @@ export default function PoliticianClient({ politician }: { politician: Politicia
{activeTab === 'financial' && } {activeTab === 'donors' && } - {activeTab === 'votes' && } + {activeTab === 'votes' && ( + + )} {activeTab === 'connections' && ( )} diff --git a/frontend/src/app/[politician_id]/VotingAnalyticsPanel.tsx b/frontend/src/app/[politician_id]/VotingAnalyticsPanel.tsx new file mode 100644 index 0000000..eee5fdb --- /dev/null +++ b/frontend/src/app/[politician_id]/VotingAnalyticsPanel.tsx @@ -0,0 +1,678 @@ +"use client"; + +import Link from 'next/link'; +import { useCallback, useEffect, useState } from 'react'; +import { DEFAULT_PROFILE_PAGE_SIZE, emptyPage, type PageResult } from '@/lib/pagination'; +import { profilePath } from '@/lib/routes'; +import { + fetchFederalVotingAlignment, + fetchFederalVotingComparison, + fetchFederalVotingSummaries, + type FederalVotingAlignment, + type FederalVotingComparison, + type FederalVotingComparisonFilter, + type FederalVotingSummary, +} from '@/lib/votingAnalytics'; +import type { LegislativeMeasure, LegislativeMeasureType } from '@/lib/votingRecords'; +import { formatDate, PaginationControls } from './ProfileSpokeStates'; + +const MEASURE_TYPE_LABELS: Record = { + hr: 'H.R.', + s: 'S.', + hjres: 'H.J.Res.', + sjres: 'S.J.Res.', + hconres: 'H.Con.Res.', + sconres: 'S.Con.Res.', + hres: 'H.Res.', + sres: 'S.Res.', + hamdt: 'H.Amdt.', + samdt: 'S.Amdt.', + suamdt: 'Senate unprinted amendment', +}; + +function scopeKey(summary: Pick): string { + return `${summary.chamber}:${summary.congress}`; +} + +function chamberLabel(chamber: FederalVotingSummary['chamber']): string { + return chamber === 'house' ? 'U.S. House' : 'U.S. Senate'; +} + +function initialComparisonPeer(): string | null { + if (typeof window === 'undefined') return null; + const candidate = new URLSearchParams(window.location.search).get('compare'); + return candidate && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(candidate) + ? candidate + : null; +} + +function updateComparisonUrl(peerId: string | null) { + if (typeof window === 'undefined') return; + const url = new URL(window.location.href); + if (peerId) url.searchParams.set('compare', peerId); + else url.searchParams.delete('compare'); + window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`); +} + +function percentage(value: number): string { + return `${Math.round(value * 100)}%`; +} + +function measureLabel(measure: LegislativeMeasure): string { + return `${MEASURE_TYPE_LABELS[measure.measure_type]} ${measure.measure_number}`; +} + +function SummaryCard({ label, value, detail }: { label: string; value: string; detail: string }) { + return ( +
+
+ {label} +
+
{value}
+
{detail}
+
+ ); +} + +function PeerCard({ + peer, + rankKind, + selected, + onCompare, +}: { + peer: FederalVotingAlignment; + rankKind: 'aligned' | 'differing'; + selected: boolean; + onCompare: () => void; +}) { + const rank = rankKind === 'aligned' ? peer.aligned_rank : peer.differing_rank; + const count = rankKind === 'aligned' ? peer.agree_count : peer.differ_count; + + return ( +
+
+
+

+ #{rank} {rankKind === 'aligned' ? 'most aligned' : 'most often differed'} +

+

{peer.full_name}

+

+ {[peer.current_office, peer.party].filter(Boolean).join(' · ') || 'Office details unavailable'} +

+
+ + {percentage(peer.agreement_rate)} aligned + +
+

+ {peer.agree_count} same · {peer.differ_count} different · {peer.shared_substantive_count} shared Yea/Nay votes +

+

+ {count} recorded {rankKind === 'aligned' ? 'agreements' : 'differences'} from {formatDate(peer.first_shared_vote_date)} to {formatDate(peer.last_shared_vote_date)} +

+
+ + + Open profile + +
+
+ ); +} + +function ComparisonMeasure({ measure }: { measure: LegislativeMeasure }) { + return ( +
+
+ {measureLabel(measure)} + {measure.official_url && ( + + Congress.gov + + )} +
+ {measure.title &&

{measure.title}

} + {measure.purpose && measure.purpose !== measure.title && ( +

Purpose: {measure.purpose}

+ )} +
+ ); +} + +function ComparisonRecordCard({ + record, + peerName, + politicianName, +}: { + record: FederalVotingComparison; + peerName: string; + politicianName: string; +}) { + const agrees = record.comparison === 'agree'; + + return ( +
+
+
+
+ + {agrees ? 'Agreement' : 'Different votes'} + + {formatDate(record.vote_date)} + Roll call {record.roll_call_number} + Session {record.session} +
+
{record.question}
+ {record.vote_result && ( +

Result: {record.vote_result}

+ )} +
+
+
+ + {politicianName} + + {record.person_vote_cast} +
+
+ {peerName} + {record.peer_vote_cast} +
+
+
+ {record.measures.length > 0 && ( +
+ {record.measures.map((measure) => ( + + ))} +
+ )} +
+ {chamberLabel(record.chamber)} · Congress {record.congress} + {record.source_url ? ( + + Source: {record.source_name} + + ) : ( + Source: {record.source_name} + )} +
+
+ ); +} + +function ComparisonPanel({ + politicianId, + politicianName, + peer, + summary, + onClose, +}: { + politicianId: string; + politicianName: string; + peer: FederalVotingAlignment; + summary: FederalVotingSummary; + onClose: () => void; +}) { + const peerId = peer.peer_person_id; + const peerName = peer.full_name; + const [filter, setFilter] = useState(''); + const [page, setPage] = useState(0); + const [result, setResult] = useState | null>(null); + const [loading, setLoading] = useState(Boolean(peerId)); + const [error, setError] = useState(false); + const [retryKey, setRetryKey] = useState(0); + + useEffect(() => { + if (!peerId) return; + let cancelled = false; + fetchFederalVotingComparison( + politicianId, + peerId, + summary.chamber, + summary.congress, + page, + DEFAULT_PROFILE_PAGE_SIZE, + filter || undefined, + ) + .then((nextResult) => { + if (!cancelled) { + setResult(nextResult); + setError(false); + } + }) + .catch((e) => { + if (!cancelled) { + console.error('Failed to load federal voting comparison:', e); + setError(true); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [filter, page, peerId, politicianId, retryKey, summary.chamber, summary.congress]); + + const changeFilter = (next: FederalVotingComparisonFilter | '') => { + setFilter(next); + setPage(0); + setResult(null); + setLoading(true); + setError(false); + }; + + const changePage = (nextPage: number) => { + setPage(nextPage); + setResult(null); + setLoading(true); + setError(false); + }; + + const current = result ?? emptyPage(page); + + return ( +
+
+
+

Official pairwise evidence

+

Shared votes with {peerName}

+

+ Only exact shared Yea/Nay votes from {chamberLabel(summary.chamber)} in Congress {summary.congress} are compared. Present and Not Voting do not affect alignment. +

+
+ +
+ +
+ {([ + ['', 'All shared votes'], + ['agree', 'Agreements'], + ['differ', 'Differences'], + ] as const).map(([value, label]) => ( + + ))} +
+ + {loading && !result ? ( +
+
+
+
+ ) : error && !result ? ( +
+

Could not load the shared-vote evidence.

+ +
+ ) : current.rows.length === 0 ? ( +
+ No {filter === 'agree' ? 'agreements' : filter === 'differ' ? 'differences' : 'shared substantive votes'} were found in this scope. +
+ ) : ( +
+ {current.rows.map((record) => ( + + ))} + {error && ( +

Could not refresh this comparison page.

+ )} + +
+ )} +
+ ); +} + +export default function VotingAnalyticsPanel({ + politicianId, + politicianName, +}: { + politicianId: string; + politicianName: string; +}) { + const [summaries, setSummaries] = useState(null); + const [selectedScopeKey, setSelectedScopeKey] = useState(null); + const [alignment, setAlignment] = useState(null); + const [selectedPeerId, setSelectedPeerId] = useState(initialComparisonPeer); + const [summaryError, setSummaryError] = useState(false); + const [alignmentError, setAlignmentError] = useState(false); + const [summaryRetryKey, setSummaryRetryKey] = useState(0); + const [alignmentRetryKey, setAlignmentRetryKey] = useState(0); + + useEffect(() => { + let cancelled = false; + fetchFederalVotingSummaries(politicianId) + .then((nextSummaries) => { + if (cancelled) return; + setSummaries(nextSummaries); + setSummaryError(false); + setSelectedScopeKey((currentKey) => { + if (currentKey && nextSummaries.some((summary) => scopeKey(summary) === currentKey)) { + return currentKey; + } + return nextSummaries[0] ? scopeKey(nextSummaries[0]) : null; + }); + }) + .catch((e) => { + if (!cancelled) { + console.error('Failed to load federal voting summary:', e); + setSummaryError(true); + } + }); + + return () => { + cancelled = true; + }; + }, [politicianId, summaryRetryKey]); + + const selectedSummary = summaries?.find((summary) => scopeKey(summary) === selectedScopeKey) + ?? summaries?.[0] + ?? null; + + useEffect(() => { + if (!selectedSummary) return; + let cancelled = false; + fetchFederalVotingAlignment( + politicianId, + selectedSummary.chamber, + selectedSummary.congress, + ) + .then((nextAlignment) => { + if (!cancelled) { + setAlignment(nextAlignment); + setAlignmentError(false); + } + }) + .catch((e) => { + if (!cancelled) { + console.error('Failed to load federal voting alignment:', e); + setAlignmentError(true); + } + }); + + return () => { + cancelled = true; + }; + }, [alignmentRetryKey, politicianId, selectedSummary]); + + const selectScope = (nextKey: string) => { + setSelectedScopeKey(nextKey); + setAlignment(null); + setAlignmentError(false); + setSelectedPeerId(null); + updateComparisonUrl(null); + }; + + const selectPeer = useCallback((peerId: string) => { + setSelectedPeerId(peerId); + updateComparisonUrl(peerId); + }, []); + + if (!summaries && !summaryError) { + return ( +
+
+
+
+
+
+
+
+
+ ); + } + + if (summaryError && !summaries) { + return ( +
+

Could not load official federal voting analytics.

+ +
+ ); + } + + if (!selectedSummary) { + return ( +
+

Official federal voting analytics

+

+ No verified House or Senate roll-call window is available for this profile. State and historical voting records remain available below when present. +

+
+ ); + } + + const aligned = [...(alignment ?? [])] + .filter((peer) => peer.aligned_rank <= 6) + .sort((a, b) => a.aligned_rank - b.aligned_rank); + const differing = [...(alignment ?? [])] + .filter((peer) => peer.differ_count > 0 && peer.differing_rank <= 6) + .sort((a, b) => a.differing_rank - b.differing_rank); + const selectedPeer = alignment?.find((peer) => peer.peer_person_id === selectedPeerId) ?? null; + + return ( +
+
+
+
+
+ + Official analytics + + + {selectedSummary.source_name} + +
+

Federal voting analytics

+

+ Coverage, participation, and peer comparisons come only from exact official roll calls tied to canonical people. These numbers describe recorded voting overlap, not political influence or endorsement. +

+
+ {summaries && summaries.length > 1 && ( + + )} +
+ +

+ {chamberLabel(selectedSummary.chamber)} · Congress {selectedSummary.congress} · {formatDate(selectedSummary.first_vote_date)}–{formatDate(selectedSummary.last_vote_date)} +

+ +
+ + + + +
+
+ +
+
+
+

Official voting alignment

+

+ Rankings require at least {selectedSummary.alignment_minimum_shared_votes} shared Yea/Nay votes in the same chamber and Congress. “Different” means opposite recorded votes on the same roll call. +

+
+ {selectedSummary.source_updated_at && ( + + Observed {formatDate(selectedSummary.source_updated_at)} + + )} +
+ + {!alignment && !alignmentError ? ( +
+
+
+
+ ) : alignmentError && !alignment ? ( +
+

Could not load official peer alignment.

+ +
+ ) : aligned.length === 0 && differing.length === 0 ? ( +
+ No peers meet the minimum shared-vote sample in this scope yet. +
+ ) : ( +
+
+

Most aligned

+
+ {aligned.map((peer) => ( + selectPeer(peer.peer_person_id)} + /> + ))} +
+
+
+

Most often differed

+
+ {differing.map((peer) => ( + selectPeer(peer.peer_person_id)} + /> + ))} +
+
+
+ )} +
+ + {selectedPeerId && selectedPeer && ( + { + setSelectedPeerId(null); + updateComparisonUrl(null); + }} + /> + )} +
+ ); +} diff --git a/frontend/src/app/[politician_id]/VotingRecordTab.tsx b/frontend/src/app/[politician_id]/VotingRecordTab.tsx index e3ff2ff..f2dd161 100644 --- a/frontend/src/app/[politician_id]/VotingRecordTab.tsx +++ b/frontend/src/app/[politician_id]/VotingRecordTab.tsx @@ -9,6 +9,7 @@ import { type VotingRecord, } from '@/lib/votingRecords'; import { EmptyState, formatDate, LoadingBlock, LoadError, PaginationControls, SectionHeading } from './ProfileSpokeStates'; +import VotingAnalyticsPanel from './VotingAnalyticsPanel'; const VOTE_FILTERS = ['', 'Yea', 'Nay', 'Present', 'Not Voting']; @@ -42,7 +43,22 @@ function measureLabel(measure: LegislativeMeasure): string { return `${MEASURE_TYPE_LABELS[measure.measure_type]} ${measure.measure_number}`; } -export default function VotingRecordTab({ politicianId }: { politicianId: string }) { +export default function VotingRecordTab({ + politicianId, + politicianName, +}: { + politicianId: string; + politicianName: string; +}) { + return ( +
+ + +
+ ); +} + +function VotingRecordsList({ politicianId }: { politicianId: string }) { const [page, setPage] = useState(0); const [voteCast, setVoteCast] = useState(''); const [result, setResult] = useState | null>(null); @@ -112,9 +128,11 @@ export default function VotingRecordTab({ politicianId }: { politicianId: string const latest = current.rows[0]?.vote_date; return ( -
+
- +
+ +