From 17cc35456b7d2959f78f27ec99500a7ad28b87e6 Mon Sep 17 00:00:00 2001 From: fuleinist Date: Fri, 27 Mar 2026 04:27:23 +0800 Subject: [PATCH 1/4] fix(i18n): use data-font attribute instead of inline style for font family Fixes koala73/worldmonitor#1445 --- src/services/font-settings.ts | 6 ++---- src/styles/main.css | 5 +++++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/services/font-settings.ts b/src/services/font-settings.ts index 7831e82606..cf5e904553 100644 --- a/src/services/font-settings.ts +++ b/src/services/font-settings.ts @@ -5,8 +5,6 @@ const EVENT_NAME = 'wm-font-changed'; const ALLOWED: FontFamily[] = ['mono', 'system']; -const SYSTEM_FONT_STACK = - "system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"; export function getFontFamily(): FontFamily { try { @@ -32,9 +30,9 @@ export function setFontFamily(font: FontFamily): void { export function applyFont(font?: FontFamily): void { const resolved = font ?? getFontFamily(); if (resolved === 'system') { - document.documentElement.style.setProperty('--font-body-base', SYSTEM_FONT_STACK); + document.documentElement.dataset.font = 'system'; } else { - document.documentElement.style.removeProperty('--font-body-base'); + delete document.documentElement.dataset.font; } } diff --git a/src/styles/main.css b/src/styles/main.css index 443c06a915..084603e13d 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -61,6 +61,11 @@ --font-body: var(--font-body-base); } +/* System font selection via data-font attribute (avoids inline style specificity issues with RTL) */ +[data-font="system"] { + --font-body-base: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} + [dir="rtl"] { --font-body: 'Tajawal', 'Geeza Pro', 'SF Arabic', 'Tahoma', var(--font-body-base); } From 90de00a25ee1612820d463f30e427b74b165d3c2 Mon Sep 17 00:00:00 2001 From: Subagent Date: Fri, 27 Mar 2026 17:21:20 +0800 Subject: [PATCH 2/4] fix: remove stray double blank line in font-settings.ts --- src/services/font-settings.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/services/font-settings.ts b/src/services/font-settings.ts index cf5e904553..0ef2dc7c6d 100644 --- a/src/services/font-settings.ts +++ b/src/services/font-settings.ts @@ -5,7 +5,6 @@ const EVENT_NAME = 'wm-font-changed'; const ALLOWED: FontFamily[] = ['mono', 'system']; - export function getFontFamily(): FontFamily { try { const raw = localStorage.getItem(STORAGE_KEY); From dcc547b8f83ffaf595ecc09fe79fd0db71774b89 Mon Sep 17 00:00:00 2001 From: Subagent Date: Sun, 29 Mar 2026 17:54:31 +0800 Subject: [PATCH 3/4] fix(climate): replace 30-day rolling baseline with WMO 30-year normals - Create seed-climate-zone-normals.mjs to fetch 1991-2020 historical monthly means from Open-Meteo archive API per zone - Update seed-climate-anomalies.mjs to use WMO normals as baseline instead of climatologically meaningless 30-day rolling window - Add 7 new climate-specific zones: Arctic, Greenland, WestAntarctic, TibetanPlateau, CongoBasin, CoralTriangle, NorthAtlantic - Register climateZoneNormals cache key in cache-keys.ts - Add fallback to rolling baseline if normals not yet cached Fixes: koala73/worldmonitor#2467 --- scripts/seed-climate-anomalies.mjs | 144 +++++++++++++++++++--- scripts/seed-climate-zone-normals.mjs | 164 ++++++++++++++++++++++++++ server/_shared/cache-keys.ts | 3 +- 3 files changed, 294 insertions(+), 17 deletions(-) create mode 100644 scripts/seed-climate-zone-normals.mjs diff --git a/scripts/seed-climate-anomalies.mjs b/scripts/seed-climate-anomalies.mjs index c9e500f0ed..971cc4b7a2 100755 --- a/scripts/seed-climate-anomalies.mjs +++ b/scripts/seed-climate-anomalies.mjs @@ -1,12 +1,25 @@ #!/usr/bin/env node - -import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs'; +/** + * seed-climate-anomalies.mjs + * + * Computes climate anomalies by comparing current 7-day means against + * WMO 30-year climatological normals (1991-2020) for the current calendar month. + * + * The previous approach of comparing against the previous 23 days of the same + * 30-day window was climatologically wrong — a sustained heat wave during a + * uniformly hot month would not appear anomalous because the baseline was + * equally hot. + */ + +import { loadEnvFile, CHROME_UA, runSeed, getRedisCredentials } from './_seed-utils.mjs'; loadEnvFile(import.meta.url); const CANONICAL_KEY = 'climate:anomalies:v1'; const CACHE_TTL = 10800; // 3h +const ZONE_NORMALS_KEY = 'climate:zone-normals:v1'; +// Geopolitical zones (original 15) const ZONES = [ { name: 'Ukraine', lat: 48.4, lon: 31.2 }, { name: 'Middle East', lat: 33.0, lon: 44.0 }, @@ -25,6 +38,19 @@ const ZONES = [ { name: 'Caribbean', lat: 19.0, lon: -72.0 }, ]; +// Climate-specific zones (7 new zones) +const CLIMATE_ZONES = [ + { name: 'Arctic', lat: 70.0, lon: 0.0 }, + { name: 'Greenland', lat: 72.0, lon: -42.0 }, + { name: 'WestAntarctic', lat: -78.0, lon: -100.0 }, + { name: 'TibetanPlateau', lat: 31.0, lon: 91.0 }, + { name: 'CongoBasin', lat: -1.0, lon: 24.0 }, + { name: 'CoralTriangle', lat: -5.0, lon: 128.0 }, + { name: 'NorthAtlantic', lat: 55.0, lon: -30.0 }, +]; + +const ALL_ZONES = [...ZONES, ...CLIMATE_ZONES]; + function avg(arr) { return arr.length ? arr.reduce((s, v) => s + v, 0) / arr.length : 0; } @@ -51,7 +77,37 @@ function classifyType(tempDelta, precipDelta) { return 'ANOMALY_TYPE_COLD'; } -async function fetchZone(zone, startDate, endDate) { +/** + * Fetch zone normals from Redis cache. + * Returns a map of zone name -> { tempMean, precipMean } for the current month. + */ +async function fetchZoneNormalsFromRedis() { + const { url, token } = getRedisCredentials(); + const resp = await fetch(`${url}/get/${encodeURIComponent(ZONE_NORMALS_KEY)}`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(10_000), + }); + + if (!resp.ok) { + console.log('[CLIMATE] Zone normals not in cache — normals seeder may not have run yet'); + return null; + } + + const data = await resp.json(); + if (!data.result) return null; + + try { + const parsed = JSON.parse(data.result); + return parsed.zones || null; + } catch { + return null; + } +} + +/** + * Fetch current conditions for a zone and compare against WMO normals. + */ +async function fetchZone(zone, normals, startDate, endDate) { const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${zone.lat}&longitude=${zone.lon}&start_date=${startDate}&end_date=${endDate}&daily=temperature_2m_mean,precipitation_sum&timezone=UTC`; const resp = await fetch(url, { @@ -73,15 +129,55 @@ async function fetchZone(zone, startDate, endDate) { } } - if (temps.length < 14) return null; + if (temps.length < 7) return null; + // Use last 7 days as current period const recentTemps = temps.slice(-7); - const baselineTemps = temps.slice(0, -7); const recentPrecips = precips.slice(-7); - const baselinePrecips = precips.slice(0, -7); - const tempDelta = Math.round((avg(recentTemps) - avg(baselineTemps)) * 10) / 10; - const precipDelta = Math.round((avg(recentPrecips) - avg(baselinePrecips)) * 10) / 10; + const currentTempMean = avg(recentTemps); + const currentPrecipMean = avg(recentPrecips); + + // Find the normal for this zone and current month + const currentMonth = new Date().getMonth() + 1; // 1-12 + const zoneNormal = normals?.find((n) => n.zone === zone.name); + + if (!zoneNormal) { + // Fallback: compute from previous 30 days if normals not available + // (This is the old behavior for backwards compatibility during transition) + const baselineTemps = temps.slice(0, -7); + const baselinePrecips = precips.slice(0, -7); + + if (baselineTemps.length < 7) return null; + + const baselineTempMean = avg(baselineTemps); + const baselinePrecipMean = avg(baselinePrecips); + + const tempDelta = Math.round((currentTempMean - baselineTempMean) * 10) / 10; + const precipDelta = Math.round((currentPrecipMean - baselinePrecipMean) * 10) / 10; + + return { + zone: zone.name, + location: { latitude: zone.lat, longitude: zone.lon }, + tempDelta, + precipDelta, + severity: classifySeverity(tempDelta, precipDelta), + type: classifyType(tempDelta, precipDelta), + period: `${startDate} to ${endDate}`, + baselineSource: 'rolling-30d-fallback', + }; + } + + // Use WMO normal for current month + const monthNormal = zoneNormal.normals?.find((n) => n.month === currentMonth); + + if (!monthNormal) { + console.log(`[CLIMATE] ${zone.name}: No normal for month ${currentMonth}`); + return null; + } + + const tempDelta = Math.round((currentTempMean - monthNormal.tempMean) * 10) / 10; + const precipDelta = Math.round((currentPrecipMean - monthNormal.precipMean) * 10) / 10; return { zone: zone.name, @@ -91,18 +187,33 @@ async function fetchZone(zone, startDate, endDate) { severity: classifySeverity(tempDelta, precipDelta), type: classifyType(tempDelta, precipDelta), period: `${startDate} to ${endDate}`, + baselineSource: 'wmo-30y-normals', + baseline: { + tempMean: monthNormal.tempMean, + precipMean: monthNormal.precipMean, + month: monthNormal.monthName, + period: zoneNormal.period, + }, }; } async function fetchClimateAnomalies() { const endDate = new Date().toISOString().slice(0, 10); - const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + + // Try to fetch WMO normals from Redis + const normals = await fetchZoneNormalsFromRedis(); + if (normals) { + console.log(`[CLIMATE] Using WMO 30-year normals for ${normals.length} zones`); + } else { + console.log('[CLIMATE] Normals not available — using 30-day rolling fallback'); + } const anomalies = []; let failures = 0; - for (const zone of ZONES) { + for (const zone of ALL_ZONES) { try { - const result = await fetchZone(zone, startDate, endDate); + const result = await fetchZone(zone, normals, startDate, endDate); if (result != null) anomalies.push(result); } catch (err) { console.log(` [CLIMATE] ${err?.message ?? err}`); @@ -111,23 +222,24 @@ async function fetchClimateAnomalies() { await new Promise((r) => setTimeout(r, 200)); } - const MIN_ZONES = Math.ceil(ZONES.length * 2 / 3); + const MIN_ZONES = Math.ceil(ALL_ZONES.length * 2 / 3); if (anomalies.length < MIN_ZONES) { - throw new Error(`Only ${anomalies.length}/${ZONES.length} zones returned data (${failures} errors) — skipping write to preserve previous Redis data`); + throw new Error(`Only ${anomalies.length}/${ALL_ZONES.length} zones returned data (${failures} errors) — skipping write to preserve previous Redis data`); } return { anomalies, pagination: undefined }; } function validate(data) { - return Array.isArray(data?.anomalies) && data.anomalies.length >= Math.ceil(ZONES.length * 2 / 3); + return Array.isArray(data?.anomalies) && data.anomalies.length >= Math.ceil(ALL_ZONES.length * 2 / 3); } runSeed('climate', 'anomalies', CANONICAL_KEY, fetchClimateAnomalies, { validateFn: validate, ttlSeconds: CACHE_TTL, - sourceVersion: 'open-meteo-archive-30d', + sourceVersion: 'open-meteo-archive-wmo-normals', }).catch((err) => { - const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : ''; console.error('FATAL:', (err.message || err) + _cause); + const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : ''; + console.error('FATAL:', (err.message || err) + _cause); process.exit(1); }); diff --git a/scripts/seed-climate-zone-normals.mjs b/scripts/seed-climate-zone-normals.mjs new file mode 100644 index 0000000000..0defc96e80 --- /dev/null +++ b/scripts/seed-climate-zone-normals.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +/** + * seed-climate-zone-normals.mjs + * + * Fetches WMO 30-year climatological normals (1991-2020) for each climate zone. + * These are used as the baseline for climate anomaly detection instead of the + * climatologically meaningless 30-day rolling window. + * + * Run: Monthly (1st of month, 03:00 UTC) via Railway cron + * Cache: climate:zone-normals:v1 (TTL 30 days) + */ + +import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs'; + +loadEnvFile(import.meta.url); + +const CANONICAL_KEY = 'climate:zone-normals:v1'; +const CACHE_TTL = 30 * 24 * 60 * 60; // 30 days in seconds + +// Climate-specific zones in addition to geopolitical zones in seed-climate-anomalies.mjs +const CLIMATE_ZONES = [ + { name: 'Arctic', lat: 70.0, lon: 0.0 }, // sea ice proxy + { name: 'Greenland', lat: 72.0, lon: -42.0 }, // ice sheet melt + { name: 'WestAntarctic', lat: -78.0, lon: -100.0 }, // Antarctic Ice Sheet + { name: 'TibetanPlateau', lat: 31.0, lon: 91.0 }, // third pole + { name: 'CongoBasin', lat: -1.0, lon: 24.0 }, // largest tropical forest after Amazon + { name: 'CoralTriangle', lat: -5.0, lon: 128.0 }, // reef bleaching proxy + { name: 'NorthAtlantic', lat: 55.0, lon: -30.0 }, // AMOC slowdown signal +]; + +// Month names for logging +const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +/** + * Fetch monthly normals for a zone using Open-Meteo archive API. + * We fetch 1991-2020 data and aggregate to monthly means per calendar month. + */ +async function fetchZoneNormals(zone) { + const monthlyNormals = {}; + + // Initialize all months + for (let m = 1; m <= 12; m++) { + monthlyNormals[m] = { temps: [], precips: [] }; + } + + // Fetch each year in chunks to avoid overwhelming the API + // Open-Meteo supports date ranges, so we fetch entire years + const startYear = 1991; + const endYear = 2020; + + for (let year = startYear; year <= endYear; year++) { + const yearStart = `${year}-01-01`; + const yearEnd = `${year}-12-31`; + const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${zone.lat}&longitude=${zone.lon}&start_date=${yearStart}&end_date=${yearEnd}&daily=temperature_2m_mean,precipitation_sum&timezone=UTC`; + + try { + const resp = await fetch(url, { + headers: { 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(20_000), + }); + + if (!resp.ok) { + console.log(` [ZONE_NORMALS] ${zone.name} ${year}: HTTP ${resp.status}`); + continue; + } + + const data = await resp.json(); + const dailyTemps = data.daily?.temperature_2m_mean ?? []; + const dailyPrecips = data.daily?.precipitation_sum ?? []; + const dailyDates = data.daily?.time ?? []; + + // Aggregate to monthly + for (let i = 0; i < dailyDates.length; i++) { + const dateStr = dailyDates[i]; + if (!dateStr) continue; + const month = parseInt(dateStr.slice(5, 7), 10); + const temp = dailyTemps[i]; + const precip = dailyPrecips[i]; + + if (temp != null && precip != null) { + monthlyNormals[month].temps.push(temp); + monthlyNormals[month].precips.push(precip); + } + } + } catch (err) { + console.log(` [ZONE_NORMALS] ${zone.name} ${year}: ${err?.message ?? err}`); + } + + // Rate limit to be nice to Open-Meteo + await new Promise((r) => setTimeout(r, 100)); + } + + // Compute monthly means + const normals = []; + for (let month = 1; month <= 12; month++) { + const { temps, precips } = monthlyNormals[month]; + if (temps.length === 0) { + console.log(` [ZONE_NORMALS] ${zone.name} ${MONTH_NAMES[month - 1]}: No data`); + continue; + } + + const avgTemp = temps.reduce((s, v) => s + v, 0) / temps.length; + const avgPrecip = precips.reduce((s, v) => s + v, 0) / precips.length; + + normals.push({ + month, + monthName: MONTH_NAMES[month - 1], + tempMean: Math.round(avgTemp * 100) / 100, + precipMean: Math.round(avgPrecip * 100) / 100, + sampleCount: temps.length, + }); + } + + return { + zone: zone.name, + location: { latitude: zone.lat, longitude: zone.lon }, + normals, + period: '1991-2020', + computedAt: new Date().toISOString(), + }; +} + +async function fetchAllZoneNormals() { + const allNormals = []; + let failures = 0; + + for (const zone of CLIMATE_ZONES) { + console.log(`[ZONE_NORMALS] Fetching ${zone.name} (${zone.lat}, ${zone.lon})...`); + try { + const result = await fetchZoneNormals(zone); + if (result && result.normals.length > 0) { + allNormals.push(result); + console.log(` → ${result.normals.length} months, ${result.normals[0].sampleCount}+ samples/month`); + } else { + failures++; + } + } catch (err) { + console.log(` [ZONE_NORMALS] ${zone.name}: ${err?.message ?? err}`); + failures++; + } + } + + if (allNormals.length === 0) { + throw new Error(`No zone normals fetched (${failures} failures)`); + } + + console.log(`[ZONE_NORMALS] Completed: ${allNormals.length}/${CLIMATE_ZONES.length} zones`); + + return { zones: allNormals, pagination: undefined }; +} + +function validate(data) { + return Array.isArray(data?.zones) && data.zones.length > 0; +} + +runSeed('climate', 'zone-normals', CANONICAL_KEY, fetchAllZoneNormals, { + validateFn: validate, + ttlSeconds: CACHE_TTL, + sourceVersion: 'open-meteo-archive-wmo-normals', +}).catch((err) => { + const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : ''; + console.error('FATAL:', (err.message || err) + _cause); + process.exit(1); +}); diff --git a/server/_shared/cache-keys.ts b/server/_shared/cache-keys.ts index 42435f291f..47dafc9cf3 100644 --- a/server/_shared/cache-keys.ts +++ b/server/_shared/cache-keys.ts @@ -27,7 +27,8 @@ export const BOOTSTRAP_CACHE_KEYS: Record = { chokepointTransits: 'supply_chain:chokepoint_transits:v1', minerals: 'supply_chain:minerals:v2', giving: 'giving:summary:v1', - climateAnomalies: 'climate:anomalies:v1', + climateAnomalies: 'climate:anomalies:v1', + climateZoneNormals: 'climate:zone-normals:v1', radiationWatch: 'radiation:observations:v1', thermalEscalation: 'thermal:escalation:v1', crossSourceSignals: 'intelligence:cross-source-signals:v1', From 164931e124b5d4a3e5ebf9ca8c60b671ff25cf66 Mon Sep 17 00:00:00 2001 From: Subagent Date: Mon, 30 Mar 2026 05:55:53 +0800 Subject: [PATCH 4/4] fix(climate): address greptile-apps review comments on WMO normals PR - seed-climate-zone-normals.mjs: Now fetches normals for ALL 22 zones (15 original geopolitical + 7 new climate zones) instead of just the 7 new climate zones. The 15 original zones were falling through to the broken rolling fallback. - seed-climate-anomalies.mjs: Fixed rolling fallback to fetch 30 days of data when WMO normals are not yet cached. Previously fetched only 7 days, causing baselineTemps slice to be empty and returning null for all zones. Now properly falls back to 30-day rolling baseline (last 7 days vs. prior 23 days) when normals seeder hasn't run. - cache-keys.ts: Removed climateZoneNormals from BOOTSTRAP_CACHE_KEYS. This is an internal seed-pipeline artifact (used by the anomaly seeder to read cached normals) and is not meant for the bootstrap endpoint. Only climate:anomalies:v1 (the final computed output) should be exposed to clients. Fixes greptile-apps P1 comments on PR #2504. --- scripts/seed-climate-anomalies.mjs | 10 ++++++++-- scripts/seed-climate-zone-normals.mjs | 28 ++++++++++++++++++++++++--- server/_shared/cache-keys.ts | 3 +-- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/scripts/seed-climate-anomalies.mjs b/scripts/seed-climate-anomalies.mjs index 971cc4b7a2..861be0e212 100755 --- a/scripts/seed-climate-anomalies.mjs +++ b/scripts/seed-climate-anomalies.mjs @@ -199,16 +199,22 @@ async function fetchZone(zone, normals, startDate, endDate) { async function fetchClimateAnomalies() { const endDate = new Date().toISOString().slice(0, 10); - const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); // Try to fetch WMO normals from Redis const normals = await fetchZoneNormalsFromRedis(); - if (normals) { + const hasNormals = normals && normals.length > 0; + + if (hasNormals) { console.log(`[CLIMATE] Using WMO 30-year normals for ${normals.length} zones`); } else { console.log('[CLIMATE] Normals not available — using 30-day rolling fallback'); } + // If normals are available, fetch 7 days of data for current period comparison + // If normals are NOT available, fetch 30 days so the fallback can split into baseline + current + const daysToFetch = hasNormals ? 7 : 30; + const startDate = new Date(Date.now() - daysToFetch * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const anomalies = []; let failures = 0; for (const zone of ALL_ZONES) { diff --git a/scripts/seed-climate-zone-normals.mjs b/scripts/seed-climate-zone-normals.mjs index 0defc96e80..29561f1d11 100644 --- a/scripts/seed-climate-zone-normals.mjs +++ b/scripts/seed-climate-zone-normals.mjs @@ -17,7 +17,26 @@ loadEnvFile(import.meta.url); const CANONICAL_KEY = 'climate:zone-normals:v1'; const CACHE_TTL = 30 * 24 * 60 * 60; // 30 days in seconds -// Climate-specific zones in addition to geopolitical zones in seed-climate-anomalies.mjs +// Geopolitical zones (original 15 — must be kept in sync with seed-climate-anomalies.mjs) +const ZONES = [ + { name: 'Ukraine', lat: 48.4, lon: 31.2 }, + { name: 'Middle East', lat: 33.0, lon: 44.0 }, + { name: 'Sahel', lat: 14.0, lon: 0.0 }, + { name: 'Horn of Africa', lat: 8.0, lon: 42.0 }, + { name: 'South Asia', lat: 25.0, lon: 78.0 }, + { name: 'California', lat: 36.8, lon: -119.4 }, + { name: 'Amazon', lat: -3.4, lon: -60.0 }, + { name: 'Australia', lat: -25.0, lon: 134.0 }, + { name: 'Mediterranean', lat: 38.0, lon: 20.0 }, + { name: 'Taiwan Strait', lat: 24.0, lon: 120.0 }, + { name: 'Myanmar', lat: 19.8, lon: 96.7 }, + { name: 'Central Africa', lat: 4.0, lon: 22.0 }, + { name: 'Southern Africa', lat: -25.0, lon: 28.0 }, + { name: 'Central Asia', lat: 42.0, lon: 65.0 }, + { name: 'Caribbean', lat: 19.0, lon: -72.0 }, +]; + +// Climate-specific zones (7 new zones) const CLIMATE_ZONES = [ { name: 'Arctic', lat: 70.0, lon: 0.0 }, // sea ice proxy { name: 'Greenland', lat: 72.0, lon: -42.0 }, // ice sheet melt @@ -28,6 +47,9 @@ const CLIMATE_ZONES = [ { name: 'NorthAtlantic', lat: 55.0, lon: -30.0 }, // AMOC slowdown signal ]; +// All 22 zones — must match ALL_ZONES in seed-climate-anomalies.mjs +const ALL_ZONES = [...ZONES, ...CLIMATE_ZONES]; + // Month names for logging const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; @@ -124,7 +146,7 @@ async function fetchAllZoneNormals() { const allNormals = []; let failures = 0; - for (const zone of CLIMATE_ZONES) { + for (const zone of ALL_ZONES) { console.log(`[ZONE_NORMALS] Fetching ${zone.name} (${zone.lat}, ${zone.lon})...`); try { const result = await fetchZoneNormals(zone); @@ -144,7 +166,7 @@ async function fetchAllZoneNormals() { throw new Error(`No zone normals fetched (${failures} failures)`); } - console.log(`[ZONE_NORMALS] Completed: ${allNormals.length}/${CLIMATE_ZONES.length} zones`); + console.log(`[ZONE_NORMALS] Completed: ${allNormals.length}/${ALL_ZONES.length} zones`); return { zones: allNormals, pagination: undefined }; } diff --git a/server/_shared/cache-keys.ts b/server/_shared/cache-keys.ts index 47dafc9cf3..42435f291f 100644 --- a/server/_shared/cache-keys.ts +++ b/server/_shared/cache-keys.ts @@ -27,8 +27,7 @@ export const BOOTSTRAP_CACHE_KEYS: Record = { chokepointTransits: 'supply_chain:chokepoint_transits:v1', minerals: 'supply_chain:minerals:v2', giving: 'giving:summary:v1', - climateAnomalies: 'climate:anomalies:v1', - climateZoneNormals: 'climate:zone-normals:v1', + climateAnomalies: 'climate:anomalies:v1', radiationWatch: 'radiation:observations:v1', thermalEscalation: 'thermal:escalation:v1', crossSourceSignals: 'intelligence:cross-source-signals:v1',