@meri-imperiumi/signalk-passage-briefing 0.12.2 → 0.12.4

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,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.12.4] - 2026-10-17
6
+
7
+ ### Fixed
8
+ - The tropical-cyclone notification flapped on/off 27 times in ~45 min (logbook 2026-10-16, TC ONE-26): every ticker republish sent a fresh value (re-alarming boards), a GDACS dip outlasted the 2-minute clear grace and the return re-raised, and the exposure envelope tightened whenever a compile switched the reference. Four layers of anti-flap: notifications publish through a three-tier gate (raise = first publish or state change, visual+sound; amendment = same state, new GDACS figures, visual only so sounders stay quiet; identical = silence); once raised, a cyclone notification latches — it stays warnable while the storm exists in the GDACS envelope at any alert level (the wide green filter feeds a new `retainLatched` carry-over) and clears only after an hour out of the matrix; the hazard filter admits via either envelope (1000 nm from the vessel or 500 nm off the route) instead of letting the closer reference tighten the band; and acknowledgements are level- and distance-aware, persisted on disk (`hazards-state.json`, 7-day prune) so restarts stay quiet — only a strictly higher level (orange → red) or a storm closed to half the acked distance re-alarms. GDACS refreshes now amend the notification text in place rather than clearing and re-raising.
9
+ - A `weather_source: "weather-api"` configuration on a server without the Weather API provider crashed `start()` outright — taking the warnings console, the bulletins and the hazard escalation down with it. The misconfiguration now degrades to Open-Meteo for the session and logs the error, so the rest of the plugin keeps working.
10
+ - Graceful-degradation regression suite (`tests/plugin-degradation.test.js`): the plugin boots and serves empty states on a bare server — position available, but no Weather API provider (auto falls back to Open-Meteo and briefs), no active route (here mode), no energy predictor (empty `energyHourly`/`energyEvents` ride the payload), no polar management (`/api/polar` serves null, webapp falls back), no Resources API (`/api/routes` serves 501), link never online (refresh 503, briefing `cached: false`), and the hazard escalation stays quiet over empty caches.
11
+ - A TC Wati storm warning (FQPS01 NFFN 2026-10-16 fixture) parsed geometry-less and was dropped whole by the track filter — the one bulletin the console must never miss. Four parsing gaps fixed: the GMDSS section anchor matched any line starting with "FORECAST" (the warning's own "FORECAST POSITION NEAR…" line split the warning section in half, leaving gale headers and cancel notices floating unanchored), so the bare `FORECAST`/`WARNINGS`/`SYNOPSIS` anchors now require heading-only lines; NFFN spells the hemispheres out in cyclone positions ("NEAR 12.1 SOUTH 163.0 WEST", line-wrapped anywhere), which defeated both the NEAR extraction and the coordinate parser — the paired lat+lon form is now contracted to the letter form in boilerplate stripping, leaving prose ("5 SOUTH OF T1") untouched; storm-declaration detection runs per paragraph inside the block, so a coalesced block leading with the "PART 1 : WARNING STORM WARNING 012" header still types as its storm's point (kept on any track) instead of merging into a geometry-less discard; and quadrant wind-radii lines ("WITHIN 95 NAUTICAL MILES IN SE QUADRANT") coalesce onto the storm's point anchor rather than the nearest area, so the full wind field rides with the warning. The result: a vessel far outside the warning areas still reads the complete storm warning, a vessel inside the gale polygon reads it whole (header, area, cancel notice), and "WITHIN 300 NAUTICAL MILES OF TROPICAL CYCLONE WATI CENTER" resolves through the collected feature instead of stopping at "OF TROPICAL".
12
+ - The `S OF`/`N OF` latitude abbreviations matched mid-word — "BETWEEN OF 16S" read as "…BETWEEn OF 16S" and clamped the band to 16S — so NFFN's stray-"OF" latitude-band phrasing ("IN THE AREA BETWEEN OF 16S AND 22S AND BETWEEN 156W AND 169W") widened to a pole-to-pole band. The abbreviations now require a word boundary, and the BETWEEN latitude pair parses as the band it states (16S–22S).
13
+
14
+ ### Changed
15
+ - The energy predictor and polar management moved from the Signal K metadata's `requires` to `recommends`: the plugin consumes them purely through Signal K paths and the resources API, and the degradation suite proves it briefs, serves and escalates without either (built-in fallback polar, empty energy outlook). `signalk-internet` stays required — the online window drives every fetch gate.
16
+
17
+ ## [0.12.3] - 2026-10-16
18
+
5
19
  ## [0.12.2] - 2026-10-16
6
20
 
7
21
  ### Changed
@@ -9,7 +23,7 @@
9
23
 
10
24
  ### Fixed
11
25
  - The webapp console logged three requests that could never succeed (work doc #40/#30 session report): the "Next" compile pill and the sea-legs day checkpoints called `/api/api/status` and `/api/api/sea-legs` — a stray `api/` segment doubled into the plugin base ever since those two UI pieces shipped, 404ing on every load and silently hiding both features. The URLs now match the registered routes. The critical-hazard banner polled the Signal K REST notifications subtree (`vessels/self/notifications/navigation/briefing/hazards`), which 404s whenever no notification was ever raised on the server; the escalation's active list now also serves from the plugin's own `GET /api/hazards/active` (same data the delta stream carries, empty list when nominal) and the banner polls that.
12
- - The route briefing had no manual refresh affordance on a fresh cache: the "Update now" button only existed in the no-cached strip and the outdated banner, so the crew couldn't regenerate an up-to-date-looking briefing (e.g. to pick up the throttled payload shape or a fresher forecast) — while conditions-here always carried its card-head button. The tactical view's "Next 24 Hours" card head now carries the same "Update now" in the same place, wired to the route refresh and disabled offline like its here-mode twin.
26
+ - The route briefing had no manual refresh affordance on a fresh cache: the "Update now" button only existed in the no-cached strip and the outdated banner, so the crew couldn't regenerate an up-to-date-looking briefing (e.g. to pick up the throttled payload shape or a fresher forecast) — while conditions-here always carried its card-head button. The tactical view's "Next 24 Hours" card head now carries the same "Update now" in the same place, wired to the route refresh and disabled offline like its here-mode twin. The root also imports the components it instantiates — its connectedCallback runs during customElements.define, so the first renderRoute created an un-upgraded tactical-dashboard whose methods did not exist yet, aborting the boot with `tactical?.setOnline is not a function` on pages that loaded only the root module first.
13
27
 
14
28
  ## [0.12.1] - 2026-10-16
15
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-passage-briefing",
3
- "version": "0.12.2",
3
+ "version": "0.12.4",
4
4
  "description": "Offshore passage daily briefing webapp for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -31,11 +31,11 @@
31
31
  "doc/here-brief.png"
32
32
  ],
33
33
  "requires": [
34
- "@meri-imperiumi/signalk-energy-predictor",
35
- "@meri-imperiumi/signalk-internet",
36
- "signalk-polar-management"
34
+ "@meri-imperiumi/signalk-internet"
37
35
  ],
38
36
  "recommends": [
37
+ "@meri-imperiumi/signalk-energy-predictor",
38
+ "signalk-polar-management",
39
39
  "signalk-watch-schedule",
40
40
  "@meri-imperiumi/signalk-ships-time"
41
41
  ]
@@ -71,7 +71,7 @@ const RETAINED_SUBJECTS = "ABE";
71
71
  * leading-dot NWS style (`.WARNINGS.`, `.SYNOPSIS AND FORECAST.`)
72
72
  * is tolerated alongside the GMDSS `PART 1` headings. */
73
73
  const SECTION_ANCHORS =
74
- /^\.?\s*(PARTS?\s+\d+\b.*(?:WARNING|SYNOPSIS|FORECAST)|WARNINGS?|SYNOPSIS(?:\s+AND\s+FORECAST)?|FORECAST)\b/i;
74
+ /^(?:\.?\s*PARTS?\s+\d+\b.*(?:WARNING|SYNOPSIS|FORECAST)\b|\.?\s*(?:WARNINGS?|SYNOPSIS(?:\s+AND\s+FORECAST)?|FORECAST)\s*\.?\s*$)/i;
75
75
 
76
76
  /**
77
77
  * Strips transmission routing boilerplate: NAVTEX `ZCZC`/`NNNN`
@@ -134,6 +134,16 @@ function stripBoilerplate(text) {
134
134
  // all-caps bulletins, and an unparsed latitude bound widens the
135
135
  // area to pole-to-pole
136
136
  .replace(/\bO(?=\d)/g, "0")
137
+ // NFFN spells the hemispheres out in cyclone positions ("NEAR
138
+ // 12.1 SOUTH 163.0 WEST", line-wrapped anywhere): contracted to
139
+ // the letter form the coordinate and NEAR parsers expect. Only
140
+ // the paired lat+lon form is rewritten, so prose like "5 SOUTH
141
+ // OF T1" is left alone.
142
+ .replace(
143
+ /\b(\d+(?:\.\d+)?)\s+(NORTH|SOUTH)\s+(\d+(?:\.\d+)?)\s+(EAST|WEST)\b/gi,
144
+ (_text, lat, latWord, lon, lonWord) =>
145
+ `${lat}${latWord[0].toUpperCase()} ${lon}${lonWord[0].toUpperCase()}`,
146
+ )
137
147
  .replace(/\n{3,}/g, "\n\n")
138
148
  .trim()
139
149
  );
@@ -267,9 +277,14 @@ function isWarningSection(sectionBlocks) {
267
277
  */
268
278
  function coalesceWarningBlocks(sectionBlocks, features = new Map()) {
269
279
  const anchored = [];
280
+ const pointAnchors = [];
270
281
  sectionBlocks.forEach((block, i) => {
271
- if (extractGeometry(block, features) != null) {
282
+ const geometry = extractGeometry(block, features);
283
+ if (geometry != null) {
272
284
  anchored.push(i);
285
+ if (geometry.type === "point") {
286
+ pointAnchors.push(i);
287
+ }
273
288
  }
274
289
  });
275
290
  if (anchored.length === 0) {
@@ -280,6 +295,10 @@ function coalesceWarningBlocks(sectionBlocks, features = new Map()) {
280
295
  // nearest anchor, ties to the earlier one — a trailer describes the
281
296
  // area above it. Coordinate-bearing blocks that place to null stay
282
297
  // unowned: they emit as their own blocks, never merged.
298
+ // Quadrant wind-radii lines ("WITHIN 95 NAUTICAL MILES IN SE
299
+ // QUADRANT") describe the storm, not the nearest area: they prefer
300
+ // a declared storm's point anchor so they keep or drop with the
301
+ // warning they belong to.
283
302
  const owner = new Map();
284
303
  for (const i of anchored) {
285
304
  owner.set(i, i);
@@ -288,7 +307,11 @@ function coalesceWarningBlocks(sectionBlocks, features = new Map()) {
288
307
  if (owner.has(i) || parseCoordinatePoints(sectionBlocks[i]).length > 0) {
289
308
  continue;
290
309
  }
291
- const target = anchored.reduce((best, candidate) =>
310
+ const candidates =
311
+ /QUADRANT/i.test(sectionBlocks[i]) && pointAnchors.length > 0
312
+ ? pointAnchors
313
+ : anchored;
314
+ const target = candidates.reduce((best, candidate) =>
292
315
  Math.abs(candidate - i) < Math.abs(best - i) ? candidate : best,
293
316
  );
294
317
  owner.set(i, target);
@@ -431,16 +454,29 @@ function parseCardinalBounds(text) {
431
454
  // full word is matched first so the abbreviation cannot swallow it
432
455
  // (an unmatched N OF left the band pole-to-pole and a North Pacific
433
456
  // synopsis intersected every water on its longitude band).
434
- const southOf = text.match(/(?:SOUTH|S)\s+OF\s+(\d+(?:\.\d+)?)\s*([NS])/i);
457
+ const southOf = text.match(/\b(?:SOUTH|S)\s+OF\s+(\d+(?:\.\d+)?)\s*([NS])/i);
435
458
  if (southOf) {
436
459
  maxLat = hemisphereDegrees(southOf[1], southOf[2].toUpperCase());
437
460
  found = true;
438
461
  }
439
- const northOf = text.match(/(?:NORTH|N)\s+OF\s+(\d+(?:\.\d+)?)\s*([NS])/i);
462
+ const northOf = text.match(/\b(?:NORTH|N)\s+OF\s+(\d+(?:\.\d+)?)\s*([NS])/i);
440
463
  if (northOf) {
441
464
  minLat = hemisphereDegrees(northOf[1], northOf[2].toUpperCase());
442
465
  found = true;
443
466
  }
467
+ // NFFN states the latitude band as a pair — with the occasional
468
+ // stray "OF" ("IN THE AREA BETWEEN OF 16S AND 22S"). Longitude
469
+ // BETWEEN pairs (EW) are matched separately below.
470
+ const latBand = text.match(
471
+ /BETWEEN\s+(?:OF\s+)?(\d+(?:\.\d+)?)\s*([NS])\s+AND\s+(\d+(?:\.\d+)?)\s*([NS])/i,
472
+ );
473
+ if (latBand) {
474
+ const a = hemisphereDegrees(latBand[1], latBand[2].toUpperCase());
475
+ const b = hemisphereDegrees(latBand[3], latBand[4].toUpperCase());
476
+ minLat = Math.max(minLat, Math.min(a, b));
477
+ maxLat = Math.min(maxLat, Math.max(a, b));
478
+ found = true;
479
+ }
444
480
  // The equator is a real bound in NWS area headers ("NORTH PACIFIC
445
481
  // EQUATOR TO 30N BETWEEN 140W AND 180W"), not a poleward default —
446
482
  // without this the area line parses to a pole-to-pole box that
@@ -606,14 +642,23 @@ function resolveNamedEndpoints(text, features) {
606
642
  * block's first coordinate pair. Null when the block declares a
607
643
  * system but carries no position.
608
644
  *
645
+ * The anchored-per-paragraph test (not the whole-block test) keeps
646
+ * coalesced warning blocks working: they lead with the "PART 1 :
647
+ * WARNING STORM WARNING 012" header, and a whole-block anchored test
648
+ * would type the merged block geometry-less — dropping the whole
649
+ * storm warning for any vessel outside the forecast line.
650
+ *
609
651
  * @param {string} text - Block text
610
652
  * @returns {number[]|null} [lon, lat]
611
653
  */
612
654
  function stormDeclarationPoint(text) {
613
- if (!STORM_DECLARATION.test(String(text ?? "").trim())) {
655
+ const declaring = String(text ?? "")
656
+ .split(/\n\n/)
657
+ .find((paragraph) => STORM_DECLARATION.test(paragraph.trim()));
658
+ if (!declaring) {
614
659
  return null;
615
660
  }
616
- const near = text.match(
661
+ const near = declaring.match(
617
662
  /NEAR\s+(\d+(?:\.\d+)?)\s*([NS])\s+(\d+(?:\.\d+)?)\s*([EW])/i,
618
663
  );
619
664
  if (near) {
@@ -622,7 +667,7 @@ function stormDeclarationPoint(text) {
622
667
  hemisphereDegrees(near[1], near[2].toUpperCase()),
623
668
  ];
624
669
  }
625
- const points = parseCoordinatePoints(text);
670
+ const points = parseCoordinatePoints(declaring);
626
671
  return points.length > 0 ? points[0] : null;
627
672
  }
628
673
 
@@ -643,7 +688,10 @@ function stormReferenceGeometry(text, features, bufferNm) {
643
688
  return null;
644
689
  }
645
690
  const match = text.match(
646
- new RegExp(`\\b(?:OF|AROUND)\\s+(${FEATURE_REFERENCE})\\b`, "i"),
691
+ new RegExp(
692
+ `\\b(?:OF|AROUND)\\s+(?:(?:SEVERE\\s+)?TROPICAL\\s+(?:CYCLONE|STORM|DEPRESSION|HURRICANE|TYPHOON)\\s+)?(${FEATURE_REFERENCE})\\b`,
693
+ "i",
694
+ ),
647
695
  );
648
696
  if (!match) {
649
697
  return null;
@@ -887,12 +935,13 @@ function composeFeatureBounds(text, features) {
887
935
  */
888
936
  function extractGeometry(blockText, features = new Map()) {
889
937
  // A storm declaration block is its own geometry: the analysed
890
- // position of the named system, typed "point"
891
- if (STORM_DECLARATION.test(blockText.trim())) {
892
- const point = stormDeclarationPoint(blockText);
893
- if (point) {
894
- return { type: "point", coordinates: point };
895
- }
938
+ // position of the named system, typed "point". Declarations are
939
+ // found per paragraph, so a coalesced block leading with the
940
+ // "PART 1 : WARNING STORM WARNING 012" header still types as its
941
+ // storm's point.
942
+ const stormPoint = stormDeclarationPoint(blockText);
943
+ if (stormPoint) {
944
+ return { type: "point", coordinates: stormPoint };
896
945
  }
897
946
  const bandMatch = blockText.match(
898
947
  /WITHIN\s+(\d+(?:\.\d+)?)\s*(?:NM|NAUTICAL\s+MILES?)\b/i,
@@ -39,6 +39,10 @@ const STATE = { emergency: "emergency", error: "error" };
39
39
  */
40
40
  const MOORED_STATES = new Set(["moored", "anchored"]);
41
41
 
42
+ /** GDACS alert-level ranks (duplicated from hazard-source.js; this
43
+ * module stays dependency-free). */
44
+ const ALERT_RANK = { green: 0, orange: 1, red: 2 };
45
+
42
46
  /**
43
47
  * Classifies one refresh's warning stream into notifications.
44
48
  *
@@ -70,15 +74,23 @@ function escalateHazards({
70
74
  typeof navigationState === "string" && MOORED_STATES.has(navigationState);
71
75
 
72
76
  // Tropical cyclone: GDACS TC at Red or Orange — the compile filters
73
- // established impact (near vessel or route)
77
+ // established impact (near vessel or route). The notification is
78
+ // marked `latch` (held through source dips, work doc #30) and
79
+ // carries the alert level so an acknowledgement can be level-aware:
80
+ // orange ack silences orange, a later red re-alarms.
74
81
  for (const event of gdacsEvents ?? []) {
75
82
  if (
76
83
  event?.type === "TC" &&
77
- (event.alertLevel === "red" || event.alertLevel === "orange")
84
+ (event.alertLevel === "red" ||
85
+ event.alertLevel === "orange" ||
86
+ event.latched === true)
78
87
  ) {
79
88
  notifications.push({
80
89
  id: `gdacs-${event.id}`,
81
90
  eventId: event.id,
91
+ level: event.alertLevel ?? null,
92
+ distanceNm: event.distanceNm ?? null,
93
+ latch: true,
82
94
  state: STATE.emergency,
83
95
  message: `Tropical cyclone: ${event.title ?? event.type} — ${event.distanceNm ?? "?"} nm from the vessel`,
84
96
  });
@@ -141,11 +153,99 @@ function escalateHazards({
141
153
  return notifications;
142
154
  }
143
155
 
156
+ /**
157
+ * Warnability carry-over for latched tropical-cyclone notifications
158
+ * (work doc #30 anti-flap): once a cyclone notification is raised, it
159
+ * stays warnable while the cyclone still exists in the GDACS envelope
160
+ * — even when its alert level dips below the fresh-escalation floor.
161
+ * GDACS recomputes with every model run and borderline systems flap
162
+ * orange/green; the vessel should not re-alarm on each flap. Fresh
163
+ * escalations still need the configured minimum level; ids held
164
+ * active by the hysteresis ride at any level the feed carries.
165
+ *
166
+ * @param {object} params
167
+ * @param {Array<object>} [params.events] - GDACS events filtered at
168
+ * the wide (green) level, distances computed
169
+ * @param {Map<string, object>} [params.previous] - Previously active
170
+ * notifications by id (the hysteresis state)
171
+ * @param {string} [params.minLevel="orange"] - Fresh-escalation floor
172
+ * @returns {Array<object>} Events to escalate this cycle
173
+ */
174
+ function retainLatched({ events, previous, minLevel = "orange" }) {
175
+ const minRank = ALERT_RANK[String(minLevel).toLowerCase()] ?? 1;
176
+ const latched = new Set(
177
+ [...(previous ?? new Map())]
178
+ .filter(([, notification]) => notification?.latch === true)
179
+ .map(([id]) => id),
180
+ );
181
+ const kept = [];
182
+ const seen = new Set();
183
+ for (const event of events ?? []) {
184
+ const id = `gdacs-${event?.id}`;
185
+ if (seen.has(id)) {
186
+ continue;
187
+ }
188
+ seen.add(id);
189
+ const rank = ALERT_RANK[String(event?.alertLevel ?? "").toLowerCase()];
190
+ if (rank >= minRank) {
191
+ kept.push(event);
192
+ } else if (latched.has(id)) {
193
+ kept.push({ ...event, latched: true });
194
+ }
195
+ }
196
+ return kept;
197
+ }
198
+
199
+ /**
200
+ * Whether one escalation may raise (or re-raise) its notification,
201
+ * given the recorded acknowledgement (work doc #30 anti-flap): an ack
202
+ * silences the same alert level and below — a strictly higher level
203
+ * (orange acked, red now) escalates through and re-alarms. So does a
204
+ * cyclone that has closed to half the distance it was at ack time:
205
+ * the situation materially changed even at the same level.
206
+ * Notifications without a level (bulletin direct-hits, CAP) suppress
207
+ * on any ack, as before.
208
+ *
209
+ * @param {object} params
210
+ * @param {object} params.notification - The current escalation
211
+ * @param {{level: string|null, distanceNm: number|null,
212
+ * at: string}|undefined} params.ack - The recorded acknowledgement,
213
+ * if any
214
+ * @returns {boolean}
215
+ */
216
+ function shouldRaise({ notification, ack }) {
217
+ if (!ack) {
218
+ return true;
219
+ }
220
+ const level = String(notification?.level ?? "").toLowerCase();
221
+ if (!(level in ALERT_RANK)) {
222
+ return false; // Level-less notification: any ack suppresses
223
+ }
224
+ const ackRank = ALERT_RANK[String(ack?.level ?? "").toLowerCase()];
225
+ if (ALERT_RANK[level] > (ackRank ?? -1)) {
226
+ return true; // Escalation: orange acked, red now
227
+ }
228
+ const ackDistance = Number(ack?.distanceNm);
229
+ const distance = Number(notification?.distanceNm);
230
+ if (
231
+ Number.isFinite(ackDistance) &&
232
+ ackDistance > 0 &&
233
+ Number.isFinite(distance) &&
234
+ distance <= ackDistance / 2
235
+ ) {
236
+ return true; // Closed to half the acked distance: materially closer
237
+ }
238
+ return false;
239
+ }
240
+
144
241
  /**
145
242
  * Suppression hysteresis (work doc #30): a notification that dips out
146
243
  * of the matrix for one refresh cycle does not flap the alarm — it
147
244
  * clears only after `missesToClear` consecutive cycles without
148
- * qualifying. Pure function over the previous active set.
245
+ * qualifying. Pure function over the previous active set. Latched
246
+ * notifications (tropical cyclones) clear only after
247
+ * `latchMissesToClear` — GDACS dips are minutes long, and a clear
248
+ * followed by a re-raise re-alarms the crew for the same storm.
149
249
  *
150
250
  * @param {object} params
151
251
  * @param {Map<string, object>} params.previous - Previously active
@@ -154,10 +254,18 @@ function escalateHazards({
154
254
  * @param {Map<string, number>} [params.missCounts] - Per-id
155
255
  * consecutive miss counters (mutated)
156
256
  * @param {number} [params.missesToClear=2]
257
+ * @param {number} [params.latchMissesToClear=60] - Cycles a latched
258
+ * notification survives before clearing
157
259
  * @returns {{active: Map<string, object>, cleared: string[]}} The new
158
260
  * active set and the ids that cleared this cycle
159
261
  */
160
- function applyHysteresis({ previous, current, missCounts, missesToClear = 2 }) {
262
+ function applyHysteresis({
263
+ previous,
264
+ current,
265
+ missCounts,
266
+ missesToClear = 2,
267
+ latchMissesToClear = 60,
268
+ }) {
161
269
  const misses = missCounts ?? new Map();
162
270
  const active = new Map();
163
271
  const cleared = [];
@@ -167,14 +275,17 @@ function applyHysteresis({ previous, current, missCounts, missesToClear = 2 }) {
167
275
  active.set(notification.id, notification);
168
276
  }
169
277
  // Previously active ids that dipped out: carry them through the
170
- // miss grace period (borderline geometry must not flap the alarm)
278
+ // miss grace period (borderline geometry must not flap the alarm);
279
+ // latched notifications earn the long grace
171
280
  for (const [id, notification] of previous ?? []) {
172
281
  if (currentIds.has(id)) {
173
282
  continue;
174
283
  }
284
+ const threshold =
285
+ notification?.latch === true ? latchMissesToClear : missesToClear;
175
286
  const count = (misses.get(id) ?? 0) + 1;
176
287
  misses.set(id, count);
177
- if (count >= missesToClear) {
288
+ if (count >= threshold) {
178
289
  cleared.push(id);
179
290
  misses.delete(id);
180
291
  } else {
@@ -191,6 +302,9 @@ function applyHysteresis({ previous, current, missCounts, missesToClear = 2 }) {
191
302
  module.exports = {
192
303
  STATE,
193
304
  MOORED_STATES,
305
+ ALERT_RANK,
194
306
  escalateHazards,
307
+ retainLatched,
308
+ shouldRaise,
195
309
  applyHysteresis,
196
310
  };
@@ -361,39 +361,53 @@ function filterHazards({
361
361
  if (rank == null || rank < minRank) {
362
362
  continue;
363
363
  }
364
- // Distance and bearing from the vessel, then from the nearest
365
- // route sample — the better (closer) reference admits the event
366
- let best = null;
367
- if (vessel && Number.isFinite(vessel.lat) && Number.isFinite(vessel.lon)) {
368
- best = {
369
- distanceNm: distanceNm(vessel.lat, vessel.lon, event.lat, event.lon),
370
- bearingDeg: bearingDeg(vessel.lat, vessel.lon, event.lat, event.lon),
371
- from: "vessel",
372
- };
373
- }
364
+ // Either envelope admits: the vessel's ahead-radius or the route
365
+ // corridor. The old best-reference-wins rule let the closer
366
+ // reference tighten the envelope — a compile switching the
367
+ // exposure reference (here mode admits at 1000 nm from the
368
+ // vessel, route mode at 500 nm off the nearest sample) tipped a
369
+ // stationary cyclone out of the matrix and back, flapping the
370
+ // notification without the storm moving an inch.
371
+ const vesselRef =
372
+ vessel && Number.isFinite(vessel.lat) && Number.isFinite(vessel.lon)
373
+ ? {
374
+ distanceNm: distanceNm(
375
+ vessel.lat,
376
+ vessel.lon,
377
+ event.lat,
378
+ event.lon,
379
+ ),
380
+ bearingDeg: bearingDeg(
381
+ vessel.lat,
382
+ vessel.lon,
383
+ event.lat,
384
+ event.lon,
385
+ ),
386
+ }
387
+ : null;
388
+ let routeRef = null;
374
389
  for (const point of route) {
375
390
  const d = distanceNm(point.lat, point.lon, event.lat, event.lon);
376
- if (!best || d < best.distanceNm) {
377
- best = {
391
+ if (routeRef === null || d < routeRef.distanceNm) {
392
+ routeRef = {
378
393
  distanceNm: d,
379
394
  bearingDeg: bearingDeg(point.lat, point.lon, event.lat, event.lon),
380
- from: "route",
381
395
  };
382
396
  }
383
397
  }
384
- if (!best) {
385
- continue;
386
- }
387
398
  const within =
388
- (best.from === "vessel" && best.distanceNm <= aheadRadiusNm) ||
389
- (best.from === "route" && best.distanceNm <= offRouteRadiusNm);
399
+ (vesselRef !== null && vesselRef.distanceNm <= aheadRadiusNm) ||
400
+ (routeRef !== null && routeRef.distanceNm <= offRouteRadiusNm);
390
401
  if (!within) {
391
402
  continue;
392
403
  }
404
+ // Report vessel-centric distance and bearing when the vessel is
405
+ // known — the crew thinks in distance from the boat
406
+ const ref = vesselRef ?? routeRef;
393
407
  surviving.push({
394
408
  ...event,
395
- distanceNm: Math.round(best.distanceNm * 10) / 10,
396
- bearingDeg: Math.round(best.bearingDeg),
409
+ distanceNm: Math.round(ref.distanceNm * 10) / 10,
410
+ bearingDeg: Math.round(ref.bearingDeg),
397
411
  });
398
412
  }
399
413
  return surviving.sort((a, b) =>
@@ -0,0 +1,73 @@
1
+ /**
2
+ * On-disk acknowledgement state for critical hazard notifications
3
+ * (work doc #30 anti-flap): the ack must survive plugin and server
4
+ * restarts, or every restart re-alarms the crew for a cyclone they
5
+ * already acknowledged. Acks are keyed by notification id and record
6
+ * the alert level they silenced, so a later escalation to a strictly
7
+ * higher level (orange → red) re-alarms while same-level refreshes
8
+ * stay quiet.
9
+ *
10
+ * Entries age out after a week: a cyclone acknowledged today should
11
+ * still be silent next restart, but ack memory must not grow
12
+ * unbounded, and a week-old hazard id is stale by any measure.
13
+ *
14
+ * @file hazard-state.js
15
+ */
16
+
17
+ const { readFileSync, renameSync, writeFileSync } = require("node:fs");
18
+ const { join } = require("node:path");
19
+
20
+ const ACK_FILE = "hazards-state.json";
21
+ const ACK_TTL_MS = 7 * 24 * 60 * 60 * 1000;
22
+
23
+ /**
24
+ * Loads the acknowledgement map, pruning stale entries.
25
+ *
26
+ * @param {string} dataDir - Plugin data directory
27
+ * @returns {Map<string, {level: string|null, at: string}>} Ack by
28
+ * notification id; empty when no state exists yet or the file is
29
+ * corrupt (a crashed read must not take the escalation down —
30
+ * re-alarms are annoying, a dead plugin is worse)
31
+ */
32
+ function loadHazardAcks(dataDir) {
33
+ const acks = new Map();
34
+ try {
35
+ const raw = JSON.parse(readFileSync(join(dataDir, ACK_FILE), "utf8"));
36
+ const now = Date.now();
37
+ for (const [id, record] of Object.entries(raw?.acked ?? {})) {
38
+ const at = new Date(record?.at).getTime();
39
+ if (Number.isFinite(at) && now - at < ACK_TTL_MS) {
40
+ acks.set(id, record);
41
+ }
42
+ }
43
+ } catch {
44
+ // No state yet (first boot) or unreadable file: start empty
45
+ }
46
+ return acks;
47
+ }
48
+
49
+ /**
50
+ * Saves the acknowledgement map, pruning stale entries and writing
51
+ * atomically (tmp file + rename, so a crash mid-write cannot leave a
52
+ * half-read JSON behind).
53
+ *
54
+ * @param {string} dataDir - Plugin data directory
55
+ * @param {Map<string, {level: string|null, at: string}>} acks
56
+ * @returns {void}
57
+ */
58
+ function saveHazardAcks(dataDir, acks) {
59
+ const acked = {};
60
+ const now = Date.now();
61
+ for (const [id, record] of acks ?? []) {
62
+ const at = new Date(record?.at).getTime();
63
+ if (Number.isFinite(at) && now - at < ACK_TTL_MS) {
64
+ acked[id] = record;
65
+ }
66
+ }
67
+ const path = join(dataDir, ACK_FILE);
68
+ const tmp = `${path}.tmp`;
69
+ writeFileSync(tmp, `${JSON.stringify({ acked }, null, 2)}\n`);
70
+ renameSync(tmp, path);
71
+ }
72
+
73
+ module.exports = { loadHazardAcks, saveHazardAcks, ACK_FILE, ACK_TTL_MS };