Compare commits
4
Commits
e670e1ee72
...
8e4a0e849e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e4a0e849e | ||
|
|
0548cb54fa | ||
|
|
c1152b4ab3 | ||
|
|
a692b6a9e2 |
@@ -521,11 +521,16 @@ below. Nothing is installed without someone clicking.
|
|||||||
A photo library fills it: point the module at a Google Drive folder shared
|
A photo library fills it: point the module at a Google Drive folder shared
|
||||||
"anyone with the link" (file name → person's name) or at a folder on the
|
"anyone with the link" (file name → person's name) or at a folder on the
|
||||||
ProdDash machine, and photos added or removed there follow. **Edit** in the
|
ProdDash machine, and photos added or removed there follow. **Edit** in the
|
||||||
title bar opens that library — drag a photo onto a slot, drag between slots
|
title bar opens that library — drag a photo onto a slot (mouse, or press and
|
||||||
to swap, rename or clear. Planning Center is optional auto-fill on top, not
|
hold on a touchscreen), drag between slots to swap, rename or clear.
|
||||||
the source of truth: **Sync** reads the upcoming plan's team members and
|
Planning Center is optional auto-fill on top, not the source of truth:
|
||||||
drops each into the slot matching their position, filling only slots nobody
|
**Sync** reads the upcoming plan's team members and places each one. A
|
||||||
placed by hand, so a guest pinned on Thursday survives Sunday's sync.
|
person whose Planning Center note names their mic ("Mix 8, VOX 5") goes to
|
||||||
|
that slot — this is how the leader and vocalists, whose position is just
|
||||||
|
"Vocalist", land on the right mic — and everyone else goes to the slot
|
||||||
|
matching their position, one slot per person. Anything placed or moved by
|
||||||
|
hand for that plan stays put through later syncs; last week's people are
|
||||||
|
replaced.
|
||||||
Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and
|
Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and
|
||||||
raining confetti, and a Choir button adds the extra spot for the weeks there
|
raining confetti, and a Choir button adds the extra spot for the weeks there
|
||||||
is one. Admin config: Planning Center credentials, service type and plan, the
|
is one. Admin config: Planning Center credentials, service type and plan, the
|
||||||
|
|||||||
+111
-47
@@ -1,5 +1,5 @@
|
|||||||
// Band Lineup — client. Renders the shared stage board from /state + /stream.
|
// Band Lineup — client. Renders the shared stage board from /state + /stream.
|
||||||
// The Edit button opens an editor: an uploaded-photo library you drag (or click)
|
// The Edit button opens an editor: an uploaded-photo library you drag (or tap)
|
||||||
// into slots, plus rename / clear / upload / delete. Edits go to the server and
|
// into slots, plus rename / clear / upload / delete. Edits go to the server and
|
||||||
// come back over SSE, so every Band Lineup screen stays in sync. Birthday week
|
// come back over SSE, so every Band Lineup screen stays in sync. Birthday week
|
||||||
// (Wed→Tue) rains confetti.
|
// (Wed→Tue) rains confetti.
|
||||||
@@ -14,26 +14,107 @@ export default function create({ root, moduleApi }) {
|
|||||||
let selectedLibId = ''; // click-to-place fallback for touch
|
let selectedLibId = ''; // click-to-place fallback for touch
|
||||||
const canvases = new Set();
|
const canvases = new Set();
|
||||||
let raf = null;
|
let raf = null;
|
||||||
// Auto-scroll the editor while dragging a photo (native DnD won't do it reliably).
|
/* ---------- drag (pointer events: one path for mouse, pen and touch) ----------
|
||||||
let dragScrolling = false;
|
* Native HTML drag-and-drop never fires for a finger, drops onto a card's child elements
|
||||||
let dragY = 0;
|
* flicker, and a board update arriving mid-drag rebuilt the editor out from under it. This
|
||||||
let dragBody = null;
|
* drives the drag by hand instead: a mouse drags as soon as it moves; a finger holds still for
|
||||||
let dragRaf = null;
|
* a moment first (so a quick swipe still scrolls the editor). A floating copy of the photo
|
||||||
function startDragAutoScroll() {
|
* follows the pointer, the spot under it lights up, and the editor auto-scrolls near its
|
||||||
if (dragRaf) return;
|
* edges. Board updates that arrive during a drag wait until it ends. */
|
||||||
const tick = () => {
|
const DRAG_MOVE_PX = 6;
|
||||||
if (!dragScrolling || !dragBody || !dragBody.isConnected) { dragRaf = null; return; }
|
const TOUCH_HOLD_MS = 220;
|
||||||
const r = dragBody.getBoundingClientRect();
|
let drag = null; // { kind: 'lib'|'slot', id, body, ghost, over, x, y, raf }
|
||||||
const zone = 90; // generous edge band so you don't have to be pixel-accurate
|
let pending = null; // a pointerdown that may become a drag
|
||||||
let dy = 0;
|
let renderHeld = false; // a state update arrived mid-drag; render when it ends
|
||||||
if (dragY < r.top + zone) dy = -Math.ceil((r.top + zone - dragY) / 4) - 3;
|
|
||||||
else if (dragY > r.bottom - zone) dy = Math.ceil((dragY - (r.bottom - zone)) / 4) + 3;
|
function slotUnder(x, y) {
|
||||||
if (dy) dragBody.scrollTop += dy;
|
const hit = document.elementFromPoint(x, y);
|
||||||
dragRaf = requestAnimationFrame(tick);
|
const cell = hit && hit.closest ? hit.closest('.bl-edit-slot') : null;
|
||||||
};
|
return cell && root.contains(cell) ? cell : null;
|
||||||
dragRaf = requestAnimationFrame(tick);
|
|
||||||
}
|
}
|
||||||
function stopDragAutoScroll() { dragScrolling = false; if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = null; } }
|
function beginDrag(p) {
|
||||||
|
const ghost = el('div', 'bl-drag-ghost');
|
||||||
|
ghost.dataset.module = 'band-lineup'; // lives on <body> (above every tile), so it carries its own scope
|
||||||
|
const src = p.source.querySelector('img');
|
||||||
|
if (src) { const img = document.createElement('img'); img.src = src.src; img.alt = ''; ghost.appendChild(img); }
|
||||||
|
else ghost.textContent = p.label || '';
|
||||||
|
document.body.appendChild(ghost);
|
||||||
|
drag = { kind: p.kind, id: p.id, body: p.body, ghost, over: null, x: p.x, y: p.y, raf: null, source: p.source };
|
||||||
|
p.source.classList.add('bl-dragging');
|
||||||
|
root.classList.add('bl-is-dragging');
|
||||||
|
moveDrag(p.x, p.y);
|
||||||
|
const tick = () => {
|
||||||
|
if (!drag) return;
|
||||||
|
const b = drag.body;
|
||||||
|
if (b && b.isConnected) {
|
||||||
|
const r = b.getBoundingClientRect(); const zone = 70; let dy = 0;
|
||||||
|
if (drag.y < r.top + zone) dy = -Math.ceil((r.top + zone - drag.y) / 4) - 2;
|
||||||
|
else if (drag.y > r.bottom - zone) dy = Math.ceil((drag.y - (r.bottom - zone)) / 4) + 2;
|
||||||
|
if (dy) { b.scrollTop += dy; moveDrag(drag.x, drag.y); }
|
||||||
|
}
|
||||||
|
drag.raf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
drag.raf = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
function moveDrag(x, y) {
|
||||||
|
drag.x = x; drag.y = y;
|
||||||
|
drag.ghost.style.transform = `translate(${x}px, ${y}px)`;
|
||||||
|
const cell = slotUnder(x, y);
|
||||||
|
if (cell !== drag.over) {
|
||||||
|
if (drag.over) drag.over.classList.remove('bl-drop-over');
|
||||||
|
drag.over = cell;
|
||||||
|
if (cell) cell.classList.add('bl-drop-over');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function endDrag(drop) {
|
||||||
|
const d = drag; drag = null;
|
||||||
|
if (!d) return;
|
||||||
|
if (d.raf) cancelAnimationFrame(d.raf);
|
||||||
|
d.ghost.remove();
|
||||||
|
d.source.classList.remove('bl-dragging');
|
||||||
|
root.classList.remove('bl-is-dragging');
|
||||||
|
if (d.over) d.over.classList.remove('bl-drop-over');
|
||||||
|
if (drop && d.over) {
|
||||||
|
const to = d.over.dataset.index;
|
||||||
|
if (d.kind === 'lib') assign(to, d.id);
|
||||||
|
else if (d.id !== to) api(`/move?from=${d.id}&to=${to}`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
if (renderHeld) { renderHeld = false; render(); }
|
||||||
|
}
|
||||||
|
function cancelPending() { if (pending && pending.timer) clearTimeout(pending.timer); pending = null; }
|
||||||
|
// Make `node` draggable. `kind`/`id` say what is being dragged.
|
||||||
|
function makeDraggable(node, kind, id, body, label) {
|
||||||
|
node.addEventListener('pointerdown', (e) => {
|
||||||
|
if (e.button !== 0 || drag) return;
|
||||||
|
if (e.target.closest('button')) return; // name / clear / delete buttons keep working
|
||||||
|
cancelPending();
|
||||||
|
pending = { kind, id, body, label, source: node, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, pointerId: e.pointerId, touch: e.pointerType !== 'mouse', armed: e.pointerType === 'mouse', timer: null };
|
||||||
|
if (pending.touch) {
|
||||||
|
const p = pending;
|
||||||
|
p.timer = setTimeout(() => { if (pending === p) { p.armed = true; beginDrag(p); try { node.setPointerCapture(p.pointerId); } catch { /* gone */ } } }, TOUCH_HOLD_MS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Document-level move/up so the drag survives leaving the card it started on.
|
||||||
|
function onPointerMove(e) {
|
||||||
|
if (drag) { e.preventDefault(); moveDrag(e.clientX, e.clientY); return; }
|
||||||
|
if (!pending || e.pointerId !== pending.pointerId) return;
|
||||||
|
const dist = Math.hypot(e.clientX - pending.sx, e.clientY - pending.sy);
|
||||||
|
if (pending.touch) { if (dist > 10) cancelPending(); return; } // moved before the hold: it's a scroll
|
||||||
|
if (dist >= DRAG_MOVE_PX) { const p = pending; pending = null; p.x = e.clientX; p.y = e.clientY; beginDrag(p); }
|
||||||
|
}
|
||||||
|
function onPointerUp(e) {
|
||||||
|
if (drag) { e.preventDefault(); suppressClick = true; setTimeout(() => { suppressClick = false; }, 0); endDrag(e.type === 'pointerup'); }
|
||||||
|
cancelPending();
|
||||||
|
}
|
||||||
|
let suppressClick = false; // the click that follows a drop must not also select / place
|
||||||
|
// While a finger drag is live, stop the page from scrolling under it.
|
||||||
|
function onTouchMove(e) { if (drag) e.preventDefault(); }
|
||||||
|
document.addEventListener('pointermove', onPointerMove, { passive: false });
|
||||||
|
document.addEventListener('pointerup', onPointerUp);
|
||||||
|
document.addEventListener('pointercancel', onPointerUp);
|
||||||
|
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||||
|
function stopDragAutoScroll() { cancelPending(); endDrag(false); }
|
||||||
|
|
||||||
/* ---------- confetti (always animates) ---------- */
|
/* ---------- confetti (always animates) ---------- */
|
||||||
function makeParticle(w, h, intensity, seedY) {
|
function makeParticle(w, h, intensity, seedY) {
|
||||||
@@ -79,6 +160,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
function stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } }
|
function stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } }
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
|
if (drag) { renderHeld = true; return; }
|
||||||
stopConfetti();
|
stopConfetti();
|
||||||
stopParallax();
|
stopParallax();
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
@@ -163,8 +245,6 @@ export default function create({ root, moduleApi }) {
|
|||||||
// board on top and the picture picker (library) below it.
|
// board on top and the picture picker (library) below it.
|
||||||
const wrap = el('div', 'bl-editor bl-editor-nobar');
|
const wrap = el('div', 'bl-editor bl-editor-nobar');
|
||||||
const body = el('div', 'bl-editor-body');
|
const body = el('div', 'bl-editor-body');
|
||||||
// Feed the drag auto-scroller the pointer position anywhere over the editor.
|
|
||||||
body.addEventListener('dragover', (e) => { dragY = e.clientY; });
|
|
||||||
|
|
||||||
// Slots (drop targets)
|
// Slots (drop targets)
|
||||||
const boardEl = el('div', 'bl-edit-board');
|
const boardEl = el('div', 'bl-edit-board');
|
||||||
@@ -176,14 +256,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
photo.appendChild(slot.person ? photoNode(slot.person) : el('div', 'bl-edit-drop', '+'));
|
photo.appendChild(slot.person ? photoNode(slot.person) : el('div', 'bl-edit-drop', '+'));
|
||||||
cell.appendChild(photo);
|
cell.appendChild(photo);
|
||||||
// An occupied card is a drag SOURCE too — move/swap it to another slot.
|
// An occupied card is a drag SOURCE too — move/swap it to another slot.
|
||||||
if (slot.person) {
|
if (slot.person) makeDraggable(cell, 'slot', String(slot.index), body, slot.person.name);
|
||||||
cell.draggable = true;
|
|
||||||
cell.addEventListener('dragstart', (e) => {
|
|
||||||
e.dataTransfer.setData('text/plain', 'slot:' + slot.index); e.dataTransfer.effectAllowed = 'move';
|
|
||||||
dragBody = body; dragScrolling = true; startDragAutoScroll();
|
|
||||||
});
|
|
||||||
cell.addEventListener('dragend', stopDragAutoScroll);
|
|
||||||
}
|
|
||||||
const nameRow = el('div', 'bl-edit-namerow');
|
const nameRow = el('div', 'bl-edit-namerow');
|
||||||
const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…');
|
const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…');
|
||||||
nameBtn.addEventListener('click', () => renameSlot(slot.index, slot.person ? slot.person.name : ''));
|
nameBtn.addEventListener('click', () => renameSlot(slot.index, slot.person ? slot.person.name : ''));
|
||||||
@@ -191,16 +264,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
if (slot.person) { const clr = el('button', 'bl-edit-clear', '×'); clr.title = 'Clear'; clr.addEventListener('click', () => api(`/slot/${slot.index}/clear`, { method: 'POST' })); nameRow.appendChild(clr); }
|
if (slot.person) { const clr = el('button', 'bl-edit-clear', '×'); clr.title = 'Clear'; clr.addEventListener('click', () => api(`/slot/${slot.index}/clear`, { method: 'POST' })); nameRow.appendChild(clr); }
|
||||||
cell.appendChild(nameRow);
|
cell.appendChild(nameRow);
|
||||||
cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`));
|
cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`));
|
||||||
// drop
|
cell.addEventListener('click', (e) => { if (suppressClick) return; if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
|
||||||
cell.addEventListener('dragover', (e) => { e.preventDefault(); cell.classList.add('bl-drop-over'); });
|
|
||||||
cell.addEventListener('dragleave', () => cell.classList.remove('bl-drop-over'));
|
|
||||||
cell.addEventListener('drop', (e) => {
|
|
||||||
e.preventDefault(); stopDragAutoScroll(); cell.classList.remove('bl-drop-over');
|
|
||||||
const data = e.dataTransfer.getData('text/plain') || '';
|
|
||||||
if (data.startsWith('lib:')) assign(slot.index, data.slice(4));
|
|
||||||
else if (data.startsWith('slot:')) { const from = data.slice(5); if (from !== String(slot.index)) api(`/move?from=${from}&to=${slot.index}`, { method: 'POST' }); }
|
|
||||||
});
|
|
||||||
cell.addEventListener('click', (e) => { if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
|
|
||||||
boardEl.appendChild(cell);
|
boardEl.appendChild(cell);
|
||||||
}
|
}
|
||||||
body.appendChild(boardEl);
|
body.appendChild(boardEl);
|
||||||
@@ -210,7 +274,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
const list = state?.library || [];
|
const list = state?.library || [];
|
||||||
if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.'));
|
if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.'));
|
||||||
for (const person of list) {
|
for (const person of list) {
|
||||||
const item = el('div', 'bl-lib-item'); item.draggable = true; item.dataset.id = person.id;
|
const item = el('div', 'bl-lib-item'); item.dataset.id = person.id;
|
||||||
if (person.id === selectedLibId) item.classList.add('bl-lib-selected');
|
if (person.id === selectedLibId) item.classList.add('bl-lib-selected');
|
||||||
const ph = el('div', 'bl-lib-photo'); ph.appendChild(photoNode(person, 'bl-lib-img')); item.appendChild(ph);
|
const ph = el('div', 'bl-lib-photo'); ph.appendChild(photoNode(person, 'bl-lib-img')); item.appendChild(ph);
|
||||||
const nm = el('button', 'bl-lib-name', person.name || 'Name…');
|
const nm = el('button', 'bl-lib-name', person.name || 'Name…');
|
||||||
@@ -219,12 +283,8 @@ export default function create({ root, moduleApi }) {
|
|||||||
const del = el('button', 'bl-lib-del', '×'); del.title = 'Delete photo';
|
const del = el('button', 'bl-lib-del', '×'); del.title = 'Delete photo';
|
||||||
del.addEventListener('click', (e) => { e.stopPropagation(); if (confirm(`Delete ${person.name || 'this photo'} from the library?`)) api(`/library/${person.id}/delete`, { method: 'POST' }); });
|
del.addEventListener('click', (e) => { e.stopPropagation(); if (confirm(`Delete ${person.name || 'this photo'} from the library?`)) api(`/library/${person.id}/delete`, { method: 'POST' }); });
|
||||||
item.appendChild(del);
|
item.appendChild(del);
|
||||||
item.addEventListener('dragstart', (e) => {
|
makeDraggable(item, 'lib', person.id, body, person.name);
|
||||||
e.dataTransfer.setData('text/plain', 'lib:' + person.id); e.dataTransfer.effectAllowed = 'copy';
|
item.addEventListener('click', () => { if (suppressClick) return; selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
|
||||||
dragBody = body; dragScrolling = true; startDragAutoScroll();
|
|
||||||
});
|
|
||||||
item.addEventListener('dragend', stopDragAutoScroll);
|
|
||||||
item.addEventListener('click', () => { selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
|
|
||||||
lib.appendChild(item);
|
lib.appendChild(item);
|
||||||
}
|
}
|
||||||
body.appendChild(lib);
|
body.appendChild(lib);
|
||||||
@@ -293,6 +353,10 @@ export default function create({ root, moduleApi }) {
|
|||||||
stopConfetti();
|
stopConfetti();
|
||||||
stopParallax();
|
stopParallax();
|
||||||
stopDragAutoScroll();
|
stopDragAutoScroll();
|
||||||
|
document.removeEventListener('pointermove', onPointerMove);
|
||||||
|
document.removeEventListener('pointerup', onPointerUp);
|
||||||
|
document.removeEventListener('pointercancel', onPointerUp);
|
||||||
|
document.removeEventListener('touchmove', onTouchMove);
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -117,9 +117,61 @@ function scoreMatch(slotLabel, positionLabel) {
|
|||||||
* member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot.
|
* member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot.
|
||||||
*/
|
*/
|
||||||
const MIN_SCORE = 60;
|
const MIN_SCORE = 60;
|
||||||
|
|
||||||
|
/** The mix numbers a label names ("[VOX 5] MIX 8" → {"8"}; "Mix 8, VOX 5" → {"8"}). */
|
||||||
|
function mixNumbers(value) {
|
||||||
|
const out = new Set(); const re = /\bmix\s*(\d+)\b/gi; let m;
|
||||||
|
while ((m = re.exec(String(value || '')))) out.add(m[1]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How well a person's Planning Center note names a spot. The booth writes the mic/role into each
|
||||||
|
* vocalist's and the leader's note every week ("Mix 8, VOX 5", "DP48, Drums"), because those
|
||||||
|
* change week to week and the position name ("Vocalist") doesn't say which mic. A note that names a
|
||||||
|
* different mix than the spot is never a match; the role part ("VOX 5") is scored like a position.
|
||||||
|
* 0 means the note says nothing usable about this spot ("NA", blank, or no overlap).
|
||||||
|
*/
|
||||||
|
function noteScore(slotLabel, note) {
|
||||||
|
const text = String(note || '').trim();
|
||||||
|
if (!text || /^n\/?a$/i.test(text)) return 0;
|
||||||
|
const slotMix = mixNumbers(slotLabel); const noteMix = mixNumbers(text);
|
||||||
|
let mixBonus = 0;
|
||||||
|
if (noteMix.size && slotMix.size) {
|
||||||
|
if (![...noteMix].some((n) => slotMix.has(n))) return 0;
|
||||||
|
mixBonus = 100;
|
||||||
|
}
|
||||||
|
const role = text.replace(/\bmix\s*\d+\b/gi, ' ');
|
||||||
|
const roleScore = scoreMatch(slotRoleText(slotLabel), role);
|
||||||
|
if (!roleScore && !mixBonus) return 0;
|
||||||
|
if (noteMix.size && slotMix.size && roleScore === 0 && role.replace(/[^a-z0-9]/gi, '')) {
|
||||||
|
// The mix agrees but the role contradicts it (e.g. "Mix 8, Keys" on a VOX spot) — trust neither.
|
||||||
|
const roleTokens = labelTokens(role);
|
||||||
|
if (roleTokens.length) return 0;
|
||||||
|
}
|
||||||
|
return mixBonus + roleScore;
|
||||||
|
}
|
||||||
|
|
||||||
function assignMembersToSlots(slots, members) {
|
function assignMembersToSlots(slots, members) {
|
||||||
const pool = members.slice();
|
const pool = members.slice();
|
||||||
return slots.map((slotLabel) => {
|
const result = slots.map(() => null);
|
||||||
|
const take = (chosen) => {
|
||||||
|
// One spot per person: someone scheduled twice (Leader + Acoustic) must not fill a second spot.
|
||||||
|
const key = chosen.personId || '';
|
||||||
|
for (let i = pool.length - 1; i >= 0; i -= 1) if (pool[i] === chosen || (key && pool[i].personId === key)) pool.splice(i, 1);
|
||||||
|
};
|
||||||
|
// Pass 1 — people whose note names their spot go exactly there, best-fitting pairs first.
|
||||||
|
const pairs = [];
|
||||||
|
slots.forEach((slotLabel, s) => pool.forEach((m) => { const sc = noteScore(slotLabel, m.note); if (sc >= MIN_SCORE) pairs.push({ s, m, sc }); }));
|
||||||
|
pairs.sort((a, b) => b.sc - a.sc);
|
||||||
|
for (const { s, m } of pairs) {
|
||||||
|
if (result[s] || !pool.includes(m)) continue;
|
||||||
|
result[s] = m; take(m);
|
||||||
|
}
|
||||||
|
// Pass 2 — everyone else by Planning Center position, into the spots still open.
|
||||||
|
return result.map((placed, s) => {
|
||||||
|
if (placed) return placed;
|
||||||
|
const slotLabel = slots[s];
|
||||||
const role = slotRoleText(slotLabel);
|
const role = slotRoleText(slotLabel);
|
||||||
let bestIndex = -1;
|
let bestIndex = -1;
|
||||||
let bestScore = MIN_SCORE - 1;
|
let bestScore = MIN_SCORE - 1;
|
||||||
@@ -128,7 +180,9 @@ function assignMembersToSlots(slots, members) {
|
|||||||
if (score > bestScore) { bestScore = score; bestIndex = i; }
|
if (score > bestScore) { bestScore = score; bestIndex = i; }
|
||||||
}
|
}
|
||||||
if (bestIndex < 0) return null;
|
if (bestIndex < 0) return null;
|
||||||
return pool.splice(bestIndex, 1)[0];
|
const chosen = pool[bestIndex];
|
||||||
|
take(chosen);
|
||||||
|
return chosen;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,5 +248,7 @@ module.exports = {
|
|||||||
numbersIn,
|
numbersIn,
|
||||||
scoreMatch,
|
scoreMatch,
|
||||||
assignMembersToSlots,
|
assignMembersToSlots,
|
||||||
|
noteScore,
|
||||||
|
mixNumbers,
|
||||||
MIN_SCORE,
|
MIN_SCORE,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -65,3 +65,41 @@ console.log('assignment:', JSON.stringify(out));
|
|||||||
assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null');
|
assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null');
|
||||||
console.log('bestLibraryMatch: all assertions passed ✓');
|
console.log('bestLibraryMatch: all assertions passed ✓');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- one spot per person: someone scheduled twice (Leader + Acoustic) fills one spot only ---
|
||||||
|
{
|
||||||
|
const P = (label, name, personId) => ({ label, name, personId });
|
||||||
|
const slots = ['[LEADER, VOX 1, ACOUSTIC] MIX 1', '[GUITAR, 2] MIX 7'];
|
||||||
|
const got = assignMembersToSlots(slots, [P('[Leader]', 'Mike', 'mp'), P('Guitar [Acoustic]', 'Mike', 'mp'), P('Guitar [Rhythm]', 'Craig', 'cs')]).map((m) => (m ? m.name : null));
|
||||||
|
assert.deepStrictEqual(got, ['Mike', 'Craig'], 'Mike once, Craig gets the guitar spot');
|
||||||
|
console.log('one spot per person: all assertions passed ✓');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Planning Center notes place the leader and vocalists (real notes from Oct 25 / Nov 1, 2026) ---
|
||||||
|
{
|
||||||
|
const { noteScore } = require('./matching');
|
||||||
|
const NA_SLOTS = [
|
||||||
|
'[LEADER, VOX 1, ACOUSTIC] MIX 1', '[VOX 2] MIX 2', '[VOX 3] MIX 3', '[VOX 4] MIX 4', '[GUITAR, LEAD] MIX 5',
|
||||||
|
'[BASS] MIX 6', '[GUITAR, 2] MIX 7', '[VOX 5] MIX 8', '[PIANO]', '[DRUMS]', '[KEYS 2]',
|
||||||
|
];
|
||||||
|
const P = (label, name, personId, note) => ({ label, name, personId, note });
|
||||||
|
// Listed in the order Planning Center returned them — vocalists alphabetical, not by mic.
|
||||||
|
const oct25 = [
|
||||||
|
P('Vocalist', 'DJ', 'dj', 'Mix 3, VOX 3'), P('[Leader]', 'Mike', 'mp', 'Mix 1, VOX 1'), P('Guitar [Lead]', 'Yeremis', 'yc'),
|
||||||
|
P('Piano/Pad', 'Kevin', 'km'), P('Vocalist', 'Genesis', 'gg', 'Mix 4, VOX 4'), P('Guitar [Rhythm]', 'Craig', 'cs'),
|
||||||
|
P('Guitar [Acoustic]', 'Mike', 'mp'), P('Vocalist', 'Rosaliz', 'rg', 'Mix 2, VOX 2'), P('Drums', 'Luis', 'ld'),
|
||||||
|
P('Vocalist', 'Bianca', 'bb', 'Mix 8, VOX 5'), P('Guitar [Bass]', 'Joram', 'jl'),
|
||||||
|
];
|
||||||
|
const got = assignMembersToSlots(NA_SLOTS, oct25).map((m) => (m ? m.name : null));
|
||||||
|
assert.deepStrictEqual(got, ['Mike', 'Rosaliz', 'DJ', 'Genesis', 'Yeremis', 'Joram', 'Craig', 'Bianca', 'Kevin', 'Luis', null], 'Oct 25 by notes');
|
||||||
|
// Nov 1: leader note in different case, a drum note with no mix, an "NA" note.
|
||||||
|
const nov1 = [P('Guitar [Acoustic]', 'Mike', 'mp', 'NA'), P('Drums', 'Alex', 'ag', 'DP48, Drums'), P('[Leader]', 'Mike', 'mp', 'Mix 1, Vox 1')];
|
||||||
|
assert.deepStrictEqual(assignMembersToSlots(NA_SLOTS, nov1).map((m) => (m ? m.name : null)).filter(Boolean), ['Mike', 'Alex'], 'Nov 1: Mike once, Alex on drums');
|
||||||
|
assert.strictEqual(assignMembersToSlots(NA_SLOTS, nov1)[9].name, 'Alex', 'DP48, Drums → [DRUMS]');
|
||||||
|
// A note never pulls someone onto a different mix.
|
||||||
|
assert.strictEqual(noteScore('[VOX 2] MIX 2', 'Mix 8, VOX 5'), 0, 'Mix 8 note must not match MIX 2');
|
||||||
|
assert.strictEqual(noteScore('[VOX 5] MIX 8', 'Mix 8, VOX 5') > 0, true, 'Mix 8, VOX 5 matches [VOX 5] MIX 8');
|
||||||
|
assert.strictEqual(noteScore('[VOX 5] MIX 8', 'NA'), 0, 'NA says nothing');
|
||||||
|
assert.strictEqual(noteScore('[VOX 1] MIX 10', 'Mix 1, VOX 1') , 0, 'Mix 1 must not match MIX 10');
|
||||||
|
console.log('notes: all assertions passed ✓');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "band-lineup",
|
"id": "band-lineup",
|
||||||
"name": "Band Lineup",
|
"name": "Band Lineup",
|
||||||
"version": "1.11.1",
|
"version": "1.12.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
|
"description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
|
|||||||
@@ -512,6 +512,7 @@ module.exports = {
|
|||||||
personId,
|
personId,
|
||||||
name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
|
name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
|
||||||
label: String(m.attributes?.team_position_name || '').trim(),
|
label: String(m.attributes?.team_position_name || '').trim(),
|
||||||
|
note: String(m.attributes?.notes || '').trim(), // "Mix 8, VOX 5" — where the booth wants them this week
|
||||||
photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(),
|
photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(),
|
||||||
});
|
});
|
||||||
if (personId) seen.add(personId);
|
if (personId) seen.add(personId);
|
||||||
@@ -530,8 +531,8 @@ module.exports = {
|
|||||||
const openIdx = []; const manualNames = new Set();
|
const openIdx = []; const manualNames = new Set();
|
||||||
for (let i = 0; i < eff.length; i += 1) {
|
for (let i = 0; i < eff.length; i += 1) {
|
||||||
const e = board[String(i)];
|
const e = board[String(i)];
|
||||||
if (e && (e.personId || e.name) && e.plan === planId) {
|
if (e && (e.personId || e.name || e.pco) && e.plan === planId) {
|
||||||
const nm = e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
|
const nm = e.pco ? e.pco.name : e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
|
||||||
if (nm) manualNames.add(normName(nm));
|
if (nm) manualNames.add(normName(nm));
|
||||||
} else openIdx.push(i);
|
} else openIdx.push(i);
|
||||||
}
|
}
|
||||||
@@ -675,6 +676,10 @@ module.exports = {
|
|||||||
const from = String(p.get('from') || ''); const to = String(p.get('to') || '');
|
const from = String(p.get('from') || ''); const to = String(p.get('to') || '');
|
||||||
if (!/^\d+$/.test(from) || !/^\d+$/.test(to) || from === to) return json(res, 400, { error: 'Bad move.' });
|
if (!/^\d+$/.test(from) || !/^\d+$/.test(to) || from === to) return json(res, 400, { error: 'Bad move.' });
|
||||||
const a = current.board[from]; const b = current.board[to];
|
const a = current.board[from]; const b = current.board[to];
|
||||||
|
// Moving a spot is a hand placement for this plan: Sync must not move it back.
|
||||||
|
const plan = current.planId();
|
||||||
|
if (a) a.plan = plan;
|
||||||
|
if (b) b.plan = plan;
|
||||||
if (a === undefined) delete current.board[to]; else current.board[to] = a;
|
if (a === undefined) delete current.board[to]; else current.board[to] = a;
|
||||||
if (b === undefined) delete current.board[from]; else current.board[from] = b;
|
if (b === undefined) delete current.board[from]; else current.board[from] = b;
|
||||||
current.saveBoard(); current.rebuildLocal();
|
current.saveBoard(); current.rebuildLocal();
|
||||||
|
|||||||
@@ -230,3 +230,20 @@
|
|||||||
background: color-mix(in srgb, var(--bg) 60%, transparent); color: var(--text); cursor: pointer; font-size: 0.85em; line-height: 1;
|
background: color-mix(in srgb, var(--bg) 60%, transparent); color: var(--text); cursor: pointer; font-size: 0.85em; line-height: 1;
|
||||||
}
|
}
|
||||||
[data-module="band-lineup"] .bl-lib-del:hover { background: var(--danger); color: #fff; }
|
[data-module="band-lineup"] .bl-lib-del:hover { background: var(--danger); color: #fff; }
|
||||||
|
|
||||||
|
/* Drag: the floating photo that follows the pointer (appended to <body>, so it scopes itself). */
|
||||||
|
[data-module="band-lineup"].bl-drag-ghost {
|
||||||
|
position: fixed; left: 0; top: 0; z-index: 10000; pointer-events: none;
|
||||||
|
width: 72px; aspect-ratio: 3 / 4; margin: -48px 0 0 -36px; border-radius: 8px; overflow: hidden;
|
||||||
|
border: 2px solid var(--accent); background: var(--panel, #222); color: var(--text); font-size: 11px;
|
||||||
|
display: flex; align-items: center; justify-content: center; text-align: center;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); opacity: 0.92;
|
||||||
|
}
|
||||||
|
[data-module="band-lineup"].bl-drag-ghost img { width: 100%; height: 100%; object-fit: cover; object-position: center top; }
|
||||||
|
[data-module="band-lineup"] .bl-lib-item,
|
||||||
|
[data-module="band-lineup"] .bl-edit-slot { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
||||||
|
[data-module="band-lineup"] .bl-edit-slot img,
|
||||||
|
[data-module="band-lineup"] .bl-lib-item img { -webkit-user-drag: none; pointer-events: none; }
|
||||||
|
[data-module="band-lineup"] .bl-dragging { opacity: 0.35; }
|
||||||
|
[data-module="band-lineup"].bl-is-dragging, [data-module="band-lineup"] .bl-is-dragging { cursor: grabbing; }
|
||||||
|
[data-module="band-lineup"] .bl-is-dragging .bl-edit-slot { transition: box-shadow 0.08s; }
|
||||||
|
|||||||
Reference in New Issue
Block a user