signalk-race-control 0.6.0 → 0.7.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
@@ -87,7 +87,10 @@ and a live projected finishing order — during and after the race.
87
87
  ended up as the name.
88
88
  - **Editable finish times** — click **Now** to record a finish as it happens, type a
89
89
  specific `HH:MM:SS` into the finish-time field to correct a mistimed click, or
90
- **Clear** to undo. Handles races that cross midnight.
90
+ **Clear** to undo. Handles races that cross midnight. **DNS** marks a boat as never
91
+ having started at all, distinct from **DNF** (started but didn't finish) — both show
92
+ up as their own status in the Excel export and rank last, and either one can be
93
+ undone the same way. Recording a real finish time clears either.
91
94
  - **Start timer** — once a start line is set and a start is scheduled, an expandable
92
95
  **Start timer** panel shows a countdown to the gun alongside three live numbers for
93
96
  this vessel specifically (from its own SignalK GPS/speed, not any other tracked
@@ -140,13 +143,17 @@ and a live projected finishing order — during and after the race.
140
143
  rounding.
141
144
  - **Stop / call off the race** — freezes elapsed/corrected time for everyone without
142
145
  touching boats, finish times, or the course (unlike Reset, which clears the race
143
- back to not-started), and marks every boat that hadn't finished as **DNF**,
144
- capturing its last known AIS position if one's available. You can also schedule a
145
- call-off for a future time (mirrors Schedule Start), and mark or un-mark an
146
- individual boat DNF by hand at any point, independent of the whole race. There's no
146
+ back to not-started), and marks every boat that hadn't finished or already been
147
+ marked DNS as **DNF**, capturing its last known AIS position if one's available. You
148
+ can also schedule a call-off for a future time (mirrors Schedule Start), and mark or
149
+ un-mark an individual boat DNF or DNS by hand at any point, independent of the whole
150
+ race. There's no
147
151
  true pause: **Resume** discards the stop, un-DNFs everyone it DNF'd, and the clock
148
152
  jumps straight back to real elapsed time — the time spent stopped isn't excluded
149
153
  from anyone's result. Recording a real finish time on a DNF'd boat clears its DNF.
154
+ Also happens automatically the moment every boat has either finished or been marked
155
+ DNF — nothing left to time, so the race calls itself off rather than sitting there
156
+ with an idle clock until someone remembers to click Stop.
150
157
  - **Compare to a "self" boat** — click the star next to a boat's name to mark it as
151
158
  self. Every other boat then shows, in the **vs Self** column: a live-ticking
152
159
  countdown to the moment self would tie them on corrected time if that boat has
@@ -155,10 +162,15 @@ and a live projected finishing order — during and after the race.
155
162
  both have finished. With no boat marked self, the column instead compares everyone
156
163
  against the current **leader** (tagged accordingly), so it's never just blank.
157
164
  - **Export to Excel** — a genuine `.xlsx` snapshot of the current standings (same
158
- ranking, same rows as the on-screen table: rank, boat, sail number, MMSI, TCF,
159
- start time, elapsed, corrected, finish time, status), with the time columns
160
- rendered in your browser's own timezone rather than the server's (and with the
161
- date included, for a multi-day race).
165
+ ranking, same rows as the on-screen table: rank, boat, sail number, MMSI, TCF, start
166
+ time, finish time, elapsed, corrected, status), with the time columns rendered in
167
+ your browser's own timezone rather than the server's (and with the date included,
168
+ for a multi-day race). Start/finish are real date-time cells, and Elapsed/Corrected
169
+ are live formulas from them (elapsed × TCF), not numbers baked in at export time —
170
+ correct a start or finish time directly in the spreadsheet afterward and the rest
171
+ recalculates. Left blank for a boat with no finish time (DNF, DNS, or still racing)
172
+ rather than a snapshot of elapsed-so-far that would go stale the moment the file is
173
+ reopened later.
162
174
  - **Download Offline Timer** — a single self-contained `.html` file, seeded with the
163
175
  current race's boats, TCF, and multi-day setting, that runs the core of race timing
