@meri-imperiumi/signalk-passage-briefing 0.2.0 → 0.3.0

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,10 +2,101 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.3.0] - 2026-09-28
6
+
7
+ ### Added
8
+
9
+ - ETA percentile rows in the strategic outlook flag night arrivals
10
+ with a moon marker: arrival day/night is computed at the
11
+ destination for each of P10/P50/P90.
12
+
13
+ - "No sails" stretches now say why the canvas is down: `No sails -
14
+ drifting` when the plan drifts below the motoring wind threshold,
15
+ `Motoring` when the engine pushes — reconciling the sail-work
16
+ queue with a zero engine-hours plan (drift mode).
17
+
18
+ ### Changed
19
+
20
+ - Strategic outlook layout: motor hours and fuel use the shared
21
+ stat styling, and the sail-work queue renders as cards (tack/gybe
22
+ highlighted) like the tactical action queue.
23
+
24
+ - "Warnings On Your Waters" and the synoptic surface-analysis chart
25
+ now also appear on the tactical dashboard, not just the strategic
26
+ view; the chart's night palette follows the document mode via its
27
+ own observation (it previously never inverted when embedded).
28
+ Shared card/stat styles moved into the common shadow-DOM base
29
+ stylesheet.
30
+
31
+ - Sail-change events no longer flap when the forecast sits on a
32
+ matrix bin edge: a suggested state must hold through a full
33
+ simulation step before it enters the sail-work queue.
34
+
35
+ - Fuel is handled in liters end to end (SI — no imperial units):
36
+ the motor burn rate is configurable as `Motor Fuel Consumption
37
+ (liters per hour)` with a 1.8 l/h default, and the strategic ETA
38
+ table shows e.g. `72.0 l` instead of gallons.
39
+
40
+ - The tactical "Next 24 Hours" hero readout is labeled `AWS` and
41
+ shows its unit: `AWS 17.0 kn` instead of a bare number. (Signal K
42
+ carries wind in SI m/s internally; the briefing displays the
43
+ nautical kn.)
44
+
45
+ - Sail-change cards in the tactical dashboard and the sail-work
46
+ timeline in the strategic outlook render the canonical sail-state
47
+ keys as human-readable labels: `GENOA_1_30_FURLED_MAIN_1_REEF`
48
+ reads "Genoa 1 30% furled + Main 1 reef", `NO_SAILS` reads "No
49
+ sails". Unparseable keys still fall back to the raw form.
50
+
51
+ ### Fixed
52
+
53
+ - A scheduled (oneshot/cron) refresh no longer fails forever when
54
+ the last briefed route has been deleted from resources: the stale
55
+ `last-route` pointer is removed and the refresh falls back to
56
+ keeping conditions-here fresh. Previously every cycle died with
57
+ `Briefing refresh failed (oneshot): Resource not found!`.
58
+
59
+ - The scheduled (oneshot/cron) route refresh no longer pulls the
60
+ bulletin stack twice per cycle: the briefing refresh already
61
+ fetches bulletins and synoptics for the track, so the outer
62
+ duplicate pass is gone.
63
+
64
+ - SWPC solar-weather timestamps are UTC but carry no offset, so
65
+ they were parsed in the server's local timezone: on a boat far
66
+ from Greenwich the entire Kp forecast window shifted and aurora
67
+ alerts degraded. Offsetless timestamps are now read as UTC.
68
+
69
+ - Switching the route selector between "Conditions here" and a route
70
+ now actually swaps the view: the tactical/strategic shell is
71
+ rebuilt for the served mode (previously the tabbed views never
72
+ came back after visiting conditions-here, so route selection
73
+ appeared to do nothing). Stale model data from the previous
74
+ selection is dropped instead of flashing.
75
+
76
+ - Selecting "Conditions here" in the route picker now actually
77
+ serves conditions-here: the briefing API treated an empty `route`
78
+ parameter as "serve the route being sailed", so the selection
79
+ silently returned the same route view. An explicit `?route=`
80
+ (even empty) now selects; only a fully omitted parameter falls
81
+ back to the active route.
82
+
83
+ - The webapp shows a loading state while a briefing loads or
84
+ refreshes (compiles can take tens of seconds on a slow link —
85
+ silence read as a broken app), and timed-out requests say the
86
+ server is busy and to retry instead of surfacing the cryptic
87
+ engine abort text. Rapid mode switching can no longer apply a
88
+ stale response after a newer one.
89
+
90
+ ## [0.2.1] - 2026-09-28
91
+
5
92
  ## [0.2.0] - 2026-09-28
