Keep the news layer inside its day and its memory bounds

From review of the news layer:
- An event's other sides older than 24 hours (or undated) are no longer
  listed, and a report dated more than 15 minutes ahead is outside the
  window, as in the regional brief.
- Picture cards are one canvas per event in a bounded cache (24), repainted
  in place when the age changes, with each card's picture dropped with it;
  the age in the key had made a new canvas every minute.
- The feed list drops reports that leave the window and is capped at 600.
- "In view" is filtered from the reports the layer already holds; it reads
  the feed only with minor news on, once per refresh, never per pan.
- Infinite scroll restarts after a filter change or a failed read.
- The two filter selects stack, so "Significant and above" is not cut off.
- The timeout tests hold the event loop until aborted, so they pass on
  Node 22 as well.
This commit is contained in:
Denis Groshev committed 2026-11-06 00:45:25 +01:00
1 parent 138cc77a11
commit e95ae7b80c
11 files changed
+560 -90

No files matched your search

+9 -3
View File
@@ -124,10 +124,16 @@ test('upstream failures, bad JSON and timeouts become bounded errors', async ()
const slow = mount({
origin: () => 'https://news.example',
timeoutMs: 10,
// AbortSignal.timeout does not hold the event loop open: the hanging
// fetch does, until its signal aborts, so the run cannot end before it.
fetchImpl: (_url, { signal }) =>
new Promise((_, reject) =>
signal.addEventListener('abort', () => reject(signal.reason)),
),
new Promise((_, reject) => {
const alive = setTimeout(() => {}, 60_000);
signal.addEventListener('abort', () => {
clearTimeout(alive);
reject(signal.reason);
});
}),
});
const timedOut = await slow('/hotspots');
assert.equal(timedOut.status, 504);
+55 -22
View File
@@ -22,10 +22,23 @@ const SCALE = 2; // canvas pixels per CSS pixel
const TITLE_FONT = '600 11px Inter, "Noto Sans Arabic", system-ui, sans-serif';
const META_FONT = '9px "JetBrains Mono", "Noto Sans Arabic", monospace';
const CREDIT_FONT = '8px Inter, "Noto Sans Arabic", system-ui, sans-serif';
const MAX_CARDS = 200;
// The map shows about ten cards plus the clicked one; a few more stay ready
// for a pan back. The least recently drawn card goes, and its picture with it.
export const MAX_CARDS = 24;
const pictures = new Map(); // url → {image, state: 'loading'|'ok'|'failed', waiters}
const cards = new Map(); // key → canvas
const cards = new Map(); // event id → {canvas, key, view, look, pic}, oldest first
/** Forget a picture no kept card shows any more. */
function releasePicture(url) {
if (!url || [...cards.values()].some((card) => card.view.image === url))
return;
const entry = pictures.get(url);
if (!entry) return;
entry.waiters.clear();
entry.image.onload = entry.image.onerror = null;
pictures.delete(url);
}
function picture(url, onLoad) {
let entry = pictures.get(url);
@@ -176,31 +189,51 @@ function paint(canvas, view, look, pic) {
}
/**
* The canvas of an event's card. `onChange` runs when its picture arrives and
* the canvas has been repainted (ask the scene for a frame).
* The canvas of an event's card, one per event: a change in what it shows
* (the age in its meta line ticks by the minute) repaints the same canvas.
* `onChange` runs when its picture arrives and the canvas has been repainted
* (ask the scene for a frame).
* @returns {HTMLCanvasElement|null} null where there is no canvas
*/
export function eventCard(id, view, look, onChange) {
const key = `${id}|${look.severity}|${look.category}|${look.rim}|${view.image || ''}|${view.title}|${view.meta}`;
if (cards.has(key)) return cards.get(key);
const canvas = globalThis.document?.createElement?.('canvas');
if (
!canvas?.getContext?.('2d') ||
typeof globalThis.Path2D !== 'function' ||
typeof globalThis.Image !== 'function'
)
return null;
canvas.width = CARD_W * SCALE;
canvas.height = CARD_H * SCALE;
const pic = view.image
const key = `${look.severity}|${look.category}|${look.rim}|${view.image || ''}|${view.credit || ''}|${view.title}|${view.meta}`;
let card = cards.get(id);
if (card) {
// Most recently drawn last.
cards.delete(id);
cards.set(id, card);
if (card.key === key) return card.canvas;
} else {
const canvas = globalThis.document?.createElement?.('canvas');
if (
!canvas?.getContext?.('2d') ||
typeof globalThis.Path2D !== 'function' ||
typeof globalThis.Image !== 'function'
)
return null;
canvas.width = CARD_W * SCALE;
canvas.height = CARD_H * SCALE;
card = { canvas, key: null, view: {}, look, pic: null };
cards.set(id, card);
while (cards.size > MAX_CARDS) {
const [oldest, gone] = cards.entries().next().value;
cards.delete(oldest);
releasePicture(gone.view.image);
}
}
const previous = card.view.image;
card.key = key;
card.view = view;
card.look = look;
const shown = card;
card.pic = view.image
? picture(view.image, () => {
paint(canvas, view, look, pic);
if (cards.get(id) !== shown || shown.view.image !== view.image) return;
paint(shown.canvas, shown.view, shown.look, shown.pic);
onChange?.();
})
: null;
paint(canvas, view, look, pic);
// Old cards go once there are many; the newest keys win.
if (cards.size >= MAX_CARDS) cards.delete(cards.keys().next().value);
cards.set(key, canvas);
return canvas;
if (previous && previous !== view.image) releasePicture(previous);
paint(card.canvas, view, look, card.pic);
return card.canvas;
}
+120
View File
@@ -0,0 +1,120 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { MAX_CARDS, eventCard } from './cards.js';
const look = { severity: 3, category: 'strike', rim: 'confirmed', size: 20 };
const view = (extra = {}) => ({
title: 'Strike on Kharkiv',
meta: '10 min · 6 sources · UA, RU',
image: 'https://a.test/p.jpg',
credit: 'A',
...extra,
});
// Node has no canvas: a 2D context that accepts every call, and an Image that
// records what was asked for.
function fakeBrowser(t) {
const saved = {
document: globalThis.document,
Path2D: globalThis.Path2D,
Image: globalThis.Image,
};
const images = [];
const ctx = new Proxy(
{},
{
get: (_target, name) =>
name === 'measureText'
? (text) => ({ width: String(text).length * 5 })
: name === 'createLinearGradient'
? () => ({ addColorStop() {} })
: () => {},
set: () => true,
},
);
let canvases = 0;
globalThis.document = {
createElement: () => {
canvases++;
return { getContext: () => ctx };
},
};
globalThis.Path2D = class {};
globalThis.Image = class {
constructor() {
images.push(this);
}
};
t.after(() => {
for (const [name, value] of Object.entries(saved))
if (value === undefined) delete globalThis[name];
else globalThis[name] = value;
});
return { images, canvases: () => canvases };
}
test('a card keeps one canvas per event while its age ticks', (t) => {
const browser = fakeBrowser(t);
const first = eventCard('ev-a', view(), look);
assert.ok(first);
// The meta line's minute-level age changes: the same canvas, repainted.
const later = eventCard('ev-a', view({ meta: '11 min · 6 sources' }), look);
assert.equal(later, first);
assert.equal(browser.canvases(), 1);
// One picture request for the event, however often it is drawn.
assert.equal(browser.images.length, 1);
// Its picture arriving repaints and asks for a frame once; a card with no
// picture never does.
let frames = 0;
const pictured = eventCard(
'ev-b',
view({ image: 'https://b.test/p.jpg' }),
look,
() => frames++,
);
assert.ok(pictured);
browser.images.at(-1).onload();
assert.equal(frames, 1);
assert.ok(eventCard('ev-c', view({ image: null }), look));
});
test('the card cache is bounded and evicts pictures with their cards', (t) => {
const browser = fakeBrowser(t);
const kept = eventCard('keep', view({ image: 'https://k.test/0.jpg' }), look);
for (let i = 0; i < MAX_CARDS * 3; i++) {
eventCard(`ev-${i}`, view({ image: `https://p.test/${i}.jpg` }), look);
// A card drawn every frame stays: the least recently drawn goes first.
assert.equal(
eventCard('keep', view({ image: 'https://k.test/0.jpg' }), look),
kept,
);
}
const requested = browser.images.length;
// The first event was evicted with its picture: drawing it again paints a
// new canvas and asks for the picture anew.
const again = eventCard(
'ev-0',
view({ image: 'https://p.test/0.jpg' }),
look,
);
assert.ok(again);
assert.equal(browser.images.length, requested + 1);
// Evicted pictures are let go: their load handlers are cleared.
assert.equal(browser.images[1].onload, null);
// The recent ones are still cached.
const recent = MAX_CARDS * 3 - 1;
eventCard(
`ev-${recent}`,
view({ image: `https://p.test/${recent}.jpg` }),
look,
);
assert.equal(browser.images.length, requested + 1);
});
test('without a canvas no card is made and nothing is cached', (t) => {
fakeBrowser(t);
globalThis.document = { createElement: () => ({ getContext: () => null }) };
assert.equal(eventCard('none', view(), look), null);
globalThis.document = undefined;
assert.equal(eventCard('none', view(), look), null);
});
+31 -13
View File
@@ -80,11 +80,13 @@ const emptyFeed = () => ({
loading: false,
error: null,
});
// `minor` holds the day's reports with minor news included, read once while
// that filter is on; otherwise In view uses the map's own reports.
const emptyInView = () => ({
active: false,
cell: null,
centre: null,
reports: [],
minor: null,
loading: false,
error: null,
});
@@ -152,7 +154,7 @@ export function createNewsLayer({
const findReport = (id) =>
mapReports.find((r) => r.id === id) ||
feed.items.find((r) => r.id === id) ||
inView.reports.find((r) => r.id === id) ||
inView.minor?.find((r) => r.id === id) ||
null;
const selected = () => (selectedId ? findReport(selectedId) : null);
const onMap = (report) => visibleOnMap(report, filters, now());
@@ -527,7 +529,7 @@ export function createNewsLayer({
]
.filter(Boolean)
.join(' · '),
sidesLine(report),
sidesLine(report, now()),
attestation(report)?.label || '',
mediaLabels(report).join(' · '),
sourceLinkLabel(report.url),
@@ -824,7 +826,7 @@ export function createNewsLayer({
if (controller.signal.aborted || feedRequest !== controller || !enabled)
return;
feed = {
...mergeFeedPage(feed, page, mode),
...mergeFeedPage(feed, page, mode, now()),
loading: false,
error: null,
};
@@ -852,9 +854,10 @@ export function createNewsLayer({
}
// "In view": below 3,000 km, the reports within 500 km of the view centre,
// read again once the camera has settled for 0.8 s in another half-degree
// cell. The feed has no area query, so the last day's geocoded reports are
// read and kept by distance here.
// placed again once the camera has settled for 0.8 s in another half-degree
// cell. The feed has no area query: the layer already holds the last day's
// geocoded reports (the map's), kept here by distance. Only "Include minor
// news" needs its own read, once per refresh, never per pan.
function scheduleInView() {
clearTimeout(settleTimer);
settleTimer = setTimeout(() => {
@@ -878,10 +881,22 @@ export function createNewsLayer({
if (!centre) return;
const cell = viewCell(centre);
if (!force && inView.active && cell === inView.cell) return;
inView = { ...inView, active: true, cell, centre };
if (!filters.includeMinor) {
inViewRequest?.abort();
inViewRequest = null;
inView = { ...inView, minor: null, loading: false, error: null };
notify();
return;
}
if (!force && (inView.minor || inViewRequest)) {
notify();
return;
}
inViewRequest?.abort();
const controller = new AbortController();
inViewRequest = controller;
inView = { ...inView, active: true, cell, centre, loading: true };
inView = { ...inView, loading: true };
notify();
try {
const page = await source.getFeed(
@@ -890,7 +905,7 @@ export function createNewsLayer({
geo: true,
today: true,
events: true,
relevance: filters.includeMinor ? '' : NEWS_RELEVANT,
relevance: '',
limit: NEWS_MAP_LIMIT,
},
{ signal: controller.signal },
@@ -898,7 +913,7 @@ export function createNewsLayer({
if (controller.signal.aborted || inViewRequest !== controller) return;
inView = {
...inView,
reports: page.reports,
minor: page.reports.filter((r) => withinNewsWindow(r, now())),
loading: false,
error: null,
};
@@ -1004,9 +1019,12 @@ export function createNewsLayer({
active: inView.active,
loading: inView.loading,
error: inView.error ? t(FEED_UNAVAILABLE) : null,
items: reportsInView(inView.reports, inView.centre, filters, at).map(
(r) => feedCardView(r, at),
),
items: reportsInView(
filters.includeMinor ? inView.minor || [] : mapReports,
inView.centre,
filters,
at,
).map((r) => feedCardView(r, at)),
},
selected:
report && withinNewsWindow(report, at)
+81 -16
View File
@@ -38,7 +38,12 @@ const report = (id, extra = {}) => ({
function fixture(
t,
{ source: overrides = {}, height = 20_000_000, reduced = false } = {},
{
source: overrides = {},
height = 20_000_000,
reduced = false,
centre = () => ({ lat: 49, lon: 36 }),
} = {},
) {
const calls = { feed: [], hotspots: 0, outline: 0 };
const reports = [
@@ -98,7 +103,7 @@ function fixture(
overlayHost,
now: () => NOW,
matchMedia: () => ({ matches: reduced }),
getViewCentre: () => ({ lat: 49, lon: 36 }),
getViewCentre: () => centre(),
});
// The context store announces selections on `window`, which Node lacks.
const originalWindow = globalThis.window;
@@ -178,31 +183,40 @@ test('the card toggle and the severity floor change what is shown, not what is r
);
});
test('In view reads again only when the camera settles low in a new cell', async (t) => {
test('In view places the held reports when the camera settles low, with no new read', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { layer, camera, calls } = fixture(t, { height: 1_000_000 });
let centre = { lat: 49, lon: 36 };
const { layer, camera, calls } = fixture(t, {
height: 1_000_000,
centre: () => centre,
});
layer.enable();
await layer.update();
const reads = () => calls.feed.filter((q) => q.limit === 500).length;
const before = reads();
camera.moveEnd.raiseEvent();
t.mock.timers.tick(799);
assert.equal(reads(), before);
t.mock.timers.tick(1);
await Promise.resolve();
await Promise.resolve();
const reads = calls.feed.length;
const view = layer.getRowControls().feed.inView;
assert.equal(view.active, true);
assert.equal(view.loading, false);
// Gaza is beyond 500 km of the view centre; the old report is past 24 h.
assert.deepEqual(
view.items.map((item) => item.id),
['major'],
);
// Same cell: no new read.
const settled = reads();
// Panning to another cell (over Gaza) reads nothing: it reuses the map's
// reports, once the camera has settled.
centre = { lat: 31.5, lon: 34.4 };
camera.moveEnd.raiseEvent();
t.mock.timers.tick(800);
assert.equal(reads(), settled);
t.mock.timers.tick(799);
assert.deepEqual(
layer.getRowControls().feed.inView.items.map((item) => item.id),
['major'],
);
t.mock.timers.tick(1);
await Promise.resolve();
assert.deepEqual(
layer.getRowControls().feed.inView.items.map((item) => item.id),
['far'],
);
assert.equal(calls.feed.length, reads);
// Up above 3,000 km, In view goes away.
camera.positionCartographic.height = 5_000_000;
camera.moveEnd.raiseEvent();
@@ -210,6 +224,57 @@ test('In view reads again only when the camera settles low in a new cell', async
assert.equal(layer.getRowControls().feed.inView.active, false);
});
test('In view with minor news reads once, not per pan; a failed read shows', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let centre = { lat: 49, lon: 36 };
let fail = false;
const minorReads = [];
const { layer, camera } = fixture(t, {
height: 1_000_000,
centre: () => centre,
source: {
getFeed: async (query) => {
if (query.relevance === '' && query.limit === 500) {
minorReads.push(query);
if (fail) throw new Error('boom');
return {
reports: [report('broad', { severity: 2, relevance: 'minor' })],
nextBefore: null,
};
}
return { reports: [], nextBefore: null };
},
},
});
layer.enable();
await layer.update();
camera.moveEnd.raiseEvent();
t.mock.timers.tick(800);
await Promise.resolve();
assert.equal(minorReads.length, 0);
layer.setParams({ filters: { includeMinor: true } });
for (let i = 0; i < 4; i++) await Promise.resolve();
assert.equal(minorReads.length, 1);
assert.deepEqual(
layer.getRowControls().feed.inView.items.map((item) => item.id),
['broad'],
);
centre = { lat: 47, lon: 33 };
camera.moveEnd.raiseEvent();
t.mock.timers.tick(800);
await Promise.resolve();
assert.equal(minorReads.length, 1);
// Turned off and on again with the server failing: the error is shown.
layer.setParams({ filters: { includeMinor: false } });
fail = true;
layer.setParams({ filters: { includeMinor: true } });
for (let i = 0; i < 4; i++) await Promise.resolve();
assert.equal(minorReads.length, 2);
const view = layer.getRowControls().feed.inView;
assert.equal(view.error, 'News feed unavailable');
assert.deepEqual(view.items, []);
});
test('a server without news is unavailable once and is not asked again', async (t) => {
const failure = Object.assign(
new Error('News feed not configured on this server'),
+59 -24
View File
@@ -11,6 +11,11 @@ export const NEWS_FEED_PAGE = 60;
export const NEWS_MAP_LIMIT = 500;
/** News is real time only: nothing older than a day is shown anywhere. */
export const NEWS_WINDOW_MS = 24 * 3_600_000;
// A publish time this far ahead of the clock is skew, not a future story
// (as data/regionalNewsWindow.js).
const NEWS_CLOCK_SKEW_MS = 15 * 60_000;
/** At most this many reports are kept in the feed list, newest first. */
export const NEWS_FEED_MAX = 600;
// Above this camera height the layer shows one beacon per hotspot; below it
// (or once a hotspot is chosen) the outlines and individual reports.
export const NEWS_DETAIL_MAX_HEIGHT_M = 2_500_000;
@@ -202,12 +207,28 @@ export const severityLabel = (report) => t(severityStyle(report).label);
const lastHeard = (report) =>
Date.parse(report?.event?.lastAt || report?.publishedAt || '');
/**
* Whether a time is inside the last 24 hours (the only news shown); one more
* than 15 minutes ahead of the clock is not.
*/
function inWindow(value, now) {
const at = Date.parse(value || '');
return (
Number.isFinite(at) &&
now - at <= NEWS_WINDOW_MS &&
at - now <= NEWS_CLOCK_SKEW_MS
);
}
/** Whether a report is from the last 24 hours (the only news shown). */
export function withinNewsWindow(report, now = Date.now()) {
const at = Date.parse(report?.publishedAt || '');
return Number.isFinite(at) && now - at <= NEWS_WINDOW_MS;
return inWindow(report?.publishedAt, now);
}
/** The event's one-report-per-side list, inside the last 24 hours only. */
const recentSides = (report, now) =>
(report?.event?.sides || []).filter((s) => inWindow(s.publishedAt, now));
/**
* How a report or its event is attested, as a chip: `claimed` (someone's
* claim, named where known), `confirmed` (both sides, or an independent
@@ -380,15 +401,18 @@ export function cardEvents(reports, now = Date.now(), limit = NEWS_CARD_LIMIT) {
.slice(0, limit);
}
/** The distinct sides an event was reported by, lead first, translated. */
function eventSides(report) {
const sides = (report?.event?.sides || []).map((s) => s.side);
/** The distinct sides an event was reported by in the last day, lead first. */
function eventSides(report, now) {
const sides = recentSides(report, now).map((s) => s.side);
return [...new Set(sides.length ? sides : [report?.side].filter(Boolean))];
}
/** "Reported by UA sources · RU sources", or '' when no side is known. */
export function sidesLine(report) {
const sides = eventSides(report).filter((side) => side !== 'unknown');
/**
* "Reported by UA sources · RU sources", or '' when no side is known; an
* unattributed side is left out.
*/
export function sidesLine(report, now = Date.now()) {
const sides = eventSides(report, now).filter((side) => side !== 'unknown');
if (!sides.length) return '';
if (sides.length === 1)
return t(REPORTED_BY_SIDE, { side: sideLabel(sides[0]) });
@@ -409,7 +433,7 @@ export function eventPicture(report) {
/** What an event's picture card on the globe shows. */
export function cardView(report, now = Date.now()) {
const event = report.event;
const sides = eventSides(report).map(sideLabel);
const sides = eventSides(report, now).map(sideLabel);
const picture = eventPicture(report);
return {
title: report.titleEn || report.title,
@@ -477,7 +501,7 @@ export function feedCardView(report, now = Date.now()) {
sources: tn(size, '{count} source', '{count} sources'),
age: ageWords(report.event?.lastAt || report.publishedAt, now),
publisher: report.publisher,
sides: sidesLine(report),
sides: sidesLine(report, now),
sourceLink: sourceLinkLabel(report.url),
picture: eventPicture(report),
chips: [
@@ -490,12 +514,14 @@ export function feedCardView(report, now = Date.now()) {
...mediaLabels(report).map((label) => ({ kind: 'media', label })),
].filter(Boolean),
// The event's first report from each other side, under the lead.
others: (report.event?.sides || []).slice(1).map((s) => ({
side: sideLabel(s.side),
publisher: s.publisher,
title: s.title,
url: s.url,
})),
others: recentSides(report, now)
.slice(1)
.map((s) => ({
side: sideLabel(s.side),
publisher: s.publisher,
title: s.title,
url: s.url,
})),
};
}
@@ -519,11 +545,18 @@ export function reportMarkerPosition(report, pin = 0) {
}
/**
* Merge a page into the feed, newest first, one row per report id.
* `mode` 'head' refreshes the top and keeps the older cursor; 'append' adds an
* older page and takes its cursor; 'replace' starts over.
* Merge a page into the feed, newest first, one row per report id, keeping
* only the last 24 hours and at most `max` rows (a long session does not grow
* the list). `mode` 'head' refreshes the top and keeps the older cursor;
* 'append' adds an older page and takes its cursor; 'replace' starts over.
*/
export function mergeFeedPage(feed, page, mode) {
export function mergeFeedPage(
feed,
page,
mode,
now = Date.now(),
max = NEWS_FEED_MAX,
) {
const byId = new Map();
const items =
mode === 'replace'
@@ -531,11 +564,13 @@ export function mergeFeedPage(feed, page, mode) {
: mode === 'append'
? [...feed.items, ...page.reports]
: [...page.reports, ...feed.items];
for (const item of items) if (!byId.has(item.id)) byId.set(item.id, item);
for (const item of items)
if (!byId.has(item.id) && withinNewsWindow(item, now))
byId.set(item.id, item);
return {
items: [...byId.values()].sort((a, b) =>
b.publishedAt.localeCompare(a.publishedAt),
),
items: [...byId.values()]
.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))
.slice(0, max),
nextBefore:
mode === 'head' && feed.items.length ? feed.nextBefore : page.nextBefore,
};
+123 -3
View File
@@ -61,8 +61,20 @@ const event = (extra = {}) => ({
severity: 3,
lastAt: '2026-10-28T09:30:00Z',
sides: [
{ side: 'UA', title: 'a', url: 'https://a.test/', publisher: 'A' },
{ side: 'RU', title: 'b', url: 'https://b.test/', publisher: 'B' },
{
side: 'UA',
title: 'a',
url: 'https://a.test/',
publisher: 'A',
publishedAt: '2026-10-28T09:00:00Z',
},
{
side: 'RU',
title: 'b',
url: 'https://b.test/',
publisher: 'B',
publishedAt: '2026-10-28T09:20:00Z',
},
],
image: { url: 'https://a.test/p.jpg', publisher: 'A' },
...extra,
@@ -189,12 +201,18 @@ test('feed pages merge newest first without duplicates', () => {
{ items: [] },
{ reports: [a, b], nextBefore: 'B' },
'replace',
NOW,
);
assert.deepEqual(
[feed.items.map((r) => r.id), feed.nextBefore],
[['a', 'b'], 'B'],
);
feed = mergeFeedPage(feed, { reports: [b, c], nextBefore: null }, 'append');
feed = mergeFeedPage(
feed,
{ reports: [b, c], nextBefore: null },
'append',
NOW,
);
assert.deepEqual(
[feed.items.map((r) => r.id), feed.nextBefore],
[['a', 'b', 'c'], null],
@@ -204,6 +222,7 @@ test('feed pages merge newest first without duplicates', () => {
{ ...feed, nextBefore: 'C' },
{ reports: [newer, a], nextBefore: 'X' },
'head',
NOW + 3_600_000,
);
assert.deepEqual(
[feed.items.map((r) => r.id), feed.nextBefore],
@@ -215,6 +234,46 @@ test('feed pages merge newest first without duplicates', () => {
);
});
test('the feed list keeps only the last 24 hours and at most its cap', () => {
const fresh = report('f', '2026-10-28T09:00:00Z');
const stale = report('s', '2026-10-27T09:00:00Z');
const ahead = report('x', '2026-10-28T11:00:00Z');
// A long session: yesterday's rows already held fall out on the next merge.
const feed = mergeFeedPage(
{ items: [stale], nextBefore: 'S' },
{ reports: [fresh, ahead], nextBefore: 'F' },
'head',
NOW,
);
assert.deepEqual(
feed.items.map((r) => r.id),
['f'],
);
const many = Array.from({ length: 30 }, (_, i) =>
report(`r${i}`, new Date(NOW - (i + 1) * 60_000).toISOString()),
);
const capped = mergeFeedPage(
{ items: [] },
{ reports: many, nextBefore: null },
'replace',
NOW,
10,
);
assert.equal(capped.items.length, 10);
assert.equal(capped.items[0].id, 'r0');
assert.equal(capped.items.at(-1).id, 'r9');
// Everything outside the window: an empty list, not an error.
assert.deepEqual(
mergeFeedPage(
{ items: [] },
{ reports: [stale], nextBefore: null },
'replace',
NOW,
).items,
[],
);
});
test('filters default to significant news and map to a last-24-hours query', () => {
const hotspots = [{ id: 'ua' }, { id: 'me' }];
const filters = normalizeFilters(
@@ -252,6 +311,11 @@ test('only the last 24 hours pass, and the severity floor applies', () => {
assert.equal(withinNewsWindow(fresh, NOW), true);
assert.equal(withinNewsWindow(old, NOW), false);
assert.equal(withinNewsWindow({ publishedAt: 'x' }, NOW), false);
// Clock skew up to 15 min ahead passes; further ahead is not news yet.
const skew = report('k', '2026-10-28T10:14:00Z');
const future = report('z', '2026-10-28T10:16:00Z');
assert.equal(withinNewsWindow(skew, NOW), true);
assert.equal(withinNewsWindow(future, NOW), false);
assert.deepEqual(
[fresh, minor, old]
.filter((r) => passesFilters(r, filters, NOW))
@@ -417,6 +481,62 @@ test('feed cards read like Breaking: headline, meta parts, sides and chips', ()
assert.equal(sidesLine({ side: 'unknown' }), '');
});
test('event sides older than a day are left out of the card and its sides line', () => {
const r = report('w', '2026-10-28T09:00:00Z', {
event: event({
sides: [
{
side: 'UA',
title: 'a',
url: 'https://a.test/',
publisher: 'A',
publishedAt: '2026-10-28T08:00:00Z',
},
{
side: 'RU',
title: 'old',
url: 'https://b.test/',
publisher: 'B',
publishedAt: '2026-10-26T08:00:00Z',
},
{
side: 'unknown',
title: 'c',
url: 'https://c.test/',
publisher: 'C',
publishedAt: '2026-10-28T09:10:00Z',
},
// No date: nothing shows it is recent.
{ side: 'IL', title: 'd', url: 'https://d.test/', publisher: 'D' },
],
}),
});
const view = feedCardView(r, NOW);
assert.deepEqual(
view.others.map((o) => [o.side, o.title]),
[['Unattributed', 'c']],
);
// The unattributed side is not named in the sides line.
assert.equal(view.sides, 'Reported by UA sources');
assert.equal(cardView(r, NOW).meta, '30 min · 6 sources · UA, Unattributed');
// All sides outside the window: no others, the report's own side remains.
const stale = report('v', '2026-10-28T09:00:00Z', {
event: event({
sides: [
{
side: 'RU',
title: 'old',
url: 'https://b.test/',
publisher: 'B',
publishedAt: '2026-10-26T08:00:00Z',
},
],
}),
});
assert.deepEqual(feedCardView(stale, NOW).others, []);
assert.equal(feedCardView(stale, NOW).sides, 'Reported by UA sources');
});
test('in view: half-degree cells and reports within 500 km of the centre', () => {
assert.equal(viewCell({ lat: 50.2, lon: 36.7 }), '100:73');
assert.equal(viewCell({ lat: 50.4, lon: 36.9 }), '100:73');
+13 -5
View File
@@ -111,12 +111,20 @@ test('malformed, failed and unconfigured reads reject; a server without news is
});
test('reads time out and follow the caller signal', async () => {
// AbortSignal.timeout does not hold the event loop open: the hanging fetch
// does, until its signal aborts, so the run cannot end before it.
const hang = (_url, { signal }) =>
new Promise((_, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
}),
);
new Promise((_, reject) => {
const alive = setTimeout(() => {}, 60_000);
signal.addEventListener(
'abort',
() => {
clearTimeout(alive);
reject(signal.reason);
},
{ once: true },
);
});
const slow = createNewsSource({ fetchImpl: hang, timeoutMs: 20 });
await assert.rejects(slow.getHotspots(), { name: 'TimeoutError' });
const caller = new AbortController();
+9 -1
View File
@@ -13,7 +13,8 @@ const set = (node, key, value) => {
* The News rail panel (`#news-panel`): it renders the `feed` view the news
* layer publishes through its row controls, shows itself only while the
* layer is on, and acts only through setLayerParams. Near the bottom of its
* scroller it asks for the next older page, once per page.
* scroller it asks for the next older page, once per page; other filters or a
* failed read let it ask again.
*/
export function createNewsPanel({ container, setLayerParams = () => {} } = {}) {
const document = container?.ownerDocument;
@@ -25,6 +26,7 @@ export function createNewsPanel({ container, setLayerParams = () => {} } = {}) {
let view = null;
let destroyed = false;
let requestedAt = -1;
let shownFilters = '';
function render() {
if (destroyed) return;
@@ -59,6 +61,12 @@ export function createNewsPanel({ container, setLayerParams = () => {} } = {}) {
return {
/** Show the layer's feed view, or hide the panel with null. */
update(next) {
const filters = JSON.stringify(next?.filters ?? null);
// A new list (other filters or hotspot), or a read that failed, may stop
// at the same length as the last request: let the next scroll ask.
if (filters !== shownFilters || (next?.error && !view?.error))
requestedAt = -1;
shownFilters = filters;
view = next || null;
render();
},
+57 -2
View File
@@ -37,8 +37,20 @@ const big = report('big', {
status: 'both-sides',
lastAt: '2026-10-28T09:50:00.000Z',
sides: [
{ side: 'UA', title: 'a', url: 'https://a.test/', publisher: 'A' },
{ side: 'RU', title: 'b', url: 'https://b.test/', publisher: 'B' },
{
side: 'UA',
title: 'a',
url: 'https://a.test/',
publisher: 'A',
publishedAt: '2026-10-28T09:00:00.000Z',
},
{
side: 'RU',
title: 'b',
url: 'https://b.test/',
publisher: 'B',
publishedAt: '2026-10-28T09:40:00.000Z',
},
],
image: { url: 'https://a.test/p.jpg', publisher: 'A' },
},
@@ -221,3 +233,46 @@ test('filters, In view and empty or failed reads', () => {
);
news.destroy();
});
test('scrolling to the end asks once per page, again after a filter change or a failed read', () => {
const { news, calls } = fixture();
const body = document.getElementById('news-panel-body');
// A scroller at its bottom.
Object.defineProperty(body, 'scrollHeight', {
value: 1000,
configurable: true,
});
Object.defineProperty(body, 'clientHeight', {
value: 400,
configurable: true,
});
body.scrollTop = 600;
const loadMores = () => calls.filter(([, p]) => p.loadMore === true).length;
const scroll = () => body.dispatchEvent(new Event('scroll'));
news.update(view());
scroll();
scroll();
assert.equal(loadMores(), 1);
// The page came back with nothing new (as many items): no second request.
news.update(view());
scroll();
assert.equal(loadMores(), 1);
// Another hotspot, the same number of rows: the new list can be read on.
const hotspot = normalizeFilters({ hotspot: 'ua-ru-war' }, [
{ id: 'ua-ru-war' },
]);
news.update(view({ filters: hotspot }));
scroll();
assert.equal(loadMores(), 2);
// The read fails: the next scroll tries again.
news.update(view({ filters: hotspot, error: 'News feed unavailable' }));
scroll();
assert.equal(loadMores(), 3);
// While loading, or with no older page, nothing is asked.
news.update(view({ filters: hotspot, loading: true }));
scroll();
news.update(view({ filters: hotspot, hasMore: false }));
scroll();
assert.equal(loadMores(), 3);
news.destroy();
});
+3 -1
View File
@@ -121,9 +121,11 @@
flex-wrap: wrap;
gap: 4px;
}
/* One select per row: at the rail's width two side by side cut off
"Significant and above" (and longer translations). */
.news-readout__filters {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
}
.news-filter {