signalk-chiplog 2.9.0 → 2.11.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,35 @@ All notable changes to Chiplog are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.11.0] - 2026-10-24
10
+
11
+ ### Added
12
+
13
+ - **Alarms can be left out of the log.** **Log Signal K alarms** turns them off altogether, and **Alarms left out of the
14
+ log** lists the ones never to record, `*` standing for any characters: `notifications.chartplotter.unknown*` silences
15
+ a chart plotter raising `unknown114`, `unknown115` and so on. An alarm already in the log is still closed when it
16
+ clears.
17
+ - **Reconnecting after the server drops out.** When the Signal K server stops answering, or your login expires, the
18
+ webapp says which in a banner at the bottom of the screen, seen wherever you are on the page, and offers **Reconnect**
19
+ (with **Sign in** for an expired login, in another tab); once the server answers, pages reload on their own and the
20
+ banner says when a failed correction can be done again. On the tablet, **Not connected** gets a **Reconnect** button
21
+ that sends waiting entries at once, and corrections or crew changes that fail offline say so with the same button.
22
+
23
+ ### Changed
24
+
25
+ - **The online backup sends only what the boat's plan keeps.** When the service says its plan keeps the passages from a
26
+ date on (Ti Bord's free plan: the last 30 days), older closed passages stay on board and are not sent, and the
27
+ passages counted for the backup's progress are those of that window. A passage under way is always sent; once the boat
28
+ is subscribed, everything is sent again, newest first.
29
+
30
+ ## [2.10.0] - 2026-10-20
31
+
32
+ ### Added
33
+
34
+ - **The online backup card points at the service's own site.** Under its opening lines, a link to
35
+ [tibord.com](https://tibord.com) for what the backups are for and what sharing your passages and voyages from them
36
+ looks like — worth knowing before deciding to pair.
37
+
9
38
  ## [2.9.0] - 2026-10-20
10
39
 
11
40
  ### Added
@@ -454,7 +483,9 @@ First release.
454
483
  - REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
455
484
  - Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
456
485
 
457
- [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.9.0...HEAD
486
+ [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.11.0...HEAD
487
+ [2.11.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.10.0...v2.11.0
488
+ [2.10.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.9.0...v2.10.0
458
489
  [2.9.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.8.0...v2.9.0
459
490
  [2.8.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.7.0...v2.8.0
460
491
  [2.7.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.6.0...v2.7.0
package/README.md CHANGED
@@ -128,7 +128,8 @@ an old value.
128
128
  Added to the timeline without anyone touching anything:
129
129
 
130
130
  - **Alarms** — any Signal K notification reaching `alarm` or `emergency` (man overboard, engine alarm, anchor watch…),
131
- and when it clears.
131
+ and when it clears. They can be turned off altogether, or some left out by name: `notifications.chartplotter.unknown*`
132
+ skips `unknown114`, `unknown115` and every other path starting that way.
132
133
  - **Autopilot** — engaged, disengaged, mode changes.
133
134
  - **Wind** — true wind, averaged over 2 minutes, rising above 20 and 30 knots and falling back below them
134
135
  (configurable).
@@ -260,6 +261,12 @@ shortcuts beside it; where any date is allowed, the calendar has an _Any date_ b
260
261
 
261
262
  **Helm entry** in the top bar opens the tablet entry app.
262
263
 
264
+ **When the server drops out** — Wi-Fi gone, Signal K restarting — a banner at the bottom of the screen says the server
265
+ does not answer and offers **Reconnect**, wherever you are on the page. Once it answers again, pages reload what they
266
+ were showing, and the banner says when a correction that failed can be done again. If your Signal K login has expired
267
+ instead, the message says so, with **Sign in** opening the admin in another tab: sign in there, come back, and the page
268
+ picks up where you left it.
269
+
263
270
  ### Your own boat in the 3D animation ⛵
264
271
 
265
272
  The 3D view draws a cartoon sailboat. To see yours instead, use **Boat → Use my own boat…** under the animation and pick
@@ -407,6 +414,10 @@ Keep logging. The header shows **Not connected** and how many entries are waitin
407
414
  the time it was made, and sent in order as soon as the server answers again, with the position the track recorded at
408
415
  that time. An entry sent just before the connection dropped is never logged twice.
409
416
 
417
+ The app tries again by itself every few seconds; **Reconnect** beside **Not connected** tries at once, and sends what is
418
+ waiting if the server answers. A correction or a crew change cannot wait like an entry: if it fails for want of a
419
+ connection, it says so with a **Reconnect** button, to do again once connected.
420
+
410
421
  If the server refuses a waiting entry when it comes back — typically nothing to attach it to — it is shown in red in the
411
422
  latest entries, to discard.
412
423
 
@@ -459,6 +470,8 @@ In the Signal K admin, **Apps & Plugins → Configuration → Chiplog**.
459
470
  | Map tiles for the summary | `https://tile.openstreetmap.org/{z}/{x}/{y}.png` | The tiles the summary map is drawn over, e.g. a self-hosted server. Empty draws the track on a plain background. |
460
471
  | Logbook language (PDF and summary emails) | en | Language of the PDF logbooks on the USB drive and of the summary emails (English or French). |
461
472
  | Ship's time zone (PDF and summary emails) | the server's | Time zone of the PDF logbooks on the USB drive and of the summary emails, e.g. `Europe/Paris`. |
473
+ | Log Signal K alarms | on | Turn off to leave every alarm out of the log. |
474
+ | Alarms left out of the log | — | Notification paths never logged; `*` stands for any characters, and the leading `notifications.` may be omitted. |
462
475
  | Wind speed thresholds | 20, 30 kn | Logged when the 2-minute average true wind crosses them. |
463
476
  | Barometric drop warning | 4 hPa / 3 h | 0 turns it off. |
464
477
  | Log heading changes | on | Turn off to leave heading changes out of the log. |
@@ -508,7 +521,9 @@ None of these is required except position and speed over ground; each feature us
508
521
 
509
522
  ## Online backup 🌐
510
523
 
511
- Keep a copy of the logbook off the boat, on the Ti Bord online service.
524
+ Keep a copy of the logbook off the boat, on the Ti Bord online service. What it does with the logbook once it is there,
525
+ and how you can share your passages and voyages from it, is at [tibord.com](https://tibord.com), which the **Online
526
+ backup** card links to as well.
512
527
 
513
528
  1. On the **Export** page, under **Online backup**, enter the service address and choose **Get a pairing code**.
514
529
  2. On the service, signed in to your account, enter the code shown (e.g. `K7QF-3MXB`) and choose your boat — or scan the
package/index.js CHANGED
@@ -314,6 +314,21 @@ module.exports = function (app) {
314
314
  description:
315
315
  'IANA time zone the PDF logbook on the USB drive and the passage summary emails are kept in, e.g. Europe/Paris. Empty uses the server’s time zone; downloads from the webapp use the browser’s'
316
316
  },
317
+ alarmsEnabled: {
318
+ type: 'boolean',
319
+ title: 'Log Signal K alarms',
320
+ description:
321
+ 'Records every notification reaching alarm or emergency, when raised and when cleared; turn off to leave alarms out of the log',
322
+ default: EVENT_DEFAULTS.alarmsEnabled
323
+ },
324
+ ignoredAlarms: {
325
+ type: 'array',
326
+ title: 'Alarms left out of the log',
327
+ description:
328
+ 'Notification paths never logged, * standing for any characters: notifications.chartplotter.unknown* leaves out unknown114, unknown115 and so on. The leading notifications. may be omitted',
329
+ items: { type: 'string' },
330
+ default: EVENT_DEFAULTS.ignoredAlarms
331
+ },
317
332
  windSpeedThresholds: {
318
333
  type: 'array',
319
334
  title: 'Wind speed thresholds (knots)',
@@ -647,6 +662,10 @@ module.exports = function (app) {
647
662
  ? config.logbookLanguage
648
663
  : 'en',
649
664
  logbookTimeZone: config.logbookTimeZone || null,
665
+ alarmsEnabled: config.alarmsEnabled ?? EVENT_DEFAULTS.alarmsEnabled,
666
+ ignoredAlarms: Array.isArray(config.ignoredAlarms)
667
+ ? config.ignoredAlarms
668
+ : EVENT_DEFAULTS.ignoredAlarms,
650
669
  windSpeedThresholds: config.windSpeedThresholds ?? EVENT_DEFAULTS.windSpeedThresholds,
651
670
  pressureDropThreshold: config.pressureDropThreshold ?? EVENT_DEFAULTS.pressureDropThreshold,
652
671
  headingChangeEnabled: config.headingChangeEnabled ?? EVENT_DEFAULTS.headingChangeEnabled,
package/lib/cloud-sync.js CHANGED
@@ -160,6 +160,9 @@ function createCloudSync({
160
160
  let progress = null;
161
161
  // Set by a backup asked for by hand: the next run sends everything in full, costly link or not.
162
162
  let fullRequested = false;
163
+ // The first moment of the logbook the boat's plan keeps on the service, as its sync state last
164
+ // said (`historyFrom`); null for all of it, and before the service has said anything.
165
+ let historyFrom = null;
163
166
 
164
167
  const iso = (ms) => new Date(ms).toISOString();
165
168
  const base = () => `${settings.cloudSyncUrl.replace(/\/+$/, '')}/v1/logbooks/${getLogbookId(db)}`;
@@ -260,15 +263,27 @@ function createCloudSync({
260
263
  }
261
264
  }
262
265
 
266
+ // A passage the service's plan keeps: begun from `historyFrom`, or still under way. The others
267
+ // stay on board and are not sent; the service keeps no more of them than their hashes anyway.
268
+ const kept = (row) =>
269
+ historyFrom === null || row.end_time === null || Date.parse(row.start_time) >= historyFrom;
270
+
263
271
  async function synchronise(costly) {
264
- const rows = db.prepare('SELECT * FROM log_entries ORDER BY start_time DESC, id DESC').all();
265
- // How many passages are on board: the service tells its owner how far the backup is.
272
+ const onBoardRows = db
273
+ .prepare('SELECT * FROM log_entries ORDER BY start_time DESC, id DESC')
274
+ .all();
275
+ // How many passages are on board for the service to keep: it tells its owner how far the
276
+ // backup is. Counted by the window it last gave, the first run by none.
266
277
  const state = await call('GET', '/sync-state', undefined, {
267
- 'x-chiplog-passages': String(rows.length)
278
+ 'x-chiplog-passages': String(onBoardRows.filter(kept).length)
268
279
  });
280
+ const from = typeof state.historyFrom === 'string' ? Date.parse(state.historyFrom) : NaN;
281
+ historyFrom = Number.isNaN(from) ? null : from;
282
+ const rows = onBoardRows.filter(kept);
269
283
  const remote = state.passages;
270
284
  const held = new Map(remote.map((item) => [item.originId, item]));
271
- const onBoard = new Set(rows.map((row) => row.id));
285
+ // Passages left out by the plan are still on board: the service is not told to delete them.
286
+ const onBoard = new Set(onBoardRows.map((row) => row.id));
272
287
  for (const id of cache.keys()) {
273
288
  if (!onBoard.has(id)) {
274
289
  cache.delete(id);
@@ -5,6 +5,8 @@ const { CRITICAL_STATES } = require('./events');
5
5
  const METRES_PER_SECOND_PER_KNOT = 1852 / 3600;
6
6
 
7
7
  const EVENT_DEFAULTS = {
8
+ alarmsEnabled: true,
9
+ ignoredAlarms: [],
8
10
  windSpeedThresholds: [20, 30],
9
11
  pressureDropThreshold: 4,
10
12
  headingChangeEnabled: true,
@@ -88,8 +90,30 @@ function collectNotifications(node, path, found, depth = 0) {
88
90
  }
89
91
  }
90
92
 
93
+ // A pattern names a notification path, `*` standing for any run of
94
+ // characters (dots included); the leading `notifications.` may be left out.
95
+ function compileAlarmPattern(pattern) {
96
+ const trimmed = String(pattern).trim();
97
+ if (trimmed === '') {
98
+ return null;
99
+ }
100
+ const full = trimmed.startsWith('notifications.') ? trimmed : `notifications.${trimmed}`;
101
+ const source = full
102
+ .split('*')
103
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
104
+ .join('.*');
105
+ return new RegExp(`^${source}$`);
106
+ }
107
+
108
+ function createAlarmFilter(patterns) {
109
+ const compiled = (patterns ?? []).map(compileAlarmPattern).filter(Boolean);
110
+ return (path) => compiled.some((pattern) => pattern.test(path));
111
+ }
112
+
91
113
  function createEventWatcher({ db, readSelfPath, settings, observe, clock = Date.now }) {
92
114
  const readFresh = createFreshnessTracker(readSelfPath);
115
+ const alarmsEnabled = settings.alarmsEnabled ?? EVENT_DEFAULTS.alarmsEnabled;
116
+ const isIgnoredAlarm = createAlarmFilter(settings.ignoredAlarms);
93
117
 
94
118
  const seenNotifications = new Map();
95
119
  const loggedNotifications = new Map();
@@ -173,6 +197,12 @@ function createEventWatcher({ db, readSelfPath, settings, observe, clock = Date.
173
197
 
174
198
  const logged = lastLoggedState(path);
175
199
  const critical = CRITICAL_STATES.has(state);
200
+ // Alarms turned off, or this one left out, are not raised in the log;
201
+ // one already logged is still closed when it clears, so no raise is
202
+ // left without its end.
203
+ if (critical && (!alarmsEnabled || isIgnoredAlarm(path))) {
204
+ continue;
205
+ }
176
206
  // Log a critical state not yet logged, or the end of one that was; a
177
207
  // clearing whose alarm never made it into the log is not news.
178
208
  const worthLogging = critical ? logged !== state : CRITICAL_STATES.has(logged);
@@ -467,4 +497,4 @@ function createEventWatcher({ db, readSelfPath, settings, observe, clock = Date.
467
497
  };
468
498
  }
469
499
 
470
- module.exports = { createEventWatcher, EVENT_DEFAULTS, CHECK_INTERVAL_MS };
500
+ module.exports = { createEventWatcher, createAlarmFilter, EVENT_DEFAULTS, CHECK_INTERVAL_MS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-chiplog",
3
- "version": "2.9.0",
3
+ "version": "2.11.0",
4
4
  "description": "Nautical logbook for Signal K: automatic entries, handwritten notes",
5
5
  "main": "index.js",
6
6
  "files": [
package/public/app.css CHANGED
@@ -251,6 +251,51 @@ main {
251
251
  border-color: transparent;
252
252
  }
253
253
 
254
+ .notice-actions {
255
+ display: flex;
256
+ flex-wrap: wrap;
257
+ align-items: center;
258
+ gap: 0.75rem;
259
+ }
260
+
261
+ /* Above the map's controls, wherever the page is scrolled. */
262
+ .connection-toast {
263
+ position: fixed;
264
+ left: 50%;
265
+ bottom: 1rem;
266
+ z-index: 1100;
267
+ transform: translateX(-50%);
268
+ width: min(40rem, calc(100% - 2rem));
269
+ box-sizing: border-box;
270
+ display: flex;
271
+ flex-wrap: wrap;
272
+ align-items: center;
273
+ justify-content: space-between;
274
+ gap: 0.5rem 1rem;
275
+ padding: 0.75rem 1rem;
276
+ border-radius: var(--radius);
277
+ background: var(--error-bg);
278
+ color: var(--text);
279
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25);
280
+ }
281
+
282
+ .connection-toast p {
283
+ margin: 0;
284
+ }
285
+
286
+ .connection-toast-ok {
287
+ background: var(--ok-bg);
288
+ }
289
+
290
+ .connection-toast .toast-close {
291
+ border: none;
292
+ background: none;
293
+ color: inherit;
294
+ font-size: 1.4rem;
295
+ line-height: 1;
296
+ cursor: pointer;
297
+ }
298
+
254
299
  .notice-ok {
255
300
  background: var(--ok-bg);
256
301
  border-color: transparent;
@@ -67,7 +67,7 @@ export function EventDialog({ title, event, bounds, onSave, onCancel }) {
67
67
  // say the entry is in, a countdown shows the banner will go on its own, and the
68
68
  // two follow-ups are secondary next to it — undoing is the destructive one, and
69
69
  // says what it undoes, since on its own "undo" reads as dismissing the banner.
70
- export function Toast({ toast, timeoutMs, onUndo, onComment, onClose }) {
70
+ export function Toast({ toast, timeoutMs, onUndo, onComment, onClose, onReconnect }) {
71
71
  const { t, format } = useLocale();
72
72
  if (!toast) {
73
73
  return null;
@@ -85,6 +85,14 @@ export function Toast({ toast, timeoutMs, onUndo, onComment, onClose }) {
85
85
  return html`
86
86
  <div class="toast toast-error" role="alert">
87
87
  <span class="toast-text">${toast.message}</span>
88
+ ${
89
+ toast.reconnect &&
90
+ html`<span class="toast-actions">
91
+ <button type="button" class="tool-button" onClick=${onReconnect}>
92
+ ${t('connection.reconnect')}
93
+ </button>
94
+ </span>`
95
+ }
88
96
  ${close}
89
97
  </div>
90
98
  `;
@@ -34,9 +34,40 @@ function passageLine(entry, t, format) {
34
34
  });
35
35
  }
36
36
 
37
- export function StatusHeader({ state, entry, online, queued, night, onToggleNight }) {
38
- const { t, format } = useLocale();
37
+ // While the server does not answer, the connection mark is a button that
38
+ // tries again at once, rather than at the next poll.
39
+ function ConnectionMark({ online, connecting, queued, onReconnect }) {
40
+ const { t } = useLocale();
39
41
  const connection = online ? t('entry.online') : t('entry.offline');
42
+ const label = queued > 0 ? `${connection} · ${t('entry.queued', { count: queued })}` : connection;
43
+ if (online) {
44
+ return html`<span class="connection connection-online">
45
+ <span class="connection-dot" aria-hidden="true"></span>
46
+ ${label}
47
+ </span>`;
48
+ }
49
+ return html`
50
+ <span class="connection connection-offline">
51
+ <span class="connection-dot" aria-hidden="true"></span>
52
+ ${label}
53
+ <button type="button" class="tool-button" disabled=${connecting} onClick=${onReconnect}>
54
+ ${connecting ? t('entry.reconnecting') : t('connection.reconnect')}
55
+ </button>
56
+ </span>
57
+ `;
58
+ }
59
+
60
+ export function StatusHeader({
61
+ state,
62
+ entry,
63
+ online,
64
+ connecting,
65
+ queued,
66
+ night,
67
+ onToggleNight,
68
+ onReconnect
69
+ }) {
70
+ const { t, format } = useLocale();
40
71
  return html`
41
72
  <header class="entry-header">
42
73
  <div class="entry-status">
@@ -50,10 +81,12 @@ export function StatusHeader({ state, entry, online, queued, night, onToggleNigh
50
81
  }
51
82
  </div>
52
83
  <div class="entry-tools">
53
- <span class=${online ? 'connection connection-online' : 'connection connection-offline'}>
54
- <span class="connection-dot" aria-hidden="true"></span>
55
- ${queued > 0 ? `${connection} · ${t('entry.queued', { count: queued })}` : connection}
56
- </span>
84
+ <${ConnectionMark}
85
+ online=${online}
86
+ connecting=${connecting}
87
+ queued=${queued}
88
+ onReconnect=${onReconnect}
89
+ />
57
90
  <button
58
91
  type="button"
59
92
  class="tool-button"
@@ -1,6 +1,13 @@
1
1
  import { html, render, useEffect, useMemo, useState } from '../../vendor/preact-htm.mjs';
2
- import { fetchAll, get, onResponse, request } from '../../js/api.mjs';
3
- import { createLocale, LocaleProvider, useLocale, usePolling } from '../../js/context.mjs';
2
+ import { connection, fetchAll, get, onResponse, request } from '../../js/api.mjs';
3
+ import { connectionProblem, watchConnection } from '../../js/connection.mjs';
4
+ import {
5
+ createLocale,
6
+ LocaleProvider,
7
+ useConnection,
8
+ useLocale,
9
+ usePolling
10
+ } from '../../js/context.mjs';
4
11
  import { randomId } from '../../js/ids.mjs';
5
12
  import { pickLanguage } from '../../js/i18n.mjs';
6
13
  import { createServerClock } from './clock.mjs';
@@ -80,10 +87,8 @@ function writeSetting(key, value) {
80
87
  }
81
88
 
82
89
  const clock = createServerClock();
83
- let markOnline = () => {};
84
90
  onResponse((response, sentAt, receivedAt) => {
85
91
  clock.observe(response.headers.get('date'), sentAt, receivedAt);
86
- markOnline(true);
87
92
  });
88
93
 
89
94
  function App({ journal }) {
@@ -93,7 +98,7 @@ function App({ journal }) {
93
98
  const [lastEntryId, setLastEntryId] = useState(null);
94
99
  const [types, setTypes] = useState(knownTypes);
95
100
  const [roster, setRoster] = useState(knownCrew);
96
- const [online, setOnline] = useState(true);
101
+ const link = useConnection();
97
102
  const [forbidden, setForbidden] = useState(false);
98
103
  const [queue, setQueue] = useState(journal.outbox.snapshot());
99
104
  const [toast, setToast] = useState(null);
@@ -104,18 +109,51 @@ function App({ journal }) {
104
109
  const [version, setVersion] = useState(0);
105
110
  const [busy, setBusy] = useState(false);
106
111
 
107
- markOnline = setOnline;
108
112
  const refresh = () => setVersion((v) => v + 1);
113
+ // The link itself is followed by `connection` (api.mjs), which every request
114
+ // reports to; a refusal is this tablet lacking access, which the gate asks for.
115
+ const online = link.state !== 'offline';
109
116
 
110
117
  const noteFailure = (error) => {
111
- if (error?.code === 'network') {
112
- setOnline(false);
113
- }
114
118
  if (error?.code === 'forbidden') {
115
119
  setForbidden(true);
116
120
  }
117
121
  };
118
122
 
123
+ // Shows what was not done. Entries are queued rather than refused when the
124
+ // link is down, so this is for corrections and the crew list.
125
+ const showFailure = (error) => {
126
+ noteFailure(error);
127
+ const offline = connectionProblem(error) === 'offline';
128
+ setToast({
129
+ kind: 'error',
130
+ reconnect: offline,
131
+ message: offline ? t('entry.notDoneOffline') : t('entry.refused', { message: error.message })
132
+ });
133
+ };
134
+
135
+ const flushQueue = async () => {
136
+ if (journal.outbox.size === 0) {
137
+ return;
138
+ }
139
+ const outcome = await journal.flush();
140
+ noteFailure(outcome.blocked);
141
+ if (outcome.sent.length > 0) {
142
+ refresh();
143
+ }
144
+ };
145
+
146
+ // Asked for by the crew: says so when the server still does not answer,
147
+ // since nothing else on screen would change.
148
+ const reconnect = async () => {
149
+ const state = await connection.check();
150
+ if (state === 'offline') {
151
+ setToast({ kind: 'error', reconnect: true, message: t('entry.stillOffline') });
152
+ } else if (toast?.reconnect) {
153
+ setToast(null);
154
+ }
155
+ };
156
+
119
157
  useEffect(() => {
120
158
  journal.setListener(setQueue);
121
159
  }, []);
@@ -164,7 +202,7 @@ function App({ journal }) {
164
202
  .catch(noteFailure);
165
203
  },
166
204
  0,
167
- [forbidden, online]
205
+ [forbidden, link.restored]
168
206
  );
169
207
 
170
208
  usePolling(
@@ -179,30 +217,28 @@ function App({ journal }) {
179
217
  .catch(noteFailure);
180
218
  },
181
219
  0,
182
- [forbidden, online]
220
+ [forbidden, link.restored]
183
221
  );
184
222
 
185
223
  // Replays the queue while it holds anything, and as soon as the network is back.
186
224
  useEffect(() => {
187
- const flush = async () => {
188
- if (journal.outbox.size === 0) {
189
- return;
190
- }
191
- const outcome = await journal.flush();
192
- noteFailure(outcome.blocked);
193
- if (outcome.sent.length > 0) {
194
- refresh();
195
- }
196
- };
197
- const timer = setInterval(flush, FLUSH_INTERVAL_MS);
198
- window.addEventListener('online', flush);
199
- flush();
225
+ const timer = setInterval(flushQueue, FLUSH_INTERVAL_MS);
226
+ window.addEventListener('online', flushQueue);
200
227
  return () => {
201
228
  clearInterval(timer);
202
- window.removeEventListener('online', flush);
229
+ window.removeEventListener('online', flushQueue);
203
230
  };
204
231
  }, []);
205
232
 
233
+ // And whenever the link comes back, along with the state it missed.
234
+ useEffect(() => {
235
+ flushQueue();
236
+ if (link.restored > 0) {
237
+ setForbidden(false);
238
+ refresh();
239
+ }
240
+ }, [link.restored]);
241
+
206
242
  const manoeuvreLabels = useMemo(
207
243
  () => Object.fromEntries(types.map((type) => [type.key, type.label])),
208
244
  [types]
@@ -266,8 +302,7 @@ function App({ journal }) {
266
302
  }
267
303
  refresh();
268
304
  } catch (error) {
269
- noteFailure(error);
270
- setToast({ kind: 'error', message: t('entry.refused', { message: error.message }) });
305
+ showFailure(error);
271
306
  }
272
307
  };
273
308
 
@@ -279,8 +314,7 @@ function App({ journal }) {
279
314
  await request('DELETE', `/events/${event.id}`);
280
315
  refresh();
281
316
  } catch (error) {
282
- noteFailure(error);
283
- setToast({ kind: 'error', message: t('entry.refused', { message: error.message }) });
317
+ showFailure(error);
284
318
  }
285
319
  };
286
320
 
@@ -295,8 +329,7 @@ function App({ journal }) {
295
329
  setManagingCrew(false);
296
330
  refresh();
297
331
  } catch (error) {
298
- noteFailure(error);
299
- setToast({ kind: 'error', message: t('entry.refused', { message: error.message }) });
332
+ showFailure(error);
300
333
  }
301
334
  };
302
335
 
@@ -306,8 +339,7 @@ function App({ journal }) {
306
339
  setRoster((prev) => [...prev, member]);
307
340
  return member;
308
341
  } catch (error) {
309
- noteFailure(error);
310
- setToast({ kind: 'error', message: t('entry.refused', { message: error.message }) });
342
+ showFailure(error);
311
343
  return null;
312
344
  }
313
345
  };
@@ -317,8 +349,7 @@ function App({ journal }) {
317
349
  const updated = await request('PATCH', `/crew/${id}`, patch);
318
350
  setRoster((prev) => prev.map((member) => (member.id === id ? updated : member)));
319
351
  } catch (error) {
320
- noteFailure(error);
321
- setToast({ kind: 'error', message: t('entry.refused', { message: error.message }) });
352
+ showFailure(error);
322
353
  }
323
354
  };
324
355
 
@@ -328,8 +359,7 @@ function App({ journal }) {
328
359
  setRoster((prev) => prev.filter((member) => member.id !== id));
329
360
  return true;
330
361
  } catch (error) {
331
- noteFailure(error);
332
- setToast({ kind: 'error', message: t('entry.refused', { message: error.message }) });
362
+ showFailure(error);
333
363
  return false;
334
364
  }
335
365
  };
@@ -342,17 +372,20 @@ function App({ journal }) {
342
372
  state=${state}
343
373
  entry=${entry}
344
374
  online=${online}
375
+ connecting=${link.checking}
376
+ onReconnect=${reconnect}
345
377
  queued=${queue.pending.length}
346
378
  night=${night}
347
379
  onToggleNight=${() => setNight((value) => !value)}
348
380
  />
349
381
  <main class="entry-main">
350
382
  ${
351
- forbidden &&
383
+ (forbidden || link.state === 'signedOut') &&
352
384
  html`<${AccessGate}
353
385
  onGranted=${() => {
354
386
  setForbidden(false);
355
- journal.flush();
387
+ connection.check();
388
+ flushQueue();
356
389
  refresh();
357
390
  }}
358
391
  />`
@@ -429,6 +462,7 @@ function App({ journal }) {
429
462
  timeoutMs=${TOAST_MS}
430
463
  onClose=${() => setToast(null)}
431
464
  onUndo=${undo}
465
+ onReconnect=${reconnect}
432
466
  onComment=${() =>
433
467
  setEditing({ ref: toast.ref, what: toast.what, event: journal.entryFor(toast.ref) })}
434
468
  />
@@ -479,6 +513,8 @@ journal.setListener = (next) => {
479
513
  listener = next;
480
514
  };
481
515
 
516
+ watchConnection(connection, { window, document });
517
+
482
518
  render(
483
519
  html`<${LocaleProvider} value=${locale}><${App} journal=${journal} /></${LocaleProvider}>`,
484
520
  document.getElementById('app')
@@ -31,6 +31,7 @@ const SHELL = [
31
31
  '../icon-small.svg',
32
32
  '../js/api.mjs',
33
33
  '../js/auth.mjs',
34
+ '../js/connection.mjs',
34
35
  '../js/context.mjs',
35
36
  '../js/days.mjs',
36
37
  '../js/format.mjs',
package/public/js/api.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  import { deviceToken } from './auth.mjs';
2
+ import { createConnection } from './connection.mjs';
2
3
 
3
4
  const BASE = '/plugins/signalk-chiplog/api';
5
+ const PROBE_TIMEOUT_MS = 10 * 1000;
4
6
 
5
7
  export class RequestError extends Error {
6
8
  constructor(status, code, message) {
@@ -23,6 +25,17 @@ export function onResponse(listener) {
23
25
  return () => responseListeners.delete(listener);
24
26
  }
25
27
 
28
+ // The link to the server, as every request finds it. Its probe is the cheapest
29
+ // read there is, and one any client with access may make.
30
+ export const connection = createConnection({
31
+ probe: () => request('GET', '/state', undefined, { timeoutMs: PROBE_TIMEOUT_MS })
32
+ });
33
+
34
+ function fail(error) {
35
+ connection.report(error);
36
+ return error;
37
+ }
38
+
26
39
  export async function request(method, path, body, { timeoutMs } = {}) {
27
40
  const headers = body === undefined ? {} : { 'content-type': 'application/json' };
28
41
  const token = deviceToken.get();
@@ -44,12 +57,13 @@ export async function request(method, path, body, { timeoutMs } = {}) {
44
57
  signal: controller?.signal
45
58
  });
46
59
  } catch (err) {
47
- throw new RequestError(0, 'network', err.message);
60
+ throw fail(new RequestError(0, 'network', err.message));
48
61
  } finally {
49
62
  clearTimeout(timer);
50
63
  }
51
64
  const receivedAt = Date.now();
52
65
  responseListeners.forEach((listener) => listener(response, sentAt, receivedAt));
66
+ connection.observe(response.status);
53
67
 
54
68
  if (response.status === 204) {
55
69
  return null;
@@ -65,7 +79,9 @@ export async function request(method, path, body, { timeoutMs } = {}) {
65
79
  response.status === 401 || response.status === 403
66
80
  ? 'forbidden'
67
81
  : (data?.error?.code ?? 'http_error');
68
- throw new RequestError(response.status, code, data?.error?.message ?? response.statusText);
82
+ throw fail(
83
+ new RequestError(response.status, code, data?.error?.message ?? response.statusText)
84
+ );
69
85
  }
70
86
  return data;
71
87
  }
@@ -3,6 +3,11 @@ import { get, request } from '../api.mjs';
3
3
  import { useLocale, usePolling } from '../context.mjs';
4
4
  import { ErrorNotice } from './common.mjs';
5
5
 
6
+ // The service's own site, not the API the boat talks to: what the backups are
7
+ // for, and what sharing a passage or a voyage from them looks like.
8
+ const SERVICE_SITE = 'https://tibord.com';
9
+ const SERVICE_SITE_LABEL = 'tibord.com';
10
+
6
11
  const STATUS_REFRESH_MS = 30 * 1000;
7
12
  // While a code waits to be claimed, so the page says "paired" soon after it is.
8
13
  const PAIRING_REFRESH_MS = 3 * 1000;
@@ -238,6 +243,10 @@ export function CloudBackup() {
238
243
  <section class="card">
239
244
  <h2>${t('cloud.title')}</h2>
240
245
  <p>${t('cloud.intro')}</p>
246
+ <p class="muted">
247
+ ${t('cloud.learnMore')}${' '}
248
+ <a href=${SERVICE_SITE} target="_blank" rel="noopener">${SERVICE_SITE_LABEL}</a>
249
+ </p>
241
250
  ${ready && html`<${BackupStatus} status=${status} />`}
242
251
  ${
243
252
  ready &&
@@ -178,7 +178,7 @@ export function LogView() {
178
178
 
179
179
  return html`
180
180
  <h1 class="page-title" tabindex="-1" ref=${title}>${t('log.title')}</h1>
181
- <${ErrorNotice} error=${error} />
181
+ <${ErrorNotice} error=${error} onRetry=${() => reload()} />
182
182
  <${LogStats} stats=${log.stats} />
183
183
  ${groupByDay(log.items).map(
184
184
  (day) => html`
@@ -2,6 +2,7 @@ import { html, useState } from '../../vendor/preact-htm.mjs';
2
2
  import { get } from '../api.mjs';
3
3
  import { useLocale, usePolling } from '../context.mjs';
4
4
  import { fallbackMessage } from '../status.mjs';
5
+ import { connectionProblem } from '../connection.mjs';
5
6
  import { ErrorNotice } from './common.mjs';
6
7
 
7
8
  const REFRESH_MS = 15 * 1000;
@@ -20,20 +21,34 @@ export function StatusBar() {
20
21
  const { t, format } = useLocale();
21
22
  const [state, setState] = useState(null);
22
23
  const [error, setError] = useState(null);
24
+ const [attempt, setAttempt] = useState(0);
23
25
 
24
- usePolling((isCurrent) => {
25
- get('/state')
26
- .then((next) => {
27
- if (isCurrent()) {
28
- setState(next);
29
- setError(null);
30
- }
31
- })
32
- .catch((err) => isCurrent() && setError(err));
33
- }, REFRESH_MS);
26
+ usePolling(
27
+ (isCurrent) => {
28
+ get('/state')
29
+ .then((next) => {
30
+ if (isCurrent()) {
31
+ setState(next);
32
+ setError(null);
33
+ }
34
+ })
35
+ .catch((err) => isCurrent() && setError(err));
36
+ },
37
+ REFRESH_MS,
38
+ [attempt]
39
+ );
34
40
 
41
+ // A lost link is the connection toast's to tell; the bar keeps what it last
42
+ // knew, and its notice only reloads it once the link is back.
43
+ if (error && connectionProblem(error) && state) {
44
+ return html`<div class=${`statusbar statusbar-${state.motion}`}>
45
+ <${ErrorNotice} error=${error} onRetry=${() => setAttempt((n) => n + 1)} />
46
+ <span class="status-dot" aria-hidden="true"></span>
47
+ <strong>${motionLabel(state, t)}</strong>
48
+ </div>`;
49
+ }
35
50
  if (error) {
36
- return html`<div class="statusbar"><${ErrorNotice} error=${error} /></div>`;
51
+ return html`<div class="statusbar"><${ErrorNotice} error=${error} onRetry=${() => setAttempt((n) => n + 1)} /></div>`;
37
52
  }
38
53
  if (!state) {
39
54
  return html`<div class="statusbar">${t('common.loading')}</div>`;
@@ -1,18 +1,127 @@
1
- import { html } from '../../vendor/preact-htm.mjs';
2
- import { useLocale } from '../context.mjs';
1
+ import { html, useEffect, useMemo, useState } from '../../vendor/preact-htm.mjs';
2
+ import { connection } from '../api.mjs';
3
+ import { connectionProblem } from '../connection.mjs';
4
+ import { useConnection, useLocale } from '../context.mjs';
5
+
6
+ // Signing in happens in the Signal K admin, on a tab of its own so that this
7
+ // page keeps what it showed; coming back to it checks the link again.
8
+ export function SignInLink({ label }) {
9
+ return html`<a href="/admin/#/login" target="_blank" rel="noopener">${label}</a>`;
10
+ }
11
+
12
+ // Failed actions waiting for the link to come back, so the toast can say, once
13
+ // it does, that there is something to do again.
14
+ const unfinished = new Set();
15
+
16
+ const RESTORED_TOAST_MS = 6000;
17
+
18
+ // One toast for the whole page while the server is out of reach, fixed to the
19
+ // bottom of the screen so it is seen wherever the reader is, however many
20
+ // requests failed: why, and a way to get the link back.
21
+ export function ConnectionToast() {
22
+ const { t } = useLocale();
23
+ const link = useConnection();
24
+ // A server that is down refuses at once: without a word, a reconnect that
25
+ // failed would look like a button that does nothing.
26
+ const [failed, setFailed] = useState(false);
27
+ const [restored, setRestored] = useState(null);
28
+
29
+ useEffect(() => {
30
+ if (link.restored === 0) {
31
+ return undefined;
32
+ }
33
+ setFailed(false);
34
+ setRestored({ again: unfinished.size > 0 });
35
+ const timer = setTimeout(() => setRestored(null), RESTORED_TOAST_MS);
36
+ return () => clearTimeout(timer);
37
+ }, [link.restored]);
38
+
39
+ if (link.state === 'connected') {
40
+ if (!restored) {
41
+ return null;
42
+ }
43
+ const message = restored.again
44
+ ? `${t('connection.restored')} ${t('connection.tryAgain')}`
45
+ : t('connection.restored');
46
+ return html`
47
+ <div class="connection-toast connection-toast-ok" role="status">
48
+ <p>${message}</p>
49
+ <button type="button" class="toast-close" aria-label=${t('common.close')} onClick=${() => setRestored(null)}>×</button>
50
+ </div>
51
+ `;
52
+ }
53
+ const signedOut = link.state === 'signedOut';
54
+ let message = t(signedOut ? 'connection.signedOut' : 'connection.offline');
55
+ if (link.checking) {
56
+ message = t('connection.checking');
57
+ } else if (failed) {
58
+ message = `${t('connection.stillDown')} ${message}`;
59
+ }
60
+ const reconnect = async () => setFailed((await connection.check()) !== 'connected');
61
+ return html`
62
+ <div class="connection-toast" role="alert">
63
+ <p>${message}</p>
64
+ <p class="notice-actions">
65
+ ${signedOut && html`<${SignInLink} label=${t('connection.signIn')} />`}
66
+ <button type="button" disabled=${link.checking} onClick=${reconnect}>
67
+ ${t('connection.reconnect')}
68
+ </button>
69
+ </p>
70
+ </div>
71
+ `;
72
+ }
73
+
74
+ // Where a request failed for want of the link, the toast speaks; here only
75
+ // what the place itself needs. A notice that can redo its request does so once
76
+ // the link is back, whoever brought it back; one that cannot is counted for the
77
+ // toast to say so.
78
+ function ConnectionNotice({ error, problem, link, since, onRetry }) {
79
+ const { t } = useLocale();
80
+ const back = link.state === 'connected' && !link.checking;
81
+ // Back, after a 401 that the probe got past: the account lacks the rights.
82
+ const refused = back && problem === 'signedOut' && link.restored === since;
83
+
84
+ useEffect(() => {
85
+ if (onRetry) {
86
+ return undefined;
87
+ }
88
+ unfinished.add(error);
89
+ return () => unfinished.delete(error);
90
+ }, [error]);
91
+
92
+ useEffect(() => {
93
+ if (back && !refused && onRetry) {
94
+ onRetry();
95
+ }
96
+ }, [back, refused]);
97
+
98
+ if (!refused) {
99
+ return null;
100
+ }
101
+ return html`
102
+ <div class="notice notice-error" role="alert">
103
+ <p>${t('error.forbidden')} <${SignInLink} label=${t('error.signIn')} /></p>
104
+ </div>
105
+ `;
106
+ }
3
107
 
4
108
  export function ErrorNotice({ error, onRetry }) {
5
109
  const { t } = useLocale();
110
+ const link = useConnection();
111
+ const problem = connectionProblem(error);
112
+ // How many times the link had come back when this error arrived.
113
+ const since = useMemo(() => link.restored, [error]);
6
114
  if (!error) {
7
115
  return null;
8
116
  }
117
+ if (problem) {
118
+ return html`<${ConnectionNotice} error=${error} problem=${problem} link=${link} since=${since} onRetry=${onRetry} />`;
119
+ }
9
120
  let message;
10
121
  if (error.code === 'forbidden') {
11
- message = html`${t('error.forbidden')} <a href="/admin/">${t('error.signIn')}</a>`;
122
+ message = html`${t('error.forbidden')} <${SignInLink} label=${t('error.signIn')} />`;
12
123
  } else if (error.code === 'plugin_not_started') {
13
124
  message = t('error.notRunning');
14
- } else if (error.code === 'network') {
15
- message = t('error.network');
16
125
  } else {
17
126
  message = t('error.generic', { message: error.message });
18
127
  }
@@ -0,0 +1,126 @@
1
+ // The state of the link to the Signal K server, shared by everything on the
2
+ // page so that every message about a lost connection agrees, and one reconnect
3
+ // serves them all.
4
+ //
5
+ // States: connected; offline (the server does not answer: the network dropped,
6
+ // or it is restarting); signedOut (it answers but no longer knows us: the login
7
+ // expired, or the device token was revoked). `checking` is true while a probe
8
+ // runs, and `restored` counts the returns to connected, so a message shown
9
+ // before one can tell that the link has come back since.
10
+
11
+ // Which kind of connection loss an API error stands for, or null when it is
12
+ // about the request itself.
13
+ export function connectionProblem(error) {
14
+ if (!error) {
15
+ return null;
16
+ }
17
+ if (error.code === 'network') {
18
+ return 'offline';
19
+ }
20
+ // A proxy in front of a server that is down answers for it.
21
+ if ((error.status === 502 || error.status === 504) && error.code === 'http_error') {
22
+ return 'offline';
23
+ }
24
+ // Signal K answers 401 to a missing or expired login and to an account that
25
+ // lacks the rights alike: only a probe tells them apart.
26
+ return error.status === 401 ? 'signedOut' : null;
27
+ }
28
+
29
+ // `probe` makes a read every client with access may make, and rejects with the
30
+ // API's error.
31
+ export function createConnection({ probe }) {
32
+ let state = 'connected';
33
+ let checking = null;
34
+ let restored = 0;
35
+ const listeners = new Set();
36
+
37
+ const snapshot = () => ({ state, checking: checking !== null, restored });
38
+ const notify = () => {
39
+ const current = snapshot();
40
+ listeners.forEach((listener) => listener(current));
41
+ };
42
+
43
+ function settle(next) {
44
+ if (next === 'connected' && state !== 'connected') {
45
+ restored += 1;
46
+ }
47
+ state = next;
48
+ }
49
+
50
+ function check() {
51
+ if (checking) {
52
+ return checking;
53
+ }
54
+ checking = (async () => {
55
+ try {
56
+ await probe();
57
+ settle('connected');
58
+ } catch (error) {
59
+ // An answer that is not about the link (the plugin stopped, say)
60
+ // still means the server is there and lets us in.
61
+ settle(connectionProblem(error) ?? 'connected');
62
+ }
63
+ checking = null;
64
+ notify();
65
+ return state;
66
+ })();
67
+ notify();
68
+ return checking;
69
+ }
70
+
71
+ return {
72
+ snapshot,
73
+
74
+ subscribe(listener) {
75
+ listeners.add(listener);
76
+ return () => listeners.delete(listener);
77
+ },
78
+
79
+ // Every response the server sends: a successful one proves the link.
80
+ observe(status) {
81
+ if (status >= 200 && status < 300 && state !== 'connected') {
82
+ settle('connected');
83
+ notify();
84
+ }
85
+ },
86
+
87
+ // Every failed request. Returns the problem it stands for, if any; a 401 is
88
+ // checked before being taken for a lost login.
89
+ report(error) {
90
+ const problem = connectionProblem(error);
91
+ if (problem === 'offline' && state !== 'offline') {
92
+ state = 'offline';
93
+ notify();
94
+ } else if (problem === 'signedOut' && state !== 'signedOut') {
95
+ check();
96
+ } else if (problem === null && state !== 'connected') {
97
+ // Refused for a reason of its own (the plugin is still starting, say):
98
+ // the server answered, and past its login.
99
+ settle('connected');
100
+ notify();
101
+ }
102
+ return problem;
103
+ },
104
+
105
+ check
106
+ };
107
+ }
108
+
109
+ // Looks again as soon as there is reason to think the link is back: the
110
+ // network returned, or the reader came back to the page — from signing in
111
+ // on another tab, typically.
112
+ export function watchConnection(connection, { window, document }) {
113
+ const recheck = () => {
114
+ if (connection.snapshot().state !== 'connected' && document.visibilityState !== 'hidden') {
115
+ connection.check();
116
+ }
117
+ };
118
+ window.addEventListener('online', recheck);
119
+ window.addEventListener('focus', recheck);
120
+ document.addEventListener('visibilitychange', recheck);
121
+ return () => {
122
+ window.removeEventListener('online', recheck);
123
+ window.removeEventListener('focus', recheck);
124
+ document.removeEventListener('visibilitychange', recheck);
125
+ };
126
+ }
@@ -1,4 +1,5 @@
1
1
  import { createContext, useContext, useEffect, useRef, useState } from '../vendor/preact-htm.mjs';
2
+ import { connection } from './api.mjs';
2
3
  import { parseAnimationHash } from './days.mjs';
3
4
  import { createFormatter } from './format.mjs';
4
5
  import { createTranslator } from './i18n.mjs';
@@ -86,3 +87,13 @@ export function usePolling(load, intervalMs, dependencies = []) {
86
87
  };
87
88
  }, dependencies);
88
89
  }
90
+
91
+ // The link to the server as the page last found it (see connection.mjs).
92
+ export function useConnection() {
93
+ const [link, setLink] = useState(connection.snapshot);
94
+ useEffect(() => {
95
+ setLink(connection.snapshot());
96
+ return connection.subscribe(setLink);
97
+ }, []);
98
+ return link;
99
+ }
@@ -27,9 +27,18 @@ export const MESSAGES = {
27
27
  'Your Signal K account is not allowed to do this. Sign in with an account that has the rights.',
28
28
  'error.signIn': 'Open the Signal K admin',
29
29
  'error.notRunning': 'The Chiplog plugin is not running. Enable it in the Signal K admin.',
30
- 'error.network': 'The Signal K server cannot be reached.',
31
30
  'error.generic': 'Something went wrong: {message}',
32
31
 
32
+ 'connection.offline':
33
+ 'The Signal K server does not answer: the network may be down, or the server restarting.',
34
+ 'connection.signedOut': 'Your Signal K session has ended. Sign in again, then reconnect.',
35
+ 'connection.checking': 'Connecting to the Signal K server…',
36
+ 'connection.reconnect': 'Reconnect',
37
+ 'connection.stillDown': 'Still no luck.',
38
+ 'connection.signIn': 'Sign in',
39
+ 'connection.restored': 'The connection is back.',
40
+ 'connection.tryAgain': 'What failed can be done again.',
41
+
33
42
  'status.underwaySail': 'Under way under sail',
34
43
  'status.underwayEngine': 'Under way under engine',
35
44
  'status.underway': 'Under way',
@@ -446,6 +455,7 @@ export const MESSAGES = {
446
455
  'cloud.pairAgain': 'Pair with another account or service',
447
456
  'cloud.pairIntro':
448
457
  'Enter the service address and ask for a code, then enter the code on the service, signed in to your account. The backup turns on as soon as it is claimed.',
458
+ 'cloud.learnMore': 'More about the backups, and about sharing your passages and voyages:',
449
459
  'cloud.serviceUrl': 'Service address',
450
460
  'cloud.pair': 'Get a pairing code',
451
461
  'cloud.enterCode': 'Enter this code on the service:',
@@ -516,6 +526,10 @@ export const MESSAGES = {
516
526
  'entry.noteTime': 'Time (now if left empty)',
517
527
  'entry.noPassageError': 'No passage to log this in: cast off or weigh anchor first.',
518
528
  'entry.refused': 'Not logged: {message}',
529
+ 'entry.notDoneOffline': 'Not done: the Signal K server does not answer.',
530
+ 'entry.stillOffline':
531
+ 'Still no answer from the Signal K server. Entries keep waiting on this tablet.',
532
+ 'entry.reconnecting': 'Connecting…',
519
533
  'entry.chooseSail': 'Which sail is up?',
520
534
  'entry.otherSail': 'Other sail',
521
535
  'entry.sketchUndo': 'Undo',
@@ -590,9 +604,19 @@ export const MESSAGES = {
590
604
  'error.signIn': 'Ouvrir l’administration Signal K',
591
605
  'error.notRunning':
592
606
  'Le plugin Chiplog n’est pas démarré. Activez-le dans l’administration Signal K.',
593
- 'error.network': 'Le serveur Signal K est injoignable.',
594
607
  'error.generic': 'Une erreur est survenue : {message}',
595
608
 
609
+ 'connection.offline':
610
+ 'Le serveur Signal K ne répond pas : le réseau est peut-être coupé, ou le serveur redémarre.',
611
+ 'connection.signedOut':
612
+ 'Votre session Signal K a pris fin. Connectez-vous à nouveau, puis rétablissez la connexion.',
613
+ 'connection.checking': 'Connexion au serveur Signal K…',
614
+ 'connection.reconnect': 'Rétablir la connexion',
615
+ 'connection.stillDown': 'Toujours sans succès.',
616
+ 'connection.signIn': 'Se connecter',
617
+ 'connection.restored': 'La connexion est rétablie.',
618
+ 'connection.tryAgain': 'Vous pouvez refaire ce qui a échoué.',
619
+
596
620
  'status.underwaySail': 'En route à la voile',
597
621
  'status.underwayEngine': 'En route au moteur',
598
622
  'status.underway': 'En route',
@@ -1016,6 +1040,8 @@ export const MESSAGES = {
1016
1040
  'cloud.pairAgain': 'Appairer avec un autre compte ou service',
1017
1041
  'cloud.pairIntro':
1018
1042
  'Indiquez l’adresse du service et demandez un code, puis saisissez ce code sur le service, connecté à votre compte. La sauvegarde s’active dès qu’il est utilisé.',
1043
+ 'cloud.learnMore':
1044
+ 'Plus d’informations sur les sauvegardes et sur le partage de vos navigations et de vos voyages :',
1019
1045
  'cloud.serviceUrl': 'Adresse du service',
1020
1046
  'cloud.pair': 'Obtenir un code d’appairage',
1021
1047
  'cloud.enterCode': 'Saisissez ce code sur le service :',
@@ -1089,6 +1115,10 @@ export const MESSAGES = {
1089
1115
  'entry.noPassageError':
1090
1116
  'Aucune navigation où consigner : appareillez ou levez l’ancre d’abord.',
1091
1117
  'entry.refused': 'Non consigné : {message}',
1118
+ 'entry.notDoneOffline': 'Non effectué : le serveur Signal K ne répond pas.',
1119
+ 'entry.stillOffline':
1120
+ 'Le serveur Signal K ne répond toujours pas. Les entrées restent en attente sur cette tablette.',
1121
+ 'entry.reconnecting': 'Connexion…',
1092
1122
  'entry.chooseSail': 'Quelle voile est envoyée ?',
1093
1123
  'entry.otherSail': 'Autre voile',
1094
1124
  'entry.sketchUndo': 'Annuler',
@@ -1,4 +1,6 @@
1
1
  import { html, render } from '../vendor/preact-htm.mjs';
2
+ import { connection } from './api.mjs';
3
+ import { watchConnection } from './connection.mjs';
2
4
  import { createLocale, LocaleProvider, useLocale, useRoute } from './context.mjs';
3
5
  import { pickLanguage } from './i18n.mjs';
4
6
  import { AnimationView } from './components/AnimationView.mjs';
@@ -8,6 +10,7 @@ import { PassageView } from './components/PassageView.mjs';
8
10
  import { ReplayView } from './components/ReplayView.mjs';
9
11
  import { StatisticsView } from './components/StatisticsView.mjs';
10
12
  import { StatusBar } from './components/StatusBar.mjs';
13
+ import { ConnectionToast } from './components/common.mjs';
11
14
 
12
15
  function Page({ route }) {
13
16
  if (route.name === 'passage') {
@@ -50,6 +53,7 @@ function Shell() {
50
53
  <${StatusBar} />
51
54
  <main><${Page} route=${route} /></main>
52
55
  <footer class="attribution">${t('footer.attribution')}</footer>
56
+ <${ConnectionToast} />
53
57
  `;
54
58
  }
55
59
 
@@ -57,6 +61,8 @@ const locale = createLocale(pickLanguage(navigator.languages, location.search));
57
61
  document.documentElement.lang = locale.language;
58
62
  document.title = locale.t('app.title');
59
63
 
64
+ watchConnection(connection, { window, document });
65
+
60
66
  render(
61
67
  html`<${LocaleProvider} value=${locale}><${Shell} /></${LocaleProvider}>`,
62
68
  document.getElementById('app')