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

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

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

No files matched your search

+6 -1
View File
@@ -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() });
+35 -8
View File
@@ -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 };
}
}