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.
- package/CHANGELOG.md +186 -37
- package/README.md +340 -134
- package/index.js +80 -40
- package/lib/api.js +109 -1
- package/lib/background-schedule.js +67 -0
- package/lib/crew.js +185 -0
- package/lib/database.js +103 -1
- package/lib/departure-forecast.js +150 -0
- package/lib/detection.js +45 -14
- package/lib/entries.js +96 -48
- package/lib/event-watcher.js +1 -3
- package/lib/events.js +64 -16
- package/lib/export.js +20 -1
- package/lib/landmark-finder.js +209 -0
- package/lib/landmarks.js +210 -0
- package/lib/logbook-pdf.js +368 -17
- package/lib/observation-recorder.js +11 -5
- package/lib/tide-forecaster.js +23 -116
- package/lib/weather-forecaster.js +147 -0
- package/package.json +2 -1
- package/public/app.css +212 -2
- package/public/entry/entry.css +71 -0
- package/public/entry/js/components/CrewDialog.mjs +200 -0
- package/public/entry/js/main.mjs +114 -0
- package/public/entry/sw.js +2 -0
- package/public/js/animation/camera.mjs +180 -0
- package/public/js/animation/formats.mjs +45 -0
- package/public/js/animation/mercator.mjs +50 -0
- package/public/js/animation/mp4.mjs +77 -0
- package/public/js/animation/player.mjs +120 -0
- package/public/js/animation/renderer.mjs +326 -0
- package/public/js/animation/schedule.mjs +49 -0
- package/public/js/animation/storyboard.mjs +195 -0
- package/public/js/animation/tiles.mjs +219 -0
- package/public/js/animation/timeline.mjs +186 -0
- package/public/js/components/AnimationExport.mjs +173 -0
- package/public/js/components/AnimationView.mjs +439 -0
- package/public/js/components/CrewCard.mjs +22 -0
- package/public/js/components/ExportView.mjs +3 -8
- package/public/js/components/PassageView.mjs +49 -19
- package/public/js/components/Timeline.mjs +30 -7
- package/public/js/components/WeatherCard.mjs +134 -0
- package/public/js/context.mjs +7 -0
- package/public/js/days.mjs +10 -0
- package/public/js/format.mjs +15 -0
- package/public/js/i18n.mjs +246 -0
- package/public/js/landmarks.mjs +128 -0
- package/public/js/main.mjs +8 -5
- package/public/js/weather.mjs +193 -0
- package/public/vendor/mediabunny-LICENSE +373 -0
- 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
|
|
27
|
-
|
|
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
|
|
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
|
|
81
|
+
title: 'Stop duration within which a new departure continues the passage (minutes)',
|
|
76
82
|
description:
|
|
77
|
-
'
|
|
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: '
|
|
143
|
-
description:
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
+
};
|