signalk-race-control 0.3.0 → 0.4.1
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/README.md +9 -4
- package/index.js +275 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -112,16 +112,21 @@ and a live projected finishing order — during and after the race.
|
|
|
112
112
|
- **Download Offline Timer** — a single self-contained `.html` file, seeded with the
|
|
113
113
|
current race's boats, TCF, and multi-day setting, that runs the core of race timing
|
|
114
114
|
(start/stop/resume/reset, add/remove boats, edit TCF, individual start times,
|
|
115
|
-
record finishes, DNF, self-comparison) with no server
|
|
115
|
+
record finishes, DNF, self-comparison) with no server connection to this plugin at
|
|
116
116
|
all — including its own editable **Race start** field (Now/Clear, right under the
|
|
117
117
|
Start/Stop/Resume/Reset buttons) for backdating or correcting the race's start time
|
|
118
118
|
directly, without needing to click Start Race and lose already-recorded progress —
|
|
119
119
|
a backup for keeping a race running if
|
|
120
120
|
this plugin's server becomes unreachable mid-event. It saves everything to that
|
|
121
121
|
browser's own local storage, so closing and reopening the same downloaded file picks
|
|
122
|
-
up right where you left off.
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
up right where you left off. If VET-tall is enabled, the register is seeded in at
|
|
123
|
+
download time and a **Refresh VET register** link lets the offline page re-fetch the
|
|
124
|
+
current sheet straight from Google Sheets (no plugin server needed for that — just
|
|
125
|
+
whatever internet connection the browser has), with the same alternatives dropdown
|
|
126
|
+
and autocomplete as the main webapp. AIS boat names/positions, the course/chart, and
|
|
127
|
+
importing a fleet from Manage2Sail still need the live server (Manage2Sail's own API
|
|
128
|
+
doesn't allow browser-side fetches at all) — TCF is entered by hand for anything the
|
|
129
|
+
VET register doesn't cover. Also has its own "Download results as CSV" button.
|
|
125
130
|
|
|
126
131
|
## Install
|
|
127
132
|
|
package/index.js
CHANGED
|
@@ -345,7 +345,8 @@ function formatLocalDateTime(utcMs, tzOffsetMinutes) {
|
|
|
345
345
|
// downloaded file picks up where it left off. Course/chart, AIS boat
|
|
346
346
|
// names/positions, estimated finish, and VET-tall import all need the live
|
|
347
347
|
// server and are intentionally left out.
|
|
348
|
-
function buildOfflineTimerHtml(race, defaultTcf) {
|
|
348
|
+
function buildOfflineTimerHtml(race, defaultTcf, vetOptions) {
|
|
349
|
+
vetOptions = vetOptions || {};
|
|
349
350
|
const seed = {
|
|
350
351
|
name: race.name,
|
|
351
352
|
startTime: race.startTime,
|
|
@@ -353,6 +354,9 @@ function buildOfflineTimerHtml(race, defaultTcf) {
|
|
|
353
354
|
selfBoatId: race.selfBoatId,
|
|
354
355
|
multiDay: !!race.multiDay,
|
|
355
356
|
defaultTcf: defaultTcf,
|
|
357
|
+
vetEnabled: !!vetOptions.vetEnabled,
|
|
358
|
+
handicapCsvUrl: vetOptions.handicapCsvUrl || null,
|
|
359
|
+
handicapBoats: vetOptions.handicapBoats || [],
|
|
356
360
|
boats: Object.values(race.boats).map((b) => ({
|
|
357
361
|
id: b.id,
|
|
358
362
|
name: b.name,
|
|
@@ -373,13 +377,27 @@ function buildOfflineTimerHtml(race, defaultTcf) {
|
|
|
373
377
|
// prematurely close the tag, e.g. via "</script>" in a boat name) is
|
|
374
378
|
// escaped. JSON.parse doesn't need this reversed; < is valid JSON.
|
|
375
379
|
const seedJson = JSON.stringify(seed).replace(/</g, '\\u003c');
|
|
380
|
+
// The race name also goes straight into HTML attributes/text below (page
|
|
381
|
+
// title, the iOS home-screen app title) — escaped the normal way for that
|
|
382
|
+
// context, distinct from the JSON escaping above.
|
|
383
|
+
const escapedRaceName = String(race.name || 'Race').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
376
384
|
|
|
377
385
|
return `<!doctype html>
|
|
378
386
|
<html lang="en">
|
|
379
387
|
<head>
|
|
380
388
|
<meta charset="utf-8" />
|
|
381
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
382
|
-
<title
|
|
389
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
390
|
+
<title>${escapedRaceName} — Offline Timer</title>
|
|
391
|
+
<meta name="theme-color" content="#0f172a" />
|
|
392
|
+
<!-- Lets "Add to Home Screen" on iOS launch this as a standalone app rather
|
|
393
|
+
than a Safari bookmark — standalone home-screen apps get their own
|
|
394
|
+
persistent storage that isn't subject to Safari's usual after-a-week
|
|
395
|
+
cleanup of unvisited sites, which is what actually makes the saved race
|
|
396
|
+
state stick around between uses. -->
|
|
397
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
398
|
+
<meta name="mobile-web-app-capable" content="yes" />
|
|
399
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
400
|
+
<meta name="apple-mobile-web-app-title" content="${escapedRaceName}" />
|
|
383
401
|
<style>
|
|
384
402
|
:root {
|
|
385
403
|
--bg: #0f172a; --panel: #1e293b; --border: #334155; --text: #e2e8f0;
|
|
@@ -388,7 +406,7 @@ function buildOfflineTimerHtml(race, defaultTcf) {
|
|
|
388
406
|
* { box-sizing: border-box; }
|
|
389
407
|
[hidden] { display: none !important; }
|
|
390
408
|
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); }
|
|
391
|
-
header { padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border); text-align: center; }
|
|
409
|
+
header { padding: max(1.25rem, env(safe-area-inset-top)) 1.5rem 1.25rem; border-bottom: 1px solid var(--border); text-align: center; }
|
|
392
410
|
h1 { margin: 0 0 0.4rem; font-size: 1.1rem; font-weight: 600; letter-spacing: 0.02em; color: var(--muted); text-transform: uppercase; }
|
|
393
411
|
h2 { margin: 0 0 0.75rem; font-size: 1.3rem; }
|
|
394
412
|
.offline-note { max-width: 40rem; margin: 0 auto 1rem; font-size: 0.8rem; color: var(--muted); }
|
|
@@ -398,6 +416,8 @@ h2 { margin: 0 0 0.75rem; font-size: 1.3rem; }
|
|
|
398
416
|
.race-start-row { margin-top: 0.6rem; display: flex; gap: 0.4rem; justify-content: center; align-items: center; font-size: 0.85rem; color: var(--muted); flex-wrap: wrap; }
|
|
399
417
|
.race-start-row input { padding: 0.3rem 0.4rem; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 4px; font-variant-numeric: tabular-nums; }
|
|
400
418
|
.race-start-row button { padding: 0.3rem 0.6rem; font-size: 0.8rem; }
|
|
419
|
+
.vet-status { display: flex; gap: 0.6rem; justify-content: center; align-items: center; margin-top: 0.4rem; }
|
|
420
|
+
.link-btn { background: none; border: none; padding: 0; font-size: 0.8rem; color: var(--accent); text-decoration: underline; cursor: pointer; }
|
|
401
421
|
button { font-size: 0.95rem; padding: 0.5rem 1.1rem; border-radius: 6px; border: 1px solid var(--border); background: var(--accent); color: #04202e; font-weight: 600; cursor: pointer; }
|
|
402
422
|
button.secondary { background: transparent; color: var(--text); }
|
|
403
423
|
button.danger { border-color: var(--bad); color: var(--bad); }
|
|
@@ -405,7 +425,7 @@ button.confirming { background: var(--bad); color: #2a0a0a; border-color: var(--
|
|
|
405
425
|
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
406
426
|
.status { min-height: 1.2em; margin-top: 0.5rem; font-size: 0.85rem; color: var(--muted); }
|
|
407
427
|
.status.error { color: var(--bad); }
|
|
408
|
-
main { padding: 1rem 1.5rem 2rem; max-width: 900px; margin: 0 auto; }
|
|
428
|
+
main { padding: 1rem 1.5rem max(2rem, env(safe-area-inset-bottom)); max-width: 900px; margin: 0 auto; }
|
|
409
429
|
.add-boat-row { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
|
|
410
430
|
.add-boat-row input[type='text'] { flex: 1; min-width: 12rem; padding: 0.45rem 0.6rem; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font-size: 0.9rem; }
|
|
411
431
|
.add-boat-row input[type='number'] { width: 6rem; padding: 0.45rem 0.6rem; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font-size: 0.9rem; }
|
|
@@ -420,6 +440,10 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
420
440
|
.sail-number-input { width: 5.5rem; padding: 0.3rem 0.4rem; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 4px; }
|
|
421
441
|
.tcf-input::-webkit-outer-spin-button, .tcf-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
|
422
442
|
.tcf-input[type='number'] { -moz-appearance: textfield; }
|
|
443
|
+
.vet-cell { display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; min-width: 11rem; }
|
|
444
|
+
.vet-select { max-width: 13rem; padding: 0.3rem 0.4rem; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 4px; font-size: 0.8rem; }
|
|
445
|
+
.vet-badge { font-size: 0.7rem; color: var(--muted); }
|
|
446
|
+
.vet-badge.warn { color: var(--bad); }
|
|
423
447
|
.self-btn { background: none; border: none; padding: 0 0.3rem 0 0; font-size: 1rem; color: var(--muted); cursor: pointer; vertical-align: middle; }
|
|
424
448
|
.self-btn.active { color: var(--accent); }
|
|
425
449
|
.vs-self { font-variant-numeric: tabular-nums; font-size: 0.85rem; color: var(--muted); }
|
|
@@ -438,10 +462,10 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
438
462
|
<body>
|
|
439
463
|
<header>
|
|
440
464
|
<h1>Race Control — Offline Timer</h1>
|
|
441
|
-
<p class="offline-note">Standalone backup — works with no server
|
|
465
|
+
<p class="offline-note">Standalone backup — works with no server connection to this plugin.
|
|
442
466
|
Everything you do here is saved in this browser only (reopen this same downloaded file
|
|
443
|
-
to continue). Boat names/positions from AIS
|
|
444
|
-
|
|
467
|
+
to continue). Boat names/positions from AIS and the course/chart aren't available
|
|
468
|
+
offline. ${vetOptions.vetEnabled ? 'VET-tall handicaps can be refreshed here directly from the internet (see below) — importing a whole fleet from Manage2Sail still needs the live plugin server, though.' : 'TCF is entered by hand.'}</p>
|
|
445
469
|
<h2 id="raceName"></h2>
|
|
446
470
|
<div id="clock" class="clock">00:00:00</div>
|
|
447
471
|
<div class="controls">
|
|
@@ -456,11 +480,16 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
456
480
|
<button id="raceStartNowBtn" type="button" class="secondary">Now</button>
|
|
457
481
|
<button id="raceStartClearBtn" type="button" class="secondary">Clear</button>
|
|
458
482
|
</div>
|
|
483
|
+
<div id="vetStatusLine" class="status vet-status" hidden>
|
|
484
|
+
<span id="vetStatusText"></span>
|
|
485
|
+
<button id="vetRefreshBtn" type="button" class="link-btn">Refresh VET register</button>
|
|
486
|
+
</div>
|
|
459
487
|
<div id="statusLine" class="status"></div>
|
|
460
488
|
</header>
|
|
461
489
|
<main>
|
|
462
490
|
<div class="add-boat-row">
|
|
463
|
-
<input type="text" id="addBoatName" placeholder="Add boat by name…" autocomplete="off" />
|
|
491
|
+
<input type="text" id="addBoatName" placeholder="Add boat by name…" autocomplete="off" list="addBoatSuggestions" />
|
|
492
|
+
<datalist id="addBoatSuggestions"></datalist>
|
|
464
493
|
<input type="number" id="addBoatTcf" step="0.001" min="0.01" title="TCF" />
|
|
465
494
|
<button id="addBoatBtn">Add Boat</button>
|
|
466
495
|
</div>
|
|
@@ -471,6 +500,7 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
471
500
|
<th>Boat</th>
|
|
472
501
|
<th>Sail #</th>
|
|
473
502
|
<th>TCF</th>
|
|
503
|
+
<th id="vetAlternativesTh" hidden>VET alternatives</th>
|
|
474
504
|
<th>Start time</th>
|
|
475
505
|
<th>Elapsed</th>
|
|
476
506
|
<th>Corrected</th>
|
|
@@ -507,8 +537,16 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
507
537
|
race = seed;
|
|
508
538
|
save();
|
|
509
539
|
}
|
|
540
|
+
// vetEnabled/handicapCsvUrl reflect the server's current plugin config,
|
|
541
|
+
// not user-entered race data, so a re-download's seed always wins for
|
|
542
|
+
// those even if a locally-saved copy is otherwise newer. The fetched
|
|
543
|
+
// register itself is kept from local storage when present, so an
|
|
544
|
+
// already-refreshed register isn't thrown away by a re-download.
|
|
545
|
+
race.vetEnabled = !!seed.vetEnabled;
|
|
546
|
+
race.handicapCsvUrl = seed.handicapCsvUrl || null;
|
|
547
|
+
if (!Array.isArray(race.handicapBoats)) race.handicapBoats = seed.handicapBoats || [];
|
|
510
548
|
} catch (e) {
|
|
511
|
-
race = { name: 'Race', startTime: null, stopTime: null, selfBoatId: null, multiDay: false, boats: [], defaultTcf: 1.0 };
|
|
549
|
+
race = { name: 'Race', startTime: null, stopTime: null, selfBoatId: null, multiDay: false, boats: [], defaultTcf: 1.0, vetEnabled: false, handicapCsvUrl: null, handicapBoats: [] };
|
|
512
550
|
}
|
|
513
551
|
|
|
514
552
|
function save() {
|
|
@@ -558,6 +596,164 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
558
596
|
statusEl.classList.toggle('error', !!isError);
|
|
559
597
|
}
|
|
560
598
|
|
|
599
|
+
var vetStatusLine = document.getElementById('vetStatusLine');
|
|
600
|
+
var vetStatusText = document.getElementById('vetStatusText');
|
|
601
|
+
var vetRefreshBtn = document.getElementById('vetRefreshBtn');
|
|
602
|
+
var vetAlternativesTh = document.getElementById('vetAlternativesTh');
|
|
603
|
+
var addBoatSuggestions = document.getElementById('addBoatSuggestions');
|
|
604
|
+
var handicapVersion = 0;
|
|
605
|
+
function setVetStatus(msg, isError) {
|
|
606
|
+
vetStatusText.textContent = msg || '';
|
|
607
|
+
vetStatusText.classList.toggle('error', !!isError);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Mirrors the plugin server's own CSV parsing (index.js: parseCsvText /
|
|
611
|
+
// parseNorwegianNumber / parseHandicapSheet) so this page can refresh the
|
|
612
|
+
// VET-tall register on its own — the sheet export is CORS-friendly and
|
|
613
|
+
// fetchable directly from a browser, unlike the SSCA lookup page or
|
|
614
|
+
// Manage2Sail, which is why only VET-tall (not fleet import) works offline.
|
|
615
|
+
function parseCsvText(text) {
|
|
616
|
+
var rows = [];
|
|
617
|
+
var row = [];
|
|
618
|
+
var field = '';
|
|
619
|
+
var inQuotes = false;
|
|
620
|
+
for (var i = 0; i < text.length; i++) {
|
|
621
|
+
var c = text[i];
|
|
622
|
+
if (inQuotes) {
|
|
623
|
+
if (c === '"') {
|
|
624
|
+
if (text[i + 1] === '"') { field += '"'; i++; } else { inQuotes = false; }
|
|
625
|
+
} else {
|
|
626
|
+
field += c;
|
|
627
|
+
}
|
|
628
|
+
} else if (c === '"') {
|
|
629
|
+
inQuotes = true;
|
|
630
|
+
} else if (c === ',') {
|
|
631
|
+
row.push(field); field = '';
|
|
632
|
+
} else if (c === '\\n') {
|
|
633
|
+
row.push(field); rows.push(row); row = []; field = '';
|
|
634
|
+
} else if (c === '\\r') {
|
|
635
|
+
// ignore
|
|
636
|
+
} else {
|
|
637
|
+
field += c;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (field.length || row.length) { row.push(field); rows.push(row); }
|
|
641
|
+
return rows;
|
|
642
|
+
}
|
|
643
|
+
function parseNorwegianNumber(s) {
|
|
644
|
+
if (s == null) return null;
|
|
645
|
+
var t = String(s).trim().replace(',', '.');
|
|
646
|
+
if (t === '') return null;
|
|
647
|
+
var n = Number(t);
|
|
648
|
+
return isFinite(n) ? n : null;
|
|
649
|
+
}
|
|
650
|
+
function parseHandicapSheet(csvText) {
|
|
651
|
+
var rows = parseCsvText(csvText);
|
|
652
|
+
var headerRowIndex = -1;
|
|
653
|
+
for (var i = 0; i < rows.length; i++) {
|
|
654
|
+
if (rows[i].indexOf('Klasse') !== -1 && rows[i].indexOf('VET 1') !== -1) { headerRowIndex = i; break; }
|
|
655
|
+
}
|
|
656
|
+
if (headerRowIndex === -1) throw new Error('Could not find the "Klasse" / "VET 1" header row in the handicap sheet');
|
|
657
|
+
var header = rows[headerRowIndex];
|
|
658
|
+
function col(name) { return header.indexOf(name); }
|
|
659
|
+
var vetCols = [col('VET 1'), col('VET 2'), col('VET 3')].filter(function (i) { return i !== -1; });
|
|
660
|
+
var classCol = col('Klasse');
|
|
661
|
+
var ownerCol = col('Eier');
|
|
662
|
+
var boats = [];
|
|
663
|
+
for (var r = headerRowIndex + 1; r < rows.length; r++) {
|
|
664
|
+
var row = rows[r];
|
|
665
|
+
var name = (row[0] || '').trim();
|
|
666
|
+
if (!name) continue;
|
|
667
|
+
var validity = (row[1] || '').trim();
|
|
668
|
+
var vets = vetCols.map(function (vc, idx) {
|
|
669
|
+
var value = parseNorwegianNumber(row[vc]);
|
|
670
|
+
if (value == null) return null;
|
|
671
|
+
var label = (row[vc - 1] || '').replace(/:\\s*$/, '').trim() || ('VET ' + (idx + 1));
|
|
672
|
+
return { label: label, value: value };
|
|
673
|
+
}).filter(Boolean);
|
|
674
|
+
if (!vets.length) continue;
|
|
675
|
+
boats.push({
|
|
676
|
+
name: name,
|
|
677
|
+
validity: validity,
|
|
678
|
+
class: classCol !== -1 ? (row[classCol] || '').trim() : '',
|
|
679
|
+
owner: ownerCol !== -1 ? (row[ownerCol] || '').trim() : '',
|
|
680
|
+
vets: vets
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
return boats;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function rebuildAddBoatSuggestions() {
|
|
687
|
+
addBoatSuggestions.innerHTML = '';
|
|
688
|
+
var seen = {};
|
|
689
|
+
race.handicapBoats.forEach(function (b) {
|
|
690
|
+
var key = b.name.toLowerCase();
|
|
691
|
+
if (seen[key]) return;
|
|
692
|
+
seen[key] = true;
|
|
693
|
+
var opt = document.createElement('option');
|
|
694
|
+
opt.value = b.name;
|
|
695
|
+
addBoatSuggestions.appendChild(opt);
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function loadHandicapRegister(force) {
|
|
700
|
+
if (!race.vetEnabled) return;
|
|
701
|
+
if (!race.handicapCsvUrl) {
|
|
702
|
+
setVetStatus('No VET register configured on the server.', true);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
setVetStatus('Loading VET register…');
|
|
706
|
+
fetch(race.handicapCsvUrl)
|
|
707
|
+
.then(function (res) {
|
|
708
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
709
|
+
return res.text();
|
|
710
|
+
})
|
|
711
|
+
.then(function (csvText) {
|
|
712
|
+
race.handicapBoats = parseHandicapSheet(csvText);
|
|
713
|
+
handicapVersion++;
|
|
714
|
+
save();
|
|
715
|
+
rebuildAddBoatSuggestions();
|
|
716
|
+
setVetStatus('VET register: ' + race.handicapBoats.length + ' boats loaded.');
|
|
717
|
+
render();
|
|
718
|
+
})
|
|
719
|
+
.catch(function (e) {
|
|
720
|
+
setVetStatus('Could not load VET register: ' + e.message, true);
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Rebuilds a row's VET-alternatives <select> from the current register —
|
|
725
|
+
// only called when the register itself (re)loads, not every render tick.
|
|
726
|
+
function refreshVetAlternatives(row, boatName) {
|
|
727
|
+
var entry = race.handicapBoats.find(function (h) { return h.name.toLowerCase() === boatName.trim().toLowerCase(); });
|
|
728
|
+
row.vetSelect.innerHTML = '';
|
|
729
|
+
var placeholder = document.createElement('option');
|
|
730
|
+
placeholder.value = '';
|
|
731
|
+
placeholder.textContent = entry ? 'Pick VET…' : 'No VET match';
|
|
732
|
+
row.vetSelect.appendChild(placeholder);
|
|
733
|
+
row.vetSelect.disabled = !entry;
|
|
734
|
+
if (entry) {
|
|
735
|
+
entry.vets.forEach(function (v) {
|
|
736
|
+
var opt = document.createElement('option');
|
|
737
|
+
opt.value = String(v.value);
|
|
738
|
+
opt.textContent = v.label + ': ' + v.value;
|
|
739
|
+
row.vetSelect.appendChild(opt);
|
|
740
|
+
});
|
|
741
|
+
var notValid = /ikke/i.test(entry.validity || '');
|
|
742
|
+
row.vetBadge.textContent = entry.validity ? (notValid ? '⚠ ' + entry.validity : entry.validity) : '';
|
|
743
|
+
row.vetBadge.classList.toggle('warn', notValid);
|
|
744
|
+
} else {
|
|
745
|
+
row.vetBadge.textContent = '';
|
|
746
|
+
row.vetBadge.classList.remove('warn');
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
function syncVetSelectValue(row, tcf) {
|
|
750
|
+
if (document.activeElement === row.vetSelect) return;
|
|
751
|
+
var match = Array.from(row.vetSelect.options).find(function (o) {
|
|
752
|
+
return o.value !== '' && Math.abs(parseFloat(o.value) - tcf) < 1e-9;
|
|
753
|
+
});
|
|
754
|
+
row.vetSelect.value = match ? match.value : '';
|
|
755
|
+
}
|
|
756
|
+
|
|
561
757
|
function pad(n) { return String(n).padStart(2, '0'); }
|
|
562
758
|
function fmtDuration(ms) {
|
|
563
759
|
if (ms == null || ms < 0 || !isFinite(ms)) return '--:--:--';
|
|
@@ -776,6 +972,27 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
776
972
|
var tdTcf = document.createElement('td');
|
|
777
973
|
tdTcf.appendChild(tcfInput);
|
|
778
974
|
|
|
975
|
+
var vetSelect = document.createElement('select');
|
|
976
|
+
vetSelect.className = 'vet-select';
|
|
977
|
+
vetSelect.addEventListener('change', function () {
|
|
978
|
+
var val = parseFloat(vetSelect.value);
|
|
979
|
+
var boat = findBoat(boatId);
|
|
980
|
+
if (boat && isFinite(val) && val > 0) {
|
|
981
|
+
boat.tcf = val;
|
|
982
|
+
save();
|
|
983
|
+
render();
|
|
984
|
+
}
|
|
985
|
+
// Left showing the picked alternative (synced from tcf on future
|
|
986
|
+
// renders) rather than reset to the placeholder — see
|
|
987
|
+
// syncVetSelectValue.
|
|
988
|
+
});
|
|
989
|
+
var vetBadge = document.createElement('span');
|
|
990
|
+
vetBadge.className = 'vet-badge';
|
|
991
|
+
var tdVet = document.createElement('td');
|
|
992
|
+
tdVet.className = 'vet-cell';
|
|
993
|
+
tdVet.hidden = !race.vetEnabled;
|
|
994
|
+
tdVet.append(vetSelect, vetBadge);
|
|
995
|
+
|
|
779
996
|
var startTimeInput = document.createElement('input');
|
|
780
997
|
startTimeInput.type = race.multiDay ? 'datetime-local' : 'time';
|
|
781
998
|
startTimeInput.step = '1';
|
|
@@ -917,9 +1134,10 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
917
1134
|
var tdRemove = document.createElement('td');
|
|
918
1135
|
tdRemove.appendChild(removeBtn);
|
|
919
1136
|
|
|
920
|
-
tr.append(tdName, tdSailNumber, tdTcf, tdStart, tdElapsed, tdCorrected, tdVsSelf, tdFinish, tdRemove);
|
|
1137
|
+
tr.append(tdName, tdSailNumber, tdTcf, tdVet, tdStart, tdElapsed, tdCorrected, tdVsSelf, tdFinish, tdRemove);
|
|
921
1138
|
return {
|
|
922
1139
|
tr: tr, selfBtn: selfBtn, nameSpan: nameSpan, sailNumberInput: sailNumberInput, tcfInput: tcfInput,
|
|
1140
|
+
vetSelect: vetSelect, vetBadge: vetBadge, vetHandicapVersion: -1,
|
|
923
1141
|
startTimeInput: startTimeInput, startNowBtn: startNowBtn, startClearBtn: startClearBtn,
|
|
924
1142
|
tdElapsed: tdElapsed, tdCorrected: tdCorrected, tdVsSelf: tdVsSelf,
|
|
925
1143
|
finishNormalWrap: finishNormalWrap, finishTimeInput: finishTimeInput, dnfWrap: dnfWrap
|
|
@@ -959,6 +1177,13 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
959
1177
|
row.selfBtn.classList.toggle('active', isSelf);
|
|
960
1178
|
if (document.activeElement !== row.sailNumberInput) row.sailNumberInput.value = b.sailNumber || '';
|
|
961
1179
|
if (document.activeElement !== row.tcfInput) row.tcfInput.value = b.tcf;
|
|
1180
|
+
if (race.vetEnabled) {
|
|
1181
|
+
if (row.vetHandicapVersion !== handicapVersion && document.activeElement !== row.vetSelect) {
|
|
1182
|
+
refreshVetAlternatives(row, b.name);
|
|
1183
|
+
row.vetHandicapVersion = handicapVersion;
|
|
1184
|
+
}
|
|
1185
|
+
syncVetSelectValue(row, b.tcf);
|
|
1186
|
+
}
|
|
962
1187
|
if (document.activeElement !== row.startTimeInput) {
|
|
963
1188
|
row.startTimeInput.value = race.multiDay ? tsToDateTimeInputValue(b.startTime) : tsToTimeInputValue(b.startTime);
|
|
964
1189
|
}
|
|
@@ -1051,6 +1276,17 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
1051
1276
|
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
|
1052
1277
|
});
|
|
1053
1278
|
|
|
1279
|
+
vetStatusLine.hidden = !race.vetEnabled;
|
|
1280
|
+
vetAlternativesTh.hidden = !race.vetEnabled;
|
|
1281
|
+
vetRefreshBtn.addEventListener('click', function () { loadHandicapRegister(true); });
|
|
1282
|
+
if (race.vetEnabled) {
|
|
1283
|
+
rebuildAddBoatSuggestions();
|
|
1284
|
+
if (race.handicapBoats.length) {
|
|
1285
|
+
setVetStatus('VET register: ' + race.handicapBoats.length + ' boats loaded (from last download/refresh).');
|
|
1286
|
+
}
|
|
1287
|
+
loadHandicapRegister();
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1054
1290
|
render();
|
|
1055
1291
|
setInterval(render, 1000);
|
|
1056
1292
|
})();
|
|
@@ -1673,12 +1909,38 @@ module.exports = function (app) {
|
|
|
1673
1909
|
// A standalone, self-contained backup timer: current boats/TCF/progress
|
|
1674
1910
|
// baked in, everything else (start/stop, finishes, DNF, self-compare)
|
|
1675
1911
|
// runs entirely client-side with no server — see buildOfflineTimerHtml.
|
|
1676
|
-
router.get('/races/:id/export-offline.html', (req, res) => {
|
|
1912
|
+
router.get('/races/:id/export-offline.html', async (req, res) => {
|
|
1677
1913
|
const race = getRace(req.params.id);
|
|
1678
1914
|
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
1679
1915
|
ensureRaceShape(race);
|
|
1680
1916
|
const defaultTcf = (plugin.options && plugin.options.defaultTcf) || 1.0;
|
|
1681
|
-
|
|
1917
|
+
// The offline page can fetch a fresh VET-tall register on its own later
|
|
1918
|
+
// (the CSV export is CORS-friendly, unlike the SSCA page or
|
|
1919
|
+
// Manage2Sail), but it's seeded with whatever we can get right now so
|
|
1920
|
+
// it's useful before the first live refresh too.
|
|
1921
|
+
let handicapCsvUrl = null;
|
|
1922
|
+
let handicapBoats = [];
|
|
1923
|
+
if (isVetEnabled()) {
|
|
1924
|
+
try {
|
|
1925
|
+
const data = await fetchHandicapBoats();
|
|
1926
|
+
handicapCsvUrl = data.csvUrl;
|
|
1927
|
+
handicapBoats = data.boats;
|
|
1928
|
+
} catch (e) {
|
|
1929
|
+
try {
|
|
1930
|
+
const sourceUrl = (plugin.options && plugin.options.handicapSourceUrl) || DEFAULT_HANDICAP_SOURCE_PAGE;
|
|
1931
|
+
handicapCsvUrl = await resolveHandicapCsvUrl(sourceUrl);
|
|
1932
|
+
} catch (e2) {
|
|
1933
|
+
// Leave handicapCsvUrl null — the offline page's own refresh will
|
|
1934
|
+
// report a clear error until it's tried again with a working
|
|
1935
|
+
// connection.
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
const html = buildOfflineTimerHtml(race, defaultTcf, {
|
|
1940
|
+
vetEnabled: isVetEnabled(),
|
|
1941
|
+
handicapCsvUrl,
|
|
1942
|
+
handicapBoats
|
|
1943
|
+
});
|
|
1682
1944
|
const safeName = (race.name || 'race').replace(/[^a-z0-9\-_]+/gi, '_').slice(0, 60) || 'race';
|
|
1683
1945
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
1684
1946
|
res.setHeader('Content-Disposition', `attachment; filename="${safeName}-offline-timer.html"`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-race-control",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "SignalK plugin and webapp for tracking elapsed and handicap-corrected (Time-on-Time) race time, with a per-boat editable TCF and boat names pulled from AIS when available.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"keywords": [
|