Skip to content

Phase 0 PR2: Forecast region filter quick win#2942

Merged
koala73 merged 3 commits into
mainfrom
feat/forecast-region-filter
Apr 11, 2026
Merged

Phase 0 PR2: Forecast region filter quick win#2942
koala73 merged 3 commits into
mainfrom
feat/forecast-region-filter

Conversation

@koala73

@koala73 koala73 commented Apr 11, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 0 PR2 of the Regional Intelligence Model. Independent of PR #2940 (the foundation snapshot writer); both can merge in any order.

The existing getForecasts(domain, region) server RPC accepts a region parameter but the UI never passed it. Adds a second filter row to ForecastPanel.ts matching the existing domain filter pattern. The selected region string is passed end-to-end with no client-side re-filtering.

  • 8 region pills (All Regions, MENA, East Asia, Europe, South Asia, Africa, LatAm, N. America)
  • Region IDs are free-text labels matching the existing forecast region strings
  • Server does case-insensitive substring match (unchanged)

Spec

docs/internal/pro-regional-intelligence-upgrade.md Feature 1, Phase 0 quick win.

Testing

  • npm run typecheck - clean
  • npm run lint on the touched file - clean
  • Manual: clicking each region pill re-fetches and filters the forecast list

Post-Deploy Monitoring & Validation

UI-only change with no backend impact.

  • What to monitor: Browser Sentry for any new errors from ForecastPanel
  • Expected healthy behavior: Region pills toggle correctly, forecast list refreshes per selection
  • Failure signal: Empty forecast list when "All Regions" is selected (regression in default fetch)
  • Validation window: Smoke test in production after merge by toggling each region pill
  • Owner: @koala73

Test plan

  • Click each region pill, verify forecast list updates
  • Click "All Regions", verify full forecast list returns
  • Verify domain filter still works in combination with region filter
  • No console errors

The getForecasts RPC already accepts a region parameter (free-text substring
match on f.region in server/worldmonitor/forecast/v1/get-forecasts.ts) but
the UI never passed it. Adds a second filter row alongside the existing
domain filter, scoped to 8 regions matching the existing forecast region
strings.

The selected region string is passed end-to-end from UI to server with no
client-side re-filtering. Earlier review caught that double-filtering with
mismatched taxonomies hides valid forecasts.

Spec: docs/internal/pro-regional-intelligence-upgrade.md (Feature 1, Phase 0)
@vercel

vercel Bot commented Apr 11, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Apr 11, 2026 1:08pm

Request Review

@greptile-apps

greptile-apps Bot commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a region filter pill row to ForecastPanel.ts, passing the selected region string to the existing getForecasts server RPC (which already supported it) with a sequence counter to guard against out-of-order responses. The implementation is clean and follows the existing domain-filter pattern, but the region filter state is not durable across periodic background refreshes.

  • P1: App.ts schedules dataLoader.loadForecasts() every 30 minutes; it always calls fetchForecasts() with no region and then calls panel.updateForecasts(allForecasts), silently overwriting the region-filtered data while the active pill stays highlighted.
  • P1: On RPC error, this.selectedRegion has already been updated and the pill re-rendered as active, but this.forecasts still holds stale data from a different region — the catch block needs to roll back selectedRegion and re-render.

Confidence Score: 3/5

Not safe to merge as-is — two P1 defects cause the region filter to silently show wrong data.

The 30-minute scheduled refresh is guaranteed to overwrite region-filtered state on every cycle, meaning the feature's core guarantee breaks predictably in production. The error-path inconsistency is a second independent P1. Both are on the primary user path of this feature.

src/components/ForecastPanel.ts — updateForecasts and refetchForRegion both need fixes before merge.

Important Files Changed

Filename Overview
src/components/ForecastPanel.ts Adds region filter pills and refetchForRegion() with sequence-based staleness guard, but periodic scheduler refreshes silently overwrite region-filtered state, and error handling leaves the active-pill/data in an inconsistent state.

Sequence Diagram

sequenceDiagram
    participant User
    participant ForecastPanel
    participant fetchForecasts as fetchForecasts RPC
    participant RefreshScheduler

    User->>ForecastPanel: click region pill (e.g. MENA)
    ForecastPanel->>ForecastPanel: selectedRegion = 'MENA', regionFetchSeq++
    ForecastPanel->>ForecastPanel: render() — MENA pill highlighted (old data shown)
    ForecastPanel->>fetchForecasts: fetchForecasts(undefined, 'MENA')
    fetchForecasts-->>ForecastPanel: MENA forecasts
    ForecastPanel->>ForecastPanel: updateForecasts(menaForecasts) → render()

    Note over RefreshScheduler,ForecastPanel: 30 min later (REFRESH_INTERVALS.forecasts)
    RefreshScheduler->>ForecastPanel: updateForecasts(allForecasts) — no region arg
    ForecastPanel->>ForecastPanel: this.forecasts = allForecasts (selectedRegion still 'MENA')
    Note over ForecastPanel: MENA pill active but all-region data displayed
