signalk-race-control 0.12.7 → 0.12.9

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/CHANGELOG.md ADDED
@@ -0,0 +1,120 @@
1
+ # Changelog
2
+
3
+ All notable changes to `signalk-race-control`, newest first.
4
+
5
+ ## 0.12.9 (2026-10-13)
6
+ - Patch `brace-expansion` (via `exceljs`) to fix the npm audit finding
7
+ - Fix the MFD tile showing "No active race" over a live race (its stylesheet ignored the `hidden` attribute)
8
+ - Add this changelog and App Store screenshots
9
+
10
+ ## 0.12.8 (2026-10-12)
11
+ - Split the Excel export by class
12
+
13
+ ## 0.12.7 (2026-10-11)
14
+ - Allow editing class start times before the race starts
15
+
16
+ ## 0.12.6 (2026-09-20)
17
+ - Add read-only MFD tile webapp for signalk-mfd-plugin
18
+
19
+ ## 0.12.5 (2026-09-20)
20
+ - Add optional buddy-list registration when adding a boat
21
+
22
+ ## 0.12.4 (2026-09-20)
23
+ - Fix webapp icon resolution via signalk.appIcon
24
+
25
+ ## 0.12.3 (2026-09-20)
26
+ - Add a favicon: a racing mark with a clock hand
27
+
28
+ ## 0.12.2 (2026-09-19)
29
+ - Repopulate the schedule input/button from the actual saved schedule
30
+
31
+ ## 0.12.1 (2026-09-18)
32
+ - Fix App Store audit warnings: repository field, uuid override
33
+ - Add a plugin-level test suite
34
+
35
+ ## 0.12.0 (2026-09-16)
36
+ - Auto-activate the course as a SignalK route on start-line crossing
37
+ - Add 10-minute laylines for self on the course chart
38
+ - Add a lift/header wind-shift instrument for self
39
+
40
+ ## 0.11.1 (2026-09-16)
41
+ - Fix OpenSeaMap autosuggest/snap never finding real data
42
+
43
+ ## 0.11.0 (2026-09-16)
44
+ - Let the chart fill the page width, without trapping page scroll
45
+ - Add a recenter button to the chart
46
+ - Reuse a chosen OpenSeaMap mark instantly in other course-point rows
47
+ - Scope the recenter button to just the race's own course
48
+ - Fall back to self position when recentering with no course set
49
+ - Schedule Start/Call-off only need a date on multi-day races
50
+ - Revert Schedule Start to always need a date
51
+ - Add classes for staggered/pursuit starts
52
+ - Fix buoy name suggestion losing to a generic reverse-geocode guess
53
+ - Fix map-pick feedback stalling on a slow Overpass lookup
54
+ - Cache OpenSeaMap nav marks per chart area
55
+ - Reorder course sections and turn course points into tables
56
+ - Collapse course positions once a course is saved
57
+
58
+ ## 0.10.1 (2026-09-15)
59
+ - Fix scheduled start/call-off firing immediately when set far enough ahead
60
+
61
+ ## 0.10.0 (2026-09-15)
62
+ - Fix multiple registered charts stacking instead of switching
63
+ - Show a boat's inherited start time instead of blank
64
+ - Autosuggest course point names from the chart area, not just waypoints
65
+ - Source the course-point name autosuggest from OpenSeaMap, not general OSM
66
+ - Try multiple Overpass mirrors, and ask to snap a map click to a nearby mark
67
+ - Default the chart to Eniro's plain map, not whichever loaded first
68
+
69
+ ## 0.9.0 (2026-09-13)
70
+ - Add click-to-place course points on the map, fix unreadable mark dropdown
71
+ - Merge course-point buttons into one, suggest names from OpenStreetMap
72
+
73
+ ## 0.8.0 (2026-09-13)
74
+ - Fix Add Boat not responding to Enter in the offline export
75
+ - Mark speed pickups on the course chart
76
+ - Draw speed-pickup rings in each boat's own trace color
77
+ - Add a real background chart to the course/replay map
78
+
79
+ ## 0.7.0 (2026-09-08)
80
+ - Auto-stop a complete race, and add a DNS status
81
+ - Make Excel export use live formulas, reorder time columns
82
+ - Sort DNS boats to the end and allow marking DNS before race start
83
+ - Sort DNS boats last in the offline export template too
84
+
85
+ ## 0.6.0 (2026-09-07)
86
+ - Add KTK's KLR handicap register alongside VET-tall
87
+
88
+ ## 0.5.1 (2026-09-04)
89
+ - Stop the boats table from resizing/reshuffling confusingly
90
+
91
+ ## 0.5.0 (2026-09-03)
92
+ - Add a start timer view with distance/ETA/burn time to the line
93
+
94
+ ## 0.4.3 (2026-09-03)
95
+ - Let course points autocomplete against existing SignalK waypoints
96
+ - Make mark-rounding radius configurable and add manual mark rounding
97
+ - Record positions for manual mark roundings and improve chart replay
98
+ - Add author to package.json
99
+
100
+ ## 0.4.2 (2026-09-02)
101
+ - Remember sail number across races, like MMSI
102
+
103
+ ## 0.4.1 (2026-08-30)
104
+ - Make the offline export installable as a standalone iOS app
105
+
106
+ ## 0.4.0 (2026-08-30)
107
+ - Fetch VET-tall handicaps live in the offline export
108
+
109
+ ## 0.3.0 (2026-08-20)
110
+ - Add Manage2Sail fleet import with handicap-system conversion
111
+
112
+ ## 0.2.0 (2026-08-20)
113
+ - Add signalk-race-control plugin: race timing, handicaps, course/chart, VET import
114
+ - Add MIT LICENSE file and drop empty repository field
115
+ - Add offline export: standalone backup race timer
116
+ - Add multi-day races and individual per-boat start times
117
+ - Fix ghost row left in the boat table after removing a boat
118
+ - Fall back to the leader when no boat is marked self
119
+ - Clarify the multi-day toggle isn't needed for ordinary overnight races
120
+ - Fix scheduled call-off loss, stale offline exports, and add race-start correction
package/README.md CHANGED
@@ -229,7 +229,9 @@ and a live projected finishing order — during and after the race.
229
229
  finishing instantly), or the current corrected-time gap if both are still racing or
