Skip to content

UK local-area breakdowns on request, in v2 and v3 - #1398

Open
anth-volk wants to merge 16 commits into
mainfrom
feat/uk-local-area-breakdown
Open

anth-volk wants to merge 16 commits into
mainfrom
feat/uk-local-area-breakdown

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Relates to #1361

Summary

From .py 6.10.0 on, a national UK result (region=uk or country/*) no longer contains constituency_impact or local_authority_impact: the keys are absent, or null. Those impacts come from a separate run instead, which the app requests with the same economy endpoint plus breakdown=local_areas:

GET {API}/{country}/economy/{reform}/over/{baseline}?region=<uk | country/*>&time_period=<year>&breakdown=local_areas

The response uses the usual status envelope. When it is ok, result is { kind: "local_areas", model_version, policyengine_version, dataset, data_version, constituency_impact: [...], local_authority_impact: [...] }. Its rows have the shape the normalizer already handles, plus an optional disclosure_tier and optional 90% bounds. Areas withheld by disclosure control are left out, and a list may be empty.

What this PR changes, in v2 and v3:

  • Every local-area view of a national result from .py 6.10.0 or later, which has no area data, shows a "Compute local-area impacts" button. A click sends one breakdown request for the report's region and year. While it runs, a progress message and bar show; on success, the maps; on error, a message and Retry. The pattern follows the v2 congressional district section (efc3914).
    • v3: the "Local areas" tab (UKLocalAreaSection, rendered by ReportView). UKLocalAreaMaps still only draws what it's given.
    • v2: the "Constituency impact" and "Local authority impact" sections of MigrationSubPage (button in the section header's right-hand slot), and the constituency and local-authority tabs (ConstituencySubPage, LocalAuthoritySubPage, wired from SocietyWideReportOutput).
  • All views share one run through useUKLocalAreaBreakdown's react-query key (country, both policies, region, year), so one click fills them all. Results stay for the browser session. The hook calls the endpoint directly, not through CalcOrchestrator, so the report's saved national output isn't overwritten.
  • The button is offered only when the result came from .py 6.10.0 or later, read from the result's policyengine_version. The app fills that field from the response's policyengine_bundle, and saved results keep it. A result from an older release, or with no version, shows its local areas as it did before this PR ("No … data available" or "… not available for this region" for a missing level), with no button and no request.
  • Saved reports in v3 show the result they saved. One from before .py 6.10.0 that contains area data shows its maps directly, with no button.
  • Saved reports in v2 behave differently, and this PR doesn't change that. When a v2 report opens, it sends the national economy request again instead of using the stored output. Once the Public API moves to .py 6.10.0, opening an older v2 report therefore returns a national result without area data. That result shows the Compute button, and replaces the output stored on the v1 report record.
  • Under each map, a note says how many areas weren't published and why, and how many shown areas are approximate. The note gives an exact count where the app can count the withheld areas (see "Withheld-area counts"). Tooltips and the v3 area details mark approximate areas and give their 90% ranges.

Merge order

Merge after PolicyEngine/policyengine-sim-api#881 is deployed, and before the Public API PR that adds breakdown and moves to .py 6.10.0.

On its own, this change does nothing visible: the Public API still runs .py 6.7.5, whose UK results contain area data, so no Compute button appears. The buttons appear for new UK reports once the Public API moves to 6.10.0.

Commits

Each commit was written test-first: the tests were added, run and seen failing for the expected reason, then the code was written, and both went into the same commit.

  1. Request a UK local-area breakdown, and accept national results without area data (9a58e92a)
    • What changed: breakdown query parameter, fetchLocalAreaBreakdown, LocalAreaBreakdown type, area fields made optional and nullable, hasUKLocalAreaImpacts, disclosure fields copied by the normalizer.
    • Red: 9 new societyWideCalculation tests failed. breakdown was dropped from the URL, and fetchLocalAreaBreakdown and hasUKLocalAreaImpacts didn't exist.
  2. Add the useUKLocalAreaBreakdown hook (f7a7aaef)
    • Red: the test file couldn't import the hook. Against an idle stub, 14 of 16 tests failed on their assertions.
  3. Add the shared UKLocalAreaComputePanel (8a132622)
    • Red: against an empty stub, 6 of 11 tests failed. The 5 that passed check for an absent button or empty output.
  4. v3: compute a UK report's local areas from the Local areas tab (952d5f52)
    • Red: 3 new ReportView tests failed (the card text, and no compute button in the tab). Against a stub that rendered the maps alone, as ReportView did before, 5 of 6 UKLocalAreaSection tests failed.
    • The saved-results tests passed before and after, as intended.
  5. v2: compute a UK report's local areas from its sections and tabs (f38064f4)
    • Red: 12 new tests failed (6 MigrationSubPage, 3 per tab), and 2 SocietyWideReportOutput tests failed against the previous file.
    • The new chart tests for null and missing area data passed before the change: the charts already showed "No … data available".
  6. Note withheld and approximate local areas, with 90% ranges, in v2 and v3 (7934ed97)
    • Red: 22 new tests failed, and the UKAreaDisclosureNotes test file couldn't import the component.
    • One more test, for a level returned as null, failed with a TypeError until withheldUKAreasOf was guarded.

Fixes from the first code review:

  1. Send a breakdown request only for the year Compute was clicked for (199b54db)
    • Bug: v3 keeps the Local areas tab mounted while a budget-window report switches year. After one Compute click, opening another year sent that year's breakdown request without a click. The hook now remembers the hashed query key that start() or retry() was called for.
    • Red: 3 new tests failed. After a year switch the hook reported "complete" instead of "idle", the request mock was called twice, and ReportView's Compute button didn't return.
  2. Keep breakdown requests and national results apart, and refuse non-breakdown results (b549d95d)
    • SocietyWideCalculationParams no longer has breakdown; only fetchLocalAreaBreakdown sends it. fetchSocietyWideCalculation forwards only the region and year.
    • An ok result counts as a breakdown only when result.kind === "local_areas". Otherwise it's an error that says the API returned national results, for example from a Public API deployment that ignores the parameter. The panel shows the error's reason above Retry.
    • Red: 3 new tests failed. The national request sent breakdown when given one, the hook reported a national result as "complete", and the panel didn't show the reason.
  3. Decide each local-area view on its own level's data (c92f31c0)
    • Every v2 and v3 view now decides on its own level. A saved result with constituencies but no local-authority key shows the constituency maps everywhere, and offers Compute in every local-authority view.
    • A saved level is never replaced by the breakdown's, and gets no withheld note. UKLocalAreaMaps takes an optional missingLevelContent, shown when the selected level is absent or null. A level computed with no areas left still says there is no data.
    • Red: 7 new tests failed (the per-level merge, the withheld filter, both v2 section cases, the v3 case and the maps' slot). The two new v2 tab tests passed before, because the tabs already decided per level.
  4. Share one hook for what each local-area view shows (254a933e)
    • Refactor with no behaviour change: useUKLocalAreaOutput returns { breakdown, areaOutput, withheld } for the v3 tab, the v2 sections and the v2 tabs, and replaces useUKWithheldAreas.
    • The four v2 charts look up their areas once and build the notes once.
    • The existing tests pass unchanged; a small test covers the new hook.

Fixes from the second code review:

  1. Offer a local-area breakdown only for results from .py 6.10.0 on (bba136fc)
    • Bug: v3 offered Compute for stored outputs from older releases, which have constituencies but no local authorities (before December 2025) or neither (before February 2025). Until the Public API serves breakdowns, that request gets a 400.
    • servesLocalAreaBreakdowns compares the result's policyengine_version with 6.10.0. Older and unversioned results show what they showed before this PR, in v3 and in the v2 sections and tabs, with no button and no request.
    • Red: 24 tests failed. 15 were the version helpers, which didn't exist yet; 9 were the views, the overview card and the hook, which still offered Compute for older and unversioned results.
  2. Name the breakdown request in its errors, with the API's message (b466e826)
    • Breakdown errors now read "Local-area breakdown failed (): …", logged as [fetchLocalAreaBreakdown]. They give the error body's message field when there is one.
    • Red: 2 tests failed; the error still read "Society-wide calculation failed".
  3. Type a breakdown request's ok result as a breakdown or national results (6abd5ec9)
    • LocalAreaBreakdownResponse's result is LocalAreaBreakdown | SocietyWideReportOutput. isLocalAreaBreakdown makes the national-result check a compiler-checked case. Typing only.
  4. Tests: Given/When/Then sections, fixture values, Retry in v2, polling stops (d995306e)
    • Given/When/Then sections in the new MigrationSubPage and ReportView tests, and literal values moved into fixtures.
    • New tests: Retry from a v2 section and a v2 tab; the hook stops polling after an error and on unmount.
    • These cover existing behaviour, so they passed on the first run. Making the hook poll at every interval made the after-error test fail.
  5. Tidy: remove isUKSocietyWideOutput, one request type, shared area names (a4ec6750)
    • The unused isUKSocietyWideOutput is removed.
    • UKReportLocalAreaRequest is derived from UKLocalAreaBreakdownRequest.
    • UK_AREA_NOUNS defines the area names once. No behaviour change.

Withheld local-authority counts:

  1. Count withheld local authorities in UK-wide reports, as for constituencies (37d24dad)
    • The local-authority note in a UK-wide report now gives a count ("2 of 361 local authorities are not shown…"): the local authorities in the metadata's region list that the breakdown's local_authority_impact list leaves out.
    • Both levels follow one rule in countWithheldUKAreas: a count only for a UK-wide report, and only when every area the breakdown returned is in the level's list. Otherwise the note has no count, as before.
    • Red: 9 tests failed, each because local authorities got null instead of a count (the adapter, useUKLocalAreaOutput, the v3 Local areas tab, the v2 local authority tab and the v2 sections).
    • The new tests for the uncounted cases (an unlisted local authority, a list without Ards and North Down, nation reports) and for the notes' wording passed before the change, as local authorities were never counted and the notes already handled either level.

Withheld-area counts

Withheld areas are counted by comparing the breakdown's list with the region list in the country metadata (useRegions('uk')). Both levels follow one rule (countWithheldUKAreas): a level gets a count only for a UK-wide report, and only when every area the breakdown returned is in that level's list.

  • Constituencies in UK-wide reports: counted ("12 of 650 constituencies are not shown…"). The Public API's metadata and .py both list the same 650 names.
  • Local authorities in UK-wide reports: counted ("2 of 361 local authorities are not shown…"), since commit 16. The Public API listed 360 local authorities and .py 6.10.0 lists 361; Request UK local-area breakdowns, fix the Northern Ireland region name, and update .py to 6.10.0 policyengine-api#4024 adds the missing one (Ards and North Down), so both list the same 361 names. Breakdowns are offered only for results from .py 6.10.0 on, which only a Public API deployment with #4024 serves.
  • If the lists disagree (a breakdown returns an area the metadata doesn't list, for example Ards and North Down against an earlier Public API deployment, or a renamed area), that level's note is shown without a count. The other level keeps its count.
  • Nation reports (country/*): the note is shown without a count, for both levels. The metadata gives only name, label and type for each area, not its code or nation, so the app can't pick out a nation's areas from the list. The breakdown's rows carry ONS codes, but those cover only the published areas, not the full list.
  • One case the rule can't detect: an earlier Public API list without Ards and North Down, with that area withheld. The count would then be one too low. It can't arise in practice, because such a deployment serves no breakdowns.

Differences from the plan

  • Every local-area view decides on its own level (hasConstituencyImpacts, hasLocalAuthorityImpacts), since commit 9. Since commit 11, a missing level is offered the breakdown only for results from .py 6.10.0 on. Those results have no saved levels, so a saved level never sits beside a breakdown level from another model or data version.
  • Only the overview card's text uses hasUKLocalAreaImpacts (either level): it says the impacts are calculated on request when neither level is saved and the result is from .py 6.10.0 on.
  • SocietyWideCalculationParams has no breakdown field at all, rather than fetchSocietyWideCalculation taking Omit<SocietyWideCalculationParams, 'breakdown'>. The effect is the same, and the existing callers are unchanged.
  • For a report with one simulation, the reform falls back to the baseline, as the national run does (SocietyWideCalcStrategy). The plan named simulations[1] only.
  • The breakdown's duration estimate (UK_LOCAL_AREA_BREAKDOWN_DURATION_MS, 3 minutes) is unmeasured, because no production breakdown runs exist yet.
  • The v2 hex maps have no data-area-id (they don't pass onSelect), so the v2 tests check the hovered hexagon's tooltip instead.
  • Withheld counts: until commit 16, only constituencies in UK-wide reports were counted, because the Public API's local-authority list lacked Ards and North Down. Commit 16 counts local authorities too, by the same rule. Nation reports still have no count for either level, because the region metadata has no area code or nation (see "Withheld-area counts").

Verification

All on Node 26.10.0, the version CI uses:

  • make format, make lint and make typecheck: clean.
  • make test on the final commit (37d24dad):
    • app: 449 files, 5,678 passed, 10 skipped;
    • website: 41 files, 357 passed.
  • make format, make lint, make typecheck and make test also passed on each review-fix commit, and on a4ec6750 (app: 5,667 passed, 10 skipped).
  • One full run during commit 4 had a single failure in NavItem.test.tsx (the header dropdown, which this PR doesn't touch). It passed alone and on the immediate full rerun.

Not done here: a local build against staging. That needs the Public API's staging deployment with breakdown, which isn't available yet.

Environment variables

None added, removed or changed. Every change is under app/src; no CI/CD workflow, Docker image, Terraform or deployment configuration is touched. The breakdown request goes to the existing BASE_URL (from app/src/constants.ts), which reads the existing VITE_API_URL and defaults to https://api.policyengine.org. This PR doesn't change it.

🤖 Generated with Claude Code

anth-volk and others added 6 commits October 22, 2026 22:23
…t area data

National UK results from .py 6.10.0 on leave out constituency_impact and
local_authority_impact (absent, or null). Their local areas come from a
separate run of the same economy endpoint with breakdown=local_areas.

- SocietyWideCalculationParams takes an optional breakdown, sent after the
  region and year only when it is set.
- fetchLocalAreaBreakdown asks for that run and types its result as
  LocalAreaBreakdown, keyed by area name like the national lists.
- The UK output type's area fields are optional and nullable, and
  hasUKLocalAreaImpacts says whether a result holds either map.
- Area records carry the optional disclosure_tier and the 90% interval
  fields, which the normalizer copies from the list rows.

Tests first: the new societyWideCalculation tests failed (9) because the
breakdown parameter was dropped from the URL and fetchLocalAreaBreakdown
and hasUKLocalAreaImpacts did not exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A UK report's constituency and local authority impacts, calculated on
request by a separate run of the economy endpoint (breakdown=local_areas)
for the report's region and year.

- Nothing is sent until start(). The run is then polled every 2 s while
  it computes; retry() sends the request again after an error.
- While it computes, the hook gives a progress estimate (the run's clock
  against an estimated duration, held inside the stage the API reports,
  and never moving backwards), the stage and the queue position.
- The react-query key holds the country, both policies, the region and
  the year, so every view of a report shares one run. A view opened while
  another view's run computes, or after it completes, follows that run
  without sending its own request. Results stay for the session
  (staleTime and gcTime Infinity).
- It calls fetchLocalAreaBreakdown directly, not CalcOrchestrator, so the
  report's saved national output is not overwritten.

UK_LOCAL_AREA_BREAKDOWN_DURATION_MS is an unmeasured 3-minute estimate:
no production breakdown runs exist yet.

Tests first: before the hook existed the test file failed to import it;
against an idle stub, 14 of the 16 tests failed on their assertions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The panel a UK report shows in place of its local-area maps until the
breakdown is in, modelled on the v2 congressional district section
(efc3914):

- idle: a sentence saying the impacts come from a separate run that takes
  a few minutes, and the "Compute local-area impacts" button;
- computing: "Computing local-area impacts…" with a progress bar, and the
  queue position or the API's stage when given;
- error: a message and Retry;
- complete: nothing, so the maps take its place.

UKLocalAreaComputeButton is the same button on its own, for a v2 section
header's right-hand slot; the panel can then leave it out.

Tests first: before the component existed the test file failed to import
it; against an empty stub, 6 of the 11 tests failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UKLocalAreaSection is the Local areas tab's content. A result that holds
its area impacts (an older saved report) shows the maps at once, as
before. A national result without them (.py 6.10.0 on) shows the compute
panel in their place: its button sends one breakdown request for the
report's region and year, progress shows while it runs, Retry follows an
error, and the maps take the panel's place when the run completes. The
hex layout comes with the results, so nothing is drawn before then.

UKLocalAreaMaps is unchanged and still only draws what it is given;
localAreaOutput puts a completed breakdown's areas into the result it
draws. The overview's local areas card says the impacts are calculated on
request when the results lack them.

Tests first: the new ReportView tests failed (3: the card copy, and no
compute button in the tab), and against a stub that rendered the maps
alone, as ReportView did, 5 of the 6 UKLocalAreaSection tests failed.
The saved-results tests passed before and after, as intended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The "Constituency impact" and "Local authority impact" sections of
MigrationSubPage, and the constituency and local-authority tabs, now
handle a national UK result without area impacts (.py 6.10.0 on).

- Sections: when the result has no area impacts, each opened section's
  header holds the "Compute local-area impacts" button (the
  CollapsibleSection right-hand slot), and its body holds the compute
  panel's description, progress or error. A click in either section sends
  the report's one breakdown request: the reform (simulations[1]) over
  the baseline (simulations[0]), for simulations[0].populationId and
  report.year. Both sections show the run's progress, and both show the
  maps when it completes, with no second request.
- Tabs: ConstituencySubPage and LocalAuthoritySubPage take the report's
  policies, region and year from SocietyWideReportOutput. Without their
  level's impacts (missing or null), they show the compute panel instead
  of "not available for this region", and pass the computed areas to
  their charts. Without the request fields (outside the UK, or on one
  constituency or local authority) they still say it is not available.
- Every view shares one run through useUKLocalAreaBreakdown's query key,
  so one click fills them all. The sections' hook lives in a UK-only
  child component, so US reports render as before.
- Saved results that hold area impacts show them directly, with no
  button.

hasConstituencyImpacts and hasLocalAuthorityImpacts check one level each,
for the tabs; hasUKLocalAreaImpacts checks either, for the sections and
v3. When a report has one simulation, the request falls back to the
baseline as the reform, as the national run does.

Tests first: 12 new tests failed (6 MigrationSubPage, 3 per subpage tab)
because no compute button existed, and the 2 new SocietyWideReportOutput
tests failed against the previous file because the tabs got only the
output. The new chart tests for null and missing area data passed before
this change: the charts already showed "No ... data available".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A local-area breakdown is disclosure-controlled: .py leaves out the areas
whose estimates rest on too few surveyed households or could reveal one
household's income, and marks those with a low effective sample as
approximate, whose 90% intervals understate the uncertainty.

- Under each map (v3 UKLocalAreaMaps; the four v2 constituency and local
  authority charts), UKAreaDisclosureNotes says how many of the level's
  areas were not published and why, and how many shown areas are
  approximate. Saved results, which are not disclosure-controlled, get
  no notes.
- Withheld areas are counted against the UK region list in the country
  metadata (useRegions), and only where the two lists agree:
  constituencies in UK-wide reports, whose 650 names API v1 and .py
  share. Local authorities get the note without a count, because API v1
  lists 360 and .py 361 ("Ards and North Down" is only in .py's list).
  Nation reports get it without a count too, because the metadata does
  not say which nation an area is in. A published name missing from the
  list also drops the count.
- Map tooltips (v2 and v3, through generateHoverText) mark approximate
  areas and give each value's 90% range; the hex points carry them from
  the constituency and local authority adapters, and from UKLocalAreaMaps.
- The v3 area details (ukAreaDetailFigures) add both changes' 90% ranges
  and an "Estimate: Approximate" row.

Tests first: 22 new tests failed (hover text, adapters, withheld counts,
v3 maps and details, v3 section, v2 tabs, charts and sections) because
none of this existed, and the UKAreaDisclosureNotes test file failed to
import the component. A further test, for a level the API returns as
null, failed with a TypeError before withheldUKAreasOf guarded it.

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

vercel Bot commented Oct 22, 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 22, 2026 9:46pm UTC
policyengine-website Ready Ready Preview Oct 22, 2026 9:46pm UTC

Request Review

anth-volk and others added 4 commits October 22, 2026 23:25
useUKLocalAreaBreakdown kept start() as one boolean, which stayed true
when the query key changed. v3 keeps the Local areas tab mounted while a
budget-window report switches year, so after one Compute click, opening
another year sent that year's breakdown request at once, without a click.

The hook now stores the hashed query key that start() or retry() was
called for, and sends requests only for that key. A year already
computed still shows its cached result without a click.

Tests first: 3 new tests failed. In the hook, after start() and a switch
to another year, the status was "complete" instead of "idle", and the
request mock was called twice instead of once. In ReportView, the
Compute button did not return after the year changed.

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

- SocietyWideCalculationParams no longer has a breakdown field. Only
  fetchLocalAreaBreakdown sends breakdown=local_areas, so a breakdown's
  result can't be typed as national results. fetchSocietyWideCalculation
  also forwards only the region and year, so a breakdown passed in anyway
  is not sent.
- useUKLocalAreaBreakdown accepts an ok result as a breakdown only when
  result.kind is "local_areas". Any other ok result, such as the national
  results a Public API deployment that ignores the parameter returns,
  becomes an error that says so.
- The compute panel's error state now shows the error's reason under its
  message, above Retry.

Tests first: 3 new tests failed. The national request sent breakdown when
given one, the hook reported a national result as "complete" instead of
"error", and the panel did not show the error's reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The v2 sections and the v3 tab decided on "either level present", while
the v2 tabs decided on their own level. So a saved result with
constituencies but no local authority key offered Compute in the v2 local
authority tab, but the v2 section and the v3 map just said there was no
local authority data.

Every view now decides on its own level:

- localAreaOutput keeps each level the result holds and fills only the
  missing levels from a completed breakdown, so a saved level is never
  replaced.
- breakdownLevelsWithheld gives withheld counts only for levels that came
  from the breakdown; a saved level gets no withheld note.
- MigrationSubPage shows the button and the compute panel in a section
  only when that section's level is missing.
- UKLocalAreaMaps takes optional missingLevelContent, shown in place of a
  level's map when the result does not hold that level at all (absent or
  null). A level computed with no areas left still says there is no
  data. UKLocalAreaSection passes the compute panel there.
- withLocalAreaBreakdown, now unused, is removed.

Tests first: 7 new tests failed (the per-level merge, the withheld
filter, both v2 section cases, the v3 section case and the maps' slot).
The two new v2 tab tests, which check that each tab agrees with its
section, passed before: the tabs already decided per level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The v3 Local areas tab (UKLocalAreaSection), the v2 sections
(UKLocalAreaSections in MigrationSubPage) and the v2 tabs
(UKLocalAreaBreakdownView) each called the breakdown hook, merged its
areas into the result and counted the withheld areas. useUKLocalAreaOutput
now does all three and returns { breakdown, areaOutput, withheld }. It
reads the region metadata itself, so useUKWithheldAreas is removed.

The four v2 constituency and local authority charts now look up their
level's areas once, and build the disclosure notes element once for both
the empty and the map branches.

No behaviour change: the existing tests pass unchanged. A new
useUKLocalAreaOutput test covers a result without areas and a saved
level kept beside a computed one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 5 commits October 23, 2026 00:02
Stored v3 outputs from before December 2025 hold constituencies but no
local authorities, and outputs from before February 2025 hold neither.
The local-area views offered "Compute local-area impacts" for their
missing levels, and until the Public API serves breakdowns that request
gets a 400.

A missing level is now offered a breakdown only when the national result
came from a .py release that serves breakdowns (6.10.0 or later):

- servesLocalAreaBreakdowns compares a version with 6.10.0;
  outputServesLocalAreaBreakdowns reads the result's policyengine_version,
  which fetchSocietyWideCalculation fills from the response's
  policyengine_bundle and saved results keep. A result with no version
  counts as an older release.
- useUKLocalAreaOutput returns canCompute, and sends no request (even
  with a cached run) when it is false.
- For an older or unversioned result, the v3 tab, the v2 sections and the
  v2 tabs show what they showed before this PR ("No ... data available",
  or "... not available for this region"), with no button, and the
  overview card keeps its previous text.

6.10.0 national results hold no area levels, so a saved level can no
longer sit beside a breakdown level from another model and data
version.

Tests first: 24 tests failed. 15 were the new version helpers, which
did not exist yet. The other 9 were the v3 tab, the v2 sections and
tabs, ReportView and the hook, which still offered compute (or did not
show the old text) for older and unversioned results. The round-1 tests
for a result with one saved level now use a result from a breakdown
release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed breakdown request reused the national request's error and log
label: "Society-wide calculation failed (400): <raw body>", logged as
[fetchSocietyWideCalculation].

fetchLocalAreaBreakdown's errors now read "Local-area breakdown failed
(<status>): ..." and are logged as [fetchLocalAreaBreakdown]. When the
error body is JSON with a "message" field, the error gives that message;
otherwise it gives the body, or the status text when the body is empty.
The national request's errors are unchanged.

Tests first: the 2 new tests failed because the error still read
"Society-wide calculation failed (...)".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LocalAreaBreakdownResponse's result is now LocalAreaBreakdown |
SocietyWideReportOutput, because a Public API deployment that ignores
breakdown=local_areas answers with national results. isLocalAreaBreakdown
tells them apart, so the hook's check for national results is a case the
compiler checks rather than a comparison on a field the type said was
always "local_areas".

Typing only, no behaviour change. The hook tests' mocks of the API module
now keep its real exports, so they get isLocalAreaBreakdown.

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

- The new UK tests in the MigrationSubPage and ReportView suites now have
  Given, When and Then sections, as testing.md asks.
- Literal values moved into fixtures: the report title and source note,
  the other budget-window year, the breakdown's local authority name,
  and the withheld counts the notes are tested with.
- v2: a section and a tab each click Retry after a failed breakdown,
  and the maps or charts then show, with a second request.
- The hook stops polling after an error, and when its view unmounts.

Test-only commit. The new tests passed on the first run, because they
cover existing behaviour. As a check, changing the hook to poll at
every interval made the after-error test fail ("expected 'computing' to
be 'error'"). The unmount test still passed with that change, since
react-query clears a removed observer's polling; it guards against
polling outside react-query later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Remove isUKSocietyWideOutput from the v2 report page. Nothing called
  it, and it identified UK results by a non-null constituency_impact,
  which national UK results from .py 6.10.0 on do not have.
- Drop ReportLocalAreaRequest. UKLocalAreaBreakdownRequest's fields are
  now string or undefined, and UKReportLocalAreaRequest is that type
  without countryId, used by the adapter, useUKLocalAreaOutput and the
  v2 tab view.
- UK_AREA_NOUNS holds each level's singular and plural names once, for
  UKLocalAreaMaps and UKAreaDisclosureNotes.

No behaviour change; the tests pass unchanged.

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

The withheld note under a local-authority map now gives an exact count
in UK-wide reports ("2 of 361 local authorities are not shown."), as the
constituency note does. The count is the number of local authorities in
the country metadata's region list that the breakdown's
local_authority_impact list leaves out.

Local authorities were not counted before because the Public API listed
360 and .py 6.10.0 lists 361 (Ards and North Down was missing).
PolicyEngine/policyengine-api#4024 adds it, so the two lists name the
same 361 areas. Breakdowns are offered only for results from .py 6.10.0
on, which that deployment serves.

Both levels now follow one rule: a level gets a count only for a UK-wide
report, and only when every area the breakdown returned is in the
level's list. A breakdown that returns an unlisted area, for example
Ards and North Down against an earlier Public API deployment, gets the
note without a count. Nation reports still get no count: the metadata
gives each area's name, label and type, not its code or nation.

Tests: 9 failed before the change, each because local authorities got
null instead of a count (adapter, output hook, v3 Local areas tab, v2
local authority tab, and v2 sections).

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

This branch was successfully deployed

2 active deployments
Preview – policyengine-calculator-next — 37d24dad Deployed Oct 22, 2026 by vercel[bot]
Preview – policyengine-website — 37d24dad Deployed Oct 22, 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