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

+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;