signalk-race-control 0.5.0 → 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.
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.5.0",
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
@@ -1607,6 +1607,19 @@
1607
1607
  };
1608
1608
  })
1609
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;
1610
1623
  if (a.rankMs == null && b.rankMs == null) return a.name.localeCompare(b.name);
1611
1624
  if (a.rankMs == null) return 1;
1612
1625
  if (b.rankMs == null) return -1;
@@ -1692,6 +1705,7 @@
1692
1705
  nameSpan.className = 'boat-name-text';
1693
1706
 
1694
1707
  const tdName = document.createElement('td');
1708
+ tdName.className = 'boat-name-col';
1695
1709
  tdName.append(selfBtn, nameSpan);
1696
1710
 
1697
1711
  const sailNumberInput = document.createElement('input');
@@ -2041,20 +2055,28 @@
2041
2055
  const vsSelfMap = computeVsSelf(boats, raceState.selfBoatId);
2042
2056
 
2043
2057
  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);
2058
+ if (!rows.get(b.boatId)) {
2059
+ rows.set(b.boatId, buildRow(b.boatId));
2057
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);
2058
2080
 
2059
2081
  row.tr.classList.toggle('finished', !!b.finishTime);
2060
2082
  row.tr.classList.toggle('dnf', !!b.dnf);
package/public/index.html CHANGED
@@ -147,7 +147,7 @@
147
147
  <table id="boatsTable">
148
148
  <thead>
149
149
  <tr>
150
- <th>Boat</th>
150
+ <th class="boat-name-col">Boat</th>
151
151
  <th>Sail #</th>
152
152
  <th>MMSI</th>
153
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
 
@@ -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
  }