signalk-race-control 0.2.0 → 0.3.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,10 +105,10 @@ 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,
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
 
@@ -269,6 +356,7 @@ function buildOfflineTimerHtml(race, defaultTcf) {
269
356
  boats: Object.values(race.boats).map((b) => ({
270
357
  id: b.id,
271
358
  name: b.name,
359
+ sailNumber: b.sailNumber || null,
272
360
  tcf: b.tcf != null ? b.tcf : defaultTcf,
273
361
  startTime: b.startTime || null,
274
362
  finishTime: b.finishTime || null,
@@ -329,6 +417,7 @@ tbody tr.finished td { color: var(--good); }
329
417
  tbody tr.dnf td { color: var(--muted); }
330
418
  .dnf-tag { color: var(--bad); font-weight: 700; font-size: 0.85rem; letter-spacing: 0.03em; }
331
419
  .tcf-input { width: 5.5rem; padding: 0.3rem 0.4rem; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 4px; }
420
+ .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
421
  .tcf-input::-webkit-outer-spin-button, .tcf-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
333
422
  .tcf-input[type='number'] { -moz-appearance: textfield; }
334
423
  .self-btn { background: none; border: none; padding: 0 0.3rem 0 0; font-size: 1rem; color: var(--muted); cursor: pointer; vertical-align: middle; }
@@ -380,6 +469,7 @@ tbody tr.dnf td { color: var(--muted); }
380
469
  <thead>
381
470
  <tr>
382
471
  <th>Boat</th>
472
+ <th>Sail #</th>
383
473
  <th>TCF</th>
384
474
  <th>Start time</th>
385
475
  <th>Elapsed</th>
@@ -653,6 +743,20 @@ tbody tr.dnf td { color: var(--muted); }
653
743
  var tdName = document.createElement('td');
654
744
  tdName.append(selfBtn, nameSpan);
655
745
 
746
+ var sailNumberInput = document.createElement('input');
747
+ sailNumberInput.type = 'text';
748
+ sailNumberInput.className = 'sail-number-input';
749
+ sailNumberInput.placeholder = 'Sail #';
750
+ sailNumberInput.addEventListener('change', function () {
751
+ var boat = findBoat(boatId);
752
+ if (!boat) return;
753
+ boat.sailNumber = sailNumberInput.value.trim() || null;
754
+ save();
755
+ render();
756
+ });
757
+ var tdSailNumber = document.createElement('td');
758
+ tdSailNumber.appendChild(sailNumberInput);
759
+
656
760
  var tcfInput = document.createElement('input');
657
761
  tcfInput.type = 'number';
658
762
  tcfInput.step = '0.001';
@@ -813,9 +917,9 @@ tbody tr.dnf td { color: var(--muted); }
813
917
  var tdRemove = document.createElement('td');
814
918
  tdRemove.appendChild(removeBtn);
815
919
 
816
- tr.append(tdName, tdTcf, tdStart, tdElapsed, tdCorrected, tdVsSelf, tdFinish, tdRemove);
920
+ tr.append(tdName, tdSailNumber, tdTcf, tdStart, tdElapsed, tdCorrected, tdVsSelf, tdFinish, tdRemove);
817
921
  return {
818
- tr: tr, selfBtn: selfBtn, nameSpan: nameSpan, tcfInput: tcfInput,
922
+ tr: tr, selfBtn: selfBtn, nameSpan: nameSpan, sailNumberInput: sailNumberInput, tcfInput: tcfInput,
819
923
  startTimeInput: startTimeInput, startNowBtn: startNowBtn, startClearBtn: startClearBtn,
820
924
  tdElapsed: tdElapsed, tdCorrected: tdCorrected, tdVsSelf: tdVsSelf,
821
925
  finishNormalWrap: finishNormalWrap, finishTimeInput: finishTimeInput, dnfWrap: dnfWrap
@@ -853,6 +957,7 @@ tbody tr.dnf td { color: var(--muted); }
853
957
  var isSelf = b.id === race.selfBoatId;
854
958
  row.selfBtn.textContent = isSelf ? '★' : '☆';
855
959
  row.selfBtn.classList.toggle('active', isSelf);
960
+ if (document.activeElement !== row.sailNumberInput) row.sailNumberInput.value = b.sailNumber || '';
856
961
  if (document.activeElement !== row.tcfInput) row.tcfInput.value = b.tcf;
857
962
  if (document.activeElement !== row.startTimeInput) {
858
963
  row.startTimeInput.value = race.multiDay ? tsToDateTimeInputValue(b.startTime) : tsToTimeInputValue(b.startTime);
@@ -905,7 +1010,7 @@ tbody tr.dnf td { color: var(--muted); }
905
1010
  if (!name) { setStatus('Enter a boat name to add.', true); addBoatName.focus(); return; }
906
1011
  var tcf = parseFloat(addBoatTcf.value);
907
1012
  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 });
1013
+ race.boats.push({ id: genId(), name: name, sailNumber: null, tcf: tcf, startTime: null, finishTime: null, dnf: false });
909
1014
  save();
910
1015
  addBoatName.value = '';
911
1016
  addBoatName.focus();
@@ -919,13 +1024,13 @@ tbody tr.dnf td { color: var(--muted); }
919
1024
 
920
1025
  downloadCsvBtn.addEventListener('click', function () {
921
1026
  var fmtWhen = race.multiDay ? tsToDateTimeInputValue : tsToTimeInputValue;
922
- var rows = [['Rank', 'Boat', 'TCF', 'Start Time', 'Elapsed', 'Corrected', 'Finish Time', 'Status']];
1027
+ var rows = [['Rank', 'Boat', 'Sail Number', 'TCF', 'Start Time', 'Elapsed', 'Corrected', 'Finish Time', 'Status']];
923
1028
  rankedList().forEach(function (r, i) {
924
1029
  var status = r.boat.dnf ? 'DNF' : r.boat.finishTime ? 'Finished' : race.startTime ? 'Racing' : 'Not started';
925
1030
  var rankLabel = r.boat.dnf ? 'DNF' : r.rankMs != null ? String(i + 1) : '';
926
1031
  var start = effectiveStart(r.boat);
927
1032
  rows.push([
928
- rankLabel, r.boat.name, r.boat.tcf, start ? fmtWhen(start) : '', fmtDuration(r.elapsedMs), fmtDuration(r.correctedMs),
1033
+ rankLabel, r.boat.name, r.boat.sailNumber || '', r.boat.tcf, start ? fmtWhen(start) : '', fmtDuration(r.elapsedMs), fmtDuration(r.correctedMs),
929
1034
  r.boat.finishTime ? fmtWhen(r.boat.finishTime) : '', status
930
1035
  ]);
931
1036
  });
@@ -982,6 +1087,12 @@ module.exports = function (app) {
982
1087
  title:
983
1088
  '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
1089
  default: false
1090
+ },
1091
+ raceImportEnabled: {
1092
+ type: 'boolean',
1093
+ title:
1094
+ '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.',
1095
+ default: false
985
1096
  }
986
1097
  }
987
1098
  };
@@ -1042,6 +1153,12 @@ module.exports = function (app) {
1042
1153
  return !!(plugin.options && plugin.options.vetEnabled === true);
1043
1154
  }
1044
1155
 
1156
+ // Plugin config setting, same pattern as vetEnabled — off by default, not
1157
+ // per-race, not writable from the webapp itself.
1158
+ function isRaceImportEnabled() {
1159
+ return !!(plugin.options && plugin.options.raceImportEnabled === true);
1160
+ }
1161
+
1045
1162
  // A boat only counts as "in VET" for registry-TCF purposes while the
1046
1163
  // config setting has VET enabled and the register (whatever's currently
1047
1164
  // cached) actually has a matching name — disabling VET makes every boat
@@ -1386,6 +1503,25 @@ module.exports = function (app) {
1386
1503
  return handicapCache;
1387
1504
  }
1388
1505
 
1506
+ async function fetchManage2SailClasses(eventUrl) {
1507
+ const res = await fetch(eventUrl);
1508
+ if (!res.ok) {
1509
+ throw new Error(`Could not load ${eventUrl}: HTTP ${res.status}`);
1510
+ }
1511
+ const html = await res.text();
1512
+ return parseManage2SailEventPage(html);
1513
+ }
1514
+
1515
+ async function fetchManage2SailEntries(eventId, regattaId) {
1516
+ const url = `https://www.manage2sail.com/api/event/${encodeURIComponent(eventId)}/regattaentry?regattaId=${encodeURIComponent(regattaId)}`;
1517
+ const res = await fetch(url);
1518
+ if (!res.ok) {
1519
+ throw new Error(`Manage2Sail entries request failed: HTTP ${res.status}`);
1520
+ }
1521
+ const json = await res.json();
1522
+ return parseManage2SailEntries(json);
1523
+ }
1524
+
1389
1525
  let trackTimer = null;
1390
1526
 
1391
1527
  plugin.start = function (options) {
@@ -1471,7 +1607,7 @@ module.exports = function (app) {
1471
1607
  // calendar days, so the date is included alongside the time —
1472
1608
  // otherwise just the time-of-day, matching how they're entered.
1473
1609
  const fmtWhen = race.multiDay ? formatLocalDateTime : formatLocalTime;
1474
- const headers = ['Rank', 'Boat', 'MMSI', 'TCF', 'Start Time', 'Elapsed', 'Corrected', 'Finish Time', 'Status'];
1610
+ const headers = ['Rank', 'Boat', 'Sail Number', 'MMSI', 'TCF', 'Start Time', 'Elapsed', 'Corrected', 'Finish Time', 'Status'];
1475
1611
 
1476
1612
  const workbook = new ExcelJS.Workbook();
1477
1613
  workbook.creator = 'Race Control';
@@ -1506,6 +1642,7 @@ module.exports = function (app) {
1506
1642
  sheet.addRow([
1507
1643
  rankLabel,
1508
1644
  r.boat.name,
1645
+ r.boat.sailNumber || '',
1509
1646
  r.boat.mmsi || '',
1510
1647
  r.boat.tcf,
1511
1648
  start ? fmtWhen(start, tz) : '',
@@ -1516,7 +1653,7 @@ module.exports = function (app) {
1516
1653
  ]);
1517
1654
  });
1518
1655
 
1519
- const widths = [7, 24, 12, 8, race.multiDay ? 17 : 12, 12, 12, race.multiDay ? 17 : 12, 12];
1656
+ const widths = [7, 24, 12, 12, 8, race.multiDay ? 17 : 12, 12, 12, race.multiDay ? 17 : 12, 12];
1520
1657
  widths.forEach((w, i) => {
1521
1658
  sheet.getColumn(i + 1).width = w;
1522
1659
  });
@@ -1782,6 +1919,7 @@ module.exports = function (app) {
1782
1919
  id: makeBoatId(),
1783
1920
  name,
1784
1921
  mmsi: mmsi || null,
1922
+ sailNumber: null,
1785
1923
  tcf,
1786
1924
  finishTime: null,
1787
1925
  startTime: null,
@@ -1885,6 +2023,17 @@ module.exports = function (app) {
1885
2023
  res.json(boat);
1886
2024
  });
1887
2025
 
2026
+ router.put('/races/:id/boats/:boatId/sailNumber', (req, res) => {
2027
+ const race = getRace(req.params.id);
2028
+ if (!race) return res.status(404).json({ error: 'No such race' });
2029
+ const boat = getBoat(race, req.params.boatId);
2030
+ if (!boat) return res.status(404).json({ error: 'No such boat' });
2031
+ const sailNumber = ((req.body && req.body.sailNumber) || '').toString().trim();
2032
+ boat.sailNumber = sailNumber || null;
2033
+ saveState();
2034
+ res.json(boat);
2035
+ });
2036
+
1888
2037
  router.get('/boat-registry', (req, res) => {
1889
2038
  res.json({ boats: Object.values(state.boatRegistry) });
1890
2039
  });
@@ -1922,6 +2071,113 @@ module.exports = function (app) {
1922
2071
  res.status(502).json({ error: 'Could not load handicap register: ' + e.message });
1923
2072
  }
1924
2073
  });
2074
+
2075
+ // Plugin config setting (Server -> Plugin Config), same pattern as
2076
+ // vet-enabled — the webapp only reads it to know whether to show the
2077
+ // import section at all.
2078
+ router.get('/race-import-enabled', (req, res) => {
2079
+ res.json({ enabled: isRaceImportEnabled() });
2080
+ });
2081
+
2082
+ // Resolves a Manage2Sail event URL to its classes, so the webapp can
2083
+ // offer a picker before importing anything.
2084
+ router.get('/import/manage2sail/classes', async (req, res) => {
2085
+ if (!isRaceImportEnabled()) return res.status(403).json({ error: 'Race import is disabled in the plugin settings' });
2086
+ const eventUrl = ((req.query.eventUrl || '') + '').trim();
2087
+ if (!eventUrl) return res.status(400).json({ error: 'eventUrl is required' });
2088
+ try {
2089
+ const { eventId, classes } = await fetchManage2SailClasses(eventUrl);
2090
+ res.json({ eventId, classes });
2091
+ } catch (e) {
2092
+ res.status(502).json({ error: 'Could not read classes from Manage2Sail: ' + e.message });
2093
+ }
2094
+ });
2095
+
2096
+ // The handicap systems this plugin can convert to a usable TCF — the
2097
+ // webapp uses this to label the disambiguation picker when a class's
2098
+ // system can't be resolved automatically (see resolveHandicapSystem).
2099
+ router.get('/import/manage2sail/handicap-systems', (req, res) => {
2100
+ if (!isRaceImportEnabled()) return res.status(403).json({ error: 'Race import is disabled in the plugin settings' });
2101
+ res.json({ systems: HANDICAP_SYSTEMS.map((s) => ({ key: s.key, label: s.label })) });
2102
+ });
2103
+
2104
+ // Imports every entry from the given class(es) of a Manage2Sail event as
2105
+ // boats in this race — a new boat per entry (name falls back from
2106
+ // BoatName to TeamName/SkipperName). The published Hcp number is
2107
+ // converted to TCF via whichever handicap system the class turns out to
2108
+ // use (see resolveHandicapSystem) — pass systemOverrides: {classId: key}
2109
+ // to pick one explicitly for a class flagged in needsSystemChoice on a
2110
+ // prior call, rather than re-guessing. Re-importing updates TCF on
2111
+ // boats already added by name rather than duplicating them.
2112
+ router.post('/races/:id/import/manage2sail', async (req, res) => {
2113
+ if (!isRaceImportEnabled()) return res.status(403).json({ error: 'Race import is disabled in the plugin settings' });
2114
+ const race = getRace(req.params.id);
2115
+ if (!race) return res.status(404).json({ error: 'No such race' });
2116
+ const eventId = ((req.body && req.body.eventId) || '').toString().trim();
2117
+ const classIds = Array.isArray(req.body && req.body.classIds) ? req.body.classIds : [];
2118
+ const systemOverrides = (req.body && req.body.systemOverrides) || {};
2119
+ if (!eventId || !classIds.length) {
2120
+ return res.status(400).json({ error: 'eventId and at least one classId are required' });
2121
+ }
2122
+ const existingByName = new Map(Object.values(race.boats).map((b) => [b.name.trim().toLowerCase(), b]));
2123
+ let added = 0;
2124
+ let updated = 0;
2125
+ let skipped = 0;
2126
+ const conversions = [];
2127
+ const needsSystemChoice = [];
2128
+ try {
2129
+ for (const regattaId of classIds) {
2130
+ const { hcpName, entries, skipped: classSkipped } = await fetchManage2SailEntries(eventId, regattaId);
2131
+ skipped += classSkipped;
2132
+ if (!entries.length) continue;
2133
+ const overrideKey = systemOverrides[regattaId];
2134
+ let systemKey = overrideKey && findHandicapSystem(overrideKey) ? overrideKey : null;
2135
+ if (!systemKey) {
2136
+ const resolution = resolveHandicapSystem(
2137
+ hcpName,
2138
+ entries.map((e) => e.hcp)
2139
+ );
2140
+ if (!resolution.resolved) {
2141
+ needsSystemChoice.push({ classId: regattaId, hcpName, candidates: resolution.candidates });
2142
+ continue;
2143
+ }
2144
+ systemKey = resolution.resolved;
2145
+ }
2146
+ const system = findHandicapSystem(systemKey);
2147
+ conversions.push({ classId: regattaId, hcpName, system: systemKey });
2148
+ entries.forEach((entry) => {
2149
+ const tcf = Math.round(system.convert(entry.hcp) * 1000) / 1000;
2150
+ const key = entry.name.toLowerCase();
2151
+ const existing = existingByName.get(key);
2152
+ if (existing) {
2153
+ existing.tcf = tcf;
2154
+ existing.sailNumber = entry.sailNumber || existing.sailNumber;
2155
+ updated++;
2156
+ } else {
2157
+ const boat = {
2158
+ id: makeBoatId(),
2159
+ name: entry.name,
2160
+ mmsi: null,
2161
+ sailNumber: entry.sailNumber || null,
2162
+ tcf,
2163
+ finishTime: null,
2164
+ startTime: null,
2165
+ track: [],
2166
+ dnf: false,
2167
+ dnfPosition: null
2168
+ };
2169
+ race.boats[boat.id] = boat;
2170
+ existingByName.set(key, boat);
2171
+ added++;
2172
+ }
2173
+ });
2174
+ }
2175
+ } catch (e) {
2176
+ return res.status(502).json({ error: 'Could not import from Manage2Sail: ' + e.message });
2177
+ }
2178
+ saveState();
2179
+ res.json({ race: raceWithEstimates(race), added, updated, skipped, conversions, needsSystemChoice });
2180
+ });
1925
2181
  };
1926
2182
 
1927
2183
  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.3.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);