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/lib/database.js CHANGED
@@ -46,7 +46,9 @@ const MIGRATIONS = [
46
46
  start_time TEXT NOT NULL,
47
47
  end_time TEXT,
48
48
  -- Set when the vessel stops, cleared if it moves again before the
49
- -- configured tolerance elapses (SPEC §3.1).
49
+ -- configured tolerance elapses (SPEC §3.1). Since migration 13, only a
50
+ -- passage the crew opened by casting off waits like this; detection
51
+ -- closes the others at once.
50
52
  stopped_since TEXT,
51
53
  start_lat REAL,
52
54
  start_lon REAL,
@@ -256,6 +258,106 @@ const MIGRATIONS = [
256
258
  -- publishes none, or the passage was opened after the fact.
257
259
  ALTER TABLE log_entries ADD COLUMN start_tanks TEXT;
258
260
  ALTER TABLE log_entries ADD COLUMN start_batteries TEXT;
261
+ `,
262
+ `
263
+ -- The marine weather forecast fetched near a passage's departure (SPEC
264
+ -- §4.5.3), shaped like tide_forecasts: "points" is JSON [{ time, windSpeed,
265
+ -- ..., currentDirection }], hourly over the 24 h from departure, in SI units
266
+ -- with null for what the service did not give; empty once fetched if it had
267
+ -- nothing for the position.
268
+ CREATE TABLE weather_forecasts (
269
+ entry_id INTEGER PRIMARY KEY REFERENCES log_entries (id) ON DELETE CASCADE,
270
+ lat REAL NOT NULL,
271
+ lon REAL NOT NULL,
272
+ fetched_at TEXT NOT NULL,
273
+ points TEXT NOT NULL
274
+ );
275
+ `,
276
+ `
277
+ -- Who closed a passage: detection, on arrival, or the crew. Only a passage
278
+ -- detection closed is reopened by a departure within stopClosureMinutes
279
+ -- (SPEC §4.2); the crew closing one confirms the arrival. NULL while active,
280
+ -- and for passages closed before this column existed.
281
+ ALTER TABLE log_entries ADD COLUMN closed_by TEXT CHECK (closed_by IN ('detection', 'crew'));
282
+ `,
283
+ `
284
+ -- Crew list (SPEC §4.11): a small global roster of who might be aboard,
285
+ -- ever -- unlike manoeuvre_types there is no builtin/sort_order, since the
286
+ -- list is short enough to sort alphabetically at read time, like places.
287
+ CREATE TABLE crew_members (
288
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
289
+ name TEXT NOT NULL,
290
+ role TEXT,
291
+ created_at TEXT NOT NULL,
292
+ updated_at TEXT NOT NULL
293
+ );
294
+
295
+ -- Who was aboard a given passage -- the schema's first many-to-many
296
+ -- relationship. name/role are denormalised on purpose, exactly like
297
+ -- log_entries.start_place_name: a logbook is a historical record, so
298
+ -- correcting or deleting a crew_members row (ON DELETE SET NULL) must
299
+ -- never rewrite who a past passage says was aboard.
300
+ CREATE TABLE log_entry_crew (
301
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
302
+ entry_id INTEGER NOT NULL REFERENCES log_entries (id) ON DELETE CASCADE,
303
+ crew_member_id INTEGER REFERENCES crew_members (id) ON DELETE SET NULL,
304
+ name TEXT NOT NULL,
305
+ role TEXT,
306
+ created_at TEXT NOT NULL
307
+ );
308
+
309
+ CREATE INDEX idx_log_entry_crew_entry ON log_entry_crew (entry_id);
310
+
311
+ -- A roster member is assigned to a given entry at most once. An ad hoc
312
+ -- crew member with no roster row (crew_member_id NULL) has nothing to
313
+ -- deduplicate against, hence the partial index.
314
+ CREATE UNIQUE INDEX idx_log_entry_crew_unique_member
315
+ ON log_entry_crew (entry_id, crew_member_id)
316
+ WHERE crew_member_id IS NOT NULL;
317
+ `,
318
+ `
319
+ -- Landmarks -- amers (SPEC §4.13): the named features a journal line's
320
+ -- position is read against, "1.2 nm NE (053°) of Phare du Cap-Ferret".
321
+ -- Fetched from OpenStreetMap area by area and kept: the bearing itself is
322
+ -- worked out at read time, so a past passage fills in as soon as its area
323
+ -- is known, and nothing here is part of the logbook's record.
324
+ CREATE TABLE landmarks (
325
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
326
+ osm_type TEXT NOT NULL CHECK (osm_type IN ('node', 'way', 'relation')),
327
+ osm_id INTEGER NOT NULL,
328
+ name TEXT NOT NULL,
329
+ kind TEXT NOT NULL CHECK (
330
+ kind IN ('lighthouse', 'light', 'cape', 'landmark', 'beacon', 'harbour')
331
+ ),
332
+ lat REAL NOT NULL,
333
+ lon REAL NOT NULL,
334
+ -- The nominal range of its light in metres, when OpenStreetMap gives one:
335
+ -- how far out this amer is still worth naming a position from.
336
+ light_range REAL,
337
+ created_at TEXT NOT NULL,
338
+ updated_at TEXT NOT NULL
339
+ );
340
+
341
+ CREATE UNIQUE INDEX idx_landmarks_osm ON landmarks (osm_type, osm_id);
342
+
343
+ -- Supports the bounding-box prefilter, as for places.
344
+ CREATE INDEX idx_landmarks_position ON landmarks (lat, lon);
345
+
346
+ -- The half-degree cells landmarks have already been fetched for. A fetch
347
+ -- covers its cell plus a margin, so every position inside the cell has all
348
+ -- the amers within range in the table -- one request per cell, ever.
349
+ CREATE TABLE landmark_areas (
350
+ cell_lat INTEGER NOT NULL,
351
+ cell_lon INTEGER NOT NULL,
352
+ fetched_at TEXT NOT NULL,
353
+ PRIMARY KEY (cell_lat, cell_lon)
354
+ );
355
+
356
+ -- Cleared once every cell a closed passage sailed through has been fetched.
357
+ -- An open passage stays pending: it keeps moving into new cells. Existing
358
+ -- passages default to pending, so their areas are fetched too.
359
+ ALTER TABLE log_entries ADD COLUMN landmarks_pending INTEGER NOT NULL DEFAULT 1
360
+ CHECK (landmarks_pending IN (0, 1));
259
361
  `
260
362
  ];
261
363
 
@@ -0,0 +1,150 @@
1
+ const { withTransaction } = require('./database');
2
+
3
+ // What the tide and weather forecasters share: fetch, once per passage, a
4
+ // forecast near the departure position for the 24 h from departure, and keep
5
+ // it in a table of its own -- one row per entry, `points` as JSON
6
+ // [{ time, ... }] (SPEC §4.5.2, §4.5.3).
7
+
8
+ const IDLE_MS = 60 * 1000;
9
+ // Offline is the normal state at sea; retry patiently rather than hammering.
10
+ const FIRST_RETRY_MS = 5 * 60 * 1000;
11
+ const MAX_RETRY_MS = 60 * 60 * 1000;
12
+ // A fetch this long after departure would no longer be "near departure" --
13
+ // the forecast window has mostly gone by, so give up rather than keep trying.
14
+ const MAX_FETCH_AGE_MS = 3 * 60 * 60 * 1000;
15
+ const WINDOW_MS = 24 * 60 * 60 * 1000;
16
+ const REQUEST_TIMEOUT_MS = 10 * 1000;
17
+
18
+ function iso(ms) {
19
+ return new Date(ms).toISOString();
20
+ }
21
+
22
+ // The entry most recently departed that has no forecast yet -- fetched or
23
+ // found to have none, either way recorded as a row, so this never re-selects
24
+ // it. Bounded to recent departures: a fetch long after departure would mean
25
+ // requesting a window mostly in the past, which the forecast API does not
26
+ // have, and repeatedly retrying an entry the boat was offline for the whole
27
+ // relevant window of would never succeed anyway.
28
+ function nextPending(db, table, now) {
29
+ return db
30
+ .prepare(
31
+ `SELECT id, start_time, start_lat AS lat, start_lon AS lon FROM log_entries
32
+ WHERE start_lat IS NOT NULL AND start_lon IS NOT NULL
33
+ AND start_time >= ?
34
+ AND id NOT IN (SELECT entry_id FROM ${table})
35
+ ORDER BY id DESC LIMIT 1`
36
+ )
37
+ .get(iso(now - MAX_FETCH_AGE_MS));
38
+ }
39
+
40
+ function save(db, table, entryId, position, now, points) {
41
+ withTransaction(db, () => {
42
+ db.prepare(
43
+ `INSERT INTO ${table} (entry_id, lat, lon, fetched_at, points)
44
+ VALUES (?, ?, ?, ?, ?)
45
+ ON CONFLICT (entry_id) DO NOTHING`
46
+ ).run(entryId, position.lat, position.lon, iso(now), JSON.stringify(points));
47
+ });
48
+ }
49
+
50
+ // Open-Meteo answers hourly times as "2026-09-17T08:00", in UTC as asked.
51
+ function utcTime(time) {
52
+ return `${time}:00.000Z`;
53
+ }
54
+
55
+ // A GET to an Open-Meteo-style service. Rate limiting or server trouble is
56
+ // worth retrying later (throws); any other refusal -- an unrecognised
57
+ // parameter, say -- is a final answer of "nothing usable" (null).
58
+ async function getJson(fetch, url, { userAgent, signal, label }) {
59
+ const response = await fetch(url, {
60
+ headers: { 'User-Agent': userAgent, Accept: 'application/json' },
61
+ signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
62
+ });
63
+ if (response.status === 429 || response.status >= 500) {
64
+ throw new Error(`${label} answered HTTP ${response.status}`);
65
+ }
66
+ if (!response.ok) {
67
+ return null;
68
+ }
69
+ return response.json();
70
+ }
71
+
72
+ // `fetchPoints(position, signal)` returns every point the service has (the
73
+ // window is cut here), throws to be retried, or returns [] for no data.
74
+ function createDepartureForecaster({ db, table, isEnabled, fetchPoints }) {
75
+ const stopping = new AbortController();
76
+ // Consecutive failures for `failingEntryId`: the retry delay grows with
77
+ // them, and starts afresh for another passage -- a new one should not
78
+ // inherit the long delay an earlier, abandoned one had reached.
79
+ let failures = 0;
80
+ let failingEntryId = null;
81
+
82
+ return {
83
+ async resolveNext() {
84
+ if (!isEnabled()) {
85
+ return { outcome: 'disabled', retryInMs: IDLE_MS };
86
+ }
87
+ const now = Date.now();
88
+ const pending = nextPending(db, table, now);
89
+ if (!pending) {
90
+ failures = 0;
91
+ return { outcome: 'idle', retryInMs: IDLE_MS };
92
+ }
93
+ if (pending.id !== failingEntryId) {
94
+ failures = 0;
95
+ failingEntryId = pending.id;
96
+ }
97
+
98
+ const departure = Date.parse(pending.start_time);
99
+ const position = { lat: pending.lat, lon: pending.lon };
100
+
101
+ let all;
102
+ try {
103
+ all = await fetchPoints(position, stopping.signal);
104
+ } catch (error) {
105
+ if (stopping.signal.aborted) {
106
+ return { outcome: 'stopped' };
107
+ }
108
+ failures += 1;
109
+ return {
110
+ outcome: 'failed',
111
+ error,
112
+ retryInMs: Math.min(FIRST_RETRY_MS * 2 ** (failures - 1), MAX_RETRY_MS)
113
+ };
114
+ }
115
+ if (stopping.signal.aborted) {
116
+ return { outcome: 'stopped' };
117
+ }
118
+ failures = 0;
119
+
120
+ const points = all.filter((point) => {
121
+ const time = Date.parse(point.time);
122
+ return time >= departure && time < departure + WINDOW_MS;
123
+ });
124
+ save(db, table, pending.id, position, now, points);
125
+ return { outcome: points.length > 0 ? 'fetched' : 'no_data', retryInMs: 0 };
126
+ },
127
+
128
+ stop() {
129
+ stopping.abort();
130
+ }
131
+ };
132
+ }
133
+
134
+ function readForecast(db, table, entryId) {
135
+ const row = db.prepare(`SELECT * FROM ${table} WHERE entry_id = ?`).get(entryId);
136
+ if (!row) {
137
+ return null;
138
+ }
139
+ const points = JSON.parse(row.points);
140
+ if (points.length === 0) {
141
+ return null;
142
+ }
143
+ return {
144
+ position: { lat: row.lat, lon: row.lon },
145
+ fetchedAt: row.fetched_at,
146
+ points
147
+ };
148
+ }
149
+
150
+ module.exports = { createDepartureForecaster, readForecast, getJson, utcTime };
package/lib/detection.js CHANGED
@@ -1,4 +1,6 @@
1
1
  const { withTransaction } = require('./database');
2
+ const crew = require('./crew');
3
+ const { recordStopover, reopenableEntry } = require('./entries');
2
4
  const { initialPlaceName } = require('./place-names');
3
5
  const { createObservationRecorder } = require('./observation-recorder');
4
6
  const { createPropulsionTracker } = require('./propulsion-detector');
@@ -280,7 +282,9 @@ function createPassageDetector({ db, readSelfPath, settings, clock = Date.now })
280
282
  return { time: entry.last_moving_at ?? iso(now), position: storedEnd(entry) ?? position };
281
283
  }
282
284
 
283
- function closePassage(entry, now) {
285
+ // Returns what the tick reports about the closure. `late` means the plugin was
286
+ // not running at the arrival: current conditions say nothing about it.
287
+ function closePassage(entry, now, { late = false } = {}) {
284
288
  // A name the crew typed before arrival is kept as it is.
285
289
  const place =
286
290
  entry.end_place_name === null
@@ -289,13 +293,27 @@ function createPassageDetector({ db, readSelfPath, settings, clock = Date.now })
289
293
  db.prepare(
290
294
  `UPDATE log_entries
291
295
  SET state = 'closed', end_time = stopped_since, stopped_since = NULL,
292
- end_place_id = ?, end_place_name = ?, end_place_pending = ?, updated_at = ?
296
+ end_place_id = ?, end_place_name = ?, end_place_pending = ?, closed_by = 'detection',
297
+ updated_at = ?
293
298
  WHERE id = ?`
294
299
  ).run(place.id, place.name, place.pending, iso(now), entry.id);
300
+ return { id: entry.id, endTime: entry.stopped_since, late };
295
301
  }
296
302
 
297
- function openPassage(now, position) {
298
- const departure = speeds.departure(now) ?? { time: now, position };
303
+ // A departure soon after detection closed the last passage carries it on:
304
+ // the stop it ended at stays on its timeline as a stopover.
305
+ function reopenPassage(entry, now, position) {
306
+ recordStopover(db, entry, iso(now));
307
+ db.prepare(
308
+ `UPDATE log_entries
309
+ SET state = 'active', end_time = NULL, closed_by = NULL, end_place_id = NULL,
310
+ end_place_name = NULL, end_place_pending = 0, updated_at = ?
311
+ WHERE id = ?`
312
+ ).run(iso(now), entry.id);
313
+ recordMovement(activeEntry(), now, position, { force: true });
314
+ }
315
+
316
+ function openPassage(now, position, departure) {
299
317
  const previousEnd = db
300
318
  .prepare('SELECT MAX(end_time) AS endTime FROM log_entries')
301
319
  .get().endTime;
@@ -324,13 +342,16 @@ function createPassageDetector({ db, readSelfPath, settings, clock = Date.now })
324
342
  iso(now),
325
343
  iso(now)
326
344
  );
327
- return Number(lastInsertRowid);
345
+ const entryId = Number(lastInsertRowid);
346
+ crew.copyCrewFromPreviousEntry(db, entryId, iso(now));
347
+ return entryId;
328
348
  }
329
349
 
330
350
  function step(now, current, position, previous) {
331
351
  let entry = activeEntry();
332
352
  let resumedAt = null;
333
353
  let opened = null;
354
+ let reopened = null;
334
355
  let closed = null;
335
356
 
336
357
  // A passage left open across a restart with no movement recorded for longer
@@ -342,6 +363,8 @@ function createPassageDetector({ db, readSelfPath, settings, clock = Date.now })
342
363
  const lastMoving = entry.last_moving_at ?? entry.start_time;
343
364
  if (now - Date.parse(lastMoving) >= closureMs) {
344
365
  entry = markStopped(entry, { time: lastMoving, position: storedEnd(entry) }, now);
366
+ closed = closePassage(entry, now, { late: true });
367
+ entry = null;
345
368
  }
346
369
  }
347
370
  }
@@ -350,21 +373,21 @@ function createPassageDetector({ db, readSelfPath, settings, clock = Date.now })
350
373
  if (current === 'underway') {
351
374
  recordMovement(entry, now, position);
352
375
  } else if (current === 'stopped') {
376
+ // Closed as soon as the vessel stops; moving again soon reopens it.
353
377
  entry = markStopped(entry, arrivalOf(entry, now, position), now);
378
+ closed = closePassage(entry, now);
379
+ entry = null;
354
380
  }
355
381
  }
356
382
 
383
+ // Still stopped while active: a passage the crew opened by casting off,
384
+ // not under way yet (or one a previous version left waiting out its stop).
357
385
  if (entry && entry.stopped_since !== null) {
358
386
  const stoppedFor = now - Date.parse(entry.stopped_since);
359
387
  if (stoppedFor >= closureMs) {
360
- closePassage(entry, now);
361
- // Closed well after the tolerance ran out means the plugin was not
362
- // running: current conditions say nothing about that arrival.
363
- closed = {
364
- id: entry.id,
365
- endTime: entry.stopped_since,
388
+ closed = closePassage(entry, now, {
366
389
  late: stoppedFor > closureMs + LATE_CLOSURE_MARGIN_MS
367
- };
390
+ });
368
391
  entry = null;
369
392
  } else if (current === 'underway') {
370
393
  resumedAt = resumeTime(entry, now);
@@ -375,10 +398,18 @@ function createPassageDetector({ db, readSelfPath, settings, clock = Date.now })
375
398
  // Only a transition opens a passage: an entry closed by hand while still
376
399
  // moving must not be reopened on the next tick.
377
400
  if (!entry && current === 'underway' && previous !== 'underway') {
378
- opened = openPassage(now, position);
401
+ const departure = speeds.departure(now) ?? { time: now, position };
402
+ const previousEntry = reopenableEntry(db, iso(departure.time), closureMs);
403
+ if (previousEntry) {
404
+ reopenPassage(previousEntry, now, position);
405
+ resumedAt = departure.time;
406
+ reopened = previousEntry.id;
407
+ } else {
408
+ opened = openPassage(now, position, departure);
409
+ }
379
410
  }
380
411
 
381
- return { resumedAt, opened, closed };
412
+ return { resumedAt, opened, reopened, closed };
382
413
  }
383
414
 
384
415
  return {
package/lib/entries.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const { withTransaction } = require('./database');
2
+ const crew = require('./crew');
2
3
  const { badRequest, conflict, notFound } = require('./errors');
3
4
  const { coordinatesName, initialPlaceName } = require('./place-names');
4
5
  const { distanceBetween, rememberPlaceName } = require('./places');
@@ -10,6 +11,9 @@ const CHILD_TABLES = ['track_points', 'observations', 'propulsion_segments', 'ev
10
11
  // passage's destination: an alarm or a note there belongs to it.
11
12
  const STILL_AT_ARRIVAL_METRES = 1852;
12
13
 
14
+ // Logged with no passage open, these open one: the crew is leaving.
15
+ const DEPARTURE_MANOEUVRES = ['cast_off', 'anchor_up'];
16
+
13
17
  function toEntry(row) {
14
18
  return {
15
19
  id: row.id,
@@ -119,7 +123,8 @@ function getEntry(db, id) {
119
123
  },
120
124
  maxSpeed,
121
125
  maxWindSpeed: wind.tws ?? wind.aws,
122
- maxWindApparent: wind.tws === null && wind.aws !== null
126
+ maxWindApparent: wind.tws === null && wind.aws !== null,
127
+ crew: crew.listEntryCrew(db, id)
123
128
  };
124
129
  }
125
130
 
@@ -211,13 +216,16 @@ function updateEntry(db, id, patch, { placeMatchRadius, now }) {
211
216
  next.distance = patch.distance;
212
217
  }
213
218
 
219
+ // landmarks_pending: an edited departure or arrival position may sit in an
220
+ // area no landmarks have been fetched for (SPEC §4.13); the lookup checks
221
+ // and clears it again.
214
222
  db.prepare(
215
223
  `UPDATE log_entries SET
216
224
  start_time = ?, end_time = ?,
217
225
  start_lat = ?, start_lon = ?, end_lat = ?, end_lon = ?,
218
226
  start_place_id = ?, end_place_id = ?, start_place_name = ?, end_place_name = ?,
219
227
  start_place_pending = ?, end_place_pending = ?,
220
- distance = ?, updated_at = ?
228
+ distance = ?, landmarks_pending = 1, updated_at = ?
221
229
  WHERE id = ?`
222
230
  ).run(
223
231
  next.start_time,
@@ -260,7 +268,8 @@ function closeEntry(db, id, { now, position, placeMatchRadius }) {
260
268
  db.prepare(
261
269
  `UPDATE log_entries
262
270
  SET state = 'closed', end_time = ?, stopped_since = NULL, end_lat = ?, end_lon = ?,
263
- end_place_id = ?, end_place_name = ?, end_place_pending = ?, updated_at = ?
271
+ end_place_id = ?, end_place_name = ?, end_place_pending = ?, closed_by = 'crew',
272
+ updated_at = ?
264
273
  WHERE id = ?`
265
274
  ).run(
266
275
  endTime,
@@ -277,6 +286,37 @@ function closeEntry(db, id, { now, position, placeMatchRadius }) {
277
286
  });
278
287
  }
279
288
 
289
+ // The end of a passage that goes on -- merged into the next one, or reopened
290
+ // by a departure soon after -- would leave no trace on the timeline once
291
+ // overwritten; it is kept as a stopover event at that stop instead. Placed no
292
+ // earlier than the entry_end reading, so it follows it on the timeline instead
293
+ // of appearing to predate the passage's own arrival. Falls back to the entry's
294
+ // end time when there is no reading to follow -- closed by hand, or with no
295
+ // instrument data. The comment carries the raw place name too, like an
296
+ // sk_alarm's message, so it still reads in a CSV export or before any
297
+ // translation is applied.
298
+ function recordStopover(db, row, now) {
299
+ if (row.end_place_name === null) {
300
+ return;
301
+ }
302
+ const reading = db
303
+ .prepare(`SELECT MAX(time) AS time FROM observations WHERE entry_id = ? AND reason = ?`)
304
+ .get(row.id, 'entry_end').time;
305
+ const time = reading && reading > row.end_time ? reading : row.end_time;
306
+ db.prepare(
307
+ `INSERT INTO events (entry_id, time, type, lat, lon, comment, payload, source, created_at)
308
+ VALUES (?, ?, 'stopover', ?, ?, ?, ?, 'auto', ?)`
309
+ ).run(
310
+ row.id,
311
+ time,
312
+ row.end_lat,
313
+ row.end_lon,
314
+ row.end_place_name,
315
+ JSON.stringify({ placeName: row.end_place_name, placePending: row.end_place_pending === 1 }),
316
+ now
317
+ );
318
+ }
319
+
280
320
  // The earlier entry survives and absorbs the later one, so the passage keeps
281
321
  // its original id and departure.
282
322
  function mergeEntries(db, id, otherId, now) {
@@ -309,15 +349,13 @@ function mergeEntries(db, id, otherId, now) {
309
349
  );
310
350
  }
311
351
 
312
- // The "entry_end" observation is dated the same as the entry's own end,
313
- // so this is normally a no-op; it only matters for an entry closed
314
- // before that was true, when the reading could be up to
315
- // `stopClosureMinutes` later than end_time. Read before the reassignment
316
- // below, while entry_id still tells earlier's own snapshot apart from
317
- // later's.
318
- const entryEndObservation = db
319
- .prepare(`SELECT MAX(time) AS time FROM observations WHERE entry_id = ? AND reason = ?`)
320
- .get(earlier.id, 'entry_end').time;
352
+ // Before the reassignment below, while entry_id still tells earlier's own
353
+ // entry_end reading apart from later's.
354
+ recordStopover(db, earlier, now);
355
+ // Unioned rather than reassigned: carry-over (§4.11) means the two crews
356
+ // usually overlap, and a straight UPDATE would collide with the partial
357
+ // unique index on (entry_id, crew_member_id).
358
+ crew.absorbCrewOnMerge(db, earlier.id, later.id, now);
321
359
 
322
360
  for (const table of CHILD_TABLES) {
323
361
  db.prepare(`UPDATE ${table} SET entry_id = ? WHERE entry_id = ?`).run(earlier.id, later.id);
@@ -326,48 +364,19 @@ function mergeEntries(db, id, otherId, now) {
326
364
  // index would otherwise reject the update below.
327
365
  db.prepare('DELETE FROM log_entries WHERE id = ?').run(later.id);
328
366
 
329
- // The merge below overwrites the earlier entry's end with the later
330
- // entry's: without this, the place where the passage actually stopped —
331
- // the whole reason a merge exists — would leave no trace on the timeline.
332
- if (earlier.end_place_name !== null) {
333
- // Placed no earlier than the entry_end reading, so it follows it on
334
- // the timeline instead of appearing to predate the passage's own
335
- // arrival. Falls back to the entry's end time when there is no
336
- // reading to follow — closed by hand, or with no instrument data.
337
- const time =
338
- entryEndObservation && entryEndObservation > earlier.end_time
339
- ? entryEndObservation
340
- : earlier.end_time;
341
- // The comment carries the raw place name too, like an sk_alarm's
342
- // message, so it still reads in a CSV export or before any
343
- // translation is applied.
344
- db.prepare(
345
- `INSERT INTO events (entry_id, time, type, lat, lon, comment, payload, source, created_at)
346
- VALUES (?, ?, 'stopover', ?, ?, ?, ?, 'auto', ?)`
347
- ).run(
348
- earlier.id,
349
- time,
350
- earlier.end_lat,
351
- earlier.end_lon,
352
- earlier.end_place_name,
353
- JSON.stringify({
354
- placeName: earlier.end_place_name,
355
- placePending: earlier.end_place_pending === 1
356
- }),
357
- now
358
- );
359
- }
360
-
367
+ // landmarks_pending: the merged passage now covers the later one's track
368
+ // as well, which may reach areas no landmarks have been fetched for.
361
369
  db.prepare(
362
370
  `UPDATE log_entries SET
363
- state = ?, end_time = ?, stopped_since = ?,
371
+ state = ?, end_time = ?, stopped_since = ?, closed_by = ?,
364
372
  end_lat = ?, end_lon = ?, end_place_id = ?, end_place_name = ?, end_place_pending = ?,
365
- distance = ?, updated_at = ?
373
+ distance = ?, landmarks_pending = 1, updated_at = ?
366
374
  WHERE id = ?`
367
375
  ).run(
368
376
  later.state,
369
377
  later.end_time,
370
378
  later.stopped_since,
379
+ later.closed_by,
371
380
  later.end_lat,
372
381
  later.end_lon,
373
382
  later.end_place_id,
@@ -405,6 +414,40 @@ function entryNearArrival(db, position) {
405
414
  return stillThere ? last.id : null;
406
415
  }
407
416
 
417
+ // The passage a departure at `time` carries on rather than starting a new
418
+ // one: the last one, if detection closed it (the crew closing one confirms the
419
+ // arrival) less than `closureMs` before -- or the crew cast off from it less
420
+ // than `closureMs` before, the boat taking a while to get going. Nothing may
421
+ // have started since, which also keeps a replay of the past from reopening a
422
+ // passage that live passages have since followed.
423
+ function reopenableEntry(db, time, closureMs) {
424
+ const last = db
425
+ .prepare(
426
+ `SELECT * FROM log_entries WHERE state = 'closed' AND end_time <= ?
427
+ ORDER BY end_time DESC, id DESC LIMIT 1`
428
+ )
429
+ .get(time);
430
+ if (!last || last.closed_by !== 'detection') {
431
+ return null;
432
+ }
433
+ const later = db
434
+ .prepare('SELECT 1 FROM log_entries WHERE id <> ? AND start_time >= ? AND start_time <= ?')
435
+ .get(last.id, last.end_time, time);
436
+ if (later) {
437
+ return null;
438
+ }
439
+ const castOff = db
440
+ .prepare(
441
+ `SELECT MAX(time) AS time FROM events
442
+ WHERE entry_id = ? AND type = 'manoeuvre' AND source = 'manual'
443
+ AND subtype IN (${DEPARTURE_MANOEUVRES.map(() => '?').join(', ')})
444
+ AND time >= ? AND time <= ?`
445
+ )
446
+ .get(last.id, ...DEPARTURE_MANOEUVRES, last.end_time, time).time;
447
+ const since = castOff ?? last.end_time;
448
+ return Date.parse(time) - Date.parse(since) < closureMs ? last : null;
449
+ }
450
+
408
451
  // A passage opened by the crew casting off, before detection sees the vessel
409
452
  // move. It starts stopped, so the first movement resumes it rather than opening
410
453
  // another one, and a departure that never happens closes like any long stop.
@@ -432,7 +475,9 @@ function openEntryByHand(db, { time, position, placeMatchRadius, now }) {
432
475
  now,
433
476
  now
434
477
  );
435
- return Number(lastInsertRowid);
478
+ const entryId = Number(lastInsertRowid);
479
+ crew.copyCrewFromPreviousEntry(db, entryId, now);
480
+ return entryId;
436
481
  }
437
482
 
438
483
  function deleteEntry(db, id) {
@@ -453,5 +498,8 @@ module.exports = {
453
498
  recomputeDurations,
454
499
  activeEntryId,
455
500
  entryNearArrival,
456
- openEntryByHand
501
+ openEntryByHand,
502
+ recordStopover,
503
+ reopenableEntry,
504
+ DEPARTURE_MANOEUVRES
457
505
  };
@@ -1,14 +1,12 @@
1
1
  const { createFreshnessTracker } = require('./detection');
2
2
  const { activeEntryId, entryNearArrival } = require('./entries');
3
+ const { CRITICAL_STATES } = require('./events');
3
4
 
4
5
  const METRES_PER_SECOND_PER_KNOT = 1852 / 3600;
5
6
 
6
7
  const EVENT_DEFAULTS = { windSpeedThresholds: [20, 30], pressureDropThreshold: 4 };
7
8
 
8
9
  const CHECK_INTERVAL_MS = 1000;
9
-
10
- // Any notification reaching these states is critical, whatever its path.
11
- const CRITICAL_STATES = new Set(['alarm', 'emergency']);
12
10
  const NOTIFICATION_METADATA_KEYS = new Set([
13
11
  'meta',
14
12
  'values',