From 9d27366b3ec42eccf96ccc323dab13579426d7f5 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:06:57 -0700 Subject: [PATCH 1/4] fix(fires): show a 47.6-hour-old detection as 2d, not 48h formatAge and formatAgeLabel picked the hour unit on the raw value and rounded afterwards, so a detection between 47.5 and 48 hours old read 48h, while one exactly 48 hours old read 2d. Round to whole hours first, then choose the unit. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 1 + src/layers/firms/model.js | 8 +++++--- src/layers/firms/model.test.mjs | 24 ++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 src/layers/firms/model.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 87b918a4e..71aa36964 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2221,6 +2221,7 @@ accessible name remains "Don't show this again". Control behavior is unchanged. ## FIRMS source status The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. +FIRMS ages round to whole hours before choosing a unit, so a 47.6-hour age reads "2d", not "48h". The `get_active_fires` tool reports acquisition times as UTC ISO timestamps, including zero-padded hours and minutes; missing or invalid dates and times remain `null`, including impossible calendar dates from a custom source. diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 9e3cf7f95..7dcf255c8 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -526,7 +526,8 @@ export function createModel({ if (!Number.isFinite(deltaMs) || deltaMs < 0) return ''; const hours = deltaMs / 3600000; if (hours < 1) return '<1h'; - if (hours < 48) return `${Math.round(hours)}h`; + const wholeHours = Math.round(hours); + if (wholeHours < 48) return `${wholeHours}h`; return `${Math.round(hours / 24)}d`; } @@ -561,8 +562,9 @@ export function createModel({ if (!Number.isFinite(deltaMs) || deltaMs < 0) return ''; const hours = deltaMs / 3600000; if (hours < 1) return t('<1h', { context: 'age' }); - if (hours < 48) - return t('{count}h', { context: 'age', count: Math.round(hours) }); + const wholeHours = Math.round(hours); + if (wholeHours < 48) + return t('{count}h', { context: 'age', count: wholeHours }); return t('{count}d', { context: 'age', count: Math.round(hours / 24) }); } diff --git a/src/layers/firms/model.test.mjs b/src/layers/firms/model.test.mjs new file mode 100644 index 000000000..7ff12cf3f --- /dev/null +++ b/src/layers/firms/model.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { getLocale, setLocale } from '../../i18n/index.js'; +import { createModel } from './model.js'; + +const HOUR_MS = 3600000; + +test('a fire age that rounds to 48 hours reads in days, as 48 hours does', () => { + const previousLocale = getLocale(); + const { formatAge, formatAgeLabel } = createModel({ + services: { anchors: {} }, + }); + try { + setLocale('en'); + assert.equal(formatAge(47.4 * HOUR_MS), '47h'); + assert.equal(formatAge(47.6 * HOUR_MS), '2d'); + assert.equal(formatAge(48 * HOUR_MS), '2d'); + assert.equal(formatAgeLabel(47.4 * HOUR_MS), '47h'); + assert.equal(formatAgeLabel(47.6 * HOUR_MS), formatAgeLabel(48 * HOUR_MS)); + assert.equal(formatAgeLabel(48 * HOUR_MS), '2d'); + } finally { + setLocale(previousLocale); + } +}); From a4bd028bd2626d71d7c237f8bc675b36de5c3f3f Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:47:41 -0800 Subject: [PATCH 2/4] changelog: FIRMS age rounding (#1531) Co-Authored-By: Claude Opus 5.5 --- changelog.d/1531-firms-age-rounding.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1531-firms-age-rounding.md diff --git a/changelog.d/1531-firms-age-rounding.md b/changelog.d/1531-firms-age-rounding.md new file mode 100644 index 000000000..e8cc12c4b --- /dev/null +++ b/changelog.d/1531-firms-age-rounding.md @@ -0,0 +1,3 @@ +- FIRMS fire ages round to whole hours before choosing a unit, so a detection + 47.5–48 hours old reads "2d", as one exactly 48 hours old does, not "48h" + (@quanticsoul4772, #1531). From ceff3eb6c97e7995938488417a66ed3e1b5af0ac Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:43:40 -0800 Subject: [PATCH 3/4] test(fires): pin the 47.5-hour flip to days and name where the age shows Co-Authored-By: Claude Opus 5.5 --- changelog.d/1531-firms-age-rounding.md | 8 +++++--- docs/CURRENT-STATE.md | 2 +- src/layers/firms/model.test.mjs | 2 ++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/changelog.d/1531-firms-age-rounding.md b/changelog.d/1531-firms-age-rounding.md index e8cc12c4b..cd9bcfc5d 100644 --- a/changelog.d/1531-firms-age-rounding.md +++ b/changelog.d/1531-firms-age-rounding.md @@ -1,3 +1,5 @@ -- FIRMS fire ages round to whole hours before choosing a unit, so a detection - 47.5–48 hours old reads "2d", as one exactly 48 hours old does, not "48h" - (@quanticsoul4772, #1531). +- FIRMS fire ages round to whole hours before choosing a unit, so an age of + 47.5–48 hours reads "2d", as exactly 48 hours does, not "48h". Detections + are filtered to the trailing 24 hours, so this shows mostly in the + `STALE · cached {age}` stats line after about two days without a successful + refresh (@quanticsoul4772, #1531). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 726201138..1e1230833 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2382,7 +2382,7 @@ reporting are unchanged. ## FIRMS source status The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. -FIRMS ages round to whole hours before choosing a unit, so a 47.6-hour age reads "2d", not "48h". +FIRMS ages round to whole hours before choosing a unit, so a 47.6-hour age reads "2d", not "48h"; with detections filtered to 24 hours, this shows mostly in the `STALE · cached {age}` stats line. The `get_active_fires` tool reports acquisition times as UTC ISO timestamps, including zero-padded hours and minutes; missing or invalid dates and times remain `null`, including impossible calendar dates from a custom source. diff --git a/src/layers/firms/model.test.mjs b/src/layers/firms/model.test.mjs index 7ff12cf3f..a0df2eb2c 100644 --- a/src/layers/firms/model.test.mjs +++ b/src/layers/firms/model.test.mjs @@ -13,9 +13,11 @@ test('a fire age that rounds to 48 hours reads in days, as 48 hours does', () => try { setLocale('en'); assert.equal(formatAge(47.4 * HOUR_MS), '47h'); + assert.equal(formatAge(47.5 * HOUR_MS), '2d'); assert.equal(formatAge(47.6 * HOUR_MS), '2d'); assert.equal(formatAge(48 * HOUR_MS), '2d'); assert.equal(formatAgeLabel(47.4 * HOUR_MS), '47h'); + assert.equal(formatAgeLabel(47.5 * HOUR_MS), '2d'); assert.equal(formatAgeLabel(47.6 * HOUR_MS), formatAgeLabel(48 * HOUR_MS)); assert.equal(formatAgeLabel(48 * HOUR_MS), '2d'); } finally { From fc6944951b4bb53d64965a0d1930779b4e57e45b Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:35:23 -0800 Subject: [PATCH 4/4] refactor(fires): read both age formatters' unit from one helper formatAge and formatAgeLabel repeated the validity guard, the <1h case, the rounding and the 48-hour switch, which is why the rounding fix had to land twice. Both now read { unit, count } from a private ageParts, so they cannot pick different units again. Output is unchanged. Co-Authored-By: Claude Opus 5.5 --- src/layers/firms/model.js | 36 +++++++++++++++++++++------------ src/layers/firms/model.test.mjs | 19 +++++++++++++++++ 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 4f12593f2..00873c2b2 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -535,15 +535,26 @@ export function createModel({ return `${isolate(latPart)} ${isolate(lonPart)}`; } + /** + * Millisecond delta → `{ unit: '<1h' | 'h' | 'd', count }`, or null for + * invalid input. Both age formatters read it, so they pick the same unit. + */ + + function ageParts(deltaMs) { + if (!Number.isFinite(deltaMs) || deltaMs < 0) return null; + const hours = deltaMs / 3600000; + if (hours < 1) return { unit: '<1h', count: 0 }; + const wholeHours = Math.round(hours); + if (wholeHours < 48) return { unit: 'h', count: wholeHours }; + return { unit: 'd', count: Math.round(hours / 24) }; + } + /** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */ function formatAge(deltaMs) { - if (!Number.isFinite(deltaMs) || deltaMs < 0) return ''; - const hours = deltaMs / 3600000; - if (hours < 1) return '<1h'; - const wholeHours = Math.round(hours); - if (wholeHours < 48) return `${wholeHours}h`; - return `${Math.round(hours / 24)}d`; + const age = ageParts(deltaMs); + if (!age) return ''; + return age.unit === '<1h' ? '<1h' : `${age.count}${age.unit}`; } /** Millisecond delta → "<1m ago" / "Xm ago" / "Xh ago" (fresh-feed readout). */ @@ -574,13 +585,12 @@ export function createModel({ /** formatAge for a card: "<1h", "24h", "3d" in the UI language ("24 ч"). */ function formatAgeLabel(deltaMs) { - if (!Number.isFinite(deltaMs) || deltaMs < 0) return ''; - const hours = deltaMs / 3600000; - if (hours < 1) return t('<1h', { context: 'age' }); - const wholeHours = Math.round(hours); - if (wholeHours < 48) - return t('{count}h', { context: 'age', count: wholeHours }); - return t('{count}d', { context: 'age', count: Math.round(hours / 24) }); + const age = ageParts(deltaMs); + if (!age) return ''; + if (age.unit === '<1h') return t('<1h', { context: 'age' }); + return age.unit === 'h' + ? t('{count}h', { context: 'age', count: age.count }) + : t('{count}d', { context: 'age', count: age.count }); } /** Normalized 0..1 confidence → low/nominal/high display bucket. */ diff --git a/src/layers/firms/model.test.mjs b/src/layers/firms/model.test.mjs index a0df2eb2c..2c42227f8 100644 --- a/src/layers/firms/model.test.mjs +++ b/src/layers/firms/model.test.mjs @@ -24,3 +24,22 @@ test('a fire age that rounds to 48 hours reads in days, as 48 hours does', () => setLocale(previousLocale); } }); + +test('both fire age formatters agree on invalid, sub-hour and multi-day ages', () => { + const previousLocale = getLocale(); + const { formatAge, formatAgeLabel } = createModel({ + services: { anchors: {} }, + }); + try { + setLocale('en'); + for (const format of [formatAge, formatAgeLabel]) { + assert.equal(format(-1), ''); + assert.equal(format(Number.NaN), ''); + assert.equal(format(0.5 * HOUR_MS), '<1h'); + assert.equal(format(1 * HOUR_MS), '1h'); + assert.equal(format(72 * HOUR_MS), '3d'); + } + } finally { + setLocale(previousLocale); + } +});