fix(disease-outbreaks): TGH master URL + new bundle parser + skip time filter#3593
Conversation
…kip time filter
PRODUCTION 2026-05-04: enabling the disease-outbreaks map layer renders
nothing despite /api/health reporting `diseaseOutbreaks: status=OK,
records=50`. Direct Redis read confirmed the cache holds 50 items but
none render. Three compound issues, fixed in one PR.
== A. ThinkGlobalHealth source returning 0 items ==
Two sub-bugs in `fetchThinkGlobalHealth`:
1. Wrong default branch in URL.
The seeder hardcoded `/main/index_bundle.js` but the TGH GitHub repo's
default branch is `master`. The `/main/` URL has been returning HTTP
404 silently — the seeder caught the !resp.ok and returned []. Fix:
`main` → `master`. Verified live: 200 OK, 7.5 MB bundle.
2. Bundle format change after 2026-04 webpack rebuild.
The legacy parser anchored on `var a=[{Alert_ID:` (unquoted JS keys).
The new bundle wraps records in `eval("var res = [...]")` blocks with
JSON-quoted keys like `\"Alert_ID\":\"8732529\"`. The old regex
(`/(\w+):"((?:[^"\\]|\\.)*)"/`) doesn't match the quoted-key form.
Fix: import `parseRealtimeAlerts` from seed-vpd-tracker.mjs (which
already handles this exact format with a battle-tested
schema-anchored scanner — VPD and Disease share the same TGH bundle).
After both fixes, TGH contributes ~1,600 ProMED-reviewed alerts with
real lat/lng, restoring the only geo-rich source (WHO/CDC/ONT only
publish country names → require centroid fallback).
== B. UI time filter eats every item ==
The map's `filterByTimeCached` (DeckGLMap.ts:1505) gated diseaseOutbreaks
by the global timeRange dropdown (max '7d'). Disease outbreaks are
sparse-by-nature — WHO DON publishes 1-2/week, CDC HAN alerts are
infrequent, TGH carries 90 days of ProMED items. When the most recent
WHO/CDC update is 8+ days old (normal), the 7d gate dropped every item
→ empty layer. Production confirmed: 50 cached items, newest 11.0 days
old, all dropped.
Fix: skip the time filter for diseaseOutbreaks. Render all items in the
cache; the seeder's per-source lookback already bounds freshness at
write time. Other layers keep the global filter.
== Out of scope ==
- C: structural health-readiness probe (seedAgeMin tracks seeder run,
not item freshness — separate followup).
- Static-layer zoom-gates (bases/nuclear/spaceports/economic show
nothing at default zoom 2 because LAYER_ZOOM_THRESHOLDS[*].minZoom
is 3-5). Intentional UX, not a data bug — separate followup if we
want a "zoom in to see N items" affordance.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR fixes three compounding issues that caused the disease-outbreaks map layer to render nothing in production: the TGH GitHub URL is corrected from Confidence Score: 4/5Safe to merge — all three fixes are well-scoped and the only findings are P2 style/UX concerns. No P0 or P1 issues found. The parser import is clean, field mappings are verified against the upstream function, and the time-filter bypass is intentional and documented. Two P2 items remain: a JSDoc field-name mismatch and the silent divergence from the global time-filter contract in the map UI. No files require special attention beyond the noted P2 items. Important Files Changed
Sequence DiagramsequenceDiagram
participant Cron as Railway Cron
participant DS as seed-disease-outbreaks.mjs
participant VPD as seed-vpd-tracker.mjs (parseRealtimeAlerts)
participant TGH as raw.githubusercontent.com/master/index_bundle.js
participant WHO as WHO DON API
participant CDC as CDC RSS
participant Redis as Redis Cache
Cron->>DS: run seeder
DS->>TGH: fetch bundle (master branch ✓)
TGH-->>DS: 7.5 MB JS bundle
DS->>VPD: parseRealtimeAlerts(bundle)
VPD-->>DS: ~1,600 normalised alerts (disease, lat, lng, …)
DS->>DS: filter to last 90d
DS->>WHO: fetch DON API
WHO-->>DS: recent outbreak notices
DS->>CDC: fetch RSS
CDC-->>DS: HAN alerts
DS->>DS: deduplicate + cap (150 TGH + 50 others)
DS->>Redis: write health:disease-outbreaks:v1 (TTL 72h)
Note over DS,Redis: UI reads from cache — time filter skipped for this layer
Reviews (1): Last reviewed commit: "fix(disease-outbreaks): TGH master-branc..." | Re-trigger Greptile |
| * as the field names (Alert_ID, lat, lng, diseases) are stable. | ||
| * | ||
| * We filter to last TGH_LOOKBACK_DAYS days post-parse. |
There was a problem hiding this comment.
JSDoc field name mismatch (
diseases vs disease)
The comment says "as long as the field names (Alert_ID, lat, lng, diseases) are stable," but parseRealtimeAlerts maps r.diseases → .disease (singular) before returning. Callers therefore work with rec.disease (which this function correctly uses), but the JSDoc implies the stable name is the raw diseases key. A future maintainer debugging a field-not-found error would be misled into checking rec.diseases rather than the mapped rec.disease.
| // the cache; the seeder's TTL + per-source lookback already bound | ||
| // freshness at write time. PR #3593: production saw 50 valid records | ||
| // cached but 0 rendered because the newest CDC item was 11d old. | ||
| const filteredDiseaseOutbreaks = mapLayers.diseaseOutbreaks ? this.diseaseOutbreaks : []; | ||
| const filteredRadiationObservations = mapLayers.radiationWatch ? this.filterByTimeCached(this.radiationObservations, (obs) => obs.observedAt) : []; | ||
| const filteredPositiveEvents = mapLayers.positiveEvents ? this.filterByTimeCached(this.positiveEvents, (e) => e.timestamp) : []; | ||
| const filteredIranEvents = mapLayers.iranAttacks ? this.filterByTimeCached(this.iranEvents, (e) => e.timestamp) : []; |
There was a problem hiding this comment.
Disease outbreaks layer silently ignores the global time-range selector
The layer now bypasses filterByTimeCached entirely, so a user who sets the dropdown to "1h", "24h", or any other window will still see all cached items — including TGH alerts up to 90 days old. Every other layer (earthquakes, natural events, radiation, etc.) honors the selector; disease outbreaks is the only exception. There's no UI affordance (e.g. a layer-level tooltip or disabled-filter indicator) to tell users the time filter doesn't apply here, which may cause silent confusion.
The seeder-side freshness bound is a reasonable architectural argument, but the asymmetry with every other map layer is worth surfacing to users rather than silently diverging from the expected time-filter contract.
Captures the architectural follow-up identified during the 2026-05-04 disease-outbreaks incident: /api/health currently reports seeder-run freshness, not content freshness. For sparse upstream sources (WHO Disease Outbreak News, IEA OPEC reports, central-bank releases, WB annual indicators) these diverge — seeder runs fine, seed-meta fetchedAt stays fresh, but the freshest item the user sees is days or weeks old. Health says OK; UI renders nothing. Plan opts seeders into a parallel content-age contract: - runSeed accepts itemTimestamp / itemsPath / maxContentAgeMin - seed-meta carries newestItemAt/oldestItemAt/maxContentAgeMin when set - api/health reports new STALE_CONTENT status when content is older than the seeder's content-age budget Backwards compatible — legacy seeders without itemTimestamp keep current behavior. Pilot on disease-outbreaks (today's incident's origin), then migrate sparse + annual seeders over Sprints 3-4. Companion to PRs #3582 (canonical-envelope-mirror), #3593 (disease- outbreaks TGH + time-filter fixes), and the broader 'fetched-recently is not the same as fresh-content' insight.
…revisions (#3595) Five rounds of Codex review against plan #3594: - Round 1 (8 findings): pilot threshold won't catch incident; canonical-mirror loses content fields; synthetic timestamps mask staleness; all-undated falls through to OK; wrong health.js target symbols; soft-disabled budget; brittle autodetect; missing tests. Adopted contentMeta(data) -> {newestItemAt, oldestItemAt} API. - Round 2 (3 findings): envelope-writer chain incomplete (need _seed-envelope-source.mjs + parity mirrors + _seed-contract.mjs); classifier precedence wrong; disease snippet broke isNaN filters and mapItem. - Round 3 (3 findings): TGH source missed migration; stale classifier code block contradicted Sprint 1; helpers leaked via list-disease-outbreaks + bootstrap. - Round 4 (3 findings): replacement classifier still used bare 'return' but real code uses status='X' assignment; mapItem section contradicted strip contract; grep missed _originalPublishedMs. - Round 5 (1 finding): test descriptions still said cached items 'have helper fields'; rewrote to separate pre-publish in-memory layer from post-strip published-canonical layer. Net: ~280 prod LOC + ~250 test LOC for Sprint 1; explicit envelope-writer chain coverage; Sprint 2 disease pilot covers all 3 sources (WHO/RSS/TGH) + helper-field strip via publishTransform + anti-regression tests. Companion: PR #3593 (immediate disease-outbreaks fixes), #3582 (canonical-envelope-mirror).
…kip time filter (koala73#3593) PRODUCTION 2026-05-04: enabling the disease-outbreaks map layer renders nothing despite /api/health reporting `diseaseOutbreaks: status=OK, records=50`. Direct Redis read confirmed the cache holds 50 items but none render. Three compound issues, fixed in one PR. == A. ThinkGlobalHealth source returning 0 items == Two sub-bugs in `fetchThinkGlobalHealth`: 1. Wrong default branch in URL. The seeder hardcoded `/main/index_bundle.js` but the TGH GitHub repo's default branch is `master`. The `/main/` URL has been returning HTTP 404 silently — the seeder caught the !resp.ok and returned []. Fix: `main` → `master`. Verified live: 200 OK, 7.5 MB bundle. 2. Bundle format change after 2026-04 webpack rebuild. The legacy parser anchored on `var a=[{Alert_ID:` (unquoted JS keys). The new bundle wraps records in `eval("var res = [...]")` blocks with JSON-quoted keys like `\"Alert_ID\":\"8732529\"`. The old regex (`/(\w+):"((?:[^"\\]|\\.)*)"/`) doesn't match the quoted-key form. Fix: import `parseRealtimeAlerts` from seed-vpd-tracker.mjs (which already handles this exact format with a battle-tested schema-anchored scanner — VPD and Disease share the same TGH bundle). After both fixes, TGH contributes ~1,600 ProMED-reviewed alerts with real lat/lng, restoring the only geo-rich source (WHO/CDC/ONT only publish country names → require centroid fallback). == B. UI time filter eats every item == The map's `filterByTimeCached` (DeckGLMap.ts:1505) gated diseaseOutbreaks by the global timeRange dropdown (max '7d'). Disease outbreaks are sparse-by-nature — WHO DON publishes 1-2/week, CDC HAN alerts are infrequent, TGH carries 90 days of ProMED items. When the most recent WHO/CDC update is 8+ days old (normal), the 7d gate dropped every item → empty layer. Production confirmed: 50 cached items, newest 11.0 days old, all dropped. Fix: skip the time filter for diseaseOutbreaks. Render all items in the cache; the seeder's per-source lookback already bounds freshness at write time. Other layers keep the global filter. == Out of scope == - C: structural health-readiness probe (seedAgeMin tracks seeder run, not item freshness — separate followup). - Static-layer zoom-gates (bases/nuclear/spaceports/economic show nothing at default zoom 2 because LAYER_ZOOM_THRESHOLDS[*].minZoom is 3-5). Intentional UX, not a data bug — separate followup if we want a "zoom in to see N items" affordance.
…a73#3594) Captures the architectural follow-up identified during the 2026-05-04 disease-outbreaks incident: /api/health currently reports seeder-run freshness, not content freshness. For sparse upstream sources (WHO Disease Outbreak News, IEA OPEC reports, central-bank releases, WB annual indicators) these diverge — seeder runs fine, seed-meta fetchedAt stays fresh, but the freshest item the user sees is days or weeks old. Health says OK; UI renders nothing. Plan opts seeders into a parallel content-age contract: - runSeed accepts itemTimestamp / itemsPath / maxContentAgeMin - seed-meta carries newestItemAt/oldestItemAt/maxContentAgeMin when set - api/health reports new STALE_CONTENT status when content is older than the seeder's content-age budget Backwards compatible — legacy seeders without itemTimestamp keep current behavior. Pilot on disease-outbreaks (today's incident's origin), then migrate sparse + annual seeders over Sprints 3-4. Companion to PRs koala73#3582 (canonical-envelope-mirror), koala73#3593 (disease- outbreaks TGH + time-filter fixes), and the broader 'fetched-recently is not the same as fresh-content' insight.
…revisions (koala73#3595) Five rounds of Codex review against plan koala73#3594: - Round 1 (8 findings): pilot threshold won't catch incident; canonical-mirror loses content fields; synthetic timestamps mask staleness; all-undated falls through to OK; wrong health.js target symbols; soft-disabled budget; brittle autodetect; missing tests. Adopted contentMeta(data) -> {newestItemAt, oldestItemAt} API. - Round 2 (3 findings): envelope-writer chain incomplete (need _seed-envelope-source.mjs + parity mirrors + _seed-contract.mjs); classifier precedence wrong; disease snippet broke isNaN filters and mapItem. - Round 3 (3 findings): TGH source missed migration; stale classifier code block contradicted Sprint 1; helpers leaked via list-disease-outbreaks + bootstrap. - Round 4 (3 findings): replacement classifier still used bare 'return' but real code uses status='X' assignment; mapItem section contradicted strip contract; grep missed _originalPublishedMs. - Round 5 (1 finding): test descriptions still said cached items 'have helper fields'; rewrote to separate pre-publish in-memory layer from post-strip published-canonical layer. Net: ~280 prod LOC + ~250 test LOC for Sprint 1; explicit envelope-writer chain coverage; Sprint 2 disease pilot covers all 3 sources (WHO/RSS/TGH) + helper-field strip via publishTransform + anti-regression tests. Companion: PR koala73#3593 (immediate disease-outbreaks fixes), koala73#3582 (canonical-envelope-mirror).
Production 2026-05-04: enabling the disease-outbreaks map layer renders nothing despite /api/health reporting
diseaseOutbreaks: status=OK, records=50. Direct Redis read confirmed cache holds 50 items. Three compound issues, fixed in one PR.A. ThinkGlobalHealth source returning 0 items
Two sub-bugs in
fetchThinkGlobalHealth:Wrong default branch: hardcoded
/main/index_bundle.jsbut TGH repo's default ismaster. The/main/URL has been silent 404 — !resp.ok caught and returned []. Fix:main→master. Verified live: 200 OK, 7.5 MB bundle.Bundle format change after 2026-04 webpack rebuild: legacy parser anchored on
var a=[{Alert_ID:(unquoted JS keys). New bundle wraps records ineval("var res = [...]")blocks with JSON-quoted keys. Old regex never matches the quoted-key form. Fix: importparseRealtimeAlertsfrom seed-vpd-tracker.mjs (which already handles this exact format with a battle-tested schema-anchored scanner — VPD and Disease share the same TGH bundle).After both fixes TGH contributes ~1,600 ProMED-reviewed alerts with real lat/lng — restoring the only geo-rich source.
B. UI time filter eats every item
DeckGLMap.ts:1505gated diseaseOutbreaks by the global timeRange dropdown (max '7d'). Disease outbreaks are sparse-by-nature — WHO DON publishes 1-2/week, CDC HAN alerts are infrequent. When the most recent WHO/CDC update is 8+ days old (normal), the 7d gate dropped every item → empty layer. Production confirmed: 50 cached items, newest 11.0d old, all dropped.Fix: skip the time filter for diseaseOutbreaks. Render all items in the cache; the seeder's per-source lookback already bounds freshness at write time. Other layers keep the global filter.
Out of scope
Test plan