signalk-chiplog 2.3.1 → 2.4.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 CHANGED
@@ -6,6 +6,26 @@ All notable changes to Chiplog are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.4.0] - 2026-09-18
10
+
11
+ ### Added
12
+
13
+ - **Previous and next passage links at the top of a passage page**, to step through the log without going back to it.
14
+ `GET /entries/:id` gives them as `previousEntryId` and `nextEntryId`, in the log's order. Alt+← and Alt+→ do the same
15
+ from the keyboard.
16
+ - **A Statistics page** sums the logbook up over a period: number of passages, first and last dates, total distance,
17
+ time under way, top speed, strongest wind, the longest passage without a stop (a passage is cut at its stopovers, each
18
+ stretch measured on its own), the flags of the countries visited, and the top 5 passages by duration, distance,
19
+ average speed, top speed and wind. The period is a shortcut — all time, this month, last month, the last 12 months,
20
+ this year, last year — or a range picked in a calendar with two clicks, the first and the last day. It reads
21
+ `GET /statistics`.
22
+ - **One range picker for every date range** in the webapp: the Animation, Export and Retrospective pages use the same
23
+ calendar — two clicks, the first and the last day — instead of a pair of date fields, so a range with its end before
24
+ its start can no longer be entered.
25
+ - **Places now know their country** (`countryCode` on `GET /places`), from the same geocoding lookup that names them;
26
+ places saved earlier, or added by hand, are asked about in the background once pending names are done. Database
27
+ migration 16.
28
+
9
29
  ## [2.3.1] - 2026-09-18
10
30
 
11
31
  ### Added
@@ -287,7 +307,8 @@ First release.
287
307
  - REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
288
308
  - Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
289
309
 
