signalk-race-control 0.4.3 → 0.5.1

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.
@@ -73,6 +79,14 @@ and a live projected finishing order — during and after the race.
73
79
  - **Editable finish times** — click **Now** to record a finish as it happens, type a
74
80
  specific `HH:MM:SS` into the finish-time field to correct a mistimed click, or
75
81
  **Clear** to undo. Handles races that cross midnight.
82
+ - **Start timer** — once a start line is set and a start is scheduled, an expandable
83
+ **Start timer** panel shows a countdown to the gun alongside three live numbers for
84
+ this vessel specifically (from its own SignalK GPS/speed, not any other tracked
85
+ boat): distance to the line, ETA to reach it at current speed, and **time to burn** —
86
+ the countdown minus that ETA. Positive means time to spare (you'll arrive early, so
87
+ slow down or take a longer approach); negative means you're behind schedule to make
88
+ the line before the gun. Disappears once the race actually starts, since the
89
+ pre-start approach is moot by then.
76
90
  - **Course & chart** — enter lat/lon for the start line, an ordered list of rounding
77
91
  marks, and the finish line (or click **Use my position** if you're sitting at that
78
92
  spot, or type a name to autocomplete against existing SignalK waypoints — e.g. ones
package/index.js CHANGED
@@ -426,7 +426,7 @@ button.confirming { background: var(--bad); color: #2a0a0a; border-color: var(--
426
426
  button:disabled { opacity: 0.5; cursor: not-allowed; }
427
427
  .status { min-height: 1.2em; margin-top: 0.5rem; font-size: 0.85rem; color: var(--muted); }
428
428
  .status.error { color: var(--bad); }
429
- main { padding: 1rem 1.5rem max(2rem, env(safe-area-inset-bottom)); max-width: 900px; margin: 0 auto; }
429
+ main { padding: 1rem 1.5rem max(2rem, env(safe-area-inset-bottom)); margin: 0 auto; }
430
430
  .add-boat-row { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
431
431
  .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
432
  .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 +434,8 @@ main { padding: 1rem 1.5rem max(2rem, env(safe-area-inset-bottom)); max-width: 9
434
434
  table { width: 100%; min-width: 44rem; border-collapse: collapse; font-variant-numeric: tabular-nums; }
435
435
  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
436
  tbody td { padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--border); }
437
+ .boat-name-col { position: sticky; left: 0; z-index: 1; background: var(--bg); }
438
+ thead .boat-name-col { z-index: 2; }
437
439
  tbody tr.finished td { color: var(--good); }
438
440
  tbody tr.dnf td { color: var(--muted); }
439
441
  .dnf-tag { color: var(--bad); font-weight: 700; font-size: 0.85rem; letter-spacing: 0.03em; }
@@ -498,7 +500,7 @@ tbody tr.dnf td { color: var(--muted); }
498
500
  <table id="boatsTable">
499
501
  <thead>
500
502
  <tr>
501
- <th>Boat</th>
503
+ <th class="boat-name-col">Boat</th>
502
504
  <th>Sail #</th>
503
505
  <th>TCF</th>
504
506
  <th id="vetAlternativesTh" hidden>VET alternatives</th>
@@ -817,6 +819,14 @@ tbody tr.dnf td { color: var(--muted); }
817
819
  return { boat: boat, elapsedMs: elapsedMs, correctedMs: correctedMs, rankMs: rankMs };
818
820
  })
819
821
  .sort(function (a, b) {
822
+ // Self always leads, whatever else is true.
823
+ var aSelf = a.boat.id === race.selfBoatId;
824
+ var bSelf = b.boat.id === race.selfBoatId;
825
+ if (aSelf !== bSelf) return aSelf ? -1 : 1;
826
+ // Before the race actually starts, nobody has a corrected time to
827
+ // rank by anyway — leave order exactly as race.boats gave it
828
+ // (registration order) rather than reshuffling on every add/remove.
829
+ if (!race.startTime) return 0;
820
830
  if (a.rankMs == null && b.rankMs == null) return a.boat.name.localeCompare(b.boat.name);
821
831
  if (a.rankMs == null) return 1;
822
832
  if (b.rankMs == null) return -1;
@@ -938,6 +948,7 @@ tbody tr.dnf td { color: var(--muted); }
938
948
  });
939
949
  var nameSpan = document.createElement('span');
940
950
  var tdName = document.createElement('td');
951
+ tdName.className = 'boat-name-col';
941
952
  tdName.append(selfBtn, nameSpan);
942
953
 
943
954
  var sailNumberInput = document.createElement('input');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-race-control",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
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
@@ -85,6 +85,14 @@
85
85
  const replaySpeedSlider = document.getElementById('replaySpeedSlider');
86
86
  const replaySpeedLabel = document.getElementById('replaySpeedLabel');
87
87
  const replayLiveBtn = document.getElementById('replayLiveBtn');
88
+ const startTimerSection = document.getElementById('startTimerSection');
89
+ const startTimerToggleBtn = document.getElementById('startTimerToggleBtn');
90
+ const startTimerBody = document.getElementById('startTimerBody');
91
+ const startCountdownValue = document.getElementById('startCountdownValue');
92
+ const startDtlValue = document.getElementById('startDtlValue');
93
+ const startEtaValue = document.getElementById('startEtaValue');
94
+ const startBurnValue = document.getElementById('startBurnValue');
95
+ const startTimerNote = document.getElementById('startTimerNote');
88
96
  const raceImportSection = document.getElementById('raceImportSection');
89
97
  const raceImportToggleBtn = document.getElementById('raceImportToggleBtn');
90
98
  const raceImportBody = document.getElementById('raceImportBody');
@@ -877,6 +885,123 @@
877
885
  return null;
878
886
  }
