// Recurring Checklist — browser frontend. // Talks to the Express backend over JSON. // State of truth lives on the server; we just render + dispatch actions. (function () { 'use strict'; // ─── DOM ───────────────────────────────────────────────────── const itemTitleInput = document.getElementById('itemTitle'); const itemDescriptionInput = document.getElementById('itemDescription'); const isRecurringCheckbox = document.getElementById('isRecurring'); const recurrenceOptions = document.getElementById('recurrenceOptions'); // The add-task recurrence controls are built by makeRecurrenceEditor (below) into // #recurrenceOptions .rec-editor — same rich option set as a list's repeat editor. const addItemBtn = document.getElementById('addItemBtn'); const todoList = document.getElementById('todoList'); const doneList = document.getElementById('doneList'); const notesList = document.getElementById('notesList'); const feedbackList = document.getElementById('feedbackList'); const listTabs = document.getElementById('listTabs'); const addListBtn = document.getElementById('addListBtn'); const addListSelect = document.getElementById('addListSelect'); const renameListBtn = document.getElementById('renameListBtn'); const deleteListBtn = document.getElementById('deleteListBtn'); const exportJsonBtn = document.getElementById('exportJsonBtn'); const importJsonBtn = document.getElementById('importJsonBtn'); const importJsonInput = document.getElementById('importJsonInput'); const sendDigestBtn = document.getElementById('sendDigestBtn'); const addTaskModal = document.getElementById('addTaskModal'); const addTaskBtn = document.getElementById('addTaskBtn'); const addNoteBtn = document.getElementById('addNoteBtn'); const addTaskCancel = document.getElementById('addTaskCancel'); const listTabsToggle = document.getElementById('listTabsToggle'); const listViewToggle = document.getElementById('listViewToggle'); const listTree = document.getElementById('listTree'); const activeListName = document.getElementById('activeListName'); const treeCollapsed = {}; // tree node key -> true when collapsed const listMenu = document.getElementById('listMenu'); const listMenuHandle = document.getElementById('listMenuHandle'); const listMenuPanel = document.getElementById('listMenuPanel'); const listMenuBackdrop = document.getElementById('listMenuBackdrop'); const undoBtn = document.getElementById('undoBtn'); const redoBtn = document.getElementById('redoBtn'); const moveItemModal = document.getElementById('moveItemModal'); const moveItemSelect = document.getElementById('moveItemSelect'); const moveMenu = document.getElementById('moveMenu'); const moveMenuBackdrop = document.getElementById('moveMenuBackdrop'); const moveItemSave = document.getElementById('moveItemSave'); const moveItemCancel = document.getElementById('moveItemCancel'); const listModal = document.getElementById('listModal'); const listModalTitle = document.getElementById('listModalTitle'); const listModalMessage = document.getElementById('listModalMessage'); const listModalInput = document.getElementById('listModalInput'); const listModalTemplate = document.getElementById('listModalTemplate'); const listModalTemplateGroup = document.getElementById('listModalTemplateGroup'); const listModalColorGroup = document.getElementById('listModalColorGroup'); const listModalHiddenGroup = document.getElementById('listModalHiddenGroup'); const listModalRepeatGroup = document.getElementById('listModalRepeatGroup'); const overviewBtn = document.getElementById('overviewBtn'); const overviewModal = document.getElementById('overviewModal'); const overviewClose = document.getElementById('overviewClose'); const overviewBody = document.getElementById('overviewBody'); const activityBtn = document.getElementById('activityBtn'); const activityModal = document.getElementById('activityModal'); const activityClose = document.getElementById('activityClose'); const activityBody = document.getElementById('activityBody'); const activitySearch = document.getElementById('activitySearch'); const offlineBadge = document.getElementById('offlineBadge'); const checkerChip = document.getElementById('checkerChip'); const checkerChipName = document.getElementById('checkerChipName'); const checkerModal = document.getElementById('checkerModal'); const checkerSelect = document.getElementById('checkerSelect'); const checkerAddRow = document.getElementById('checkerAddRow'); const checkerNewName = document.getElementById('checkerNewName'); const checkerAddBtn = document.getElementById('checkerAddBtn'); const checkerClear = document.getElementById('checkerClear'); const checkerClose = document.getElementById('checkerClose'); const rosterBtn = document.getElementById('rosterBtn'); const rosterModal = document.getElementById('rosterModal'); const rosterBody = document.getElementById('rosterBody'); const rosterClose = document.getElementById('rosterClose'); const rosterSearch = document.getElementById('rosterSearch'); let rosterTeams = []; // cached for search re-draw const listModalLocation = document.getElementById('listModalLocation'); const listModalLocationGroup = document.getElementById('listModalLocationGroup'); const listModalTeam = document.getElementById('listModalTeam'); const listModalTeamGroup = document.getElementById('listModalTeamGroup'); const listModalColors = document.getElementById('listModalColors'); const listModalHidden = document.getElementById('listModalHidden'); const listModalResetType = document.getElementById('listModalResetType'); const listModalResetEvery = document.getElementById('listModalResetEvery'); const listModalResetInterval = document.getElementById('listModalResetInterval'); const listModalResetUnit = document.getElementById('listModalResetUnit'); const listModalResetHint = document.getElementById('listModalResetHint'); const listModalResetMonth = document.getElementById('listModalResetMonth'); const listModalResetMonthRow = document.getElementById('listModalResetMonthRow'); const listModalResetDow = document.getElementById('listModalResetDow'); const listModalResetDowRow = document.getElementById('listModalResetDowRow'); const listModalResetDom = document.getElementById('listModalResetDom'); const listModalResetDomRow = document.getElementById('listModalResetDomRow'); const listModalResetStartDow = document.getElementById('listModalResetStartDow'); const listModalResetStartRow = document.getElementById('listModalResetStartRow'); const listModalResetTimesRow = document.getElementById('listModalResetTimesRow'); const listModalResetTimesList = document.getElementById('listModalResetTimesList'); const listModalResetTimesAdd = document.getElementById('listModalResetTimesAdd'); const listModalSave = document.getElementById('listModalSave'); const listModalCancel = document.getElementById('listModalCancel'); const resetListBtn = document.getElementById('resetListBtn'); const duplicateListBtn = document.getElementById('duplicateListBtn'); const editTaskModal = document.getElementById('editTaskModal'); const editTitle = document.getElementById('editTitle'); const editDescription = document.getElementById('editDescription'); const editIsRecurring = document.getElementById('editIsRecurring'); const editRecurrenceOptions = document.getElementById('editRecurrenceOptions'); // Edit-task recurrence controls are built by makeRecurrenceEditor into // #editRecurrenceOptions .rec-editor (same option set as the list repeat editor). const editModalSave = document.getElementById('editModalSave'); const editModalCancel = document.getElementById('editModalCancel'); const userBadge = document.getElementById('userBadge'); const userBadgeName = document.getElementById('userBadgeName'); const loginModal = document.getElementById('loginModal'); const loginLocation = document.getElementById('loginLocation'); const loginTeam = document.getElementById('loginTeam'); const loginExecName = document.getElementById('loginExecName'); const loginExecNameGroup = document.getElementById('loginExecNameGroup'); const loginPassword = document.getElementById('loginPassword'); const loginPasswordToggle = document.getElementById('loginPasswordToggle'); const loginSubmit = document.getElementById('loginSubmit'); const loginError = document.getElementById('loginError'); const adminFilterLocation = document.getElementById('adminFilterLocation'); const adminFilterTeamSel = document.getElementById('adminFilterTeam'); const manageUsersBtn = document.getElementById('manageUsersBtn'); const userMgmtModal = document.getElementById('userMgmtModal'); const userMgmtList = document.getElementById('userMgmtList'); const userMgmtClose = document.getElementById('userMgmtClose'); // Equipment library (master) + photo lightbox (everyone) const equipMgmtBtn = document.getElementById('equipMgmtBtn'); const equipMgmtModal = document.getElementById('equipMgmtModal'); const equipMgmtClose = document.getElementById('equipMgmtClose'); const equipMgmtList = document.getElementById('equipMgmtList'); const equipMgmtError = document.getElementById('equipMgmtError'); const equipSearch = document.getElementById('equipSearch'); const equipName = document.getElementById('equipName'); const equipAliases = document.getElementById('equipAliases'); const equipImage = document.getElementById('equipImage'); const equipNote = document.getElementById('equipNote'); const equipSave = document.getElementById('equipSave'); const equipCancelEdit = document.getElementById('equipCancelEdit'); const equipFormTitle = document.getElementById('equipFormTitle'); const equipAddSection = document.getElementById('equipAddSection'); // collapsible add-form const equipAddToggle = document.getElementById('equipAddToggle'); const equipFile = document.getElementById('equipFile'); const equipCamBtn = document.getElementById('equipCamBtn'); const equipCamModal = document.getElementById('equipCamModal'); const equipCamVideo = document.getElementById('equipCamVideo'); const equipCamCapture = document.getElementById('equipCamCapture'); const equipCamSwitch = document.getElementById('equipCamSwitch'); const equipCamCancel = document.getElementById('equipCamCancel'); const equipCamError = document.getElementById('equipCamError'); const equipUploadStatus = document.getElementById('equipUploadStatus'); const equipLocRow = document.getElementById('equipLocRow'); const equipTeamRow = document.getElementById('equipTeamRow'); const equipScopeHelp = document.getElementById('equipScopeHelp'); const equipModal = document.getElementById('equipModal'); const equipModalName = document.getElementById('equipModalName'); const equipModalImg = document.getElementById('equipModalImg'); const equipModalNote = document.getElementById('equipModalNote'); const equipModalClose = document.getElementById('equipModalClose'); const equipStage = document.getElementById('equipStage'); const equipZoomIn = document.getElementById('equipZoomIn'); const equipZoomOut = document.getElementById('equipZoomOut'); const equipZoomReset = document.getElementById('equipZoomReset'); const equipZoomHint = document.getElementById('equipZoomHint'); const equipPrev = document.getElementById('equipPrev'); // lightbox gallery nav const equipNext = document.getElementById('equipNext'); const equipThumbs = document.getElementById('equipThumbs'); const equipCaption = document.getElementById('equipCaption'); const equipPhotos = document.getElementById('equipPhotos'); // manager multi-photo editor const equipAddUrl = document.getElementById('equipAddUrl'); const equipDocs = document.getElementById('equipDocs'); // manager attached-documents editor const equipDocFile = document.getElementById('equipDocFile'); const equipDocUrl = document.getElementById('equipDocUrl'); const equipDocAdd = document.getElementById('equipDocAdd'); let equipEditId = null; // id being edited in the manager (null = add mode) let equipFormPhotos = []; // [{url,caption}] being edited in the manager let equipFormDocs = []; // [{url,name}] attached documents being edited let equipItems = {}; // cached equipment items for the manager list const equipTreeCollapsed = {}; // location group key -> true when collapsed const userMgmtError = document.getElementById('userMgmtError'); const newUserLocation = document.getElementById('newUserLocation'); const newUserTeam = document.getElementById('newUserTeam'); const newUserPassword = document.getElementById('newUserPassword'); const newUserEmails = document.getElementById('newUserEmails'); const newUserCreate = document.getElementById('newUserCreate'); const directoryEditor = document.getElementById('directoryEditor'); const execList = document.getElementById('execList'); const userMgmtSearch = document.getElementById('userMgmtSearch'); const execSearch = document.getElementById('execSearch'); const newExecName = document.getElementById('newExecName'); const newExecPassword = document.getElementById('newExecPassword'); const newExecCreate = document.getElementById('newExecCreate'); const toast = document.getElementById('toast'); // ─── State ─────────────────────────────────────────────────── const FALLBACK_LIST = { id: 'new-list', name: 'New List' }; let checklistData = { lists: [], selectedListId: null }; let addTargetListId = null; let sessionToken = localStorage.getItem('checklist:token') || ''; let currentUser = null; // {username, displayName, location, role} let userName = ''; // kept as alias for display (= currentUser.displayName) let activeListId = localStorage.getItem('checklist:activeListId') || null; let modalMode = null; let modalTargetId = null; let editingTaskId = null; let moveModalItemId = null; let isSaving = false; let pollTimer = null; let adminFilterLoc = ''; // master-admin filter: location ('' = all) let adminFilterTeam = ''; // master-admin filter: team ('' = all) let listModalColorValue = ''; // chosen color in the Add/Edit List modal let mgmtUsers = []; // cached team logins for the Manage Access list (sort/group/filter) let mgmtExecs = []; // cached executives for the Manage Access list (sort/filter) function isAdmin() { return !!(currentUser && currentUser.role === 'admin'); } // Master admin = a role:'admin' account with no team (the env "admin" login). // Team admins are role:'admin' WITH a team and only manage their own lists. function isMasterAdmin() { return !!(currentUser && currentUser.role === 'admin' && !(currentUser.team && currentUser.team.trim())); } // Editors get the editing UI: team admins, the master/owner, and named executives. function isEditor() { return isAdmin() || !!(currentUser && currentUser.role === 'executive'); } // ─── API ───────────────────────────────────────────────────── function authHeaders() { return sessionToken ? { 'Authorization': 'Bearer ' + sessionToken } : {}; } async function apiFetch(url, opts) { const o = Object.assign({}, opts || {}); o.headers = Object.assign({}, o.headers || {}, authHeaders()); if (o.body && !o.headers['Content-Type']) o.headers['Content-Type'] = 'application/json'; const res = await fetch(url, o); if (res.status === 401) { handleSessionExpired(); throw new Error('Session expired'); } if (!res.ok) { let msg = `HTTP ${res.status}`; try { const j = await res.json(); if (j && j.error) msg = j.error; } catch (_) { try { const t = await res.text(); if (t) msg = t; } catch (_) {} } throw new Error(msg); } return res.json(); } function apiGet(url) { return apiFetch(url, { method: 'GET' }); } function apiPost(url, body) { return apiFetch(url, { method: 'POST', body: JSON.stringify(body || {}) }); } function apiPut(url, body) { return apiFetch(url, { method: 'PUT', body: JSON.stringify(body || {}) }); } function apiDelete(url) { return apiFetch(url, { method: 'DELETE' }); } // ── Self-update after a deploy ─────────────────────────────────────── // This app is a single page: signing out and back in never reloads it, so a tab left open on a // wall tablet keeps running whatever JS it loaded — new features are simply absent there until // someone manually refreshes. The server stamps each /api/state poll with the build it serves; // when that changes we reload, but only at a SAFE moment: never mid-typing, never with a dialog // open, never mid-animation, and never while check-offs are still queued for sync. If it isn't // safe we show a banner and let the user pick the moment. let bootBuildId = null; function updateSafeNow() { const ae = document.activeElement; if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT' || ae.isContentEditable)) return false; // Any dialog up? Test COMPUTED display, not the inline-style string — an inline // `align-items: flex-start` would false-match a naive [style*="flex"] check. const dialogs = document.querySelectorAll('.modal-overlay, .firstrun, .kb-cheat, .search-overlay'); for (let i = 0; i < dialogs.length; i++) { if (getComputedStyle(dialogs[i]).display !== 'none') return false; } if (document.querySelector('.list-menu.open, .move-menu.open')) return false; if (Date.now() < animatingUntil) return false; if (loadPending().length) return false; // let queued check-offs reach the server first return true; } function showUpdateBanner() { if (document.getElementById('updateBanner')) return; const b = document.createElement('div'); b.id = 'updateBanner'; b.className = 'update-banner'; b.innerHTML = 'A new version is available.'; b.querySelector('button').addEventListener('click', () => location.reload()); document.body.appendChild(b); } function noteServerBuild(id) { if (!id) return; if (!bootBuildId) { bootBuildId = id; return; } // first poll of this tab = the build we booted if (id === bootBuildId) return; if (updateSafeNow()) location.reload(); else showUpdateBanner(); // retried on the next poll; the banner is the manual escape hatch } async function refreshState({ showToastOnError = true } = {}) { try { const incoming = await apiGet('/api/state'); // Preserve the user's currently-selected tab across polls. // The server's selectedListId is only used as a fallback when the client has no choice yet. checklistData = incoming; noteServerBuild(incoming.buildId); // deploy landed? refresh this tab (see below) reapplyPendingChecks(); // keep optimistic, not-yet-synced check-offs visible pruneCollapseState(); // drop overrides for deleted items (no-op on an empty poll) pruneColFoldState(); // drop column folds for deleted lists (same empty-poll guard) if (incoming.directory) renderDirectory(incoming.directory); if (!addTargetListId || !checklistData.lists.find((l) => l.id === addTargetListId)) { addTargetListId = checklistData.selectedListId || (checklistData.lists[0] && checklistData.lists[0].id) || null; } // Don't rebuild the columns while a check animation is playing (the optimistic DOM // already reflects this state) — defer the reconcile to just after it settles. But never // starve it: a sustained tapping burst keeps pushing animatingUntil out, so hard-cap the // total deferral so a teammate's change can't stay hidden longer than ~1.6s. if (Date.now() < animatingUntil) { if (!firstDeferAt) firstDeferAt = Date.now(); if (Date.now() - firstDeferAt > 1600) renderLists(); // cap reached → reconcile now else renderLater(); } else renderLists(); // Live-refresh the Overview while it's open (works for execs too — this poll runs for // every role), so progress + recent activity update without closing/reopening it. if (overviewModal && getComputedStyle(overviewModal).display !== 'none') { const keep = overviewBody ? overviewBody.scrollTop : 0; drawOverview(); if (overviewBody) overviewBody.scrollTop = keep; } // Keep the incident badge + any open incident views in sync across polls. updateIncidentsBadge(); if (incidentsModal && incidentsModal.style.display === 'flex') drawIncidents(); if (reliabilityModal && reliabilityModal.style.display === 'flex') drawReliability(); if (equipModal && equipModal.style.display === 'flex' && equipGalleryEquipId) renderEquipIssues(equipGalleryEquipId); } catch (err) { if (showToastOnError) showToast('Failed to load state: ' + err.message, 'error'); } } async function pushAdminState(next) { if (!isMasterAdmin()) { showToast('Master admin only', 'error'); return false; } const before = applyingHistory ? null : snapshotStructure(checklistData); isSaving = true; try { await apiPost('/api/admin/state', { state: next }); checklistData = Object.assign({}, checklistData, { lists: next.lists, selectedListId: next.selectedListId, checks: next.checks || checklistData.checks }); renderLists(); if (before) recordHistory({ type: 'structure', before: before, after: snapshotStructure(checklistData) }); return true; } catch (err) { showToast('Save failed: ' + err.message, 'error'); return false; } finally { isSaving = false; } } // ─── Undo / redo (full-admin, this session) ───────────────────────── // Covers the editor's own actions: structural edits funnel through // pushAdminState (add/edit/move/delete/duplicate tasks, add/rename/delete // lists, drag-reorder, duplicate list) and the editor's own check/uncheck. // History is in-memory (cleared on reload) and per-this-browser, so it never // reaches into a teammate's work. Crucially, restoring a structural snapshot // keeps the CURRENT live checks (we swap only the lists) so undoing an edit // can never wipe checkmarks a teammate just made during a service. // Deliberately NOT undoable: Reset List and Import JSON (confirmed, // bulk/destructive actions with their own warning). const HISTORY_LIMIT = 60; let undoStack = []; let redoStack = []; let applyingHistory = false; function snapshotStructure(d) { return { lists: JSON.parse(JSON.stringify(d && d.lists ? d.lists : [])), selectedListId: d ? d.selectedListId : null }; } function recordHistory(entry) { undoStack.push(entry); if (undoStack.length > HISTORY_LIMIT) undoStack.shift(); redoStack = []; updateUndoUI(); } function clearHistory() { undoStack = []; redoStack = []; updateUndoUI(); } function updateUndoUI() { if (undoBtn) undoBtn.disabled = !undoStack.length; if (redoBtn) redoBtn.disabled = !redoStack.length; } async function applyHistoryEntry(entry, dir) { if (entry.type === 'structure') { const target = dir === 'undo' ? entry.before : entry.after; await pushAdminState({ lists: JSON.parse(JSON.stringify(target.lists)), selectedListId: target.selectedListId, checks: checklistData.checks // keep live checks — never clobber a teammate's }); } else if (entry.type === 'scoped-ops') { // Executive / team-admin structural undo: apply id-targeted ops for ONLY the // list(s) this action changed (set = that list's snapshot, or null to delete). // Keeps live checks; never touches a co-owner's other lists. const ops = entry.ops.map((op) => ({ id: op.id, set: (dir === 'undo' ? op.before : op.after) })); await apiScopedRestore(ops); await refreshState({ showToastOnError: false }); } else if (entry.type === 'check') { const val = dir === 'undo' ? entry.before : entry.after; pushCheck(entry.listId, entry.itemId, val); } } async function undo() { if (!isEditor() || applyingHistory || !undoStack.length) return; playOnce(undoBtn, 'nudge'); const entry = undoStack.pop(); applyingHistory = true; try { await applyHistoryEntry(entry, 'undo'); redoStack.push(entry); } catch (err) { undoStack.push(entry); // restore — keep it undoable, never silently drop a step showToast('Undo failed: ' + err.message, 'error'); } finally { applyingHistory = false; updateUndoUI(); } } async function redo() { if (!isEditor() || applyingHistory || !redoStack.length) return; playOnce(redoBtn, 'nudge'); const entry = redoStack.pop(); applyingHistory = true; try { await applyHistoryEntry(entry, 'redo'); undoStack.push(entry); } catch (err) { redoStack.push(entry); showToast('Redo failed: ' + err.message, 'error'); } finally { applyingHistory = false; updateUndoUI(); } } // ─── Offline-tolerant check-offs ──────────────────────────────────── // A check is applied locally right away (optimistic), queued in localStorage, // and flushed to the server. If the network is down the queue persists and // syncs on reconnect — so a flaky venue wifi never blocks setup. const PENDING_KEY = 'checklist:pendingChecks'; let isFlushing = false; function loadPending() { try { return JSON.parse(localStorage.getItem(PENDING_KEY) || '[]'); } catch (_) { return []; } } function savePending(q) { try { localStorage.setItem(PENDING_KEY, JSON.stringify(q)); } catch (_) {} } function enqueueCheck(op) { let q = loadPending(); if (op.index != null) { // One entry per (list,item,index): a later toggle supersedes an earlier queued one. // NB: index 0 is valid — compare with != null, never truthiness. q = q.filter((p) => !(p.listId === op.listId && p.itemId === op.itemId && p.index === op.index)); q.push({ listId: op.listId, itemId: op.itemId, index: op.index, checked: op.checked, by: op.by || '' }); } else { // A parent toggle supersedes any queued op (parent + checkbox) for that item. q = q.filter((p) => !(p.listId === op.listId && p.itemId === op.itemId)); q.push({ listId: op.listId, itemId: op.itemId, completed: op.completed, by: op.by || '' }); } savePending(q); } // Mirror the server's check write into the local state so the UI reflects it now. function applyCheckLocally(listId, itemId, completed, by) { const key = readScopeKey(listId); if (!key) return; checklistData.checks = checklistData.checks || {}; checklistData.checks[key] = checklistData.checks[key] || {}; checklistData.checks[key][listId] = checklistData.checks[key][listId] || {}; if (completed) { checklistData.checks[key][listId][itemId] = { completed: true, completedDate: new Date().toISOString(), completedBy: by || (currentUser && (currentUser.displayName || currentUser.username)) || '', pending: true }; } else { delete checklistData.checks[key][listId][itemId]; } } // Mirror a checkbox toggle into local state (optimistic), then re-derive the parent's // completion the same way the server will, so the card moves the instant you tap. function applyTaskCheckLocally(listId, itemId, index, checked, by) { const key = readScopeKey(listId); if (!key) return; checklistData.checks = checklistData.checks || {}; checklistData.checks[key] = checklistData.checks[key] || {}; checklistData.checks[key][listId] = checklistData.checks[key][listId] || {}; const rec = checklistData.checks[key][listId][itemId] || {}; rec.subChecks = rec.subChecks || {}; const who = by || (currentUser && (currentUser.displayName || currentUser.username)) || ''; if (checked) rec.subChecks[String(index)] = { by: who, at: new Date().toISOString() }; else delete rec.subChecks[String(index)]; const total = itemTaskCount(listId, itemId); const doneCount = Object.keys(rec.subChecks).filter((k) => { const n = Number(k); return Number.isInteger(n) && n >= 0 && n < total; }).length; const allDone = total > 0 && doneCount === total; if (allDone) { rec.completed = true; rec.completedDate = rec.completedDate || new Date().toISOString(); rec.completedBy = rec.completedBy || who; rec.pending = true; } else { rec.completed = false; delete rec.completedDate; delete rec.completedBy; } if (!rec.completed && Object.keys(rec.subChecks).length === 0) delete checklistData.checks[key][listId][itemId]; else checklistData.checks[key][listId][itemId] = rec; } // Re-apply still-pending check-offs on top of fresh server state (so a poll // that lands before sync doesn't visibly "uncheck" what the user just tapped). function reapplyPendingChecks() { loadPending().forEach((op) => { if (op.index != null) applyTaskCheckLocally(op.listId, op.itemId, op.index, op.checked, op.by); else applyCheckLocally(op.listId, op.itemId, op.completed, op.by); }); } function updateOfflineUI() { if (!offlineBadge) return; const pending = loadPending().length; const offline = !navigator.onLine; if (offline || pending) { offlineBadge.textContent = offline ? (pending ? `📴 Offline — ${pending} change${pending === 1 ? '' : 's'} will sync` : '📴 Offline') : `Syncing ${pending} change${pending === 1 ? '' : 's'}…`; offlineBadge.classList.toggle('is-offline', offline); offlineBadge.style.display = ''; } else { offlineBadge.style.display = 'none'; } } async function flushPendingChecks() { if (isFlushing || !sessionToken) return; isFlushing = true; try { while (loadPending().length) { const op = loadPending()[0]; try { if (op.index != null) { await apiPost('/api/check/sub', { listId: op.listId, itemId: op.itemId, index: op.index, checked: op.checked, by: op.by || '' }); } else { await apiPost('/api/check', { listId: op.listId, itemId: op.itemId, completed: op.completed, by: op.by || '' }); } } catch (err) { break; // offline or session issue — keep the queue, retry later } // Drop exactly the op we sent (a newer toggle for the same target stays). savePending(loadPending().filter((p) => op.index != null ? !(p.listId === op.listId && p.itemId === op.itemId && p.index === op.index && p.checked === op.checked) : !(p.listId === op.listId && p.itemId === op.itemId && p.completed === op.completed))); updateOfflineUI(); } } finally { isFlushing = false; updateOfflineUI(); // Fully drained + online → reconcile with the server's authoritative state. if (!loadPending().length && navigator.onLine && sessionToken) { refreshState({ showToastOnError: false }); } } } function pushCheck(listId, itemId, completed) { // On a shared team login, attribute to the tapped person (if set). const by = (currentUser && currentUser.role === 'user' && activePerson) ? activePerson : ''; applyCheckLocally(listId, itemId, completed, by); // optimistic — instant feedback enqueueCheck({ listId, itemId, completed, by }); renderLists(); updateOfflineUI(); flushPendingChecks(); } function pushTaskCheck(listId, itemId, index, checked) { const by = (currentUser && currentUser.role === 'user' && activePerson) ? activePerson : ''; const wasDone = isCheckedForMe(listId, itemId); applyTaskCheckLocally(listId, itemId, index, checked, by); // optimistic // Only fly the card between columns when the LAST/first step flips completion. if (isCheckedForMe(listId, itemId) !== wasDone) { justMovedItemId = itemId; clearCollapseOverride(itemId); // fresh completion force-collapses; reopen expands } enqueueCheck({ listId, itemId, index, checked, by }); renderLists(); updateOfflineUI(); flushPendingChecks(); } // ─── "Tap your name" (shared team logins only) ────────────────────── // ─── Per-user, per-device collapse state (NEVER synced to the server) ────────── // Which task cards THIS account has manually collapsed/expanded, stored as DEVIATIONS // from the default (Done → collapsed, To Do → expanded). Keyed by username like // checkerKey(), so it lives only in this browser + this login and can't affect anyone // else viewing the same shared list. Each entry is { s:'collapsed'|'expanded', d: } so an override applies ONLY within the completion-state it was made in — // a reset back to To Do (or a fresh completion) re-derives the default for free. let collapseState = {}; let justSwiped = 0; // ms of the last horizontal list-swipe → guards a stray tap→collapse function collapseKey() { return 'checklist:collapse:' + (currentUser ? currentUser.username : ''); } function loadCollapseState() { try { const raw = currentUser ? localStorage.getItem(collapseKey()) : null; collapseState = raw ? (JSON.parse(raw) || {}) : {}; } catch (_) { collapseState = {}; } if (!collapseState || typeof collapseState !== 'object') collapseState = {}; } function saveCollapseState() { if (!currentUser) return; try { localStorage.setItem(collapseKey(), JSON.stringify(collapseState)); } catch (_) {} } function isCollapsed(itemId, isDone) { const o = collapseState[itemId]; if (o && typeof o === 'object' && o.d === !!isDone) return o.s === 'collapsed'; return !!isDone; // default: Done collapsed, To Do expanded } function setCollapseOverride(itemId, collapse, isDone) { if (!!collapse === !!isDone) delete collapseState[itemId]; // equals the default → store no deviation else collapseState[itemId] = { s: collapse ? 'collapsed' : 'expanded', d: !!isDone }; saveCollapseState(); } // Drop any manual collapse override for an item. Called when a task moves between To Do // and Done (a completion/uncompletion) so each fresh completion FORCE-collapses and each // reopen expands — a peek-expand of a Done card no longer survives a re-completion. function clearCollapseOverride(itemId) { if (collapseState[itemId] != null) { delete collapseState[itemId]; saveCollapseState(); } } // Drop overrides for items that no longer exist. NEVER run on an empty dataset — a // transient no-lists poll would otherwise wipe the whole saved layout. function pruneCollapseState() { if (!currentUser || !Array.isArray(checklistData.lists) || checklistData.lists.length === 0) return; const live = new Set(); checklistData.lists.forEach((l) => (l.items || []).forEach((i) => live.add(i.id))); let changed = false; Object.keys(collapseState).forEach((id) => { if (!live.has(id)) { delete collapseState[id]; changed = true; } }); if (changed) saveCollapseState(); } // ── Fold a WHOLE column (the To Do / Done / Notes list) to just its header ── // Same contract as task collapse: client-only, per-user (every role gets it), // never synced — folding your Done list can't hide it for a teammate. Stored // sparsely: only folded entries exist, keyed "::"; default open. let colFoldState = {}; function colFoldKey() { return 'checklist:colfold:' + (currentUser ? currentUser.username : ''); } function loadColFoldState() { try { const raw = currentUser ? localStorage.getItem(colFoldKey()) : null; colFoldState = raw ? (JSON.parse(raw) || {}) : {}; } catch (_) { colFoldState = {}; } if (!colFoldState || typeof colFoldState !== 'object') colFoldState = {}; } function saveColFoldState() { if (!currentUser) return; try { localStorage.setItem(colFoldKey(), JSON.stringify(colFoldState)); } catch (_) {} } function isColFolded(listId, colKey) { return !!colFoldState[listId + '::' + colKey]; } function setColFolded(listId, colKey, fold) { const k = listId + '::' + colKey; if (fold) colFoldState[k] = 1; else delete colFoldState[k]; // deviations only saveColFoldState(); } // Drop folds for lists that no longer exist. NEVER on an empty dataset — a // transient no-lists poll would otherwise wipe the whole saved layout. function pruneColFoldState() { if (!currentUser || !Array.isArray(checklistData.lists) || checklistData.lists.length === 0) return; const live = new Set(checklistData.lists.map((l) => l.id)); let changed = false; Object.keys(colFoldState).forEach((k) => { if (!live.has(k.split('::')[0])) { delete colFoldState[k]; changed = true; } }); if (changed) saveColFoldState(); } let activePerson = ''; function checkerKey() { return 'checklist:checker:' + (currentUser ? currentUser.username : ''); } function loadActivePerson() { try { activePerson = (currentUser && currentUser.role === 'user') ? (localStorage.getItem(checkerKey()) || '') : ''; } catch (_) { activePerson = ''; } updateCheckerChip(); } function setActivePerson(name) { activePerson = name || ''; try { if (activePerson) localStorage.setItem(checkerKey(), activePerson); else localStorage.removeItem(checkerKey()); } catch (_) {} updateCheckerChip(); } function updateCheckerChip() { if (!checkerChipName || !checkerChip) return; checkerChipName.textContent = activePerson || 'Who’s checking?'; checkerChip.classList.toggle('is-set', !!activePerson); } // Fill the picker dropdown: a placeholder, "+ Add a name", then the roster. function populateCheckerSelect() { if (!checkerSelect) return; const roster = (checklistData && checklistData.myRoster) || []; checkerSelect.innerHTML = '' + '' + roster.map((n) => ``).join(''); checkerSelect.value = (activePerson && roster.indexOf(activePerson) >= 0) ? activePerson : ''; } function openCheckerModal() { if (!checkerModal) return; populateCheckerSelect(); if (checkerAddRow) checkerAddRow.style.display = 'none'; if (checkerNewName) checkerNewName.value = ''; if (checkerClear) checkerClear.style.display = activePerson ? '' : 'none'; checkerModal.style.display = 'flex'; } function onCheckerSelectChange() { const v = checkerSelect.value; if (v === '__add__') { if (checkerAddRow) { checkerAddRow.style.display = 'flex'; } if (checkerNewName) { checkerNewName.value = ''; checkerNewName.focus(); } return; } if (checkerAddRow) checkerAddRow.style.display = 'none'; if (v) { setActivePerson(v); playOnce(checkerChip, 'just-set'); closeOverlay(checkerModal); } } async function addRosterName() { const name = (checkerNewName && checkerNewName.value || '').trim(); if (!name) { if (checkerNewName) checkerNewName.focus(); return; } try { const res = await apiPost('/api/roster/add', { name }); if (res && res.roster) checklistData.myRoster = res.roster; setActivePerson(name); playOnce(checkerChip, 'just-set'); closeOverlay(checkerModal); } catch (err) { showToast('Could not add name: ' + err.message, 'error'); } } // On a fresh checker sign-in with no name chosen yet, prompt once. function maybePromptChecker() { if (currentUser && currentUser.role === 'user' && !activePerson) openCheckerModal(); } // ─── Team-name rosters: add/remove names (master + execs) ─────────── async function openRosterMgmt() { if (!rosterModal || !rosterBody) return; rosterModal.style.display = 'flex'; rosterBody.innerHTML = '
Loading…
'; try { const data = await apiGet('/api/admin/rosters'); rosterTeams = (data && data.teams) || []; drawRosterMgmt(); } catch (err) { rosterBody.innerHTML = `
Failed to load: ${escapeHtml(err.message)}
`; } } // 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 = '
No team logins to manage.
'; return; } const q = ((rosterSearch && rosterSearch.value) || '').trim().toLowerCase(); const locName = (t) => (t.location || '').trim() || '(no location)'; let teams = rosterTeams.filter((t) => !q || (locName(t) + ' ' + (t.team || '') + ' ' + (t.roster || []).join(' ')).toLowerCase().includes(q)); if (!teams.length) { rosterBody.innerHTML = '
No teams match that search.
'; return; } teams = teams.slice().sort((a, b) => locName(a).toLowerCase().localeCompare(locName(b).toLowerCase()) || (a.team || '').toLowerCase().localeCompare((b.team || '').toLowerCase())); const counts = {}; teams.forEach((t) => { const L = locName(t); counts[L] = (counts[L] || 0) + 1; }); let html = ''; let curLoc = null; teams.forEach((t) => { const L = locName(t); if (L !== curLoc) { curLoc = L; html += `
${escapeHtml(L)} ${counts[L]}
`; } const team = (t.team || '').trim() ? t.team : 'Whole building'; const chips = (t.roster || []).length ? t.roster.map((n) => `${escapeHtml(n)}`).join('') : 'no names yet'; html += `
${escapeHtml(team)}
${chips}
`; }); 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 = '
⚠ Known issues (' + open.length + ')
'; open.forEach((inc) => { html += '
' + '' + (inc.severity === 'down' ? '🔴' : '🟡') + '' + '' + escapeHtml(inc.what || 'Reported problem') + '' + (inc.details ? '' + escapeHtml(inc.details) + '' : '') + '' + escapeHtml(inc.by || '') + ' · ' + escapeHtml(timeAgo(inc.ts)) + '' + (canFix ? '' : '') + '
'; }); el.innerHTML = html; } // Editor triage view — every incident the user can see, resolve/reopen. const incidentsModal = document.getElementById('incidentsModal'); const incidentsList = document.getElementById('incidentsList'); const incidentsOpenOnly = document.getElementById('incidentsOpenOnly'); const incidentsSearch = document.getElementById('incidentsSearch'); function openIncidents() { if (!incidentsModal) return; incidentsModal.style.display = 'flex'; drawIncidents(); } function closeIncidents() { if (incidentsModal) closeOverlay(incidentsModal); } function drawIncidents() { if (!incidentsList) return; const openOnly = incidentsOpenOnly ? incidentsOpenOnly.checked : true; const q = (incidentsSearch && incidentsSearch.value || '').trim().toLowerCase(); let rows = allIncidents().slice(); if (openOnly) rows = rows.filter((i) => i.status === 'open'); if (q) rows = rows.filter((i) => ((i.equipName || '') + ' ' + (i.what || '') + ' ' + (i.details || '') + ' ' + (i.location || '') + ' ' + (i.team || '') + ' ' + (i.by || '')).toLowerCase().indexOf(q) >= 0); // Open first (down before minor), then by recency. rows.sort((a, b) => { if ((a.status === 'open') !== (b.status === 'open')) return a.status === 'open' ? -1 : 1; if (a.status === 'open' && a.severity !== b.severity) return a.severity === 'down' ? -1 : 1; return String(b.ts).localeCompare(String(a.ts)); }); if (!rows.length) { incidentsList.innerHTML = '
No gear issues' + (openOnly ? ' open' : '') + ' in your area. 🎉
'; return; } incidentsList.innerHTML = rows.map((inc) => { const scope = [inc.location, inc.team].filter(Boolean).join(' · ') || 'Unassigned'; const resolved = inc.status === 'resolved'; return '
' + '' + (resolved ? '✅' : (inc.severity === 'down' ? '🔴' : '🟡')) + '' + '
' + escapeHtml(inc.equipName || inc.what || 'Gear issue') + ' ' + escapeHtml(scope) + '
' + (inc.equipName && inc.what ? '
' + escapeHtml(inc.what) + '
' : '') + (inc.details ? '
' + escapeHtml(inc.details) + '
' : '') + '
' + escapeHtml(inc.by || '') + ' · ' + escapeHtml(timeAgo(inc.ts)) + (resolved ? ' · resolved by ' + escapeHtml(inc.resolvedBy || '?') + ' ' + escapeHtml(timeAgo(inc.resolvedAt)) : '') + '
' + '' + '
'; }).join(''); } async function resolveIncident(id, reopen) { try { await apiPost('/api/incidents/' + encodeURIComponent(id) + '/resolve', { reopen: !!reopen }); await refreshState({ showToastOnError: false }); drawIncidents(); updateIncidentsBadge(); if (equipModal && equipModal.style.display === 'flex' && equipGalleryEquipId) renderEquipIssues(equipGalleryEquipId); showToast(reopen ? 'Reopened.' : 'Marked resolved.', 'success'); } catch (err) { showToast(err && err.message ? err.message : 'Could not update.', 'error'); } } function updateIncidentsBadge() { const badge = document.getElementById('incidentsBtnCount'); if (!badge) return; const n = openIncidentCount(); if (n > 0) { badge.textContent = n; badge.style.display = ''; } else badge.style.display = 'none'; } // Wiring { const b = document.getElementById('reportIssueBtn'); if (b) b.addEventListener('click', () => openIncidentModal(null)); } { const b = document.getElementById('equipReportBtn'); if (b) b.addEventListener('click', () => openIncidentModal(equipGalleryEquipId ? { equipId: equipGalleryEquipId, equipName: equipGalleryName } : { equipName: equipGalleryName })); } { const b = document.getElementById('incidentsBtn'); if (b) b.addEventListener('click', openIncidents); } // menu panel auto-closes on button click { const b = document.getElementById('incidentClose'); if (b) b.addEventListener('click', closeIncidentModal); } { const b = document.getElementById('incidentsClose'); if (b) b.addEventListener('click', closeIncidents); } if (incidentSeverity) incidentSeverity.addEventListener('click', (e) => { const btn = e.target.closest('.sev-btn'); if (btn) setSeverity(btn.getAttribute('data-sev')); }); if (incidentSubmit) incidentSubmit.addEventListener('click', submitIncident); if (incidentWhat) incidentWhat.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); submitIncident(); } }); if (incidentsOpenOnly) incidentsOpenOnly.addEventListener('change', drawIncidents); if (incidentsSearch) incidentsSearch.addEventListener('input', drawIncidents); if (incidentsList) incidentsList.addEventListener('click', (e) => { const b = e.target.closest('.ir-action'); if (b) resolveIncident(b.getAttribute('data-incident-id'), b.getAttribute('data-reopen') === '1'); }); { const el = document.getElementById('equipIssues'); if (el) el.addEventListener('click', (e) => { const b = e.target.closest('.ei-resolve'); if (b) resolveIncident(b.getAttribute('data-incident-id'), false); }); } // ── Reliability dashboard — gear ranked by reported issues (read-only, over the scoped incidents) ── const reliabilityModal = document.getElementById('reliabilityModal'); const reliabilityList = document.getElementById('reliabilityList'); const reliabilityOpenFirst = document.getElementById('reliabilityOpenFirst'); const reliabilitySummary = document.getElementById('reliabilitySummary'); const RELIABILITY_WEEKS = 8; function openReliability() { if (!reliabilityModal) return; reliabilityModal.style.display = 'flex'; drawReliability(); } function closeReliability() { if (reliabilityModal) closeOverlay(reliabilityModal); } function drawReliability() { if (!reliabilityList) return; const incs = allIncidents(); const groups = {}; incs.forEach((i) => { const key = i.equipId || ('name:' + (i.equipName || i.what || 'Unknown')); const g = groups[key] || (groups[key] = { name: i.equipName || i.what || 'Unknown gear', equipId: i.equipId || '', total: 0, open: 0, down: 0, last: '', scopes: {}, items: [] }); g.total++; if (i.status === 'open') g.open++; if (i.severity === 'down') g.down++; if (!g.last || String(i.ts) > g.last) g.last = i.ts; const sc = [i.location, i.team].filter(Boolean).join(' · '); if (sc) g.scopes[sc] = 1; g.items.push(i); }); const openFirst = reliabilityOpenFirst ? reliabilityOpenFirst.checked : true; const rows = Object.values(groups).sort((a, b) => { if (openFirst && b.open !== a.open) return b.open - a.open; if (b.total !== a.total) return b.total - a.total; return String(b.last).localeCompare(String(a.last)); }); if (reliabilitySummary) reliabilitySummary.textContent = rows.length ? (rows.length + (rows.length === 1 ? ' item · ' : ' items · ') + incs.length + (incs.length === 1 ? ' report' : ' reports')) : ''; if (!rows.length) { reliabilityList.innerHTML = '
No gear issues reported yet in your area. 🎉
'; return; } const weekAgo = (ts) => Math.floor((Date.now() - new Date(ts).getTime()) / (7 * 86400000)); reliabilityList.innerHTML = rows.map((g) => { const buckets = new Array(RELIABILITY_WEEKS).fill(0); g.items.forEach((i) => { const w = weekAgo(i.ts); if (w >= 0 && w < RELIABILITY_WEEKS) buckets[RELIABILITY_WEEKS - 1 - w]++; }); const max = Math.max(1, ...buckets); const spark = buckets.map((c, idx) => '').join(''); const scopes = Object.keys(g.scopes).join(', '); const badge = g.open ? '' + g.open + ' open' : 'clear'; return '
' + '
' + escapeHtml(g.name) + (scopes ? ' ' + escapeHtml(scopes) + '' : '') + '
' + '
' + g.total + (g.total === 1 ? ' report' : ' reports') + (g.down ? ' · ' + g.down + ' down' : '') + ' · last ' + escapeHtml(timeAgo(g.last)) + '
' + '
' + spark + '
' + '
' + badge + '
'; }).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