mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +00:00
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:
1 parent
ee3593cfbd
commit
4817d2a648
22 files changed
+198
-21
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
- 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 (or the fewer hours a server names, shown in
|
24 hours from curated hotspots (or the fewer hours a server names), narrowed
|
||||||
the panel), coloured by severity only, with hotspot
|
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
|
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
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
"Reading the news…": "جارٍ قراءة الأخبار…",
|
"Reading the news…": "جارٍ قراءة الأخبار…",
|
||||||
"News, last {hours} hours": "الأخبار، آخر {hours} ساعة",
|
"News, last {hours} hours": "الأخبار، آخر {hours} ساعة",
|
||||||
"Last {hours} h": "آخر {hours} ساعة",
|
"Last {hours} h": "آخر {hours} ساعة",
|
||||||
|
"Time window": "الفترة الزمنية",
|
||||||
"Hotspots": "بؤر ساخنة",
|
"Hotspots": "بؤر ساخنة",
|
||||||
"Severity": "الخطورة",
|
"Severity": "الخطورة",
|
||||||
"Category": "الفئة",
|
"Category": "الفئة",
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
"Reading the news…": "Nachrichten werden gelesen…",
|
"Reading the news…": "Nachrichten werden gelesen…",
|
||||||
"News, last {hours} hours": "Nachrichten der letzten {hours} Stunden",
|
"News, last {hours} hours": "Nachrichten der letzten {hours} Stunden",
|
||||||
"Last {hours} h": "Letzte {hours} Std.",
|
"Last {hours} h": "Letzte {hours} Std.",
|
||||||
|
"Time window": "Zeitraum",
|
||||||
"Hotspots": "Brennpunkte",
|
"Hotspots": "Brennpunkte",
|
||||||
"Severity": "Schweregrad",
|
"Severity": "Schweregrad",
|
||||||
"Category": "Kategorie",
|
"Category": "Kategorie",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"Reading the news…": "Leyendo las noticias…",
|
"Reading the news…": "Leyendo las noticias…",
|
||||||
"News, last {hours} hours": "Noticias de las últimas {hours} horas",
|
"News, last {hours} hours": "Noticias de las últimas {hours} horas",
|
||||||
"Last {hours} h": "Últimas {hours} h",
|
"Last {hours} h": "Últimas {hours} h",
|
||||||
|
"Time window": "Periodo",
|
||||||
"Hotspots": "Focos",
|
"Hotspots": "Focos",
|
||||||
"Severity": "Gravedad",
|
"Severity": "Gravedad",
|
||||||
"Category": "Categoría",
|
"Category": "Categoría",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"Reading the news…": "Lecture des actualités…",
|
"Reading the news…": "Lecture des actualités…",
|
||||||
"News, last {hours} hours": "Actualités des dernières {hours} heures",
|
"News, last {hours} hours": "Actualités des dernières {hours} heures",
|
||||||
"Last {hours} h": "Dernières {hours} h",
|
"Last {hours} h": "Dernières {hours} h",
|
||||||
|
"Time window": "Période",
|
||||||
"Hotspots": "Foyers",
|
"Hotspots": "Foyers",
|
||||||
"Severity": "Gravité",
|
"Severity": "Gravité",
|
||||||
"Category": "Catégorie",
|
"Category": "Catégorie",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"Reading the news…": "Lettura delle notizie…",
|
"Reading the news…": "Lettura delle notizie…",
|
||||||
"News, last {hours} hours": "Notizie delle ultime {hours} ore",
|
"News, last {hours} hours": "Notizie delle ultime {hours} ore",
|
||||||
"Last {hours} h": "Ultime {hours} h",
|
"Last {hours} h": "Ultime {hours} h",
|
||||||
|
"Time window": "Periodo",
|
||||||
"Hotspots": "Focolai",
|
"Hotspots": "Focolai",
|
||||||
"Severity": "Gravità",
|
"Severity": "Gravità",
|
||||||
"Category": "Categoria",
|
"Category": "Categoria",
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
"Reading the news…": "ニュースを読み込み中…",
|
"Reading the news…": "ニュースを読み込み中…",
|
||||||
"News, last {hours} hours": "直近{hours}時間のニュース",
|
"News, last {hours} hours": "直近{hours}時間のニュース",
|
||||||
"Last {hours} h": "直近{hours}時間",
|
"Last {hours} h": "直近{hours}時間",
|
||||||
|
"Time window": "期間",
|
||||||
"Hotspots": "ホットスポット",
|
"Hotspots": "ホットスポット",
|
||||||
"Severity": "深刻度",
|
"Severity": "深刻度",
|
||||||
"Category": "カテゴリ",
|
"Category": "カテゴリ",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"Reading the news…": "Lendo as notícias…",
|
"Reading the news…": "Lendo as notícias…",
|
||||||
"News, last {hours} hours": "Notícias das últimas {hours} horas",
|
"News, last {hours} hours": "Notícias das últimas {hours} horas",
|
||||||
"Last {hours} h": "Últimas {hours} h",
|
"Last {hours} h": "Últimas {hours} h",
|
||||||
|
"Time window": "Período",
|
||||||
"Hotspots": "Focos",
|
"Hotspots": "Focos",
|
||||||
"Severity": "Gravidade",
|
"Severity": "Gravidade",
|
||||||
"Category": "Categoria",
|
"Category": "Categoria",
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
"Reading the news…": "Читаем новости…",
|
"Reading the news…": "Читаем новости…",
|
||||||
"News, last {hours} hours": "Новости за последние {hours} ч",
|
"News, last {hours} hours": "Новости за последние {hours} ч",
|
||||||
"Last {hours} h": "За {hours} ч",
|
"Last {hours} h": "За {hours} ч",
|
||||||
|
"Time window": "Период",
|
||||||
"Hotspots": "Горячие точки",
|
"Hotspots": "Горячие точки",
|
||||||
"Severity": "Тяжесть",
|
"Severity": "Тяжесть",
|
||||||
"Category": "Категория",
|
"Category": "Категория",
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
"Reading the news…": "Читаємо новини…",
|
"Reading the news…": "Читаємо новини…",
|
||||||
"News, last {hours} hours": "Новини за останні {hours} год",
|
"News, last {hours} hours": "Новини за останні {hours} год",
|
||||||
"Last {hours} h": "За {hours} год",
|
"Last {hours} h": "За {hours} год",
|
||||||
|
"Time window": "Період",
|
||||||
"Hotspots": "Гарячі точки",
|
"Hotspots": "Гарячі точки",
|
||||||
"Severity": "Тяжкість",
|
"Severity": "Тяжкість",
|
||||||
"Category": "Категорія",
|
"Category": "Категорія",
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
"Reading the news…": "正在读取新闻…",
|
"Reading the news…": "正在读取新闻…",
|
||||||
"News, last {hours} hours": "最近 {hours} 小时的新闻",
|
"News, last {hours} hours": "最近 {hours} 小时的新闻",
|
||||||
"Last {hours} h": "最近 {hours} 小时",
|
"Last {hours} h": "最近 {hours} 小时",
|
||||||
|
"Time window": "时间范围",
|
||||||
"Hotspots": "热点",
|
"Hotspots": "热点",
|
||||||
"Severity": "严重程度",
|
"Severity": "严重程度",
|
||||||
"Category": "类别",
|
"Category": "类别",
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
"Reading the news…": "正在讀取新聞…",
|
"Reading the news…": "正在讀取新聞…",
|
||||||
"News, last {hours} hours": "最近 {hours} 小時的新聞",
|
"News, last {hours} hours": "最近 {hours} 小時的新聞",
|
||||||
"Last {hours} h": "最近 {hours} 小時",
|
"Last {hours} h": "最近 {hours} 小時",
|
||||||
|
"Time window": "時間範圍",
|
||||||
"Hotspots": "熱點",
|
"Hotspots": "熱點",
|
||||||
"Severity": "嚴重程度",
|
"Severity": "嚴重程度",
|
||||||
"Category": "類別",
|
"Category": "類別",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
markerStyle,
|
markerStyle,
|
||||||
mediaLabels,
|
mediaLabels,
|
||||||
mergeFeedPage,
|
mergeFeedPage,
|
||||||
|
newsWindowChoices,
|
||||||
newsWindowMs,
|
newsWindowMs,
|
||||||
normalizeFilters,
|
normalizeFilters,
|
||||||
passesFilters,
|
passesFilters,
|
||||||
@@ -142,7 +143,9 @@ export function createNewsLayer({
|
|||||||
autoPages = 0;
|
autoPages = 0;
|
||||||
let hotspots = [];
|
let hotspots = [];
|
||||||
// The window the server serves (newsWindowMs): a host may serve
|
// 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 windowMs = NEWS_WINDOW_MS;
|
||||||
let mapReports = [];
|
let mapReports = [];
|
||||||
let feed = emptyFeed();
|
let feed = emptyFeed();
|
||||||
@@ -165,6 +168,11 @@ export function createNewsLayer({
|
|||||||
null;
|
null;
|
||||||
const selected = () => (selectedId ? findReport(selectedId) : null);
|
const selected = () => (selectedId ? findReport(selectedId) : null);
|
||||||
const onMap = (report) => visibleOnMap(report, filters, now(), windowMs);
|
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) {
|
function markerCartesian(report, pin = 0) {
|
||||||
const { lat, lon } = reportMarkerPosition(report, pin);
|
const { lat, lon } = reportMarkerPosition(report, pin);
|
||||||
@@ -920,7 +928,9 @@ 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(), windowMs)),
|
minor: page.reports.filter((r) =>
|
||||||
|
withinNewsWindow(r, now(), maxWindowMs),
|
||||||
|
),
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
@@ -1045,6 +1055,7 @@ export function createNewsLayer({
|
|||||||
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,
|
windowHours: windowMs / 3_600_000,
|
||||||
|
windowChoices: newsWindowChoices(maxWindowMs / 3_600_000),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1139,7 +1150,10 @@ export function createNewsLayer({
|
|||||||
notify();
|
notify();
|
||||||
try {
|
try {
|
||||||
const [index, todays] = await Promise.all([
|
const [index, todays] = await Promise.all([
|
||||||
source.getHotspots({ signal: controller.signal }),
|
source.getHotspots({
|
||||||
|
signal: controller.signal,
|
||||||
|
hours: askedHours(),
|
||||||
|
}),
|
||||||
source.getFeed(
|
source.getFeed(
|
||||||
{
|
{
|
||||||
hotspot: 'all',
|
hotspot: 'all',
|
||||||
@@ -1157,9 +1171,16 @@ export function createNewsLayer({
|
|||||||
hotspots = index.hotspots;
|
hotspots = index.hotspots;
|
||||||
loadOutlines();
|
loadOutlines();
|
||||||
lastRunAt = index.lastRunAt;
|
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) =>
|
mapReports = todays.reports.filter((r) =>
|
||||||
withinNewsWindow(r, now(), windowMs),
|
withinNewsWindow(r, now(), maxWindowMs),
|
||||||
);
|
);
|
||||||
filters = normalizeFilters({}, hotspots, filters);
|
filters = normalizeFilters({}, hotspots, filters);
|
||||||
error = null;
|
error = null;
|
||||||
@@ -1196,6 +1217,8 @@ export function createNewsLayer({
|
|||||||
const reread = readKey(next) !== readKey(filters);
|
const reread = readKey(next) !== readKey(filters);
|
||||||
const minorChanged = next.includeMinor !== filters.includeMinor;
|
const minorChanged = next.includeMinor !== filters.includeMinor;
|
||||||
filters = next;
|
filters = next;
|
||||||
|
const previousWindowMs = windowMs;
|
||||||
|
windowMs = shownWindowMs();
|
||||||
if (
|
if (
|
||||||
Object.hasOwn(params.filters, 'hotspot') &&
|
Object.hasOwn(params.filters, 'hotspot') &&
|
||||||
next.hotspot !== 'all'
|
next.hotspot !== 'all'
|
||||||
@@ -1208,6 +1231,8 @@ export function createNewsLayer({
|
|||||||
render();
|
render();
|
||||||
if (reread) void loadFeed('replace');
|
if (reread) void loadFeed('replace');
|
||||||
if (minorChanged) void refreshInView({ force: true });
|
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();
|
notify();
|
||||||
}
|
}
|
||||||
if (params.loadMore === true) void loadFeed('append');
|
if (params.loadMore === true) void loadFeed('append');
|
||||||
|
|||||||
@@ -275,6 +275,53 @@ test('In view with minor news reads once, not per pan; a failed read shows', asy
|
|||||||
assert.deepEqual(view.items, []);
|
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) => {
|
test('a server without news is unavailable once and is not asked again', async (t) => {
|
||||||
const failure = Object.assign(
|
const failure = Object.assign(
|
||||||
new Error('News feed not configured on this server'),
|
new Error('News feed not configured on this server'),
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ export function newsWindowMs(hours) {
|
|||||||
? hours * 3_600_000
|
? hours * 3_600_000
|
||||||
: NEWS_WINDOW_MS;
|
: 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
|
// 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;
|
||||||
@@ -616,6 +628,8 @@ export function normalizeFilters(next = {}, hotspots = [], previous = {}) {
|
|||||||
includeMinor: merged.includeMinor === true,
|
includeMinor: merged.includeMinor === true,
|
||||||
// Event cards on the map; off leaves only the clicked event's card.
|
// Event cards on the map; off leaves only the clicked event's card.
|
||||||
cards: merged.cards !== false,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
isPressing,
|
isPressing,
|
||||||
markerStyle,
|
markerStyle,
|
||||||
mergeFeedPage,
|
mergeFeedPage,
|
||||||
|
newsWindowChoices,
|
||||||
newsWindowMs,
|
newsWindowMs,
|
||||||
normalizeFilters,
|
normalizeFilters,
|
||||||
passesFilters,
|
passesFilters,
|
||||||
@@ -287,8 +288,11 @@ test('filters default to significant news and map to a last-24-hours query', ()
|
|||||||
minSeverity: 2,
|
minSeverity: 2,
|
||||||
includeMinor: false,
|
includeMinor: false,
|
||||||
cards: true,
|
cards: true,
|
||||||
|
hours: null,
|
||||||
});
|
});
|
||||||
assert.equal(normalizeFilters({ cards: false }, hotspots).cards, false);
|
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({ minSeverity: 0 }, hotspots).minSeverity, 0);
|
||||||
assert.equal(normalizeFilters({ hotspot: 'gone' }, hotspots).hotspot, 'all');
|
assert.equal(normalizeFilters({ hotspot: 'gone' }, hotspots).hotspot, 'all');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -599,6 +603,21 @@ test('the hotspots answer carries the window it was counted over', () => {
|
|||||||
normalizeHotspotsPayload({ ...base, windowHours: 48 }).windowHours,
|
normalizeHotspotsPayload({ ...base, windowHours: 48 }).windowHours,
|
||||||
null,
|
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', () => {
|
test('a checked severity keeps saying who set it', () => {
|
||||||
|
|||||||
@@ -95,10 +95,15 @@ function normalizeDeveloping(items) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hoursOrNull = (hours) =>
|
||||||
|
Number.isInteger(hours) && hours >= 1 && hours <= 24 ? hours : null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `/api/news/hotspots` → `{hotspots, categories, lastRunAt, windowHours}`
|
* `/api/news/hotspots` → `{hotspots, categories, lastRunAt, windowHours,
|
||||||
* (`windowHours` null when the server names none), or null when the
|
* maxWindowHours}`, or null when the payload has no hotspot list.
|
||||||
* 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) {
|
export function normalizeHotspotsPayload(payload) {
|
||||||
if (!Array.isArray(payload?.hotspots)) return null;
|
if (!Array.isArray(payload?.hotspots)) return null;
|
||||||
@@ -136,12 +141,8 @@ export function normalizeHotspotsPayload(payload) {
|
|||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
lastRunAt: iso(payload.ingest?.lastRunAt),
|
lastRunAt: iso(payload.ingest?.lastRunAt),
|
||||||
windowHours:
|
windowHours: hoursOrNull(payload.windowHours),
|
||||||
Number.isInteger(payload.windowHours) &&
|
maxWindowHours: hoursOrNull(payload.maxWindowHours),
|
||||||
payload.windowHours >= 1 &&
|
|
||||||
payload.windowHours <= 24
|
|
||||||
? payload.windowHours
|
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,15 @@ export function newsFeedQuery(query = {}) {
|
|||||||
export function newsQueryAllowed(url) {
|
export function newsQueryAllowed(url) {
|
||||||
const params = [...url.searchParams];
|
const params = [...url.searchParams];
|
||||||
if (new Set(params.map(([key]) => key)).size !== params.length) return false;
|
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`) {
|
if (url.pathname === `${BASE_PATH}/outline`) {
|
||||||
const keys = params.map(([key]) => key).sort();
|
const keys = params.map(([key]) => key).sort();
|
||||||
const value = url.searchParams;
|
const value = url.searchParams;
|
||||||
@@ -128,9 +136,12 @@ export function createNewsSource({
|
|||||||
return readResponseJsonCapped(response, MAX_BYTES, request);
|
return readResponseJsonCapped(response, MAX_BYTES, request);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
async getHotspots({ signal } = {}) {
|
// `hours` asks for counts over a shorter window than the server's own.
|
||||||
|
async getHotspots({ signal, hours } = {}) {
|
||||||
const payload = normalizeHotspotsPayload(
|
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'));
|
if (!payload) throw new Error(msg('Malformed news hotspots response'));
|
||||||
return payload;
|
return payload;
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ test('reads validated hotspots, feed and outlines from the same-origin routes',
|
|||||||
);
|
);
|
||||||
assert.deepEqual((await source.getOutline('ua')).polygons, []);
|
assert.deepEqual((await source.getOutline('ua')).polygons, []);
|
||||||
await source.getCountryOutline('SD');
|
await source.getCountryOutline('SD');
|
||||||
|
await source.getHotspots({ hours: 6 });
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
calls.map((c) => c.url),
|
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/feed?hotspot=all&limit=60&today=1',
|
||||||
'/api/news/outline?hotspot=ua&v=4',
|
'/api/news/outline?hotspot=ua&v=4',
|
||||||
'/api/news/outline?country=SD&v=4',
|
'/api/news/outline?country=SD&v=4',
|
||||||
|
'/api/news/hotspots?hours=6',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
for (const { url, init } of calls) {
|
for (const { url, init } of calls) {
|
||||||
@@ -155,7 +157,12 @@ test('the host allowlist admits the client routes and refuses anything else', ()
|
|||||||
),
|
),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
assert.equal(allowed('/api/news/hotspots?hours=6'), true);
|
||||||
for (const path of [
|
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/hotspots?x=1',
|
||||||
'/api/news/feed?limit=501',
|
'/api/news/feed?limit=501',
|
||||||
'/api/news/feed?limit=0',
|
'/api/news/feed?limit=0',
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import RailChips from '../rail/RailChips.vue';
|
|||||||
import NewsCard from './NewsCard.vue';
|
import NewsCard from './NewsCard.vue';
|
||||||
|
|
||||||
// The News panel: the window the server serves only (the last 24 hours, or
|
// 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)
|
// 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`.
|
||||||
@@ -44,6 +45,15 @@ const categoryOptions = computed(() => [
|
|||||||
{ value: 'all', label: t('All categories') },
|
{ value: 'all', label: t('All categories') },
|
||||||
...(props.view?.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);
|
const selectedId = computed(() => props.view?.selected?.id ?? null);
|
||||||
// The chosen event leads the panel unless the feed already shows it open.
|
// The chosen event leads the panel unless the feed already shows it open.
|
||||||
const selectedApart = computed(() => {
|
const selectedApart = computed(() => {
|
||||||
@@ -78,7 +88,15 @@ const clear = () => props.onParams({ clear: true });
|
|||||||
class="news-readout"
|
class="news-readout"
|
||||||
:aria-label="t('News, last {hours} hours', { hours: view.windowHours })"
|
: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 })
|
t('Last {hours} h', { hours: view.windowHours })
|
||||||
}}</p>
|
}}</p>
|
||||||
<div class="news-readout__chips" role="group" :aria-label="t('Hotspots')">
|
<div class="news-readout__chips" role="group" :aria-label="t('Hotspots')">
|
||||||
|
|||||||
@@ -278,6 +278,24 @@ test('scrolling to the end asks once per page, again after a filter change or a
|
|||||||
news.destroy();
|
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', () => {
|
test('the panel names the window the server serves', () => {
|
||||||
const { news, panel } = fixture();
|
const { news, panel } = fixture();
|
||||||
news.update(view({ windowHours: 12 }));
|
news.update(view({ windowHours: 12 }));
|
||||||
|
|||||||
@@ -131,6 +131,11 @@
|
|||||||
.news-filter {
|
.news-filter {
|
||||||
min-width: 0;
|
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 {
|
.news-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in new issue
Block a user