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 +3 -1
- package/index.js +85 -52
- package/package.json +1 -1
- package/test/plugin.test.js +80 -0
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
|
|
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
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
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.
|
|
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>",
|
package/test/plugin.test.js
CHANGED
|
@@ -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
|
+
});
|