Skip to content

Fix v3 report content: shared-link dates, NaN labels, people wording, downloads, phone names, district decimals - #1281

Merged
PavelMakarchuk merged 44 commits into
mainfrom
fix/v3-audit-report-content
Oct 15, 2026
Merged

PavelMakarchuk merged 44 commits into
mainfrom
fix/v3-audit-report-content

Conversation

@PavelMakarchuk

Copy link
Copy Markdown
Contributor

Summary

Fixes six report content problems that an audit of app v3 reproduced. Each fix started from a failing test. All changes are in v3 (flagship) code, except one opt-in marker in the shared chartUtils.ts SVG download.

Problems and fixes

1. Shared report links describe the policy changes for 2026, not the report year (medium)

Open /us/report/4327 (MD CTC Increase, 2027, policy 98731) from a link: the Policy changes table read "From Jan 1, 2026" and the 2026 values. Report.page.tsx called provenanceFromPolicy without the report year, and provisionsFromPolicy took today's current law, the 2026 (or first) interval and no dates.

Fix: the page passes report.year. provisionsFromPolicy copies the policy's dated values into intervals and runs the provisions through provisionsForYear with current law at that year, the same path the run's own browser stashes. The table and NoChangeNotice now get the reform's real dates and the values the simulation used.

2. A group with a zero baseline rate gives "NaN%" on the All bar (medium)

For a group with no one in (deep) poverty before the reform, reform / baseline - 1 is NaN. Recharts drew no bar for it, so the value labels (indexed by drawn bars) shifted: "NaN%" sat on All and All's value was lost.

Fix: a shared v3 helper (relativeChange.ts) returns no ratio for a zero baseline. That bar draws at zero and reads "n/a", every other bar keeps its own label, and the hover text and title give the change in percentage points. Applied to the five poverty charts and the inequality chart.

3. The overview tile says "Households gaining" for a share of people (low)

The API weights intra_decile by people, so the share is a share of people. The tile and the winners tooltip now read "People gaining income" and "All people". When more people lose than gain, the tile shows "People losing income". The tracker's stored bill figure uses the same wording.

4. The winners and losers SVG download has no legend and no All row (medium)

downloadChartAsSvg skips every SVG under 40px as an icon, which dropped the compact 28px All row. The legend was HTML, which the download does not copy.

Fix: the legend now draws in SVG. The chart marks its All row and legend with a new opt-in marker, CHART_EXPORT_KEEP (data-chart-export="keep"), which the download keeps at any visible size.

5. On phones, bars lose their category names (medium)

At 375px, Recharts dropped names that would touch: the waterfall's "State and local income taxes", the inequality chart's "Top 10% share" and the race chart's "Hispanic". Their value labels stayed.

Fix: a v3 category axis (categoryAxis.tsx) sets interval={0}. Each name shows in full where it fits its bar's band, else in a short form ("State taxes", "Top 10%", "Adults"), else over two lines, else in a smaller font. The axis grows by a line when a name wraps. Desktop names are unchanged, and compact age charts still read "Adults".

6. The district Relative view reads 0.0% for every district in a state report (medium)

The map, its legend and the rankings formatted to one decimal, so changes of 0.002% to 0.010% all read "0.0%", with "-0.0%" at the legend's end.

Fix: percentages take one to four decimals, so the largest value shows two significant figures. A value that rounds to zero reads without a minus, and the same applies to dollar amounts. The two rankings split a state with fewer than ten districts and no longer list a district in both. A view where every district is zero (for example, Loser % when no one loses) shows a message instead of a ranking of zeros.

v2 impact

No legacy v2 page or behavior changes. The flagship chart components are used only by v3. The one shared file is app/src/utils/chartUtils.ts: downloadChartAsSvg keeps an SVG under 40px only when an ancestor has the new CHART_EXPORT_KEEP marker. No v2 code sets the marker, so v2 downloads stay the same. A test proves that an unmarked SVG under 40px is still skipped.

