Merge pull request #1531 from quanticsoul4772/upstream/firms-age-rounding

fix(fires): show a 47.6-hour-old detection as 2d, not 48h
This commit is contained in:
German Khokhlov authored and GitHub committed 2026-11-11 20:24:53 +04:00
commit aa95c2c25f
4 files changed
+74 -11

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
- 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).
+1
View File
@@ -3053,6 +3053,7 @@ to a set time, naming its end as `at` (epoch ms): the row then reads
`window_end`. The dev proxy never sets `at`.
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"; 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.
+23 -11
View File
@@ -535,14 +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';
if (hours < 48) return `${Math.round(hours)}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). */
@@ -573,12 +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' });
if (hours < 48)
return t('{count}h', { context: 'age', count: Math.round(hours) });
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. */
+45
View File
@@ -0,0 +1,45 @@
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.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 {
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);
}
});