git:20260709.c20d181 to git:20260811.f3966a7

4 added, 1 removed. Audit A to A.

---
name: update-regional
description: Refresh the sub-national (oblast / NUTS 3) indicators data (data/regional.json) — re-fetch Eurostat NUTS 3 annual series (GDP per capita, population, net migration, recorded-theft rate, active-enterprise density) and merge АЗ long-term unemployment plus four NSI JSON-stat open-data datasets (FDI per capita, museum visits, hospital beds, crude death rate). Use when the daily watch report flags new Eurostat regional releases ("Eurostat regional (BG): new release") or NSI regional open-data releases ("НСИ regional open-data (BG): new release"), when the user asks to refresh regional indicators, or when adding a new NUTS 3 / NSI oblast indicator.
allowed-tools:
- Read
- Bash
- Edit
- Write
---
# Update Regional skill
Refreshes `data/regional.json` — the per-oblast indicator backdrop rendered on `/municipality/<oblast>` drilldowns and the `/demographics` regional choropleth. Pulls Eurostat NUTS 3 annual series: GDP per capita (`nama_10r_3gdp`), population (`nama_10r_3popgdp`), net migration rate (`demo_r_gind3`), recorded-theft rate (`crim_gen_reg`), plus a derived active-enterprise density (`bd_size_r3` ÷ population); merges one АЗ oblast series — long-term unemployment share (`ltUnemployment`); and merges four NSI JSON-stat open-data datasets — cumulative FDI per capita (id=629), museum visits per 1000 (id=844), hospital beds per 1000 (id=1206), crude death rate per 1000 (id=1139 = deaths by district ÷ population; a descriptive age-dominated demographic outcome, NOT a spend-adjustable measure). Together these close the Regional-Profiles gap categories Security, Business environment, Investment, Culture and Health at oblast grain.
## When to run
| Trigger | Action |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Daily watcher reports `Eurostat regional (BG): new release · nama_10r_3gdp <date>, ...` | Run Step 1 (both scripts) |
| Daily watcher reports `AZ (Агенция по заетостта): new year(s)` | Run Step 1 — refreshes the `ltUnemployment` oblast series |
| Daily watcher reports `НСИ regional open-data (BG): new release · …` | Run Step 1 — `fetch_nsi.ts` re-pulls the changed NSI dataset(s) |
| User asks to "refresh regional indicators" or "update regional data" | Same |
| Adding a new NUTS 3 indicator | Add an entry to `INDICATORS` in `scripts/regional/fetch_eurostat.ts` (Eurostat) or to `NSI_INDICATORS` in `scripts/regional/fetch_nsi.ts` (NSI open-data), extend the `RegionalIndicatorKey` union + `DELTA_KIND` + `formatRegionalValue` in `src/data/regional/useRegional.tsx`, then run |
Eurostat publishes these series on an annual cadence (typically February-March for the prior year); АЗ publishes its annual review in Q1. Day-to-day there is normally nothing to do.
## Step 1 — Fetch
```bash
npx tsx scripts/regional/fetch_eurostat.ts # Eurostat NUTS 3 series
npx tsx scripts/regional/fetch_az_oblast.ts # merge АЗ ltUnemployment
npx tsx scripts/regional/fetch_nsi.ts # merge NSI open-data (FDI, museums, hospital beds, death rate)
```
- Run **all three, in order** — `fetch_eurostat.ts` rewrites `data/regional.json` from scratch (the Eurostat NUTS3 series + the derived `theftRate` crime and `enterpriseDensity` business indicators), then the two mergers add their indicators into the file it wrote. `fetch_az_oblast.ts` reads the cached АЗ XLSX (downloaded by `update-indicators`) for `ltUnemployment`; `fetch_nsi.ts` pulls four NSI JSON-stat open-data datasets (`fdiPerCapita` id=629, `museumVisitsPer1000` id=844, `hospitalBedsPer1000` id=1206, `deathRatePer1000` id=1139), normalising each against the population series the first script wrote. Running only the first drops `ltUnemployment` + the NSI indicators until the mergers re-run.
+ Run **all three, in order** — `fetch_eurostat.ts` rewrites `data/regional.json` from scratch (the Eurostat NUTS3 series + the derived `theftRate` crime and `enterpriseDensity` business indicators), then the two mergers add their indicators into the file it wrote. `fetch_az_oblast.ts` reads the cached АЗ XLSX (downloaded by `update-indicators`) for `ltUnemployment`; `fetch_nsi.ts` pulls four NSI JSON-stat open-data datasets (`fdiPerCapita` id=629, `museumVisitsPer1000` id=844, `hospitalBedsPer1000` id=1206, `deathRatePer1000` id=1139), normalising each against the population series the first script wrote.
+ Running only the first no longer DESTROYS the other five indicators — `fetch_eurostat.ts` carries every foreign indicator key across its rewrite and names them on stdout (`carried forward from other writers: …`). It still leaves them at the previous vintage, so run all three when the mergers' own sources moved. Until 2026-08-11 the rewrite dropped all five from both the `indicators` and the `series` block with a valid file, a green run and no warning; `FOREIGN_INDICATORS` in `scripts/regional/fetch_eurostat.ts` is what prevents that now, and a new side-merged indicator MUST be added to it — `scripts/regional/regional_foreign_indicators.test.ts` fails when the committed artifact carries a key nothing declares.
+
Expected output on a normal day:
```
NUTS3 → oblast mapping: 28 NUTS3 codes
Loading gdpPerCapita (nama_10r_3gdp)... 31 oblasts, 620 points
Loading population (nama_10r_3popgdp)... 31 oblasts, 620 points
Loading netMigration (demo_r_gind3)... 31 oblasts, 620 points
Loading theftRate (crim_gen_reg)... 31 oblasts, 248 points
Deriving enterpriseDensity (bd_size_r3 V11910)... 31 oblasts
Wrote /Users/.../data/regional.json
+ carried forward from other writers: deathRatePer1000, fdiPerCapita, hospitalBedsPer1000, ltUnemployment, museumVisitsPer1000
```
31 oblasts = 27 administrative oblasts + 3 Sofia city МИР (S23/S24/S25, all sharing BG411) + the Plovdiv city МИР (PDV-00, sharing BG421 with the rural PDV). Most indicators auto-fail on a per-oblast point regression or a >10% drop vs. the prior committed file — investigate before re-running if you see `safety check: <key> ...`. **Exception:** `enterpriseDensity` is a known-frozen supplementary indicator (`bd_size_r3` ended 2020) and degrades gracefully — if you see `! enterpriseDensity skipped — …`, Eurostat narrowed or retired the dataset; the rest of the refresh still completes.
## Step 2 — Verify
```bash
node -e "
const d = require('./data/regional.json');
for (const [k, byOblast] of Object.entries(d.series)) {
const codes = Object.keys(byOblast);
const sample = byOblast[codes[0]];
const last = sample[sample.length - 1];
console.log(k.padEnd(16), 'oblasts=' + codes.length, 'latest', last.year);
}
console.log('size:', require('fs').statSync('./data/regional.json').size, 'bytes');
"
```
Eyeball:
- Each indicator should report 31 oblasts — except `ltUnemployment` (30; АЗ doesn't split one Sofia МИР) and any indicator whose source froze (`enterpriseDensity` latest 2020).
- `latest` should be the prior calendar year (or two prior in Q1 if Eurostat hasn't released the latest annual figures yet). `enterpriseDensity` will show 2020 (upstream frozen).
- File size ~115 KB raw / ~16 KB gzipped (10 indicators). `regional.json` is in the `bucket:gz` hot-files list, so prod serves it compressed — run `npm run bucket:sync:all` (sync **then** gzip) so it doesn't ship uncompressed. >180 KB raw would indicate a regression.
- Spot-check a known-extreme oblast: Sofia city (S23) should have GDP/capita ~30,000+ EUR; Vidin (VID) ~8,000 EUR. If those flip, something is wrong with the NUTS 3 ↔ oblast mapping.
## Step 3 — Upload to bucket
```bash
npm run bucket:sync
```
(Or `npm run bucket:sync:dry-run` first to preview.) This rsyncs `data/regional.json` (and any other changed data files) to `gs://data-electionsbg-com/`.
## Step 4 — Commit
```bash
git add data/regional.json
git commit -m "regional: refresh through <latest year>"
```
If you added a new indicator, also commit the script + hook changes:
```bash
git add scripts/regional/fetch_eurostat.ts src/data/regional/useRegional.tsx data/regional.json
git commit -m "regional: add <indicatorKey> from Eurostat <dataset_code>"
```
## Adding a new indicator (advanced)
Add an entry to `INDICATORS` in `scripts/regional/fetch_eurostat.ts`:
```ts
{
key: "newKey", // also add to RegionalIndicatorKey in src/data/regional/useRegional.tsx
dataset: "dataset_code", // Eurostat code, e.g. "nama_10r_3gva"
query: { unit: "EUR_HAB", freq: "A" }, // narrowing dim filters
titleEn: "...", titleBg: "...",
unitLabelEn: "...", unitLabelBg: "...",
sourceUrl: "https://ec.europa.eu/eurostat/databrowser/view/<dataset>/default/table",
},
```
Then:
1. Add the key to `RegionalIndicatorKey` in `src/data/regional/useRegional.tsx`.
2. Add an entry to the `DELTA_KIND` map in the same file — `"percent"` for absolute counts (GDP, population), `"absolute"` for already-normalised rates (migration, unemployment rate).
3. Add the new code to the `DATASETS` array in `scripts/watch/sources/eurostat_regional.ts` so the watcher fingerprints it.
4. If the formatter needs a custom rule (anything that isn't "round to whole number with separators" or "signed rate with one decimal"), extend `formatRegionalValue` in `useRegional.tsx`.
5. Run this skill to regenerate `data/regional.json`.
Before adding a new dataset, verify the query against the Eurostat REST API:
```bash
curl -s 'https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/<dataset>?format=JSON&lang=EN&<query>&lastTimePeriod=4' | head -c 1500
```
Confirm the response covers all 28 BG NUTS 3 oblasts. If it only returns aggregates (BG, BG3, BG31, ...) or returns empty `value: {}`, Eurostat does not publish that indicator at NUTS 3 granularity for Bulgaria — try a NUTS 2 dataset (one of `_2*`) or skip the indicator.
## Data-integrity contract
The fetcher fails loud rather than write a partial `data/regional.json`:
| Surface | Trigger |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Eurostat HTTP non-2xx for any indicator | `Eurostat <key> returned <status> for <url>` |
| Unexpected dimension order in the response | `Eurostat <key>: unexpected dimension order ... — expected geo,time as last two` |
| Per-oblast point floor breach | `safety check: <key> weakest oblast <code> has N points (floor 10)` |
| 10% regression vs. prior `data/regional.json` total points | `safety check: <key> total points dropped P → C (X% > 10%). Refusing to overwrite.` |
| `process.exit(1)` from the top-level `.catch` | Any unhandled rejection |
Two important quirks of this data set:
1. **The app's `data/municipalities.json` uses non-standard `nuts3` codes** (BG416/417/418 for Sofia МИР; BG421-1 for Plovdiv-minus-city). The fetcher does NOT derive the NUTS 3 → oblast mapping from that file. Instead, an explicit `EUROSTAT_NUTS3_TO_OBLAST` table at the top of `scripts/regional/fetch_eurostat.ts` maps Eurostat's real NUTS 3 codes to app oblast codes (with Sofia stolitsa BG411 fanning out to S23/S24/S25).
2. **Sofia stolitsa is one Eurostat entity, three app entities.** The same series value is duplicated to S23, S24, and S25. The drilldown tile and choropleth render a footnote noting this.
## What this skill does NOT do
- **Does not refresh macro indicators.** That's `update-macro` — separate watcher source, separate skill.
- **Does not scrape NSI or other Bulgarian government portals.** NUTS 3 (oblast-level) data comes from Eurostat. Sub-municipal (LAU 2 / per-обштина) data — registered unemployment per ОНС, DZI scores, EU funds absorption — would need a separate ingest path and is currently out of scope.
- **Does not change the SPA UI.** Adding a new indicator key requires hand-editing the type union and (if needed) the formatter in `useRegional.tsx`. The skill only refreshes data.
## File map
| Path | Purpose |
| --------------------------------------------------------------- | --------------------------------------------------------------- |
| `scripts/regional/fetch_eurostat.ts` | CLI entry — fetch all NUTS 3 series, write `data/regional.json` |
| `scripts/watch/sources/eurostat_regional.ts` | Daily watcher — fingerprints the 3 NUTS 3 datasets |
| `data/regional.json` | Generated payload (~50 KB, minified) — committed |
| `src/data/regional/useRegional.tsx` | React Query hook + types + `formatRegionalValue` helper |
| `src/screens/dashboard/RegionalIndicatorsTile.tsx` | Drilldown tile (geography section of `/municipality/<code>`) |
| `src/screens/components/regional/RegionalChoroplethMap.tsx` | Choropleth on `/demographics` |
| `src/screens/components/regional/RegionalIndicatorSelector.tsx` | Indicator dropdown for the choropleth |