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 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.7.0...HEAD
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 annotated.
218
- Under each position, in grey, its bearing and distance from the nearest landmark.
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 for 10 seconds, with two big buttons:
359
- - **Undo**, for a mistaken tap;
360
- - **Add a comment**, e.g. "25 kn, second reef".
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
- function positionAt(db, time, { now, vesselPosition }) {
142
- if (time === undefined) {
143
- return vesselPosition;
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
- 'SELECT time, lat, lon FROM track_points WHERE time <= ? ORDER BY time DESC LIMIT 1'
150
+ `SELECT time, lat, lon FROM track_points WHERE time <= ?${scope} ORDER BY time DESC LIMIT 1`
149
151
  ),
150
- db.prepare('SELECT time, lat, lon FROM track_points WHERE time >= ? ORDER BY time LIMIT 1')
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(time))
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
- if (nearest) {
160
- return { lat: nearest.lat, lon: nearest.lon };
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
- return Math.abs(Date.parse(now) - target) <= CURRENT_POSITION_WINDOW_MS ? vesselPosition : null;
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
- function updateEvent(db, id, patch) {
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.prepare('UPDATE events SET time = ?, subtype = ?, comment = ?, payload = ? WHERE id = ?').run(
276
- next.time,
277
- next.subtype,
278
- next.comment,
279
- serializePayload(next.payload),
280
- id
281
- );
282
-
283
- return toEvent(requireEventRow(db, id));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-chiplog",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "description": "Nautical logbook for Signal K: automatic entries, handwritten notes",
5
5
  "main": "index.js",
6
6
  "files": [
package/public/app.css CHANGED
@@ -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 {
@@ -631,7 +631,10 @@ input {
631
631
  align-items: center;
632
632
  gap: 0.75rem;
633
633
  width: min(46rem, calc(100% - 2rem));
634
- padding: 0.75rem 1rem;
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
- export function CommentDialog({ title, initial = '', onSave, onCancel }) {
5
- const { t } = useLocale();
6
- const [text, setText] = useState(initial ?? '');
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 = (event) => {
11
- event.preventDefault();
12
- onSave(text.trim() === '' ? null : text.trim());
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=${(event) => event.target === event.currentTarget && onCancel()}>
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=${(event) => setText(event.currentTarget.value)}
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, with the two things one wants right after: undo a
37
- // mistaken tap, or add a word about it.
38
- export function Toast({ toast, onUndo, onComment, onClose }) {
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
- <button type="button" class="tool-button" onClick=${onClose}>×</button>
88
+ ${close}
48
89
  </div>
49
90
  `;
50
91
  }
51
92
  if (toast.kind === 'undone') {
52
- return html`<div class="toast" role="status"><span class="toast-text">${t('entry.undone')}</span></div>`;
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 class=${toast.queued ? 'toast toast-queued' : 'toast'} role="status">
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
- <button type="button" class="big-button" onClick=${onComment}>${t('entry.addComment')}</button>
61
- <button type="button" class="big-button" onClick=${onUndo}>${t('entry.undo')}</button>
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
- export function NotePanel({ busy, onLog }) {
5
- const { t } = useLocale();
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
- if (await onLog({ type: 'text_annotation', comment }, t('entry.note'))) {
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
- CLIENT_TYPES.has(event.type) &&
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 comments
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: { ...entry, time: madeAt },
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
- async comment(ref, comment) {
128
- if (outbox.update(ref, { comment })) {
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, comment });
136
- return sendOrQueue('PATCH', `/events/${event.id}`, { comment });
152
+ delivered.set(ref, { ...event, ...patch });
153
+ return sendOrQueue('PATCH', `/events/${event.id}`, patch);
137
154
  }
138
155
  };
139
156
  }
@@ -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 { CommentDialog, Toast } from './components/Dialogs.mjs';
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 [commenting, setCommenting] = useState(null);
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 saveComment = async (comment) => {
252
- const target = commenting;
253
- setCommenting(null);
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.comment(target.ref, comment));
263
+ noteFailure(await journal.edit(target.ref, patch));
261
264
  } else {
262
- await request('PATCH', `/events/${target.event.id}`, { comment });
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 commentTitle = commenting?.what ?? t('entry.comment');
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'}><${NotePanel} busy=${busy} onLog=${log} /></div>
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) => setCommenting({ 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=${() => setCommenting({ ref: toast.ref, what: toast.what })}
432
+ onComment=${() =>
433
+ setEditing({ ref: toast.ref, what: toast.what, event: journal.entryFor(toast.ref) })}
426
434
  />
427
435
  ${
428
- commenting &&
429
- html`<${CommentDialog}
430
- title=${commentTitle}
431
- initial=${commenting.event?.comment ?? null}
432
- onCancel=${() => setCommenting(null)}
433
- onSave=${saveComment}
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 editComment = (event, comment) =>
358
+ const editEvent = (event, patch) =>
359
359
  act(async () => {
360
- await request('PATCH', `/events/${event.id}`, { comment });
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
- onEditComment=${editComment}
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-comment and delete controls. Editing is
68
- // limited to the comment on anything the plugin logged itself (SPEC: "an
69
- // edited comment"). Deleting is offered for what the crew themselves logged
70
- // (`source: "manual"`) and for an alarm (`sk_alarm`) — which takes its paired
71
- // line, the raise or the clearing, with it (lib/events.js `pairedAlarmEvent`);
72
- // other automatic lines (autopilot, weather, corrections) can only be
73
- // annotated.
74
- function EventLine({ event, manoeuvreLabels, busy, onEditComment, onDelete }) {
75
- const { t } = useLocale();
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
- onEditComment(event, trimmed === '' ? null : trimmed);
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
- onEditComment,
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
- onEditComment=${onEditComment}
260
+ onEditEvent=${onEditEvent}
235
261
  onDelete=${onDelete}
236
262
  />`
237
263
  : html`<span class="muted">${t(`observation.${readings.reason}`)}</span>`
@@ -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
@@ -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}',
@@ -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);