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>
108 lines
4.1 KiB
JavaScript
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 };
|