Show news from the window the server serves

A host may serve fewer hours than the day. The hotspots answer may name its window
(`windowHours`, a whole number from 1 to 24); the layer keeps reports, the
feed, In view and the chosen event inside it, and the panel says
"Last 12 h" or "Last 24 h" accordingly. Without a window named, or with one
out of range, the layer keeps the full day as before.
This commit is contained in:
Denis Groshev committed 2026-11-06 00:45:27 +01:00
1 parent f3e206bf37
commit 60fad38c3f
18 files changed
+178 -73

No files matched your search

+2 -1
View File
@@ -1,5 +1,6 @@
- Add the News layer (Events ▸ News) and its right-rail NEWS panel: the last - Add the News layer (Events ▸ News) and its right-rail NEWS panel: the last
24 hours from curated hotspots, coloured by severity only, with hotspot 24 hours from curated hotspots (or the fewer hours a server names, shown in
the panel), coloured by severity only, with hotspot
beacons, outlines with disputed areas, picture cards for major events, an 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 "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 to the event. Core serves no news itself: set `GEV_NEWS_ORIGIN` to a news
+4 -4
View File
@@ -82,17 +82,17 @@
"{severity} only": "{severity} فقط", "{severity} only": "{severity} فقط",
"All categories": "كل الفئات", "All categories": "كل الفئات",
"Reading the news…": "جارٍ قراءة الأخبار…", "Reading the news…": "جارٍ قراءة الأخبار…",
"News, last 24 hours": "الأخبار، آخر 24 ساعة", "News, last {hours} hours": "الأخبار، آخر {hours} ساعة",
"Last 24 h": "آخر 24 ساعة", "Last {hours} h": "آخر {hours} ساعة",
"Hotspots": "بؤر ساخنة", "Hotspots": "بؤر ساخنة",
"Severity": "الخطورة", "Severity": "الخطورة",
"Category": "الفئة", "Category": "الفئة",
"Include minor news": "تضمين الأخبار الثانوية", "Include minor news": "تضمين الأخبار الثانوية",
"Show event cards": "إظهار بطاقات الأحداث", "Show event cards": "إظهار بطاقات الأحداث",
"In view": "في مجال الرؤية", "In view": "في مجال الرؤية",
"Nothing reported near here in the last 24 h": "لم يُبلَّغ عن شيء قريب من هنا في آخر 24 ساعة", "Nothing reported near here in the last {hours} h": "لم يُبلَّغ عن شيء قريب من هنا في آخر {hours} ساعة",
"Latest news": "آخر الأخبار", "Latest news": "آخر الأخبار",
"No news matches these filters in the last 24 h": "لا أخبار تطابق هذه المرشحات في آخر 24 ساعة", "No news matches these filters in the last {hours} h": "لا أخبار تطابق هذه المرشحات في آخر {hours} ساعة",
"Load older news": "تحميل أخبار أقدم", "Load older news": "تحميل أخبار أقدم",
"How to read the map": "كيف تقرأ الخريطة", "How to read the map": "كيف تقرأ الخريطة",
"Colour: severity · icon: type of event": "اللون: الخطورة · الرمز: نوع الحدث", "Colour: severity · icon: type of event": "اللون: الخطورة · الرمز: نوع الحدث",
+4 -4
View File
@@ -70,17 +70,17 @@
"{severity} only": "Nur {severity}", "{severity} only": "Nur {severity}",
"All categories": "Alle Kategorien", "All categories": "Alle Kategorien",
"Reading the news…": "Nachrichten werden gelesen…", "Reading the news…": "Nachrichten werden gelesen…",
"News, last 24 hours": "Nachrichten der letzten 24 Stunden", "News, last {hours} hours": "Nachrichten der letzten {hours} Stunden",
"Last 24 h": "Letzte 24 Std.", "Last {hours} h": "Letzte {hours} Std.",
"Hotspots": "Brennpunkte", "Hotspots": "Brennpunkte",
"Severity": "Schweregrad", "Severity": "Schweregrad",
"Category": "Kategorie", "Category": "Kategorie",
"Include minor news": "Nebensächliche Meldungen einbeziehen", "Include minor news": "Nebensächliche Meldungen einbeziehen",
"Show event cards": "Ereigniskarten zeigen", "Show event cards": "Ereigniskarten zeigen",
"In view": "Im Blick", "In view": "Im Blick",
"Nothing reported near here in the last 24 h": "In den letzten 24 h nichts in der Nähe gemeldet", "Nothing reported near here in the last {hours} h": "In den letzten {hours} h nichts in der Nähe gemeldet",
"Latest news": "Neueste Nachrichten", "Latest news": "Neueste Nachrichten",
"No news matches these filters in the last 24 h": "Keine Nachrichten der letzten 24 h passen zu diesen Filtern", "No news matches these filters in the last {hours} h": "Keine Nachrichten der letzten {hours} h passen zu diesen Filtern",
"Load older news": "Ältere Nachrichten laden", "Load older news": "Ältere Nachrichten laden",
"How to read the map": "So liest man die Karte", "How to read the map": "So liest man die Karte",
"Colour: severity · icon: type of event": "Farbe: Schweregrad · Symbol: Art des Ereignisses", "Colour: severity · icon: type of event": "Farbe: Schweregrad · Symbol: Art des Ereignisses",
+4 -4
View File
@@ -73,17 +73,17 @@
"{severity} only": "Solo {severity}", "{severity} only": "Solo {severity}",
"All categories": "Todas las categorías", "All categories": "Todas las categorías",
"Reading the news…": "Leyendo las noticias…", "Reading the news…": "Leyendo las noticias…",
"News, last 24 hours": "Noticias de las últimas 24 horas", "News, last {hours} hours": "Noticias de las últimas {hours} horas",
"Last 24 h": "Últimas 24 h", "Last {hours} h": "Últimas {hours} h",
"Hotspots": "Focos", "Hotspots": "Focos",
"Severity": "Gravedad", "Severity": "Gravedad",
"Category": "Categoría", "Category": "Categoría",
"Include minor news": "Incluir noticias menores", "Include minor news": "Incluir noticias menores",
"Show event cards": "Mostrar tarjetas de sucesos", "Show event cards": "Mostrar tarjetas de sucesos",
"In view": "A la vista", "In view": "A la vista",
"Nothing reported near here in the last 24 h": "Nada informado cerca de aquí en las últimas 24 h", "Nothing reported near here in the last {hours} h": "Nada informado cerca de aquí en las últimas {hours} h",
"Latest news": "Últimas noticias", "Latest news": "Últimas noticias",
"No news matches these filters in the last 24 h": "Ninguna noticia de las últimas 24 h coincide con estos filtros", "No news matches these filters in the last {hours} h": "Ninguna noticia de las últimas {hours} h coincide con estos filtros",
"Load older news": "Cargar noticias anteriores", "Load older news": "Cargar noticias anteriores",
"How to read the map": "Cómo leer el mapa", "How to read the map": "Cómo leer el mapa",
"Colour: severity · icon: type of event": "Color: gravedad · icono: tipo de suceso", "Colour: severity · icon: type of event": "Color: gravedad · icono: tipo de suceso",
+4 -4
View File
@@ -73,17 +73,17 @@
"{severity} only": "{severity} uniquement", "{severity} only": "{severity} uniquement",
"All categories": "Toutes les catégories", "All categories": "Toutes les catégories",
"Reading the news…": "Lecture des actualités…", "Reading the news…": "Lecture des actualités…",
"News, last 24 hours": "Actualités des dernières 24 heures", "News, last {hours} hours": "Actualités des dernières {hours} heures",
"Last 24 h": "Dernières 24 h", "Last {hours} h": "Dernières {hours} h",
"Hotspots": "Foyers", "Hotspots": "Foyers",
"Severity": "Gravité", "Severity": "Gravité",
"Category": "Catégorie", "Category": "Catégorie",
"Include minor news": "Inclure les actualités secondaires", "Include minor news": "Inclure les actualités secondaires",
"Show event cards": "Afficher les cartes d’événement", "Show event cards": "Afficher les cartes d’événement",
"In view": "Dans la vue", "In view": "Dans la vue",
"Nothing reported near here in the last 24 h": "Rien de signalé près d’ici ces dernières 24 h", "Nothing reported near here in the last {hours} h": "Rien de signalé près d’ici ces dernières {hours} h",
"Latest news": "Dernières actualités", "Latest news": "Dernières actualités",
"No news matches these filters in the last 24 h": "Aucune actualité des dernières 24 h ne correspond à ces filtres", "No news matches these filters in the last {hours} h": "Aucune actualité des dernières {hours} h ne correspond à ces filtres",
"Load older news": "Charger des actualités plus anciennes", "Load older news": "Charger des actualités plus anciennes",
"How to read the map": "Comment lire la carte", "How to read the map": "Comment lire la carte",
"Colour: severity · icon: type of event": "Couleur : gravité · icône : type d’événement", "Colour: severity · icon: type of event": "Couleur : gravité · icône : type d’événement",
+4 -4
View File
@@ -73,17 +73,17 @@
"{severity} only": "Solo {severity}", "{severity} only": "Solo {severity}",
"All categories": "Tutte le categorie", "All categories": "Tutte le categorie",
"Reading the news…": "Lettura delle notizie…", "Reading the news…": "Lettura delle notizie…",
"News, last 24 hours": "Notizie delle ultime 24 ore", "News, last {hours} hours": "Notizie delle ultime {hours} ore",
"Last 24 h": "Ultime 24 h", "Last {hours} h": "Ultime {hours} h",
"Hotspots": "Focolai", "Hotspots": "Focolai",
"Severity": "Gravità", "Severity": "Gravità",
"Category": "Categoria", "Category": "Categoria",
"Include minor news": "Includi notizie minori", "Include minor news": "Includi notizie minori",
"Show event cards": "Mostra le schede degli eventi", "Show event cards": "Mostra le schede degli eventi",
"In view": "In vista", "In view": "In vista",
"Nothing reported near here in the last 24 h": "Nulla segnalato qui vicino nelle ultime 24 h", "Nothing reported near here in the last {hours} h": "Nulla segnalato qui vicino nelle ultime {hours} h",
"Latest news": "Ultime notizie", "Latest news": "Ultime notizie",
"No news matches these filters in the last 24 h": "Nessuna notizia delle ultime 24 h corrisponde a questi filtri", "No news matches these filters in the last {hours} h": "Nessuna notizia delle ultime {hours} h corrisponde a questi filtri",
"Load older news": "Carica notizie precedenti", "Load older news": "Carica notizie precedenti",
"How to read the map": "Come leggere la mappa", "How to read the map": "Come leggere la mappa",
"Colour: severity · icon: type of event": "Colore: gravità · icona: tipo di evento", "Colour: severity · icon: type of event": "Colore: gravità · icona: tipo di evento",
+4 -4
View File
@@ -67,17 +67,17 @@
"{severity} only": "{severity}のみ", "{severity} only": "{severity}のみ",
"All categories": "すべてのカテゴリ", "All categories": "すべてのカテゴリ",
"Reading the news…": "ニュースを読み込み中…", "Reading the news…": "ニュースを読み込み中…",
"News, last 24 hours": "直近24時間のニュース", "News, last {hours} hours": "直近{hours}時間のニュース",
"Last 24 h": "直近24時間", "Last {hours} h": "直近{hours}時間",
"Hotspots": "ホットスポット", "Hotspots": "ホットスポット",
"Severity": "深刻度", "Severity": "深刻度",
"Category": "カテゴリ", "Category": "カテゴリ",
"Include minor news": "軽微なニュースも含める", "Include minor news": "軽微なニュースも含める",
"Show event cards": "イベントカードを表示", "Show event cards": "イベントカードを表示",
"In view": "表示範囲内", "In view": "表示範囲内",
"Nothing reported near here in the last 24 h": "直近24時間、この付近の報道はありません", "Nothing reported near here in the last {hours} h": "直近{hours}時間、この付近の報道はありません",
"Latest news": "最新ニュース", "Latest news": "最新ニュース",
"No news matches these filters in the last 24 h": "直近24時間で条件に合うニュースはありません", "No news matches these filters in the last {hours} h": "直近{hours}時間で条件に合うニュースはありません",
"Load older news": "以前のニュースを読み込む", "Load older news": "以前のニュースを読み込む",
"How to read the map": "地図の見方", "How to read the map": "地図の見方",
"Colour: severity · icon: type of event": "色: 深刻度 · アイコン: 出来事の種類", "Colour: severity · icon: type of event": "色: 深刻度 · アイコン: 出来事の種類",
+4 -4
View File
@@ -73,17 +73,17 @@
"{severity} only": "Apenas {severity}", "{severity} only": "Apenas {severity}",
"All categories": "Todas as categorias", "All categories": "Todas as categorias",
"Reading the news…": "Lendo as notícias…", "Reading the news…": "Lendo as notícias…",
"News, last 24 hours": "Notícias das últimas 24 horas", "News, last {hours} hours": "Notícias das últimas {hours} horas",
"Last 24 h": "Últimas 24 h", "Last {hours} h": "Últimas {hours} h",
"Hotspots": "Focos", "Hotspots": "Focos",
"Severity": "Gravidade", "Severity": "Gravidade",
"Category": "Categoria", "Category": "Categoria",
"Include minor news": "Incluir notícias menores", "Include minor news": "Incluir notícias menores",
"Show event cards": "Mostrar cartões de eventos", "Show event cards": "Mostrar cartões de eventos",
"In view": "Na vista", "In view": "Na vista",
"Nothing reported near here in the last 24 h": "Nada noticiado aqui perto nas últimas 24 h", "Nothing reported near here in the last {hours} h": "Nada noticiado aqui perto nas últimas {hours} h",
"Latest news": "Últimas notícias", "Latest news": "Últimas notícias",
"No news matches these filters in the last 24 h": "Nenhuma notícia das últimas 24 h corresponde a estes filtros", "No news matches these filters in the last {hours} h": "Nenhuma notícia das últimas {hours} h corresponde a estes filtros",
"Load older news": "Carregar notícias anteriores", "Load older news": "Carregar notícias anteriores",
"How to read the map": "Como ler o mapa", "How to read the map": "Como ler o mapa",
"Colour: severity · icon: type of event": "Cor: gravidade · ícone: tipo de evento", "Colour: severity · icon: type of event": "Cor: gravidade · ícone: tipo de evento",
+4 -4
View File
@@ -76,17 +76,17 @@
"{severity} only": "Только {severity}", "{severity} only": "Только {severity}",
"All categories": "Все категории", "All categories": "Все категории",
"Reading the news…": "Читаем новости…", "Reading the news…": "Читаем новости…",
"News, last 24 hours": "Новости за последние 24 часа", "News, last {hours} hours": "Новости за последние {hours} ч",
"Last 24 h": "За 24 ч", "Last {hours} h": "За {hours} ч",
"Hotspots": "Горячие точки", "Hotspots": "Горячие точки",
"Severity": "Тяжесть", "Severity": "Тяжесть",
"Category": "Категория", "Category": "Категория",
"Include minor news": "Показывать второстепенные новости", "Include minor news": "Показывать второстепенные новости",
"Show event cards": "Показывать карточки событий", "Show event cards": "Показывать карточки событий",
"In view": "В поле зрения", "In view": "В поле зрения",
"Nothing reported near here in the last 24 h": "За последние 24 ч поблизости ничего не сообщалось", "Nothing reported near here in the last {hours} h": "За последние {hours} ч поблизости ничего не сообщалось",
"Latest news": "Последние новости", "Latest news": "Последние новости",
"No news matches these filters in the last 24 h": "За последние 24 ч нет новостей по этим фильтрам", "No news matches these filters in the last {hours} h": "За последние {hours} ч нет новостей по этим фильтрам",
"Load older news": "Загрузить более ранние новости", "Load older news": "Загрузить более ранние новости",
"How to read the map": "Как читать карту", "How to read the map": "Как читать карту",
"Colour: severity · icon: type of event": "Цвет: тяжесть · значок: тип события", "Colour: severity · icon: type of event": "Цвет: тяжесть · значок: тип события",
+4 -4
View File
@@ -76,17 +76,17 @@
"{severity} only": "Лише {severity}", "{severity} only": "Лише {severity}",
"All categories": "Усі категорії", "All categories": "Усі категорії",
"Reading the news…": "Читаємо новини…", "Reading the news…": "Читаємо новини…",
"News, last 24 hours": "Новини за останні 24 години", "News, last {hours} hours": "Новини за останні {hours} год",
"Last 24 h": "За 24 год", "Last {hours} h": "За {hours} год",
"Hotspots": "Гарячі точки", "Hotspots": "Гарячі точки",
"Severity": "Тяжкість", "Severity": "Тяжкість",
"Category": "Категорія", "Category": "Категорія",
"Include minor news": "Показувати другорядні новини", "Include minor news": "Показувати другорядні новини",
"Show event cards": "Показувати картки подій", "Show event cards": "Показувати картки подій",
"In view": "У полі зору", "In view": "У полі зору",
"Nothing reported near here in the last 24 h": "За останні 24 год поблизу нічого не повідомлялося", "Nothing reported near here in the last {hours} h": "За останні {hours} год поблизу нічого не повідомлялося",
"Latest news": "Останні новини", "Latest news": "Останні новини",
"No news matches these filters in the last 24 h": "За останні 24 год немає новин за цими фільтрами", "No news matches these filters in the last {hours} h": "За останні {hours} год немає новин за цими фільтрами",
"Load older news": "Завантажити давніші новини", "Load older news": "Завантажити давніші новини",
"How to read the map": "Як читати мапу", "How to read the map": "Як читати мапу",
"Colour: severity · icon: type of event": "Колір: тяжкість · значок: тип події", "Colour: severity · icon: type of event": "Колір: тяжкість · значок: тип події",
+4 -4
View File
@@ -67,17 +67,17 @@
"{severity} only": "仅{severity}", "{severity} only": "仅{severity}",
"All categories": "所有类别", "All categories": "所有类别",
"Reading the news…": "正在读取新闻…", "Reading the news…": "正在读取新闻…",
"News, last 24 hours": "最近 24 小时的新闻", "News, last {hours} hours": "最近 {hours} 小时的新闻",
"Last 24 h": "最近 24 小时", "Last {hours} h": "最近 {hours} 小时",
"Hotspots": "热点", "Hotspots": "热点",
"Severity": "严重程度", "Severity": "严重程度",
"Category": "类别", "Category": "类别",
"Include minor news": "包括次要新闻", "Include minor news": "包括次要新闻",
"Show event cards": "显示事件卡片", "Show event cards": "显示事件卡片",
"In view": "视野内", "In view": "视野内",
"Nothing reported near here in the last 24 h": "最近 24 小时附近无报道", "Nothing reported near here in the last {hours} h": "最近 {hours} 小时附近无报道",
"Latest news": "最新新闻", "Latest news": "最新新闻",
"No news matches these filters in the last 24 h": "最近 24 小时没有符合这些筛选条件的新闻", "No news matches these filters in the last {hours} h": "最近 {hours} 小时没有符合这些筛选条件的新闻",
"Load older news": "加载更早的新闻", "Load older news": "加载更早的新闻",
"How to read the map": "如何解读地图", "How to read the map": "如何解读地图",
"Colour: severity · icon: type of event": "颜色:严重程度 · 图标:事件类型", "Colour: severity · icon: type of event": "颜色:严重程度 · 图标:事件类型",
+4 -4
View File
@@ -67,17 +67,17 @@
"{severity} only": "僅{severity}", "{severity} only": "僅{severity}",
"All categories": "所有類別", "All categories": "所有類別",
"Reading the news…": "正在讀取新聞…", "Reading the news…": "正在讀取新聞…",
"News, last 24 hours": "最近 24 小時的新聞", "News, last {hours} hours": "最近 {hours} 小時的新聞",
"Last 24 h": "最近 24 小時", "Last {hours} h": "最近 {hours} 小時",
"Hotspots": "熱點", "Hotspots": "熱點",
"Severity": "嚴重程度", "Severity": "嚴重程度",
"Category": "類別", "Category": "類別",
"Include minor news": "包括次要新聞", "Include minor news": "包括次要新聞",
"Show event cards": "顯示事件卡片", "Show event cards": "顯示事件卡片",
"In view": "視野內", "In view": "視野內",
"Nothing reported near here in the last 24 h": "最近 24 小時附近無報導", "Nothing reported near here in the last {hours} h": "最近 {hours} 小時附近無報導",
"Latest news": "最新新聞", "Latest news": "最新新聞",
"No news matches these filters in the last 24 h": "最近 24 小時沒有符合這些篩選條件的新聞", "No news matches these filters in the last {hours} h": "最近 {hours} 小時沒有符合這些篩選條件的新聞",
"Load older news": "載入更早的新聞", "Load older news": "載入更早的新聞",
"How to read the map": "如何解讀地圖", "How to read the map": "如何解讀地圖",
"Colour: severity · icon: type of event": "顏色:嚴重程度 · 圖示:事件類型", "Colour: severity · icon: type of event": "顏色:嚴重程度 · 圖示:事件類型",
+18 -7
View File
@@ -29,6 +29,7 @@ import {
NEWS_RELEVANT, NEWS_RELEVANT,
NEWS_SETTLE_MS, NEWS_SETTLE_MS,
NEWS_SEVERITY, NEWS_SEVERITY,
NEWS_WINDOW_MS,
ageWords, ageWords,
attestation, attestation,
beaconPixelSize, beaconPixelSize,
@@ -44,6 +45,7 @@ import {
markerStyle, markerStyle,
mediaLabels, mediaLabels,
mergeFeedPage, mergeFeedPage,
newsWindowMs,
normalizeFilters, normalizeFilters,
passesFilters, passesFilters,
reportMarkerPosition, reportMarkerPosition,
@@ -139,6 +141,9 @@ export function createNewsLayer({
lastRunAt = null, lastRunAt = null,
autoPages = 0; autoPages = 0;
let hotspots = []; let hotspots = [];
// The window the server serves (newsWindowMs): a host may serve
// fewer hours than the day.
let windowMs = NEWS_WINDOW_MS;
let mapReports = []; let mapReports = [];
let feed = emptyFeed(); let feed = emptyFeed();
let inView = emptyInView(); let inView = emptyInView();
@@ -159,7 +164,7 @@ export function createNewsLayer({
inView.minor?.find((r) => r.id === id) || inView.minor?.find((r) => r.id === id) ||
null; null;
const selected = () => (selectedId ? findReport(selectedId) : null); const selected = () => (selectedId ? findReport(selectedId) : null);
const onMap = (report) => visibleOnMap(report, filters, now()); const onMap = (report) => visibleOnMap(report, filters, now(), windowMs);
function markerCartesian(report, pin = 0) { function markerCartesian(report, pin = 0) {
const { lat, lon } = reportMarkerPosition(report, pin); const { lat, lon } = reportMarkerPosition(report, pin);
@@ -806,7 +811,7 @@ export function createNewsLayer({
} }
const shownCount = () => const shownCount = () =>
feed.items.filter((r) => passesFilters(r, filters, now())).length; feed.items.filter((r) => passesFilters(r, filters, now(), windowMs)).length;
async function loadFeed(mode) { async function loadFeed(mode) {
if (!enabled || unavailable) return; if (!enabled || unavailable) return;
@@ -828,7 +833,7 @@ export function createNewsLayer({
if (controller.signal.aborted || feedRequest !== controller || !enabled) if (controller.signal.aborted || feedRequest !== controller || !enabled)
return; return;
feed = { feed = {
...mergeFeedPage(feed, page, mode, now()), ...mergeFeedPage(feed, page, mode, now(), undefined, windowMs),
loading: false, loading: false,
error: null, error: null,
}; };
@@ -915,7 +920,7 @@ export function createNewsLayer({
if (controller.signal.aborted || inViewRequest !== controller) return; if (controller.signal.aborted || inViewRequest !== controller) return;
inView = { inView = {
...inView, ...inView,
minor: page.reports.filter((r) => withinNewsWindow(r, now())), minor: page.reports.filter((r) => withinNewsWindow(r, now(), windowMs)),
loading: false, loading: false,
error: null, error: null,
}; };
@@ -1015,7 +1020,7 @@ export function createNewsLayer({
color: level.color, color: level.color,
})), })),
items: feed.items items: feed.items
.filter((r) => passesFilters(r, filters, at)) .filter((r) => passesFilters(r, filters, at, windowMs))
.map((r) => feedCardView(r, at)), .map((r) => feedCardView(r, at)),
inView: { inView: {
active: inView.active, active: inView.active,
@@ -1026,10 +1031,12 @@ export function createNewsLayer({
inView.centre, inView.centre,
filters, filters,
at, at,
undefined,
windowMs,
).map((r) => feedCardView(r, at)), ).map((r) => feedCardView(r, at)),
}, },
selected: selected:
report && withinNewsWindow(report, at) report && withinNewsWindow(report, at, windowMs)
? feedCardView(report, at) ? feedCardView(report, at)
: null, : null,
hasMore: Boolean(feed.nextBefore), hasMore: Boolean(feed.nextBefore),
@@ -1037,6 +1044,7 @@ export function createNewsLayer({
// Read failures are English data; the panel shows them translated. // Read failures are English data; the panel shows them translated.
error: error || feed.error ? t(error || feed.error) : null, error: error || feed.error ? t(error || feed.error) : null,
lastRunAt, lastRunAt,
windowHours: windowMs / 3_600_000,
}; };
} }
@@ -1149,7 +1157,10 @@ export function createNewsLayer({
hotspots = index.hotspots; hotspots = index.hotspots;
loadOutlines(); loadOutlines();
lastRunAt = index.lastRunAt; lastRunAt = index.lastRunAt;
mapReports = todays.reports.filter((r) => withinNewsWindow(r, now())); windowMs = newsWindowMs(index.windowHours);
mapReports = todays.reports.filter((r) =>
withinNewsWindow(r, now(), windowMs),
);
filters = normalizeFilters({}, hotspots, filters); filters = normalizeFilters({}, hotspots, filters);
error = null; error = null;
lastUpdate = now(); lastUpdate = now();
+42 -15
View File
@@ -11,6 +11,16 @@ export const NEWS_FEED_PAGE = 60;
export const NEWS_MAP_LIMIT = 500; export const NEWS_MAP_LIMIT = 500;
/** News is real time only: nothing older than a day is shown anywhere. */ /** News is real time only: nothing older than a day is shown anywhere. */
export const NEWS_WINDOW_MS = 24 * 3_600_000; export const NEWS_WINDOW_MS = 24 * 3_600_000;
/**
* The window a server serves, in ms: its `windowHours` (a whole number of
* hours up to 24; a host may serve fewer), else the full day.
*/
export function newsWindowMs(hours) {
return Number.isInteger(hours) && hours >= 1 && hours <= 24
? hours * 3_600_000
: NEWS_WINDOW_MS;
}
// A publish time this far ahead of the clock is skew, not a future story // A publish time this far ahead of the clock is skew, not a future story
// (as data/regionalNewsWindow.js). // (as data/regionalNewsWindow.js).
const NEWS_CLOCK_SKEW_MS = 15 * 60_000; const NEWS_CLOCK_SKEW_MS = 15 * 60_000;
@@ -210,21 +220,26 @@ const lastHeard = (report) =>
Date.parse(report?.event?.lastAt || report?.publishedAt || ''); Date.parse(report?.event?.lastAt || report?.publishedAt || '');
/** /**
* Whether a time is inside the last 24 hours (the only news shown); one more * Whether a time is inside the window (the last 24 hours unless the server
* than 15 minutes ahead of the clock is not. * serves fewer; the only news shown); one more than 15 minutes ahead of the
* clock is not.
*/ */
function inWindow(value, now) { function inWindow(value, now, windowMs = NEWS_WINDOW_MS) {
const at = Date.parse(value || ''); const at = Date.parse(value || '');
return ( return (
Number.isFinite(at) && Number.isFinite(at) &&
now - at <= NEWS_WINDOW_MS && now - at <= windowMs &&
at - now <= NEWS_CLOCK_SKEW_MS at - now <= NEWS_CLOCK_SKEW_MS
); );
} }
/** Whether a report is from the last 24 hours (the only news shown). */ /** Whether a report is inside the window (the only news shown). */
export function withinNewsWindow(report, now = Date.now()) { export function withinNewsWindow(
return inWindow(report?.publishedAt, now); report,
now = Date.now(),
windowMs = NEWS_WINDOW_MS,
) {
return inWindow(report?.publishedAt, now, windowMs);
} }
/** The event's one-report-per-side list, inside the last 24 hours only. */ /** The event's one-report-per-side list, inside the last 24 hours only. */
@@ -548,7 +563,7 @@ export function reportMarkerPosition(report, pin = 0) {
/** /**
* Merge a page into the feed, newest first, one row per report id, keeping * 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 * only the window and at most `max` rows (a long session does not grow
* the list). `mode` 'head' refreshes the top and keeps the older cursor; * the list). `mode` 'head' refreshes the top and keeps the older cursor;
* 'append' adds an older page and takes its cursor; 'replace' starts over. * 'append' adds an older page and takes its cursor; 'replace' starts over.
*/ */
@@ -558,6 +573,7 @@ export function mergeFeedPage(
mode, mode,
now = Date.now(), now = Date.now(),
max = NEWS_FEED_MAX, max = NEWS_FEED_MAX,
windowMs = NEWS_WINDOW_MS,
) { ) {
const byId = new Map(); const byId = new Map();
const items = const items =
@@ -567,7 +583,7 @@ export function mergeFeedPage(
? [...feed.items, ...page.reports] ? [...feed.items, ...page.reports]
: [...page.reports, ...feed.items]; : [...page.reports, ...feed.items];
for (const item of items) for (const item of items)
if (!byId.has(item.id) && withinNewsWindow(item, now)) if (!byId.has(item.id) && withinNewsWindow(item, now, windowMs))
byId.set(item.id, item); byId.set(item.id, item);
return { return {
items: [...byId.values()] items: [...byId.values()]
@@ -604,24 +620,34 @@ export function normalizeFilters(next = {}, hotspots = [], previous = {}) {
} }
/** Whether a report passes the filters the server does not apply. */ /** Whether a report passes the filters the server does not apply. */
export function passesFilters(report, filters, now = Date.now()) { export function passesFilters(
report,
filters,
now = Date.now(),
windowMs = NEWS_WINDOW_MS,
) {
return ( return (
withinNewsWindow(report, now) && withinNewsWindow(report, now, windowMs) &&
reportSeverity(report) >= filters.minSeverity reportSeverity(report) >= filters.minSeverity
); );
} }
/** Whether a report belongs on the map under the current filters. */ /** Whether a report belongs on the map under the current filters. */
export function visibleOnMap(report, filters, now = Date.now()) { export function visibleOnMap(
report,
filters,
now = Date.now(),
windowMs = NEWS_WINDOW_MS,
) {
return ( return (
(filters.hotspot === 'all' || (filters.hotspot === 'all' ||
(report.hotspotIds || [report.hotspotId]).includes(filters.hotspot)) && (report.hotspotIds || [report.hotspotId]).includes(filters.hotspot)) &&
(filters.category === 'all' || report.category === filters.category) && (filters.category === 'all' || report.category === filters.category) &&
passesFilters(report, filters, now) passesFilters(report, filters, now, windowMs)
); );
} }
/** API query for the feed under the current filters: always the last 24 h. */ /** API query for the feed under the current filters: the window only. */
export function feedQueryFor(filters, extra = {}) { export function feedQueryFor(filters, extra = {}) {
return { return {
events: true, events: true,
@@ -660,12 +686,13 @@ export function reportsInView(
filters, filters,
now = Date.now(), now = Date.now(),
limit = NEWS_IN_VIEW_LIMIT, limit = NEWS_IN_VIEW_LIMIT,
windowMs = NEWS_WINDOW_MS,
) { ) {
if (!centre) return []; if (!centre) return [];
return reports return reports
.filter( .filter(
(r) => (r) =>
passesFilters(r, filters, now) && passesFilters(r, filters, now, windowMs) &&
(filters.category === 'all' || r.category === filters.category) && (filters.category === 'all' || r.category === filters.category) &&
r.pins?.some( r.pins?.some(
(pin) => distanceKm(pin, centre) <= NEWS_IN_VIEW_RADIUS_KM, (pin) => distanceKm(pin, centre) <= NEWS_IN_VIEW_RADIUS_KM,
+38
View File
@@ -20,6 +20,7 @@ import {
isPressing, isPressing,
markerStyle, markerStyle,
mergeFeedPage, mergeFeedPage,
newsWindowMs,
normalizeFilters, normalizeFilters,
passesFilters, passesFilters,
reportMarkerPosition, reportMarkerPosition,
@@ -562,3 +563,40 @@ test('in view: half-degree cells and reports within 500 km of the centre', () =>
assert.deepEqual(reportsInView([near], null, filters, NOW), []); assert.deepEqual(reportsInView([near], null, filters, NOW), []);
assert.deepEqual(reportsInView([], centre, filters, NOW), []); assert.deepEqual(reportsInView([], centre, filters, NOW), []);
}); });
test('a server may serve fewer hours than the day, never more', () => {
assert.equal(newsWindowMs(12), 12 * 3_600_000);
assert.equal(newsWindowMs(24), 24 * 3_600_000);
for (const bad of [0, 25, 12.5, '12', null, undefined])
assert.equal(newsWindowMs(bad), 24 * 3_600_000);
const page = {
reports: [
report('fresh', '2026-10-28T02:00:00Z'),
report('old', '2026-10-27T20:00:00Z'),
],
nextBefore: null,
};
const empty = { items: [], nextBefore: null };
const ids = (windowMs) =>
mergeFeedPage(empty, page, 'replace', NOW, undefined, windowMs).items.map(
(r) => r.id,
);
assert.deepEqual(ids(newsWindowMs(12)), ['fresh']);
assert.deepEqual(ids(newsWindowMs(24)), ['fresh', 'old']);
});
test('the hotspots answer carries the window it was counted over', () => {
const base = {
hotspots: [],
ingest: { lastRunAt: '2026-10-28T09:00:00Z' },
};
assert.equal(
normalizeHotspotsPayload({ ...base, windowHours: 12 }).windowHours,
12,
);
assert.equal(normalizeHotspotsPayload(base).windowHours, null);
assert.equal(
normalizeHotspotsPayload({ ...base, windowHours: 48 }).windowHours,
null,
);
});
+8 -1
View File
@@ -96,7 +96,8 @@ function normalizeDeveloping(items) {
} }
/** /**
* `/api/news/hotspots` → `{hotspots, categories, lastRunAt}`, or null when the * `/api/news/hotspots` → `{hotspots, categories, lastRunAt, windowHours}`
* (`windowHours` null when the server names none), or null when the
* payload has no hotspot list. * payload has no hotspot list.
*/ */
export function normalizeHotspotsPayload(payload) { export function normalizeHotspotsPayload(payload) {
@@ -135,6 +136,12 @@ export function normalizeHotspotsPayload(payload) {
) )
: [], : [],
lastRunAt: iso(payload.ingest?.lastRunAt), lastRunAt: iso(payload.ingest?.lastRunAt),
windowHours:
Number.isInteger(payload.windowHours) &&
payload.windowHours >= 1 &&
payload.windowHours <= 24
? payload.windowHours
: null,
}; };
} }
+16 -5
View File
@@ -7,7 +7,8 @@ import UiSelect from '../kit/UiSelect.js';
import RailChips from '../rail/RailChips.vue'; import RailChips from '../rail/RailChips.vue';
import NewsCard from './NewsCard.vue'; import NewsCard from './NewsCard.vue';
// The News panel: the last 24 hours only. Hotspot chips, filters, the chosen // 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
// event, "In view" (reports near the view centre while the camera is low) // 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 // and a continuous feed, newest first. It renders the `feed` view the news
// layer publishes and acts only through `onParams`. // layer publishes and acts only through `onParams`.
@@ -75,9 +76,11 @@ const clear = () => props.onParams({ clear: true });
<section <section
v-if="view" v-if="view"
class="news-readout" class="news-readout"
:aria-label="t('News, last 24 hours')" :aria-label="t('News, last {hours} hours', { hours: view.windowHours })"
> >
<p class="news-readout__window ui-mono">{{ t('Last 24 h') }}</p> <p 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')"> <div class="news-readout__chips" role="group" :aria-label="t('Hotspots')">
<RailChips <RailChips
:chips="chips" :chips="chips"
@@ -136,7 +139,11 @@ const clear = () => props.onParams({ clear: true });
<UiEmpty <UiEmpty
v-else-if="!view.inView.items.length && !view.inView.loading" v-else-if="!view.inView.items.length && !view.inView.loading"
class="news-readout__empty" class="news-readout__empty"
:title="t('Nothing reported near here in the last 24 h')" :title="
t('Nothing reported near here in the last {hours} h', {
hours: view.windowHours,
})
"
/> />
<ol <ol
v-else v-else
@@ -173,7 +180,11 @@ const clear = () => props.onParams({ clear: true });
<UiEmpty <UiEmpty
v-else-if="!view.loading && !view.error" v-else-if="!view.loading && !view.error"
class="news-readout__empty" class="news-readout__empty"
:title="t('No news matches these filters in the last 24 h')" :title="
t('No news matches these filters in the last {hours} h', {
hours: view.windowHours,
})
"
/> />
<div class="news-readout__footer"> <div class="news-readout__footer">
<span class="news-readout__status" role="status">{{ status }}</span> <span class="news-readout__status" role="status">{{ status }}</span>
+10
View File
@@ -76,6 +76,7 @@ function view(extra = {}) {
inView: { active: false, loading: false, error: null, items: [] }, inView: { active: false, loading: false, error: null, items: [] },
selected: null, selected: null,
hasMore: true, hasMore: true,
windowHours: 24,
loading: false, loading: false,
error: null, error: null,
...extra, ...extra,
@@ -276,3 +277,12 @@ test('scrolling to the end asks once per page, again after a filter change or a
assert.equal(loadMores(), 3); assert.equal(loadMores(), 3);
news.destroy(); news.destroy();
}); });
test('the panel names the window the server serves', () => {
const { news, panel } = fixture();
news.update(view({ windowHours: 12 }));
assert.equal(
panel.querySelector('.news-readout__window').textContent,
'Last 12 h',
);
});