signalk-race-control 0.5.0 → 0.6.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 +34 -13
- package/index.js +206 -38
- package/package.json +1 -1
- package/public/app.js +114 -36
- package/public/index.html +6 -2
- package/public/style.css +15 -1
package/README.md
CHANGED
|
@@ -25,7 +25,13 @@ and a live projected finishing order — during and after the race.
|
|
|
25
25
|
- **Add/remove boats explicitly** — type a name (autocompletes against the VET
|
|
26
26
|
register, the cross-race boat registry, and any live AIS/self vessel — matching
|
|
27
27
|
anywhere in the name, not just the start) and click **Add Boat**. Boats aren't
|
|
28
|
-
auto-populated from AIS; you control exactly who's racing.
|
|
28
|
+
auto-populated from AIS; you control exactly who's racing. Rows stay in the order
|
|
29
|
+
boats were added — adding or removing one doesn't reshuffle the rest — until the
|
|
30
|
+
race actually starts, when the table switches to rank order; whichever boat is
|
|
31
|
+
marked **self** always stays on top regardless of phase. The **Boat** column stays
|
|
32
|
+
pinned in place while scrolling sideways through the rest of the (wide) table, and
|
|
33
|
+
the page itself isn't width-capped, so a larger screen shows more columns at once
|
|
34
|
+
without scrolling.
|
|
29
35
|
- **Elapsed / corrected time** — Time-on-Time correction: `corrected = elapsed × TCF`,
|
|
30
36
|
where TCF is edited per boat directly in the webapp and persisted server-side,
|
|
31
37
|
shared by everyone viewing the page.
|
|
@@ -43,12 +49,21 @@ and a live projected finishing order — during and after the race.
|
|
|
43
49
|
default** — turn it on in the plugin's settings (`vetEnabled`) for clubs that
|
|
44
50
|
actually race under VET-tall; see **Cross-race TCF memory** below for what happens
|
|
45
51
|
to handicaps while it's off.
|
|
46
|
-
- **
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
- **KTK import** — the same idea as VET-tall, for [Klassisk Treseiler
|
|
53
|
+
Klubb](http://klassisktreseilerklubb.blogspot.com/)'s (KTK) own KLR handicap
|
|
54
|
+
register, published each season as a table in a blog post — this plugin always
|
|
55
|
+
reads whichever post is most recent, so it tracks the current season without
|
|
56
|
+
needing an update. A KLR number isn't a TCF directly: corrected time is elapsed ×
|
|
57
|
+
(KLR / 100), so that conversion happens automatically. Off by default — turn it on
|
|
58
|
+
in the plugin's settings (`ktkEnabled`) for clubs that race under KLR. If a boat is
|
|
59
|
+
listed in **both** VET-tall and KTK, the dropdown shows alternatives from both
|
|
60
|
+
(labeled by which is which), rather than picking one register over the other.
|
|
61
|
+
- **Cross-race TCF memory for boats outside every register** — whenever a boat isn't
|
|
62
|
+
matched in VET-tall or KTK (or both are disabled), the TCF you set for it by hand is
|
|
63
|
+
remembered by boat name and applied automatically the next time a boat with that
|
|
64
|
+
name is added to any race. A boat matched in either register never has its TCF
|
|
65
|
+
carried over this way — it always starts from the default until you pick an
|
|
66
|
+
alternative from the dropdown or edit it again.
|
|
52
67
|
- **MMSI and sail number, remembered across races** — set once per boat (MMSI can
|
|
53
68
|
also be picked up automatically from a live AIS/self vessel with a matching name),
|
|
54
69
|
both are remembered in a small cross-race registry: add a boat with the same name
|
|
@@ -158,7 +173,10 @@ and a live projected finishing order — during and after the race.
|
|
|
158
173
|
download time and a **Refresh VET register** link lets the offline page re-fetch the
|
|
159
174
|
current sheet straight from Google Sheets (no plugin server needed for that — just
|
|
160
175
|
whatever internet connection the browser has), with the same alternatives dropdown
|
|
161
|
-
and autocomplete as the main webapp.
|
|
176
|
+
and autocomplete as the main webapp. If KTK is enabled too, its KLR numbers are
|
|
177
|
+
seeded in the same way, but — since KTK's own page doesn't allow that kind of direct
|
|
178
|
+
browser fetch — only as a snapshot from download time, with no offline refresh
|
|
179
|
+
equivalent. AIS boat names/positions, the course/chart, and
|
|
162
180
|
importing a fleet from Manage2Sail still need the live server (Manage2Sail's own API
|
|
163
181
|
doesn't allow browser-side fetches at all) — TCF is entered by hand for anything the
|
|
164
182
|
VET register doesn't cover. Also has its own "Download results as CSV" button.
|
|
@@ -230,11 +248,14 @@ finish time/rank for boats still racing, when the plugin has enough to estimate
|
|
|
230
248
|
tracks for a fresh run.
|
|
231
249
|
- The VET-tall source page can be overridden in the plugin's settings
|
|
232
250
|
(`handicapSourceUrl`) — point it at a specific year's Google Sheet link directly to
|
|
233
|
-
skip the SSCA page lookup, e.g. if a club uses its own register.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
251
|
+
skip the SSCA page lookup, e.g. if a club uses its own register. KTK's source page
|
|
252
|
+
can likewise be overridden (`ktkSourceUrl`) — point it at a specific post directly to
|
|
253
|
+
skip the label-listing lookup.
|
|
254
|
+
- Whether the VET-tall register is used at all (`vetEnabled`, off by default) is also a
|
|
255
|
+
plugin setting, not a per-race or webapp-side option — same for KTK's KLR register
|
|
256
|
+
(`ktkEnabled`) — change either under **Server → Plugin Config → Race Control** and
|
|
257
|
+
restart the plugin (the server does this automatically on save) for the webapp to
|
|
258
|
+
pick it up.
|
|
238
259
|
- The mark-rounding radius used for the estimated finish time and remaining-distance
|
|
239
260
|
calculations (`markRoundingRadiusM`, 100m by default) is also a plugin setting —
|
|
240
261
|
loosen it for a fleet with noisier AIS tracks, or tighten it if marks sit close
|
package/index.js
CHANGED
|
@@ -5,6 +5,16 @@ const ExcelJS = require('exceljs');
|
|
|
5
5
|
const DEFAULT_HANDICAP_SOURCE_PAGE = 'https://ssca.no/aktiviteter/vet-tall';
|
|
6
6
|
const HANDICAP_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
7
7
|
|
|
8
|
+
// KTK (Klassisk Treseiler Klubb) publishes its own handicap register, KLR,
|
|
9
|
+
// as an HTML table embedded directly in a Blogger post tagged "KLR" — the
|
|
10
|
+
// label listing page below always shows the most recent post first (with
|
|
11
|
+
// its full content, not just an excerpt), so it doubles as "whichever
|
|
12
|
+
// season's numbers are current" without needing to track individual post
|
|
13
|
+
// URLs across years, the same way the VET-tall page above always links to
|
|
14
|
+
// the current sheet. A specific post URL works too, since it has the exact
|
|
15
|
+
// same table.
|
|
16
|
+
const DEFAULT_KTK_SOURCE_PAGE = 'http://klassisktreseilerklubb.blogspot.com/search/label/KLR';
|
|
17
|
+
|
|
8
18
|
function parseCsvText(text) {
|
|
9
19
|
const rows = [];
|
|
10
20
|
let row = [];
|
|
@@ -99,6 +109,51 @@ function parseHandicapSheet(csvText) {
|
|
|
99
109
|
return boats;
|
|
100
110
|
}
|
|
101
111
|
|
|
112
|
+
function stripHtmlToText(html) {
|
|
113
|
+
return html
|
|
114
|
+
.replace(/<[^>]*>/g, '')
|
|
115
|
+
.replace(/ /gi, ' ')
|
|
116
|
+
.replace(/&/gi, '&')
|
|
117
|
+
.trim();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// KTK's KLR table has just two columns (boat name, KLR number) and no label
|
|
121
|
+
// text distinguishing multiple entries for the same boat (unlike VET-tall's
|
|
122
|
+
// named sail configurations) — a boat with more than one row just gets
|
|
123
|
+
// numbered alternatives. The published KLR number isn't a TCF directly:
|
|
124
|
+
// corrected time = elapsed × (KLR / 100), so that division happens here,
|
|
125
|
+
// once, rather than at every point KLR values get used downstream — same
|
|
126
|
+
// as VET-tall's own numbers, which likewise need no conversion once parsed.
|
|
127
|
+
function parseKtkHtml(html) {
|
|
128
|
+
const tableMatch = html.match(/<table[\s\S]*?<\/table>/i);
|
|
129
|
+
if (!tableMatch) {
|
|
130
|
+
throw new Error('Could not find the KLR table on the page');
|
|
131
|
+
}
|
|
132
|
+
const rowMatches = tableMatch[0].match(/<tr[\s\S]*?<\/tr>/gi) || [];
|
|
133
|
+
const boatsByKey = new Map();
|
|
134
|
+
rowMatches.forEach((rowHtml) => {
|
|
135
|
+
const cellMatches = rowHtml.match(/<td[\s\S]*?<\/td>/gi) || [];
|
|
136
|
+
if (cellMatches.length < 2) return;
|
|
137
|
+
const name = stripHtmlToText(cellMatches[0]);
|
|
138
|
+
const rawValue = parseNorwegianNumber(stripHtmlToText(cellMatches[1]));
|
|
139
|
+
if (!name || rawValue == null || rawValue <= 0) return;
|
|
140
|
+
const key = name.toLowerCase();
|
|
141
|
+
const existing = boatsByKey.get(key) || { name, vets: [] };
|
|
142
|
+
// raw is the published KLR number itself (e.g. 166) — kept alongside
|
|
143
|
+
// the converted TCF (1.66) so the dropdown can show the number sailors
|
|
144
|
+
// actually recognize, not just the already-divided multiplier.
|
|
145
|
+
existing.vets.push({ label: '', value: Math.round((rawValue / 100) * 1000) / 1000, raw: rawValue });
|
|
146
|
+
boatsByKey.set(key, existing);
|
|
147
|
+
});
|
|
148
|
+
const boats = Array.from(boatsByKey.values());
|
|
149
|
+
boats.forEach((b) => {
|
|
150
|
+
b.vets.forEach((v, i) => {
|
|
151
|
+
v.label = b.vets.length > 1 ? `KLR ${i + 1}` : 'KLR';
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
return boats;
|
|
155
|
+
}
|
|
156
|
+
|
|
102
157
|
function extractGoogleSheetId(text) {
|
|
103
158
|
const m = text.match(/docs\.google\.com\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/);
|
|
104
159
|
return m ? m[1] : null;
|
|
@@ -358,6 +413,10 @@ function buildOfflineTimerHtml(race, defaultTcf, vetOptions) {
|
|
|
358
413
|
vetEnabled: !!vetOptions.vetEnabled,
|
|
359
414
|
handicapCsvUrl: vetOptions.handicapCsvUrl || null,
|
|
360
415
|
handicapBoats: vetOptions.handicapBoats || [],
|
|
416
|
+
// KTK has no live-refresh counterpart offline (see the route handler) —
|
|
417
|
+
// just this one-time snapshot from export time.
|
|
418
|
+
ktkEnabled: !!vetOptions.ktkEnabled,
|
|
419
|
+
ktkBoats: vetOptions.ktkBoats || [],
|
|
361
420
|
boats: Object.values(race.boats).map((b) => ({
|
|
362
421
|
id: b.id,
|
|
363
422
|
name: b.name,
|
|
@@ -382,6 +441,12 @@ function buildOfflineTimerHtml(race, defaultTcf, vetOptions) {
|
|
|
382
441
|
// title, the iOS home-screen app title) — escaped the normal way for that
|
|
383
442
|
// context, distinct from the JSON escaping above.
|
|
384
443
|
const escapedRaceName = String(race.name || 'Race').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
444
|
+
const handicapNotes = [];
|
|
445
|
+
if (vetOptions.vetEnabled) handicapNotes.push('VET-tall handicaps can be refreshed here directly from the internet (see below)');
|
|
446
|
+
if (vetOptions.ktkEnabled) handicapNotes.push("KTK's KLR numbers are included as of download time, but can't be refreshed offline (no direct internet fetch support)");
|
|
447
|
+
const handicapNote = handicapNotes.length
|
|
448
|
+
? handicapNotes.join(', and ') + '. Importing a whole fleet from Manage2Sail still needs the live plugin server, though.'
|
|
449
|
+
: 'TCF is entered by hand.';
|
|
385
450
|
|
|
386
451
|
return `<!doctype html>
|
|
387
452
|
<html lang="en">
|
|
@@ -426,7 +491,7 @@ button.confirming { background: var(--bad); color: #2a0a0a; border-color: var(--
|
|
|
426
491
|
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
427
492
|
.status { min-height: 1.2em; margin-top: 0.5rem; font-size: 0.85rem; color: var(--muted); }
|
|
428
493
|
.status.error { color: var(--bad); }
|
|
429
|
-
main { padding: 1rem 1.5rem max(2rem, env(safe-area-inset-bottom));
|
|
494
|
+
main { padding: 1rem 1.5rem max(2rem, env(safe-area-inset-bottom)); margin: 0 auto; }
|
|
430
495
|
.add-boat-row { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
|
|
431
496
|
.add-boat-row input[type='text'] { flex: 1; min-width: 12rem; padding: 0.45rem 0.6rem; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font-size: 0.9rem; }
|
|
432
497
|
.add-boat-row input[type='number'] { width: 6rem; padding: 0.45rem 0.6rem; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font-size: 0.9rem; }
|
|
@@ -434,6 +499,8 @@ main { padding: 1rem 1.5rem max(2rem, env(safe-area-inset-bottom)); max-width: 9
|
|
|
434
499
|
table { width: 100%; min-width: 44rem; border-collapse: collapse; font-variant-numeric: tabular-nums; }
|
|
435
500
|
thead th { text-align: left; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); }
|
|
436
501
|
tbody td { padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--border); }
|
|
502
|
+
.boat-name-col { position: sticky; left: 0; z-index: 1; background: var(--bg); }
|
|
503
|
+
thead .boat-name-col { z-index: 2; }
|
|
437
504
|
tbody tr.finished td { color: var(--good); }
|
|
438
505
|
tbody tr.dnf td { color: var(--muted); }
|
|
439
506
|
.dnf-tag { color: var(--bad); font-weight: 700; font-size: 0.85rem; letter-spacing: 0.03em; }
|
|
@@ -466,7 +533,7 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
466
533
|
<p class="offline-note">Standalone backup — works with no server connection to this plugin.
|
|
467
534
|
Everything you do here is saved in this browser only (reopen this same downloaded file
|
|
468
535
|
to continue). Boat names/positions from AIS and the course/chart aren't available
|
|
469
|
-
offline. ${
|
|
536
|
+
offline. ${handicapNote}</p>
|
|
470
537
|
<h2 id="raceName"></h2>
|
|
471
538
|
<div id="clock" class="clock">00:00:00</div>
|
|
472
539
|
<div class="controls">
|
|
@@ -498,10 +565,10 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
498
565
|
<table id="boatsTable">
|
|
499
566
|
<thead>
|
|
500
567
|
<tr>
|
|
501
|
-
<th>Boat</th>
|
|
568
|
+
<th class="boat-name-col">Boat</th>
|
|
502
569
|
<th>Sail #</th>
|
|
503
570
|
<th>TCF</th>
|
|
504
|
-
<th id="vetAlternativesTh" hidden>
|
|
571
|
+
<th id="vetAlternativesTh" hidden>Handicap alternatives</th>
|
|
505
572
|
<th>Start time</th>
|
|
506
573
|
<th>Elapsed</th>
|
|
507
574
|
<th>Corrected</th>
|
|
@@ -546,8 +613,12 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
546
613
|
race.vetEnabled = !!seed.vetEnabled;
|
|
547
614
|
race.handicapCsvUrl = seed.handicapCsvUrl || null;
|
|
548
615
|
if (!Array.isArray(race.handicapBoats)) race.handicapBoats = seed.handicapBoats || [];
|
|
616
|
+
// KTK has no live refresh offline (see the seed's own comment above), so
|
|
617
|
+
// its snapshot only ever comes from the seed — always take it fresh.
|
|
618
|
+
race.ktkEnabled = !!seed.ktkEnabled;
|
|
619
|
+
race.ktkBoats = seed.ktkBoats || [];
|
|
549
620
|
} catch (e) {
|
|
550
|
-
race = { name: 'Race', startTime: null, stopTime: null, selfBoatId: null, multiDay: false, boats: [], defaultTcf: 1.0, vetEnabled: false, handicapCsvUrl: null, handicapBoats: [] };
|
|
621
|
+
race = { name: 'Race', startTime: null, stopTime: null, selfBoatId: null, multiDay: false, boats: [], defaultTcf: 1.0, vetEnabled: false, handicapCsvUrl: null, handicapBoats: [], ktkEnabled: false, ktkBoats: [] };
|
|
551
622
|
}
|
|
552
623
|
|
|
553
624
|
function save() {
|
|
@@ -687,7 +758,7 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
687
758
|
function rebuildAddBoatSuggestions() {
|
|
688
759
|
addBoatSuggestions.innerHTML = '';
|
|
689
760
|
var seen = {};
|
|
690
|
-
race.handicapBoats.forEach(function (b) {
|
|
761
|
+
race.handicapBoats.concat(race.ktkBoats).forEach(function (b) {
|
|
691
762
|
var key = b.name.toLowerCase();
|
|
692
763
|
if (seen[key]) return;
|
|
693
764
|
seen[key] = true;
|
|
@@ -722,25 +793,41 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
722
793
|
});
|
|
723
794
|
}
|
|
724
795
|
|
|
725
|
-
// Rebuilds a row's
|
|
726
|
-
// only called when
|
|
796
|
+
// Rebuilds a row's alternatives <select> from the current register(s) —
|
|
797
|
+
// only called when a register itself (re)loads, not every render tick.
|
|
798
|
+
// Merges VET-tall and KTK when both are enabled and a boat matches both,
|
|
799
|
+
// rather than picking one over the other.
|
|
727
800
|
function refreshVetAlternatives(row, boatName) {
|
|
728
|
-
var
|
|
801
|
+
var name = boatName.trim().toLowerCase();
|
|
802
|
+
var vetEntry = race.vetEnabled ? race.handicapBoats.find(function (h) { return h.name.toLowerCase() === name; }) : null;
|
|
803
|
+
var ktkEntry = race.ktkEnabled ? race.ktkBoats.find(function (h) { return h.name.toLowerCase() === name; }) : null;
|
|
804
|
+
var bothEnabled = race.vetEnabled && race.ktkEnabled;
|
|
805
|
+
var options = [];
|
|
806
|
+
if (vetEntry) {
|
|
807
|
+
vetEntry.vets.forEach(function (v) {
|
|
808
|
+
options.push({ value: v.value, text: (bothEnabled ? 'VET ' : '') + v.label + ': ' + v.value });
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
if (ktkEntry) {
|
|
812
|
+
ktkEntry.vets.forEach(function (v) {
|
|
813
|
+
options.push({ value: v.value, text: (bothEnabled ? 'KTK ' : '') + v.label + ': ' + v.raw + ' → ' + v.value });
|
|
814
|
+
});
|
|
815
|
+
}
|
|
729
816
|
row.vetSelect.innerHTML = '';
|
|
730
817
|
var placeholder = document.createElement('option');
|
|
731
818
|
placeholder.value = '';
|
|
732
|
-
placeholder.textContent =
|
|
819
|
+
placeholder.textContent = options.length ? 'Pick…' : 'No match';
|
|
733
820
|
row.vetSelect.appendChild(placeholder);
|
|
734
|
-
row.vetSelect.disabled = !
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
var notValid = /ikke/i.test(
|
|
743
|
-
row.vetBadge.textContent =
|
|
821
|
+
row.vetSelect.disabled = !options.length;
|
|
822
|
+
options.forEach(function (o) {
|
|
823
|
+
var opt = document.createElement('option');
|
|
824
|
+
opt.value = String(o.value);
|
|
825
|
+
opt.textContent = o.text;
|
|
826
|
+
row.vetSelect.appendChild(opt);
|
|
827
|
+
});
|
|
828
|
+
if (vetEntry) {
|
|
829
|
+
var notValid = /ikke/i.test(vetEntry.validity || '');
|
|
830
|
+
row.vetBadge.textContent = vetEntry.validity ? (notValid ? '⚠ ' + vetEntry.validity : vetEntry.validity) : '';
|
|
744
831
|
row.vetBadge.classList.toggle('warn', notValid);
|
|
745
832
|
} else {
|
|
746
833
|
row.vetBadge.textContent = '';
|
|
@@ -817,6 +904,14 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
817
904
|
return { boat: boat, elapsedMs: elapsedMs, correctedMs: correctedMs, rankMs: rankMs };
|
|
818
905
|
})
|
|
819
906
|
.sort(function (a, b) {
|
|
907
|
+
// Self always leads, whatever else is true.
|
|
908
|
+
var aSelf = a.boat.id === race.selfBoatId;
|
|
909
|
+
var bSelf = b.boat.id === race.selfBoatId;
|
|
910
|
+
if (aSelf !== bSelf) return aSelf ? -1 : 1;
|
|
911
|
+
// Before the race actually starts, nobody has a corrected time to
|
|
912
|
+
// rank by anyway — leave order exactly as race.boats gave it
|
|
913
|
+
// (registration order) rather than reshuffling on every add/remove.
|
|
914
|
+
if (!race.startTime) return 0;
|
|
820
915
|
if (a.rankMs == null && b.rankMs == null) return a.boat.name.localeCompare(b.boat.name);
|
|
821
916
|
if (a.rankMs == null) return 1;
|
|
822
917
|
if (b.rankMs == null) return -1;
|
|
@@ -938,6 +1033,7 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
938
1033
|
});
|
|
939
1034
|
var nameSpan = document.createElement('span');
|
|
940
1035
|
var tdName = document.createElement('td');
|
|
1036
|
+
tdName.className = 'boat-name-col';
|
|
941
1037
|
tdName.append(selfBtn, nameSpan);
|
|
942
1038
|
|
|
943
1039
|
var sailNumberInput = document.createElement('input');
|
|
@@ -991,7 +1087,7 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
991
1087
|
vetBadge.className = 'vet-badge';
|
|
992
1088
|
var tdVet = document.createElement('td');
|
|
993
1089
|
tdVet.className = 'vet-cell';
|
|
994
|
-
tdVet.hidden = !race.vetEnabled;
|
|
1090
|
+
tdVet.hidden = !race.vetEnabled && !race.ktkEnabled;
|
|
995
1091
|
tdVet.append(vetSelect, vetBadge);
|
|
996
1092
|
|
|
997
1093
|
var startTimeInput = document.createElement('input');
|
|
@@ -1178,7 +1274,7 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
1178
1274
|
row.selfBtn.classList.toggle('active', isSelf);
|
|
1179
1275
|
if (document.activeElement !== row.sailNumberInput) row.sailNumberInput.value = b.sailNumber || '';
|
|
1180
1276
|
if (document.activeElement !== row.tcfInput) row.tcfInput.value = b.tcf;
|
|
1181
|
-
if (race.vetEnabled) {
|
|
1277
|
+
if (race.vetEnabled || race.ktkEnabled) {
|
|
1182
1278
|
if (row.vetHandicapVersion !== handicapVersion && document.activeElement !== row.vetSelect) {
|
|
1183
1279
|
refreshVetAlternatives(row, b.name);
|
|
1184
1280
|
row.vetHandicapVersion = handicapVersion;
|
|
@@ -1278,8 +1374,9 @@ tbody tr.dnf td { color: var(--muted); }
|
|
|
1278
1374
|
});
|
|
1279
1375
|
|
|
1280
1376
|
vetStatusLine.hidden = !race.vetEnabled;
|
|
1281
|
-
vetAlternativesTh.hidden = !race.vetEnabled;
|
|
1377
|
+
vetAlternativesTh.hidden = !race.vetEnabled && !race.ktkEnabled;
|
|
1282
1378
|
vetRefreshBtn.addEventListener('click', function () { loadHandicapRegister(true); });
|
|
1379
|
+
if (race.ktkEnabled) rebuildAddBoatSuggestions();
|
|
1283
1380
|
if (race.vetEnabled) {
|
|
1284
1381
|
rebuildAddBoatSuggestions();
|
|
1285
1382
|
if (race.handicapBoats.length) {
|
|
@@ -1325,6 +1422,18 @@ module.exports = function (app) {
|
|
|
1325
1422
|
'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.',
|
|
1326
1423
|
default: false
|
|
1327
1424
|
},
|
|
1425
|
+
ktkSourceUrl: {
|
|
1426
|
+
type: 'string',
|
|
1427
|
+
title:
|
|
1428
|
+
"KTK page to fetch the current season's KLR numbers from — either the label listing (whose most recent post is used) or a specific post directly",
|
|
1429
|
+
default: DEFAULT_KTK_SOURCE_PAGE
|
|
1430
|
+
},
|
|
1431
|
+
ktkEnabled: {
|
|
1432
|
+
type: 'boolean',
|
|
1433
|
+
title:
|
|
1434
|
+
"Use KTK's KLR register for autocomplete and per-boat handicap alternatives too, alongside VET-tall if that's also on — a boat listed in both shows alternatives from both. Corrected time from a KLR number is elapsed × (KLR / 100).",
|
|
1435
|
+
default: false
|
|
1436
|
+
},
|
|
1328
1437
|
raceImportEnabled: {
|
|
1329
1438
|
type: 'boolean',
|
|
1330
1439
|
title:
|
|
@@ -1351,6 +1460,7 @@ module.exports = function (app) {
|
|
|
1351
1460
|
const scheduleTimers = new Map(); // raceId -> Timeout, for scheduledStart
|
|
1352
1461
|
const callOffTimers = new Map(); // raceId -> Timeout, for scheduledCallOff
|
|
1353
1462
|
let handicapCache = { fetchedAt: 0, boats: [], sourceUrl: null, csvUrl: null };
|
|
1463
|
+
let ktkCache = { fetchedAt: 0, boats: [], sourceUrl: null };
|
|
1354
1464
|
|
|
1355
1465
|
function loadState() {
|
|
1356
1466
|
try {
|
|
@@ -1394,20 +1504,29 @@ module.exports = function (app) {
|
|
|
1394
1504
|
return !!(plugin.options && plugin.options.vetEnabled === true);
|
|
1395
1505
|
}
|
|
1396
1506
|
|
|
1507
|
+
// Same pattern as vetEnabled, for KTK's KLR register.
|
|
1508
|
+
function isKtkEnabled() {
|
|
1509
|
+
return !!(plugin.options && plugin.options.ktkEnabled === true);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1397
1512
|
// Plugin config setting, same pattern as vetEnabled — off by default, not
|
|
1398
1513
|
// per-race, not writable from the webapp itself.
|
|
1399
1514
|
function isRaceImportEnabled() {
|
|
1400
1515
|
return !!(plugin.options && plugin.options.raceImportEnabled === true);
|
|
1401
1516
|
}
|
|
1402
1517
|
|
|
1403
|
-
// A boat only counts as "in
|
|
1404
|
-
//
|
|
1405
|
-
// cached) actually has a matching name — disabling
|
|
1406
|
-
// "outside
|
|
1407
|
-
|
|
1408
|
-
|
|
1518
|
+
// A boat only counts as "in a handicap register" for registry-TCF
|
|
1519
|
+
// purposes while that register is enabled and (whatever's currently
|
|
1520
|
+
// cached of) it actually has a matching name — disabling a register makes
|
|
1521
|
+
// every boat "outside" it for this purpose too, per the setting's whole
|
|
1522
|
+
// point. Checks both VET-tall and KTK, since a boat matched by either one
|
|
1523
|
+
// should be picked fresh from its own dropdown rather than carrying over
|
|
1524
|
+
// a remembered TCF.
|
|
1525
|
+
function isHandicapRegisterMatch(name) {
|
|
1409
1526
|
const n = (name || '').trim().toLowerCase();
|
|
1410
|
-
|
|
1527
|
+
if (isVetEnabled() && handicapCache.boats.some((b) => b.name.toLowerCase() === n)) return true;
|
|
1528
|
+
if (isKtkEnabled() && ktkCache.boats.some((b) => b.name.toLowerCase() === n)) return true;
|
|
1529
|
+
return false;
|
|
1411
1530
|
}
|
|
1412
1531
|
|
|
1413
1532
|
function saveState() {
|
|
@@ -1788,6 +1907,22 @@ module.exports = function (app) {
|
|
|
1788
1907
|
return handicapCache;
|
|
1789
1908
|
}
|
|
1790
1909
|
|
|
1910
|
+
async function fetchKtkBoats(forceRefresh) {
|
|
1911
|
+
const sourceUrl = (plugin.options && plugin.options.ktkSourceUrl) || DEFAULT_KTK_SOURCE_PAGE;
|
|
1912
|
+
const age = Date.now() - ktkCache.fetchedAt;
|
|
1913
|
+
if (!forceRefresh && ktkCache.sourceUrl === sourceUrl && age < HANDICAP_CACHE_TTL_MS) {
|
|
1914
|
+
return ktkCache;
|
|
1915
|
+
}
|
|
1916
|
+
const res = await fetch(sourceUrl);
|
|
1917
|
+
if (!res.ok) {
|
|
1918
|
+
throw new Error(`KTK page returned HTTP ${res.status}`);
|
|
1919
|
+
}
|
|
1920
|
+
const html = await res.text();
|
|
1921
|
+
const boats = parseKtkHtml(html);
|
|
1922
|
+
ktkCache = { fetchedAt: Date.now(), boats, sourceUrl };
|
|
1923
|
+
return ktkCache;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1791
1926
|
async function fetchManage2SailClasses(eventUrl) {
|
|
1792
1927
|
const res = await fetch(eventUrl);
|
|
1793
1928
|
if (!res.ok) {
|
|
@@ -1985,10 +2120,26 @@ module.exports = function (app) {
|
|
|
1985
2120
|
}
|
|
1986
2121
|
}
|
|
1987
2122
|
}
|
|
2123
|
+
// KTK's page has no CORS support (unlike the VET-tall CSV export), so
|
|
2124
|
+
// unlike handicapBoats above this is a one-time snapshot only — the
|
|
2125
|
+
// offline page can't refresh it live itself. Still seeded in, since a
|
|
2126
|
+
// snapshot from export time is better than nothing.
|
|
2127
|
+
let ktkBoats = [];
|
|
2128
|
+
if (isKtkEnabled()) {
|
|
2129
|
+
try {
|
|
2130
|
+
const data = await fetchKtkBoats();
|
|
2131
|
+
ktkBoats = data.boats;
|
|
2132
|
+
} catch (e) {
|
|
2133
|
+
// Leave ktkBoats empty — same best-effort spirit as the rest of
|
|
2134
|
+
// this route.
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
1988
2137
|
const html = buildOfflineTimerHtml(race, defaultTcf, {
|
|
1989
2138
|
vetEnabled: isVetEnabled(),
|
|
1990
2139
|
handicapCsvUrl,
|
|
1991
|
-
handicapBoats
|
|
2140
|
+
handicapBoats,
|
|
2141
|
+
ktkEnabled: isKtkEnabled(),
|
|
2142
|
+
ktkBoats
|
|
1992
2143
|
});
|
|
1993
2144
|
const safeName = (race.name || 'race').replace(/[^a-z0-9\-_]+/gi, '_').slice(0, 60) || 'race';
|
|
1994
2145
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
@@ -2222,11 +2373,12 @@ module.exports = function (app) {
|
|
|
2222
2373
|
const sailNumber = givenSailNumber || (registryEntry && registryEntry.sailNumber) || null;
|
|
2223
2374
|
if (sailNumber) upsertBoatRegistry(name, { sailNumber });
|
|
2224
2375
|
const defaultTcf = (plugin.options && plugin.options.defaultTcf) || 1.0;
|
|
2225
|
-
// A remembered TCF only applies to boats outside
|
|
2226
|
-
// boat should be picked fresh from the
|
|
2227
|
-
// than silently carrying over a
|
|
2376
|
+
// A remembered TCF only applies to boats outside every enabled
|
|
2377
|
+
// register — a matched boat should be picked fresh from the
|
|
2378
|
+
// register's own dropdown rather than silently carrying over a
|
|
2379
|
+
// number from wherever it last raced.
|
|
2228
2380
|
let tcf = defaultTcf;
|
|
2229
|
-
if (!
|
|
2381
|
+
if (!isHandicapRegisterMatch(name) && registryEntry && registryEntry.tcf != null) {
|
|
2230
2382
|
tcf = registryEntry.tcf;
|
|
2231
2383
|
}
|
|
2232
2384
|
const boat = {
|
|
@@ -2402,6 +2554,11 @@ module.exports = function (app) {
|
|
|
2402
2554
|
res.json({ enabled: isVetEnabled() });
|
|
2403
2555
|
});
|
|
2404
2556
|
|
|
2557
|
+
// Same pattern as vet-enabled, for KTK's KLR register.
|
|
2558
|
+
router.get('/ktk-enabled', (req, res) => {
|
|
2559
|
+
res.json({ enabled: isKtkEnabled() });
|
|
2560
|
+
});
|
|
2561
|
+
|
|
2405
2562
|
// Lets the course editor offer existing SignalK waypoints (e.g. ones
|
|
2406
2563
|
// already placed on a chart plotter) as start/mark/finish positions,
|
|
2407
2564
|
// instead of only typing lat/lon by hand. Best-effort, same spirit as
|
|
@@ -2438,9 +2595,10 @@ module.exports = function (app) {
|
|
|
2438
2595
|
return res.status(400).json({ error: 'tcf must be a positive number' });
|
|
2439
2596
|
}
|
|
2440
2597
|
boat.tcf = tcf;
|
|
2441
|
-
// Remembered across races only for boats outside
|
|
2442
|
-
// on upsertBoatRegistry/
|
|
2443
|
-
|
|
2598
|
+
// Remembered across races only for boats outside every enabled
|
|
2599
|
+
// register — see the note on upsertBoatRegistry/isHandicapRegisterMatch
|
|
2600
|
+
// above.
|
|
2601
|
+
if (!isHandicapRegisterMatch(boat.name)) upsertBoatRegistry(boat.name, { tcf });
|
|
2444
2602
|
saveState();
|
|
2445
2603
|
res.json(boat);
|
|
2446
2604
|
});
|
|
@@ -2455,6 +2613,16 @@ module.exports = function (app) {
|
|
|
2455
2613
|
}
|
|
2456
2614
|
});
|
|
2457
2615
|
|
|
2616
|
+
router.get('/ktk-source', async (req, res) => {
|
|
2617
|
+
try {
|
|
2618
|
+
const force = req.query.refresh === 'true' || req.query.refresh === '1';
|
|
2619
|
+
const data = await fetchKtkBoats(force);
|
|
2620
|
+
res.json(data);
|
|
2621
|
+
} catch (e) {
|
|
2622
|
+
res.status(502).json({ error: 'Could not load KTK register: ' + e.message });
|
|
2623
|
+
}
|
|
2624
|
+
});
|
|
2625
|
+
|
|
2458
2626
|
// Plugin config setting (Server -> Plugin Config), same pattern as
|
|
2459
2627
|
// vet-enabled — the webapp only reads it to know whether to show the
|
|
2460
2628
|
// import section at all.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-race-control",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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
|
"author": "Joachim Bakke <github@heiamoss.com>",
|
package/public/app.js
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
let suggestionItems = []; // currently-shown filtered suggestions
|
|
12
12
|
let suggestionActiveIndex = -1;
|
|
13
13
|
let handicapBoats = [];
|
|
14
|
-
let handicapVersion = 0; // bumped each successful VET-register load
|
|
14
|
+
let handicapVersion = 0; // bumped each successful VET-register (or KTK) load
|
|
15
15
|
let vetEnabled = false; // plugin config setting (Server -> Plugin Config), read-only here; off by default
|
|
16
|
+
let ktkBoats = []; // [{name, vets: [{label, value}]}] from KTK's KLR register — same shape as handicapBoats
|
|
17
|
+
let ktkEnabled = false; // plugin config setting, same pattern as vetEnabled
|
|
16
18
|
let raceImportEnabled = false; // plugin config setting, same pattern as vetEnabled
|
|
17
19
|
let importEventId = null; // Manage2Sail event id from the last successful "Find Classes" lookup
|
|
18
20
|
let importClasses = []; // [{id, name}] from that same lookup
|
|
@@ -60,6 +62,9 @@
|
|
|
60
62
|
const vetStatusText = document.getElementById('vetStatusText');
|
|
61
63
|
const vetRefreshBtn = document.getElementById('vetRefreshBtn');
|
|
62
64
|
const vetAlternativesTh = document.getElementById('vetAlternativesTh');
|
|
65
|
+
const ktkStatusLine = document.getElementById('ktkStatusLine');
|
|
66
|
+
const ktkStatusText = document.getElementById('ktkStatusText');
|
|
67
|
+
const ktkRefreshBtn = document.getElementById('ktkRefreshBtn');
|
|
63
68
|
const addBoatRow = document.getElementById('addBoatRow');
|
|
64
69
|
const addBoatName = document.getElementById('addBoatName');
|
|
65
70
|
const addBoatBtn = document.getElementById('addBoatBtn');
|
|
@@ -225,6 +230,11 @@
|
|
|
225
230
|
vetStatusText.classList.toggle('error', !!isError);
|
|
226
231
|
}
|
|
227
232
|
|
|
233
|
+
function setKtkStatus(msg, isError) {
|
|
234
|
+
ktkStatusText.textContent = msg || '';
|
|
235
|
+
ktkStatusText.classList.toggle('error', !!isError);
|
|
236
|
+
}
|
|
237
|
+
|
|
228
238
|
// Vessels are only used as autocomplete suggestions when adding a boat
|
|
229
239
|
// (name + MMSI hint) — a race's boat list is otherwise entered explicitly,
|
|
230
240
|
// not auto-populated from AIS.
|
|
@@ -264,6 +274,7 @@
|
|
|
264
274
|
}
|
|
265
275
|
}
|
|
266
276
|
if (vetEnabled) handicapBoats.forEach((b) => upsert(b.name, ''));
|
|
277
|
+
if (ktkEnabled) ktkBoats.forEach((b) => upsert(b.name, ''));
|
|
267
278
|
boatRegistry.forEach((b) => upsert(b.name, b.mmsi));
|
|
268
279
|
vesselSuggestions.forEach((v) => upsert(v.name, v.mmsi));
|
|
269
280
|
nameSuggestionPool = Array.from(byName.values());
|
|
@@ -362,10 +373,23 @@
|
|
|
362
373
|
}
|
|
363
374
|
// When disabled, every trace of VET is removed from the webapp rather
|
|
364
375
|
// than shown as a disabled/placeholder state — the status line (register
|
|
365
|
-
// status text + refresh link)
|
|
366
|
-
// column
|
|
376
|
+
// status text + refresh link) disappears entirely. The "Handicap
|
|
377
|
+
// alternatives" column is shared with KTK, so it's only hidden once
|
|
378
|
+
// both are off — see loadKtkEnabled.
|
|
367
379
|
vetStatusLine.hidden = !vetEnabled;
|
|
368
|
-
vetAlternativesTh.hidden = !vetEnabled;
|
|
380
|
+
vetAlternativesTh.hidden = !vetEnabled && !ktkEnabled;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Same pattern as loadVetEnabled, for KTK's KLR register.
|
|
384
|
+
async function loadKtkEnabled() {
|
|
385
|
+
try {
|
|
386
|
+
const data = await fetchJSON(`${API}/ktk-enabled`);
|
|
387
|
+
ktkEnabled = data.enabled === true;
|
|
388
|
+
} catch (e) {
|
|
389
|
+
ktkEnabled = false;
|
|
390
|
+
}
|
|
391
|
+
ktkStatusLine.hidden = !ktkEnabled;
|
|
392
|
+
vetAlternativesTh.hidden = !vetEnabled && !ktkEnabled;
|
|
369
393
|
}
|
|
370
394
|
|
|
371
395
|
// Same pattern as loadVetEnabled — when disabled, the whole import
|
|
@@ -406,6 +430,20 @@
|
|
|
406
430
|
}
|
|
407
431
|
}
|
|
408
432
|
|
|
433
|
+
async function loadKtkRegister(force) {
|
|
434
|
+
if (!ktkEnabled) return;
|
|
435
|
+
try {
|
|
436
|
+
setKtkStatus('Loading KTK register…');
|
|
437
|
+
const data = await fetchJSON(`${API}/ktk-source${force ? '?refresh=true' : ''}`);
|
|
438
|
+
ktkBoats = data.boats || [];
|
|
439
|
+
handicapVersion++;
|
|
440
|
+
rebuildNameSuggestionPool();
|
|
441
|
+
setKtkStatus(`KTK register: ${ktkBoats.length} boats loaded.`);
|
|
442
|
+
} catch (e) {
|
|
443
|
+
setKtkStatus('Could not load KTK register: ' + e.message, true);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
409
447
|
async function loadRacesList() {
|
|
410
448
|
try {
|
|
411
449
|
const data = await fetchJSON(`${API}/races`);
|
|
@@ -1607,6 +1645,19 @@
|
|
|
1607
1645
|
};
|
|
1608
1646
|
})
|
|
1609
1647
|
.sort((a, b) => {
|
|
1648
|
+
// Self always leads, whatever else is true — it's the boat whoever
|
|
1649
|
+
// is looking at this screen cares about finding without hunting
|
|
1650
|
+
// for it.
|
|
1651
|
+
const aSelf = a.boatId === raceState.selfBoatId;
|
|
1652
|
+
const bSelf = b.boatId === raceState.selfBoatId;
|
|
1653
|
+
if (aSelf !== bSelf) return aSelf ? -1 : 1;
|
|
1654
|
+
// Before the race actually starts, nobody has a corrected time to
|
|
1655
|
+
// rank by anyway — sorting by name (or anything else derived) just
|
|
1656
|
+
// reshuffled the rows confusingly every time a boat was added or
|
|
1657
|
+
// removed. Rely on sort's stability and leave order exactly as
|
|
1658
|
+
// Object.values(raceState.boats) gave it (i.e. registration order)
|
|
1659
|
+
// until there's an actual race to rank.
|
|
1660
|
+
if (!raceState.startTime) return 0;
|
|
1610
1661
|
if (a.rankMs == null && b.rankMs == null) return a.name.localeCompare(b.name);
|
|
1611
1662
|
if (a.rankMs == null) return 1;
|
|
1612
1663
|
if (b.rankMs == null) return -1;
|
|
@@ -1692,6 +1743,7 @@
|
|
|
1692
1743
|
nameSpan.className = 'boat-name-text';
|
|
1693
1744
|
|
|
1694
1745
|
const tdName = document.createElement('td');
|
|
1746
|
+
tdName.className = 'boat-name-col';
|
|
1695
1747
|
tdName.append(selfBtn, nameSpan);
|
|
1696
1748
|
|
|
1697
1749
|
const sailNumberInput = document.createElement('input');
|
|
@@ -1754,7 +1806,7 @@
|
|
|
1754
1806
|
vetBadge.className = 'vet-badge';
|
|
1755
1807
|
const tdVet = document.createElement('td');
|
|
1756
1808
|
tdVet.className = 'vet-cell';
|
|
1757
|
-
tdVet.hidden = !vetEnabled;
|
|
1809
|
+
tdVet.hidden = !vetEnabled && !ktkEnabled;
|
|
1758
1810
|
tdVet.append(vetSelect, vetBadge);
|
|
1759
1811
|
|
|
1760
1812
|
// A boat's own start time, for a staggered/pursuit start or to correct
|
|
@@ -1909,28 +1961,43 @@
|
|
|
1909
1961
|
};
|
|
1910
1962
|
}
|
|
1911
1963
|
|
|
1912
|
-
// Rebuilds a row's
|
|
1913
|
-
// only called when
|
|
1914
|
-
// so an open dropdown or mid-pick isn't disrupted every
|
|
1915
|
-
// called at all while vetEnabled
|
|
1916
|
-
// "
|
|
1964
|
+
// Rebuilds a row's handicap-alternatives <select> from the current
|
|
1965
|
+
// register(s) — only called when a register itself (re)loads, not every
|
|
1966
|
+
// render tick, so an open dropdown or mid-pick isn't disrupted every
|
|
1967
|
+
// second. Never called at all while both vetEnabled and ktkEnabled are
|
|
1968
|
+
// false (see the call site) — the "Handicap alternatives" column is
|
|
1969
|
+
// hidden entirely in that case. Merges VET-tall and KTK when both are
|
|
1970
|
+
// enabled and a boat matches both, rather than picking one over the
|
|
1971
|
+
// other.
|
|
1917
1972
|
function refreshVetAlternatives(row, boatName) {
|
|
1918
|
-
const
|
|
1973
|
+
const name = boatName.trim().toLowerCase();
|
|
1974
|
+
const vetEntry = vetEnabled ? handicapBoats.find((h) => h.name.toLowerCase() === name) : null;
|
|
1975
|
+
const ktkEntry = ktkEnabled ? ktkBoats.find((h) => h.name.toLowerCase() === name) : null;
|
|
1976
|
+
const bothEnabled = vetEnabled && ktkEnabled;
|
|
1977
|
+
const options = [];
|
|
1978
|
+
if (vetEntry) {
|
|
1979
|
+
vetEntry.vets.forEach((v) => options.push({ value: v.value, text: `${bothEnabled ? 'VET ' : ''}${v.label}: ${v.value}` }));
|
|
1980
|
+
}
|
|
1981
|
+
if (ktkEntry) {
|
|
1982
|
+
ktkEntry.vets.forEach((v) =>
|
|
1983
|
+
options.push({ value: v.value, text: `${bothEnabled ? 'KTK ' : ''}${v.label}: ${v.raw} → ${v.value}` })
|
|
1984
|
+
);
|
|
1985
|
+
}
|
|
1919
1986
|
row.vetSelect.innerHTML = '';
|
|
1920
1987
|
const placeholder = document.createElement('option');
|
|
1921
1988
|
placeholder.value = '';
|
|
1922
|
-
placeholder.textContent =
|
|
1989
|
+
placeholder.textContent = options.length ? 'Pick…' : 'No match';
|
|
1923
1990
|
row.vetSelect.appendChild(placeholder);
|
|
1924
|
-
row.vetSelect.disabled = !
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
const notValid = /ikke/i.test(
|
|
1933
|
-
row.vetBadge.textContent =
|
|
1991
|
+
row.vetSelect.disabled = !options.length;
|
|
1992
|
+
options.forEach((o) => {
|
|
1993
|
+
const opt = document.createElement('option');
|
|
1994
|
+
opt.value = String(o.value);
|
|
1995
|
+
opt.textContent = o.text;
|
|
1996
|
+
row.vetSelect.appendChild(opt);
|
|
1997
|
+
});
|
|
1998
|
+
if (vetEntry) {
|
|
1999
|
+
const notValid = /ikke/i.test(vetEntry.validity || '');
|
|
2000
|
+
row.vetBadge.textContent = vetEntry.validity ? (notValid ? '⚠ ' + vetEntry.validity : vetEntry.validity) : '';
|
|
1934
2001
|
row.vetBadge.classList.toggle('warn', notValid);
|
|
1935
2002
|
} else {
|
|
1936
2003
|
row.vetBadge.textContent = '';
|
|
@@ -2041,20 +2108,28 @@
|
|
|
2041
2108
|
const vsSelfMap = computeVsSelf(boats, raceState.selfBoatId);
|
|
2042
2109
|
|
|
2043
2110
|
boats.forEach((b) => {
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
row = buildRow(b.boatId);
|
|
2047
|
-
rows.set(b.boatId, row);
|
|
2048
|
-
}
|
|
2049
|
-
// Moving an already-attached node to its sorted position keeps it
|
|
2050
|
-
// intact (focus, in-progress edits) rather than recreating it — but
|
|
2051
|
-
// the move itself still resets a text cursor and closes an open
|
|
2052
|
-
// <select>, so skip it entirely while the user has something in this
|
|
2053
|
-
// row focused. It'll snap to its correct position as soon as they're
|
|
2054
|
-
// done (next render after focus moves away).
|
|
2055
|
-
if (!row.tr.contains(document.activeElement)) {
|
|
2056
|
-
boatsBody.appendChild(row.tr);
|
|
2111
|
+
if (!rows.get(b.boatId)) {
|
|
2112
|
+
rows.set(b.boatId, buildRow(b.boatId));
|
|
2057
2113
|
}
|
|
2114
|
+
});
|
|
2115
|
+
// Moving already-attached nodes to their sorted position keeps them
|
|
2116
|
+
// intact (focus, in-progress edits) rather than recreating them — but
|
|
2117
|
+
// the move itself still resets a text cursor and closes an open
|
|
2118
|
+
// <select>, so the whole reorder pass is skipped while the user has
|
|
2119
|
+
// anything in ANY row focused, not just the row that would move.
|
|
2120
|
+
// Reordering around a skipped row by moving everyone else in sequence
|
|
2121
|
+
// can still shuffle it to a different spot as a side effect of those
|
|
2122
|
+
// other moves, which is exactly the "keep self on top" guarantee this
|
|
2123
|
+
// was supposed to preserve — so it's all-or-nothing instead. It'll
|
|
2124
|
+
// snap to the fully correct order on the next render once focus moves
|
|
2125
|
+
// away.
|
|
2126
|
+
const anyRowFocused = boats.some((b) => rows.get(b.boatId).tr.contains(document.activeElement));
|
|
2127
|
+
if (!anyRowFocused) {
|
|
2128
|
+
boats.forEach((b) => boatsBody.appendChild(rows.get(b.boatId).tr));
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
boats.forEach((b) => {
|
|
2132
|
+
const row = rows.get(b.boatId);
|
|
2058
2133
|
|
|
2059
2134
|
row.tr.classList.toggle('finished', !!b.finishTime);
|
|
2060
2135
|
row.tr.classList.toggle('dnf', !!b.dnf);
|
|
@@ -2071,7 +2146,7 @@
|
|
|
2071
2146
|
if (document.activeElement !== row.tcfInput) {
|
|
2072
2147
|
row.tcfInput.value = b.tcf;
|
|
2073
2148
|
}
|
|
2074
|
-
if (vetEnabled && row.vetHandicapVersion !== handicapVersion && document.activeElement !== row.vetSelect) {
|
|
2149
|
+
if ((vetEnabled || ktkEnabled) && row.vetHandicapVersion !== handicapVersion && document.activeElement !== row.vetSelect) {
|
|
2075
2150
|
refreshVetAlternatives(row, b.name);
|
|
2076
2151
|
row.vetHandicapVersion = handicapVersion;
|
|
2077
2152
|
}
|
|
@@ -2214,6 +2289,7 @@
|
|
|
2214
2289
|
scheduleCallOffBtn.addEventListener('click', scheduleCallOff);
|
|
2215
2290
|
cancelCallOffBtn.addEventListener('click', cancelCallOffSchedule);
|
|
2216
2291
|
vetRefreshBtn.addEventListener('click', () => loadHandicapRegister(true));
|
|
2292
|
+
ktkRefreshBtn.addEventListener('click', () => loadKtkRegister(true));
|
|
2217
2293
|
addBoatBtn.addEventListener('click', () => {
|
|
2218
2294
|
hideSuggestions();
|
|
2219
2295
|
addBoat();
|
|
@@ -2337,11 +2413,13 @@
|
|
|
2337
2413
|
|
|
2338
2414
|
async function init() {
|
|
2339
2415
|
await loadVetEnabled();
|
|
2416
|
+
await loadKtkEnabled();
|
|
2340
2417
|
await loadRaceImportEnabled();
|
|
2341
2418
|
await Promise.all([
|
|
2342
2419
|
loadVessels(),
|
|
2343
2420
|
loadBoatRegistry(),
|
|
2344
2421
|
loadHandicapRegister(false),
|
|
2422
|
+
loadKtkRegister(false),
|
|
2345
2423
|
loadWaypoints(),
|
|
2346
2424
|
loadRacesList(),
|
|
2347
2425
|
refreshSelfNav()
|
package/public/index.html
CHANGED
|
@@ -55,6 +55,10 @@
|
|
|
55
55
|
<span id="vetStatusText"></span>
|
|
56
56
|
<button id="vetRefreshBtn" class="link-btn">Refresh VET register</button>
|
|
57
57
|
</div>
|
|
58
|
+
<div id="ktkStatusLine" class="status vet-status">
|
|
59
|
+
<span id="ktkStatusText"></span>
|
|
60
|
+
<button id="ktkRefreshBtn" class="link-btn">Refresh KTK register</button>
|
|
61
|
+
</div>
|
|
58
62
|
</header>
|
|
59
63
|
<main>
|
|
60
64
|
<p id="noRacesMsg" class="empty" hidden>No races yet. Click "+ New Race" to plan one.</p>
|
|
@@ -147,11 +151,11 @@
|
|
|
147
151
|
<table id="boatsTable">
|
|
148
152
|
<thead>
|
|
149
153
|
<tr>
|
|
150
|
-
<th>Boat</th>
|
|
154
|
+
<th class="boat-name-col">Boat</th>
|
|
151
155
|
<th>Sail #</th>
|
|
152
156
|
<th>MMSI</th>
|
|
153
157
|
<th>TCF</th>
|
|
154
|
-
<th id="vetAlternativesTh">
|
|
158
|
+
<th id="vetAlternativesTh">Handicap alternatives</th>
|
|
155
159
|
<th>Start time</th>
|
|
156
160
|
<th>Elapsed</th>
|
|
157
161
|
<th>Corrected</th>
|
package/public/style.css
CHANGED
|
@@ -216,7 +216,6 @@ button:disabled {
|
|
|
216
216
|
|
|
217
217
|
main {
|
|
218
218
|
padding: 1rem 1.5rem 2rem;
|
|
219
|
-
max-width: 1000px;
|
|
220
219
|
margin: 0 auto;
|
|
221
220
|
}
|
|
222
221
|
|
|
@@ -591,6 +590,21 @@ tbody td {
|
|
|
591
590
|
border-bottom: 1px solid var(--border);
|
|
592
591
|
}
|
|
593
592
|
|
|
593
|
+
/* Frozen first column, so the boat's name stays visible while scrolling
|
|
594
|
+
the (much wider than most screens, especially phones in landscape)
|
|
595
|
+
table sideways to reach the other columns. Needs its own opaque
|
|
596
|
+
background so scrolled-under cells don't show through. */
|
|
597
|
+
.boat-name-col {
|
|
598
|
+
position: sticky;
|
|
599
|
+
left: 0;
|
|
600
|
+
z-index: 1;
|
|
601
|
+
background: var(--bg);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
thead .boat-name-col {
|
|
605
|
+
z-index: 2;
|
|
606
|
+
}
|
|
607
|
+
|
|
594
608
|
tbody tr.finished td {
|
|
595
609
|
color: var(--good);
|
|
596
610
|
}
|