@meri-imperiumi/signalk-passage-briefing 0.7.0 → 0.8.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,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.8.0] - 2026-10-06
6
+
7
+ ### Added
8
+ - Conditions tabs on the unified timeline (work doc #18): each entry carries a small colored tab on its left edge — the comfort tier color the tactical sparkline paints at that hour, so the passage's weather development reads at a glance. The tier comes from the simulated track's nearest hourly step; sail changes keep their own enriched conditions block as the authoritative tier, slatting renders as the sparkline's hatch stripes, and entries outside track coverage degrade to the unknown gray instead of inventing a tier.
9
+ - Provenance on every estimation and warning (work doc #31): a compact ↗ affordance where an external origin or on-board viewer exists, deep-linked to the specific record rather than a site front page — GDACS events link their gdacs.org event page (hazard notes on the timeline carry the same page through from the note), CAP alerts the sender's info page (feed URL as fallback), bulletin console blocks the feed rung the text arrived from, zone transitions the exact Marine Regions gazetteer record keyed by the boundary zone's MRGID (search-by-territory fallback when a source carries no MRGID; the here view's waters-you-are-in zone names link their records too), satellite passes a live tracker page keyed by the NORAD catalog number parsed from TLE line 1, and weather the on-board viewer deep link (`signalk-weather-map` webapp with `lat/lon/time/layer`, detected via the server's registered webapps — absent siblings serve no dead links) plus the external Open-Meteo forecast page rewritten to the item's own position: a simulated item links the patch of ocean the boat plans to be at, not the departure. Computed-on-board items carry no glyph: absence never implies external authority. Sky-event and official-alert rows on the conditions-here view get the same treatment.
10
+ - Night-darkness cloud cover (work doc #32): Open-Meteo `cloud_cover` rides the simulated track (interpolated in `weatherAt`, hourly rows and the compact `passageSummary.track`; unknown on either side of a span stays null, never blended or invented) and is stamped on every timeline item at its hour. Night entries render a ☁ beside the moon glyph, opacity scaling with cover (every night entry with a known cover carries the mark — clear nights show it faint, so an absent mark unambiguously means the source publishes none); night ETA rows in the strategic outlook get the same mark; provider mode stays honest with the moon glyph alone.
11
+ - Sky times on tactical and here (work doc #33): `celestialNights` entries gain `sunrise`/`sunset`/`moonrise`/`moonset` from `astronomy-engine` rise/set searches (null-degrading across polar day/night), and both views render a compact monospace sky line — sunset, nautical dusk (the briefing's night boundary), the moon's rise/set within the night window, and the now cloud cover from the first forecast step — so watch planning reads off one line instead of guesswork around dusk. Also fixed the conditions-here CAP empty state early-returning before the waters-you-are-in card could render.
12
+
13
+ ### Changed
14
+ - The briefing refresh button reads "Update now" instead of "Fetch now" (matching loading strips "Updating briefing…" / "Updating conditions…"): from the crew's side the action regenerates the briefing with fresh weather — the fetching is the plugin's business, not the button's.
15
+
16
+ ### Fixed
17
+ - The critical-hazard banner rendered always — an empty red strip with an ACK button above the webapp header even with no active alerts: the banner stylesheet's `display: flex` beats the browser's built-in `[hidden] { display: none }` regardless of specificity, so the `hidden` attribute did nothing (the `.tab-bar` already carried the `[hidden]` antidote; the banner and the embed-mode header now do too). With the bar permanently visible but empty, ACK had nothing to acknowledge. Also fixed red-on-red text: the banner painted message and background with the same `--color-red`, so a real emergency's message would have been invisible — text now uses the base canvas color per the codebase's text-on-color pattern.
18
+ - CAP alerts whose `<instruction>` carries a bare placeholder — PTWC ships a literal "N/A" on its tsunami information statements — rendered "N/A" as the crew's action line; the parser now treats the placeholder as no instruction, and the Official Alerts card renders no action row for them.
19
+
20
+ ### Changed
21
+
22
+ - Logbook reading moved from signalk-logbook's on-disk YAML store to the Signal K v2 Resources API: the plugin now consumes the `logentries` resource type (the contract signalk-logbook documents in `docs/logentries-resource.md` and serves as its reference provider) through `app.resourcesApi` — in-process, no tokens, no filesystem coupling to another plugin's storage layout. The reader normalizes the resource representation at one boundary (`navigation.position` pathvalue for position, `environment.wind.speedOverGround` m/s → kn and `environment.wind.directionTrue` rad → deg for the wind snapshots); the sail-text parsing and event classification are unchanged. The source-status checklist row probes the resource type instead of the store directory. `bin/backfill-report.js` reads over the REST surface (`--url`/`--token`, a `readonly` token suffices) instead of a local YAML copy — usable ashore against any reachable server. The `yaml` dependency is dropped.
23
+
24
+ ## [0.7.1] - 2026-10-05
25
+
26
+ ### Fixed
27
+ - Notes resource `bbox` queries crossing the antimeridian now match notes on both sides of 180° (`plugin/notes-store.js`): a west edge east of the east edge — the natural way to frame a Pacific box around the seam — previously matched nothing, since no single longitude is simultaneously ≥ 170 and ≤ -175. Such a box now wraps the seam (matching west of the east edge or east of the west edge); regular west≤east boxes behave exactly as before
28
+
5
29
  ## [0.7.0] - 2026-10-04
6
30
 
7
31
  ### Added
package/README.md CHANGED
@@ -28,7 +28,7 @@ Part of the Lille Ø offshore suite, alongside [@meri-imperiumi/signalk-energy-p
28
28
  - Resources API — route geometries and polar tables
29
29
  - History API (on board) — wind/attempt snapshots for the sail-event backfill
30
30
  - Published tile paths — `navigation.briefing.generatedAt` / `.route` / `.hasNew` / `.comfort` drive the plotter-extension tile (`.acknowledgedAt` is writable to clear the NEW badge)
31
- - [signalk-logbook](https://github.com/meri-imperiumi/signalk-logbook) store — crewed sail events (reefs, sail changes) that train the preference matrix
31
+ - `logentries` resource API (for example [signalk-logbook](https://github.com/meri-imperiumi/signalk-logbook)) — crewed sail events (reefs, sail changes) that train the preference matrix; any provider implementing the resource contract works
32
32
  - [signalk-ships-time](https://github.com/meri-imperiumi/signalk-ships-time) — `environment.time.timezoneOffset` / `.timezoneRegion`, the vessel's published timezone: briefing stamps render in ship's time when available (the offset rides on every stamp), falling back to UTC `Z` when nothing is published
33
33
  - [signalk-watch-schedule](https://github.com/hoeken/signalk-watch-schedule) — `watch.state.onWatch`, `watch.state.startedAt`, `watch.system` and `watch.schedule`: the running watch rotation. While a watch is running, planned sail changes anchor to the previous watch handover (both teams awake) instead of sunrise/sunset; boundaries are extrapolated across the forecast horizon from the rotation cycle
34
34
  - [@signalk/sailsconfiguration](https://www.npmjs.com/package/@signalk/sailsconfiguration) — sail inventory used to filter free-text noise out of log entries
@@ -2,10 +2,10 @@
2
2
  /**
3
3
  * Logbook backfill & sail usage report.
4
4
  *
5
- * Runs the SPEC §4.2 sail preference backfill over a
6
- * `signalk-logbook` store using the wind snapshots written in the log
7
- * entries (no History API needed — usable ashore against a copy of
8
- * the log repository), then reports:
5
+ * Runs the SPEC §4.2 sail preference backfill over the logbook
6
+ * entries served by a Signal K server's `logentries` resource (the
7
+ * signalk-logbook contract), using the wind snapshots written in the
8
+ * log entries (no History API needed), then reports:
9
9
  *
10
10
  * 1. the learned sail preference matrix (SPEC §3.2);
11
11
  * 2. the conditions actually experienced per sail combination, next
@@ -15,11 +15,13 @@
15
15
  *
16
16
  * Usage:
17
17
  *
18
- * node bin/backfill-report.js [--dir <logbook-dir>] [--sails <json>]
19
- * [--from <iso>] [--to <iso>] [--db <sqlite-file>] [--json]
18
+ * node bin/backfill-report.js [--url <server>] [--token <jwt>]
19
+ * [--sails <json>] [--from <iso>] [--to <iso>] [--db <sqlite-file>]
20
+ * [--json]
20
21
  *
21
- * Defaults: `--dir ~/.signalk/plugin-config-data/signalk-logbook`,
22
- * `--sails ~/.signalk/plugin-config-data/sailsconfiguration.json`.
22
+ * Defaults: `--url http://localhost:3000` (a `readonly` token is
23
+ * enough for reads), `--sails
24
+ * ~/.signalk/plugin-config-data/sailsconfiguration.json`.
23
25
  *
24
26
  * Once on board, re-run the backfill through the plugin's
25
27
  * `POST /api/backfill?source=history&baseUrl=<server>` route to learn
@@ -34,7 +36,7 @@ const { join } = require("node:path");
34
36
 
35
37
  const { PassageDatabase } = require("../plugin/sqlite-db.js");
36
38
  const {
37
- readLogbookEntries,
39
+ readLogbookEntriesRest,
38
40
  readLogbookSailEvents,
39
41
  } = require("../plugin/logbook-source.js");
40
42
  const {
@@ -49,6 +51,11 @@ const {
49
51
 
50
52
  const SIGNALK_DIR = join(homedir(), ".signalk", "plugin-config-data");
51
53
 
54
+ /**
55
+ * Server to read the logbook from when `--url` is not given.
56
+ */
57
+ const DEFAULT_SERVER_URL = "http://localhost:3000";
58
+
52
59
  /**
53
60
  * Parses argv into a `{flag: value}` map (`--flag value`, boolean
54
61
  * flags without value become true).
@@ -77,10 +84,9 @@ function parseArgs(argv) {
77
84
 
78
85
  function main() {
79
86
  const args = parseArgs(process.argv.slice(2));
80
- const logbookDir =
81
- typeof args.dir === "string"
82
- ? args.dir
83
- : join(SIGNALK_DIR, "signalk-logbook");
87
+ const serverUrl =
88
+ typeof args.url === "string" ? args.url : DEFAULT_SERVER_URL;
89
+ const token = typeof args.token === "string" ? args.token : undefined;
84
90
  const sailsFile =
85
91
  typeof args.sails === "string"
86
92
  ? args.sails
@@ -94,54 +100,75 @@ function main() {
94
100
  );
95
101
  const db = new PassageDatabase(dataDir);
96
102
 
97
- return Promise.all([
98
- readLogbookEntries(logbookDir),
99
- readSailsConfiguration(sailsFile),
100
- ])
101
- .then(async ([entries, sails]) => {
102
- const knownSailKeys =
103
- sails.length > 0
104
- ? new Set(sails.map((sail) => sail.nameKey))
105
- : undefined;
106
- const events = await readLogbookSailEvents({
107
- dir: logbookDir,
103
+ (async () => {
104
+ try {
105
+ const report = await collectReport({
106
+ serverUrl,
107
+ token,
108
+ sailsFile,
108
109
  from,
109
110
  to,
110
- knownSailKeys,
111
- });
112
- const summary = await backfillSailEvents({
113
111
  db,
114
- events,
115
- getWindStats: createLogbookWindStats(entries),
116
112
  });
117
- const matrix = db.getSailPreferenceMatrix();
118
- const usage = summarizeSailUsage(
119
- db.getSailEvents({ limit: 10000 }),
120
- db.getWindHistory("0000-01-01T00:00:00Z", "9999-12-31T23:59:59Z"),
121
- );
122
- return {
123
- logbook: logbookDir,
124
- entries: entries.length,
125
- events: events.length,
126
- summary,
127
- matrix,
128
- usage,
129
- sails,
130
- };
131
- })
132
- .then((report) => {
133
- db.close();
134
113
  if (asJson) {
135
114
  console.log(JSON.stringify(report, null, 2));
136
115
  return;
137
116
  }
138
117
  printReport(report);
139
- })
140
- .catch((error) => {
141
- db.close();
118
+ } catch (error) {
142
119
  console.error(`backfill-report: ${error.message}`);
143
120
  process.exitCode = 1;
144
- });
121
+ } finally {
122
+ db.close();
123
+ }
124
+ })();
125
+ }
126
+
127
+ /**
128
+ * Reads the logbook over the resources API and runs the backfill.
129
+ *
130
+ * @param {object} options
131
+ * @param {string} options.serverUrl - Signal K server base URL
132
+ * @param {string|undefined} options.token - Bearer token for reads
133
+ * @param {string} options.sailsFile - Sails configuration JSON path
134
+ * @param {string|undefined} options.from - Event window start
135
+ * @param {string|undefined} options.to - Event window end
136
+ * @param {object} options.db - Passage database
137
+ * @returns {Promise<object>} Report for printReport/JSON output
138
+ */
139
+ async function collectReport({ serverUrl, token, sailsFile, from, to, db }) {
140
+ const entries = await readLogbookEntriesRest({
141
+ baseUrl: serverUrl,
142
+ token,
143
+ });
144
+ const sails = await readSailsConfiguration(sailsFile);
145
+ const knownSailKeys =
146
+ sails.length > 0 ? new Set(sails.map((sail) => sail.nameKey)) : undefined;
147
+ const events = await readLogbookSailEvents({
148
+ entries,
149
+ from,
150
+ to,
151
+ knownSailKeys,
152
+ });
153
+ const summary = await backfillSailEvents({
154
+ db,
155
+ events,
156
+ getWindStats: createLogbookWindStats(entries),
157
+ });
158
+ const matrix = db.getSailPreferenceMatrix();
159
+ const usage = summarizeSailUsage(
160
+ db.getSailEvents({ limit: 10000 }),
161
+ db.getWindHistory("0000-01-01T00:00:00Z", "9999-12-31T23:59:59Z"),
162
+ );
163
+ return {
164
+ logbook: serverUrl,
165
+ entries: entries.length,
166
+ events: events.length,
167
+ summary,
168
+ matrix,
169
+ usage,
170
+ sails,
171
+ };
145
172
  }
146
173
 
147
174
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-passage-briefing",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Offshore passage daily briefing webapp for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -46,8 +46,7 @@
46
46
  "dependencies": {
47
47
  "@openwaters/maritime-zones": "^0.2.1",
48
48
  "astronomy-engine": "^2.1.19",
49
- "satellite.js": "^7.1.0",
50
- "yaml": "^2.9.0"
49
+ "satellite.js": "^7.1.0"
51
50
  },
52
51
  "devDependencies": {
53
52
  "@signalk/server-api": "^2.30.0"
@@ -27,6 +27,13 @@ const { tagValue, unescapeXml } = require("./hazard-source.js");
27
27
  /** Severity ranking for CAP's own vocabulary. */
28
28
  const SEVERITY_RANK = { extreme: 3, severe: 2, moderate: 1, minor: 0 };
29
29
 
30
+ /**
31
+ * Instruction placeholders some senders ship (PTWC's CAP documents
32
+ * carry a literal "N/A"): rendered as the crew's action, so a bare
33
+ * placeholder is no instruction at all.
34
+ */
35
+ const INSTRUCTION_PLACEHOLDER = /^n\/?a\.?$/i;
36
+
30
37
  /** Default feed: the NWS Pacific tsunami warning center. */
31
38
  const CAP_DEFAULT_URL = "https://www.tsunami.gov/events/xml/PHEBCAP.xml";
32
39
 
@@ -218,6 +225,7 @@ function parseCapAlert(xml, { sourceUrl = null, raw = null } = {}) {
218
225
  const info = pickInfoBlock(xml) ?? "";
219
226
  const expires = capText(info, "expires") || null;
220
227
  const geometry = infoGeometry(info);
228
+ const instruction = capText(info, "instruction") || null;
221
229
  return {
222
230
  id: `${identifier}|${sent ?? ""}`,
223
231
  identifier,
@@ -232,7 +240,10 @@ function parseCapAlert(xml, { sourceUrl = null, raw = null } = {}) {
232
240
  expires,
233
241
  headline: capText(info, "headline") || null,
234
242
  description: capText(info, "description") || null,
235
- instruction: capText(info, "instruction") || null,
243
+ instruction:
244
+ instruction && !INSTRUCTION_PLACEHOLDER.test(instruction)
245
+ ? instruction
246
+ : null,
236
247
  web: capText(info, "web") || null,
237
248
  areaDesc: capText(info, "areaDesc") || null,
238
249
  geometry,
@@ -238,10 +238,19 @@ function twilightTimes(date, lat, lon) {
238
238
 
239
239
  /**
240
240
  * Daily night context for the briefing payload (`celestialNights`):
241
- * one entry per day with the twilight times of that night and the
242
- * moon phase/illumination at nightfall. The timeline's night glyph
243
- * and any future "when is it really dark" display read this instead
244
- * of re-deriving astronomy in the browser.
241
+ * one entry per day with the twilight times of that night, the moon
242
+ * phase/illumination at nightfall, and the sun/moon rise and set
243
+ * stamps bracketing the night (work doc #33 — the sky line reads
244
+ * these instead of re-deriving astronomy in the browser). The
245
+ * timeline's night glyph and any future "when is it really dark"
246
+ * display read this instead of re-deriving astronomy in the browser.
247
+ *
248
+ * Rise/set search semantics: sunset is the setting crossing before
249
+ * the night's civil dusk, sunrise the rising crossing after the dusk
250
+ * anchor; moonrise/moonset are the moon's next crossings after dusk
251
+ * (a moon already up at dusk reports tomorrow's rise — the set stamp
252
+ * is tonight's). Polar day/night and missing crossings degrade to
253
+ * null like the twilight bounds.
245
254
  *
246
255
  * @param {object} params
247
256
  * @param {Date} params.from - Window start
@@ -252,21 +261,61 @@ function twilightTimes(date, lat, lon) {
252
261
  * moonIllumination: number, civilDusk: string|null,
253
262
  * nauticalDusk: string|null, astronomicalDusk: string|null,
254
263
  * astronomicalDawn: string|null, nauticalDawn: string|null,
255
- * civilDawn: string|null}>}
264
+ * civilDawn: string|null, sunrise: string|null, sunset: string|null,
265
+ * moonrise: string|null, moonset: string|null}>}
256
266
  */
257
267
  function celestialNights({ from, days = 8, lat, lon }) {
258
268
  const nights = [];
269
+ const observer = observerAt(lat, lon);
270
+ // First rise/set crossing of a body after `start`; missing
271
+ // crossings (polar day/night, no crossing within the window)
272
+ // degrade to null
273
+ const riseSet = (body, direction, start) => {
274
+ try {
275
+ const found = A.SearchRiseSet(
276
+ body,
277
+ observer,
278
+ direction,
279
+ A.MakeTime(start),
280
+ 1.5,
281
+ );
282
+ return found ? found.date.toISOString() : null;
283
+ } catch (_error) {
284
+ return null;
285
+ }
286
+ };
259
287
  for (let d = 0; d < days; d++) {
260
288
  const base = new Date(from.getTime() + d * 86400000);
261
289
  const twilights = twilightTimes(base, lat, lon);
262
290
  const anchor = twilights.nauticalDusk
263
291
  ? new Date(twilights.nauticalDusk)
264
292
  : base;
293
+ // Sunset: the setting crossing before this night's civil dusk —
294
+ // search from a day back so the search's forward window covers it
295
+ const civilDuskMs = twilights.civilDusk
296
+ ? new Date(twilights.civilDusk).getTime()
297
+ : null;
298
+ const sunset = civilDuskMs
299
+ ? riseSet(A.Body.Sun, -1, new Date(civilDuskMs - 18 * 3600000))
300
+ : null;
301
+ // Dawn side: the next rising after the dusk anchor (the same
302
+ // night's sunrise); the +2 min skips the numerical tangency at
303
+ // the crossing itself, as the dawn chain does
304
+ const afterDusk = new Date(anchor.getTime() + 120000);
305
+ const sunrise = riseSet(A.Body.Sun, 1, afterDusk);
306
+ // Moon brackets (work docs #32/#33): the next set and rise after
307
+ // dusk — a moon already up at dusk reports tomorrow's rise
308
+ const moonset = riseSet(A.Body.Moon, -1, afterDusk);
309
+ const moonrise = riseSet(A.Body.Moon, 1, afterDusk);
265
310
  nights.push({
266
311
  timestamp: anchor.toISOString(),
267
312
  moonPhaseDeg: Math.round(moonPhaseDeg(anchor) * 10) / 10,
268
313
  moonIllumination: Math.round(moonIllumination(anchor) * 1000) / 1000,
269
314
  ...twilights,
315
+ sunset,
316
+ sunrise,
317
+ moonrise,
318
+ moonset,
270
319
  });
271
320
  }
272
321
  return nights;
@@ -564,11 +564,21 @@ async function fetchWeatherAlongTrack({
564
564
  if (currentsResponse != null) {
565
565
  models.push("marine:smoc_currents");
566
566
  }
567
+ const fetchedAt = new Date().toISOString();
567
568
  return {
568
569
  metadata: {
569
- fetchedAt: new Date().toISOString(),
570
+ fetchedAt,
570
571
  source: "api",
571
572
  models,
573
+ // Where this forecast came from (work doc #31): the canonical
574
+ // per-position Open-Meteo forecast page for the first waypoint
575
+ provenance: {
576
+ kind: "forecast",
577
+ label: `Open-Meteo · ${models.join(", ")}`,
578
+ url: `https://open-meteo.com/en/docs?latitude=${waypoints[0].lat}&longitude=${waypoints[0].lon}`,
579
+ viewerBase: null,
580
+ at: fetchedAt,
581
+ },
572
582
  },
573
583
  waypoints: payloadWaypoints,
574
584
  };
package/plugin/index.js CHANGED
@@ -20,17 +20,12 @@
20
20
 
21
21
  const { join } = require("node:path");
22
22
  const { homedir } = require("node:os");
23
- const {
24
- mkdir,
25
- readdir,
26
- readFile,
27
- unlink,
28
- writeFile,
29
- } = require("node:fs/promises");
23
+ const { mkdir, readFile, unlink, writeFile } = require("node:fs/promises");
30
24
 
31
25
  const { PassageStateMachine } = require("./state-machine.js");
32
26
  const { PassageDatabase } = require("./sqlite-db.js");
33
27
  const {
28
+ logbookAvailable,
34
29
  readLogbookEntries,
35
30
  readLogbookSailEvents,
36
31
  } = require("./logbook-source.js");
@@ -533,11 +528,19 @@ module.exports = (app) => {
533
528
  /** Filtered blocks for one cache entry against a track: structured
534
529
  * UKHO JSON entries skip the text pipeline entirely. */
535
530
  function blocksForEntry(entry, track) {
531
+ // Provenance (work doc #31): the feed URL the text arrived from,
532
+ // stamped on every surviving block — the spool path has a file
533
+ // path, not a URL, and carries no provenance
534
+ const sourceUrl = /^https?:\/\//.test(entry.url ?? "") ? entry.url : null;
535
+ const withProvenance = (blocks) =>
536
+ sourceUrl ? blocks.map((block) => ({ ...block, sourceUrl })) : blocks;
536
537
  if (entry.format === "ukho-json") {
537
538
  try {
538
- return ukhoBlocksFromWarnings(
539
- parseUkhoWarnings(JSON.parse(entry.text)),
540
- track,
539
+ return withProvenance(
540
+ ukhoBlocksFromWarnings(
541
+ parseUkhoWarnings(JSON.parse(entry.text)),
542
+ track,
543
+ ),
541
544
  );
542
545
  } catch {
543
546
  return []; // Corrupted entry: skip, other entries still serve
@@ -549,7 +552,7 @@ module.exports = (app) => {
549
552
  track,
550
553
  issuedAt: entry.fetchedAt,
551
554
  });
552
- return bulletin?.blocks ?? [];
555
+ return withProvenance(bulletin?.blocks ?? []);
553
556
  }
554
557
 
555
558
  /** Bulletin-level metadata (header/issuedAt/source/raw text) for
@@ -830,6 +833,42 @@ module.exports = (app) => {
830
833
  * samples (route mode) or the position waypoint (here mode)
831
834
  * @returns {Promise<void>}
832
835
  */
836
+ /**
837
+ * On-board viewer base for weather provenance (work doc #31): the
838
+ * weather-map sibling webapp renders the same fields the briefing
839
+ * derives from, reachable over the LAN underway. The server knows
840
+ * its registered webapps; a link to an absent sibling is dropped
841
+ * rather than served dead. Deep-link parameters (lat/lon/time/
842
+ * layer) are appended per item by the webapp.
843
+ *
844
+ * @returns {string|null} Viewer base path when installed
845
+ */
846
+ function weatherViewerBase() {
847
+ const webapps = app.webapps;
848
+ if (!Array.isArray(webapps)) {
849
+ return null;
850
+ }
851
+ return webapps.some((webapp) => webapp?.name === "signalk-weather-map")
852
+ ? "/signalk-weather-map/"
853
+ : null;
854
+ }
855
+
856
+ /**
857
+ * Stamps the on-board viewer link onto a payload's weather
858
+ * provenance record (work doc #31). Payloads whose cache predates
859
+ * the provenance field keep no record — the UI degrades to no
860
+ * glyph, never a dead link.
861
+ *
862
+ * @param {object} payload - Briefing payload, mutated in place
863
+ * @returns {void}
864
+ */
865
+ function attachWeatherViewer(payload) {
866
+ const base = weatherViewerBase();
867
+ if (base && payload?.metadata?.provenance) {
868
+ payload.metadata.provenance.viewerBase = base;
869
+ }
870
+ }
871
+
833
872
  async function attachHazardEvents(payload, vessel, waypoints) {
834
873
  if (!hazardEventsEnabled) {
835
874
  return;
@@ -854,7 +893,22 @@ module.exports = (app) => {
854
893
  ...hazardFilterConfig,
855
894
  now: new Date(),
856
895
  });
857
- payload.hazardEvents = events;
896
+ // Provenance (work doc #31): the GDACS event page rides each
897
+ // surviving event — one tap from timeline row to origin
898
+ payload.hazardEvents = events.map((event) =>
899
+ event.link
900
+ ? {
901
+ ...event,
902
+ provenance: {
903
+ kind: "warning",
904
+ label: "GDACS",
905
+ url: event.link,
906
+ viewerUrl: null,
907
+ at: event.timestamp ?? null,
908
+ },
909
+ }
910
+ : event,
911
+ );
858
912
  if (notesStore) {
859
913
  const result = await publishHazardNotes({ store: notesStore, events });
860
914
  if (result.published.length > 0 || result.pruned.length > 0) {
@@ -954,12 +1008,33 @@ module.exports = (app) => {
954
1008
  }
955
1009
  const events =
956
1010
  result?.events ?? (await capSource.loadCapAlerts(app.getDataDirPath()));
957
- payload.capAlerts = capSource.filterCapAlerts({
958
- events,
959
- track: waypoints.map((w) => [w.lon, w.lat]),
960
- minSeverity: capMinSeverity,
961
- now: new Date(),
962
- });
1011
+ // Provenance (work doc #31): the sender's info page when
1012
+ // published, else the feed the document arrived from. Stamped on
1013
+ // the FILTERED survivors — stamping the raw cache instead would
1014
+ // bypass the severity, expiry and geography gates (a Puerto Rico
1015
+ // information statement reached a Tonga vessel that way once).
1016
+ payload.capAlerts = capSource
1017
+ .filterCapAlerts({
1018
+ events,
1019
+ track: waypoints.map((w) => [w.lon, w.lat]),
1020
+ minSeverity: capMinSeverity,
1021
+ now: new Date(),
1022
+ })
1023
+ .map((alert) => {
1024
+ const url = alert.web ?? alert.sourceUrl ?? null;
1025
+ return url
1026
+ ? {
1027
+ ...alert,
1028
+ provenance: {
1029
+ kind: "warning",
1030
+ label: alert.web ? "Official alert page" : "CAP feed",
1031
+ url,
1032
+ viewerUrl: null,
1033
+ at: alert.sent ?? null,
1034
+ },
1035
+ }
1036
+ : alert;
1037
+ });
963
1038
  if (notesStore) {
964
1039
  const result = await publishCapAlertNotes({
965
1040
  store: notesStore,
@@ -1045,6 +1120,7 @@ module.exports = (app) => {
1045
1120
  }),
1046
1121
  );
1047
1122
  payload.metadata.mode = "here";
1123
+ attachWeatherViewer(payload);
1048
1124
  const bulletin = await bulletinForTrack(waypoints);
1049
1125
  if (bulletin) {
1050
1126
  payload.metareaBulletin = bulletin;
@@ -1208,6 +1284,7 @@ module.exports = (app) => {
1208
1284
  const payload = await withSourceStatus(WEATHER_SOURCE, () =>
1209
1285
  trackWeatherFetcher({ waypoints, forecastDays }),
1210
1286
  );
1287
+ attachWeatherViewer(payload);
1211
1288
  const bulletin = await bulletinForTrack(waypoints);
1212
1289
  if (bulletin) {
1213
1290
  payload.metareaBulletin = bulletin;
@@ -1782,18 +1859,6 @@ module.exports = (app) => {
1782
1859
  }
1783
1860
  }
1784
1861
 
1785
- /**
1786
- * Location of the signalk-logbook on-disk store. When the Signal K
1787
- * Resource API grows logbook support, this (and the logbook-source
1788
- * module behind it) is the swap point.
1789
- *
1790
- * @returns {string}
1791
- */
1792
- function logbookStoreDir() {
1793
- const configPath = app.config?.configPath ?? app.dataDir;
1794
- return join(configPath, "plugin-config-data", "signalk-logbook");
1795
- }
1796
-
1797
1862
  /** Startup grace (ms) before the delta-fed path sources are
1798
1863
  * recorded in the status registry (work doc #23, bergie session
1799
1864
  * note): the server replays current values to new subscriptions a
@@ -1932,19 +1997,12 @@ module.exports = (app) => {
1932
1997
  now,
1933
1998
  });
1934
1999
  }
1935
- let logbookReadable = false;
1936
- try {
1937
- await readdir(logbookStoreDir());
1938
- logbookReadable = true;
1939
- } catch (_error) {
1940
- logbookReadable = false;
1941
- }
1942
2000
  sourceStatus.record({
1943
2001
  id: "sk-logbook",
1944
2002
  label: "Logbook (signalk-logbook)",
1945
2003
  kind: "signalk",
1946
- url: logbookStoreDir(),
1947
- ...(logbookReadable ? {} : { absent: true }),
2004
+ url: "resources/logentries",
2005
+ ...((await logbookAvailable(app)) ? {} : { absent: true }),
1948
2006
  now,
1949
2007
  });
1950
2008
  }
@@ -2440,7 +2498,7 @@ module.exports = (app) => {
2440
2498
  router.get("/api/logbook-events", async (req, res) => {
2441
2499
  try {
2442
2500
  const events = await readLogbookSailEvents({
2443
- dir: logbookStoreDir(),
2501
+ app,
2444
2502
  from:
2445
2503
  typeof req.query.from === "string" ? req.query.from : undefined,
2446
2504
  to: typeof req.query.to === "string" ? req.query.to : undefined,
@@ -2700,7 +2758,7 @@ module.exports = (app) => {
2700
2758
  const to =
2701
2759
  typeof req.query.to === "string" ? req.query.to : undefined;
2702
2760
  const events = await readLogbookSailEvents({
2703
- dir: logbookStoreDir(),
2761
+ app,
2704
2762
  from,
2705
2763
  to,
2706
2764
  knownSailKeys: await knownSailKeys(),
@@ -2709,9 +2767,7 @@ module.exports = (app) => {
2709
2767
  req.query.source === "history" &&
2710
2768
  typeof req.query.baseUrl === "string"
2711
2769
  ? createHistoryWindStats({ baseUrl: req.query.baseUrl })
2712
- : createLogbookWindStats(
2713
- await readLogbookEntries(logbookStoreDir()),
2714
- );
2770
+ : createLogbookWindStats(await readLogbookEntries(app));
2715
2771
  const summary = await backfillSailEvents({
2716
2772
  db,
2717
2773
  events,