signalk-chiplog 2.0.0 → 2.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 (51) hide show
  1. package/CHANGELOG.md +186 -37
  2. package/README.md +340 -134
  3. package/index.js +80 -40
  4. package/lib/api.js +109 -1
  5. package/lib/background-schedule.js +67 -0
  6. package/lib/crew.js +185 -0
  7. package/lib/database.js +103 -1
  8. package/lib/departure-forecast.js +150 -0
  9. package/lib/detection.js +45 -14
  10. package/lib/entries.js +96 -48
  11. package/lib/event-watcher.js +1 -3
  12. package/lib/events.js +64 -16
  13. package/lib/export.js +20 -1
  14. package/lib/landmark-finder.js +209 -0
  15. package/lib/landmarks.js +210 -0
  16. package/lib/logbook-pdf.js +368 -17
  17. package/lib/observation-recorder.js +11 -5
  18. package/lib/tide-forecaster.js +23 -116
  19. package/lib/weather-forecaster.js +147 -0
  20. package/package.json +2 -1
  21. package/public/app.css +212 -2
  22. package/public/entry/entry.css +71 -0
  23. package/public/entry/js/components/CrewDialog.mjs +200 -0
  24. package/public/entry/js/main.mjs +114 -0
  25. package/public/entry/sw.js +2 -0
  26. package/public/js/animation/camera.mjs +180 -0
  27. package/public/js/animation/formats.mjs +45 -0
  28. package/public/js/animation/mercator.mjs +50 -0
  29. package/public/js/animation/mp4.mjs +77 -0
  30. package/public/js/animation/player.mjs +120 -0
  31. package/public/js/animation/renderer.mjs +326 -0
  32. package/public/js/animation/schedule.mjs +49 -0
  33. package/public/js/animation/storyboard.mjs +195 -0
  34. package/public/js/animation/tiles.mjs +219 -0
  35. package/public/js/animation/timeline.mjs +186 -0
  36. package/public/js/components/AnimationExport.mjs +173 -0
  37. package/public/js/components/AnimationView.mjs +439 -0
  38. package/public/js/components/CrewCard.mjs +22 -0
  39. package/public/js/components/ExportView.mjs +3 -8
  40. package/public/js/components/PassageView.mjs +49 -19
  41. package/public/js/components/Timeline.mjs +30 -7
  42. package/public/js/components/WeatherCard.mjs +134 -0
  43. package/public/js/context.mjs +7 -0
  44. package/public/js/days.mjs +10 -0
  45. package/public/js/format.mjs +15 -0
  46. package/public/js/i18n.mjs +246 -0
  47. package/public/js/landmarks.mjs +128 -0
  48. package/public/js/main.mjs +8 -5
  49. package/public/js/weather.mjs +193 -0
  50. package/public/vendor/mediabunny-LICENSE +373 -0
  51. package/public/vendor/mediabunny.min.mjs +50 -0
package/index.js CHANGED
@@ -2,13 +2,16 @@ const { registerRoutes } = require('./lib/api');
2
2
  const { openDatabase } = require('./lib/database');
3
3
  const { createPassageDetector, DETECTION_DEFAULTS, TICK_INTERVAL_MS } = require('./lib/detection');
4
4
  const { ApiError } = require('./lib/errors');
5
+ const { createBackgroundSchedule } = require('./lib/background-schedule');
5
6
  const { createEventWatcher, CHECK_INTERVAL_MS, EVENT_DEFAULTS } = require('./lib/event-watcher');
6
7
  const { INFLUX_DEFAULTS } = require('./lib/influx-history');
8
+ const { createLandmarkFinder, LANDMARK_DEFAULTS } = require('./lib/landmark-finder');
7
9
  const { OBSERVATION_DEFAULTS } = require('./lib/observation-recorder');
8
10
  const { createPlaceNamer, GEOCODING_DEFAULTS } = require('./lib/place-names');
9
11
  const { PROPULSION_DEFAULTS } = require('./lib/propulsion-detector');
10
12
  const { createReplayJob } = require('./lib/replay-job');