230
230
  both have finished. With no boat marked self, the column instead compares everyone
231
231
  against the current **leader** (tagged accordingly), so it's never just blank.
232
- - **Export to Excel** — a genuine `.xlsx` snapshot of the current standings (same
232
+ - **Export to Excel** — a genuine `.xlsx` snapshot of the current standings, with one
233
+ worksheet per class (each ranked on its own; boats with no class go on a "No class"
234
+ sheet, and a race with no classes gets a single "Results" sheet) (same
233
235
  ranking, same rows as the on-screen table: rank, boat, sail number, MMSI, TCF, start
234
236
  time, finish time, elapsed, corrected, status), with the time columns rendered in
235
237
  your browser's own timezone rather than the server's (and with the date included,
package/index.js CHANGED
@@ -2400,28 +2400,12 @@ module.exports = function (app) {
2400
2400
  const workbook = new ExcelJS.Workbook();
2401
2401
  workbook.creator = 'Race Control';
2402
2402
  workbook.created = new Date();
2403
- const sheet = workbook.addWorksheet('Results');
2404
-
2405
- const titleRow = sheet.addRow([race.name]);
2406
- sheet.mergeCells(titleRow.number, 1, titleRow.number, headers.length);
2407
- titleRow.getCell(1).font = { bold: true, size: 14 };
2408
2403
 
2409
2404
  const statusLine = race.stopTime
2410
2405
  ? `Called off ${formatLocalDateTime(race.stopTime, tz)}`
2411
2406
  : race.startTime
2412
2407
  ? `Started ${formatLocalDateTime(race.startTime, tz)}`
2413
2408
  : 'Not started';
2414
- const infoRow = sheet.addRow([statusLine]);
2415
- sheet.mergeCells(infoRow.number, 1, infoRow.number, headers.length);
2416
- infoRow.getCell(1).font = { italic: true, color: { argb: 'FF64748B' } };
2417
-
2418
- sheet.addRow([]);
2419
-
2420
- const headerRow = sheet.addRow(headers);
2421
- headerRow.font = { bold: true };
2422
- headerRow.eachCell((cell) => {
2423
- cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
2424
- });
2425
2409
 
2426
2410
  // Real Excel date/time values in Start/Finish (shifted the same way
2427
2411
  // formatLocalDateTime above does, to display as the exporting
@@ -2435,43 +2419,92 @@ module.exports = function (app) {
2435
2419
  // that would just sit there, wrong, once the file is reopened later.
2436
2420
  const dateNumFmt = race.multiDay ? 'yyyy-mm-dd hh:mm:ss' : 'hh:mm:ss';
2437
2421
  const durationNumFmt = '[h]:mm:ss';
2438
- ranked.forEach((r, i) => {
2439
- const status = r.boat.dns
2440
- ? 'DNS'
2441
- : r.boat.dnf
2442
- ? 'DNF'
2443
- : r.boat.finishTime
2444
- ? 'Finished'
2445
- : race.startTime
2446
- ? 'Racing'
2447
- : 'Not started';
2448
- const rankLabel = r.boat.dns ? 'DNS' : r.boat.dnf ? 'DNF' : r.rankMs != null ? i + 1 : '';
2449
- const start = effectiveStartTime(race, r.boat);
2450
- const startDate = start != null ? new Date(start - tz * 60000) : null;
2451
- const finishDate = r.boat.finishTime != null ? new Date(r.boat.finishTime - tz * 60000) : null;
2452
- const rowNum = headerRow.number + 1 + i;
2453
- const row = sheet.addRow([
2454
- rankLabel,
2455
- r.boat.name,
2456
- r.boat.sailNumber || '',
2457
- r.boat.mmsi || '',
2458
- r.boat.tcf,
2459
- startDate,
2460
- finishDate,
2461
- { formula: `IF(OR(F${rowNum}="",G${rowNum}=""),"",G${rowNum}-F${rowNum})` },
2462
- { formula: `IF(H${rowNum}="","",H${rowNum}*E${rowNum})` },
2463
- status
2464
- ]);
2465
- row.getCell(6).numFmt = dateNumFmt;
2466
- row.getCell(7).numFmt = dateNumFmt;
2467
- row.getCell(8).numFmt = durationNumFmt;
2468
- row.getCell(9).numFmt = durationNumFmt;
2469
- });
2470
2422
 
2471
- const widths = [7, 24, 12, 12, 8, race.multiDay ? 17 : 12, race.multiDay ? 17 : 12, 12, 12, 12];
2472
- widths.forEach((w, i) => {
2473
- sheet.getColumn(i + 1).width = w;
2474
- });
2423
+ // One worksheet per class, each ranked on its own (the global order
2424
+ // from rankedBoatList is preserved within a class, so DNF/DNS/unranked
2425
+ // boats still trail). A race with no classes keeps the single
2426
+ // "Results" sheet.
2427
+ const usedSheetNames = new Set();
2428
+ const uniqueSheetName = (raw) => {
2429
+ // Excel sheet names: max 31 chars, none of : \ / ? * [ ], unique.
2430
+ const base = (String(raw).replace(/[:\\/?*\[\]]/g, ' ').replace(/\s+/g, ' ').trim() || 'Class').slice(0, 31);
2431
+ let name = base;
2432
+ for (let n = 2; usedSheetNames.has(name.toLowerCase()); n++) {
2433
+ const suffix = ` (${n})`;
2434
+ name = base.slice(0, 31 - suffix.length) + suffix;
2435
+ }
2436
+ usedSheetNames.add(name.toLowerCase());
2437
+ return name;
2438
+ };
2439
+
2440
+ const addResultsSheet = (sheetName, title, entries) => {
2441
+ const sheet = workbook.addWorksheet(uniqueSheetName(sheetName));
2442
+
2443
+ const titleRow = sheet.addRow([title]);
2444
+ sheet.mergeCells(titleRow.number, 1, titleRow.number, headers.length);
2445
+ titleRow.getCell(1).font = { bold: true, size: 14 };
2446
+
2447
+ const infoRow = sheet.addRow([statusLine]);
2448
+ sheet.mergeCells(infoRow.number, 1, infoRow.number, headers.length);
2449
+ infoRow.getCell(1).font = { italic: true, color: { argb: 'FF64748B' } };
2450
+
2451
+ sheet.addRow([]);
2452
+
2453
+ const headerRow = sheet.addRow(headers);
2454
+ headerRow.font = { bold: true };
2455
+ headerRow.eachCell((cell) => {
2456
+ cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
2457
+ });
2458
+
2459
+ entries.forEach((r, i) => {
2460
+ const status = r.boat.dns
2461
+ ? 'DNS'
2462
+ : r.boat.dnf
2463
+ ? 'DNF'
2464
+ : r.boat.finishTime
2465
+ ? 'Finished'
2466
+ : race.startTime
2467
+ ? 'Racing'
2468
+ : 'Not started';
2469
+ const rankLabel = r.boat.dns ? 'DNS' : r.boat.dnf ? 'DNF' : r.rankMs != null ? i + 1 : '';
2470
+ const start = effectiveStartTime(race, r.boat);
2471
+ const startDate = start != null ? new Date(start - tz * 60000) : null;
2472
+ const finishDate = r.boat.finishTime != null ? new Date(r.boat.finishTime - tz * 60000) : null;
2473
+ const rowNum = headerRow.number + 1 + i;
2474
+ const row = sheet.addRow([
2475
+ rankLabel,
2476
+ r.boat.name,
2477
+ r.boat.sailNumber || '',
2478
+ r.boat.mmsi || '',
2479
+ r.boat.tcf,
2480
+ startDate,
2481
+ finishDate,
2482
+ { formula: `IF(OR(F${rowNum}="",G${rowNum}=""),"",G${rowNum}-F${rowNum})` },
2483
+ { formula: `IF(H${rowNum}="","",H${rowNum}*E${rowNum})` },
2484
+ status
2485
+ ]);
2486
+ row.getCell(6).numFmt = dateNumFmt;
2487
+ row.getCell(7).numFmt = dateNumFmt;
2488
+ row.getCell(8).numFmt = durationNumFmt;
2489
+ row.getCell(9).numFmt = durationNumFmt;
2490
+ });
2491
+
2492
+ const widths = [7, 24, 12, 12, 8, race.multiDay ? 17 : 12, race.multiDay ? 17 : 12, 12, 12, 12];
2493
+ widths.forEach((w, i) => {
2494
+ sheet.getColumn(i + 1).width = w;
2495
+ });
2496
+ };
2497
+
2498
+ const classes = race.classes || [];
2499
+ if (!classes.length) {
2500
+ addResultsSheet('Results', race.name, ranked);
2501
+ } else {
2502
+ classes.forEach((cls) => {
2503
+ addResultsSheet(cls.name, `${race.name} — ${cls.name}`, ranked.filter((r) => r.boat.classId === cls.id));
2504
+ });
2505
+ const unclassed = ranked.filter((r) => !r.boat.classId);
2506
+ if (unclassed.length) addResultsSheet('No class', `${race.name} — No class`, unclassed);
2507
+ }
2475
2508
 
2476
2509
  const safeName = (race.name || 'race').replace(/[^a-z0-9\-_]+/gi, '_').slice(0, 60) || 'race';
2477
2510
  res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-race-control",
3
- "version": "0.12.7",
3
+ "version": "0.12.9",
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>",
@@ -9,7 +9,15 @@
9
9
  "url": "https://github.com/joabakk/signalk-race-control.git"
10
10
  },
11
11
  "signalk": {
12
- "appIcon": "favicon.svg"
12
+ "appIcon": "favicon.svg",
13
+ "displayName": "Race Control",
14
+ "screenshots": [
15
+ "screenshots/results.png",
16
+ "screenshots/course-chart.png",
17
+ "screenshots/start-timer.png",
18
+ "screenshots/mfd-leaderboard.png",
19
+ "screenshots/mfd-countdown.png"
20
+ ]
13
21
  },
14
22
  "keywords": [
15
23
  "signalk-node-server-plugin",
@@ -27,6 +35,12 @@
27
35
  "exceljs": "^4.4.0"
28
36
  },
29
37
  "overrides": {
30
- "uuid": "^11.1.1"
38
+ "uuid": "^11.1.1",
39
+ "glob": {
40
+ "brace-expansion": "^1.1.21"
41
+ },
42
+ "readdir-glob": {
43
+ "brace-expansion": "^2.1.7"
44
+ }
31
45
  }
32
46
  }
@@ -17,6 +17,9 @@
17
17
  --bad: #f87171;
18
18
  }
19
19
  * { box-sizing: border-box; }
20
+ /* Elements that set their own `display` would otherwise override the
21
+ `hidden` attribute's UA-stylesheet `display: none`. */
22
+ [hidden] { display: none !important; }
20
23
  html, body {
21
24
  margin: 0;
22
25
  height: 100%;
Binary file
@@ -326,3 +326,83 @@ describe('/overpass proxy — falls back across mirrors', () => {
326
326
  assert.deepEqual(res.body.elements, []);
327
327
  });
328
328
  });
