diff --git a/server/providers/firms.js b/server/providers/firms.js index d21dc296e..0fe7f1cb5 100644 --- a/server/providers/firms.js +++ b/server/providers/firms.js @@ -145,6 +145,10 @@ export async function fetchFirms( * * Routes: * GET /api/firms → {fetchedAt, stale, ttlMs, sources, count, fires} + * + * The trailing 24 hours to now. A source may instead answer the 24 hours to + * a set time, naming its end as `at` (epoch ms); the layer's row then shows + * that time rather than LIVE. This proxy never sets `at`. * GET /api/firms/status → {hasKey, lastFetch, count, stale, ttlMs, transactions} * * Keyless (no FIRMS_MAP_KEY): /api/firms → 503 {error:'no_key'}; status → diff --git a/src/i18n/firms-live-zh.test.mjs b/src/i18n/firms-live-zh.test.mjs index 03b3af6c8..04d22ec0d 100644 --- a/src/i18n/firms-live-zh.test.mjs +++ b/src/i18n/firms-live-zh.test.mjs @@ -43,3 +43,43 @@ for (const [age, zh, en, hant] of cases) { } }); } + +test('a FIRMS snapshot that ends at a set time names it instead of LIVE', (context) => { + const now = 1800000000000; + context.mock.method(Date, 'now', () => now); + const previousLocale = getLocale(); + const at = Date.UTC(2026, 9, 21, 15); + const layerState = { + _loading: false, + _stale: false, + _keyRequired: false, + _error: null, + _lastUpdate: now - 120000, + _at: at, + _count: 1, + _cellCount: 0, + _fires: [], + }; + const model = createModel({ services: { anchors: {} } }); + const { methods } = createQueries({ + layerState, + components: { model }, + config: {}, + }); + try { + setLocale('en'); + assert.equal(methods.getStats().loadingLabel, '24 h to 10-21 15:00 UTC'); + assert.equal(methods.getStats().at, at); + setLocale('de'); + assert.equal( + methods.getStats().loadingLabel, + '24 Std. bis 21.10. 15:00 UTC', + ); + layerState._at = null; + setLocale('en'); + assert.equal(methods.getStats().loadingLabel, 'LIVE · updated 2m ago'); + assert.equal(methods.getStats().at, null); + } finally { + setLocale(previousLocale); + } +}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 59a18415f..20315a871 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -879,6 +879,7 @@ "KEY REQUIRED": "المفتاح مطلوب", "STALE · cached {age}": "متقادم · مخزّن {age}", "LIVE · updated {age}": "مباشر · حُدّث {age}", + "24 h to {day} {time} UTC": "24 ساعة حتى {day} {time} UTC", "<1m ago": "قبل أقل من دقيقة", "FIRE · {frp} MW": "حريق · {frp} MW", "▲ {frp} MW": "▲ {frp} MW", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 02d6fdb73..d4fbdf244 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -164,6 +164,7 @@ "loading...": "lädt...", "KEY REQUIRED": "SCHLÜSSEL ERFORDERLICH", "LIVE · updated {age}": "LIVE · aktualisiert {age}", + "24 h to {day} {time} UTC": "24 Std. bis {day} {time} UTC", "loading mapped installation context": "Kontext der kartierten Anlage wird geladen", "MAPPED INSTALLATION": "KARTIERTE ANLAGE", "LAUNCH SITE · {site}": "STARTPLATZ · {site}", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index a007bec4e..e5d4d0689 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -737,6 +737,7 @@ "KEY REQUIRED": "CLAVE REQUERIDA", "STALE · cached {age}": "DESACTUALIZADO · en caché hace {age}", "LIVE · updated {age}": "EN VIVO · actualizado {age}", + "24 h to {day} {time} UTC": "24 h hasta {day} {time} UTC", "<1m ago": "hace <1 min", "FIRE · {frp} MW": "INCENDIO · {frp} MW", "▲ {frp} MW": "▲ {frp} MW", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index f394eaf98..452d8bf04 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -734,6 +734,7 @@ "loading...": "chargement…", "KEY REQUIRED": "CLÉ REQUISE", "LIVE · updated {age}": "DIRECT · mis à jour {age}", + "24 h to {day} {time} UTC": "24 h jusqu’au {day} {time} UTC", "Source snapshot time unavailable": "Heure de l'instantané source indisponible", "Live data unavailable": "Données en direct indisponibles", "Intel Picture": "Situation renseignement", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 8122e2c47..227296b93 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -734,6 +734,7 @@ "loading...": "caricamento...", "KEY REQUIRED": "CHIAVE RICHIESTA", "LIVE · updated {age}": "LIVE · aggiornato {age}", + "24 h to {day} {time} UTC": "24 h fino al {day} {time} UTC", "Source snapshot time unavailable": "Ora dello snapshot della fonte non disponibile", "Live data unavailable": "Dati live non disponibili", "Intel Picture": "Quadro intelligence", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9c716ec48..650869d13 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -716,6 +716,7 @@ "loading...": "読込中…", "KEY REQUIRED": "キーが必要", "LIVE · updated {age}": "ライブ · 更新 {age}", + "24 h to {day} {time} UTC": "{day} {time} UTC までの24時間", "Source snapshot time unavailable": "ソースのスナップショット時刻を取得できません", "Live data unavailable": "ライブデータを利用できません", "Intel Picture": "情勢図", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 1afd6d382..8545f38e8 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -732,6 +732,7 @@ "loading...": "carregando...", "KEY REQUIRED": "CHAVE NECESSÁRIA", "LIVE · updated {age}": "AO VIVO · atualizado {age}", + "24 h to {day} {time} UTC": "24 h até {day} {time} UTC", "Source snapshot time unavailable": "Horário do snapshot da fonte indisponível", "Live data unavailable": "Dados ao vivo indisponíveis", "Intel Picture": "Panorama de inteligência", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index d6af529c0..4b3d2c9f5 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -147,6 +147,7 @@ "loading...": "загрузка...", "KEY REQUIRED": "ТРЕБУЕТСЯ КЛЮЧ", "LIVE · updated {age}": "ОНЛАЙН · обновлено {age}", + "24 h to {day} {time} UTC": "24 ч до {day} {time} UTC", "Retrying mapped sites…": "Повторный запрос объектов…", "Fetching mapped sites…": "Загрузка объектов…", "Zoom in to search mapped installations": "Приблизьте, чтобы искать картографированные объекты", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 6826eb2fa..e5a26fd14 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -742,6 +742,7 @@ "loading...": "завантаження...", "KEY REQUIRED": "ПОТРІБЕН КЛЮЧ", "LIVE · updated {age}": "ОНЛАЙН · оновлено {age}", + "24 h to {day} {time} UTC": "24 год до {day} {time} UTC", "Source snapshot time unavailable": "Час знімка джерела невідомий", "Live data unavailable": "Дані в реальному часі недоступні", "Intel Picture": "Розвідувальна обстановка", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index d0af7ee5e..707891bc2 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -716,6 +716,7 @@ "loading...": "加载中…", "KEY REQUIRED": "需要密钥", "LIVE · updated {age}": "实时 · 更新于 {age}", + "24 h to {day} {time} UTC": "截至 {day} {time} UTC 的 24 小时", "Source snapshot time unavailable": "来源快照时间不可用", "Live data unavailable": "实时数据不可用", "Low-accuracy score < 2%": "低精度评分 < 2%", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c0e22141b..5deb177ed 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -711,6 +711,7 @@ "loading...": "加載中…", "KEY REQUIRED": "需要密鑰", "LIVE · updated {age}": "實時 · 更新於 {age}", + "24 h to {day} {time} UTC": "截至 {day} {time} UTC 的 24 小時", "Source snapshot time unavailable": "來源快照時間不可用", "Live data unavailable": "實時數據不可用", "Low-accuracy score < 2%": "低精度評分 < 2%", diff --git a/src/layers/firms/ingestion.js b/src/layers/firms/ingestion.js index cb9dbd72f..dc3f64d9a 100644 --- a/src/layers/firms/ingestion.js +++ b/src/layers/firms/ingestion.js @@ -39,6 +39,9 @@ export function createIngestion({ layerState._keyRequired = false; layerState._error = null; layerState._stale = Boolean(payload?.stale); + // A snapshot of the 24 hours to a set time names it; one of the + // trailing 24 hours to now does not. + layerState._at = Number.isFinite(payload?.at) ? payload.at : null; const previousSelection = layerState._selectedFire; layerState._selectedFire = null; layerState._allFires = adaptFirmsRecords(payload?.fires); diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 90d9cc217..d106ed5ad 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -127,6 +127,7 @@ export function createLifecycle({ layerState._rowControlsListener = null; layerState._cellCount = 0; layerState._lastUpdate = null; + layerState._at = null; layerState._keyRequired = false; layerState._stale = false; layerState._error = null; diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 34040ea43..f2e5674e5 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -8,7 +8,7 @@ import { rampTrackGradient, sliderPositionForFrp, } from '../../data/firmsIntensity.js'; -import { formatNumber, t } from '../../i18n/index.js'; +import { formatNumber, formatUtcDayMonth, t } from '../../i18n/index.js'; import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js'; /** Slider resolution: whole-percent positions along the log axis. */ @@ -190,8 +190,9 @@ export function createQueries({ * Layer stats for the data panel. Degraded feed states surface through * `error` (established qa-failstate pattern: a dead feed must never look * like a healthy empty layer) with a matching human `loadingLabel`: - * 'LIVE · updated Xm ago' fresh, 'STALE · cached Xh' when the proxy - * served past-TTL cache, 'KEY REQUIRED' keyless. + * 'LIVE · updated Xm ago' fresh, '24 h to MM-DD HH:MM UTC' for a + * snapshot that ends at a set time (`at`), 'STALE · cached Xh' when the + * proxy served past-TTL cache, 'KEY REQUIRED' keyless. */ getStats() { const now = Date.now(); @@ -213,6 +214,11 @@ export function createQueries({ loadingLabel = staleText; } else if (layerState._error) { loadingLabel = layerState._error; + } else if (Number.isFinite(layerState._at)) { + loadingLabel = t('24 h to {day} {time} UTC', { + day: formatUtcDayMonth(layerState._at), + time: new Date(layerState._at).toISOString().slice(11, 16), + }); } else if (layerState._lastUpdate) { loadingLabel = t('LIVE · updated {age}', { age: components.model.formatAgoMinutes(now - layerState._lastUpdate), @@ -226,6 +232,9 @@ export function createQueries({ lastUpdate: layerState._lastUpdate, loading: layerState._loading, stale: layerState._stale, + // The end of the 24 hours shown when the snapshot ends at a set time; + // null for the trailing 24 hours to now. + at: Number.isFinite(layerState._at) ? layerState._at : null, // The machine-readable half of the keyless state, ahead of the human // strings below: without it "no key configured" is indistinguishable // from a broken feed, and the row reads as a fault instead of a step diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index 0b96c53ea..8c34528ad 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -77,6 +77,8 @@ export function createFirmsState({ services, config }) { layerState._lastUpdate = null; + layerState._at = null; + layerState._currentLodId = null; layerState._currentLodIndex = -1;