@meri-imperiumi/signalk-passage-briefing 0.12.1 → 0.12.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,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.12.2] - 2026-10-16
6
+
7
+ ### Changed
8
+ - Briefing payloads stopped carrying every forecast hour at every waypoint: the waypoints now ride an ETA-window throttle (a 15-waypoint / 7-day route went from ~1 MB to ~340 KB of JSON with no rendering change). The window opens 12 h before the arrival a fast boat (10 kn) would make and closes 48 h after the arrival a slow boat (2.5 kn) would make — steps outside are dropped, since the boat reads its neighbouring waypoints' data then — and inside the window the hourly steps beyond the first 24 h collapse to every third hour, the wave models' own native resolution (`weatherAt` interpolates). The horizon's last step always rides along (the simulator clamps to it), a guaranteed minimum span keeps coverage when the speed estimate is badly wrong, and the throttle applies to both source paths (Open-Meteo and the Weather API provider).
9
+
10
+ ### Fixed
11
+ - 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.
13
+
5
14
  ## [0.12.1] - 2026-10-16
6
15
 
7
16
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-passage-briefing",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "description": "Offshore passage daily briefing webapp for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -59,6 +59,39 @@ const DEFAULT_FORECAST_DAYS = 7;
59
59
  */
60
60
  const KEEP_SNAPSHOTS = 8;
61
61
 
62
+ /**
63
+ * One hour in milliseconds.
64
+ */
65
+ const HOUR_MS = 3600000;
66
+
67
+ /**
68
+ * Throttle speed bracket: the fastest arrival the window must cover
69
+ * (window opens before it).
70
+ */
71
+ const THROTTLE_FAST_KN = 10;
72
+
73
+ /**
74
+ * Throttle speed bracket: the slowest arrival the window must cover
75
+ * (window closes after it, plus the tail margin).
76
+ */
77
+ const THROTTLE_SLOW_KN = 2.5;
78
+
79
+ /**
80
+ * Throttle window: opens this long before the fastest arrival.
81
+ */
82
+ const THROTTLE_LEAD_HOURS = 12;
83
+
84
+ /**
85
+ * Throttle window: closes this long after the slowest arrival.
86
+ */
87
+ const THROTTLE_TAIL_HOURS = 48;
88
+
89
+ /**
90
+ * Throttle resolution: hourly steps inside the first span of the
91
+ * window, every third hour after.
92
+ */
93
+ const THROTTLE_HOURLY_HOURS = 24;
94
+
62
95
  const FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
63
96
  const MARINE_URL = "https://marine-api.open-meteo.com/v1/marine";
64
97
 
