mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 05:42:22 +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
@@ -0,0 +1,7 @@
|
||||
- Cockpit Regional News holds its list to the last 24 hours, newest first, on
|
||||
the server and in the client, and labels its four states apart: fresh
|
||||
results with no banner and a `LAST 24 H` footer, earlier results kept after
|
||||
a failed refresh as `CACHED · REFRESH FAILED`, `NO NEWS IN THE LAST 24 H`,
|
||||
and `REGIONAL NEWS UNAVAILABLE`. The banner is now really hidden over a
|
||||
populated list, and the footer no longer reverts to a static `RECENT` on a
|
||||
page change.
|
||||
@@ -1932,8 +1932,9 @@ Overpass query validation, geometry simplification, disk caching and upstream
|
||||
transport now have separate modules; military-installation search reuses that
|
||||
transport. Regional briefing combines separate place, news and weather sources,
|
||||
while weather effects uses only the weather source.
|
||||
GDELT compact dates that do not name a real calendar instant keep the article
|
||||
with an unknown publication time. Existing process-scoped
|
||||
GDELT compact dates that do not name a real calendar instant normalize to an
|
||||
unknown publication time, and the 24-hour regional-news window then drops the
|
||||
article. Existing process-scoped
|
||||
caches, rate limits, stale fallbacks and route ordering are preserved.
|
||||
|
||||
Local voice has separate HUD-summary, debug-log and Realtime-token handlers,
|
||||
@@ -6096,11 +6097,11 @@ apply to `public/` assets or built-preview serving.
|
||||
`CONTEXT STANDBY`. The visible Cockpit actions, the `C` shortcut and voice use
|
||||
the same gate; voice entry still opens Contacts first, as its layer-targeted
|
||||
entry navigates through Contacts.
|
||||
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leavinLine truncated
|
||||
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Regional News holds its list to the last 24 hours (`newsWindowHours` in the brief; the client drops anything older or undated even if a server sends it) and shows four distinct states: fresh results with no banner and a `{SOURCE} · LAST 24 H` footer; earlier results kept after a failed refresh under `CACHED · REFRESH FAILED` with the cached age in the footer; `NO NEWS IN THE LAST 24 H` when a source answeLine truncated
|
||||
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
|
||||
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
|
||||
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
|
||||
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
|
||||
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query for the last day (`when:1d`) first, with the existing GDELT query (24 h timespan) retained only as a fail-soft fallback; both keep only articles published in the last 24 hours, newest first; linked headlines remain reporting, not verified incidents or risk intelligence.
|
||||
- Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`.
|
||||
- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`.
|
||||
- **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition.
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"src/ui/views/panels/CockpitSignalList.vue",
|
||||
"src/ui/views/panels/CockpitUtilityControls.vue",
|
||||
"src/ui/cockpitBriefPages.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/ui/cockpitContextState.js",
|
||||
"src/ui/cockpitInstrumentState.js",
|
||||
"src/ui/cockpitRollingValue.js",
|
||||
@@ -467,6 +468,7 @@
|
||||
"server/providers/regional/weather-effects.js",
|
||||
"server/providers/regional/weather.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/geoDistance.js",
|
||||
"src/nominatimGeocode.js",
|
||||
"src/sources/httpBody.js",
|
||||
@@ -566,6 +568,7 @@
|
||||
"src/search/nominatim.js",
|
||||
"src/nominatimGeocode.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/data/bundledJson.js",
|
||||
@@ -863,6 +866,7 @@
|
||||
"src/ui/cockpit.js",
|
||||
"src/ui/cockpitBriefing.js",
|
||||
"src/ui/cockpitBriefPages.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/ui/cockpitContextState.js",
|
||||
"src/ui/cockpitInstrumentState.js",
|
||||
"src/ui/cockpitRollingValue.js",
|
||||
@@ -992,6 +996,7 @@
|
||||
"src/ui/cockpit.js",
|
||||
"src/ui/cockpitBriefing.js",
|
||||
"src/ui/cockpitBriefPages.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/ui/cockpitContextState.js",
|
||||
"src/ui/cockpitInstrumentState.js",
|
||||
"src/ui/cockpitRollingValue.js",
|
||||
@@ -2785,6 +2790,7 @@
|
||||
"src/ui/cockpit.js",
|
||||
"src/ui/cockpitBriefing.js",
|
||||
"src/ui/cockpitBriefPages.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/ui/cockpitContextState.js",
|
||||
"src/ui/cockpitInstrumentState.js",
|
||||
"src/ui/cockpitRollingValue.js",
|
||||
@@ -3712,6 +3718,7 @@
|
||||
"src/search/nominatim.js",
|
||||
"src/nominatimGeocode.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/sources/nominatim.js",
|
||||
@@ -3955,6 +3962,7 @@
|
||||
"src/search/nominatim.js",
|
||||
"src/nominatimGeocode.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/sources/nominatim.js",
|
||||
@@ -4250,7 +4258,11 @@
|
||||
},
|
||||
"regional-source": {
|
||||
"exports": ["./sources/regional"],
|
||||
"modules": ["src/data/regionalModel.js", "src/geoDistance.js"],
|
||||
"modules": [
|
||||
"src/data/regionalModel.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/geoDistance.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"voice-action-schemas": {
|
||||
@@ -4439,6 +4451,7 @@
|
||||
"src/search/google.js",
|
||||
"src/search/geospatial.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/geoDistance.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
@@ -4463,6 +4476,7 @@
|
||||
"src/nominatimGeocode.js",
|
||||
"src/search/geospatial.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/data/regionalNewsWindow.js",
|
||||
"src/geoDistance.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { fetchRegionalPlace } from './place.js';
|
||||
import { fetchRegionalWeather } from './weather.js';
|
||||
import { fetchRegionalNews } from './news.js';
|
||||
import { validRegionalPoint } from './query.js';
|
||||
import { REGIONAL_NEWS_WINDOW_HOURS } from '../../../src/data/regionalModel.js';
|
||||
import { coalesceProxyRequest } from '../common/http.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -50,10 +51,11 @@ function regionalBriefProxy({ placeProvider = fetchRegionalPlace } = {}) {
|
||||
if (!regionalBriefHasAnySource({ place, weather, news })) {
|
||||
throw new Error('All regional briefing sources unavailable');
|
||||
}
|
||||
const retrievedAt = new Date().toISOString();
|
||||
const payload = {
|
||||
status:
|
||||
place && weather && news.status !== 'unavailable' ? 'ready' : 'partial',
|
||||
retrievedAt: new Date().toISOString(),
|
||||
retrievedAt,
|
||||
coordinates: point,
|
||||
place,
|
||||
placeStatus: place ? 'ready' : 'unavailable',
|
||||
@@ -62,6 +64,9 @@ function regionalBriefProxy({ placeProvider = fetchRegionalPlace } = {}) {
|
||||
newsStatus: news.status,
|
||||
newsQuery: news.query,
|
||||
newsSource: news.source,
|
||||
newsWindowHours: REGIONAL_NEWS_WINDOW_HOURS,
|
||||
// A cached or stale answer keeps this, so the client can show its age.
|
||||
newsFetchedAt: retrievedAt,
|
||||
articles: news.articles,
|
||||
};
|
||||
_regionalBriefCache.set(key, { payload, cachedAt: Date.now() });
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { fetchRegionalText, fetchRegionalJson } from './http.js';
|
||||
import { normalizeRegionalArticles } from '../../../src/data/regionalModel.js';
|
||||
import {
|
||||
REGIONAL_NEWS_WINDOW_HOURS,
|
||||
googleNewsWindowOperator,
|
||||
normalizeRegionalArticles,
|
||||
recentRegionalArticles,
|
||||
} from '../../../src/data/regionalModel.js';
|
||||
|
||||
function decodeRssText(value) {
|
||||
return String(value || '')
|
||||
@@ -22,7 +27,10 @@ function rssTag(block, tag) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRssArticles(xml, limit = 5) {
|
||||
// Feed items read before the window filter; Google returns about 100.
|
||||
const RSS_ITEM_CAP = 100;
|
||||
|
||||
function normalizeRssArticles(xml, limit = RSS_ITEM_CAP) {
|
||||
const seen = new Set();
|
||||
const articles = [];
|
||||
for (const match of String(xml || '').matchAll(
|
||||
@@ -57,12 +65,27 @@ function normalizeRssArticles(xml, limit = 5) {
|
||||
return articles;
|
||||
}
|
||||
|
||||
async function fetchRegionalNews(place) {
|
||||
/**
|
||||
* Headlines for a place from the last REGIONAL_NEWS_WINDOW_HOURS, newest
|
||||
* first: Google News RSS, then GDELT when RSS fails or has nothing recent.
|
||||
* `empty` means a source answered with nothing in the window; `unavailable`
|
||||
* means there was no place or no source answered.
|
||||
* @param {{locality?: string, region?: string, country?: string}|null} place
|
||||
* @param {{now?: () => number}} [options]
|
||||
*/
|
||||
async function fetchRegionalNews(place, { now = Date.now } = {}) {
|
||||
const query = place?.locality || place?.region || place?.country;
|
||||
if (!query)
|
||||
return { status: 'unavailable', query: null, articles: [], source: null };
|
||||
const recent = (articles) =>
|
||||
recentRegionalArticles(articles, {
|
||||
now: now(),
|
||||
windowHours: REGIONAL_NEWS_WINDOW_HOURS,
|
||||
limit: 5,
|
||||
});
|
||||
let answered = false;
|
||||
const rssParams = new URLSearchParams({
|
||||
q: String(query).replace(/["\\]/g, ' ').trim(),
|
||||
q: `${String(query).replace(/["\\]/g, ' ').trim()} ${googleNewsWindowOperator()}`,
|
||||
hl: 'en-US',
|
||||
gl: 'US',
|
||||
ceid: 'US:en',
|
||||
@@ -75,7 +98,8 @@ async function fetchRegionalNews(place) {
|
||||
timeoutMs: 12_000,
|
||||
},
|
||||
);
|
||||
const articles = normalizeRssArticles(xml, 5);
|
||||
answered = true;
|
||||
const articles = recent(normalizeRssArticles(xml));
|
||||
if (articles.length)
|
||||
return { status: 'ready', query, articles, source: 'Google News RSS' };
|
||||
} catch {
|
||||
@@ -87,7 +111,7 @@ async function fetchRegionalNews(place) {
|
||||
format: 'json',
|
||||
maxrecords: '5',
|
||||
sort: 'datedesc',
|
||||
timespan: '48h',
|
||||
timespan: `${REGIONAL_NEWS_WINDOW_HOURS}h`,
|
||||
});
|
||||
try {
|
||||
const payload = await fetchRegionalJson(
|
||||
@@ -97,7 +121,7 @@ async function fetchRegionalNews(place) {
|
||||
timeoutMs: 12_000,
|
||||
},
|
||||
);
|
||||
const articles = normalizeRegionalArticles(payload, 5);
|
||||
const articles = recent(normalizeRegionalArticles(payload, 5));
|
||||
return {
|
||||
status: articles.length ? 'ready' : 'empty',
|
||||
query,
|
||||
@@ -105,7 +129,10 @@ async function fetchRegionalNews(place) {
|
||||
source: 'GDELT fallback',
|
||||
};
|
||||
} catch {
|
||||
return { status: 'unavailable', query, articles: [], source: null };
|
||||
// An RSS answer with nothing recent is still a true empty result.
|
||||
return answered
|
||||
? { status: 'empty', query, articles: [], source: 'Google News RSS' }
|
||||
: { status: 'unavailable', query, articles: [], source: null };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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