6
93
 
7
94
  ### Fixed
8
95
 
96
+ - The route simulation crashed with `startTime.getTime is not a
97
+ function` when the caller passed the payload's ISO timestamp
98
+ string instead of a Date — simulatePassage now accepts both.
99
+
9
100
  - The plotter widget HTML and JS are cache-busted with the plugin
10
101
  version, so widget updates actually reach the host's iframe —
11
102
  a stale cached copy was showing a removed Open button.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-passage-briefing",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Offshore passage daily briefing webapp for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "keywords": [
16
16
  "signalk-node-server-plugin",
17
- "signalk-category-utility",
17
+ "signalk-category-weather",
18
18
  "signalk-webapp"
19
19
  ],
20
20
  "author": "Henri Bergius <henri.bergius@iki.fi>",
@@ -26,7 +26,7 @@
26
26
  "signalk-plugin-enabled-by-default": false,
27
27
  "signalk": {
28
28
  "displayName": "Passage Briefing",
29
- "appIcon": "./icon.png",
29
+ "appIcon": "./icon-256.png",
30
30
  "screenshots": [
31
31
  "doc/here-brief.png"
32
32
  ],
@@ -87,7 +87,13 @@ function parseKpForecast(json, { from = new Date(), hours = 24 } = {}) {
87
87
  const end = start + hours * 3600000;
88
88
  const entries = [];
89
89
  for (const row of json) {
90
- const t = new Date(row?.time_tag).getTime();
90
+ // SWPC time_tag is UTC but offsetless: parsing it bare would
91
+ // interpret it in the server's timezone and shift the whole
92
+ // window (a UTC+13 boat must see the same forecast as a UTC one)
93
+ const raw = typeof row?.time_tag === "string" ? row.time_tag : "";
94
+ const t = new Date(
95
+ /[Zz]|[+-]\d\d:?\d\d$/.test(raw) ? raw : `${raw}Z`,
96
+ ).getTime();
91
97
  const kp = row?.kp;
92
98
  if (!Number.isFinite(t) || typeof kp !== "number" || !Number.isFinite(kp)) {
93
99
  continue;
package/plugin/index.js CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  const { join } = require("node:path");
22
22
  const { homedir } = require("node:os");
23
- const { mkdir, readFile, writeFile } = require("node:fs/promises");
23
+ const { mkdir, readFile, unlink, writeFile } = require("node:fs/promises");
24
24
 
25
25
  const { PassageStateMachine } = require("./state-machine.js");
26
26
  const { PassageDatabase } = require("./sqlite-db.js");
@@ -125,6 +125,7 @@ const HERE_TTL_MS = 3 * 60 * 60 * 1000;
125
125
  const DEFAULTS = {
126
126
  motoring_tws_threshold: 3.5,
127
127
  drift_mode_enabled: true,
128
+ motor_fuel_l_per_hour: 1.8,
128
129
  waterline_length_m: 9.4,
129
130
  spool_directory: join(homedir(), ".signalk", "spool", "passage-outlook"),
130
131
  k_heel: 0.35,
@@ -168,6 +169,7 @@ module.exports = (app) => {
168
169
  let simulationConfig = {
169
170
  motoring_tws_threshold: DEFAULTS.motoring_tws_threshold,
170
171
  drift_mode_enabled: DEFAULTS.drift_mode_enabled,
172
+ motor_fuel_l_per_hour: DEFAULTS.motor_fuel_l_per_hour,
171
173
  waterline_length_m: DEFAULTS.waterline_length_m,
172
174
  k_heel: DEFAULTS.k_heel,
173
175
  k_pitch: DEFAULTS.k_pitch,
@@ -631,7 +633,12 @@ module.exports = (app) => {
631
633
  }
632
634
  let coordinates;
633
635
  if (typeof app.resourcesApi?.getResource === "function") {
634
- const route = await app.resourcesApi.getResource("routes", routeId);
636
+ let route;
637
+ try {
638
+ route = await app.resourcesApi.getResource("routes", routeId);
639
+ } catch (error) {
640
+ throw new Error(`Route ${routeId} not found (${error.message})`);
641
+ }
635
642
  coordinates = route?.feature?.geometry?.coordinates;
636
643
  }
637
644
  if (!Array.isArray(coordinates) || coordinates.length < 2) {
@@ -687,6 +694,12 @@ module.exports = (app) => {
687
694
  * trigger). The multi-endpoint fetch lands in the briefing refresh;
688
695
  * the outcome is surfaced through the plugin status.
689
696
  *
697
+ * The route being sailed wins; otherwise the last briefed one is
698
+ * re-briefed. A route that has vanished from resources (deleted
699
+ * after it was briefed) is skipped instead of failing every future
700
+ * refresh — with no route left, conditions-here stays fresh (work
701
+ * doc #7).
702
+ *
690
703
  * @param {"oneshot"|"cron"} trigger
691
704
  */
692
705
  async function runFetch(trigger) {
@@ -694,41 +707,41 @@ module.exports = (app) => {
694
707
  setStatus(`Fetch skipped while offline (${trigger})`);
695
708
  return;
696
709
  }
697
- // The route being sailed wins; otherwise re-brief the last one;
698
- // with no route at all keep conditions-here fresh (work doc #7)
699
- let routeId = activeRouteId(observations[ACTIVE_ROUTE_PATH]);
700
- if (!routeId) {
701
- try {
702
- routeId = (
703
- await readFile(
704
- join(app.getDataDirPath(), "weather", "last-route"),
705
- "utf8",
706
- )
707
- ).trim();
708
- } catch (_error) {
709
- routeId = null;
710
- }
710
+ const lastRouteFile = join(app.getDataDirPath(), "weather", "last-route");
711
+ let lastRouteId = null;
712
+ try {
713
+ lastRouteId = (await readFile(lastRouteFile, "utf8")).trim();
714
+ } catch (_error) {
715
+ lastRouteId = null;
711
716
  }
712
- if (!routeId) {
717
+ const activeId = activeRouteId(observations[ACTIVE_ROUTE_PATH]);
718
+ const candidates = [...new Set([activeId, lastRouteId].filter(Boolean))];
719
+ for (const routeId of candidates) {
713
720
  try {
714
- const here = await refreshHere();
715
- setStatus(`Conditions here cached at ${here.cachedAt} (${trigger})`);
721
+ const result = await refreshBriefing(routeId);
722
+ setStatus(
723
+ `Briefing for ${routeId} cached at ${result.fetchedAt} (${trigger})`,
724
+ );
725
+ return;
716
726
  } catch (error) {
717
- app.error(`Here refresh failed (${trigger}): ${error.message}`);
718
- setError(`Here refresh failed (${trigger}): ${error.message}`);
727
+ if (!/not found/i.test(error.message)) {
728
+ app.error(`Briefing refresh failed (${trigger}): ${error.message}`);
729
+ setError(`Briefing refresh failed (${trigger}): ${error.message}`);
730
+ return;
731
+ }
732
+ // Route deleted since it was briefed: try the next candidate
733
+ app.debug?.(`Route ${routeId} no longer exists (${trigger})`);
734
+ if (routeId === lastRouteId && routeId !== activeId) {
735
+ await unlink(lastRouteFile).catch(() => {});
736
+ }
719
737
  }
720
- return;
721
738
  }
722
739
  try {
723
- const result = await refreshBriefing(routeId);
724
- // Bulletins ride the same online window (work doc #4 §1)
725
- await refreshBulletinsOnline(trigger, result.waypoints);
726
- setStatus(
727
- `Briefing for ${routeId} cached at ${result.fetchedAt} (${trigger})`,
728
- );
740
+ const here = await refreshHere();
741
+ setStatus(`Conditions here cached at ${here.cachedAt} (${trigger})`);
729
742
  } catch (error) {
730
- app.error(`Briefing refresh failed (${trigger}): ${error.message}`);
731
- setError(`Briefing refresh failed (${trigger}): ${error.message}`);
743
+ app.error(`Here refresh failed (${trigger}): ${error.message}`);
744
+ setError(`Here refresh failed (${trigger}): ${error.message}`);
732
745
  }
733
746
  }
734
747
 
@@ -951,6 +964,14 @@ module.exports = (app) => {
951
964
  title: "Enable Drift Mode (Zero Fuel / Current Drift)",
952
965
  default: DEFAULTS.drift_mode_enabled,
953
966
  },
967
+ motor_fuel_l_per_hour: {
968
+ type: "number",
969
+ title: "Motor Fuel Consumption (liters per hour)",
970
+ description:
971
+ "Burn rate of the auxiliary at its passage speed, used when " +
972
+ "drift mode is off and the wind is below the motoring threshold.",
973
+ default: DEFAULTS.motor_fuel_l_per_hour,
974
+ },
954
975
  waterline_length_m: {
955
976
  type: "number",
956
977
  title: "Waterline Length (meters)",
@@ -1034,6 +1055,7 @@ module.exports = (app) => {
1034
1055
  simulationConfig = {
1035
1056
  motoring_tws_threshold: config.motoring_tws_threshold,
1036
1057
  drift_mode_enabled: config.drift_mode_enabled,
1058
+ motor_fuel_l_per_hour: config.motor_fuel_l_per_hour,
1037
1059
  waterline_length_m: config.waterline_length_m,
1038
1060
  k_heel: config.k_heel,
1039
1061
  k_pitch: config.k_pitch,
@@ -1247,10 +1269,14 @@ module.exports = (app) => {
1247
1269
  * everything else is here mode.
1248
1270
  */
1249
1271
  router.get("/api/briefing", async (req, res) => {
1250
- const routeId =
1251
- typeof req.query.route === "string" ? req.query.route : "";
1272
+ // An explicit ?route= (even empty) selects the mode — the
1273
+ // route picker's "Conditions here" entry requests exactly
1274
+ // that. With no param at all the route being sailed wins,
1275
+ // falling back to here when none (work doc #7)
1276
+ const explicitRoute = typeof req.query.route === "string";
1277
+ const routeId = explicitRoute ? req.query.route.trim() : "";
1252
1278
  const activeId = activeRouteId(observations[ACTIVE_ROUTE_PATH]);
1253
- const effectiveRoute = routeId || activeId;
1279
+ const effectiveRoute = routeId || (explicitRoute ? "" : activeId);
1254
1280
  if (!effectiveRoute) {
1255
1281
  const here = await loadHere();
1256
1282
  if (!here) {
@@ -88,16 +88,16 @@ export function fmtKn(knots) {
88
88
  }
89
89
 
90
90
  /**
91
- * Formats fuel in gallons with one decimal.
91
+ * Formats fuel in liters with one decimal (SI — never imperial).
92
92
  *
93
- * @param {number|null|undefined} gallons
93
+ * @param {number|null|undefined} liters
94
94
  * @returns {string} Empty string when unset/invalid
95
95
  */
96
- export function fmtGal(gallons) {
97
- if (gallons == null || !Number.isFinite(gallons)) {
96
+ export function fmtLiters(liters) {
97
+ if (liters == null || !Number.isFinite(liters)) {
98
98
  return "";
99
99
  }
100
- return `${gallons.toFixed(1)} gal`;
100
+ return `${liters.toFixed(1)} l`;
101
101
  }
102
102
 
103
103
  /**
@@ -300,19 +300,148 @@ export function tacticalNow(exceptions) {
300
300
  };
301
301
  }
302
302
 
303
+ /**
304
+ * Sail-type words that mark the start of a new sail component inside
305
+ * a canonical sail-state key. Reef/furl bits always terminate their
306
+ * component, so only consecutive *full* sails are ambiguous without
307
+ * the vessel's sail inventory — this small dictionary of common sail
308
+ * types closes the gap (e.g. `GENOA_1_MAIN` is two sails, not one
309
+ * named "Genoa 1 Main").
310
+ */
311
+ const SAIL_COMPONENT_WORDS = new Set([
312
+ "MAIN",
313
+ "GENOA",
314
+ "JIB",
315
+ "STAYSAIL",
316
+ "FORESAIL",
317
+ "MIZZEN",
318
+ "SPINNAKER",
319
+ "GENNAKER",
320
+ "YANKEE",
321
+ "FOCK",
322
+ "CODE",
323
+ "DRIFTER",
324
+ "SPIN",
325
+ ]);
326
+
327
+ /**
328
+ * Human-readable label for a canonical sail-state key (as built by
329
+ * `logbook-source.js` `sailStateKey`): `GENOA_1_30_FURLED_MAIN_1_REEF`
330
+ * reads as `Genoa 1 30% furled + Main 1 reef`, `NO_SAILS` as
331
+ * `No sails`. A trailing `@side` (maneuver states) is ignored.
332
+ *
333
+ * @param {string|null|undefined} key - Canonical key, e.g. `MAIN_1_REEF`
334
+ * @returns {string} Label, or the raw input when nothing parses
335
+ */
336
+ export function sailStateLabel(key) {
337
+ const raw = String(key ?? "").trim();
338
+ if (!raw) {
339
+ return "";
340
+ }
341
+ const combination = raw.split("@")[0];
342
+ if (!combination || combination === "?") {
343
+ return raw;
344
+ }
345
+ if (combination === "NO_SAILS") {
346
+ return "No sails";
347
+ }
348
+
349
+ const components = [];
350
+ let words = [];
351
+ let reefs = null;
352
+ let furled = null;
353
+ const flush = () => {
354
+ if (words.length > 0 || reefs != null || furled != null) {
355
+ components.push({ words, reefs, furled });
356
+ }
357
+ words = [];
358
+ reefs = null;
359
+ furled = null;
360
+ };
361
+
362
+ const tokens = combination.split("_");
363
+ for (let i = 0; i < tokens.length; i++) {
364
+ const token = tokens[i];
365
+ const value = /^\d+$/.test(token) ? Number(token) : null;
366
+ const next = tokens[i + 1];
367
+ if (value != null && (next === "REEF" || next === "FURLED")) {
368
+ // Bit run `N REEF` / `N FURLED`: ends the component name run
369
+ if (next === "REEF") {
370
+ reefs = value;
371
+ } else {
372
+ furled = value;
373
+ }
374
+ i++;
375
+ continue;
376
+ }
377
+ if (
378
+ words.length > 0 &&
379
+ (reefs != null || furled != null || SAIL_COMPONENT_WORDS.has(token))
380
+ ) {
381
+ flush();
382
+ }
383
+ words.push(token);
384
+ }
385
+ flush();
386
+
387
+ if (components.length === 0) {
388
+ return raw;
389
+ }
390
+ return components
391
+ .map(({ words: sailWords, reefs: nReef, furled: nFurled }) => {
392
+ const name = sailWords
393
+ .map((word) =>
394
+ /^\d/.test(word)
395
+ ? word
396
+ : word.charAt(0) + word.slice(1).toLowerCase(),
397
+ )
398
+ .join(" ");
399
+ const bits = [];
400
+ if (nReef != null) {
401
+ bits.push(`${nReef} reef${nReef === 1 ? "" : "s"}`);
402
+ }
403
+ if (nFurled != null) {
404
+ bits.push(`${nFurled}% furled`);
405
+ }
406
+ return [name, ...bits].filter(Boolean).join(" ");
407
+ })
408
+ .join(" + ");
409
+ }
410
+
411
+ /**
412
+ * Label for one sail-change event: bare "No sails" says WHY the
413
+ * canvas is down — the plan drifts (`No sails - drifting`) or the
414
+ * engine pushes (`Motoring`) — instead of implying bare-pole sailing.
415
+ *
416
+ * @param {{sailState: string|null, propulsion?: string|null}} event
417
+ * @returns {string} Human label
418
+ */
419
+ function sailEventLabel(event) {
420
+ const label = sailStateLabel(event.sailState);
421
+ if (label === "No sails") {
422
+ if (event.propulsion === "adrift") {
423
+ return "No sails - drifting";
424
+ }
425
+ if (event.propulsion === "motor") {
426
+ return "Motoring";
427
+ }
428
+ }
429
+ return label;
430
+ }
431
+
303
432
  /**
304
433
  * Sail action cards for the tactical dashboard, oldest first.
305
434
  * Maneuver events (work doc #5) carry `maneuver`, `toTack` and the
306
435
  * expected TWS so the cards can read "Tack to starboard ~14:20, 12 kt".
307
436
  *
308
437
  * @param {object|null} exceptions
309
- * @returns {Array<{hoursFromNow: number, stamp: string, sailState: string, night: boolean, maneuver: string|null, toTack: string|null, twsKnots: number|null}>}
438
+ * @returns {Array<{hoursFromNow: number, stamp: string, label: string, night: boolean, maneuver: string|null, toTack: string|null, twsKnots: number|null}>}
310
439
  */
311
440
  export function sailActionCards(exceptions) {
312
441
  return (exceptions?.next24h?.sailChanges ?? []).map((e) => ({
313
442
  hoursFromNow: e.hoursFromNow,
314
443
  stamp: fmtUtc(e.timestamp),
315
- sailState: e.sailState ?? "?",
444
+ label: sailEventLabel(e) || "?",
316
445
  night: Boolean(e.night),
317
446
  maneuver: e.maneuver ?? null,
318
447
  toTack: e.toTack ?? null,
@@ -330,10 +459,10 @@ export function sailActionCards(exceptions) {
330
459
  export function sailWorkTimeline(exceptions) {
331
460
  return (exceptions?.passageSummary?.sailChanges ?? []).map((e) => {
332
461
  const state = String(e.sailState ?? "?");
333
- const [combination, tack] = state.split("@");
462
+ const [, tack] = state.split("@");
334
463
  const label = e.maneuver
335
464
  ? `${e.maneuver === "tack" ? "Tack" : "Gybe"} to ${e.toTack ?? tack ?? "?"}`
336
- : combination || state;
465
+ : sailEventLabel(e) || state;
337
466
  const detail = [
338
467
  e.distanceFromStartNm != null
339
468
  ? `${Math.round(e.distanceFromStartNm)} nm`
@@ -359,14 +488,28 @@ export function sailWorkTimeline(exceptions) {
359
488
  */
360
489
  export function etaTable(exceptions) {
361
490
  const summary = exceptions?.passageSummary ?? {};
491
+ const night = summary.etaNight ?? {};
362
492
  return {
363
493
  rows: [
364
- { label: "P10", stamp: fmtUtc(summary.etaP10) },
365
- { label: "P50", stamp: fmtUtc(summary.etaP50) },
366
- { label: "P90", stamp: fmtUtc(summary.etaP90) },
494
+ {
495
+ label: "P10",
496
+ stamp: fmtUtc(summary.etaP10),
497
+ night: night.p10 === true,
498
+ },
499
+ {
500
+ label: "P50",
501
+ stamp: fmtUtc(summary.etaP50),
502
+ night: night.p50 === true,
503
+ },
504
+ {
505
+ label: "P90",
506
+ stamp: fmtUtc(summary.etaP90),
507
+ night: night.p90 === true,
508
+ },
367
509
  ],
368
510
  motorHours:
369
511
  summary.totalMotorHours != null ? fmtHours(summary.totalMotorHours) : "",
370
- fuel: summary.totalFuelGal != null ? fmtGal(summary.totalFuelGal) : "",
512
+ fuel:
513
+ summary.totalFuelLiters != null ? fmtLiters(summary.totalFuelLiters) : "",
371
514
  };
372
515
  }