signalk-chiplog 1.0.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +21 -0
  3. package/README.md +290 -0
  4. package/index.js +327 -0
  5. package/lib/api.js +427 -0
  6. package/lib/database.js +264 -0
  7. package/lib/detection.js +407 -0
  8. package/lib/entries.js +345 -0
  9. package/lib/errors.js +22 -0
  10. package/lib/event-watcher.js +332 -0
  11. package/lib/events.js +287 -0
  12. package/lib/export.js +220 -0
  13. package/lib/formats.js +215 -0
  14. package/lib/manoeuvre-types.js +84 -0
  15. package/lib/observation-recorder.js +152 -0
  16. package/lib/place-names.js +208 -0
  17. package/lib/places.js +128 -0
  18. package/lib/propulsion-detector.js +156 -0
  19. package/lib/propulsion.js +75 -0
  20. package/lib/rows.js +15 -0
  21. package/lib/track-recorder.js +149 -0
  22. package/lib/track.js +58 -0
  23. package/lib/usb-scheduler.js +131 -0
  24. package/lib/validation.js +105 -0
  25. package/package.json +67 -0
  26. package/public/app.css +603 -0
  27. package/public/entry/entry.css +528 -0
  28. package/public/entry/icons/apple-touch-icon.png +0 -0
  29. package/public/entry/icons/icon-192.png +0 -0
  30. package/public/entry/icons/icon-512.png +0 -0
  31. package/public/entry/index.html +22 -0
  32. package/public/entry/js/access.mjs +116 -0
  33. package/public/entry/js/clock.mjs +29 -0
  34. package/public/entry/js/components/AccessGate.mjs +70 -0
  35. package/public/entry/js/components/Dialogs.mjs +64 -0
  36. package/public/entry/js/components/ManoeuvrePad.mjs +106 -0
  37. package/public/entry/js/components/NotePanel.mjs +34 -0
  38. package/public/entry/js/components/RecentList.mjs +113 -0
  39. package/public/entry/js/components/SketchPanel.mjs +181 -0
  40. package/public/entry/js/components/StatusHeader.mjs +69 -0
  41. package/public/entry/js/journal.mjs +139 -0
  42. package/public/entry/js/main.mjs +360 -0
  43. package/public/entry/js/outbox.mjs +124 -0
  44. package/public/entry/js/strokes.mjs +81 -0
  45. package/public/entry/manifest.webmanifest +26 -0
  46. package/public/entry/sw.js +75 -0
  47. package/public/icon.svg +7 -0
  48. package/public/index.html +18 -0
  49. package/public/js/api.mjs +85 -0
  50. package/public/js/auth.mjs +41 -0
  51. package/public/js/components/ExportView.mjs +182 -0
  52. package/public/js/components/LogView.mjs +132 -0
  53. package/public/js/components/PassageView.mjs +343 -0
  54. package/public/js/components/PropulsionStrip.mjs +60 -0
  55. package/public/js/components/StatusBar.mjs +55 -0
  56. package/public/js/components/Timeline.mjs +226 -0
  57. package/public/js/components/TrackMap.mjs +85 -0
  58. package/public/js/components/common.mjs +70 -0
  59. package/public/js/context.mjs +56 -0
  60. package/public/js/days.mjs +56 -0
  61. package/public/js/format.mjs +84 -0
  62. package/public/js/i18n.mjs +480 -0
  63. package/public/js/ids.mjs +10 -0
  64. package/public/js/main.mjs +48 -0
  65. package/public/js/status.mjs +21 -0
  66. package/public/vendor/htm-LICENSE +202 -0
  67. package/public/vendor/leaflet/LICENSE +26 -0
  68. package/public/vendor/leaflet/images/layers-2x.png +0 -0
  69. package/public/vendor/leaflet/images/layers.png +0 -0
  70. package/public/vendor/leaflet/images/marker-icon-2x.png +0 -0
  71. package/public/vendor/leaflet/images/marker-icon.png +0 -0
  72. package/public/vendor/leaflet/images/marker-shadow.png +0 -0
  73. package/public/vendor/leaflet/leaflet.css +661 -0
  74. package/public/vendor/leaflet/leaflet.js +6 -0
  75. package/public/vendor/preact-LICENSE +21 -0
  76. package/public/vendor/preact-htm.mjs +1 -0
