signalk-race-control 0.2.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 CHANGED
@@ -53,6 +53,23 @@ and a live projected finishing order — during and after the race.
53
53
  from a live AIS/self vessel with a matching name), it's remembered in a small
54
54
  cross-race registry: add a boat with the same name in a later race and its MMSI
55
55
  fills in on its own.
56
+ - **Import a whole fleet from Manage2Sail** — off by default (`raceImportEnabled` in
57
+ the plugin's settings, alongside `vetEnabled`); once turned on, an **Import boats
58
+ from Manage2Sail** section lets you paste a Manage2Sail event URL, pick one or more
59
+ of its classes, and add every entry in them as a boat in the current race. Since a
60
+ class's published handicap number isn't always a directly-usable Time-on-Time
61
+ factor — Yardstick numbers, for instance, run the opposite direction (lower number
62
+ = faster boat) and need `TCF = 100 / number` (or `1000 /` for the RYA scale) rather
63
+ than being used as-is — the plugin converts it automatically once it recognizes the
64
+ system. If a class's numbers don't clearly match a known system's scale, you're
65
+ asked to pick which one applies (e.g. Yardstick vs. Portsmouth Yardstick) before
66
+ anything is imported for it; anything else unrecognized (ORC, IRC, ...) is used
67
+ as-is, same as before. Re-importing updates TCF on boats it already added (matched
68
+ by name) instead of duplicating them. Not every entry has a named boat — where
69
+ Manage2Sail has no boat name, the sail number is used as the identifier instead of
70
+ falling back to the skipper's name, and every imported boat's sail number is also
71
+ set in its own **Sail #** column (editable directly, like MMSI) whether or not it
72
+ ended up as the name.
56
73
  - **Editable finish times** — click **Now** to record a finish as it happens, type a
57
74
  specific `HH:MM:SS` into the finish-time field to correct a mistimed click, or
58
75
  **Clear** to undo. Handles races that cross midnight.
@@ -88,23 +105,28 @@ and a live projected finishing order — during and after the race.
88
105
  both have finished. With no boat marked self, the column instead compares everyone
89
106
  against the current **leader** (tagged accordingly), so it's never just blank.
90
107
  - **Export to Excel** — a genuine `.xlsx` snapshot of the current standings (same
91
- ranking, same rows as the on-screen table: rank, boat, MMSI, TCF, start time,
92
- elapsed, corrected, finish time, status), with the time columns rendered in your
93
- browser's own timezone rather than the server's (and with the date included, for a
94
- multi-day race).
108
+ ranking, same rows as the on-screen table: rank, boat, sail number, MMSI, TCF,
109
+ start time, elapsed, corrected, finish time, status), with the time columns
110
+ rendered in your browser's own timezone rather than the server's (and with the
111
+ date included, for a multi-day race).
95
112
  - **Download Offline Timer** — a single self-contained `.html` file, seeded with the
96
113
  current race's boats, TCF, and multi-day setting, that runs the core of race timing
97
114
  (start/stop/resume/reset, add/remove boats, edit TCF, individual start times,
98
- 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
99
116
  all — including its own editable **Race start** field (Now/Clear, right under the
100
117
  Start/Stop/Resume/Reset buttons) for backdating or correcting the race's start time
101
118
  directly, without needing to click Start Race and lose already-recorded progress —
102
119
  a backup for keeping a race running if
103
120
  this plugin's server becomes unreachable mid-event. It saves everything to that
104
121
  browser's own local storage, so closing and reopening the same downloaded file picks
105
- up right where you left off. AIS boat names/positions, VET-tall lookup, and the
106
- course/chart all need the live server and aren't included; TCF is entered by hand
107
- 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.
108
130
 
109
131
  ## Install
110
132
 
package/index.js CHANGED
@@ -125,6 +125,92 @@ async function resolveHandicapCsvUrl(sourceUrl) {
125
125
  return `https://docs.google.com/spreadsheets/d/${sheetId}/export?format=csv`;
126
126
  }
127
127
 
128
+ // Manage2Sail has no public "list classes for this event" API — the event
129
+ // page itself embeds the classes ("regattas" in their terminology) as JSON
130
+ // in a window.boostrapedResourceData script tag, and the event's own GUID
131
+ // (needed for the entries API below) only appears in a support-form link on
132
+ // the same page. Both are scraped from the page's HTML.
133
+ function parseManage2SailEventPage(html) {
134
+ const eventIdMatch = html.match(/EventIssue\?eventId=([0-9a-f-]{36})/i);
135
+ if (!eventIdMatch) {
136
+ throw new Error("Could not find this event's id on the page — check the URL is a Manage2Sail event page");
137
+ }
138
+ const dataMatch = html.match(/window\.boostrapedResourceData\s*=\s*(\{.*?\});/s);
139
+ if (!dataMatch) {
140
+ throw new Error('Could not find class data on the page — check the URL is a Manage2Sail event page');
141
+ }
142
+ let data;
143
+ try {
144
+ data = JSON.parse(dataMatch[1]);
145
+ } catch (e) {
146
+ throw new Error('Could not parse class data from the Manage2Sail page');
147
+ }
148
+ const classes = (data.Regatta || []).map((r) => ({ id: r.Id, name: r.Name }));
149
+ return { eventId: eventIdMatch[1], classes };
150
+ }
151
+
152
+ // Many entries (dinghies, small keelboats) have no named boat — the display
153
+ // name falls back through BoatName -> SailNumber -> TeamName -> SkipperName.
154
+ // SailNumber comes before Team/SkipperName specifically because it
155
+ // identifies the boat itself (stable across a re-import even if the
156
+ // skipper changes), where a person's name doesn't.
157
+ //
158
+ // hcp is left as the raw published number here — converting it to a usable
159
+ // TCF depends on which handicap system it's actually under (see
160
+ // HANDICAP_SYSTEMS / resolveHandicapSystem below), which isn't known until
161
+ // the caller has both hcpName and a look at the actual values.
162
+ function parseManage2SailEntries(json) {
163
+ const hcpName = json.HcpName || '';
164
+ const entries = [];
165
+ let skipped = 0;
166
+ (json.Entries || []).forEach((e) => {
167
+ const name = ((e.BoatName || e.SailNumber || e.TeamName || e.SkipperName || '') + '').trim();
168
+ const hcp = parseFloat(((e.Hcp || '') + '').replace(',', '.'));
169
+ if (!name || !isFinite(hcp) || hcp <= 0) {
170
+ skipped++;
171
+ return;
172
+ }
173
+ entries.push({ name, hcp, sailNumber: e.SailNumber || '' });
174
+ });
175
+ return { hcpName, entries, skipped };
176
+ }
177
+
178
+ // Handicap systems this plugin knows how to turn into a Time-on-Time TCF
179
+ // (corrected = elapsed * TCF, higher = faster). "tcf" is the fallback: the
180
+ // published number is assumed to already be usable as-is. Yardstick systems
181
+ // work the other way around (lower number = faster boat) and aren't a
182
+ // direct multiplier, so they need the conversion below instead.
183
+ const HANDICAP_SYSTEMS = [
184
+ { key: 'tcf', label: 'Time-on-Time — use the published number as TCF directly', convert: (hcp) => hcp },
185
+ { key: 'ys', label: 'Yardstick — YS (German/DSV, scale 100): TCF = 100 / number', convert: (hcp) => 100 / hcp },
186
+ { key: 'py', label: 'Portsmouth Yardstick — PY/PN (RYA, scale 1000): TCF = 1000 / number', convert: (hcp) => 1000 / hcp }
187
+ ];
188
+
189
+ function findHandicapSystem(key) {
190
+ return HANDICAP_SYSTEMS.find((s) => s.key === key) || null;
191
+ }
192
+
193
+ // hcpName alone doesn't reliably say which system is in play — different
194
+ // clubs/countries publish under the same label with very different scales.
195
+ // Where the label maps to exactly one system whose scale actually matches
196
+ // the observed values, resolve automatically; otherwise report the
197
+ // candidates so the caller can ask the user to pick.
198
+ function resolveHandicapSystem(hcpName, sampleHcpValues) {
199
+ const name = (hcpName || '').trim().toUpperCase();
200
+ const looksDsvScale = sampleHcpValues.length > 0 && sampleHcpValues.every((v) => v >= 40 && v <= 250);
201
+ const looksRyaScale = sampleHcpValues.length > 0 && sampleHcpValues.every((v) => v >= 400 && v <= 3000);
202
+ if (name === 'YS') {
203
+ if (looksDsvScale && !looksRyaScale) return { resolved: 'ys' };
204
+ if (looksRyaScale && !looksDsvScale) return { resolved: 'py' };
205
+ return { resolved: null, candidates: ['ys', 'py', 'tcf'] };
206
+ }
207
+ if (name === 'PY' || name === 'PN') {
208
+ return { resolved: 'py' };
209
+ }
210
+ // Unrecognized/blank/ORC/IRC/etc. — default to treating it as already TCF.
211
+ return { resolved: 'tcf' };
212
+ }
213
+
128
214
  function makeRaceId() {
129
215
  return 'r' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
130
216
  }
@@ -164,6 +250,7 @@ function ensureRaceShape(race) {
164
250
  if (b.dnf === undefined) b.dnf = false;
165
251
  if (b.dnfPosition === undefined) b.dnfPosition = null;
166
252
  if (b.startTime === undefined) b.startTime = null;
253
+ if (b.sailNumber === undefined) b.sailNumber = null;
167
254
  });
