From e95ae7b80cd2f7b82cfe8f2b7ba83e6b95db286f Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 02:48:53 +0100 Subject: [PATCH] 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. --- server/providers/news.test.mjs | 12 ++- src/layers/news/cards.js | 77 +++++++++++++------ src/layers/news/cards.test.mjs | 120 ++++++++++++++++++++++++++++++ src/layers/news/index.js | 44 +++++++---- src/layers/news/layer.test.mjs | 97 ++++++++++++++++++++---- src/layers/news/model.js | 83 +++++++++++++++------ src/layers/news/model.test.mjs | 126 +++++++++++++++++++++++++++++++- src/layers/news/source.test.mjs | 18 +++-- src/ui/newsPanel.js | 10 ++- src/ui/newsPanel.test.mjs | 59 ++++++++++++++- src/ui/styles/news.css | 4 +- 11 files changed, 560 insertions(+), 90 deletions(-) create mode 100644 src/layers/news/cards.test.mjs diff --git a/server/providers/news.test.mjs b/server/providers/news.test.mjs index 31e3ad4bb..73defce9f 100644 --- a/server/providers/news.test.mjs +++ b/server/providers/news.test.mjs @@ -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); diff --git a/src/layers/news/cards.js b/src/layers/news/cards.js index 75b9932eb..3e5902ee4 100644 --- a/src/layers/news/cards.js +++ b/src/layers/news/cards.js @@ -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; } diff --git a/src/layers/news/cards.test.mjs b/src/layers/news/cards.test.mjs new file mode 100644 index 000000000..82dd03c56 --- /dev/null +++ b/src/layers/news/cards.test.mjs @@ -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); +}); diff --git a/src/layers/news/index.js b/src/layers/news/index.js index 2f8c08f90..c097ab149 100644 --- a/src/layers/news/index.js +++ b/src/layers/news/index.js @@ -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) diff --git a/src/layers/news/layer.test.mjs b/src/layers/news/layer.test.mjs index 5cc49938c..92ebef968 100644 --- a/src/layers/news/layer.test.mjs +++ b/src/layers/news/layer.test.mjs @@ -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'), diff --git a/src/layers/news/model.js b/src/layers/news/model.js index 41fce5ab4..ff26b0fde 100644 --- a/src/layers/news/model.js +++ b/src/layers/news/model.js @@ -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, }; diff --git a/src/layers/news/model.test.mjs b/src/layers/news/model.test.mjs index 39492cc41..88eb0db73 100644 --- a/src/layers/news/model.test.mjs +++ b/src/layers/news/model.test.mjs @@ -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'); diff --git a/src/layers/news/source.test.mjs b/src/layers/news/source.test.mjs index 3f48093ea..c99be800c 100644 --- a/src/layers/news/source.test.mjs +++ b/src/layers/news/source.test.mjs @@ -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(); diff --git a/src/ui/newsPanel.js b/src/ui/newsPanel.js index 78fd6ea24..b32d1d899 100644 --- a/src/ui/newsPanel.js +++ b/src/ui/newsPanel.js @@ -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(); }, diff --git a/src/ui/newsPanel.test.mjs b/src/ui/newsPanel.test.mjs index 71346fdc3..fa63b78fc 100644 --- a/src/ui/newsPanel.test.mjs +++ b/src/ui/newsPanel.test.mjs @@ -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(); +}); diff --git a/src/ui/styles/news.css b/src/ui/styles/news.css index 9b70f2004..e183548e6 100644 --- a/src/ui/styles/news.css +++ b/src/ui/styles/news.css @@ -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 {