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 +21 -9
- package/index.js +168 -56
- package/package.json +1 -1
- package/public/app.js +58 -8
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
|
|
144
|
-
capturing its last known AIS position if one's available. You
|
|
145
|
-
call-off for a future time (mirrors Schedule Start), and mark or
|
|
146
|
-
individual boat DNF by hand at any point, independent of the whole
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
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
|
-
|
|
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.
|
|
2060
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
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,
|
|
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
|
|
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.
|
|
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
|
|