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 +21 -4
- package/index.js +263 -7
- package/package.json +1 -1
- package/public/app.js +201 -1
- package/public/index.html +17 -0
- package/public/style.css +61 -1
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,
|
|
92
|
-
elapsed, corrected, finish time, status), with the time columns
|
|
93
|
-
browser's own timezone rather than the server's (and with the
|
|
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.
|
|
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);
|