164
176
  (start/stop/resume/reset, add/remove boats, edit TCF, individual start times,
package/index.js CHANGED
@@ -303,6 +303,7 @@ function ensureRaceShape(race) {
303
303
  Object.values(race.boats).forEach((b) => {
304
304
  if (!b.track) b.track = [];
305
305
  if (b.dnf === undefined) b.dnf = false;
306
+ if (b.dns === undefined) b.dns = false;
306
307
  if (b.dnfPosition === undefined) b.dnfPosition = null;
307
308
  if (b.startTime === undefined) b.startTime = null;
308
309
  if (b.sailNumber === undefined) b.sailNumber = null;
@@ -358,30 +359,11 @@ function validateLine(line) {
358
359
  return [a, b];
359
360
  }
360
361
 
361
- // HH:MM:SS for a duration, or '' when there's nothing to show — matches the
362
- // webapp's fmtDuration, used for the Excel export's Elapsed/Corrected columns.
363
- function formatDurationHms(ms) {
364
- if (ms == null || ms < 0 || !isFinite(ms)) return '';
365
- const totalSec = Math.floor(ms / 1000);
366
- const h = Math.floor(totalSec / 3600);
367
- const m = Math.floor((totalSec % 3600) / 60);
368
- const s = totalSec % 60;
369
- const pad = (n) => String(n).padStart(2, '0');
370
- return `${pad(h)}:${pad(m)}:${pad(s)}`;
371
- }
372
-
373
362
  // Renders an absolute instant as the wall-clock time it was in the
374
363
  // exporting browser's own timezone (tzOffsetMinutes = that browser's
375
364
  // Date.prototype.getTimezoneOffset()) rather than the server's timezone,
376
365
  // which may well be different — the server can be headless/UTC while the
377
366
  // person opening the spreadsheet is reading it in their own local time.
378
- function formatLocalTime(utcMs, tzOffsetMinutes) {
379
- if (utcMs == null) return '';
380
- const shifted = new Date(utcMs - tzOffsetMinutes * 60000);
381
- const pad = (n) => String(n).padStart(2, '0');
382
- return `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}`;
383
- }
384
-
385
367
  function formatLocalDateTime(utcMs, tzOffsetMinutes) {
386
368
  if (utcMs == null) return '';
387
369
  const shifted = new Date(utcMs - tzOffsetMinutes * 60000);
@@ -424,7 +406,8 @@ function buildOfflineTimerHtml(race, defaultTcf, vetOptions) {
424
406
  tcf: b.tcf != null ? b.tcf : defaultTcf,
425
407
  startTime: b.startTime || null,
426
408
  finishTime: b.finishTime || null,
427
- dnf: !!b.dnf
409
+ dnf: !!b.dnf,
410
+ dns: !!b.dns
428
411
  })),
429
412
  storageKey: 'raceControlOffline_v1_' + race.id,
430
413
  // Lets a re-download know whether it's actually newer than whatever
@@ -638,14 +621,25 @@ tbody tr.dnf td { color: var(--muted); }
638
621
  function doStart() {
639
622
  race.startTime = Date.now();
640
623
  race.stopTime = null;
641
- race.boats.forEach(function (b) { b.finishTime = null; b.startTime = null; b.dnf = false; });
624
+ race.boats.forEach(function (b) { b.finishTime = null; b.startTime = null; b.dnf = false; b.dns = false; });
642
625
  save();
643
626
  }
644
627
  function doStop() {
645
628
  race.stopTime = Date.now();
646
- race.boats.forEach(function (b) { if (!b.finishTime && !b.dnf) b.dnf = true; });
629
+ race.boats.forEach(function (b) { if (!b.finishTime && !b.dnf && !b.dns) b.dnf = true; });
647
630
  save();
648
631
  }
632
+ // A race with at least one boat where every boat has either finished or
633
+ // been marked DNF/DNS has nothing left to time — call it off
634
+ // automatically rather than leaving the clock running until someone
635
+ // remembers to.
636
+ function isRaceComplete() {
637
+ if (!race.boats.length) return false;
638
+ return race.boats.every(function (b) { return b.finishTime || b.dnf || b.dns; });
639
+ }
640
+ function maybeAutoStop() {
641
+ if (race.startTime && !race.stopTime && isRaceComplete()) doStop();
642
+ }
649
643
  function doResume() {
650
644
  if (!race.stopTime) return;
651
645
  race.stopTime = null;
@@ -655,7 +649,7 @@ tbody tr.dnf td { color: var(--muted); }
655
649
  function doReset() {
656
650
  race.startTime = null;
657
651
  race.stopTime = null;
658
- race.boats.forEach(function (b) { b.finishTime = null; b.startTime = null; b.dnf = false; });
652
+ race.boats.forEach(function (b) { b.finishTime = null; b.startTime = null; b.dnf = false; b.dns = false; });
659
653
  save();
660
654
  }
661
655
  function effectiveStart(boat) {
@@ -897,10 +891,10 @@ tbody tr.dnf td { color: var(--muted); }
897
891
  return race.boats
898
892
  .map(function (boat) {
899
893
  var start = effectiveStart(boat);
900
- var elapsedMs = !boat.dnf && start && start <= now ? (boat.finishTime || now) - start : null;
894
+ var elapsedMs = !boat.dnf && !boat.dns && start && start <= now ? (boat.finishTime || now) - start : null;
901
895
  var tcf = boat.tcf != null ? boat.tcf : 1.0;
902
896
  var correctedMs = elapsedMs != null ? elapsedMs * tcf : null;
903
- var rankMs = boat.dnf ? null : correctedMs;
897
+ var rankMs = boat.dnf || boat.dns ? null : correctedMs;
904
898
  return { boat: boat, elapsedMs: elapsedMs, correctedMs: correctedMs, rankMs: rankMs };
905
899
  })
906
900
  .sort(function (a, b) {
@@ -908,6 +902,11 @@ tbody tr.dnf td { color: var(--muted); }
908
902
  var aSelf = a.boat.id === race.selfBoatId;
909
903
  var bSelf = b.boat.id === race.selfBoatId;
910
904
  if (aSelf !== bSelf) return aSelf ? -1 : 1;
905
+ // DNS sits at the very end regardless of phase — it can be marked
906
+ // before the race even starts (a known no-show), and never really
907
+ // "entered" the race the way even a DNF (which did start) still did.
908
+ if (a.boat.dns !== b.boat.dns) return a.boat.dns ? 1 : -1;
909
+ if (a.boat.dns) return a.boat.name.localeCompare(b.boat.name);
911
910
  // Before the race actually starts, nobody has a corrected time to
912
911
  // rank by anyway — leave order exactly as race.boats gave it
913
912
  // (registration order) rather than reshuffling on every add/remove.
@@ -935,7 +934,7 @@ tbody tr.dnf td { color: var(--muted); }
935
934
  if (!self) return map;
936
935
  ranked.forEach(function (r) {
937
936
  if (r.boat.id === self.boat.id) { map[r.boat.id] = { type: 'self', isLeader: isLeaderFallback }; return; }
938
- if (self.boat.dnf) { map[r.boat.id] = { type: 'none' }; return; }
937
+ if (self.boat.dnf || self.boat.dns) { map[r.boat.id] = { type: 'none' }; return; }
939
938
  if (!self.boat.finishTime && r.boat.finishTime) {
940
939
  var thresholdElapsedMs = r.correctedMs / (self.boat.tcf || 1);
941
940
  map[r.boat.id] = {
@@ -1154,6 +1153,7 @@ tbody tr.dnf td { color: var(--muted); }
1154
1153
  ? dateTimeInputValueToTs(finishTimeInput.value)
1155
1154
  : timeInputValueToTs(finishTimeInput.value);
1156
1155
  }
1156
+ maybeAutoStop();
1157
1157
  save();
1158
1158
  render();
1159
1159
  });
@@ -1165,6 +1165,7 @@ tbody tr.dnf td { color: var(--muted); }
1165
1165
  var boat = findBoat(boatId);
1166
1166
  if (!boat) return;
1167
1167
  boat.finishTime = Date.now();
1168
+ maybeAutoStop();
1168
1169
  save();
1169
1170
  render();
1170
1171
  });
@@ -1187,12 +1188,26 @@ tbody tr.dnf td { color: var(--muted); }
1187
1188
  var boat = findBoat(boatId);
1188
1189
  if (!boat) return;
1189
1190
  boat.dnf = true;
1191
+ maybeAutoStop();
1192
+ save();
1193
+ render();
1194
+ });
1195
+ var finishDnsBtn = document.createElement('button');
1196
+ finishDnsBtn.type = 'button';
1197
+ finishDnsBtn.className = 'finish-dnf-btn';
1198
+ finishDnsBtn.textContent = 'DNS';
1199
+ finishDnsBtn.title = 'Did not start';
1200
+ finishDnsBtn.addEventListener('click', function () {
1201
+ var boat = findBoat(boatId);
1202
+ if (!boat) return;
1203
+ boat.dns = true;
1204
+ maybeAutoStop();
1190
1205
  save();
1191
1206
  render();
1192
1207
  });
1193
1208
  var finishNormalWrap = document.createElement('div');
1194
1209
  finishNormalWrap.className = 'finish-cell';
1195
- finishNormalWrap.append(finishTimeInput, finishNowBtn, finishClearBtn, finishDnfBtn);
1210
+ finishNormalWrap.append(finishTimeInput, finishNowBtn, finishClearBtn, finishDnfBtn, finishDnsBtn);
1196
1211
 
1197
1212
  var dnfTag = document.createElement('span');
1198
1213
  dnfTag.className = 'dnf-tag';
@@ -1212,8 +1227,26 @@ tbody tr.dnf td { color: var(--muted); }
1212
1227
  dnfWrap.className = 'finish-cell';
1213
1228
  dnfWrap.append(dnfTag, undoDnfBtn);
1214
1229
 
1230
+ var dnsTag = document.createElement('span');
1231
+ dnsTag.className = 'dnf-tag';
1232
+ dnsTag.textContent = 'DNS';
1233
+ var undoDnsBtn = document.createElement('button');
1234
+ undoDnsBtn.type = 'button';
1235
+ undoDnsBtn.className = 'undo-dnf-btn';
1236
+ undoDnsBtn.textContent = 'Undo DNS';
1237
+ undoDnsBtn.addEventListener('click', function () {
1238
+ var boat = findBoat(boatId);
1239
+ if (!boat) return;
1240
+ boat.dns = false;
1241
+ save();
1242
+ render();
1243
+ });
1244
+ var dnsWrap = document.createElement('div');
1245
+ dnsWrap.className = 'finish-cell';
1246
+ dnsWrap.append(dnsTag, undoDnsBtn);
1247
+
1215
1248
  var tdFinish = document.createElement('td');
1216
- tdFinish.append(finishNormalWrap, dnfWrap);
1249
+ tdFinish.append(finishNormalWrap, dnfWrap, dnsWrap);
1217
1250
 
1218
1251
  var removeBtn = document.createElement('button');
1219
1252
  removeBtn.type = 'button';
@@ -1225,6 +1258,7 @@ tbody tr.dnf td { color: var(--muted); }
1225
1258
  // from the DOM, not just forget about it.
1226
1259
  race.boats = race.boats.filter(function (b) { return b.id !== boatId; });
1227
1260
  if (race.selfBoatId === boatId) race.selfBoatId = null;
1261
+ maybeAutoStop();
1228
1262
  save();
1229
1263
  render();
1230
1264
  });
@@ -1237,7 +1271,7 @@ tbody tr.dnf td { color: var(--muted); }
1237
1271
  vetSelect: vetSelect, vetBadge: vetBadge, vetHandicapVersion: -1,
1238
1272
  startTimeInput: startTimeInput, startNowBtn: startNowBtn, startClearBtn: startClearBtn,
1239
1273
  tdElapsed: tdElapsed, tdCorrected: tdCorrected, tdVsSelf: tdVsSelf,
1240
- finishNormalWrap: finishNormalWrap, finishTimeInput: finishTimeInput, dnfWrap: dnfWrap
1274
+ finishNormalWrap: finishNormalWrap, finishTimeInput: finishTimeInput, dnfWrap: dnfWrap, dnsWrap: dnsWrap
1241
1275
  };
1242
1276
  }