329
+
330
+ describe('GET /races/:id/export.xlsx — split by class', () => {
331
+ const ExcelJS = require('exceljs');
332
+ const { Writable } = require('node:stream');
333
+ let plugin;
334
+ let router;
335
+
336
+ before(() => {
337
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'race-control-test-'));
338
+ plugin = pluginFactory(makeApp(dataDir));
339
+ plugin.start({});
340
+ router = makeRouter();
341
+ plugin.registerWithRouter(router);
342
+ });
343
+
344
+ after(() => {
345
+ plugin.stop();
346
+ });
347
+
348
+ // The export streams a workbook into `res`; collect it and re-read it.
349
+ async function exportWorkbook(raceId) {
350
+ const route = router._routes.find((r) => r.method === 'GET' && r.pattern.test(`/races/${raceId}/export.xlsx`));
351
+ const chunks = [];
352
+ const res = new Writable({
353
+ write(chunk, _enc, cb) {
354
+ chunks.push(Buffer.from(chunk));
355
+ cb();
356
+ }
357
+ });
358
+ res.setHeader = () => {};
359
+ res.status = () => res;
360
+ res.json = () => {};
361
+ const done = new Promise((resolve) => res.on('finish', resolve));
362
+ await route.handler({ params: { id: raceId }, query: { tzOffsetMinutes: '0' }, body: {} }, res);
363
+ await done;
364
+ const wb = new ExcelJS.Workbook();
365
+ await wb.xlsx.load(Buffer.concat(chunks));
366
+ return wb;
367
+ }
368
+
369
+ const boatNames = (sheet) => {
370
+ const names = [];
371
+ sheet.eachRow((row, n) => {
372
+ if (n > 4) names.push(row.getCell(2).value);
373
+ });
374
+ return names;
375
+ };
376
+
377
+ test('a race with no classes exports a single Results sheet', async () => {
378
+ const created = await call(router, 'POST', '/races', { body: { name: 'Plain' } });
379
+ const id = created.body.race.id;
380
+ await call(router, 'POST', `/races/${id}/boats`, { body: { name: 'Alpha' } });
381
+ const wb = await exportWorkbook(id);
382
+ assert.deepEqual(wb.worksheets.map((w) => w.name), ['Results']);
383
+ assert.deepEqual(boatNames(wb.getWorksheet('Results')), ['Alpha']);
384
+ });
385
+
386
+ test('a race with classes exports one sheet per class, ranked within it', async () => {
387
+ const created = await call(router, 'POST', '/races', { body: { name: 'Split' } });
388
+ const id = created.body.race.id;
389
+ const classA = (await call(router, 'POST', `/races/${id}/classes`, { body: { name: 'Cruisers' } })).body.classes[0];
390
+ const classes = (await call(router, 'POST', `/races/${id}/classes`, { body: { name: 'Racers: 1/2' } })).body.classes;
391
+ const classB = classes[1];
392
+ const add = async (name, classId) => {
393
+ const b = (await call(router, 'POST', `/races/${id}/boats`, { body: { name } })).body;
394
+ if (classId) await call(router, 'PUT', `/races/${id}/boats/${b.id}/class`, { body: { classId } });
395
+ };
396
+ await add('A1', classA.id);
397
+ await add('A2', classA.id);
398
+ await add('B1', classB.id);
399
+ await add('Loner', null);
400
+
401
+ const wb = await exportWorkbook(id);
402
+ assert.deepEqual(wb.worksheets.map((w) => w.name), ['Cruisers', 'Racers 1 2', 'No class']);
403
+ assert.deepEqual(boatNames(wb.getWorksheet('Cruisers')).sort(), ['A1', 'A2']);
404
+ assert.deepEqual(boatNames(wb.getWorksheet('Racers 1 2')), ['B1']);
405
+ assert.deepEqual(boatNames(wb.getWorksheet('No class')), ['Loner']);
406
+ assert.match(wb.getWorksheet('Cruisers').getCell(1, 1).value, /Split — Cruisers/);
407
+ });
408
+ });