@@ -0,0 +1,528 @@
1
+ /* Entry app for a tablet at the helm: big targets for gloves and wet fingers,
2
+ strong contrast in sunlight, and a red night mode that spares night vision. */
3
+
4
+ :root {
5
+ color-scheme: light dark;
6
+ --bg: #eef2f6;
7
+ --surface: #ffffff;
8
+ --text: #0f1b26;
9
+ --muted: #4f6072;
10
+ --border: #c9d3dd;
11
+ --accent: #0b5cad;
12
+ --accent-contrast: #ffffff;
13
+ --danger: #b42318;
14
+ --ok: #1f7a36;
15
+ --warn: #9a6700;
16
+ --queued-bg: #fff4d6;
17
+ --error-bg: #fde8e8;
18
+ --ink: #0f1b26;
19
+ --paper: #fbfaf5;
20
+ --radius: 14px;
21
+ --target: 4.5rem;
22
+ font-family:
23
+ system-ui,
24
+ -apple-system,
25
+ 'Segoe UI',
26
+ Roboto,
27
+ sans-serif;
28
+ font-size: 18px;
29
+ line-height: 1.35;
30
+ }
31
+
32
+ @media (prefers-color-scheme: dark) {
33
+ :root {
34
+ --bg: #0b1218;
35
+ --surface: #152029;
36
+ --text: #e6edf3;
37
+ --muted: #9fb0c0;
38
+ --border: #2c3b49;
39
+ --accent: #5aa9ff;
40
+ --accent-contrast: #081018;
41
+ --danger: #ff7b72;
42
+ --ok: #56d364;
43
+ --warn: #e3b341;
44
+ --queued-bg: #3a2e10;
45
+ --error-bg: #3b1a1a;
46
+ --ink: #e6edf3;
47
+ --paper: #0f171e;
48
+ }
49
+ }
50
+
51
+ :root[data-theme='night'] {
52
+ color-scheme: dark;
53
+ --bg: #000000;
54
+ --surface: #0d0000;
55
+ --text: #ff4d4d;
56
+ --muted: #b33636;
57
+ --border: #4d0f0f;
58
+ --accent: #ff3b3b;
59
+ --accent-contrast: #000000;
60
+ --danger: #ff6b6b;
61
+ --ok: #ff4d4d;
62
+ --warn: #ff4d4d;
63
+ --queued-bg: #260000;
64
+ --error-bg: #330000;
65
+ --ink: #ff4d4d;
66
+ --paper: #050000;
67
+ }
68
+
69
+ * {
70
+ box-sizing: border-box;
71
+ }
72
+
73
+ html,
74
+ body {
75
+ margin: 0;
76
+ background: var(--bg);
77
+ color: var(--text);
78
+ -webkit-tap-highlight-color: transparent;
79
+ }
80
+
81
+ body {
82
+ padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom)
83
+ env(safe-area-inset-left);
84
+ }
85
+
86
+ button,
87
+ input,
88
+ textarea {
89
+ font: inherit;
90
+ color: inherit;
91
+ }
92
+
93
+ a {
94
+ color: var(--accent);
95
+ }
96
+
97
+ [hidden] {
98
+ display: none !important;
99
+ }
100
+
101
+ h2 {
102
+ margin: 0;
103
+ }
104
+
105
+ .muted,
106
+ .hint {
107
+ color: var(--muted);
108
+ }
109
+
110
+ .hint {
111
+ margin: 0 0 0.75rem;
112
+ }
113
+
114
+ /* Header */
115
+
116
+ .entry-header {
117
+ position: sticky;
118
+ top: 0;
119
+ z-index: 5;
120
+ display: flex;
121
+ flex-wrap: wrap;
122
+ align-items: center;
123
+ justify-content: space-between;
124
+ gap: 0.5rem 1rem;
125
+ padding: 0.6rem 1rem;
126
+ background: var(--surface);
127
+ border-bottom: 1px solid var(--border);
128
+ }
129
+
130
+ .entry-status {
131
+ display: flex;
132
+ flex-wrap: wrap;
133
+ align-items: baseline;
134
+ gap: 0.25rem 0.9rem;
135
+ min-width: 0;
136
+ }
137
+
138
+ .entry-motion {
139
+ font-size: 1.35rem;
140
+ }
141
+
142
+ .motion-underway {
143
+ color: var(--ok);
144
+ }
145
+
146
+ .entry-passage {
147
+ color: var(--muted);
148
+ }
149
+
150
+ .entry-fallback {
151
+ color: var(--warn);
152
+ font-size: 1.2rem;
153
+ }
154
+
155
+ .entry-tools {
156
+ display: flex;
157
+ flex-wrap: wrap;
158
+ align-items: center;
159
+ gap: 0.5rem;
160
+ }
161
+
162
+ .connection {
163
+ display: inline-flex;
164
+ align-items: center;
165
+ gap: 0.4rem;
166
+ font-weight: 600;
167
+ }
168
+
169
+ .connection-dot {
170
+ width: 0.8rem;
171
+ height: 0.8rem;
172
+ border-radius: 50%;
173
+ background: var(--ok);
174
+ }
175
+
176
+ .connection-offline {
177
+ color: var(--warn);
178
+ }
179
+
180
+ .connection-offline .connection-dot {
181
+ background: var(--warn);
182
+ }
183
+
184
+ /* Controls */
185
+
186
+ .tool-button,
187
+ .big-button,
188
+ .tab {
189
+ display: inline-flex;
190
+ align-items: center;
191
+ justify-content: center;
192
+ border: 2px solid var(--border);
193
+ border-radius: var(--radius);
194
+ background: var(--surface);
195
+ color: var(--text);
196
+ text-decoration: none;
197
+ cursor: pointer;
198
+ touch-action: manipulation;
199
+ user-select: none;
200
+ -webkit-user-select: none;
201
+ }
202
+
203
+ .tool-button {
204
+ min-height: 3rem;
205
+ padding: 0.3rem 0.9rem;
206
+ font-weight: 600;
207
+ }
208
+
209
+ .tool-button[aria-pressed='true'] {
210
+ border-color: var(--accent);
211
+ color: var(--accent);
212
+ }
213
+
214
+ .big-button {
215
+ min-height: var(--target);
216
+ padding: 0.5rem 1rem;
217
+ font-size: 1.15rem;
218
+ font-weight: 700;
219
+ text-align: center;
220
+ }
221
+
222
+ .big-button:active,
223
+ .tool-button:active,
224
+ .tab:active {
225
+ transform: scale(0.97);
226
+ }
227
+
228
+ .primary {
229
+ background: var(--accent);
230
+ border-color: var(--accent);
231
+ color: var(--accent-contrast);
232
+ }
233
+
234
+ /* A lit red block would dazzle at night: primary actions are outlined instead. */
235
+ :root[data-theme='night'] .primary {
236
+ background: #330000;
237
+ color: var(--text);
238
+ }
239
+
240
+ .danger {
241
+ color: var(--danger);
242
+ }
243
+
244
+ button:disabled {
245
+ opacity: 0.45;
246
+ cursor: default;
247
+ transform: none;
248
+ }
249
+
250
+ button:focus-visible,
251
+ a:focus-visible,
252
+ input:focus-visible,
253
+ textarea:focus-visible {
254
+ outline: 3px solid var(--accent);
255
+ outline-offset: 2px;
256
+ }
257
+
258
+ .link-button {
259
+ border: none;
260
+ background: none;
261
+ color: var(--accent);
262
+ min-height: 3rem;
263
+ padding: 0 0.5rem;
264
+ cursor: pointer;
265
+ }
266
+
267
+ /* Layout */
268
+
269
+ .entry-main {
270
+ display: grid;
271
+ gap: 1rem;
272
+ padding: 1rem 1rem 9rem;
273
+ max-width: 1400px;
274
+ margin: 0 auto;
275
+ }
276
+
277
+ .entry-columns {
278
+ display: grid;
279
+ gap: 1rem;
280
+ align-items: start;
281
+ }
282
+
283
+ @media (min-width: 900px) {
284
+ .entry-columns {
285
+ grid-template-columns: 1.2fr 1fr;
286
+ }
287
+ }
288
+
289
+ .panel {
290
+ background: var(--surface);
291
+ border: 1px solid var(--border);
292
+ border-radius: var(--radius);
293
+ padding: 1rem;
294
+ }
295
+
296
+ .panel-title {
297
+ font-size: 1rem;
298
+ text-transform: uppercase;
299
+ letter-spacing: 0.06em;
300
+ color: var(--muted);
301
+ margin-bottom: 0.75rem;
302
+ }
303
+
304
+ /* Manoeuvres */
305
+
306
+ .pad-grid {
307
+ display: grid;
308
+ grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
309
+ gap: 0.75rem;
310
+ }
311
+
312
+ .pad-grid .big-button {
313
+ min-height: 5.5rem;
314
+ }
315
+
316
+ /* Note and handwriting */
317
+
318
+ .tabs {
319
+ display: flex;
320
+ gap: 0.5rem;
321
+ margin-bottom: 0.75rem;
322
+ }
323
+
324
+ .tab {
325
+ flex: 1;
326
+ min-height: 3.5rem;
327
+ font-weight: 700;
328
+ }
329
+
330
+ .tab[aria-selected='true'] {
331
+ border-color: var(--accent);
332
+ color: var(--accent);
333
+ box-shadow: inset 0 -4px 0 var(--accent);
334
+ }
335
+
336
+ .note-form {
337
+ display: grid;
338
+ gap: 0.75rem;
339
+ }
340
+
341
+ textarea,
342
+ input {
343
+ width: 100%;
344
+ border: 2px solid var(--border);
345
+ border-radius: var(--radius);
346
+ background: var(--paper);
347
+ padding: 0.75rem;
348
+ }
349
+
350
+ textarea {
351
+ resize: vertical;
352
+ min-height: 9rem;
353
+ }
354
+
355
+ input {
356
+ min-height: 3.5rem;
357
+ }
358
+
359
+ .sketch {
360
+ display: grid;
361
+ gap: 0.75rem;
362
+ }
363
+
364
+ .sketch-surface {
365
+ position: relative;
366
+ }
367
+
368
+ .sketch-canvas {
369
+ display: block;
370
+ width: 100%;
371
+ height: clamp(16rem, 42vh, 30rem);
372
+ border: 2px solid var(--border);
373
+ border-radius: var(--radius);
374
+ background: var(--paper);
375
+ color: var(--ink);
376
+ touch-action: none;
377
+ cursor: crosshair;
378
+ }
379
+
380
+ .sketch-hint {
381
+ position: absolute;
382
+ inset: 0;
383
+ display: grid;
384
+ place-items: center;
385
+ color: var(--muted);
386
+ pointer-events: none;
387
+ }
388
+
389
+ .sketch-actions {
390
+ display: flex;
391
+ flex-wrap: wrap;
392
+ gap: 0.5rem;
393
+ }
394
+
395
+ .sketch-actions .big-button {
396
+ flex: 1;
397
+ }
398
+
399
+ /* Recent entries */
400
+
401
+ .recent-list {
402
+ list-style: none;
403
+ margin: 0;
404
+ padding: 0;
405
+ }
406
+
407
+ .recent-item {
408
+ display: grid;
409
+ grid-template-columns: 4rem 1fr auto;
410
+ gap: 0.75rem;
411
+ align-items: center;
412
+ padding: 0.6rem 0;
413
+ border-top: 1px solid var(--border);
414
+ }
415
+
416
+ .recent-time {
417
+ font-variant-numeric: tabular-nums;
418
+ font-weight: 700;
419
+ }
420
+
421
+ .recent-what {
422
+ min-width: 0;
423
+ overflow-wrap: anywhere;
424
+ }
425
+
426
+ .recent-what .strokes {
427
+ display: block;
428
+ max-width: 14rem;
429
+ max-height: 6rem;
430
+ }
431
+
432
+ .recent-actions {
433
+ display: flex;
434
+ gap: 0.4rem;
435
+ }
436
+
437
+ .recent-pending {
438
+ background: var(--queued-bg);
439
+ }
440
+
441
+ .recent-failed {
442
+ background: var(--error-bg);
443
+ }
444
+
445
+ .recent-pending,
446
+ .recent-failed {
447
+ padding-inline: 0.5rem;
448
+ border-radius: 8px;
449
+ }
450
+
451
+ .alarm {
452
+ color: var(--danger);
453
+ }
454
+
455
+ /* Toast and sheets */
456
+
457
+ .toast {
458
+ position: fixed;
459
+ left: 50%;
460
+ bottom: calc(1rem + env(safe-area-inset-bottom));
461
+ transform: translateX(-50%);
462
+ z-index: 10;
463
+ display: flex;
464
+ flex-wrap: wrap;
465
+ align-items: center;
466
+ gap: 0.75rem;
467
+ width: min(46rem, calc(100% - 2rem));
468
+ padding: 0.75rem 1rem;
469
+ background: var(--surface);
470
+ border: 2px solid var(--accent);
471
+ border-radius: var(--radius);
472
+ box-shadow: 0 8px 30px rgb(0 0 0 / 30%);
473
+ }
474
+
475
+ .toast-text {
476
+ flex: 1 1 12rem;
477
+ }
478
+
479
+ .toast-queued {
480
+ border-color: var(--warn);
481
+ background: var(--queued-bg);
482
+ }
483
+
484
+ .toast-error {
485
+ border-color: var(--danger);
486
+ background: var(--error-bg);
487
+ }
488
+
489
+ .sheet-backdrop {
490
+ position: fixed;
491
+ inset: 0;
492
+ z-index: 20;
493
+ display: grid;
494
+ place-items: center;
495
+ padding: 1rem;
496
+ background: rgb(0 0 0 / 55%);
497
+ }
498
+
499
+ .sheet {
500
+ display: grid;
501
+ gap: 1rem;
502
+ width: min(40rem, 100%);
503
+ max-height: 100%;
504
+ overflow: auto;
505
+ padding: 1.25rem;
506
+ background: var(--surface);
507
+ border-radius: var(--radius);
508
+ }
509
+
510
+ .sail-grid {
511
+ display: grid;
512
+ grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
513
+ gap: 0.75rem;
514
+ }
515
+
516
+ .sheet-row {
517
+ display: flex;
518
+ flex-wrap: wrap;
519
+ gap: 0.75rem;
520
+ }
521
+
522
+ .sheet-row > * {
523
+ flex: 1 1 10rem;
524
+ }
525
+
526
+ .access p {
527
+ margin: 0 0 1rem;
528
+ }
Binary file
Binary file
@@ -0,0 +1,22 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <meta name="color-scheme" content="light dark" />
7
+ <meta name="theme-color" content="#0b4f8a" />
8
+ <meta name="apple-mobile-web-app-capable" content="yes" />
9
+ <meta name="mobile-web-app-capable" content="yes" />
10
+ <meta name="apple-mobile-web-app-title" content="Chiplog" />
11
+ <title>Chiplog</title>
12
+ <link rel="manifest" href="manifest.webmanifest" />
13
+ <link rel="icon" href="../icon.svg" type="image/svg+xml" />
14
+ <link rel="apple-touch-icon" href="icons/apple-touch-icon.png" />
15
+ <link rel="stylesheet" href="entry.css" />
16
+ <script type="module" src="js/main.mjs"></script>
17
+ </head>
18
+ <body>
19
+ <div id="app"></div>
20
+ <noscript>Chiplog needs JavaScript. / Chiplog nécessite JavaScript.</noscript>
21
+ </body>
22
+ </html>
@@ -0,0 +1,116 @@
1
+ // Signal K's device access requests: the tablet asks once, an administrator
2
+ // approves it in the Signal K admin (Security → Access Requests), and the
3
+ // server hands back a token the tablet keeps. See the server's
4
+ // /signalk/v1/access/requests.
5
+
6
+ import { randomId } from '../../js/ids.mjs';
7
+
8
+ const REQUEST_KEY = 'chiplog.accessRequest';
9
+ const CLIENT_ID_KEY = 'chiplog.clientId';
10
+
11
+ function read(storage, key) {
12
+ try {
13
+ return storage?.getItem(key) ?? null;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ function write(storage, key, value) {
20
+ try {
21
+ if (value === null) {
22
+ storage?.removeItem(key);
23
+ } else {
24
+ storage?.setItem(key, value);
25
+ }
26
+ } catch {
27
+ // Kept for this page only.
28
+ }
29
+ }
30
+
31
+ // States: idle, pending, approved, denied, expired, disabled (the server does
32
+ // not accept device requests), unavailable (security is off), error.
33
+ export function createAccessRequester({ fetch, storage, tokenStore, description }) {
34
+ // One identity per tablet, so asking again replaces its entry in the server's
35
+ // device list instead of adding another.
36
+ function clientId() {
37
+ let id = read(storage, CLIENT_ID_KEY);
38
+ if (!id) {
39
+ id = randomId();
40
+ write(storage, CLIENT_ID_KEY, id);
41
+ }
42
+ return id;
43
+ }
44
+
45
+ async function readJson(response) {
46
+ return response.json().catch(() => null);
47
+ }
48
+
49
+ return {
50
+ hasPendingRequest() {
51
+ return read(storage, REQUEST_KEY) !== null;
52
+ },
53
+
54
+ async request() {
55
+ let response;
56
+ try {
57
+ response = await fetch('/signalk/v1/access/requests', {
58
+ method: 'POST',
59
+ headers: { 'content-type': 'application/json' },
60
+ body: JSON.stringify({ clientId: clientId(), description, permissions: 'readwrite' })
61
+ });
62
+ } catch {
63
+ return { state: 'error' };
64
+ }
65
+ if (response.status === 404) {
66
+ return { state: 'unavailable' };
67
+ }
68
+ const reply = await readJson(response);
69
+ if (response.status === 403 || reply?.statusCode === 403) {
70
+ return { state: 'disabled' };
71
+ }
72
+ if (reply?.href && (reply.state === 'PENDING' || response.status === 202)) {
73
+ write(storage, REQUEST_KEY, reply.href);
74
+ return { state: 'pending' };
75
+ }
76
+ // A request from this tablet already waits: keep polling the one we know.
77
+ if (response.status === 400 && this.hasPendingRequest()) {
78
+ return { state: 'pending' };
79
+ }
80
+ return { state: 'error', message: reply?.message };
81
+ },
82
+
83
+ async poll() {
84
+ const href = read(storage, REQUEST_KEY);
85
+ if (!href) {
86
+ return { state: 'idle' };
87
+ }
88
+ let response;
89
+ try {
90
+ response = await fetch(href);
91
+ } catch {
92
+ return { state: 'pending' };
93
+ }
94
+ const reply = response.ok ? await readJson(response) : null;
95
+ if (!reply) {
96
+ // The server forgets requests after an hour and on restart.
97
+ write(storage, REQUEST_KEY, null);
98
+ return { state: 'expired' };
99
+ }
100
+ if (reply.state !== 'COMPLETED') {
101
+ return { state: 'pending' };
102
+ }
103
+ write(storage, REQUEST_KEY, null);
104
+ const { permission, token } = reply.accessRequest ?? {};
105
+ if (permission === 'APPROVED' && token) {
106
+ tokenStore.set(token);
107
+ return { state: 'approved' };
108
+ }
109
+ return { state: permission === 'DENIED' ? 'denied' : 'error' };
110
+ },
111
+
112
+ cancel() {
113
+ write(storage, REQUEST_KEY, null);
114
+ }
115
+ };
116
+ }
@@ -0,0 +1,29 @@
1
+ // A tablet's clock may be far off. Entries queued offline carry the time they
2
+ // were made, so it is corrected by the server's clock, read from the Date
3
+ // header of its responses. That header has one-second resolution: offsets
4
+ // under two seconds are noise and ignored.
5
+
6
+ const NEGLIGIBLE_OFFSET_MS = 2000;
7
+
8
+ export function createServerClock({ now = Date.now } = {}) {
9
+ let offset = 0;
10
+
11
+ return {
12
+ observe(dateHeader, sentAt, receivedAt) {
13
+ const server = Date.parse(dateHeader ?? '');
14
+ if (Number.isNaN(server)) {
15
+ return;
16
+ }
17
+ // The header is truncated to the second: its midpoint is the best guess.
18
+ offset = server + 500 - (sentAt + receivedAt) / 2;
19
+ },
20
+
21
+ offset() {
22
+ return Math.abs(offset) < NEGLIGIBLE_OFFSET_MS ? 0 : offset;
23
+ },
24
+
25
+ now() {
26
+ return now() + this.offset();
27
+ }
28
+ };
29
+ }