168
255
  }
169
256
 
@@ -258,7 +345,8 @@ function formatLocalDateTime(utcMs, tzOffsetMinutes) {
258
345
  // downloaded file picks up where it left off. Course/chart, AIS boat
259
346
  // names/positions, estimated finish, and VET-tall import all need the live
260
347
  // server and are intentionally left out.
261
- function buildOfflineTimerHtml(race, defaultTcf) {
348
+ function buildOfflineTimerHtml(race, defaultTcf, vetOptions) {
349
+ vetOptions = vetOptions || {};
262
350
  const seed = {
263
351
  name: race.name,
264
352
  startTime: race.startTime,
@@ -266,9 +354,13 @@ function buildOfflineTimerHtml(race, defaultTcf) {
266
354
  selfBoatId: race.selfBoatId,
267
355
  multiDay: !!race.multiDay,
268
356
  defaultTcf: defaultTcf,
357
+ vetEnabled: !!vetOptions.vetEnabled,
358
+ handicapCsvUrl: vetOptions.handicapCsvUrl || null,
359
+ handicapBoats: vetOptions.handicapBoats || [],
269
360
  boats: Object.values(race.boats).map((b) => ({
270
361
  id: b.id,
271
362
  name: b.name,
363
+ sailNumber: b.sailNumber || null,
272
364
  tcf: b.tcf != null ? b.tcf : defaultTcf,
273
365
  startTime: b.startTime || null,
274
366
  finishTime: b.finishTime || null,
@@ -310,6 +402,8 @@ h2 { margin: 0 0 0.75rem; font-size: 1.3rem; }
310
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; }
311
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; }
312
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; }
313
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; }
314
408
  button.secondary { background: transparent; color: var(--text); }
315
409
  button.danger { border-color: var(--bad); color: var(--bad); }
@@ -329,8 +423,13 @@ tbody tr.finished td { color: var(--good); }
329
423
  tbody tr.dnf td { color: var(--muted); }
330
424
  .dnf-tag { color: var(--bad); font-weight: 700; font-size: 0.85rem; letter-spacing: 0.03em; }
331
425
  .tcf-input { width: 5.5rem; padding: 0.3rem 0.4rem; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 4px; }
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; }
332
427
  .tcf-input::-webkit-outer-spin-button, .tcf-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
333
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); }
334
433
  .self-btn { background: none; border: none; padding: 0 0.3rem 0 0; font-size: 1rem; color: var(--muted); cursor: pointer; vertical-align: middle; }
335
434
  .self-btn.active { color: var(--accent); }
336
435
  .vs-self { font-variant-numeric: tabular-nums; font-size: 0.85rem; color: var(--muted); }
@@ -349,10 +448,10 @@ tbody tr.dnf td { color: var(--muted); }
349
448
  <body>
350
449
  <header>
351
450
  <h1>Race Control — Offline Timer</h1>
352
- <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.
353
452
  Everything you do here is saved in this browser only (reopen this same downloaded file
354
- to continue). Boat names/positions from AIS, VET-tall handicap lookup, and the
355
- 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>
356
455
  <h2 id="raceName"></h2>
357
456
  <div id="clock" class="clock">00:00:00</div>
358
457
  <div class="controls">
@@ -367,11 +466,16 @@ tbody tr.dnf td { color: var(--muted); }
367
466
  <button id="raceStartNowBtn" type="button" class="secondary">Now</button>
368
467
  <button id="raceStartClearBtn" type="button" class="secondary">Clear</button>
369
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>
370
473
  <div id="statusLine" class="status"></div>
371
474
  </header>
372
475
  <main>
373
476
  <div class="add-boat-row">
374
- <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>
375
479
  <input type="number" id="addBoatTcf" step="0.001" min="0.01" title="TCF" />
376
480
  <button id="addBoatBtn">Add Boat</button>
377
481
  </div>
@@ -380,7 +484,9 @@ tbody tr.dnf td { color: var(--muted); }
380
484
  <thead>
381
485
  <tr>
382
486
  <th>Boat</th>
487
+ <th>Sail #</th>
383
488
  <th>TCF</th>
489
+ <th id="vetAlternativesTh" hidden>VET alternatives</th>
384
490
  <th>Start time</th>
385
491
  <th>Elapsed</th>
386
492
  <th>Corrected</th>
@@ -417,8 +523,16 @@ tbody tr.dnf td { color: var(--muted); }
417
523
  race = seed;
418
524
  save();
419
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 || [];
420
534
  } catch (e) {
421
- 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: [] };
422
536
  }
