Written post-hoc by the PM agent (2026-05-24) to codify shipped reality through Phase 7 + Rounds A–D.
The implementation plan that drove the initial build lives at research/PLAN.md.
Only the PM agent may edit this file.
A static-file, framework-free interactive dashboard that surfaces member-level
roll-call voting behavior for every member of the 119th US Congress. Eight
standalone legacy HTML files (Massie, Khanna, AOC, Omar, MTG, Jordan, Donalds,
Graham) were replaced with a unified single-page application covering all ~552
seated members, served from a single build artifact at results/119/.
The project is a personal research and analysis tool, built and operated by a
solo hobbyist analyst. It is designed for the analyst's own use and for sharing
with third-party hosts who can embed the artifact via iframe or inline <div>.
| User | Description |
|---|---|
| Primary | The analyst themselves — personal research, exploration, and editorial work on congressional voting patterns. |
| Secondary | Third-party hosts who embed the artifact via iframe or inline <div> into their own pages. Embedding is fully supported by design. |
Given any member of the 119th Congress, instantly render a reproducible, citation-quality breakdown of how their roll-call votes aligned with or diverged from each party's majority position — without a page reload, without a server, and without any external network dependency after the initial page load.
| Feature | Description | Priority |
|---|---|---|
| Single-member SPA dashboard | app.html — searchable typeahead + sidebar filters (chamber, party, state); on selection, fetches per-member JSON and mutates Chart.js datasets in place without teardown |
Must Have |
| 5 core charts | Vote distribution, alignment doughnut, blocking bars, alignment-over-time line, with/against stacked bar — created once at page init; fixed-height frames (340px desktop / 280px mobile) with .chart-canvas-wrap to prevent Chart.js infinite-growth bug |
Must Have |
| 8 KPI tiles | Vote counts, alignment rates, plus "Voted With GOP" and "Voted With Dem" tiles (up from 6 at Phase 6) | Must Have |
| Sortable/filterable vote table | Per-vote rows; all upstream strings rendered via textContent (no innerHTML) |
Must Have |
| URL deep-linking | pushState on member selection; replaceState on filter typing; reload restores state |
Must Have |
| localStorage persistence | Last-selected member persisted as polisci:v119:lastMember; validated against manifest on read |
Could Have (shipped) |
| Comparison view | compare.html — multi-select pills (up to 6 members); 5 overlay charts; shareable ?ids= URL |
Must Have |
| All-member coverage | enrich_roster.py pulls the complete 119th roster from Congress.gov API; members with zero roll-call votes receive a served_partial banner |
Must Have |
| Framework-free embedding | Three modes: standalone, iframe (sandbox="allow-scripts allow-same-origin"), inline <div id="polisci-root" data-base="…"> |
Must Have |
| Zero CDN dependencies | Chart.js 4.4.0 + SortableJS 1.15.2 vendored locally in template/vendor/; no runtime external requests |
Must Have |
| Parameterized pipeline | All build scripts accept --congress N; generalizes to future Congresses without code changes |
Must Have |
| Reproducibility metadata | Every per-member JSON includes a _meta block: schema version, pipeline version, classifier hash, data snapshot date, source XML counts |
Must Have |
| Test suite | pytest tests/test_analyze.py with frozen XML fixtures covering partisan, bipartisan, absent-member, and failed-blocking cases |
Must Have |
| Rankings page | ranking.html + ranking.js — 14 sortable metrics across House/Senate; party + chamber filters; row-click opens member dashboard in new tab; shareable URL state; driven by per-member KPI dict (k field) in the manifest (one fetch only) |
Must Have |
| Structural member banners | app.js renderNote() detects and renders contextual banners by priority: delegate > unseated (member-elect) > died > replaced_by > replaces > served_partial > none; predecessor/successor names rendered as in-app links via state.membersById |
Must Have |
| Delegate detection | 6 territorial House delegates (AS/DC/GU/MP/PR/VI) detected by USPS code; parse.py corrects "XX" state in vote XMLs to real territory code; is_delegate flag in per-member JSON + dl: true in manifest; yellow explanatory banner in app.js |
Must Have |
| Replacement linking | 8 House replacement pairs auto-linked in the 119th (e.g. Gaetz→Patronis, Waltz→Fine, Grijalva R.→Grijalva A.); replaces / replaced_by bioguide refs in per-member JSON |
Must Have |
NOTES.md for full rationale)lastMember is persisted; filter state persistence deferred (NOTES item 4)compare.html uses Helped Neither as a proxy; a precise monthly series requires a future analyze.py enhancement (NOTES item 6)Single-member view (app.html):
1. Page loads → manifest inlined in <script type="application/json" id="polisci-manifest"> (no fetch needed under file://)
2. Sidebar populated (chamber, party, state checkboxes)
3. User types in typeahead or applies sidebar filters → list narrows live
4. User selects a member → pushState updates URL to ?id=<bioguide>
5. App fetches data/members/<id>.json (cache-busted by manifest version)
6. Chart.js datasets mutated in place; chart.update('none') called — no teardown
7. KPI cards (8 tiles), sortable/filterable vote table, and member-note banner update
8. Structural banner rendered if member is a delegate, member-elect, deceased, or part of a replacement chain
9. Reloading the URL restores the same member
Comparison view (compare.html):
1. Page loads → same inlined manifest; sidebar setup
2. User selects members via typeahead → color-coded pills appear
3. App fetches each selected member's JSON; 5 overlay charts update
4. URL updates to ?ids=<id1>,<id2>,... (shareable, capped at 6)
5. Pill click opens member's app.html?id=<id> in a new tab
6. Reloading restores all selected members from URL
Rankings view (ranking.html):
1. Page loads → manifest KPI dict (k field) used directly — no per-member fetches
2. Table rendered with all members; 14 metrics available as sortable columns
3. Party + chamber filters narrow the table live
4. Column header click sorts ascending/descending
5. Row click opens member's app.html?id=<id> in a new tab
6. Filter + sort state encoded in URL (shareable)
@media (max-width: 768px) block in app.csstextContent; bill links built via createElement with validated href#polisci-root (0 unscoped rules per Phase 6 audit); data-base attribute makes data path host-configurablebuild_app.py for file:// compatibility); per-member JSON is ~80 KB; switching members costs one fetch, not a page load; full build completes in under 5 secondsTech stack:
pytest for testsbuild_app.py into <script type="application/json" id="polisci-manifest"> so the picker works under file:// without a local HTTP server; per-member JSON still requires HTTPData pipeline:
clerk.house.gov XML ──┐
├─→ fetch.py → parse.py → enrich_roster.py ─────────────┐
senate.gov XML ────┘ │
Congress.gov API ─────────────────────────────────────────────────────────────→┘
enrich_roster.py runs three passes:
1. Rescue pass — individual /v3/member/{bg} fetch for
vote-derived bioguides missing from the bulk listing
(e.g. Matt Gaetz, member-elect who declined the seat)
2. Replacement-linking pass — pairs predecessor↔successor
by (state, district) within the Congress window;
emits replaces / replaced_by bioguide refs
3. Detail-enrichment pass — individual lookups for every
member on a replacement chain to get congress_term
(startYear/endYear/district), death_year, current_member
↓
build_members.py (parallel pool)
→ data/119/members/<id>.json × 552
(carries: congress_term, death_year, current_member,
replaces, replaced_by, is_delegate)
→ data/119/manifest.json
(carries: per-member KPI dict k, dl flag for delegates)
↓
build_app.py
→ results/119/ (embeddable artifact)
(manifest inlined into app.html / ranking.html)
Data sources:
clerk.house.gov — House roll-call XML (553 votes cached)senate.gov — Senate roll-call XML (789 votes cached)congress.gov/v3 API — complete 119th roster + Senate LIS-to-bioguide crosswalk; API key in .env (gitignored)Documentation:
Methodology.md (repo root + copied into results/<C>/) — end-user methodology doc; linked from page footers alongside House Clerk + Senate XML indexesCLAUDE.md (repo root) — Claude Code project contextSecurity posture:
textContent, never innerHTMLparse.py rejects strings containing <, >, or control charactersid matched against ^[A-Z]\d{6}$|^S\d{3}$ and verified against manifest before any fetch or DOM useids capped at 6; each validated against manifestpostMessage API (frame-boundary attack surface closed in v1)Deployment: fully static; serve results/119/ from any file host or web server. Recommended CSP and iframe sandbox snippet documented in results/119/README.md.
.env with API key; add .gitignore (.env, __pycache__/, *.pyc, data/*/cache/)DOCUMENTATION.md §2; add rotation guidanceNOTES.md with 6 deferred concernstemplate/vendor/enrich_roster.py: paginate Congress.gov /member/congress/119; write members_directory.json; build lis_to_bioguide.json via second-pass senator detail fetchparse.py: merge members_directory.json into roster.json; apply served_partial flag to zero-vote members; validate len(roster) >= 535build_members.py: multiprocessing.Pool, atomic writes, _meta block, build_report.json, manifest array + version fieldtests/fixtures/*.xml (partisan, bipartisan, absent, failed-blocking) + tests/test_analyze.pytemplate/app.html + template/app.css (namespaced under #polisci-root)enrich_roster.py runs clean; parse.py produces merged roster; pytest passes; build_members.py emits ≥535 JSONs + manifesttemplate/app.js: manifest loader, sidebar filters, typeahead, member-fetch + in-place Chart.js updates for all 5 chartstextContent substitution)pushState on selection, replaceState on filter typing, popstate handler)served_partial memberslastMember (namespaced + validated)legacy/*.htmlbuild_members.py --congress 119 for all members; confirm 552 JSONs, 0 failures in build_report.jsoncompare.html + multi-select pills, shareable ?ids= URL (scaffolding)Helped Neither proxy)build_app.py: copy template → results; stamp manifest version into HTML; copy data; write results/119/README.md with CSP + sandbox snippetresults/119/_embed_test.htmldata-base attribute support; iframe embed test (_iframe_test.html, _iframe_compare_test.html); HTTP smoke via python3 -m http.server 8765; external-URL grep audit (zero runtime external calls)DOCUMENTATION.md §8 (new file layout), §9 (new regeneration commands), §11 (change-log entries)PROJECT_SCOPE.md reflecting shipped reality (this file)legacy/ after user confirmationranking.html + ranking.js): 14 sortable metrics, party + chamber filters, row-click to member dashboard, shareable URL statek field per member) so Rankings page needs only one fetch; build_app.py inlines full manifest into <script type="application/json" id="polisci-manifest"> for file:// support.chart-canvas-wrapMethodology.md; write Methodology.md (repo root + copied into results/<C>/); write CLAUDE.md (project context, 104 lines)parse.py corrects "XX" state in vote XMLs; is_delegate in per-member JSON + dl: true in manifest; yellow explanatory banner in app.jsenrich_roster.py rescue pass (individual lookups for vote-derived bioguides missing from bulk listing); replacement-linking pass (pairs predecessor↔successor by state+district, emits replaces/replaced_by); detail-enrichment pass (individual lookups for replacement-chain members to get congress_term, death_year, current_member)app.js renderNote() priority chain: delegate > unseated > died > replaced_by > replaces > served_partial > none; predecessor/successor rendered as in-app links via state.membersById; 8 House replacement pairs auto-linked.claude/skills/bulk-update/SKILL.md: skill doc orchestrating the multi-round scope-update workflowpytest tests/ — all greenbuild_members.py --congress 119 — 552 member JSONs, 0 failures, completes in under 5 secondsapp.html re-renders without a page loadSee NOTES.md for the full 6-item list with rationale. Summary:
lastMember persisted; full filter persistence not implemented.Helped Neither used as proxy in compare chart; a true series requires a future analyze.py change.The pipeline is fully parameterized. To build the 120th Congress dashboard:
python3 fetch.py --congress 120 # once roll-call data is available
python3 parse.py --congress 120
python3 enrich_roster.py --congress 120
pytest tests/
python3 build_members.py --congress 120
python3 build_app.py --congress 120
# artifact at results/120/
No code changes are required. The only prerequisite is live data from
clerk.house.gov and senate.gov for the 120th Congress.
Features added after initial scope. Complete current Implementation Plan progress before starting these.
| Feature | Description | Added On | Rationale |
|---|---|---|---|
| (none yet) |