290
- [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.3.1...HEAD
310
+ [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.4.0...HEAD
311
+ [2.4.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.3.1...v2.4.0
291
312
  [2.3.1]: https://github.com/ricard33/signalk-chiplog/compare/v2.3.0...v2.3.1
292
313
  [2.3.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.2.0...v2.3.0
293
314
  [2.2.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.1.0...v2.2.0
package/README.md CHANGED
@@ -201,36 +201,48 @@ Open **Chiplog** from the Signal K webapps, or `/signalk-chiplog/`. Reading need
201
201
  not just what is loaded), then passages grouped by day, newest first, with times, departure and arrival, distance,
202
202
  duration and an engine/sail bar. A passage across midnight appears on both days. Provisional place names are shown as
203
203
  such.
204
- - **Passage page** — summary (distance, duration, average speed, the highest speed and wind seen, and the crew aboard),
205
- map of the track (OpenStreetMap with OpenSeaMap seamarks, which can be hidden) with a small boat marker at the
206
- selected point, a scrubber under the map to step back and forth through its history (defaulting to the latest point,
207
- so it shows the current position on a passage in progress) with a band of that point's time, SOG, COG, STW, TWS, TWD,
208
- TWA and AWA, the marine weather forecast every 3 hours from departure, the tide forecast near the departure (place,
209
- high/low times and heights, height curve) when one was fetched, the engine and sail periods, the boat's status (each
210
- engine's hour counter at departure and arrival and the hours run, and the tank levels and battery charge, voltage and
211
- current noted at departure), and the log: every reading and event in order, including handwritten notes. A passage in
212
- progress refreshes every minute. Each line's comment can be edited (read/write access); a manoeuvre or note the crew
213
- logged themselves can also be deleted automatic lines (alarms, autopilot, weather, corrections) can only be
214
- annotated. Under each position, in grey, its bearing and distance from the nearest landmark.
204
+ - **Passage page** — links at the top to the previous and the next passage (or **Alt+←** and **Alt+→**), then a summary
205
+ (distance, duration, average speed, the highest speed and wind seen, and the crew aboard), map of the track
206
+ (OpenStreetMap with OpenSeaMap seamarks, which can be hidden) with a small boat marker at the selected point, a
207
+ scrubber under the map to step back and forth through its history (defaulting to the latest point, so it shows the
208
+ current position on a passage in progress) with a band of that point's time, SOG, COG, STW, TWS, TWD, TWA and AWA, the
209
+ marine weather forecast every 3 hours from departure, the tide forecast near the departure (place, high/low times and
210
+ heights, height curve) when one was fetched, the engine and sail periods, the boat's status (each engine's hour
211
+ counter at departure and arrival and the hours run, and the tank levels and battery charge, voltage and current noted
212
+ at departure), and the log: every reading and event in order, including handwritten notes. A passage in progress
213
+ refreshes every minute. Each line's comment can be edited (read/write access); a manoeuvre or note the crew logged
214
+ themselves can also be deleted — automatic lines (alarms, autopilot, weather, corrections) can only be annotated.
215
+ Under each position, in grey, its bearing and distance from the nearest landmark.
215
216
  - **Corrections** (read/write access):
216
217
  - rename the departure, or the arrival once the passage is closed — a passage in progress has none yet to rename;
217
218
  - switch an engine period to sail or back;
218
219
  - close a passage in progress, e.g. to confirm an arrival;
219
220
  - merge with the previous or next passage;
220
221
  - delete a passage (admin).
221
- - **Animation** — pick two dates and every passage between them replays on the map, one after another, the port time
222
- skipped. The map follows the boat at a scale chosen for each passage a short hop kept readable rather than
223
- magnified, a long crossing allowed a wider view but never so wide the boat crawls across empty water while a bubble
224
- shows the speed, the distance covered since the start and the date. Play, pause and a slider over the animation's own
225
- time, at ×0,5, ×1, ×2 or ×4. **Export MP4** saves it as a video in one of five shapes (Mobile 9:16, Portrait 3:4,
226
- Square 1:1, Landscape 4:3, Widescreen 16:9). Everything happens in the browser: nothing is rendered or encoded on the
227
- Signal K server, and the map tiles are the only thing downloaded.
222
+ - **Statistics** — the logbook summed up over a period: a shortcut (all time, this month, last month, the last 12
223
+ months, this year, last year) or a range picked in a calendar with two clicks, the first and the last day. It gives
224
+ the number of passages, the dates of the first and the last, total distance, time under way, top speed and strongest
225
+ wind, the longest passage without a stop (a passage with a stopover counts as its stretches between stops) with its
226
+ distance and time, the flags of the countries visited, and the top 5 passages by duration, distance, average speed,
227
+ top speed and wind. Countries come from the place names, so they need geocoding enabled and the boat online now and
228
+ then; places named before this existed fill in by themselves.
229
+ - **Animation** — pick a period and every passage in it replays on the map, one after another, the port time skipped.
230
+ The map follows the boat at a scale chosen for each passage — a short hop kept readable rather than magnified, a long
231
+ crossing allowed a wider view but never so wide the boat crawls across empty water — while a bubble shows the speed,
232
+ the distance covered since the start and the date. Play, pause and a slider over the animation's own time, at ×0,5,
233
+ ×1, ×2 or ×4. **Export MP4** saves it as a video in one of five shapes (Mobile 9:16, Portrait 3:4, Square 1:1,
234
+ Landscape 4:3, Widescreen 16:9). Everything happens in the browser: nothing is rendered or encoded on the Signal K
235
+ server, and the map tiles are the only thing downloaded.
228
236
  - **Export** — download the whole logbook or a date range as a PDF logbook to print, JSON, CSV or GPX, and write the
229
237
  abandon-ship copy to the USB drive now (admin). The PDF is written in the webapp's language and the device's time
230
238
  zone.
231
239
  - **Retrospective** (admin) — reconstruct past passages for a date range from an InfluxDB history (see
232
240
  [Retrospective analysis](#retrospective-analysis-)).
233
241
 
242
+ Every date range in the webapp — Statistics, Animation, Export and Retrospective — is picked the same way: one button
243
+ showing the period, and a calendar under it where two clicks, the first and the last day, make the range. Statistics has
244
+ shortcuts beside it; where any date is allowed, the calendar has an _Any date_ button to clear the range.
245
+
234
246
  **Helm entry** in the top bar opens the tablet entry app.
235
247
 
236
248
  ## The tablet entry app 📱
@@ -410,6 +422,8 @@ at the time.
410
422
  - **Place names.** With geocoding on, the position of each departure and arrival that matches no known place is sent to
411
423
  the geocoding service — OpenStreetMap's public Nominatim by default. Nothing else is sent, and nothing at all when it
412
424
  is off.
425
+ - **Countries.** With geocoding on, a place whose country is not known yet has its position sent to the geocoding
426
+ service once, to ask which country it is in. Nothing at all when it is off.
413
427
  - **Landmarks.** With them on, the area a passage sailed through — a half-degree box, not its track — is sent to the
414
428
  Overpass service, OpenStreetMap's public instance by default, once per area ever. Nothing at all when it is off.
415
429
  - **Tide forecast.** With it on, the departure position of each passage is sent to the tide service — the public
package/lib/api.js CHANGED
@@ -12,6 +12,7 @@ const places = require('./places');
12
12
  const propulsion = require('./propulsion');
13
13
  const { getTideForecast } = require('./tide-forecaster');
14
14
  const { getWeatherForecast } = require('./weather-forecaster');
15
+ const { getStatistics } = require('./statistics');
15
16
  const track = require('./track');
16
17
  const v = require('./validation');
17
18
 
@@ -132,6 +133,11 @@ function registerRoutes(router, { getContext, logError }) {
132
133
  handle(({ db, now }, req) => entries.getStats(db, { ...parseRange(req.query), now: now() }))
133
134
  );
134
135
 
136
+ readonly.get(
137
+ '/api/statistics',
138
+ handle(({ db, now }, req) => getStatistics(db, { ...parseRange(req.query), now: now() }))
139
+ );
140
+
135
141
  readonly.get(
136
142
  '/api/entries/:id',
137
143
  handle(({ db }, req) => entries.getEntry(db, entryId(req)))
package/lib/database.js CHANGED
@@ -358,6 +358,19 @@ const MIGRATIONS = [
358
358
  -- passages default to pending, so their areas are fetched too.
359
359
  ALTER TABLE log_entries ADD COLUMN landmarks_pending INTEGER NOT NULL DEFAULT 1
360
360
  CHECK (landmarks_pending IN (0, 1));
361
+ `,
362
+ `
363
+ -- The country a place is in (SPEC §4.14), as an upper-case ISO 3166-1
364
+ -- alpha-2 code, for the statistics page's visited countries. It lives on the
365
+ -- place rather than on each entry: a passage reaches its country through
366
+ -- start_place_id / end_place_id. country_checked says the lookup has been
367
+ -- made, so a place whose position has no country (open water, or geocoding
368
+ -- switched off after the fact) is not asked about forever. Existing places
369
+ -- start unchecked and are filled in by the same background chain that names
370
+ -- pending departures.
371
+ ALTER TABLE places ADD COLUMN country_code TEXT;
372
+ ALTER TABLE places ADD COLUMN country_checked INTEGER NOT NULL DEFAULT 0
373
+ CHECK (country_checked IN (0, 1));
361
374
  `
362
375
  ];
363
376
 
package/lib/entries.js CHANGED
@@ -48,7 +48,7 @@ function requireEntryRow(db, id) {
48
48
  return row;
49
49
  }
50
50
 
51
- // Shared by listEntries and getStats, so a date range means the same thing
51
+ // Shared by listEntries, getStats and the statistics page, so a date range means the same thing
52
52
  // to both: matched on startTime, from inclusive and to exclusive.
53
53
  function rangeWhere({ from, to }) {
54
54
  const conditions = [];
@@ -94,8 +94,27 @@ function getStats(db, { from, to, now }) {
94
94
  return { count: row.count, distance: row.distance, duration: Math.round(row.duration) };
95
95
  }
96
96
 
97
+ // The passages either side of this one in the log's order (start time, then
98
+ // id), for a page to step to its neighbours.
99
+ function neighbourIds(db, row) {
100
+ const before = db
101
+ .prepare(
102
+ `SELECT id FROM log_entries WHERE start_time < ?1 OR (start_time = ?1 AND id < ?2)
103
+ ORDER BY start_time DESC, id DESC LIMIT 1`
104
+ )
105
+ .get(row.start_time, row.id);
106
+ const after = db
107
+ .prepare(
108
+ `SELECT id FROM log_entries WHERE start_time > ?1 OR (start_time = ?1 AND id > ?2)
109
+ ORDER BY start_time, id LIMIT 1`
110
+ )
111
+ .get(row.start_time, row.id);
112
+ return { previousEntryId: before?.id ?? null, nextEntryId: after?.id ?? null };
113
+ }
114
+
97
115
  function getEntry(db, id) {
98
- const entry = toEntry(requireEntryRow(db, id));
116
+ const row = requireEntryRow(db, id);
117
+ const entry = toEntry(row);
99
118
  const count = (table) =>
100
119
  db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE entry_id = ?`).get(id).n;
101
120
  const maxSpeed = db
@@ -116,6 +135,7 @@ function getEntry(db, id) {
116
135
  .get(id, id);
117
136
  return {
118
137
  ...entry,
138
+ ...neighbourIds(db, row),
119
139
  counts: {
120
140
  trackPoints: count('track_points'),
121
141
  observations: count('observations'),
@@ -488,6 +508,7 @@ function deleteEntry(db, id) {
488
508
  module.exports = {
489
509
  toEntry,
490
510
  requireEntryRow,
511
+ rangeWhere,
491
512
  listEntries,
492
513
  getStats,
493
514
  getEntry,
@@ -61,6 +61,19 @@ function pickName(result) {
61
61
  return district && district !== settlement ? `${settlement} (${district})` : settlement;
62
62
  }
63
63
 
64
+ // The country as an upper-case ISO 3166-1 alpha-2 code, which Nominatim gives in
65
+ // lower case. Null when the position has none, as at sea.
66
+ function pickCountry(result) {
67
+ const code = result?.address?.country_code;
68
+ return typeof code === 'string' && /^[a-z]{2}$/i.test(code) ? code.toUpperCase() : null;
69
+ }
70
+
71
+ // Country level for a place asked about only for its country: the answer to a
72
+ // position in a marina at street level would be the same, at more cost to
73
+ // Nominatim's own lookup.
74
+ const COUNTRY_ZOOM = 3;
75
+ const NAME_ZOOM = 17;
76
+
64
77
  function createPlaceNamer({ db, settings, userAgent, fetch = globalThis.fetch }) {
65
78
  const stopping = new AbortController();
66
79
  let failures = 0;
@@ -101,18 +114,35 @@ function createPlaceNamer({ db, settings, userAgent, fetch = globalThis.fetch })
101
114
 
102
115
  // The places table is the geocoding cache: the next departure or arrival
103
116
  // within the radius is named from it, with no request.
104
- function rememberGeocoded(name, { lat, lon }) {
117
+ function rememberGeocoded(name, { lat, lon }, countryCode) {
105
118
  const now = new Date().toISOString();
106
119
  const { lastInsertRowid } = db
107
120
  .prepare(
108
- `INSERT INTO places (name, lat, lon, source, created_at, updated_at)
109
- VALUES (?, ?, ?, 'geocoding', ?, ?)`
121
+ `INSERT INTO places (name, lat, lon, source, country_code, country_checked, created_at, updated_at)
122
+ VALUES (?, ?, ?, 'geocoding', ?, 1, ?, ?)`
110
123
  )
111
- .run(name, lat, lon, now, now);
124
+ .run(name, lat, lon, countryCode, now, now);
112
125
  return { id: Number(lastInsertRowid), name };
113
126
  }
114
127
 
115
- async function lookup({ lat, lon }) {
128
+ // A place added by hand, or one saved before countries were recorded, has
129
+ // not been asked about yet.
130
+ function nextPlaceWithoutCountry() {
131
+ return db
132
+ .prepare('SELECT id, lat, lon FROM places WHERE country_checked = 0 ORDER BY id DESC LIMIT 1')
133
+ .get();
134
+ }
135
+
136
+ // Only a place whose country has not been set since is filled in, so a
137
+ // lookup that was out while the place was deleted or corrected changes nothing.
138
+ function settleCountry({ id }, countryCode) {
139
+ db.prepare(
140
+ 'UPDATE places SET country_code = ?, country_checked = 1 WHERE id = ? AND country_checked = 0'
141
+ ).run(countryCode, id);
142
+ }
143
+
144
+ // The raw answer, or null when Nominatim has nothing for the position.
145
+ async function lookup({ lat, lon }, zoom) {
116
146
  const base = settings.geocodingUrl.endsWith('/')
117
147
  ? settings.geocodingUrl
118
148
  : `${settings.geocodingUrl}/`;
@@ -120,7 +150,7 @@ function createPlaceNamer({ db, settings, userAgent, fetch = globalThis.fetch })
120
150
  url.searchParams.set('format', 'jsonv2');
121
151
  url.searchParams.set('lat', String(lat));
122
152
  url.searchParams.set('lon', String(lon));
123
- url.searchParams.set('zoom', '17');
153
+ url.searchParams.set('zoom', String(zoom));
124
154
  url.searchParams.set('addressdetails', '1');
125
155
 
126
156
  const response = await fetch(url, {
@@ -141,7 +171,36 @@ function createPlaceNamer({ db, settings, userAgent, fetch = globalThis.fetch })
141
171
  return null;
142
172
  }
143
173
  const result = await response.json();
144
- return result.error ? null : pickName(result);
174
+ return result.error ? null : result;
175
+ }
176
+
177
+ // What the caller does with a lookup that could not be made or was
178
+ // interrupted; shared by naming and country lookups.
179
+ function failure(error) {
180
+ if (stopping.signal.aborted) {
181
+ return { outcome: 'stopped' };
182
+ }
183
+ failures += 1;
184
+ return {
185
+ outcome: 'failed',
186
+ error,
187
+ retryInMs: Math.min(FIRST_RETRY_MS * 2 ** (failures - 1), MAX_RETRY_MS)
188
+ };
189
+ }
190
+
191
+ async function resolveCountry(place) {
192
+ let result;
193
+ try {
194
+ result = await lookup(place, COUNTRY_ZOOM);
195
+ } catch (error) {
196
+ return failure(error);
197
+ }
198
+ if (stopping.signal.aborted) {
199
+ return { outcome: 'stopped' };
200
+ }
201
+ failures = 0;
202
+ settleCountry(place, pickCountry(result));
203
+ return { outcome: 'country', retryInMs: NEXT_LOOKUP_MS };
145
204
  }
146
205
 
147
206
  return {
@@ -151,7 +210,8 @@ function createPlaceNamer({ db, settings, userAgent, fetch = globalThis.fetch })
151
210
  }
152
211
  const pending = nextPending();
153
212
  if (!pending) {
154
- return { outcome: 'idle', retryInMs: IDLE_MS };
213
+ const unchecked = nextPlaceWithoutCountry();
214
+ return unchecked ? resolveCountry(unchecked) : { outcome: 'idle', retryInMs: IDLE_MS };
155
215
  }
156
216
 
157
217
  // A lookup for a nearby departure or arrival may already have named it.
@@ -161,25 +221,18 @@ function createPlaceNamer({ db, settings, userAgent, fetch = globalThis.fetch })
161
221
  return { outcome: 'known', retryInMs: 0 };
162
222
  }
163
223
 
164
- let name;
224
+ let result;
165
225
  try {
166
- name = await lookup(pending);
226
+ result = await lookup(pending, NAME_ZOOM);
167
227
  } catch (error) {
168
- if (stopping.signal.aborted) {
169
- return { outcome: 'stopped' };
170
- }
171
- failures += 1;
172
- return {
173
- outcome: 'failed',
174
- error,
175
- retryInMs: Math.min(FIRST_RETRY_MS * 2 ** (failures - 1), MAX_RETRY_MS)
176
- };
228
+ return failure(error);
177
229
  }
178
230
  if (stopping.signal.aborted) {
179
231
  return { outcome: 'stopped' };
180
232
  }
181
233
  failures = 0;
182
234
 
235
+ const name = result ? pickName(result) : null;
183
236
  if (!name) {
184
237
  settle(pending, null);
185
238
  return { outcome: 'no_result', retryInMs: NEXT_LOOKUP_MS };
@@ -187,7 +240,7 @@ function createPlaceNamer({ db, settings, userAgent, fetch = globalThis.fetch })
187
240
  withTransaction(db, () => {
188
241
  const place =
189
242
  findNearestPlace(db, pending, settings.placeMatchRadius) ??
190
- rememberGeocoded(name, pending);
243
+ rememberGeocoded(name, pending, pickCountry(result));
191
244
  settle(pending, place);
192
245
  });
193
246
  return { outcome: 'resolved', name, retryInMs: NEXT_LOOKUP_MS };
package/lib/places.js CHANGED
@@ -54,6 +54,7 @@ function toPlace(row) {
54
54
  name: row.name,
55
55
  position: { lat: row.lat, lon: row.lon },
56
56
  source: row.source,
57
+ countryCode: row.country_code,
57
58
  createdAt: row.created_at,
58
59
  updatedAt: row.updated_at
59
60
  };
@@ -0,0 +1,208 @@
1
+ const { rangeWhere } = require('./entries');
2
+ const { distanceBetween } = require('./places');
3
+
4
+ const TOP_COUNT = 5;
5
+
6
+ // Each ranking of the statistics page, sorted on the passage figure of the same name.
7
+ const RANKINGS = ['duration', 'distance', 'averageSpeed', 'maxSpeed', 'maxWindSpeed'];
8
+
9
+ const seconds = (from, to) => Math.max(0, (Date.parse(to) - Date.parse(from)) / 1000);
10
+
11
+ const keepHigher = (current, candidate) =>
12
+ candidate !== null && (current === null || candidate > current) ? candidate : current;
13
+
14
+ // Highest reading per passage, from the dense track points and the sparse
15
+ // instrument snapshots alike -- the same sources as a single passage's own
16
+ // figures (GET /entries/:id), and the same preference for true wind over apparent.
17
+ function loadPeaks(db, where, params) {
18
+ const peaks = new Map();
19
+ const inRange = `entry_id IN (SELECT id FROM log_entries ${where})`;
20
+ for (const table of ['track_points', 'observations']) {
21
+ const rows = db
22
+ .prepare(
23
+ `SELECT entry_id, MAX(sog) AS speed, MAX(tws) AS tws, MAX(aws) AS aws
24
+ FROM ${table} WHERE ${inRange} GROUP BY entry_id`
25
+ )
26
+ .all(...params);
27
+ for (const row of rows) {
28
+ const peak = peaks.get(row.entry_id) ?? { speed: null, tws: null, aws: null };
29
+ peak.speed = keepHigher(peak.speed, row.speed);
30
+ peak.tws = keepHigher(peak.tws, row.tws);
31
+ peak.aws = keepHigher(peak.aws, row.aws);
32
+ peaks.set(row.entry_id, peak);
33
+ }
34
+ }
35
+ return peaks;
36
+ }
37
+
38
+ function loadStops(db, where, params) {
39
+ const stops = new Map();
40
+ const rows = db
41
+ .prepare(
42
+ `SELECT entry_id, time, comment FROM events
43
+ WHERE type = 'stopover' AND entry_id IN (SELECT id FROM log_entries ${where})
44
+ ORDER BY entry_id, time`
45
+ )
46
+ .all(...params);
47
+ for (const row of rows) {
48
+ const list = stops.get(row.entry_id) ?? [];
49
+ list.push(row);
50
+ stops.set(row.entry_id, list);
51
+ }
52
+ return stops;
53
+ }
54
+
55
+ // A passage is cut wherever it stopped over -- a lock, a lunch anchorage kept
56
+ // within one entry (SPEC §3.1) -- and each stretch between two stops is measured
57
+ // on its own from the track. A passage that never stopped is one stretch, and
58
+ // its own distance and times stand: no track needs reading for it.
59
+ function legsOf(db, entry, stops, now) {
60
+ if (stops.length === 0) {
61
+ return [
62
+ {
63
+ entryId: entry.id,
64
+ startTime: entry.start_time,
65
+ endTime: entry.end_time,
66
+ distance: entry.distance,
67
+ duration: seconds(entry.start_time, entry.end_time ?? now),
68
+ startPlaceName: entry.start_place_name,
69
+ endPlaceName: entry.end_place_name
70
+ }
71
+ ];
72
+ }
73
+
74
+ const legs = stops.map((stop, index) => ({
75
+ entryId: entry.id,
76
+ startTime: index === 0 ? entry.start_time : null,
77
+ endTime: stop.time,
78
+ distance: 0,
79
+ startPlaceName: index === 0 ? entry.start_place_name : stops[index - 1].comment,
80
+ endPlaceName: stop.comment
81
+ }));
82
+ legs.push({
83
+ entryId: entry.id,
84
+ startTime: null,
85
+ endTime: entry.end_time,
86
+ distance: 0,
87
+ startPlaceName: stops[stops.length - 1].comment,
88
+ endPlaceName: entry.end_place_name
89
+ });
90
+
91
+ let leg = 0;
92
+ let previous = null;
93
+ const points = db
94
+ .prepare('SELECT time, lat, lon FROM track_points WHERE entry_id = ? ORDER BY time')
95
+ .iterate(entry.id);
96
+ for (const point of points) {
97
+ // A point at the very moment of a stop still belongs to the stretch that
98
+ // ended there.
99
+ while (leg < stops.length && point.time > stops[leg].time) {
100
+ leg += 1;
101
+ previous = null;
102
+ }
103
+ // A stretch starts when the vessel got under way again: its first point.
104
+ legs[leg].startTime ??= point.time;
105
+ if (previous) {
106
+ legs[leg].distance += distanceBetween(previous, point);
107
+ }
108
+ previous = point;
109
+ }
110
+
111
+ // A stretch with no track has no distance to speak of, and no start time
112
+ // either; it cannot be the longest.
113
+ return legs
114
+ .filter((candidate) => candidate.startTime !== null)
115
+ .map((candidate) => ({
116
+ ...candidate,
117
+ duration: seconds(candidate.startTime, candidate.endTime ?? now)
118
+ }));
119
+ }
120
+
121
+ // Countries reached by a departure or an arrival in the range, in order of the
122
+ // first visit. A place whose country is not known yet -- geocoding off, or not
123
+ // answered so far -- is simply not counted.
124
+ function loadCountries(db, where, params, now) {
125
+ return db
126
+ .prepare(
127
+ `SELECT places.country_code AS code, MIN(visit.time) AS firstTime
128
+ FROM (
129
+ SELECT start_place_id AS place_id, start_time AS time FROM log_entries ${where}
130
+ UNION ALL
131
+ SELECT end_place_id, COALESCE(end_time, ?) FROM log_entries ${where}
132
+ ) AS visit
133
+ JOIN places ON places.id = visit.place_id
134
+ WHERE places.country_code IS NOT NULL
135
+ GROUP BY places.country_code
136
+ ORDER BY firstTime, code`
137
+ )
138
+ .all(...params, now, ...params);
139
+ }
140
+
141
+ // Figures across every passage the range matches (SPEC §4.14). A passage still
142
+ // in progress counts as it stands, as in GET /entries/stats.
143
+ function getStatistics(db, { from, to, now }) {
144
+ const { where, params } = rangeWhere({ from, to });
145
+ const entries = db
146
+ .prepare(`SELECT * FROM log_entries ${where} ORDER BY start_time, id`)
147
+ .all(...params);
148
+ const peaks = loadPeaks(db, where, params);
149
+ const stops = loadStops(db, where, params);
150
+
151
+ const passages = entries.map((entry) => {
152
+ const peak = peaks.get(entry.id) ?? { speed: null, tws: null, aws: null };
153
+ const duration = seconds(entry.start_time, entry.end_time ?? now);
154
+ return {
155
+ id: entry.id,
156
+ startTime: entry.start_time,
157
+ endTime: entry.end_time,
158
+ startPlaceName: entry.start_place_name,
159
+ endPlaceName: entry.end_place_name,
160
+ startPlacePending: entry.start_place_pending === 1,
161
+ endPlacePending: entry.end_place_pending === 1,
162
+ distance: entry.distance,
163
+ duration,
164
+ averageSpeed: duration > 0 ? entry.distance / duration : null,
165
+ maxSpeed: peak.speed,
166
+ maxWindSpeed: peak.tws ?? peak.aws,
167
+ maxWindApparent: peak.tws === null && peak.aws !== null
168
+ };
169
+ });
170
+
171
+ const top = {};
172
+ for (const ranking of RANKINGS) {
173
+ top[ranking] = passages
174
+ .filter((passage) => passage[ranking] !== null && passage[ranking] > 0)
175
+ .sort(
176
+ (a, b) => b[ranking] - a[ranking] || b.startTime.localeCompare(a.startTime) || b.id - a.id
177
+ )
178
+ .slice(0, TOP_COUNT);
179
+ }
180
+
181
+ let longestNonStop = null;
182
+ for (const entry of entries) {
183
+ for (const leg of legsOf(db, entry, stops.get(entry.id) ?? [], now)) {
184
+ if (leg.distance > 0 && (longestNonStop === null || leg.distance > longestNonStop.distance)) {
185
+ longestNonStop = leg;
186
+ }
187
+ }
188
+ }
189
+
190
+ return {
191
+ count: passages.length,
192
+ distance: passages.reduce((sum, passage) => sum + passage.distance, 0),
193
+ duration: Math.round(passages.reduce((sum, passage) => sum + passage.duration, 0)),
194
+ firstTime: passages[0]?.startTime ?? null,
195
+ lastTime: passages.reduce((latest, passage) => {
196
+ const end = passage.endTime ?? now;
197
+ return latest === null || end > latest ? end : latest;
198
+ }, null),
199
+ maxSpeed: top.maxSpeed[0]?.maxSpeed ?? null,
200
+ maxWindSpeed: top.maxWindSpeed[0]?.maxWindSpeed ?? null,
201
+ maxWindApparent: top.maxWindSpeed[0]?.maxWindApparent ?? false,
202
+ longestNonStop,
203
+ countries: loadCountries(db, where, params, now),
204
+ top
205
+ };
206
+ }
207
+
208
+ module.exports = { getStatistics, TOP_COUNT };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-chiplog",
3
- "version": "2.3.1",
3
+ "version": "2.4.0",
4
4
  "description": "Nautical logbook for Signal K: automatic entries, handwritten notes",
5
5
  "main": "index.js",
6
6
  "files": [