`; }
}
// Grouped by location with sticky headers + counts, in a scrollable list —
// the same layout as the Team logins list.
function drawRosterMgmt() {
if (!rosterBody) return;
if (!rosterTeams.length) { rosterBody.innerHTML = '
`;
});
rosterBody.innerHTML = html;
}
async function rosterAdd(username, name) {
const clean = (name || '').trim();
if (!clean) return;
try { await apiPost('/api/admin/rosters/add', { username, name: clean }); await openRosterMgmt(); }
catch (err) { showToast('Failed: ' + err.message, 'error'); }
}
async function rosterRemove(username, name) {
try { await apiPost('/api/admin/rosters/remove', { username, name }); await openRosterMgmt(); }
catch (err) { showToast('Failed: ' + err.message, 'error'); }
}
// Scoped write endpoints used by team admins. The master admin keeps using
// the full-state pushAdminState path (unchanged).
function apiCreateList(body) { return apiPost('/api/lists', body); }
function apiUpdateList(id, body) { return apiPut('/api/lists/' + encodeURIComponent(id), body); }
function apiDeleteList(id) { return apiDelete('/api/lists/' + encodeURIComponent(id)); }
function apiResetList(id) { return apiPost('/api/lists/' + encodeURIComponent(id) + '/reset', {}); }
function apiAddItem(listId, body) { return apiPost('/api/lists/' + encodeURIComponent(listId) + '/items', body); }
function apiUpdateItem(listId, itemId, body) { return apiPut('/api/lists/' + encodeURIComponent(listId) + '/items/' + encodeURIComponent(itemId), body); }
function apiDeleteItem(listId, itemId) { return apiDelete('/api/lists/' + encodeURIComponent(listId) + '/items/' + encodeURIComponent(itemId)); }
function apiMoveItem(listId, itemId, targetListId) { return apiPost('/api/lists/' + encodeURIComponent(listId) + '/items/' + encodeURIComponent(itemId) + '/move', { targetListId }); }
function apiReorderItems(listId, order) { return apiPost('/api/lists/' + encodeURIComponent(listId) + '/items/reorder', { order }); }
function apiReorderLists(order) { return apiPost('/api/lists/reorder', { order }); }
function apiScopedRestore(ops) { return apiPost('/api/scoped-restore', { ops }); }
// Run a scoped mutation, then re-sync from the server. Mirrors pushAdminState's
// isSaving guard so the 6s poll can't clobber an in-flight edit.
async function saveMutation(promise, beforeSnapshot) {
// Executives & team admins edit via scoped endpoints (not pushAdminState), so
// capture their owned lists before/after for undo/redo. (Master never hits this
// path — it uses pushAdminState — so this only records for non-master editors.)
// A caller that optimistically mutated state BEFORE calling us (e.g. drag-reorder)
// must pass its own pre-mutation snapshot, else `before` would already include the change.
const before = beforeSnapshot !== undefined ? beforeSnapshot
: ((!applyingHistory && isEditor() && !isMasterAdmin()) ? ownedListMap() : null);
isSaving = true;
try {
const res = await promise;
await refreshState({ showToastOnError: false });
if (before) {
const ops = diffOwnedMaps(before, ownedListMap()); // only the list(s) this action changed
if (ops.length) recordHistory({ type: 'scoped-ops', ops: ops });
}
return res || true;
} catch (err) {
showToast('Save failed: ' + err.message, 'error');
return false;
} finally {
isSaving = false;
}
}
// Lists this account may EDIT. Master: all. Team admin: only its own team's.
// Executive scope (mirrors server execCanEditList): INTERSECTION of locations
// AND teams; an empty column means "any" on that axis; no scope = no access.
function execCanEditClient(l) {
if (!currentUser) return false;
const locs = (currentUser.scopeLocations || []).map(normalizeName);
const teams = (currentUser.scopeTeams || []).map(normalizeName);
if (locs.length === 0 && teams.length === 0) return false;
const lLoc = normalizeName(l.location || '');
const lTeam = normalizeName(l.team || '');
const locationOK = locs.length === 0 || locs.includes(lLoc);
const teamOK = teams.length === 0 || teams.includes(lTeam);
return locationOK && teamOK;
}
function ownsListClient(l) {
if (!l) return false;
if (isMasterAdmin()) return true;
if (!isEditor()) return false; // members (role 'user') can never edit → no action buttons, no drag handle
if (!currentUser) return false;
if (currentUser.role === 'executive') return execCanEditClient(l);
const uLoc = normalizeName(currentUser.location || '');
const uTeam = normalizeName(currentUser.team || '');
return !!uTeam && normalizeName(l.location || '') === uLoc && normalizeName(l.team || '') === uTeam;
}
function editableLists() {
return (checklistData.lists || []).filter(ownsListClient);
}
// Owned lists keyed by id (deep-cloned) — used to diff before/after an edit so
// executive/team-admin undo touches ONLY the list(s) the action actually changed
// (never a co-owner's other lists). See diffOwnedMaps + the 'scoped-ops' history.
function ownedListMap() {
const m = {};
editableLists().forEach((l) => { m[l.id] = JSON.parse(JSON.stringify(l)); });
return m;
}
// ops = [{id, before, after}] for each owned list that changed (added/removed/edited).
function diffOwnedMaps(before, after) {
const ops = [];
const ids = new Set([...Object.keys(before), ...Object.keys(after)]);
ids.forEach((id) => {
const b = before[id] || null;
const a = after[id] || null;
if (JSON.stringify(b) !== JSON.stringify(a)) ops.push({ id: id, before: b, after: a });
});
return ops;
}
// ─── Initial setup ───────────────────────────────────────────
// Rich recurrence editors for the add- and edit-task modals (same option set as
// a list's repeat editor: type, every-N, start weekday, day/month, intraday times).
const addRec = makeRecurrenceEditor(recurrenceOptions ? recurrenceOptions.querySelector('.rec-editor') : null);
const editRec = makeRecurrenceEditor(editRecurrenceOptions ? editRecurrenceOptions.querySelector('.rec-editor') : null);
isRecurringCheckbox.addEventListener('change', () => {
recurrenceOptions.style.display = isRecurringCheckbox.checked ? 'block' : 'none';
});
// "+ Add Task" and "+ Add Note" are two distinct buttons sharing this one modal. addKind records
// which one opened it — a note has no checkbox and never recurs — so there's no in-modal toggle.
let addKind = 'task';
const addModalTitle = document.getElementById('addModalTitle');
function setAddMode(kind) {
addKind = kind === 'note' ? 'note' : 'task';
const isNote = addKind === 'note';
if (addModalTitle) addModalTitle.textContent = isNote ? 'Add Note' : 'Add Task';
if (addItemBtn) addItemBtn.textContent = isNote ? 'Add Note' : 'Add Task';
const recRow = document.getElementById('recurringFieldGroup');
if (recRow) recRow.style.display = isNote ? 'none' : '';
if (isNote) { isRecurringCheckbox.checked = false; if (recurrenceOptions) recurrenceOptions.style.display = 'none'; }
itemTitleInput.placeholder = isNote ? 'Note title…' : 'Task title...';
}
addItemBtn.addEventListener('click', addItem);
itemTitleInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); addItem(); }
});
addListBtn.addEventListener('click', () => openListModal('add'));
renameListBtn.addEventListener('click', () => openListModal('rename'));
deleteListBtn.addEventListener('click', deleteList);
if (resetListBtn) resetListBtn.addEventListener('click', resetCurrentList);
if (duplicateListBtn) duplicateListBtn.addEventListener('click', duplicateCurrentList);
// Undo / redo (full-admin): buttons + Ctrl/⌘+Z, Ctrl/⌘+Shift+Z (or Ctrl+Y).
if (undoBtn) undoBtn.addEventListener('click', undo);
if (redoBtn) redoBtn.addEventListener('click', redo);
document.addEventListener('keydown', (e) => {
if (!isEditor()) return;
const key = (e.key || '').toLowerCase();
if ((key !== 'z' && key !== 'y') || !(e.ctrlKey || e.metaKey)) return;
// Never hijack native text-editing undo inside inputs / the description editor.
const t = e.target;
if (t && ((t.matches && t.matches('input, textarea, select, [contenteditable], [contenteditable="true"]')) || t.isContentEditable)) return;
// Don't fire while a modal editor is open (the user is mid-edit there).
const modalOpen = [...document.querySelectorAll('.modal-overlay')].some((m) => m.style.display && m.style.display !== 'none');
if (modalOpen) return;
e.preventDefault();
if (key === 'y' || (key === 'z' && e.shiftKey)) redo(); else undo();
});
// ─── Scroll lock: freeze the page behind any open overlay ───────────
// The page itself scrolls now (full-page scroll), so without this the
// background scrolls behind a modal / the ☰ tools menu / the Move popover.
// One MutationObserver watches every overlay's style+class, so the lock
// engages no matter which code path opened it — modals (display flip),
// the list menu (.open), and the move popover (.open).
function anyOverlayOpen() {
const modal = [...document.querySelectorAll('.modal-overlay')].some((m) => m.style.display && m.style.display !== 'none');
return modal || !!document.querySelector('.list-menu.open, .move-menu.open');
}
function syncScrollLock() {
const lock = anyOverlayOpen();
document.documentElement.classList.toggle('scroll-locked', lock);
document.body.classList.toggle('scroll-locked', lock);
}
(function setupScrollLock() {
const obs = new MutationObserver(syncScrollLock);
document.querySelectorAll('.modal-overlay, .list-menu, .move-menu').forEach((el) =>
obs.observe(el, { attributes: true, attributeFilter: ['style', 'class'] }));
syncScrollLock();
})();
// Animate a modal/overlay OUT, then hide it — mirrors the ☰ menu's close.
// Keeps display:flex through the exit (so the animation shows AND the scroll lock
// stays engaged until it's truly gone), then flips display:none on animationend
// (with a timeout fallback). Reduced motion → instant hide. Every modal close
// routes through here, so the exit animation is consistent everywhere.
function closeOverlay(el) {
if (!el) return;
clearTimeout(el._closeTimer);
if (el.style.display === 'none') { el.classList.remove('closing'); return; }
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { el.classList.remove('closing'); el.style.display = 'none'; return; }
const finish = () => {
clearTimeout(el._closeTimer);
el.removeEventListener('animationend', onEnd, true);
el.classList.remove('closing');
el.style.display = 'none';
};
const onEnd = (e) => { if (e.animationName === 'modalOut' || e.target === el) finish(); };
el.classList.add('closing');
el.addEventListener('animationend', onEnd, true);
el._closeTimer = setTimeout(finish, 260);
}
// Replay a one-shot animation class (retrigger by removing + reflow + re-adding),
// and self-clean on animationend so it can fire again next time.
function playOnce(el, cls) {
if (!el) return;
el.classList.remove(cls);
void el.offsetWidth; // force reflow so the animation restarts
el.classList.add(cls);
el.addEventListener('animationend', () => el.classList.remove(cls), { once: true });
}
// ─── Motion One spring animations ──────────────────────────────────
// Motion One (window.Motion, vendored & loaded before this file) gives us
// real spring physics for the checklist. Everything here is OPTIONAL: if the
// library didn't load (offline first paint, blocked), motionReady() is false
// and the app keeps its pure-CSS animations. Reduced-motion always wins → instant.
function motionReady() { return !!(window.Motion && typeof window.Motion.animate === 'function'); }
function prefersReduced() {
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
}
// Tell the stylesheet that JS owns the item slide-in, so the CSS `itemArrive`
// transform stands down (the color flash stays). Without this flag the CSS
// fallback runs instead — so the app animates either way.
if (motionReady()) document.documentElement.classList.add('motion-on');
// ─── Haptics ───────────────────────────────────────────────────────
// Short vibration feedback on devices that support the Web Vibration API (Android Chrome/
// Firefox). iOS Safari has NO Vibration API and desktops have no vibrator, so this silently
// no-ops there. Gated on reduced-motion like the visual animations, so a user who's asked for
// less sensory feedback gets none.
const HAPTIC_TAP = 10; // subtle tick on a button / checkbox press
const HAPTIC_DONE = [18, 40, 18, 40, 18, 40, 110]; // little celebratory rhythm at 100% complete
function haptic(pattern) {
if (prefersReduced()) return;
if (typeof navigator === 'undefined' || typeof navigator.vibrate !== 'function') return;
try { navigator.vibrate(pattern); } catch (_) {}
}
// A light tick when a control is pressed. Capture phase so it fires even if a handler stops
// propagation; 'click' (not pointerdown) so a scroll that begins on a control doesn't buzz.
document.addEventListener('click', (e) => {
const t = e.target;
if (t && t.closest && t.closest('button, [role="button"], input[type="checkbox"], .list-tab, .checklist-item-header.is-collapsible, .checklist-column h2.col-foldable')) haptic(HAPTIC_TAP);
}, true);
// FLIP + cross-column flight. Checking a box moves the item from To Do → Done, but the
// columns clip their contents (overflow:hidden), so a normal in-place transform would get
// chopped at the column edge — that's the "snap". flyAcross() briefly lifts that clipping and
// lifts the item above the other column so it visibly TRAVELS to its new spot; meanwhile the
// other items glide within their column to open/close the gap. capture() records each item's
// old rect AND which column it was in; play() runs after the re-render.
function clearInline(el) { el.style.transform = ''; el.style.opacity = ''; el.style.position = ''; el.style.zIndex = ''; }
// Animate an item, then ALWAYS strip its inline styles — on finish AND via a timer backstop,
// so a node orphaned mid-animation (or an engine that never resolves .finished) can't leave an
// item stuck offset or, worse, invisible. maxMs must comfortably exceed the spring's settle time.
function springClean(el, keyframes, options, maxMs) {
setTimeout(() => clearInline(el), maxMs); // arm the backstop FIRST so a throwing animate() can't skip it
let a = null;
try { a = window.Motion.animate(el, keyframes, options); }
catch (_) { clearInline(el); return null; }
a.finished.then(() => clearInline(el)).catch(() => clearInline(el));
return a;
}
// FLIP move via the Web Animations API DIRECTLY. Motion One animates a CSS *variable* that the
// transform references, and that variable doesn't resolve into the computed transform on the very
// first frame — so the item flashed at its destination, then jumped back and swiped in (the
// "twitchy snap"). WAAPI interpolates the LITERAL transform; fill:'backwards' shows the start
// offset immediately (no snap) and it reverts to the natural settled position when done.
function flipAnim(el, fromX, fromY, durMs) {
let a = null;
try {
a = el.animate(
[{ transform: 'translate(' + fromX + 'px,' + fromY + 'px)' }, { transform: 'none' }],
{ duration: durMs, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', fill: 'backwards' }
);
} catch (_) { clearInline(el); return null; }
a.finished.then(() => clearInline(el)).catch(() => clearInline(el));
setTimeout(() => clearInline(el), durMs + 400);
return a;
}
// Gentle stagger-in for a single item (used for new items + list switches).
function enterItem(el, i) {
const delay = Math.min(i * 0.035, 0.3);
el.style.opacity = '0';
springClean(el, { opacity: [0, 1], y: [12, 0] },
{ easing: window.Motion.spring({ stiffness: 440, damping: 32 }), delay }, delay * 1000 + 1100);
}
// While an item is mid-flight, let columns show content outside their box and lift the
// flying item's column above its neighbour. Restored on a TIMER (not a completion ref-count)
// so a re-render that orphans the animating node can never leave a column stuck open.
let _colRestoreTimer = null;
const _flightCols = new Set();
function liftColumnsForFlight(col) {
document.querySelectorAll('.checklist-column').forEach((c) => { c.style.overflow = 'visible'; });
if (col) { col.style.position = 'relative'; col.style.zIndex = '5'; _flightCols.add(col); }
clearTimeout(_colRestoreTimer);
_colRestoreTimer = setTimeout(restoreColumnsAfterFlight, 1300); // ≥ the flight backstop, so columns never re-clip a still-flying item
}
function restoreColumnsAfterFlight() {
clearTimeout(_colRestoreTimer); _colRestoreTimer = null;
document.querySelectorAll('.checklist-column').forEach((c) => { c.style.overflow = ''; });
_flightCols.forEach((c) => { c.style.zIndex = ''; c.style.position = ''; });
_flightCols.clear();
}
// Fly an item that switched columns from its old position to its new one. Falls back to a
// gentle pop when the trip would be off-screen or huge (e.g. a tall stacked phone layout).
function flyAcross(el, dx, dy, nowRect) {
const M = window.Motion;
const offscreen = nowRect.bottom < 8 || nowRect.top > window.innerHeight - 8;
if (offscreen || Math.abs(dy) > window.innerHeight * 1.1) {
el.style.opacity = '0';
springClean(el, { opacity: [0, 1], scale: [0.96, 1] },
{ easing: M.spring({ stiffness: 420, damping: 30 }) }, 1100);
return;
}
liftColumnsForFlight(el.closest('.checklist-column'));
el.style.position = 'relative';
el.style.zIndex = '6';
flipAnim(el, dx, dy, 560); // glide from the OLD column position to the new one — no snap
}
const Flip = {
capture(containers) {
if (!motionReady() || prefersReduced()) return null;
const rects = new Map();
containers.forEach((c, col) => {
if (!c) return;
c.querySelectorAll('.checklist-item[data-item-id]').forEach((el) => {
rects.set(el.getAttribute('data-item-id'), { rect: el.getBoundingClientRect(), col });
});
});
return rects;
},
play(first, containers) {
if (!first) return;
const M = window.Motion;
const fresh = [];
containers.forEach((c, col) => {
if (!c) return;
c.querySelectorAll('.checklist-item[data-item-id]').forEach((el) => {
const id = el.getAttribute('data-item-id');
const prev = first.get(id);
if (!prev) { fresh.push(el); return; } // appeared this render → enter below
const now = el.getBoundingClientRect();
const dx = prev.rect.left - now.left;
const dy = prev.rect.top - now.top;
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return; // didn't move
if (prev.col !== col) {
flyAcross(el, dx, dy, now); // you checked/unchecked it → fly across the columns
} else {
flipAnim(el, dx, dy, 440); // shifted within its column → glide to the new spot
}
});
});
fresh.forEach((el, i) => enterItem(el, i));
},
// Stagger every visible item in — used on a list SWITCH / first load (never on polls).
enterAll(containers) {
if (!motionReady() || prefersReduced()) return;
let i = 0;
containers.forEach((c) => {
if (!c) return;
c.querySelectorAll('.checklist-item[data-item-id]').forEach((el) => { enterItem(el, i++); });
});
}
};
// A short, celebratory particle burst when a list hits 100% complete.
// Pure DOM + Motion, fixed-position, pointer-events:none, self-cleaning.
function celebrateComplete(originEl) {
if (!motionReady() || prefersReduced()) return;
const M = window.Motion;
const host = originEl || doneList || document.querySelector('.checklists-section') || document.body;
const r = host.getBoundingClientRect();
if (!r.width && !r.height) return;
const cx = r.left + r.width / 2;
const cy = r.top + Math.min(64, r.height / 2);
const layer = document.createElement('div');
layer.className = 'celebrate-layer';
layer.setAttribute('aria-hidden', 'true');
layer.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;z-index:4000;pointer-events:none;';
document.body.appendChild(layer);
const colors = ['#39a8ff', '#2ecc71', '#e0a800', '#ef4444', '#9b59b6', '#eceef2'];
const N = 28;
let pending = N;
const done = () => { if (--pending <= 0 && layer.parentNode) layer.remove(); };
for (let i = 0; i < N; i++) {
const size = 6 + Math.round(Math.random() * 6);
const p = document.createElement('div');
p.style.cssText = 'position:fixed;top:' + cy + 'px;left:' + cx + 'px;width:' + size + 'px;height:' + size +
'px;background:' + colors[i % colors.length] + ';border-radius:' + (Math.random() < 0.5 ? '50%' : '2px') +
';will-change:transform,opacity;';
layer.appendChild(p);
const angle = (Math.PI * 2 * i) / N + (Math.random() - 0.5) * 0.6;
const dist = 90 + Math.random() * 130;
const dx = Math.cos(angle) * dist;
const dy = Math.sin(angle) * dist - 40; // bias the burst upward, then it falls
M.animate(p, { x: [0, dx], y: [0, dy, dy + 130], rotate: [0, (Math.random() - 0.5) * 560], opacity: [1, 1, 0] },
{ duration: 1.0 + Math.random() * 0.45, easing: 'ease-out' })
.finished.then(done).catch(done);
}
setTimeout(() => { if (layer.parentNode) layer.remove(); }, 2400); // safety net
}
// ─── Collapsible cards: collapse/expand a single card IN PLACE ──────────────────
// State (the .collapsed class) is set by the caller via the resting render or here on
// finish; the animation is pure decoration. It ALWAYS lands on the resting class — even
// if interrupted (backstop timer) or unavailable (reduced-motion / no WAAPI → instant).
function animateCollapse(card, collapse) {
const body = card.querySelector('.checklist-item-body');
if (!body) return;
if (body._collapseAnim) { try { body._collapseAnim.cancel(); } catch (_) {} body._collapseAnim = null; }
if (body._collapseTimer) { clearTimeout(body._collapseTimer); body._collapseTimer = 0; }
const commit = () => {
card.classList.toggle('collapsed', collapse); // display:none makes the height moot
if (body._collapseAnim) { try { body._collapseAnim.cancel(); } catch (_) {} body._collapseAnim = null; }
body.style.height = ''; body.style.overflow = '';
if (body._collapseTimer) { clearTimeout(body._collapseTimer); body._collapseTimer = 0; }
};
if (prefersReduced() || typeof body.animate !== 'function') { commit(); return; }
let from, to;
if (collapse) { from = body.scrollHeight; to = 0; }
else { card.classList.remove('collapsed'); body.style.height = '0px'; body.style.overflow = 'hidden'; to = body.scrollHeight; from = 0; }
body.style.overflow = 'hidden';
body._collapseTimer = setTimeout(commit, 360); // backstop armed FIRST
let a;
// fill:'forwards' on collapse holds height:0 to the pre-paint onfinish commit (no flash
// back to full height on Safari); expand reverts to its natural height, which is correct.
try { a = body.animate([{ height: from + 'px' }, { height: to + 'px' }], { duration: 220, easing: 'cubic-bezier(0.4,0,0.2,1)', fill: collapse ? 'forwards' : 'none' }); }
catch (_) { commit(); return; }
body._collapseAnim = a;
a.onfinish = commit; // fires pre-paint in the animation-update step
if (a.finished) a.finished.then(commit).catch(commit);
animatingUntil = Math.max(animatingUntil, Date.now() + 300); // defer the poll rebuild
}
function toggleCollapse(card) {
if (!card || !card.isConnected) return;
if (Date.now() - justSwiped < 350) return; // ignore the stray click that follows a list-swipe
const body = card.querySelector('.checklist-item-body');
if (!body) return;
const id = card.getAttribute('data-item-id');
const isDone = card.classList.contains('done');
const willCollapse = !card.classList.contains('collapsed');
setCollapseOverride(id, willCollapse, isDone); // PERSIST FIRST — state can never be stranded
const btn = card.querySelector('.collapse-toggle');
if (btn) btn.setAttribute('aria-expanded', willCollapse ? 'false' : 'true'); // chevron rotates off this
animateCollapse(card, willCollapse);
}
// Fold/unfold a whole column, same shape as animateCollapse: the resting state is the
// .col-folded class (display:none), the height animation is pure decoration and always
// lands on the class — backstop timer, reduced-motion/no-WAAPI → instant. The list's
// padding (and its mobile min-height floor) animate to 0 alongside the height so the
// fold closes flush instead of stopping at the padding box.
function animateColFold(col, fold) {
const body = col.querySelector('.checklist-list');
if (!body) return;
if (body._foldAnim) { try { body._foldAnim.cancel(); } catch (_) {} body._foldAnim = null; }
if (body._foldTimer) { clearTimeout(body._foldTimer); body._foldTimer = 0; }
const commit = () => {
col.classList.toggle('col-folded', fold);
if (body._foldAnim) { try { body._foldAnim.cancel(); } catch (_) {} body._foldAnim = null; }
body.style.height = ''; body.style.overflow = ''; body.style.minHeight = '';
if (body._foldTimer) { clearTimeout(body._foldTimer); body._foldTimer = 0; }
};
if (prefersReduced() || typeof body.animate !== 'function') { commit(); return; }
const cs = getComputedStyle(body);
const pt = cs.paddingTop, pb = cs.paddingBottom;
let from, to;
if (fold) { from = body.scrollHeight; to = 0; }
else { col.classList.remove('col-folded'); body.style.height = '0px'; body.style.overflow = 'hidden'; to = body.scrollHeight; from = 0; }
body.style.overflow = 'hidden'; body.style.minHeight = '0';
body._foldTimer = setTimeout(commit, 400); // backstop armed FIRST
let a;
try {
a = body.animate(
[{ height: from + 'px', paddingTop: fold ? pt : '0px', paddingBottom: fold ? pb : '0px' },
{ height: to + 'px', paddingTop: fold ? '0px' : pt, paddingBottom: fold ? '0px' : pb }],
{ duration: 240, easing: 'cubic-bezier(0.4,0,0.2,1)', fill: fold ? 'forwards' : 'none' });
} catch (_) { commit(); return; }
body._foldAnim = a;
a.onfinish = commit;
if (a.finished) a.finished.then(commit).catch(commit);
animatingUntil = Math.max(animatingUntil, Date.now() + 320); // defer the poll rebuild
}
function toggleColFold(col) {
if (!col || !col.isConnected) return;
if (Date.now() - justSwiped < 350) return; // ignore the stray click that follows a list-swipe
const list = getCurrentList();
if (!list) return;
const key = col.getAttribute('data-col');
if (!key) return;
const willFold = !col.classList.contains('col-folded');
setColFolded(list.id, key, willFold); // PERSIST FIRST — state can never be stranded
const btn = col.querySelector('.col-fold');
if (btn) {
const colName = COL_LABEL[key] || ((col.querySelector('.col-title') || {}).textContent) || key;
btn.setAttribute('aria-expanded', willFold ? 'false' : 'true'); // chevron rotates off this
btn.setAttribute('aria-label', (willFold ? 'Unfold the ' : 'Fold the ') + colName + ' list');
}
animateColFold(col, willFold);
}
// "Sucked into the header" flourish when a task completes: a throwaway clone of its body
// retracts UPWARD + fades at the spot it occupied, while the already-collapsed card flies
// to Done. Lives on a fixed overlay (like celebrateComplete) so a re-render can't strand it
// and it never affects card layout. Purely optional — the card is already correctly collapsed.
function playSuckIntoHeader(snap) {
if (!snap || !snap.clone || !snap.rect) return;
const r = snap.rect;
const layer = document.createElement('div');
layer.setAttribute('aria-hidden', 'true');
layer.style.cssText = 'position:fixed;left:0;top:0;z-index:4000;pointer-events:none;';
const el = snap.clone;
el.querySelectorAll('[tabindex]').forEach((n) => n.removeAttribute('tabindex')); // no phantom tabstops
el.style.cssText = 'position:fixed;left:' + r.left + 'px;top:' + r.top + 'px;width:' + r.width + 'px;margin:0;box-sizing:border-box;background:' + (snap.bg || '#252932') + ';transform-origin:top center;will-change:transform,opacity;';
layer.appendChild(el);
document.body.appendChild(layer);
const cleanup = () => { if (layer.parentNode) layer.parentNode.removeChild(layer); };
let a;
try { a = el.animate([{ transform: 'translateY(0) scaleY(1)', opacity: 1 }, { transform: 'translateY(-42%) scaleY(0.06)', opacity: 0 }], { duration: 360, easing: 'cubic-bezier(0.55,0,0.85,0.35)', fill: 'forwards' }); }
catch (_) { cleanup(); return; }
a.onfinish = cleanup; if (a.finished) a.finished.then(cleanup).catch(cleanup);
setTimeout(cleanup, 720); // backstop
}
let celebratedListId = null; // guard: fire the burst once per completion, not on every poll
let lastRenderedListId = null; // which list the columns last showed → detect a list switch for the cascade
let animatingUntil = 0; // wall-clock ms: a reconcile render before this would rebuild the DOM mid-animation
let deferredRenderTimer = null;
let firstDeferAt = 0; // when the current reconcile first got deferred → hard-cap total staleness
// Schedule the reconcile render for just after the current item animation finishes, so the
// post-check server sync (or a 6s poll) can't rebuild the columns and cut the animation short.
function renderLater() {
if (deferredRenderTimer) clearTimeout(deferredRenderTimer);
deferredRenderTimer = setTimeout(() => {
deferredRenderTimer = null;
if (Date.now() < animatingUntil) { renderLater(); return; } // still animating → keep waiting
renderLists();
}, Math.max(60, animatingUntil - Date.now() + 60));
}
// Reset-schedule picker: reveal the day-of-week / day-of-month control for the chosen type.
if (listModalResetType) listModalResetType.addEventListener('change', updateResetScheduleControls);
if (listModalResetInterval) listModalResetInterval.addEventListener('input', updateResetScheduleControls);
if (listModalResetTimesAdd) listModalResetTimesAdd.addEventListener('click', () => { const inp = addResetTimeEntry(''); if (inp) inp.focus(); });
exportJsonBtn.addEventListener('click', exportChecklists);
importJsonBtn.addEventListener('click', () => importJsonInput.click());
importJsonInput.addEventListener('change', importChecklists);
sendDigestBtn.addEventListener('click', sendDigestNow);
if (addTaskBtn) addTaskBtn.addEventListener('click', openAddTask);
if (addNoteBtn) addNoteBtn.addEventListener('click', () => { const l = getCurrentList(); if (l) openAddNote(l.id); else showToast('Pick a list first', 'error'); });
// ── Booth keyboard shortcuts (desktop operator) ──────────────────────
// No-mouse operation from the booth. Guarded so they never fire while typing in a field, and nav
// keys are ignored when a modal/menu is open. A blue ring marks the focused card; focus is tracked
// by item id so it survives the 6s-poll re-render (re-applied at the end of renderLists).
let kbFocusId = null;
// offsetParent filter: skip cards inside a folded (or notes-only-hidden) column —
// j/k must never land the focus ring on an invisible card.
function kbVisibleCards() { return Array.prototype.slice.call(document.querySelectorAll('#todoList .checklist-item, .checklist-column[data-col^="grp-"] .checklist-item, #doneList .checklist-item, #notesList .checklist-item')).filter((el) => el.offsetParent !== null); }
function applyKbFocus() {
document.querySelectorAll('.checklist-item.kb-focus').forEach((c) => c.classList.remove('kb-focus'));
if (!kbFocusId) return;
const el = document.querySelector('.checklist-item[data-item-id="' + cssEscape(kbFocusId) + '"]');
if (el) el.classList.add('kb-focus');
}
function kbMoveFocus(delta) {
const cards = kbVisibleCards();
if (!cards.length) { kbFocusId = null; return; }
let idx = cards.findIndex((c) => c.getAttribute('data-item-id') === kbFocusId);
idx = idx < 0 ? (delta > 0 ? 0 : cards.length - 1) : Math.max(0, Math.min(cards.length - 1, idx + delta));
kbFocusId = cards[idx].getAttribute('data-item-id');
applyKbFocus();
cards[idx].scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
function kbFocusedCard() { return kbFocusId ? document.querySelector('.checklist-item[data-item-id="' + cssEscape(kbFocusId) + '"]') : null; }
function kbToggleFocused() {
const el = kbFocusedCard(); if (!el) { kbMoveFocus(1); return; }
const cb = el.querySelector('.checklist-item-header input[type="checkbox"][data-action="toggle"]');
if (cb) cb.click(); // plain-checkbox task only; notes & step-parents have none → harmless no-op
}
function kbCollapseFocused() { const el = kbFocusedCard(); const chev = el && el.querySelector('.collapse-toggle'); if (chev) chev.click(); }
function kbSwitchList(delta) {
let items = Array.prototype.slice.call(document.querySelectorAll('.list-tab'));
if (!items.length) items = Array.prototype.slice.call(document.querySelectorAll('[data-tree-list]'));
if (!items.length) return;
let i = items.findIndex((t) => t.classList.contains('active'));
i = i < 0 ? 0 : Math.max(0, Math.min(items.length - 1, i + delta));
items[i].click();
kbFocusId = null; // new list → drop stale focus
}
let kbCheatEl = null;
function kbCheatSheet() {
if (kbCheatEl) return kbCheatEl;
kbCheatEl = document.createElement('div');
kbCheatEl.className = 'kb-cheat';
kbCheatEl.innerHTML = '
Keyboard shortcuts
'
+ [['← / →', 'Previous / next list'], ['j / k', 'Move down / up the tasks'], ['Space', 'Check / uncheck the highlighted task'], ['c', 'Collapse / expand it'], ['/', 'Search'], ['?', 'Show this list'], ['Esc', 'Close']]
.map((r) => '
' + r[1] + '' + r[0] + '
').join('')
+ '
';
kbCheatEl.addEventListener('click', (e) => { if (e.target === kbCheatEl || e.target.hasAttribute('data-kb-close')) kbCheatEl.classList.remove('open'); });
document.body.appendChild(kbCheatEl);
return kbCheatEl;
}
document.addEventListener('keydown', (e) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
if (e.key === '?') { e.preventDefault(); kbCheatSheet().classList.toggle('open'); return; }
if (e.key === 'Escape') { if (kbCheatEl) kbCheatEl.classList.remove('open'); return; } // existing modal/menu Esc still runs
if (document.querySelector('.kb-cheat.open, .modal-overlay[style*="flex"], .list-menu.open, .move-menu.open')) return;
switch (e.key) {
case 'ArrowLeft': e.preventDefault(); kbSwitchList(-1); break;
case 'ArrowRight': e.preventDefault(); kbSwitchList(1); break;
case 'j': e.preventDefault(); kbMoveFocus(1); break;
case 'k': e.preventDefault(); kbMoveFocus(-1); break;
case ' ': if (kbFocusId) { e.preventDefault(); kbToggleFocused(); } break;
case 'c': if (kbFocusId) { e.preventDefault(); kbCollapseFocused(); } break;
case '/': e.preventDefault(); openSearch(); break;
}
});
// ── First-run welcome / tips (shown once per user, replayable from the menu) ──
let firstRunChecked = false, firstRunEl = null;
function firstRunKey() { return 'checklist:seen-firstrun:' + (currentUser ? currentUser.username : ''); }
function buildFirstRun() {
const tips = [
['✓', "Tap the checkbox to mark a step done — it slides over to Done."],
['👆', "Tap a step's title to open its photos, videos, and notes."],
['📝', "A Notes column holds reference info — nothing to check off."]
];
if (isEditor()) tips.push(['☰', "Use the Menu to add tasks or notes, reorder columns, and more."]);
tips.push(['⌨️', "On a desktop, press ? any time for keyboard shortcuts."]);
const el = document.createElement('div');
el.className = 'firstrun';
el.innerHTML = '
Welcome 👋
A few quick things, then you’re set.
'
+ tips.map((t) => '
' + t[0] + '' + t[1] + '
').join('')
+ '
';
el.addEventListener('click', (e) => { if (e.target === el || e.target.hasAttribute('data-fr-close')) { el.classList.remove('open'); try { localStorage.setItem(firstRunKey(), '1'); } catch (_) {} } });
return el;
}
function showFirstRun(force) {
if (!currentUser) return;
if (!force) { try { if (localStorage.getItem(firstRunKey()) === '1') return; } catch (_) { return; } }
if (firstRunEl) firstRunEl.remove();
firstRunEl = buildFirstRun();
document.body.appendChild(firstRunEl);
requestAnimationFrame(() => firstRunEl.classList.add('open'));
}
function maybeShowFirstRun() { if (firstRunChecked) return; firstRunChecked = true; setTimeout(() => showFirstRun(false), 700); }
{ const b = document.getElementById('helpTipsBtn'); if (b) b.addEventListener('click', () => showFirstRun(true)); }
// ── Global search (gear · notes · tasks · lists) — volunteer-accessible ──
const searchOverlay = document.getElementById('searchOverlay');
const globalSearchInput = document.getElementById('globalSearchInput');
const searchResults = document.getElementById('searchResults');
let searchHits = [], searchActive = -1;
function openSearch() {
if (!searchOverlay) return;
searchOverlay.style.display = 'flex';
globalSearchInput.value = '';
runSearch('');
requestAnimationFrame(() => globalSearchInput.focus());
}
function closeSearch() { if (searchOverlay) searchOverlay.style.display = 'none'; }
function runSearch(qRaw) {
const q = String(qRaw || '').trim().toLowerCase();
const hits = [];
const lists = visibleLists() || [];
const scopeOf = (l) => [l.location, l.team].filter(Boolean).join(' · ');
if (q) {
const eq = (checklistData.equipment && checklistData.equipment.items) || {}; // gear first — troubleshooting
// Editors see gear from EVERY campus here, and same-named entries (one per
// campus, e.g. two "Ableton"s) are otherwise indistinguishable — tag each hit
// with its scope so it's clear which location's gear it is BEFORE opening it.
// Same rule as the list-tab badges: master + executives get the scope, members
// only ever see their own scope so the tag would be redundant noise.
const showGearScope = isMasterAdmin() || (currentUser && currentUser.role === 'executive');
const gearSub = (e) => {
if (!showGearScope) return 'Equipment';
const parts = (e.scopeLocations || []).filter(Boolean)
.concat((e.scopeTeams || []).filter(Boolean).map((t) => t + ' team'));
return 'Equipment · ' + (parts.length ? parts.join(' · ') : 'everywhere');
};
Object.keys(eq).forEach((id) => { const e = eq[id]; if (((e.name || '') + ' ' + (e.note || '')).toLowerCase().indexOf(q) >= 0) hits.push({ group: 'Gear', type: 'gear', title: e.name || 'Gear', sub: gearSub(e), entry: e }); });
lists.forEach((l) => (l.items || []).forEach((it) => {
if (((it.title || '') + ' ' + (it.description || '')).toLowerCase().indexOf(q) >= 0) {
const note = it.kind === 'note';
hits.push({ group: note ? 'Notes' : 'Tasks', type: note ? 'note' : 'task', title: it.title || '(untitled)', sub: (note ? 'Note' : 'Task') + ' · ' + l.name, listId: l.id, itemId: it.id });
}
}));
lists.forEach((l) => { if ((l.name || '').toLowerCase().indexOf(q) >= 0) hits.push({ group: 'Lists', type: 'list', title: l.name, sub: scopeOf(l) || 'List', listId: l.id }); });
}
searchHits = hits.slice(0, 60);
searchActive = searchHits.length ? 0 : -1;
renderSearchResults(q);
}
function renderSearchResults(q) {
if (!q) { searchResults.innerHTML = '
Type to search gear, notes, tasks, and lists.
'; return; }
if (!searchHits.length) { searchResults.innerHTML = '
No matches for “' + escapeHtml(q) + '”.
'; return; }
const icon = { gear: '🔧', note: '📝', task: '✓', list: '📋' };
let html = '', lastGroup = null;
searchHits.forEach((h, i) => {
if (h.group !== lastGroup) { lastGroup = h.group; html += '
' + h.group + '
'; }
html += '
' + (icon[h.type] || '•') + '
' + escapeHtml(h.title) + '
' + escapeHtml(h.sub) + '
';
});
searchResults.innerHTML = html;
}
function openSearchHit(h) {
if (!h) return;
closeSearch();
if (h.type === 'gear') { openEquipLightbox(h.entry.name, equipPhotosOf(h.entry), h.entry.note || '', h.entry.id, h.entry.docs || []); return; }
if (h.listId) {
setSelectedList(h.listId);
if (h.itemId) { kbFocusId = h.itemId; setTimeout(() => { applyKbFocus(); const el = kbFocusedCard(); if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' }); }, 140); }
}
}
if (searchOverlay && globalSearchInput && searchResults) {
globalSearchInput.addEventListener('input', () => runSearch(globalSearchInput.value));
globalSearchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { e.preventDefault(); closeSearch(); }
else if (e.key === 'ArrowDown') { e.preventDefault(); if (searchHits.length) { searchActive = Math.min(searchHits.length - 1, searchActive + 1); renderSearchResults(globalSearchInput.value); } }
else if (e.key === 'ArrowUp') { e.preventDefault(); if (searchHits.length) { searchActive = Math.max(0, searchActive - 1); renderSearchResults(globalSearchInput.value); } }
else if (e.key === 'Enter') { e.preventDefault(); openSearchHit(searchHits[searchActive]); }
});
searchResults.addEventListener('click', (e) => { const el = e.target.closest('[data-hit]'); if (el) openSearchHit(searchHits[parseInt(el.getAttribute('data-hit'), 10)]); });
searchOverlay.addEventListener('click', (e) => { if (e.target === searchOverlay) closeSearch(); });
const sc = document.getElementById('searchClose'); if (sc) sc.addEventListener('click', closeSearch);
const sb = document.getElementById('searchBtn'); if (sb) sb.addEventListener('click', openSearch);
}
// ── Incident reports (gear problems) — volunteer report + editor triage ──
const incidentModal = document.getElementById('incidentModal');
const incidentGearRow = document.getElementById('incidentGearRow');
const incidentGearName = document.getElementById('incidentGearName');
const incidentSeverity = document.getElementById('incidentSeverity');
const incidentWhat = document.getElementById('incidentWhat');
const incidentDetails = document.getElementById('incidentDetails');
const incidentReportingAs = document.getElementById('incidentReportingAs');
const incidentSubmit = document.getElementById('incidentSubmit');
let incidentGear = null; // { equipId, equipName } or null for a general report
function allIncidents() { return (checklistData && Array.isArray(checklistData.incidents)) ? checklistData.incidents : []; }
function openIncidentsFor(equipId) { return allIncidents().filter((i) => i.status === 'open' && equipId && i.equipId === equipId); }
function openIncidentCount() { return allIncidents().filter((i) => i.status === 'open').length; }
function timeAgo(iso) {
if (!iso) return '';
const then = new Date(iso).getTime(); if (isNaN(then)) return '';
const s = Math.max(0, Math.round((Date.now() - then) / 1000));
if (s < 60) return 'just now';
const m = Math.round(s / 60); if (m < 60) return m + 'm ago';
const h = Math.round(m / 60); if (h < 24) return h + 'h ago';
const d = Math.round(h / 24); if (d < 7) return d + 'd ago';
return new Date(iso).toLocaleDateString();
}
function setSeverity(sev) {
if (!incidentSeverity) return;
incidentSeverity.querySelectorAll('.sev-btn').forEach((b) => b.classList.toggle('is-selected', b.getAttribute('data-sev') === sev));
}
function currentSeverity() {
const sel = incidentSeverity && incidentSeverity.querySelector('.sev-btn.is-selected');
return (sel && sel.getAttribute('data-sev')) || 'minor';
}
function openIncidentModal(gear) {
if (!incidentModal) return;
incidentGear = gear || null;
if (incidentGearRow) {
if (incidentGear && incidentGear.equipName) { incidentGearRow.style.display = ''; incidentGearName.textContent = incidentGear.equipName; }
else incidentGearRow.style.display = 'none';
}
setSeverity('minor');
if (incidentWhat) incidentWhat.value = '';
if (incidentDetails) incidentDetails.value = '';
// On a shared team login, attribute to whoever's checking (if they've tapped in).
if (incidentReportingAs) {
if (currentUser && currentUser.role === 'user' && activePerson) {
incidentReportingAs.style.display = ''; incidentReportingAs.textContent = 'Reporting as ' + activePerson;
} else incidentReportingAs.style.display = 'none';
}
incidentModal.style.display = 'flex';
requestAnimationFrame(() => { if (incidentWhat) incidentWhat.focus(); });
}
function closeIncidentModal() { if (incidentModal) closeOverlay(incidentModal); }
async function submitIncident() {
const what = (incidentWhat && incidentWhat.value || '').trim();
if (!what && !(incidentGear && incidentGear.equipName)) { showToast('Tell us what’s wrong first.', 'error'); if (incidentWhat) incidentWhat.focus(); return; }
const severity = currentSeverity();
const body = {
severity, what,
details: (incidentDetails && incidentDetails.value || '').trim(),
equipId: incidentGear ? (incidentGear.equipId || '') : '',
equipName: incidentGear ? (incidentGear.equipName || '') : '',
listId: (typeof getCurrentList === 'function' && getCurrentList()) ? getCurrentList().id : ''
};
if (currentUser && currentUser.role === 'user' && activePerson) body.by = activePerson;
if (incidentSubmit) { incidentSubmit.disabled = true; incidentSubmit.textContent = 'Sending…'; }
try {
const r = await apiPost('/api/incidents', body);
closeIncidentModal();
showToast(severity === 'down'
? (r && r.emailed ? '🔴 Reported — the team has been emailed.' : '🔴 Reported. (No email recipients set — it’s in the report.)')
: '🟡 Thanks — logged for the report.', 'success');
await refreshState({ showToastOnError: false }); // pull the new incident into state
if (equipModal && equipModal.style.display === 'flex' && equipGalleryEquipId) renderEquipIssues(equipGalleryEquipId);
updateIncidentsBadge();
} catch (err) {
showToast(err && err.message ? err.message : 'Could not send the report.', 'error');
} finally {
if (incidentSubmit) { incidentSubmit.disabled = false; incidentSubmit.textContent = 'Send report'; }
}
}
// Issue history shown inside the gear lightbox (open issues only; volunteer-facing).
function renderEquipIssues(equipId) {
const el = document.getElementById('equipIssues');
if (!el) return;
const open = openIncidentsFor(equipId);
if (!open.length) { el.innerHTML = ''; el.style.display = 'none'; return; }
el.style.display = '';
const canFix = isEditor();
let html = '
';
}).join('');
}
{ const b = document.getElementById('reliabilityBtn'); if (b) b.addEventListener('click', openReliability); }
{ const b = document.getElementById('reliabilityClose'); if (b) b.addEventListener('click', closeReliability); }
if (reliabilityOpenFirst) reliabilityOpenFirst.addEventListener('change', drawReliability);
// Click a gear → open its full issue history in the triage view.
if (reliabilityList) reliabilityList.addEventListener('click', (e) => {
const row = e.target.closest('.reliability-row'); if (!row) return;
closeReliability();
if (incidentsSearch) incidentsSearch.value = row.getAttribute('data-gear') || '';
if (incidentsOpenOnly) incidentsOpenOnly.checked = false;
openIncidents();
});
if (addTaskCancel) addTaskCancel.addEventListener('click', closeAddTask);
// Intentionally NO click-outside-to-close: an accidental backdrop tap must not
// discard a half-typed form. Close only via the Close button (true for all the
// edit/data-entry modals below — only the photo lightbox closes on click-off).
addListSelect.addEventListener('change', (e) => { addTargetListId = e.target.value; populateItemGroupSelect(addTargetListId); });
// Column-header + buttons: open Add Task with that section preselected.
document.addEventListener('click', (e) => {
const b = e.target && e.target.closest && e.target.closest('[data-add-task-group]');
if (!b) return;
addGroupPreset = b.getAttribute('data-add-task-group');
openAddTask();
});
moveItemSave.addEventListener('click', handleMoveItemSave);
moveItemCancel.addEventListener('click', closeMoveItemModal);
listModalSave.addEventListener('click', handleModalSave);
listModalCancel.addEventListener('click', closeListModal);
if (listModalColors) listModalColors.addEventListener('click', (e) => {
const sw = e.target.closest('.swatch');
// The custom-color swatch drives itself via its below.
if (sw && !sw.classList.contains('swatch-custom')) setListModalColor(sw.dataset.color || '');
});
const listModalCustomColor = document.getElementById('listModalCustomColor');
if (listModalCustomColor) ['input', 'change'].forEach((ev) =>
listModalCustomColor.addEventListener(ev, (e) => setListModalColor(e.target.value)));
listModalInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); handleModalSave(); }
else if (e.key === 'Escape') { e.preventDefault(); closeListModal(); }
});
editIsRecurring.addEventListener('change', () => {
editRecurrenceOptions.style.display = editIsRecurring.checked ? 'block' : 'none';
});
editModalSave.addEventListener('click', saveEditTask);
editModalCancel.addEventListener('click', closeEditModal);
userBadge.addEventListener('click', () => {
// Local mode has no account to sign out of — the "user" is a synthetic session this
// device created for itself. Signing out would strand you on a password sheet for a
// password that only exists inside the app, so the badge offers the one thing that
// actually makes sense here: leaving on-device mode.
const N = window.CHECKLIST_NATIVE;
if (N && N.mode === 'local') { switchAppMode(); return; }
if (!sessionToken) { openLogin(); return; }
if (confirm('Sign out?')) logout();
});
loginSubmit.addEventListener('click', submitLogin);
if (loginPasswordToggle && loginPassword) loginPasswordToggle.addEventListener('click', () => {
const show = loginPassword.type === 'password';
loginPassword.type = show ? 'text' : 'password';
loginPasswordToggle.textContent = show ? '🙈' : '🐵';
const label = show ? 'Hide password' : 'Show password';
loginPasswordToggle.setAttribute('aria-label', label);
loginPasswordToggle.title = label;
loginPassword.focus();
});
[loginLocation, loginTeam, loginExecName, loginPassword].forEach((el) => el && el.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); submitLogin(); }
}));
if (loginLocation) loginLocation.addEventListener('change', updateLoginTeamVisibility);
if (adminFilterLocation) adminFilterLocation.addEventListener('change', (e) => { adminFilterLoc = e.target.value; renderLists(); });
if (adminFilterTeamSel) adminFilterTeamSel.addEventListener('change', (e) => { adminFilterTeam = e.target.value; renderLists(); });
if (listTabsToggle) listTabsToggle.addEventListener('click', () => {
animateListsHidden(!document.body.classList.contains('lists-hidden'));
});
if (listViewToggle) listViewToggle.addEventListener('click', () => {
switchListView(!document.body.classList.contains('tree-view'));
});
if (listTree) listTree.addEventListener('click', (e) => {
const toggle = e.target.closest('[data-tree-toggle]');
if (toggle) { const k = toggle.dataset.treeToggle; treeCollapsed[k] = !treeCollapsed[k]; renderListTree(); return; }
const leaf = e.target.closest('[data-tree-list]');
if (leaf) setSelectedList(leaf.dataset.treeList);
});
manageUsersBtn.addEventListener('click', openUserMgmt);
userMgmtClose.addEventListener('click', () => { closeOverlay(userMgmtModal); });
// Equipment manager (master) + photo lightbox wiring
if (checkerChip) checkerChip.addEventListener('click', openCheckerModal);
if (checkerClose) checkerClose.addEventListener('click', () => { closeOverlay(checkerModal); });
if (checkerModal) checkerModal.addEventListener('click', (e) => { if (e.target === checkerModal) closeOverlay(checkerModal); });
if (checkerSelect) checkerSelect.addEventListener('change', onCheckerSelectChange);
if (checkerAddBtn) checkerAddBtn.addEventListener('click', addRosterName);
if (checkerNewName) checkerNewName.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addRosterName(); } });
if (checkerClear) checkerClear.addEventListener('click', () => { setActivePerson(''); closeOverlay(checkerModal); });
// Roster manager (master + execs)
if (rosterBtn) rosterBtn.addEventListener('click', openRosterMgmt);
if (rosterSearch) rosterSearch.addEventListener('input', drawRosterMgmt);
if (rosterClose) rosterClose.addEventListener('click', () => { closeOverlay(rosterModal); });
if (rosterModal) rosterModal.addEventListener('click', (e) => { if (e.target === rosterModal) closeOverlay(rosterModal); });
if (rosterBody) {
rosterBody.addEventListener('click', (e) => {
const del = e.target.closest('.roster-del');
if (del) { rosterRemove(del.dataset.rosterUser, del.dataset.rosterName); return; }
const addBtn = e.target.closest('.roster-add-btn');
if (addBtn) { const inp = rosterBody.querySelector(`.roster-add-input[data-roster-add-user="${CSS.escape(addBtn.dataset.rosterAddUser)}"]`); rosterAdd(addBtn.dataset.rosterAddUser, inp && inp.value); }
});
rosterBody.addEventListener('keydown', (e) => {
const inp = e.target.closest('.roster-add-input');
if (inp && e.key === 'Enter') { e.preventDefault(); rosterAdd(inp.dataset.rosterAddUser, inp.value); }
});
}
if (overviewBtn) overviewBtn.addEventListener('click', openOverview);
if (overviewClose) overviewClose.addEventListener('click', () => { closeOverlay(overviewModal); });
if (overviewModal) overviewModal.addEventListener('click', (e) => { if (e.target === overviewModal) closeOverlay(overviewModal); });
if (overviewBody) overviewBody.addEventListener('click', (e) => {
const row = e.target.closest('[data-overview-list]');
if (row) { setSelectedList(row.dataset.overviewList); closeOverlay(overviewModal); }
});
if (activityBtn) activityBtn.addEventListener('click', openActivity);
if (activityClose) activityClose.addEventListener('click', () => { stopActivityAutoRefresh(); closeOverlay(activityModal); });
if (activityModal) activityModal.addEventListener('click', (e) => { if (e.target === activityModal) { stopActivityAutoRefresh(); closeOverlay(activityModal); } });
if (activitySearch) activitySearch.addEventListener('input', () => drawActivity());
if (listModalTemplate) listModalTemplate.addEventListener('change', onTemplatePick);
if (equipMgmtBtn) equipMgmtBtn.addEventListener('click', openEquipMgmt);
if (equipMgmtClose) equipMgmtClose.addEventListener('click', () => { closeEquipCamera(); closeOverlay(equipMgmtModal); });
if (equipSave) equipSave.addEventListener('click', saveEquipment);
if (equipCancelEdit) equipCancelEdit.addEventListener('click', resetEquipForm);
if (equipAddToggle) equipAddToggle.addEventListener('click', () => {
const open = !!(equipAddSection && equipAddSection.classList.contains('open'));
if (open) {
// Collapsing while editing = abandon the edit, so we never keep a live
// equipEditId behind a collapsed "Add equipment" button (which could later
// PUT-overwrite the wrong item). resetEquipForm() also collapses.
if (equipEditId) resetEquipForm(); else setEquipAddOpen(false);
} else {
setEquipAddOpen(true);
}
});
if (equipFile) equipFile.addEventListener('change', handleEquipFile);
// In-app camera. Hide the button entirely if this browser/context can't use it
// (e.g. non-HTTPS without localhost) so it's never a dead button.
if (equipCamBtn) {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
equipCamBtn.addEventListener('click', openEquipCamera);
} else { equipCamBtn.style.display = 'none'; }
}
if (equipCamCapture) equipCamCapture.addEventListener('click', captureEquipPhoto);
if (equipCamSwitch) equipCamSwitch.addEventListener('click', switchEquipCamera);
if (equipCamCancel) equipCamCancel.addEventListener('click', closeEquipCamera);
if (equipCamModal) equipCamModal.addEventListener('click', (e) => { if (e.target === equipCamModal) closeEquipCamera(); });
// "Add" a pasted/typed image URL to the photo gallery (or press Enter in the field).
if (equipAddUrl) equipAddUrl.addEventListener('click', addEquipUrlFromInput);
if (equipImage) equipImage.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addEquipUrlFromInput(); } });
// The photo gallery: caption edits, remove, and drag-reorder.
if (equipPhotos) {
equipPhotos.addEventListener('click', (e) => {
const rm = e.target.closest('[data-equip-photo-remove]');
if (rm) { const i = parseInt(rm.getAttribute('data-equip-photo-remove'), 10); equipFormPhotos.splice(i, 1); renderEquipPhotos(); }
});
equipPhotos.addEventListener('input', (e) => {
const cap = e.target.closest('input[data-equip-photo-cap]');
if (cap) { const i = parseInt(cap.getAttribute('data-equip-photo-cap'), 10); if (equipFormPhotos[i]) equipFormPhotos[i].caption = cap.value; }
});
setupEquipPhotoReorder();
}
// Attached documents: upload, pasted link, rename, remove.
if (equipDocFile) equipDocFile.addEventListener('change', handleEquipDocFile);
if (equipDocAdd) equipDocAdd.addEventListener('click', addEquipDocUrlFromInput);
if (equipDocUrl) equipDocUrl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addEquipDocUrlFromInput(); } });
if (equipDocs) {
equipDocs.addEventListener('click', (e) => {
const rm = e.target.closest('[data-equip-doc-remove]');
if (rm) { const i = parseInt(rm.getAttribute('data-equip-doc-remove'), 10); equipFormDocs.splice(i, 1); renderEquipDocs(); }
});
equipDocs.addEventListener('input', (e) => {
const nm = e.target.closest('input[data-equip-doc-name]');
if (nm) { const i = parseInt(nm.getAttribute('data-equip-doc-name'), 10); if (equipFormDocs[i]) equipFormDocs[i].name = nm.value; }
});
}
if (equipSearch) equipSearch.addEventListener('input', drawEquipList);
if (equipMgmtList) equipMgmtList.addEventListener('click', handleEquipListClick);
if (equipModalClose) equipModalClose.addEventListener('click', closeEquipLightbox);
if (equipModal) equipModal.addEventListener('click', (e) => { if (e.target === equipModal) closeEquipLightbox(); });
// Tap any equipment word anywhere → show its photo.
document.addEventListener('click', (e) => {
const term = e.target.closest && e.target.closest('.equip-term');
if (!term) return;
const id = term.getAttribute('data-equip-id');
const entry = id && checklistData.equipment && checklistData.equipment.items && checklistData.equipment.items[id];
// Prefer the entry's full photo list; fall back to the single inline image
// (e.g. a [[label|url]] marker that doesn't resolve to a library entry).
const single = term.getAttribute('data-equip-image');
const photos = entry ? equipPhotosOf(entry) : (single ? [{ url: single, caption: '' }] : []);
openEquipLightbox(term.getAttribute('data-equip-name'), photos, term.getAttribute('data-equip-note'), id || '', (entry && entry.docs) || []);
});
// Toggle an inline checkbox (LIVE list only). Only a tap ON THE BOX itself checks the
// item — tapping the label (including an equipment word) never toggles, so you can tap a
// gear name for its photo with no risk of an accidental check-off. Keyboard toggles the
// focused row (deliberate, so it's not restricted to the box). No