@@ -614,6 +647,7 @@ async function fetchWeatherAlongTrack({
614
647
  models.push("marine:smoc_currents");
615
648
  }
616
649
  const fetchedAt = new Date().toISOString();
650
+ throttleWaypointForecasts(payloadWaypoints, fetchedAt);
617
651
  return {
618
652
  metadata: {
619
653
  fetchedAt,
@@ -633,6 +667,83 @@ async function fetchWeatherAlongTrack({
633
667
  };
634
668
  }
635
669
 
670
+ /**
671
+ * ETA-window throttle for waypoint forecasts (briefing size, bergie
672
+ * session: a 15-waypoint / 7-day route carried every hour of the full
673
+ * horizon at every waypoint — over a megabyte of JSON to render a few
674
+ * KB of page). The boat is at waypoint i only around its arrival, and
675
+ * the arrival time depends on boat speed — so the window opens
676
+ * `THROTTLE_LEAD_HOURS` before the arrival a fast boat would make and
677
+ * closes `THROTTLE_TAIL_HOURS` after the arrival a slow boat would
678
+ * make; steps outside are dropped. Inside the window the hourly steps
679
+ * beyond the first `THROTTLE_HOURLY_HOURS` collapse to every third
680
+ * hour: the wave models underneath are 3-hourly anyway, and
681
+ * `weatherAt` interpolates between steps. A guaranteed minimum span
682
+ * keeps coverage even when the estimate is badly wrong, and the
683
+ * horizon's last step always rides along (the sim clamps to it).
684
+ *
685
+ * @param {Array<{distanceFromStartNm: number, forecasts: Array<object>}>}
686
+ * payloadWaypoints - Mutated in place
687
+ * @param {string|number|Date} fetchedAt - The fetch instant the
688
+ * estimates anchor to (the payload's `fetchedAt`)
689
+ * @returns {Array} The same waypoints, forecasts throttled
690
+ */
691
+ function throttleWaypointForecasts(payloadWaypoints, fetchedAt) {
692
+ const startMs = new Date(fetchedAt).getTime();
693
+ if (!Array.isArray(payloadWaypoints) || !Number.isFinite(startMs)) {
694
+ return payloadWaypoints ?? [];
695
+ }
696
+ for (const waypoint of payloadWaypoints) {
697
+ const steps = waypoint?.forecasts;
698
+ if (!Array.isArray(steps) || steps.length === 0) {
699
+ continue;
700
+ }
701
+ const times = steps.map((step) => new Date(step.timestamp).getTime());
702
+ const firstMs = times[0];
703
+ const lastMs = times[times.length - 1];
704
+ const distNm = Number.isFinite(waypoint.distanceFromStartNm)
705
+ ? Math.max(0, waypoint.distanceFromStartNm)
706
+ : 0;
707
+ let windowStart = Math.max(
708
+ firstMs,
709
+ startMs +
710
+ (distNm / THROTTLE_FAST_KN) * HOUR_MS -
711
+ THROTTLE_LEAD_HOURS * HOUR_MS,
712
+ );
713
+ const windowEnd = Math.min(
714
+ lastMs,
715
+ startMs +
716
+ (distNm / THROTTLE_SLOW_KN) * HOUR_MS +
717
+ THROTTLE_TAIL_HOURS * HOUR_MS,
718
+ );
719
+ // Guaranteed minimum span: a wildly wrong speed estimate must not
720
+ // strip a waypoint bare
721
+ if (windowEnd - windowStart < 48 * HOUR_MS) {
722
+ windowStart = Math.max(firstMs, windowEnd - 48 * HOUR_MS);
723
+ }
724
+ const inWindow = steps.filter(
725
+ (_step, i) => times[i] >= windowStart && times[i] <= windowEnd,
726
+ );
727
+ if (inWindow.length === 0) {
728
+ continue;
729
+ }
730
+ const baseMs = new Date(inWindow[0].timestamp).getTime();
731
+ const lastInWindow = inWindow.length - 1;
732
+ waypoint.forecasts = inWindow.filter((step, i) => {
733
+ // The horizon's last step always rides along: weatherAt clamps
734
+ // to it, so a dropped tail would freeze the sim on stale values
735
+ if (i === lastInWindow) {
736
+ return true;
737
+ }
738
+ const hours = Math.round(
739
+ (new Date(step.timestamp).getTime() - baseMs) / HOUR_MS,
740
+ );
741
+ return hours < THROTTLE_HOURLY_HOURS || hours % 3 === 0;
742
+ });
743
+ }
744
+ return payloadWaypoints;
745
+ }
746
+
636
747
  /**
637
748
  * Cache directory for a data dir.
638
749
  *
@@ -774,6 +885,7 @@ module.exports = {
774
885
  normalizeLocations,
775
886
  buildTimeSteps,
776
887
  fetchWeatherAlongTrack,
888
+ throttleWaypointForecasts,
777
889
  savePayload,
778
890
  loadPayload,
779
891
  listCachedRoutes,
package/plugin/index.js CHANGED
@@ -2873,6 +2873,25 @@ module.exports = (app) => {
2873
2873
  // the notification returns to nominal for every connected
2874
2874
  // client, and the escalation does not re-raise the event until
2875
2875
  // it leaves the matrix and returns
2876
+ // Active critical-hazard notifications (work doc #30): the
2877
+ // same list the escalation publishes over the Signal K stream,
2878
+ // served here so the webapp banner doesn't have to poll the
2879
+ // REST notifications subtree (absent path = noisy 404)
2880
+ router.get("/api/hazards/active", (_req, res) => {
2881
+ const notifications = [];
2882
+ for (const [id, notification] of activeHazardNotifications) {
2883
+ if (acknowledgedHazards.has(id)) {
2884
+ continue; // Acked: suppressed until the event returns
2885
+ }
2886
+ notifications.push({
2887
+ id,
2888
+ state: notification.state,
2889
+ message: notification.message,
2890
+ });
2891
+ }
2892
+ res.json({ notifications });
2893
+ });
2894
+
2876
2895
  router.post("/api/hazards/ack", async (req, res) => {
2877
2896
  const id = typeof req.body?.id === "string" ? req.body.id : null;
2878
2897
  if (!id) {
@@ -31,7 +31,10 @@
31
31
  * @file weather-source.js
32
32
  */
33
33
 
34
- const { fetchWeatherAlongTrack } = require("./fetch-engine.js");
34
+ const {
35
+ fetchWeatherAlongTrack,
36
+ throttleWaypointForecasts,
37
+ } = require("./fetch-engine.js");
35
38
 
36
39
  /**
37
40
  * Meters per second to knots.
@@ -177,6 +180,7 @@ function buildApiPayload(waypoints, forecastsByWaypoint) {
177
180
  };
178
181
  });
179
182
  const fetchedAt = new Date().toISOString();
183
+ throttleWaypointForecasts(payloadWaypoints, fetchedAt);
180
184
  return {
181
185
  metadata: {
182
186
  fetchedAt,
@@ -515,13 +515,13 @@ class PassageOutlook extends HTMLElement {
515
515
  // The next scheduled compile window rides /api/status (the same
516
516
  // state-machine field the checklist reads); its absence degrades
517
517
  // the Next pill to hidden, never to an invented stamp
518
- this._nextCron = await fetchJson(`${PLUGIN_API}/api/status`, 8000)
518
+ this._nextCron = await fetchJson(`${PLUGIN_API}/status`, 8000)
519
519
  .then((status) => status?.nextCronRun ?? null)
520
520
  .catch(() => null);
521
521
  // Sea legs (work doc #40): the passage day context. Absent or
522
522
  // failed lookups degrade to null — the timeline checkpoints
523
523
  // simply don't render, never invented
524
- this._seaLegs = await fetchJson(`${PLUGIN_API}/api/sea-legs`, 8000)
524
+ this._seaLegs = await fetchJson(`${PLUGIN_API}/sea-legs`, 8000)
525
525
  .then((s) => s?.passageStartedAt ?? null)
526
526
  .catch(() => null);
527
527
  this._briefing = await fetchJson(`${PLUGIN_API}/briefing${query}`, 20000);
@@ -830,6 +830,14 @@ class PassageOutlook extends HTMLElement {
830
830
  ? "<strategic-outlook></strategic-outlook>"
831
831
  : "<tactical-dashboard></tactical-dashboard>";
832
832
  this._applyModeToView();
833
+ // The tactical card head carries the route's Update now — the
834
+ // same affordance conditions-here has in its card head; disabled
835
+ // offline like the here button (the server refuses the refresh)
836
+ const tactical = this._view.querySelector("tactical-dashboard");
837
+ tactical?.setOnline(this._status?.online === true);
838
+ if (tactical) {
839
+ tactical.onRefresh = () => this.refreshBriefing();
840
+ }
833
841
  if (this._exceptions) {
834
842
  this.renderData();
835
843
  }
@@ -287,38 +287,33 @@ export async function fetchNavigationState() {
287
287
  }
288
288
 
289
289
  /**
290
- * The plugin's critical-hazard notifications
291
- * (`notifications.navigation.briefing.hazards.*`), best effort — the
292
- * path is absent when nothing is active.
290
+ * The plugin's active critical-hazard notifications (work doc #30),
291
+ * from the plugin's own `api/hazards/active` endpoint — the same list
292
+ * the escalation publishes over the Signal K stream. Polling the REST
293
+ * notifications subtree instead 404'd whenever nothing was active.
293
294
  *
294
295
  * @returns {Promise<Array<{id: string, state: string, message: string}>|
295
- * null>} Active notifications (state !== normal), null when the
296
- * path or server is unavailable
296
+ * null>} Active notifications, null when the server is unreachable
297
297
  */
298
298
  export async function fetchHazardNotifications() {
299
- const unwrap = (node) =>
300
- node && typeof node === "object" && "value" in node ? node.value : node;
301
299
  try {
302
- const subtree = await fetchJson(
303
- `${BASE}/api/vessels/self/notifications/navigation/briefing/hazards`,
300
+ const body = await fetchJson(
301
+ "/plugins/signalk-passage-briefing/api/hazards/active",
304
302
  );
305
- const node = unwrap(subtree);
306
- if (!node || typeof node !== "object") {
303
+ if (
304
+ !body ||
305
+ typeof body !== "object" ||
306
+ !Array.isArray(body.notifications)
307
+ ) {
307
308
  return null;
308
309
  }
309
- const notifications = [];
310
- for (const [id, raw] of Object.entries(node)) {
311
- const value = unwrap(raw);
312
- if (!value || typeof value !== "object" || value.state === "normal") {
313
- continue;
314
- }
315
- notifications.push({
316
- id,
317
- state: String(value.state ?? "warn"),
318
- message: String(value.message ?? ""),
319
- });
320
- }
321
- return notifications;
310
+ return body.notifications
311
+ .filter((n) => n && typeof n === "object" && n.state !== "normal")
312
+ .map((n) => ({
313
+ id: String(n.id),
314
+ state: String(n.state ?? "warn"),
315
+ message: String(n.message ?? ""),
316
+ }));
322
317
  } catch {
323
318
  return null;
324
319
  }
@@ -99,9 +99,21 @@ strong.sev {
99
99
  text-overflow: ellipsis;
100
100
  }
101
101
  .sky span { margin-right: 10px; }
102
+ .card-head {
103
+ display: flex; align-items: center; justify-content: space-between;
104
+ gap: 8px;
105
+ }
106
+ .card-head h2 { margin: 0; }
107
+ .refresh {
108
+ min-height: 36px; padding: 6px 12px; flex: none;
109
+ }
110
+ .refresh:disabled { opacity: 0.5; }
102
111
  </style>
103
112
  <section class="sk-card theme-teal">
104
- <h2>Next 24 Hours</h2>
113
+ <div class="card-head">
114
+ <h2>Next 24 Hours</h2>
115
+ <button id="refresh" class="refresh">Update now</button>
116
+ </div>
105
117
  <div class="now">
106
118
  <span class="label">AWS</span>
107
119
  <span class="value" id="aws">—</span>
@@ -128,6 +140,9 @@ strong.sev {
128
140
  this._awsEl = this.shadowRoot.getElementById("aws");
129
141
  this._tierEl = this.shadowRoot.getElementById("tier");
130
142
  this._cinfo = this.shadowRoot.getElementById("cinfo");
143
+ this._refreshEl = this.shadowRoot.getElementById("refresh");
144
+ this._refreshEl.disabled = !this._online;
145
+ this._refreshEl.addEventListener("click", () => this.onRefresh?.());
131
146
  this._spark = this.shadowRoot.getElementById("spark");
132
147
  this._departureControl =
133
148
  this.shadowRoot.getElementById("departure-control");
@@ -230,6 +245,27 @@ strong.sev {
230
245
  */
231
246
  onDepartureChange = null;
232
247
 
248
+ /**
249
+ * Online state: the refresh button only fires while the link is up
250
+ * (the server refuses offline refreshes).
251
+ *
252
+ * @param {boolean} online
253
+ */
254
+ setOnline(online) {
255
+ this._online = online === true;
256
+ if (this._refreshEl) {
257
+ this._refreshEl.disabled = !this._online;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Refresh callback (set by the root component): posts the route
263
+ * refresh and reloads — the tactical view otherwise has no way to
264
+ * recompile between scheduled windows, and a cached payload
265
+ * predating a new payload field would render stale forever.
266
+ */
267
+ onRefresh = null;
268
+
233
269
  /**
234
270
  * Briefing payload (work docs #3, #17): carries the space events
235
271
  * and zone transitions the timeline merges in alongside the
@@ -18,6 +18,7 @@ const {
18
18
  kIndex,
19
19
  normalizeLocations,
20
20
  fetchWeatherAlongTrack,
21
+ throttleWaypointForecasts,
21
22
  savePayload,
22
23
  loadPayload,
23
24
  listCachedRoutes,
@@ -133,6 +134,101 @@ const WAYPOINTS = sampleRoutePoints([
133
134
  [0, 1.5],
134
135
  ]);
135
136
 
137
+ describe("throttleWaypointForecasts", () => {
138
+ const HOUR = 3600000;
139
+ const FETCH = "2026-10-16T00:00:00.000Z";
140
+
141
+ /** Builds one hourly step. */
142
+ const step = (h) => ({
143
+ timestamp: new Date(Date.parse(FETCH) + h * HOUR).toISOString(),
144
+ surface: { tws: h },
145
+ });
146
+
147
+ test("windows bracket the arrival across the speed range", () => {
148
+ const waypoints = [
149
+ {
150
+ distanceFromStartNm: 0,
151
+ forecasts: Array.from({ length: 168 }, (_, h) => step(h)),
152
+ },
153
+ {
154
+ distanceFromStartNm: 210,
155
+ forecasts: Array.from({ length: 168 }, (_, h) => step(h)),
156
+ },
157
+ ];
158
+ throttleWaypointForecasts(waypoints, FETCH);
159
+ const second = waypoints[1].forecasts.map((f) =>
160
+ Math.round((Date.parse(f.timestamp) - Date.parse(FETCH)) / HOUR),
161
+ );
162
+ // Fast boat: 210 nm at 10 kn arrives at 21 h, window opens 12 h
163
+ // before — the early hours the boat cannot be there yet are gone
164
+ assert.equal(second[0], 9);
165
+ // Slow boat: 210 nm at 2.5 kn arrives at 84 h, window closes 48 h
166
+ // after — beyond that the boat is reading the next waypoint's data
167
+ assert.equal(second[second.length - 1], 132);
168
+ });
169
+
170
+ test("hourly steps collapse to 3-hourly past the first day of the window", () => {
171
+ const waypoints = [
172
+ {
173
+ distanceFromStartNm: 0,
174
+ forecasts: Array.from({ length: 168 }, (_, h) => step(h)),
175
+ },
176
+ ];
177
+ throttleWaypointForecasts(waypoints, FETCH);
178
+ const stamps = waypoints[0].forecasts.map((f) =>
179
+ Math.round((Date.parse(f.timestamp) - Date.parse(FETCH)) / HOUR),
180
+ );
181
+ // The departure waypoint's window closes 48 h after its
182
+ // (immediate) arrival: 24 hourly steps, then every third hour
183
+ assert.equal(stamps.length, 24 + (48 - 24) / 3 + 1);
184
+ assert.equal(stamps[stamps.length - 1], 48);
185
+ for (let i = 24; i < stamps.length - 1; i++) {
186
+ assert.equal(stamps[i] % 3, 0);
187
+ }
188
+ });
189
+
190
+ test("a guaranteed minimum span survives a wildly slow boat", () => {
191
+ // 420 nm: the slow-bracket arrival (2.5 kn) sits past the 168 h
192
+ // horizon, so the window rides the whole horizon's tail — plenty
193
+ // of coverage either way, and the last step must survive decimation
194
+ const waypoints = [
195
+ {
196
+ distanceFromStartNm: 420,
197
+ forecasts: Array.from({ length: 168 }, (_, h) => step(h)),
198
+ },
199
+ ];
200
+ throttleWaypointForecasts(waypoints, FETCH);
201
+ const stamps = waypoints[0].forecasts.map((f) =>
202
+ Math.round((Date.parse(f.timestamp) - Date.parse(FETCH)) / HOUR),
203
+ );
204
+ assert.ok(stamps.length >= 16, "at least two days' 3-hourly coverage");
205
+ assert.equal(stamps[stamps.length - 1], 167);
206
+ });
207
+
208
+ test("short fixtures pass through unchanged", () => {
209
+ const forecasts = [step(0), step(1)];
210
+ const waypoints = [{ distanceFromStartNm: 0, forecasts }];
211
+ throttleWaypointForecasts(waypoints, FETCH);
212
+ assert.equal(waypoints[0].forecasts.length, 2);
213
+ });
214
+
215
+ test("roughly a quarter of the original payload remains on a week-long route", () => {
216
+ // 15 waypoints, 30 nm apart, 7-day horizon: the shape behind the
217
+ // 1.3 MB briefing
218
+ const waypoints = Array.from({ length: 15 }, (_, i) => ({
219
+ distanceFromStartNm: i * 30,
220
+ forecasts: Array.from({ length: 168 }, (_, h) => step(h)),
221
+ }));
222
+ const before = waypoints.reduce((sum, wp) => sum + wp.forecasts.length, 0);
223
+ throttleWaypointForecasts(waypoints, FETCH);
224
+ const after = waypoints.reduce((sum, wp) => sum + wp.forecasts.length, 0);
225
+ assert.ok(after <= before * 0.35, `${after} of ${before} steps remain`);
226
+ for (const wp of waypoints) {
227
+ assert.ok(wp.forecasts.length > 0, "no waypoint stripped bare");
228
+ }
229
+ });
230
+ });
231
+
136
232
  describe("fetchWeatherAlongTrack", () => {
137
233
  test("builds the SPEC §3.1 payload with unit conversions", async () => {
138
234
  const payload = await fetchWeatherAlongTrack({
@@ -2687,3 +2687,31 @@ test("routes listing: cached with stale-while-revalidate, distances active-only"
2687
2687
  assert.equal(listCalls, 1);
2688
2688
  plugin.stop();
2689
2689
  });
2690
+
2691
+ test("active hazard notifications serve from the plugin's own endpoint", async () => {
2692
+ // Work doc #30 webapp transport: the escalation's active list rides
2693
+ // GET /api/hazards/active (200 with an empty list when nothing is
2694
+ // active) — polling the Signal K REST notifications subtree instead
2695
+ // 404'd whenever no notification had ever been raised
2696
+ const app = createMockApp();
2697
+ const plugin = pluginFactory(app);
2698
+ plugin.start({});
2699
+ plugin.registerWithRouter(app.router);
2700
+ const route = app.getRoutes().find((r) => r.path === "/api/hazards/active");
2701
+ assert.ok(route, "hazards/active route registered");
2702
+ const res = {
2703
+ code: null,
2704
+ payload: null,
2705
+ status(code) {
2706
+ this.code = code;
2707
+ return this;
2708
+ },
2709
+ json(payload) {
2710
+ this.payload = payload;
2711
+ },
2712
+ };
2713
+ await route.handler({}, res);
2714
+ assert.equal(res.code, null);
2715
+ assert.deepEqual(res.payload, { notifications: [] });
2716
+ plugin.stop();
2717
+ });