@meri-imperiumi/signalk-passage-briefing 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.5.2] - 2026-10-04
6
+
7
+ ### Fixed
8
+
9
+ - Cached briefings kept serving bulletin blocks computed at compile time, so filtering and parsing fixes only reached the crew after a re-fetch — on the Tactical route view the console still showed the pre-fix boilerplate while the freshly compiled conditions-here view was already clean. The `/api/briefing` serve path now recomputes the merged bulletin console from the bulletin cache against the payload's track on every serve (route and here mode alike, subsuming the old splice-when-missing logic): parsing and filtering improvements apply to cached briefings on the next page load, a payload compiled before a fresher bulletin arrived picks the newer merged view, and a payload whose bulletin cache is gone keeps its own compile-time console.
10
+ - Residual bulletin-console noise after the boilerplate rule went live: the EP1 seasonal coverage note ("FROM OCTOBER 15 TO APRIL 1, DUE TO THE CLIMATOLOGY…") survived by enumerating the newly added severity keywords (freezing spray, dense fog, volcanic ash), and the NWS area-header line ("NORTH PACIFIC EQUATOR TO 30N BETWEEN 140W AND 180W") parsed to a pole-to-pole box on its longitude band, intersecting every water on it — visible as far away as Fiji. Fixed both ways: the fixed NWS disclaimer paragraphs (seasonal note, sea-state definition, 1-minute-winds note, tropical-cyclone pointers, ice-free note, outreach appeal) are now stripped whole by their stable opening lines at cruft-cut time, and "EQUATOR" parses as a real southern bound (lat 0) in the cardinal-bounds reader, so "EQUATOR TO 30N" clips to the bulletin's actual hemisphere.
11
+ - Severity keyword false positives on product and office names: "HIGH SEAS FORECAST" (the masthead) no longer trips the HIGH SEAS keyword and "NATIONAL HURRICANE CENTER" no longer trips the HURRICANE keyword in the boilerplate classifier, so mastheads and forecaster footers cannot masquerade as warnings. HURRICANE and CYCLONE are now severity keywords (kept in sync with the webapp highlighter, which also gained freezing spray, dense fog and volcanic ash) — hurricane and tropical-cyclone paragraphs highlight and survive the console filter, as the crew intended: a cyclone near our waters is on our radar even when the exact bounding phrase parses loosely.
12
+ - The "Warnings On Your Waters" console drowned in bulletin boilerplate (day-one finding from the data-source checklist): the conservative keep for geometry-less blocks meant every NWS masthead, disclaimer ("SEAS GIVEN AS…", "SUPERSEDED BY…", "SECURITE"), section heading and forecaster footer from all four merged bulletins rendered as "warnings" while the actual warning paragraphs were correctly filtered out as off-waters. The block filter now drops blocks that carry neither parseable geography nor a severity keyword — transmission boilerplate is not a warning — with the product name "HIGH SEAS FORECAST" excluded from the severity match so the masthead cannot masquerade as a HIGH SEAS warning. Structured UKHO warnings are unaffected (they are warnings by definition). The severity keyword list gains freezing spray, dense fog and volcanic ash, synced to the webapp highlighter; a warning whose geography failed to parse still survives on its severity alone.
13
+ - NWS High Seas Forecast texts via the api.weather.gov product feed began with a `000` frame line and a bare WMO routing header (`FZPN03 KNHC 030838`), which became the bulletin header ("000") and a junk block in the console. The cruft cutter strips digit-only frame lines and routing headers without the trailing `Z`; the product code line (`HSFEP2`) survives and now serves as the bulletin header.
14
+ - The NWS api.weather.gov product resolver (configured HSF bulletin feeds) failed with `400 Bad Request`: the API started rejecting the `?limit=1` query parameter the resolver appended ("Query parameter limit is not recognized"), and the second resolution step fetched the graph's `id` field — a bare UUID, not a URL. The resolver now requests the bare locations URL, picks the newest iteration by `issuanceTime`, and follows `@id` (falling back to the UUID in the products URL). Fetch failures now carry the failing URL in the message so the source status checklist row detail names the exact request. Verified live: the NP feed returns the FZPN40 High Seas Forecast text.
15
+ - The UKHO structured-warnings source was dead twice over: the `/api/Warnings/Area/{XIV}` JSON endpoint no longer exists (the MSI site is server-rendered HTML now), and it was being queried for every resolved zone although the UKHO only coordinates NAVAREA I — other zones 404 forever. `ukhoWarningsUrl` now returns the Radio Navigational Warnings page for zone 1 and null otherwise, and the page's warning sections (reference, date-time group, full ANMB text in `<pre class="warning-description">`) parse into the same structured warning shape the old JSON fed, so the serve-time filtering pipeline is unchanged. Verified live: 28 in-force warnings parsed with references and timestamps.
16
+
17
+ ### Added
18
+
19
+ - Data source status checklist (work doc #23): every ingest path — the weather fetch, the per-zone bulletin and UKHO pulls, the configured extra bulletin feeds, the synoptic chart rasters, the GDACS hazard feed and the local celestial ephemeris — now records the outcome of its latest cycle in a persistent per-source registry (`plugin/source-status.js`, survives restarts in `source-status.json`), and the webapp renders it as a collapsed-by-default checklist card in the strategic outlook. Error classification is coarse and stable (`http-404`, `http-429`, `http-4xx`, `http-5xx`, `timeout`, `network`, `parse`, `unavailable`) so a moved URL reads differently from a dead host; cycles skipped while the boat is offline record as `offline-skipped` and never count as failures — an offline boat shows a wall of `[ SKIP ]`, which is correct information, not an error state. The checklist also covers the Signal K side: the subscribed paths the state machine and payload compile consume (internet state, navigation state, house SoC required; active route and the energy outlook optional) and the resource sources (route resources, polar, ship's time, logbook) record availability every ticker minute — a required path with no value since startup reads `[ FAIL ]`, an absent optional source renders muted `[ N/A ]`. Brackets: `[ OK ]` green, `[ WARN ]` orange (429/parse failures, or success stale beyond the source's expected refresh interval), `[ FAIL ]` red, `[ SKIP ]`/`[ N/A ]` muted; the summary line ("12 sources, 1 FAIL") stays visible while collapsed, rows expand to URL, last success, classified error and consecutive-failure count. Served at `GET /plugins/signalk-passage-briefing/sources` for external diagnostics; polling at a minute rate, rows update in place.
20
+
21
+ ## [0.5.1] - 2026-10-03
22
+
23
+ ### Fixed
24
+ - Duplicate satellite-pass lines (ISS ×2, Tiangong ×3): CelesTrak's stations group lists several catalog entries per crewed station — the docked modules ISS (ZARYA), ISS (NAUKA) and CSS (TIANHE), CSS (WENTIAN), CSS (MENGTIAN) — all mapped to the same display name, so every pass was propagated and reported once per module. `parseTLEs` now keeps one element set per station, preferring the core module's (ZARYA, TIANHE).
25
+
5
26
  ## [0.5.0] - 2026-10-03
6
27
 
7
28
  ### Added
package/SPEC.md CHANGED
@@ -164,6 +164,12 @@ The plugin monitors `network.internet.state`, `navigation.state`, and `electrica
164
164
  * **Cron Schedule Rules:** Runs at `02:15`, `08:15`, `14:15`, and `20:15` UTC (15 minutes after major global ensemble publication windows).
165
165
  * **Execution Guard:** Checks if `navigation.state === 'moored' | 'anchored'`. If `navigation.state === 'sailing'`, cron timers are disabled and data fetches are strictly tied to single explicit transitions of `network.internet.state` to `online` or `metered`.
166
166
 
167
+ ### 2.3 Data Source Status Registry (work doc #23)
168
+
169
+ Every ingest path records the outcome of its latest cycle in a persistent per-source registry (`plugin/source-status.js`, persisted in `source-status.json`) so a moved URL or dead host is diagnosable mid-passage instead of surfacing only as absence of data. Covered sources: the weather fetch, per-zone bulletin/UKHO pulls, the configured extra bulletin feeds, the synoptic chart rasters, the GDACS hazard feed, the local celestial ephemeris, and — checked on the one-minute ticker — the Signal K sources themselves (required subscribed paths: internet state, navigation state, house SoC; optional: active route, energy outlook, route resources, polar, ship's time, logbook).
170
+
171
+ Each entry carries `id`, `label`, `kind`, `url`, `lastAttemptAt`, `lastSuccessAt`, `lastError` (`{class, message}`), `consecutiveFailures`, `expectedRefreshMs` and `lastStatus` (`ok` / `fail` / `skip` / `absent`). Error classes are coarse and stable: `http-404`, `http-429`, `http-4xx`, `http-5xx`, `timeout`, `network`, `parse`, `unavailable`. Cycles the online gate skips record `offline-skipped` behavior via `lastStatus: "skip"` — never counted as failures. The registry serves at `GET /plugins/signalk-passage-briefing/sources` (diagnostics, not briefing content) and survives restarts.
172
+
167
173
  ---
168
174
 
169
175
  ## 3. Data Schemas & Structural Interfaces
@@ -563,6 +569,7 @@ Displays:
563
569
  2. Macro Sea State warnings block.
564
570
  3. Convective Warning block (CAPE / K-Index).
565
571
  4. Raw METAREA text bulletin container.
572
+ 5. `<source-status>` checklist (work doc #23): collapsed by default, summary line always visible ("12 sources, 1 FAIL"). Rows: last-attempt stamp (muted) | source label + kind | right-aligned monospace status bracket mapped by the pure `statusVerdict` view model — `[ OK ]` green, `[ WARN ]` orange (429/parse failures, or success stale beyond the source's expected refresh interval), `[ FAIL ]` red, `[ SKIP ]`/`[ N/A ]` muted. Tap expands the row's detail (URL, last success, classified error, consecutive failures). The component polls `GET /plugins/signalk-passage-briefing/sources` at a minute rate and mutates only `textContent`/class attributes; expanded state persists in `localStorage`.
566
573
 
567
574
  #### `<horizon-sparkline>` (SVG Renderer)
568
575
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-passage-briefing",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Offshore passage daily briefing webapp for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -26,15 +26,40 @@
26
26
  const SEVERE_KEYWORDS = [
27
27
  "GALE",
28
28
  "STORM",
29
+ "HURRICANE",
29
30
  "HURRICANE FORCE",
31
+ "CYCLONE",
30
32
  "SQUALL",
31
33
  "VIOLENT STORM",
32
34
  "ROUGH SEAS",
33
35
  "VERY ROUGH SEAS",
34
36
  "HIGH SEAS",
35
37
  "PHENOMENAL SEAS",
38
+ "FREEZING SPRAY",
39
+ "DENSE FOG",
40
+ "VOLCANIC ASH",
36
41
  ];
37
42
 
43
+ /**
44
+ * Whether a block carries a severity keyword (kept in sync with the
45
+ * webapp's highlighter copy in `public/components/models.mjs`).
46
+ *
47
+ * Product and office names must not trip the keywords, or every NWS
48
+ * masthead survives the boilerplate filter: "HIGH SEAS FORECAST"
49
+ * would read as a HIGH SEAS warning, "NATIONAL HURRICANE CENTER"
50
+ * as a hurricane warning.
51
+ *
52
+ * @param {string} text
53
+ * @returns {boolean}
54
+ */
55
+ function hasSevereKeyword(text) {
56
+ const upper = String(text ?? "")
57
+ .toUpperCase()
58
+ .replace(/HIGH SEAS FORECAST/g, "")
59
+ .replace(/HURRICANE CENTER/g, "");
60
+ return SEVERE_KEYWORDS.some((keyword) => upper.includes(keyword));
61
+ }
62
+
38
63
  /**
39
64
  * Retained NAVTEX B_2 subject indicators (work doc #4): A navigational
40
65
  * warnings, B gale warnings, E meteorological forecasts. The rest
@@ -61,19 +86,46 @@ const SECTION_ANCHORS =
61
86
  * @param {string} text - Raw bulletin text
62
87
  * @returns {string} Cleaned text
63
88
  */
89
+ /**
90
+ * NWS fixed disclaimer paragraphs, stripped whole by their stable
91
+ * opening line. These frame every High Seas Forecast (seasonal
92
+ * coverage note, sea-state definitions, tropical-cyclone pointers);
93
+ * they are transmission framing, not bulletin content — and with
94
+ * HURRICANE/CYCLONE in the severity keywords they would otherwise
95
+ * survive the boilerplate filter by enumeration.
96
+ */
97
+ const DISCLAIMER_START =
98
+ /^(SEAS GIVEN AS SIGNIFICANT|SUPERSEDED BY NEXT ISSUANCE|THIS HIGH SEAS FORECAST USES|FORECAST WINDS IN AND NEAR ACTIVE TROPICAL CYCLONES|ONLY YOU KNOW THE WEATHER|FOR ANY TROPICAL CYCLONE INFORMATION|ALL FORECASTS VALID OVER ICE FREE|FROM\s+[A-Z]+\s+\d+\s+TO\s+[A-Z]+\s+\d+\s*,?\s+DUE TO THE CLIMATOLOGY)/i;
99
+
64
100
  function stripBoilerplate(text) {
65
- return text
66
- .split(/\r?\n/)
67
- .filter((line) => {
68
- const trimmed = line.trim();
69
- if (/^NNNN$/.test(trimmed)) {
70
- return false; // End-of-message frame
71
- }
72
- if (/^[A-Z]{4}\d{2}\s+[A-Z]{4}\s+\d{6}Z/.test(trimmed)) {
73
- return false; // Routing header (FQPS01 NFFN 011200Z AUG 26)
74
- }
75
- return true;
76
- })
101
+ const kept = [];
102
+ let skipping = false;
103
+ for (const line of text.split(/\r?\n/)) {
104
+ const trimmed = line.trim();
105
+ if (trimmed.length === 0) {
106
+ skipping = false;
107
+ kept.push(line);
108
+ continue;
109
+ }
110
+ if (/^NNNN$/.test(trimmed)) {
111
+ continue; // End-of-message frame
112
+ }
113
+ if (/^\d{3,4}$/.test(trimmed)) {
114
+ continue; // NWS product frame line ("000")
115
+ }
116
+ if (/^[A-Z]{4}\d{2}\s+[A-Z]{4}\s+\d{6}Z?/.test(trimmed)) {
117
+ continue; // Routing header (FQPS01 NFFN 011200Z AUG 26,
118
+ // or the NWS API's bare "FZPN03 KNHC 030838")
119
+ }
120
+ if (!skipping && DISCLAIMER_START.test(trimmed)) {
121
+ skipping = true; // Fixed disclaimer paragraph: drop to the blank line
122
+ continue;
123
+ }
124
+ if (!skipping) {
125
+ kept.push(line);
126
+ }
127
+ }
128
+ return kept
77
129
  .join("\n")
78
130
  .replace(/\bGULF OF AMERICA\b/gi, "GULF OF MEXICO")
79
131
  .replace(/\n{3,}/g, "\n\n")
@@ -280,6 +332,22 @@ function parseCardinalBounds(text) {
280
332
  minLat = hemisphereDegrees(northOf[1], northOf[2].toUpperCase());
281
333
  found = true;
282
334
  }
335
+ // The equator is a real bound in NWS area headers ("NORTH PACIFIC
336
+ // EQUATOR TO 30N BETWEEN 140W AND 180W"), not a poleward default —
337
+ // without this the area line parses to a pole-to-pole box that
338
+ // intersects every water on the longitude band
339
+ const equatorTo = text.match(/EQUATOR\s+TO\s+(\d+(?:\.\d+)?)\s*([NS])/i);
340
+ if (equatorTo) {
341
+ const deg = hemisphereDegrees(equatorTo[1], equatorTo[2].toUpperCase());
342
+ if (deg >= 0) {
343
+ minLat = Math.max(minLat, 0);
344
+ maxLat = Math.min(maxLat, deg);
345
+ } else {
346
+ minLat = Math.max(minLat, deg);
347
+ maxLat = Math.min(maxLat, 0);
348
+ }
349
+ found = true;
350
+ }
283
351
 
284
352
  // EAST OF x means lon ≥ x; WEST OF y means lon ≤ y. Both present:
285
353
  // the box runs from x eastwards to y (unwrapping y across the seam
@@ -829,6 +897,13 @@ function filterBulletin({
829
897
  if (!intersectsTrack(geometry, track)) {
830
898
  return null; // Discard rule: not on our waters
831
899
  }
900
+ // The warnings console is for relevant paragraphs only: a block
901
+ // with neither geography nor severity is transmission
902
+ // boilerplate (preamble, disclaimers, footers) — dropping it
903
+ // keeps real warnings from drowning in NWS preamble text
904
+ if (!geometry && !hasSevereKeyword(blockText)) {
905
+ return null;
906
+ }
832
907
  return {
833
908
  text: blockText,
834
909
  subject: subject ?? null,
@@ -914,7 +989,11 @@ async function resolveBulletinSource(
914
989
  headers: { Accept: "text/plain" },
915
990
  });
916
991
  if (!response.ok) {
917
- throw new Error(`${response.status} ${response.statusText}`);
992
+ // The URL rides in the message: the source status checklist
993
+ // shows it verbatim in the row detail (work doc #23)
994
+ throw new Error(
995
+ `${url} returned ${response.status} ${response.statusText}`,
996
+ );
918
997
  }
919
998
  return { text: await response.text(), source: "api" };
920
999
  } finally {
@@ -922,8 +1001,11 @@ async function resolveBulletinSource(
922
1001
  }
923
1002
  }
924
1003
 
925
- // api.weather.gov: latest iteration of the product type/location
926
- const listUrl = `${apiMatch[0]}?limit=1`;
1004
+ // api.weather.gov: latest iteration of the product type/location.
1005
+ // No query parameters — the API now rejects unknown ones (limit
1006
+ // included) with a 400, so "latest" is resolved client-side by
1007
+ // sorting the returned graph on issuanceTime
1008
+ const listUrl = apiMatch[0];
927
1009
  const get = async (u) => {
928
1010
  const controller = new AbortController();
929
1011
  const timer = setTimeout(() => controller.abort(), timeoutMs ?? 15000);
@@ -933,7 +1015,9 @@ async function resolveBulletinSource(
933
1015
  headers: { Accept: "application/geo+json, application/json" },
934
1016
  });
935
1017
  if (!response.ok) {
936
- throw new Error(`${response.status} ${response.statusText}`);
1018
+ throw new Error(
1019
+ `${u} returned ${response.status} ${response.statusText}`,
1020
+ );
937
1021
  }
938
1022
  return response.json();
939
1023
  } finally {
@@ -941,11 +1025,18 @@ async function resolveBulletinSource(
941
1025
  }
942
1026
  };
943
1027
  const list = await get(listUrl);
944
- const latest = (list?.["@graph"] ?? [])[0];
945
- if (!latest?.id) {
1028
+ // "id" is a bare UUID; the fetchable product URL lives in "@id"
1029
+ const latest = [...(list?.["@graph"] ?? [])]
1030
+ .filter((product) => product?.id)
1031
+ .sort((a, b) =>
1032
+ String(b.issuanceTime ?? "").localeCompare(String(a.issuanceTime ?? "")),
1033
+ )[0];
1034
+ if (!latest) {
946
1035
  throw new Error("No product iterations available");
947
1036
  }
948
- const product = await get(latest.id);
1037
+ const productUrl =
1038
+ latest["@id"] ?? `https://api.weather.gov/products/${latest.id}`;
1039
+ const product = await get(productUrl);
949
1040
  const text = product?.productText;
950
1041
  if (typeof text !== "string" || text.length === 0) {
951
1042
  throw new Error("Product has no text");
@@ -1018,6 +1109,7 @@ module.exports = {
1018
1109
  SEVERE_KEYWORDS,
1019
1110
  SECTION_ANCHORS,
1020
1111
  stripBoilerplate,
1112
+ hasSevereKeyword,
1021
1113
  navtexSubject,
1022
1114
  shouldRetainSubject,
1023
1115
  segmentBlocks,
@@ -95,6 +95,9 @@ async function saveBulletinCache(dataDir, entries) {
95
95
  * [params.zoneBulletins] - Already-fetched zone bulletins
96
96
  * @param {typeof fetch} [params.fetchImpl]
97
97
  * @param {number} [params.timeoutMs]
98
+ * @param {Function} [params.onFailure] - Called with `(url, error)`
99
+ * for every URL that failed this cycle (source status registry,
100
+ * work doc #23)
98
101
  * @returns {Promise<{fetched: string[], failed: string[], entries: BulletinEntry[]}>}
99
102
  * Newly fetched URLs (failures listed separately) and the merged
100
103
  * cache
@@ -105,6 +108,7 @@ async function refreshBulletins({
105
108
  zoneBulletins = [],
106
109
  fetchImpl = fetch,
107
110
  timeoutMs,
111
+ onFailure,
108
112
  }) {
109
113
  const cached = await loadBulletinCache(dataDir);
110
114
  const fetched = [];
@@ -133,7 +137,8 @@ async function refreshBulletins({
133
137
  source,
134
138
  });
135
139
  fetched.push(url);
136
- } catch {
140
+ } catch (error) {
141
+ onFailure?.(url, error);
137
142
  failed.push(url);
138
143
  }
139
144
  }
@@ -225,12 +225,16 @@ async function saveHazards(dataDir, events) {
225
225
  * @param {string} params.dataDir - Plugin data directory
226
226
  * @param {typeof fetch} [params.fetchImpl]
227
227
  * @param {number} [params.timeoutMs]
228
+ * @param {Function} [params.onFailure] - Called with the failure
229
+ * (transport error or HTTP status error) when the feed could not
230
+ * be fetched this cycle (source status registry, work doc #23)
228
231
  * @returns {Promise<{fetched: boolean, events: Array<object>}>}
229
232
  */
230
233
  async function refreshHazards({
231
234
  dataDir,
232
235
  fetchImpl = fetch,
233
236
  timeoutMs = 15000,
237
+ onFailure,
234
238
  }) {
235
239
  const cached = await loadHazards(dataDir);
236
240
  let incoming = null;
@@ -242,8 +246,13 @@ async function refreshHazards({
242
246
  });
243
247
  if (response.ok) {
244
248
  incoming = parseHazardsXml(await response.text());
249
+ } else {
250
+ onFailure?.(
251
+ new Error(`${response.status} ${response.statusText}`.trim()),
252
+ );
245
253
  }
246
- } catch {
254
+ } catch (error) {
255
+ onFailure?.(error);
247
256
  incoming = null;
248
257
  } finally {
249
258
  clearTimeout(timer);