signalk-race-control 0.3.0 → 0.4.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/README.md +9 -4
- package/index.js +257 -9
- 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,
|
|
@@ -398,6 +402,8 @@ h2 { margin: 0 0 0.75rem; font-size: 1.3rem; }
|
|
|
398
402
|
.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
403
|
.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
404
|
.race-start-row button { padding: 0.3rem 0.6rem; font-size: 0.8rem; }
|
|
405
|
+
.vet-status { display: flex; gap: 0.6rem; justify-content: center; align-items: center; margin-top: 0.4rem; }
|
|
406
|
+
.link-btn { background: none; border: none; padding: 0; font-size: 0.8rem; color: var(--accent); text-decoration: underline; cursor: pointer; }
|
|
401
407
|
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
408
|
button.secondary { background: transparent; color: var(--text); }
|
|
403
409
|
button.danger { border-color: var(--bad); color: var(--bad); }
|
|
@@ -420,6 +426,10 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
420
426
|
.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
427
|
.tcf-input::-webkit-outer-spin-button, .tcf-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
|
422
428
|
.tcf-input[type='number'] { -moz-appearance: textfield; }
|
|
429
|
+
.vet-cell { display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; min-width: 11rem; }
|
|
430
|
+
.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; }
|
|
431
|
+
.vet-badge { font-size: 0.7rem; color: var(--muted); }
|
|
432
|
+
.vet-badge.warn { color: var(--bad); }
|
|
423
433
|
.self-btn { background: none; border: none; padding: 0 0.3rem 0 0; font-size: 1rem; color: var(--muted); cursor: pointer; vertical-align: middle; }
|
|
424
434
|
.self-btn.active { color: var(--accent); }
|
|
425
435
|
.vs-self { font-variant-numeric: tabular-nums; font-size: 0.85rem; color: var(--muted); }
|
|
@@ -438,10 +448,10 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
438
448
|
<body>
|
|
439
449
|
<header>
|
|
440
450
|
<h1>Race Control — Offline Timer</h1>
|
|
441
|
-
<p class="offline-note">Standalone backup — works with no server
|
|
451
|
+
<p class="offline-note">Standalone backup — works with no server connection to this plugin.
|
|
442
452
|
Everything you do here is saved in this browser only (reopen this same downloaded file
|
|
443
|
-
to continue). Boat names/positions from AIS
|
|
444
|
-
|
|
453
|
+
to continue). Boat names/positions from AIS and the course/chart aren't available
|
|
454
|
+
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
455
|
<h2 id="raceName"></h2>
|
|
446
456
|
<div id="clock" class="clock">00:00:00</div>
|
|
447
457
|
<div class="controls">
|
|
@@ -456,11 +466,16 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
456
466
|
<button id="raceStartNowBtn" type="button" class="secondary">Now</button>
|
|
457
467
|
<button id="raceStartClearBtn" type="button" class="secondary">Clear</button>
|
|
458
468
|
</div>
|
|
469
|
+
<div id="vetStatusLine" class="status vet-status" hidden>
|
|
470
|
+
<span id="vetStatusText"></span>
|
|
471
|
+
<button id="vetRefreshBtn" type="button" class="link-btn">Refresh VET register</button>
|
|
472
|
+
</div>
|
|
459
473
|
<div id="statusLine" class="status"></div>
|
|
460
474
|
</header>
|
|
461
475
|
<main>
|
|
462
476
|
<div class="add-boat-row">
|
|
463
|
-
<input type="text" id="addBoatName" placeholder="Add boat by name…" autocomplete="off" />
|
|
477
|
+
<input type="text" id="addBoatName" placeholder="Add boat by name…" autocomplete="off" list="addBoatSuggestions" />
|
|
478
|
+
<datalist id="addBoatSuggestions"></datalist>
|
|
464
479
|
<input type="number" id="addBoatTcf" step="0.001" min="0.01" title="TCF" />
|
|
465
480
|
<button id="addBoatBtn">Add Boat</button>
|
|
466
481
|
</div>
|
|
@@ -471,6 +486,7 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
471
486
|
<th>Boat</th>
|
|
472
487
|
<th>Sail #</th>
|
|
473
488
|
<th>TCF</th>
|
|
489
|
+
<th id="vetAlternativesTh" hidden>VET alternatives</th>
|
|
474
490
|
<th>Start time</th>
|
|
475
491
|
<th>Elapsed</th>
|
|
476
492
|
<th>Corrected</th>
|
|
@@ -507,8 +523,16 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
507
523
|
race = seed;
|
|
508
524
|
save();
|
|
509
525
|
}
|
|
526
|
+
// vetEnabled/handicapCsvUrl reflect the server's current plugin config,
|
|
527
|
+
// not user-entered race data, so a re-download's seed always wins for
|
|
528
|
+
// those even if a locally-saved copy is otherwise newer. The fetched
|
|
529
|
+
// register itself is kept from local storage when present, so an
|
|
530
|
+
// already-refreshed register isn't thrown away by a re-download.
|
|
531
|
+
race.vetEnabled = !!seed.vetEnabled;
|
|
532
|
+
race.handicapCsvUrl = seed.handicapCsvUrl || null;
|
|
533
|
+
if (!Array.isArray(race.handicapBoats)) race.handicapBoats = seed.handicapBoats || [];
|
|
510
534
|
} catch (e) {
|
|
511
|
-
race = { name: 'Race', startTime: null, stopTime: null, selfBoatId: null, multiDay: false, boats: [], defaultTcf: 1.0 };
|
|
535
|
+
race = { name: 'Race', startTime: null, stopTime: null, selfBoatId: null, multiDay: false, boats: [], defaultTcf: 1.0, vetEnabled: false, handicapCsvUrl: null, handicapBoats: [] };
|
|
512
536
|
}
|
|
513
537
|
|
|
514
538
|
function save() {
|
|
@@ -558,6 +582,164 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
558
582
|
statusEl.classList.toggle('error', !!isError);
|
|
559
583
|
}
|
|
560
584
|
|
|
585
|
+
var vetStatusLine = document.getElementById('vetStatusLine');
|
|
586
|
+
var vetStatusText = document.getElementById('vetStatusText');
|
|
587
|
+
var vetRefreshBtn = document.getElementById('vetRefreshBtn');
|
|
588
|
+
var vetAlternativesTh = document.getElementById('vetAlternativesTh');
|
|
589
|
+
var addBoatSuggestions = document.getElementById('addBoatSuggestions');
|
|
590
|
+
var handicapVersion = 0;
|
|
591
|
+
function setVetStatus(msg, isError) {
|
|
592
|
+
vetStatusText.textContent = msg || '';
|
|
593
|
+
vetStatusText.classList.toggle('error', !!isError);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Mirrors the plugin server's own CSV parsing (index.js: parseCsvText /
|
|
597
|
+
// parseNorwegianNumber / parseHandicapSheet) so this page can refresh the
|
|
598
|
+
// VET-tall register on its own — the sheet export is CORS-friendly and
|
|
599
|
+
// fetchable directly from a browser, unlike the SSCA lookup page or
|
|
600
|
+
// Manage2Sail, which is why only VET-tall (not fleet import) works offline.
|
|
601
|
+
function parseCsvText(text) {
|
|
602
|
+
var rows = [];
|
|
603
|
+
var row = [];
|
|
604
|
+
var field = '';
|
|
605
|
+
var inQuotes = false;
|
|
606
|
+
for (var i = 0; i < text.length; i++) {
|
|
607
|
+
var c = text[i];
|
|
608
|
+
if (inQuotes) {
|
|
609
|
+
if (c === '"') {
|
|
610
|
+
if (text[i + 1] === '"') { field += '"'; i++; } else { inQuotes = false; }
|
|
611
|
+
} else {
|
|
612
|
+
field += c;
|
|
613
|
+
}
|
|
614
|
+
} else if (c === '"') {
|
|
615
|
+
inQuotes = true;
|
|
616
|
+
} else if (c === ',') {
|
|
617
|
+
row.push(field); field = '';
|
|
618
|
+
} else if (c === '\\n') {
|
|
619
|
+
row.push(field); rows.push(row); row = []; field = '';
|
|
620
|
+
} else if (c === '\\r') {
|
|
621
|
+
// ignore
|
|
622
|
+
} else {
|
|
623
|
+
field += c;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (field.length || row.length) { row.push(field); rows.push(row); }
|
|
627
|
+
return rows;
|
|
628
|
+
}
|
|
629
|
+
function parseNorwegianNumber(s) {
|
|
630
|
+
if (s == null) return null;
|
|
631
|
+
var t = String(s).trim().replace(',', '.');
|
|
632
|
+
if (t === '') return null;
|
|
633
|
+
var n = Number(t);
|
|
634
|
+
return isFinite(n) ? n : null;
|
|
635
|
+
}
|
|
636
|
+
function parseHandicapSheet(csvText) {
|
|
637
|
+
var rows = parseCsvText(csvText);
|
|
638
|
+
var headerRowIndex = -1;
|
|
639
|
+
for (var i = 0; i < rows.length; i++) {
|
|
640
|
+
if (rows[i].indexOf('Klasse') !== -1 && rows[i].indexOf('VET 1') !== -1) { headerRowIndex = i; break; }
|
|
641
|
+
}
|
|
642
|
+
if (headerRowIndex === -1) throw new Error('Could not find the "Klasse" / "VET 1" header row in the handicap sheet');
|
|
643
|
+
var header = rows[headerRowIndex];
|
|
644
|
+
function col(name) { return header.indexOf(name); }
|
|
645
|
+
var vetCols = [col('VET 1'), col('VET 2'), col('VET 3')].filter(function (i) { return i !== -1; });
|
|
646
|
+
var classCol = col('Klasse');
|
|
647
|
+
var ownerCol = col('Eier');
|
|
648
|
+
var boats = [];
|
|
649
|
+
for (var r = headerRowIndex + 1; r < rows.length; r++) {
|
|
650
|
+
var row = rows[r];
|
|
651
|
+
var name = (row[0] || '').trim();
|
|
652
|
+
if (!name) continue;
|
|
653
|
+
var validity = (row[1] || '').trim();
|
|
654
|
+
var vets = vetCols.map(function (vc, idx) {
|
|
655
|
+
var value = parseNorwegianNumber(row[vc]);
|
|
656
|
+
if (value == null) return null;
|
|
657
|
+
var label = (row[vc - 1] || '').replace(/:\\s*$/, '').trim() || ('VET ' + (idx + 1));
|
|
658
|
+
return { label: label, value: value };
|
|
659
|
+
}).filter(Boolean);
|
|
660
|
+
if (!vets.length) continue;
|
|
661
|
+
boats.push({
|
|
662
|
+
name: name,
|
|
663
|
+
validity: validity,
|
|
664
|
+
class: classCol !== -1 ? (row[classCol] || '').trim() : '',
|
|
665
|
+
owner: ownerCol !== -1 ? (row[ownerCol] || '').trim() : '',
|
|
666
|
+
vets: vets
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
return boats;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function rebuildAddBoatSuggestions() {
|
|
673
|
+
addBoatSuggestions.innerHTML = '';
|
|
674
|
+
var seen = {};
|
|
675
|
+
race.handicapBoats.forEach(function (b) {
|
|
676
|
+
var key = b.name.toLowerCase();
|
|
677
|
+
if (seen[key]) return;
|
|
678
|
+
seen[key] = true;
|
|
679
|
+
var opt = document.createElement('option');
|
|
680
|
+
opt.value = b.name;
|
|
681
|
+
addBoatSuggestions.appendChild(opt);
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function loadHandicapRegister(force) {
|
|
686
|
+
if (!race.vetEnabled) return;
|
|
687
|
+
if (!race.handicapCsvUrl) {
|
|
688
|
+
setVetStatus('No VET register configured on the server.', true);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
setVetStatus('Loading VET register…');
|
|
692
|
+
fetch(race.handicapCsvUrl)
|
|
693
|
+
.then(function (res) {
|
|
694
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
695
|
+
return res.text();
|
|
696
|
+
})
|
|
697
|
+
.then(function (csvText) {
|
|
698
|
+
race.handicapBoats = parseHandicapSheet(csvText);
|
|
699
|
+
handicapVersion++;
|
|
700
|
+
save();
|
|
701
|
+
rebuildAddBoatSuggestions();
|
|
702
|
+
setVetStatus('VET register: ' + race.handicapBoats.length + ' boats loaded.');
|
|
703
|
+
render();
|
|
704
|
+
})
|
|
705
|
+
.catch(function (e) {
|
|
706
|
+
setVetStatus('Could not load VET register: ' + e.message, true);
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Rebuilds a row's VET-alternatives <select> from the current register —
|
|
711
|
+
// only called when the register itself (re)loads, not every render tick.
|
|
712
|
+
function refreshVetAlternatives(row, boatName) {
|
|
713
|
+
var entry = race.handicapBoats.find(function (h) { return h.name.toLowerCase() === boatName.trim().toLowerCase(); });
|
|
714
|
+
row.vetSelect.innerHTML = '';
|
|
715
|
+
var placeholder = document.createElement('option');
|
|
716
|
+
placeholder.value = '';
|
|
717
|
+
placeholder.textContent = entry ? 'Pick VET…' : 'No VET match';
|
|
718
|
+
row.vetSelect.appendChild(placeholder);
|
|
719
|
+
row.vetSelect.disabled = !entry;
|
|
720
|
+
if (entry) {
|
|
721
|
+
entry.vets.forEach(function (v) {
|
|
722
|
+
var opt = document.createElement('option');
|
|
723
|
+
opt.value = String(v.value);
|
|
724
|
+
opt.textContent = v.label + ': ' + v.value;
|
|
725
|
+
row.vetSelect.appendChild(opt);
|
|
726
|
+
});
|
|
727
|
+
var notValid = /ikke/i.test(entry.validity || '');
|
|
728
|
+
row.vetBadge.textContent = entry.validity ? (notValid ? '⚠ ' + entry.validity : entry.validity) : '';
|
|
729
|
+
row.vetBadge.classList.toggle('warn', notValid);
|
|
730
|
+
} else {
|
|
731
|
+
row.vetBadge.textContent = '';
|
|
732
|
+
row.vetBadge.classList.remove('warn');
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
function syncVetSelectValue(row, tcf) {
|
|
736
|
+
if (document.activeElement === row.vetSelect) return;
|
|
737
|
+
var match = Array.from(row.vetSelect.options).find(function (o) {
|
|
738
|
+
return o.value !== '' && Math.abs(parseFloat(o.value) - tcf) < 1e-9;
|
|
739
|
+
});
|
|
740
|
+
row.vetSelect.value = match ? match.value : '';
|
|
741
|
+
}
|
|
742
|
+
|
|
561
743
|
function pad(n) { return String(n).padStart(2, '0'); }
|
|
562
744
|
function fmtDuration(ms) {
|
|
563
745
|
if (ms == null || ms < 0 || !isFinite(ms)) return '--:--:--';
|
|
@@ -776,6 +958,27 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
776
958
|
var tdTcf = document.createElement('td');
|
|
777
959
|
tdTcf.appendChild(tcfInput);
|
|
778
960
|
|
|
961
|
+
var vetSelect = document.createElement('select');
|
|
962
|
+
vetSelect.className = 'vet-select';
|
|
963
|
+
vetSelect.addEventListener('change', function () {
|
|
964
|
+
var val = parseFloat(vetSelect.value);
|
|
965
|
+
var boat = findBoat(boatId);
|
|
966
|
+
if (boat && isFinite(val) && val > 0) {
|
|
967
|
+
boat.tcf = val;
|
|
968
|
+
save();
|
|
969
|
+
render();
|
|
970
|
+
}
|
|
971
|
+
// Left showing the picked alternative (synced from tcf on future
|
|
972
|
+
// renders) rather than reset to the placeholder — see
|
|
973
|
+
// syncVetSelectValue.
|
|
974
|
+
});
|
|
975
|
+
var vetBadge = document.createElement('span');
|
|
976
|
+
vetBadge.className = 'vet-badge';
|
|
977
|
+
var tdVet = document.createElement('td');
|
|
978
|
+
tdVet.className = 'vet-cell';
|
|
979
|
+
tdVet.hidden = !race.vetEnabled;
|
|
980
|
+
tdVet.append(vetSelect, vetBadge);
|
|
981
|
+
|
|
779
982
|
var startTimeInput = document.createElement('input');
|
|
780
983
|
startTimeInput.type = race.multiDay ? 'datetime-local' : 'time';
|
|
781
984
|
startTimeInput.step = '1';
|
|
@@ -917,9 +1120,10 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
917
1120
|
var tdRemove = document.createElement('td');
|
|
918
1121
|
tdRemove.appendChild(removeBtn);
|
|
919
1122
|
|
|
920
|
-
tr.append(tdName, tdSailNumber, tdTcf, tdStart, tdElapsed, tdCorrected, tdVsSelf, tdFinish, tdRemove);
|
|
1123
|
+
tr.append(tdName, tdSailNumber, tdTcf, tdVet, tdStart, tdElapsed, tdCorrected, tdVsSelf, tdFinish, tdRemove);
|
|
921
1124
|
return {
|
|
922
1125
|
tr: tr, selfBtn: selfBtn, nameSpan: nameSpan, sailNumberInput: sailNumberInput, tcfInput: tcfInput,
|
|
1126
|
+
vetSelect: vetSelect, vetBadge: vetBadge, vetHandicapVersion: -1,
|
|
923
1127
|
startTimeInput: startTimeInput, startNowBtn: startNowBtn, startClearBtn: startClearBtn,
|
|
924
1128
|
tdElapsed: tdElapsed, tdCorrected: tdCorrected, tdVsSelf: tdVsSelf,
|
|
925
1129
|
finishNormalWrap: finishNormalWrap, finishTimeInput: finishTimeInput, dnfWrap: dnfWrap
|
|
@@ -959,6 +1163,13 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
959
1163
|
row.selfBtn.classList.toggle('active', isSelf);
|
|
960
1164
|
if (document.activeElement !== row.sailNumberInput) row.sailNumberInput.value = b.sailNumber || '';
|
|
961
1165
|
if (document.activeElement !== row.tcfInput) row.tcfInput.value = b.tcf;
|
|
1166
|
+
if (race.vetEnabled) {
|
|
1167
|
+
if (row.vetHandicapVersion !== handicapVersion && document.activeElement !== row.vetSelect) {
|
|
1168
|
+
refreshVetAlternatives(row, b.name);
|
|
1169
|
+
row.vetHandicapVersion = handicapVersion;
|
|
1170
|
+
}
|
|
1171
|
+
syncVetSelectValue(row, b.tcf);
|
|
1172
|
+
}
|
|
962
1173
|
if (document.activeElement !== row.startTimeInput) {
|
|
963
1174
|
row.startTimeInput.value = race.multiDay ? tsToDateTimeInputValue(b.startTime) : tsToTimeInputValue(b.startTime);
|
|
964
1175
|
}
|
|
@@ -1051,6 +1262,17 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
1051
1262
|
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
|
1052
1263
|
});
|
|
1053
1264
|
|
|
1265
|
+
vetStatusLine.hidden = !race.vetEnabled;
|
|
1266
|
+
vetAlternativesTh.hidden = !race.vetEnabled;
|
|
1267
|
+
vetRefreshBtn.addEventListener('click', function () { loadHandicapRegister(true); });
|
|
1268
|
+
if (race.vetEnabled) {
|
|
1269
|
+
rebuildAddBoatSuggestions();
|
|
1270
|
+
if (race.handicapBoats.length) {
|
|
1271
|
+
setVetStatus('VET register: ' + race.handicapBoats.length + ' boats loaded (from last download/refresh).');
|
|
1272
|
+
}
|
|
1273
|
+
loadHandicapRegister();
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1054
1276
|
render();
|
|
1055
1277
|
setInterval(render, 1000);
|
|
1056
1278
|
})();
|
|
@@ -1673,12 +1895,38 @@ module.exports = function (app) {
|
|
|
1673
1895
|
// A standalone, self-contained backup timer: current boats/TCF/progress
|
|
1674
1896
|
// baked in, everything else (start/stop, finishes, DNF, self-compare)
|
|
1675
1897
|
// runs entirely client-side with no server — see buildOfflineTimerHtml.
|
|
1676
|
-
router.get('/races/:id/export-offline.html', (req, res) => {
|
|
1898
|
+
router.get('/races/:id/export-offline.html', async (req, res) => {
|
|
1677
1899
|
const race = getRace(req.params.id);
|
|
1678
1900
|
if (!race) return res.status(404).json({ error: 'No such race' });
|
|
1679
1901
|
ensureRaceShape(race);
|
|
1680
1902
|
const defaultTcf = (plugin.options && plugin.options.defaultTcf) || 1.0;
|
|
1681
|
-
|
|
1903
|
+
// The offline page can fetch a fresh VET-tall register on its own later
|
|
1904
|
+
// (the CSV export is CORS-friendly, unlike the SSCA page or
|
|
1905
|
+
// Manage2Sail), but it's seeded with whatever we can get right now so
|
|
1906
|
+
// it's useful before the first live refresh too.
|
|
1907
|
+
let handicapCsvUrl = null;
|
|
1908
|
+
let handicapBoats = [];
|
|
1909
|
+
if (isVetEnabled()) {
|
|
1910
|
+
try {
|
|
1911
|
+
const data = await fetchHandicapBoats();
|
|
1912
|
+
handicapCsvUrl = data.csvUrl;
|
|
1913
|
+
handicapBoats = data.boats;
|
|
1914
|
+
} catch (e) {
|
|
1915
|
+
try {
|
|
1916
|
+
const sourceUrl = (plugin.options && plugin.options.handicapSourceUrl) || DEFAULT_HANDICAP_SOURCE_PAGE;
|
|
1917
|
+
handicapCsvUrl = await resolveHandicapCsvUrl(sourceUrl);
|
|
1918
|
+
} catch (e2) {
|
|
1919
|
+
// Leave handicapCsvUrl null — the offline page's own refresh will
|
|
1920
|
+
// report a clear error until it's tried again with a working
|
|
1921
|
+
// connection.
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
const html = buildOfflineTimerHtml(race, defaultTcf, {
|
|
1926
|
+
vetEnabled: isVetEnabled(),
|
|
1927
|
+
handicapCsvUrl,
|
|
1928
|
+
handicapBoats
|
|
1929
|
+
});
|
|
1682
1930
|
const safeName = (race.name || 'race').replace(/[^a-z0-9\-_]+/gi, '_').slice(0, 60) || 'race';
|
|
1683
1931
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
1684
1932
|
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.0",
|
|
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": [
|