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 +30 -8
- package/index.js +519 -15
- 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,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,
|
|
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,
|
|
98
|
-
record finishes, DNF, self-comparison) with no server
|
|
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.
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
|
355
|
-
|
|
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
|
-
|
|
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.
|
|
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);
|