@meri-imperiumi/signalk-passage-briefing 0.7.1 → 0.8.1

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,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.8.1] - 2026-10-08
6
+
7
+ ### Added
8
+ - Branding on the webapp page (per the Signal K visuals spec): the plugin's app icon (`icon-256.png`) serves as favicon and the base canvas color ships as the `theme-color` meta, so browser chrome matches the console background in both day and night modes.
9
+
10
+ ### Fixed
11
+ - The per-minute source-status tick no longer touches the logbook — fixes "Server unreachable: Fetch is aborted" with an empty route picker on production since 0.8.0. The checklist's logbook probe went through the resources API, whose `logentries` provider answers even a `limit: 1` listing by reading and schema-validating every day file — on a multi-year log that meant overlapping full-store scans every 60 s starving the server so `/api/status` and `/api/routes` blew the webapp's 8 s abort timeout. The logbook is a once-used learning resource (backfill), not an ongoing ingest source, so it leaves the data source statuses entirely; with no provider installed, backfill now fails loudly with the reason instead of silently reporting zero events.
12
+
13
+ ## [0.8.0] - 2026-10-06
14
+
15
+ ### Added
16
+ - 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.
17
+ - 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.
18
+ - 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.
19
+ - 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.
20
+
21
+ ### Changed
22
+ - 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.
23
+
24
+ ### Fixed
25
+ - 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.
26
+ - 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.
27
+
28
+ ### Changed
29
+
30
+ - 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.
31
+
5
32
  ## [0.7.1] - 2026-10-05
6
33
 
7
34
  ### Fixed
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.1",
3
+ "version": "0.8.1",
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,13 +20,7 @@
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");
@@ -533,11 +527,19 @@ module.exports = (app) => {
533
527
  /** Filtered blocks for one cache entry against a track: structured
534
528
  * UKHO JSON entries skip the text pipeline entirely. */
535
529
  function blocksForEntry(entry, track) {
530
+ // Provenance (work doc #31): the feed URL the text arrived from,
531
+ // stamped on every surviving block — the spool path has a file
532
+ // path, not a URL, and carries no provenance
533
+ const sourceUrl = /^https?:\/\//.test(entry.url ?? "") ? entry.url : null;
534
+ const withProvenance = (blocks) =>
535
+ sourceUrl ? blocks.map((block) => ({ ...block, sourceUrl })) : blocks;
536
536
  if (entry.format === "ukho-json") {
537
537
  try {
538
- return ukhoBlocksFromWarnings(
539
- parseUkhoWarnings(JSON.parse(entry.text)),
540
- track,
538
+ return withProvenance(
539
+ ukhoBlocksFromWarnings(
540
+ parseUkhoWarnings(JSON.parse(entry.text)),
541
+ track,
542
+ ),
541
543
  );
542
544
  } catch {
543
545
  return []; // Corrupted entry: skip, other entries still serve
@@ -549,7 +551,7 @@ module.exports = (app) => {
549
551
  track,
550
552
  issuedAt: entry.fetchedAt,
551
553
  });
552
- return bulletin?.blocks ?? [];
554
+ return withProvenance(bulletin?.blocks ?? []);
553
555
  }
554
556
 
555
557
  /** Bulletin-level metadata (header/issuedAt/source/raw text) for
@@ -830,6 +832,42 @@ module.exports = (app) => {
830
832
  * samples (route mode) or the position waypoint (here mode)
831
833
  * @returns {Promise<void>}
832
834
  */
835
+ /**
836
+ * On-board viewer base for weather provenance (work doc #31): the
837
+ * weather-map sibling webapp renders the same fields the briefing
838
+ * derives from, reachable over the LAN underway. The server knows
839
+ * its registered webapps; a link to an absent sibling is dropped
840
+ * rather than served dead. Deep-link parameters (lat/lon/time/
841
+ * layer) are appended per item by the webapp.
842
+ *
843
+ * @returns {string|null} Viewer base path when installed
844
+ */
845
+ function weatherViewerBase() {
846
+ const webapps = app.webapps;
847
+ if (!Array.isArray(webapps)) {
848
+ return null;
849
+ }
850
+ return webapps.some((webapp) => webapp?.name === "signalk-weather-map")
851
+ ? "/signalk-weather-map/"
852
+ : null;
853
+ }
854
+
855
+ /**
856
+ * Stamps the on-board viewer link onto a payload's weather
857
+ * provenance record (work doc #31). Payloads whose cache predates
858
+ * the provenance field keep no record — the UI degrades to no
859
+ * glyph, never a dead link.
860
+ *
861
+ * @param {object} payload - Briefing payload, mutated in place
862
+ * @returns {void}
863
+ */
864
+ function attachWeatherViewer(payload) {
865
+ const base = weatherViewerBase();
866
+ if (base && payload?.metadata?.provenance) {
867
+ payload.metadata.provenance.viewerBase = base;
868
+ }
869
+ }
870
+
833
871
  async function attachHazardEvents(payload, vessel, waypoints) {
834
872
  if (!hazardEventsEnabled) {
835
873
  return;
@@ -854,7 +892,22 @@ module.exports = (app) => {
854
892
  ...hazardFilterConfig,
855
893
  now: new Date(),
856
894
  });
857
- payload.hazardEvents = events;
895
+ // Provenance (work doc #31): the GDACS event page rides each
896
+ // surviving event — one tap from timeline row to origin
897
+ payload.hazardEvents = events.map((event) =>
898
+ event.link
899
+ ? {
900
+ ...event,
901
+ provenance: {
902
+ kind: "warning",
903
+ label: "GDACS",
904
+ url: event.link,
905
+ viewerUrl: null,
906
+ at: event.timestamp ?? null,
907
+ },
908
+ }
909
+ : event,
910
+ );
858
911
  if (notesStore) {
859
912
  const result = await publishHazardNotes({ store: notesStore, events });
860
913
  if (result.published.length > 0 || result.pruned.length > 0) {
@@ -954,12 +1007,33 @@ module.exports = (app) => {
954
1007
  }
955
1008
  const events =
956
1009
  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
- });
1010
+ // Provenance (work doc #31): the sender's info page when
1011
+ // published, else the feed the document arrived from. Stamped on
1012
+ // the FILTERED survivors — stamping the raw cache instead would
1013
+ // bypass the severity, expiry and geography gates (a Puerto Rico
1014
+ // information statement reached a Tonga vessel that way once).
1015
+ payload.capAlerts = capSource
1016
+ .filterCapAlerts({
1017
+ events,
1018
+ track: waypoints.map((w) => [w.lon, w.lat]),
1019
+ minSeverity: capMinSeverity,
1020
+ now: new Date(),
1021
+ })
1022
+ .map((alert) => {
1023
+ const url = alert.web ?? alert.sourceUrl ?? null;
1024
+ return url
1025
+ ? {
1026
+ ...alert,
1027
+ provenance: {
1028
+ kind: "warning",
1029
+ label: alert.web ? "Official alert page" : "CAP feed",
1030
+ url,
1031
+ viewerUrl: null,
1032
+ at: alert.sent ?? null,
1033
+ },
1034
+ }
1035
+ : alert;
1036
+ });
963
1037
  if (notesStore) {
964
1038
  const result = await publishCapAlertNotes({
965
1039
  store: notesStore,
@@ -1045,6 +1119,7 @@ module.exports = (app) => {
1045
1119
  }),
1046
1120
  );
1047
1121
  payload.metadata.mode = "here";
1122
+ attachWeatherViewer(payload);
1048
1123
  const bulletin = await bulletinForTrack(waypoints);
1049
1124
  if (bulletin) {
1050
1125
  payload.metareaBulletin = bulletin;
@@ -1208,6 +1283,7 @@ module.exports = (app) => {
1208
1283
  const payload = await withSourceStatus(WEATHER_SOURCE, () =>
1209
1284
  trackWeatherFetcher({ waypoints, forecastDays }),
1210
1285
  );
1286
+ attachWeatherViewer(payload);
1211
1287
  const bulletin = await bulletinForTrack(waypoints);
1212
1288
  if (bulletin) {
1213
1289
  payload.metareaBulletin = bulletin;
@@ -1782,18 +1858,6 @@ module.exports = (app) => {
1782
1858
  }
1783
1859
  }
1784
1860
 
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
1861
  /** Startup grace (ms) before the delta-fed path sources are
1798
1862
  * recorded in the status registry (work doc #23, bergie session
1799
1863
  * note): the server replays current values to new subscriptions a
@@ -1932,21 +1996,6 @@ module.exports = (app) => {
1932
1996
  now,
1933
1997
  });
1934
1998
  }
1935
- let logbookReadable = false;
1936
- try {
1937
- await readdir(logbookStoreDir());
1938
- logbookReadable = true;
1939
- } catch (_error) {
1940
- logbookReadable = false;
1941
- }
1942
- sourceStatus.record({
1943
- id: "sk-logbook",
1944
- label: "Logbook (signalk-logbook)",
1945
- kind: "signalk",
1946
- url: logbookStoreDir(),
1947
- ...(logbookReadable ? {} : { absent: true }),
1948
- now,
1949
- });
1950
1999
  }
1951
2000
 
1952
2001
  /**
@@ -2440,7 +2489,7 @@ module.exports = (app) => {
2440
2489
  router.get("/api/logbook-events", async (req, res) => {
2441
2490
  try {
2442
2491
  const events = await readLogbookSailEvents({
2443
- dir: logbookStoreDir(),
2492
+ app,
2444
2493
  from:
2445
2494
  typeof req.query.from === "string" ? req.query.from : undefined,
2446
2495
  to: typeof req.query.to === "string" ? req.query.to : undefined,
@@ -2700,7 +2749,7 @@ module.exports = (app) => {
2700
2749
  const to =
2701
2750
  typeof req.query.to === "string" ? req.query.to : undefined;
2702
2751
  const events = await readLogbookSailEvents({
2703
- dir: logbookStoreDir(),
2752
+ app,
2704
2753
  from,
2705
2754
  to,
2706
2755
  knownSailKeys: await knownSailKeys(),
@@ -2709,9 +2758,7 @@ module.exports = (app) => {
2709
2758
  req.query.source === "history" &&
2710
2759
  typeof req.query.baseUrl === "string"
2711
2760
  ? createHistoryWindStats({ baseUrl: req.query.baseUrl })
2712
- : createLogbookWindStats(
2713
- await readLogbookEntries(logbookStoreDir()),
2714
- );
2761
+ : createLogbookWindStats(await readLogbookEntries(app));
2715
2762
  const summary = await backfillSailEvents({
2716
2763
  db,
2717
2764
  events,