Hold Cockpit regional news to the last 24 hours, and tell its states apart

Regional News showed "REGIONAL NEWS UNAVAILABLE" above populated rows and a
static "RECENT" footer over months-old articles. The status banner kept
`display: grid` when hidden, every non-empty answer carried the unavailable
text, and nothing limited or sorted articles by age.

- One window (regionalNewsWindow.js, 24 h): the local proxy asks Google News
  with `when:1d` and GDELT for 24 h, drops undated, older or future-dated
  articles, sorts newest first, and reports newsWindowHours/newsFetchedAt.
- The Cockpit labels four states: fresh (banner hidden, "LAST 24 H"),
  kept after a failed refresh ("CACHED · REFRESH FAILED"), no news in the
  window, and unavailable. The footer lives in the news state, so it
  survives page and tab changes, and the client drops out-of-window rows.
- `.cockpit-brief-status[hidden]` now hides the banner.
This commit is contained in:
Denis Groshev committed 2026-11-06 00:45:19 +01:00
1 parent 2470b39f88
commit dd83812a21
24 files changed
+637 -85

No files matched your search

+38
View File
@@ -1,7 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
REGIONAL_NEWS_WINDOW_HOURS,
googleNewsWindowOperator,
normalizeRegionalArticles,
recentRegionalArticles,
regionalNewsWindowHours,
normalizeRegionalPlace,
normalizeRegionalWeather,
regionalDistanceM,
@@ -61,3 +65,37 @@ test('regional distance handles nearby movement and missing positions', () => {
assert.ok(distance > 11000 && distance < 11200);
assert.equal(regionalDistanceM(null, null), Infinity);
});
test('regional news is held to one 24-hour window, newest first, undated dropped', () => {
assert.equal(REGIONAL_NEWS_WINDOW_HOURS, 24);
assert.equal(googleNewsWindowOperator(), 'when:1d');
assert.equal(googleNewsWindowOperator(6), 'when:6h');
assert.equal(regionalNewsWindowHours('12'), 12);
assert.equal(regionalNewsWindowHours(0), 24);
assert.equal(regionalNewsWindowHours(undefined), 24);
const now = Date.parse('2026-11-04T12:00:00Z');
const at = (hours) => new Date(now - hours * 3_600_000).toISOString();
const rows = [
{ title: 'a', publishedAt: at(23) },
{ title: 'stale', publishedAt: at(25) },
{ title: 'undated', publishedAt: null },
{ title: 'garbled', publishedAt: 'not a date' },
{ title: 'b', publishedAt: at(1) },
{ title: 'future', publishedAt: at(-2) },
{ title: 'c', publishedAt: at(5) },
];
assert.deepEqual(
recentRegionalArticles(rows, { now }).map((row) => row.title),
['b', 'c', 'a'],
);
assert.deepEqual(
recentRegionalArticles(rows, { now, limit: 2 }).map((row) => row.title),
['b', 'c'],
);
assert.deepEqual(
recentRegionalArticles(rows, { now, windowHours: 4 }).map((row) => row.title),
['b'],
);
assert.deepEqual(recentRegionalArticles([], { now }), []);
assert.deepEqual(recentRegionalArticles(undefined, { now }), []);
});
+7
View File
@@ -55,6 +55,13 @@ export function normalizeRegionalPlace(payload) {
};
}
export {
REGIONAL_NEWS_WINDOW_HOURS,
googleNewsWindowOperator,
recentRegionalArticles,
regionalNewsWindowHours,
} from './regionalNewsWindow.js';
/** Normalize and deduplicate GDELT ArticleList output without trusting article HTML. */
export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
const rows = Array.isArray(payload?.articles) ? payload.articles : [];
+66
View File
@@ -0,0 +1,66 @@
/** The regional-news freshness window, shared by the servers and the Cockpit. */
/** At most this many articles answer a regional brief. */
export const REGIONAL_NEWS_ROWS = 5;
/**
* The regional-news freshness window, in hours. The servers ask upstream for
* this window, drop anything older and report it as `newsWindowHours`; the
* Cockpit labels its list with it.
*/
export const REGIONAL_NEWS_WINDOW_HOURS = 24;
// A publish time this far ahead of the clock is skew, not a future story.
const REGIONAL_NEWS_CLOCK_SKEW_MS = 15 * 60_000;
/**
* A served window in hours, or the default when it is missing or out of range.
* @param {unknown} value
* @returns {number}
*/
export function regionalNewsWindowHours(value) {
const hours = Number(value);
return Number.isInteger(hours) && hours >= 1 && hours <= 168
? hours
: REGIONAL_NEWS_WINDOW_HOURS;
}
/**
* The Google News search operator asking for the window: `when:1d` for 24 h.
* @param {number} [hours]
* @returns {string}
*/
export function googleNewsWindowOperator(hours = REGIONAL_NEWS_WINDOW_HOURS) {
const span = regionalNewsWindowHours(hours);
return span % 24 === 0 ? `when:${span / 24}d` : `when:${span}h`;
}
/**
* The articles published inside the window, newest first, at most `limit`.
* An article without a parseable publish time is dropped: nothing shows it is
* recent. One dated more than 15 minutes ahead of `now` is dropped too.
* @param {Array<{publishedAt?: string|null}>} articles
* @param {{now?: number, windowHours?: number, limit?: number}} [options]
* @returns {Array<object>}
*/
export function recentRegionalArticles(
articles,
{
now = Date.now(),
windowHours = REGIONAL_NEWS_WINDOW_HOURS,
limit = REGIONAL_NEWS_ROWS,
} = {},
) {
const oldest = now - regionalNewsWindowHours(windowHours) * 3_600_000;
return (Array.isArray(articles) ? articles : [])
.map((article) => ({ article, at: Date.parse(article?.publishedAt) }))
.filter(
({ at }) =>
Number.isFinite(at) &&
at >= oldest &&
at <= now + REGIONAL_NEWS_CLOCK_SKEW_MS,
)
.sort((a, b) => b.at - a.at)
.slice(0, Math.max(0, limit))
.map(({ article }) => article);
}
+48
View File
@@ -11,6 +11,7 @@ import createViteConfig, {
validMilitaryInstallationBox,
validRegionalPoint,
} from '../../vite.config.js';
import { fetchRegionalNews } from '../../server/providers/regional/news.js';
test('missing Google place context is a quiet keyless capability, not a 503', () => {
assert.deepEqual(keylessGooglePlacesResponse(undefined), {
@@ -113,3 +114,50 @@ test('regional brief treats an all-source outage as total failure', () => {
news: { status: 'unavailable' },
}), true);
});
test('local regional news asks for the last day, drops old and undated items, newest first', async (t) => {
const realFetch = globalThis.fetch;
t.after(() => { globalThis.fetch = realFetch; });
const urls = [];
let rss;
let gdelt;
globalThis.fetch = async (url) => {
urls.push(new URL(String(url)));
const answer = String(url).includes('news.google') ? rss : gdelt;
return typeof answer === 'function' ? answer() : answer.clone();
};
const at = (hours) => new Date(Date.now() - hours * 3_600_000).toUTCString();
const item = (title, date) =>
`<item><title>${title}</title><link>https://news.example/${title}</link>${date ? `<pubDate>${date}</pubDate>` : ''}</item>`;
rss = new Response(`<rss><channel>${[
item('old', at(4 * 24)),
item('undated', null),
item('day', at(20)),
item('hour', at(1)),
].join('')}</channel></rss>`);
const place = { locality: 'Austin' };
const ready = await fetchRegionalNews(place);
assert.equal(urls[0].searchParams.get('q'), 'Austin when:1d');
assert.equal(ready.status, 'ready');
assert.deepEqual(ready.articles.map((row) => row.title), ['hour', 'day']);
// Nothing recent in RSS: GDELT is asked for the same 24 h window.
rss = new Response(`<rss><channel>${item('old', at(30))}</channel></rss>`);
gdelt = Response.json({ articles: [] });
urls.length = 0;
const empty = await fetchRegionalNews(place);
assert.equal(urls[1].searchParams.get('timespan'), '24h');
assert.deepEqual(empty, { status: 'empty', query: 'Austin', articles: [], source: 'GDELT fallback' });
// RSS answered with nothing recent and GDELT failed: still a true empty.
rss = new Response('<rss><channel></channel></rss>');
gdelt = () => Promise.reject(new TypeError('network'));
assert.equal((await fetchRegionalNews(place)).status, 'empty');
// Neither source answered, or no place: unavailable.
rss = () => Promise.resolve(new Response('busy', { status: 503 }));
const failed = await fetchRegionalNews(place);
assert.equal(failed.status, 'unavailable');
assert.deepEqual(failed.articles, []);
assert.equal((await fetchRegionalNews(null)).status, 'unavailable');
});
+4 -3
View File
@@ -388,11 +388,12 @@
"PLACE / CONDITIONS / POSITION": "المكان / الأحوال / الموضع",
"POSITION UNAVAILABLE": "الموضع غير متاح",
"REGIONAL NEWS UNAVAILABLE": "الأخبار الإقليمية غير متاحة",
"CACHED · REFRESH FAILED": "مخزنة · فشل التحديث",
"REGION UNAVAILABLE": "المنطقة غير متاحة",
"NO RECENT LOCATION MATCHES": "لا توجد تقارير حديثة مطابقة للموقع",
"NO NEWS IN THE LAST {hours} H": "لا أخبار خلال آخر {hours} س",
"SOURCE": "المصدر",
"{domain} · {age}": "{domain} · {age}",
"{source} · LOCATION QUERY": "{source} · استعلام الموقع",
"{source} · CACHED {age}": "{source} · مخزنة {age}",
"TIME UNKNOWN": "الوقت غير معروف",
"{count}M AGO": "قبل {count} د",
"{count}H AGO": "قبل {count} س",
@@ -2194,7 +2195,7 @@
"ADS-B PRIORITY · {status}": "أولوية ADS-B · {status}",
"ADS-B EMERGENCY · {status}": "طوارئ ADS-B · {status}",
"{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}",
"{source} · LOCATION QUERY · RECENT": "{source} · استعلام الموقع · حديث",
"{source} · LAST {hours} H": "{source} · آخر {hours} س",
"Rendered globe frames per second · toggle with `": "إطارات الكرة الأرضية المعروضة في الثانية · بدّل بالمفتاح `",
"Ground heights could not be refreshed. Try the file again when the map has loaded.": "تعذّر تحديث ارتفاعات الأرض. أعد استيراد الملف بعد تحميل الخريطة.",
"{count} map objects": {
+4 -3
View File
@@ -399,13 +399,14 @@
"Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Briefing-Seiten automatisch alle 9 Sekunden wechseln (Signale → News → Lokal). Pausiert, solange sich der Mauszeiger über dem Bereich befindet oder dieser den Fokus hat. Live-Signaldaten werden in beiden Fällen laufend aktualisiert.",
"ACQUIRING REGIONAL NEWS": "REGIONALE NEWS WERDEN ERFASST",
"REGIONAL NEWS UNAVAILABLE": "REGIONALE NEWS NICHT VERFÜGBAR",
"CACHED · REFRESH FAILED": "CACHE · AKTUALISIERUNG FEHLGESCHLAGEN",
"RESOLVING REGION": "REGION WIRD ERMITTELT",
"REGION UNAVAILABLE": "REGION NICHT VERFÜGBAR",
"NO RECENT LOCATION MATCHES": "KEINE AKTUELLEN ORTSTREFFER",
"NO NEWS IN THE LAST {hours} H": "KEINE NEWS IN DEN LETZTEN {hours} H",
"{domain} · {age}": "{domain} · {age}",
"CLOUD {pct}%": "WOLKEN {pct} %",
"CLOUD UNKNOWN": "WOLKEN UNBEKANNT",
"{source} · LOCATION QUERY": "{source} · ORTSABFRAGE",
"{source} · CACHED {age}": "{source} · CACHE {age}",
"REGIONAL NEWS": "REGIONALE NEWS",
"CONTEXT STANDBY": "KONTEXT STANDBY",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "GLOBALEN KONTEXT FÜR NÄHE-PINGS AKTIVIEREN",
@@ -2094,7 +2095,7 @@
"ADS-B PRIORITY · {status}": "ADS-B-PRIORITÄT · {status}",
"ADS-B EMERGENCY · {status}": "ADS-B-NOTFALL · {status}",
"{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}",
"{source} · LOCATION QUERY · RECENT": "{source} · ORTSABFRAGE · AKTUELL",
"{source} · LAST {hours} H": "{source} · LETZTE {hours} H",
"Rendered globe frames per second · toggle with `": "Gerenderte Globus-Frames pro Sekunde · umschalten mit `",
"Ground heights could not be refreshed. Try the file again when the map has loaded.": "Geländehöhen konnten nicht aktualisiert werden. Die Datei erneut laden, wenn die Karte geladen ist.",
"{count} map objects": {
+4 -3
View File
@@ -274,11 +274,12 @@
"PLACE / CONDITIONS / POSITION": "LUGAR / CLIMA / POSICIÓN",
"POSITION UNAVAILABLE": "POSICIÓN NO DISPONIBLE",
"REGIONAL NEWS UNAVAILABLE": "NOTICIAS DE ZONA NO DISPONIBLES",
"CACHED · REFRESH FAILED": "EN CACHÉ · ERROR AL ACTUALIZAR",
"REGION UNAVAILABLE": "REGIÓN NO DISPONIBLE",
"NO RECENT LOCATION MATCHES": "SIN COINCIDENCIAS RECIENTES DE UBICACIÓN",
"NO NEWS IN THE LAST {hours} H": "SIN NOTICIAS EN LAS ÚLTIMAS {hours} H",
"SOURCE": "FUENTE",
"{domain} · {age}": "{domain} · {age}",
"{source} · LOCATION QUERY": "{source} · CONSULTA POR UBICACIÓN",
"{source} · CACHED {age}": "{source} · EN CACHÉ {age}",
"TIME UNKNOWN": "HORA DESCONOCIDA",
"{count}M AGO": "HACE {count} MIN",
"{count}H AGO": "HACE {count} H",
@@ -1923,7 +1924,7 @@
"MILITARY FEED OFF · PARTIAL PICTURE": "FUENTE MILITAR DESACTIVADA · PANORAMA PARCIAL",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FUENTE DE VUELOS DESACTIVADA · PANORAMA PARCIAL",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES CERCANAS INCOMPLETAS · NO IMPLICA TODO DESPEJADO",
"{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR UBICACIÓN · RECIENTE",
"{source} · LAST {hours} H": "{source} · ÚLTIMAS {hours} H",
"ft": "ft",
"kts": "kn",
"PAUSE": "PAUSA",
+4 -3
View File
@@ -1649,14 +1649,15 @@
"Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Faire défiler automatiquement les pages du briefing toutes les 9 secondes (Signaux → Actus → Local). Pause pendant le survol ou quand le panneau a le focus. Les signaux en direct s'actualisent en continu dans les deux cas.",
"ACQUIRING REGIONAL NEWS": "ACQUISITION DES ACTUS RÉGIONALES",
"REGIONAL NEWS UNAVAILABLE": "ACTUS RÉGIONALES INDISPONIBLES",
"CACHED · REFRESH FAILED": "EN CACHE · ÉCHEC DE L’ACTUALISATION",
"RESOLVING REGION": "IDENTIFICATION DE LA RÉGION",
"REGION UNAVAILABLE": "RÉGION INDISPONIBLE",
"NO RECENT LOCATION MATCHES": "AUCUNE ACTU RÉCENTE POUR CE LIEU",
"NO NEWS IN THE LAST {hours} H": "AUCUNE ACTU CES {hours} DERNIÈRES H",
"{domain} · {age}": "{domain} · {age}",
"{value} KM/H": "{value} KM/H",
"CLOUD {pct}%": "NÉBULOSITÉ {pct} %",
"CLOUD UNKNOWN": "NÉBULOSITÉ INCONNUE",
"{source} · LOCATION QUERY": "{source} · REQUÊTE DE LIEU",
"{source} · CACHED {age}": "{source} · EN CACHE {age}",
"REGIONAL NEWS": "ACTUS RÉGIONALES",
"{label} · {radius} AIR/SEA WINDOW": "{label} · AIR/MER {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLATIONS : {coverage}",
@@ -1716,7 +1717,7 @@
"OBSERVED / MAPPED PINGS": "PINGS OBSERVÉS / CARTOGRAPHIÉS",
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "ÉVÉNEMENTS SOURCÉS · AUCUNE ACTU SYNTHÉTIQUE",
"LATEST LOCATION-MATCHED REPORTING": "DERNIÈRES ACTUS LIÉES AU LIEU",
"{source} · LOCATION QUERY · RECENT": "{source} · REQUÊTE DE LIEU · RÉCENT",
"{source} · LAST {hours} H": "{source} · {hours} DERNIÈRES H",
"LOCAL INFO": "INFOS LOCALES",
"PLACE / CONDITIONS / POSITION": "LIEU / CONDITIONS / POSITION",
"TIME UNKNOWN": "HEURE INCONNUE",
+4 -3
View File
@@ -1649,14 +1649,15 @@
"Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Scorri automaticamente le pagine del briefing ogni 9 secondi (Segnali → Notizie → Locale). Lo scorrimento si interrompe quando passi sul pannello o lo selezioni. I dati dei segnali live si aggiornano comunque di continuo.",
"ACQUIRING REGIONAL NEWS": "ACQUISIZIONE NOTIZIE REGIONALI",
"REGIONAL NEWS UNAVAILABLE": "NOTIZIE REGIONALI NON DISPONIBILI",
"CACHED · REFRESH FAILED": "IN CACHE · AGGIORNAMENTO NON RIUSCITO",
"RESOLVING REGION": "IDENTIFICAZIONE REGIONE",
"REGION UNAVAILABLE": "REGIONE NON DISPONIBILE",
"NO RECENT LOCATION MATCHES": "NESSUNA CORRISPONDENZA RECENTE PER POSIZIONE",
"NO NEWS IN THE LAST {hours} H": "NESSUNA NOTIZIA NELLE ULTIME {hours} H",
"{domain} · {age}": "{domain} · {age}",
"{value} KM/H": "{value} KM/H",
"CLOUD {pct}%": "NUVOLE {pct}%",
"CLOUD UNKNOWN": "NUVOLE N/D",
"{source} · LOCATION QUERY": "{source} · QUERY PER POSIZIONE",
"{source} · CACHED {age}": "{source} · IN CACHE {age}",
"REGIONAL NEWS": "NOTIZIE REGIONALI",
"{label} · {radius} AIR/SEA WINDOW": "{label} · ARIA/MARE {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · SITI {coverage}",
@@ -1716,7 +1717,7 @@
"OBSERVED / MAPPED PINGS": "PING OSSERVATI / MAPPATI",
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "EVENTI DA FONTI · NESSUNA NOTIZIA SINTETICA",
"LATEST LOCATION-MATCHED REPORTING": "ULTIME NOTIZIE PER POSIZIONE",
"{source} · LOCATION QUERY · RECENT": "{source} · QUERY PER POSIZIONE · RECENTI",
"{source} · LAST {hours} H": "{source} · ULTIME {hours} H",
"LOCAL INFO": "INFO LOCALI",
"PLACE / CONDITIONS / POSITION": "LUOGO / CONDIZIONI / POSIZIONE",
"TIME UNKNOWN": "ORA SCONOSCIUTA",
+4 -3
View File
@@ -1601,14 +1601,15 @@
"Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "9秒ごとにブリーフィングページを自動で切り替えます(シグナル → ニュース → ローカル)。パネルにカーソルを合わせるかフォーカスしている間は一時停止します。ライブシグナルのデータは、どちらの場合も継続的に更新されます。",
"ACQUIRING REGIONAL NEWS": "地域ニュースを取得中",
"REGIONAL NEWS UNAVAILABLE": "地域ニュースを取得できません",
"CACHED · REFRESH FAILED": "キャッシュ · 更新失敗",
"RESOLVING REGION": "地域を特定中",
"REGION UNAVAILABLE": "地域を取得できません",
"NO RECENT LOCATION MATCHES": "この地域の最近のニュースなし",
"NO NEWS IN THE LAST {hours} H": "過去{hours}時間のニュースなし",
"{domain} · {age}": "{domain} · {age}",
"{value} KM/H": "{value} km/h",
"CLOUD {pct}%": "雲量 {pct}%",
"CLOUD UNKNOWN": "雲量不明",
"{source} · LOCATION QUERY": "{source} · 位置クエリ",
"{source} · CACHED {age}": "{source} · キャッシュ {age}",
"REGIONAL NEWS": "地域ニュース",
"{label} · {radius} AIR/SEA WINDOW": "{label} · 航空機/船舶の範囲 {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 施設 {coverage}",
@@ -1666,7 +1667,7 @@
"OBSERVED / MAPPED PINGS": "観測 / マップ済みピング",
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "出典付きイベントのみ · 合成ニュースなし",
"LATEST LOCATION-MATCHED REPORTING": "位置に一致する最新の報道",
"{source} · LOCATION QUERY · RECENT": "{source} · 位置クエリ · 最近",
"{source} · LAST {hours} H": "{source} · 過去{hours}時間",
"LOCAL INFO": "ローカル情報",
"PLACE / CONDITIONS / POSITION": "場所 / 天候 / 位置",
"TIME UNKNOWN": "時刻不明",
+4 -3
View File
@@ -1649,14 +1649,15 @@
"Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Alternar as páginas do briefing automaticamente a cada 9 segundos (Sinais → Notícias → Local). A alternância pausa enquanto você passa o mouse ou foca o painel. Os dados de sinais ao vivo continuam sendo atualizados de qualquer forma.",
"ACQUIRING REGIONAL NEWS": "OBTENDO NOTÍCIAS DA ÁREA",
"REGIONAL NEWS UNAVAILABLE": "NOTÍCIAS DA ÁREA INDISPONÍVEIS",
"CACHED · REFRESH FAILED": "EM CACHE · FALHA AO ATUALIZAR",
"RESOLVING REGION": "IDENTIFICANDO REGIÃO",
"REGION UNAVAILABLE": "REGIÃO INDISPONÍVEL",
"NO RECENT LOCATION MATCHES": "NENHUMA CORRESPONDÊNCIA RECENTE COM O LOCAL",
"NO NEWS IN THE LAST {hours} H": "SEM NOTÍCIAS NAS ÚLTIMAS {hours} H",
"{domain} · {age}": "{domain} · {age}",
"{value} KM/H": "{value} KM/H",
"CLOUD {pct}%": "NUVENS {pct}%",
"CLOUD UNKNOWN": "NEBULOSIDADE DESCONHECIDA",
"{source} · LOCATION QUERY": "{source} · CONSULTA POR LOCAL",
"{source} · CACHED {age}": "{source} · EM CACHE {age}",
"REGIONAL NEWS": "NOTÍCIAS DA ÁREA",
"{label} · {radius} AIR/SEA WINDOW": "{label} · AR/MAR {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALAÇÕES: {coverage}",
@@ -1716,7 +1717,7 @@
"OBSERVED / MAPPED PINGS": "PINGS OBSERVADOS / MAPEADOS",
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "EVENTOS COM FONTE · SEM NOTÍCIAS SINTÉTICAS",
"LATEST LOCATION-MATCHED REPORTING": "ÚLTIMAS NOTÍCIAS DO LOCAL",
"{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR LOCAL · RECENTE",
"{source} · LAST {hours} H": "{source} · ÚLTIMAS {hours} H",
"LOCAL INFO": "INFO LOCAL",
"PLACE / CONDITIONS / POSITION": "LUGAR / CONDIÇÕES / POSIÇÃO",
"TIME UNKNOWN": "HORA DESCONHECIDA",
+4 -3
View File
@@ -366,10 +366,11 @@
"OVERCAST": "ПАСМУРНО",
"ACQUIRING REGIONAL NEWS": "ЗАГРУЗКА РЕГИОНАЛЬНЫХ НОВОСТЕЙ",
"REGIONAL NEWS UNAVAILABLE": "РЕГИОНАЛЬНЫЕ НОВОСТИ НЕДОСТУПНЫ",
"CACHED · REFRESH FAILED": "КЭШ · ОБНОВЛЕНИЕ НЕ УДАЛОСЬ",
"RESOLVING REGION": "ОПРЕДЕЛЕНИЕ РЕГИОНА",
"REGION UNAVAILABLE": "РЕГИОН НЕДОСТУПЕН",
"FOG": "ТУМАН",
"NO RECENT LOCATION MATCHES": "НЕТ СВЕЖИХ СОВПАДЕНИЙ ПО МЕСТУ",
"NO NEWS IN THE LAST {hours} H": "НЕТ НОВОСТЕЙ ЗА {hours} Ч",
"DRIZZLE": "МОРОСЬ",
"RAIN": "ДОЖДЬ",
"{domain} · {age}": "{domain} · {age}",
@@ -381,7 +382,7 @@
"CLOUD {pct}%": "ОБЛАЧНОСТЬ {pct}%",
"CLOUD UNKNOWN": "ОБЛАЧНОСТЬ НЕИЗВЕСТНА",
"MIXED CONDITIONS": "СМЕШАННЫЕ УСЛОВИЯ",
"{source} · LOCATION QUERY": "{source} · ЗАПРОС ПО МЕСТУ",
"{source} · CACHED {age}": "{source} · КЭШ {age}",
"REGIONAL NEWS": "НОВОСТИ РЕГИОНА",
"CYCLE ON": "АВТО: ВКЛ",
"CYCLE OFF": "АВТО: ВЫКЛ",
@@ -1956,7 +1957,7 @@
"MILITARY FEED OFF · PARTIAL PICTURE": "ВОЕННЫЙ ПОТОК ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТОК РЕЙСОВ ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЖНИЕ ВОЗДУШНЫЕ СУДА: ДАННЫЕ НЕПОЛНЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО",
"{source} · LOCATION QUERY · RECENT": "{source} · ЗАПРОС ПО МЕСТУ · НЕДАВНИЕ",
"{source} · LAST {hours} H": "{source} · ЗА {hours} Ч",
"ft": "фт",
"kts": "уз",
"PAUSE": "ПАУЗА",
+4 -3
View File
@@ -1672,14 +1672,15 @@
"Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Автоматично гортає сторінки брифінгу кожні 9 секунд (Сигнали → Новини → Місцеві дані). Пауза, доки ви наводите вказівник на панель або вона у фокусі. Дані сигналів у реальному часі однаково оновлюються безперервно.",
"ACQUIRING REGIONAL NEWS": "ОТРИМАННЯ РЕГІОНАЛЬНИХ НОВИН",
"REGIONAL NEWS UNAVAILABLE": "РЕГІОНАЛЬНІ НОВИНИ НЕДОСТУПНІ",
"CACHED · REFRESH FAILED": "КЕШ · ОНОВЛЕННЯ НЕ ВДАЛОСЯ",
"RESOLVING REGION": "ВИЗНАЧЕННЯ РЕГІОНУ",
"REGION UNAVAILABLE": "РЕГІОН НЕДОСТУПНИЙ",
"NO RECENT LOCATION MATCHES": "НЕМАЄ СВІЖИХ ЗБІГІВ ЗА РОЗТАШУВАННЯМ",
"NO NEWS IN THE LAST {hours} H": "НЕМАЄ НОВИН ЗА {hours} ГОД",
"{domain} · {age}": "{domain} · {age}",
"{value} KM/H": "{value} КМ/ГОД",
"CLOUD {pct}%": "ХМАРНІСТЬ {pct}%",
"CLOUD UNKNOWN": "ХМАРНІСТЬ НЕВІДОМА",
"{source} · LOCATION QUERY": "{source} · ЗАПИТ ЗА РОЗТАШУВАННЯМ",
"{source} · CACHED {age}": "{source} · КЕШ {age}",
"REGIONAL NEWS": "РЕГІОНАЛЬНІ НОВИНИ",
"{label} · {radius} AIR/SEA WINDOW": "{label} · РЕЙСИ / СУДНА · РАДІУС {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}",
@@ -1740,7 +1741,7 @@
"OBSERVED / MAPPED PINGS": "СПОСТЕРЕЖЕНІ / НАНЕСЕНІ КОНТАКТИ",
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "ПОДІЇ ПІДТВЕРДЖЕНІ ДЖЕРЕЛАМИ · БЕЗ ІМІТОВАНИХ НОВИН",
"LATEST LOCATION-MATCHED REPORTING": "ОСТАННІ ПУБЛІКАЦІЇ ЗА РОЗТАШУВАННЯМ",
"{source} · LOCATION QUERY · RECENT": "{source} · ЗАПИТ ЗА РОЗТАШУВАННЯМ · НЕЩОДАВНІ",
"{source} · LAST {hours} H": "{source} · ЗА {hours} ГОД",
"LOCAL INFO": "МІСЦЕВІ ДАНІ",
"PLACE / CONDITIONS / POSITION": "МІСЦЕ / УМОВИ / ПОЗИЦІЯ",
"TIME UNKNOWN": "ЧАС НЕВІДОМИЙ",
+4 -3
View File
@@ -1577,14 +1577,15 @@
"Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "每 9 秒自动轮换简报页面(信号 → 新闻 → 本地)。悬停或聚焦面板时暂停。无论是否轮换,实时信号数据都会持续刷新。",
"ACQUIRING REGIONAL NEWS": "正在获取地区新闻",
"REGIONAL NEWS UNAVAILABLE": "地区新闻不可用",
"CACHED · REFRESH FAILED": "缓存 · 刷新失败",
"RESOLVING REGION": "正在解析区域",
"REGION UNAVAILABLE": "区域不可用",
"NO RECENT LOCATION MATCHES": "暂无近期位置匹配结果",
"NO NEWS IN THE LAST {hours} H": "过去 {hours} 小时无新闻",
"{domain} · {age}": "{domain} · {age}",
"{value} KM/H": "{value} km/h",
"CLOUD {pct}%": "云量 {pct}%",
"CLOUD UNKNOWN": "云量未知",
"{source} · LOCATION QUERY": "{source} · 位置查询",
"{source} · CACHED {age}": "{source} · 缓存 {age}",
"REGIONAL NEWS": "地区新闻",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海监视窗口",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}",
@@ -1642,7 +1643,7 @@
"OBSERVED / MAPPED PINGS": "观测 / 地图标注目标",
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "来源可查的事件 · 无合成新闻",
"LATEST LOCATION-MATCHED REPORTING": "当前地区最新报道",
"{source} · LOCATION QUERY · RECENT": "{source} · 位置查询 · 近期",
"{source} · LAST {hours} H": "{source} · 过去 {hours} 小时",
"LOCAL INFO": "本地信息",
"PLACE / CONDITIONS / POSITION": "地点 / 状况 / 位置",
"TIME UNKNOWN": "时间未知",
+4 -3
View File
@@ -1567,14 +1567,15 @@
"Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "每 9 秒自動輪換簡報頁面(信號 → 新聞 → 本地)。懸停或聚焦面板時暫停。無論是否輪換,實時信號數據都會持續刷新。",
"ACQUIRING REGIONAL NEWS": "正在獲取地區新聞",
"REGIONAL NEWS UNAVAILABLE": "地區新聞不可用",
"CACHED · REFRESH FAILED": "快取 · 重新整理失敗",
"RESOLVING REGION": "正在解析區域",
"REGION UNAVAILABLE": "區域不可用",
"NO RECENT LOCATION MATCHES": "暫無近期位置匹配結果",
"NO NEWS IN THE LAST {hours} H": "過去 {hours} 小時無新聞",
"{domain} · {age}": "{domain} · {age}",
"{value} KM/H": "{value} km/h",
"CLOUD {pct}%": "雲量 {pct}%",
"CLOUD UNKNOWN": "雲量未知",
"{source} · LOCATION QUERY": "{source} · 位置查詢",
"{source} · CACHED {age}": "{source} · 快取 {age}",
"REGIONAL NEWS": "地區新聞",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}",
@@ -1632,7 +1633,7 @@
"OBSERVED / MAPPED PINGS": "觀測 / 地圖標註目標",
"SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "來源可查的事件 · 無合成新聞",
"LATEST LOCATION-MATCHED REPORTING": "當前地區最新報道",
"{source} · LOCATION QUERY · RECENT": "{source} · 位置查詢 · 近期",
"{source} · LAST {hours} H": "{source} · 過去 {hours} 小時",
"LOCAL INFO": "本地信息",
"PLACE / CONDITIONS / POSITION": "地點 / 狀況 / 位置",
"TIME UNKNOWN": "時間未知",
+23 -3
View File
@@ -1,9 +1,29 @@
/**
* The Cockpit briefing's pages and the state it starts in. It needs only the
* i18n module, so the controller and the view can both read it.
* i18n module and the regional-news window, so the controller and the view
* can both read it.
*/
import { REGIONAL_NEWS_WINDOW_HOURS } from '../data/regionalNewsWindow.js';
import { msg, t } from '../i18n/index.js';
// i18n-ignore: provider name
/** The news feed named before any briefing has answered. */
export const DEFAULT_NEWS_SOURCE = 'GOOGLE NEWS RSS';
/**
* The Regional News footer for a fresh, empty or unavailable list: the feed
* and the window its articles are held to.
* @param {string} [source]
* @param {number} [hours]
* @returns {string}
*/
export function newsWindowFooter(
source = DEFAULT_NEWS_SOURCE,
hours = REGIONAL_NEWS_WINDOW_HOURS,
) {
return t('{source} · LAST {hours} H', { source, hours });
}
export const COCKPIT_BRIEF_PAGES = [
{
id: 'signals',
@@ -15,8 +35,7 @@ export const COCKPIT_BRIEF_PAGES = [
id: 'news',
kickerKey: msg('REGIONAL NEWS'),
subtitleKey: msg('LATEST LOCATION-MATCHED REPORTING'),
sourceKey: msg('{source} · LOCATION QUERY · RECENT'),
sourceName: 'GOOGLE NEWS RSS', // i18n-ignore: provider name
// The news footer comes from the news state (`news.footer`), not here.
},
{
id: 'local',
@@ -60,6 +79,7 @@ export function createBriefState() {
statusHidden: false,
state: null,
status: t('ACQUIRING REGIONAL NEWS'),
footer: newsWindowFooter(),
articles: [],
},
local: {
+101 -20
View File
@@ -12,7 +12,14 @@ import {
COCKPIT_BRIEF_PAGES,
CYCLE_HELP_KEYS,
createBriefState,
newsWindowFooter,
} from './cockpitBriefPages.js';
import {
recentRegionalArticles,
regionalNewsWindowHours,
} from '../data/regionalNewsWindow.js';
const NEWS_ROWS = 4;
// regionalModel.weatherCodeLabel is shared with node code and returns
// English; translate it here.
@@ -52,9 +59,13 @@ export function showBriefPage(index, { manual = false } = {}) {
kicker: t(page.kickerKey),
subtitle: t(page.subtitleKey),
position: `${this.briefPageIndex + 1} / ${count}`,
source: page.sourceKey
? t(page.sourceKey, { source: page.sourceName })
: page.source,
// The news footer follows the news state, so it survives page changes.
source:
page.id === 'news'
? this.brief.news.footer
: page.sourceKey
? t(page.sourceKey)
: page.source,
});
this.publishBriefView();
if (manual && this.briefAutoRotateEnabled)
@@ -196,8 +207,8 @@ export function maybeRefreshRegionalBrief(info) {
subjectId !== this.regionalBriefSubjectId
)
return;
this.regionalBrief = payload;
this.renderRegionalBrief(payload, info);
this.regionalBrief = withCachedNews(payload, this.regionalBrief);
this.renderRegionalBrief(this.regionalBrief, info);
})
.catch((error) => {
if (
@@ -206,7 +217,11 @@ export function maybeRefreshRegionalBrief(info) {
requestToken === this.regionalBriefRequestToken &&
subjectId === this.regionalBriefSubjectId
) {
this.renderRegionalBriefStatus('unavailable', info);
if (this.regionalBrief) {
const previous = this.regionalBrief;
this.regionalBrief = cachedNews(previous, previous);
this.renderRegionalBrief(this.regionalBrief, info);
} else this.renderRegionalBriefStatus('unavailable', info);
}
})
.finally(() => {
@@ -215,6 +230,75 @@ export function maybeRefreshRegionalBrief(info) {
});
}
/**
* A brief whose news comes from `previous` after a refresh that brought none:
* the earlier articles stay, marked `cached` so they are labelled as such.
* @param {object} payload The brief to keep everything else from.
* @param {object} previous The last brief rendered for this subject.
*/
function cachedNews(payload, previous) {
return {
...payload,
newsStatus: 'cached',
newsSource: previous.newsSource,
newsWindowHours: previous.newsWindowHours,
newsFetchedAt: previous.newsFetchedAt ?? previous.retrievedAt ?? null,
articles: previous.articles,
};
}
// A refresh whose news read failed keeps the articles from before it.
function withCachedNews(payload, previous) {
return previous && payload?.newsStatus === 'unavailable'
? cachedNews(payload, previous)
: payload;
}
/**
* The Regional News state a brief presents. `ready` and `cached` have rows
* inside the window; `cached` is a refresh that failed (a client-side failure,
* or the local proxy's `stale` answer). `empty` is a source that answered with
* nothing in the window; anything else is `unavailable`.
* @returns {{state: string, status: string, footer: string, articles: object[]}}
*/
function regionalNewsView(payload) {
const hours = regionalNewsWindowHours(payload?.newsWindowHours);
// Old caches and the local path are held to the window here too.
const articles = recentRegionalArticles(payload?.articles, {
windowHours: hours,
limit: NEWS_ROWS,
});
const source = String(
payload?.newsSource || t('REGIONAL NEWS'),
).toUpperCase();
const refreshFailed =
payload?.newsStatus === 'cached' || payload?.status === 'stale';
if (articles.length && refreshFailed)
return {
state: 'cached',
status: t('CACHED · REFRESH FAILED'),
footer: t('{source} · CACHED {age}', {
source,
age: formatCockpitBriefAge(
payload?.newsFetchedAt ?? payload?.retrievedAt,
),
}),
articles,
};
const answered =
!refreshFailed && ['ready', 'empty'].includes(payload?.newsStatus);
return {
state: articles.length ? 'ready' : answered ? 'empty' : 'unavailable',
status: articles.length
? ''
: answered
? t('NO NEWS IN THE LAST {hours} H', { hours })
: t('REGIONAL NEWS UNAVAILABLE'),
footer: newsWindowFooter(source, hours),
articles,
};
}
export function renderRegionalBriefStatus(status, info) {
const { news, local } = this.brief;
news.statusHidden = false;
@@ -223,7 +307,10 @@ export function renderRegionalBriefStatus(status, info) {
status === 'loading'
? t('ACQUIRING REGIONAL NEWS')
: t('REGIONAL NEWS UNAVAILABLE');
if (status === 'unavailable') news.articles = [];
news.footer = newsWindowFooter();
// Only called with no brief for this subject: any rows are another's.
news.articles = [];
if (this.briefPageIndex === 1) this.brief.source = news.footer;
if (status === 'loading') local.place = t('RESOLVING REGION');
if (status === 'unavailable') local.place = t('REGION UNAVAILABLE');
local.coordinates = localCoordinates(info);
@@ -231,7 +318,7 @@ export function renderRegionalBriefStatus(status, info) {
}
export function renderRegionalBrief(payload, info) {
const articles = Array.isArray(payload?.articles) ? payload.articles : [];
const { state, status, footer, articles } = regionalNewsView(payload);
const seen = new Map();
const storyKey = (url) => {
const count = seen.get(url) ?? 0;
@@ -244,13 +331,11 @@ export function renderRegionalBrief(payload, info) {
const placeLabel =
payload?.place?.label || payload?.place?.country || t('REGION UNAVAILABLE');
this.brief.news = {
statusHidden: articles.length > 0,
state: payload?.newsStatus || 'unavailable',
status:
payload?.newsStatus === 'empty'
? t('NO RECENT LOCATION MATCHES')
: t('REGIONAL NEWS UNAVAILABLE'),
articles: articles.slice(0, 4).map((article) => ({
statusHidden: state === 'ready',
state,
status,
footer,
articles: articles.map((article) => ({
// A row keeps its node, and so its focus, only for the same story.
key: storyKey(article.url),
url: article.url,
@@ -284,11 +369,7 @@ export function renderRegionalBrief(payload, info) {
: '—',
};
this.brief.regionalStatus = payload?.status || 'partial';
if (this.briefPageIndex === 1) {
this.brief.source = t('{source} · LOCATION QUERY', {
source: String(payload?.newsSource || t('REGIONAL NEWS')).toUpperCase(),
});
}
if (this.briefPageIndex === 1) this.brief.source = footer;
this.publishBriefView();
this.scheduleContextLayout();
}
+235 -14
View File
@@ -3,11 +3,14 @@
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { createPinia } from 'pinia';
import { createApp, nextTick } from 'vue';
import { setLocale } from '../i18n/index.js';
import { createBriefState } from './cockpitBriefPages.js';
import {
maybeRefreshRegionalBrief,
publishBriefView,
renderRegionalBrief,
renderRegionalBriefStatus,
@@ -88,10 +91,7 @@ test('pages, the cycle toggle and the source follow the controller', async (t) =
await nextTick();
assert.equal(text('#cockpit-brief-kicker'), 'REGIONAL NEWS');
assert.equal(text('#cockpit-brief-position'), '2 / 3');
assert.equal(
text('#cockpit-brief-source'),
'GOOGLE NEWS RSS · LOCATION QUERY · RECENT',
);
assert.equal(text('#cockpit-brief-source'), 'GOOGLE NEWS RSS · LAST 24 H');
assert.deepEqual(visiblePages(), ['news']);
assert.deepEqual(current(), ['false', 'true', 'false']);
assert.equal($('#cockpit-signal-stream').dataset.briefPage, 'news');
@@ -129,7 +129,8 @@ test('regional news and local conditions render what the region returned', async
url: `https://example.com/${index}`,
title: `Story ${index}`,
domain: 'example.com',
publishedAt: new Date().toISOString(),
// Newest first is the shown order, so Story 0 is the newest.
publishedAt: new Date(Date.now() - index * 60_000).toISOString(),
})),
weather: {
temperatureC: 21.6,
@@ -159,7 +160,7 @@ test('regional news and local conditions render what the region returned', async
assert.equal(text('#cockpit-local-condition'), 'RAIN');
assert.equal(text('#cockpit-local-cloud'), 'CLOUD 40%');
assert.equal(text('#cockpit-local-precipitation'), '1.3');
assert.equal(text('#cockpit-brief-source'), 'WIRE · LOCATION QUERY');
assert.equal(text('#cockpit-brief-source'), 'WIRE · LAST 24 H');
assert.equal($('#cockpit-signal-stream').dataset.regionalStatus, 'ok');
renderRegionalBriefStatus.call(owner, 'unavailable', {});
await nextTick();
@@ -210,26 +211,246 @@ test('a refreshed news list keeps a focused story on its own link', async (t) =>
assert.equal($('#cockpit-news-list').textContent.includes('Story y'), true);
});
test('the news source names the feed only while it is current on page 2', async (t) => {
// This contract changed on purpose: the footer used to fall back to a
// static "· RECENT" on every page change, which promised nothing. It now
// follows the news state and names the window it holds articles to.
test('the news footer follows the news state across page changes, not a static RECENT', async (t) => {
const { text, owner } = fixture(t);
showBriefPage.call(owner, 1);
renderRegionalBrief.call(owner, { newsSource: 'wire', articles: [] }, INFO);
renderRegionalBrief.call(
owner,
{ newsStatus: 'ready', newsSource: 'wire', newsWindowHours: 24, articles: [story('a')] },
INFO,
);
await nextTick();
assert.equal(text('#cockpit-brief-source'), 'WIRE · LOCATION QUERY');
assert.equal(text('#cockpit-brief-source'), 'WIRE · LAST 24 H');
showBriefPage.call(owner, 2);
showBriefPage.call(owner, 1);
await nextTick();
assert.equal(
text('#cockpit-brief-source'),
'GOOGLE NEWS RSS · LOCATION QUERY · RECENT',
);
assert.equal(text('#cockpit-brief-source'), 'WIRE · LAST 24 H');
showBriefPage.call(owner, 0);
renderRegionalBrief.call(owner, { newsSource: 'wire', articles: [] }, INFO);
renderRegionalBrief.call(
owner,
{ newsStatus: 'ready', newsSource: 'wire', newsWindowHours: 12, articles: [story('a')] },
INFO,
);
await nextTick();
// Off page 2 the footer is the current page's.
assert.equal(
text('#cockpit-brief-source'),
'SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS',
);
showBriefPage.call(owner, 1);
await nextTick();
assert.equal(text('#cockpit-brief-source'), 'WIRE · LAST 12 H');
});
test('the news banner is hidden by the stylesheet, not only by the attribute', async (t) => {
// happy-dom applies no author CSS by itself, so `hidden` alone proves
// nothing: `.cockpit-brief-status { display: grid }` used to beat it.
const css = readFileSync(
fileURLToPath(new URL('./styles/cockpit.css', import.meta.url)),
'utf8',
);
assert.match(css, /\.cockpit-brief-status\[hidden\]\s*\{\s*display:\s*none;?\s*\}/);
const { $, owner } = fixture(t);
const style = document.head.appendChild(document.createElement('style'));
t.after(() => style.remove());
style.textContent = css;
showBriefPage.call(owner, 1);
renderRegionalBrief.call(owner, { newsStatus: 'ready', articles: [story('a')] }, INFO);
await nextTick();
const banner = $('#cockpit-news-status');
assert.equal(banner.hidden, true);
assert.equal(getComputedStyle(banner).display, 'none');
renderRegionalBrief.call(owner, { newsStatus: 'empty', articles: [] }, INFO);
await nextTick();
assert.equal(getComputedStyle(banner).display, 'grid');
});
const hoursAgo = (hours) => new Date(Date.now() - hours * 3_600_000).toISOString();
test('ready, cached, empty and unavailable each label the news differently', async (t) => {
const { $, text, owner } = fixture(t);
showBriefPage.call(owner, 1);
const rows = () =>
[...document.querySelectorAll('#cockpit-news-list strong')].map((row) => row.textContent);
const banner = () => ({
hidden: $('#cockpit-news-status').hidden,
state: $('#cockpit-news-status').dataset.state,
text: text('#cockpit-news-status'),
});
const ready = {
status: 'ready',
newsStatus: 'ready',
newsSource: 'Google News RSS',
newsWindowHours: 24,
newsFetchedAt: hoursAgo(0.5),
articles: [story('a'), story('b')],
};
renderRegionalBrief.call(owner, ready, INFO);
await nextTick();
assert.equal(banner().hidden, true);
assert.equal(banner().state, 'ready');
assert.deepEqual(rows(), ['Story a', 'Story b']);
assert.equal(text('#cockpit-brief-source'), 'GOOGLE NEWS RSS · LAST 24 H');
// The local proxy's stale answer is a refresh that failed.
renderRegionalBrief.call(owner, { ...ready, status: 'stale' }, INFO);
await nextTick();
assert.deepEqual(banner(), {
hidden: false,
state: 'cached',
text: 'CACHED · REFRESH FAILED',
});
assert.deepEqual(rows(), ['Story a', 'Story b']);
assert.equal(text('#cockpit-brief-source'), 'GOOGLE NEWS RSS · CACHED 30M AGO');
renderRegionalBrief.call(
owner,
{ ...ready, newsStatus: 'empty', newsSource: 'Google News RSS', articles: [] },
INFO,
);
await nextTick();
assert.deepEqual(banner(), {
hidden: false,
state: 'empty',
text: 'NO NEWS IN THE LAST 24 H',
});
assert.deepEqual(rows(), []);
renderRegionalBrief.call(
owner,
{ ...ready, newsStatus: 'unavailable', newsSource: null, articles: [] },
INFO,
);
await nextTick();
assert.deepEqual(banner(), {
hidden: false,
state: 'unavailable',
text: 'REGIONAL NEWS UNAVAILABLE',
});
assert.deepEqual(rows(), []);
});
test('articles outside the window are dropped even when a server sends them', async (t) => {
const { $, text, owner } = fixture(t);
showBriefPage.call(owner, 1);
const dated = (id, publishedAt) => ({ ...story(id), publishedAt });
// The reported case: 4, 104, 215 and 165 days old under "RECENT".
renderRegionalBrief.call(
owner,
{
newsStatus: 'ready',
articles: [
dated('old', hoursAgo(4 * 24)),
dated('older', hoursAgo(104 * 24)),
dated('undated', null),
dated('one', hoursAgo(20)),
dated('two', hoursAgo(2)),
],
},
INFO,
);
await nextTick();
assert.deepEqual(
[...document.querySelectorAll('#cockpit-news-list strong')].map((row) => row.textContent),
['Story two', 'Story one'],
);
assert.equal($('#cockpit-news-status').hidden, true);
renderRegionalBrief.call(
owner,
{ newsStatus: 'ready', articles: [dated('old', hoursAgo(25))] },
INFO,
);
await nextTick();
assert.equal(document.querySelectorAll('#cockpit-news-list li').length, 0);
assert.equal(text('#cockpit-news-status'), 'NO NEWS IN THE LAST 24 H');
});
test('a failed refresh keeps the earlier articles as cached, and clears only when there are none', async (t) => {
const { $, text, owner } = fixture(t);
const answers = [];
Object.assign(owner, {
active: true,
regionalBriefSubjectId: null,
regionalBriefAbort: null,
regionalBriefRequestToken: 0,
regionalBriefFetchedAt: 0,
regionalBrief: null,
renderRegionalBrief,
renderRegionalBriefStatus,
});
owner.services = {
...owner.services,
regionalDistanceM: () => 0,
fetchRegionalBrief: () => answers.shift()(),
};
const refresh = async (answer, info = { ...INFO, icao24: 'abc123' }) => {
answers.push(answer);
owner.regionalBriefFetchedAt = 0;
maybeRefreshRegionalBrief.call(owner, info);
await new Promise((resolve) => setTimeout(resolve, 0));
await nextTick();
};
const rows = () => document.querySelectorAll('#cockpit-news-list li').length;
showBriefPage.call(owner, 1);
// No earlier brief: an error clears and says unavailable.
await refresh(() => Promise.reject(new Error('503')));
assert.equal($('#cockpit-news-status').dataset.state, 'unavailable');
assert.equal(text('#cockpit-news-status'), 'REGIONAL NEWS UNAVAILABLE');
assert.equal(rows(), 0);
const fetchedAt = hoursAgo(1);
await refresh(() =>
Promise.resolve({
status: 'ready',
newsStatus: 'ready',
newsSource: 'Google News RSS',
newsWindowHours: 24,
newsFetchedAt: fetchedAt,
articles: [story('a'), story('b')],
}),
);
assert.equal($('#cockpit-news-status').hidden, true);
assert.equal(rows(), 2);
// A network failure keeps the rows and says so, with their age.
await refresh(() => Promise.reject(new TypeError('Failed to fetch')));
assert.equal($('#cockpit-news-status').hidden, false);
assert.equal(text('#cockpit-news-status'), 'CACHED · REFRESH FAILED');
assert.equal(rows(), 2);
assert.equal(text('#cockpit-brief-source'), 'GOOGLE NEWS RSS · CACHED 1H AGO');
showBriefPage.call(owner, 2);
showBriefPage.call(owner, 1);
await nextTick();
assert.equal(text('#cockpit-brief-source'), 'GOOGLE NEWS RSS · CACHED 1H AGO');
// So does a brief whose news read failed while weather answered.
await refresh(() =>
Promise.resolve({ status: 'partial', newsStatus: 'unavailable', articles: [] }),
);
assert.equal(text('#cockpit-news-status'), 'CACHED · REFRESH FAILED');
assert.equal(rows(), 2);
// A successful refresh clears the cached label; a true empty clears rows.
await refresh(() =>
Promise.resolve({ status: 'ready', newsStatus: 'empty', newsSource: 'Google News RSS', articles: [] }),
);
assert.equal(text('#cockpit-news-status'), 'NO NEWS IN THE LAST 24 H');
assert.equal(rows(), 0);
assert.equal(text('#cockpit-brief-source'), 'GOOGLE NEWS RSS · LAST 24 H');
// Another aircraft never inherits the last one's rows.
await refresh(() => Promise.resolve({ newsStatus: 'ready', articles: [story('c')] }));
assert.equal(rows(), 1);
await refresh(
() => Promise.reject(new Error('503')),
{ ...INFO, icao24: 'def456' },
);
assert.equal(text('#cockpit-news-status'), 'REGIONAL NEWS UNAVAILABLE');
assert.equal(rows(), 0);
});
test('an unchanged position publishes nothing new', (t) => {
+7 -1
View File
@@ -1818,7 +1818,13 @@ html:not([lang='en']) #cockpit-brief-auto {
letter-spacing: 0.13em;
text-align: center;
}
.cockpit-brief-status[data-state='unavailable'] {
/* `display: grid` above would otherwise beat the UA [hidden] rule and keep a
stale banner over a populated list. */
.cockpit-brief-status[hidden] {
display: none;
}
.cockpit-brief-status[data-state='unavailable'],
.cockpit-brief-status[data-state='cached'] {
color: #d5a863;
}
.cockpit-local-place {