@opndev/racecontrol 0.0.1
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/Changes +5 -0
- package/README.md +49 -0
- package/lib/index.mjs +294 -0
- package/package.json +30 -0
package/Changes
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
|
|
3
|
+
|
|
4
|
+
SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
|
|
5
|
+
-->
|
|
6
|
+
|
|
7
|
+
# Welcome to @opndev/racecontrol
|
|
8
|
+
|
|
9
|
+
Race control package
|
|
10
|
+
|
|
11
|
+
This package it tightly related to a project that is not open to the world. You
|
|
12
|
+
may use it at your own discretion.
|
|
13
|
+
|
|
14
|
+
### License
|
|
15
|
+
|
|
16
|
+
Please be aware that this package is GPL-3.0-or-later with exceptions. Please
|
|
17
|
+
refer to the LICENSES directory for more on this.
|
|
18
|
+
|
|
19
|
+
### Code contributions
|
|
20
|
+
|
|
21
|
+
This project does not accept pull, merge or patches from others.
|
|
22
|
+
|
|
23
|
+
Due to the license and how copyright law works, this module will not accept
|
|
24
|
+
code written by people who are not employed by opndev.io.
|
|
25
|
+
|
|
26
|
+
### jsdoc
|
|
27
|
+
|
|
28
|
+
You can build the documentation by running `npm run jsdoc`.
|
|
29
|
+
|
|
30
|
+
### Semver
|
|
31
|
+
|
|
32
|
+
This project does not adhere to semver and one should not rely on the version
|
|
33
|
+
x.y.z notation to infer stability or reliability. Read the Changes file to see
|
|
34
|
+
any updates a version may bring. The fact that this module sits currently at
|
|
35
|
+
0.x.z ranges does not indicate alpha or beta or even unstable associations. It
|
|
36
|
+
is just a number and we started at 0.0.1.
|
|
37
|
+
|
|
38
|
+
In general the following hard guarantee will be given: We will not break your
|
|
39
|
+
code. In case we do happen to cause breakage: we will fix it accordingly.
|
|
40
|
+
|
|
41
|
+
In case we foresee breaking changes we'll add deprecation warnings. Giving you
|
|
42
|
+
time to fix things before a breaking change will be introduced. When a change
|
|
43
|
+
will be introduced is communicated in the Changes file. Security fixes may
|
|
44
|
+
cause breakage at any given time without notice.
|
|
45
|
+
|
|
46
|
+
This package is released by `@opndev/rzilla`, changes to `package.json` will be
|
|
47
|
+
overridden. In addition to a little bit of promotion, this also means that
|
|
48
|
+
version numbers are autoincremented at release time and bumped in all relevant
|
|
49
|
+
files: Versioning for humans, not machines.
|
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
const VERSION = "0.0.1"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check whether a participant matches a subdivision.
|
|
10
|
+
*
|
|
11
|
+
* Gender `x` acts as open. Age `0` means unbounded.
|
|
12
|
+
*
|
|
13
|
+
* @param {Object} participant Event participant data
|
|
14
|
+
* @param {Object} subdivision Subdivision configuration
|
|
15
|
+
* @returns {Boolean} true when the participant fits the subdivision
|
|
16
|
+
*/
|
|
17
|
+
export function participantMatchesSubdivision(participant, subdivision) {
|
|
18
|
+
if (!participant || !subdivision) {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!subdivision.active) {
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (
|
|
27
|
+
subdivision.gender
|
|
28
|
+
&& subdivision.gender !== 'x'
|
|
29
|
+
&& participant.gender !== subdivision.gender
|
|
30
|
+
) {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const age = Number(participant.age || 0)
|
|
35
|
+
const minAge = Number(subdivision.min_age || subdivision.age_min || 0)
|
|
36
|
+
const maxAge = Number(subdivision.max_age || subdivision.age_max || 0)
|
|
37
|
+
|
|
38
|
+
if (minAge > 0 && age < minAge) {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (maxAge > 0 && age > maxAge) {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create empty assignment buckets for subdivisions.
|
|
51
|
+
*
|
|
52
|
+
* @param {Object[]} subdivisions Subdivision configuration records
|
|
53
|
+
* @returns {Object[]} assignment buckets
|
|
54
|
+
*/
|
|
55
|
+
export function createBuckets(subdivisions) {
|
|
56
|
+
return subdivisions
|
|
57
|
+
.filter(subdivision => subdivision.active !== false)
|
|
58
|
+
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0))
|
|
59
|
+
.map(subdivision => ({
|
|
60
|
+
subdivision_id: subdivision.id,
|
|
61
|
+
subdivision,
|
|
62
|
+
participants: [],
|
|
63
|
+
warnings: [],
|
|
64
|
+
}))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Assign participants to the first matching subdivision.
|
|
69
|
+
*
|
|
70
|
+
* @param {Object[]} participants Event participants
|
|
71
|
+
* @param {Object[]} subdivisions Subdivision configuration records
|
|
72
|
+
* @returns {Object} buckets and unassigned participants
|
|
73
|
+
*/
|
|
74
|
+
export function assignParticipants(participants, subdivisions) {
|
|
75
|
+
const buckets = createBuckets(subdivisions)
|
|
76
|
+
const unassigned = []
|
|
77
|
+
|
|
78
|
+
for (const participant of participants) {
|
|
79
|
+
const bucket = buckets.find(candidate => {
|
|
80
|
+
return participantMatchesSubdivision(
|
|
81
|
+
participant,
|
|
82
|
+
candidate.subdivision
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
if (bucket) {
|
|
87
|
+
bucket.participants.push(participant)
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
unassigned.push(participant)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
buckets,
|
|
96
|
+
unassigned,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Apply configured frontend rules to assignment buckets.
|
|
102
|
+
*
|
|
103
|
+
* Currently supported:
|
|
104
|
+
* - merge_if_below_min
|
|
105
|
+
*
|
|
106
|
+
* @param {Object[]} buckets Assignment buckets
|
|
107
|
+
* @param {Object[]} rules Rule configuration records
|
|
108
|
+
* @returns {Object[]} updated assignment buckets
|
|
109
|
+
*/
|
|
110
|
+
export function applyRules(buckets, rules) {
|
|
111
|
+
const orderedRules = [...rules]
|
|
112
|
+
.filter(rule => rule.active !== false)
|
|
113
|
+
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0))
|
|
114
|
+
|
|
115
|
+
for (const rule of orderedRules) {
|
|
116
|
+
if (rule.rule_type !== 'merge_if_below_min') {
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
applyMergeIfBelowMin(buckets, rule)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return buckets
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Apply one merge_if_below_min rule.
|
|
128
|
+
*
|
|
129
|
+
* @param {Object[]} buckets Assignment buckets
|
|
130
|
+
* @param {Object} rule Rule configuration record
|
|
131
|
+
* @returns {Object[]} updated assignment buckets
|
|
132
|
+
*/
|
|
133
|
+
export function applyMergeIfBelowMin(buckets, rule) {
|
|
134
|
+
const sourceIds = rule.source_subdivision_ids || []
|
|
135
|
+
const targetId = rule.target_subdivision_id
|
|
136
|
+
const minParticipants = Number(rule.min_participants || 0)
|
|
137
|
+
|
|
138
|
+
const target = buckets.find(bucket => bucket.subdivision_id === targetId)
|
|
139
|
+
|
|
140
|
+
if (!target) {
|
|
141
|
+
return buckets
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const sourceId of sourceIds) {
|
|
145
|
+
const source = buckets.find(bucket => bucket.subdivision_id === sourceId)
|
|
146
|
+
|
|
147
|
+
if (!source || source.subdivision_id === target.subdivision_id) {
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (source.participants.length >= minParticipants) {
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
target.participants.push(...source.participants)
|
|
156
|
+
source.participants = []
|
|
157
|
+
source.warnings.push({
|
|
158
|
+
type: 'merged',
|
|
159
|
+
target_subdivision_id: target.subdivision_id,
|
|
160
|
+
rule_id: rule.id || null,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return buckets
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Add min participant warnings to buckets.
|
|
169
|
+
*
|
|
170
|
+
* @param {Object[]} buckets Assignment buckets
|
|
171
|
+
* @returns {Object[]} buckets with warnings
|
|
172
|
+
*/
|
|
173
|
+
export function addWarnings(buckets) {
|
|
174
|
+
for (const bucket of buckets) {
|
|
175
|
+
const minParticipants = Number(
|
|
176
|
+
bucket.subdivision.min_participants || 0
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if (
|
|
180
|
+
minParticipants > 0
|
|
181
|
+
&& bucket.participants.length > 0
|
|
182
|
+
&& bucket.participants.length < minParticipants
|
|
183
|
+
) {
|
|
184
|
+
bucket.warnings.push({
|
|
185
|
+
type: 'below_min_participants',
|
|
186
|
+
min_participants: minParticipants,
|
|
187
|
+
count: bucket.participants.length,
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return buckets
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Generate bibs for assigned participants.
|
|
197
|
+
*
|
|
198
|
+
* If a subdivision or division uses sail numbers, the participant sailnumber is
|
|
199
|
+
* used as bib. Otherwise the subdivision or division prefix is used with a
|
|
200
|
+
* sequential number.
|
|
201
|
+
*
|
|
202
|
+
* @param {Object[]} buckets Assignment buckets
|
|
203
|
+
* @param {Object} options Bib options
|
|
204
|
+
* @returns {Object[]} buckets with participant bib values
|
|
205
|
+
*/
|
|
206
|
+
export function generateBibs(buckets, options = {}) {
|
|
207
|
+
const counters = {}
|
|
208
|
+
const division = options.division || {}
|
|
209
|
+
const competition = options.competition || {}
|
|
210
|
+
const usesSailnumber = Boolean(
|
|
211
|
+
competition.sailnumber || division.sailnumber
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
for (const bucket of buckets) {
|
|
215
|
+
const prefix = bucket.subdivision.bib_prefix || division.bib_prefix || ''
|
|
216
|
+
|
|
217
|
+
if (counters[prefix] === undefined) {
|
|
218
|
+
counters[prefix] = 1
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
bucket.participants = bucket.participants.map(participant => {
|
|
222
|
+
if (usesSailnumber) {
|
|
223
|
+
return {
|
|
224
|
+
...participant,
|
|
225
|
+
bib: participant.sailnumber || null,
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const bib = `${prefix}${counters[prefix]}`
|
|
230
|
+
counters[prefix] += 1
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
...participant,
|
|
234
|
+
bib,
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return buckets
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Convert buckets to the API save payload.
|
|
244
|
+
*
|
|
245
|
+
* @param {Object[]} buckets Assignment buckets
|
|
246
|
+
* @returns {Object[]} payload for race control save endpoint
|
|
247
|
+
*/
|
|
248
|
+
export function toSavePayload(buckets) {
|
|
249
|
+
return buckets.map(bucket => ({
|
|
250
|
+
subdivision_id: bucket.subdivision_id,
|
|
251
|
+
participants: bucket.participants.map(participant => ({
|
|
252
|
+
id: participant.id,
|
|
253
|
+
bib: participant.bib || null,
|
|
254
|
+
})),
|
|
255
|
+
}))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Build the full race control state.
|
|
260
|
+
*
|
|
261
|
+
* @param {Object} input Race control input
|
|
262
|
+
* @returns {Object} computed race control state
|
|
263
|
+
*/
|
|
264
|
+
export function buildRaceControl(input) {
|
|
265
|
+
const assigned = assignParticipants(
|
|
266
|
+
input.participants || [],
|
|
267
|
+
input.subdivisions || []
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
applyRules(assigned.buckets, input.rules || [])
|
|
271
|
+
addWarnings(assigned.buckets)
|
|
272
|
+
generateBibs(assigned.buckets, {
|
|
273
|
+
competition: input.competition || {},
|
|
274
|
+
division: input.division || {},
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
return assigned
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export {
|
|
281
|
+
VERSION,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export default {
|
|
285
|
+
addWarnings,
|
|
286
|
+
applyMergeIfBelowMin,
|
|
287
|
+
applyRules,
|
|
288
|
+
assignParticipants,
|
|
289
|
+
buildRaceControl,
|
|
290
|
+
createBuckets,
|
|
291
|
+
generateBibs,
|
|
292
|
+
participantMatchesSubdivision,
|
|
293
|
+
toSavePayload,
|
|
294
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"author": {
|
|
3
|
+
"email": "wesley@opndev.io",
|
|
4
|
+
"name": "Wesley Schwengle"
|
|
5
|
+
},
|
|
6
|
+
"description": "Race control package",
|
|
7
|
+
"devDependencies": {
|
|
8
|
+
"jsdoc": "latest",
|
|
9
|
+
"tap": "latest"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./lib/index.mjs"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"racecontrol"
|
|
16
|
+
],
|
|
17
|
+
"license": "GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions",
|
|
18
|
+
"name": "@opndev/racecontrol",
|
|
19
|
+
"private": false,
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "rzil build",
|
|
22
|
+
"jsdoc": "jsdoc -c .jsdoc.json",
|
|
23
|
+
"pkg": "rzil pkg",
|
|
24
|
+
"release": "rzil release",
|
|
25
|
+
"test": "tap"
|
|
26
|
+
},
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"type": "module",
|
|
29
|
+
"version": "0.0.1"
|
|
30
|
+
}
|