Loading

Comments Outside Diff (1)

  1. src/components/ForecastPanel.ts, line 302-308 (link)

    P1 Periodic refresh silently clears the region filter

    App.ts schedules dataLoader.loadForecasts() on a 30-minute timer (REFRESH_INTERVALS.forecasts = 30 * 60 * 1000). That call always invokes fetchForecasts() with no arguments and then calls panel.updateForecasts(allForecasts), which unconditionally replaces this.forecasts. After the timer fires, a user who selected "MENA" will see all-region data while the MENA pill still appears active — a silent, guaranteed regression every 30 minutes.

    The simplest fix is to guard updateForecasts against external pushes when a region is selected:

    updateForecasts(forecasts: Forecast[]): void {
      // Ignore external pushes that don't match the user's active region selection.
      // The scheduler calls this with unfiltered data; re-fetch for the active region instead.
      if (this.selectedRegion) {
        void this.refetchForRegion();
        return;
      }
      this.forecasts = forecasts;
      const visible = this.getVisibleForecasts();
      this.setCount(visible.length);
      this.setDataBadge(visible.length > 0 ? 'live' : 'unavailable');
      this.render();
    }

Reviews (1): Last reviewed commit: "feat(forecast): add region filter to For..." | Re-trigger Greptile

Comment thread src/components/ForecastPanel.ts Outdated
Comment on lines +323 to +334
private async refetchForRegion(): Promise<void> {
const seq = ++this.regionFetchSeq;
// Repaint immediately so the clicked pill's active state updates.
this.render();
try {
const forecasts = await fetchForecasts(undefined, this.selectedRegion || undefined);
if (seq !== this.regionFetchSeq) return;
this.updateForecasts(forecasts);
} catch {
// Silent failure: premium RPC, same pattern as the initial load in data-loader.
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Fetch error leaves region pill and displayed data out of sync

this.selectedRegion is updated by the caller before refetchForRegion() is invoked, and the immediately following this.render() already paints the new pill as active. If the fetchForecasts call then throws, the catch is silent: this.forecasts still holds data from the previous region (or all regions), but the UI continues to show the new region pill as selected.

Rolling back selectedRegion inside the catch restores a consistent state:

private async refetchForRegion(): Promise<void> {
  const seq = ++this.regionFetchSeq;
  const prevRegion = this.selectedRegion;
  this.render();
  try {
    const forecasts = await fetchForecasts(undefined, this.selectedRegion || undefined);
    if (seq !== this.regionFetchSeq) return;
    this.updateForecasts(forecasts);
  } catch {
    if (seq === this.regionFetchSeq) {
      this.selectedRegion = prevRegion;
      this.render();
    }
  }
}

Comment on lines +265 to +272
const regionBtn = target.closest('[data-fc-region]') as HTMLElement | null;
if (regionBtn) {
const nextRegion = regionBtn.dataset.fcRegion ?? '';
if (nextRegion === this.selectedRegion) return;
this.selectedRegion = nextRegion;
void this.refetchForRegion();
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No loading indicator while region refetch is in progress

After clicking a region pill the initial render() in refetchForRegion immediately highlights the new pill, but the data below it still reflects the old region until the RPC completes. There is no visual cue (spinner, skeleton, opacity) to tell the user a request is in flight, making the panel feel inconsistent for the duration of the round-trip.

koala73 added a commit that referenced this pull request Apr 11, 2026
… review

Records 19 P2 and 3 P3 todos from the kieran-typescript, security-sentinel,
performance-oracle, architecture-strategist, code-simplicity, agent-native,
and learnings researcher reviews of PR #2940 and PR #2942.

P2 (19): isCloseToThreshold inverted for lt; sequential readLatestSnapshot
1600ms; sequential persist 1600ms; seeder bypasses runSeed; region taxonomy
3 sources of truth; Redis keys coupled to compute modules; stored XSS risk;
AbortController missing; getForecasts no cachedFetchJson; REGIONS.find
duplicated; undated inputs treated as fresh; inconsistent unknown-region
error handling; writeExtraKeyWithMeta positional args; pipeline non-atomic
persist; trigger watching runs on delta operators; regime.transition_driver
always empty; orphan scripts/shared mirror; dangling docs/internal refs;
refetchForRegion silent error swallowing.

P3 (3): redundant JSON.stringify(caseFile) hot loops; helper/config
cleanups (round dedup, generateSnapshotId entropy, matchesHorizon regex,
coercive_pressure dead 0.30 weight); perf micro-cleanups (buildPreMeta x8,
signal indexing, prototype pollution guards).
Addresses 2 P1 review findings on PR #2942.

P1 #1: Region pills keyed to labels that do not match persisted Forecast.region.
  The seed-forecasts writes Forecast.region with country names (Israel, Iran,
  Taiwan, United States...) and theater/market values (Red Sea, Black Sea,
  Baltic Sea, South China Sea, Eastern Mediterranean, Strait of Hormuz,
  Western Pacific...). The server RPC only does a substring .includes() match,
  so pills like 'Middle East' and 'Europe' miss most of the data. The
  seed already computes macroRegion via MACRO_REGION_MAP but that field is
  not in the Forecast proto, so the client never sees it.

  Fix: mirror MACRO_REGION_MAP to new shared/forecast-macro-regions.js and
  filter client-side by macro region. Pills now use MENA, EAST_ASIA, EUROPE,
  AMERICAS, SOUTH_ASIA, AFRICA as the six macro regions that match the
  publisher's taxonomy.

P1 #2: Region filter state lost on refresh.
  selectedRegion was only consumed by refetchForRegion(). When the data
  loader or scheduler called updateForecasts(fullForecasts), this.forecasts
  was overwritten and the region filter disappeared. render() did not know
  about the filter.

  Fix: apply region filter inside the render pipeline (matching the existing
  activeDomain pattern). this.forecasts stays as the full unfiltered set.
  Clicking a pill no longer issues an RPC; it just updates selectedRegion
  and re-renders. Removed the regionFetchSeq counter since no network call
  fires.

  Side effect: improved perf (no redundant RPC on pill clicks) and instant
  response. The getForecasts RPC is called once on initial load, same as
  before.
@koala73

koala73 commented Apr 11, 2026

Copy link
Copy Markdown
Owner Author

P1 review fixes landed in 01965eb — addresses both issues from the review.

P1 #1: Region pills now match the seed's actual region vocabulary
The prior pills (Middle East, East Asia, Europe…) keyed off a server-side substring .includes() match, but the seed writes Forecast.region with country names (Israel, Iran, Taiwan, United States…) and theater/market values (Red Sea, Black Sea, South China Sea, Strait of Hormuz, Western Pacific…). Most data was invisible to every pill.

The seed already computes a macroRegion via MACRO_REGION_MAP in scripts/seed-forecasts.mjs (lines 8219–8238), but that field isn't in the Forecast proto, so the client never sees it. Fix: mirror the map to shared/forecast-macro-regions.js (+ companion .d.ts) and filter on the client by walking f.region through getForecastMacroRegion(). Pills are now the six macro regions from the publisher taxonomy: MENA, EAST_ASIA, EUROPE, AMERICAS, SOUTH_ASIA, AFRICA.

P1 #2: Region filter no longer wiped on refresh
selectedRegion was only consumed by refetchForRegion(). When data-loader.ts or the scheduler called updateForecasts(fullForecasts), this.forecasts was overwritten and the filter disappeared because render() had no knowledge of it.

Fix: the region filter now lives in getVisibleForecasts() alongside the prob-min filter, mirroring how activeDomain already works. this.forecasts stays the full unfiltered set at all times. Clicking a pill no longer fires an RPC — it just updates selectedRegion, recomputes the count, and re-renders. Removed the regionFetchSeq counter since no network call is in flight anymore.

Side effect: instant pill response (no roundtrip) and one fewer premium RPC per click.

Files touched (3):

  • src/components/ForecastPanel.ts (+33 / −34)
  • shared/forecast-macro-regions.js (new)
  • shared/forecast-macro-regions.d.ts (new)

Checks:

  • npm run typecheck — clean
  • npm run typecheck:api — clean
  • npx biome check src/components/ForecastPanel.ts shared/forecast-macro-regions.{js,d.ts} — clean (one pre-existing info on line 514, untouched)
  • Pre-push hook (unit tests, version sync, proto freshness) — 164/164 pass

…from filter

Addresses 1 P1 + 1 P2 review finding on PR #2942.

P1: narrow macro map silently dropped valid forecasts.
  The hardcoded FORECAST_MACRO_REGION_MAP only covered ~50 strings, but
  seed-forecasts.mjs emits conflict/political/cyber/infrastructure rows
  with region set to ANY source country name (Algeria, Niger, Kazakhstan,
  Uruguay, Vietnam...) and market rows with 'Global' or 'Northern Europe'.
  Any row whose region string was not in the 50-entry map returned null
  and vanished on every pill click.

  Fix: replace the narrow map with a 3-stage lookup:
    1. Theater / geo-label map for multi-country strings (Red Sea, Baltic
       Sea, Northern Europe, Sahel, Horn of Africa, South China Sea, etc.)
    2. Country name -> ISO2 via a 302-entry normalized dictionary mirroring
       shared/country-names.json.
    3. ISO2 -> region via an inlined 218-entry ISO2_TO_REGION map (the
       World Bank taxonomy with strategic overrides: AF/PK/LK -> south-asia,
       TR -> mena, MX -> north-america, TW -> east-asia).
  Unknown strings return null. 'Global' resolves to a distinct 'global' id
  so callers can tell "truly global" from "unclassified"; both only appear
  under the All Regions pill since no specific pill matches them.

  Pills now use the 7 region IDs from the regional intelligence taxonomy
  (mena, east-asia, europe, north-america, south-asia, latam,
  sub-saharan-africa), replacing the old uppercase enum that was tied to
  the narrow MACRO_REGION_MAP.

P2: status badge reflected filter result, not fetch success.
  Picking a region with zero matches flipped the badge to 'unavailable'
  even though the RPC succeeded and the feed was healthy. Badge is now
  tied to this.forecasts.length (fetch success) and the empty-state copy
  differentiates "No forecasts match the current filter" (filter miss)
  from "No forecasts available" (fetch miss) so users can tell the two
  apart without mistaking a filter miss for a broken feed.
@koala73

koala73 commented Apr 11, 2026

Copy link
Copy Markdown
Owner Author

Addressed 1 P1 + 1 P2 review finding in ed3eae4be.

P1 — Narrow macro map drops valid forecasts

The old FORECAST_MACRO_REGION_MAP only covered ~50 strings, mirroring scripts/seed-forecasts.mjs's narrow map. But the seed actually emits Forecast.region as:

  • Any source country name via region: c.name for conflict / political / cyber / infrastructure rows (e.g., Algeria, Niger, Kazakhstan, Uruguay, Vietnam, New Zealand, and hundreds more)
  • Theater / geo labels like 'Northern Europe', 'Horn of Africa', 'Sahel'
  • The literal string 'Global' for cross-market signals

Any row whose region string was not in the 50-entry map returned null and silently disappeared on every pill click.

Replaced the narrow map with a 3-stage lookup in shared/forecast-macro-regions.js:

  1. Theater / geo-label map (Red Sea, Baltic Sea, Northern Europe, Sahel, Horn of Africa, South China Sea, Kerch Strait, etc.)
  2. Country-name → ISO2 via a 302-entry normalized dictionary mirroring shared/country-names.json
  3. ISO2 → region via an inlined 218-entry ISO2_TO_REGION map — the World Bank taxonomy with strategic overrides (AF/PK/LK → south-asia, TR → mena, MX → north-america, TW → east-asia)

Unknown strings return null. 'Global' resolves to a distinct 'global' id so callers can tell "truly global" from "unclassified"; both only surface under the All Regions pill.

Pills now use the 7 canonical region IDs: mena, east-asia, europe, north-america, south-asia, latam, sub-saharan-africa. Matches the regional intelligence taxonomy.

P2 — Badge reflects filter result, not fetch success

The status badge was tied to the filtered visible.length, which meant picking a region with zero matches flipped the panel badge to unavailable even though the RPC succeeded and the feed was healthy.

Fixed: badge now reads this.forecasts.length > 0 ? 'live' : 'unavailable' (fetch success). The empty-state copy differentiates "No forecasts match the current filter" (filter miss) from "No forecasts available" (fetch miss) so users can tell the two apart without mistaking a filter miss for a broken feed.

Verification

  • npm run typecheck: clean
  • npm run typecheck:api: clean
  • npx biome lint shared/forecast-macro-regions.js shared/forecast-macro-regions.d.ts src/components/ForecastPanel.ts: clean (one pre-existing info on an unrelated regex in ForecastPanel.ts:533, untouched)
  • Node smoke test over 32 cases including edge paths (Algeria → mena, Niger → sub-saharan-africa, Kazakhstan → europe, Uruguay → latam, Vietnam → east-asia, New Zealand → east-asia, Red Sea → mena, Northern Europe → europe, Sahel → sub-saharan-africa, Horn of Africa → sub-saharan-africa, Global → global, Martian Colony → null, plus all 7 active pill ids round-tripping): 32/32 passing.

Files changed:

  • shared/forecast-macro-regions.js (rewrite)
  • shared/forecast-macro-regions.d.ts (rewrite)
  • src/components/ForecastPanel.ts (pill IDs + badge decoupling + empty-state copy)

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