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.
Files changed (3) hide show
  1. package/README.md +9 -4
  2. package/index.js +257 -9
  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,
@@ -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 or internet connection.
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, VET-tall handicap lookup, and the
444
- course/chart aren't available offline — TCF is entered by hand.</p>
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
- const html = buildOfflineTimerHtml(race, defaultTcf);
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.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": [