signalk-race-control 0.5.1 → 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 CHANGED
@@ -49,12 +49,21 @@ and a live projected finishing order — during and after the race.
49
49
  default** — turn it on in the plugin's settings (`vetEnabled`) for clubs that
50
50
  actually race under VET-tall; see **Cross-race TCF memory** below for what happens
51
51
  to handicaps while it's off.
52
- - **Cross-race TCF memory for boats outside VET** — whenever a boat isn't matched in
53
- the VET register (or the register is disabled altogether), the TCF you set for it by
54
- hand is remembered by boat name and applied automatically the next time a boat with
55
- that name is added to any race. A boat that *is* VET-matched never has its TCF
56
- carried over this way — it always starts from the default until you pick a VET
57
- alternative or edit it again.
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.
58
67
  - **MMSI and sail number, remembered across races** — set once per boat (MMSI can
59
68
  also be picked up automatically from a live AIS/self vessel with a matching name),
60
69
  both are remembered in a small cross-race registry: add a boat with the same name
@@ -164,7 +173,10 @@ and a live projected finishing order — during and after the race.
164
173
  download time and a **Refresh VET register** link lets the offline page re-fetch the
165
174
  current sheet straight from Google Sheets (no plugin server needed for that — just
166
175
  whatever internet connection the browser has), with the same alternatives dropdown
167
- and autocomplete as the main webapp. AIS boat names/positions, the course/chart, and
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
168
180
  importing a fleet from Manage2Sail still need the live server (Manage2Sail's own API
169
181
  doesn't allow browser-side fetches at all) — TCF is entered by hand for anything the
170
182
  VET register doesn't cover. Also has its own "Download results as CSV" button.
@@ -236,11 +248,14 @@ finish time/rank for boats still racing, when the plugin has enough to estimate
236
248
  tracks for a fresh run.
237
249
  - The VET-tall source page can be overridden in the plugin's settings
238
250
  (`handicapSourceUrl`) — point it at a specific year's Google Sheet link directly to
239
- skip the SSCA page lookup, e.g. if a club uses its own register.
240
- - Whether the VET-tall register is used at all (`vetEnabled`, off by default) is also a plugin
241
- setting, not a per-race or webapp-side option — change it under
242
- **Server → Plugin Config → Race Control** and restart the plugin (the server does
243
- this automatically on save) for the webapp to pick it up.
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.
244
259
  - The mark-rounding radius used for the estimated finish time and remaining-distance
245
260
  calculations (`markRoundingRadiusM`, 100m by default) is also a plugin setting —
246
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(/&nbsp;/gi, ' ')
116
+ .replace(/&amp;/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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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">
@@ -468,7 +533,7 @@ tbody tr.dnf td { color: var(--muted); }
468
533
  <p class="offline-note">Standalone backup — works with no server connection to this plugin.
469
534
  Everything you do here is saved in this browser only (reopen this same downloaded file
470
535
  to continue). Boat names/positions from AIS and the course/chart aren't available
471
- 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>
536
+ offline. ${handicapNote}</p>
472
537
  <h2 id="raceName"></h2>
473
538
  <div id="clock" class="clock">00:00:00</div>
474
539
  <div class="controls">
@@ -503,7 +568,7 @@ tbody tr.dnf td { color: var(--muted); }
503
568
  <th class="boat-name-col">Boat</th>
504
569
  <th>Sail #</th>
505
570
  <th>TCF</th>
506
- <th id="vetAlternativesTh" hidden>VET alternatives</th>
571
+ <th id="vetAlternativesTh" hidden>Handicap alternatives</th>
507
572
  <th>Start time</th>
508
573
  <th>Elapsed</th>
509
574
  <th>Corrected</th>
@@ -548,8 +613,12 @@ tbody tr.dnf td { color: var(--muted); }
548
613
  race.vetEnabled = !!seed.vetEnabled;
549
614
  race.handicapCsvUrl = seed.handicapCsvUrl || null;
550
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 || [];
551
620
  } catch (e) {
552
- 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: [] };
553
622
  }
554
623
 