1243
1277
 
@@ -1267,7 +1301,7 @@ tbody tr.dnf td { color: var(--muted); }
1267
1301
  var row = rows[b.id];
1268
1302
  if (!row) { row = buildRow(b.id); rows[b.id] = row; }
1269
1303
  row.tr.classList.toggle('finished', !!b.finishTime);
1270
- row.tr.classList.toggle('dnf', !!b.dnf);
1304
+ row.tr.classList.toggle('dnf', !!b.dnf || !!b.dns);
1271
1305
  row.nameSpan.textContent = b.name;
1272
1306
  var isSelf = b.id === race.selfBoatId;
1273
1307
  row.selfBtn.textContent = isSelf ? '★' : '☆';
@@ -1303,8 +1337,9 @@ tbody tr.dnf td { color: var(--muted); }
1303
1337
  row.tdVsSelf.textContent = fmtSigned(vs.gapMs);
1304
1338
  row.tdVsSelf.classList.add(vs.gapMs < 0 ? 'ahead' : 'behind');
1305
1339
  }
1306
- row.finishNormalWrap.hidden = !!b.dnf;
1340
+ row.finishNormalWrap.hidden = !!b.dnf || !!b.dns;
1307
1341
  row.dnfWrap.hidden = !b.dnf;
1342
+ row.dnsWrap.hidden = !b.dns;
1308
1343
  if (document.activeElement !== row.finishTimeInput) {
1309
1344
  row.finishTimeInput.value = race.multiDay ? tsToDateTimeInputValue(b.finishTime) : tsToTimeInputValue(b.finishTime);
1310
1345
  }