423
537
 
424
538
  function save() {
@@ -468,6 +582,164 @@ tbody tr.dnf td { color: var(--muted); }
468
582
  statusEl.classList.toggle('error', !!isError);
469
583
  }
470
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
+
471
743
  function pad(n) { return String(n).padStart(2, '0'); }
472
744
  function fmtDuration(ms) {
473
745
  if (ms == null || ms < 0 || !isFinite(ms)) return '--:--:--';
@@ -653,6 +925,20 @@ tbody tr.dnf td { color: var(--muted); }
653
925
  var tdName = document.createElement('td');
654
926
  tdName.append(selfBtn, nameSpan);
655
927
 
928
+ var sailNumberInput = document.createElement('input');
929
+ sailNumberInput.type = 'text';
930
+ sailNumberInput.className = 'sail-number-input';
931
+ sailNumberInput.placeholder = 'Sail #';
932
+ sailNumberInput.addEventListener('change', function () {
933
+ var boat = findBoat(boatId);
934
+ if (!boat) return;
935
+ boat.sailNumber = sailNumberInput.value.trim() || null;
936
+ save();
937
+ render();
938
+ });
939
+ var tdSailNumber = document.createElement('td');
940
+ tdSailNumber.appendChild(sailNumberInput);
941
+
656
942
  var tcfInput = document.createElement('input');
657
943
  tcfInput.type = 'number';
658
944
  tcfInput.step = '0.001';
@@ -672,6 +958,27 @@ tbody tr.dnf td { color: var(--muted); }
672
958
  var tdTcf = document.createElement('td');
673
959
  tdTcf.appendChild(tcfInput);
674
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
+
675
982
  var startTimeInput = document.createElement('input');
676
983
  startTimeInput.type = race.multiDay ? 'datetime-local' : 'time';
677
984
  startTimeInput.step = '1';
@@ -813,9 +1120,10 @@ tbody tr.dnf td { color: var(--muted); }
813
1120
  var tdRemove = document.createElement('td');
814
1121
  tdRemove.appendChild(removeBtn);
815
1122
 
816
- tr.append(tdName, tdTcf, tdStart, tdElapsed, tdCorrected, tdVsSelf, tdFinish, tdRemove);
1123
+ tr.append(tdName, tdSailNumber, tdTcf, tdVet, tdStart, tdElapsed, tdCorrected, tdVsSelf, tdFinish, tdRemove);
817
1124
  return {
818
- tr: tr, selfBtn: selfBtn, nameSpan: nameSpan, tcfInput: tcfInput,
1125
+ tr: tr, selfBtn: selfBtn, nameSpan: nameSpan, sailNumberInput: sailNumberInput, tcfInput: tcfInput,
1126
+ vetSelect: vetSelect, vetBadge: vetBadge, vetHandicapVersion: -1,
819
1127
  startTimeInput: startTimeInput, startNowBtn: startNowBtn, startClearBtn: startClearBtn,
820
1128
  tdElapsed: tdElapsed, tdCorrected: tdCorrected, tdVsSelf: tdVsSelf,
821
1129
  finishNormalWrap: finishNormalWrap, finishTimeInput: finishTimeInput, dnfWrap: dnfWrap
@@ -853,7 +1161,15 @@ tbody tr.dnf td { color: var(--muted); }
853
1161
  var isSelf = b.id === race.selfBoatId;
854
1162
  row.selfBtn.textContent = isSelf ? '★' : '☆';
855
1163
  row.selfBtn.classList.toggle('active', isSelf);
1164
+ if (document.activeElement !== row.sailNumberInput) row.sailNumberInput.value = b.sailNumber || '';
856
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
+ }
857
1173
  if (document.activeElement !== row.startTimeInput) {
858
1174
  row.startTimeInput.value = race.multiDay ? tsToDateTimeInputValue(b.startTime) : tsToTimeInputValue(b.startTime);
859
1175
  }
@@ -905,7 +1221,7 @@ tbody tr.dnf td { color: var(--muted); }
905
1221
  if (!name) { setStatus('Enter a boat name to add.', true); addBoatName.focus(); return; }
906
1222
  var tcf = parseFloat(addBoatTcf.value);
907
1223
  if (!isFinite(tcf) || tcf <= 0) tcf = race.defaultTcf || 1.0;
908
- race.boats.push({ id: genId(), name: name, tcf: tcf, startTime: null, finishTime: null, dnf: false });
1224
+ race.boats.push({ id: genId(), name: name, sailNumber: null, tcf: tcf, startTime: null, finishTime: null, dnf: false });
909
1225
  save();
910
1226
  addBoatName.value = '';
911
1227
  addBoatName.focus();
@@ -919,13 +1235,13 @@ tbody tr.dnf td { color: var(--muted); }
919
1235
 
920
1236
  downloadCsvBtn.addEventListener('click', function () {
921
1237
  var fmtWhen = race.multiDay ? tsToDateTimeInputValue : tsToTimeInputValue;
922
- var rows = [['Rank', 'Boat', 'TCF', 'Start Time', 'Elapsed', 'Corrected', 'Finish Time', 'Status']];
1238
+ var rows = [['Rank', 'Boat', 'Sail Number', 'TCF', 'Start Time', 'Elapsed', 'Corrected', 'Finish Time', 'Status']];
923
1239
  rankedList().forEach(function (r, i) {
924
1240
  var status = r.boat.dnf ? 'DNF' : r.boat.finishTime ? 'Finished' : race.startTime ? 'Racing' : 'Not started';
925
1241
  var rankLabel = r.boat.dnf ? 'DNF' : r.rankMs != null ? String(i + 1) : '';
926
1242
  var start = effectiveStart(r.boat);
927
1243
  rows.push([
928
- rankLabel, r.boat.name, r.boat.tcf, start ? fmtWhen(start) : '', fmtDuration(r.elapsedMs), fmtDuration(r.correctedMs),
1244
+ rankLabel, r.boat.name, r.boat.sailNumber || '', r.boat.tcf, start ? fmtWhen(start) : '', fmtDuration(r.elapsedMs), fmtDuration(r.correctedMs),
929
1245
  r.boat.finishTime ? fmtWhen(r.boat.finishTime) : '', status
930
1246
  ]);
931
1247
  });
@@ -946,6 +1262,17 @@ tbody tr.dnf td { color: var(--muted); }
946
1262
  setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
947
1263
  });
948
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
+
949
1276
  render();
950
1277
  setInterval(render, 1000);
951
1278
  })();
