fenne 1.0.0 → 1.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.
Files changed (3) hide show
  1. package/README.md +97 -0
  2. package/index.js +684 -0
  3. package/package.json +16 -3
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # fenne
2
+
3
+ Zero-dependency CommonJS helpers for appointment-studio floor operations: station time blocks, walk-in queues, booking packs, operator skill matching, paced async lanes, cash-drawer ledgers, and overlapping booking scans.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install fenne
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```js
14
+ const {
15
+ coalesceStationBlocks,
16
+ rankFloorQueue,
17
+ packStationBookings,
18
+ matchOperatorSkills,
19
+ runStationLanes,
20
+ foldDrawerEvents,
21
+ detectBookingCollisions,
22
+ } = require("fenne");
23
+
24
+ const now = Date.now();
25
+
26
+ const blocks = coalesceStationBlocks([
27
+ { stationId: "chair-a", operatorId: "op-1", start: now, end: now + 1_800_000, serviceId: "cut" },
28
+ { stationId: "chair-a", operatorId: "op-1", start: now + 1_800_000, end: now + 3_600_000, serviceId: "style" },
29
+ ]);
30
+
31
+ const queue = rankFloorQueue(
32
+ [
33
+ { id: "t-12", kind: "walkin", joinedAt: now - 20_000, slaMinutes: 10, serviceMin: 45, visits: 4 },
34
+ { id: "t-13", kind: "consult", joinedAt: now, slaMinutes: 30, serviceMin: 15, visits: 0 },
35
+ ],
36
+ now
37
+ );
38
+
39
+ const packed = packStationBookings(
40
+ [{ id: "b-1", stationId: "chair-a", durationMin: 30, earliest: now, latest: now + 3_600_000, bufferMin: 5 }],
41
+ [{ id: "chair-a", open: now, close: now + 8 * 3_600_000 }]
42
+ );
43
+
44
+ const roster = matchOperatorSkills(
45
+ { skills: ["cut"], languages: ["en"], durationMin: 30 },
46
+ [{ id: "op-1", skills: ["cut", "color"], languages: ["en"], open: true, load: 1 }]
47
+ );
48
+
49
+ runStationLanes([() => Promise.resolve("notify-1"), () => Promise.resolve("notify-2")], { width: 2 }).then((lane) => {
50
+ const drawer = foldDrawerEvents([
51
+ { type: "open", drawerId: "front", at: now, amount: 120, actorId: "op-1" },
52
+ { type: "sale", drawerId: "front", at: now + 1, amount: 48 },
53
+ { type: "tip", drawerId: "front", at: now + 2, amount: 8 },
54
+ ]);
55
+ const clashes = detectBookingCollisions([
56
+ { id: "x", stationId: "chair-a", operatorId: "op-1", start: now, end: now + 1_800_000 },
57
+ { id: "y", stationId: "chair-a", operatorId: "op-2", start: now + 600_000, end: now + 2_400_000 },
58
+ ]);
59
+ console.log(blocks, queue[0].id, packed.assigned, roster[0].operatorId, lane.ok, drawer, clashes);
60
+ });
61
+ ```
62
+
63
+ ## API
64
+
65
+ All times are finite epoch milliseconds. Invalid input throws `TypeError` or `RangeError`. Objects and arrays passed in are not mutated.
66
+
67
+ ### `coalesceStationBlocks(blocks)`
68
+
69
+ Collapse overlapping or adjacent station blocks that share `stationId` and `operatorId`. Optional `serviceId` values are collected without duplicates.
70
+
71
+ ### `rankFloorQueue(tickets, now)`
72
+
73
+ Score floor tickets from `booked|walkin|waitlist|consult` kind, remaining wait SLA, service minutes, visit count, and overdue penalty. Returns a new array sorted by score descending.
74
+
75
+ ### `packStationBookings(requests, stations)`
76
+
77
+ Greedy-pack bookings onto stations with an open/close window. Optional `bufferMin` keeps a gap after each assigned booking. Returns `{ assigned, rejected }`.
78
+
79
+ ### `matchOperatorSkills(request, operators)`
80
+
81
+ Score operators against required `skills` and optional `languages`. Any missing required skill yields score `0`. Results are sorted by score descending.
82
+
83
+ ### `runStationLanes(tasks, options?)`
84
+
85
+ Run promise factories with a bounded `width` (default `2`). Resolves `{ ok, failed }` in original index order. A rejected task does not abort the rest.
86
+
87
+ ### `foldDrawerEvents(events)`
88
+
89
+ Fold `open|sale|tip|payout|close` events into per-drawer balances. A later `open` after `close` resets sales, tips, and payouts for that drawer.
90
+
91
+ ### `detectBookingCollisions(bookings)`
92
+
93
+ Find pairs that overlap on the same station, the same operator, or both. Touching edges are not collisions.
94
+
95
+ ## License
96
+
97
+ ISC
package/index.js ADDED
@@ -0,0 +1,684 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Appointment studio helpers: station time blocks, floor queues,
5
+ * booking packs, operator skill matching, paced lanes, drawer ledgers,
6
+ * and overlapping booking scans.
7
+ */
8
+
9
+ /**
10
+ * @param {string} name
11
+ * @param {*} value
12
+ * @param {string} expected
13
+ * @returns {never}
14
+ */
15
+ function failType(name, value, expected) {
16
+ const actual = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
17
+ throw new TypeError(name + " must be " + expected + ", got " + actual);
18
+ }
19
+
20
+ /**
21
+ * @param {string} name
22
+ * @param {*} value
23
+ */
24
+ function assertArray(name, value) {
25
+ if (!Array.isArray(value)) {
26
+ failType(name, value, "an array");
27
+ }
28
+ }
29
+
30
+ /**
31
+ * @param {*} value
32
+ * @returns {boolean}
33
+ */
34
+ function isPlainObject(value) {
35
+ return value !== null && typeof value === "object" && !Array.isArray(value);
36
+ }
37
+
38
+ /**
39
+ * @param {string} name
40
+ * @param {*} value
41
+ */
42
+ function assertPlainObject(name, value) {
43
+ if (!isPlainObject(value)) {
44
+ failType(name, value, "a plain object");
45
+ }
46
+ }
47
+
48
+ /**
49
+ * @param {string} name
50
+ * @param {*} value
51
+ */
52
+ function assertFunction(name, value) {
53
+ if (typeof value !== "function") {
54
+ failType(name, value, "a function");
55
+ }
56
+ }
57
+
58
+ /**
59
+ * @param {string} name
60
+ * @param {*} value
61
+ * @returns {number}
62
+ */
63
+ function assertEpoch(name, value) {
64
+ if (typeof value !== "number" || !Number.isFinite(value)) {
65
+ failType(name, value, "a finite epoch millisecond");
66
+ }
67
+ return value;
68
+ }
69
+
70
+ /**
71
+ * @param {string} name
72
+ * @param {*} value
73
+ * @param {number} min
74
+ * @returns {number}
75
+ */
76
+ function assertFiniteNumber(name, value, min) {
77
+ if (typeof value !== "number" || !Number.isFinite(value)) {
78
+ failType(name, value, "a finite number");
79
+ }
80
+ if (value < min) {
81
+ throw new RangeError(name + " must be >= " + min + ", got " + value);
82
+ }
83
+ return value;
84
+ }
85
+
86
+ /**
87
+ * @param {string} name
88
+ * @param {*} value
89
+ * @returns {string}
90
+ */
91
+ function assertNonEmptyString(name, value) {
92
+ if (typeof value !== "string" || value.trim() === "") {
93
+ failType(name, value, "a non-empty string");
94
+ }
95
+ return value.trim();
96
+ }
97
+
98
+ /**
99
+ * @param {string} name
100
+ * @param {*} value
101
+ * @returns {string[]}
102
+ */
103
+ function optionalStringList(name, value) {
104
+ if (value == null) {
105
+ return [];
106
+ }
107
+ assertArray(name, value);
108
+ const out = [];
109
+ const seen = Object.create(null);
110
+ for (let i = 0; i < value.length; i += 1) {
111
+ const item = assertNonEmptyString(name + "[" + i + "]", value[i]);
112
+ if (!seen[item]) {
113
+ seen[item] = true;
114
+ out.push(item);
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * Merge overlapping or touching station blocks that share station and operator.
122
+ * Adjacent edges (end === next.start) collapse. Input is copied, never mutated.
123
+ *
124
+ * @param {Array<{ stationId: string, operatorId: string, start: number, end: number, serviceId?: string }>} blocks
125
+ * @returns {Array<{ stationId: string, operatorId: string, start: number, end: number, serviceIds: string[] }>}
126
+ */
127
+ function coalesceStationBlocks(blocks) {
128
+ assertArray("blocks", blocks);
129
+
130
+ /** @type {Record<string, Array<{ stationId: string, operatorId: string, start: number, end: number, serviceIds: string[] }>>} */
131
+ const groups = Object.create(null);
132
+
133
+ for (let i = 0; i < blocks.length; i += 1) {
134
+ const row = blocks[i];
135
+ assertPlainObject("blocks[" + i + "]", row);
136
+ const stationId = assertNonEmptyString("blocks[" + i + "].stationId", row.stationId);
137
+ const operatorId = assertNonEmptyString("blocks[" + i + "].operatorId", row.operatorId);
138
+ const start = assertEpoch("blocks[" + i + "].start", row.start);
139
+ const end = assertEpoch("blocks[" + i + "].end", row.end);
140
+ if (end <= start) {
141
+ throw new RangeError("blocks[" + i + "].end must be greater than start");
142
+ }
143
+ const serviceIds = [];
144
+ if (row.serviceId != null) {
145
+ serviceIds.push(assertNonEmptyString("blocks[" + i + "].serviceId", row.serviceId));
146
+ }
147
+ const key = stationId + "\0" + operatorId;
148
+ if (!groups[key]) {
149
+ groups[key] = [];
150
+ }
151
+ groups[key].push({ stationId, operatorId, start, end, serviceIds });
152
+ }
153
+
154
+ const merged = [];
155
+ const keys = Object.keys(groups);
156
+ for (let g = 0; g < keys.length; g += 1) {
157
+ const list = groups[keys[g]];
158
+ list.sort(function (a, b) {
159
+ return a.start - b.start || a.end - b.end;
160
+ });
161
+ let current = null;
162
+ for (let i = 0; i < list.length; i += 1) {
163
+ const next = list[i];
164
+ if (current === null || next.start > current.end) {
165
+ current = {
166
+ stationId: next.stationId,
167
+ operatorId: next.operatorId,
168
+ start: next.start,
169
+ end: next.end,
170
+ serviceIds: next.serviceIds.slice(),
171
+ };
172
+ merged.push(current);
173
+ } else {
174
+ if (next.end > current.end) {
175
+ current.end = next.end;
176
+ }
177
+ for (let s = 0; s < next.serviceIds.length; s += 1) {
178
+ if (current.serviceIds.indexOf(next.serviceIds[s]) === -1) {
179
+ current.serviceIds.push(next.serviceIds[s]);
180
+ }
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ merged.sort(function (a, b) {
187
+ return a.start - b.start || a.stationId.localeCompare(b.stationId) || a.operatorId.localeCompare(b.operatorId);
188
+ });
189
+ return merged;
190
+ }
191
+
192
+ const QUEUE_KIND_WEIGHT = {
193
+ booked: 80,
194
+ walkin: 45,
195
+ waitlist: 25,
196
+ consult: 15,
197
+ };
198
+
199
+ /**
200
+ * Rank floor tickets by remaining wait SLA, kind, service minutes, and visit count.
201
+ * Overdue tickets receive a growing penalty. Returns a new array sorted by score desc.
202
+ *
203
+ * @param {Array<{ id: string, kind: "booked"|"walkin"|"waitlist"|"consult", joinedAt: number, slaMinutes: number, serviceMin?: number, visits?: number, stationId?: string }>} tickets
204
+ * @param {number} now
205
+ * @returns {Array<{ id: string, score: number, overdueMs: number, remainingMs: number, kind: string, stationId: string|null }>}
206
+ */
207
+ function rankFloorQueue(tickets, now) {
208
+ assertArray("tickets", tickets);
209
+ assertEpoch("now", now);
210
+
211
+ const ranked = [];
212
+ for (let i = 0; i < tickets.length; i += 1) {
213
+ const row = tickets[i];
214
+ assertPlainObject("tickets[" + i + "]", row);
215
+ const id = assertNonEmptyString("tickets[" + i + "].id", row.id);
216
+ const kind = assertNonEmptyString("tickets[" + i + "].kind", row.kind);
217
+ if (!Object.prototype.hasOwnProperty.call(QUEUE_KIND_WEIGHT, kind)) {
218
+ throw new RangeError("tickets[" + i + "].kind must be booked|walkin|waitlist|consult");
219
+ }
220
+ const joinedAt = assertEpoch("tickets[" + i + "].joinedAt", row.joinedAt);
221
+ const slaMinutes = assertFiniteNumber("tickets[" + i + "].slaMinutes", row.slaMinutes, 1);
222
+ const serviceMin =
223
+ row.serviceMin == null ? 30 : assertFiniteNumber("tickets[" + i + "].serviceMin", row.serviceMin, 1);
224
+ const visits = row.visits == null ? 0 : assertFiniteNumber("tickets[" + i + "].visits", row.visits, 0);
225
+ const slaMs = slaMinutes * 60 * 1000;
226
+ const dueAt = joinedAt + slaMs;
227
+ const remainingMs = dueAt - now;
228
+ const overdueMs = remainingMs < 0 ? -remainingMs : 0;
229
+ const waitPressure = (now - joinedAt) / slaMs;
230
+ const durationLoad = Math.log(serviceMin) / Math.LN10 + 1;
231
+ const loyalty = Math.min(20, visits * 2);
232
+ const overdueBonus = overdueMs > 0 ? 30 + Math.min(40, overdueMs / 60000) : 0;
233
+ const score = QUEUE_KIND_WEIGHT[kind] + waitPressure * 35 + durationLoad * 8 + loyalty + overdueBonus;
234
+ ranked.push({
235
+ id: id,
236
+ score: Math.round(score * 1000) / 1000,
237
+ overdueMs: overdueMs,
238
+ remainingMs: remainingMs,
239
+ kind: kind,
240
+ stationId: row.stationId == null ? null : assertNonEmptyString("tickets[" + i + "].stationId", row.stationId),
241
+ });
242
+ }
243
+
244
+ ranked.sort(function (a, b) {
245
+ return b.score - a.score || a.remainingMs - b.remainingMs || a.id.localeCompare(b.id);
246
+ });
247
+ return ranked;
248
+ }
249
+
250
+ /**
251
+ * Greedy-pack service bookings onto stations with open/close windows and a buffer.
252
+ * Occupancy never exceeds one active booking per station after the buffer gap.
253
+ *
254
+ * @param {Array<{ id: string, stationId: string, durationMin: number, earliest: number, latest: number, bufferMin?: number }>} requests
255
+ * @param {Array<{ id: string, open: number, close: number }>} stations
256
+ * @returns {{ assigned: Array<{ requestId: string, stationId: string, start: number, end: number }>, rejected: Array<{ requestId: string, reason: string }> }}
257
+ */
258
+ function packStationBookings(requests, stations) {
259
+ assertArray("requests", requests);
260
+ assertArray("stations", stations);
261
+
262
+ /** @type {Record<string, { id: string, open: number, close: number, occupied: Array<{ start: number, end: number }> }>} */
263
+ const floor = Object.create(null);
264
+ for (let i = 0; i < stations.length; i += 1) {
265
+ const row = stations[i];
266
+ assertPlainObject("stations[" + i + "]", row);
267
+ const id = assertNonEmptyString("stations[" + i + "].id", row.id);
268
+ if (floor[id]) {
269
+ throw new RangeError("duplicate station id " + id);
270
+ }
271
+ const open = assertEpoch("stations[" + i + "].open", row.open);
272
+ const close = assertEpoch("stations[" + i + "].close", row.close);
273
+ if (close <= open) {
274
+ throw new RangeError("stations[" + i + "].close must be greater than open");
275
+ }
276
+ floor[id] = { id: id, open: open, close: close, occupied: [] };
277
+ }
278
+
279
+ const assigned = [];
280
+ const rejected = [];
281
+
282
+ const ordered = requests.map(function (row, index) {
283
+ assertPlainObject("requests[" + index + "]", row);
284
+ const id = assertNonEmptyString("requests[" + index + "].id", row.id);
285
+ const stationId = assertNonEmptyString("requests[" + index + "].stationId", row.stationId);
286
+ const durationMin = assertFiniteNumber("requests[" + index + "].durationMin", row.durationMin, 1);
287
+ const earliest = assertEpoch("requests[" + index + "].earliest", row.earliest);
288
+ const latest = assertEpoch("requests[" + index + "].latest", row.latest);
289
+ if (latest <= earliest) {
290
+ throw new RangeError("requests[" + index + "].latest must be greater than earliest");
291
+ }
292
+ const bufferMin =
293
+ row.bufferMin == null ? 0 : assertFiniteNumber("requests[" + index + "].bufferMin", row.bufferMin, 0);
294
+ return {
295
+ id: id,
296
+ stationId: stationId,
297
+ durationMs: durationMin * 60 * 1000,
298
+ earliest: earliest,
299
+ latest: latest,
300
+ bufferMs: bufferMin * 60 * 1000,
301
+ index: index,
302
+ };
303
+ });
304
+
305
+ ordered.sort(function (a, b) {
306
+ return a.earliest - b.earliest || a.durationMs - b.durationMs || a.id.localeCompare(b.id);
307
+ });
308
+
309
+ for (let i = 0; i < ordered.length; i += 1) {
310
+ const req = ordered[i];
311
+ const station = floor[req.stationId];
312
+ if (!station) {
313
+ rejected.push({ requestId: req.id, reason: "unknown-station" });
314
+ continue;
315
+ }
316
+ const durationEndLimit = req.latest;
317
+ let placed = false;
318
+ let cursor = Math.max(req.earliest, station.open);
319
+ station.occupied.sort(function (a, b) {
320
+ return a.start - b.start;
321
+ });
322
+ const gaps = [];
323
+ let probe = station.open;
324
+ for (let o = 0; o < station.occupied.length; o += 1) {
325
+ const occ = station.occupied[o];
326
+ if (occ.start > probe) {
327
+ gaps.push({ start: probe, end: occ.start });
328
+ }
329
+ probe = Math.max(probe, occ.end);
330
+ }
331
+ if (probe < station.close) {
332
+ gaps.push({ start: probe, end: station.close });
333
+ }
334
+ for (let g = 0; g < gaps.length && !placed; g += 1) {
335
+ const gap = gaps[g];
336
+ const start = Math.max(cursor, gap.start);
337
+ const end = start + req.durationMs;
338
+ const paddedEnd = end + req.bufferMs;
339
+ if (end <= durationEndLimit && paddedEnd <= gap.end && end <= station.close && start >= station.open) {
340
+ station.occupied.push({ start: start, end: paddedEnd });
341
+ assigned.push({
342
+ requestId: req.id,
343
+ stationId: station.id,
344
+ start: start,
345
+ end: end,
346
+ });
347
+ placed = true;
348
+ }
349
+ }
350
+ if (!placed) {
351
+ rejected.push({ requestId: req.id, reason: "no-fit" });
352
+ }
353
+ }
354
+
355
+ assigned.sort(function (a, b) {
356
+ return a.start - b.start || a.requestId.localeCompare(b.requestId);
357
+ });
358
+ return { assigned: assigned, rejected: rejected };
359
+ }
360
+
361
+ /**
362
+ * Score operators against a service request by required skills, languages, and open flag.
363
+ * Missing required skills zero the match. Returns a new array sorted by score desc.
364
+ *
365
+ * @param {{ skills: string[], languages?: string[], durationMin?: number }} request
366
+ * @param {Array<{ id: string, skills: string[], languages?: string[], open?: boolean, load?: number }>} operators
367
+ * @returns {Array<{ operatorId: string, score: number, missing: string[], open: boolean }>}
368
+ */
369
+ function matchOperatorSkills(request, operators) {
370
+ assertPlainObject("request", request);
371
+ assertArray("operators", operators);
372
+ const required = optionalStringList("request.skills", request.skills);
373
+ if (required.length === 0) {
374
+ throw new RangeError("request.skills must contain at least one skill");
375
+ }
376
+ const wantLang = optionalStringList("request.languages", request.languages);
377
+ const durationMin =
378
+ request.durationMin == null ? 30 : assertFiniteNumber("request.durationMin", request.durationMin, 1);
379
+
380
+ const scored = [];
381
+ for (let i = 0; i < operators.length; i += 1) {
382
+ const row = operators[i];
383
+ assertPlainObject("operators[" + i + "]", row);
384
+ const id = assertNonEmptyString("operators[" + i + "].id", row.id);
385
+ const skills = optionalStringList("operators[" + i + "].skills", row.skills);
386
+ const languages = optionalStringList("operators[" + i + "].languages", row.languages);
387
+ const open = row.open == null ? true : row.open === true;
388
+ const load = row.load == null ? 0 : assertFiniteNumber("operators[" + i + "].load", row.load, 0);
389
+ const skillSet = Object.create(null);
390
+ for (let s = 0; s < skills.length; s += 1) {
391
+ skillSet[skills[s]] = true;
392
+ }
393
+ const missing = [];
394
+ let hit = 0;
395
+ for (let r = 0; r < required.length; r += 1) {
396
+ if (skillSet[required[r]]) {
397
+ hit += 1;
398
+ } else {
399
+ missing.push(required[r]);
400
+ }
401
+ }
402
+ let langHit = 0;
403
+ if (wantLang.length > 0) {
404
+ const langSet = Object.create(null);
405
+ for (let l = 0; l < languages.length; l += 1) {
406
+ langSet[languages[l]] = true;
407
+ }
408
+ for (let w = 0; w < wantLang.length; w += 1) {
409
+ if (langSet[wantLang[w]]) {
410
+ langHit += 1;
411
+ }
412
+ }
413
+ }
414
+ const coverage = hit / required.length;
415
+ const langBonus = wantLang.length === 0 ? 8 : (langHit / wantLang.length) * 20;
416
+ const loadPenalty = Math.min(25, load * 5);
417
+ const durationBias = Math.min(10, durationMin / 15);
418
+ const score =
419
+ missing.length > 0 ? 0 : Math.round((coverage * 70 + langBonus + (open ? 12 : 0) + durationBias - loadPenalty) * 1000) / 1000;
420
+ scored.push({
421
+ operatorId: id,
422
+ score: score,
423
+ missing: missing,
424
+ open: open,
425
+ });
426
+ }
427
+
428
+ scored.sort(function (a, b) {
429
+ return b.score - a.score || a.operatorId.localeCompare(b.operatorId);
430
+ });
431
+ return scored;
432
+ }
433
+
434
+ /**
435
+ * Run async station-side tasks with a bounded lane width.
436
+ * Settled results keep input order. Does not mutate the task list.
437
+ *
438
+ * @param {Array<() => Promise<*>>} tasks
439
+ * @param {{ width?: number }} [options]
440
+ * @returns {Promise<{ ok: Array<{ index: number, value: * }>, failed: Array<{ index: number, error: Error }> }>}
441
+ */
442
+ function runStationLanes(tasks, options) {
443
+ assertArray("tasks", tasks);
444
+ const opts = options == null ? {} : options;
445
+ assertPlainObject("options", opts);
446
+ const width = opts.width == null ? 2 : assertFiniteNumber("options.width", opts.width, 1);
447
+ if (!Number.isInteger(width)) {
448
+ throw new RangeError("options.width must be an integer");
449
+ }
450
+ for (let i = 0; i < tasks.length; i += 1) {
451
+ assertFunction("tasks[" + i + "]", tasks[i]);
452
+ }
453
+
454
+ return new Promise(function (resolve) {
455
+ const ok = [];
456
+ const failed = [];
457
+ let next = 0;
458
+ let inflight = 0;
459
+ let settled = 0;
460
+ const total = tasks.length;
461
+
462
+ if (total === 0) {
463
+ resolve({ ok: ok, failed: failed });
464
+ return;
465
+ }
466
+
467
+ function finishOne() {
468
+ settled += 1;
469
+ inflight -= 1;
470
+ if (settled === total) {
471
+ ok.sort(function (a, b) {
472
+ return a.index - b.index;
473
+ });
474
+ failed.sort(function (a, b) {
475
+ return a.index - b.index;
476
+ });
477
+ resolve({ ok: ok, failed: failed });
478
+ return;
479
+ }
480
+ pump();
481
+ }
482
+
483
+ function pump() {
484
+ while (inflight < width && next < total) {
485
+ const index = next;
486
+ next += 1;
487
+ inflight += 1;
488
+ Promise.resolve()
489
+ .then(function () {
490
+ return tasks[index]();
491
+ })
492
+ .then(
493
+ function (value) {
494
+ ok.push({ index: index, value: value });
495
+ finishOne();
496
+ },
497
+ function (error) {
498
+ const err = error instanceof Error ? error : new Error(String(error));
499
+ failed.push({ index: index, error: err });
500
+ finishOne();
501
+ }
502
+ );
503
+ }
504
+ }
505
+
506
+ pump();
507
+ });
508
+ }
509
+
510
+ const DRAWER_TYPES = {
511
+ open: true,
512
+ sale: true,
513
+ tip: true,
514
+ payout: true,
515
+ close: true,
516
+ };
517
+
518
+ /**
519
+ * Fold cash-drawer events into per-drawer running balances and a close snapshot.
520
+ * Events are copied and applied in time order. Input is not mutated.
521
+ *
522
+ * @param {Array<{ type: "open"|"sale"|"tip"|"payout"|"close", drawerId: string, at: number, amount?: number, actorId?: string }>} events
523
+ * @returns {Record<string, { drawerId: string, openedAt: number|null, closedAt: number|null, sales: number, tips: number, payouts: number, balance: number, actors: string[] }>}
524
+ */
525
+ function foldDrawerEvents(events) {
526
+ assertArray("events", events);
527
+
528
+ const rows = [];
529
+ for (let i = 0; i < events.length; i += 1) {
530
+ const row = events[i];
531
+ assertPlainObject("events[" + i + "]", row);
532
+ const type = assertNonEmptyString("events[" + i + "].type", row.type);
533
+ if (!DRAWER_TYPES[type]) {
534
+ throw new RangeError("events[" + i + "].type must be open|sale|tip|payout|close");
535
+ }
536
+ const drawerId = assertNonEmptyString("events[" + i + "].drawerId", row.drawerId);
537
+ const at = assertEpoch("events[" + i + "].at", row.at);
538
+ let amount = 0;
539
+ if (type === "sale" || type === "tip" || type === "payout") {
540
+ amount = assertFiniteNumber("events[" + i + "].amount", row.amount, 0);
541
+ } else if (row.amount != null) {
542
+ amount = assertFiniteNumber("events[" + i + "].amount", row.amount, 0);
543
+ }
544
+ const actorId =
545
+ row.actorId == null ? null : assertNonEmptyString("events[" + i + "].actorId", row.actorId);
546
+ rows.push({ type: type, drawerId: drawerId, at: at, amount: amount, actorId: actorId });
547
+ }
548
+
549
+ rows.sort(function (a, b) {
550
+ return a.at - b.at || a.drawerId.localeCompare(b.drawerId);
551
+ });
552
+
553
+ /** @type {Record<string, { drawerId: string, openedAt: number|null, closedAt: number|null, sales: number, tips: number, payouts: number, balance: number, actors: string[] }>} */
554
+ const ledgers = Object.create(null);
555
+
556
+ function ensure(drawerId) {
557
+ if (!ledgers[drawerId]) {
558
+ ledgers[drawerId] = {
559
+ drawerId: drawerId,
560
+ openedAt: null,
561
+ closedAt: null,
562
+ sales: 0,
563
+ tips: 0,
564
+ payouts: 0,
565
+ balance: 0,
566
+ actors: [],
567
+ };
568
+ }
569
+ return ledgers[drawerId];
570
+ }
571
+
572
+ for (let i = 0; i < rows.length; i += 1) {
573
+ const ev = rows[i];
574
+ const ledger = ensure(ev.drawerId);
575
+ if (ev.actorId && ledger.actors.indexOf(ev.actorId) === -1) {
576
+ ledger.actors.push(ev.actorId);
577
+ }
578
+ if (ev.type === "open") {
579
+ if (ledger.closedAt != null && ev.at >= ledger.closedAt) {
580
+ ledger.openedAt = ev.at;
581
+ ledger.closedAt = null;
582
+ ledger.sales = 0;
583
+ ledger.tips = 0;
584
+ ledger.payouts = 0;
585
+ ledger.balance = ev.amount;
586
+ ledger.actors = ev.actorId ? [ev.actorId] : [];
587
+ } else if (ledger.openedAt == null) {
588
+ ledger.openedAt = ev.at;
589
+ ledger.balance += ev.amount;
590
+ }
591
+ } else if (ev.type === "sale") {
592
+ ledger.sales += ev.amount;
593
+ ledger.balance += ev.amount;
594
+ } else if (ev.type === "tip") {
595
+ ledger.tips += ev.amount;
596
+ ledger.balance += ev.amount;
597
+ } else if (ev.type === "payout") {
598
+ ledger.payouts += ev.amount;
599
+ ledger.balance -= ev.amount;
600
+ } else if (ev.type === "close") {
601
+ ledger.closedAt = ev.at;
602
+ }
603
+ }
604
+
605
+ return ledgers;
606
+ }
607
+
608
+ /**
609
+ * Detect overlapping bookings that share a station or an operator.
610
+ * Touching edges (end === next.start) are not collisions. Input is not mutated.
611
+ *
612
+ * @param {Array<{ id: string, stationId: string, operatorId: string, start: number, end: number }>} bookings
613
+ * @returns {Array<{ leftId: string, rightId: string, shared: "station"|"operator"|"both", overlapMs: number }>}
614
+ */
615
+ function detectBookingCollisions(bookings) {
616
+ assertArray("bookings", bookings);
617
+
618
+ const rows = [];
619
+ const seenIds = Object.create(null);
620
+ for (let i = 0; i < bookings.length; i += 1) {
621
+ const row = bookings[i];
622
+ assertPlainObject("bookings[" + i + "]", row);
623
+ const id = assertNonEmptyString("bookings[" + i + "].id", row.id);
624
+ if (seenIds[id]) {
625
+ throw new RangeError("duplicate booking id " + id);
626
+ }
627
+ seenIds[id] = true;
628
+ const stationId = assertNonEmptyString("bookings[" + i + "].stationId", row.stationId);
629
+ const operatorId = assertNonEmptyString("bookings[" + i + "].operatorId", row.operatorId);
630
+ const start = assertEpoch("bookings[" + i + "].start", row.start);
631
+ const end = assertEpoch("bookings[" + i + "].end", row.end);
632
+ if (end <= start) {
633
+ throw new RangeError("bookings[" + i + "].end must be greater than start");
634
+ }
635
+ rows.push({ id: id, stationId: stationId, operatorId: operatorId, start: start, end: end });
636
+ }
637
+
638
+ rows.sort(function (a, b) {
639
+ return a.start - b.start || a.id.localeCompare(b.id);
640
+ });
641
+
642
+ const clashes = [];
643
+ for (let i = 0; i < rows.length; i += 1) {
644
+ const left = rows[i];
645
+ for (let j = i + 1; j < rows.length; j += 1) {
646
+ const right = rows[j];
647
+ if (right.start >= left.end) {
648
+ continue;
649
+ }
650
+ const sameStation = left.stationId === right.stationId;
651
+ const sameOperator = left.operatorId === right.operatorId;
652
+ if (!sameStation && !sameOperator) {
653
+ continue;
654
+ }
655
+ const overlapStart = Math.max(left.start, right.start);
656
+ const overlapEnd = Math.min(left.end, right.end);
657
+ const overlapMs = overlapEnd - overlapStart;
658
+ if (overlapMs <= 0) {
659
+ continue;
660
+ }
661
+ clashes.push({
662
+ leftId: left.id,
663
+ rightId: right.id,
664
+ shared: sameStation && sameOperator ? "both" : sameStation ? "station" : "operator",
665
+ overlapMs: overlapMs,
666
+ });
667
+ }
668
+ }
669
+
670
+ clashes.sort(function (a, b) {
671
+ return b.overlapMs - a.overlapMs || a.leftId.localeCompare(b.leftId) || a.rightId.localeCompare(b.rightId);
672
+ });
673
+ return clashes;
674
+ }
675
+
676
+ module.exports = {
677
+ coalesceStationBlocks,
678
+ rankFloorQueue,
679
+ packStationBookings,
680
+ matchOperatorSkills,
681
+ runStationLanes,
682
+ foldDrawerEvents,
683
+ detectBookingCollisions,
684
+ };
package/package.json CHANGED
@@ -1,12 +1,25 @@
1
1
  {
2
2
  "name": "fenne",
3
- "version": "1.0.0",
4
- "description": "",
3
+ "version": "1.0.1",
4
+ "description": "Appointment studio helpers for station blocks, floor queues, booking packs, operator skills, paced lanes, drawer ledgers, and collision scans",
5
5
  "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "README.md"
9
+ ],
6
10
  "scripts": {
7
11
  "test": "echo \"Error: no test specified\" && exit 1"
8
12
  },
9
- "keywords": [],
13
+ "keywords": [
14
+ "appointment",
15
+ "studio",
16
+ "station",
17
+ "queue",
18
+ "booking",
19
+ "operator",
20
+ "drawer",
21
+ "collision"
22
+ ],
10
23
  "author": "",
11
24
  "license": "ISC",
12
25
  "type": "commonjs"