mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 15:22:21 +00:00
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:
1 parent
2470b39f88
commit
dd83812a21
24 files changed
+637
-85
No files matched your search
@@ -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 }), []);
|
||||
});
|
||||
@@ -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 : [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "時刻不明",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ПАУЗА",
|
||||
|
||||
@@ -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": "ЧАС НЕВІДОМИЙ",
|
||||
|
||||
@@ -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": "时间未知",
|
||||
|
||||
@@ -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": "時間未知",
|
||||
|
||||
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user