@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 ADDED
@@ -0,0 +1,5 @@
1
+ Revision history for @opndev/racecontrol
2
+
3
+ 0.0.1 2026-05-09 20:48:20Z
4
+
5
+ * First release to an unsuspecting world
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
+ }