555
624
  function save() {
@@ -689,7 +758,7 @@ tbody tr.dnf td { color: var(--muted); }
689
758
  function rebuildAddBoatSuggestions() {
690
759
  addBoatSuggestions.innerHTML = '';
691
760
  var seen = {};
692
- race.handicapBoats.forEach(function (b) {
761
+ race.handicapBoats.concat(race.ktkBoats).forEach(function (b) {
693
762
  var key = b.name.toLowerCase();
694
763
  if (seen[key]) return;
695
764
  seen[key] = true;
@@ -724,25 +793,41 @@ tbody tr.dnf td { color: var(--muted); }
724
793
  });
725
794
  }
726
795
 
727
- // Rebuilds a row's VET-alternatives <select> from the current register —
728
- // only called when the register itself (re)loads, not every render tick.
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.
729
800
  function refreshVetAlternatives(row, boatName) {
730
- var entry = race.handicapBoats.find(function (h) { return h.name.toLowerCase() === boatName.trim().toLowerCase(); });
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
+ }
731
816
  row.vetSelect.innerHTML = '';
732
817
  var placeholder = document.createElement('option');
733
818
  placeholder.value = '';
734
- placeholder.textContent = entry ? 'Pick VET…' : 'No VET match';
819
+ placeholder.textContent = options.length ? 'Pick…' : 'No match';
735
820
  row.vetSelect.appendChild(placeholder);
736
- row.vetSelect.disabled = !entry;
737
- if (entry) {
738
- entry.vets.forEach(function (v) {
739
- var opt = document.createElement('option');
740
- opt.value = String(v.value);
741
- opt.textContent = v.label + ': ' + v.value;
742
- row.vetSelect.appendChild(opt);
743
- });
744
- var notValid = /ikke/i.test(entry.validity || '');
745
- row.vetBadge.textContent = entry.validity ? (notValid ? '⚠ ' + entry.validity : entry.validity) : '';
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) : '';
746
831
  row.vetBadge.classList.toggle('warn', notValid);
747
832
  } else {
748
833
  row.vetBadge.textContent = '';
@@ -1002,7 +1087,7 @@ tbody tr.dnf td { color: var(--muted); }
1002
1087
  vetBadge.className = 'vet-badge';
1003
1088
  var tdVet = document.createElement('td');
1004
1089
  tdVet.className = 'vet-cell';
1005
- tdVet.hidden = !race.vetEnabled;
1090
+ tdVet.hidden = !race.vetEnabled && !race.ktkEnabled;
1006
1091
  tdVet.append(vetSelect, vetBadge);
1007
1092
 
1008
1093
  var startTimeInput = document.createElement('input');
@@ -1189,7 +1274,7 @@ tbody tr.dnf td { color: var(--muted); }
1189
1274
  row.selfBtn.classList.toggle('active', isSelf);
1190
1275
  if (document.activeElement !== row.sailNumberInput) row.sailNumberInput.value = b.sailNumber || '';
1191
1276
  if (document.activeElement !== row.tcfInput) row.tcfInput.value = b.tcf;
1192
- if (race.vetEnabled) {
1277
+ if (race.vetEnabled || race.ktkEnabled) {
1193
1278
  if (row.vetHandicapVersion !== handicapVersion && document.activeElement !== row.vetSelect) {
1194
1279
  refreshVetAlternatives(row, b.name);
1195
1280
  row.vetHandicapVersion = handicapVersion;
@@ -1289,8 +1374,9 @@ tbody tr.dnf td { color: var(--muted); }
1289
1374
  });
1290
1375
 
1291
1376
  vetStatusLine.hidden = !race.vetEnabled;
1292
- vetAlternativesTh.hidden = !race.vetEnabled;
1377
+ vetAlternativesTh.hidden = !race.vetEnabled && !race.ktkEnabled;
1293
1378
  vetRefreshBtn.addEventListener('click', function () { loadHandicapRegister(true); });
1379
+ if (race.ktkEnabled) rebuildAddBoatSuggestions();
1294
1380
  if (race.vetEnabled) {
1295
1381
  rebuildAddBoatSuggestions();
1296
1382
  if (race.handicapBoats.length) {
@@ -1336,6 +1422,18 @@ module.exports = function (app) {
1336
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.',
1337
1423
  default: false
1338
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
+ },
1339
1437
  raceImportEnabled: {
1340
1438
  type: 'boolean',
1341
1439
  title:
@@ -1362,6 +1460,7 @@ module.exports = function (app) {
1362
1460
  const scheduleTimers = new Map(); // raceId -> Timeout, for scheduledStart
1363
1461
  const callOffTimers = new Map(); // raceId -> Timeout, for scheduledCallOff
1364
1462
  let handicapCache = { fetchedAt: 0, boats: [], sourceUrl: null, csvUrl: null };
1463
+ let ktkCache = { fetchedAt: 0, boats: [], sourceUrl: null };
1365
1464
 
1366
1465
  function loadState() {
1367
1466
  try {
@@ -1405,20 +1504,29 @@ module.exports = function (app) {
1405
1504
  return !!(plugin.options && plugin.options.vetEnabled === true);
1406
1505
  }
1407
1506
 
1507
+ // Same pattern as vetEnabled, for KTK's KLR register.
1508
+ function isKtkEnabled() {
1509
+ return !!(plugin.options && plugin.options.ktkEnabled === true);
1510
+ }
1511
+
1408
1512
  // Plugin config setting, same pattern as vetEnabled — off by default, not
1409
1513
  // per-race, not writable from the webapp itself.
1410
1514
  function isRaceImportEnabled() {
1411
1515
  return !!(plugin.options && plugin.options.raceImportEnabled === true);
1412
1516
  }
1413
1517
 
1414
- // A boat only counts as "in VET" for registry-TCF purposes while the
1415
- // config setting has VET enabled and the register (whatever's currently
1416
- // cached) actually has a matching name — disabling VET makes every boat
1417
- // "outside VET" for this purpose too, per the setting's whole point.
1418
- function isVetMatch(name) {
1419
- if (!isVetEnabled()) return false;
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) {
1420
1526
  const n = (name || '').trim().toLowerCase();
1421
- return handicapCache.boats.some((b) => b.name.toLowerCase() === n);
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;
1422
1530
  }
1423
1531
 
1424
1532
  function saveState() {
@@ -1799,6 +1907,22 @@ module.exports = function (app) {
1799
1907
  return handicapCache;
1800
1908
  }
1801
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
+
1802
1926
  async function fetchManage2SailClasses(eventUrl) {
1803
1927
  const res = await fetch(eventUrl);
1804
1928
  if (!res.ok) {
@@ -1996,10 +2120,26 @@ module.exports = function (app) {
1996
2120
  }
1997
2121
  }
1998
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
+ }
1999
2137
  const html = buildOfflineTimerHtml(race, defaultTcf, {
2000
2138
  vetEnabled: isVetEnabled(),
2001
2139
  handicapCsvUrl,
2002
- handicapBoats
2140
+ handicapBoats,
2141
+ ktkEnabled: isKtkEnabled(),
2142
+ ktkBoats
2003
2143
  });
2004
2144
  const safeName = (race.name || 'race').replace(/[^a-z0-9\-_]+/gi, '_').slice(0, 60) || 'race';
2005
2145
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
@@ -2233,11 +2373,12 @@ module.exports = function (app) {
2233
2373
  const sailNumber = givenSailNumber || (registryEntry && registryEntry.sailNumber) || null;
2234
2374
  if (sailNumber) upsertBoatRegistry(name, { sailNumber });
2235
2375
  const defaultTcf = (plugin.options && plugin.options.defaultTcf) || 1.0;
2236
- // A remembered TCF only applies to boats outside VET — a VET-matched
2237
- // boat should be picked fresh from the register's own dropdown rather
2238
- // than silently carrying over a number from wherever it last raced.
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.
2239
2380
  let tcf = defaultTcf;
2240
- if (!isVetMatch(name) && registryEntry && registryEntry.tcf != null) {
2381
+ if (!isHandicapRegisterMatch(name) && registryEntry && registryEntry.tcf != null) {
2241
2382
  tcf = registryEntry.tcf;
2242
2383
  }
2243
2384
  const boat = {
@@ -2413,6 +2554,11 @@ module.exports = function (app) {
2413
2554
  res.json({ enabled: isVetEnabled() });
2414
2555
  });
2415
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
+
2416
2562
  // Lets the course editor offer existing SignalK waypoints (e.g. ones
2417
2563
  // already placed on a chart plotter) as start/mark/finish positions,
2418
2564
  // instead of only typing lat/lon by hand. Best-effort, same spirit as
@@ -2449,9 +2595,10 @@ module.exports = function (app) {
2449
2595
  return res.status(400).json({ error: 'tcf must be a positive number' });
2450
2596
  }
2451
2597
  boat.tcf = tcf;
2452
- // Remembered across races only for boats outside VET — see the note
2453
- // on upsertBoatRegistry/isVetMatch above.
2454
- if (!isVetMatch(boat.name)) upsertBoatRegistry(boat.name, { tcf });
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 });
2455
2602
  saveState();
2456
2603
  res.json(boat);
2457
2604
  });
@@ -2466,6 +2613,16 @@ module.exports = function (app) {
2466
2613
  }
2467
2614
  });
2468
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
+
2469
2626
  // Plugin config setting (Server -> Plugin Config), same pattern as
2470
2627
  // vet-enabled — the webapp only reads it to know whether to show the
2471
2628
  // import section at all.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-race-control",
3
- "version": "0.5.1",
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) and the whole "VET alternatives" table
366
- // column disappear entirely.
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`);
@@ -1768,7 +1806,7 @@
1768
1806
  vetBadge.className = 'vet-badge';
1769
1807
  const tdVet = document.createElement('td');
1770
1808
  tdVet.className = 'vet-cell';
1771
- tdVet.hidden = !vetEnabled;
1809
+ tdVet.hidden = !vetEnabled && !ktkEnabled;
1772
1810
  tdVet.append(vetSelect, vetBadge);
1773
1811
 
1774
1812
  // A boat's own start time, for a staggered/pursuit start or to correct
@@ -1923,28 +1961,43 @@
1923
1961
  };
1924
1962
  }
1925
1963
 
1926
- // Rebuilds a row's VET-alternatives <select> from the current register —
1927
- // only called when the register itself (re)loads, not every render tick,
1928
- // so an open dropdown or mid-pick isn't disrupted every second. Never
1929
- // called at all while vetEnabled is false (see the call site) — the
1930
- // "VET alternatives" column is hidden entirely in that case.
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.
1931
1972
  function refreshVetAlternatives(row, boatName) {
1932
- const entry = handicapBoats.find((h) => h.name.toLowerCase() === boatName.trim().toLowerCase());
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
+ }
1933
1986
  row.vetSelect.innerHTML = '';
1934
1987
  const placeholder = document.createElement('option');
1935
1988
  placeholder.value = '';
1936
- placeholder.textContent = entry ? 'Pick VET…' : 'No VET match';
1989
+ placeholder.textContent = options.length ? 'Pick…' : 'No match';
1937
1990
  row.vetSelect.appendChild(placeholder);
1938
- row.vetSelect.disabled = !entry;
1939
- if (entry) {
1940
- entry.vets.forEach((v) => {
1941
- const opt = document.createElement('option');
1942
- opt.value = String(v.value);
1943
- opt.textContent = `${v.label}: ${v.value}`;
1944
- row.vetSelect.appendChild(opt);
1945
- });
1946
- const notValid = /ikke/i.test(entry.validity || '');
1947
- row.vetBadge.textContent = entry.validity ? (notValid ? '⚠ ' + entry.validity : entry.validity) : '';
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) : '';
1948
2001
  row.vetBadge.classList.toggle('warn', notValid);
1949
2002
  } else {
1950
2003
  row.vetBadge.textContent = '';
@@ -2093,7 +2146,7 @@
2093
2146
  if (document.activeElement !== row.tcfInput) {
2094
2147
  row.tcfInput.value = b.tcf;
2095
2148
  }
2096
- if (vetEnabled && row.vetHandicapVersion !== handicapVersion && document.activeElement !== row.vetSelect) {
2149
+ if ((vetEnabled || ktkEnabled) && row.vetHandicapVersion !== handicapVersion && document.activeElement !== row.vetSelect) {
2097
2150
  refreshVetAlternatives(row, b.name);
2098
2151
  row.vetHandicapVersion = handicapVersion;
2099
2152
  }
@@ -2236,6 +2289,7 @@
2236
2289
  scheduleCallOffBtn.addEventListener('click', scheduleCallOff);
2237
2290
  cancelCallOffBtn.addEventListener('click', cancelCallOffSchedule);
2238
2291
  vetRefreshBtn.addEventListener('click', () => loadHandicapRegister(true));
2292
+ ktkRefreshBtn.addEventListener('click', () => loadKtkRegister(true));
2239
2293
  addBoatBtn.addEventListener('click', () => {
2240
2294
  hideSuggestions();
2241
2295
  addBoat();
@@ -2359,11 +2413,13 @@
2359
2413
 
2360
2414
  async function init() {
2361
2415
  await loadVetEnabled();
2416
+ await loadKtkEnabled();
2362
2417
  await loadRaceImportEnabled();
2363
2418
  await Promise.all([
2364
2419
  loadVessels(),
2365
2420
  loadBoatRegistry(),
2366
2421
  loadHandicapRegister(false),
2422
+ loadKtkRegister(false),
2367
2423
  loadWaypoints(),
2368
2424
  loadRacesList(),
2369
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>
@@ -151,7 +155,7 @@
151
155
  <th>Sail #</th>
152
156
  <th>MMSI</th>
153
157
  <th>TCF</th>
154
- <th id="vetAlternativesTh">VET alternatives</th>
158
+ <th id="vetAlternativesTh">Handicap alternatives</th>
155
159
  <th>Start time</th>
156
160
  <th>Elapsed</th>
157
161
  <th>Corrected</th>