signalk-race-control 0.12.7 → 0.12.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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.8",
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>",
@@ -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
+ });