Tests

  • New and updated unit tests for each problem: reportProvenance, ReportSharedProvenance, relativeChange, ReportBarCharts, ReportContents, billMetrics, WinnersLosersIncomeDecileSubPage (the download keeps All and the legend), chartUtils (marker kept, unmarked default unchanged), ReportCategoryNames and categoryAxis (phone widths with Recharts' text measuring estimated), and DistrictImpactCard (Maryland 4327 districts).
  • bun run typecheck: pass.
  • bunx eslint and bunx prettier --check on changed files: pass.
  • bunx vitest run on the flagship folders and chartUtils: 95 files, 1008 tests pass.
  • bun run vitest (full): 433 files, 4973 tests pass, 10 skipped.

Note: this PR is based on the open #1274 (fix/v3-relevant-comparisons). Retarget it to main after #1274 merges.

🤖 Generated with Claude Code

PavelMakarchuk and others added 30 commits October 14, 2026 18:11
Each paged read of a tracker table now sends an order on the table's
key. Without ORDER BY, Postgres gave the two 1,000-row pages of
processed_bills in a different order on each load, so some rows came
back twice and others were never read. Those bills lost their stage
badge, their bill text link and their place in the stage filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The provisions rebuilt from the reform policy took today's current law,
the 2026 value (or the first interval), and no dates, so a shared 2027
report read 'From Jan 1, 2026'. They now carry the policy's dated values
and pass through provisionsForYear for the report year, as the run's own
stash does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each paged tracker read now names a stable order by the table's key.
Without one, page 2 of processed_bills could repeat rows of page 1 and
skip others, so bills lost their stage and bill-text link. When two
status rows share a bill number, the one with the latest action wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An id with words after the number (hi-sb3125-hd1,
nj-a1185-marriage-penalty-elimination, ut-sb60-bill) matched no
processed_bills row, so the bill showed no stage. After the id, the
join now tries the title's bill number, as the tracker does, and then
the id's first part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The stored budget card on Economic impacts now takes the bill and uses
the overview's own wording: "Annual revenue loss" for a federal bill,
"Annual state revenue loss" for a state bill, with the amount unsigned
next to the loss or gain word, as the overview shows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each paged tracker read now names a unique order column (bill_id for
processed_bills, id for the rest). Without one, Postgres gave page 2 of
processed_bills rows already on page 1, so about 418 of 1,418 status
rows were lost on each load, and a different set each time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Open as draft reform started a national draft for this year, so a bill
from 2027 scored no change from Build and a state bill ran nationwide.
The draft now opens with the bill's run year and state, as the bill's
report and Run impact report do. loadProvisionsIntoDraft takes an
opt-in source; without one the draft stays hand-built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tage

When two processed_bills rows share one normalized bill number (NY
"S 4487" and "S04487"), the feed kept whichever row it read last. It
now keeps the row with the higher LegiScan bill id, the later bill, so
the stage and the bill text link no longer depend on the read order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Economic impacts tab labelled every stored budget 'Annual state
revenue', so HR 1425's -$225.5bn read as state revenue while the
Overview said 'Annual revenue loss'. The stored budget now takes the
bill's state and words it as the Overview does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Delete in the reform detail now opens a confirm dialog that names the
reform and says how many of its reports leave the list (they still
open from their links). A failed delete or duplicate shows an error,
and the error clears when another reform opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tracker's fiscal_note_source was read but never shown, and a note
stored as a range without a point estimate lost its link, so
'Within fiscal-note range' cited a note no one could open. The
fiscal-note row now takes the stored source as its name, and a note
without an estimate is named and linked under the accepted range, or
in a row of its own when no range is stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
reform / baseline - 1 is NaN (or Infinity) for a group with no one in
poverty before. Recharts drew no bar for it, so the value labels, which
go by the index of the bars drawn, moved onto the next group: 'NaN%'
sat on All and All's own value was lost. Such a group now draws a bar
of zero that reads 'n/a', and its hover and the title say the change in
points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… re-run

UT SB60's tracker row scores 0.045, the law before the bill, under a
Policy changes table of 4.5% to 4.45%, so Load full results showed an
$85.0m revenue gain for a rate cut. The bill report now compares the
values a run would score with the write-up's 'after' values. When they
disagree, the page keeps the tracker's stored estimate and says why the
full results are not available.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lead

The API's winner shares weight each household by its people, so the
overview tile read a share of people as 'Households gaining' and the
winners tooltip named the row 'All households'. They now say 'People
gaining income' and 'All people'. When more people lose than gain, the
tile shows 'People losing income' instead of a bare 0.0% gaining. The
tracker's stored figure on a bill report reads the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Recharts' default tick interval hid category labels that crowd, so on a
375px screen the Hispanic bar, the Top 10% share bar and the state tax
bar had a value and no name. The report's bar charts and the budget
waterfall now show every tick: each label wraps by word to its own
slot, the font shrinks until the longest word fits, and the x-axis
grows to hold the label with the most lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
react-simple-maps gives every district shape tabindex 0, with no name
and, here, no focus outline, so a keyboard user pressed Tab hundreds of
times to pass the map. The districts now take no focus, and the map
reads as one image whose name points to the ranked district lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This reverts commit 9ae8107. The
paged tracker reads get their stable order in PR #1277
(fix/v3-audit-security), so this branch does not carry its own copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The card's SVG download skipped every SVG under 40px as an icon, so the
compact 28px All row was dropped, and the legend was HTML, which the
download does not copy. The legend now draws in SVG, and the chart marks
its All row and legend with CHART_EXPORT_KEEP, an opt-in marker the
download keeps at any visible size. Unmarked SVGs are filtered as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aders

The search input is a combobox whose focus stays in the input while the
arrow keys move the highlight, but nothing told a screen reader which
row was highlighted, so Enter added a parameter the user had not heard.
Each result now has its own id, the input points at the highlighted one
with aria-activedescendant (none while a folder is pointed at, the list
is out of date or no results show), and it says it filters a list with
aria-autocomplete="list".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o fix/v3-audit-states-a11y

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Revert 2e15b9f. The paged processed_bills read and the rule for two
status rows under one bill number belong to #1277 (fix/v3-audit-security)
only. The feed reads processed_bills without last_action_date again, and
the title and id-prefix stage join stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The id "sc-income-tax-cut" names no bill number, so only the title's
"H. 3492" finds the processed_bills row. The test fails when the title
candidate is removed from processedBillFor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Recharts drops a category name that would touch its neighbour, so at
375px the budget waterfall lost 'State and local income taxes', the
inequality chart 'Top 10% share' and the race chart 'Hispanic', while
their value labels stayed. The report's category axes now show every
name: in full where it fits under its bar, else a short form ('State
taxes', 'Top 10%'), else over two lines, else in a smaller font.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When two status rows share one bill number (NY "S04487" from LegiScan
and "S 4487" from OpenStates), the row with the later last_action_date
now wins. On the same day, or with no dates, the row that links to
LegiScan wins. The bill id is only a fixed tie-break after that: the
OpenStates and Congress monitors hash their ids above 10,000,000, so
a higher id does not mean a later bill, and the OpenStates row always
won before. The test rows now carry realistic ids for each source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Relative view, its legend and its rankings rounded to one decimal,
so a state reform that moves each district by thousandths of a percent
read 0.0% throughout, and '-0.0%' at the legend's end. Percentages now
take the decimals the largest value needs (one to four), and a value
that rounds to zero reads without a minus. A state with fewer than ten
districts splits them between the two rankings instead of listing some
in both, and a view where every district is zero, such as Loser % when
no one loses, says so instead of ranking zeros.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
billScoresOtherThanWriteUp flagged a bill when any paired provision held
no value near the write-up's "after". Correct bills lost "Load full
results": a filing-status change written with one status's value, a
yearly write-up of a monthly parameter, a step ("+1%") or a percent
stored as a whole number.

The check now needs evidence of a swap: the named provisions together
hold the write-up's "before" value and none of its "after" value. A
signed "after" next to an unsigned "before" is a step and is not checked.
A stored value also reads as a percent the other way (4.45 for "0.0445").
UT SB60 (0.045 for "4.5% → 4.45%") still flags.

changeNamesPath is exported, so the Policy changes table can share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PavelMakarchuk and others added 4 commits October 15, 2026 08:53
The 1,001-bill test now gives every reform_impacts row its baseline
params and checks that every bill's provisions carry that baseline.
Before, the baselines read could lose its row order and the test
still passed; now that read drops the 1,001st bill's baseline and the
test fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…red helper

ReportChanges copied changeNamesPath line by line as namesPath. It now
imports the one in billProvisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The federal page test passes with the page's state prop removed, since
no state already gives the federal words. This test fails then: a Utah
bill must read "Annual state revenue loss" on Economic impacts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The feed maps a null fiscal_note_url to undefined but keeps "". With no
estimate, an empty link then gave an "Official fiscal note · No
estimate" row with no link and no source. The estimates table now
treats a blank link as absent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
policyengine-calculator-next Ready Ready Preview Oct 15, 2026 1:52pm UTC
policyengine-website Ready Ready Preview Oct 15, 2026 1:52pm UTC

Request Review

PavelMakarchuk and others added 2 commits October 15, 2026 08:58
The test asserted the draft's year, which draftYear already gave before
the fix. It now reads the year the draft runs in, and asserts what the
fix changed: the draft opens with source "bill" and no state region.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… no draft

The bill report stopped re-running UT SB60, but on the Reforms page
"Open as draft reform" still loaded its swapped 0.045 into Build, and a
run there scored the same +$85m increase. The bill panel now uses the
same check: "Run impact report" and "Open as draft reform" are disabled,
and a note says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@PavelMakarchuk
PavelMakarchuk changed the base branch from fix/v3-relevant-comparisons to main October 15, 2026 13:40
# Conflicts:
#	app/src/api/billFeed.ts
#	app/src/tests/unit/api/billFeed.test.ts
# Conflicts:
#	app/src/api/billFeed.ts
#	app/src/tests/unit/api/billFeed.test.ts
…mp/audit-int

# Conflicts:
#	app/src/components/flagship/report/StoredImpactCharts.tsx
#	app/src/pages/flagship/BillReport.page.tsx
#	app/src/tests/unit/components/flagship/report/StoredImpactCharts.test.tsx
…o tmp/audit-int

# Conflicts:
#	app/src/components/flagship/report/charts/DeepPovertyImpactByAgeSubPage.tsx
#	app/src/components/flagship/report/charts/DeepPovertyImpactByGenderSubPage.tsx
#	app/src/components/flagship/report/charts/InequalityImpactSubPage.tsx
#	app/src/components/flagship/report/charts/PovertyImpactByAgeSubPage.tsx
#	app/src/components/flagship/report/charts/PovertyImpactByGenderSubPage.tsx
#	app/src/components/flagship/report/charts/PovertyImpactByRaceSubPage.tsx
#	app/src/components/flagship/report/charts/ReportWaterfallChart.tsx
@PavelMakarchuk
PavelMakarchuk merged commit 0f4e956 into main Oct 15, 2026
12 checks passed
anth-volk added a commit that referenced this pull request Oct 15, 2026
…audit and report re-runs

Brought over from main: the v3 audit fixes (#1273, #1274, #1277, #1278,
#1280, #1281, #1283, #1284, #1285), run progress from the API, a report
that opens from its stored output and records a failed run, a wait for
the reform policy, the report year switcher and All years view (#1292),
dated edits in the adjust panel (#1294), each country's own draft, and
Your reforms' read-only reform detail with a parameters table, "View
results", "Re-run" and the delete prompt (#1288), with the other merged
fixes (#1289, #1290, #1293, #1295, #1296).

How the conflicts were resolved:

- Report page: a link is still a saved report's ID, read through the
  credential-free shared-report route, with the v1 report's number only
  without accounts. The v1 report it names (the shared row's
  legacy_report_id) gets main's run lifecycle: stored output, recorded
  failures, the household guard, the wait for the reform policy (for any
  link that resolved), the unknown-baseline state and the year switcher.
- Reforms page and ReformReports: main's read-only detail, parameters
  table, delete prompt, View results and Re-run, on this session's stores
  and session-scoped query keys. "View results", "Open" and "View full
  impact report" go to reportLink(report), never the v1 report's number.
  The detail keeps this branch's guards: a reform whose changes did not
  load cannot run and says so, a reform that changes nothing cannot run,
  and the run sends what Build would. A record without a year reads it
  from the API's record.
- Build's run bar: still "Save and run", which saves first and runs
  nothing when the save fails; it now saves only the values that differ
  from current law, leaves a reused reform (and its name) as it is, and
  names a budget window's years. A new reform has nothing to save when
  nothing differs.
- Adjust panel: main's dated edits, budget window and unknown-baseline
  guard on this session's reform store; recompute still waits until a
  value differs from current law.
- draftReform: loadProvisionsIntoDraft takes both the saved reform it
  edits and main's window and source.
- Tests: main's run-hook tests sit beside this branch's store tests; the
  run bar test that ran a report after a failed save is dropped, as a
  failed save runs nothing here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

2 active deployments
Preview – policyengine-calculator-next — b395029d Deployed Oct 15, 2026 by vercel[bot]
Preview – policyengine-website — b395029d Deployed Oct 15, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant