@meri-imperiumi/signalk-passage-briefing 0.2.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.
Files changed (86) hide show
  1. package/.editorconfig +5 -0
  2. package/.github/workflows/publish.yml +34 -0
  3. package/.github/workflows/signalk-ci.yml +11 -0
  4. package/.github/workflows/test.yml +21 -0
  5. package/CHANGELOG.md +423 -0
  6. package/README.md +86 -0
  7. package/SPEC.md +488 -0
  8. package/bin/backfill-report.js +218 -0
  9. package/bin/backtest-cli.js +132 -0
  10. package/biome.json +6 -0
  11. package/doc/here-brief.png +0 -0
  12. package/package.json +48 -0
  13. package/plugin/backtest.js +742 -0
  14. package/plugin/brief-ext.js +173 -0
  15. package/plugin/bulletin-engine.js +746 -0
  16. package/plugin/bulletin-source.js +150 -0
  17. package/plugin/celestial-source.js +417 -0
  18. package/plugin/fetch-engine.js +717 -0
  19. package/plugin/history-backfill.js +540 -0
  20. package/plugin/index.js +1479 -0
  21. package/plugin/logbook-source.js +463 -0
  22. package/plugin/notes-publisher.js +228 -0
  23. package/plugin/notes-store.js +241 -0
  24. package/plugin/raster-convert.js +168 -0
  25. package/plugin/sails-configuration.js +111 -0
  26. package/plugin/spool-watcher.js +218 -0
  27. package/plugin/sqlite-db.js +378 -0
  28. package/plugin/state-machine.js +249 -0
  29. package/plugin/statustilesexamples.js +101 -0
  30. package/plugin/synoptic-map.json +45 -0
  31. package/plugin/synoptic-source.js +227 -0
  32. package/plugin/zone-source.js +339 -0
  33. package/public/app.js +18 -0
  34. package/public/brief-ext-model.js +64 -0
  35. package/public/brief-ext-widget.html +14 -0
  36. package/public/brief-ext-widget.js +294 -0
  37. package/public/components/backfill-controls.js +100 -0
  38. package/public/components/comfort-info.js +94 -0
  39. package/public/components/conditions-here.js +234 -0
  40. package/public/components/horizon-sparkline.js +77 -0
  41. package/public/components/models.mjs +372 -0
  42. package/public/components/passage-outlook.js +368 -0
  43. package/public/components/sk-api.js +146 -0
  44. package/public/components/sk-base-css.js +177 -0
  45. package/public/components/strategic-outlook.js +248 -0
  46. package/public/components/synoptic-chart.js +48 -0
  47. package/public/components/tactical-dashboard.js +172 -0
  48. package/public/css/visuals.css +292 -0
  49. package/public/gmdss-zones-min.json +287 -0
  50. package/public/icon.png +0 -0
  51. package/public/index.html +13 -0
  52. package/public/polar.mjs +303 -0
  53. package/public/route-sim.mjs +750 -0
  54. package/public/sereno-physics.mjs +592 -0
  55. package/public/tack-gybe.js +174 -0
  56. package/public/vendor/plotterext-bus/LICENSE +21 -0
  57. package/public/vendor/plotterext-bus/README.md +17 -0
  58. package/public/vendor/plotterext-bus/chunk-4W6N34SD.js +333 -0
  59. package/public/vendor/plotterext-bus/chunk-7XRFPDQL.js +263 -0
  60. package/public/vendor/plotterext-bus/chunk-RED55KML.js +117 -0
  61. package/public/vendor/plotterext-bus/extension.js +29 -0
  62. package/public/vendor/plotterext-bus/host.js +28 -0
  63. package/public/vendor/utif/LICENSE +21 -0
  64. package/public/vendor/utif/UTIF.js +1171 -0
  65. package/public/worker.js +35 -0
  66. package/status-tiles-examples.json +55 -0
  67. package/tests/backtest.test.js +461 -0
  68. package/tests/brief-ext.test.js +194 -0
  69. package/tests/bulletin-engine.test.js +361 -0
  70. package/tests/celestial-source.test.js +265 -0
  71. package/tests/fetch-engine.test.js +297 -0
  72. package/tests/history-backfill.test.js +341 -0
  73. package/tests/logbook-source.test.js +324 -0
  74. package/tests/notes-publisher.test.js +161 -0
  75. package/tests/notes-store.test.js +138 -0
  76. package/tests/openmeteo-mock.js +100 -0
  77. package/tests/plugin.test.js +1344 -0
  78. package/tests/route-sim.test.js +533 -0
  79. package/tests/sereno-physics.test.js +333 -0
  80. package/tests/sqlite-db.test.js +164 -0
  81. package/tests/state-machine.test.js +182 -0
  82. package/tests/statustilesexamples.test.js +86 -0
  83. package/tests/synoptic-source.test.js +195 -0
  84. package/tests/tack-gybe.test.js +211 -0
  85. package/tests/webapp.test.js +257 -0
  86. package/tests/zone-source.test.js +226 -0
