Files
hope-events/backend/src/utils/allocation.js
T
joshuaandClaude Opus 5.5 1516e104df Add rooms & groups allocation
Supervisors can split an event's named attendees into allocation sets
(rooms, small groups, buses) with capacity, gender and leader per group,
using a drag-and-drop board or auto-allocate (keep bookings together or
split them). Adds printable group and master lists (medical view
audited), the group on tickets and in My Events, messaging by group,
a "My groups" page for leaders, and an optional per-event gender
question for named attendees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:44:58 +02:00

108 lines
4.1 KiB
JavaScript

/**
* Group / room allocation — placing named attendees into the groups of an allocation
* set ("Rooms", "Buses"…). Groups can have a capacity and a gender restriction.
* The placement rules and auto-allocate are pure, so they're unit-tested.
*/
const GENDERS = ['male', 'female'];
function allocationError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
/** Normalises a gender value ('male' | 'female' | null); throws on anything else. */
function cleanGender(value) {
if (value === undefined || value === null || value === '') return null;
const g = String(value).trim().toLowerCase();
if (!GENDERS.includes(g)) throw allocationError('Gender must be male or female.');
return g;
}
/**
* Why `attendees` can't all join `group` (which has `count` members already), or null. Pure.
* @param {object} group - { name, capacity?, gender? }
*/
function joinBlocker(group, count, attendees) {
if (group.capacity != null && count + attendees.length > group.capacity) {
return `${group.name} is full (${group.capacity} places).`;
}
if (group.gender) {
const wrong = attendees.find(a => a.gender !== group.gender);
if (wrong) return `${group.name} is for ${group.gender === 'male' ? 'males' : 'females'} only${wrong.gender ? '' : ` — ${wrong.name} has no gender recorded`}.`;
}
return null;
}
/**
* Places every unassigned attendee it can. Pure.
*
* Groups fill evenly (the emptiest group that fits goes first). With mode 'together',
* people on the same registration (siblings) stay in one group when a group can take
* them all, otherwise they're placed one by one. With mode 'split', people on the same
* registration (friends booking together) are spread across groups where possible.
*
* @param {object} p
* @param {Array} p.groups - [{ id, name, capacity?, gender?, order? }]
* @param {Array} p.attendees - [{ id, name, registrationId, gender? }]
* @param {Map} p.assigned - attendeeId → groupId for attendees already placed
* @param {'together'|'split'} [p.mode]
* @returns {{ placements: {attendeeId, groupId}[], unplaced: string[] }}
*/
function autoAllocate({ groups, attendees, assigned = new Map(), mode = 'together' }) {
const count = new Map(groups.map(g => [g.id, 0]));
const regInGroup = new Map(groups.map(g => [g.id, new Map()]));
const byId = new Map(attendees.map(a => [a.id, a]));
const place = (groupId, a) => {
count.set(groupId, count.get(groupId) + 1);
const regs = regInGroup.get(groupId);
regs.set(a.registrationId, (regs.get(a.registrationId) || 0) + 1);
};
for (const [attendeeId, groupId] of assigned) {
const a = byId.get(attendeeId);
if (a && count.has(groupId)) place(groupId, a);
}
const todo = attendees.filter(a => !assigned.has(a.id));
let units;
if (mode === 'together') {
const byReg = new Map();
for (const a of todo) byReg.set(a.registrationId, [...(byReg.get(a.registrationId) || []), a]);
units = [...byReg.values()].sort((x, y) => y.length - x.length);
} else {
units = todo.map(a => [a]);
}
const orderOf = new Map(groups.map((g, i) => [g.id, g.order ?? i]));
const best = (members) => {
const reg = members[0].registrationId;
const fitting = groups.filter(g => !joinBlocker(g, count.get(g.id), members));
if (fitting.length === 0) return null;
fitting.sort((x, y) =>
(mode === 'split' ? (regInGroup.get(x.id).get(reg) || 0) - (regInGroup.get(y.id).get(reg) || 0) : 0)
|| count.get(x.id) - count.get(y.id)
|| orderOf.get(x.id) - orderOf.get(y.id));
return fitting[0];
};
const placements = [];
const unplaced = [];
const queue = [...units];
while (queue.length) {
const unit = queue.shift();
const g = best(unit);
if (g) {
for (const a of unit) { place(g.id, a); placements.push({ attendeeId: a.id, groupId: g.id }); }
} else if (unit.length > 1) {
// Can't keep them together — place them one by one instead.
queue.unshift(...unit.map(a => [a]));
} else {
unplaced.push(unit[0].id);
}
}
return { placements, unplaced };
}
module.exports = { GENDERS, allocationError, cleanGender, joinBlocker, autoAllocate };