signalk-race-control 0.1.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/public/app.js ADDED
@@ -0,0 +1,1540 @@
1
+ (function () {
2
+ const API = '/plugins/race-control';
3
+ const SK_API = '/signalk/v1/api';
4
+
5
+ let races = []; // race summaries from GET /races
6
+ let activeRaceId = null;
7
+ let raceState = null; // full race object for activeRaceId, or null
8
+ let vesselSuggestions = []; // [{name, mmsi}] from AIS/self, for the add-boat autocomplete
9
+ let boatRegistry = []; // [{name, mmsi}] cross-race memory persisted server-side
10
+ let nameSuggestionPool = []; // [{name, mmsi}] combined VET-register + registry + vessel names
11
+ let suggestionItems = []; // currently-shown filtered suggestions
12
+ let suggestionActiveIndex = -1;
13
+ let handicapBoats = [];
14
+ let handicapVersion = 0; // bumped each successful VET-register load
15
+ let vetEnabled = false; // plugin config setting (Server -> Plugin Config), read-only here; off by default
16
+ let startLineRefs = null; // [pointRefs, pointRefs] or null
17
+ let finishLineRefs = null;
18
+ let markRefs = []; // [pointRefs, ...]
19
+ let lastCourseFormRaceId = undefined; // tracks which race the course form reflects
20
+ let replayLive = true;
21
+ let replayTime = Date.now();
22
+
23
+ const raceSelect = document.getElementById('raceSelect');
24
+ const newRaceBtn = document.getElementById('newRaceBtn');
25
+ const deleteRaceBtn = document.getElementById('deleteRaceBtn');
26
+ const exportBtn = document.getElementById('exportBtn');
27
+ const newRaceForm = document.getElementById('newRaceForm');
28
+ const newRaceNameInput = document.getElementById('newRaceName');
29
+ const createRaceBtn = document.getElementById('createRaceBtn');
30
+ const cancelNewRaceBtn = document.getElementById('cancelNewRaceBtn');
31
+ const raceArea = document.getElementById('raceArea');
32
+ const clockEl = document.getElementById('clock');
33
+ const startBtn = document.getElementById('startBtn');
34
+ const stopBtn = document.getElementById('stopBtn');
35
+ const resumeBtn = document.getElementById('resumeBtn');
36
+ const resetBtn = document.getElementById('resetBtn');
37
+ const scheduleInput = document.getElementById('scheduleInput');
38
+ const scheduleBtn = document.getElementById('scheduleBtn');
39
+ const cancelScheduleBtn = document.getElementById('cancelScheduleBtn');
40
+ const callOffInput = document.getElementById('callOffInput');
41
+ const scheduleCallOffBtn = document.getElementById('scheduleCallOffBtn');
42
+ const cancelCallOffBtn = document.getElementById('cancelCallOffBtn');
43
+ const statusEl = document.getElementById('statusLine');
44
+ const vetStatusLine = document.getElementById('vetStatusLine');
45
+ const vetStatusText = document.getElementById('vetStatusText');
46
+ const vetRefreshBtn = document.getElementById('vetRefreshBtn');
47
+ const vetAlternativesTh = document.getElementById('vetAlternativesTh');
48
+ const addBoatRow = document.getElementById('addBoatRow');
49
+ const addBoatName = document.getElementById('addBoatName');
50
+ const addBoatBtn = document.getElementById('addBoatBtn');
51
+ const boatsTable = document.getElementById('boatsTable');
52
+ const boatsBody = document.getElementById('boatsBody');
53
+ const emptyMsg = document.getElementById('emptyMsg');
54
+ const noRacesMsg = document.getElementById('noRacesMsg');
55
+ const addBoatSuggestions = document.getElementById('addBoatSuggestions');
56
+ const courseSection = document.getElementById('courseSection');
57
+ const courseToggleBtn = document.getElementById('courseToggleBtn');
58
+ const courseBody = document.getElementById('courseBody');
59
+ const startLineRowsEl = document.getElementById('startLineRows');
60
+ const finishLineRowsEl = document.getElementById('finishLineRows');
61
+ const marksRowsEl = document.getElementById('marksRows');
62
+ const addMarkBtn = document.getElementById('addMarkBtn');
63
+ const saveCourseBtn = document.getElementById('saveCourseBtn');
64
+ const courseStatusText = document.getElementById('courseStatusText');
65
+ const courseChart = document.getElementById('courseChart');
66
+ const replayControls = document.getElementById('replayControls');
67
+ const replaySlider = document.getElementById('replaySlider');
68
+ const replayTimeLabel = document.getElementById('replayTimeLabel');
69
+ const replayLiveBtn = document.getElementById('replayLiveBtn');
70
+
71
+ function unwrapValue(x) {
72
+ if (x && typeof x === 'object' && 'value' in x) return x.value;
73
+ return x;
74
+ }
75
+
76
+ function fmtDuration(ms) {
77
+ if (ms == null || ms < 0 || !isFinite(ms)) return '--:--:--';
78
+ const totalSec = Math.floor(ms / 1000);
79
+ const h = Math.floor(totalSec / 3600);
80
+ const m = Math.floor((totalSec % 3600) / 60);
81
+ const s = totalSec % 60;
82
+ const pad = (n) => String(n).padStart(2, '0');
83
+ return `${pad(h)}:${pad(m)}:${pad(s)}`;
84
+ }
85
+
86
+ // Same as fmtDuration but signed, for "vs self" comparisons where a
87
+ // negative value (behind) is a normal, meaningful result rather than
88
+ // "not available yet".
89
+ function formatSignedDuration(ms) {
90
+ if (ms == null || !isFinite(ms)) return '—';
91
+ const totalSec = Math.floor(Math.abs(ms) / 1000);
92
+ const h = Math.floor(totalSec / 3600);
93
+ const m = Math.floor((totalSec % 3600) / 60);
94
+ const s = totalSec % 60;
95
+ const pad = (n) => String(n).padStart(2, '0');
96
+ return (ms < 0 ? '-' : '+') + `${pad(h)}:${pad(m)}:${pad(s)}`;
97
+ }
98
+
99
+ // The race clock's "now" — frozen at stopTime while a race is stopped, so
100
+ // elapsed/corrected times stop advancing without needing every call site
101
+ // to know about the stop state.
102
+ function raceNow() {
103
+ return (raceState && raceState.stopTime) || Date.now();
104
+ }
105
+
106
+ function pad2(n) {
107
+ return String(n).padStart(2, '0');
108
+ }
109
+
110
+ // HH:MM:SS of the given timestamp, for the per-boat finish <input type=time>.
111
+ function tsToTimeInputValue(ts) {
112
+ if (!ts) return '';
113
+ const d = new Date(ts);
114
+ return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
115
+ }
116
+
117
+ // Combines a "HH:MM:SS" wall-clock value with the race's start date to get
118
+ // an absolute timestamp, rolling over to the next day if the entered time
119
+ // is earlier than the start time (so overnight races finish correctly).
120
+ function timeInputValueToTs(value) {
121
+ if (!value) return null;
122
+ const parts = value.split(':').map(Number);
123
+ const [h, m, s] = [parts[0] || 0, parts[1] || 0, parts[2] || 0];
124
+ const base = raceState && raceState.startTime ? new Date(raceState.startTime) : new Date();
125
+ const d = new Date(base.getFullYear(), base.getMonth(), base.getDate(), h, m, s, 0);
126
+ let ts = d.getTime();
127
+ if (raceState && raceState.startTime && ts < raceState.startTime) {
128
+ ts += 24 * 3600 * 1000;
129
+ }
130
+ return ts;
131
+ }
132
+
133
+ async function fetchJSON(url, opts) {
134
+ const res = await fetch(url, opts);
135
+ if (!res.ok) {
136
+ let msg = res.statusText;
137
+ try {
138
+ const j = await res.json();
139
+ if (j && j.error) msg = j.error;
140
+ } catch (e) {
141
+ // ignore
142
+ }
143
+ const err = new Error(msg);
144
+ err.status = res.status;
145
+ throw err;
146
+ }
147
+ if (res.status === 204) return null;
148
+ return res.json();
149
+ }
150
+
151
+ function setStatus(msg, isError) {
152
+ statusEl.textContent = msg || '';
153
+ statusEl.classList.toggle('error', !!isError);
154
+ }
155
+
156
+ function setVetStatus(msg, isError) {
157
+ vetStatusText.textContent = msg || '';
158
+ vetStatusText.classList.toggle('error', !!isError);
159
+ }
160
+
161
+ // Vessels are only used as autocomplete suggestions when adding a boat
162
+ // (name + MMSI hint) — a race's boat list is otherwise entered explicitly,
163
+ // not auto-populated from AIS.
164
+ async function loadVessels() {
165
+ try {
166
+ const all = await fetchJSON(`${SK_API}/vessels`);
167
+ const suggestions = [];
168
+ Object.keys(all || {}).forEach((context) => {
169
+ const v = all[context] || {};
170
+ const rawName = unwrapValue(v.name);
171
+ const rawMmsi = unwrapValue(v.mmsi);
172
+ if (rawName && String(rawName).trim()) {
173
+ suggestions.push({ name: String(rawName).trim(), mmsi: rawMmsi || '' });
174
+ }
175
+ });
176
+ vesselSuggestions = suggestions;
177
+ rebuildNameSuggestionPool();
178
+ } catch (e) {
179
+ setStatus('Could not load boat list from SignalK: ' + e.message, true);
180
+ }
181
+ }
182
+
183
+ // Merges three sources into one suggestion list, keyed by name: the VET
184
+ // register (names only), the cross-race boat registry (name + remembered
185
+ // MMSI), and live AIS/self vessels (name + current MMSI, freshest so it
186
+ // wins last). Later sources overwrite an earlier entry's MMSI when they
187
+ // have one, but never blank out an MMSI an earlier source already found.
188
+ function rebuildNameSuggestionPool() {
189
+ const byName = new Map();
190
+ function upsert(name, mmsi) {
191
+ const key = name.toLowerCase();
192
+ const existing = byName.get(key);
193
+ if (existing) {
194
+ if (mmsi) existing.mmsi = mmsi;
195
+ } else {
196
+ byName.set(key, { name, mmsi: mmsi || '' });
197
+ }
198
+ }
199
+ if (vetEnabled) handicapBoats.forEach((b) => upsert(b.name, ''));
200
+ boatRegistry.forEach((b) => upsert(b.name, b.mmsi));
201
+ vesselSuggestions.forEach((v) => upsert(v.name, v.mmsi));
202
+ nameSuggestionPool = Array.from(byName.values());
203
+ }
204
+
205
+ async function loadBoatRegistry() {
206
+ try {
207
+ const data = await fetchJSON(`${API}/boat-registry`);
208
+ boatRegistry = data.boats || [];
209
+ rebuildNameSuggestionPool();
210
+ } catch (e) {
211
+ // Non-fatal — MMSI auto-fill from history just won't be available.
212
+ }
213
+ }
214
+
215
+ function findMmsiByName(name) {
216
+ const n = name.trim().toLowerCase();
217
+ const hit = nameSuggestionPool.find((s) => s.name.toLowerCase() === n && s.mmsi);
218
+ return hit ? hit.mmsi : null;
219
+ }
220
+
221
+ // Custom dropdown instead of a native <datalist>, because datalist only
222
+ // matches from the start of the value in most browsers — this matches
223
+ // anywhere in the name (e.g. "21" or "solli" both find "RS 21 Solli").
224
+ function escapeHtml(s) {
225
+ return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
226
+ }
227
+
228
+ function hideSuggestions() {
229
+ addBoatSuggestions.hidden = true;
230
+ addBoatSuggestions.innerHTML = '';
231
+ suggestionItems = [];
232
+ suggestionActiveIndex = -1;
233
+ }
234
+
235
+ function renderSuggestionActive() {
236
+ Array.from(addBoatSuggestions.children).forEach((el, i) => {
237
+ el.classList.toggle('active', i === suggestionActiveIndex);
238
+ });
239
+ }
240
+
241
+ function selectSuggestion(item) {
242
+ addBoatName.value = item.name;
243
+ hideSuggestions();
244
+ addBoatName.focus();
245
+ }
246
+
247
+ function showSuggestionsFor(query) {
248
+ const q = query.trim().toLowerCase();
249
+ if (!q) {
250
+ hideSuggestions();
251
+ return;
252
+ }
253
+ const matches = nameSuggestionPool.filter((s) => s.name.toLowerCase().includes(q)).slice(0, 20);
254
+ suggestionItems = matches;
255
+ suggestionActiveIndex = -1;
256
+ if (!matches.length) {
257
+ hideSuggestions();
258
+ return;
259
+ }
260
+ addBoatSuggestions.innerHTML = '';
261
+ matches.forEach((item) => {
262
+ const div = document.createElement('div');
263
+ div.className = 'suggestion-item';
264
+ const idx = item.name.toLowerCase().indexOf(q);
265
+ if (idx === -1) {
266
+ div.textContent = item.name;
267
+ } else {
268
+ div.innerHTML =
269
+ escapeHtml(item.name.slice(0, idx)) +
270
+ '<mark>' +
271
+ escapeHtml(item.name.slice(idx, idx + q.length)) +
272
+ '</mark>' +
273
+ escapeHtml(item.name.slice(idx + q.length));
274
+ }
275
+ // mousedown (not click) fires before the input's blur, so the
276
+ // dropdown doesn't close itself out from under the click.
277
+ div.addEventListener('mousedown', (e) => {
278
+ e.preventDefault();
279
+ selectSuggestion(item);
280
+ });
281
+ addBoatSuggestions.appendChild(div);
282
+ });
283
+ addBoatSuggestions.hidden = false;
284
+ }
285
+
286
+ // Whether the VET-tall register is used at all is a plugin config setting
287
+ // (Server -> Plugin Config), not something the webapp can change — this
288
+ // just reads it once at startup to decide whether to fetch/offer it.
289
+ async function loadVetEnabled() {
290
+ try {
291
+ const data = await fetchJSON(`${API}/vet-enabled`);
292
+ vetEnabled = data.enabled === true;
293
+ } catch (e) {
294
+ vetEnabled = false;
295
+ }
296
+ // When disabled, every trace of VET is removed from the webapp rather
297
+ // than shown as a disabled/placeholder state — the status line (register
298
+ // status text + refresh link) and the whole "VET alternatives" table
299
+ // column disappear entirely.
300
+ vetStatusLine.hidden = !vetEnabled;
301
+ vetAlternativesTh.hidden = !vetEnabled;
302
+ }
303
+
304
+ async function loadHandicapRegister(force) {
305
+ if (!vetEnabled) return;
306
+ try {
307
+ setVetStatus('Loading VET register…');
308
+ const data = await fetchJSON(`${API}/handicap-source${force ? '?refresh=true' : ''}`);
309
+ handicapBoats = data.boats || [];
310
+ handicapVersion++;
311
+ rebuildNameSuggestionPool();
312
+ setVetStatus(`VET register: ${handicapBoats.length} boats loaded.`);
313
+ } catch (e) {
314
+ setVetStatus('Could not load VET register: ' + e.message, true);
315
+ }
316
+ }
317
+
318
+ async function loadRacesList() {
319
+ try {
320
+ const data = await fetchJSON(`${API}/races`);
321
+ races = data.races || [];
322
+ if (!activeRaceId || !races.some((r) => r.id === activeRaceId)) {
323
+ activeRaceId = data.currentRaceId && races.some((r) => r.id === data.currentRaceId) ? data.currentRaceId : races.length ? races[races.length - 1].id : null;
324
+ }
325
+ setStatus('');
326
+ } catch (e) {
327
+ setStatus('Could not reach race-control plugin: ' + e.message, true);
328
+ }
329
+ }
330
+
331
+ async function loadRaceState() {
332
+ if (!activeRaceId) {
333
+ raceState = null;
334
+ } else {
335
+ try {
336
+ raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}`);
337
+ } catch (e) {
338
+ raceState = null;
339
+ setStatus('Could not load race: ' + e.message, true);
340
+ }
341
+ }
342
+ // Only rebuild the course form when we've actually switched to a
343
+ // different race (or none) — never on a background poll of the same
344
+ // race, so it can't interrupt someone mid-edit.
345
+ const id = raceState ? raceState.id : null;
346
+ if (id !== lastCourseFormRaceId) {
347
+ loadCourseFormFromRace();
348
+ lastCourseFormRaceId = id;
349
+ }
350
+ }
351
+
352
+ // Native prompt()/confirm() dialogs are unreliable in embedded SignalK
353
+ // webviews (chartplotters, Kip, tablet browsers) and in automated
354
+ // testing — they can silently no-op instead of showing anything. All
355
+ // "are you sure" / "name it" interactions use in-page UI instead.
356
+ function openNewRaceForm() {
357
+ newRaceForm.hidden = false;
358
+ newRaceNameInput.value = '';
359
+ newRaceNameInput.focus();
360
+ }
361
+
362
+ function closeNewRaceForm() {
363
+ newRaceForm.hidden = true;
364
+ }
365
+
366
+ async function submitNewRace() {
367
+ const name = newRaceNameInput.value.trim();
368
+ if (!name) {
369
+ setStatus('Race name cannot be empty.', true);
370
+ newRaceNameInput.focus();
371
+ return;
372
+ }
373
+ try {
374
+ const data = await fetchJSON(`${API}/races`, {
375
+ method: 'POST',
376
+ headers: { 'Content-Type': 'application/json' },
377
+ body: JSON.stringify({ name })
378
+ });
379
+ activeRaceId = data.race.id;
380
+ closeNewRaceForm();
381
+ await loadRacesList();
382
+ await loadRaceState();
383
+ render();
384
+ } catch (e) {
385
+ setStatus(e.message, true);
386
+ }
387
+ }
388
+
389
+ async function performDeleteRace() {
390
+ if (!activeRaceId || !raceState) return;
391
+ try {
392
+ await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}`, { method: 'DELETE' });
393
+ activeRaceId = null;
394
+ await loadRacesList();
395
+ await loadRaceState();
396
+ render();
397
+ } catch (e) {
398
+ setStatus(e.message, true);
399
+ }
400
+ }
401
+
402
+ // Content-Disposition: attachment on the export response means a plain
403
+ // top-level navigation downloads the file without actually leaving this
404
+ // page — no need for a temporary <a> element or window.open.
405
+ function exportRace() {
406
+ if (!activeRaceId) return;
407
+ const tzOffsetMinutes = new Date().getTimezoneOffset();
408
+ window.location.href = `${API}/races/${encodeURIComponent(activeRaceId)}/export.xlsx?tzOffsetMinutes=${tzOffsetMinutes}`;
409
+ }
410
+
411
+ // Click once to arm, click again within a few seconds to confirm — an
412
+ // in-page substitute for window.confirm() (see note above).
413
+ function armConfirm(button, idleLabel, confirmLabel, onConfirm) {
414
+ let armed = false;
415
+ let timer = null;
416
+ function disarm() {
417
+ armed = false;
418
+ clearTimeout(timer);
419
+ button.textContent = idleLabel;
420
+ button.classList.remove('confirming');
421
+ }
422
+ button.addEventListener('click', () => {
423
+ if (!armed) {
424
+ armed = true;
425
+ button.textContent = confirmLabel;
426
+ button.classList.add('confirming');
427
+ timer = setTimeout(disarm, 4000);
428
+ } else {
429
+ disarm();
430
+ onConfirm();
431
+ }
432
+ });
433
+ }
434
+
435
+ async function selectRace(id) {
436
+ activeRaceId = id;
437
+ fetchJSON(`${API}/races/${encodeURIComponent(id)}/select`, { method: 'POST' }).catch(() => {});
438
+ await loadRaceState();
439
+ render();
440
+ }
441
+
442
+ async function startRace() {
443
+ if (!activeRaceId) return;
444
+ try {
445
+ raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/start`, { method: 'POST' });
446
+ render();
447
+ } catch (e) {
448
+ setStatus(e.message, true);
449
+ }
450
+ }
451
+
452
+ async function performResetRace() {
453
+ if (!activeRaceId) return;
454
+ try {
455
+ raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/reset`, { method: 'POST' });
456
+ render();
457
+ } catch (e) {
458
+ setStatus(e.message, true);
459
+ }
460
+ }
461
+
462
+ async function stopRace() {
463
+ if (!activeRaceId) return;
464
+ try {
465
+ raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/stop`, { method: 'POST' });
466
+ render();
467
+ } catch (e) {
468
+ setStatus(e.message, true);
469
+ }
470
+ }
471
+
472
+ async function resumeRace() {
473
+ if (!activeRaceId) return;
474
+ try {
475
+ raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/resume`, { method: 'POST' });
476
+ render();
477
+ } catch (e) {
478
+ setStatus(e.message, true);
479
+ }
480
+ }
481
+
482
+ async function scheduleCallOff() {
483
+ if (!activeRaceId) return;
484
+ if (!callOffInput.value) {
485
+ setStatus('Pick a date/time to schedule the call-off.', true);
486
+ return;
487
+ }
488
+ const ts = new Date(callOffInput.value).getTime();
489
+ if (!isFinite(ts)) {
490
+ setStatus('Invalid call-off time.', true);
491
+ return;
492
+ }
493
+ try {
494
+ raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/schedule-call-off`, {
495
+ method: 'POST',
496
+ headers: { 'Content-Type': 'application/json' },
497
+ body: JSON.stringify({ time: ts })
498
+ });
499
+ render();
500
+ } catch (e) {
501
+ setStatus(e.message, true);
502
+ }
503
+ }
504
+
505
+ async function cancelCallOffSchedule() {
506
+ if (!activeRaceId) return;
507
+ try {
508
+ raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/schedule-call-off/cancel`, {
509
+ method: 'POST'
510
+ });
511
+ render();
512
+ } catch (e) {
513
+ setStatus(e.message, true);
514
+ }
515
+ }
516
+
517
+ async function setDnf(boatId, dnf) {
518
+ if (!activeRaceId) return;
519
+ try {
520
+ const boat = await fetchJSON(
521
+ `${API}/races/${encodeURIComponent(activeRaceId)}/boats/${encodeURIComponent(boatId)}/dnf`,
522
+ {
523
+ method: 'PUT',
524
+ headers: { 'Content-Type': 'application/json' },
525
+ body: JSON.stringify({ dnf })
526
+ }
527
+ );
528
+ raceState.boats[boatId] = boat;
529
+ render();
530
+ } catch (e) {
531
+ setStatus(e.message, true);
532
+ }
533
+ }
534
+
535
+ async function toggleSelf(boatId) {
536
+ if (!activeRaceId || !raceState) return;
537
+ const nextSelf = raceState.selfBoatId === boatId ? null : boatId;
538
+ try {
539
+ raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/self`, {
540
+ method: 'PUT',
541
+ headers: { 'Content-Type': 'application/json' },
542
+ body: JSON.stringify({ boatId: nextSelf })
543
+ });
544
+ render();
545
+ } catch (e) {
546
+ setStatus(e.message, true);
547
+ }
548
+ }
549
+
550
+ async function scheduleRace() {
551
+ if (!activeRaceId) return;
552
+ if (!scheduleInput.value) {
553
+ setStatus('Pick a date/time to schedule the start.', true);
554
+ return;
555
+ }
556
+ const ts = new Date(scheduleInput.value).getTime();
557
+ if (!isFinite(ts)) {
558
+ setStatus('Invalid scheduled start time.', true);
559
+ return;
560
+ }
561
+ try {
562
+ raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/schedule`, {
563
+ method: 'POST',
564
+ headers: { 'Content-Type': 'application/json' },
565
+ body: JSON.stringify({ startTime: ts })
566
+ });
567
+ render();
568
+ } catch (e) {
569
+ setStatus(e.message, true);
570
+ }
571
+ }
572
+
573
+ async function cancelSchedule() {
574
+ if (!activeRaceId) return;
575
+ try {
576
+ raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/schedule/cancel`, {
577
+ method: 'POST'
578
+ });
579
+ render();
580
+ } catch (e) {
581
+ setStatus(e.message, true);
582
+ }
583
+ }
584
+
585
+ async function addBoat() {
586
+ if (!activeRaceId) return;
587
+ const name = addBoatName.value.trim();
588
+ if (!name) {
589
+ setStatus('Enter a boat name to add.', true);
590
+ addBoatName.focus();
591
+ return;
592
+ }
593
+ // MMSI is looked up silently (from AIS or a previous race's entry for
594
+ // this name) rather than typed at add time — it's only ever hand-edited
595
+ // per boat afterward, in the table row.
596
+ const mmsi = findMmsiByName(name);
597
+ try {
598
+ const boat = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/boats`, {
599
+ method: 'POST',
600
+ headers: { 'Content-Type': 'application/json' },
601
+ body: JSON.stringify({ name, mmsi })
602
+ });
603
+ raceState.boats[boat.id] = boat;
604
+ addBoatName.value = '';
605
+ addBoatName.focus();
606
+ render();
607
+ } catch (e) {
608
+ setStatus(e.message, true);
609
+ }
610
+ }
611
+
612
+ // Idempotent: if the boat is already gone server-side (a double-click
613
+ // firing twice, or another device/tab removing it first — the race
614
+ // committee often has more than one person editing the same race), a 404
615
+ // here means the desired end state is already reached, not a failure.
616
+ async function removeBoat(boatId) {
617
+ if (!activeRaceId) return true;
618
+ try {
619
+ await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/boats/${encodeURIComponent(boatId)}`, {
620
+ method: 'DELETE'
621
+ });
622
+ } catch (e) {
623
+ if (e.status !== 404) {
624
+ setStatus(e.message, true);
625
+ return false;
626
+ }
627
+ }
628
+ if (raceState && raceState.boats) delete raceState.boats[boatId];
629
+ rows.delete(boatId);
630
+ render();
631
+ return true;
632
+ }
633
+
634
+ async function setFinishTime(boatId, ts) {
635
+ if (!activeRaceId) return;
636
+ try {
637
+ const boat = await fetchJSON(
638
+ `${API}/races/${encodeURIComponent(activeRaceId)}/boats/${encodeURIComponent(boatId)}/finishTime`,
639
+ {
640
+ method: 'PUT',
641
+ headers: { 'Content-Type': 'application/json' },
642
+ body: JSON.stringify({ finishTime: ts })
643
+ }
644
+ );
645
+ raceState.boats[boatId] = boat;
646
+ render();
647
+ } catch (e) {
648
+ setStatus(e.message, true);
649
+ }
650
+ }
651
+
652
+ async function setMmsi(boatId, mmsi) {
653
+ if (!activeRaceId) return;
654
+ try {
655
+ const boat = await fetchJSON(
656
+ `${API}/races/${encodeURIComponent(activeRaceId)}/boats/${encodeURIComponent(boatId)}/mmsi`,
657
+ {
658
+ method: 'PUT',
659
+ headers: { 'Content-Type': 'application/json' },
660
+ body: JSON.stringify({ mmsi })
661
+ }
662
+ );
663
+ raceState.boats[boatId] = boat;
664
+ if (boat.mmsi) loadBoatRegistry();
665
+ } catch (e) {
666
+ setStatus(e.message, true);
667
+ }
668
+ }
669
+
670
+ async function setTcf(boatId, tcf) {
671
+ if (!activeRaceId) return;
672
+ try {
673
+ const boat = await fetchJSON(
674
+ `${API}/races/${encodeURIComponent(activeRaceId)}/boats/${encodeURIComponent(boatId)}/tcf`,
675
+ {
676
+ method: 'PUT',
677
+ headers: { 'Content-Type': 'application/json' },
678
+ body: JSON.stringify({ tcf })
679
+ }
680
+ );
681
+ raceState.boats[boatId] = boat;
682
+ } catch (e) {
683
+ setStatus(e.message, true);
684
+ }
685
+ }
686
+
687
+ // ---- Course editor -------------------------------------------------
688
+
689
+ function setCourseStatus(msg, isError) {
690
+ courseStatusText.textContent = msg || '';
691
+ courseStatusText.classList.toggle('error', !!isError);
692
+ }
693
+
694
+ async function fetchSelfPosition() {
695
+ try {
696
+ const leaf = await fetchJSON(`${SK_API}/vessels/self/navigation/position`);
697
+ const v = leaf && leaf.value;
698
+ if (v && typeof v.latitude === 'number' && typeof v.longitude === 'number') {
699
+ return { lat: v.latitude, lon: v.longitude };
700
+ }
701
+ } catch (e) {
702
+ // fall through
703
+ }
704
+ return null;
705
+ }
706
+
707
+ // One name+lat+lon(+optional "use my position") row, shared by start
708
+ // line, finish line, and mark entry.
709
+ function buildPointRow(point) {
710
+ const row = document.createElement('div');
711
+ row.className = 'course-point-row';
712
+
713
+ const nameInput = document.createElement('input');
714
+ nameInput.type = 'text';
715
+ nameInput.className = 'course-name-input';
716
+ nameInput.placeholder = 'Name (optional)';
717
+ nameInput.value = (point && point.name) || '';
718
+
719
+ const latInput = document.createElement('input');
720
+ latInput.type = 'number';
721
+ latInput.step = 'any';
722
+ latInput.className = 'course-coord-input';
723
+ latInput.placeholder = 'Lat';
724
+ latInput.value = point ? point.lat : '';
725
+
726
+ const lonInput = document.createElement('input');
727
+ lonInput.type = 'number';
728
+ lonInput.step = 'any';
729
+ lonInput.className = 'course-coord-input';
730
+ lonInput.placeholder = 'Lon';
731
+ lonInput.value = point ? point.lon : '';
732
+
733
+ const useHereBtn = document.createElement('button');
734
+ useHereBtn.type = 'button';
735
+ useHereBtn.className = 'secondary';
736
+ useHereBtn.textContent = 'Use my position';
737
+ useHereBtn.addEventListener('click', async () => {
738
+ const pos = await fetchSelfPosition();
739
+ if (pos) {
740
+ latInput.value = pos.lat;
741
+ lonInput.value = pos.lon;
742
+ } else {
743
+ setCourseStatus('Could not read a current position from SignalK.', true);
744
+ }
745
+ });
746
+
747
+ row.append(nameInput, latInput, lonInput, useHereBtn);
748
+ return { row, nameInput, latInput, lonInput };
749
+ }
750
+
751
+ function renderMarkRows() {
752
+ marksRowsEl.innerHTML = '';
753
+ markRefs.forEach((refs, i) => {
754
+ refs.nameInput.placeholder = `Mark ${i + 1} name`;
755
+ marksRowsEl.appendChild(refs.row);
756
+ });
757
+ }
758
+
759
+ function buildMarkRow(mark) {
760
+ const refs = buildPointRow(mark);
761
+ const upBtn = document.createElement('button');
762
+ upBtn.type = 'button';
763
+ upBtn.className = 'secondary';
764
+ upBtn.textContent = '↑';
765
+ upBtn.addEventListener('click', () => {
766
+ const idx = markRefs.indexOf(refs);
767
+ if (idx > 0) {
768
+ [markRefs[idx - 1], markRefs[idx]] = [markRefs[idx], markRefs[idx - 1]];
769
+ renderMarkRows();
770
+ }
771
+ });
772
+ const downBtn = document.createElement('button');
773
+ downBtn.type = 'button';
774
+ downBtn.className = 'secondary';
775
+ downBtn.textContent = '↓';
776
+ downBtn.addEventListener('click', () => {
777
+ const idx = markRefs.indexOf(refs);
778
+ if (idx !== -1 && idx < markRefs.length - 1) {
779
+ [markRefs[idx + 1], markRefs[idx]] = [markRefs[idx], markRefs[idx + 1]];
780
+ renderMarkRows();
781
+ }
782
+ });
783
+ const removeBtn = document.createElement('button');
784
+ removeBtn.type = 'button';
785
+ removeBtn.className = 'secondary danger';
786
+ removeBtn.textContent = '×';
787
+ removeBtn.addEventListener('click', () => {
788
+ const idx = markRefs.indexOf(refs);
789
+ if (idx !== -1) {
790
+ markRefs.splice(idx, 1);
791
+ renderMarkRows();
792
+ }
793
+ });
794
+ refs.row.append(upBtn, downBtn, removeBtn);
795
+ return refs;
796
+ }
797
+
798
+ // Repopulates the course form from raceState.course — only called on race
799
+ // switch/load and after a successful save, never on the 1s/5s polling
800
+ // ticks, so it can't interrupt someone mid-edit (same reasoning as the
801
+ // boat-row focus guards above, just at the level of "which race is this
802
+ // form even showing").
803
+ function loadCourseFormFromRace() {
804
+ const c = (raceState && raceState.course) || { startLine: null, marks: [], finishLine: null };
805
+
806
+ startLineRefs = [buildPointRow(c.startLine ? c.startLine[0] : null), buildPointRow(c.startLine ? c.startLine[1] : null)];
807
+ startLineRefs[0].nameInput.placeholder = 'Pin end name';
808
+ startLineRefs[1].nameInput.placeholder = 'Committee boat end name';
809
+ startLineRowsEl.innerHTML = '';
810
+ startLineRefs.forEach((r) => startLineRowsEl.appendChild(r.row));
811
+
812
+ finishLineRefs = [buildPointRow(c.finishLine ? c.finishLine[0] : null), buildPointRow(c.finishLine ? c.finishLine[1] : null)];
813
+ finishLineRefs[0].nameInput.placeholder = 'Pin end name';
814
+ finishLineRefs[1].nameInput.placeholder = 'Committee boat end name';
815
+ finishLineRowsEl.innerHTML = '';
816
+ finishLineRefs.forEach((r) => finishLineRowsEl.appendChild(r.row));
817
+
818
+ markRefs = (c.marks || []).map((m) => buildMarkRow(m));
819
+ renderMarkRows();
820
+ setCourseStatus('');
821
+ }
822
+
823
+ function pointFromRefs(refs) {
824
+ const lat = parseFloat(refs.latInput.value);
825
+ const lon = parseFloat(refs.lonInput.value);
826
+ if (!isFinite(lat) || !isFinite(lon)) return null;
827
+ const name = refs.nameInput.value.trim();
828
+ return name ? { lat, lon, name } : { lat, lon };
829
+ }
830
+
831
+ async function saveCourse() {
832
+ if (!activeRaceId) return;
833
+ const startA = pointFromRefs(startLineRefs[0]);
834
+ const startB = pointFromRefs(startLineRefs[1]);
835
+ const finishA = pointFromRefs(finishLineRefs[0]);
836
+ const finishB = pointFromRefs(finishLineRefs[1]);
837
+ if ((startA && !startB) || (!startA && startB)) {
838
+ setCourseStatus('Enter both ends of the start line, or leave both blank.', true);
839
+ return;
840
+ }
841
+ if ((finishA && !finishB) || (!finishA && finishB)) {
842
+ setCourseStatus('Enter both ends of the finish line, or leave both blank.', true);
843
+ return;
844
+ }
845
+ const marks = [];
846
+ for (let i = 0; i < markRefs.length; i++) {
847
+ const p = pointFromRefs(markRefs[i]);
848
+ if (!p) {
849
+ setCourseStatus(`Mark ${i + 1} needs both a lat and a lon.`, true);
850
+ return;
851
+ }
852
+ marks.push(Object.assign({ id: `m${i}` }, p));
853
+ }
854
+ try {
855
+ raceState = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/course`, {
856
+ method: 'PUT',
857
+ headers: { 'Content-Type': 'application/json' },
858
+ body: JSON.stringify({
859
+ startLine: startA && startB ? [startA, startB] : null,
860
+ finishLine: finishA && finishB ? [finishA, finishB] : null,
861
+ marks
862
+ })
863
+ });
864
+ setCourseStatus('Course saved.');
865
+ loadCourseFormFromRace();
866
+ renderChart();
867
+ } catch (e) {
868
+ setCourseStatus(e.message, true);
869
+ }
870
+ }
871
+
872
+ // ---- Course / track chart -------------------------------------------
873
+
874
+ const CHART_PALETTE = ['#38bdf8', '#fbbf24', '#f472b6', '#a78bfa', '#34d399', '#fb923c', '#60a5fa', '#facc15'];
875
+
876
+ // Simple equirectangular fit (longitude scaled by cos(mean latitude) so
877
+ // the small area a race course covers looks roughly to-scale) mapped into
878
+ // the SVG's viewBox with padding. Re-derived on every render rather than
879
+ // held fixed, since the bounding box grows as tracks come in.
880
+ function buildProjection(points, width, height, padding) {
881
+ const meanLat = points.reduce((s, p) => s + p.lat, 0) / points.length;
882
+ const cosLat = Math.cos((meanLat * Math.PI) / 180) || 1;
883
+ const xs = points.map((p) => p.lon * cosLat);
884
+ const ys = points.map((p) => -p.lat);
885
+ const minX = Math.min(...xs);
886
+ const maxX = Math.max(...xs);
887
+ const minY = Math.min(...ys);
888
+ const maxY = Math.max(...ys);
889
+ const spanX = Math.max(maxX - minX, 1e-9);
890
+ const spanY = Math.max(maxY - minY, 1e-9);
891
+ const scale = Math.min((width - 2 * padding) / spanX, (height - 2 * padding) / spanY);
892
+ const offX = padding + (width - 2 * padding - spanX * scale) / 2;
893
+ const offY = padding + (height - 2 * padding - spanY * scale) / 2;
894
+ return (p) => ({ x: offX + (p.lon * cosLat - minX) * scale, y: offY + (-p.lat - minY) * scale });
895
+ }
896
+
897
+ function renderChart() {
898
+ if (!raceState) {
899
+ courseChart.innerHTML = '';
900
+ replayControls.hidden = true;
901
+ return;
902
+ }
903
+ const course = raceState.course || { startLine: null, marks: [], finishLine: null };
904
+ const boatsWithTrack = Object.values(raceState.boats || {}).filter((b) => b.track && b.track.length);
905
+
906
+ const allPoints = [];
907
+ if (course.startLine) allPoints.push(...course.startLine);
908
+ if (course.finishLine) allPoints.push(...course.finishLine);
909
+ allPoints.push(...course.marks);
910
+ boatsWithTrack.forEach((b) => allPoints.push(...b.track));
911
+
912
+ if (allPoints.length < 2) {
913
+ courseChart.innerHTML =
914
+ '<text x="400" y="250" text-anchor="middle" fill="var(--muted)" font-size="14">Add a course, then start the race, to see the chart here</text>';
915
+ replayControls.hidden = true;
916
+ return;
917
+ }
918
+
919
+ const width = 800;
920
+ const height = 500;
921
+ const proj = buildProjection(allPoints, width, height, 40);
922
+ const parts = [];
923
+
924
+ const coursePts = [];
925
+ if (course.startLine) coursePts.push({ lat: (course.startLine[0].lat + course.startLine[1].lat) / 2, lon: (course.startLine[0].lon + course.startLine[1].lon) / 2 });
926
+ course.marks.forEach((m) => coursePts.push(m));
927
+ if (course.finishLine) coursePts.push({ lat: (course.finishLine[0].lat + course.finishLine[1].lat) / 2, lon: (course.finishLine[0].lon + course.finishLine[1].lon) / 2 });
928
+ if (coursePts.length >= 2) {
929
+ const d = coursePts.map((p, i) => (i === 0 ? 'M' : 'L') + proj(p).x.toFixed(1) + ',' + proj(p).y.toFixed(1)).join(' ');
930
+ parts.push(`<path d="${d}" fill="none" stroke="var(--muted)" stroke-width="1.5" stroke-dasharray="4,4" />`);
931
+ }
932
+
933
+ if (course.startLine) {
934
+ const a = proj(course.startLine[0]);
935
+ const b = proj(course.startLine[1]);
936
+ parts.push(`<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="var(--good)" stroke-width="2" />`);
937
+ parts.push(`<text x="${(a.x + b.x) / 2}" y="${(a.y + b.y) / 2 - 8}" fill="var(--good)" font-size="11" text-anchor="middle">Start</text>`);
938
+ }
939
+ if (course.finishLine) {
940
+ const a = proj(course.finishLine[0]);
941
+ const b = proj(course.finishLine[1]);
942
+ parts.push(`<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="var(--accent)" stroke-width="2" />`);
943
+ parts.push(`<text x="${(a.x + b.x) / 2}" y="${(a.y + b.y) / 2 - 8}" fill="var(--accent)" font-size="11" text-anchor="middle">Finish</text>`);
944
+ }
945
+ course.marks.forEach((m, i) => {
946
+ const p = proj(m);
947
+ parts.push(`<circle cx="${p.x}" cy="${p.y}" r="5" fill="var(--bg)" stroke="var(--text)" stroke-width="1.5" />`);
948
+ parts.push(`<text x="${p.x}" y="${p.y - 10}" fill="var(--text)" font-size="11" text-anchor="middle">${escapeHtml(m.name || String(i + 1))}</text>`);
949
+ });
950
+
951
+ let minT = Infinity;
952
+ let maxT = -Infinity;
953
+ boatsWithTrack.forEach((b) => {
954
+ b.track.forEach((pt) => {
955
+ if (pt.t < minT) minT = pt.t;
956
+ if (pt.t > maxT) maxT = pt.t;
957
+ });
958
+ });
959
+ const cutoff = replayLive ? Infinity : replayTime;
960
+
961
+ boatsWithTrack.forEach((b, idx) => {
962
+ const color = CHART_PALETTE[idx % CHART_PALETTE.length];
963
+ const pts = b.track.filter((pt) => pt.t <= cutoff);
964
+ if (!pts.length) return;
965
+ const d = pts.map((pt, i) => (i === 0 ? 'M' : 'L') + proj(pt).x.toFixed(1) + ',' + proj(pt).y.toFixed(1)).join(' ');
966
+ parts.push(`<path d="${d}" fill="none" stroke="${color}" stroke-width="1.5" opacity="0.85" />`);
967
+ const last = proj(pts[pts.length - 1]);
968
+ parts.push(`<circle cx="${last.x}" cy="${last.y}" r="4" fill="${color}" />`);
969
+ parts.push(`<text x="${(last.x + 7).toFixed(1)}" y="${(last.y + 3).toFixed(1)}" fill="${color}" font-size="10">${escapeHtml(b.name)}</text>`);
970
+ });
971
+
972
+ courseChart.innerHTML = parts.join('');
973
+
974
+ replayControls.hidden = boatsWithTrack.length === 0 || !isFinite(minT);
975
+ if (!replayControls.hidden && document.activeElement !== replaySlider) {
976
+ replaySlider.min = String(minT);
977
+ replaySlider.max = String(maxT);
978
+ if (replayLive) {
979
+ replaySlider.value = String(maxT);
980
+ replayTimeLabel.textContent = 'Live';
981
+ } else {
982
+ replaySlider.value = String(Math.min(Math.max(replayTime, minT), maxT));
983
+ replayTimeLabel.textContent = new Date(replayTime).toLocaleTimeString();
984
+ }
985
+ }
986
+ }
987
+
988
+ function boatList() {
989
+ if (!raceState) return [];
990
+ const now = raceNow();
991
+ return Object.values(raceState.boats || {})
992
+ .map((b) => {
993
+ // A DNF boat is out of the race — no ticking clock, no rank (sorts
994
+ // to the bottom, same as any other boat with nothing to rank by).
995
+ const elapsedMs = !b.dnf && raceState.startTime ? (b.finishTime || now) - raceState.startTime : null;
996
+ const tcf = b.tcf != null ? b.tcf : 1.0;
997
+ const correctedMs = elapsedMs != null ? elapsedMs * tcf : null;
998
+ const estimate = b.estimate || null;
999
+ // Finished boats rank on their real corrected time; still-racing
1000
+ // boats rank on the projected corrected time when the server could
1001
+ // estimate one (course + live position/speed available), otherwise
1002
+ // fall back to elapsed-so-far like before.
1003
+ const rankMs = b.dnf ? null : b.finishTime ? correctedMs : estimate ? estimate.estCorrectedMs : correctedMs;
1004
+ return {
1005
+ boatId: b.id,
1006
+ name: b.name,
1007
+ mmsi: b.mmsi || '',
1008
+ tcf,
1009
+ finishTime: b.finishTime,
1010
+ dnf: !!b.dnf,
1011
+ dnfPosition: b.dnfPosition || null,
1012
+ elapsedMs,
1013
+ correctedMs,
1014
+ estimate,
1015
+ rankMs
1016
+ };
1017
+ })
1018
+ .sort((a, b) => {
1019
+ if (a.rankMs == null && b.rankMs == null) return a.name.localeCompare(b.name);
1020
+ if (a.rankMs == null) return 1;
1021
+ if (b.rankMs == null) return -1;
1022
+ return a.rankMs - b.rankMs;
1023
+ });
1024
+ }
1025
+
1026
+ // Compares every boat to whichever one is marked "self". Two shapes:
1027
+ // - "countdown": self is still racing and the other boat has already
1028
+ // finished, so the other's corrected time is a fixed target — this is
1029
+ // how much longer self can take (in elapsed time) and still finish
1030
+ // with a lower corrected time. Ticks down every render; goes negative
1031
+ // once self can no longer catch up even by finishing instantly.
1032
+ // - "gap": both values are pinned (self finished) or both still moving
1033
+ // (neither finished) — just the current corrected-time gap, positive
1034
+ // when self is ahead.
1035
+ function computeVsSelf(boats, selfBoatId) {
1036
+ const map = new Map();
1037
+ const self = boats.find((b) => b.boatId === selfBoatId);
1038
+ if (!self) return map;
1039
+ boats.forEach((b) => {
1040
+ if (b.boatId === selfBoatId) {
1041
+ map.set(b.boatId, { type: 'self' });
1042
+ return;
1043
+ }
1044
+ if (self.dnf) {
1045
+ // Self is out of the race — no meaningful comparison to make.
1046
+ map.set(b.boatId, { type: 'none' });
1047
+ return;
1048
+ }
1049
+ if (!self.finishTime && b.finishTime) {
1050
+ const thresholdElapsedMs = b.correctedMs / (self.tcf || 1);
1051
+ map.set(b.boatId, { type: 'countdown', remainingMs: thresholdElapsedMs - self.elapsedMs });
1052
+ return;
1053
+ }
1054
+ const selfVal = self.finishTime ? self.correctedMs : self.rankMs;
1055
+ const otherVal = b.finishTime ? b.correctedMs : b.rankMs;
1056
+ if (selfVal == null || otherVal == null) {
1057
+ map.set(b.boatId, { type: 'none' });
1058
+ return;
1059
+ }
1060
+ map.set(b.boatId, { type: 'gap', gapMs: otherVal - selfVal });
1061
+ });
1062
+ return map;
1063
+ }
1064
+
1065
+ // Row DOM nodes are created once per boat and reused across renders, so an
1066
+ // in-progress edit (TCF, finish time) is never blown away by the
1067
+ // once-a-second clock tick that redraws elapsed/corrected time.
1068
+ const rows = new Map(); // boatId -> row refs
1069
+
1070
+ function buildRow(boatId) {
1071
+ const tr = document.createElement('tr');
1072
+
1073
+ const selfBtn = document.createElement('button');
1074
+ selfBtn.type = 'button';
1075
+ selfBtn.className = 'self-btn';
1076
+ selfBtn.title = 'Mark as self, to compare other boats against';
1077
+ selfBtn.textContent = '☆';
1078
+ selfBtn.addEventListener('click', () => toggleSelf(boatId));
1079
+
1080
+ const nameSpan = document.createElement('span');
1081
+ nameSpan.className = 'boat-name-text';
1082
+
1083
+ const tdName = document.createElement('td');
1084
+ tdName.append(selfBtn, nameSpan);
1085
+
1086
+ const mmsiInput = document.createElement('input');
1087
+ mmsiInput.type = 'text';
1088
+ mmsiInput.className = 'mmsi-input';
1089
+ mmsiInput.placeholder = 'MMSI';
1090
+ mmsiInput.addEventListener('change', () => setMmsi(boatId, mmsiInput.value.trim()));
1091
+ const tdMmsi = document.createElement('td');
1092
+ tdMmsi.appendChild(mmsiInput);
1093
+
1094
+ const tcfInput = document.createElement('input');
1095
+ tcfInput.type = 'number';
1096
+ tcfInput.step = '0.001';
1097
+ tcfInput.min = '0.01';
1098
+ tcfInput.className = 'tcf-input';
1099
+ tcfInput.addEventListener('change', () => {
1100
+ const val = parseFloat(tcfInput.value);
1101
+ if (isFinite(val) && val > 0) setTcf(boatId, val);
1102
+ });
1103
+ // Handicap edits must be deliberate text entry — block the number
1104
+ // input's scroll-wheel and up/down-arrow increment/decrement so a
1105
+ // stray scroll or arrow key can't silently nudge a boat's TCF.
1106
+ tcfInput.addEventListener(
1107
+ 'wheel',
1108
+ (e) => {
1109
+ e.preventDefault();
1110
+ },
1111
+ { passive: false }
1112
+ );
1113
+ tcfInput.addEventListener('keydown', (e) => {
1114
+ if (e.key === 'ArrowUp' || e.key === 'ArrowDown') e.preventDefault();
1115
+ });
1116
+ const tdTcf = document.createElement('td');
1117
+ tdTcf.appendChild(tcfInput);
1118
+
1119
+ // Alternatives specific to this boat's name (populated once the VET
1120
+ // register is loaded/matched — see refreshVetAlternatives), so picking
1121
+ // one applies that exact number without retyping.
1122
+ const vetSelect = document.createElement('select');
1123
+ vetSelect.className = 'vet-select';
1124
+ vetSelect.addEventListener('change', () => {
1125
+ const val = parseFloat(vetSelect.value);
1126
+ if (isFinite(val) && val > 0) {
1127
+ tcfInput.value = val;
1128
+ setTcf(boatId, val);
1129
+ }
1130
+ // Left showing the picked alternative (synced from tcf on future
1131
+ // renders) rather than reset to the placeholder — see
1132
+ // syncVetSelectValue.
1133
+ });
1134
+ const vetBadge = document.createElement('span');
1135
+ vetBadge.className = 'vet-badge';
1136
+ const tdVet = document.createElement('td');
1137
+ tdVet.className = 'vet-cell';
1138
+ tdVet.hidden = !vetEnabled;
1139
+ tdVet.append(vetSelect, vetBadge);
1140
+
1141
+ const tdElapsed = document.createElement('td');
1142
+ const tdCorrected = document.createElement('td');
1143
+ const tdEstFinish = document.createElement('td');
1144
+ tdEstFinish.className = 'est-finish';
1145
+ const tdVsSelf = document.createElement('td');
1146
+ tdVsSelf.className = 'vs-self';
1147
+
1148
+ const finishTimeInput = document.createElement('input');
1149
+ finishTimeInput.type = 'time';
1150
+ finishTimeInput.step = '1';
1151
+ finishTimeInput.className = 'finish-time-input';
1152
+ finishTimeInput.addEventListener('change', () => {
1153
+ const ts = finishTimeInput.value ? timeInputValueToTs(finishTimeInput.value) : null;
1154
+ setFinishTime(boatId, ts);
1155
+ });
1156
+
1157
+ const finishNowBtn = document.createElement('button');
1158
+ finishNowBtn.type = 'button';
1159
+ finishNowBtn.className = 'finish-now-btn';
1160
+ finishNowBtn.textContent = 'Now';
1161
+ finishNowBtn.addEventListener('click', () => setFinishTime(boatId, Date.now()));
1162
+
1163
+ const finishClearBtn = document.createElement('button');
1164
+ finishClearBtn.type = 'button';
1165
+ finishClearBtn.className = 'finish-clear-btn';
1166
+ finishClearBtn.textContent = 'Clear';
1167
+ finishClearBtn.addEventListener('click', () => setFinishTime(boatId, null));
1168
+
1169
+ const finishDnfBtn = document.createElement('button');
1170
+ finishDnfBtn.type = 'button';
1171
+ finishDnfBtn.className = 'finish-dnf-btn';
1172
+ finishDnfBtn.textContent = 'DNF';
1173
+ finishDnfBtn.addEventListener('click', () => setDnf(boatId, true));
1174
+
1175
+ const finishNormalWrap = document.createElement('div');
1176
+ finishNormalWrap.className = 'finish-cell';
1177
+ finishNormalWrap.append(finishTimeInput, finishNowBtn, finishClearBtn, finishDnfBtn);
1178
+
1179
+ const dnfTag = document.createElement('span');
1180
+ dnfTag.className = 'dnf-tag';
1181
+ dnfTag.textContent = 'DNF';
1182
+ const dnfPosSpan = document.createElement('span');
1183
+ dnfPosSpan.className = 'dnf-pos';
1184
+ const undoDnfBtn = document.createElement('button');
1185
+ undoDnfBtn.type = 'button';
1186
+ undoDnfBtn.className = 'undo-dnf-btn';
1187
+ undoDnfBtn.textContent = 'Undo DNF';
1188
+ undoDnfBtn.addEventListener('click', () => setDnf(boatId, false));
1189
+ const dnfWrap = document.createElement('div');
1190
+ dnfWrap.className = 'finish-cell';
1191
+ dnfWrap.append(dnfTag, dnfPosSpan, undoDnfBtn);
1192
+
1193
+ const tdFinish = document.createElement('td');
1194
+ tdFinish.append(finishNormalWrap, dnfWrap);
1195
+
1196
+ const removeBtn = document.createElement('button');
1197
+ removeBtn.type = 'button';
1198
+ removeBtn.className = 'secondary danger remove-boat-btn';
1199
+ removeBtn.textContent = 'Remove';
1200
+ armConfirm(removeBtn, 'Remove', 'Confirm?', async () => {
1201
+ removeBtn.disabled = true;
1202
+ const removed = await removeBoat(boatId);
1203
+ // On success the row (and this button) is gone from the DOM already;
1204
+ // only re-enable if it's still here, i.e. a real failure occurred.
1205
+ if (!removed) removeBtn.disabled = false;
1206
+ });
1207
+ const tdRemove = document.createElement('td');
1208
+ tdRemove.appendChild(removeBtn);
1209
+
1210
+ tr.append(tdName, tdMmsi, tdTcf, tdVet, tdElapsed, tdCorrected, tdEstFinish, tdVsSelf, tdFinish, tdRemove);
1211
+
1212
+ return {
1213
+ tr,
1214
+ selfBtn,
1215
+ nameSpan,
1216
+ tdEstFinish,
1217
+ tdVsSelf,
1218
+ mmsiInput,
1219
+ tcfInput,
1220
+ vetSelect,
1221
+ vetBadge,
1222
+ tdElapsed,
1223
+ tdCorrected,
1224
+ finishNormalWrap,
1225
+ finishTimeInput,
1226
+ finishNowBtn,
1227
+ finishClearBtn,
1228
+ finishDnfBtn,
1229
+ dnfWrap,
1230
+ dnfPosSpan,
1231
+ vetHandicapVersion: -1
1232
+ };
1233
+ }
1234
+
1235
+ // Rebuilds a row's VET-alternatives <select> from the current register —
1236
+ // only called when the register itself (re)loads, not every render tick,
1237
+ // so an open dropdown or mid-pick isn't disrupted every second. Never
1238
+ // called at all while vetEnabled is false (see the call site) — the
1239
+ // "VET alternatives" column is hidden entirely in that case.
1240
+ function refreshVetAlternatives(row, boatName) {
1241
+ const entry = handicapBoats.find((h) => h.name.toLowerCase() === boatName.trim().toLowerCase());
1242
+ row.vetSelect.innerHTML = '';
1243
+ const placeholder = document.createElement('option');
1244
+ placeholder.value = '';
1245
+ placeholder.textContent = entry ? 'Pick VET…' : 'No VET match';
1246
+ row.vetSelect.appendChild(placeholder);
1247
+ row.vetSelect.disabled = !entry;
1248
+ if (entry) {
1249
+ entry.vets.forEach((v) => {
1250
+ const opt = document.createElement('option');
1251
+ opt.value = String(v.value);
1252
+ opt.textContent = `${v.label}: ${v.value}`;
1253
+ row.vetSelect.appendChild(opt);
1254
+ });
1255
+ const notValid = /ikke/i.test(entry.validity || '');
1256
+ row.vetBadge.textContent = entry.validity ? (notValid ? '⚠ ' + entry.validity : entry.validity) : '';
1257
+ row.vetBadge.classList.toggle('warn', notValid);
1258
+ } else {
1259
+ row.vetBadge.textContent = '';
1260
+ row.vetBadge.classList.remove('warn');
1261
+ }
1262
+ }
1263
+
1264
+ // Keeps the select showing whichever alternative is actually in effect
1265
+ // (matching the boat's current TCF) rather than resetting to the
1266
+ // placeholder after a pick — falls back to the placeholder if the TCF was
1267
+ // typed by hand and doesn't match any listed alternative.
1268
+ function syncVetSelectValue(row, tcf) {
1269
+ if (document.activeElement === row.vetSelect) return;
1270
+ const match = Array.from(row.vetSelect.options).find(
1271
+ (o) => o.value !== '' && Math.abs(parseFloat(o.value) - tcf) < 1e-9
1272
+ );
1273
+ row.vetSelect.value = match ? match.value : '';
1274
+ }
1275
+
1276
+ function renderRaceSelect() {
1277
+ const prevFocus = document.activeElement === raceSelect;
1278
+ raceSelect.innerHTML = '';
1279
+ races.forEach((r) => {
1280
+ const opt = document.createElement('option');
1281
+ opt.value = r.id;
1282
+ const when = r.startTime
1283
+ ? new Date(r.startTime).toLocaleString()
1284
+ : r.scheduledStart
1285
+ ? 'scheduled ' + new Date(r.scheduledStart).toLocaleString()
1286
+ : 'not started';
1287
+ opt.textContent = `${r.name} (${when})`;
1288
+ raceSelect.appendChild(opt);
1289
+ });
1290
+ if (activeRaceId) raceSelect.value = activeRaceId;
1291
+ if (prevFocus) raceSelect.focus();
1292
+ }
1293
+
1294
+ function render() {
1295
+ renderRaceSelect();
1296
+ noRacesMsg.hidden = races.length > 0;
1297
+ raceArea.hidden = !raceState;
1298
+ addBoatRow.hidden = !raceState;
1299
+ boatsTable.hidden = !raceState;
1300
+ deleteRaceBtn.hidden = !raceState;
1301
+ exportBtn.hidden = !raceState;
1302
+ courseSection.hidden = !raceState;
1303
+
1304
+ if (!raceState) {
1305
+ emptyMsg.hidden = true;
1306
+ renderChart();
1307
+ return;
1308
+ }
1309
+
1310
+ renderChart();
1311
+
1312
+ const now = Date.now();
1313
+ if (raceState.startTime) {
1314
+ clockEl.textContent = fmtDuration(raceNow() - raceState.startTime);
1315
+ clockEl.classList.remove('countdown');
1316
+ clockEl.classList.toggle('stopped', !!raceState.stopTime);
1317
+ if (raceState.scheduledCallOff && !raceState.stopTime && now >= raceState.scheduledCallOff) {
1318
+ // The scheduled call-off moment has passed locally; poke the server
1319
+ // so we pick up the auto-stop/DNF without waiting for the next poll.
1320
+ loadRaceState();
1321
+ }
1322
+ } else if (raceState.scheduledStart) {
1323
+ clockEl.textContent = '-' + fmtDuration(raceState.scheduledStart - now);
1324
+ clockEl.classList.add('countdown');
1325
+ clockEl.classList.remove('stopped');
1326
+ if (now >= raceState.scheduledStart) {
1327
+ // The scheduled moment has passed locally; poke the server so we
1328
+ // pick up the auto-start without waiting for the next 5s poll.
1329
+ loadRaceState();
1330
+ }
1331
+ } else {
1332
+ clockEl.textContent = '00:00:00';
1333
+ clockEl.classList.remove('countdown', 'stopped');
1334
+ }
1335
+
1336
+ startBtn.disabled = !!raceState.startTime;
1337
+ startBtn.textContent = raceState.startTime ? 'Race Started' : 'Start Race';
1338
+ stopBtn.hidden = !raceState.startTime || !!raceState.stopTime;
1339
+ resumeBtn.hidden = !raceState.stopTime;
1340
+
1341
+ scheduleInput.disabled = !!raceState.startTime;
1342
+ scheduleBtn.disabled = !!raceState.startTime;
1343
+ cancelScheduleBtn.hidden = !raceState.scheduledStart || !!raceState.startTime;
1344
+
1345
+ callOffInput.disabled = !!raceState.stopTime;
1346
+ scheduleCallOffBtn.disabled = !!raceState.stopTime;
1347
+ scheduleCallOffBtn.textContent = raceState.scheduledCallOff
1348
+ ? 'Call-off: ' + new Date(raceState.scheduledCallOff).toLocaleTimeString()
1349
+ : 'Schedule Call-off';
1350
+ cancelCallOffBtn.hidden = !raceState.scheduledCallOff || !!raceState.stopTime;
1351
+
1352
+ const boats = boatList();
1353
+ emptyMsg.hidden = boats.length > 0;
1354
+ const vsSelfMap = computeVsSelf(boats, raceState.selfBoatId);
1355
+
1356
+ boats.forEach((b) => {
1357
+ let row = rows.get(b.boatId);
1358
+ if (!row) {
1359
+ row = buildRow(b.boatId);
1360
+ rows.set(b.boatId, row);
1361
+ }
1362
+ // Moving an already-attached node to its sorted position keeps it
1363
+ // intact (focus, in-progress edits) rather than recreating it — but
1364
+ // the move itself still resets a text cursor and closes an open
1365
+ // <select>, so skip it entirely while the user has something in this
1366
+ // row focused. It'll snap to its correct position as soon as they're
1367
+ // done (next render after focus moves away).
1368
+ if (!row.tr.contains(document.activeElement)) {
1369
+ boatsBody.appendChild(row.tr);
1370
+ }
1371
+
1372
+ row.tr.classList.toggle('finished', !!b.finishTime);
1373
+ row.tr.classList.toggle('dnf', !!b.dnf);
1374
+ row.nameSpan.textContent = b.name;
1375
+ const isSelf = b.boatId === raceState.selfBoatId;
1376
+ row.selfBtn.textContent = isSelf ? '★' : '☆';
1377
+ row.selfBtn.classList.toggle('active', isSelf);
1378
+ if (document.activeElement !== row.mmsiInput) {
1379
+ row.mmsiInput.value = b.mmsi;
1380
+ }
1381
+ if (document.activeElement !== row.tcfInput) {
1382
+ row.tcfInput.value = b.tcf;
1383
+ }
1384
+ if (vetEnabled && row.vetHandicapVersion !== handicapVersion && document.activeElement !== row.vetSelect) {
1385
+ refreshVetAlternatives(row, b.name);
1386
+ row.vetHandicapVersion = handicapVersion;
1387
+ }
1388
+ syncVetSelectValue(row, b.tcf);
1389
+ row.tdElapsed.textContent = fmtDuration(b.elapsedMs);
1390
+ row.tdCorrected.textContent = fmtDuration(b.correctedMs);
1391
+
1392
+ if (b.dnf) {
1393
+ row.tdEstFinish.textContent = 'DNF';
1394
+ } else if (b.finishTime) {
1395
+ row.tdEstFinish.textContent = '—';
1396
+ } else if (b.estimate) {
1397
+ const t = new Date(b.estimate.estFinishTime);
1398
+ const marks = b.estimate.marksRemaining;
1399
+ const viaMarks = marks > 0 ? ` via ${marks} mark${marks === 1 ? '' : 's'}` : '';
1400
+ row.tdEstFinish.innerHTML =
1401
+ `${pad2(t.getHours())}:${pad2(t.getMinutes())}:${pad2(t.getSeconds())}` +
1402
+ `<span class="est-detail">~${b.estimate.remainingNm}nm${viaMarks} @ ${b.estimate.sogKn}kn</span>`;
1403
+ } else {
1404
+ row.tdEstFinish.textContent = '—';
1405
+ }
1406
+
1407
+ const vs = vsSelfMap.get(b.boatId);
1408
+ if (!vs || vs.type === 'none') {
1409
+ row.tdVsSelf.textContent = raceState.selfBoatId ? '—' : '';
1410
+ row.tdVsSelf.className = 'vs-self';
1411
+ row.tdVsSelf.title = '';
1412
+ } else if (vs.type === 'self') {
1413
+ row.tdVsSelf.innerHTML = '<span class="self-tag">SELF</span>';
1414
+ row.tdVsSelf.className = 'vs-self';
1415
+ row.tdVsSelf.title = '';
1416
+ } else if (vs.type === 'countdown') {
1417
+ const behind = vs.remainingMs < 0;
1418
+ row.tdVsSelf.textContent = (behind ? '-' : '') + fmtDuration(Math.abs(vs.remainingMs));
1419
+ row.tdVsSelf.className = 'vs-self ' + (behind ? 'behind' : 'ahead');
1420
+ row.tdVsSelf.title = behind
1421
+ ? 'Self would already finish behind this boat on corrected time'
1422
+ : 'Time self has left to finish and still beat this boat on corrected time';
1423
+ } else {
1424
+ row.tdVsSelf.textContent = formatSignedDuration(vs.gapMs);
1425
+ row.tdVsSelf.className = 'vs-self ' + (vs.gapMs >= 0 ? 'ahead' : 'behind');
1426
+ row.tdVsSelf.title = 'Corrected-time gap to self (positive = self ahead)';
1427
+ }
1428
+
1429
+ row.finishNormalWrap.hidden = b.dnf;
1430
+ row.dnfWrap.hidden = !b.dnf;
1431
+ if (b.dnf) {
1432
+ row.dnfPosSpan.textContent = b.dnfPosition
1433
+ ? `at ${b.dnfPosition.lat.toFixed(4)}°, ${b.dnfPosition.lon.toFixed(4)}°`
1434
+ : 'position unknown';
1435
+ } else {
1436
+ if (document.activeElement !== row.finishTimeInput) {
1437
+ row.finishTimeInput.value = tsToTimeInputValue(b.finishTime);
1438
+ }
1439
+ const canFinish = !!raceState.startTime;
1440
+ row.finishTimeInput.disabled = !canFinish;
1441
+ row.finishNowBtn.disabled = !canFinish;
1442
+ row.finishClearBtn.disabled = !canFinish || !b.finishTime;
1443
+ row.finishDnfBtn.disabled = !canFinish;
1444
+ }
1445
+ });
1446
+
1447
+ const seen = new Set(boats.map((b) => b.boatId));
1448
+ rows.forEach((row, boatId) => {
1449
+ if (!seen.has(boatId)) {
1450
+ row.tr.remove();
1451
+ rows.delete(boatId);
1452
+ }
1453
+ });
1454
+ }
1455
+
1456
+ raceSelect.addEventListener('change', () => selectRace(raceSelect.value));
1457
+ newRaceBtn.addEventListener('click', openNewRaceForm);
1458
+ cancelNewRaceBtn.addEventListener('click', closeNewRaceForm);
1459
+ createRaceBtn.addEventListener('click', submitNewRace);
1460
+ newRaceNameInput.addEventListener('keydown', (e) => {
1461
+ if (e.key === 'Enter') submitNewRace();
1462
+ if (e.key === 'Escape') closeNewRaceForm();
1463
+ });
1464
+ exportBtn.addEventListener('click', exportRace);
1465
+ armConfirm(deleteRaceBtn, 'Delete Race', 'Confirm Delete?', performDeleteRace);
1466
+ startBtn.addEventListener('click', startRace);
1467
+ stopBtn.addEventListener('click', stopRace);
1468
+ resumeBtn.addEventListener('click', resumeRace);
1469
+ armConfirm(resetBtn, 'Reset', 'Confirm Reset?', performResetRace);
1470
+ scheduleBtn.addEventListener('click', scheduleRace);
1471
+ cancelScheduleBtn.addEventListener('click', cancelSchedule);
1472
+ scheduleCallOffBtn.addEventListener('click', scheduleCallOff);
1473
+ cancelCallOffBtn.addEventListener('click', cancelCallOffSchedule);
1474
+ vetRefreshBtn.addEventListener('click', () => loadHandicapRegister(true));
1475
+ addBoatBtn.addEventListener('click', () => {
1476
+ hideSuggestions();
1477
+ addBoat();
1478
+ });
1479
+ addBoatName.addEventListener('input', () => showSuggestionsFor(addBoatName.value));
1480
+ addBoatName.addEventListener('focus', () => {
1481
+ if (addBoatName.value.trim()) showSuggestionsFor(addBoatName.value);
1482
+ });
1483
+ addBoatName.addEventListener('blur', () => hideSuggestions());
1484
+ addBoatName.addEventListener('keydown', (e) => {
1485
+ if (e.key === 'ArrowDown' && suggestionItems.length) {
1486
+ e.preventDefault();
1487
+ suggestionActiveIndex = (suggestionActiveIndex + 1) % suggestionItems.length;
1488
+ renderSuggestionActive();
1489
+ } else if (e.key === 'ArrowUp' && suggestionItems.length) {
1490
+ e.preventDefault();
1491
+ suggestionActiveIndex = (suggestionActiveIndex - 1 + suggestionItems.length) % suggestionItems.length;
1492
+ renderSuggestionActive();
1493
+ } else if (e.key === 'Enter') {
1494
+ e.preventDefault();
1495
+ if (suggestionActiveIndex >= 0 && suggestionItems[suggestionActiveIndex]) {
1496
+ selectSuggestion(suggestionItems[suggestionActiveIndex]);
1497
+ } else {
1498
+ hideSuggestions();
1499
+ addBoat();
1500
+ }
1501
+ } else if (e.key === 'Escape') {
1502
+ hideSuggestions();
1503
+ }
1504
+ });
1505
+ courseToggleBtn.addEventListener('click', () => {
1506
+ courseBody.hidden = !courseBody.hidden;
1507
+ courseToggleBtn.textContent = (courseBody.hidden ? '▸' : '▾') + ' Course & chart';
1508
+ if (!courseBody.hidden) renderChart();
1509
+ });
1510
+ addMarkBtn.addEventListener('click', () => {
1511
+ markRefs.push(buildMarkRow(null));
1512
+ renderMarkRows();
1513
+ });
1514
+ saveCourseBtn.addEventListener('click', saveCourse);
1515
+ replaySlider.addEventListener('input', () => {
1516
+ replayLive = false;
1517
+ replayTime = Number(replaySlider.value);
1518
+ replayTimeLabel.textContent = new Date(replayTime).toLocaleTimeString();
1519
+ renderChart();
1520
+ });
1521
+ replayLiveBtn.addEventListener('click', () => {
1522
+ replayLive = true;
1523
+ renderChart();
1524
+ });
1525
+
1526
+ async function init() {
1527
+ await loadVetEnabled();
1528
+ await Promise.all([loadVessels(), loadBoatRegistry(), loadHandicapRegister(false), loadRacesList()]);
1529
+ await loadRaceState();
1530
+ render();
1531
+ setInterval(render, 1000);
1532
+ setInterval(loadVessels, 10000);
1533
+ setInterval(() => {
1534
+ if (activeRaceId) loadRaceState();
1535
+ }, 5000);
1536
+ setInterval(loadRacesList, 15000);
1537
+ }
1538
+
1539
+ init();
1540
+ })();