diff --git a/changelog.d/1-news-layer.md b/changelog.d/1-news-layer.md index 081316b9d..8915beafa 100644 --- a/changelog.d/1-news-layer.md +++ b/changelog.d/1-news-layer.md @@ -1,5 +1,6 @@ - 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 "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 diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index f779fc9e9..a03ffc9b2 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -82,17 +82,17 @@ "{severity} only": "{severity} فقط", "All categories": "كل الفئات", "Reading the news…": "جارٍ قراءة الأخبار…", - "News, last 24 hours": "الأخبار، آخر 24 ساعة", - "Last 24 h": "آخر 24 ساعة", + "News, last {hours} hours": "الأخبار، آخر {hours} ساعة", + "Last {hours} h": "آخر {hours} ساعة", "Hotspots": "بؤر ساخنة", "Severity": "الخطورة", "Category": "الفئة", "Include minor news": "تضمين الأخبار الثانوية", "Show event cards": "إظهار بطاقات الأحداث", "In view": "في مجال الرؤية", - "Nothing reported near here in the last 24 h": "لم يُبلَّغ عن شيء قريب من هنا في آخر 24 ساعة", + "Nothing reported near here in the last {hours} h": "لم يُبلَّغ عن شيء قريب من هنا في آخر {hours} ساعة", "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": "تحميل أخبار أقدم", "How to read the map": "كيف تقرأ الخريطة", "Colour: severity · icon: type of event": "اللون: الخطورة · الرمز: نوع الحدث", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 43b913524..e773191b7 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -70,17 +70,17 @@ "{severity} only": "Nur {severity}", "All categories": "Alle Kategorien", "Reading the news…": "Nachrichten werden gelesen…", - "News, last 24 hours": "Nachrichten der letzten 24 Stunden", - "Last 24 h": "Letzte 24 Std.", + "News, last {hours} hours": "Nachrichten der letzten {hours} Stunden", + "Last {hours} h": "Letzte {hours} Std.", "Hotspots": "Brennpunkte", "Severity": "Schweregrad", "Category": "Kategorie", "Include minor news": "Nebensächliche Meldungen einbeziehen", "Show event cards": "Ereigniskarten zeigen", "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", - "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", "How to read the map": "So liest man die Karte", "Colour: severity · icon: type of event": "Farbe: Schweregrad · Symbol: Art des Ereignisses", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ba4fa99a9..f0cbb5cbd 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -73,17 +73,17 @@ "{severity} only": "Solo {severity}", "All categories": "Todas las categorías", "Reading the news…": "Leyendo las noticias…", - "News, last 24 hours": "Noticias de las últimas 24 horas", - "Last 24 h": "Últimas 24 h", + "News, last {hours} hours": "Noticias de las últimas {hours} horas", + "Last {hours} h": "Últimas {hours} h", "Hotspots": "Focos", "Severity": "Gravedad", "Category": "Categoría", "Include minor news": "Incluir noticias menores", "Show event cards": "Mostrar tarjetas de sucesos", "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", - "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", "How to read the map": "Cómo leer el mapa", "Colour: severity · icon: type of event": "Color: gravedad · icono: tipo de suceso", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index aae0bbb7d..60329833c 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -73,17 +73,17 @@ "{severity} only": "{severity} uniquement", "All categories": "Toutes les catégories", "Reading the news…": "Lecture des actualités…", - "News, last 24 hours": "Actualités des dernières 24 heures", - "Last 24 h": "Dernières 24 h", + "News, last {hours} hours": "Actualités des dernières {hours} heures", + "Last {hours} h": "Dernières {hours} h", "Hotspots": "Foyers", "Severity": "Gravité", "Category": "Catégorie", "Include minor news": "Inclure les actualités secondaires", "Show event cards": "Afficher les cartes d’événement", "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", - "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", "How to read the map": "Comment lire la carte", "Colour: severity · icon: type of event": "Couleur : gravité · icône : type d’événement", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index cb9c27098..92a597e40 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -73,17 +73,17 @@ "{severity} only": "Solo {severity}", "All categories": "Tutte le categorie", "Reading the news…": "Lettura delle notizie…", - "News, last 24 hours": "Notizie delle ultime 24 ore", - "Last 24 h": "Ultime 24 h", + "News, last {hours} hours": "Notizie delle ultime {hours} ore", + "Last {hours} h": "Ultime {hours} h", "Hotspots": "Focolai", "Severity": "Gravità", "Category": "Categoria", "Include minor news": "Includi notizie minori", "Show event cards": "Mostra le schede degli eventi", "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", - "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", "How to read the map": "Come leggere la mappa", "Colour: severity · icon: type of event": "Colore: gravità · icona: tipo di evento", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index fc62c1703..67a102e67 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -67,17 +67,17 @@ "{severity} only": "{severity}のみ", "All categories": "すべてのカテゴリ", "Reading the news…": "ニュースを読み込み中…", - "News, last 24 hours": "直近24時間のニュース", - "Last 24 h": "直近24時間", + "News, last {hours} hours": "直近{hours}時間のニュース", + "Last {hours} h": "直近{hours}時間", "Hotspots": "ホットスポット", "Severity": "深刻度", "Category": "カテゴリ", "Include minor news": "軽微なニュースも含める", "Show event cards": "イベントカードを表示", "In view": "表示範囲内", - "Nothing reported near here in the last 24 h": "直近24時間、この付近の報道はありません", + "Nothing reported near here in the last {hours} h": "直近{hours}時間、この付近の報道はありません", "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": "以前のニュースを読み込む", "How to read the map": "地図の見方", "Colour: severity · icon: type of event": "色: 深刻度 · アイコン: 出来事の種類", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index a5da05da8..d66890fa9 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -73,17 +73,17 @@ "{severity} only": "Apenas {severity}", "All categories": "Todas as categorias", "Reading the news…": "Lendo as notícias…", - "News, last 24 hours": "Notícias das últimas 24 horas", - "Last 24 h": "Últimas 24 h", + "News, last {hours} hours": "Notícias das últimas {hours} horas", + "Last {hours} h": "Últimas {hours} h", "Hotspots": "Focos", "Severity": "Gravidade", "Category": "Categoria", "Include minor news": "Incluir notícias menores", "Show event cards": "Mostrar cartões de eventos", "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", - "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", "How to read the map": "Como ler o mapa", "Colour: severity · icon: type of event": "Cor: gravidade · ícone: tipo de evento", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 2725ef272..74a39f5be 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -76,17 +76,17 @@ "{severity} only": "Только {severity}", "All categories": "Все категории", "Reading the news…": "Читаем новости…", - "News, last 24 hours": "Новости за последние 24 часа", - "Last 24 h": "За 24 ч", + "News, last {hours} hours": "Новости за последние {hours} ч", + "Last {hours} h": "За {hours} ч", "Hotspots": "Горячие точки", "Severity": "Тяжесть", "Category": "Категория", "Include minor news": "Показывать второстепенные новости", "Show event cards": "Показывать карточки событий", "In view": "В поле зрения", - "Nothing reported near here in the last 24 h": "За последние 24 ч поблизости ничего не сообщалось", + "Nothing reported near here in the last {hours} h": "За последние {hours} ч поблизости ничего не сообщалось", "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": "Загрузить более ранние новости", "How to read the map": "Как читать карту", "Colour: severity · icon: type of event": "Цвет: тяжесть · значок: тип события", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 77aa0f593..7125396c0 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -76,17 +76,17 @@ "{severity} only": "Лише {severity}", "All categories": "Усі категорії", "Reading the news…": "Читаємо новини…", - "News, last 24 hours": "Новини за останні 24 години", - "Last 24 h": "За 24 год", + "News, last {hours} hours": "Новини за останні {hours} год", + "Last {hours} h": "За {hours} год", "Hotspots": "Гарячі точки", "Severity": "Тяжкість", "Category": "Категорія", "Include minor news": "Показувати другорядні новини", "Show event cards": "Показувати картки подій", "In view": "У полі зору", - "Nothing reported near here in the last 24 h": "За останні 24 год поблизу нічого не повідомлялося", + "Nothing reported near here in the last {hours} h": "За останні {hours} год поблизу нічого не повідомлялося", "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": "Завантажити давніші новини", "How to read the map": "Як читати мапу", "Colour: severity · icon: type of event": "Колір: тяжкість · значок: тип події", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 0b398051d..654189a6f 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -67,17 +67,17 @@ "{severity} only": "仅{severity}", "All categories": "所有类别", "Reading the news…": "正在读取新闻…", - "News, last 24 hours": "最近 24 小时的新闻", - "Last 24 h": "最近 24 小时", + "News, last {hours} hours": "最近 {hours} 小时的新闻", + "Last {hours} h": "最近 {hours} 小时", "Hotspots": "热点", "Severity": "严重程度", "Category": "类别", "Include minor news": "包括次要新闻", "Show event cards": "显示事件卡片", "In view": "视野内", - "Nothing reported near here in the last 24 h": "最近 24 小时附近无报道", + "Nothing reported near here in the last {hours} h": "最近 {hours} 小时附近无报道", "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": "加载更早的新闻", "How to read the map": "如何解读地图", "Colour: severity · icon: type of event": "颜色:严重程度 · 图标:事件类型", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index a89b6ba22..f18e75d07 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -67,17 +67,17 @@ "{severity} only": "僅{severity}", "All categories": "所有類別", "Reading the news…": "正在讀取新聞…", - "News, last 24 hours": "最近 24 小時的新聞", - "Last 24 h": "最近 24 小時", + "News, last {hours} hours": "最近 {hours} 小時的新聞", + "Last {hours} h": "最近 {hours} 小時", "Hotspots": "熱點", "Severity": "嚴重程度", "Category": "類別", "Include minor news": "包括次要新聞", "Show event cards": "顯示事件卡片", "In view": "視野內", - "Nothing reported near here in the last 24 h": "最近 24 小時附近無報導", + "Nothing reported near here in the last {hours} h": "最近 {hours} 小時附近無報導", "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": "載入更早的新聞", "How to read the map": "如何解讀地圖", "Colour: severity · icon: type of event": "顏色:嚴重程度 · 圖示:事件類型", diff --git a/src/layers/news/index.js b/src/layers/news/index.js index 7e70d652b..21450bd55 100644 --- a/src/layers/news/index.js +++ b/src/layers/news/index.js @@ -29,6 +29,7 @@ import { NEWS_RELEVANT, NEWS_SETTLE_MS, NEWS_SEVERITY, + NEWS_WINDOW_MS, ageWords, attestation, beaconPixelSize, @@ -44,6 +45,7 @@ import { markerStyle, mediaLabels, mergeFeedPage, + newsWindowMs, normalizeFilters, passesFilters, reportMarkerPosition, @@ -139,6 +141,9 @@ export function createNewsLayer({ lastRunAt = null, autoPages = 0; 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 feed = emptyFeed(); let inView = emptyInView(); @@ -159,7 +164,7 @@ export function createNewsLayer({ inView.minor?.find((r) => r.id === id) || 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) { const { lat, lon } = reportMarkerPosition(report, pin); @@ -806,7 +811,7 @@ export function createNewsLayer({ } 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) { if (!enabled || unavailable) return; @@ -828,7 +833,7 @@ export function createNewsLayer({ if (controller.signal.aborted || feedRequest !== controller || !enabled) return; feed = { - ...mergeFeedPage(feed, page, mode, now()), + ...mergeFeedPage(feed, page, mode, now(), undefined, windowMs), loading: false, error: null, }; @@ -915,7 +920,7 @@ export function createNewsLayer({ if (controller.signal.aborted || inViewRequest !== controller) return; inView = { ...inView, - minor: page.reports.filter((r) => withinNewsWindow(r, now())), + minor: page.reports.filter((r) => withinNewsWindow(r, now(), windowMs)), loading: false, error: null, }; @@ -1015,7 +1020,7 @@ export function createNewsLayer({ color: level.color, })), items: feed.items - .filter((r) => passesFilters(r, filters, at)) + .filter((r) => passesFilters(r, filters, at, windowMs)) .map((r) => feedCardView(r, at)), inView: { active: inView.active, @@ -1026,10 +1031,12 @@ export function createNewsLayer({ inView.centre, filters, at, + undefined, + windowMs, ).map((r) => feedCardView(r, at)), }, selected: - report && withinNewsWindow(report, at) + report && withinNewsWindow(report, at, windowMs) ? feedCardView(report, at) : null, hasMore: Boolean(feed.nextBefore), @@ -1037,6 +1044,7 @@ export function createNewsLayer({ // Read failures are English data; the panel shows them translated. error: error || feed.error ? t(error || feed.error) : null, lastRunAt, + windowHours: windowMs / 3_600_000, }; } @@ -1149,7 +1157,10 @@ export function createNewsLayer({ hotspots = index.hotspots; loadOutlines(); 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); error = null; lastUpdate = now(); diff --git a/src/layers/news/model.js b/src/layers/news/model.js index 3465ec3c8..075329227 100644 --- a/src/layers/news/model.js +++ b/src/layers/news/model.js @@ -11,6 +11,16 @@ 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; + +/** + * 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 // (as data/regionalNewsWindow.js). const NEWS_CLOCK_SKEW_MS = 15 * 60_000; @@ -210,21 +220,26 @@ 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. + * Whether a time is inside the window (the last 24 hours unless the server + * 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 || ''); return ( Number.isFinite(at) && - now - at <= NEWS_WINDOW_MS && + now - at <= windowMs && 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()) { - return inWindow(report?.publishedAt, now); +/** Whether a report is inside the window (the only news shown). */ +export function withinNewsWindow( + 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. */ @@ -548,7 +563,7 @@ export function reportMarkerPosition(report, pin = 0) { /** * 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; * 'append' adds an older page and takes its cursor; 'replace' starts over. */ @@ -558,6 +573,7 @@ export function mergeFeedPage( mode, now = Date.now(), max = NEWS_FEED_MAX, + windowMs = NEWS_WINDOW_MS, ) { const byId = new Map(); const items = @@ -567,7 +583,7 @@ export function mergeFeedPage( ? [...feed.items, ...page.reports] : [...page.reports, ...feed.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); return { items: [...byId.values()] @@ -604,24 +620,34 @@ export function normalizeFilters(next = {}, hotspots = [], previous = {}) { } /** 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 ( - withinNewsWindow(report, now) && + withinNewsWindow(report, now, windowMs) && reportSeverity(report) >= filters.minSeverity ); } /** 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 ( (filters.hotspot === 'all' || (report.hotspotIds || [report.hotspotId]).includes(filters.hotspot)) && (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 = {}) { return { events: true, @@ -660,12 +686,13 @@ export function reportsInView( filters, now = Date.now(), limit = NEWS_IN_VIEW_LIMIT, + windowMs = NEWS_WINDOW_MS, ) { if (!centre) return []; return reports .filter( (r) => - passesFilters(r, filters, now) && + passesFilters(r, filters, now, windowMs) && (filters.category === 'all' || r.category === filters.category) && r.pins?.some( (pin) => distanceKm(pin, centre) <= NEWS_IN_VIEW_RADIUS_KM, diff --git a/src/layers/news/model.test.mjs b/src/layers/news/model.test.mjs index 66b74a9c5..b5f3eaba7 100644 --- a/src/layers/news/model.test.mjs +++ b/src/layers/news/model.test.mjs @@ -20,6 +20,7 @@ import { isPressing, markerStyle, mergeFeedPage, + newsWindowMs, normalizeFilters, passesFilters, 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([], 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, + ); +}); diff --git a/src/layers/news/records.js b/src/layers/news/records.js index 266d17f7c..e573f9ac9 100644 --- a/src/layers/news/records.js +++ b/src/layers/news/records.js @@ -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. */ export function normalizeHotspotsPayload(payload) { @@ -135,6 +136,12 @@ export function normalizeHotspotsPayload(payload) { ) : [], lastRunAt: iso(payload.ingest?.lastRunAt), + windowHours: + Number.isInteger(payload.windowHours) && + payload.windowHours >= 1 && + payload.windowHours <= 24 + ? payload.windowHours + : null, }; } diff --git a/src/ui/news/NewsFeed.vue b/src/ui/news/NewsFeed.vue index 5d6fc8bce..a7709d33b 100644 --- a/src/ui/news/NewsFeed.vue +++ b/src/ui/news/NewsFeed.vue @@ -7,7 +7,8 @@ import UiSelect from '../kit/UiSelect.js'; import RailChips from '../rail/RailChips.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) // and a continuous feed, newest first. It renders the `feed` view the news // layer publishes and acts only through `onParams`. @@ -75,9 +76,11 @@ const clear = () => props.onParams({ clear: true });
-

{{ t('Last 24 h') }}

+

{{ + t('Last {hours} h', { hours: view.windowHours }) + }}

props.onParams({ clear: true });
    props.onParams({ clear: true });