signalk-chiplog 2.10.0 → 2.12.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,39 @@ All notable changes to Chiplog are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.12.0] - 2026-10-24
10
+
11
+ ### Added
12
+
13
+ - **Helm entry beside the chart.** Chartplotters supporting the Plotter Extensions API can open Chiplog from a toolbar
14
+ button. The iframe panel keeps drafts while hidden and follows the plotter's night mode when available. The standalone
15
+ tablet app and its offline queue remain available.
16
+
17
+ ### Fixed
18
+
19
+ - Event and crew deletion confirmations work inside sandboxed plotter panels.
20
+
21
+ ## [2.11.0] - 2026-10-24
22
+
23
+ ### Added
24
+
25
+ - **Alarms can be left out of the log.** **Log Signal K alarms** turns them off altogether, and **Alarms left out of the
26
+ log** lists the ones never to record, `*` standing for any characters: `notifications.chartplotter.unknown*` silences
27
+ a chart plotter raising `unknown114`, `unknown115` and so on. An alarm already in the log is still closed when it
28
+ clears.
29
+ - **Reconnecting after the server drops out.** When the Signal K server stops answering, or your login expires, the
30
+ webapp says which in a banner at the bottom of the screen, seen wherever you are on the page, and offers **Reconnect**
31
+ (with **Sign in** for an expired login, in another tab); once the server answers, pages reload on their own and the
32
+ banner says when a failed correction can be done again. On the tablet, **Not connected** gets a **Reconnect** button
33
+ that sends waiting entries at once, and corrections or crew changes that fail offline say so with the same button.
34
+
35
+ ### Changed
36
+
37
+ - **The online backup sends only what the boat's plan keeps.** When the service says its plan keeps the passages from a
38
+ date on (Ti Bord's free plan: the last 30 days), older closed passages stay on board and are not sent, and the
39
+ passages counted for the backup's progress are those of that window. A passage under way is always sent; once the boat
40
+ is subscribed, everything is sent again, newest first.
41
+
9
42
  ## [2.10.0] - 2026-10-20
10
43
 
11
44
  ### Added
@@ -462,7 +495,9 @@ First release.
462
495
  - REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
463
496
  - Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
464
497
 
465
- [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.10.0...HEAD
498
+ [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.12.0...HEAD
499
+ [2.12.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.11.0...v2.12.0
500
+ [2.11.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.10.0...v2.11.0
466
501
  [2.10.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.9.0...v2.10.0
467
502
  [2.9.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.8.0...v2.9.0
468
503
  [2.8.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.7.0...v2.8.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
 
@@ -426,6 +437,18 @@ Logging needs read/write access. The first time, the app shows **This tablet nee
426
437
  To revoke it, delete the device under **Security → Devices**: the tablet asks for access again. **Sign in instead** uses
427
438
  a regular Signal K user account.
428
439
 
440
+ ### From a chartplotter
441
+
442
+ In a chartplotter supporting the Signal K Plotter Extensions API (such as Freeboard-SK), the **Chiplog** toolbar button
443
+ opens the helm entry app in an iframe panel beside the chart. Closing and reopening it keeps notes and handwriting in
444
+ progress; reloading the plotter still discards unsent drafts. The usual offline queue and Signal K access rights apply.
445
+ The panel follows the plotter's night mode when available, and its night switch also changes the plotter's mode.
446
+ Standalone tablet use remains available at the same address.
447
+
448
+ The extension is advertised automatically while the plugin is enabled. A server without the resource-provider API can
449
+ still run the standalone apps. Journal and login navigation links are hidden in the extension: sign in through the
450
+ chartplotter, or use the panel's device access request.
451
+
429
452
  ## Configuration 🔧
430
453
 
431
454
  In the Signal K admin, **Apps & Plugins → Configuration → Chiplog**.
@@ -459,6 +482,8 @@ In the Signal K admin, **Apps & Plugins → Configuration → Chiplog**.
459
482
  | 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
483
  | 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
484
  | 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`. |
485
+ | Log Signal K alarms | on | Turn off to leave every alarm out of the log. |
486
+ | Alarms left out of the log | — | Notification paths never logged; `*` stands for any characters, and the leading `notifications.` may be omitted. |
462
487
  | Wind speed thresholds | 20, 30 kn | Logged when the 2-minute average true wind crosses them. |
463
488
  | Barometric drop warning | 4 hPa / 3 h | 0 turns it off. |
464
489
  | Log heading changes | on | Turn off to leave heading changes out of the log. |
package/index.js CHANGED
@@ -8,6 +8,7 @@ const { INFLUX_DEFAULTS } = require('./lib/influx-history');
8
8
  const { createLandmarkFinder, LANDMARK_DEFAULTS } = require('./lib/landmark-finder');
9
9
  const { OBSERVATION_DEFAULTS } = require('./lib/observation-recorder');
10
10
  const { createPlaceNamer, GEOCODING_DEFAULTS } = require('./lib/place-names');
11
+ const { registerPlotterExtension } = require('./lib/plotter-extension');
11
12
  const { PROPULSION_DEFAULTS } = require('./lib/propulsion-detector');
12
13
  const { createReplayJob } = require('./lib/replay-job');
13
14
  const { createSummaryMailer, SUMMARY_MAIL_DEFAULTS } = require('./lib/summary-mailer');
@@ -314,6 +315,21 @@ module.exports = function (app) {
314
315
  description:
315
316
  '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
317
  },
318
+ alarmsEnabled: {
319
+ type: 'boolean',
320
+ title: 'Log Signal K alarms',
321
+ description:
322
+ 'Records every notification reaching alarm or emergency, when raised and when cleared; turn off to leave alarms out of the log',
323
+ default: EVENT_DEFAULTS.alarmsEnabled
324
+ },
325
+ ignoredAlarms: {
326
+ type: 'array',
327
+ title: 'Alarms left out of the log',
328
+ description:
329
+ 'Notification paths never logged, * standing for any characters: notifications.chartplotter.unknown* leaves out unknown114, unknown115 and so on. The leading notifications. may be omitted',
330
+ items: { type: 'string' },
331
+ default: EVENT_DEFAULTS.ignoredAlarms
332
+ },
317
333
  windSpeedThresholds: {
318
334
  type: 'array',
319
335
  title: 'Wind speed thresholds (knots)',
@@ -647,6 +663,10 @@ module.exports = function (app) {
647
663
  ? config.logbookLanguage
648
664
  : 'en',
649
665
  logbookTimeZone: config.logbookTimeZone || null,
666
+ alarmsEnabled: config.alarmsEnabled ?? EVENT_DEFAULTS.alarmsEnabled,
667
+ ignoredAlarms: Array.isArray(config.ignoredAlarms)
668
+ ? config.ignoredAlarms
669
+ : EVENT_DEFAULTS.ignoredAlarms,
650
670
  windSpeedThresholds: config.windSpeedThresholds ?? EVENT_DEFAULTS.windSpeedThresholds,
651
671
  pressureDropThreshold: config.pressureDropThreshold ?? EVENT_DEFAULTS.pressureDropThreshold,
652
672
  headingChangeEnabled: config.headingChangeEnabled ?? EVENT_DEFAULTS.headingChangeEnabled,
@@ -687,6 +707,8 @@ module.exports = function (app) {
687
707
  throw err;
688
708
  }
689
709
 
710
+ registerPlotterExtension(app, () => database !== null);
711
+
690
712
  const readSelfPath = (path) => app.getSelfPath(path);
691
713
  detector = createPassageDetector({ db: database, readSelfPath, settings });
692
714
  const recorder = createTrackRecorder({ db: database, readSelfPath, settings });
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 };
@@ -0,0 +1,59 @@
1
+ const { version } = require('../package.json');
2
+
3
+ const EXTENSION_ID = 'signalk-chiplog';
4
+
5
+ function buildManifest() {
6
+ return {
7
+ name: 'Chiplog — Helm entry',
8
+ description: 'Log manoeuvres, notes and handwriting without leaving the chart.',
9
+ version,
10
+ apiVersion: '1',
11
+ requires: ['panels.iframe', 'buttons'],
12
+ optional: ['nightMode'],
13
+ panels: [
14
+ {
15
+ id: 'helm-entry',
16
+ title: 'Chiplog',
17
+ type: 'iframe',
18
+ url: '/signalk-chiplog/entry/?plotterExtension=1',
19
+ lifecycle: 'keepAlive'
20
+ }
21
+ ],
22
+ buttons: [
23
+ {
24
+ id: 'open-helm-entry',
25
+ title: 'Chiplog',
26
+ slot: 'mapToolbar',
27
+ icon: 'edit_note',
28
+ action: { type: 'togglePanel', panel: 'helm-entry' }
29
+ }
30
+ ]
31
+ };
32
+ }
33
+
34
+ function registerPlotterExtension(app, isRunning) {
35
+ // Older servers can still run the standalone logbook and tablet app.
36
+ if (typeof app.registerResourceProvider !== 'function') {
37
+ return;
38
+ }
39
+ app.registerResourceProvider({
40
+ type: 'plotterExtensions',
41
+ methods: {
42
+ listResources: async () => (isRunning() ? { [EXTENSION_ID]: buildManifest() } : {}),
43
+ getResource: async (id) => {
44
+ if (!isRunning() || id !== EXTENSION_ID) {
45
+ throw new Error(`No such plotterExtensions resource: ${id}`);
46
+ }
47
+ return buildManifest();
48
+ },
49
+ setResource: async () => {
50
+ throw new Error('Chiplog plotter extension is read-only');
51
+ },
52
+ deleteResource: async () => {
53
+ throw new Error('Chiplog plotter extension is read-only');
54
+ }
55
+ }
56
+ });
57
+ }
58
+
59
+ module.exports = { registerPlotterExtension };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-chiplog",
3
- "version": "2.10.0",
3
+ "version": "2.12.0",
4
4
  "description": "Nautical logbook for Signal K: automatic entries, handwritten notes",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -81,6 +81,7 @@
81
81
  "mediabunny": "1.58.0",
82
82
  "preact": "^10.29.8",
83
83
  "prettier": "^3.9.6",
84
+ "signalk-plotterext-bus": "^0.21.0",
84
85
  "three": "0.186.0"
85
86
  }
86
87
  }
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;
@@ -749,6 +749,32 @@ input {
749
749
  border-radius: var(--radius);
750
750
  }
751
751
 
752
+ .confirmation-dialog {
753
+ width: min(40rem, calc(100% - 2rem));
754
+ max-height: calc(100% - 2rem);
755
+ padding: 0;
756
+ border: 0;
757
+ border-radius: var(--radius);
758
+ background: var(--surface);
759
+ color: var(--text);
760
+ }
761
+
762
+ .confirmation-dialog::backdrop {
763
+ background: rgb(0 0 0 / 55%);
764
+ }
765
+
766
+ .confirmation-dialog .sheet {
767
+ width: 100%;
768
+ }
769
+
770
+ :root[data-plotter-extension='true'] [data-standalone-link] {
771
+ display: none;
772
+ }
773
+
774
+ :root[data-plotter-extension='true'] .entry-main {
775
+ padding: 0.75rem 0.75rem 9rem;
776
+ }
777
+
752
778
  .sail-grid {
753
779
  display: grid;
754
780
  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
@@ -63,7 +63,7 @@ export function AccessGate({ onGranted }) {
63
63
  ${t('access.request')}
64
64
  </button>`
65
65
  }
66
- <a class="big-button" href="/admin/#/login">${t('access.signIn')}</a>
66
+ <a class="big-button" data-standalone-link href="/admin/#/login">${t('access.signIn')}</a>
67
67
  </div>
68
68
  </section>
69
69
  `;
@@ -1,6 +1,7 @@
1
1
  import { html, useState } from '../../../vendor/preact-htm.mjs';
2
2
  import { useLocale } from '../../../js/context.mjs';
3
3
  import { PencilIcon, TrashIcon } from './Icons.mjs';
4
+ import { confirmAction } from '../confirm.mjs';
4
5
 
5
6
  // .big-button is a row flex container, so a bare <br/> between its children
6
7
  // would not stack them -- wrap name and role in their own column instead.
@@ -83,7 +84,7 @@ export function CrewDialog({
83
84
  };
84
85
 
85
86
  const removeMember = async (member) => {
86
- if (!confirm(t('entry.deleteCrewMemberConfirm', { name: member.name }))) {
87
+ if (!(await confirmAction(t('entry.deleteCrewMemberConfirm', { name: member.name }), t))) {
87
88
  return;
88
89
  }
89
90
  const removed = await onDeleteMember(member.id);
@@ -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"
@@ -62,7 +95,7 @@ export function StatusHeader({ state, entry, online, queued, night, onToggleNigh
62
95
  >
63
96
  ${t('entry.night')}
64
97
  </button>
65
- <a class="tool-button" href="../">${t('entry.logbook')}</a>
98
+ <a class="tool-button" data-standalone-link href="../">${t('entry.logbook')}</a>
66
99
  </div>
67
100
  </header>
68
101
  `;
@@ -0,0 +1,42 @@
1
+ // Native confirm() is blocked in plotter-extension sandboxes. An HTML dialog
2
+ // works there, traps focus, supports Escape, and keeps the same confirmation
3
+ // in the standalone app. Text is never interpreted as HTML.
4
+ export function confirmAction(message, t) {
5
+ return new Promise((resolve) => {
6
+ const dialog = document.createElement('dialog');
7
+ dialog.className = 'confirmation-dialog';
8
+ dialog.setAttribute('aria-label', message);
9
+ const form = document.createElement('form');
10
+ form.method = 'dialog';
11
+ form.className = 'sheet';
12
+ const text = document.createElement('p');
13
+ text.textContent = message;
14
+ const actions = document.createElement('div');
15
+ actions.className = 'sheet-row';
16
+ for (const [value, label, className] of [
17
+ ['cancel', t('common.cancel'), 'big-button'],
18
+ ['confirm', t('common.delete'), 'big-button danger']
19
+ ]) {
20
+ const button = document.createElement('button');
21
+ button.type = 'submit';
22
+ button.value = value;
23
+ button.textContent = label;
24
+ button.className = className;
25
+ button.autofocus = value === 'cancel';
26
+ actions.append(button);
27
+ }
28
+ form.append(text, actions);
29
+ dialog.append(form);
30
+ dialog.addEventListener(
31
+ 'close',
32
+ () => {
33
+ const confirmed = dialog.returnValue === 'confirm';
34
+ dialog.remove();
35
+ resolve(confirmed);
36
+ },
37
+ { once: true }
38
+ );
39
+ document.body.append(dialog);
40
+ dialog.showModal();
41
+ });
42
+ }