879
887
 
888
+ // ---- Start timer ------------------------------------------------------
889
+ // Countdown to the scheduled start + a live "distance/ETA to the start
890
+ // line, time to burn" instrument for this vessel — the same thing a
891
+ // start-line transit timer app shows, computed from this vessel's own
892
+ // live position/speed (not any other boat's).
893
+
894
+ let selfNav = null; // {lat, lon, sogMs} | null, refreshed on its own poll — see below
895
+
896
+ async function fetchSelfNav() {
897
+ try {
898
+ const [posLeaf, sogLeaf] = await Promise.all([
899
+ fetchJSON(`${SK_API}/vessels/self/navigation/position`).catch(() => null),
900
+ fetchJSON(`${SK_API}/vessels/self/navigation/speedOverGround`).catch(() => null)
901
+ ]);
902
+ const v = posLeaf && posLeaf.value;
903
+ if (!v || typeof v.latitude !== 'number' || typeof v.longitude !== 'number') return null;
904
+ const sogMs = sogLeaf && typeof sogLeaf.value === 'number' ? sogLeaf.value : null;
905
+ return { lat: v.latitude, lon: v.longitude, sogMs };
906
+ } catch (e) {
907
+ return null;
908
+ }
909
+ }
910
+
911
+ const MS_TO_KNOTS = 1.9438444924574;
912
+ function toRad(deg) {
913
+ return (deg * Math.PI) / 180;
914
+ }
915
+
916
+ // Perpendicular distance from a point to the start-line segment (or to
917
+ // its nearest end, if the point doesn't fall between the two ends) — a
918
+ // flat-earth approximation local to the line, which is fine at the scale
919
+ // of a start line and a boat's approach to it (same simplifying
920
+ // assumption the course chart's own projection already makes).
921
+ function distanceToSegmentNm(p, a, b) {
922
+ const meanLat = (a.lat + b.lat + p.lat) / 3;
923
+ const cosLat = Math.cos(toRad(meanLat)) || 1;
924
+ const NM_PER_DEG_LAT = 60;
925
+ const toXY = (pt) => ({ x: pt.lon * cosLat * NM_PER_DEG_LAT, y: pt.lat * NM_PER_DEG_LAT });
926
+ const P = toXY(p);
927
+ const A = toXY(a);
928
+ const B = toXY(b);
929
+ const abx = B.x - A.x;
930
+ const aby = B.y - A.y;
931
+ const lenSq = abx * abx + aby * aby;
932
+ let t = lenSq > 0 ? ((P.x - A.x) * abx + (P.y - A.y) * aby) / lenSq : 0;
933
+ t = Math.max(0, Math.min(1, t));
934
+ const cx = A.x + abx * t;
935
+ const cy = A.y + aby * t;
936
+ const dx = P.x - cx;
937
+ const dy = P.y - cy;
938
+ return Math.sqrt(dx * dx + dy * dy);
939
+ }
940
+
941
+ function fmtSignedDuration(ms) {
942
+ const sign = ms < 0 ? '-' : '+';
943
+ const s = Math.round(Math.abs(ms) / 1000);
944
+ const h = Math.floor(s / 3600);
945
+ const m = Math.floor((s % 3600) / 60);
946
+ const sec = s % 60;
947
+ return sign + (h ? `${h}:${String(m).padStart(2, '0')}` : m) + ':' + String(sec).padStart(2, '0');
948
+ }
949
+
950
+ // Shown whenever there's a start line to approach and the race hasn't
951
+ // actually started yet (pre-start is moot once it has) — refreshes every
952
+ // tick from the last-polled selfNav, independent of whether that poll
953
+ // itself just ran.
954
+ function renderStartTimer() {
955
+ const hasStartLine = !!(raceState && raceState.course && raceState.course.startLine);
956
+ startTimerSection.hidden = !raceState || !hasStartLine || !!raceState.startTime;
957
+ if (startTimerSection.hidden || startTimerBody.hidden) return;
958
+
959
+ const target = raceState.scheduledStart;
960
+ if (!target) {
961
+ startCountdownValue.textContent = '--:--:--';
962
+ startDtlValue.textContent = '--';
963
+ startEtaValue.textContent = '--:--:--';
964
+ startBurnValue.textContent = '--';
965
+ startBurnValue.className = 'start-timer-value';
966
+ startTimerNote.textContent = 'Set a scheduled start time above to see the countdown and burn time here.';
967
+ return;
968
+ }
969
+ const now = Date.now();
970
+ const toStartMs = target - now;
971
+ startCountdownValue.textContent = fmtDuration(Math.max(0, toStartMs));
972
+
973
+ if (!selfNav) {
974
+ startDtlValue.textContent = '--';
975
+ startEtaValue.textContent = '--:--:--';
976
+ startBurnValue.textContent = '--';
977
+ startBurnValue.className = 'start-timer-value';
978
+ startTimerNote.textContent = "Waiting for this vessel's own position from SignalK.";
979
+ return;
980
+ }
981
+ const [a, b] = raceState.course.startLine;
982
+ const dtlNm = distanceToSegmentNm(selfNav, a, b);
983
+ startDtlValue.textContent = `${dtlNm.toFixed(2)}nm`;
984
+
985
+ if (selfNav.sogMs == null || selfNav.sogMs < 0.25) {
986
+ startEtaValue.textContent = '--:--:--';
987
+ startBurnValue.textContent = '--';
988
+ startBurnValue.className = 'start-timer-value';
989
+ startTimerNote.textContent = 'Waiting for this vessel to be making way (SOG) to estimate ETA and burn time.';
990
+ return;
991
+ }
992
+ const sogKn = selfNav.sogMs * MS_TO_KNOTS;
993
+ const etaMs = (dtlNm / sogKn) * 3600 * 1000;
994
+ startEtaValue.textContent = new Date(now + etaMs).toLocaleTimeString();
995
+
996
+ const burnMs = toStartMs - etaMs;
997
+ startBurnValue.textContent = fmtSignedDuration(burnMs);
998
+ startBurnValue.className = 'start-timer-value ' + (burnMs >= 0 ? 'early' : 'late');
999
+ startTimerNote.textContent =
1000
+ burnMs >= 0
1001
+ ? "Positive: time to spare before the gun at this speed — you'll arrive at the line before it, so you have time to burn."
1002
+ : "Negative: you're behind schedule to reach the line at this speed before the gun.";
1003
+ }
1004
+
880
1005
  // Generic substring-match autocomplete, wiring `input` to `dropdown` (a
