Let the News panel narrow the window to 12, 6, 3 or 1 hours

The panel's window label becomes a choice of the server's window and each
shorter one. The feed and map filter to it locally; the hotspot counts are
read again for it (/api/news/hotspots?hours=N, now on the allowlist). A
server names the longest window it serves as maxWindowHours.
This commit is contained in:
Denis Groshev committed 2026-11-06 00:45:28 +01:00
1 parent ee3593cfbd
commit 4817d2a648
22 files changed
+198 -21

No files matched your search

+3 -2
View File
@@ -1,6 +1,7 @@
- Add the News layer (Events ▸ News) and its right-rail NEWS panel: the last
24 hours from curated hotspots (or the fewer hours a server names, shown in
the panel), coloured by severity only, with hotspot
24 hours from curated hotspots (or the fewer hours a server names), narrowed
in the panel to the last 12, 6, 3 or 1 hours with the hotspot counts read for
that window, coloured by severity only, with hotspot
beacons, outlines with disputed areas, picture cards for major events, an
"In view" list below 3,000 km and a continuous feed whose cards fly the globe
to the event. Core serves no news itself: set `GEV_NEWS_ORIGIN` to a news
+1
View File
@@ -84,6 +84,7 @@
"Reading the news…": "جارٍ قراءة الأخبار…",
"News, last {hours} hours": "الأخبار، آخر {hours} ساعة",
"Last {hours} h": "آخر {hours} ساعة",
"Time window": "الفترة الزمنية",
"Hotspots": "بؤر ساخنة",
"Severity": "الخطورة",
"Category": "الفئة",
+1
View File
@@ -72,6 +72,7 @@
"Reading the news…": "Nachrichten werden gelesen…",
"News, last {hours} hours": "Nachrichten der letzten {hours} Stunden",
"Last {hours} h": "Letzte {hours} Std.",
"Time window": "Zeitraum",
"Hotspots": "Brennpunkte",
"Severity": "Schweregrad",
"Category": "Kategorie",
+1
View File
@@ -75,6 +75,7 @@
"Reading the news…": "Leyendo las noticias…",
"News, last {hours} hours": "Noticias de las últimas {hours} horas",
"Last {hours} h": "Últimas {hours} h",
"Time window": "Periodo",
"Hotspots": "Focos",
"Severity": "Gravedad",
"Category": "Categoría",
+1
View File
@@ -75,6 +75,7 @@
"Reading the news…": "Lecture des actualités…",
"News, last {hours} hours": "Actualités des dernières {hours} heures",
"Last {hours} h": "Dernières {hours} h",
"Time window": "Période",
"Hotspots": "Foyers",
"Severity": "Gravité",
"Category": "Catégorie",
+1
View File
@@ -75,6 +75,7 @@
"Reading the news…": "Lettura delle notizie…",
"News, last {hours} hours": "Notizie delle ultime {hours} ore",
"Last {hours} h": "Ultime {hours} h",
"Time window": "Periodo",
"Hotspots": "Focolai",
"Severity": "Gravità",
"Category": "Categoria",
+1
View File
@@ -69,6 +69,7 @@
"Reading the news…": "ニュースを読み込み中…",
"News, last {hours} hours": "直近{hours}時間のニュース",
"Last {hours} h": "直近{hours}時間",
"Time window": "期間",
"Hotspots": "ホットスポット",
"Severity": "深刻度",
"Category": "カテゴリ",
+1
View File
@@ -75,6 +75,7 @@
"Reading the news…": "Lendo as notícias…",
"News, last {hours} hours": "Notícias das últimas {hours} horas",
"Last {hours} h": "Últimas {hours} h",
"Time window": "Período",
"Hotspots": "Focos",
"Severity": "Gravidade",
"Category": "Categoria",
+1
View File
@@ -78,6 +78,7 @@
"Reading the news…": "Читаем новости…",
"News, last {hours} hours": "Новости за последние {hours} ч",
"Last {hours} h": "За {hours} ч",
"Time window": "Период",
"Hotspots": "Горячие точки",
"Severity": "Тяжесть",
"Category": "Категория",
+1
View File
@@ -78,6 +78,7 @@
"Reading the news…": "Читаємо новини…",
"News, last {hours} hours": "Новини за останні {hours} год",
"Last {hours} h": "За {hours} год",
"Time window": "Період",
"Hotspots": "Гарячі точки",
"Severity": "Тяжкість",
"Category": "Категорія",
+1
View File
@@ -69,6 +69,7 @@
"Reading the news…": "正在读取新闻…",
"News, last {hours} hours": "最近 {hours} 小时的新闻",
"Last {hours} h": "最近 {hours} 小时",
"Time window": "时间范围",
"Hotspots": "热点",
"Severity": "严重程度",
"Category": "类别",
+1
View File
@@ -69,6 +69,7 @@
"Reading the news…": "正在讀取新聞…",
"News, last {hours} hours": "最近 {hours} 小時的新聞",
"Last {hours} h": "最近 {hours} 小時",
"Time window": "時間範圍",
"Hotspots": "熱點",
"Severity": "嚴重程度",
"Category": "類別",
+30 -5
View File
@@ -45,6 +45,7 @@ import {
markerStyle,
mediaLabels,
mergeFeedPage,
newsWindowChoices,
newsWindowMs,
normalizeFilters,
passesFilters,
@@ -142,7 +143,9 @@ export function createNewsLayer({
autoPages = 0;
let hotspots = [];
// The window the server serves (newsWindowMs): a host may serve
// fewer hours than the day.
// fewer hours than the day. `windowMs` is the one shown: that window, or a
// shorter one chosen in the panel (filters.hours).
let maxWindowMs = NEWS_WINDOW_MS;
let windowMs = NEWS_WINDOW_MS;
let mapReports = [];
let feed = emptyFeed();
@@ -165,6 +168,11 @@ export function createNewsLayer({
null;
const selected = () => (selectedId ? findReport(selectedId) : null);
const onMap = (report) => visibleOnMap(report, filters, now(), windowMs);
const shownWindowMs = () =>
Math.min(maxWindowMs, (filters.hours ?? Infinity) * 3_600_000);
// The hours to ask the hotspot counts for, when shorter than the server's.
const askedHours = () =>
windowMs < maxWindowMs ? windowMs / 3_600_000 : undefined;
function markerCartesian(report, pin = 0) {
const { lat, lon } = reportMarkerPosition(report, pin);
@@ -920,7 +928,9 @@ export function createNewsLayer({
if (controller.signal.aborted || inViewRequest !== controller) return;
inView = {
...inView,
minor: page.reports.filter((r) => withinNewsWindow(r, now(), windowMs)),
minor: page.reports.filter((r) =>
withinNewsWindow(r, now(), maxWindowMs),
),
loading: false,
error: null,
};
@@ -1045,6 +1055,7 @@ export function createNewsLayer({
error: error || feed.error ? t(error || feed.error) : null,
lastRunAt,
windowHours: windowMs / 3_600_000,
windowChoices: newsWindowChoices(maxWindowMs / 3_600_000),
};
}
@@ -1139,7 +1150,10 @@ export function createNewsLayer({
notify();
try {
const [index, todays] = await Promise.all([
source.getHotspots({ signal: controller.signal }),
source.getHotspots({
signal: controller.signal,
hours: askedHours(),
}),
source.getFeed(
{
hotspot: 'all',
@@ -1157,9 +1171,16 @@ export function createNewsLayer({
hotspots = index.hotspots;
loadOutlines();
lastRunAt = index.lastRunAt;
windowMs = newsWindowMs(index.windowHours);
// A server that names no limit serves the window its counts cover
// when asked for none.
maxWindowMs = newsWindowMs(
index.maxWindowHours ??
(askedHours() ? maxWindowMs / 3_600_000 : index.windowHours),
);
windowMs = shownWindowMs();
// Kept for the server's whole window, so a longer choice needs no read.
mapReports = todays.reports.filter((r) =>
withinNewsWindow(r, now(), windowMs),
withinNewsWindow(r, now(), maxWindowMs),
);
filters = normalizeFilters({}, hotspots, filters);
error = null;
@@ -1196,6 +1217,8 @@ export function createNewsLayer({
const reread = readKey(next) !== readKey(filters);
const minorChanged = next.includeMinor !== filters.includeMinor;
filters = next;
const previousWindowMs = windowMs;
windowMs = shownWindowMs();
if (
Object.hasOwn(params.filters, 'hotspot') &&
next.hotspot !== 'all'
@@ -1208,6 +1231,8 @@ export function createNewsLayer({
render();
if (reread) void loadFeed('replace');
if (minorChanged) void refreshInView({ force: true });
// The hotspot counts are the server's, so a new window reads them again.
if (windowMs !== previousWindowMs) void layer.update(viewer);
notify();
}
if (params.loadMore === true) void loadFeed('append');
+47
View File
@@ -275,6 +275,53 @@ test('In view with minor news reads once, not per pan; a failed read shows', asy
assert.deepEqual(view.items, []);
});
test('a shorter window narrows the feed and reads the hotspot counts for it', async (t) => {
const asked = [];
const { layer } = fixture(t, {
source: {
getHotspots: async ({ hours } = {}) => {
asked.push(hours);
return {
...hotspotsPayload,
windowHours: hours ?? 12,
maxWindowHours: 12,
};
},
getFeed: async () => ({
reports: [
report('recent', { publishedAt: '2026-10-28T09:30:00.000Z' }),
report('earlier', { publishedAt: '2026-10-28T05:00:00.000Z' }),
],
nextBefore: null,
}),
},
});
layer.enable();
await layer.update();
await settle();
let view = layer.getRowControls().feed;
assert.deepEqual(view.windowChoices, [12, 6, 3, 1]);
assert.equal(view.windowHours, 12);
assert.deepEqual(
view.items.map((item) => item.id),
['recent', 'earlier'],
);
layer.setParams({ filters: { hours: 3 } });
view = layer.getRowControls().feed;
assert.equal(view.windowHours, 3);
assert.deepEqual(
view.items.map((item) => item.id),
['recent'],
);
for (let i = 0; i < 3; i++) await settle();
assert.deepEqual(asked, [undefined, 3]);
// The server's own window still bounds the choices.
assert.deepEqual(layer.getRowControls().feed.windowChoices, [12, 6, 3, 1]);
// A choice longer than the server serves shows all it serves.
layer.setParams({ filters: { hours: 24 } });
assert.equal(layer.getRowControls().feed.windowHours, 12);
});
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'),
+14
View File
@@ -21,6 +21,18 @@ export function newsWindowMs(hours) {
? hours * 3_600_000
: NEWS_WINDOW_MS;
}
/** The windows the News panel offers, longest first. */
export const NEWS_WINDOW_CHOICES = Object.freeze([24, 12, 6, 3, 1]);
/**
* The windows offered under a server's own window of `maxHours`: that window
* itself, then each shorter choice.
*/
export function newsWindowChoices(maxHours) {
const cap = newsWindowMs(maxHours) / 3_600_000;
return [cap, ...NEWS_WINDOW_CHOICES.filter((hours) => hours < cap)];
}
// 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;
@@ -616,6 +628,8 @@ export function normalizeFilters(next = {}, hotspots = [], previous = {}) {
includeMinor: merged.includeMinor === true,
// Event cards on the map; off leaves only the clicked event's card.
cards: merged.cards !== false,
// A shorter window than the server's; null shows all it serves.
hours: NEWS_WINDOW_CHOICES.includes(merged.hours) ? merged.hours : null,
};
}
+19
View File
@@ -20,6 +20,7 @@ import {
isPressing,
markerStyle,
mergeFeedPage,
newsWindowChoices,
newsWindowMs,
normalizeFilters,
passesFilters,
@@ -287,8 +288,11 @@ test('filters default to significant news and map to a last-24-hours query', ()
minSeverity: 2,
includeMinor: false,
cards: true,
hours: null,
});
assert.equal(normalizeFilters({ cards: false }, hotspots).cards, false);
assert.equal(normalizeFilters({ hours: 6 }, hotspots).hours, 6);
assert.equal(normalizeFilters({ hours: 5 }, hotspots).hours, null);
assert.equal(normalizeFilters({ minSeverity: 0 }, hotspots).minSeverity, 0);
assert.equal(normalizeFilters({ hotspot: 'gone' }, hotspots).hotspot, 'all');
assert.equal(
@@ -599,6 +603,21 @@ test('the hotspots answer carries the window it was counted over', () => {
normalizeHotspotsPayload({ ...base, windowHours: 48 }).windowHours,
null,
);
const shorter = normalizeHotspotsPayload({
...base,
windowHours: 3,
maxWindowHours: 12,
});
assert.deepEqual([shorter.windowHours, shorter.maxWindowHours], [3, 12]);
assert.equal(normalizeHotspotsPayload(base).maxWindowHours, null);
});
test('the window choices stop at the window the server serves', () => {
assert.deepEqual(newsWindowChoices(24), [24, 12, 6, 3, 1]);
assert.deepEqual(newsWindowChoices(12), [12, 6, 3, 1]);
assert.deepEqual(newsWindowChoices(8), [8, 6, 3, 1]);
assert.deepEqual(newsWindowChoices(1), [1]);
assert.deepEqual(newsWindowChoices(null), [24, 12, 6, 3, 1]);
});
test('a checked severity keeps saying who set it', () => {
+10 -9
View File
@@ -95,10 +95,15 @@ function normalizeDeveloping(items) {
return out;
}
const hoursOrNull = (hours) =>
Number.isInteger(hours) && hours >= 1 && hours <= 24 ? hours : null;
/**
* `/api/news/hotspots` → `{hotspots, categories, lastRunAt, windowHours}`
* (`windowHours` null when the server names none), or null when the
* payload has no hotspot list.
* `/api/news/hotspots` → `{hotspots, categories, lastRunAt, windowHours,
* maxWindowHours}`, or null when the payload has no hotspot list.
* `windowHours` is the window the counts cover and `maxWindowHours` the
* longest the server serves this reader; each is null when the server names
* none.
*/
export function normalizeHotspotsPayload(payload) {
if (!Array.isArray(payload?.hotspots)) return null;
@@ -136,12 +141,8 @@ export function normalizeHotspotsPayload(payload) {
)
: [],
lastRunAt: iso(payload.ingest?.lastRunAt),
windowHours:
Number.isInteger(payload.windowHours) &&
payload.windowHours >= 1 &&
payload.windowHours <= 24
? payload.windowHours
: null,
windowHours: hoursOrNull(payload.windowHours),
maxWindowHours: hoursOrNull(payload.maxWindowHours),
};
}
+14 -3
View File
@@ -63,7 +63,15 @@ export function newsFeedQuery(query = {}) {
export function newsQueryAllowed(url) {
const params = [...url.searchParams];
if (new Set(params.map(([key]) => key)).size !== params.length) return false;
if (url.pathname === `${BASE_PATH}/hotspots`) return params.length === 0;
if (url.pathname === `${BASE_PATH}/hotspots`)
return (
params.length === 0 ||
(params.length === 1 &&
params[0][0] === 'hours' &&
/^\d{1,2}$/.test(params[0][1]) &&
Number(params[0][1]) >= 1 &&
Number(params[0][1]) <= 24)
);
if (url.pathname === `${BASE_PATH}/outline`) {
const keys = params.map(([key]) => key).sort();
const value = url.searchParams;
@@ -128,9 +136,12 @@ export function createNewsSource({
return readResponseJsonCapped(response, MAX_BYTES, request);
}
return {
async getHotspots({ signal } = {}) {
// `hours` asks for counts over a shorter window than the server's own.
async getHotspots({ signal, hours } = {}) {
const payload = normalizeHotspotsPayload(
await get('/hotspots', { signal }),
await get(hours ? `/hotspots?hours=${hours}` : '/hotspots', {
signal,
}),
);
if (!payload) throw new Error(msg('Malformed news hotspots response'));
return payload;
+7
View File
@@ -53,6 +53,7 @@ test('reads validated hotspots, feed and outlines from the same-origin routes',
);
assert.deepEqual((await source.getOutline('ua')).polygons, []);
await source.getCountryOutline('SD');
await source.getHotspots({ hours: 6 });
assert.deepEqual(
calls.map((c) => c.url),
[
@@ -60,6 +61,7 @@ test('reads validated hotspots, feed and outlines from the same-origin routes',
'/api/news/feed?hotspot=all&limit=60&today=1',
'/api/news/outline?hotspot=ua&v=4',
'/api/news/outline?country=SD&v=4',
'/api/news/hotspots?hours=6',
],
);
for (const { url, init } of calls) {
@@ -155,7 +157,12 @@ test('the host allowlist admits the client routes and refuses anything else', ()
),
true,
);
assert.equal(allowed('/api/news/hotspots?hours=6'), true);
for (const path of [
'/api/news/hotspots?hours=0',
'/api/news/hotspots?hours=25',
'/api/news/hotspots?hours=6&hours=3',
'/api/news/hotspots?hours=6&x=1',
'/api/news/hotspots?x=1',
'/api/news/feed?limit=501',
'/api/news/feed?limit=0',
+20 -2
View File
@@ -8,7 +8,8 @@ import RailChips from '../rail/RailChips.vue';
import NewsCard from './NewsCard.vue';
// The News panel: the window the server serves only (the last 24 hours, or
// fewer where the host serves less). Hotspot chips, filters, the chosen
// fewer where the host serves less), or a shorter one chosen at the
// top (24/12/6/3/1 h). Hotspot chips, filters, the chosen
// event, "In view" (reports near the view centre while the camera is low)
// and a continuous feed, newest first. It renders the `feed` view the news
// layer publishes and acts only through `onParams`.
@@ -44,6 +45,15 @@ const categoryOptions = computed(() => [
{ value: 'all', label: t('All categories') },
...(props.view?.categories ?? []),
]);
const windowOptions = computed(() =>
(props.view?.windowChoices ?? []).map((hours) => ({
value: String(hours),
label: t('Last {hours} h', { hours }),
})),
);
// The longest choice is the server's whole window, kept as no choice.
const chooseWindow = (hours) =>
setFilter('hours', hours === props.view?.windowChoices?.[0] ? null : hours);
const selectedId = computed(() => props.view?.selected?.id ?? null);
// The chosen event leads the panel unless the feed already shows it open.
const selectedApart = computed(() => {
@@ -78,7 +88,15 @@ const clear = () => props.onParams({ clear: true });
class="news-readout"
:aria-label="t('News, last {hours} hours', { hours: view.windowHours })"
>
<p class="news-readout__window ui-mono">{{
<UiSelect
v-if="windowOptions.length > 1"
class="gev-field news-filter news-readout__window-choice"
:aria-label="t('Time window')"
:model-value="String(view.windowHours)"
:options="windowOptions"
@update:model-value="chooseWindow(Number($event))"
/>
<p v-else class="news-readout__window ui-mono">{{
t('Last {hours} h', { hours: view.windowHours })
}}</p>
<div class="news-readout__chips" role="group" :aria-label="t('Hotspots')">
+18
View File
@@ -278,6 +278,24 @@ test('scrolling to the end asks once per page, again after a filter change or a
news.destroy();
});
test('the window choice offers shorter windows; the longest is the server window', () => {
const { news, panel, calls } = fixture();
news.update(view({ windowHours: 12, windowChoices: [12, 6, 3, 1] }));
assert.equal(panel.querySelector('.news-readout__window'), null);
const choice = panel.querySelector('select[aria-label="Time window"]');
assert.deepEqual(
[...choice.options].map((option) => option.textContent),
['Last 12 h', 'Last 6 h', 'Last 3 h', 'Last 1 h'],
);
assert.equal(choice.value, '12');
choice.value = '3';
choice.dispatchEvent(new Event('change'));
assert.deepEqual(calls.at(-1)[1], { filters: { hours: 3 } });
choice.value = '12';
choice.dispatchEvent(new Event('change'));
assert.deepEqual(calls.at(-1)[1], { filters: { hours: null } });
});
test('the panel names the window the server serves', () => {
const { news, panel } = fixture();
news.update(view({ windowHours: 12 }));
+5
View File
@@ -131,6 +131,11 @@
.news-filter {
min-width: 0;
}
/* The window choice sits where the window label does, as narrow as its text. */
.news-readout__window-choice {
align-self: flex-start;
font-family: var(--font-mono);
}
.news-toggle {
display: flex;
align-items: center;