signalk-chiplog 2.7.0 → 2.8.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 +22 -1
- package/README.md +15 -7
- package/lib/api.js +2 -2
- package/lib/events.js +84 -20
- package/package.json +1 -1
- package/public/app.css +20 -0
- package/public/entry/entry.css +94 -1
- package/public/entry/js/components/Dialogs.mjs +82 -16
- package/public/entry/js/components/Icons.mjs +33 -0
- package/public/entry/js/components/NotePanel.mjs +21 -3
- package/public/entry/js/components/RecentList.mjs +2 -3
- package/public/entry/js/journal.mjs +24 -7
- package/public/entry/js/main.mjs +27 -18
- package/public/js/components/PassageView.mjs +4 -3
- package/public/js/components/Timeline.mjs +40 -14
- package/public/js/format.mjs +34 -0
- package/public/js/i18n.mjs +14 -2
- package/public/js/log-lines.mjs +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,26 @@ All notable changes to Chiplog are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.8.0] - 2026-10-01
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **A manual entry's time can be typed in or corrected.** A note can be written up after the fact with the time it
|
|
14
|
+
happened, and any manoeuvre, note or sketch the crew logged can be redated later from the logbook's timeline or from
|
|
15
|
+
the tablet — an entry still waiting for the connection included. Since such an entry was not made where it happened,
|
|
16
|
+
its position is read back from the track at that time and left empty when the track has nothing within 2 minutes of
|
|
17
|
+
it, along with the instrument readings taken when the button was pressed. A time outside the passage or in the future
|
|
18
|
+
is refused, and the departure manoeuvre that opened a passage is still moved through that passage's departure time.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- **The tablet's confirmation banner no longer reads as a question.** Logging an entry put up two buttons the size of
|
|
23
|
+
the app's primary actions, "Add a comment" and "Undo", which looked like a dialog waiting for an answer — it was not
|
|
24
|
+
clear the entry was already in, nor that "Undo" deleted it rather than dismissing the banner. The banner now confirms
|
|
25
|
+
first (a check mark, or a clock for an entry held on the tablet), carries a close button and a bar draining over the
|
|
26
|
+
ten seconds it stays, and puts the two follow-ups below the message at a smaller size, the undo in red and labelled
|
|
27
|
+
with what it undoes.
|
|
28
|
+
|
|
9
29
|
## [2.7.0] - 2026-09-22
|
|
10
30
|
|
|
11
31
|
### Added
|
|
@@ -383,7 +403,8 @@ First release.
|
|
|
383
403
|
- REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
|
|
384
404
|
- Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
|
|
385
405
|
|
|
386
|
-
[Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.
|
|
406
|
+
[Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.8.0...HEAD
|
|
407
|
+
[2.8.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.7.0...v2.8.0
|
|
387
408
|
[2.7.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.6.0...v2.7.0
|
|
388
409
|
[2.6.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.5.1...v2.6.0
|
|
389
410
|
[2.5.1]: https://github.com/ricard33/signalk-chiplog/compare/v2.5.0...v2.5.1
|
package/README.md
CHANGED
|
@@ -214,8 +214,12 @@ Open **Chiplog** from the Signal K webapps, or `/signalk-chiplog/`. Reading need
|
|
|
214
214
|
counter at departure and arrival and the hours run, and the tank levels and battery charge, voltage and current noted
|
|
215
215
|
at departure), and the log: every reading and event in order, including handwritten notes. A passage in progress
|
|
216
216
|
refreshes every minute. Each line's comment can be edited (read/write access); a manoeuvre or note the crew logged
|
|
217
|
-
themselves can also be deleted — automatic lines (alarms, autopilot, weather, corrections) can only be
|
|
218
|
-
|
|
217
|
+
themselves can also be **redated** and deleted — automatic lines (alarms, autopilot, weather, corrections) can only be
|
|
218
|
+
annotated. Correcting a time reads the position back from the track at that time, and leaves it empty when the track
|
|
219
|
+
has nothing within 2 minutes of it, since the entry was not made where it happened. A time outside the passage, or in
|
|
220
|
+
the future, is refused, and the departure manoeuvre that opened a passage is moved by changing that passage's
|
|
221
|
+
departure time in **Corrections** below. Under each position, in grey, its bearing and distance from the nearest
|
|
222
|
+
landmark.
|
|
219
223
|
- **Corrections** (read/write access):
|
|
220
224
|
- rename the departure, or the arrival once the passage is closed — a passage in progress has none yet to rename;
|
|
221
225
|
- switch an engine period to sail or back;
|
|
@@ -355,9 +359,11 @@ change. One tap logs it with the time, the position and an instrument reading.
|
|
|
355
359
|
|
|
356
360
|
- **Sail change** asks which sail went up: mainsail, genoa, jib, staysail, spinnaker, gennaker, code 0, storm jib, or
|
|
357
361
|
any name you type.
|
|
358
|
-
- A banner then confirms it
|
|
359
|
-
|
|
360
|
-
|
|
362
|
+
- A banner then confirms it: a check mark, what was logged and at what time, and a bar along the bottom draining over
|
|
363
|
+
the 10 seconds it stays. Nothing is waiting on you — it goes on its own, or at once with the **×**. Under the message
|
|
364
|
+
sit two optional follow-ups:
|
|
365
|
+
- **Add a comment**, e.g. "25 kn, second reef";
|
|
366
|
+
- **Undo this entry**, in red, for a mistaken tap — it deletes the entry just logged.
|
|
361
367
|
|
|
362
368
|
### Departures open the passage
|
|
363
369
|
|
|
@@ -372,7 +378,8 @@ note once moored belongs to the passage that brought you there. Anywhere else, t
|
|
|
372
378
|
|
|
373
379
|
### Notes and handwriting
|
|
374
380
|
|
|
375
|
-
- **Note** — type and tap **Log it**.
|
|
381
|
+
- **Note** — type and tap **Log it**. Leave **Time** empty for now, or fill it in to write up something that happened
|
|
382
|
+
earlier; a note dated by hand takes its position from the track rather than from where you are standing.
|
|
376
383
|
- **Handwriting** — takes over the whole screen, with a toolbar above the pad: fine pen, thick pen, highlighter, eraser,
|
|
377
384
|
undo, and a choice of colour (kept to the theme's colour in night mode, to spare night vision). Pen pressure also sets
|
|
378
385
|
the line width. The eraser removes only what it actually touches, splitting a stroke rather than deleting all of it;
|
|
@@ -385,7 +392,8 @@ export, not just on the tablet.
|
|
|
385
392
|
### Latest entries
|
|
386
393
|
|
|
387
394
|
Below, the latest entries of the current passage (or of the last one) are listed with their time. Each can take a
|
|
388
|
-
comment, and your own entries can be deleted; a note's text can be edited.
|
|
395
|
+
comment, and your own entries can be redated and deleted; a note's text can be edited. An entry still waiting for the
|
|
396
|
+
connection can be corrected too — the change travels with it.
|
|
389
397
|
|
|
390
398
|
### Night mode
|
|
391
399
|
|
package/lib/api.js
CHANGED
|
@@ -499,7 +499,7 @@ function registerRoutes(router, { getContext, logError }) {
|
|
|
499
499
|
|
|
500
500
|
readwrite.patch(
|
|
501
501
|
'/api/events/:id',
|
|
502
|
-
handle(({ db }, req) => {
|
|
502
|
+
handle(({ db, now }, req) => {
|
|
503
503
|
const id = v.parseId(req.params.id);
|
|
504
504
|
const body = v.requireBody(req.body, ['time', 'comment', 'subtype', 'payload']);
|
|
505
505
|
const patch = withoutUndefined({
|
|
@@ -508,7 +508,7 @@ function registerRoutes(router, { getContext, logError }) {
|
|
|
508
508
|
subtype: optional(body, 'subtype', nullableString(200)),
|
|
509
509
|
payload: optional(body, 'payload', parsePayload)
|
|
510
510
|
});
|
|
511
|
-
return events.updateEvent(db, id, patch);
|
|
511
|
+
return events.updateEvent(db, id, patch, { now: now() });
|
|
512
512
|
})
|
|
513
513
|
);
|
|
514
514
|
|
package/lib/events.js
CHANGED
|
@@ -138,28 +138,44 @@ function allEvents(db, entryId) {
|
|
|
138
138
|
.map(toEvent);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
141
|
+
// Where the track says the vessel was at `time`, or null when it recorded
|
|
142
|
+
// nothing close enough. `entryId` narrows the search to one passage, which only
|
|
143
|
+
// an edit can do: a new entry does not know its passage yet.
|
|
144
|
+
function trackPositionAt(db, time, entryId = null) {
|
|
145
145
|
const target = Date.parse(time);
|
|
146
|
+
const scope = entryId === null ? '' : ' AND entry_id = ?';
|
|
147
|
+
const bounds = entryId === null ? [time] : [time, entryId];
|
|
146
148
|
const nearest = [
|
|
147
149
|
db.prepare(
|
|
148
|
-
|
|
150
|
+
`SELECT time, lat, lon FROM track_points WHERE time <= ?${scope} ORDER BY time DESC LIMIT 1`
|
|
149
151
|
),
|
|
150
|
-
db.prepare(
|
|
152
|
+
db.prepare(
|
|
153
|
+
`SELECT time, lat, lon FROM track_points WHERE time >= ?${scope} ORDER BY time LIMIT 1`
|
|
154
|
+
)
|
|
151
155
|
]
|
|
152
|
-
.map((statement) => statement.get(
|
|
156
|
+
.map((statement) => statement.get(...bounds))
|
|
153
157
|
.filter(
|
|
154
158
|
(point) => point && Math.abs(Date.parse(point.time) - target) <= TRACK_POSITION_WINDOW_MS
|
|
155
159
|
)
|
|
156
160
|
.sort(
|
|
157
161
|
(a, b) => Math.abs(Date.parse(a.time) - target) - Math.abs(Date.parse(b.time) - target)
|
|
158
162
|
)[0];
|
|
159
|
-
|
|
160
|
-
|
|
163
|
+
return nearest ? { lat: nearest.lat, lon: nearest.lon } : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function positionAt(db, time, { now, vesselPosition }) {
|
|
167
|
+
if (time === undefined) {
|
|
168
|
+
return vesselPosition;
|
|
169
|
+
}
|
|
170
|
+
const recorded = trackPositionAt(db, time);
|
|
171
|
+
if (recorded) {
|
|
172
|
+
return recorded;
|
|
161
173
|
}
|
|
162
|
-
|
|
174
|
+
// Still the vessel's own position when the entry was made moments ago — a
|
|
175
|
+
// replay from the tablet's queue, where the track may hold nothing yet.
|
|
176
|
+
return Math.abs(Date.parse(now) - Date.parse(time)) <= CURRENT_POSITION_WINDOW_MS
|
|
177
|
+
? vesselPosition
|
|
178
|
+
: null;
|
|
163
179
|
}
|
|
164
180
|
|
|
165
181
|
function findByClientRef(db, clientRef) {
|
|
@@ -259,7 +275,39 @@ function logCrewEvent(db, input, context) {
|
|
|
259
275
|
});
|
|
260
276
|
}
|
|
261
277
|
|
|
262
|
-
|
|
278
|
+
// A hand-written time has to stay a time the passage could have been logged at,
|
|
279
|
+
// and must not contradict the departure it opened.
|
|
280
|
+
function checkRetiming(db, event, time, now) {
|
|
281
|
+
if (Date.parse(time) > Date.parse(now)) {
|
|
282
|
+
throw badRequest('time cannot be in the future');
|
|
283
|
+
}
|
|
284
|
+
const entry = requireEntryRow(db, event.entryId);
|
|
285
|
+
const until = entry.end_time ?? now;
|
|
286
|
+
if (Date.parse(time) < Date.parse(entry.start_time) || Date.parse(time) > Date.parse(until)) {
|
|
287
|
+
throw badRequest('time must fall within the passage');
|
|
288
|
+
}
|
|
289
|
+
const opens = db.prepare('SELECT 1 FROM log_entries WHERE opened_by_event_id = ?').get(event.id);
|
|
290
|
+
if (opens) {
|
|
291
|
+
throw badRequest('This event opened the passage; change the passage departure time instead');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// The instrument snapshot taken when the event was logged (SPEC §4.1) belongs to
|
|
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.
|
|
298
|
+
function dropEventSnapshot(db, event) {
|
|
299
|
+
const others = db
|
|
300
|
+
.prepare('SELECT 1 FROM events WHERE entry_id = ? AND time = ? AND id <> ?')
|
|
301
|
+
.get(event.entryId, event.time, event.id);
|
|
302
|
+
if (!others) {
|
|
303
|
+
db.prepare("DELETE FROM observations WHERE entry_id = ? AND time = ? AND reason = 'event'").run(
|
|
304
|
+
event.entryId,
|
|
305
|
+
event.time
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function updateEvent(db, id, patch, context = {}) {
|
|
263
311
|
const current = toEvent(requireEventRow(db, id));
|
|
264
312
|
|
|
265
313
|
if (!CLIENT_EVENT_TYPES.includes(current.type)) {
|
|
@@ -271,16 +319,32 @@ function updateEvent(db, id, patch) {
|
|
|
271
319
|
|
|
272
320
|
const next = { ...current, ...patch };
|
|
273
321
|
validateContent(db, next);
|
|
322
|
+
const retimed = next.time !== current.time;
|
|
323
|
+
if (retimed) {
|
|
324
|
+
checkRetiming(db, current, next.time, context.now);
|
|
325
|
+
}
|
|
274
326
|
|
|
275
|
-
db
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
next.
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
327
|
+
return withTransaction(db, () => {
|
|
328
|
+
// Redating an event unmoors it from where the crew was when they logged it:
|
|
329
|
+
// its position is the track's at the new time, or none at all.
|
|
330
|
+
const position = retimed ? trackPositionAt(db, next.time, current.entryId) : current.position;
|
|
331
|
+
db.prepare(
|
|
332
|
+
`UPDATE events SET time = ?, subtype = ?, comment = ?, payload = ?, lat = ?, lon = ?
|
|
333
|
+
WHERE id = ?`
|
|
334
|
+
).run(
|
|
335
|
+
next.time,
|
|
336
|
+
next.subtype,
|
|
337
|
+
next.comment,
|
|
338
|
+
serializePayload(next.payload),
|
|
339
|
+
position ? position.lat : null,
|
|
340
|
+
position ? position.lon : null,
|
|
341
|
+
id
|
|
342
|
+
);
|
|
343
|
+
if (retimed) {
|
|
344
|
+
dropEventSnapshot(db, current);
|
|
345
|
+
}
|
|
346
|
+
return toEvent(requireEventRow(db, id));
|
|
347
|
+
});
|
|
284
348
|
}
|
|
285
349
|
|
|
286
350
|
// An alarm is logged as two lines — the notification reaching `alarm` or
|
package/package.json
CHANGED
package/public/app.css
CHANGED
|
@@ -628,6 +628,26 @@ main {
|
|
|
628
628
|
resize: vertical;
|
|
629
629
|
}
|
|
630
630
|
|
|
631
|
+
.timeline-comment-form textarea,
|
|
632
|
+
.timeline-time-field input {
|
|
633
|
+
font: inherit;
|
|
634
|
+
padding: 0.4rem 0.6rem;
|
|
635
|
+
border-radius: 8px;
|
|
636
|
+
border: 1px solid var(--border);
|
|
637
|
+
background: var(--surface);
|
|
638
|
+
color: var(--text);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
.timeline-time-field {
|
|
642
|
+
display: flex;
|
|
643
|
+
flex-direction: column;
|
|
644
|
+
gap: 0.2rem;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
.timeline-time-field small {
|
|
648
|
+
font-size: 0.8em;
|
|
649
|
+
}
|
|
650
|
+
|
|
631
651
|
/* Tide */
|
|
632
652
|
|
|
633
653
|
.tide-place {
|
package/public/entry/entry.css
CHANGED
|
@@ -631,7 +631,10 @@ input {
|
|
|
631
631
|
align-items: center;
|
|
632
632
|
gap: 0.75rem;
|
|
633
633
|
width: min(46rem, calc(100% - 2rem));
|
|
634
|
-
|
|
634
|
+
/* Room on the right for the close button and under for the countdown, both
|
|
635
|
+
taken out of the flow so neither wraps away from the line it belongs to. */
|
|
636
|
+
padding: 0.75rem 4rem 1rem 1rem;
|
|
637
|
+
overflow: hidden;
|
|
635
638
|
background: var(--surface);
|
|
636
639
|
border: 2px solid var(--accent);
|
|
637
640
|
border-radius: var(--radius);
|
|
@@ -647,6 +650,79 @@ input {
|
|
|
647
650
|
background: var(--queued-bg);
|
|
648
651
|
}
|
|
649
652
|
|
|
653
|
+
/* The banner confirms; it does not ask. The mark reads before the words, the
|
|
654
|
+
follow-ups are tool-sized rather than the app's primary size, and the
|
|
655
|
+
countdown says it will go on its own. */
|
|
656
|
+
.toast-mark {
|
|
657
|
+
display: inline-flex;
|
|
658
|
+
flex: none;
|
|
659
|
+
align-items: center;
|
|
660
|
+
justify-content: center;
|
|
661
|
+
width: 2.5rem;
|
|
662
|
+
height: 2.5rem;
|
|
663
|
+
border: 2px solid currentcolor;
|
|
664
|
+
border-radius: 50%;
|
|
665
|
+
color: var(--accent);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
.toast-queued .toast-mark {
|
|
669
|
+
color: var(--warn);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
.toast-actions {
|
|
673
|
+
display: flex;
|
|
674
|
+
flex-wrap: wrap;
|
|
675
|
+
gap: 0.5rem;
|
|
676
|
+
/* Always a line of their own, under the message: a follow-up one may take,
|
|
677
|
+
rather than the answer to a question asked beside it. */
|
|
678
|
+
flex: 1 0 100%;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
.toast-actions .tool-button {
|
|
682
|
+
gap: 0.4rem;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
.toast-close {
|
|
686
|
+
position: absolute;
|
|
687
|
+
top: 0.5rem;
|
|
688
|
+
right: 0.5rem;
|
|
689
|
+
min-width: 2.75rem;
|
|
690
|
+
min-height: 2.75rem;
|
|
691
|
+
border-color: transparent;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
.toast-countdown {
|
|
695
|
+
position: absolute;
|
|
696
|
+
right: 0;
|
|
697
|
+
bottom: 0;
|
|
698
|
+
left: 0;
|
|
699
|
+
height: 4px;
|
|
700
|
+
background: var(--accent);
|
|
701
|
+
transform-origin: left;
|
|
702
|
+
animation: toast-countdown var(--toast-ms, 10s) linear forwards;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
.toast-queued .toast-countdown {
|
|
706
|
+
background: var(--warn);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
@keyframes toast-countdown {
|
|
710
|
+
from {
|
|
711
|
+
transform: scaleX(1);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
to {
|
|
715
|
+
transform: scaleX(0);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
@media (prefers-reduced-motion: reduce) {
|
|
720
|
+
.toast-countdown {
|
|
721
|
+
animation: none;
|
|
722
|
+
opacity: 0.4;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
650
726
|
.toast-error {
|
|
651
727
|
border-color: var(--danger);
|
|
652
728
|
background: var(--error-bg);
|
|
@@ -679,6 +755,23 @@ input {
|
|
|
679
755
|
gap: 0.75rem;
|
|
680
756
|
}
|
|
681
757
|
|
|
758
|
+
.sheet-field {
|
|
759
|
+
display: grid;
|
|
760
|
+
gap: 0.35rem;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
.sheet-field input,
|
|
764
|
+
.note-time input {
|
|
765
|
+
font: inherit;
|
|
766
|
+
/* Thumb-sized: the sheet is read and tapped at arm's length. */
|
|
767
|
+
min-height: 3rem;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
.note-time {
|
|
771
|
+
display: grid;
|
|
772
|
+
gap: 0.35rem;
|
|
773
|
+
}
|
|
774
|
+
|
|
682
775
|
.sheet-row {
|
|
683
776
|
display: flex;
|
|
684
777
|
flex-wrap: wrap;
|
|
@@ -1,28 +1,58 @@
|
|
|
1
1
|
import { html, useEffect, useRef, useState } from '../../../vendor/preact-htm.mjs';
|
|
2
2
|
import { useLocale } from '../../../js/context.mjs';
|
|
3
|
+
import { EDITABLE_EVENT_TYPES } from '../../../js/log-lines.mjs';
|
|
4
|
+
import { CheckIcon, ClockIcon, CloseIcon, UndoIcon } from './Icons.mjs';
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
// Correcting an entry just made, or one picked out of the recent list: its
|
|
7
|
+
// comment, and — for what the crew logged themselves — its time. Redating it
|
|
8
|
+
// makes the server read its position back from the track, so an entry written up
|
|
9
|
+
// after the fact still sits in the right place. `event` is the logged event, or
|
|
10
|
+
// the body of one still waiting in the outbox.
|
|
11
|
+
export function EventDialog({ title, event, bounds, onSave, onCancel }) {
|
|
12
|
+
const { t, format } = useLocale();
|
|
13
|
+
const [text, setText] = useState(event?.comment ?? '');
|
|
14
|
+
const [when, setWhen] = useState(() => (event?.time ? format.dateTimeInput(event.time) : ''));
|
|
7
15
|
const field = useRef(null);
|
|
8
16
|
useEffect(() => field.current?.focus(), []);
|
|
17
|
+
const retimeable = EDITABLE_EVENT_TYPES.includes(event?.type);
|
|
9
18
|
|
|
10
|
-
const submit = (
|
|
11
|
-
|
|
12
|
-
|
|
19
|
+
const submit = (submitEvent) => {
|
|
20
|
+
submitEvent.preventDefault();
|
|
21
|
+
const comment = text.trim() === '' ? null : text.trim();
|
|
22
|
+
const patch = comment === (event?.comment ?? null) ? {} : { comment };
|
|
23
|
+
const time = retimeable && when !== '' ? format.fromDateTimeInput(when) : null;
|
|
24
|
+
if (time !== null && time !== event?.time) {
|
|
25
|
+
patch.time = time;
|
|
26
|
+
}
|
|
27
|
+
onSave(patch);
|
|
13
28
|
};
|
|
14
29
|
|
|
15
30
|
return html`
|
|
16
|
-
<div class="sheet-backdrop" onClick=${(
|
|
31
|
+
<div class="sheet-backdrop" onClick=${(clickEvent) => clickEvent.target === clickEvent.currentTarget && onCancel()}>
|
|
17
32
|
<form class="sheet" role="dialog" aria-modal="true" aria-labelledby="comment-title" onSubmit=${submit}>
|
|
18
33
|
<h2 id="comment-title">${title}</h2>
|
|
34
|
+
${
|
|
35
|
+
retimeable &&
|
|
36
|
+
html`<label class="sheet-field">
|
|
37
|
+
<span>${t('entry.eventTime')}</span>
|
|
38
|
+
<input
|
|
39
|
+
type="datetime-local"
|
|
40
|
+
step="60"
|
|
41
|
+
value=${when}
|
|
42
|
+
min=${bounds?.start ? format.dateTimeInput(bounds.start) : undefined}
|
|
43
|
+
max=${format.dateTimeInput(bounds?.end ?? Date.now())}
|
|
44
|
+
onInput=${(inputEvent) => setWhen(inputEvent.currentTarget.value)}
|
|
45
|
+
/>
|
|
46
|
+
<small class="muted">${t('entry.timeFromTrack')}</small>
|
|
47
|
+
</label>`
|
|
48
|
+
}
|
|
19
49
|
<textarea
|
|
20
50
|
ref=${field}
|
|
21
51
|
rows="4"
|
|
22
52
|
maxlength="10000"
|
|
23
53
|
value=${text}
|
|
24
54
|
aria-label=${t('entry.comment')}
|
|
25
|
-
onInput=${(
|
|
55
|
+
onInput=${(inputEvent) => setText(inputEvent.currentTarget.value)}
|
|
26
56
|
></textarea>
|
|
27
57
|
<div class="sheet-row">
|
|
28
58
|
<button type="button" class="big-button" onClick=${onCancel}>${t('common.cancel')}</button>
|
|
@@ -33,32 +63,68 @@ export function CommentDialog({ title, initial = '', onSave, onCancel }) {
|
|
|
33
63
|
`;
|
|
34
64
|
}
|
|
35
65
|
|
|
36
|
-
// What was just logged
|
|
37
|
-
//
|
|
38
|
-
|
|
66
|
+
// What was just logged. It confirms rather than asks: the mark and the wording
|
|
67
|
+
// say the entry is in, a countdown shows the banner will go on its own, and the
|
|
68
|
+
// two follow-ups are secondary next to it — undoing is the destructive one, and
|
|
69
|
+
// says what it undoes, since on its own "undo" reads as dismissing the banner.
|
|
70
|
+
export function Toast({ toast, timeoutMs, onUndo, onComment, onClose }) {
|
|
39
71
|
const { t, format } = useLocale();
|
|
40
72
|
if (!toast) {
|
|
41
73
|
return null;
|
|
42
74
|
}
|
|
75
|
+
const close = html`<button
|
|
76
|
+
type="button"
|
|
77
|
+
class="tool-button icon-button toast-close"
|
|
78
|
+
aria-label=${t('common.close')}
|
|
79
|
+
onClick=${onClose}
|
|
80
|
+
>
|
|
81
|
+
<${CloseIcon} />
|
|
82
|
+
</button>`;
|
|
83
|
+
|
|
43
84
|
if (toast.kind === 'error') {
|
|
44
85
|
return html`
|
|
45
86
|
<div class="toast toast-error" role="alert">
|
|
46
87
|
<span class="toast-text">${toast.message}</span>
|
|
47
|
-
|
|
88
|
+
${close}
|
|
48
89
|
</div>
|
|
49
90
|
`;
|
|
50
91
|
}
|
|
51
92
|
if (toast.kind === 'undone') {
|
|
52
|
-
return html
|
|
93
|
+
return html`
|
|
94
|
+
<div
|
|
95
|
+
key=${toast}
|
|
96
|
+
class="toast"
|
|
97
|
+
role="status"
|
|
98
|
+
style=${`--toast-ms: ${timeoutMs}ms`}
|
|
99
|
+
>
|
|
100
|
+
<span class="toast-text">${t('entry.undone')}</span>
|
|
101
|
+
${close}
|
|
102
|
+
<span class="toast-countdown" aria-hidden="true"></span>
|
|
103
|
+
</div>
|
|
104
|
+
`;
|
|
53
105
|
}
|
|
54
106
|
const detail = toast.queued
|
|
55
107
|
? t('entry.loggedQueued')
|
|
56
108
|
: `${t('entry.logged', { time: format.time(toast.time) })}${toast.openedEntry ? ` · ${t('entry.openedPassage')}` : ''}`;
|
|
57
109
|
return html`
|
|
58
|
-
<div
|
|
110
|
+
<div
|
|
111
|
+
key=${toast}
|
|
112
|
+
class=${toast.queued ? 'toast toast-queued' : 'toast toast-logged'}
|
|
113
|
+
role="status"
|
|
114
|
+
style=${`--toast-ms: ${timeoutMs}ms`}
|
|
115
|
+
>
|
|
116
|
+
<span class="toast-mark">${toast.queued ? html`<${ClockIcon} />` : html`<${CheckIcon} />`}</span>
|
|
59
117
|
<span class="toast-text"><strong>${toast.what}</strong><br />${detail}</span>
|
|
60
|
-
<
|
|
61
|
-
|
|
118
|
+
<span class="toast-actions">
|
|
119
|
+
<button type="button" class="tool-button" onClick=${onComment}>
|
|
120
|
+
${t('entry.addComment')}
|
|
121
|
+
</button>
|
|
122
|
+
<button type="button" class="tool-button danger" onClick=${onUndo}>
|
|
123
|
+
<${UndoIcon} />${t('entry.undo')}
|
|
124
|
+
</button>
|
|
125
|
+
</span>
|
|
126
|
+
${close}
|
|
127
|
+
<span class="toast-countdown" aria-hidden="true"></span>
|
|
62
128
|
</div>
|
|
63
129
|
`;
|
|
64
130
|
}
|
|
@@ -134,3 +134,36 @@ export function TrashIcon() {
|
|
|
134
134
|
/>
|
|
135
135
|
</svg>`;
|
|
136
136
|
}
|
|
137
|
+
|
|
138
|
+
export function CheckIcon() {
|
|
139
|
+
return html`<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false">
|
|
140
|
+
<path
|
|
141
|
+
d="M4 10.5 8 14.5 16 5.5"
|
|
142
|
+
fill="none"
|
|
143
|
+
stroke="currentColor"
|
|
144
|
+
stroke-width="2.4"
|
|
145
|
+
stroke-linecap="round"
|
|
146
|
+
stroke-linejoin="round"
|
|
147
|
+
/>
|
|
148
|
+
</svg>`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function ClockIcon() {
|
|
152
|
+
return html`<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false">
|
|
153
|
+
<circle cx="10" cy="10" r="7" fill="none" stroke="currentColor" stroke-width="1.8" />
|
|
154
|
+
<path
|
|
155
|
+
d="M10 5.8V10l2.8 1.8"
|
|
156
|
+
fill="none"
|
|
157
|
+
stroke="currentColor"
|
|
158
|
+
stroke-width="1.8"
|
|
159
|
+
stroke-linecap="round"
|
|
160
|
+
stroke-linejoin="round"
|
|
161
|
+
/>
|
|
162
|
+
</svg>`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function CloseIcon() {
|
|
166
|
+
return html`<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false">
|
|
167
|
+
<path d="M5.5 5.5 14.5 14.5M14.5 5.5 5.5 14.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
168
|
+
</svg>`;
|
|
169
|
+
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { html, useState } from '../../../vendor/preact-htm.mjs';
|
|
2
2
|
import { useLocale } from '../../../js/context.mjs';
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
// A free-text note. Its time is left empty for the usual case — the server dates
|
|
5
|
+
// it, and takes an instrument snapshot with it — and filled in when the note is
|
|
6
|
+
// being written up after the fact, where the position comes from the track.
|
|
7
|
+
export function NotePanel({ busy, bounds, onLog }) {
|
|
8
|
+
const { t, format } = useLocale();
|
|
6
9
|
const [text, setText] = useState('');
|
|
10
|
+
const [when, setWhen] = useState('');
|
|
7
11
|
|
|
8
12
|
const submit = async (event) => {
|
|
9
13
|
event.preventDefault();
|
|
@@ -11,8 +15,11 @@ export function NotePanel({ busy, onLog }) {
|
|
|
11
15
|
if (!comment) {
|
|
12
16
|
return;
|
|
13
17
|
}
|
|
14
|
-
|
|
18
|
+
const time = when === '' ? null : format.fromDateTimeInput(when);
|
|
19
|
+
const body = time === null ? { comment } : { comment, time };
|
|
20
|
+
if (await onLog({ type: 'text_annotation', ...body }, t('entry.note'))) {
|
|
15
21
|
setText('');
|
|
22
|
+
setWhen('');
|
|
16
23
|
}
|
|
17
24
|
};
|
|
18
25
|
|
|
@@ -26,6 +33,17 @@ export function NotePanel({ busy, onLog }) {
|
|
|
26
33
|
aria-label=${t('entry.note')}
|
|
27
34
|
onInput=${(event) => setText(event.currentTarget.value)}
|
|
28
35
|
></textarea>
|
|
36
|
+
<label class="note-time">
|
|
37
|
+
<span>${t('entry.noteTime')}</span>
|
|
38
|
+
<input
|
|
39
|
+
type="datetime-local"
|
|
40
|
+
step="60"
|
|
41
|
+
value=${when}
|
|
42
|
+
min=${bounds?.start ? format.dateTimeInput(bounds.start) : undefined}
|
|
43
|
+
max=${format.dateTimeInput(bounds?.end ?? Date.now())}
|
|
44
|
+
onInput=${(event) => setWhen(event.currentTarget.value)}
|
|
45
|
+
/>
|
|
46
|
+
</label>
|
|
29
47
|
<button type="submit" class="big-button primary" disabled=${busy || !text.trim()}>
|
|
30
48
|
${t('entry.send')}
|
|
31
49
|
</button>
|
|
@@ -2,13 +2,12 @@ import { html, useState } from '../../../vendor/preact-htm.mjs';
|
|
|
2
2
|
import { fetchAll } from '../../../js/api.mjs';
|
|
3
3
|
import { useLocale, usePolling } from '../../../js/context.mjs';
|
|
4
4
|
import { EventRemark } from '../../../js/components/Timeline.mjs';
|
|
5
|
+
import { EDITABLE_EVENT_TYPES } from '../../../js/log-lines.mjs';
|
|
5
6
|
import { PencilIcon, TrashIcon } from './Icons.mjs';
|
|
6
7
|
|
|
7
8
|
const REFRESH_MS = 30 * 1000;
|
|
8
9
|
const SHOWN = 15;
|
|
9
10
|
|
|
10
|
-
const CLIENT_TYPES = new Set(['manoeuvre', 'text_annotation', 'handwritten_annotation']);
|
|
11
|
-
|
|
12
11
|
function queuedLabel(body, t, manoeuvreLabels) {
|
|
13
12
|
if (body.type === 'manoeuvre') {
|
|
14
13
|
const key = `manoeuvre.${body.subtype}`;
|
|
@@ -106,7 +105,7 @@ export function RecentList({
|
|
|
106
105
|
</button>
|
|
107
106
|
${
|
|
108
107
|
event.source === 'manual' &&
|
|
109
|
-
|
|
108
|
+
EDITABLE_EVENT_TYPES.includes(event.type) &&
|
|
110
109
|
html`<button
|
|
111
110
|
type="button"
|
|
112
111
|
class="tool-button icon-button danger"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// What the tablet does with an entry: send it at once when the server answers,
|
|
2
|
-
// otherwise keep it in the outbox with the time it was made. Undo and
|
|
2
|
+
// otherwise keep it in the outbox with the time it was made. Undo and edits
|
|
3
3
|
// follow the entry wherever it is — still queued, or already logged.
|
|
4
4
|
|
|
5
5
|
import { createOutbox, isTransient } from './outbox.mjs';
|
|
@@ -70,7 +70,10 @@ export function createJournal({ request, storage, clock, newRef, onChange = () =
|
|
|
70
70
|
flush,
|
|
71
71
|
|
|
72
72
|
// Resolves to { status: 'sent', event } | { status: 'queued', error } and
|
|
73
|
-
// rejects with the server's refusal (a 400 or 409).
|
|
73
|
+
// rejects with the server's refusal (a 400 or 409). A `time` on the body is
|
|
74
|
+
// the crew dating the entry themselves: it is sent as it stands, online as
|
|
75
|
+
// well as queued, which is also what tells the server not to take an
|
|
76
|
+
// instrument snapshot for a moment already past.
|
|
74
77
|
async log(body) {
|
|
75
78
|
const ref = newRef();
|
|
76
79
|
const madeAt = new Date(clock.now()).toISOString();
|
|
@@ -80,7 +83,7 @@ export function createJournal({ request, storage, clock, newRef, onChange = () =
|
|
|
80
83
|
ref,
|
|
81
84
|
method: 'POST',
|
|
82
85
|
path: '/events',
|
|
83
|
-
body: {
|
|
86
|
+
body: { time: madeAt, ...entry },
|
|
84
87
|
createdAt: Date.parse(madeAt)
|
|
85
88
|
});
|
|
86
89
|
|
|
@@ -110,6 +113,17 @@ export function createJournal({ request, storage, clock, newRef, onChange = () =
|
|
|
110
113
|
return outbox.snapshot().pending.some((item) => item.ref === ref);
|
|
111
114
|
},
|
|
112
115
|
|
|
116
|
+
// What there is to show of an entry, wherever it sits: the logged event, or
|
|
117
|
+
// the body of one still waiting its turn — both carry a type, a time and a
|
|
118
|
+
// comment, which is all an edit sheet needs.
|
|
119
|
+
entryFor(ref) {
|
|
120
|
+
return (
|
|
121
|
+
delivered.get(ref) ??
|
|
122
|
+
outbox.snapshot().pending.find((item) => item.ref === ref)?.body ??
|
|
123
|
+
null
|
|
124
|
+
);
|
|
125
|
+
},
|
|
126
|
+
|
|
113
127
|
async undo(ref) {
|
|
114
128
|
if (outbox.remove(ref)) {
|
|
115
129
|
// Removed from the queue, but a replay may have picked it up already.
|
|
@@ -124,16 +138,19 @@ export function createJournal({ request, storage, clock, newRef, onChange = () =
|
|
|
124
138
|
return sendOrQueue('DELETE', `/events/${event.id}`);
|
|
125
139
|
},
|
|
126
140
|
|
|
127
|
-
|
|
128
|
-
|
|
141
|
+
// A comment, a corrected time, or both. On an entry still in the queue it
|
|
142
|
+
// rewrites the body that has yet to go out, so the server works its position
|
|
143
|
+
// out from the corrected time when it finally lands.
|
|
144
|
+
async edit(ref, patch) {
|
|
145
|
+
if (outbox.update(ref, patch)) {
|
|
129
146
|
return null;
|
|
130
147
|
}
|
|
131
148
|
const event = delivered.get(ref);
|
|
132
149
|
if (!event) {
|
|
133
150
|
return null;
|
|
134
151
|
}
|
|
135
|
-
delivered.set(ref, { ...event,
|
|
136
|
-
return sendOrQueue('PATCH', `/events/${event.id}`,
|
|
152
|
+
delivered.set(ref, { ...event, ...patch });
|
|
153
|
+
return sendOrQueue('PATCH', `/events/${event.id}`, patch);
|
|
137
154
|
}
|
|
138
155
|
};
|
|
139
156
|
}
|
package/public/entry/js/main.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { pickLanguage } from '../../js/i18n.mjs';
|
|
|
6
6
|
import { createServerClock } from './clock.mjs';
|
|
7
7
|
import { createJournal } from './journal.mjs';
|
|
8
8
|
import { AccessGate } from './components/AccessGate.mjs';
|
|
9
|
-
import {
|
|
9
|
+
import { EventDialog, Toast } from './components/Dialogs.mjs';
|
|
10
10
|
import { CrewDialog } from './components/CrewDialog.mjs';
|
|
11
11
|
import { PencilIcon } from './components/Icons.mjs';
|
|
12
12
|
import { ManoeuvrePad } from './components/ManoeuvrePad.mjs';
|
|
@@ -97,7 +97,7 @@ function App({ journal }) {
|
|
|
97
97
|
const [forbidden, setForbidden] = useState(false);
|
|
98
98
|
const [queue, setQueue] = useState(journal.outbox.snapshot());
|
|
99
99
|
const [toast, setToast] = useState(null);
|
|
100
|
-
const [
|
|
100
|
+
const [editing, setEditing] = useState(null);
|
|
101
101
|
const [managingCrew, setManagingCrew] = useState(false);
|
|
102
102
|
const [tab, setTab] = useState('note');
|
|
103
103
|
const [night, setNight] = useState(readSetting(NIGHT_KEY) === 'on');
|
|
@@ -248,18 +248,21 @@ function App({ journal }) {
|
|
|
248
248
|
refresh();
|
|
249
249
|
};
|
|
250
250
|
|
|
251
|
-
const
|
|
252
|
-
const target =
|
|
253
|
-
|
|
251
|
+
const saveEvent = async (patch) => {
|
|
252
|
+
const target = editing;
|
|
253
|
+
setEditing(null);
|
|
254
254
|
// A note is its text: emptying it would be a deletion, which has its own button.
|
|
255
|
-
if (comment === null && target.event?.type === 'text_annotation') {
|
|
255
|
+
if (patch.comment === null && target.event?.type === 'text_annotation') {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (Object.keys(patch).length === 0) {
|
|
256
259
|
return;
|
|
257
260
|
}
|
|
258
261
|
try {
|
|
259
262
|
if (target.ref) {
|
|
260
|
-
noteFailure(await journal.
|
|
263
|
+
noteFailure(await journal.edit(target.ref, patch));
|
|
261
264
|
} else {
|
|
262
|
-
await request('PATCH', `/events/${target.event.id}`,
|
|
265
|
+
await request('PATCH', `/events/${target.event.id}`, patch);
|
|
263
266
|
}
|
|
264
267
|
refresh();
|
|
265
268
|
} catch (error) {
|
|
@@ -331,7 +334,8 @@ function App({ journal }) {
|
|
|
331
334
|
}
|
|
332
335
|
};
|
|
333
336
|
|
|
334
|
-
const
|
|
337
|
+
const editTitle = editing?.what ?? t('entry.comment');
|
|
338
|
+
const bounds = entry ? { start: entry.startTime, end: entry.endTime } : null;
|
|
335
339
|
|
|
336
340
|
return html`
|
|
337
341
|
<${StatusHeader}
|
|
@@ -402,7 +406,9 @@ function App({ journal }) {
|
|
|
402
406
|
</button>`
|
|
403
407
|
)}
|
|
404
408
|
</div>
|
|
405
|
-
<div class="tab-panel" hidden=${tab !== 'note'}
|
|
409
|
+
<div class="tab-panel" hidden=${tab !== 'note'}>
|
|
410
|
+
<${NotePanel} busy=${busy} bounds=${bounds} onLog=${log} />
|
|
411
|
+
</div>
|
|
406
412
|
<div class="tab-panel" hidden=${tab !== 'sketch'}>
|
|
407
413
|
<${SketchPanel} busy=${busy} onLog=${log} night=${night} />
|
|
408
414
|
</div>
|
|
@@ -415,22 +421,25 @@ function App({ journal }) {
|
|
|
415
421
|
manoeuvreLabels=${manoeuvreLabels}
|
|
416
422
|
onDelete=${deleteEvent}
|
|
417
423
|
onDiscard=${discard}
|
|
418
|
-
onComment=${(event) =>
|
|
424
|
+
onComment=${(event) => setEditing({ event })}
|
|
419
425
|
/>
|
|
420
426
|
</main>
|
|
421
427
|
<${Toast}
|
|
422
428
|
toast=${toast}
|
|
429
|
+
timeoutMs=${TOAST_MS}
|
|
423
430
|
onClose=${() => setToast(null)}
|
|
424
431
|
onUndo=${undo}
|
|
425
|
-
onComment=${() =>
|
|
432
|
+
onComment=${() =>
|
|
433
|
+
setEditing({ ref: toast.ref, what: toast.what, event: journal.entryFor(toast.ref) })}
|
|
426
434
|
/>
|
|
427
435
|
${
|
|
428
|
-
|
|
429
|
-
html`<${
|
|
430
|
-
title=${
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
436
|
+
editing &&
|
|
437
|
+
html`<${EventDialog}
|
|
438
|
+
title=${editTitle}
|
|
439
|
+
event=${editing.event}
|
|
440
|
+
bounds=${bounds}
|
|
441
|
+
onCancel=${() => setEditing(null)}
|
|
442
|
+
onSave=${saveEvent}
|
|
434
443
|
/>`
|
|
435
444
|
}
|
|
436
445
|
${
|
|
@@ -355,9 +355,9 @@ export function PassageView({ id }) {
|
|
|
355
355
|
await request('DELETE', `/entries/${id}`);
|
|
356
356
|
location.hash = '#/';
|
|
357
357
|
});
|
|
358
|
-
const
|
|
358
|
+
const editEvent = (event, patch) =>
|
|
359
359
|
act(async () => {
|
|
360
|
-
await request('PATCH', `/events/${event.id}`,
|
|
360
|
+
await request('PATCH', `/events/${event.id}`, patch);
|
|
361
361
|
reload();
|
|
362
362
|
});
|
|
363
363
|
const deleteEvent = (event) =>
|
|
@@ -529,9 +529,10 @@ export function PassageView({ id }) {
|
|
|
529
529
|
events=${data.events}
|
|
530
530
|
observations=${data.observations}
|
|
531
531
|
landmarks=${data.landmarks}
|
|
532
|
+
bounds=${{ start: entry.startTime, end: entry.endTime }}
|
|
532
533
|
manoeuvreLabels=${data.manoeuvreLabels}
|
|
533
534
|
busy=${busy}
|
|
534
|
-
|
|
535
|
+
onEditEvent=${editEvent}
|
|
535
536
|
onDelete=${deleteEvent}
|
|
536
537
|
/>
|
|
537
538
|
</section>
|
|
@@ -2,7 +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 { buildRows, describeEvent } from '../log-lines.mjs';
|
|
5
|
+
import { EDITABLE_EVENT_TYPES, buildRows, describeEvent } from '../log-lines.mjs';
|
|
6
6
|
|
|
7
7
|
// A dot — the stroke of an i or a full stop — has a single point, which a
|
|
8
8
|
// polyline only draws when given twice.
|
|
@@ -64,24 +64,28 @@ export function EventRemark({ event, manoeuvreLabels }) {
|
|
|
64
64
|
return html`${label}${strokes}${detail}${comment}`;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// A logged event's remarks, with edit
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
67
|
+
// A logged event's remarks, with edit and delete controls. Editing is limited to
|
|
68
|
+
// the comment on anything the plugin logged itself (SPEC: "an edited comment");
|
|
69
|
+
// what the crew logged (`EDITABLE_EVENT_TYPES`) can also be redated, which makes
|
|
70
|
+
// the server re-read its position from the track. Deleting is offered for what
|
|
71
|
+
// the crew themselves logged (`source: "manual"`) and for an alarm (`sk_alarm`)
|
|
72
|
+
// — which takes its paired line, the raise or the clearing, with it
|
|
73
|
+
// (lib/events.js `pairedAlarmEvent`); other automatic lines (autopilot, weather,
|
|
74
|
+
// corrections) can only be annotated.
|
|
75
|
+
function EventLine({ event, bounds, manoeuvreLabels, busy, onEditEvent, onDelete }) {
|
|
76
|
+
const { t, format } = useLocale();
|
|
76
77
|
const [editing, setEditing] = useState(false);
|
|
77
78
|
const [draft, setDraft] = useState(event.comment ?? '');
|
|
79
|
+
const [when, setWhen] = useState(() => format.dateTimeInput(event.time));
|
|
78
80
|
const field = useRef(null);
|
|
81
|
+
const retimeable = EDITABLE_EVENT_TYPES.includes(event.type);
|
|
79
82
|
|
|
80
83
|
useEffect(() => {
|
|
81
84
|
if (!editing) {
|
|
82
85
|
setDraft(event.comment ?? '');
|
|
86
|
+
setWhen(format.dateTimeInput(event.time));
|
|
83
87
|
}
|
|
84
|
-
}, [event.comment, editing]);
|
|
88
|
+
}, [event.comment, event.time, editing]);
|
|
85
89
|
|
|
86
90
|
useEffect(() => {
|
|
87
91
|
if (editing) {
|
|
@@ -93,11 +97,31 @@ function EventLine({ event, manoeuvreLabels, busy, onEditComment, onDelete }) {
|
|
|
93
97
|
const submit = (submitEvent) => {
|
|
94
98
|
submitEvent.preventDefault();
|
|
95
99
|
const trimmed = draft.trim();
|
|
100
|
+
const patch = { comment: trimmed === '' ? null : trimmed };
|
|
101
|
+
const time = retimeable ? format.fromDateTimeInput(when) : null;
|
|
102
|
+
if (time !== null && time !== event.time) {
|
|
103
|
+
patch.time = time;
|
|
104
|
+
}
|
|
96
105
|
setEditing(false);
|
|
97
|
-
|
|
106
|
+
onEditEvent(event, patch);
|
|
98
107
|
};
|
|
99
108
|
return html`
|
|
100
109
|
<form class="timeline-comment-form" onSubmit=${submit}>
|
|
110
|
+
${
|
|
111
|
+
retimeable &&
|
|
112
|
+
html`<label class="timeline-time-field">
|
|
113
|
+
<span>${t('timeline.eventTime')}</span>
|
|
114
|
+
<input
|
|
115
|
+
type="datetime-local"
|
|
116
|
+
step="60"
|
|
117
|
+
value=${when}
|
|
118
|
+
min=${bounds?.start ? format.dateTimeInput(bounds.start) : undefined}
|
|
119
|
+
max=${format.dateTimeInput(bounds?.end ?? Date.now())}
|
|
120
|
+
onInput=${(inputEvent) => setWhen(inputEvent.currentTarget.value)}
|
|
121
|
+
/>
|
|
122
|
+
<small class="muted">${t('timeline.timeFromTrack')}</small>
|
|
123
|
+
</label>`
|
|
124
|
+
}
|
|
101
125
|
<textarea
|
|
102
126
|
ref=${field}
|
|
103
127
|
rows="2"
|
|
@@ -146,9 +170,10 @@ export function Timeline({
|
|
|
146
170
|
events,
|
|
147
171
|
observations,
|
|
148
172
|
landmarks,
|
|
173
|
+
bounds,
|
|
149
174
|
manoeuvreLabels,
|
|
150
175
|
busy,
|
|
151
|
-
|
|
176
|
+
onEditEvent,
|
|
152
177
|
onDelete
|
|
153
178
|
}) {
|
|
154
179
|
const { t, format } = useLocale();
|
|
@@ -229,9 +254,10 @@ export function Timeline({
|
|
|
229
254
|
row.event
|
|
230
255
|
? html`<${EventLine}
|
|
231
256
|
event=${row.event}
|
|
257
|
+
bounds=${bounds}
|
|
232
258
|
manoeuvreLabels=${manoeuvreLabels}
|
|
233
259
|
busy=${busy}
|
|
234
|
-
|
|
260
|
+
onEditEvent=${onEditEvent}
|
|
235
261
|
onDelete=${onDelete}
|
|
236
262
|
/>`
|
|
237
263
|
: html`<span class="muted">${t(`observation.${readings.reason}`)}</span>`
|
package/public/js/format.mjs
CHANGED
|
@@ -12,6 +12,11 @@ function toDegrees(radians) {
|
|
|
12
12
|
return (radians * 180) / Math.PI;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// `Date.parse` falls back to guessing at anything it is handed, so a
|
|
16
|
+
// `datetime-local` value is matched before being read. Seconds are optional:
|
|
17
|
+
// a field stepped finer than a minute supplies them.
|
|
18
|
+
const DATE_TIME_INPUT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/;
|
|
19
|
+
|
|
15
20
|
// `timeZone` is an IANA zone for dates and times; without it, the device's own.
|
|
16
21
|
export function createFormatter({ locale, units, timeZone }) {
|
|
17
22
|
const number = (digits) =>
|
|
@@ -53,11 +58,25 @@ export function createFormatter({ locale, units, timeZone }) {
|
|
|
53
58
|
day: '2-digit',
|
|
54
59
|
timeZone
|
|
55
60
|
});
|
|
61
|
+
// What `<input type="datetime-local">` reads and writes: `YYYY-MM-DDTHH:mm`.
|
|
62
|
+
const inputFormat = new Intl.DateTimeFormat('en-CA', {
|
|
63
|
+
year: 'numeric',
|
|
64
|
+
month: '2-digit',
|
|
65
|
+
day: '2-digit',
|
|
66
|
+
hour: '2-digit',
|
|
67
|
+
minute: '2-digit',
|
|
68
|
+
hourCycle: 'h23',
|
|
69
|
+
timeZone
|
|
70
|
+
});
|
|
56
71
|
const offsetFormat = new Intl.DateTimeFormat('en-US', {
|
|
57
72
|
timeZone,
|
|
58
73
|
timeZoneName: 'longOffset'
|
|
59
74
|
});
|
|
60
75
|
|
|
76
|
+
const localParts = (instant) => inputFormat.format(new Date(instant)).replace(', ', 'T');
|
|
77
|
+
// How far the formatter's zone runs ahead of UTC around `instant`.
|
|
78
|
+
const zoneOffsetMs = (instant) => Date.parse(`${localParts(instant)}:00Z`) - instant;
|
|
79
|
+
|
|
61
80
|
function sexagesimal(value, positive, negative, width) {
|
|
62
81
|
const absolute = Math.abs(value);
|
|
63
82
|
let degrees = Math.floor(absolute);
|
|
@@ -139,6 +158,21 @@ export function createFormatter({ locale, units, timeZone }) {
|
|
|
139
158
|
time: (value) => timeFormat.format(new Date(value)),
|
|
140
159
|
// The calendar day of an instant in the formatter's time zone.
|
|
141
160
|
dayKey: (value) => dayKeyFormat.format(new Date(value)),
|
|
161
|
+
// An instant as a `datetime-local` field reads it, in the formatter's zone.
|
|
162
|
+
dateTimeInput: (value) => localParts(Date.parse(new Date(value).toISOString())),
|
|
163
|
+
// And back: the instant that wall-clock reading stands for, or null when the
|
|
164
|
+
// field holds nothing usable. Two passes, so a reading on the far side of a
|
|
165
|
+
// daylight-saving change settles on its own offset rather than the one in
|
|
166
|
+
// force at the first guess; the hour a zone repeats when it falls back is
|
|
167
|
+
// genuinely ambiguous in such a field, and reads as the later of the two.
|
|
168
|
+
fromDateTimeInput: (local) => {
|
|
169
|
+
if (!DATE_TIME_INPUT.test(local ?? '')) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
const asUtc = Date.parse(`${local.slice(0, 16)}:00Z`);
|
|
173
|
+
const guess = asUtc - zoneOffsetMs(asUtc);
|
|
174
|
+
return new Date(asUtc - zoneOffsetMs(guess)).toISOString();
|
|
175
|
+
},
|
|
142
176
|
// "UTC+02:00" at that instant; daylight saving time changes it.
|
|
143
177
|
utcOffset: (value) => {
|
|
144
178
|
const zone = offsetFormat
|
package/public/js/i18n.mjs
CHANGED
|
@@ -21,6 +21,7 @@ export const MESSAGES = {
|
|
|
21
21
|
'common.cancel': 'Cancel',
|
|
22
22
|
'common.edit': 'Edit',
|
|
23
23
|
'common.delete': 'Delete',
|
|
24
|
+
'common.close': 'Close',
|
|
24
25
|
|
|
25
26
|
'error.forbidden':
|
|
26
27
|
'Your Signal K account is not allowed to do this. Sign in with an account that has the rights.',
|
|
@@ -282,6 +283,8 @@ export const MESSAGES = {
|
|
|
282
283
|
'timeline.apparent': 'app.',
|
|
283
284
|
'timeline.heading': 'hdg',
|
|
284
285
|
'timeline.comment': 'Comment',
|
|
286
|
+
'timeline.eventTime': 'Time',
|
|
287
|
+
'timeline.timeFromTrack': 'Changing the time reads the position back from the track.',
|
|
285
288
|
'timeline.deleteConfirm': 'Delete this log line? This cannot be undone.',
|
|
286
289
|
'timeline.deleteAlarmConfirm': 'Delete this alarm and its resolution? This cannot be undone.',
|
|
287
290
|
|
|
@@ -458,10 +461,13 @@ export const MESSAGES = {
|
|
|
458
461
|
'entry.logged': 'Logged at {time}',
|
|
459
462
|
'entry.loggedQueued': 'Kept on this tablet, sent once connected',
|
|
460
463
|
'entry.openedPassage': 'Passage opened',
|
|
461
|
-
'entry.undo': 'Undo',
|
|
464
|
+
'entry.undo': 'Undo this entry',
|
|
462
465
|
'entry.undone': 'Undone',
|
|
463
466
|
'entry.addComment': 'Add a comment',
|
|
464
467
|
'entry.comment': 'Comment',
|
|
468
|
+
'entry.eventTime': 'Time',
|
|
469
|
+
'entry.timeFromTrack': 'Changing the time reads the position back from the track.',
|
|
470
|
+
'entry.noteTime': 'Time (now if left empty)',
|
|
465
471
|
'entry.noPassageError': 'No passage to log this in: cast off or weigh anchor first.',
|
|
466
472
|
'entry.refused': 'Not logged: {message}',
|
|
467
473
|
'entry.chooseSail': 'Which sail is up?',
|
|
@@ -531,6 +537,7 @@ export const MESSAGES = {
|
|
|
531
537
|
'common.cancel': 'Annuler',
|
|
532
538
|
'common.edit': 'Modifier',
|
|
533
539
|
'common.delete': 'Supprimer',
|
|
540
|
+
'common.close': 'Fermer',
|
|
534
541
|
|
|
535
542
|
'error.forbidden':
|
|
536
543
|
'Votre compte Signal K n’a pas les droits pour cette action. Connectez-vous avec un compte autorisé.',
|
|
@@ -796,6 +803,8 @@ export const MESSAGES = {
|
|
|
796
803
|
'timeline.apparent': 'app.',
|
|
797
804
|
'timeline.heading': 'cap',
|
|
798
805
|
'timeline.comment': 'Commentaire',
|
|
806
|
+
'timeline.eventTime': 'Heure',
|
|
807
|
+
'timeline.timeFromTrack': 'Changer l’heure relit la position dans la trace.',
|
|
799
808
|
'timeline.deleteConfirm': 'Supprimer cette ligne du journal ? C’est irréversible.',
|
|
800
809
|
'timeline.deleteAlarmConfirm': 'Supprimer cette alarme et sa résolution ? C’est irréversible.',
|
|
801
810
|
|
|
@@ -977,10 +986,13 @@ export const MESSAGES = {
|
|
|
977
986
|
'entry.logged': 'Consigné à {time}',
|
|
978
987
|
'entry.loggedQueued': 'Gardé sur la tablette, envoyé au retour de la connexion',
|
|
979
988
|
'entry.openedPassage': 'Navigation ouverte',
|
|
980
|
-
'entry.undo': 'Annuler',
|
|
989
|
+
'entry.undo': 'Annuler l’entrée',
|
|
981
990
|
'entry.undone': 'Annulé',
|
|
982
991
|
'entry.addComment': 'Ajouter un commentaire',
|
|
983
992
|
'entry.comment': 'Commentaire',
|
|
993
|
+
'entry.eventTime': 'Heure',
|
|
994
|
+
'entry.timeFromTrack': 'Changer l’heure relit la position dans la trace.',
|
|
995
|
+
'entry.noteTime': 'Heure (maintenant si vide)',
|
|
984
996
|
'entry.noPassageError':
|
|
985
997
|
'Aucune navigation où consigner : appareillez ou levez l’ancre d’abord.',
|
|
986
998
|
'entry.refused': 'Non consigné : {message}',
|
package/public/js/log-lines.mjs
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
// reading goes on which line, and what each event says. Pure — the server
|
|
3
3
|
// imports it too — so strings only, no markup.
|
|
4
4
|
|
|
5
|
+
// What a client may edit beyond the comment, mirroring `CLIENT_EVENT_TYPES` in
|
|
6
|
+
// lib/events.js: an event the crew logged themselves. Narrower than
|
|
7
|
+
// `source: "manual"`, which also covers a `manual_correction`.
|
|
8
|
+
export const EDITABLE_EVENT_TYPES = ['manoeuvre', 'text_annotation', 'handwritten_annotation'];
|
|
9
|
+
|
|
5
10
|
function autopilotTarget(target, format) {
|
|
6
11
|
if (typeof target === 'number') {
|
|
7
12
|
return format.bearing(target);
|