@@ -982,6 +1309,12 @@ module.exports = function (app) {
982
1309
  title:
983
1310
  'Use the VET-tall register for autocomplete and per-boat handicap alternatives. When off, every boat is treated as outside VET: TCF is remembered per boat name across races instead.',
984
1311
  default: false
1312
+ },
1313
+ raceImportEnabled: {
1314
+ type: 'boolean',
1315
+ title:
1316
+ 'Allow importing a complete fleet (every boat, with TCF) into a race from an external regatta system — currently Manage2Sail. Off by default, since it fetches from a third-party site and bulk-adds boats.',
1317
+ default: false
985
1318
  }
986
1319
  }
987
1320
  };
@@ -1042,6 +1375,12 @@ module.exports = function (app) {
1042
1375
  return !!(plugin.options && plugin.options.vetEnabled === true);
1043
1376
  }
1044
1377
 
1378
+ // Plugin config setting, same pattern as vetEnabled — off by default, not
1379
+ // per-race, not writable from the webapp itself.
1380
+ function isRaceImportEnabled() {
1381
+ return !!(plugin.options && plugin.options.raceImportEnabled === true);
1382
+ }
1383
+
1045
1384
  // A boat only counts as "in VET" for registry-TCF purposes while the
1046
1385
  // config setting has VET enabled and the register (whatever's currently
1047
1386
  // cached) actually has a matching name — disabling VET makes every boat
@@ -1386,6 +1725,25 @@ module.exports = function (app) {
1386
1725
  return handicapCache;
1387
1726
  }
1388
1727
 
1728
+ async function fetchManage2SailClasses(eventUrl) {
1729
+ const res = await fetch(eventUrl);
1730
+ if (!res.ok) {
1731
+ throw new Error(`Could not load ${eventUrl}: HTTP ${res.status}`);
1732
+ }
1733
+ const html = await res.text();
1734
+ return parseManage2SailEventPage(html);
1735
+ }
1736
+
1737
+ async function fetchManage2SailEntries(eventId, regattaId) {
1738
+ const url = `https://www.manage2sail.com/api/event/${encodeURIComponent(eventId)}/regattaentry?regattaId=${encodeURIComponent(regattaId)}`;
1739
+ const res = await fetch(url);
1740
+ if (!res.ok) {
1741
+ throw new Error(`Manage2Sail entries request failed: HTTP ${res.status}`);
1742
+ }
1743
+ const json = await res.json();
1744
+ return parseManage2SailEntries(json);
1745
+ }
1746
+
1389
1747
  let trackTimer = null;
1390
1748
 