881
1006
  // hidden .suggestions element already sitting next to it in the DOM) —
882
1007
  // same matching/keyboard-nav behavior as the boat-name autocomplete
@@ -1482,6 +1607,19 @@
1482
1607
  };
1483
1608
  })
1484
1609
  .sort((a, b) => {
1610
+ // Self always leads, whatever else is true — it's the boat whoever
1611
+ // is looking at this screen cares about finding without hunting
1612
+ // for it.
1613
+ const aSelf = a.boatId === raceState.selfBoatId;
1614
+ const bSelf = b.boatId === raceState.selfBoatId;
1615
+ if (aSelf !== bSelf) return aSelf ? -1 : 1;
1616
+ // Before the race actually starts, nobody has a corrected time to
1617
+ // rank by anyway — sorting by name (or anything else derived) just
1618
+ // reshuffled the rows confusingly every time a boat was added or
1619
+ // removed. Rely on sort's stability and leave order exactly as
1620
+ // Object.values(raceState.boats) gave it (i.e. registration order)
1621
+ // until there's an actual race to rank.
1622
+ if (!raceState.startTime) return 0;
1485
1623
  if (a.rankMs == null && b.rankMs == null) return a.name.localeCompare(b.name);
1486
1624
  if (a.rankMs == null) return 1;
1487
1625
  if (b.rankMs == null) return -1;
@@ -1567,6 +1705,7 @@
1567
1705
  nameSpan.className = 'boat-name-text';
1568
1706
 
1569
1707
  const tdName = document.createElement('td');
1708
+ tdName.className = 'boat-name-col';
1570
1709
  tdName.append(selfBtn, nameSpan);
1571
1710
 
1572
1711
  const sailNumberInput = document.createElement('input');
@@ -1854,6 +1993,7 @@
1854
1993
  exportOfflineBtn.hidden = !raceState;
1855
1994
  courseSection.hidden = !raceState;
1856
1995
  raceImportSection.hidden = !raceState || !raceImportEnabled;
1996
+ renderStartTimer();
1857
1997
 
1858
1998
  if (!raceState) {
1859
1999
  emptyMsg.hidden = true;
@@ -1915,20 +2055,28 @@
1915
2055
  const vsSelfMap = computeVsSelf(boats, raceState.selfBoatId);
1916
2056
 
1917
2057
  boats.forEach((b) => {
1918
- let row = rows.get(b.boatId);
1919
- if (!row) {
1920
- row = buildRow(b.boatId);
1921
- rows.set(b.boatId, row);
1922
- }
1923
- // Moving an already-attached node to its sorted position keeps it
1924
- // intact (focus, in-progress edits) rather than recreating it — but
1925
- // the move itself still resets a text cursor and closes an open
1926
- // <select>, so skip it entirely while the user has something in this
1927
- // row focused. It'll snap to its correct position as soon as they're
1928
- // done (next render after focus moves away).
1929
- if (!row.tr.contains(document.activeElement)) {
1930
- boatsBody.appendChild(row.tr);
2058
+ if (!rows.get(b.boatId)) {
2059
+ rows.set(b.boatId, buildRow(b.boatId));
1931
2060
  }
2061
+ });
2062
+ // Moving already-attached nodes to their sorted position keeps them
2063
+ // intact (focus, in-progress edits) rather than recreating them — but
2064
+ // the move itself still resets a text cursor and closes an open
2065
+ // <select>, so the whole reorder pass is skipped while the user has
2066
+ // anything in ANY row focused, not just the row that would move.
2067
+ // Reordering around a skipped row by moving everyone else in sequence
2068
+ // can still shuffle it to a different spot as a side effect of those
2069
+ // other moves, which is exactly the "keep self on top" guarantee this
2070
+ // was supposed to preserve — so it's all-or-nothing instead. It'll
2071
+ // snap to the fully correct order on the next render once focus moves
2072
+ // away.
2073
+ const anyRowFocused = boats.some((b) => rows.get(b.boatId).tr.contains(document.activeElement));
2074
+ if (!anyRowFocused) {
2075
+ boats.forEach((b) => boatsBody.appendChild(rows.get(b.boatId).tr));
2076
+ }
2077
+
2078
+ boats.forEach((b) => {
2079
+ const row = rows.get(b.boatId);
1932
2080
 
1933
2081
  row.tr.classList.toggle('finished', !!b.finishTime);
1934
2082
  row.tr.classList.toggle('dnf', !!b.dnf);
@@ -2123,6 +2271,11 @@
2123
2271
  courseToggleBtn.textContent = (courseBody.hidden ? '▸' : '▾') + ' Course & chart';
2124
2272
  if (!courseBody.hidden) renderChart();
2125
2273
  });
2274
+ startTimerToggleBtn.addEventListener('click', () => {
2275
+ startTimerBody.hidden = !startTimerBody.hidden;
2276
+ startTimerToggleBtn.textContent = (startTimerBody.hidden ? '▸' : '▾') + ' Start timer';
2277
+ if (!startTimerBody.hidden) renderStartTimer();
2278
+ });
2126
2279
  raceImportToggleBtn.addEventListener('click', () => {
2127
2280
  raceImportBody.hidden = !raceImportBody.hidden;
2128
2281
  raceImportToggleBtn.textContent = (raceImportBody.hidden ? '▸' : '▾') + ' Import boats from Manage2Sail';
@@ -2199,14 +2352,27 @@
2199
2352
  renderChart();
2200
2353
  });
2201
2354
 
2355
+ async function refreshSelfNav() {
2356
+ selfNav = await fetchSelfNav();
2357
+ renderStartTimer();
2358
+ }
2359
+
2202
2360
  async function init() {
2203
2361
  await loadVetEnabled();
2204
2362
  await loadRaceImportEnabled();
2205
- await Promise.all([loadVessels(), loadBoatRegistry(), loadHandicapRegister(false), loadWaypoints(), loadRacesList()]);
2363
+ await Promise.all([
2364
+ loadVessels(),
2365
+ loadBoatRegistry(),
2366
+ loadHandicapRegister(false),
2367
+ loadWaypoints(),
2368
+ loadRacesList(),
2369
+ refreshSelfNav()
2370
+ ]);
2206
2371
  await loadRaceState();
2207
2372
  render();
2208
2373
  setInterval(render, 1000);
2209
2374
  setInterval(loadVessels, 10000);
2375
+ setInterval(refreshSelfNav, 3000);
2210
2376
  setInterval(() => {
2211
2377
  if (activeRaceId) loadRaceState();
2212
2378
  }, 5000);
package/public/index.html CHANGED
@@ -95,6 +95,31 @@
95
95
  </div>
96
96
  </div>
97
97
 
98
+ <div id="startTimerSection" class="course-section" hidden>
99
+ <button id="startTimerToggleBtn" type="button" class="link-btn">▸ Start timer</button>
100
+ <div id="startTimerBody" hidden>
101
+ <div class="start-timer-grid">
102
+ <div class="start-timer-tile">
103
+ <div class="start-timer-label">Countdown to start</div>
104
+ <div id="startCountdownValue" class="start-timer-value">--:--:--</div>
105
+ </div>
106
+ <div class="start-timer-tile">
107
+ <div class="start-timer-label">Distance to line</div>
108
+ <div id="startDtlValue" class="start-timer-value">--</div>
109
+ </div>
110
+ <div class="start-timer-tile">
111
+ <div class="start-timer-label">ETA to line</div>
112
+ <div id="startEtaValue" class="start-timer-value">--:--:--</div>
113
+ </div>
114
+ <div class="start-timer-tile">
115
+ <div class="start-timer-label">Time to burn</div>
116
+ <div id="startBurnValue" class="start-timer-value">--</div>
117
+ </div>
118
+ </div>
119
+ <p id="startTimerNote" class="start-timer-note"></p>
120
+ </div>
121
+ </div>
122
+
98
123
  <div id="raceImportSection" class="course-section" hidden>
99
124
  <button id="raceImportToggleBtn" type="button" class="link-btn">▸ Import boats from Manage2Sail</button>
100
125
  <div id="raceImportBody" hidden>
@@ -122,7 +147,7 @@
122
147
  <table id="boatsTable">
123
148
  <thead>
124
149
  <tr>
125
- <th>Boat</th>
150
+ <th class="boat-name-col">Boat</th>
126
151
  <th>Sail #</th>
127
152
  <th>MMSI</th>
128
153
  <th>TCF</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
 
@@ -226,6 +225,50 @@ main {
226
225
  padding-bottom: 1rem;
227
226
  }
228
227
 
228
+ .start-timer-grid {
229
+ margin-top: 0.75rem;
230
+ display: grid;
231
+ grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
232
+ gap: 1rem;
233
+ max-width: 44rem;
234
+ }
235
+
236
+ .start-timer-tile {
237
+ background: var(--panel);
238
+ border: 1px solid var(--border);
239
+ border-radius: 8px;
240
+ padding: 0.75rem 1rem;
241
+ text-align: center;
242
+ }
243
+
244
+ .start-timer-label {
245
+ font-size: 0.7rem;
246
+ text-transform: uppercase;
247
+ letter-spacing: 0.04em;
248
+ color: var(--muted);
249
+ margin-bottom: 0.3rem;
250
+ }
251
+
252
+ .start-timer-value {
253
+ font-size: 1.4rem;
254
+ font-weight: 700;
255
+ font-variant-numeric: tabular-nums;
256
+ }
257
+
258
+ .start-timer-value.early {
259
+ color: var(--good);
260
+ }
261
+
262
+ .start-timer-value.late {
263
+ color: var(--bad);
264
+ }
265
+
266
+ .start-timer-note {
267
+ margin-top: 0.6rem;
268
+ font-size: 0.8rem;
269
+ color: var(--muted);
270
+ }
271
+
229
272
  .course-grid {
230
273
  margin-top: 0.75rem;
231
274
  display: grid;
@@ -547,6 +590,21 @@ tbody td {
547
590
  border-bottom: 1px solid var(--border);
548
591
  }
549
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
+
550
608
  tbody tr.finished td {
551
609
  color: var(--good);
552
610
  }