@@ -1348,8 +1383,8 @@ tbody tr.dnf td { color: var(--muted); }
1348
1383
  var fmtWhen = race.multiDay ? tsToDateTimeInputValue : tsToTimeInputValue;
1349
1384
  var rows = [['Rank', 'Boat', 'Sail Number', 'TCF', 'Start Time', 'Elapsed', 'Corrected', 'Finish Time', 'Status']];
1350
1385
  rankedList().forEach(function (r, i) {
1351
- var status = r.boat.dnf ? 'DNF' : r.boat.finishTime ? 'Finished' : race.startTime ? 'Racing' : 'Not started';
1352
- var rankLabel = r.boat.dnf ? 'DNF' : r.rankMs != null ? String(i + 1) : '';
1386
+ var status = r.boat.dns ? 'DNS' : r.boat.dnf ? 'DNF' : r.boat.finishTime ? 'Finished' : race.startTime ? 'Racing' : 'Not started';
1387
+ var rankLabel = r.boat.dns ? 'DNS' : r.boat.dnf ? 'DNF' : r.rankMs != null ? String(i + 1) : '';
1353
1388
  var start = effectiveStart(r.boat);
1354
1389
  rows.push([
1355
1390
  rankLabel, r.boat.name, r.boat.sailNumber || '', r.boat.tcf, start ? fmtWhen(start) : '', fmtDuration(r.elapsedMs), fmtDuration(r.correctedMs),
@@ -1657,7 +1692,7 @@ module.exports = function (app) {
1657
1692
  // there's a finish line, a live position, and a non-trivial speed.
1658
1693
  function estimateFinish(race, boat) {
1659
1694
  const start = effectiveStartTime(race, boat);
1660
- if (!start || start > Date.now() || boat.finishTime || boat.dnf || race.stopTime) return null;
1695
+ if (!start || start > Date.now() || boat.finishTime || boat.dnf || boat.dns || race.stopTime) return null;
1661
1696
  if (!race.course || !race.course.finishLine) return null;
1662
1697
  const live = getLivePosition(boat.mmsi);
1663
1698
  if (!live || live.sogMs == null || live.sogMs < 0.25) return null;
@@ -1701,18 +1736,22 @@ module.exports = function (app) {
1701
1736
  return Object.values(race.boats)
1702
1737
  .map((boat) => {
1703
1738
  const start = effectiveStartTime(race, boat);
1704
- const elapsedMs = !boat.dnf && start && start <= now ? (boat.finishTime || now) - start : null;
1739
+ const elapsedMs = !boat.dnf && !boat.dns && start && start <= now ? (boat.finishTime || now) - start : null;
1705
1740
  const tcf = boat.tcf != null ? boat.tcf : 1.0;
1706
1741
  const correctedMs = elapsedMs != null ? elapsedMs * tcf : null;
1707
1742
  const estimate = estimateFinish(race, boat);
1708
1743
  const roundedMarksCount = countRoundedMarks(race, boat);
1709
- const rankMs = boat.dnf ? null : boat.finishTime ? correctedMs : estimate ? estimate.estCorrectedMs : correctedMs;
1744
+ const rankMs = boat.dnf || boat.dns ? null : boat.finishTime ? correctedMs : estimate ? estimate.estCorrectedMs : correctedMs;
1710
1745
  return { boat, elapsedMs, correctedMs, estimate, roundedMarksCount, rankMs };
1711
1746
  })
1712
1747
  .sort((a, b) => {
1713
- if (a.rankMs == null && b.rankMs == null) return a.boat.name.localeCompare(b.boat.name);
1714
- if (a.rankMs == null) return 1;
1715
- if (b.rankMs == null) return -1;
1748
+ // DNS sits below everyone else, including DNF — it never even
1749
+ // started, so it's not really "in" the race the way a DNF (which
1750
+ // did start) still arguably is.
1751
+ const aTier = a.boat.dns ? 2 : a.rankMs == null ? 1 : 0;
1752
+ const bTier = b.boat.dns ? 2 : b.rankMs == null ? 1 : 0;
1753
+ if (aTier !== bTier) return aTier - bTier;
1754
+ if (aTier !== 0) return a.boat.name.localeCompare(b.boat.name);
1716
1755
  // Among still-racing boats with no AIS-based ETA to fall back on
1717
1756
  // (elapsed-so-far alone says nothing about how much course is
1718
1757
  // left), a boat recorded further around the course — manually or
@@ -1789,7 +1828,7 @@ module.exports = function (app) {
1789
1828
  Object.values(state.races).forEach((race) => {
1790
1829
  if (!race.startTime || race.stopTime) return;
1791
1830
  Object.values(race.boats).forEach((boat) => {
1792
- if (boat.finishTime || boat.dnf || !boat.mmsi) return;
1831
+ if (boat.finishTime || boat.dnf || boat.dns || !boat.mmsi) return;
1793
1832
  const live = getLivePosition(boat.mmsi);
1794
1833
  if (!live) return;
1795
1834
  if (!boat.track) boat.track = [];
@@ -1820,6 +1859,7 @@ module.exports = function (app) {
1820
1859
  b.startTime = null;
1821
1860
  b.track = [];
1822
1861
  b.dnf = false;
1862
+ b.dns = false;
1823
1863
  b.dnfPosition = null;
1824
1864
  });
1825
1865
  saveState();
@@ -1865,7 +1905,7 @@ module.exports = function (app) {
1865
1905
  race.stopTime = atTime;
1866
1906
  race.scheduledCallOff = null;
1867
1907
  Object.values(race.boats).forEach((boat) => {
1868
- if (!boat.finishTime && !boat.dnf) {
1908
+ if (!boat.finishTime && !boat.dnf && !boat.dns) {
1869
1909
  boat.dnf = true;
1870
1910
  boat.dnfPosition = getLastKnownPosition(boat);
1871
1911
  }
@@ -1873,6 +1913,24 @@ module.exports = function (app) {
1873
1913
  saveState();
1874
1914
  }
1875
1915
 
1916
+ // A race with at least one boat where every boat has either finished or
1917
+ // been marked DNF/DNS has nothing left to time.
1918
+ function isRaceComplete(race) {
1919
+ const boats = Object.values(race.boats);
1920
+ if (!boats.length) return false;
1921
+ return boats.every((boat) => boat.finishTime || boat.dnf || boat.dns);
1922
+ }
1923
+
1924
+ // Called after anything that could newly complete a race (a finish, a
1925
+ // DNF, or removing the one boat still racing) — calls it off
1926
+ // automatically rather than leaving the clock running with nothing left
1927
+ // to time until someone remembers to click Stop.
1928
+ function maybeAutoStop(race) {
1929
+ if (race.startTime && !race.stopTime && isRaceComplete(race)) {
1930
+ doStop(race, Date.now());
1931
+ }
1932
+ }
1933
+
1876
1934
  // Re-arms (or clears) the timer that auto-calls-off a race at its
1877
1935
  // scheduledCallOff, mirroring armSchedule for the start.
1878
1936
  function armCallOffSchedule(race) {
@@ -2023,11 +2081,7 @@ module.exports = function (app) {
2023
2081
  const tz = isFinite(tzParsed) ? tzParsed : new Date().getTimezoneOffset();
2024
2082
 
2025
2083
  const ranked = rankedBoatList(race);
2026
- // A multi-day race's finish/start times can land on different
2027
- // calendar days, so the date is included alongside the time —
2028
- // otherwise just the time-of-day, matching how they're entered.
2029
- const fmtWhen = race.multiDay ? formatLocalDateTime : formatLocalTime;
2030
- const headers = ['Rank', 'Boat', 'Sail Number', 'MMSI', 'TCF', 'Start Time', 'Elapsed', 'Corrected', 'Finish Time', 'Status'];
2084
+ const headers = ['Rank', 'Boat', 'Sail Number', 'MMSI', 'TCF', 'Start Time', 'Finish Time', 'Elapsed', 'Corrected', 'Status'];
2031
2085
 
2032
2086
  const workbook = new ExcelJS.Workbook();
2033
2087
  workbook.creator = 'Race Control';
@@ -2055,25 +2109,52 @@ module.exports = function (app) {
2055
2109
  cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
2056
2110
  });
2057
2111
 
2112
+ // Real Excel date/time values in Start/Finish (shifted the same way
2113
+ // formatLocalDateTime above does, to display as the exporting
2114
+ // browser's local wall-clock rather than the server's), not just
2115
+ // formatted text — so Elapsed and Corrected can be genuine
2116
+ // formulas referencing those cells instead of numbers baked in at
2117
+ // export time. Recalculates if a start/finish time is corrected
2118
+ // directly in the spreadsheet afterward. Left blank (by leaving
2119
+ // Finish Time blank) for a boat with no finish time — DNF, DNS, or
2120
+ // still racing — rather than showing a snapshot of elapsed-so-far
2121
+ // that would just sit there, wrong, once the file is reopened later.
2122
+ const dateNumFmt = race.multiDay ? 'yyyy-mm-dd hh:mm:ss' : 'hh:mm:ss';
2123
+ const durationNumFmt = '[h]:mm:ss';
2058
2124
  ranked.forEach((r, i) => {
2059
- const status = r.boat.dnf ? 'DNF' : r.boat.finishTime ? 'Finished' : race.startTime ? 'Racing' : 'Not started';
2060
- const rankLabel = r.boat.dnf ? 'DNF' : r.rankMs != null ? i + 1 : '';
2125
+ const status = r.boat.dns
2126
+ ? 'DNS'
2127
+ : r.boat.dnf
2128
+ ? 'DNF'
2129
+ : r.boat.finishTime
2130
+ ? 'Finished'
2131
+ : race.startTime
2132
+ ? 'Racing'
2133
+ : 'Not started';
2134
+ const rankLabel = r.boat.dns ? 'DNS' : r.boat.dnf ? 'DNF' : r.rankMs != null ? i + 1 : '';
2061
2135
  const start = effectiveStartTime(race, r.boat);
2062
- sheet.addRow([
2136
+ const startDate = start != null ? new Date(start - tz * 60000) : null;
2137
+ const finishDate = r.boat.finishTime != null ? new Date(r.boat.finishTime - tz * 60000) : null;
2138
+ const rowNum = headerRow.number + 1 + i;
2139
+ const row = sheet.addRow([
2063
2140
  rankLabel,
2064
2141
  r.boat.name,
2065
2142
  r.boat.sailNumber || '',
2066
2143
  r.boat.mmsi || '',
2067
2144
  r.boat.tcf,
2068
- start ? fmtWhen(start, tz) : '',
2069
- formatDurationHms(r.elapsedMs),
2070
- formatDurationHms(r.correctedMs),
2071
- r.boat.finishTime ? fmtWhen(r.boat.finishTime, tz) : '',
2145
+ startDate,
2146
+ finishDate,
2147
+ { formula: `IF(OR(F${rowNum}="",G${rowNum}=""),"",G${rowNum}-F${rowNum})` },
2148
+ { formula: `IF(H${rowNum}="","",H${rowNum}*E${rowNum})` },
2072
2149
  status
2073
2150
  ]);
2151
+ row.getCell(6).numFmt = dateNumFmt;
2152
+ row.getCell(7).numFmt = dateNumFmt;
2153
+ row.getCell(8).numFmt = durationNumFmt;
2154
+ row.getCell(9).numFmt = durationNumFmt;
2074
2155
  });
2075
2156
 
2076
- const widths = [7, 24, 12, 12, 8, race.multiDay ? 17 : 12, 12, 12, race.multiDay ? 17 : 12, 12];
2157
+ const widths = [7, 24, 12, 12, 8, race.multiDay ? 17 : 12, race.multiDay ? 17 : 12, 12, 12, 12];
2077
2158
  widths.forEach((w, i) => {
2078
2159
  sheet.getColumn(i + 1).width = w;
2079
2160
  });
@@ -2237,6 +2318,7 @@ module.exports = function (app) {
2237
2318
  b.startTime = null;
2238
2319
  b.track = [];
2239
2320
  b.dnf = false;
2321
+ b.dns = false;
2240
2322
  b.dnfPosition = null;
2241
2323
  });
2242
2324
  saveState();
@@ -2391,6 +2473,7 @@ module.exports = function (app) {
2391
2473
  startTime: null,
2392
2474
  track: [],
2393
2475
  dnf: false,
2476
+ dns: false,
2394
2477
  dnfPosition: null,
2395
2478
  markTimes: {}
2396
2479
  };
@@ -2405,6 +2488,7 @@ module.exports = function (app) {
2405
2488
  if (!getBoat(race, req.params.boatId)) return res.status(404).json({ error: 'No such boat' });
2406
2489
  delete race.boats[req.params.boatId];
2407
2490
  if (race.selfBoatId === req.params.boatId) race.selfBoatId = null;
2491
+ maybeAutoStop(race);
2408
2492
  saveState();
2409
2493
  res.json({ ok: true });
2410
2494
  });
@@ -2425,11 +2509,13 @@ module.exports = function (app) {
2425
2509
  return res.status(400).json({ error: 'finishTime must be an epoch-millisecond timestamp or null' });
2426
2510
  }
2427
2511
  boat.finishTime = t;
2428
- // A real finish supersedes a DNF (e.g. correcting a call-off that
2429
- // caught a boat that had actually already crossed the line).
2512
+ // A real finish supersedes a DNF or DNS (e.g. correcting a call-off
2513
+ // that caught a boat that had actually already crossed the line).
2430
2514
  boat.dnf = false;
2515
+ boat.dns = false;
2431
2516
  boat.dnfPosition = null;
2432
2517
  }
2518
+ maybeAutoStop(race);
2433
2519
  saveState();
2434
2520
  res.json(boat);
2435
2521
  });
@@ -2468,12 +2554,37 @@ module.exports = function (app) {
2468
2554
  const dnf = !!(req.body && req.body.dnf);
2469
2555
  if (dnf) {
2470
2556
  boat.dnf = true;
2557
+ boat.dns = false;
2471
2558
  boat.finishTime = null;
2472
2559
  boat.dnfPosition = getLastKnownPosition(boat);
2473
2560
  } else {
2474
2561
  boat.dnf = false;
2475
2562
  boat.dnfPosition = null;
2476
2563
  }
2564
+ maybeAutoStop(race);
2565
+ saveState();
2566
+ res.json(boat);
2567
+ });
2568
+
2569
+ // Marks (or, with dns: false, un-marks) one boat DNS by hand — it never
2570
+ // started the race at all, as distinct from DNF (started but didn't
2571
+ // finish). Setting it also clears any finish time/DNF, since a boat
2572
+ // can't be more than one of finished/DNF/DNS at once.
2573
+ router.put('/races/:id/boats/:boatId/dns', (req, res) => {
2574
+ const race = getRace(req.params.id);
2575
+ if (!race) return res.status(404).json({ error: 'No such race' });
2576
+ const boat = getBoat(race, req.params.boatId);
2577
+ if (!boat) return res.status(404).json({ error: 'No such boat' });
2578
+ const dns = !!(req.body && req.body.dns);
2579
+ if (dns) {
2580
+ boat.dns = true;
2581
+ boat.dnf = false;
2582
+ boat.finishTime = null;
2583
+ boat.dnfPosition = null;
2584
+ } else {
2585
+ boat.dns = false;
2586
+ }
2587
+ maybeAutoStop(race);
2477
2588
  saveState();
2478
2589
  res.json(boat);
2479
2590
  });
@@ -2715,6 +2826,7 @@ module.exports = function (app) {
2715
2826
  startTime: null,
2716
2827
  track: [],
2717
2828
  dnf: false,
2829
+ dns: false,
2718
2830
  dnfPosition: null,
2719
2831
  markTimes: {}
2720
2832
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-race-control",
3
- "version": "0.6.0",
3
+ "version": "0.7.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
@@ -691,6 +691,25 @@
691
691
  }
692
692
  }
693
693
 
694
+ // Did Not Start — distinct from DNF (started but didn't finish).
695
+ async function setDns(boatId, dns) {
696
+ if (!activeRaceId) return;
697
+ try {
698
+ const boat = await fetchJSON(
699
+ `${API}/races/${encodeURIComponent(activeRaceId)}/boats/${encodeURIComponent(boatId)}/dns`,
700
+ {
701
+ method: 'PUT',
702
+ headers: { 'Content-Type': 'application/json' },
703
+ body: JSON.stringify({ dns })
704
+ }
705
+ );
706
+ raceState.boats[boatId] = boat;
707
+ render();
708
+ } catch (e) {
709
+ setStatus(e.message, true);
710
+ }
711
+ }
712
+
694
713
  async function toggleSelf(boatId) {
695
714
  if (!activeRaceId || !raceState) return;
696
715
  const nextSelf = raceState.selfBoatId === boatId ? null : boatId;
@@ -1617,7 +1636,7 @@
1617
1636
  // to the bottom, same as any other boat with nothing to rank by).
1618
1637
  // A start time still in the future (e.g. a later pursuit-start
1619
1638
  // group) means this boat hasn't actually started yet either.
1620
- const elapsedMs = !b.dnf && start && start <= now ? (b.finishTime || now) - start : null;
1639
+ const elapsedMs = !b.dnf && !b.dns && start && start <= now ? (b.finishTime || now) - start : null;
1621
1640
  const tcf = b.tcf != null ? b.tcf : 1.0;
1622
1641
  const correctedMs = elapsedMs != null ? elapsedMs * tcf : null;
1623
1642
  const estimate = b.estimate || null;
@@ -1625,7 +1644,7 @@
1625
1644
  // boats rank on the projected corrected time when the server could
1626
1645
  // estimate one (course + live position/speed available), otherwise
1627
1646
  // fall back to elapsed-so-far like before.
1628
- const rankMs = b.dnf ? null : b.finishTime ? correctedMs : estimate ? estimate.estCorrectedMs : correctedMs;
1647
+ const rankMs = b.dnf || b.dns ? null : b.finishTime ? correctedMs : estimate ? estimate.estCorrectedMs : correctedMs;
1629
1648
  return {
1630
1649
  boatId: b.id,
1631
1650
  name: b.name,
@@ -1635,6 +1654,7 @@
1635
1654
  startTime: b.startTime,
1636
1655
  finishTime: b.finishTime,
1637
1656
  dnf: !!b.dnf,
1657
+ dns: !!b.dns,
1638
1658
  dnfPosition: b.dnfPosition || null,
1639
1659
  markTimes: b.markTimes || {},
1640
1660
  roundedMarksCount: b.roundedMarksCount || 0,
@@ -1651,6 +1671,12 @@
1651
1671
  const aSelf = a.boatId === raceState.selfBoatId;
1652
1672
  const bSelf = b.boatId === raceState.selfBoatId;
1653
1673
  if (aSelf !== bSelf) return aSelf ? -1 : 1;
1674
+ // DNS sits at the very end regardless of phase — it can be marked
1675
+ // before the race even starts (a known no-show), and never really
1676
+ // "entered" the race the way even a DNF (which did start) still
1677
+ // did.
1678
+ if (a.dns !== b.dns) return a.dns ? 1 : -1;
1679
+ if (a.dns) return a.name.localeCompare(b.name);
1654
1680
  // Before the race actually starts, nobody has a corrected time to
1655
1681
  // rank by anyway — sorting by name (or anything else derived) just
1656
1682
  // reshuffled the rows confusingly every time a boat was added or
@@ -1699,7 +1725,7 @@
1699
1725
  map.set(b.boatId, { type: 'self', isLeader: isLeaderFallback });
1700
1726
  return;
1701
1727
  }
1702
- if (self.dnf) {
1728
+ if (self.dnf || self.dns) {
1703
1729
  // Self is out of the race — no meaningful comparison to make.
1704
1730
  map.set(b.boatId, { type: 'none' });
1705
1731
  return;
@@ -1894,9 +1920,16 @@
1894
1920
  finishDnfBtn.textContent = 'DNF';
1895
1921
  finishDnfBtn.addEventListener('click', () => setDnf(boatId, true));
1896
1922
 
1923
+ const finishDnsBtn = document.createElement('button');
1924
+ finishDnsBtn.type = 'button';
1925
+ finishDnsBtn.className = 'finish-dnf-btn';
1926
+ finishDnsBtn.textContent = 'DNS';
1927
+ finishDnsBtn.title = 'Did not start';
1928
+ finishDnsBtn.addEventListener('click', () => setDns(boatId, true));
1929
+
1897
1930
  const finishNormalWrap = document.createElement('div');
1898
1931
  finishNormalWrap.className = 'finish-cell';
1899
- finishNormalWrap.append(finishTimeInput, finishNowBtn, finishClearBtn, finishDnfBtn);
1932
+ finishNormalWrap.append(finishTimeInput, finishNowBtn, finishClearBtn, finishDnfBtn, finishDnsBtn);
1900
1933
 
1901
1934
  const dnfTag = document.createElement('span');
1902
1935
  dnfTag.className = 'dnf-tag';
@@ -1912,8 +1945,20 @@
1912
1945
  dnfWrap.className = 'finish-cell';
1913
1946
  dnfWrap.append(dnfTag, dnfPosSpan, undoDnfBtn);
1914
1947
 
1948
+ const dnsTag = document.createElement('span');
1949
+ dnsTag.className = 'dnf-tag';
1950
+ dnsTag.textContent = 'DNS';
1951
+ const undoDnsBtn = document.createElement('button');
1952
+ undoDnsBtn.type = 'button';
1953
+ undoDnsBtn.className = 'undo-dnf-btn';
1954
+ undoDnsBtn.textContent = 'Undo DNS';
1955
+ undoDnsBtn.addEventListener('click', () => setDns(boatId, false));
1956
+ const dnsWrap = document.createElement('div');
1957
+ dnsWrap.className = 'finish-cell';
1958
+ dnsWrap.append(dnsTag, undoDnsBtn);
1959
+
1915
1960
  const tdFinish = document.createElement('td');
1916
- tdFinish.append(finishNormalWrap, dnfWrap);
1961
+ tdFinish.append(finishNormalWrap, dnfWrap, dnsWrap);
1917
1962
 
1918
1963
  const removeBtn = document.createElement('button');
1919
1964
  removeBtn.type = 'button';
@@ -1955,8 +2000,10 @@
1955
2000
  finishNowBtn,
1956
2001
  finishClearBtn,
1957
2002
  finishDnfBtn,
2003
+ finishDnsBtn,
1958
2004
  dnfWrap,
1959
2005
  dnfPosSpan,
2006
+ dnsWrap,
1960
2007
  vetHandicapVersion: -1
1961
2008
  };
1962
2009
  }
@@ -2132,7 +2179,7 @@
2132
2179
  const row = rows.get(b.boatId);
2133
2180
 
2134
2181
  row.tr.classList.toggle('finished', !!b.finishTime);
2135
- row.tr.classList.toggle('dnf', !!b.dnf);
2182
+ row.tr.classList.toggle('dnf', !!b.dnf || !!b.dns);
2136
2183
  row.nameSpan.textContent = b.name;
2137
2184
  const isSelf = b.boatId === raceState.selfBoatId;
2138
2185
  row.selfBtn.textContent = isSelf ? '★' : '☆';
@@ -2227,13 +2274,14 @@
2227
2274
  row.tdVsSelf.title = `Corrected-time gap to the ${who} (positive = ${who} ahead)`;
2228
2275
  }
2229
2276
 
2230
- row.finishNormalWrap.hidden = b.dnf;
2277
+ row.finishNormalWrap.hidden = b.dnf || b.dns;
2231
2278
  row.dnfWrap.hidden = !b.dnf;
2279
+ row.dnsWrap.hidden = !b.dns;
2232
2280
  if (b.dnf) {
2233
2281
  row.dnfPosSpan.textContent = b.dnfPosition
2234
2282
  ? `at ${b.dnfPosition.lat.toFixed(4)}°, ${b.dnfPosition.lon.toFixed(4)}°`
2235
2283
  : 'position unknown';
2236
- } else {
2284
+ } else if (!b.dns) {
2237
2285
  if (document.activeElement !== row.finishTimeInput) {
2238
2286
  row.finishTimeInput.value = raceState.multiDay
2239
2287
  ? tsToDateTimeInputValue(b.finishTime)
@@ -2244,6 +2292,8 @@
2244
2292
  row.finishNowBtn.disabled = !canFinish;
2245
2293
  row.finishClearBtn.disabled = !canFinish || !b.finishTime;
2246
2294
  row.finishDnfBtn.disabled = !canFinish;
2295
+ // DNS (unlike DNF) is meaningful before the race has even started —
2296
+ // it's how a known no-show gets recorded — so it's never disabled.
2247
2297
  }
2248
2298
  });
2249
2299