signalk-race-control 0.1.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/index.js ADDED
@@ -0,0 +1,1133 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const ExcelJS = require('exceljs');
4
+
5
+ const DEFAULT_HANDICAP_SOURCE_PAGE = 'https://ssca.no/aktiviteter/vet-tall';
6
+ const HANDICAP_CACHE_TTL_MS = 60 * 60 * 1000;
7
+
8
+ function parseCsvText(text) {
9
+ const rows = [];
10
+ let row = [];
11
+ let field = '';
12
+ let inQuotes = false;
13
+ for (let i = 0; i < text.length; i++) {
14
+ const c = text[i];
15
+ if (inQuotes) {
16
+ if (c === '"') {
17
+ if (text[i + 1] === '"') {
18
+ field += '"';
19
+ i++;
20
+ } else {
21
+ inQuotes = false;
22
+ }
23
+ } else {
24
+ field += c;
25
+ }
26
+ } else if (c === '"') {
27
+ inQuotes = true;
28
+ } else if (c === ',') {
29
+ row.push(field);
30
+ field = '';
31
+ } else if (c === '\n') {
32
+ row.push(field);
33
+ rows.push(row);
34
+ row = [];
35
+ field = '';
36
+ } else if (c === '\r') {
37
+ // ignore, \n (possibly preceded by \r) ends the row
38
+ } else {
39
+ field += c;
40
+ }
41
+ }
42
+ if (field.length || row.length) {
43
+ row.push(field);
44
+ rows.push(row);
45
+ }
46
+ return rows;
47
+ }
48
+
49
+ // The register uses Norwegian decimal commas (e.g. "1,07"), quoted in the
50
+ // CSV export precisely because the comma would otherwise look like a field
51
+ // separator.
52
+ function parseNorwegianNumber(s) {
53
+ if (s == null) return null;
54
+ const t = String(s).trim().replace(',', '.');
55
+ if (t === '') return null;
56
+ const n = Number(t);
57
+ return isFinite(n) ? n : null;
58
+ }
59
+
60
+ // The sheet has a title-row block, a sail-area-weighting block, and then the
61
+ // real header row (identifiable by having both "Klasse" and "VET 1" cells).
62
+ // Boat rows follow it: name, validity flag, then a repeating
63
+ // [sail-config label, VET value] pair per rating (up to 3).
64
+ function parseHandicapSheet(csvText) {
65
+ const rows = parseCsvText(csvText);
66
+ const headerRowIndex = rows.findIndex((r) => r.includes('Klasse') && r.includes('VET 1'));
67
+ if (headerRowIndex === -1) {
68
+ throw new Error('Could not find the "Klasse" / "VET 1" header row in the handicap sheet');
69
+ }
70
+ const header = rows[headerRowIndex];
71
+ const col = (name) => header.indexOf(name);
72
+ const vetCols = [col('VET 1'), col('VET 2'), col('VET 3')].filter((i) => i !== -1);
73
+ const classCol = col('Klasse');
74
+ const ownerCol = col('Eier');
75
+
76
+ const boats = [];
77
+ for (let r = headerRowIndex + 1; r < rows.length; r++) {
78
+ const row = rows[r];
79
+ const name = (row[0] || '').trim();
80
+ if (!name) continue;
81
+ const validity = (row[1] || '').trim();
82
+ const vets = vetCols
83
+ .map((vc, idx) => {
84
+ const value = parseNorwegianNumber(row[vc]);
85
+ if (value == null) return null;
86
+ const label = (row[vc - 1] || '').replace(/:\s*$/, '').trim() || `VET ${idx + 1}`;
87
+ return { label, value };
88
+ })
89
+ .filter(Boolean);
90
+ if (!vets.length) continue;
91
+ boats.push({
92
+ name,
93
+ validity,
94
+ class: classCol !== -1 ? (row[classCol] || '').trim() : '',
95
+ owner: ownerCol !== -1 ? (row[ownerCol] || '').trim() : '',
96
+ vets
97
+ });
98
+ }
99
+ return boats;
100
+ }
101
+
102
+ function extractGoogleSheetId(text) {
103
+ const m = text.match(/docs\.google\.com\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/);
104
+ return m ? m[1] : null;
105
+ }
106
+
107
+ // The sheet ID changes year to year, so by default we re-resolve it from the
108
+ // SSCA page that links to "this year's" register rather than hardcoding an
109
+ // ID that will go stale. A directly-configured Google Sheets URL skips the
110
+ // page-scrape and is used as-is.
111
+ async function resolveHandicapCsvUrl(sourceUrl) {
112
+ const directId = extractGoogleSheetId(sourceUrl);
113
+ if (directId) {
114
+ return `https://docs.google.com/spreadsheets/d/${directId}/export?format=csv`;
115
+ }
116
+ const res = await fetch(sourceUrl);
117
+ if (!res.ok) {
118
+ throw new Error(`Could not load ${sourceUrl}: HTTP ${res.status}`);
119
+ }
120
+ const html = await res.text();
121
+ const sheetId = extractGoogleSheetId(html);
122
+ if (!sheetId) {
123
+ throw new Error(`No Google Sheets link found on ${sourceUrl}`);
124
+ }
125
+ return `https://docs.google.com/spreadsheets/d/${sheetId}/export?format=csv`;
126
+ }
127
+
128
+ function makeRaceId() {
129
+ return 'r' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
130
+ }
131
+
132
+ function makeBoatId() {
133
+ return 'b' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
134
+ }
135
+
136
+ function raceSummary(race) {
137
+ const boats = Object.values(race.boats);
138
+ return {
139
+ id: race.id,
140
+ name: race.name,
141
+ createdAt: race.createdAt,
142
+ scheduledStart: race.scheduledStart,
143
+ startTime: race.startTime,
144
+ boatCount: boats.length,
145
+ finishedCount: boats.filter((b) => b.finishTime).length,
146
+ dnfCount: boats.filter((b) => b.dnf).length
147
+ };
148
+ }
149
+
150
+ function emptyCourse() {
151
+ return { startLine: null, marks: [], finishLine: null };
152
+ }
153
+
154
+ // Pre-existing races (saved before these fields existed) are missing them —
155
+ // backfill in place so every handler can assume they exist.
156
+ function ensureRaceShape(race) {
157
+ if (!race.course) race.course = emptyCourse();
158
+ if (race.selfBoatId === undefined) race.selfBoatId = null;
159
+ if (race.stopTime === undefined) race.stopTime = null;
160
+ if (race.scheduledCallOff === undefined) race.scheduledCallOff = null;
161
+ Object.values(race.boats).forEach((b) => {
162
+ if (!b.track) b.track = [];
163
+ if (b.dnf === undefined) b.dnf = false;
164
+ if (b.dnfPosition === undefined) b.dnfPosition = null;
165
+ });
166
+ }
167
+
168
+ const EARTH_RADIUS_NM = 3440.065;
169
+ const MS_TO_KNOTS = 1.9438444924574;
170
+
171
+ function toRad(deg) {
172
+ return (deg * Math.PI) / 180;
173
+ }
174
+
175
+ function distanceNm(a, b) {
176
+ const dLat = toRad(b.lat - a.lat);
177
+ const dLon = toRad(b.lon - a.lon);
178
+ const lat1 = toRad(a.lat);
179
+ const lat2 = toRad(b.lat);
180
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
181
+ return 2 * EARTH_RADIUS_NM * Math.asin(Math.min(1, Math.sqrt(h)));
182
+ }
183
+
184
+ function midpoint(a, b) {
185
+ return { lat: (a.lat + b.lat) / 2, lon: (a.lon + b.lon) / 2 };
186
+ }
187
+
188
+ function validateCoordPoint(p) {
189
+ if (!p || typeof p.lat !== 'number' || typeof p.lon !== 'number' || !isFinite(p.lat) || !isFinite(p.lon)) {
190
+ return null;
191
+ }
192
+ if (p.lat < -90 || p.lat > 90 || p.lon < -180 || p.lon > 180) return null;
193
+ const out = { lat: p.lat, lon: p.lon };
194
+ const name = (p.name || '').toString().trim();
195
+ if (name) out.name = name;
196
+ return out;
197
+ }
198
+
199
+ function validateLine(line) {
200
+ if (line === null) return null;
201
+ if (!Array.isArray(line) || line.length !== 2) return undefined; // undefined = invalid, caller rejects
202
+ const a = validateCoordPoint(line[0]);
203
+ const b = validateCoordPoint(line[1]);
204
+ if (!a || !b) return undefined;
205
+ return [a, b];
206
+ }
207
+
208
+ // HH:MM:SS for a duration, or '' when there's nothing to show — matches the
209
+ // webapp's fmtDuration, used for the Excel export's Elapsed/Corrected columns.
210
+ function formatDurationHms(ms) {
211
+ if (ms == null || ms < 0 || !isFinite(ms)) return '';
212
+ const totalSec = Math.floor(ms / 1000);
213
+ const h = Math.floor(totalSec / 3600);
214
+ const m = Math.floor((totalSec % 3600) / 60);
215
+ const s = totalSec % 60;
216
+ const pad = (n) => String(n).padStart(2, '0');
217
+ return `${pad(h)}:${pad(m)}:${pad(s)}`;
218
+ }
219
+
220
+ // Renders an absolute instant as the wall-clock time it was in the
221
+ // exporting browser's own timezone (tzOffsetMinutes = that browser's
222
+ // Date.prototype.getTimezoneOffset()) rather than the server's timezone,
223
+ // which may well be different — the server can be headless/UTC while the
224
+ // person opening the spreadsheet is reading it in their own local time.
225
+ function formatLocalTime(utcMs, tzOffsetMinutes) {
226
+ if (utcMs == null) return '';
227
+ const shifted = new Date(utcMs - tzOffsetMinutes * 60000);
228
+ const pad = (n) => String(n).padStart(2, '0');
229
+ return `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}`;
230
+ }
231
+
232
+ function formatLocalDateTime(utcMs, tzOffsetMinutes) {
233
+ if (utcMs == null) return '';
234
+ const shifted = new Date(utcMs - tzOffsetMinutes * 60000);
235
+ const pad = (n) => String(n).padStart(2, '0');
236
+ return (
237
+ `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())} ` +
238
+ `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}`
239
+ );
240
+ }
241
+
242
+ module.exports = function (app) {
243
+ const plugin = {};
244
+
245
+ plugin.id = 'race-control';
246
+ plugin.name = 'Race Control';
247
+ plugin.description =
248
+ 'Elapsed and handicap-corrected (Time-on-Time) race time tracking for named, plannable races, with a webapp UI, per-boat editable TCF, and VET-tall import from SSCA.';
249
+
250
+ plugin.schema = {
251
+ type: 'object',
252
+ properties: {
253
+ defaultTcf: {
254
+ type: 'number',
255
+ title: 'Default Time-on-Time correction factor (TCF) applied to newly seen boats',
256
+ default: 1.0
257
+ },
258
+ handicapSourceUrl: {
259
+ type: 'string',
260
+ title:
261
+ 'VET-tall page to resolve the current handicap register from (or a direct Google Sheets link to override)',
262
+ default: DEFAULT_HANDICAP_SOURCE_PAGE
263
+ },
264
+ vetEnabled: {
265
+ type: 'boolean',
266
+ title:
267
+ 'Use the VET-tall register for autocomplete and per-boat handicap alternatives. When off, every boat is treated as outside VET: TCF is remembered per boat name across races instead.',
268
+ default: false
269
+ }
270
+ }
271
+ };
272
+
273
+ // Races are kept by id so several can be planned ahead and reviewed after
274
+ // the fact, rather than a single race getting overwritten by Reset.
275
+ // boatRegistry is a separate, cross-race name->MMSI/TCF memory: once a
276
+ // boat's MMSI or (for boats outside VET) TCF is entered anywhere, it's
277
+ // applied automatically next time that name is used in any race.
278
+ let state = { races: {}, order: [], currentRaceId: null, boatRegistry: {} };
279
+ let dataFile = null;
280
+ const scheduleTimers = new Map(); // raceId -> Timeout, for scheduledStart
281
+ const callOffTimers = new Map(); // raceId -> Timeout, for scheduledCallOff
282
+ let handicapCache = { fetchedAt: 0, boats: [], sourceUrl: null, csvUrl: null };
283
+
284
+ function loadState() {
285
+ try {
286
+ const raw = fs.readFileSync(dataFile, 'utf8');
287
+ const loaded = JSON.parse(raw);
288
+ state = {
289
+ races: loaded.races || {},
290
+ order: loaded.order || [],
291
+ currentRaceId: loaded.currentRaceId || null,
292
+ boatRegistry: loaded.boatRegistry || {}
293
+ };
294
+ } catch (e) {
295
+ state = { races: {}, order: [], currentRaceId: null, boatRegistry: {} };
296
+ }
297
+ }
298
+
299
+ // Cross-race memory keyed by boat name, merging in whichever fields are
300
+ // given (mmsi and/or tcf) without clobbering the other. tcf is only ever
301
+ // written here for boats outside the VET register — see the callers.
302
+ function upsertBoatRegistry(name, fields) {
303
+ if (!name) return;
304
+ const key = name.trim().toLowerCase();
305
+ const existing = state.boatRegistry[key] || { name: name.trim() };
306
+ existing.name = name.trim();
307
+ if (fields.mmsi) existing.mmsi = String(fields.mmsi).trim();
308
+ if (fields.tcf != null) existing.tcf = fields.tcf;
309
+ state.boatRegistry[key] = existing;
310
+ }
311
+
312
+ function getRegistryEntry(name) {
313
+ return state.boatRegistry[(name || '').trim().toLowerCase()] || null;
314
+ }
315
+
316
+ function lookupBoatRegistry(name) {
317
+ const entry = getRegistryEntry(name);
318
+ return entry ? entry.mmsi : null;
319
+ }
320
+
321
+ // vetEnabled is a plugin config setting (Server -> Plugin Config), not
322
+ // per-race and not editable from the webapp itself. Off by default — a
323
+ // fresh install (or one that's never touched this setting) treats every
324
+ // boat as outside VET until it's explicitly turned on.
325
+ function isVetEnabled() {
326
+ return !!(plugin.options && plugin.options.vetEnabled === true);
327
+ }
328
+
329
+ // A boat only counts as "in VET" for registry-TCF purposes while the
330
+ // config setting has VET enabled and the register (whatever's currently
331
+ // cached) actually has a matching name — disabling VET makes every boat
332
+ // "outside VET" for this purpose too, per the setting's whole point.
333
+ function isVetMatch(name) {
334
+ if (!isVetEnabled()) return false;
335
+ const n = (name || '').trim().toLowerCase();
336
+ return handicapCache.boats.some((b) => b.name.toLowerCase() === n);
337
+ }
338
+
339
+ function saveState() {
340
+ try {
341
+ fs.writeFileSync(dataFile, JSON.stringify(state, null, 2));
342
+ } catch (e) {
343
+ app.error('race-control: failed to persist state: ' + e.message);
344
+ }
345
+ }
346
+
347
+ function getRace(id) {
348
+ return state.races[id] || null;
349
+ }
350
+
351
+ function getBoat(race, boatId) {
352
+ return race.boats[boatId] || null;
353
+ }
354
+
355
+ function contextForMmsi(mmsi) {
356
+ return `vessels.urn:mrn:imo:mmsi:${mmsi}`;
357
+ }
358
+
359
+ // Live snapshot straight from SignalK's in-memory model (no HTTP
360
+ // round-trip) — used both for track recording and the finish estimate.
361
+ function getLivePosition(mmsi) {
362
+ if (!mmsi) return null;
363
+ try {
364
+ const posLeaf = app.getPath(`${contextForMmsi(mmsi)}.navigation.position`);
365
+ const pos = posLeaf && posLeaf.value;
366
+ if (!pos || typeof pos.latitude !== 'number' || typeof pos.longitude !== 'number') return null;
367
+ const sogLeaf = app.getPath(`${contextForMmsi(mmsi)}.navigation.speedOverGround`);
368
+ const sogMs = sogLeaf && typeof sogLeaf.value === 'number' ? sogLeaf.value : null;
369
+ return { lat: pos.latitude, lon: pos.longitude, sogMs };
370
+ } catch (e) {
371
+ return null;
372
+ }
373
+ }
374
+
375
+ // Live position if we have one right now, else the last recorded track
376
+ // point, else nothing — used when marking a boat DNF, since by then it
377
+ // may no longer be broadcasting AIS.
378
+ function getLastKnownPosition(boat) {
379
+ const live = getLivePosition(boat.mmsi);
380
+ if (live) return { lat: live.lat, lon: live.lon };
381
+ if (boat.track && boat.track.length) {
382
+ const last = boat.track[boat.track.length - 1];
383
+ return { lat: last.lat, lon: last.lon };
384
+ }
385
+ return null;
386
+ }
387
+
388
+ // A mark counts as "rounded" once the boat's recorded track came within
389
+ // this radius of it. ~0.1nm (~185m) — loose enough to tolerate AIS
390
+ // position jitter and the 15s sampling gap (a boat doing 7kn covers about
391
+ // 0.03nm between samples) without needing an exact pass.
392
+ const MARK_ROUNDING_RADIUS_NM = 0.1;
393
+
394
+ // Scans a boat's recorded track chronologically, advancing to the next
395
+ // mark each time the track comes within rounding radius of the current
396
+ // one — so it naturally requires marks to be rounded in course order.
397
+ // Automatic (no manual "boat X rounded mark Y" input) by design, at the
398
+ // cost of missing a rounding if a boat cuts far outside the radius.
399
+ function countRoundedMarks(race, boat) {
400
+ const marks = race.course.marks;
401
+ if (!marks.length || !boat.track || !boat.track.length) return 0;
402
+ let markIdx = 0;
403
+ for (const pt of boat.track) {
404
+ if (markIdx >= marks.length) break;
405
+ if (distanceNm(pt, marks[markIdx]) <= MARK_ROUNDING_RADIUS_NM) {
406
+ markIdx++;
407
+ }
408
+ }
409
+ return markIdx;
410
+ }
411
+
412
+ // Distance from the boat's current position, around each remaining mark
413
+ // in order, to the finish line — not just a straight line to the finish.
414
+ // Returns null if there's nothing left to route to (no remaining marks and
415
+ // no finish line).
416
+ function remainingCourseDistanceNm(race, boat, currentPos) {
417
+ const roundedCount = countRoundedMarks(race, boat);
418
+ const waypoints = race.course.marks.slice(roundedCount).map((m) => ({ lat: m.lat, lon: m.lon }));
419
+ if (race.course.finishLine) waypoints.push(midpoint(race.course.finishLine[0], race.course.finishLine[1]));
420
+ if (!waypoints.length) return null;
421
+ let total = 0;
422
+ let from = currentPos;
423
+ for (const wp of waypoints) {
424
+ total += distanceNm(from, wp);
425
+ from = wp;
426
+ }
427
+ return { nm: total, marksRemaining: race.course.marks.length - roundedCount };
428
+ }
429
+
430
+ // Distance-to-go (current position, around remaining marks in rounding
431
+ // order, to the finish line) divided by current speed over ground.
432
+ // Accurate only as far as the automatic mark-rounding detection above is;
433
+ // if a boat hasn't been recorded rounding a mark yet, that mark (and
434
+ // everything after it) is still counted as ahead of it. Only offered when
435
+ // there's a finish line, a live position, and a non-trivial speed.
436
+ function estimateFinish(race, boat) {
437
+ if (!race.startTime || boat.finishTime || boat.dnf || race.stopTime) return null;
438
+ if (!race.course || !race.course.finishLine) return null;
439
+ const live = getLivePosition(boat.mmsi);
440
+ if (!live || live.sogMs == null || live.sogMs < 0.25) return null;
441
+ const remaining = remainingCourseDistanceNm(race, boat, live);
442
+ if (remaining == null) return null;
443
+ const sogKn = live.sogMs * MS_TO_KNOTS;
444
+ const hoursRemaining = remaining.nm / sogKn;
445
+ const estFinishTime = Date.now() + hoursRemaining * 3600 * 1000;
446
+ const estElapsedMs = estFinishTime - race.startTime;
447
+ const tcf = boat.tcf != null ? boat.tcf : 1.0;
448
+ return {
449
+ remainingNm: Math.round(remaining.nm * 100) / 100,
450
+ marksRemaining: remaining.marksRemaining,
451
+ sogKn: Math.round(sogKn * 10) / 10,
452
+ estFinishTime,
453
+ estElapsedMs,
454
+ estCorrectedMs: estElapsedMs * tcf
455
+ };
456
+ }
457
+
458
+ // Attaches a live `estimate` to each unfinished boat without mutating the
459
+ // stored race — it's derived from live data, never persisted.
460
+ function raceWithEstimates(race) {
461
+ const out = JSON.parse(JSON.stringify(race));
462
+ Object.values(out.boats).forEach((b) => {
463
+ b.estimate = estimateFinish(race, race.boats[b.id]);
464
+ });
465
+ return out;
466
+ }
467
+
468
+ // Same ranking the webapp's table uses (finished boats by real corrected
469
+ // time, still-racing boats by projected corrected time when available,
470
+ // DNF/unrankable boats last) — shared by the Excel export so its row
471
+ // order and the on-screen order always agree.
472
+ function rankedBoatList(race) {
473
+ const now = race.stopTime || Date.now();
474
+ return Object.values(race.boats)
475
+ .map((boat) => {
476
+ const elapsedMs = !boat.dnf && race.startTime ? (boat.finishTime || now) - race.startTime : null;
477
+ const tcf = boat.tcf != null ? boat.tcf : 1.0;
478
+ const correctedMs = elapsedMs != null ? elapsedMs * tcf : null;
479
+ const estimate = estimateFinish(race, boat);
480
+ const rankMs = boat.dnf ? null : boat.finishTime ? correctedMs : estimate ? estimate.estCorrectedMs : correctedMs;
481
+ return { boat, elapsedMs, correctedMs, estimate, rankMs };
482
+ })
483
+ .sort((a, b) => {
484
+ if (a.rankMs == null && b.rankMs == null) return a.boat.name.localeCompare(b.boat.name);
485
+ if (a.rankMs == null) return 1;
486
+ if (b.rankMs == null) return -1;
487
+ return a.rankMs - b.rankMs;
488
+ });
489
+ }
490
+
491
+ function waypointResource(name, pt) {
492
+ return {
493
+ name,
494
+ feature: {
495
+ type: 'Feature',
496
+ geometry: { type: 'Point', coordinates: [pt.lon, pt.lat] },
497
+ properties: {}
498
+ }
499
+ };
500
+ }
501
+
502
+ function routeResource(name, points) {
503
+ return {
504
+ name,
505
+ feature: {
506
+ type: 'Feature',
507
+ geometry: { type: 'LineString', coordinates: points.map((p) => [p.lon, p.lat]) },
508
+ properties: {}
509
+ }
510
+ };
511
+ }
512
+
513
+ // Best-effort: publishes the course as SignalK waypoint/route resources so
514
+ // any chart plotter (freeboard-sk etc.) that reads the standard resources
515
+ // API shows it overlaid on a real chart. Silently no-ops if this server
516
+ // has no resources provider registered — core race-control features never
517
+ // depend on this succeeding.
518
+ async function publishCourseResources(race) {
519
+ if (!app.resourcesApi || typeof app.resourcesApi.setResource !== 'function') return;
520
+ try {
521
+ const points = [];
522
+ const put = (id, name, pt) => app.resourcesApi.setResource('waypoints', id, waypointResource(name, pt));
523
+ if (race.course.startLine) {
524
+ await put(`race-${race.id}-start-pin`, `${race.name} — Start (pin)`, race.course.startLine[0]);
525
+ await put(`race-${race.id}-start-committee`, `${race.name} — Start (committee)`, race.course.startLine[1]);
526
+ points.push(race.course.startLine[0], race.course.startLine[1]);
527
+ }
528
+ for (const m of race.course.marks) {
529
+ await put(`race-${race.id}-mark-${m.id}`, `${race.name} — ${m.name}`, m);
530
+ points.push(m);
531
+ }
532
+ if (race.course.finishLine) {
533
+ await put(`race-${race.id}-finish-pin`, `${race.name} — Finish (pin)`, race.course.finishLine[0]);
534
+ await put(`race-${race.id}-finish-committee`, `${race.name} — Finish (committee)`, race.course.finishLine[1]);
535
+ points.push(race.course.finishLine[0], race.course.finishLine[1]);
536
+ }
537
+ if (points.length >= 2) {
538
+ await app.resourcesApi.setResource('routes', `race-${race.id}-course`, routeResource(race.name, points));
539
+ }
540
+ } catch (e) {
541
+ app.debug('race-control: could not publish course to SignalK resources: ' + e.message);
542
+ }
543
+ }
544
+
545
+ // Every live (started, not all finished) race's unfinished boats get a
546
+ // position sample appended periodically — the recorded trail is what the
547
+ // webapp's course/replay chart draws.
548
+ function recordTrackSample() {
549
+ let changed = false;
550
+ Object.values(state.races).forEach((race) => {
551
+ if (!race.startTime || race.stopTime) return;
552
+ Object.values(race.boats).forEach((boat) => {
553
+ if (boat.finishTime || boat.dnf || !boat.mmsi) return;
554
+ const live = getLivePosition(boat.mmsi);
555
+ if (!live) return;
556
+ if (!boat.track) boat.track = [];
557
+ boat.track.push({ t: Date.now(), lat: live.lat, lon: live.lon });
558
+ if (boat.track.length > 2000) boat.track.shift();
559
+ changed = true;
560
+ });
561
+ });
562
+ if (changed) saveState();
563
+ }
564
+
565
+ function disarmSchedule(raceId) {
566
+ const t = scheduleTimers.get(raceId);
567
+ if (t) {
568
+ clearTimeout(t);
569
+ scheduleTimers.delete(raceId);
570
+ }
571
+ }
572
+
573
+ function doStart(race, atTime) {
574
+ disarmSchedule(race.id);
575
+ disarmCallOffSchedule(race.id);
576
+ race.startTime = atTime;
577
+ race.scheduledStart = null;
578
+ race.stopTime = null;
579
+ race.scheduledCallOff = null;
580
+ Object.values(race.boats).forEach((b) => {
581
+ b.finishTime = null;
582
+ b.track = [];
583
+ b.dnf = false;
584
+ b.dnfPosition = null;
585
+ });
586
+ saveState();
587
+ }
588
+
589
+ // Re-arms (or clears) the timer that auto-starts a race at its
590
+ // scheduledStart. Called on every state change that touches
591
+ // scheduledStart, and once per race at plugin startup so a schedule set
592
+ // before a server restart still fires.
593
+ function armSchedule(race) {
594
+ disarmSchedule(race.id);
595
+ if (race.scheduledStart && !race.startTime) {
596
+ const delay = race.scheduledStart - Date.now();
597
+ if (delay <= 0) {
598
+ doStart(race, race.scheduledStart);
599
+ } else {
600
+ scheduleTimers.set(
601
+ race.id,
602
+ setTimeout(() => doStart(race, race.scheduledStart), delay)
603
+ );
604
+ }
605
+ }
606
+ }
607
+
608
+ function disarmCallOffSchedule(raceId) {
609
+ const t = callOffTimers.get(raceId);
610
+ if (t) {
611
+ clearTimeout(t);
612
+ callOffTimers.delete(raceId);
613
+ }
614
+ }
615
+
616
+ // Calling off a race (whether by clicking Stop or via a scheduled
617
+ // call-off firing) freezes the clock and marks every boat that hasn't
618
+ // finished as DNF, capturing its last known position if one's available —
619
+ // finished boats are left alone.
620
+ function doStop(race, atTime) {
621
+ disarmCallOffSchedule(race.id);
622
+ race.stopTime = atTime;
623
+ race.scheduledCallOff = null;
624
+ Object.values(race.boats).forEach((boat) => {
625
+ if (!boat.finishTime && !boat.dnf) {
626
+ boat.dnf = true;
627
+ boat.dnfPosition = getLastKnownPosition(boat);
628
+ }
629
+ });
630
+ saveState();
631
+ }
632
+
633
+ // Re-arms (or clears) the timer that auto-calls-off a race at its
634
+ // scheduledCallOff, mirroring armSchedule for the start.
635
+ function armCallOffSchedule(race) {
636
+ disarmCallOffSchedule(race.id);
637
+ if (race.scheduledCallOff && race.startTime && !race.stopTime) {
638
+ const delay = race.scheduledCallOff - Date.now();
639
+ if (delay <= 0) {
640
+ doStop(race, race.scheduledCallOff);
641
+ } else {
642
+ callOffTimers.set(
643
+ race.id,
644
+ setTimeout(() => doStop(race, race.scheduledCallOff), delay)
645
+ );
646
+ }
647
+ }
648
+ }
649
+
650
+ async function fetchHandicapBoats(forceRefresh) {
651
+ const sourceUrl = (plugin.options && plugin.options.handicapSourceUrl) || DEFAULT_HANDICAP_SOURCE_PAGE;
652
+ const age = Date.now() - handicapCache.fetchedAt;
653
+ if (!forceRefresh && handicapCache.sourceUrl === sourceUrl && age < HANDICAP_CACHE_TTL_MS) {
654
+ return handicapCache;
655
+ }
656
+ const csvUrl = await resolveHandicapCsvUrl(sourceUrl);
657
+ const res = await fetch(csvUrl);
658
+ if (!res.ok) {
659
+ throw new Error(`Handicap sheet returned HTTP ${res.status}`);
660
+ }
661
+ const csvText = await res.text();
662
+ const boats = parseHandicapSheet(csvText);
663
+ handicapCache = { fetchedAt: Date.now(), boats, sourceUrl, csvUrl };
664
+ return handicapCache;
665
+ }
666
+
667
+ let trackTimer = null;
668
+
669
+ plugin.start = function (options) {
670
+ plugin.options = options || {};
671
+ dataFile = path.join(app.getDataDirPath(), 'race-state.json');
672
+ loadState();
673
+ Object.values(state.races).forEach((race) => {
674
+ ensureRaceShape(race);
675
+ armSchedule(race);
676
+ armCallOffSchedule(race);
677
+ });
678
+ trackTimer = setInterval(recordTrackSample, 15000);
679
+ app.setPluginStatus('Race control ready');
680
+ };
681
+
682
+ plugin.stop = function () {
683
+ scheduleTimers.forEach((t) => clearTimeout(t));
684
+ scheduleTimers.clear();
685
+ callOffTimers.forEach((t) => clearTimeout(t));
686
+ callOffTimers.clear();
687
+ if (trackTimer) {
688
+ clearInterval(trackTimer);
689
+ trackTimer = null;
690
+ }
691
+ };
692
+
693
+ plugin.registerWithRouter = function (router) {
694
+ router.get('/races', (req, res) => {
695
+ const races = state.order.map((id) => raceSummary(state.races[id])).filter(Boolean);
696
+ res.json({ currentRaceId: state.currentRaceId, races });
697
+ });
698
+
699
+ router.post('/races', (req, res) => {
700
+ const name = ((req.body && req.body.name) || '').trim();
701
+ if (!name) {
702
+ return res.status(400).json({ error: 'name is required' });
703
+ }
704
+ const scheduledStart = req.body && req.body.scheduledStart != null ? Number(req.body.scheduledStart) : null;
705
+ const race = {
706
+ id: makeRaceId(),
707
+ name,
708
+ createdAt: Date.now(),
709
+ scheduledStart: isFinite(scheduledStart) ? scheduledStart : null,
710
+ startTime: null,
711
+ stopTime: null,
712
+ scheduledCallOff: null,
713
+ boats: {},
714
+ course: emptyCourse(),
715
+ selfBoatId: null
716
+ };
717
+ state.races[race.id] = race;
718
+ state.order.push(race.id);
719
+ state.currentRaceId = race.id;
720
+ saveState();
721
+ armSchedule(race);
722
+ res.json({ currentRaceId: state.currentRaceId, race });
723
+ });
724
+
725
+ router.get('/races/:id', (req, res) => {
726
+ const race = getRace(req.params.id);
727
+ if (!race) return res.status(404).json({ error: 'No such race' });
728
+ ensureRaceShape(race);
729
+ res.json(raceWithEstimates(race));
730
+ });
731
+
732
+ // A snapshot of the current standings as a downloadable .xlsx — same
733
+ // rows, same order as the webapp's table. ?tzOffsetMinutes=<n> should be
734
+ // the exporting browser's own Date.prototype.getTimezoneOffset(), so
735
+ // the Finish Time column reads in their local time, not the server's.
736
+ router.get('/races/:id/export.xlsx', async (req, res) => {
737
+ const race = getRace(req.params.id);
738
+ if (!race) return res.status(404).json({ error: 'No such race' });
739
+ ensureRaceShape(race);
740
+ const tzParsed = Number(req.query.tzOffsetMinutes);
741
+ const tz = isFinite(tzParsed) ? tzParsed : new Date().getTimezoneOffset();
742
+
743
+ const ranked = rankedBoatList(race);
744
+ const headers = ['Rank', 'Boat', 'MMSI', 'TCF', 'Elapsed', 'Corrected', 'Finish Time', 'Status'];
745
+
746
+ const workbook = new ExcelJS.Workbook();
747
+ workbook.creator = 'Race Control';
748
+ workbook.created = new Date();
749
+ const sheet = workbook.addWorksheet('Results');
750
+
751
+ const titleRow = sheet.addRow([race.name]);
752
+ sheet.mergeCells(titleRow.number, 1, titleRow.number, headers.length);
753
+ titleRow.getCell(1).font = { bold: true, size: 14 };
754
+
755
+ const statusLine = race.stopTime
756
+ ? `Called off ${formatLocalDateTime(race.stopTime, tz)}`
757
+ : race.startTime
758
+ ? `Started ${formatLocalDateTime(race.startTime, tz)}`
759
+ : 'Not started';
760
+ const infoRow = sheet.addRow([statusLine]);
761
+ sheet.mergeCells(infoRow.number, 1, infoRow.number, headers.length);
762
+ infoRow.getCell(1).font = { italic: true, color: { argb: 'FF64748B' } };
763
+
764
+ sheet.addRow([]);
765
+
766
+ const headerRow = sheet.addRow(headers);
767
+ headerRow.font = { bold: true };
768
+ headerRow.eachCell((cell) => {
769
+ cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
770
+ });
771
+
772
+ ranked.forEach((r, i) => {
773
+ const status = r.boat.dnf ? 'DNF' : r.boat.finishTime ? 'Finished' : race.startTime ? 'Racing' : 'Not started';
774
+ const rankLabel = r.boat.dnf ? 'DNF' : r.rankMs != null ? i + 1 : '';
775
+ sheet.addRow([
776
+ rankLabel,
777
+ r.boat.name,
778
+ r.boat.mmsi || '',
779
+ r.boat.tcf,
780
+ formatDurationHms(r.elapsedMs),
781
+ formatDurationHms(r.correctedMs),
782
+ r.boat.finishTime ? formatLocalTime(r.boat.finishTime, tz) : '',
783
+ status
784
+ ]);
785
+ });
786
+
787
+ const widths = [7, 24, 12, 8, 12, 12, 12, 12];
788
+ widths.forEach((w, i) => {
789
+ sheet.getColumn(i + 1).width = w;
790
+ });
791
+
792
+ const safeName = (race.name || 'race').replace(/[^a-z0-9\-_]+/gi, '_').slice(0, 60) || 'race';
793
+ res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
794
+ res.setHeader('Content-Disposition', `attachment; filename="${safeName}-results.xlsx"`);
795
+ try {
796
+ await workbook.xlsx.write(res);
797
+ res.end();
798
+ } catch (e) {
799
+ app.error('race-control: failed to generate xlsx export: ' + e.message);
800
+ if (!res.headersSent) res.status(500).json({ error: 'Could not generate export' });
801
+ }
802
+ });
803
+
804
+ router.post('/races/:id/select', (req, res) => {
805
+ const race = getRace(req.params.id);
806
+ if (!race) return res.status(404).json({ error: 'No such race' });
807
+ state.currentRaceId = race.id;
808
+ saveState();
809
+ res.json({ currentRaceId: state.currentRaceId });
810
+ });
811
+
812
+ router.delete('/races/:id', (req, res) => {
813
+ const race = getRace(req.params.id);
814
+ if (!race) return res.status(404).json({ error: 'No such race' });
815
+ disarmSchedule(race.id);
816
+ delete state.races[race.id];
817
+ state.order = state.order.filter((id) => id !== race.id);
818
+ if (state.currentRaceId === race.id) {
819
+ state.currentRaceId = state.order.length ? state.order[state.order.length - 1] : null;
820
+ }
821
+ saveState();
822
+ res.json({ currentRaceId: state.currentRaceId });
823
+ });
824
+
825
+ router.post('/races/:id/start', (req, res) => {
826
+ const race = getRace(req.params.id);
827
+ if (!race) return res.status(404).json({ error: 'No such race' });
828
+ doStart(race, Date.now());
829
+ res.json(raceWithEstimates(race));
830
+ });
831
+
832
+ router.post('/races/:id/schedule', (req, res) => {
833
+ const race = getRace(req.params.id);
834
+ if (!race) return res.status(404).json({ error: 'No such race' });
835
+ if (race.startTime) {
836
+ return res.status(400).json({ error: 'Race has already started' });
837
+ }
838
+ const t = Number(req.body && req.body.startTime);
839
+ if (!isFinite(t)) {
840
+ return res.status(400).json({ error: 'startTime must be an epoch-millisecond timestamp' });
841
+ }
842
+ race.scheduledStart = t;
843
+ saveState();
844
+ armSchedule(race);
845
+ res.json(raceWithEstimates(race));
846
+ });
847
+
848
+ router.post('/races/:id/schedule/cancel', (req, res) => {
849
+ const race = getRace(req.params.id);
850
+ if (!race) return res.status(404).json({ error: 'No such race' });
851
+ race.scheduledStart = null;
852
+ saveState();
853
+ armSchedule(race);
854
+ res.json(raceWithEstimates(race));
855
+ });
856
+
857
+ router.post('/races/:id/reset', (req, res) => {
858
+ const race = getRace(req.params.id);
859
+ if (!race) return res.status(404).json({ error: 'No such race' });
860
+ disarmSchedule(race.id);
861
+ disarmCallOffSchedule(race.id);
862
+ race.startTime = null;
863
+ race.scheduledStart = null;
864
+ race.stopTime = null;
865
+ race.scheduledCallOff = null;
866
+ Object.values(race.boats).forEach((b) => {
867
+ b.finishTime = null;
868
+ b.track = [];
869
+ b.dnf = false;
870
+ b.dnfPosition = null;
871
+ });
872
+ saveState();
873
+ res.json(raceWithEstimates(race));
874
+ });
875
+
876
+ // Calls off the race right now: the clock freezes (and track recording
877
+ // / finish estimates stop), and every boat that hasn't finished is
878
+ // marked DNF with its last known position — see doStop. Unlike Reset,
879
+ // this doesn't touch boats, finish times, or the course.
880
+ router.post('/races/:id/stop', (req, res) => {
881
+ const race = getRace(req.params.id);
882
+ if (!race) return res.status(404).json({ error: 'No such race' });
883
+ if (!race.startTime) return res.status(400).json({ error: 'Race has not started' });
884
+ if (race.stopTime) return res.status(400).json({ error: 'Race is already stopped' });
885
+ doStop(race, Date.now());
886
+ res.json(raceWithEstimates(race));
887
+ });
888
+
889
+ // Discards the stop — not a true pause/resume. The clock jumps straight
890
+ // back to real elapsed time (startTime to now); the time spent stopped
891
+ // is not excluded from anyone's elapsed/corrected time. Every DNF'd boat
892
+ // is un-DNF'd too, since calling the race back on means it's still live
893
+ // (there's no separate tracking of "DNF'd by hand" vs "DNF'd by Stop").
894
+ router.post('/races/:id/resume', (req, res) => {
895
+ const race = getRace(req.params.id);
896
+ if (!race) return res.status(404).json({ error: 'No such race' });
897
+ if (!race.stopTime) return res.status(400).json({ error: 'Race is not stopped' });
898
+ race.stopTime = null;
899
+ Object.values(race.boats).forEach((boat) => {
900
+ if (boat.dnf) {
901
+ boat.dnf = false;
902
+ boat.dnfPosition = null;
903
+ }
904
+ });
905
+ saveState();
906
+ res.json(raceWithEstimates(race));
907
+ });
908
+
909
+ // Schedules an automatic call-off (same effect as Stop) at a future
910
+ // time — mirrors /schedule for the start.
911
+ router.post('/races/:id/schedule-call-off', (req, res) => {
912
+ const race = getRace(req.params.id);
913
+ if (!race) return res.status(404).json({ error: 'No such race' });
914
+ if (race.stopTime) return res.status(400).json({ error: 'Race is already stopped' });
915
+ const t = Number(req.body && req.body.time);
916
+ if (!isFinite(t)) {
917
+ return res.status(400).json({ error: 'time must be an epoch-millisecond timestamp' });
918
+ }
919
+ race.scheduledCallOff = t;
920
+ saveState();
921
+ armCallOffSchedule(race);
922
+ res.json(raceWithEstimates(race));
923
+ });
924
+
925
+ router.post('/races/:id/schedule-call-off/cancel', (req, res) => {
926
+ const race = getRace(req.params.id);
927
+ if (!race) return res.status(404).json({ error: 'No such race' });
928
+ race.scheduledCallOff = null;
929
+ saveState();
930
+ armCallOffSchedule(race);
931
+ res.json(raceWithEstimates(race));
932
+ });
933
+
934
+ // Marks one boat as "self" so the webapp can show live/finished
935
+ // comparisons (gap, or a countdown-to-catch-up) against every other
936
+ // boat. Pass boatId: null to clear it.
937
+ router.put('/races/:id/self', (req, res) => {
938
+ const race = getRace(req.params.id);
939
+ if (!race) return res.status(404).json({ error: 'No such race' });
940
+ const boatId = (req.body && req.body.boatId) || null;
941
+ if (boatId && !getBoat(race, boatId)) return res.status(400).json({ error: 'No such boat' });
942
+ race.selfBoatId = boatId;
943
+ saveState();
944
+ res.json(raceWithEstimates(race));
945
+ });
946
+
947
+ // Replaces the whole course in one save (matches the webapp's single
948
+ // "Save Course" form). Pass startLine/finishLine as `null` to clear a
949
+ // line, or a [pointA, pointB] pair to set one; marks is the full
950
+ // ordered replacement list.
951
+ router.put('/races/:id/course', (req, res) => {
952
+ const race = getRace(req.params.id);
953
+ if (!race) return res.status(404).json({ error: 'No such race' });
954
+ const body = req.body || {};
955
+ ensureRaceShape(race);
956
+
957
+ let startLine = race.course.startLine;
958
+ if ('startLine' in body) {
959
+ const v = validateLine(body.startLine);
960
+ if (v === undefined) return res.status(400).json({ error: 'startLine must be null or [pointA, pointB]' });
961
+ startLine = v;
962
+ }
963
+ let finishLine = race.course.finishLine;
964
+ if ('finishLine' in body) {
965
+ const v = validateLine(body.finishLine);
966
+ if (v === undefined) return res.status(400).json({ error: 'finishLine must be null or [pointA, pointB]' });
967
+ finishLine = v;
968
+ }
969
+ let marks = race.course.marks;
970
+ if ('marks' in body) {
971
+ if (!Array.isArray(body.marks)) return res.status(400).json({ error: 'marks must be an array' });
972
+ const validated = [];
973
+ for (let i = 0; i < body.marks.length; i++) {
974
+ const p = validateCoordPoint(body.marks[i]);
975
+ if (!p) return res.status(400).json({ error: `mark ${i + 1} has an invalid lat/lon` });
976
+ validated.push({ id: (body.marks[i].id || `m${i}`).toString(), name: p.name || `Mark ${i + 1}`, lat: p.lat, lon: p.lon });
977
+ }
978
+ marks = validated;
979
+ }
980
+
981
+ race.course = { startLine, marks, finishLine };
982
+ saveState();
983
+ publishCourseResources(race).catch(() => {});
984
+ res.json(raceWithEstimates(race));
985
+ });
986
+
987
+ // Boats are entered explicitly per race (not auto-populated from AIS) so
988
+ // the committee can add/remove exactly who's racing; the webapp still
989
+ // offers AIS and VET-register names as autocomplete suggestions when
990
+ // adding one.
991
+ router.post('/races/:id/boats', (req, res) => {
992
+ const race = getRace(req.params.id);
993
+ if (!race) return res.status(404).json({ error: 'No such race' });
994
+ const name = ((req.body && req.body.name) || '').trim();
995
+ if (!name) {
996
+ return res.status(400).json({ error: 'name is required' });
997
+ }
998
+ const givenMmsi = ((req.body && req.body.mmsi) || '').toString().trim();
999
+ const mmsi = givenMmsi || lookupBoatRegistry(name);
1000
+ if (mmsi) upsertBoatRegistry(name, { mmsi });
1001
+ const defaultTcf = (plugin.options && plugin.options.defaultTcf) || 1.0;
1002
+ // A remembered TCF only applies to boats outside VET — a VET-matched
1003
+ // boat should be picked fresh from the register's own dropdown rather
1004
+ // than silently carrying over a number from wherever it last raced.
1005
+ let tcf = defaultTcf;
1006
+ if (!isVetMatch(name)) {
1007
+ const remembered = getRegistryEntry(name);
1008
+ if (remembered && remembered.tcf != null) tcf = remembered.tcf;
1009
+ }
1010
+ const boat = {
1011
+ id: makeBoatId(),
1012
+ name,
1013
+ mmsi: mmsi || null,
1014
+ tcf,
1015
+ finishTime: null,
1016
+ track: [],
1017
+ dnf: false,
1018
+ dnfPosition: null
1019
+ };
1020
+ race.boats[boat.id] = boat;
1021
+ saveState();
1022
+ res.json(boat);
1023
+ });
1024
+
1025
+ router.delete('/races/:id/boats/:boatId', (req, res) => {
1026
+ const race = getRace(req.params.id);
1027
+ if (!race) return res.status(404).json({ error: 'No such race' });
1028
+ if (!getBoat(race, req.params.boatId)) return res.status(404).json({ error: 'No such boat' });
1029
+ delete race.boats[req.params.boatId];
1030
+ if (race.selfBoatId === req.params.boatId) race.selfBoatId = null;
1031
+ saveState();
1032
+ res.json({ ok: true });
1033
+ });
1034
+
1035
+ // Sets (or, with finishTime: null, clears) a boat's finish time to an
1036
+ // arbitrary timestamp, so race committee can correct a mistimed click.
1037
+ router.put('/races/:id/boats/:boatId/finishTime', (req, res) => {
1038
+ const race = getRace(req.params.id);
1039
+ if (!race) return res.status(404).json({ error: 'No such race' });
1040
+ const boat = getBoat(race, req.params.boatId);
1041
+ if (!boat) return res.status(404).json({ error: 'No such boat' });
1042
+ const raw = req.body ? req.body.finishTime : undefined;
1043
+ if (raw === null) {
1044
+ boat.finishTime = null;
1045
+ } else {
1046
+ const t = Number(raw);
1047
+ if (!isFinite(t) || t <= 0) {
1048
+ return res.status(400).json({ error: 'finishTime must be an epoch-millisecond timestamp or null' });
1049
+ }
1050
+ boat.finishTime = t;
1051
+ // A real finish supersedes a DNF (e.g. correcting a call-off that
1052
+ // caught a boat that had actually already crossed the line).
1053
+ boat.dnf = false;
1054
+ boat.dnfPosition = null;
1055
+ }
1056
+ saveState();
1057
+ res.json(boat);
1058
+ });
1059
+
1060
+ // Marks (or, with dnf: false, un-marks) one boat DNF by hand, outside of
1061
+ // a full race call-off — e.g. a boat retires mid-race. Setting it also
1062
+ // clears any finish time and captures the boat's last known position.
1063
+ router.put('/races/:id/boats/:boatId/dnf', (req, res) => {
1064
+ const race = getRace(req.params.id);
1065
+ if (!race) return res.status(404).json({ error: 'No such race' });
1066
+ const boat = getBoat(race, req.params.boatId);
1067
+ if (!boat) return res.status(404).json({ error: 'No such boat' });
1068
+ const dnf = !!(req.body && req.body.dnf);
1069
+ if (dnf) {
1070
+ boat.dnf = true;
1071
+ boat.finishTime = null;
1072
+ boat.dnfPosition = getLastKnownPosition(boat);
1073
+ } else {
1074
+ boat.dnf = false;
1075
+ boat.dnfPosition = null;
1076
+ }
1077
+ saveState();
1078
+ res.json(boat);
1079
+ });
1080
+
1081
+ router.put('/races/:id/boats/:boatId/mmsi', (req, res) => {
1082
+ const race = getRace(req.params.id);
1083
+ if (!race) return res.status(404).json({ error: 'No such race' });
1084
+ const boat = getBoat(race, req.params.boatId);
1085
+ if (!boat) return res.status(404).json({ error: 'No such boat' });
1086
+ const mmsi = ((req.body && req.body.mmsi) || '').toString().trim();
1087
+ boat.mmsi = mmsi || null;
1088
+ if (boat.mmsi) upsertBoatRegistry(boat.name, { mmsi: boat.mmsi });
1089
+ saveState();
1090
+ res.json(boat);
1091
+ });
1092
+
1093
+ router.get('/boat-registry', (req, res) => {
1094
+ res.json({ boats: Object.values(state.boatRegistry) });
1095
+ });
1096
+
1097
+ // Plugin config setting (Server -> Plugin Config), not per-race and not
1098
+ // writable from the webapp — the webapp only reads it to know whether to
1099
+ // offer the VET-tall register at all.
1100
+ router.get('/vet-enabled', (req, res) => {
1101
+ res.json({ enabled: isVetEnabled() });
1102
+ });
1103
+
1104
+ router.put('/races/:id/boats/:boatId/tcf', (req, res) => {
1105
+ const race = getRace(req.params.id);
1106
+ if (!race) return res.status(404).json({ error: 'No such race' });
1107
+ const boat = getBoat(race, req.params.boatId);
1108
+ if (!boat) return res.status(404).json({ error: 'No such boat' });
1109
+ const tcf = Number(req.body && req.body.tcf);
1110
+ if (!isFinite(tcf) || tcf <= 0) {
1111
+ return res.status(400).json({ error: 'tcf must be a positive number' });
1112
+ }
1113
+ boat.tcf = tcf;
1114
+ // Remembered across races only for boats outside VET — see the note
1115
+ // on upsertBoatRegistry/isVetMatch above.
1116
+ if (!isVetMatch(boat.name)) upsertBoatRegistry(boat.name, { tcf });
1117
+ saveState();
1118
+ res.json(boat);
1119
+ });
1120
+
1121
+ router.get('/handicap-source', async (req, res) => {
1122
+ try {
1123
+ const force = req.query.refresh === 'true' || req.query.refresh === '1';
1124
+ const data = await fetchHandicapBoats(force);
1125
+ res.json(data);
1126
+ } catch (e) {
1127
+ res.status(502).json({ error: 'Could not load handicap register: ' + e.message });
1128
+ }
1129
+ });
1130
+ };
1131
+
1132
+ return plugin;
1133
+ };