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 +120 -0
- package/README.md +3 -1
- package/index.js +85 -52
- package/package.json +17 -3
- package/public/mfd-tile.html +3 -0
- package/public/screenshots/course-chart.png +0 -0
- package/public/screenshots/mfd-countdown.png +0 -0
- package/public/screenshots/mfd-leaderboard.png +0 -0
- package/public/screenshots/results.png +0 -0
- package/public/screenshots/start-timer.png +0 -0
- package/test/plugin.test.js +80 -0
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
|
|
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.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
|
}
|
package/public/mfd-tile.html
CHANGED
|
@@ -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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
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
|
+
});
|