11
13
  const { createTideForecaster, TIDE_DEFAULTS } = require('./lib/tide-forecaster');
14
+ const { createWeatherForecaster, WEATHER_DEFAULTS } = require('./lib/weather-forecaster');
12
15
  const { createTrackRecorder, SAMPLE_INTERVAL_MS, TRACK_DEFAULTS } = require('./lib/track-recorder');
13
16
  const {
14
17
  createUsbExportScheduler,
@@ -23,8 +26,10 @@ const { version } = require('./package.json');
23
26
  const DEFAULT_PLACE_MATCH_RADIUS = 200;
24
27
  const FIRST_NAMING_DELAY_MS = 5 * 1000;
25
28
  const NAMING_ERROR_RETRY_MS = 5 * 60 * 1000;
26
- const FIRST_TIDE_DELAY_MS = 5 * 1000;
27
- const TIDE_ERROR_RETRY_MS = 5 * 60 * 1000;
29
+ const FIRST_FORECAST_DELAY_MS = 5 * 1000;
30
+ // Landmarks are not needed for the passage under way, only for reading it back,
31
+ // so the first lookup waits for the busier start-up work to be done.
32
+ const FIRST_LANDMARK_DELAY_MS = 30 * 1000;
28
33
 
29
34
  const MOTION_LABELS = { underway: 'Under way', stopped: 'Stopped', unknown: 'Waiting for data' };
30
35
  const MODE_LABELS = { autostate: 'navigation.state', fallback: 'speed fallback' };
@@ -55,11 +60,12 @@ module.exports = function (app) {
55
60
  let settings = null;
56
61
  let detector = null;
57
62
  let namer = null;
58
- let tideForecaster = null;
63
+ let schedules = [];
64
+ // The open passage detection last reported, to notice a new one.
65
+ let lastActiveEntryId = null;
59
66
  let usbExport = null;
60
67
  let replayJob = null;
61
68
  let namingTimer = null;
62
- let tideTimer = null;
63
69
  let timers = [];
64
70
  let lastStatus = null;
65
71
 
@@ -72,9 +78,9 @@ module.exports = function (app) {
72
78
  properties: {
73
79
  stopClosureMinutes: {
74
80
  type: 'number',
75
- title: 'Stop duration that ends a passage (minutes)',
81
+ title: 'Stop duration within which a new departure continues the passage (minutes)',
76
82
  description:
77
- 'Shorter stops, such as waiting for a lock or a lunch anchorage, stay within the same logbook entry',
83
+ 'A passage closes as soon as the boat stops; leaving again sooner than this, after waiting for a lock or a lunch anchorage, reopens it and keeps the stop as a stopover',
78
84
  default: DETECTION_DEFAULTS.stopClosureMinutes,
79
85
  minimum: 1
80
86
  },
@@ -130,6 +136,19 @@ module.exports = function (app) {
130
136
  description: 'The public OpenStreetMap instance by default, or a self-hosted Nominatim',
131
137
  default: GEOCODING_DEFAULTS.geocodingUrl
132
138
  },
139
+ landmarksEnabled: {
140
+ type: 'boolean',
141
+ title: 'Read each journal line against the nearest landmark',
142
+ description:
143
+ 'Fetches the lighthouses, capes, towers and harbours of the areas sailed through from OpenStreetMap, so every position in the logbook is also given as a bearing and distance from the nearest one (© OpenStreetMap contributors, ODbL). Without it, only the coordinates are shown',
144
+ default: LANDMARK_DEFAULTS.landmarksEnabled
145
+ },
146
+ overpassUrl: {
147
+ type: 'string',
148
+ title: 'Landmark service (Overpass API)',
149
+ description: 'The public Overpass instance by default, or a self-hosted one',
150
+ default: LANDMARK_DEFAULTS.overpassUrl
151
+ },
133
152
  tidesEnabled: {
134
153
  type: 'boolean',
135
154
  title: 'Fetch the tide forecast at departure',
@@ -139,10 +158,24 @@ module.exports = function (app) {
139
158
  },
140
159
  tideUrl: {
141
160
  type: 'string',
142
- title: 'Tide service (Open-Meteo Marine-compatible)',
143
- description: 'The public Open-Meteo instance by default, or a self-hosted one',
161
+ title: 'Marine service (Open-Meteo Marine-compatible)',
162
+ description:
163
+ 'Tides, and waves, swell, sea temperature and current for the weather forecast. The public Open-Meteo instance by default, or a self-hosted one',
144
164
  default: TIDE_DEFAULTS.tideUrl
145
165
  },
166
+ weatherEnabled: {
167
+ type: 'boolean',
168
+ title: 'Fetch the marine weather forecast at departure',
169
+ description:
170
+ 'Sends the departure position to the weather service below and to the marine service above for the next 24 hours of wind, sky, sea state and current. Data comes from Open-Meteo (CC BY 4.0)',
171
+ default: WEATHER_DEFAULTS.weatherEnabled
172
+ },
173
+ weatherUrl: {
174
+ type: 'string',
175
+ title: 'Weather service (Open-Meteo-compatible)',
176
+ description: 'The public Open-Meteo instance by default, or a self-hosted one',
177
+ default: WEATHER_DEFAULTS.weatherUrl
178
+ },
146
179
  usbExportPath: {
147
180
  type: 'string',
148
181
  title: 'USB export directory',
@@ -236,6 +269,13 @@ module.exports = function (app) {
236
269
  try {
237
270
  const outcome = detector.tick();
238
271
  usbExport.afterDetection(outcome);
272
+ // A passage just opened: fetch its forecasts and the landmarks of where
273
+ // it is starting from now, not at the next idle poll or at the end of a
274
+ // retry delay left from an earlier failure.
275
+ if (outcome.activeEntryId !== null && outcome.activeEntryId !== lastActiveEntryId) {
276
+ schedules.forEach((schedule) => schedule.nudge());
277
+ }
278
+ lastActiveEntryId = outcome.activeEntryId;
239
279
  const usbError = usbExport.status().lastError;
240
280
  const status = `${describeDetection(outcome)}${usbError ? ` — USB copy failing: ${usbError.message}` : ''}`;
241
281
  if (status !== lastStatus) {
@@ -284,29 +324,6 @@ module.exports = function (app) {
284
324
  namingTimer = setTimeout(runNaming, result.retryInMs);
285
325
  }
286
326
 
287
- // Same idea as geocoding: a network call fetching the tide forecast near a
288
- // recent departure, on its own chain rather than inside detection.
289
- async function runTides() {
290
- const current = tideForecaster;
291
- let result;
292
- try {
293
- result = await current.resolveNext();
294
- } catch (err) {
295
- app.error(`Tide forecast failed: ${err.stack ?? err}`);
296
- result = { retryInMs: TIDE_ERROR_RETRY_MS };
297
- }
298
- if (tideForecaster !== current || result.outcome === 'stopped') {
299
- return;
300
- }
301
- if (result.outcome === 'failed') {
302
- // Expected whenever the boat is out of reach of a network.
303
- app.debug(
304
- `Tide forecast unavailable, retrying in ${Math.round(result.retryInMs / 60000)} min: ${result.error.message}`
305
- );
306
- }
307
- tideTimer = setTimeout(runTides, result.retryInMs);
308
- }
309
-
310
327
  // For work that runs every second: log a failure once, not on every run.
311
328
  function guarded(label, work) {
312
329
  let failing = false;
@@ -345,8 +362,12 @@ module.exports = function (app) {
345
362
  placeMatchRadius: config.placeMatchRadius ?? DEFAULT_PLACE_MATCH_RADIUS,
346
363
  geocodingEnabled: config.geocodingEnabled ?? GEOCODING_DEFAULTS.geocodingEnabled,
347
364
  geocodingUrl: config.geocodingUrl || GEOCODING_DEFAULTS.geocodingUrl,
365
+ landmarksEnabled: config.landmarksEnabled ?? LANDMARK_DEFAULTS.landmarksEnabled,
366
+ overpassUrl: config.overpassUrl || LANDMARK_DEFAULTS.overpassUrl,
348
367
  tidesEnabled: config.tidesEnabled ?? TIDE_DEFAULTS.tidesEnabled,
349
368
  tideUrl: config.tideUrl || TIDE_DEFAULTS.tideUrl,
369
+ weatherEnabled: config.weatherEnabled ?? WEATHER_DEFAULTS.weatherEnabled,
370
+ weatherUrl: config.weatherUrl || WEATHER_DEFAULTS.weatherUrl,
350
371
  usbExportPath: config.usbExportPath || null,
351
372
  usbExportIntervalMinutes:
352
373
  config.usbExportIntervalMinutes ?? USB_EXPORT_DEFAULTS.usbExportIntervalMinutes,
@@ -395,11 +416,6 @@ module.exports = function (app) {
395
416
  settings,
396
417
  userAgent: `signalk-chiplog/${version}`
397
418
  });
398
- tideForecaster = createTideForecaster({
399
- db: database,
400
- settings,
401
- userAgent: `signalk-chiplog/${version}`
402
- });
403
419
  usbExport = createUsbExportScheduler({
404
420
  db: database,
405
421
  settings,
@@ -414,7 +430,32 @@ module.exports = function (app) {
414
430
  onDone: nudgeNaming
415
431
  });
416
432
  namingTimer = setTimeout(runNaming, FIRST_NAMING_DELAY_MS);
417
- tideTimer = setTimeout(runTides, FIRST_TIDE_DELAY_MS);
433
+ // Same idea as geocoding: network calls fetching the tide and weather
434
+ // forecasts near a recent departure, and the landmarks of the areas
435
+ // sailed through, each on its own chain rather than inside detection.
436
+ const networkOptions = { db: database, settings, userAgent: `signalk-chiplog/${version}` };
437
+ const log = (level, message) => (level === 'error' ? app.error(message) : app.debug(message));
438
+ lastActiveEntryId = null;
439
+ schedules = [
440
+ createBackgroundSchedule({
441
+ label: 'Tide forecast',
442
+ resolver: createTideForecaster(networkOptions),
443
+ log,
444
+ firstDelayMs: FIRST_FORECAST_DELAY_MS
445
+ }),
446
+ createBackgroundSchedule({
447
+ label: 'Weather forecast',
448
+ resolver: createWeatherForecaster(networkOptions),
449
+ log,
450
+ firstDelayMs: FIRST_FORECAST_DELAY_MS
451
+ }),
452
+ createBackgroundSchedule({
453
+ label: 'Landmark lookup',
454
+ resolver: createLandmarkFinder(networkOptions),
455
+ log,
456
+ firstDelayMs: FIRST_LANDMARK_DELAY_MS
457
+ })
458
+ ];
418
459
  lastStatus = null;
419
460
  runDetection();
420
461
  timers = [
@@ -440,9 +481,8 @@ module.exports = function (app) {
440
481
  clearTimeout(namingTimer);
441
482
  namer?.stop();
442
483
  namer = null;
443
- clearTimeout(tideTimer);
444
- tideForecaster?.stop();
445
- tideForecaster = null;
484
+ schedules.forEach((schedule) => schedule.stop());
485
+ schedules = [];
446
486
  usbExport?.stop();
447
487
  usbExport = null;
448
488
  replayJob?.cancel();
package/lib/api.js CHANGED
@@ -1,14 +1,17 @@
1
1
  const { getSchemaVersion } = require('./database');
2
+ const crew = require('./crew');
2
3
  const { ApiError, badRequest, conflict, notFound } = require('./errors');
3
4
  const entries = require('./entries');
4
5
  const events = require('./events');
5
6
  const { renderExport } = require('./export');
7
+ const { listEntryLandmarks } = require('./landmarks');
6
8
  const { isTimeZone, PDF_LANGUAGES } = require('./logbook-pdf');
7
9
  const { toGeoJson, toGpx } = require('./formats');
8
10
  const manoeuvreTypes = require('./manoeuvre-types');
9
11
  const places = require('./places');
10
12
  const propulsion = require('./propulsion');
11
13
  const { getTideForecast } = require('./tide-forecaster');
14
+ const { getWeatherForecast } = require('./weather-forecaster');
12
15
  const track = require('./track');
13
16
  const v = require('./validation');
14
17
 
@@ -227,6 +230,20 @@ function registerRoutes(router, { getContext, logError }) {
227
230
  })
228
231
  );
229
232
 
233
+ // The amers a line of this passage's journal can be read against (SPEC
234
+ // §4.13); which one each line takes, and the bearing, is worked out where
235
+ // the line is shown. Empty until the area's landmarks have been fetched.
236
+ readonly.get(
237
+ '/api/entries/:id/landmarks',
238
+ handle(({ db }, req) => {
239
+ const id = entryId(req);
240
+ entries.requireEntryRow(db, id);
241
+ const { limit, offset } = v.parsePagination(req.query);
242
+ const items = listEntryLandmarks(db, id);
243
+ return { total: items.length, limit, offset, items: items.slice(offset, offset + limit) };
244
+ })
245
+ );
246
+
230
247
  readonly.get(
231
248
  '/api/entries/:id/tide',
232
249
  handle(({ db }, req) => {
@@ -240,6 +257,19 @@ function registerRoutes(router, { getContext, logError }) {
240
257
  })
241
258
  );
242
259
 
260
+ readonly.get(
261
+ '/api/entries/:id/weather',
262
+ handle(({ db }, req) => {
263
+ const id = entryId(req);
264
+ entries.requireEntryRow(db, id);
265
+ const forecast = getWeatherForecast(db, id);
266
+ if (!forecast) {
267
+ throw notFound('weather', id);
268
+ }
269
+ return forecast;
270
+ })
271
+ );
272
+
243
273
  // Events
244
274
 
245
275
  readonly.get(
@@ -304,7 +334,8 @@ function registerRoutes(router, { getContext, logError }) {
304
334
  const outcome = events.logCrewEvent(db, input, {
305
335
  now: now(),
306
336
  vesselPosition: vesselPosition(),
307
- placeMatchRadius: config.placeMatchRadius
337
+ placeMatchRadius: config.placeMatchRadius,
338
+ stopClosureMinutes: config.stopClosureMinutes
308
339
  });
309
340
  observeLiveEvent(context, input, outcome);
310
341
  res.status(outcome.created ? 201 : 200);
@@ -405,6 +436,83 @@ function registerRoutes(router, { getContext, logError }) {
405
436
  })
406
437
  );
407
438
 
439
+ // Crew
440
+
441
+ readonly.get(
442
+ '/api/crew',
443
+ handle(({ db }, req) => crew.listCrewMembers(db, v.parsePagination(req.query)))
444
+ );
445
+
446
+ readwrite.post(
447
+ '/api/crew',
448
+ handle(({ db, now }, req, res) => {
449
+ const body = v.requireBody(req.body, ['name', 'role']);
450
+ const member = crew.createCrewMember(
451
+ db,
452
+ {
453
+ name: v.parseString(body.name, 'name', { maxLength: 100 }),
454
+ role: optional(body, 'role', nullableString(100)) ?? null
455
+ },
456
+ now()
457
+ );
458
+ res.status(201);
459
+ return member;
460
+ })
461
+ );
462
+
463
+ readwrite.patch(
464
+ '/api/crew/:id',
465
+ handle(({ db, now }, req) => {
466
+ const id = v.parseId(req.params.id);
467
+ const body = v.requireBody(req.body, ['name', 'role']);
468
+ return crew.updateCrewMember(
469
+ db,
470
+ id,
471
+ {
472
+ name: optional(body, 'name', requiredString(100)),
473
+ role: optional(body, 'role', nullableString(100))
474
+ },
475
+ now()
476
+ );
477
+ })
478
+ );
479
+
480
+ readwrite.delete(
481
+ '/api/crew/:id',
482
+ handle(({ db }, req) => {
483
+ crew.deleteCrewMember(db, v.parseId(req.params.id));
484
+ })
485
+ );
486
+
487
+ readwrite.put(
488
+ '/api/entries/:id/crew',
489
+ handle(({ db, now }, req) => {
490
+ const id = entryId(req);
491
+ entries.requireEntryRow(db, id);
492
+ const body = v.requireBody(req.body, ['members']);
493
+ if (!Array.isArray(body.members)) {
494
+ throw badRequest('members must be an array');
495
+ }
496
+ const members = body.members.map((item, index) => {
497
+ if (!v.isPlainObject(item)) {
498
+ throw badRequest(`members[${index}] must be an object`);
499
+ }
500
+ if ('crewMemberId' in item) {
501
+ const member = v.requireBody(item, ['crewMemberId']);
502
+ return {
503
+ crewMemberId: v.parseId(member.crewMemberId, `members[${index}].crewMemberId`)
504
+ };
505
+ }
506
+ const member = v.requireBody(item, ['name', 'role']);
507
+ return {
508
+ name: v.parseString(member.name, `members[${index}].name`, { maxLength: 100 }),
509
+ role: optional(member, 'role', nullableString(100))
510
+ };
511
+ });
512
+ return crew.setEntryCrew(db, id, members, now());
513
+ })
514
+ );
515
+
408
516
  // Export
409
517
 
410
518
  readonly.get(
@@ -0,0 +1,67 @@
1
+ // Runs a background resolver -- a departure forecast, a landmark lookup -- as
2
+ // its own chain of timeouts: each `resolveNext()` says when the next is due.
3
+ // `nudge()` runs it now -- when a passage opens, rather than leaving it to the
4
+ // idle poll or a retry delay. A nudge during a fetch runs it again as soon as
5
+ // that one ends, never a second chain alongside.
6
+
7
+ const ERROR_RETRY_MS = 5 * 60 * 1000;
8
+
9
+ function createBackgroundSchedule({ label, resolver, log, firstDelayMs }) {
10
+ let timer = null;
11
+ let running = false;
12
+ let nudged = false;
13
+ let stopped = false;
14
+
15
+ function schedule(delayMs) {
16
+ clearTimeout(timer);
17
+ timer = setTimeout(run, delayMs);
18
+ }
19
+
20
+ async function run() {
21
+ timer = null;
22
+ running = true;
23
+ nudged = false;
24
+ let result;
25
+ try {
26
+ result = await resolver.resolveNext();
27
+ } catch (err) {
28
+ log('error', `${label} failed: ${err.stack ?? err}`);
29
+ result = { retryInMs: ERROR_RETRY_MS };
30
+ }
31
+ running = false;
32
+ if (stopped || result.outcome === 'stopped') {
33
+ return;
34
+ }
35
+ if (result.outcome === 'failed') {
36
+ // Expected whenever the boat is out of reach of a network.
37
+ log(
38
+ 'debug',
39
+ `${label} unavailable, retrying in ${Math.round(result.retryInMs / 60000)} min: ${result.error.message}`
40
+ );
41
+ }
42
+ schedule(nudged ? 0 : result.retryInMs);
43
+ }
44
+
45
+ schedule(firstDelayMs);
46
+
47
+ return {
48
+ nudge() {
49
+ if (stopped) {
50
+ return;
51
+ }
52
+ if (running) {
53
+ nudged = true;
54
+ } else {
55
+ schedule(0);
56
+ }
57
+ },
58
+
59
+ stop() {
60
+ stopped = true;
61
+ clearTimeout(timer);
62
+ resolver.stop();
63
+ }
64
+ };
65
+ }
66
+
67
+ module.exports = { createBackgroundSchedule };
package/lib/crew.js ADDED
@@ -0,0 +1,185 @@
1
+ const { notFound } = require('./errors');
2
+
3
+ const nameCollator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
4
+
5
+ function toCrewMember(row) {
6
+ return {
7
+ id: row.id,
8
+ name: row.name,
9
+ role: row.role,
10
+ createdAt: row.created_at,
11
+ updatedAt: row.updated_at
12
+ };
13
+ }
14
+
15
+ function requireCrewMemberRow(db, id) {
16
+ const row = db.prepare('SELECT * FROM crew_members WHERE id = ?').get(id);
17
+ if (!row) {
18
+ throw notFound('crew_member', id);
19
+ }
20
+ return row;
21
+ }
22
+
23
+ // Sorted in JavaScript, same reasoning as listPlaces: SQLite's NOCASE only
24
+ // folds ASCII, and a boat's crew roster stays small enough.
25
+ function listCrewMembers(db, { limit, offset }) {
26
+ const rows = db.prepare('SELECT * FROM crew_members').all();
27
+ rows.sort((a, b) => nameCollator.compare(a.name, b.name) || a.id - b.id);
28
+ return {
29
+ total: rows.length,
30
+ limit,
31
+ offset,
32
+ items: rows.slice(offset, offset + limit).map(toCrewMember)
33
+ };
34
+ }
35
+
36
+ function createCrewMember(db, { name, role }, now) {
37
+ const { lastInsertRowid } = db
38
+ .prepare('INSERT INTO crew_members (name, role, created_at, updated_at) VALUES (?, ?, ?, ?)')
39
+ .run(name, role ?? null, now, now);
40
+ return toCrewMember(requireCrewMemberRow(db, Number(lastInsertRowid)));
41
+ }
42
+
43
+ function updateCrewMember(db, id, patch, now) {
44
+ const row = requireCrewMemberRow(db, id);
45
+ db.prepare('UPDATE crew_members SET name = ?, role = ?, updated_at = ? WHERE id = ?').run(
46
+ patch.name ?? row.name,
47
+ patch.role !== undefined ? patch.role : row.role,
48
+ now,
49
+ id
50
+ );
51
+ return toCrewMember(requireCrewMemberRow(db, id));
52
+ }
53
+
54
+ function deleteCrewMember(db, id) {
55
+ requireCrewMemberRow(db, id);
56
+ db.prepare('DELETE FROM crew_members WHERE id = ?').run(id);
57
+ }
58
+
59
+ // Reuses an existing roster member by name (case- and accent-insensitive,
60
+ // like listCrewMembers' sort), the way an unrecognised place name becomes a
61
+ // new place: a role given now fills one the existing member did not have,
62
+ // but never overwrites one already set -- that goes through updateCrewMember.
63
+ function findOrCreateCrewMember(db, name, role, now) {
64
+ const existing = db
65
+ .prepare('SELECT * FROM crew_members')
66
+ .all()
67
+ .find((row) => nameCollator.compare(row.name, name) === 0);
68
+ if (!existing) {
69
+ return createCrewMember(db, { name, role }, now);
70
+ }
71
+ if (role !== null && existing.role === null) {
72
+ db.prepare('UPDATE crew_members SET role = ?, updated_at = ? WHERE id = ?').run(
73
+ role,
74
+ now,
75
+ existing.id
76
+ );
77
+ return toCrewMember(requireCrewMemberRow(db, existing.id));
78
+ }
79
+ return toCrewMember(existing);
80
+ }
81
+
82
+ // Who was aboard a passage, in the order they were added.
83
+ function listEntryCrew(db, entryId) {
84
+ return db
85
+ .prepare('SELECT * FROM log_entry_crew WHERE entry_id = ? ORDER BY id')
86
+ .all(entryId)
87
+ .map((row) => ({
88
+ id: row.id,
89
+ crewMemberId: row.crew_member_id,
90
+ name: row.name,
91
+ role: row.role
92
+ }));
93
+ }
94
+
95
+ // Replaces entryId's crew with exactly `members`: each item is either
96
+ // { crewMemberId } (an existing roster member, 404 if it doesn't exist) or
97
+ // { name, role } (extends the roster via findOrCreateCrewMember).
98
+ // name/role are denormalised onto the junction row at assignment time.
99
+ function setEntryCrew(db, entryId, members, now) {
100
+ db.prepare('DELETE FROM log_entry_crew WHERE entry_id = ?').run(entryId);
101
+ const insert = db.prepare(
102
+ `INSERT INTO log_entry_crew (entry_id, crew_member_id, name, role, created_at)
103
+ VALUES (?, ?, ?, ?, ?)`
104
+ );
105
+ const seen = new Set();
106
+ for (const member of members) {
107
+ const row =
108
+ member.crewMemberId !== undefined
109
+ ? requireCrewMemberRow(db, member.crewMemberId)
110
+ : findOrCreateCrewMemberRow(db, member.name, member.role ?? null, now);
111
+ if (seen.has(row.id)) {
112
+ continue; // the same roster member picked twice in one request
113
+ }
114
+ seen.add(row.id);
115
+ insert.run(entryId, row.id, row.name, row.role, now);
116
+ }
117
+ return listEntryCrew(db, entryId);
118
+ }
119
+
120
+ function findOrCreateCrewMemberRow(db, name, role, now) {
121
+ const member = findOrCreateCrewMember(db, name, role, now);
122
+ return requireCrewMemberRow(db, member.id);
123
+ }
124
+
125
+ // A new passage starts with the same crew as the one immediately before it
126
+ // (SPEC §4.11), still adjustable from there. Called right after INSERT in
127
+ // openPassage/openEntryByHand, inside the same transaction.
128
+ function copyCrewFromPreviousEntry(db, newEntryId, now) {
129
+ const previous = db
130
+ .prepare('SELECT id FROM log_entries WHERE id <> ? ORDER BY start_time DESC, id DESC LIMIT 1')
131
+ .get(newEntryId);
132
+ if (!previous) {
133
+ return;
134
+ }
135
+ const insert = db.prepare(
136
+ `INSERT INTO log_entry_crew (entry_id, crew_member_id, name, role, created_at)
137
+ VALUES (?, ?, ?, ?, ?)`
138
+ );
139
+ for (const member of listEntryCrew(db, previous.id)) {
140
+ insert.run(newEntryId, member.crewMemberId, member.name, member.role, now);
141
+ }
142
+ }
143
+
144
+ // Called from mergeEntries before the later entry's row is deleted: unions
145
+ // laterId's crew onto earlierId's, skipping anyone already aboard -- carry-
146
+ // over means the two lists usually overlap heavily, and the partial unique
147
+ // index forbids assigning the same roster member to one entry twice.
148
+ function absorbCrewOnMerge(db, earlierId, laterId, now) {
149
+ const earlierCrew = listEntryCrew(db, earlierId);
150
+ const knownIds = new Set(earlierCrew.map((m) => m.crewMemberId).filter((id) => id !== null));
151
+ const knownNames = new Set(
152
+ earlierCrew.filter((m) => m.crewMemberId === null).map((m) => m.name.toLocaleLowerCase())
153
+ );
154
+ const insert = db.prepare(
155
+ `INSERT INTO log_entry_crew (entry_id, crew_member_id, name, role, created_at)
156
+ VALUES (?, ?, ?, ?, ?)`
157
+ );
158
+ for (const member of listEntryCrew(db, laterId)) {
159
+ if (member.crewMemberId !== null) {
160
+ if (knownIds.has(member.crewMemberId)) {
161
+ continue;
162
+ }
163
+ knownIds.add(member.crewMemberId);
164
+ } else {
165
+ const key = member.name.toLocaleLowerCase();
166
+ if (knownNames.has(key)) {
167
+ continue;
168
+ }
169
+ knownNames.add(key);
170
+ }
171
+ insert.run(earlierId, member.crewMemberId, member.name, member.role, now);
172
+ }
173
+ }
174
+
175
+ module.exports = {
176
+ listCrewMembers,
177
+ createCrewMember,
178
+ updateCrewMember,
179
+ deleteCrewMember,
180
+ requireCrewMemberRow,
181
+ listEntryCrew,
182
+ setEntryCrew,
183
+ copyCrewFromPreviousEntry,
184
+ absorbCrewOnMerge
185
+ };