signalk-chiplog 2.13.0 → 2.15.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 +27 -1
- package/README.md +15 -6
- package/index.js +7 -0
- package/lib/api.js +7 -0
- package/lib/events.js +7 -2
- package/lib/passage-summary.js +10 -2
- package/lib/summary-mailer.js +12 -1
- package/lib/track.js +9 -1
- package/package.json +1 -1
- package/public/app.css +34 -0
- package/public/js/components/CrewCard.mjs +197 -14
- package/public/js/components/PassageView.mjs +13 -1
- package/public/js/components/TideCard.mjs +21 -3
- package/public/js/components/Timeline.mjs +27 -23
- package/public/js/components/common.mjs +31 -0
- package/public/js/i18n.mjs +4 -0
- package/public/js/tide.mjs +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,30 @@ All notable changes to Chiplog are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.15.0] - 2026-10-30
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Delete an instrument reading.** An administrator can delete any reading from a passage's journal (trash icon on its
|
|
14
|
+
row), including those taken at departure and arrival. Any log line, automatic ones included, can now be deleted from
|
|
15
|
+
the journal too.
|
|
16
|
+
- **Link to the passage in the summary emails.** A new **Server address for the summary link** setting; each summary
|
|
17
|
+
then ends with a link that opens the passage in the webapp. Without it, no link.
|
|
18
|
+
- **Icon buttons in the journal**: edit and delete are a pencil and a bin instead of words.
|
|
19
|
+
- **Hour scale on the tide graph**: the passage page's curve is graduated every three hours, in the logbook's time zone.
|
|
20
|
+
|
|
21
|
+
## [2.14.0] - 2026-10-25
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **Edit the crew from the webapp.** The crew card of a passage has an Edit button: tick or untick names from the crew
|
|
26
|
+
list, add a new one with an optional role, and save.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **Deleting a logbook line also deletes its instrument reading.** A note or manoeuvre logged live took a snapshot that
|
|
31
|
+
stayed in the logbook, and in the PDF, after the line was deleted.
|
|
32
|
+
|
|
9
33
|
## [2.13.0] - 2026-10-24
|
|
10
34
|
|
|
11
35
|
### Added
|
|
@@ -511,7 +535,9 @@ First release.
|
|
|
511
535
|
- REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
|
|
512
536
|
- Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
|
|
513
537
|
|
|
514
|
-
[Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.
|
|
538
|
+
[Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.15.0...HEAD
|
|
539
|
+
[2.15.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.14.0...v2.15.0
|
|
540
|
+
[2.14.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.13.0...v2.14.0
|
|
515
541
|
[2.13.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.12.0...v2.13.0
|
|
516
542
|
[2.12.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.11.0...v2.12.0
|
|
517
543
|
[2.11.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.10.0...v2.11.0
|
package/README.md
CHANGED
|
@@ -217,12 +217,13 @@ Open **Chiplog** from the Signal K webapps, or `/signalk-chiplog/`. Reading need
|
|
|
217
217
|
counter at departure and arrival and the hours run, and the tank levels and battery charge, voltage and current noted
|
|
218
218
|
at departure), and the log: every reading and event in order, including handwritten notes. A passage in progress
|
|
219
219
|
refreshes every minute. Each line's comment can be edited (read/write access); a manoeuvre or note the crew logged
|
|
220
|
-
themselves can also be **redated**
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
the
|
|
224
|
-
|
|
225
|
-
|
|
220
|
+
themselves can also be **redated** — automatic lines (alarms, autopilot, weather, corrections) can only be annotated.
|
|
221
|
+
Any line can be deleted (with the instrument reading taken when it was logged), and so can a reading on its own, which
|
|
222
|
+
needs an administrator. Correcting a time reads the position back from the track at that time, and leaves it empty
|
|
223
|
+
when the track has nothing within 2 minutes of it, since the entry was not made where it happened. A time outside the
|
|
224
|
+
passage, or in the future, is refused, and the departure manoeuvre that opened a passage is moved by changing that
|
|
225
|
+
passage's departure time in **Corrections** below. Under each position, in grey, its bearing and distance from the
|
|
226
|
+
nearest landmark.
|
|
226
227
|
- **Corrections** (read/write access):
|
|
227
228
|
- rename the departure, or the arrival once the passage is closed — a passage in progress has none yet to rename;
|
|
228
229
|
- switch an engine period to sail or back;
|
|
@@ -361,6 +362,9 @@ dialog, including one just added, carries its own pencil to correct it and a tra
|
|
|
361
362
|
good; a removal asks to confirm first, and past passages that recorded the name keep it. A new passage starts with the
|
|
362
363
|
same crew as the one before it, ready to adjust rather than re-enter from scratch.
|
|
363
364
|
|
|
365
|
+
The consultation webapp edits it too: the **Crew** card of a passage has an Edit button to tick or untick names from the
|
|
366
|
+
list and add a new one, then save (read/write access).
|
|
367
|
+
|
|
364
368
|
### Logging a manoeuvre
|
|
365
369
|
|
|
366
370
|
Tap the manoeuvre: tack, gybe, reef in, shake out reef, sail change, anchor down, anchor up, moor, cast off, watch
|
|
@@ -479,6 +483,7 @@ In the Signal K admin, **Apps & Plugins → Configuration → Chiplog**.
|
|
|
479
483
|
| SMTP port | 587 | 587 for submission with STARTTLS, 465 for implicit TLS, 25 for a local relay. |
|
|
480
484
|
| SMTP security | STARTTLS | STARTTLS, TLS, or none for a relay on the boat itself. |
|
|
481
485
|
| SMTP user name / password | — | Leave empty if the relay needs no authentication. |
|
|
486
|
+
| Server address for the summary link | — | The address the server is reached at by whoever reads the summary, e.g. `https://boat.example.org`. Each summary then links to its passage in the webapp. Empty leaves the link out. |
|
|
482
487
|
| 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. |
|
|
483
488
|
| Logbook language (PDF and summary emails) | en | Language of the PDF logbooks on the USB drive and of the summary emails (English or French). |
|
|
484
489
|
| 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`. |
|
|
@@ -592,6 +597,10 @@ summary of every passage once it is over for good.
|
|
|
592
597
|
distance, the duration, the time under way, the time under engine and under sail, the average and maximum speed, and
|
|
593
598
|
the maximum wind — the same figures and wording as the passage page, in the **logbook language** and **ship's time
|
|
594
599
|
zone** set below.
|
|
600
|
+
- **A link to the passage.** Set **Server address for the summary link** to the address the server is reached at
|
|
601
|
+
(`https://boat.example.org`, or a LAN address if the readers are aboard) and each summary ends with a link that opens
|
|
602
|
+
the passage in the webapp. Without it the summary carries no link, as the plugin cannot know its own address from
|
|
603
|
+
outside.
|
|
595
604
|
- **The map** is drawn on the server and travels inside the message, so it shows up in the mail rather than as an
|
|
596
605
|
attachment to open. Tiles come from OpenStreetMap by default; set your own tile server, or clear the setting to get
|
|
597
606
|
the track drawn on a plain background and send nothing to a tile server.
|
package/index.js
CHANGED
|
@@ -261,6 +261,12 @@ module.exports = function (app) {
|
|
|
261
261
|
title: 'SMTP password',
|
|
262
262
|
format: 'password'
|
|
263
263
|
},
|
|
264
|
+
summaryMailServerUrl: {
|
|
265
|
+
type: 'string',
|
|
266
|
+
title: 'Server address for the summary link',
|
|
267
|
+
description:
|
|
268
|
+
'The address the server is reached at from the recipients’ side, e.g. https://boat.example.org or http://192.168.1.10:3000. Each summary then links to its passage in the logbook; empty leaves the link out'
|
|
269
|
+
},
|
|
264
270
|
summaryMailTileUrl: {
|
|
265
271
|
type: 'string',
|
|
266
272
|
title: 'Map tiles for the summary',
|
|
@@ -673,6 +679,7 @@ module.exports = function (app) {
|
|
|
673
679
|
: SUMMARY_MAIL_DEFAULTS.smtpSecurity,
|
|
674
680
|
smtpUsername: config.smtpUsername || null,
|
|
675
681
|
smtpPassword: config.smtpPassword || null,
|
|
682
|
+
summaryMailServerUrl: config.summaryMailServerUrl || null,
|
|
676
683
|
summaryMailTileUrl:
|
|
677
684
|
config.summaryMailTileUrl === undefined
|
|
678
685
|
? SUMMARY_MAIL_DEFAULTS.summaryMailTileUrl
|
package/lib/api.js
CHANGED
|
@@ -369,6 +369,13 @@ function registerRoutes(router, { getContext, logError }) {
|
|
|
369
369
|
handle(({ db }, req) => track.listObservations(db, entryId(req), v.parsePagination(req.query)))
|
|
370
370
|
);
|
|
371
371
|
|
|
372
|
+
admin.delete(
|
|
373
|
+
'/api/observations/:id',
|
|
374
|
+
handle(({ db }, req) => {
|
|
375
|
+
track.deleteObservation(db, v.parseId(req.params.id));
|
|
376
|
+
})
|
|
377
|
+
);
|
|
378
|
+
|
|
372
379
|
readonly.get(
|
|
373
380
|
'/api/entries/:id/propulsion',
|
|
374
381
|
handle(({ db }, req) => propulsion.listSegments(db, entryId(req), v.parsePagination(req.query)))
|
package/lib/events.js
CHANGED
|
@@ -294,7 +294,9 @@ function checkRetiming(db, event, time, now) {
|
|
|
294
294
|
|
|
295
295
|
// The instrument snapshot taken when the event was logged (SPEC §4.1) belongs to
|
|
296
296
|
// the moment the button was pressed, not to the moment the event is redated to:
|
|
297
|
-
// it goes, unless another event of the passage still stands at that time.
|
|
297
|
+
// it goes, unless another event of the passage still stands at that time. Called
|
|
298
|
+
// after the event's row is deleted, or with the event still standing when it is
|
|
299
|
+
// redated (the `id <> ?` below covers both).
|
|
298
300
|
function dropEventSnapshot(db, event) {
|
|
299
301
|
const others = db
|
|
300
302
|
.prepare('SELECT 1 FROM events WHERE entry_id = ? AND time = ? AND id <> ?')
|
|
@@ -373,7 +375,8 @@ function pairedAlarmEvent(db, row) {
|
|
|
373
375
|
}
|
|
374
376
|
|
|
375
377
|
// Undoing the departure that opened a passage, before the vessel moved and
|
|
376
|
-
// before anything else was logged in it, takes the passage away too.
|
|
378
|
+
// before anything else was logged in it, takes the passage away too. Otherwise
|
|
379
|
+
// the instrument snapshot logged with the event goes with it.
|
|
377
380
|
function deleteEvent(db, id) {
|
|
378
381
|
const row = requireEventRow(db, id);
|
|
379
382
|
withTransaction(db, () => {
|
|
@@ -391,8 +394,10 @@ function deleteEvent(db, id) {
|
|
|
391
394
|
const paired = pairedAlarmEvent(db, row);
|
|
392
395
|
if (paired) {
|
|
393
396
|
db.prepare('DELETE FROM events WHERE id = ?').run(paired.id);
|
|
397
|
+
dropEventSnapshot(db, toEvent(paired));
|
|
394
398
|
}
|
|
395
399
|
db.prepare('DELETE FROM events WHERE id = ?').run(id);
|
|
400
|
+
dropEventSnapshot(db, toEvent(row));
|
|
396
401
|
});
|
|
397
402
|
}
|
|
398
403
|
|
package/lib/passage-summary.js
CHANGED
|
@@ -20,6 +20,7 @@ const STYLE = {
|
|
|
20
20
|
table: 'width:100%;border-collapse:collapse;margin-top:16px;font-size:14px;',
|
|
21
21
|
label: 'padding:6px 12px 6px 0;color:#52606d;white-space:nowrap;',
|
|
22
22
|
value: 'padding:6px 0;text-align:right;font-weight:bold;color:#102a43;',
|
|
23
|
+
link: 'margin:16px 0 0;font-size:14px;',
|
|
23
24
|
footer: 'margin:18px 0 0;font-size:12px;color:#7b8794;'
|
|
24
25
|
};
|
|
25
26
|
|
|
@@ -103,10 +104,11 @@ function htmlRow(label, value) {
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
// `mapAvailable` says whether an image part accompanies the message; without
|
|
106
|
-
// one the <img> would show as a broken attachment.
|
|
107
|
+
// one the <img> would show as a broken attachment. `passageUrl`, when given, is
|
|
108
|
+
// where the passage can be opened in the webapp.
|
|
107
109
|
async function renderPassageSummary(
|
|
108
110
|
{ entry, trackPoints = [] },
|
|
109
|
-
{ language = 'en', timeZone, vesselName = null, mapAvailable = false } = {}
|
|
111
|
+
{ language = 'en', timeZone, vesselName = null, mapAvailable = false, passageUrl = null } = {}
|
|
110
112
|
) {
|
|
111
113
|
const { i18n, format: formatModule } = await loadShared();
|
|
112
114
|
const t = i18n.createTranslator(language);
|
|
@@ -139,6 +141,7 @@ async function renderPassageSummary(
|
|
|
139
141
|
'',
|
|
140
142
|
...facts.map(([label, value]) => `${label}: ${value}`),
|
|
141
143
|
...(trackPoints.length === 0 ? ['', t('passage.noTrack')] : []),
|
|
144
|
+
...(passageUrl ? ['', `${t('mail.link')}: ${passageUrl}`] : []),
|
|
142
145
|
'',
|
|
143
146
|
t('mail.footer')
|
|
144
147
|
].join('\n');
|
|
@@ -158,6 +161,11 @@ ${
|
|
|
158
161
|
${lines.map(({ label, place, when: at }) => htmlRow(label, at ? `${place} — ${at}` : place)).join('\n')}
|
|
159
162
|
${facts.map(([label, value]) => htmlRow(label, value)).join('\n')}
|
|
160
163
|
</table>
|
|
164
|
+
${
|
|
165
|
+
passageUrl
|
|
166
|
+
? `<p style="${STYLE.link}"><a href="${escapeHtml(passageUrl)}">${escapeHtml(t('mail.link'))}</a></p>`
|
|
167
|
+
: ''
|
|
168
|
+
}
|
|
161
169
|
<p style="${STYLE.footer}">${escapeHtml(t('mail.footer'))}</p>
|
|
162
170
|
</div>
|
|
163
171
|
</body></html>
|
package/lib/summary-mailer.js
CHANGED
|
@@ -79,6 +79,16 @@ function writeOffStale(db, now) {
|
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// Where a passage can be opened in the webapp, from the address the crew reaches
|
|
83
|
+
// the server at; nothing when none is configured -- the plugin cannot know its
|
|
84
|
+
// own address from the outside.
|
|
85
|
+
function passageUrl(baseUrl, entryId) {
|
|
86
|
+
const base = String(baseUrl ?? '')
|
|
87
|
+
.trim()
|
|
88
|
+
.replace(/\/+$/, '');
|
|
89
|
+
return /^https?:\/\/\S+$/i.test(base) ? `${base}/signalk-chiplog/#/passages/${entryId}` : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
82
92
|
function configurationProblem(settings) {
|
|
83
93
|
if (!settings.smtpHost) {
|
|
84
94
|
return 'no SMTP server is configured';
|
|
@@ -135,7 +145,8 @@ function createSummaryMailer({
|
|
|
135
145
|
language: settings.logbookLanguage,
|
|
136
146
|
timeZone: settings.logbookTimeZone ?? undefined,
|
|
137
147
|
vesselName: vessel,
|
|
138
|
-
mapAvailable: map !== null
|
|
148
|
+
mapAvailable: map !== null,
|
|
149
|
+
passageUrl: passageUrl(settings.summaryMailServerUrl, entryId)
|
|
139
150
|
}
|
|
140
151
|
);
|
|
141
152
|
|
package/lib/track.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { requireEntryRow } = require('./entries');
|
|
2
|
+
const { notFound } = require('./errors');
|
|
2
3
|
const { toPosition, paginate } = require('./rows');
|
|
3
4
|
|
|
4
5
|
function toTrackPoint(row) {
|
|
@@ -68,4 +69,11 @@ function listObservations(db, entryId, { limit, offset }) {
|
|
|
68
69
|
});
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
function deleteObservation(db, id) {
|
|
73
|
+
const { changes } = db.prepare('DELETE FROM observations WHERE id = ?').run(id);
|
|
74
|
+
if (changes === 0) {
|
|
75
|
+
throw notFound('observation', id);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { allTrackPoints, allObservations, listObservations, deleteObservation };
|
package/package.json
CHANGED
package/public/app.css
CHANGED
|
@@ -78,6 +78,16 @@ button.link-button {
|
|
|
78
78
|
padding: 0.2rem 0.4rem;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
button.icon-button {
|
|
82
|
+
display: inline-flex;
|
|
83
|
+
align-items: center;
|
|
84
|
+
padding: 0.25rem;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
button.icon-button.danger {
|
|
88
|
+
border: none;
|
|
89
|
+
}
|
|
90
|
+
|
|
81
91
|
button.danger {
|
|
82
92
|
background: none;
|
|
83
93
|
color: var(--danger);
|
|
@@ -735,6 +745,17 @@ main {
|
|
|
735
745
|
stroke-width: 1;
|
|
736
746
|
}
|
|
737
747
|
|
|
748
|
+
.tide-tick {
|
|
749
|
+
stroke: var(--border);
|
|
750
|
+
stroke-width: 1;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
.tide-hour {
|
|
754
|
+
fill: var(--muted);
|
|
755
|
+
font-size: 11px;
|
|
756
|
+
text-anchor: middle;
|
|
757
|
+
}
|
|
758
|
+
|
|
738
759
|
.tide-area {
|
|
739
760
|
fill: var(--accent);
|
|
740
761
|
opacity: 0.12;
|
|
@@ -809,6 +830,19 @@ main {
|
|
|
809
830
|
gap: 0.4rem;
|
|
810
831
|
}
|
|
811
832
|
|
|
833
|
+
.crew-editor {
|
|
834
|
+
display: flex;
|
|
835
|
+
flex-direction: column;
|
|
836
|
+
gap: 0.8rem;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
.crew-choice {
|
|
840
|
+
display: flex;
|
|
841
|
+
align-items: center;
|
|
842
|
+
gap: 0.5rem;
|
|
843
|
+
cursor: pointer;
|
|
844
|
+
}
|
|
845
|
+
|
|
812
846
|
.crew-role {
|
|
813
847
|
color: var(--muted);
|
|
814
848
|
font-size: 0.9rem;
|
|
@@ -1,22 +1,205 @@
|
|
|
1
|
-
import { html } from '../../vendor/preact-htm.mjs';
|
|
1
|
+
import { html, useEffect, useState } from '../../vendor/preact-htm.mjs';
|
|
2
|
+
import { fetchAll } from '../api.mjs';
|
|
2
3
|
import { useLocale } from '../context.mjs';
|
|
4
|
+
import { ErrorNotice } from './common.mjs';
|
|
3
5
|
|
|
4
|
-
|
|
6
|
+
const sameName = (a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }) === 0;
|
|
7
|
+
|
|
8
|
+
// Everyone who can be ticked: the passage's own crew first, in the order they
|
|
9
|
+
// were added, then the rest of the roster, then whoever was just typed in. A
|
|
10
|
+
// member the roster no longer holds (`crewMemberId` null) stays listed, since
|
|
11
|
+
// the passage recorded them.
|
|
12
|
+
function candidates(crew, roster, added) {
|
|
13
|
+
const known = new Set(crew.map((member) => member.crewMemberId));
|
|
14
|
+
return [
|
|
15
|
+
...crew.map((member) => ({
|
|
16
|
+
key: `${member.id}`,
|
|
17
|
+
crewMemberId: member.crewMemberId,
|
|
18
|
+
name: member.name,
|
|
19
|
+
role: member.role
|
|
20
|
+
})),
|
|
21
|
+
...roster
|
|
22
|
+
.filter((member) => !known.has(member.id))
|
|
23
|
+
.map((member) => ({
|
|
24
|
+
key: `roster-${member.id}`,
|
|
25
|
+
crewMemberId: member.id,
|
|
26
|
+
name: member.name,
|
|
27
|
+
role: member.role
|
|
28
|
+
})),
|
|
29
|
+
...added
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function CrewEditor({ crew, busy, onSave, onCancel }) {
|
|
5
34
|
const { t } = useLocale();
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
35
|
+
const [roster, setRoster] = useState(null);
|
|
36
|
+
const [error, setError] = useState(null);
|
|
37
|
+
const [added, setAdded] = useState([]);
|
|
38
|
+
const [picked, setPicked] = useState(() => new Set(crew.map((member) => `${member.id}`)));
|
|
39
|
+
const [name, setName] = useState('');
|
|
40
|
+
const [role, setRole] = useState('');
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
let cancelled = false;
|
|
44
|
+
fetchAll('/crew')
|
|
45
|
+
.then((items) => !cancelled && setRoster(items))
|
|
46
|
+
.catch((err) => !cancelled && setError(err));
|
|
47
|
+
return () => {
|
|
48
|
+
cancelled = true;
|
|
49
|
+
};
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const toggle = (key) =>
|
|
53
|
+
setPicked((previous) => {
|
|
54
|
+
const next = new Set(previous);
|
|
55
|
+
if (!next.delete(key)) {
|
|
56
|
+
next.add(key);
|
|
57
|
+
}
|
|
58
|
+
return next;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const items = candidates(crew, roster ?? [], added);
|
|
62
|
+
|
|
63
|
+
const add = () => {
|
|
64
|
+
const trimmed = name.trim();
|
|
65
|
+
if (!trimmed) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const existing = items.find((item) => sameName(item.name, trimmed));
|
|
69
|
+
if (existing) {
|
|
70
|
+
setPicked((previous) => new Set(previous).add(existing.key));
|
|
71
|
+
} else {
|
|
72
|
+
const key = `added-${added.length}`;
|
|
73
|
+
setAdded((previous) => [
|
|
74
|
+
...previous,
|
|
75
|
+
{ key, crewMemberId: null, name: trimmed, role: role.trim() || null }
|
|
76
|
+
]);
|
|
77
|
+
setPicked((previous) => new Set(previous).add(key));
|
|
78
|
+
}
|
|
79
|
+
setName('');
|
|
80
|
+
setRole('');
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const submit = (event) => {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
onSave(
|
|
86
|
+
items
|
|
87
|
+
.filter((item) => picked.has(item.key))
|
|
88
|
+
.map((item) =>
|
|
89
|
+
item.crewMemberId !== null && !item.key.startsWith('added-')
|
|
90
|
+
? { crewMemberId: item.crewMemberId }
|
|
91
|
+
: { name: item.name, role: item.role }
|
|
92
|
+
)
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Enter in the name or role field adds that person rather than saving the card.
|
|
97
|
+
const onFieldKeyDown = (event) => {
|
|
98
|
+
if (event.key === 'Enter') {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
add();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return html`
|
|
105
|
+
<form class="crew-editor" onSubmit=${submit}>
|
|
106
|
+
<${ErrorNotice} error=${error} />
|
|
107
|
+
${
|
|
108
|
+
roster === null && !error
|
|
109
|
+
? html`<p class="muted">…</p>`
|
|
110
|
+
: html`<ul class="crew-list">
|
|
111
|
+
${items.map(
|
|
112
|
+
(item) =>
|
|
113
|
+
html`<li key=${item.key}>
|
|
114
|
+
<label class="crew-choice">
|
|
115
|
+
<input
|
|
116
|
+
type="checkbox"
|
|
117
|
+
checked=${picked.has(item.key)}
|
|
118
|
+
onChange=${() => toggle(item.key)}
|
|
119
|
+
/>
|
|
120
|
+
${item.name}${item.role ? html` <span class="crew-role">${item.role}</span>` : ''}
|
|
121
|
+
</label>
|
|
122
|
+
</li>`
|
|
123
|
+
)}
|
|
124
|
+
</ul>`
|
|
125
|
+
}
|
|
126
|
+
<div class="name-form">
|
|
127
|
+
<input
|
|
128
|
+
value=${name}
|
|
129
|
+
maxlength="100"
|
|
130
|
+
placeholder=${t('entry.crewName')}
|
|
131
|
+
aria-label=${t('entry.crewName')}
|
|
132
|
+
onInput=${(event) => setName(event.currentTarget.value)}
|
|
133
|
+
onKeyDown=${onFieldKeyDown}
|
|
134
|
+
/>
|
|
135
|
+
<input
|
|
136
|
+
value=${role}
|
|
137
|
+
maxlength="100"
|
|
138
|
+
placeholder=${t('entry.crewRole')}
|
|
139
|
+
aria-label=${t('entry.crewRole')}
|
|
140
|
+
onInput=${(event) => setRole(event.currentTarget.value)}
|
|
141
|
+
onKeyDown=${onFieldKeyDown}
|
|
142
|
+
/>
|
|
143
|
+
<button type="button" disabled=${!name.trim()} onClick=${add}>
|
|
144
|
+
${t('entry.addCrewMember')}
|
|
145
|
+
</button>
|
|
146
|
+
</div>
|
|
147
|
+
<div class="name-form">
|
|
148
|
+
<button type="submit" disabled=${busy || (roster === null && !error)}>
|
|
149
|
+
${t('common.save')}
|
|
150
|
+
</button>
|
|
151
|
+
<button type="button" class="link-button" onClick=${onCancel}>${t('common.cancel')}</button>
|
|
152
|
+
</div>
|
|
153
|
+
</form>
|
|
154
|
+
`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function CrewCard({ crew, busy, onSave }) {
|
|
158
|
+
const { t } = useLocale();
|
|
159
|
+
const [editing, setEditing] = useState(false);
|
|
160
|
+
|
|
161
|
+
const save = (members) => {
|
|
162
|
+
setEditing(false);
|
|
163
|
+
onSave(members);
|
|
164
|
+
};
|
|
165
|
+
|
|
9
166
|
return html`
|
|
10
167
|
<section class="card crew-card">
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
${
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
168
|
+
<header class="card-header">
|
|
169
|
+
<h2>${t('passage.crew')}</h2>
|
|
170
|
+
${
|
|
171
|
+
!editing &&
|
|
172
|
+
html`<button
|
|
173
|
+
type="button"
|
|
174
|
+
class="link-button"
|
|
175
|
+
disabled=${busy}
|
|
176
|
+
onClick=${() => setEditing(true)}
|
|
177
|
+
>
|
|
178
|
+
${t('common.edit')}
|
|
179
|
+
</button>`
|
|
180
|
+
}
|
|
181
|
+
</header>
|
|
182
|
+
${
|
|
183
|
+
editing
|
|
184
|
+
? html`<${CrewEditor}
|
|
185
|
+
crew=${crew}
|
|
186
|
+
busy=${busy}
|
|
187
|
+
onSave=${save}
|
|
188
|
+
onCancel=${() => setEditing(false)}
|
|
189
|
+
/>`
|
|
190
|
+
: crew.length === 0
|
|
191
|
+
? html`<p class="muted">${t('entry.noCrew')}</p>`
|
|
192
|
+
: html`<ul class="crew-list">
|
|
193
|
+
${crew.map(
|
|
194
|
+
(member) =>
|
|
195
|
+
html`<li key=${member.id}>
|
|
196
|
+
${member.name}${
|
|
197
|
+
member.role ? html` <span class="crew-role">${member.role}</span>` : ''
|
|
198
|
+
}
|
|
199
|
+
</li>`
|
|
200
|
+
)}
|
|
201
|
+
</ul>`
|
|
202
|
+
}
|
|
20
203
|
</section>
|
|
21
204
|
`;
|
|
22
205
|
}
|
|
@@ -355,6 +355,11 @@ export function PassageView({ id }) {
|
|
|
355
355
|
await request('DELETE', `/entries/${id}`);
|
|
356
356
|
location.hash = '#/';
|
|
357
357
|
});
|
|
358
|
+
const setCrew = (members) =>
|
|
359
|
+
act(async () => {
|
|
360
|
+
await request('PUT', `/entries/${id}/crew`, { members });
|
|
361
|
+
reload();
|
|
362
|
+
});
|
|
358
363
|
const editEvent = (event, patch) =>
|
|
359
364
|
act(async () => {
|
|
360
365
|
await request('PATCH', `/events/${event.id}`, patch);
|
|
@@ -368,6 +373,12 @@ export function PassageView({ id }) {
|
|
|
368
373
|
await request('DELETE', `/events/${event.id}`);
|
|
369
374
|
reload();
|
|
370
375
|
});
|
|
376
|
+
const deleteReading = (reading) =>
|
|
377
|
+
confirm(t('timeline.deleteReadingConfirm')) &&
|
|
378
|
+
act(async () => {
|
|
379
|
+
await request('DELETE', `/observations/${reading.id}`);
|
|
380
|
+
reload();
|
|
381
|
+
});
|
|
371
382
|
|
|
372
383
|
const sameDay =
|
|
373
384
|
entry.endTime && dayKey(new Date(entry.startTime)) === dayKey(new Date(entry.endTime));
|
|
@@ -520,7 +531,7 @@ export function PassageView({ id }) {
|
|
|
520
531
|
|
|
521
532
|
<${BoatState} entry=${entry} observations=${data.observations} active=${active} />
|
|
522
533
|
|
|
523
|
-
<${CrewCard} crew=${entry.crew} />
|
|
534
|
+
<${CrewCard} crew=${entry.crew} busy=${busy} onSave=${setCrew} />
|
|
524
535
|
</div>
|
|
525
536
|
|
|
526
537
|
<section class="card">
|
|
@@ -534,6 +545,7 @@ export function PassageView({ id }) {
|
|
|
534
545
|
busy=${busy}
|
|
535
546
|
onEditEvent=${editEvent}
|
|
536
547
|
onDelete=${deleteEvent}
|
|
548
|
+
onDeleteReading=${deleteReading}
|
|
537
549
|
/>
|
|
538
550
|
</section>
|
|
539
551
|
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { html } from '../../vendor/preact-htm.mjs';
|
|
2
2
|
import { useLocale } from '../context.mjs';
|
|
3
|
-
import { smoothPath, tideExtremes } from '../tide.mjs';
|
|
3
|
+
import { hourTicks, smoothPath, tideExtremes } from '../tide.mjs';
|
|
4
4
|
|
|
5
5
|
const CHART_WIDTH = 600;
|
|
6
|
-
const CHART_HEIGHT =
|
|
7
|
-
|
|
6
|
+
const CHART_HEIGHT = 180;
|
|
7
|
+
// The bottom margin holds the hour scale.
|
|
8
|
+
const PADDING = { top: 16, right: 12, bottom: 32, left: 12 };
|
|
9
|
+
const TICK_LENGTH = 4;
|
|
8
10
|
|
|
9
11
|
// Pixel coordinates for the curve, an area fill down to the baseline, and the
|
|
10
12
|
// scales used to place the high/low markers at the same spots.
|
|
@@ -37,6 +39,7 @@ export function TideCard({ tide, placeName }) {
|
|
|
37
39
|
}
|
|
38
40
|
const chart = layout(points);
|
|
39
41
|
const extremes = tideExtremes(points);
|
|
42
|
+
const ticks = hourTicks(points, format.time);
|
|
40
43
|
|
|
41
44
|
return html`
|
|
42
45
|
<section class="card tide-card">
|
|
@@ -57,6 +60,21 @@ export function TideCard({ tide, placeName }) {
|
|
|
57
60
|
y2=${chart.baseline}
|
|
58
61
|
class="tide-baseline"
|
|
59
62
|
/>
|
|
63
|
+
${ticks.map(
|
|
64
|
+
(tick) =>
|
|
65
|
+
html`<g key=${tick.time}>
|
|
66
|
+
<line
|
|
67
|
+
x1=${chart.x(tick.time)}
|
|
68
|
+
y1=${chart.baseline}
|
|
69
|
+
x2=${chart.x(tick.time)}
|
|
70
|
+
y2=${chart.baseline + TICK_LENGTH}
|
|
71
|
+
class="tide-tick"
|
|
72
|
+
/>
|
|
73
|
+
<text x=${chart.x(tick.time)} y=${chart.baseline + TICK_LENGTH + 13} class="tide-hour">
|
|
74
|
+
${tick.label}
|
|
75
|
+
</text>
|
|
76
|
+
</g>`
|
|
77
|
+
)}
|
|
60
78
|
<path d=${chart.areaPath} class="tide-area" />
|
|
61
79
|
<path d=${chart.linePath} class="tide-line" />
|
|
62
80
|
${extremes.map(
|
|
@@ -2,6 +2,7 @@ import { html, useEffect, useRef, useState } from '../../vendor/preact-htm.mjs';
|
|
|
2
2
|
import { useLocale } from '../context.mjs';
|
|
3
3
|
import { dayKey } from '../days.mjs';
|
|
4
4
|
import { landmarkLine } from '../landmarks.mjs';
|
|
5
|
+
import { IconButton } from './common.mjs';
|
|
5
6
|
import { EDITABLE_EVENT_TYPES, buildRows, describeEvent } from '../log-lines.mjs';
|
|
6
7
|
|
|
7
8
|
// A dot — the stroke of an i or a full stop — has a single point, which a
|
|
@@ -68,10 +69,9 @@ export function EventRemark({ event, manoeuvreLabels }) {
|
|
|
68
69
|
// the comment on anything the plugin logged itself (SPEC: "an edited comment");
|
|
69
70
|
// what the crew logged (`EDITABLE_EVENT_TYPES`) can also be redated, which makes
|
|
70
71
|
// the server re-read its position from the track. Deleting is offered for what
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
// corrections) can only be annotated.
|
|
72
|
+
// every line — an alarm (`sk_alarm`) takes its paired line, the raise or the
|
|
73
|
+
// clearing, with it (lib/events.js `pairedAlarmEvent`). The server decides who
|
|
74
|
+
// may: an administrator for a reading, whoever has write access for a line.
|
|
75
75
|
function EventLine({ event, bounds, manoeuvreLabels, busy, onEditEvent, onDelete }) {
|
|
76
76
|
const { t, format } = useLocale();
|
|
77
77
|
const [editing, setEditing] = useState(false);
|
|
@@ -143,25 +143,19 @@ function EventLine({ event, bounds, manoeuvreLabels, busy, onEditEvent, onDelete
|
|
|
143
143
|
return html`
|
|
144
144
|
<${EventRemark} event=${event} manoeuvreLabels=${manoeuvreLabels} />
|
|
145
145
|
<div class="timeline-actions">
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
<${IconButton}
|
|
147
|
+
icon="edit"
|
|
148
|
+
label=${t('common.edit')}
|
|
149
149
|
disabled=${busy}
|
|
150
150
|
onClick=${() => setEditing(true)}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
disabled=${busy}
|
|
160
|
-
onClick=${() => onDelete(event)}
|
|
161
|
-
>
|
|
162
|
-
${t('common.delete')}
|
|
163
|
-
</button>`
|
|
164
|
-
}
|
|
151
|
+
/>
|
|
152
|
+
<${IconButton}
|
|
153
|
+
icon="delete"
|
|
154
|
+
label=${t('common.delete')}
|
|
155
|
+
danger
|
|
156
|
+
disabled=${busy}
|
|
157
|
+
onClick=${() => onDelete(event)}
|
|
158
|
+
/>
|
|
165
159
|
</div>
|
|
166
160
|
`;
|
|
167
161
|
}
|
|
@@ -174,7 +168,8 @@ export function Timeline({
|
|
|
174
168
|
manoeuvreLabels,
|
|
175
169
|
busy,
|
|
176
170
|
onEditEvent,
|
|
177
|
-
onDelete
|
|
171
|
+
onDelete,
|
|
172
|
+
onDeleteReading
|
|
178
173
|
}) {
|
|
179
174
|
const { t, format } = useLocale();
|
|
180
175
|
const rows = buildRows(events, observations);
|
|
@@ -260,7 +255,16 @@ export function Timeline({
|
|
|
260
255
|
onEditEvent=${onEditEvent}
|
|
261
256
|
onDelete=${onDelete}
|
|
262
257
|
/>`
|
|
263
|
-
: html`<span class="muted">${t(`observation.${readings.reason}`)}</span
|
|
258
|
+
: html`<span class="muted">${t(`observation.${readings.reason}`)}</span>
|
|
259
|
+
<div class="timeline-actions">
|
|
260
|
+
<${IconButton}
|
|
261
|
+
icon="delete"
|
|
262
|
+
label=${t('common.delete')}
|
|
263
|
+
danger
|
|
264
|
+
disabled=${busy}
|
|
265
|
+
onClick=${() => onDeleteReading(readings)}
|
|
266
|
+
/>
|
|
267
|
+
</div>`
|
|
264
268
|
}
|
|
265
269
|
</td>
|
|
266
270
|
</tr>
|
|
@@ -9,6 +9,37 @@ export function SignInLink({ label }) {
|
|
|
9
9
|
return html`<a href="/admin/#/login" target="_blank" rel="noopener">${label}</a>`;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
const ICON_PATHS = {
|
|
13
|
+
edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z',
|
|
14
|
+
delete: 'M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14M10 10v6M14 10v6'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// A button drawn as an icon, named for screen readers and as a tooltip.
|
|
18
|
+
export function IconButton({ icon, label, danger, disabled, onClick }) {
|
|
19
|
+
return html`<button
|
|
20
|
+
type="button"
|
|
21
|
+
class=${`link-button icon-button${danger ? ' danger' : ''}`}
|
|
22
|
+
disabled=${disabled}
|
|
23
|
+
title=${label}
|
|
24
|
+
aria-label=${label}
|
|
25
|
+
onClick=${onClick}
|
|
26
|
+
>
|
|
27
|
+
<svg
|
|
28
|
+
viewBox="0 0 24 24"
|
|
29
|
+
width="16"
|
|
30
|
+
height="16"
|
|
31
|
+
fill="none"
|
|
32
|
+
stroke="currentColor"
|
|
33
|
+
stroke-width="2"
|
|
34
|
+
stroke-linecap="round"
|
|
35
|
+
stroke-linejoin="round"
|
|
36
|
+
aria-hidden="true"
|
|
37
|
+
>
|
|
38
|
+
<path d=${ICON_PATHS[icon]} />
|
|
39
|
+
</svg>
|
|
40
|
+
</button>`;
|
|
41
|
+
}
|
|
42
|
+
|
|
12
43
|
// Failed actions waiting for the link to come back, so the toast can say, once
|
|
13
44
|
// it does, that there is something to do again.
|
|
14
45
|
const unfinished = new Set();
|
package/public/js/i18n.mjs
CHANGED
|
@@ -219,6 +219,7 @@ export const MESSAGES = {
|
|
|
219
219
|
'mail.subjectVessel': '{vessel} — {route}',
|
|
220
220
|
'mail.when': '{date} ({zone})',
|
|
221
221
|
'mail.mapAlt': 'The track of the passage on the map',
|
|
222
|
+
'mail.link': 'Open the passage in the logbook',
|
|
222
223
|
'mail.footer': 'Sent automatically by Chiplog, this vessel’s logbook.',
|
|
223
224
|
|
|
224
225
|
'map.seamarks': 'Seamarks',
|
|
@@ -305,6 +306,7 @@ export const MESSAGES = {
|
|
|
305
306
|
'timeline.timeFromTrack': 'Changing the time reads the position back from the track.',
|
|
306
307
|
'timeline.deleteConfirm': 'Delete this log line? This cannot be undone.',
|
|
307
308
|
'timeline.deleteAlarmConfirm': 'Delete this alarm and its resolution? This cannot be undone.',
|
|
309
|
+
'timeline.deleteReadingConfirm': 'Delete this instrument reading? This cannot be undone.',
|
|
308
310
|
|
|
309
311
|
// Where the position is from the landmark, the way a logbook gives it.
|
|
310
312
|
'landmark.bearing': '{distance} {cardinal} ({bearing}) — {name}',
|
|
@@ -801,6 +803,7 @@ export const MESSAGES = {
|
|
|
801
803
|
'mail.subjectVessel': '{vessel} — {route}',
|
|
802
804
|
'mail.when': 'Le {date} ({zone})',
|
|
803
805
|
'mail.mapAlt': 'La trace de la navigation sur la carte',
|
|
806
|
+
'mail.link': 'Ouvrir la navigation dans le journal',
|
|
804
807
|
'mail.footer': 'Envoyé automatiquement par Chiplog, le journal de bord de ce navire.',
|
|
805
808
|
|
|
806
809
|
'map.seamarks': 'Balisage',
|
|
@@ -888,6 +891,7 @@ export const MESSAGES = {
|
|
|
888
891
|
'timeline.timeFromTrack': 'Changer l’heure relit la position dans la trace.',
|
|
889
892
|
'timeline.deleteConfirm': 'Supprimer cette ligne du journal ? C’est irréversible.',
|
|
890
893
|
'timeline.deleteAlarmConfirm': 'Supprimer cette alarme et sa résolution ? C’est irréversible.',
|
|
894
|
+
'timeline.deleteReadingConfirm': 'Supprimer ce relevé des instruments ? C’est irréversible.',
|
|
891
895
|
|
|
892
896
|
// Le relèvement inverse : où se trouve le bateau par rapport à l’amer.
|
|
893
897
|
// Sans préposition, qui varierait avec l’aire de vent (au NE, à l’E).
|
package/public/js/tide.mjs
CHANGED
|
@@ -17,6 +17,17 @@ export function tideExtremes(points) {
|
|
|
17
17
|
return extremes;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
// The hour scale under the curve: the samples whose local hour is a multiple of
|
|
21
|
+
// `step`, labelled by `label` (the formatter's `time`, so the zone and the
|
|
22
|
+
// 24-hour clock are the reader's). Samples are used as they are rather than
|
|
23
|
+
// recomputed from the clock, so a zone that is not on a whole hour still lines
|
|
24
|
+
// up with the curve.
|
|
25
|
+
export function hourTicks(points, label, step = 3) {
|
|
26
|
+
return points
|
|
27
|
+
.map((point) => ({ time: point.time, label: label(point.time) }))
|
|
28
|
+
.filter((tick) => Number(tick.label.slice(0, 2)) % step === 0);
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
// A smoothed SVG path through the points, quadratic Bezier segments via
|
|
21
32
|
// midpoints -- simple, and hourly samples are dense enough that it reads as
|
|
22
33
|
// a real tide curve rather than a jagged line.
|