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 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
- - **Cross-race TCF memory for boats outside VET** — whenever a boat isn't matched in
47
- the VET register (or the register is disabled altogether), the TCF you set for it by
48
- hand is remembered by boat name and applied automatically the next time a boat with
49
- that name is added to any race. A boat that *is* VET-matched never has its TCF
50
- carried over this way — it always starts from the default until you pick a VET
51
- 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.
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. 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
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
- - Whether the VET-tall register is used at all (`vetEnabled`, off by default) is also a plugin
235
- setting, not a per-race or webapp-side option — change it under
236
- **Server → Plugin Config → Race Control** and restart the plugin (the server does
237
- 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.
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(/&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">
@@ -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)); max-width: 900px; margin: 0 auto; }
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. ${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>
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>VET alternatives</th>
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 VET-alternatives <select> from the current register —
726
- // 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.
727
800
  function refreshVetAlternatives(row, boatName) {
728
- 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
+ }
729
816
  row.vetSelect.innerHTML = '';
730
817
  var placeholder = document.createElement('option');
731
818
  placeholder.value = '';
732
- placeholder.textContent = entry ? 'Pick VET…' : 'No VET match';
819
+ placeholder.textContent = options.length ? 'Pick…' : 'No match';
733
820
  row.vetSelect.appendChild(placeholder);
734
- row.vetSelect.disabled = !entry;
735
- if (entry) {
736
- entry.vets.forEach(function (v) {
737
- var opt = document.createElement('option');
738
- opt.value = String(v.value);
739
- opt.textContent = v.label + ': ' + v.value;
740
- row.vetSelect.appendChild(opt);
741
- });
742
- var notValid = /ikke/i.test(entry.validity || '');
743
- 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) : '';
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 VET" for registry-TCF purposes while the
1404
- // config setting has VET enabled and the register (whatever's currently
1405
- // cached) actually has a matching name — disabling VET makes every boat
1406
- // "outside VET" for this purpose too, per the setting's whole point.
1407
- function isVetMatch(name) {
1408
- 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) {
1409
1526
  const n = (name || '').trim().toLowerCase();
1410
- 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;
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 VET — a VET-matched
2226
- // boat should be picked fresh from the register's own dropdown rather
2227
- // 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.
2228
2380
  let tcf = defaultTcf;
2229
- if (!isVetMatch(name) && registryEntry && registryEntry.tcf != null) {
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 VET — see the note
2442
- // on upsertBoatRegistry/isVetMatch above.
2443
- 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 });
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.5.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) 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`);
@@ -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 VET-alternatives <select> from the current register —
1913
- // only called when the register itself (re)loads, not every render tick,
1914
- // so an open dropdown or mid-pick isn't disrupted every second. Never
1915
- // called at all while vetEnabled is false (see the call site) — the
1916
- // "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.
1917
1972
  function refreshVetAlternatives(row, boatName) {
1918
- 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
+ }
1919
1986
  row.vetSelect.innerHTML = '';
1920
1987
  const placeholder = document.createElement('option');
1921
1988
  placeholder.value = '';
1922
- placeholder.textContent = entry ? 'Pick VET…' : 'No VET match';
1989
+ placeholder.textContent = options.length ? 'Pick…' : 'No match';
1923
1990
  row.vetSelect.appendChild(placeholder);
1924
- row.vetSelect.disabled = !entry;
1925
- if (entry) {
1926
- entry.vets.forEach((v) => {
1927
- const opt = document.createElement('option');
1928
- opt.value = String(v.value);
1929
- opt.textContent = `${v.label}: ${v.value}`;
1930
- row.vetSelect.appendChild(opt);
1931
- });
1932
- const notValid = /ikke/i.test(entry.validity || '');
1933
- 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) : '';
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
- let row = rows.get(b.boatId);
2045
- if (!row) {
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">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>
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
  }