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.
Files changed (3) hide show
  1. package/README.md +9 -4
  2. package/index.js +275 -13
  3. 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 and no internet connection at
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. AIS boat names/positions, VET-tall lookup, and the
123
- course/chart all need the live server and aren't included; TCF is entered by hand
124
- instead. Also has its own "Download results as CSV" button.
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>Race Control — Offline Timer</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 or internet connection.
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, VET-tall handicap lookup, and the
444
- course/chart aren't available offline — TCF is entered by hand.</p>
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
- const html = buildOfflineTimerHtml(race, defaultTcf);
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.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": [