@@ -0,0 +1,341 @@
1
+ const { test, describe } = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const { mkdtempSync } = require("node:fs");
4
+ const { tmpdir } = require("node:os");
5
+ const { join } = require("node:path");
6
+
7
+ const { PassageDatabase } = require("../plugin/sqlite-db.js");
8
+ const {
9
+ WINDOW_MINUTES,
10
+ foldTwaDegrees,
11
+ circularMeanDegrees,
12
+ createLogbookWindStats,
13
+ extractWindWindowStats,
14
+ createHistoryWindStats,
15
+ applySailEvent,
16
+ backfillSailEvents,
17
+ } = require("../plugin/history-backfill.js");
18
+
19
+ const makeDb = () =>
20
+ new PassageDatabase(mkdtempSync(join(tmpdir(), "passage-backfill-")));
21
+
22
+ const approx = (actual, expected, epsilon = 1e-3) =>
23
+ assert.ok(
24
+ Math.abs(actual - expected) < epsilon,
25
+ `expected ${actual} ≈ ${expected}`,
26
+ );
27
+
28
+ describe("wind helpers", () => {
29
+ test("folds directions onto 0..180", () => {
30
+ assert.equal(foldTwaDegrees(0), 0);
31
+ assert.equal(foldTwaDegrees(90), 90);
32
+ assert.equal(foldTwaDegrees(210), 150);
33
+ assert.equal(foldTwaDegrees(-30), 30);
34
+ assert.equal(foldTwaDegrees(360), 0);
35
+ });
36
+
37
+ test("circular mean of folded angles stays in 0..180", () => {
38
+ assert.equal(circularMeanDegrees([10, 20]), 15);
39
+ assert.equal(circularMeanDegrees([45, 135]), 90);
40
+ // Opposite ends of the folded range average through the middle
41
+ assert.equal(circularMeanDegrees([175, 5]), 90);
42
+ assert.equal(circularMeanDegrees([10]), 10);
43
+ });
44
+ });
45
+
46
+ describe("createLogbookWindStats", () => {
47
+ const entries = [
48
+ {
49
+ datetime: "2023-03-20T08:00:00.000Z",
50
+ wind: { speed: 10, direction: 200 },
51
+ },
52
+ {
53
+ datetime: "2023-03-20T09:20:00.000Z",
54
+ text: "Motor stopped, sailing with Main",
55
+ wind: { speed: 12, direction: 210 },
56
+ },
57
+ {
58
+ datetime: "2023-03-20T09:28:00.000Z",
59
+ text: "Sails set: Main (1st reef)",
60
+ wind: { speed: 16, direction: 220 },
61
+ },
62
+ {
63
+ datetime: "2023-03-20T11:00:00.000Z",
64
+ wind: { speed: 20, direction: 230 },
65
+ },
66
+ ];
67
+
68
+ test("collects the event entry and adjacent entries in the window", () => {
69
+ const getStats = createLogbookWindStats(entries);
70
+ const stats = getStats({ timestamp: "2023-03-20T09:28:00.000Z" });
71
+ assert.equal(stats.samples, 2);
72
+ assert.equal(stats.twsAvg, 14);
73
+ assert.equal(stats.twsPeak, 16);
74
+ // Circular mean of 150 (from 210°) and 140 (from 220°)
75
+ assert.equal(Math.round(stats.twaAvg), 145);
76
+ });
77
+
78
+ test("window is bounded at 15 minutes before the event", () => {
79
+ const getStats = createLogbookWindStats(entries);
80
+ const stats = getStats({ timestamp: "2023-03-20T09:28:00.000Z" });
81
+ // 08:00 entry (88 min before) is outside the window
82
+ assert.equal(stats.samples, 2);
83
+
84
+ const hour = createLogbookWindStats(entries, 300);
85
+ assert.equal(hour({ timestamp: "2023-03-20T09:28:00.000Z" }).samples, 3);
86
+ });
87
+
88
+ test("null when the window has no wind data", () => {
89
+ const getStats = createLogbookWindStats(entries);
90
+ assert.equal(getStats({ timestamp: "2023-03-20T10:30:00.000Z" }), null);
91
+ });
92
+ });
93
+
94
+ describe("createHistoryWindStats", () => {
95
+ test("queries the window and converts SI values", async () => {
96
+ const calls = [];
97
+ const fetchImpl = async (url) => {
98
+ calls.push(String(url));
99
+ const u = new URL(url);
100
+ assert.equal(u.pathname, "/signalk/v2/api/history/values");
101
+ assert.equal(
102
+ u.searchParams.get("paths"),
103
+ "environment.wind.speedTrue,environment.wind.angleTrue",
104
+ );
105
+ return {
106
+ ok: true,
107
+ json: async () => ({
108
+ values: [
109
+ { path: "environment.wind.speedTrue" },
110
+ { path: "environment.wind.angleTrue" },
111
+ ],
112
+ data: [
113
+ // m/s, radians: 5.1444 m/s = 10 kn, 210° from = -150° → folded 150
114
+ ["2023-03-20T09:20:00Z", 5.1444, -2.617994],
115
+ ["2023-03-20T09:25:00Z", 8.231, -2.792527],
116
+ ],
117
+ }),
118
+ };
119
+ };
120
+ const getStats = createHistoryWindStats({
121
+ baseUrl: "http://localhost:3000",
122
+ fetchImpl,
123
+ });
124
+ const stats = await getStats({ timestamp: "2023-03-20T09:28:00.000Z" });
125
+ assert.equal(stats.samples, 2);
126
+ // 5.1444 m/s = 10 kn, 8.2310 m/s = 16 kn
127
+ approx(stats.twsAvg, 13);
128
+ approx(stats.twsPeak, 16);
129
+ // Angles −150° and −160° fold to 150° and 160°
130
+ approx(stats.twaAvg, 155);
131
+ const from = new URL(calls[0]).searchParams.get("from");
132
+ const to = new URL(calls[0]).searchParams.get("to");
133
+ assert.equal(
134
+ new Date(to).getTime() - new Date(from).getTime(),
135
+ WINDOW_MINUTES * 60 * 1000,
136
+ );
137
+ });
138
+
139
+ test("HTTP errors propagate", async () => {
140
+ const getStats = createHistoryWindStats({
141
+ baseUrl: "http://localhost:3000",
142
+ fetchImpl: async () => ({ ok: false, status: 500, statusText: "boom" }),
143
+ });
144
+ await assert.rejects(
145
+ getStats({ timestamp: "2023-03-20T09:28:00.000Z" }),
146
+ /History API returned 500/,
147
+ );
148
+ });
149
+ });
150
+
151
+ describe("extractWindWindowStats", () => {
152
+ test("bounds the window and skips non-numeric samples", () => {
153
+ const historyData = {
154
+ values: [
155
+ { path: "environment.wind.speedTrue" },
156
+ { path: "environment.wind.angleTrue" },
157
+ ],
158
+ data: [
159
+ ["2023-03-20T09:00:00Z", 5.0, 1.0], // Outside window
160
+ ["2023-03-20T09:20:00Z", 5.1444, 2.617994], // 10 kn, 150° TWA
161
+ ["2023-03-20T09:25:00Z", null, 2.0], // No speed → skipped
162
+ ["2023-03-20T09:27:00Z", 8.23104, 1.5707963], // 16 kn, 90° TWA
163
+ ],
164
+ };
165
+ const stats = extractWindWindowStats({
166
+ historyData,
167
+ from: new Date("2023-03-20T09:15:00Z"),
168
+ to: new Date("2023-03-20T09:28:00Z"),
169
+ });
170
+ assert.equal(stats.samples, 2);
171
+ approx(stats.twsAvg, 13);
172
+ approx(stats.twsPeak, 16);
173
+ approx(stats.twaAvg, 120);
174
+ });
175
+
176
+ test("null when no speed column or no data", () => {
177
+ assert.equal(
178
+ extractWindWindowStats({
179
+ historyData: { values: [], data: [] },
180
+ from: new Date("2023-03-20T09:15:00Z"),
181
+ to: new Date("2023-03-20T09:28:00Z"),
182
+ }),
183
+ null,
184
+ );
185
+ });
186
+ });
187
+
188
+ describe("applySailEvent", () => {
189
+ test("seeds the matrix bin and the EMA converges with more samples", () => {
190
+ const db = makeDb();
191
+ try {
192
+ const first = applySailEvent({
193
+ db,
194
+ event: {
195
+ timestamp: "2023-03-20T09:28:00.000Z",
196
+ eventType: "REEF_INCREASE",
197
+ sailState: "GENOA_1_MAIN_1_REEF",
198
+ },
199
+ stats: { samples: 1, twsAvg: 16, twsPeak: 16, twaAvg: 145 },
200
+ });
201
+ assert.equal(first.twsBin, 3); // 15..20 kn bin
202
+ assert.equal(first.twaBin, 4); // 120..150° bin
203
+ assert.equal(first.sampleCount, 1);
204
+ assert.equal(first.avgTwsTrigger, 16);
205
+ assert.equal(first.peakGustTrigger, 16);
206
+
207
+ // α = 0.2 EMA toward the new observation (same bin: 15..20 kn)
208
+ const second = applySailEvent({
209
+ db,
210
+ event: {
211
+ timestamp: "2023-03-25T09:28:00.000Z",
212
+ eventType: "REEF_INCREASE",
213
+ sailState: "GENOA_1_MAIN_1_REEF",
214
+ },
215
+ stats: { samples: 2, twsAvg: 18, twsPeak: 21, twaAvg: 145 },
216
+ });
217
+ assert.equal(second.sampleCount, 2);
218
+ approx(second.avgTwsTrigger, 0.8 * 16 + 0.2 * 18);
219
+ approx(second.peakGustTrigger, 0.8 * 16 + 0.2 * 21);
220
+
221
+ const matrix = db.getSailPreferenceMatrix();
222
+ assert.equal(matrix.matrix.length, 1);
223
+ assert.equal(matrix.matrix[0].preferredSailState, "GENOA_1_MAIN_1_REEF");
224
+ assert.equal(matrix.matrix[0].samplesCount, 2);
225
+
226
+ // Wind cache rows exist for both events
227
+ assert.equal(
228
+ db.getWindHistory("2023-03-20T00:00:00Z", "2023-03-26T00:00:00Z")
229
+ .length,
230
+ 2,
231
+ );
232
+ } finally {
233
+ db.close();
234
+ }
235
+ });
236
+ });
237
+
238
+ describe("backfillSailEvents", () => {
239
+ const events = [
240
+ {
241
+ timestamp: "2023-03-20T09:28:00.000Z",
242
+ eventType: "REEF_INCREASE",
243
+ sailState: "GENOA_1_MAIN_1_REEF",
244
+ notes: "Sails set: Main (1st reef), Genoa 1",
245
+ },
246
+ {
247
+ timestamp: "2023-03-20T09:45:00.000Z", // Window has no wind entries
248
+ eventType: "SAIL_CHANGE",
249
+ sailState: "NO_SAILS",
250
+ },
251
+ ];
252
+
253
+ test("learns events with wind, skips events without, is idempotent", async () => {
254
+ const db = makeDb();
255
+ try {
256
+ const entries = [
257
+ {
258
+ datetime: "2023-03-20T09:28:00.000Z",
259
+ wind: { speed: 16, direction: 220 },
260
+ },
261
+ ];
262
+ const getWindStats = createLogbookWindStats(entries);
263
+
264
+ const first = await backfillSailEvents({ db, events, getWindStats });
265
+ assert.equal(first.total, 2);
266
+ assert.equal(first.learned, 1);
267
+ assert.equal(first.skippedNoData, 1);
268
+ assert.equal(first.cells.length, 1);
269
+
270
+ // Recorded into the logbook events table
271
+ assert.equal(db.getSailEvents().length, 1);
272
+ assert.equal(db.getSailEvents()[0].eventType, "REEF_INCREASE");
273
+
274
+ // Re-run: everything already cached
275
+ const second = await backfillSailEvents({ db, events, getWindStats });
276
+ assert.equal(second.learned, 0);
277
+ assert.equal(second.skippedCached, 1);
278
+ assert.equal(second.skippedNoData, 1);
279
+ assert.equal(db.getMatrixBins().length, 1);
280
+ assert.equal(db.getMatrixBins()[0].sampleCount, 1);
281
+ } finally {
282
+ db.close();
283
+ }
284
+ });
285
+
286
+ test("processes events chronologically regardless of input order", async () => {
287
+ const db = makeDb();
288
+ try {
289
+ const entries = [
290
+ {
291
+ datetime: "2023-03-20T09:28:00.000Z",
292
+ wind: { speed: 16, direction: 220 },
293
+ },
294
+ {
295
+ datetime: "2023-03-21T09:28:00.000Z",
296
+ wind: { speed: 8, direction: 220 },
297
+ },
298
+ ];
299
+ // Shuffled input: the 03-21 reef decrease first, then the two
300
+ // 03-20 events out of order. The shake-out 90 s after the reef
301
+ // shares the reef event's wind window (adjacent-entry wind).
302
+ const summary = await backfillSailEvents({
303
+ db,
304
+ events: [
305
+ {
306
+ timestamp: "2023-03-21T09:28:00.000Z",
307
+ eventType: "REEF_DECREASE",
308
+ sailState: "GENOA_1_MAIN",
309
+ },
310
+ {
311
+ timestamp: "2023-03-20T09:29:30.000Z",
312
+ eventType: "SAIL_CHANGE",
313
+ sailState: "NO_SAILS",
314
+ },
315
+ {
316
+ timestamp: "2023-03-20T09:28:00.000Z",
317
+ eventType: "REEF_INCREASE",
318
+ sailState: "GENOA_1_MAIN_1_REEF",
319
+ },
320
+ ],
321
+ getWindStats: createLogbookWindStats(entries),
322
+ });
323
+ assert.equal(summary.total, 3);
324
+ assert.equal(summary.learned, 3);
325
+
326
+ const bins = db.getMatrixBins();
327
+ assert.equal(bins.length, 2);
328
+ // Chronological order matters: the bin's preferred sail is the
329
+ // most recent observation, and the EMA runs over 16 → 16 → 8 kn
330
+ const reefBin = bins.find((bin) => bin.twsBin === 3);
331
+ assert.equal(reefBin.sampleCount, 2);
332
+ approx(reefBin.avgTwsTrigger, 0.8 * 16 + 0.2 * 16);
333
+ assert.equal(reefBin.preferredSail, "NO_SAILS");
334
+ const lightBin = bins.find((bin) => bin.twsBin === 1);
335
+ assert.equal(lightBin.sampleCount, 1);
336
+ assert.equal(lightBin.preferredSail, "GENOA_1_MAIN");
337
+ } finally {
338
+ db.close();
339
+ }
340
+ });
341
+ });
@@ -0,0 +1,324 @@
1
+ const { test, describe } = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const { mkdtempSync, writeFileSync, mkdirSync } = require("node:fs");
4
+ const { tmpdir } = require("node:os");
5
+ const { join } = require("node:path");
6
+
7
+ const {
8
+ parseSailsString,
9
+ sailStateKey,
10
+ classifySailChange,
11
+ extractSailEvents,
12
+ readLogbookSailEvents,
13
+ } = require("../plugin/logbook-source.js");
14
+
15
+ describe("parseSailsString", () => {
16
+ test("parses a plain sail list", () => {
17
+ const state = parseSailsString("Main, Genoa 1");
18
+ assert.deepEqual(state, {
19
+ MAIN: { reefs: null, furledPercent: null },
20
+ GENOA_1: { reefs: null, furledPercent: null },
21
+ });
22
+ });
23
+
24
+ test("parses reef and furl modifiers", () => {
25
+ const state = parseSailsString("Main (1st reef), Genoa 1 (20% furled)");
26
+ assert.equal(state.MAIN.reefs, 1);
27
+ assert.equal(state.GENOA_1.furledPercent, 20);
28
+ });
29
+
30
+ test("cuts manually appended free text at the first period", () => {
31
+ const state = parseSailsString(
32
+ "Main (1st reef), Genoa 1 (30% furled). Big waves and low clouds",
33
+ );
34
+ assert.deepEqual(Object.keys(state), ["MAIN", "GENOA_1"]);
35
+ assert.equal(state.MAIN.reefs, 1);
36
+ assert.equal(state.GENOA_1.furledPercent, 30);
37
+ });
38
+
39
+ test("filters free-text noise against the sail inventory", () => {
40
+ const known = new Set(["MAIN", "GENOA_1", "STAYSAIL"]);
41
+ // Manual edit without a period separator: unknown component ends
42
+ // the parse before the noise
43
+ const state = parseSailsString(
44
+ "Main, the combo stopped working so we drifted",
45
+ { knownSailKeys: known },
46
+ );
47
+ assert.deepEqual(Object.keys(state), ["MAIN"]);
48
+ // Without an inventory everything parses (and the noise pollutes)
49
+ const unfiltered = parseSailsString("Main, the combo stopped working");
50
+ assert.equal(unfiltered.THE_COMBO_STOPPED_WORKING.reefs, null);
51
+ });
52
+
53
+ test("keeps periods inside parentheticals", () => {
54
+ const state = parseSailsString("Main, Genoa 1 (2.5 oz cloth)");
55
+ assert.deepEqual(Object.keys(state), ["MAIN", "GENOA_1"]);
56
+ });
57
+
58
+ test("ignores unrecognized parentheticals but keeps the sail", () => {
59
+ const state = parseSailsString("Main (?)");
60
+ assert.deepEqual(state, { MAIN: { reefs: null, furledPercent: null } });
61
+ });
62
+
63
+ test("returns null for a fragment with no sail component", () => {
64
+ assert.equal(parseSailsString(". Just notes, no sails"), null);
65
+ assert.equal(parseSailsString(""), null);
66
+ });
67
+ });
68
+
69
+ describe("sailStateKey", () => {
70
+ test("is order-independent and canonical", () => {
71
+ const a = sailStateKey(parseSailsString("Main (1st reef), Genoa 1"));
72
+ const b = sailStateKey(parseSailsString("Genoa 1, Main (1st reef)"));
73
+ assert.equal(a, b);
74
+ assert.equal(a, "GENOA_1_MAIN_1_REEF");
75
+ });
76
+
77
+ test("encodes furl ratio", () => {
78
+ assert.equal(
79
+ sailStateKey(parseSailsString("Genoa 1 (20% furled)")),
80
+ "GENOA_1_20_FURLED",
81
+ );
82
+ });
83
+
84
+ test("bare poles", () => {
85
+ assert.equal(sailStateKey({}), "NO_SAILS");
86
+ assert.equal(sailStateKey(null), "NO_SAILS");
87
+ });
88
+ });
89
+
90
+ describe("classifySailChange", () => {
91
+ const s = (text) => parseSailsString(text);
92
+
93
+ test("reef increase", () => {
94
+ assert.equal(
95
+ classifySailChange(s("Main, Genoa 1"), s("Main (1st reef), Genoa 1")),
96
+ "REEF_INCREASE",
97
+ );
98
+ });
99
+
100
+ test("reef decrease", () => {
101
+ assert.equal(
102
+ classifySailChange(
103
+ s("Main (2nd reef), Staysail"),
104
+ s("Main (1st reef), Staysail"),
105
+ ),
106
+ "REEF_DECREASE",
107
+ );
108
+ });
109
+
110
+ test("furling in counts as a reef increase", () => {
111
+ assert.equal(
112
+ classifySailChange(s("Main, Genoa 1"), s("Main, Genoa 1 (30% furled)")),
113
+ "REEF_INCREASE",
114
+ );
115
+ });
116
+
117
+ test("handing a sail is a sail change even with reefs involved", () => {
118
+ assert.equal(
119
+ classifySailChange(s("Main (1st reef), Genoa 1"), s("Main (2nd reef)")),
120
+ "SAIL_CHANGE",
121
+ );
122
+ });
123
+
124
+ test("unknown previous state is a plain sail change", () => {
125
+ assert.equal(classifySailChange(null, s("Main")), "SAIL_CHANGE");
126
+ });
127
+ });
128
+
129
+ describe("extractSailEvents", () => {
130
+ test("inventory filter keeps events but normalizes state keys", () => {
131
+ const known = new Set(["MAIN", "GENOA_1"]);
132
+ const events = extractSailEvents(
133
+ [
134
+ {
135
+ datetime: "2023-03-20T14:19:16.099Z",
136
+ text: "Sails set: Main (1st reef), The cone",
137
+ wind: { speed: 15, direction: 225 },
138
+ },
139
+ {
140
+ datetime: "2023-03-20T16:00:00.000Z",
141
+ text: "Sails set: Main (2nd reef), Genoa 1",
142
+ wind: { speed: 20, direction: 225 },
143
+ },
144
+ {
145
+ datetime: "2023-03-20T18:00:00.000Z",
146
+ text: "Sails set: Main (3rd reef), Genoa 1",
147
+ wind: { speed: 24, direction: 225 },
148
+ },
149
+ ],
150
+ { knownSailKeys: known },
151
+ );
152
+ // First event parses to just the Main component; the state still
153
+ // transitions, and the noise never becomes a sail state key
154
+ assert.equal(events.length, 3);
155
+ assert.equal(events[0].sailState, "MAIN_1_REEF");
156
+ assert.equal(events[1].sailState, "GENOA_1_MAIN_2_REEF");
157
+ assert.equal(events[2].sailState, "GENOA_1_MAIN_3_REEF");
158
+ // Unknown → Main is a plain change; handing the genoa alongside a
159
+ // reef is a mixed change; the third step is a pure reef increase
160
+ assert.equal(events[0].eventType, "SAIL_CHANGE");
161
+ assert.equal(events[1].eventType, "SAIL_CHANGE");
162
+ assert.equal(events[2].eventType, "REEF_INCREASE");
163
+ });
164
+
165
+ test("tracks state across the real-world text patterns", () => {
166
+ const events = extractSailEvents([
167
+ {
168
+ datetime: "2023-03-20T10:00:00.000Z",
169
+ text: "Motor stopped, sailing with Main, Genoa 1",
170
+ wind: { speed: 11.2, direction: 210 },
171
+ },
172
+ {
173
+ datetime: "2023-03-20T12:00:00.000Z",
174
+ text: "Sailing with Main, Genoa 1",
175
+ wind: { speed: 12, direction: 220 },
176
+ },
177
+ {
178
+ datetime: "2023-03-20T14:19:16.099Z",
179
+ text: "Sails set: Main (1st reef), Genoa 1",
180
+ wind: { speed: 15.5, direction: 225 },
181
+ },
182
+ {
183
+ datetime: "2023-03-20T15:49:51.097Z",
184
+ text: "Sails set: Main, Genoa 1",
185
+ wind: { speed: 13, direction: 230 },
186
+ },
187
+ {
188
+ datetime: "2023-03-20T17:00:00.000Z",
189
+ text: "Sails down, motoring. Thunder.",
190
+ wind: { speed: 6, direction: 180 },
191
+ },
192
+ {
193
+ datetime: "2023-03-20T17:30:00.000Z",
194
+ text: "Sails up, motor off",
195
+ },
196
+ ]);
197
+
198
+ assert.equal(events.length, 3);
199
+ // The motor-stop snapshot established the state, so the identical
200
+ // "Sailing with" declaration produced no event — the reef went in next
201
+ assert.equal(events[0].eventType, "REEF_INCREASE");
202
+ assert.equal(events[0].sailState, "GENOA_1_MAIN_1_REEF");
203
+ assert.equal(events[0].twsKnots, 15.5);
204
+ assert.equal(events[0].twaDeg, 135);
205
+ assert.match(events[0].notes, /Sails set/);
206
+ // Shake-out
207
+ assert.equal(events[1].eventType, "REEF_DECREASE");
208
+ // Bare poles (manual suffix ignored)
209
+ assert.equal(events[2].eventType, "SAIL_CHANGE");
210
+ assert.equal(events[2].sailState, "NO_SAILS");
211
+ assert.equal(events[2].twaDeg, 180);
212
+ });
213
+
214
+ test("skips duplicate state declarations and sorts out of order input", () => {
215
+ const events = extractSailEvents([
216
+ { datetime: "2023-03-20T12:00:00.000Z", text: "Sails set: Main" },
217
+ {
218
+ datetime: "2023-03-20T10:00:00.000Z",
219
+ text: "Sails set: Main, Genoa 1",
220
+ },
221
+ { datetime: "2023-03-20T14:00:00.000Z", text: "Sails set: Main" },
222
+ ]);
223
+ assert.equal(events.length, 2);
224
+ assert.equal(events[0].sailState, "GENOA_1_MAIN");
225
+ assert.equal(events[1].sailState, "MAIN");
226
+ });
227
+
228
+ test("unknown motor-stop snapshots do not clobber known state", () => {
229
+ const events = extractSailEvents([
230
+ {
231
+ datetime: "2023-03-20T10:00:00.000Z",
232
+ text: "Sails set: Main (1st reef)",
233
+ },
234
+ { datetime: "2023-03-20T11:00:00.000Z", text: "Motor stopped, sailing" },
235
+ {
236
+ datetime: "2023-03-20T12:00:00.000Z",
237
+ text: "Sails set: Main (2nd reef)",
238
+ },
239
+ ]);
240
+ assert.equal(events.length, 2);
241
+ assert.equal(events[1].eventType, "REEF_INCREASE");
242
+ });
243
+ });
244
+
245
+ describe("readLogbookSailEvents", () => {
246
+ test("walks day files chronologically and filters the range", async () => {
247
+ const dir = mkdtempSync(join(tmpdir(), "passage-logbook-"));
248
+ writeFileSync(
249
+ join(dir, "2023-03-19.yml"),
250
+ `- datetime: 2023-03-19T09:00:00.000Z
251
+ text: "Motor stopped, sailing with Main, Genoa 1"
252
+ wind:
253
+ speed: 10.0
254
+ direction: 200
255
+ `,
256
+ );
257
+ writeFileSync(
258
+ join(dir, "2023-03-20.yml"),
259
+ `- datetime: 2023-03-20T08:00:00.000Z
260
+ text: "Sails set: Main (1st reef), Genoa 1"
261
+ wind:
262
+ speed: 16.0
263
+ direction: 210
264
+ - datetime: 2023-03-20T10:00:00.000Z
265
+ text: "Sails down, motoring"
266
+ wind:
267
+ speed: 8.0
268
+ direction: 220
269
+ `,
270
+ );
271
+ // Unrelated file must be ignored
272
+ writeFileSync(join(dir, "notes.txt"), "hello");
273
+
274
+ const all = await readLogbookSailEvents({ dir });
275
+ assert.equal(all.length, 2);
276
+ assert.equal(all[0].eventType, "REEF_INCREASE");
277
+ assert.equal(all[1].eventType, "SAIL_CHANGE");
278
+
279
+ const windowed = await readLogbookSailEvents({
280
+ dir,
281
+ from: "2023-03-20T00:00:00.000Z",
282
+ to: "2023-03-20T09:00:00.000Z",
283
+ });
284
+ assert.equal(windowed.length, 1);
285
+ assert.equal(windowed[0].eventType, "REEF_INCREASE");
286
+
287
+ // Missing store is not fatal
288
+ const none = await readLogbookSailEvents({
289
+ dir: join(dir, "nope"),
290
+ });
291
+ assert.deepEqual(none, []);
292
+ });
293
+
294
+ test("state tracking reaches back before the requested range", async () => {
295
+ const dir = mkdtempSync(join(tmpdir(), "passage-logbook-"));
296
+ writeFileSync(
297
+ join(dir, "2023-03-19.yml"),
298
+ `- datetime: 2023-03-19T09:00:00.000Z
299
+ text: "Sails set: Main (1st reef)"
300
+ `,
301
+ );
302
+ writeFileSync(
303
+ join(dir, "2023-03-20.yml"),
304
+ `- datetime: 2023-03-20T08:00:00.000Z
305
+ text: "Sails set: Main (2nd reef)"
306
+ `,
307
+ );
308
+ const windowed = await readLogbookSailEvents({
309
+ dir,
310
+ from: "2023-03-20T00:00:00.000Z",
311
+ });
312
+ assert.equal(windowed.length, 1);
313
+ // Classified against the state from the previous day's file
314
+ assert.equal(windowed[0].eventType, "REEF_INCREASE");
315
+ });
316
+
317
+ test("skips corrupt day files", async () => {
318
+ const dir = mkdtempSync(join(tmpdir(), "passage-logbook-"));
319
+ mkdirSync(dir, { recursive: true });
320
+ writeFileSync(join(dir, "2023-03-20.yml"), "{{{{ not yaml");
321
+ const events = await readLogbookSailEvents({ dir });
322
+ assert.deepEqual(events, []);
323
+ });
324
+ });