1391
1749
  plugin.start = function (options) {
@@ -1471,7 +1829,7 @@ module.exports = function (app) {
1471
1829
  // calendar days, so the date is included alongside the time —
1472
1830
  // otherwise just the time-of-day, matching how they're entered.
1473
1831
  const fmtWhen = race.multiDay ? formatLocalDateTime : formatLocalTime;
1474
- const headers = ['Rank', 'Boat', 'MMSI', 'TCF', 'Start Time', 'Elapsed', 'Corrected', 'Finish Time', 'Status'];
1832
+ const headers = ['Rank', 'Boat', 'Sail Number', 'MMSI', 'TCF', 'Start Time', 'Elapsed', 'Corrected', 'Finish Time', 'Status'];
1475
1833
 
1476
1834
  const workbook = new ExcelJS.Workbook();
1477
1835
  workbook.creator = 'Race Control';
@@ -1506,6 +1864,7 @@ module.exports = function (app) {
1506
1864
  sheet.addRow([
1507
1865
  rankLabel,
1508
1866
  r.boat.name,
1867
+ r.boat.sailNumber || '',
1509
1868
  r.boat.mmsi || '',
1510
1869
  r.boat.tcf,
1511
1870
  start ? fmtWhen(start, tz) : '',
@@ -1516,7 +1875,7 @@ module.exports = function (app) {
1516
1875
  ]);
1517
1876
  });
1518
1877
 
1519
- const widths = [7, 24, 12, 8, race.multiDay ? 17 : 12, 12, 12, race.multiDay ? 17 : 12, 12];
1878
+ const widths = [7, 24, 12, 12, 8, race.multiDay ? 17 : 12, 12, 12, race.multiDay ? 17 : 12, 12];
1520
1879
  widths.forEach((w, i) => {
1521
1880
  sheet.getColumn(i + 1).width = w;
1522
1881
  });
@@ -1536,12 +1895,38 @@ module.exports = function (app) {
1536
1895
  // A standalone, self-contained backup timer: current boats/TCF/progress
1537
1896
  // baked in, everything else (start/stop, finishes, DNF, self-compare)
1538
1897
  // runs entirely client-side with no server — see buildOfflineTimerHtml.
1539
- router.get('/races/:id/export-offline.html', (req, res) => {
1898
+ router.get('/races/:id/export-offline.html', async (req, res) => {
1540
1899
  const race = getRace(req.params.id);
1541
1900
  if (!race) return res.status(404).json({ error: 'No such race' });
1542
1901
  ensureRaceShape(race);
1543
1902
  const defaultTcf = (plugin.options && plugin.options.defaultTcf) || 1.0;
1544
- 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
+ });
1545
1930
  const safeName = (race.name || 'race').replace(/[^a-z0-9\-_]+/gi, '_').slice(0, 60) || 'race';
1546
1931
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
1547
1932
  res.setHeader('Content-Disposition', `attachment; filename="${safeName}-offline-timer.html"`);
@@ -1782,6 +2167,7 @@ module.exports = function (app) {
1782
2167
  id: makeBoatId(),
1783
2168
  name,
1784
2169
  mmsi: mmsi || null,
2170
+ sailNumber: null,
1785
2171
  tcf,
1786
2172
  finishTime: null,
1787
2173
  startTime: null,
@@ -1885,6 +2271,17 @@ module.exports = function (app) {
1885
2271
  res.json(boat);
1886
2272
  });
1887
2273
 
2274
+ router.put('/races/:id/boats/:boatId/sailNumber', (req, res) => {
2275
+ const race = getRace(req.params.id);
2276
+ if (!race) return res.status(404).json({ error: 'No such race' });
2277
+ const boat = getBoat(race, req.params.boatId);
2278
+ if (!boat) return res.status(404).json({ error: 'No such boat' });
2279
+ const sailNumber = ((req.body && req.body.sailNumber) || '').toString().trim();
2280
+ boat.sailNumber = sailNumber || null;
2281
+ saveState();
2282
+ res.json(boat);
2283
+ });
2284
+
1888
2285
  router.get('/boat-registry', (req, res) => {
1889
2286
  res.json({ boats: Object.values(state.boatRegistry) });
1890
2287
  });
@@ -1922,6 +2319,113 @@ module.exports = function (app) {
1922
2319
  res.status(502).json({ error: 'Could not load handicap register: ' + e.message });
1923
2320
  }
1924
2321
  });
2322
+
2323
+ // Plugin config setting (Server -> Plugin Config), same pattern as
2324
+ // vet-enabled — the webapp only reads it to know whether to show the
2325
+ // import section at all.
2326
+ router.get('/race-import-enabled', (req, res) => {
2327
+ res.json({ enabled: isRaceImportEnabled() });
2328
+ });
2329
+
2330
+ // Resolves a Manage2Sail event URL to its classes, so the webapp can
2331
+ // offer a picker before importing anything.
2332
+ router.get('/import/manage2sail/classes', async (req, res) => {
2333
+ if (!isRaceImportEnabled()) return res.status(403).json({ error: 'Race import is disabled in the plugin settings' });
2334
+ const eventUrl = ((req.query.eventUrl || '') + '').trim();
2335
+ if (!eventUrl) return res.status(400).json({ error: 'eventUrl is required' });
2336
+ try {
2337
+ const { eventId, classes } = await fetchManage2SailClasses(eventUrl);
2338
+ res.json({ eventId, classes });
2339
+ } catch (e) {
2340
+ res.status(502).json({ error: 'Could not read classes from Manage2Sail: ' + e.message });
2341
+ }
2342
+ });
2343
+
2344
+ // The handicap systems this plugin can convert to a usable TCF — the
2345
+ // webapp uses this to label the disambiguation picker when a class's
2346
+ // system can't be resolved automatically (see resolveHandicapSystem).
2347
+ router.get('/import/manage2sail/handicap-systems', (req, res) => {
2348
+ if (!isRaceImportEnabled()) return res.status(403).json({ error: 'Race import is disabled in the plugin settings' });
2349
+ res.json({ systems: HANDICAP_SYSTEMS.map((s) => ({ key: s.key, label: s.label })) });
2350
+ });
2351
+
2352
+ // Imports every entry from the given class(es) of a Manage2Sail event as
2353
+ // boats in this race — a new boat per entry (name falls back from
2354
+ // BoatName to TeamName/SkipperName). The published Hcp number is
2355
+ // converted to TCF via whichever handicap system the class turns out to
2356
+ // use (see resolveHandicapSystem) — pass systemOverrides: {classId: key}
2357
+ // to pick one explicitly for a class flagged in needsSystemChoice on a
2358
+ // prior call, rather than re-guessing. Re-importing updates TCF on
2359
+ // boats already added by name rather than duplicating them.
2360
+ router.post('/races/:id/import/manage2sail', async (req, res) => {
2361
+ if (!isRaceImportEnabled()) return res.status(403).json({ error: 'Race import is disabled in the plugin settings' });
2362
+ const race = getRace(req.params.id);
2363
+ if (!race) return res.status(404).json({ error: 'No such race' });
2364
+ const eventId = ((req.body && req.body.eventId) || '').toString().trim();
2365
+ const classIds = Array.isArray(req.body && req.body.classIds) ? req.body.classIds : [];
2366
+ const systemOverrides = (req.body && req.body.systemOverrides) || {};
2367
+ if (!eventId || !classIds.length) {
2368
+ return res.status(400).json({ error: 'eventId and at least one classId are required' });
2369
+ }
2370
+ const existingByName = new Map(Object.values(race.boats).map((b) => [b.name.trim().toLowerCase(), b]));
2371
+ let added = 0;
2372
+ let updated = 0;
2373
+ let skipped = 0;
2374
+ const conversions = [];
2375
+ const needsSystemChoice = [];
2376
+ try {
2377
+ for (const regattaId of classIds) {
2378
+ const { hcpName, entries, skipped: classSkipped } = await fetchManage2SailEntries(eventId, regattaId);
2379
+ skipped += classSkipped;
2380
+ if (!entries.length) continue;
2381
+ const overrideKey = systemOverrides[regattaId];
2382
+ let systemKey = overrideKey && findHandicapSystem(overrideKey) ? overrideKey : null;
2383
+ if (!systemKey) {
2384
+ const resolution = resolveHandicapSystem(
2385
+ hcpName,
2386
+ entries.map((e) => e.hcp)
2387
+ );
2388
+ if (!resolution.resolved) {
2389
+ needsSystemChoice.push({ classId: regattaId, hcpName, candidates: resolution.candidates });
2390
+ continue;
2391
+ }
2392
+ systemKey = resolution.resolved;
2393
+ }
2394
+ const system = findHandicapSystem(systemKey);
2395
+ conversions.push({ classId: regattaId, hcpName, system: systemKey });
2396
+ entries.forEach((entry) => {
2397
+ const tcf = Math.round(system.convert(entry.hcp) * 1000) / 1000;
2398
+ const key = entry.name.toLowerCase();
2399
+ const existing = existingByName.get(key);
2400
+ if (existing) {
2401
+ existing.tcf = tcf;
2402
+ existing.sailNumber = entry.sailNumber || existing.sailNumber;
2403
+ updated++;
2404
+ } else {
2405
+ const boat = {
2406
+ id: makeBoatId(),
2407
+ name: entry.name,
2408
+ mmsi: null,
2409
+ sailNumber: entry.sailNumber || null,
2410
+ tcf,
2411
+ finishTime: null,
2412
+ startTime: null,
2413
+ track: [],
2414
+ dnf: false,
2415
+ dnfPosition: null
2416
+ };
2417
+ race.boats[boat.id] = boat;
2418
+ existingByName.set(key, boat);
2419
+ added++;
2420
+ }
2421
+ });
2422
+ }
2423
+ } catch (e) {
2424
+ return res.status(502).json({ error: 'Could not import from Manage2Sail: ' + e.message });
2425
+ }
2426
+ saveState();
2427
+ res.json({ race: raceWithEstimates(race), added, updated, skipped, conversions, needsSystemChoice });
2428
+ });
1925
2429
  };
1926
2430
 
1927
2431
  return plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-race-control",
3
- "version": "0.2.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": [
package/public/app.js CHANGED
@@ -13,6 +13,11 @@
13
13
  let handicapBoats = [];
14
14
  let handicapVersion = 0; // bumped each successful VET-register load
15
15
  let vetEnabled = false; // plugin config setting (Server -> Plugin Config), read-only here; off by default
16
+ let raceImportEnabled = false; // plugin config setting, same pattern as vetEnabled
17
+ let importEventId = null; // Manage2Sail event id from the last successful "Find Classes" lookup
18
+ let importClasses = []; // [{id, name}] from that same lookup
19
+ let importSystemOverrides = {}; // classId -> handicap system key, from the disambiguation picker
20
+ let importHandicapSystems = null; // [{key, label}], fetched lazily on first ambiguity
16
21
  let startLineRefs = null; // [pointRefs, pointRefs] or null
17
22
  let finishLineRefs = null;
18
23
  let markRefs = []; // [pointRefs, ...]
@@ -72,6 +77,15 @@
72
77
  const replaySlider = document.getElementById('replaySlider');
73
78
  const replayTimeLabel = document.getElementById('replayTimeLabel');
74
79
  const replayLiveBtn = document.getElementById('replayLiveBtn');
80
+ const raceImportSection = document.getElementById('raceImportSection');
81
+ const raceImportToggleBtn = document.getElementById('raceImportToggleBtn');
82
+ const raceImportBody = document.getElementById('raceImportBody');
83
+ const importEventUrl = document.getElementById('importEventUrl');
84
+ const importFindClassesBtn = document.getElementById('importFindClassesBtn');
85
+ const importClassesList = document.getElementById('importClassesList');
86
+ const importSystemChoices = document.getElementById('importSystemChoices');
87
+ const importBoatsBtn = document.getElementById('importBoatsBtn');
88
+ const importStatusText = document.getElementById('importStatusText');
75
89
 
76
90
  function unwrapValue(x) {
77
91
  if (x && typeof x === 'object' && 'value' in x) return x.value;
@@ -338,6 +352,17 @@
338
352
  vetAlternativesTh.hidden = !vetEnabled;
339
353
  }
340
354
 
355
+ // Same pattern as loadVetEnabled — when disabled, the whole import
356
+ // section is removed rather than shown disabled.
357
+ async function loadRaceImportEnabled() {
358
+ try {
359
+ const data = await fetchJSON(`${API}/race-import-enabled`);
360
+ raceImportEnabled = data.enabled === true;
361
+ } catch (e) {
362
+ raceImportEnabled = false;
363
+ }
364
+ }
365
+
341
366
  async function loadHandicapRegister(force) {
342
367
  if (!vetEnabled) return;
343
368
  try {
@@ -750,6 +775,23 @@
750
775
  }
751
776
  }
752
777
 
778
+ async function setSailNumber(boatId, sailNumber) {
779
+ if (!activeRaceId) return;
780
+ try {
781
+ const boat = await fetchJSON(
782
+ `${API}/races/${encodeURIComponent(activeRaceId)}/boats/${encodeURIComponent(boatId)}/sailNumber`,
783
+ {
784
+ method: 'PUT',
785
+ headers: { 'Content-Type': 'application/json' },
786
+ body: JSON.stringify({ sailNumber })
787
+ }
788
+ );
789
+ raceState.boats[boatId] = boat;
790
+ } catch (e) {
791
+ setStatus(e.message, true);
792
+ }
793
+ }
794
+
753
795
  async function setTcf(boatId, tcf) {
754
796
  if (!activeRaceId) return;
755
797
  try {
@@ -952,6 +994,143 @@
952
994
  }
953
995
  }
954
996
 
997
+ // ---- Manage2Sail import ----------------------------------------------
998
+
999
+ function setImportStatus(msg, isError) {
1000
+ importStatusText.textContent = msg || '';
1001
+ importStatusText.classList.toggle('error', !!isError);
1002
+ }
1003
+
1004
+ async function findImportClasses() {
1005
+ const url = importEventUrl.value.trim();
1006
+ if (!url) {
1007
+ setImportStatus('Paste a Manage2Sail event URL first.', true);
1008
+ return;
1009
+ }
1010
+ importBoatsBtn.hidden = true;
1011
+ importClassesList.innerHTML = '';
1012
+ importSystemChoices.innerHTML = '';
1013
+ importSystemOverrides = {};
1014
+ importEventId = null;
1015
+ importClasses = [];
1016
+ setImportStatus('Looking up classes…');
1017
+ try {
1018
+ const data = await fetchJSON(`${API}/import/manage2sail/classes?eventUrl=${encodeURIComponent(url)}`);
1019
+ importEventId = data.eventId;
1020
+ importClasses = data.classes || [];
1021
+ if (!importClasses.length) {
1022
+ setImportStatus('No classes found on that event.', true);
1023
+ return;
1024
+ }
1025
+ importClassesList.innerHTML = '';
1026
+ importClasses.forEach((c) => {
1027
+ const label = document.createElement('label');
1028
+ const cb = document.createElement('input');
1029
+ cb.type = 'checkbox';
1030
+ cb.value = c.id;
1031
+ label.append(cb, document.createTextNode(c.name));
1032
+ importClassesList.appendChild(label);
1033
+ });
1034
+ importBoatsBtn.hidden = false;
1035
+ setImportStatus(`Found ${importClasses.length} class${importClasses.length === 1 ? '' : 'es'} — pick which to import.`);
1036
+ } catch (e) {
1037
+ setImportStatus(e.message, true);
1038
+ }
1039
+ }
1040
+
1041
+ async function loadImportHandicapSystems() {
1042
+ if (importHandicapSystems) return importHandicapSystems;
1043
+ try {
1044
+ const data = await fetchJSON(`${API}/import/manage2sail/handicap-systems`);
1045
+ importHandicapSystems = data.systems || [];
1046
+ } catch (e) {
1047
+ importHandicapSystems = [];
1048
+ }
1049
+ return importHandicapSystems;
1050
+ }
1051
+
1052
+ function importClassName(classId) {
1053
+ const c = importClasses.find((cl) => cl.id === classId);
1054
+ return c ? c.name : classId;
1055
+ }
1056
+
1057
+ function importSystemLabel(key) {
1058
+ const s = (importHandicapSystems || []).find((sys) => sys.key === key);
1059
+ return s ? s.label : key;
1060
+ }
1061
+
1062
+ // A class ends up here only when its handicap system couldn't be resolved
1063
+ // automatically (e.g. "YS" whose values don't clearly match either the
1064
+ // German or RYA Yardstick scale) — one picker per such class, defaulting
1065
+ // to its first candidate so a re-click of Import works even if the user
1066
+ // doesn't touch the dropdown, but they should actually check it's right.
1067
+ async function renderSystemChoices(needsSystemChoice) {
1068
+ importSystemChoices.innerHTML = '';
1069
+ if (!needsSystemChoice.length) return;
1070
+ await loadImportHandicapSystems();
1071
+ needsSystemChoice.forEach((item) => {
1072
+ if (importSystemOverrides[item.classId] == null) {
1073
+ importSystemOverrides[item.classId] = item.candidates[0];
1074
+ }
1075
+ const row = document.createElement('div');
1076
+ row.className = 'import-system-choice-row';
1077
+ const label = document.createElement('span');
1078
+ label.textContent = `${importClassName(item.classId)} (published as "${item.hcpName}") — which handicap system is this?`;
1079
+ const select = document.createElement('select');
1080
+ item.candidates.forEach((key) => {
1081
+ const opt = document.createElement('option');
1082
+ opt.value = key;
1083
+ opt.textContent = importSystemLabel(key);
1084
+ select.appendChild(opt);
1085
+ });
1086
+ select.value = importSystemOverrides[item.classId];
1087
+ select.addEventListener('change', () => {
1088
+ importSystemOverrides[item.classId] = select.value;
1089
+ });
1090
+ row.append(label, select);
1091
+ importSystemChoices.appendChild(row);
1092
+ });
1093
+ }
1094
+
1095
+ async function importSelectedBoats() {
1096
+ if (!activeRaceId || !importEventId) return;
1097
+ const classIds = Array.from(importClassesList.querySelectorAll('input[type=checkbox]:checked')).map((cb) => cb.value);
1098
+ if (!classIds.length) {
1099
+ setImportStatus('Select at least one class to import.', true);
1100
+ return;
1101
+ }
1102
+ setImportStatus('Importing…');
1103
+ try {
1104
+ const data = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/import/manage2sail`, {
1105
+ method: 'POST',
1106
+ headers: { 'Content-Type': 'application/json' },
1107
+ body: JSON.stringify({ eventId: importEventId, classIds, systemOverrides: importSystemOverrides })
1108
+ });
1109
+ raceState = data.race;
1110
+ render();
1111
+ const needsChoice = data.needsSystemChoice || [];
1112
+ if (needsChoice.length) {
1113
+ await renderSystemChoices(needsChoice);
1114
+ const names = needsChoice.map((c) => importClassName(c.classId)).join(', ');
1115
+ setImportStatus(
1116
+ `Added ${data.added}, updated ${data.updated}. Pick a handicap system below for ${names}, then click Import again.`,
1117
+ true
1118
+ );
1119
+ return;
1120
+ }
1121
+ importSystemChoices.innerHTML = '';
1122
+ importSystemOverrides = {};
1123
+ const skippedNote = data.skipped ? `, skipped ${data.skipped}` : '';
1124
+ await loadImportHandicapSystems();
1125
+ const conversionNote = (data.conversions || [])
1126
+ .map((c) => `${importClassName(c.classId)}: ${importSystemLabel(c.system)}`)
1127
+ .join('; ');
1128
+ setImportStatus(`Added ${data.added}, updated ${data.updated}${skippedNote}.${conversionNote ? ' ' + conversionNote : ''}`);
1129
+ } catch (e) {
1130
+ setImportStatus(e.message, true);
1131
+ }
1132
+ }
1133
+
955
1134
  // ---- Course / track chart -------------------------------------------
956
1135
 
957
1136
  const CHART_PALETTE = ['#38bdf8', '#fbbf24', '#f472b6', '#a78bfa', '#34d399', '#fb923c', '#60a5fa', '#facc15'];
@@ -1092,6 +1271,7 @@
1092
1271
  return {
1093
1272
  boatId: b.id,
1094
1273
  name: b.name,
1274
+ sailNumber: b.sailNumber || '',
1095
1275
  mmsi: b.mmsi || '',
1096
1276
  tcf,
1097
1277
  startTime: b.startTime,
@@ -1185,6 +1365,14 @@
1185
1365
  const tdName = document.createElement('td');
1186
1366
  tdName.append(selfBtn, nameSpan);
1187
1367
 
1368
+ const sailNumberInput = document.createElement('input');
1369
+ sailNumberInput.type = 'text';
1370
+ sailNumberInput.className = 'sail-number-input';
1371
+ sailNumberInput.placeholder = 'Sail #';
1372
+ sailNumberInput.addEventListener('change', () => setSailNumber(boatId, sailNumberInput.value.trim()));
1373
+ const tdSailNumber = document.createElement('td');
1374
+ tdSailNumber.appendChild(sailNumberInput);
1375
+
1188
1376
  const mmsiInput = document.createElement('input');
1189
1377
  mmsiInput.type = 'text';
1190
1378
  mmsiInput.className = 'mmsi-input';
@@ -1351,7 +1539,7 @@
1351
1539
  const tdRemove = document.createElement('td');
1352
1540
  tdRemove.appendChild(removeBtn);
1353
1541
 
1354
- tr.append(tdName, tdMmsi, tdTcf, tdVet, tdStart, tdElapsed, tdCorrected, tdEstFinish, tdVsSelf, tdFinish, tdRemove);
1542
+ tr.append(tdName, tdSailNumber, tdMmsi, tdTcf, tdVet, tdStart, tdElapsed, tdCorrected, tdEstFinish, tdVsSelf, tdFinish, tdRemove);
1355
1543
 
1356
1544
  return {
1357
1545
  tr,
@@ -1359,6 +1547,7 @@
1359
1547
  nameSpan,
1360
1548
  tdEstFinish,
1361
1549
  tdVsSelf,
1550
+ sailNumberInput,
1362
1551
  mmsiInput,
1363
1552
  tcfInput,
1364
1553
  vetSelect,
@@ -1448,6 +1637,7 @@
1448
1637
  exportBtn.hidden = !raceState;
1449
1638
  exportOfflineBtn.hidden = !raceState;
1450
1639
  courseSection.hidden = !raceState;
1640
+ raceImportSection.hidden = !raceState || !raceImportEnabled;
1451
1641
 
1452
1642
  if (!raceState) {
1453
1643
  emptyMsg.hidden = true;
@@ -1530,6 +1720,9 @@
1530
1720
  const isSelf = b.boatId === raceState.selfBoatId;
1531
1721
  row.selfBtn.textContent = isSelf ? '★' : '☆';
1532
1722
  row.selfBtn.classList.toggle('active', isSelf);
1723
+ if (document.activeElement !== row.sailNumberInput) {
1724
+ row.sailNumberInput.value = b.sailNumber || '';
1725
+ }
1533
1726
  if (document.activeElement !== row.mmsiInput) {
1534
1727
  row.mmsiInput.value = b.mmsi;
1535
1728
  }
@@ -1687,6 +1880,12 @@
1687
1880
  courseToggleBtn.textContent = (courseBody.hidden ? '▸' : '▾') + ' Course & chart';
1688
1881
  if (!courseBody.hidden) renderChart();
1689
1882
  });
1883
+ raceImportToggleBtn.addEventListener('click', () => {
1884
+ raceImportBody.hidden = !raceImportBody.hidden;
1885
+ raceImportToggleBtn.textContent = (raceImportBody.hidden ? '▸' : '▾') + ' Import boats from Manage2Sail';
1886
+ });
1887
+ importFindClassesBtn.addEventListener('click', findImportClasses);
1888
+ importBoatsBtn.addEventListener('click', importSelectedBoats);
1690
1889
  addMarkBtn.addEventListener('click', () => {
1691
1890
  markRefs.push(buildMarkRow(null));
1692
1891
  renderMarkRows();
@@ -1705,6 +1904,7 @@
1705
1904
 
1706
1905
  async function init() {
1707
1906
  await loadVetEnabled();
1907
+ await loadRaceImportEnabled();
1708
1908
  await Promise.all([loadVessels(), loadBoatRegistry(), loadHandicapRegister(false), loadRacesList()]);
1709
1909
  await loadRaceState();
1710
1910
  render();
package/public/index.html CHANGED
@@ -92,6 +92,22 @@
92
92
  </div>
93
93
  </div>
94
94
 
95
+ <div id="raceImportSection" class="course-section" hidden>
96
+ <button id="raceImportToggleBtn" type="button" class="link-btn">▸ Import boats from Manage2Sail</button>
97
+ <div id="raceImportBody" hidden>
98
+ <div class="import-row">
99
+ <input type="text" id="importEventUrl" placeholder="Manage2Sail event URL, e.g. https://www.manage2sail.com/en-US/event/SCER23" autocomplete="off" />
100
+ <button id="importFindClassesBtn" type="button" class="secondary">Find Classes</button>
101
+ </div>
102
+ <div id="importClassesList" class="import-classes-list"></div>
103
+ <div id="importSystemChoices" class="import-system-choices"></div>
104
+ <div class="import-row">
105
+ <button id="importBoatsBtn" type="button" hidden>Import Selected Boats</button>
106
+ <span id="importStatusText" class="status"></span>
107
+ </div>
108
+ </div>
109
+ </div>
110
+
95
111
  <div id="addBoatRow" class="add-boat-row" hidden>
96
112
  <div class="autocomplete">
97
113
  <input type="text" id="addBoatName" placeholder="Add boat by name…" autocomplete="off" />
@@ -104,6 +120,7 @@
104
120
  <thead>
105
121
  <tr>
106
122
  <th>Boat</th>
123
+ <th>Sail #</th>
107
124
  <th>MMSI</th>
108
125
  <th>TCF</th>
109
126
  <th id="vetAlternativesTh">VET alternatives</th>
package/public/style.css CHANGED
@@ -289,6 +289,65 @@ main {
289
289
  gap: 0.75rem;
290
290
  }
291
291
 
292
+ .import-row {
293
+ display: flex;
294
+ align-items: center;
295
+ gap: 0.5rem;
296
+ flex-wrap: wrap;
297
+ margin-top: 0.75rem;
298
+ }
299
+
300
+ .import-row input[type='text'] {
301
+ flex: 1;
302
+ min-width: 18rem;
303
+ padding: 0.4rem 0.5rem;
304
+ background: var(--panel);
305
+ color: var(--text);
306
+ border: 1px solid var(--border);
307
+ border-radius: 6px;
308
+ font-size: 0.85rem;
309
+ }
310
+
311
+ .import-classes-list {
312
+ margin-top: 0.6rem;
313
+ display: flex;
314
+ flex-direction: column;
315
+ gap: 0.3rem;
316
+ }
317
+
318
+ .import-classes-list label {
319
+ display: flex;
320
+ align-items: center;
321
+ gap: 0.4rem;
322
+ font-size: 0.85rem;
323
+ cursor: pointer;
324
+ }
325
+
326
+ .import-system-choices {
327
+ margin-top: 0.6rem;
328
+ display: flex;
329
+ flex-direction: column;
330
+ gap: 0.4rem;
331
+ }
332
+
333
+ .import-system-choice-row {
334
+ display: flex;
335
+ align-items: center;
336
+ gap: 0.5rem;
337
+ flex-wrap: wrap;
338
+ font-size: 0.85rem;
339
+ color: var(--muted);
340
+ }
341
+
342
+ .import-system-choice-row select {
343
+ padding: 0.3rem 0.4rem;
344
+ background: var(--panel);
345
+ color: var(--text);
346
+ border: 1px solid var(--border);
347
+ border-radius: 4px;
348
+ font-size: 0.85rem;
349
+ }
350
+
292
351
  .chart-wrap {
293
352
  margin-top: 1rem;
294
353
  }
@@ -467,7 +526,8 @@ tbody tr.dnf td {
467
526
  color: var(--muted);
468
527
  }
469
528
 
470
- .mmsi-input {
529
+ .mmsi-input,
530
+ .sail-number-input {
471
531
  width: 7rem;
472
532
  padding: 0.3rem 0.4rem;
473
533
  background: var(--panel);