Files

7656 lines
452 KiB
JavaScript
Raw Permalink Normal View History

// 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 = '<span>A new version is available.</span><button type="button" class="btn btn-primary btn-small">Refresh</button>';
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:<isDone
// when set> } 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 "<listId>::<col>"; 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 =
'<option value="">— pick your name —</option>' +
'<option value="__add__">+ Add a name…</option>' +
roster.map((n) => `<option value="${escapeAttr(n)}">${escapeHtml(n)}</option>`).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 = '<div class="empty-state" style="padding:8px;">Loading…</div>';
try {
const data = await apiGet('/api/admin/rosters');
rosterTeams = (data && data.teams) || [];
drawRosterMgmt();
} catch (err) { rosterBody.innerHTML = `<div class="empty-state" style="padding:8px;">Failed to load: ${escapeHtml(err.message)}</div>`; }
}
// 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 = '<div class="empty-state" style="padding:8px;">No team logins to manage.</div>'; 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 = '<div class="empty-state" style="padding:8px;">No teams match that search.</div>'; 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 += `<div class="mgmt-group-header">${escapeHtml(L)} <span class="mgmt-group-count">${counts[L]}</span></div>`;
}
const team = (t.team || '').trim() ? t.team : 'Whole building';
const chips = (t.roster || []).length
? t.roster.map((n) => `<span class="roster-chip">${escapeHtml(n)}<button type="button" class="roster-del" data-roster-user="${escapeAttr(t.username)}" data-roster-name="${escapeAttr(n)}" aria-label="Remove ${escapeAttr(n)}">×</button></span>`).join('')
: '<span class="user-row-tag empty">no names yet</span>';
html += `<div class="roster-team">
<div class="roster-team-head">${escapeHtml(team)}</div>
<div class="roster-chips">${chips}</div>
<div class="roster-add">
<input type="text" class="roster-add-input" data-roster-add-user="${escapeAttr(t.username)}" placeholder="Add a name…" autocapitalize="words" />
<button type="button" class="btn btn-secondary btn-small roster-add-btn" data-roster-add-user="${escapeAttr(t.username)}">Add</button>
</div>
</div>`;
});
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 = '<div class="panel"><h3>Keyboard shortcuts</h3>'
+ [['← / →', '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) => '<div class="krow"><span>' + r[1] + '</span><kbd>' + r[0] + '</kbd></div>').join('')
+ '<button class="btn btn-secondary btn-small" style="width:100%;margin-top:14px;" data-kb-close type="button">Close</button></div>';
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 <b>checkbox</b> to mark a step done — it slides over to <b>Done</b>."],
['👆', "Tap a step's <b>title</b> to open its photos, videos, and notes."],
['📝', "A <b>Notes</b> column holds reference info — nothing to check off."]
];
if (isEditor()) tips.push(['☰', "Use the <b>Menu</b> to add tasks or notes, reorder columns, and more."]);
tips.push(['⌨️', "On a desktop, press <b>?</b> any time for keyboard shortcuts."]);
const el = document.createElement('div');
el.className = 'firstrun';
el.innerHTML = '<div class="panel"><h3>Welcome 👋</h3><p class="lead">A few quick things, then you’re set.</p>'
+ tips.map((t) => '<div class="tip"><span class="ic">' + t[0] + '</span><span class="tx">' + t[1] + '</span></div>').join('')
+ '<button class="btn btn-primary btn-small go" data-fr-close type="button">Got it</button></div>';
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 = '<div class="search-empty">Type to search gear, notes, tasks, and lists.</div>'; return; }
if (!searchHits.length) { searchResults.innerHTML = '<div class="search-empty">No matches for “' + escapeHtml(q) + '”.</div>'; return; }
const icon = { gear: '🔧', note: '📝', task: '✓', list: '📋' };
let html = '', lastGroup = null;
searchHits.forEach((h, i) => {
if (h.group !== lastGroup) { lastGroup = h.group; html += '<div class="search-group">' + h.group + '</div>'; }
html += '<div class="search-hit' + (i === searchActive ? ' active' : '') + '" data-hit="' + i + '"><span class="ic">' + (icon[h.type] || '•') + '</span><span class="main"><div class="t">' + escapeHtml(h.title) + '</div><div class="sub">' + escapeHtml(h.sub) + '</div></span></div>';
});
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 = '<div class="equip-issues-head">⚠ Known issues (' + open.length + ')</div>';
open.forEach((inc) => {
html += '<div class="equip-issue ' + (inc.severity === 'down' ? 'down' : 'minor') + '">'
+ '<span class="ei-sev">' + (inc.severity === 'down' ? '🔴' : '🟡') + '</span>'
+ '<span class="ei-body"><span class="ei-what">' + escapeHtml(inc.what || 'Reported problem') + '</span>'
+ (inc.details ? '<span class="ei-details">' + escapeHtml(inc.details) + '</span>' : '')
+ '<span class="ei-meta">' + escapeHtml(inc.by || '') + ' · ' + escapeHtml(timeAgo(inc.ts)) + '</span></span>'
+ (canFix ? '<button class="btn btn-secondary btn-small ei-resolve" data-incident-id="' + inc.id + '">Resolve</button>' : '')
+ '</div>';
});
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 = '<div class="incidents-empty">No gear issues' + (openOnly ? ' open' : '') + ' in your area. 🎉</div>'; return; }
incidentsList.innerHTML = rows.map((inc) => {
const scope = [inc.location, inc.team].filter(Boolean).join(' · ') || 'Unassigned';
const resolved = inc.status === 'resolved';
return '<div class="incident-row ' + (resolved ? 'is-resolved' : inc.severity) + '">'
+ '<span class="ir-sev">' + (resolved ? '✅' : (inc.severity === 'down' ? '🔴' : '🟡')) + '</span>'
+ '<div class="ir-main"><div class="ir-title">' + escapeHtml(inc.equipName || inc.what || 'Gear issue') + ' <span class="ir-scope">' + escapeHtml(scope) + '</span></div>'
+ (inc.equipName && inc.what ? '<div class="ir-what">' + escapeHtml(inc.what) + '</div>' : '')
+ (inc.details ? '<div class="ir-details">' + escapeHtml(inc.details) + '</div>' : '')
+ '<div class="ir-meta">' + escapeHtml(inc.by || '') + ' · ' + escapeHtml(timeAgo(inc.ts))
+ (resolved ? ' · resolved by ' + escapeHtml(inc.resolvedBy || '?') + ' ' + escapeHtml(timeAgo(inc.resolvedAt)) : '') + '</div></div>'
+ '<button class="btn btn-secondary btn-small ir-action" data-incident-id="' + inc.id + '" data-reopen="' + (resolved ? '1' : '0') + '">' + (resolved ? 'Reopen' : 'Resolve') + '</button>'
+ '</div>';
}).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 = '<div class="incidents-empty">No gear issues reported yet in your area. 🎉</div>'; 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) => '<span class="spark-bar' + (c ? ' has' : '') + '" style="height:' + Math.round(3 + (c / max) * 21) + 'px" title="' + c + ' in week ' + (RELIABILITY_WEEKS - idx) + '"></span>').join('');
const scopes = Object.keys(g.scopes).join(', ');
const badge = g.open ? '<span class="rr-open">' + g.open + ' open</span>' : '<span class="rr-clear">clear</span>';
return '<div class="reliability-row' + (g.open ? '' : ' is-clear') + '" data-gear="' + escapeAttr(g.name) + '">'
+ '<div class="rr-main"><div class="rr-name">' + escapeHtml(g.name) + (scopes ? ' <span class="rr-scope">' + escapeHtml(scopes) + '</span>' : '') + '</div>'
+ '<div class="rr-meta">' + g.total + (g.total === 1 ? ' report' : ' reports') + (g.down ? ' · ' + g.down + ' down' : '') + ' · last ' + escapeHtml(timeAgo(g.last)) + '</div></div>'
+ '<div class="rr-spark" title="Issues over the last ' + RELIABILITY_WEEKS + ' weeks">' + spark + '</div>'
+ '<div class="rr-badge">' + badge + '</div></div>';
}).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 <input type="color"> 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 <label> wrapper.
function taskTargetFrom(el, requireBox) {
if (!el || !el.closest) return null;
if (requireBox && !el.closest('.md-task-box')) return null;
const row = el.closest('.md-task');
if (!row || !row.closest('.checklist-item-description')) return null; // live view only
const card = row.closest('.checklist-item');
if (card && card.getAttribute('data-kind') === 'note') return null; // a note's boxes are inert (backstops the server no-op)
const itemId = card && card.getAttribute('data-item-id');
const idx = parseInt(row.getAttribute('data-task-index'), 10);
if (!itemId || isNaN(idx)) return null;
return { row, itemId, idx };
}
document.addEventListener('click', (e) => {
const t = taskTargetFrom(e.target, true); // pointer: the BOX only
if (t) taskToggle(t.itemId, t.idx, !t.row.classList.contains('is-done'));
});
document.addEventListener('keydown', (e) => {
if (e.key !== ' ' && e.key !== 'Enter') return;
const t = taskTargetFrom(e.target, false); // keyboard: the focused row
if (t) { e.preventDefault(); taskToggle(t.itemId, t.idx, !t.row.classList.contains('is-done')); }
});
// Collapse/expand a task card: the chevron <button> (also handles Space/Enter natively),
// or a tap anywhere on a collapsible header EXCEPT the completion checkbox, the progress
// pill, or any other interactive control — those keep their own behavior.
document.addEventListener('click', (e) => {
const t = e.target; if (!t || !t.closest) return;
let card = null;
const btn = t.closest('.collapse-toggle');
if (btn) card = btn.closest('.checklist-item');
else {
const header = t.closest('.checklist-item-header.is-collapsible');
if (header && !t.closest('input,[data-action],button,a,label,.equip-term,.md-task,.subtask-parent')) {
card = header.closest('.checklist-item');
}
}
if (card) toggleCollapse(card);
});
// Request photos: tap a thumbnail → the full pan/zoom lightbox gallery.
document.addEventListener('click', (e) => {
const b = e.target && e.target.closest && e.target.closest('.request-photo-open');
if (!b) return;
const itemId = b.getAttribute('data-req-photos');
const ownerList = (checklistData.lists || []).find((l) => (l.items || []).some((i) => i.id === itemId));
const item = ownerList && ownerList.items.find((i) => i.id === itemId);
if (item && Array.isArray(item.images) && item.images.length) openEquipLightbox(item.title, item.images, '', '', []);
});
// Fold/unfold a whole column: its header chevron, or a tap anywhere on the column
// header EXCEPT the reorder/add-note buttons — those keep their own behavior.
document.addEventListener('click', (e) => {
const t = e.target; if (!t || !t.closest) return;
let col = null;
const btn = t.closest('.col-fold');
if (btn) col = btn.closest('.checklist-column');
else {
const h2 = t.closest('.checklist-column h2.col-foldable');
if (h2 && !t.closest('button,input,a,label')) col = h2.closest('.checklist-column');
}
if (col) toggleColFold(col);
});
// Lightbox gallery navigation: arrows, thumbnail strip, and ← / → keys.
if (equipPrev) equipPrev.addEventListener('click', () => equipShowPhoto(equipGalleryIndex - 1));
if (equipNext) equipNext.addEventListener('click', () => equipShowPhoto(equipGalleryIndex + 1));
if (equipThumbs) equipThumbs.addEventListener('click', (e) => {
const b = e.target.closest('.equip-thumb-btn');
if (b) equipShowPhoto(parseInt(b.getAttribute('data-i'), 10) || 0);
});
document.addEventListener('keydown', (e) => {
if (!equipModal || equipModal.style.display === 'none' || equipGalleryPhotos.length < 2) return;
if (e.key === 'ArrowLeft') { e.preventDefault(); equipShowPhoto(equipGalleryIndex - 1); }
else if (e.key === 'ArrowRight') { e.preventDefault(); equipShowPhoto(equipGalleryIndex + 1); }
});
newUserCreate.addEventListener('click', createUser);
if (directoryEditor) directoryEditor.addEventListener('click', (e) => {
const addBtn = e.target.closest('button[data-dir-add]');
if (addBtn) { addDirectoryEntry(addBtn.dataset.dirAdd); return; }
const editBtn = e.target.closest('button[data-dir-edit]');
if (editBtn) { beginDirectoryRename(editBtn, editBtn.dataset.dirEdit, editBtn.dataset.name); return; }
const saveBtn = e.target.closest('button[data-dir-rename-save]');
if (saveBtn) { commitDirectoryRename(saveBtn.dataset.dirRenameSave, saveBtn.dataset.from); return; }
const cancelBtn = e.target.closest('button[data-dir-rename-cancel]');
if (cancelBtn) { renderDirectoryEditor(); return; }
const delBtn = e.target.closest('button[data-dir-del]');
if (delBtn) removeDirectoryEntry(delBtn.dataset.dirDel, delBtn.dataset.name);
});
// Save a location's abbreviation when its field loses focus / changes.
if (directoryEditor) directoryEditor.addEventListener('change', (e) => {
const inp = e.target.closest('input[data-dir-abbrev]');
if (inp) saveLocationAbbrev(inp.dataset.dirAbbrev, inp.value);
});
if (newExecCreate) newExecCreate.addEventListener('click', createExec);
if (execList) execList.addEventListener('click', handleExecListClick);
if (userMgmtSearch) userMgmtSearch.addEventListener('input', drawUserMgmtList);
if (execSearch) execSearch.addEventListener('input', drawExecList);
setupListMenuHandle();
setupListSwipeNav();
setupMarkdownToolbars();
setupMovePopover();
// ─── Markdown formatting toolbar (description fields) ────────
// Lets anyone format without knowing markdown: buttons insert the syntax into
// the textarea selection, and a Write/Preview toggle shows the rendered result.
function applyMarkdown(ta, action) {
const val = ta.value, start = ta.selectionStart, end = ta.selectionEnd;
const sel = val.slice(start, end);
let out, selStart, selEnd;
const wrap = (mk, ph) => {
const m = mk.length;
// Already wrapped (markers inside the selection)? → unwrap (toggle off).
if (sel.length >= 2 * m && sel.slice(0, m) === mk && sel.slice(-m) === mk) {
const inner = sel.slice(m, sel.length - m);
out = val.slice(0, start) + inner + val.slice(end);
selStart = start; selEnd = start + inner.length;
return;
}
// Markers sit just OUTSIDE the selection? → strip them (toggle off).
if (val.slice(start - m, start) === mk && val.slice(end, end + m) === mk) {
out = val.slice(0, start - m) + sel + val.slice(end + m);
selStart = start - m; selEnd = selStart + sel.length;
return;
}
const text = sel || ph;
out = val.slice(0, start) + mk + text + mk + val.slice(end);
selStart = start + mk.length; selEnd = selStart + text.length; // keep the text selected
};
// `re` detects "is this line already this kind of prefix?" (toggle-off test);
// `stripRe` (defaults to `re`) removes any competing marker before adding a new one,
// so e.g. converting a bullet → checkbox never stacks "- [ ] - ".
const linePrefix = (fn, re, stripRe) => {
const strip = stripRe || re;
const ls = val.lastIndexOf('\n', start - 1) + 1; // start of the first selected line
const block = val.slice(ls, end);
const lines = block.split('\n');
const meaningful = lines.filter((ln) => ln.trim() !== '');
// Toggle OFF when every non-blank line already carries this kind of prefix (mirrors bold).
const allPrefixed = meaningful.length > 0 && meaningful.every((ln) => re.test(ln));
const next = lines.map((ln, i) => {
const bare = ln.replace(strip, ''); // strip any competing prefix (no stacking)
if (allPrefixed) return bare; // toggle off
return ln.trim() === '' ? ln : fn(i) + bare; // toggle on (skip blank lines)
}).join('\n');
out = val.slice(0, ls) + next + val.slice(end);
selStart = selEnd = ls + next.length; // cursor at end, ready to type
};
switch (action) {
case 'bold': wrap('**', 'bold text'); break;
case 'italic': wrap('*', 'italic text'); break;
case 'h': linePrefix(() => '## ', /^#{1,6} /); break;
case 'ul': linePrefix(() => '- ', /^- /); break;
case 'ol': linePrefix((i) => (i + 1) + '. ', /^\d+\. /); break;
// Checkbox: insert "- [ ] " (always UNCHECKED — checked state is runtime, never
// authored); toggling off strips the checkbox back to plain text; converting a
// bullet/number line strips its marker first so nothing stacks.
case 'task': linePrefix(() => '- [ ] ', /^\s*[-*]\s+\[[ xX]\]\s?/, /^\s*(?:[-*]\s+(?:\[[ xX]\]\s?)?|\d+\.\s+)/); break;
case 'link': {
const text = sel || 'link text';
out = val.slice(0, start) + '[' + text + '](https://)' + val.slice(end);
selStart = start + 1; selEnd = start + 1 + text.length; // select the label to overtype
break;
}
default: return;
}
ta.value = out;
ta.focus();
ta.setSelectionRange(selStart, selEnd);
}
// ─── WYSIWYG description editor ──────────────────────────────
// Two surfaces share ONE canonical value (the <textarea> = markdown source):
// • Formatted mode (default): a contenteditable shows rendered markdown and is
// edited directly; the toolbar applies real formatting (no symbols shown).
// • Source mode ("Markdown" on): the raw <textarea>, edited as markdown text.
// The textarea is always what gets saved, so save/load code is untouched. We only
// re-derive the source from the formatted view when the user actually edited it
// (a dirty flag) — so untouched descriptions are preserved byte-for-byte.
function richFor(taId) { return document.querySelector('.md-rich[data-md-rich-for="' + taId + '"]'); }
function barFor(taId) { return document.querySelector('.md-toolbar[data-md-target="' + taId + '"]'); }
// Inline HTML → markdown for the supported subset (bold/italic/links + nesting).
function inlineToMarkdown(node) {
let s = '';
node.childNodes.forEach((ch) => {
if (ch.nodeType === 3) { s += ch.nodeValue.replace(/\u00a0/g, ' '); return; }
if (ch.nodeType !== 1) return;
const tag = ch.nodeName;
if (tag === 'BR') { s += '\n'; return; }
const inner = inlineToMarkdown(ch);
if (tag === 'A') {
const href = (ch.getAttribute('href') || '').trim();
const label = inner.replace(/\s*\n+\s*/g, ' ').trim(); // no newlines inside [label] — keeps the link valid
s += (href && label) ? '[' + label + '](' + href + ')' : inner;
return;
}
if (!inner) return; // drop empty inline wrappers
const style = (ch.getAttribute && ch.getAttribute('style')) || '';
const bold = tag === 'STRONG' || tag === 'B' || /font-weight:\s*(bold|[6-9]00)/i.test(style);
const italic = tag === 'EM' || tag === 'I' || /font-style:\s*italic/i.test(style);
if (bold && italic) s += '***' + inner + '***';
else if (bold) s += '**' + inner + '**';
else if (italic) s += '*' + inner + '*';
else s += inner; // span/font/u/unknown inline → just its text
});
return s;
}
// One inline node (text or element) → markdown, applying its OWN formatting.
// Browsers emit top-level <b>/<i>/<a> with no block wrapper; this keeps their
// formatting (inlineToMarkdown only wraps a node's *children*).
function inlineNodeToMarkdown(node) {
if (node.nodeType === 3) return node.nodeValue.replace(/\u00a0/g, ' ');
if (node.nodeType !== 1) return '';
const tag = node.nodeName;
if (tag === 'BR') return '\n';
const inner = inlineToMarkdown(node);
if (tag === 'A') {
const href = (node.getAttribute('href') || '').trim();
const label = inner.replace(/\s*\n+\s*/g, ' ').trim(); // no newlines inside [label] — keeps the link valid
return (href && label) ? '[' + label + '](' + href + ')' : inner;
}
if (!inner) return '';
const style = (node.getAttribute && node.getAttribute('style')) || '';
const bold = tag === 'STRONG' || tag === 'B' || /font-weight:\s*(bold|[6-9]00)/i.test(style);
const italic = tag === 'EM' || tag === 'I' || /font-style:\s*italic/i.test(style);
if (bold && italic) return '***' + inner + '***';
if (bold) return '**' + inner + '**';
if (italic) return '*' + inner + '*';
return inner;
}
// Whole contenteditable → markdown source. Block-aware; defensive about the
// varied HTML browsers produce on Enter/paste (div/p/br, b/i/span styles).
function htmlToMarkdown(root) {
const blocks = [];
let para = [];
const flush = () => {
if (!para.length) return;
const t = para.join('').replace(/[ \t]+\n/g, '\n').replace(/\s+$/, '');
if (t.trim() !== '') blocks.push(t);
para = [];
};
const hashes = (tag) => '#'.repeat(((parseInt(tag.charAt(1), 10) - 1) % 3) + 1);
Array.prototype.forEach.call(root.childNodes, (node) => {
if (node.nodeType === 3) { para.push(node.nodeValue.replace(/\u00a0/g, ' ')); return; }
if (node.nodeType !== 1) return;
const tag = node.nodeName;
if (tag === 'BR') { para.push('\n'); return; }
if (/^H[1-6]$/.test(tag)) { flush(); const t = inlineToMarkdown(node).replace(/\n+/g, ' ').trim(); if (t) blocks.push(hashes(tag) + ' ' + t); return; }
if (tag === 'UL' || tag === 'OL') {
flush();
const lines = []; let n = 1;
Array.prototype.forEach.call(node.children, (li) => {
if (li.nodeName !== 'LI') return;
// A checkbox row → re-emit "- [ ]"/"- [x]". Serialize the LABEL span only
// (the .md-task-box is decorative/empty) so the box glyph can never fold
// into the label and grow the markdown on each round-trip.
if (li.classList && li.classList.contains('md-task')) {
// Serialize the label PLUS any text a bad delete/merge may have stranded
// elsewhere in the row — but never the decorative (hardened-empty) box — so a
// checkbox line's text can never be silently dropped on save even if the DOM
// was momentarily damaged. Clone-and-drop-box keeps the box from ever folding
// in (the original label-only concern) while capturing stray content.
const tmp = li.cloneNode(true);
tmp.querySelectorAll('.md-task-box').forEach((b) => b.remove()); // drop every decorative box
const t = inlineToMarkdown(tmp).replace(/\n+/g, ' ').trim();
lines.push('- [' + (li.getAttribute('data-checked') === 'true' ? 'x' : ' ') + '] ' + t);
return;
}
const t = inlineToMarkdown(li).replace(/\n+/g, ' ').trim();
lines.push((tag === 'OL' ? (n++) + '. ' : '- ') + t);
});
if (lines.length) blocks.push(lines.join('\n'));
return;
}
if (tag === 'P' || tag === 'DIV' || tag === 'BLOCKQUOTE') {
const hasBlockKids = Array.prototype.some.call(node.childNodes, (c) =>
c.nodeType === 1 && /^(P|DIV|H[1-6]|UL|OL|BLOCKQUOTE)$/.test(c.nodeName));
flush();
if (hasBlockKids) { const inner = htmlToMarkdown(node); if (inner.trim()) blocks.push(inner); }
else { const t = inlineToMarkdown(node).replace(/\s+$/, ''); if (t.trim() !== '') blocks.push(t); }
return;
}
para.push(inlineNodeToMarkdown(node)); // top-level inline element (keeps its own b/i/a)
});
flush();
return blocks.join('\n\n');
}
function markdownToRichHtml(md) {
try { return (window.MD && MD.render) ? MD.render(md || '') : escapeHtml(md || ''); }
catch (_) { return escapeHtml(md || ''); }
}
// Make rendered links atomic in the editor: Chrome otherwise "extends" a link
// when you type at its edge, swallowing surrounding text and corrupting the
// markdown round-trip. contenteditable=false stops that.
function hardenRichLinks(rich) {
if (!rich) return;
rich.querySelectorAll('a').forEach((a) => a.setAttribute('contenteditable', 'false'));
// Keep the decorative checkbox box atomic + guaranteed EMPTY: a caret/paste that
// leaked text into it would otherwise fold into the label and grow the markdown.
rich.querySelectorAll('.md-task-box').forEach((b) => {
b.setAttribute('contenteditable', 'false');
if (b.textContent) b.textContent = '';
});
}
function updateRichEmpty(rich) {
rich.classList.toggle('is-empty', !rich.textContent.trim());
}
// Build a fresh, UNCHECKED checkbox <li> matching md.js's output shape (empty CSS-drawn
// box + a label span) — used when Enter continues a checklist in the formatted editor.
function makeTaskLi() {
const li = document.createElement('li');
li.className = 'md-task';
li.setAttribute('data-checked', 'false');
const box = document.createElement('span');
box.className = 'md-task-box';
box.setAttribute('contenteditable', 'false');
box.setAttribute('aria-hidden', 'true');
const label = document.createElement('span');
label.className = 'md-task-label';
li.appendChild(box);
li.appendChild(label);
return li;
}
// Shared tail after any structural edit inside the formatted editor: mark dirty, refresh the
// empty/placeholder state, re-harden links/boxes, re-light the toolbar, keep the caret in view.
function finishRichEdit(rich) {
rich.dataset.dirty = '1';
updateRichEmpty(rich);
hardenRichLinks(rich);
refreshToolbarActive(rich.dataset.mdRichFor);
scrollCaretIntoView(rich);
}
// Replace a checkbox <li> with a plain block, splitting/removing its <ul> correctly. Shared by
// the Enter-exit and the Backspace-unwrap so the (fiddly) list surgery lives in ONE place. Has
// an explicit first-row branch the old inline Enter code lacked (which left an empty <ul>).
function replaceTaskLiWithBlock(li, block) {
const ul = li.closest('ul');
if (!ul) { li.replaceWith(block); return; }
if (!li.previousElementSibling && !li.nextElementSibling) {
ul.parentNode.insertBefore(block, ul); ul.remove(); // sole row → replace the list
} else if (!li.previousElementSibling) {
ul.parentNode.insertBefore(block, ul); li.remove(); // first row → line BEFORE the list
} else if (!li.nextElementSibling) {
ul.parentNode.insertBefore(block, ul.nextSibling); li.remove(); // last row → line AFTER the list
} else {
const newUl = document.createElement('ul'); // middle → split the list
let sib = li.nextElementSibling;
while (sib) { const next = sib.nextElementSibling; newUl.appendChild(sib); sib = next; }
ul.parentNode.insertBefore(block, ul.nextSibling);
if (newUl.children.length) ul.parentNode.insertBefore(newUl, block.nextSibling);
li.remove();
}
}
// Enter inside the FORMATTED editor: if the caret is on a checkbox line, continue the
// list with a new checkbox (splitting the label at the caret); pressing Enter on an
// EMPTY checkbox ends the list (a plain new line), mirroring how bullet/numbered lists
// behave. Non-checkbox lines fall through to the browser's normal Enter.
function handleRichEnter(e, rich) {
if (e.key !== 'Enter' || e.shiftKey || e.isComposing) return;
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
if (!range.collapsed) return; // a selection → let the browser handle it
let el = range.startContainer;
el = (el.nodeType === 1) ? el : el.parentNode;
const li = el && el.closest ? el.closest('li.md-task') : null;
if (!li || !rich.contains(li)) return;
e.preventDefault();
const label = li.querySelector('.md-task-label');
if (!label || label.textContent.trim() === '') {
// Empty checkbox + Enter → end the list: drop the checkbox and drop the caret onto
// a plain blank line where the list splits (shared surgery; handles the first-row case).
const block = document.createElement('div');
block.appendChild(document.createElement('br'));
replaceTaskLiWithBlock(li, block);
const r = document.createRange(); r.setStart(block, 0); r.collapse(true);
sel.removeAllRanges(); sel.addRange(r);
} else {
// Continue: move everything after the caret into a brand-new checkbox row.
const after = range.cloneRange();
after.setEnd(label, label.childNodes.length);
const frag = after.extractContents();
const newLi = makeTaskLi();
newLi.querySelector('.md-task-label').appendChild(frag);
li.parentNode.insertBefore(newLi, li.nextSibling);
const r = document.createRange(); r.setStart(newLi.querySelector('.md-task-label'), 0); r.collapse(true);
sel.removeAllRanges(); sel.addRange(r);
}
rich.dataset.dirty = '1';
updateRichEmpty(rich);
hardenRichLinks(rich);
refreshToolbarActive(rich.dataset.mdRichFor);
scrollCaretIntoView(rich);
}
// ── Escaping a checkbox line in the FORMATTED editor (Backspace / Delete) ──
// A step is <li.md-task>[<span.md-task-box ce=false>, <span.md-task-label>]. With no handler the
// browser's default delete is incoherent against the contenteditable=false box: it strips the
// label span, orphans the box, hides the caret, and leaves an li that still serializes to "- [ ]"
// (a stuck checkbox). These handlers make delete DO THE OBVIOUS THING instead — see handleRichDeleteKeys.
// Collapsed caret at the very START of a checkbox label? Returns that <li.md-task> (where Backspace
// should REMOVE the checkbox), else null. Resolves the li FIRST so a caret parked at li/box level
// (e.g. a left-margin tap) still counts as "start"; only bails on genuine text before the caret.
function taskLiIfCaretAtStart(range) {
if (!range || !range.collapsed) return null;
const node = range.startContainer;
const startEl = (node.nodeType === 1) ? node : node.parentNode;
const li = startEl && startEl.closest ? startEl.closest('li.md-task') : null;
if (!li) return null;
const label = li.querySelector('.md-task-label');
if (!label) return li; // damaged li (label gone) → still convertible
if (node !== label && !label.contains(node)) {
// caret seated at li/box level, not inside the label
if (node === li) {
const labelIdx = Array.prototype.indexOf.call(li.childNodes, label);
if (range.startOffset > labelIdx) return null; // caret is AFTER the label → not start
}
return li;
}
const pre = document.createRange();
pre.setStart(label, 0);
try { pre.setEnd(node, range.startOffset); } catch (_) { return null; }
if ((pre.cloneContents().textContent || '') !== '') return null; // real text before caret
return li;
}
// Turn a checkbox line into a plain paragraph: keep its text, drop the checkbox, caret at start.
function convertTaskLiToPlain(li, rich) {
const block = document.createElement('div');
// Move every child into the plain block in document order EXCEPT the decorative box;
// unwrap the label span (keep its inline children). Iterating ALL children — not just the
// label's — means any stray text/element a prior bad merge left OUTSIDE the label is
// salvaged here too, matching the save-path serialization so an unwrap can never drop text.
Array.prototype.slice.call(li.childNodes).forEach((c) => {
if (c.nodeType === 1 && c.classList && c.classList.contains('md-task-box')) return;
if (c.nodeType === 1 && c.classList && c.classList.contains('md-task-label')) {
while (c.firstChild) block.appendChild(c.firstChild);
} else {
block.appendChild(c);
}
});
// Drop empty text nodes: the real browser leaves a zero-length "" text node inside an
// emptied inline label, and moving it here would make the block look non-empty — so the
// <br> below never gets added and the resulting <div> is NOT a valid caret container
// (the caret then falls back into the previous step and typing lands there). Stripping
// them means an emptied line is truly empty and always gets its caret-holding <br>.
Array.prototype.slice.call(block.childNodes).forEach((c) => {
if (c.nodeType === 3 && c.nodeValue === '') c.remove();
});
if (!block.childNodes.length) block.appendChild(document.createElement('br'));
replaceTaskLiWithBlock(li, block);
const r = document.createRange(); r.setStart(block, 0); r.collapse(true);
const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(r);
finishRichEdit(rich);
}
// Collapsed caret at the START of a plain line whose PREVIOUS block is a checkbox list?
// Returns {block, li} to merge the plain line's text onto that checkbox's label, else null.
function plainLineToMergeUp(range) {
if (!range || !range.collapsed) return null;
const node = range.startContainer;
const startEl = (node.nodeType === 1) ? node : node.parentNode;
if (!startEl || !startEl.closest) return null;
if (startEl.closest('li.md-task')) return null; // that's a task line, not a plain one
const block = startEl.closest('div,p');
if (!block || !block.parentNode) return null;
const pre = document.createRange();
pre.setStart(block, 0);
try { pre.setEnd(node, range.startOffset); } catch (_) { return null; }
if ((pre.cloneContents().textContent || '') !== '') return null; // not at block start
const prev = block.previousElementSibling;
if (!prev) return null;
let li = null;
if (prev.nodeName === 'UL') {
const last = prev.lastElementChild;
if (last && last.classList && last.classList.contains('md-task')) li = last;
} else if (prev.classList && prev.classList.contains('md-task')) {
li = prev;
}
return li ? { block: block, li: li } : null;
}
// Append one node list onto a label at its end and seat the caret at the join point.
function appendIntoLabelAtCaret(label, source, rich) {
while (source.lastChild && source.lastChild.nodeType === 1 && source.lastChild.nodeName === 'BR') {
source.removeChild(source.lastChild); // drop a trailing empty-line <br>
}
const anchor = label.lastChild; // original end of the label (join point)
while (source.firstChild) label.appendChild(source.firstChild);
const r = document.createRange();
if (anchor) {
if (anchor.nodeType === 3) r.setStart(anchor, anchor.nodeValue.length);
else r.setStartAfter(anchor);
} else { r.setStart(label, 0); }
r.collapse(true);
const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(r);
finishRichEdit(rich);
}
// Backspace at start of a plain line under a checkbox list → merge the line into that label.
function mergePlainLineUp(block, li, rich) {
const label = li.querySelector('.md-task-label');
if (!label) { convertTaskLiToPlain(li, rich); return; } // damaged → don't guess
appendIntoLabelAtCaret(label, block, rich);
block.remove();
}
// Forward Delete at the END of a checkbox label whose next row is a checkbox → merge next up.
function nextTaskToMerge(range) {
if (!range || !range.collapsed) return null;
const node = range.startContainer;
const startEl = (node.nodeType === 1) ? node : node.parentNode;
const label = startEl && startEl.closest ? startEl.closest('.md-task-label') : null;
if (!label) return null;
const li = label.closest('li.md-task');
if (!li) return null;
const post = document.createRange();
try { post.setStart(node, range.startOffset); } catch (_) { return null; }
post.setEnd(label, label.childNodes.length);
if ((post.cloneContents().textContent || '') !== '') return null; // not at label end
const next = li.nextElementSibling;
if (next && next.classList && next.classList.contains('md-task')) return { li: li, next: next };
return null;
}
function mergeNextTaskUp(li, next, rich) {
const label = li.querySelector('.md-task-label');
const nextLabel = next.querySelector('.md-task-label');
if (!label) { next.remove(); finishRichEdit(rich); return; }
appendIntoLabelAtCaret(label, nextLabel || document.createElement('span'), rich);
next.remove();
}
// Detect whether the current caret is at one of the checkbox-escape boundaries and, if so,
// return the DOM action to run (else null). Shared by the keydown and beforeinput handlers so
// the two entry points can never disagree. kind: 'back' (Backspace) or 'fwd' (forward Delete).
function escapeDeleteAction(kind, rich) {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return null;
const range = sel.getRangeAt(0);
if (!range.collapsed || !rich.contains(range.startContainer)) return null; // selection → default
if (kind === 'back') {
const li = taskLiIfCaretAtStart(range);
if (li) return () => convertTaskLiToPlain(li, rich); // start of a step → drop the checkbox
const up = plainLineToMergeUp(range);
if (up) return () => mergePlainLineUp(up.block, up.li, rich); // plain line under a step → merge up
} else {
const fwd = nextTaskToMerge(range);
if (fwd) return () => mergeNextTaskUp(fwd.li, fwd.next, rich); // end of a step → pull next step up
}
return null;
}
// We intercept the delete on BOTH keydown AND beforeinput. Desktop browsers fire keydown and
// honor its preventDefault (which then suppresses beforeinput). But iOS/iPad Safari's soft
// keyboard often does NOT deliver a usable Backspace keydown (key can be 'Unidentified'), so the
// browser's default runs and merges the new line up into the step above — the reported bug.
// beforeinput's deleteContent*Backward/Forward is the reliable, spec-standard editing signal iOS
// DOES fire, so it catches what keydown misses. A short timestamp guard stops the two paths from
// both acting on the same physical delete. Modifier+Backspace at a boundary intentionally unwraps.
function handleRichDeleteKeys(e, rich) {
if (e.isComposing) return;
const kind = e.key === 'Backspace' ? 'back' : (e.key === 'Delete' ? 'fwd' : null);
if (!kind) return;
const act = escapeDeleteAction(kind, rich);
if (act) { e.preventDefault(); rich._delTs = Date.now(); act(); }
}
function handleRichBeforeInputDelete(e, rich) {
if (e.isComposing) return;
const t = e.inputType || '';
const kind = (/^delete\w*Backward$/.test(t)) ? 'back' : ((/^delete\w*Forward$/.test(t)) ? 'fwd' : null);
if (!kind) return;
if (rich._delTs && (Date.now() - rich._delTs) < 250) return; // keydown already handled this delete
const act = escapeDeleteAction(kind, rich);
if (!act) return; // not one of our boundaries → let the normal delete run
if (e.cancelable === false) return; // can't stop the default → let the input-repair net cope
e.preventDefault();
rich._delTs = Date.now();
act();
}
// Safety net: if any incoherent delete we DIDN'T intercept (dictation, an OS gesture, an iOS edge)
// ever leaves a checkbox <li> with its label span gone, the caret vanishes and the checkbox sticks.
// Repair that ghost state in place from the input event. Idempotent + a no-op during normal typing
// (a valid task li always has a label), so it only ever fires to self-heal real damage.
function repairDamagedTaskLis(rich) {
const damaged = [];
Array.prototype.forEach.call(rich.querySelectorAll('li.md-task'), (li) => {
if (!li.querySelector('.md-task-label')) damaged.push(li);
});
if (!damaged.length) return false;
const sel = window.getSelection();
const saved = (sel && sel.rangeCount) ? sel.getRangeAt(0).cloneRange() : null;
const caretNode = saved ? saved.startContainer : null;
damaged.forEach((li) => {
if (!li.isConnected) return;
const caretHere = caretNode && li.contains(caretNode);
convertTaskLiToPlain(li, rich); // reseats the caret into the repaired block
// If we healed a row the user ISN'T in, don't yank their caret to it — restore it.
if (!caretHere && saved) { try { sel.removeAllRanges(); sel.addRange(saved); } catch (_) {} }
});
return true;
}
// Enter inside the SOURCE (Markdown) textarea: same behavior via plain string edits.
function handleTaEnter(e, ta) {
if (e.key !== 'Enter' || e.shiftKey || e.isComposing) return;
const v = ta.value, s = ta.selectionStart, en = ta.selectionEnd;
if (s !== en) return;
const ls = v.lastIndexOf('\n', s - 1) + 1;
let le = v.indexOf('\n', s); if (le < 0) le = v.length;
const m = /^(\s*[-*]\s+\[[ xX]\]\s?)(.*)$/.exec(v.slice(ls, le));
if (!m) return; // not a checkbox line → normal Enter
e.preventDefault();
if (m[2].trim() === '') {
ta.value = v.slice(0, ls) + v.slice(le); // empty checkbox → end the list
ta.setSelectionRange(ls, ls);
} else {
const insert = '\n- [ ] ';
ta.value = v.slice(0, s) + insert + v.slice(s);
const c = s + insert.length;
ta.setSelectionRange(c, c);
}
autoGrowTa(ta);
refreshToolbarActive(ta.id);
}
// Grow the source-mode textarea to fit its content (up to ~half the viewport) so it's
// never a cramped little box and there's no need to fish for the resize grip on mobile.
function autoGrowTa(ta) {
if (!ta || ta.offsetParent === null) return; // hidden (formatted mode) → skip
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight + 2, Math.round(window.innerHeight * 0.5)) + 'px';
}
// If the formatted view was edited, push its content back to the source textarea.
function flushMarkdownEditor(taId) {
const ta = document.getElementById(taId);
const bar = barFor(taId);
const rich = richFor(taId);
if (!ta || !bar || !rich) return;
if (!bar.classList.contains('source-mode') && rich.dataset.dirty === '1') {
ta.value = htmlToMarkdown(rich);
rich.dataset.dirty = '';
}
}
// ── Caret preservation across the formatted ↔ source toggle ──
// Best range for the rich editor's caret: the live selection if it's in the
// editor, else the one captured on pointer-down (toolbar clicks steal focus).
function currentRichRange(rich) {
const sel = window.getSelection();
if (sel.rangeCount && rich.contains(sel.getRangeAt(0).endContainer)) return sel.getRangeAt(0);
if (rich._savedRange && rich.contains(rich._savedRange.endContainer)) return rich._savedRange;
return null;
}
// Rich-editor caret → offset in the MARKDOWN source (serialize everything before
// the caret; its length is the caret's position in the raw text).
function richCaretToMarkdownOffset(rich) {
const r = currentRichRange(rich);
if (!r) return null;
try {
const pre = document.createRange();
pre.setStart(rich, 0);
pre.setEnd(r.endContainer, r.endOffset);
const tmp = document.createElement('div');
tmp.appendChild(pre.cloneContents());
return htmlToMarkdown(tmp).length;
} catch (_) { return null; }
}
// Reverse: a caret offset in the markdown source → plain-text offset in the
// rendered formatted view (render the source prefix, measure its text length).
function markdownCaretToPlainOffset(mdValue, mdCaret) {
const tmp = document.createElement('div');
tmp.innerHTML = markdownToRichHtml(mdValue.slice(0, Math.max(0, mdCaret)));
return tmp.textContent.length;
}
function scrollCaretIntoView(container) {
const sel = window.getSelection();
if (!sel.rangeCount) return;
const rect = sel.getRangeAt(0).getBoundingClientRect();
if (!rect || (rect.top === 0 && rect.bottom === 0)) return; // can't measure → skip
const box = container.getBoundingClientRect();
if (rect.bottom > box.bottom) container.scrollTop += (rect.bottom - box.bottom) + 10;
else if (rect.top < box.top) container.scrollTop -= (box.top - rect.top) + 10;
}
// Put the caret at a plain-text offset inside the rich editor, then scroll to it.
function placeRichCaretAtPlainOffset(rich, target) {
const sel = window.getSelection();
const walker = document.createTreeWalker(rich, NodeFilter.SHOW_TEXT);
let node, last = null, remaining = target;
while ((node = walker.nextNode())) {
last = node;
if (remaining <= node.nodeValue.length) {
const r = document.createRange(); r.setStart(node, remaining); r.collapse(true);
sel.removeAllRanges(); sel.addRange(r); scrollCaretIntoView(rich); return;
}
remaining -= node.nodeValue.length;
}
if (last) { const r = document.createRange(); r.setStart(last, last.nodeValue.length); r.collapse(true); sel.removeAllRanges(); sel.addRange(r); scrollCaretIntoView(rich); }
}
// Toggle one field between formatted (default) and raw markdown source,
// keeping the caret where the user left it.
function setMarkdownMode(taId, source) {
const ta = document.getElementById(taId);
const bar = barFor(taId);
const rich = richFor(taId);
if (!ta || !bar || !rich) return;
const btn = bar.querySelector('.md-preview-btn');
if (source) {
const caret = richCaretToMarkdownOffset(rich); // map caret BEFORE hiding
if (rich.dataset.dirty === '1') { ta.value = htmlToMarkdown(rich); rich.dataset.dirty = ''; }
ta.style.height = (rich.offsetHeight || 180) + 'px'; // no size jump
rich.style.display = 'none'; ta.style.display = '';
bar.classList.add('source-mode');
if (btn) { btn.classList.add('active'); btn.setAttribute('aria-pressed', 'true'); }
ta.focus();
autoGrowTa(ta); // fit the box to its content now that it's visible
if (caret != null) { const p = Math.max(0, Math.min(caret, ta.value.length)); try { ta.setSelectionRange(p, p); } catch (_) {} }
} else {
const srcCaret = ta.selectionStart; // textareas keep their selection when blurred
rich.innerHTML = markdownToRichHtml(ta.value); // re-render any raw edits
hardenRichLinks(rich);
rich.dataset.dirty = '';
if (ta.style.display !== 'none') rich.style.height = (ta.offsetHeight || 180) + 'px';
ta.style.display = 'none'; rich.style.display = '';
bar.classList.remove('source-mode');
if (btn) { btn.classList.remove('active'); btn.setAttribute('aria-pressed', 'false'); }
updateRichEmpty(rich);
rich.focus();
placeRichCaretAtPlainOffset(rich, markdownCaretToPlainOffset(ta.value, srcCaret));
}
refreshToolbarActive(taId); // re-light the format buttons for the new mode/caret
}
// Apply real formatting inside the contenteditable (formatted mode).
function applyRichFormat(rich, action) {
rich.focus();
// Toolbar buttons can steal focus and collapse the live selection to a bare
// caret. Prefer the real (non-collapsed) selection captured on pointer-down so
// we format the text the user actually highlighted.
const sel = window.getSelection();
const live = sel.rangeCount ? sel.getRangeAt(0) : null;
const liveUsable = live && !live.collapsed && rich.contains(live.commonAncestorContainer);
const saved = rich._savedRange;
if (!liveUsable && saved && rich.contains(saved.commonAncestorContainer)) {
try { sel.removeAllRanges(); sel.addRange(saved); } catch (_) {}
}
try {
switch (action) {
case 'bold': document.execCommand('bold'); break;
case 'italic': document.execCommand('italic'); break;
case 'h': { // toggle heading on/off like bold: heading → paragraph, else → heading
const cur = (document.queryCommandValue('formatBlock') || '').toLowerCase();
document.execCommand('formatBlock', false, /^h[1-6]$/.test(cur) ? 'p' : 'h4');
break;
}
case 'ul': document.execCommand('insertUnorderedList'); break;
case 'ol': document.execCommand('insertOrderedList'); break;
case 'task': {
// No execCommand for task lists → round-trip through the markdown source:
// capture current edits, toggle the caret's line to/from "- [ ] ", re-render.
const taId = rich.dataset.mdRichFor;
const ta = taId && document.getElementById(taId);
if (!ta) break;
let caret = richCaretToMarkdownOffset(rich);
ta.value = htmlToMarkdown(rich);
if (caret == null) caret = ta.value.length;
caret = Math.max(0, Math.min(caret, ta.value.length));
const v = ta.value;
const ls = v.lastIndexOf('\n', caret - 1) + 1;
let le = v.indexOf('\n', caret); if (le < 0) le = v.length;
const line = v.slice(ls, le);
const next = /^\s*[-*]\s+\[[ xX]\]\s?/.test(line)
? line.replace(/^\s*[-*]\s+\[[ xX]\]\s?/, '') // toggle off → plain
: '- [ ] ' + line.replace(/^\s*(?:[-*]\s+(?:\[[ xX]\]\s?)?|\d+\.\s+)/, ''); // on → strip marker, add box
ta.value = v.slice(0, ls) + next + v.slice(le);
const newCaret = ls + next.length;
rich.innerHTML = markdownToRichHtml(ta.value);
hardenRichLinks(rich);
rich.dataset.dirty = '1';
updateRichEmpty(rich);
placeRichCaretAtPlainOffset(rich, markdownCaretToPlainOffset(ta.value, newCaret));
if (sel.rangeCount) rich._savedRange = sel.getRangeAt(0).cloneRange();
return; // skip the shared tail (we already hardened/marked dirty/saved range)
}
case 'link': {
const r = sel.rangeCount ? sel.getRangeAt(0) : null;
const hasSel = r && !r.collapsed && rich.contains(r.commonAncestorContainer);
if (hasSel) {
// Turn the highlighted text into a link (e.g. select "pco" → link it).
const url = (window.prompt('Paste the web address (URL) for the selected text:', 'https://') || '').trim();
if (url) document.execCommand('createLink', false, url);
} else {
// Nothing selected: ask for the text to show and where it points.
const text = (window.prompt('Link text (what people see):', '') || '').trim();
if (!text) break;
const url = (window.prompt('Paste the web address (URL):', 'https://') || '').trim();
if (!url) break;
const a = document.createElement('a'); a.href = url; a.textContent = text;
if (r && rich.contains(r.commonAncestorContainer)) {
r.insertNode(a); r.setStartAfter(a); r.collapse(true);
sel.removeAllRanges(); sel.addRange(r);
} else { rich.appendChild(a); }
}
break;
}
default: return;
}
} catch (_) { /* ignore unsupported execCommand */ }
hardenRichLinks(rich); // any newly-created link becomes atomic too
rich.dataset.dirty = '1';
updateRichEmpty(rich);
if (sel.rangeCount) rich._savedRange = sel.getRangeAt(0).cloneRange();
}
// Light up the toolbar buttons whose formatting is active at the caret (like the Markdown
// toggle lights up in source mode). WYSIWYG uses queryCommandState; source mode reads the
// markdown around the caret. Never touches the 'preview' button — setMarkdownMode owns that.
function mdQueryState(cmd) { try { return document.queryCommandState(cmd); } catch (_) { return false; } }
function mdQueryValue(cmd) { try { return document.queryCommandValue(cmd) || ''; } catch (_) { return ''; } }
function refreshToolbarActive(taId) {
const bar = barFor(taId); if (!bar) return;
const setOn = (md, on) => {
const b = bar.querySelector('button[data-md="' + md + '"]');
if (b) { b.classList.toggle('active', !!on); b.setAttribute('aria-pressed', on ? 'true' : 'false'); }
};
if (bar.classList.contains('source-mode')) {
const ta = document.getElementById(taId); if (!ta) return;
const v = ta.value, s = ta.selectionStart;
const lineStart = v.lastIndexOf('\n', s - 1) + 1;
let lineEnd = v.indexOf('\n', s); if (lineEnd === -1) lineEnd = v.length;
const line = v.slice(lineStart, lineEnd);
const isTaskLine = /^\s*[-*]\s+\[[ xX]\]\s?/.test(line);
setOn('task', isTaskLine);
setOn('h', /^#{1,6} /.test(line));
setOn('ul', /^- /.test(line) && !isTaskLine); // a checkbox line isn't a plain bullet
setOn('ol', /^\d+\. /.test(line));
setOn('bold', v.slice(lineStart, s).includes('**') && v.slice(s, lineEnd).includes('**'));
setOn('italic', false); // '*' vs '**' is ambiguous in raw text — don't guess
setOn('link', false);
} else {
const rich = richFor(taId); if (!rich) return;
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return;
let node = sel.getRangeAt(0).commonAncestorContainer;
if (!rich.contains(node)) return; // selection is elsewhere — leave this toolbar alone
setOn('bold', mdQueryState('bold'));
setOn('italic', mdQueryState('italic'));
setOn('h', /^h[1-6]$/.test(mdQueryValue('formatBlock').toLowerCase()));
while (node && node.nodeType !== 1) node = node.parentNode;
const inTask = !!(node && node.closest && node.closest('li.md-task'));
setOn('task', inTask);
setOn('ul', mdQueryState('insertUnorderedList') && !inTask); // don't invite pressing ul on a task line
setOn('ol', mdQueryState('insertOrderedList'));
setOn('link', !!(node && node.closest && node.closest('a')));
}
}
// ── Attach a document (📎) ────────────────────────────────────────────
// Upload a local file (PDF/Word/Excel/PowerPoint…) and insert a link to it at
// the caret — the description counterpart of an equipment photo. Google Docs
// and other web pages don't need this: the 🔗 Link button takes any URL.
let attachInput = null;
function attachDocument(taId, btn) {
const ta = document.getElementById(taId);
const bar = barFor(taId);
const rich = richFor(taId);
if (!ta || !bar || !rich) return;
if (!attachInput) {
attachInput = document.createElement('input');
attachInput.type = 'file';
attachInput.accept = '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.rtf';
attachInput.style.display = 'none';
document.body.appendChild(attachInput);
}
attachInput.onchange = async () => {
const file = attachInput.files && attachInput.files[0];
attachInput.value = ''; // allow re-picking the same file later
if (!file) return;
if (file.size > 25 * 1024 * 1024) { showToast('File too large (max 25 MB)', 'error'); return; }
const dataUrl = await new Promise((resolve) => {
const r = new FileReader();
r.onload = () => resolve(r.result);
r.onerror = () => resolve(null);
r.readAsDataURL(file);
});
if (!dataUrl) { showToast('Could not read that file', 'error'); return; }
if (btn) btn.disabled = true;
try {
const res = await apiPost('/api/documents/upload', { dataUrl, name: file.name });
// [label] can't contain ] or newlines — keep the filename otherwise.
const label = String(res.name || file.name).replace(/[\[\]\n]/g, ' ').trim() || 'document';
if (bar.classList.contains('source-mode')) {
const start = ta.selectionStart, end = ta.selectionEnd;
const md = '[' + label + '](' + res.path + ')';
ta.value = ta.value.slice(0, start) + md + ta.value.slice(end);
ta.focus();
ta.setSelectionRange(start + md.length, start + md.length);
} else {
const a = document.createElement('a');
a.href = res.path;
a.className = 'doc-link';
a.target = '_blank'; a.rel = 'noopener noreferrer';
a.setAttribute('contenteditable', 'false'); // atomic, like hardenRichLinks
a.textContent = label;
const space = document.createTextNode(' '); // typing continues OUTSIDE the atomic link
const saved = rich._savedRange;
const sel = window.getSelection();
if (saved && rich.contains(saved.commonAncestorContainer)) {
saved.collapse(false); // after any highlighted text, never replacing it
saved.insertNode(space);
saved.insertNode(a);
saved.setStartAfter(space); saved.collapse(true);
try { sel.removeAllRanges(); sel.addRange(saved); } catch (_) {}
} else {
rich.appendChild(a);
rich.appendChild(space);
}
rich.dataset.dirty = '1';
updateRichEmpty(rich);
}
showToast('Attached “' + label + '”', 'success');
} catch (err) {
showToast('Upload failed: ' + (err && err.message ? err.message : 'try again'), 'error');
} finally {
if (btn) btn.disabled = false;
}
};
attachInput.click();
}
function setupMarkdownToolbars() {
// Remember the caret/selection inside whichever formatted editor it's in, so
// toolbar buttons (which can steal focus) still format the intended text.
document.addEventListener('selectionchange', () => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const rg = sel.getRangeAt(0);
let node = rg.commonAncestorContainer;
while (node && node.nodeType !== 1) node = node.parentNode;
const rich = node && node.closest ? node.closest('.md-rich') : null;
if (rich) { rich._savedRange = rg.cloneRange(); refreshToolbarActive(rich.dataset.mdRichFor); }
const ae = document.activeElement; // source-mode textarea caret moves
if (ae && ae.tagName === 'TEXTAREA' && barFor(ae.id)) refreshToolbarActive(ae.id);
});
document.querySelectorAll('.md-toolbar').forEach((bar) => {
const taId = bar.dataset.mdTarget;
const ta = document.getElementById(taId);
const rich = richFor(taId);
if (!ta || !rich) return;
// Pressing a toolbar button must not lose the editor's selection. Capture
// it synchronously on pointer/mouse-down (before focus moves to the button)
// and preventDefault so focus stays in the editor.
const captureSel = (e) => {
if (!e.target.closest('button[data-md]')) return;
e.preventDefault();
const sel = window.getSelection();
if (sel && sel.rangeCount) {
const rg = sel.getRangeAt(0);
if (rich.contains(rg.commonAncestorContainer)) rich._savedRange = rg.cloneRange();
}
};
bar.addEventListener('pointerdown', captureSel);
bar.addEventListener('mousedown', captureSel);
bar.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-md]');
if (!btn) return;
e.preventDefault();
if (btn.dataset.md === 'preview') { setMarkdownMode(taId, !bar.classList.contains('source-mode')); return; }
if (btn.dataset.md === 'attach') { attachDocument(taId, btn); return; } // async upload+insert, same in both modes
if (bar.classList.contains('source-mode')) applyMarkdown(ta, btn.dataset.md);
else applyRichFormat(rich, btn.dataset.md);
refreshToolbarActive(taId);
});
rich.addEventListener('input', () => { repairDamagedTaskLis(rich); rich.dataset.dirty = '1'; updateRichEmpty(rich); refreshToolbarActive(taId); });
rich.addEventListener('keydown', (e) => handleRichEnter(e, rich));
rich.addEventListener('keydown', (e) => handleRichDeleteKeys(e, rich));
rich.addEventListener('beforeinput', (e) => handleRichBeforeInputDelete(e, rich));
['keyup', 'click', 'input'].forEach((ev) => ta.addEventListener(ev, () => refreshToolbarActive(taId)));
ta.addEventListener('input', () => autoGrowTa(ta));
ta.addEventListener('keydown', (e) => handleTaEnter(e, ta));
});
}
// Open a description fresh in formatted mode (never stuck in source/markdown).
function resetMarkdownEditor(taId) {
const ta = document.getElementById(taId);
const bar = barFor(taId);
const rich = richFor(taId);
if (!ta || !bar || !rich) return;
bar.classList.remove('source-mode');
const btn = bar.querySelector('.md-preview-btn');
if (btn) { btn.classList.remove('active'); btn.setAttribute('aria-pressed', 'false'); }
rich.innerHTML = markdownToRichHtml(ta.value);
hardenRichLinks(rich);
rich.dataset.dirty = '';
rich.style.height = ''; ta.style.height = '';
ta.style.display = 'none'; rich.style.display = '';
updateRichEmpty(rich);
refreshToolbarActive(taId); // start with the buttons reflecting the (empty) content
}
// ─── List menu (popover / bottom sheet) ──────────────────────
// Position the fixed panel so it's always fully on screen: align its right
// edge under the button, open below if there's room else flip above, and
// clamp to the viewport (capping height + scrolling if it's very tall). On
// narrow phones the CSS bottom-sheet rule takes over, so we bail out there.
function positionListMenu() {
const panel = listMenuPanel;
panel.style.left = panel.style.top = panel.style.maxHeight = '';
if (window.innerWidth <= 700) return; // CSS bottom sheet handles phones
const margin = 8;
const vw = window.innerWidth, vh = window.innerHeight;
panel.style.maxHeight = (vh - margin * 2) + 'px';
const br = listMenuHandle.getBoundingClientRect();
const pw = panel.offsetWidth;
const ph = Math.min(panel.scrollHeight, vh - margin * 2);
let left = Math.max(margin, Math.min(br.right - pw, vw - pw - margin));
let top;
if (ph <= vh - br.bottom - margin) top = br.bottom + 6; // below
else if (ph <= br.top - margin) top = br.top - ph - 6; // flip above
else top = margin; // clamp + scroll
panel.style.left = left + 'px';
panel.style.top = top + 'px';
}
function setupListMenuHandle() {
if (!listMenu || !listMenuHandle || !listMenuPanel) return;
let closeTimer = null;
const clearSheetInline = () => {
listMenuPanel.style.transition = '';
listMenuPanel.style.transform = '';
listMenuPanel.style.opacity = '';
if (listMenuBackdrop) { listMenuBackdrop.style.transition = ''; listMenuBackdrop.style.opacity = ''; }
};
const setOpen = (open) => {
if (open) {
if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; }
clearSheetInline();
listMenu.classList.add('open');
listMenuHandle.setAttribute('aria-expanded', 'true');
positionListMenu();
return;
}
if (!listMenu.classList.contains('open')) return;
listMenuHandle.setAttribute('aria-expanded', 'false');
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { listMenu.classList.remove('open'); clearSheetInline(); return; }
// Animate out: slide down (mobile sheet) or fade+lift (desktop popover),
// starting from wherever it is now (so a mid-swipe drag continues smoothly).
const mobile = window.innerWidth <= 700;
void listMenuPanel.offsetHeight; // commit current transform before transitioning
listMenuPanel.style.transition = 'transform 0.2s ease, opacity 0.2s ease';
listMenuPanel.style.transform = mobile ? 'translateY(110%)' : 'translateY(-8px) scale(0.97)';
listMenuPanel.style.opacity = '0';
if (listMenuBackdrop) { listMenuBackdrop.style.transition = 'opacity 0.2s ease'; listMenuBackdrop.style.opacity = '0'; }
if (closeTimer) clearTimeout(closeTimer);
closeTimer = setTimeout(() => { listMenu.classList.remove('open'); clearSheetInline(); closeTimer = null; }, 210);
};
listMenuHandle.addEventListener('click', (e) => {
e.stopPropagation();
setOpen(!listMenu.classList.contains('open'));
});
// Pick a tool → run it, then close the menu (skip the hidden file input).
listMenuPanel.addEventListener('click', (e) => {
if (e.target.closest('button')) setOpen(false);
});
if (listMenuBackdrop) listMenuBackdrop.addEventListener('click', () => setOpen(false));
// Click anywhere outside the menu closes it.
document.addEventListener('click', (e) => {
if (listMenu.classList.contains('open') && !listMenu.contains(e.target)) setOpen(false);
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && listMenu.classList.contains('open')) { setOpen(false); listMenuHandle.focus(); }
});
// Keep it on-screen if the window resizes while open.
window.addEventListener('resize', () => { if (listMenu.classList.contains('open')) positionListMenu(); });
// Mobile bottom sheet: swipe it down to dismiss. We only drag-to-close when
// the sheet's content is scrolled to the top and the finger moves DOWN — so
// normal scrolling of the menu items is untouched.
let touchStartY = null, touchStartScroll = 0, dragging = false;
const SHEET_DISMISS = 80; // px of downward drag to close
listMenuPanel.addEventListener('touchstart', (e) => {
if (window.innerWidth > 700 || e.touches.length !== 1) { touchStartY = null; return; }
touchStartY = e.touches[0].clientY;
touchStartScroll = listMenuPanel.scrollTop;
dragging = false;
}, { passive: true });
listMenuPanel.addEventListener('touchmove', (e) => {
if (touchStartY === null) return;
const dy = e.touches[0].clientY - touchStartY;
if (dy > 0 && touchStartScroll <= 0) {
dragging = true;
listMenuPanel.style.transition = 'none';
listMenuPanel.style.transform = 'translateY(' + Math.max(0, dy) + 'px)';
if (e.cancelable) e.preventDefault(); // suppress page scroll / rubber-band while dragging
}
}, { passive: false });
const endSwipe = (e) => {
if (touchStartY === null) return;
const t = (e.changedTouches && e.changedTouches[0]) || null;
const dy = t ? t.clientY - touchStartY : 0;
if (dragging) {
if (dy > SHEET_DISMISS) {
setOpen(false); // animates the slide-out from the current dragged position
} else {
listMenuPanel.style.transition = 'transform 0.18s ease';
listMenuPanel.style.transform = ''; // snap back into place
}
}
touchStartY = null; dragging = false;
};
listMenuPanel.addEventListener('touchend', endSwipe, { passive: true });
listMenuPanel.addEventListener('touchcancel', endSwipe, { passive: true });
}
// ─── Mobile swipe between lists ───────────────────────────────
// On phones, a mostly-horizontal swipe across the board moves to the
// previous / next list. We step through visibleLists() — the SAME ordered,
// permission-filtered set the tabs are built from — so every tier (member,
// team admin, executive, master) swipes only through the lists it's allowed
// to see, in tab order. No separate permission check is needed here.
function setupListSwipeNav() {
const main = document.querySelector('.main-content');
if (!main) return;
const SWIPE_MIN = 60; // px of horizontal travel to count as a swipe
const EDGE_RESIST = 0.3; // rubber-band when dragging past the first/last list
let startX = 0, startY = 0, lastX = 0;
let tracking = false; // a one-finger candidate gesture is in progress
let locked = null; // null until we commit to 'x' (swipe) or 'y' (scroll)
let section = null; // the .checklists-section we translate to follow the finger
let neighbors = null; // { prevId, nextId } resolved when the gesture starts
const board = () => document.querySelector('.checklists-section');
// Previous / next list ids within THIS user's own visible, ordered set.
function resolveNeighbors() {
const lists = visibleLists();
const cur = getCurrentList();
if (!cur || lists.length < 2) return null; // nothing to swipe to
const i = lists.findIndex((l) => l.id === cur.id);
if (i < 0) return null;
return {
prevId: i > 0 ? lists[i - 1].id : null,
nextId: i < lists.length - 1 ? lists[i + 1].id : null,
};
}
function clearFollow(animate) {
if (!section) return;
section.style.transition = animate ? 'transform 0.2s ease-out' : 'none';
section.style.transform = '';
}
main.addEventListener('touchstart', (e) => {
// Phones only; one finger; never hijack the tabs (tap-to-switch / admin
// drag-reorder), a contenteditable, or anything while a dialog is open.
if (window.innerWidth > 700 || e.touches.length !== 1) { tracking = false; return; }
if (e.target.closest('.list-tab, .list-tree, .modal-overlay, [contenteditable="true"]')) { tracking = false; return; }
neighbors = resolveNeighbors();
if (!neighbors) { tracking = false; return; }
const t = e.touches[0];
startX = lastX = t.clientX; startY = t.clientY;
tracking = true; locked = null; section = board();
}, { passive: true });
main.addEventListener('touchmove', (e) => {
if (!tracking) return;
const t = e.touches[0];
const dx = t.clientX - startX;
const dy = t.clientY - startY;
lastX = t.clientX;
if (locked === null) {
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return; // wait for a clear direction
locked = (Math.abs(dx) > Math.abs(dy) * 1.3) ? 'x' : 'y';
if (locked === 'y') { tracking = false; return; } // it's a scroll — let the page move
}
if (locked !== 'x') return;
if (e.cancelable) e.preventDefault(); // we own this gesture now → suppress page scroll
// Rubber-band when sliding toward a list that isn't there (first/last).
let eff = dx;
if ((dx > 0 && !neighbors.prevId) || (dx < 0 && !neighbors.nextId)) eff = dx * EDGE_RESIST;
if (section && !prefersReduced()) {
section.style.transition = 'none';
section.style.transform = 'translateX(' + eff + 'px)';
}
}, { passive: false });
function endGesture() {
if (!tracking) return;
tracking = false;
if (locked !== 'x') return;
justSwiped = Date.now(); // a horizontal swipe happened → suppress the stray trailing tap→collapse
const dx = lastX - startX;
// Swipe left → next list; swipe right → previous list.
const targetId = dx <= -SWIPE_MIN ? neighbors.nextId
: dx >= SWIPE_MIN ? neighbors.prevId
: null;
if (targetId) {
haptic(HAPTIC_TAP);
clearFollow(false); // drop the follow; the incoming list cascades in
setSelectedList(targetId);
} else {
clearFollow(true); // too short, or hit an end → snap back
}
}
main.addEventListener('touchend', endGesture, { passive: true });
main.addEventListener('touchcancel', () => { tracking = false; clearFollow(true); }, { passive: true });
}
// ─── Login / logout ──────────────────────────────────────────
function openLogin() {
loginLocation.value = '';
loginTeam.value = '';
if (loginExecName) loginExecName.value = '';
loginPassword.value = '';
loginError.style.display = 'none';
loginModal.style.display = 'flex';
updateLoginTeamVisibility();
loadDirectory();
// Autofocus is a real help on a desktop — you can type the location straight away.
// On a touch device, focusing a <select> immediately pops the OS picker over the
// whole sign-in sheet, so the app (and mobile web) opens on a wall of options
// instead of the form. Fine pointer = mouse/trackpad, so desktop keeps the
// affordance and phones get the sheet they expect.
if (!window.matchMedia || window.matchMedia('(pointer: fine)').matches) {
requestAnimationFrame(() => { loginLocation.focus({ preventScroll: true }); });
}
}
// "Executive" login swaps the Team dropdown for a "Who are you?" name field.
function updateLoginTeamVisibility() {
const teamGroup = loginTeam && loginTeam.closest('.form-group');
const isExec = loginLocation && loginLocation.value === 'executive';
if (teamGroup) {
teamGroup.style.display = isExec ? 'none' : 'block';
if (isExec) loginTeam.value = '';
}
if (loginExecNameGroup) {
loginExecNameGroup.style.display = isExec ? 'block' : 'none';
if (!isExec && loginExecName) loginExecName.value = '';
}
}
function closeLogin() { closeOverlay(loginModal); }
async function submitLogin() {
const location = loginLocation.value.trim();
const team = loginTeam.value.trim();
const name = (loginExecName && loginExecName.value || '').trim();
const password = loginPassword.value;
if (!location || !password) {
loginError.textContent = 'Enter your location and password.';
loginError.style.display = 'block';
return;
}
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// location doubles as `username` (master/legacy resolution); `name` is
// the "Who are you?" value used only for named-executive logins.
body: JSON.stringify({ username: location, location, team, name, password })
});
if (!res.ok) {
let msg = 'Wrong username or password.';
try { const j = await res.json(); if (j && j.error) msg = j.error; } catch (_) {}
loginError.textContent = msg;
loginError.style.display = 'block';
return;
}
const data = await res.json();
sessionToken = data.token;
localStorage.setItem('checklist:token', sessionToken);
applyUser(data.user);
closeLogin();
await refreshState();
maybePromptChecker(); // checker with no name yet → pop the picker
} catch (err) {
loginError.textContent = 'Network error: ' + err.message;
loginError.style.display = 'block';
}
}
async function logout() {
try { await apiPost('/api/logout', {}); } catch (_) {}
sessionToken = '';
localStorage.removeItem('checklist:token');
applyUser(null);
openLogin();
}
function applyUser(user) {
currentUser = user;
userName = user ? (user.displayName || user.username) : '';
clearHistory(); // undo/redo history is per-session and per-user — never carry it across logins
// Animation state is per-session too — start each login/logout with a clean slate so the
// first render can't mis-detect a list switch, mis-fire confetti, or carry a deferred render.
if (deferredRenderTimer) { clearTimeout(deferredRenderTimer); deferredRenderTimer = null; }
restoreColumnsAfterFlight();
animatingUntil = 0; firstDeferAt = 0; lastRenderedListId = null; celebratedListId = null; justMovedItemId = null;
// admin-mode = any admin (gets editing UI). master-mode = full control
// (user management, digests, import — the master-only menu items).
document.body.classList.toggle('admin-mode', !!(user && isEditor()));
document.body.classList.toggle('master-mode', !!(user && isMasterAdmin()));
// checker-mode = a shared team login (gets the "tap your name" chip).
document.body.classList.toggle('checker-mode', !!(user && user.role === 'user'));
loadActivePerson();
loadCollapseState(); // per-user collapse layout (empty map on logout → no leak to the next login)
loadColFoldState(); // per-user column folds (same lifecycle)
// First-run tips: re-arm the check on each login (shows once per user+device); clear on logout.
firstRunChecked = false;
if (firstRunEl) firstRunEl.classList.remove('open');
if (user) maybeShowFirstRun();
// Restore the "hide lists" preference (only takes visual effect in master mode).
let listsHidden = false;
try { listsHidden = localStorage.getItem('checklist:listsHidden') === '1'; } catch (_) {}
document.body.classList.toggle('lists-hidden', listsHidden);
// Restore the tree-vs-tabs navigator preference (master mode only, via CSS).
let treeView = false;
try { treeView = localStorage.getItem('checklist:treeView') === '1'; } catch (_) {}
document.body.classList.toggle('tree-view', treeView);
if (user) {
userBadgeName.textContent = userBadgeText(user);
} else {
userBadgeName.textContent = 'Sign in';
checklistData = { lists: [], selectedListId: null, checks: {} };
renderLists();
}
}
// Clean header label: master -> "Admin"; team login -> "Woonsocket · Production".
function userBadgeText(user) {
if (!user) return 'Sign in';
if (isMasterAdmin()) return user.displayName || 'Executive';
const loc = (user.location || '').trim();
const team = (user.team || '').trim();
if (loc && team) return loc + ' · ' + team;
if (loc) return loc;
return user.displayName || user.username;
}
function handleSessionExpired() {
sessionToken = '';
localStorage.removeItem('checklist:token');
applyUser(null);
openLogin();
}
// ─── User management (admin only) ────────────────────────────
async function openUserMgmt() {
userMgmtError.style.display = 'none';
newUserLocation.value = '';
newUserTeam.value = '';
newUserPassword.value = '';
newUserEmails.value = '';
userMgmtModal.style.display = 'flex';
if (newExecName) newExecName.value = '';
if (newExecPassword) newExecPassword.value = '';
if (userMgmtSearch) userMgmtSearch.value = '';
if (execSearch) execSearch.value = '';
renderDirectoryEditor();
renderExecList();
await renderUserMgmtList();
}
// Editable short codes (abbreviations) for each location/team in the directory.
function renderDirectoryEditor() {
if (!directoryEditor) return;
const dir = checklistData.directory || { locations: [], teams: [] };
const rows = (kind, entries) => (entries && entries.length)
? entries.map((e) => {
// Locations get an editable abbreviation (shown in the list tags).
const abbrevField = kind === 'locations'
? `<input class="dir-abbrev" type="text" maxlength="5" value="${escapeAttr((e.abbrev ? e.abbrev : locAbbrevDefault(e.name)).toUpperCase())}" data-dir-abbrev="${escapeAttr(e.name)}" title="Short tag for ${escapeAttr(e.name)}" aria-label="Abbreviation for ${escapeAttr(e.name)}" />`
: '';
return `
<div class="dir-row">
<span class="dir-name">${escapeHtml(e.name)}</span>
${abbrevField}
<span class="dir-row-actions">
<button class="dir-edit" type="button" title="Rename" data-dir-edit="${kind}" data-name="${escapeAttr(e.name)}">✎</button>
<button class="dir-del" type="button" title="Remove" data-dir-del="${kind}" data-name="${escapeAttr(e.name)}">×</button>
</span>
</div>`;
}).join('')
: '<div class="empty-state" style="padding:4px;">none yet</div>';
const addRow = (kind, ph) => kind === 'locations'
? `<div class="dir-add">
<input id="dirAdd-locations" placeholder="${ph}" autocapitalize="words" />
<input id="dirAddAbbrev-locations" class="dir-abbrev" maxlength="5" placeholder="ABBR" aria-label="Abbreviation for the new location" />
<button class="btn btn-secondary btn-small" type="button" data-dir-add="locations">Add</button>
</div>`
: `<div class="dir-add">
<input id="dirAdd-${kind}" placeholder="${ph}" autocapitalize="words" />
<button class="btn btn-secondary btn-small" type="button" data-dir-add="${kind}">Add</button>
</div>`;
directoryEditor.innerHTML =
`<div class="dir-col"><div class="dir-head">Locations</div>${rows('locations', dir.locations)}${addRow('locations', 'Add location…')}</div>` +
`<div class="dir-col"><div class="dir-head">Teams</div>${rows('teams', dir.teams)}${addRow('teams', 'Add team…')}</div>`;
}
// A fresh copy of the stored directory to mutate before PUTting it back.
function dirClone() {
const d = checklistData.directory || { locations: [], teams: [] };
return {
locations: (d.locations || []).map((e) => ({ name: e.name, abbrev: e.abbrev || '' })),
teams: (d.teams || []).map((e) => ({ name: e.name, abbrev: e.abbrev || '' }))
};
}
// Serialize all directory writes through one chain so concurrent ops can't
// clobber each other — e.g. an abbrev field's blur-save racing a delete click.
// Each step reads a FRESH clone (the prior step already refreshed checklistData),
// so the delete operates on state that already includes the abbrev change.
let dirChain = Promise.resolve();
function queueDirChange(mutate, successMsg) {
dirChain = dirChain.then(async () => {
const dir = dirClone();
if (mutate(dir) === false) return; // mutator aborted (no-op / validation)
await apiPut('/api/admin/directory', dir);
await refreshState({ showToastOnError: false }); // re-renders dropdowns everywhere
renderDirectoryEditor();
if (successMsg) showToast(successMsg, 'success');
}).catch((err) => { if (userMgmtError) { userMgmtError.textContent = err.message; userMgmtError.style.display = 'block'; } });
return dirChain;
}
function addDirectoryEntry(kind) {
const input = document.getElementById('dirAdd-' + kind);
const name = ((input && input.value) || '').trim();
if (!name) return;
let abbrev = '';
if (kind === 'locations') { const a = document.getElementById('dirAddAbbrev-locations'); abbrev = ((a && a.value) || '').trim(); }
queueDirChange((dir) => {
const arr = kind === 'teams' ? dir.teams : dir.locations;
if (arr.some((e) => (e.name || '').toLowerCase() === name.toLowerCase())) { showToast('Already in the list', 'error'); return false; }
arr.push(kind === 'teams' ? { name, abbrev: '' } : { name, abbrev });
}, `Added "${name}"`);
}
function saveLocationAbbrev(name, abbrev) {
const next = (abbrev || '').trim();
queueDirChange((dir) => {
const entry = dir.locations.find((e) => (e.name || '').toLowerCase() === (name || '').toLowerCase());
if (!entry) return false;
if ((entry.abbrev || '').toLowerCase() === next.toLowerCase()) return false; // no change
entry.abbrev = next;
});
}
function removeDirectoryEntry(kind, name) {
queueDirChange((dir) => {
if (kind === 'teams') dir.teams = dir.teams.filter((e) => e.name !== name);
else dir.locations = dir.locations.filter((e) => e.name !== name);
}, `Removed "${name}"`);
}
// Swap a directory row into an inline rename field (kind is 'locations'|'teams').
function beginDirectoryRename(btn, kind, name) {
const row = btn.closest('.dir-row');
if (!row) return;
row.innerHTML =
`<input class="dir-rename-input" type="text" value="${escapeAttr(name)}" autocapitalize="words" />` +
`<span class="dir-row-actions">` +
`<button class="dir-edit" type="button" title="Save" data-dir-rename-save="${kind}" data-from="${escapeAttr(name)}">✓</button>` +
`<button class="dir-del" type="button" title="Cancel" data-dir-rename-cancel="1">×</button>` +
`</span>`;
const input = row.querySelector('.dir-rename-input');
if (input) {
input.focus();
input.select();
input.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') { ev.preventDefault(); commitDirectoryRename(kind, name); }
else if (ev.key === 'Escape') { ev.preventDefault(); renderDirectoryEditor(); }
});
}
}
// Rename a location/team everywhere (cascades on the server); kind is plural here.
async function commitDirectoryRename(kind, from) {
const input = directoryEditor.querySelector('.dir-rename-input');
const to = ((input && input.value) || '').trim();
if (!to || to.toLowerCase() === String(from).toLowerCase()) { renderDirectoryEditor(); return; }
const singular = kind === 'teams' ? 'team' : 'location';
try {
await apiPost('/api/admin/directory/rename', { kind: singular, from, to });
await refreshState({ showToastOnError: false }); // re-renders all dropdowns/tabs
renderDirectoryEditor();
await renderUserMgmtList(); // logins may have been relabeled
renderExecList(); // exec scopes may have shifted
showToast(`Renamed to "${to}"`, 'success');
} catch (err) {
userMgmtError.textContent = err.message;
userMgmtError.style.display = 'block';
renderDirectoryEditor();
}
}
// ─── Executives (owner-managed named editors) ────────────────
async function renderExecList() {
if (!execList) return;
execList.innerHTML = '<div class="empty-state" style="padding:8px;">Loading…</div>';
try { mgmtExecs = (await apiGet('/api/admin/executives')).executives || []; }
catch (err) { execList.innerHTML = `<div class="empty-state" style="padding:8px;">Failed to load: ${escapeHtml(err.message)}</div>`; return; }
drawExecList();
}
// Sorted (alphabetical by name) + filtered render from the cached executives.
function drawExecList() {
if (!execList) return;
if (!mgmtExecs.length) { execList.innerHTML = '<div class="empty-state" style="padding:8px;">No executives yet. Add one below.</div>'; return; }
const q = ((execSearch && execSearch.value) || '').trim().toLowerCase();
let execs = mgmtExecs.filter((e) => !q || (e.name || '').toLowerCase().includes(q));
if (!execs.length) { execList.innerHTML = '<div class="empty-state" style="padding:8px;">No executives match that search.</div>'; return; }
execs = execs.slice().sort((a, b) => (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase()));
const dir = checklistData.directory || { locations: [], teams: [] };
const has = (arr, name) => (arr || []).some((s) => s.toLowerCase() === name.toLowerCase());
const cb = (kind, name, on, stale) => `<label class="exec-cb${stale ? ' exec-cb-stale' : ''}"><input type="checkbox" data-exec-cb="${kind}" value="${escapeAttr(name)}" ${on ? 'checked' : ''}/> ${escapeHtml(name)}${stale ? ' (removed)' : ''}</label>`;
// A scope entry whose name is no longer in the directory (the server keeps it
// only when scrubbing would broaden access): label it, and give it a checkbox
// so the grant can be explicitly unchecked — otherwise it'd be un-removable.
const dirHas = (arr) => { const s = new Set((arr || []).map((x) => (x.name || '').toLowerCase())); return (name) => s.has((name || '').toLowerCase()); };
const locInDir = dirHas(dir.locations), teamInDir = dirHas(dir.teams);
execList.innerHTML = execs.map((e) => {
const tag = (s, inDir, suffix) => `<span class="user-row-tag${inDir ? '' : ' user-row-tag-stale'}" ${inDir ? '' : 'title="No longer in the directory"'}>${escapeHtml(s)}${suffix}${inDir ? '' : ' · removed'}</span>`;
const tags = (e.scopeLocations || []).map((s) => tag(s, locInDir(s), ''))
.concat((e.scopeTeams || []).map((s) => tag(s, teamInDir(s), ' team')));
const scopeHtml = tags.length ? tags.join(' ') : '<span class="user-row-tag empty">no access yet</span>';
const staleLocs = (e.scopeLocations || []).filter((s) => !locInDir(s));
const staleTeams = (e.scopeTeams || []).filter((s) => !teamInDir(s));
const locCbs = (dir.locations || []).map((l) => cb('location', l.name, has(e.scopeLocations, l.name)))
.concat(staleLocs.map((s) => cb('location', s, true, true))).join('');
const teamCbs = (dir.teams || []).map((t) => cb('team', t.name, has(e.scopeTeams, t.name)))
.concat(staleTeams.map((s) => cb('team', s, true, true))).join('');
return `
<div class="user-row" data-exec-id="${escapeAttr(e.id)}">
<div class="user-row-main">
<div class="user-row-name">${escapeHtml(e.name)} <span class="user-row-username">executive</span></div>
<div class="user-row-meta">${scopeHtml}</div>
</div>
<div class="user-row-actions">
<button class="btn btn-secondary btn-small" data-exec-action="rename" data-exec-id="${escapeAttr(e.id)}" data-exec-name="${escapeAttr(e.name)}">Edit name</button>
<button class="btn btn-secondary btn-small" data-exec-action="access" data-exec-id="${escapeAttr(e.id)}">Edit access</button>
<button class="btn btn-secondary btn-small" data-exec-action="pw" data-exec-id="${escapeAttr(e.id)}">Reset password</button>
<button class="btn btn-danger btn-small" data-exec-action="del" data-exec-id="${escapeAttr(e.id)}">Delete</button>
</div>
</div>
<div class="exec-access" id="exec-access-${escapeAttr(e.id)}" style="display:none;">
<div class="exec-access-help">Edits lists matching the checked location(s) AND team(s). Unchecked column = all.</div>
<div class="dir-head">Locations</div>
<div class="exec-cb-row">${locCbs || '<span class="empty-state">none</span>'}</div>
<div class="dir-head" style="margin-top:6px;">Teams</div>
<div class="exec-cb-row">${teamCbs || '<span class="empty-state">none</span>'}</div>
<div style="text-align:right;margin-top:6px;">
<button class="btn btn-primary btn-small" data-exec-action="save-access" data-exec-id="${escapeAttr(e.id)}">Save access</button>
</div>
</div>
`;
}).join('');
}
async function createExec() {
userMgmtError.style.display = 'none';
const name = ((newExecName && newExecName.value) || '').trim();
const password = (newExecPassword && newExecPassword.value) || '';
if (!name || !password) { userMgmtError.textContent = 'Name and password are required.'; userMgmtError.style.display = 'block'; return; }
try {
await apiPost('/api/admin/executives', { name, password });
newExecName.value = '';
newExecPassword.value = '';
showToast(`Executive "${name}" created`, 'success');
await renderExecList();
} catch (err) { userMgmtError.textContent = err.message; userMgmtError.style.display = 'block'; }
}
async function handleExecListClick(ev) {
const btn = ev.target.closest('button[data-exec-action]');
if (!btn) return;
const id = btn.dataset.execId;
const action = btn.dataset.execAction;
userMgmtError.style.display = 'none';
try {
if (action === 'rename') {
const current = btn.dataset.execName || '';
const name = prompt('Edit executive name (add a last name to tell people apart):', current);
if (name === null) return; // cancelled
const trimmed = name.trim();
if (!trimmed || trimmed === current) return; // no change
await apiPut('/api/admin/executives/' + encodeURIComponent(id), { name: trimmed });
showToast('Name updated', 'success');
await renderExecList();
} else if (action === 'access') {
const panel = document.getElementById('exec-access-' + id);
if (panel) panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
} else if (action === 'save-access') {
const panel = document.getElementById('exec-access-' + id);
const scopeLocations = [...panel.querySelectorAll('input[data-exec-cb="location"]:checked')].map((i) => i.value);
const scopeTeams = [...panel.querySelectorAll('input[data-exec-cb="team"]:checked')].map((i) => i.value);
await apiPut('/api/admin/executives/' + encodeURIComponent(id), { scopeLocations, scopeTeams });
showToast('Access updated', 'success');
await renderExecList();
} else if (action === 'pw') {
const pw = prompt('New password for this executive (4+ characters):');
if (!pw) return;
await apiPut('/api/admin/executives/' + encodeURIComponent(id), { password: pw });
showToast('Password updated', 'success');
} else if (action === 'del') {
if (!confirm('Delete this executive?')) return;
await apiDelete('/api/admin/executives/' + encodeURIComponent(id));
showToast('Executive deleted', 'success');
await renderExecList();
}
} catch (err) { userMgmtError.textContent = err.message; userMgmtError.style.display = 'block'; }
}
// Turn "north-attleboro" / "woonsocket" into "North Attleboro" / "Woonsocket"
function prettyFromUsername(username) {
const clean = (username || '').trim().toLowerCase();
if (!clean) return '';
return clean
.replace(/[-_.]+/g, ' ')
.replace(/(^|\s)(\p{L})/gu, (_, sep, ch) => sep + ch.toUpperCase());
}
async function renderUserMgmtList() {
userMgmtList.innerHTML = '<div class="empty-state" style="padding:8px;">Loading...</div>';
try {
mgmtUsers = (await apiGet('/api/admin/users')).users || [];
} catch (err) {
userMgmtList.innerHTML = `<div class="empty-state" style="padding:8px;">Failed to load: ${escapeHtml(err.message)}</div>`;
return;
}
drawUserMgmtList();
}
// Grouped by location (alphabetical), teams alphabetical within each group
// (whole-building logins first), plus a live text filter — so a long list
// stays scannable.
function drawUserMgmtList() {
if (!userMgmtList) return;
if (!mgmtUsers.length) {
userMgmtList.innerHTML = '<div class="empty-state" style="padding:8px;">No team logins yet. Create one below.</div>';
return;
}
const dir = checklistData.directory || { locations: [], teams: [] };
const eq = (a, b) => String(a || '').trim().toLowerCase() === String(b || '').trim().toLowerCase();
const locOpts = (sel) => '<option value="">Select location…</option>' +
(dir.locations || []).map((l) => `<option value="${escapeAttr(l.name)}" ${eq(l.name, sel) ? 'selected' : ''}>${escapeHtml(l.name)}</option>`).join('');
// Only offer the team-less option to preserve a legacy building-wide login
// that already has no team; new/normal logins must pick a team.
const teamOpts = (sel) => (String(sel || '').trim() ? '' : '<option value="">Whole building (no team)</option>') +
(dir.teams || []).map((t) => `<option value="${escapeAttr(t.name)}" ${eq(t.name, sel) ? 'selected' : ''}>${escapeHtml(t.name)}</option>`).join('');
const q = ((userMgmtSearch && userMgmtSearch.value) || '').trim().toLowerCase();
let users = mgmtUsers.filter((u) => {
if (!q) return true;
const hay = [u.location, u.team, u.username, (u.emailRecipients || []).join(' ')].join(' ').toLowerCase();
return hay.includes(q);
});
if (!users.length) { userMgmtList.innerHTML = '<div class="empty-state" style="padding:8px;">No logins match that search.</div>'; return; }
const locName = (u) => (u.location || '').trim() || '(no location)';
const teamKey = (u) => ((u.team || '').trim() ? u.team.toLowerCase() : ''); // blank (whole-building) sorts first
users = users.slice().sort((a, b) =>
locName(a).toLowerCase().localeCompare(locName(b).toLowerCase()) || teamKey(a).localeCompare(teamKey(b)));
const counts = {};
users.forEach((u) => { const L = locName(u); counts[L] = (counts[L] || 0) + 1; });
let html = '';
let curLoc = null;
users.forEach((u) => {
const L = locName(u);
if (L !== curLoc) {
curLoc = L;
html += `<div class="mgmt-group-header">${escapeHtml(L)} <span class="mgmt-group-count">${counts[L]}</span></div>`;
}
const team = (u.team || '').trim() ? u.team : 'Whole building';
const emails = (u.emailRecipients && u.emailRecipients.length) ? u.emailRecipients.join(', ') : '';
const uname = escapeAttr(u.username);
html += `
<div class="user-row" data-username="${uname}">
<div class="user-row-main">
<div class="user-row-name">${escapeHtml(team)} <span class="user-row-username">@${escapeHtml(u.username)}</span></div>
<div class="user-row-meta">
${emails ? `<span class="user-row-tag email" title="${escapeAttr(emails)}">✉ ${escapeHtml(emails)}</span>` : '<span class="user-row-tag empty">no report email</span>'}
${u.role === 'admin' ? '<span class="user-row-tag admin">Team admin</span>' : ''}
</div>
</div>
<div class="user-row-actions">
<button class="btn btn-secondary btn-small" data-action="edit" data-username="${uname}">Edit</button>
<button class="btn btn-secondary btn-small" data-action="pw" data-username="${uname}">Reset password</button>
<button class="btn btn-danger btn-small" data-action="del" data-username="${uname}">Delete</button>
</div>
</div>
<div class="user-edit" id="user-edit-${uname}" style="display:none;">
<div class="user-edit-grid">
<label>Location<select data-edit="location">${locOpts(u.location)}</select></label>
<label>Team<select data-edit="team">${teamOpts(u.team)}</select></label>
<label>Report email(s)<input data-edit="emails" type="text" value="${escapeAttr(emails)}" placeholder="comma-separated — optional" /></label>
</div>
<div class="user-edit-actions">
<button class="btn btn-secondary btn-small" data-action="edit-cancel" data-username="${uname}">Cancel</button>
<button class="btn btn-primary btn-small" data-action="save-edit" data-username="${uname}">Save changes</button>
</div>
</div>`;
});
userMgmtList.innerHTML = html;
}
userMgmtList.addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const u = btn.dataset.username;
const action = btn.dataset.action;
// Toggle the inline editor without re-rendering the whole list.
if (action === 'edit' || action === 'edit-cancel') {
const panel = document.getElementById('user-edit-' + u);
if (panel) panel.style.display = (action === 'edit' && panel.style.display === 'none') ? 'block' : 'none';
return;
}
try {
if (action === 'del') {
if (!confirm(`Delete user "${u}"?`)) return;
await apiDelete(`/api/admin/users/${encodeURIComponent(u)}`);
showToast('User deleted', 'success');
await renderUserMgmtList();
} else if (action === 'pw') {
const pw = prompt(`New password for "${u}" (4+ characters):`);
if (!pw) return;
await apiPut(`/api/admin/users/${encodeURIComponent(u)}`, { password: pw });
showToast('Password updated', 'success');
} else if (action === 'save-edit') {
const panel = document.getElementById('user-edit-' + u);
if (!panel) return;
const location = panel.querySelector('select[data-edit="location"]').value.trim();
const team = panel.querySelector('select[data-edit="team"]').value.trim();
const emailRecipients = panel.querySelector('input[data-edit="emails"]').value.trim();
if (!location) { userMgmtError.textContent = 'Pick a location for this login.'; userMgmtError.style.display = 'block'; return; }
await apiPut(`/api/admin/users/${encodeURIComponent(u)}`, { location, team, emailRecipients });
showToast('Login updated', 'success');
await renderUserMgmtList();
await refreshState({ showToastOnError: false }); // pick up any new location/team + relabel
}
} catch (err) {
userMgmtError.textContent = err.message;
userMgmtError.style.display = 'block';
}
});
async function createUser() {
userMgmtError.style.display = 'none';
const location = newUserLocation.value.trim();
const team = newUserTeam.value.trim();
const password = newUserPassword.value;
const emailRecipients = newUserEmails.value.trim();
if (!location || !team || !password) {
userMgmtError.textContent = 'Location, team, and password are required.';
userMgmtError.style.display = 'block';
return;
}
try {
await apiPost('/api/admin/users', { location, team, password, role: 'user', emailRecipients });
newUserLocation.value = '';
newUserTeam.value = '';
newUserPassword.value = '';
newUserEmails.value = '';
const label = team ? `${location} · ${team}` : location;
showToast(`Login "${label}" created`, 'success');
await renderUserMgmtList();
await refreshState({ showToastOnError: false }); // pick up newly-registered location/team
} catch (err) {
userMgmtError.textContent = err.message;
userMgmtError.style.display = 'block';
}
}
// ─── Rendering ───────────────────────────────────────────────
// Preset section for the next Add Task open (set by a column header's + button).
let addGroupPreset = null;
// Fill the Add Task "Section" select from the target list's task sections;
// hidden entirely for notes and for lists with only the default section.
function populateItemGroupSelect(listId, preset) {
const row = document.getElementById('itemGroupRow');
const sel = document.getElementById('itemGroupSelect');
if (!row || !sel) return;
const list = (checklistData.lists || []).find((l) => l.id === listId);
const groups = list ? taskGroupsOf(list) : [{ id: 'todo', name: 'To Do' }];
if (addKind === 'note' || groups.length < 2) { row.style.display = 'none'; sel.innerHTML = ''; return; }
sel.innerHTML = groups.map((g) => `<option value="${escapeAttr(g.id)}">${escapeHtml(g.name)}</option>`).join('');
sel.value = (preset && groups.some((g) => g.id === preset)) ? preset : 'todo';
row.style.display = '';
}
function openAddTask() {
renderListSelect(); // populate "Add to list" (scope-limited for execs/team admins)
if (!addTargetListId) { showToast('No list you can add to yet', 'error'); return; }
setAddMode('task');
populateItemGroupSelect(addTargetListId, addGroupPreset);
addGroupPreset = null; // one-shot
resetMarkdownEditor('itemDescription');
addTaskModal.style.display = 'flex';
requestAnimationFrame(() => { itemTitleInput.focus({ preventScroll: true }); });
}
function closeAddTask() {
closeOverlay(addTaskModal);
}
function normalizeName(name) {
return (name || '').trim().toLowerCase();
}
// Auto-initials fallback when a location has no abbreviation set yet
// (mirrors the server's defaultAbbrev): "North Attleboro" -> "NA", "Greenville" -> "GR".
function locAbbrevDefault(name) {
const words = String(name || '').trim().split(/\s+/).filter(Boolean);
if (words.length >= 2) return words.map((w) => w[0]).join('').toUpperCase().slice(0, 4);
return String(name || '').trim().slice(0, 2).toUpperCase();
}
// Short label for a location, used in all the list scope tags. Prefers the
// master-set abbreviation from the directory; falls back to auto-initials.
function abbrevForLocation(name) {
const n = (name || '').trim();
if (!n) return '';
const dir = (checklistData.directory && checklistData.directory.locations) || [];
const hit = dir.find((e) => (e.name || '').toLowerCase() === n.toLowerCase());
return (hit && hit.abbrev) ? String(hit.abbrev).toUpperCase() : locAbbrevDefault(n);
}
// Human-readable description of a list's repeat (auto-reset) schedule.
// "HH:MM" (24h) → "9:00 AM" for human-readable schedule text.
function formatClock12(t) {
const m = /^(\d{1,2}):(\d{2})$/.exec(String(t || ''));
if (!m) return t;
let h = parseInt(m[1], 10);
const ap = h < 12 ? 'AM' : 'PM';
h = h % 12; if (h === 0) h = 12;
return `${h}:${m[2]} ${ap}`;
}
function resetScheduleText(s) {
if (!s || !s.type) return '';
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const n = (s.interval && s.interval > 1) ? s.interval : 1;
const every = (unit) => n > 1 ? `every ${n} ${unit}s` : `every ${unit}`;
let text;
switch (s.type) {
case 'daily': text = `Repeats ${every('day')}` + (n > 1 && s.startDow != null ? ` (from ${days[s.startDow]})` : ''); break;
case 'weekly': text = `Repeats ${every('week')} on ${days[s.dayOfWeek] || '?'}`; break;
case 'monthly': text = `Repeats ${every('month')} on day ${s.dayOfMonth}`; break;
case 'yearly': text = `Repeats ${every('year')} on ${months[s.month] || '?'} ${s.dayOfMonth}`; break;
default: return '';
}
// Intraday times (daily/weekly): "… at 9:00 AM, 12:00 PM & 5:00 PM".
if (Array.isArray(s.times) && s.times.length) {
const labels = s.times.map(formatClock12);
const joined = labels.length > 1
? labels.slice(0, -1).join(', ') + ' & ' + labels[labels.length - 1]
: labels[0];
text += ` at ${joined}`;
}
return text;
}
function canonicalName(name) {
const cleaned = (name || '').trim().replace(/\s+/g, ' ').slice(0, 80);
if (!cleaned) return '';
return cleaned
.toLowerCase()
.replace(/(^|[\s\-'])(\p{L})/gu, (_, sep, ch) => sep + ch.toUpperCase());
}
// Mirror the server's scopeKey() so the checkboxes we render line up with the
// keys the server stores checks under. Teamless users collapse to location only.
function myScopeKey() {
if (!currentUser) return '';
const loc = (currentUser.location || '').trim();
const team = (currentUser.team || '').trim();
if (loc && team) return canonicalName(loc) + '::' + canonicalName(team);
if (loc) return canonicalName(loc);
return canonicalName(currentUser.displayName || currentUser.username);
}
// The (location, team) scope key a list's checks live under — mirrors the
// server's scopeKey(list.location, list.team). '' for a global list.
function scopeKeyForList(list) {
if (!list) return '';
const loc = (list.location || '').trim();
const team = (list.team || '').trim();
if (loc && team) return canonicalName(loc) + '::' + canonicalName(team);
if (loc) return canonicalName(loc);
return '';
}
// Which scope's checks to READ for a list. The master AND executives oversee teams they
// don't belong to, so they observe each team's live record (the list's own scope) — the same
// shared record checkers write to. Members/team-admins use their own scope, which already IS
// their lists' scope. (Write path mirrors this: applyCheckLocally + server /api/check.)
function readScopeKey(listId) {
if (isMasterAdmin() || (currentUser && currentUser.role === 'executive')) {
const list = (checklistData.lists || []).find((l) => l.id === listId);
return scopeKeyForList(list) || myScopeKey();
}
return myScopeKey();
}
function getMyCheck(listId, itemId) {
const me = readScopeKey(listId);
if (!me) return null;
const c = checklistData.checks && checklistData.checks[me] && checklistData.checks[me][listId];
return (c && c[itemId]) || null;
}
function visibleLists() {
const lists = checklistData.lists || [];
if (isMasterAdmin()) return applyAdminFilter(lists);
if (currentUser && currentUser.role === 'executive') return lists.filter(execCanEditClient);
if (!currentUser) return [];
const myLoc = normalizeName(currentUser.location || '');
const myTeam = normalizeName(currentUser.team || '');
// Members never see hidden lists; editors (master/exec) still see them (dimmed) to manage.
return lists.filter((l) => listVisibleTo(l, myLoc, myTeam) && !l.hidden);
}
// global (no location, no team) | building-wide (location, no team) | team (location + team)
function listVisibleTo(l, myLoc, myTeam) {
const loc = normalizeName(l.location || '');
const team = normalizeName(l.team || '');
if (!loc && !team) return true; // global — everyone
if (loc && loc !== myLoc) return false; // a different building
if (!team) return true; // building-wide — all teams here
return team === myTeam; // team-specific
}
// Master-admin filter: narrow the visible lists by Location and/or Team.
function applyAdminFilter(lists) {
if (!adminFilterLoc && !adminFilterTeam) return lists;
return lists.filter((l) => {
const okLoc = !adminFilterLoc || normalizeName(l.location || '') === normalizeName(adminFilterLoc);
const okTeam = !adminFilterTeam || normalizeName(l.team || '') === normalizeName(adminFilterTeam);
return okLoc && okTeam;
});
}
function renderAdminFilters() {
const dir = checklistData.directory || { locations: [], teams: [] };
if (adminFilterLocation) {
adminFilterLocation.innerHTML = '<option value="">All locations</option>'
+ (dir.locations || []).map((l) => `<option value="${escapeAttr(l.name)}">${escapeHtml(l.name)}</option>`).join('');
adminFilterLocation.value = adminFilterLoc;
if (adminFilterLocation.value !== adminFilterLoc) { adminFilterLoc = ''; adminFilterLocation.value = ''; } // selection no longer exists
}
if (adminFilterTeamSel) {
adminFilterTeamSel.innerHTML = '<option value="">All teams</option>'
+ (dir.teams || []).map((t) => `<option value="${escapeAttr(t.name)}">${escapeHtml(t.name)}</option>`).join('');
adminFilterTeamSel.value = adminFilterTeam;
if (adminFilterTeamSel.value !== adminFilterTeam) { adminFilterTeam = ''; adminFilterTeamSel.value = ''; }
}
}
// Fill all location/team dropdowns (login form, list modal, add-login form) from the directory.
function renderDirectory(dir) {
const locs = (dir && dir.locations) || [];
const teams = (dir && dir.teams) || [];
// Add-team-login dropdowns (master): location required, team optional (whole-building login).
if (newUserLocation && newUserLocation.tagName === 'SELECT') {
const prev = newUserLocation.value;
newUserLocation.innerHTML =
'<option value="">Select location…</option>' +
locs.map((l) => `<option value="${escapeAttr(l.name)}">${escapeHtml(l.name)}</option>`).join('');
newUserLocation.value = prev || '';
}
if (newUserTeam && newUserTeam.tagName === 'SELECT') {
const prev = newUserTeam.value;
newUserTeam.innerHTML =
'<option value="">Select team…</option>' +
teams.map((t) => `<option value="${escapeAttr(t.name)}">${escapeHtml(t.name)}</option>`).join('');
newUserTeam.value = prev || '';
}
// Login location dropdown: placeholder, every location, then Executive (master).
if (loginLocation && loginLocation.tagName === 'SELECT') {
const prev = loginLocation.value;
loginLocation.innerHTML =
'<option value="">Select location…</option>' +
locs.map((l) => `<option value="${escapeAttr(l.name)}">${escapeHtml(l.name)}</option>`).join('') +
'<option value="executive">Executive</option>';
loginLocation.value = prev || '';
}
// Login team dropdown: a "Select team" prompt, then each team.
if (loginTeam && loginTeam.tagName === 'SELECT') {
const prev = loginTeam.value;
loginTeam.innerHTML =
'<option value="">Select team</option>' +
teams.map((t) => `<option value="${escapeAttr(t.name)}">${escapeHtml(t.name)}</option>`).join('');
loginTeam.value = prev || '';
}
populateScopeSelects({ locations: locs, teams: teams });
}
// Fill the Edit List location/team dropdowns (master) from the directory.
function populateScopeSelects(dir) {
dir = dir || checklistData.directory || { locations: [], teams: [] };
let locOpts = (dir.locations || []).slice();
let teamOpts = (dir.teams || []).slice();
// The master may place a list anywhere ("Everyone" / "Whole building"
// allowed). An executive is limited to the locations/teams they're scoped
// to; if a scope side is empty (= "any"), that side stays unrestricted.
let allowAllLoc = true;
let allowNoTeam = true;
if (currentUser && currentUser.role === 'executive') {
const sLoc = (currentUser.scopeLocations || []).map((s) => s.toLowerCase());
const sTeam = (currentUser.scopeTeams || []).map((s) => s.toLowerCase());
if (sLoc.length) { locOpts = locOpts.filter((l) => sLoc.includes((l.name || '').toLowerCase())); allowAllLoc = false; }
if (sTeam.length) { teamOpts = teamOpts.filter((t) => sTeam.includes((t.name || '').toLowerCase())); allowNoTeam = false; }
}
// New lists must pick a real location AND team — no "Everyone"/"Whole building".
// Those options only appear when EDITING a list that already has that empty
// value, so an existing un-scoped list is never silently reassigned.
const editing = (modalMode === 'rename' && modalTargetId)
? (checklistData.lists || []).find((l) => l.id === modalTargetId) : null;
const keepEmptyLoc = !!(editing && !String(editing.location || '').trim());
const keepEmptyTeam = !!(editing && !String(editing.team || '').trim());
if (listModalLocation && listModalLocation.tagName === 'SELECT') {
const prev = listModalLocation.value;
listModalLocation.innerHTML = (allowAllLoc && keepEmptyLoc ? '<option value="">Everyone (all locations)</option>' : '') +
locOpts.map((l) => `<option value="${escapeAttr(l.name)}">${escapeHtml(l.name)}</option>`).join('');
listModalLocation.value = prev || '';
}
if (listModalTeam && listModalTeam.tagName === 'SELECT') {
const prev = listModalTeam.value;
listModalTeam.innerHTML = (allowNoTeam && keepEmptyTeam ? '<option value="">Whole building (no team)</option>' : '') +
teamOpts.map((t) => `<option value="${escapeAttr(t.name)}">${escapeHtml(t.name)}</option>`).join('');
listModalTeam.value = prev || '';
}
}
async function loadDirectory() {
try { renderDirectory(await fetch('/api/directory').then((r) => r.json())); } catch (_) {}
}
function getCurrentList() {
const lists = visibleLists();
// Prefer the user's own client-side choice (survives polls).
let list = activeListId ? lists.find((l) => l.id === activeListId) : null;
if (!list) {
// Fall back to server's selectedListId, then first visible list.
list = lists.find((l) => l.id === checklistData.selectedListId) || lists[0];
if (list) {
activeListId = list.id;
localStorage.setItem('checklist:activeListId', list.id);
}
}
return list;
}
function setSelectedList(id) {
activeListId = id;
localStorage.setItem('checklist:activeListId', id);
if (!addTargetListId) addTargetListId = id;
renderLists();
}
window.changeList = setSelectedList;
// Defensive client-side column-order normalizer (mirror of the server's sanitizeColumnOrder): keep
// valid keys in order, dedupe, append any missing — so a garbage/undefined order from a replace-path
// never breaks the DOM reorder.
const COLUMN_KEYS_CLIENT = ['todo', 'done', 'notes', 'feedback'];
function columnOrderOf(list) {
const seen = new Set(); const out = [];
(Array.isArray(list.columnOrder) ? list.columnOrder : []).forEach((k) => {
if (COLUMN_KEYS_CLIENT.includes(k) && !seen.has(k)) { seen.add(k); out.push(k); }
});
COLUMN_KEYS_CLIENT.forEach((k) => { if (!seen.has(k)) out.push(k); });
return out;
}
// Show/hide the Notes column, order the three columns per the shared columnOrder, and toggle
// .has-notes (drives the ≤960px stacking). Idempotent — only touches the DOM when the order
// differs — so a normal check-off render doesn't shuffle nodes between Flip.capture and Flip.play.
// Returns whether Notes is visible, so the caller includes notesList in the FLIP arrays only then.
// ── Custom task sections (To Do + owner-added columns like Monday/Tuesday) ──
// Mirrors the server's sanitizeTaskGroups: the default section (stable id
// 'todo', renamable) always exists and always leads; legacy lists = just it.
function taskGroupsOf(list) {
const raw = Array.isArray(list && list.taskGroups) ? list.taskGroups.filter((g) => g && g.id && g.name) : [];
const out = raw.map((g) => ({ id: String(g.id), name: String(g.name) }));
const di = out.findIndex((g) => g.id === 'todo');
if (di < 0) out.unshift({ id: 'todo', name: 'To Do' });
else if (di > 0) out.unshift(out.splice(di, 1)[0]);
return out;
}
// Which section a task renders in — unknown/deleted section ids fall back to
// the default so a task can never be stranded invisible.
function itemGroupId(item, groups) {
return (item && item.group && groups.some((g) => g.id === item.group)) ? item.group : 'todo';
}
// Materialize the EXTRA sections as real columns inserted right after the
// built-in To Do column (which displays the default section under its custom
// name). Idempotent and NODE-PRESERVING: existing group columns are reused so
// FLIP container identity and fold animations survive re-renders; columns are
// only added/removed when the section set actually changes. Runs for every
// role — checkers see the sections; the + add-task shortcut is owner-only.
function syncTaskGroupColumns(list, groups) {
const section = todoList.closest('.checklists-section');
const todoCol = todoList.closest('.checklist-column');
if (!section || !todoCol) return;
const t = todoCol.querySelector('.col-title');
if (t) t.textContent = groups[0].name;
const wanted = groups.slice(1);
const wantedIds = new Set(wanted.map((g) => 'grp-' + g.id));
section.querySelectorAll('.checklist-column[data-col^="grp-"]').forEach((c) => {
if (!wantedIds.has(c.getAttribute('data-col'))) c.remove(); // section deleted (or another list's)
});
let prev = todoCol;
wanted.forEach((g) => {
const key = 'grp-' + g.id;
let col = section.querySelector('.checklist-column[data-col="' + cssEscape(key) + '"]');
if (!col) {
col = document.createElement('div');
col.className = 'checklist-column';
col.setAttribute('data-col', key);
col.innerHTML = '<h2><span class="col-title"></span></h2><div class="checklist-list" data-group-list="' + escapeAttr(g.id) + '"></div>';
}
col.querySelector('.col-title').textContent = g.name;
col.classList.toggle('col-hidden', todoCol.classList.contains('col-hidden')); // mirror tasksVisible
if (prev.nextElementSibling !== col) prev.insertAdjacentElement('afterend', col);
prev = col;
});
// Owner-only "+ task" shortcut on every task column once sections exist,
// so adding straight into "Tuesday" is one tap.
const cols = [todoCol].concat([...section.querySelectorAll('.checklist-column[data-col^="grp-"]')]);
const showAdd = groups.length > 1 && ownsListClient(list) && !list.notesOnly;
cols.forEach((col, i) => {
const h2 = col.querySelector('h2');
let btn = h2 && h2.querySelector('.col-add-task');
if (!showAdd) { if (btn) btn.remove(); return; }
if (!btn) {
btn = document.createElement('button');
btn.type = 'button';
btn.className = 'col-move col-add-task';
btn.innerHTML = '<svg viewBox="0 0 20 20" width="15" height="15" aria-hidden="true"><path d="M10 4v12M4 10h12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
const fold = h2.querySelector('.col-fold');
if (fold) h2.insertBefore(btn, fold); else h2.appendChild(btn);
}
const gid = i === 0 ? 'todo' : col.getAttribute('data-col').slice(4);
const gname = (col.querySelector('.col-title') || {}).textContent || 'this section';
btn.setAttribute('data-add-task-group', gid);
btn.setAttribute('title', 'Add a task to ' + gname);
btn.setAttribute('aria-label', 'Add a task to ' + gname);
});
}
function applyColumnLayout(list, canEdit, noteCount, taskCount, feedbackCount) {
const section = todoList.closest('.checklists-section');
const notesCol = notesList.closest('.checklist-column');
const feedbackCol = feedbackList && feedbackList.closest('.checklist-column');
// Editors ALWAYS see the Notes column when notes exist (even if showNotes is off) so a note can
// never be stranded/invisible after disabling Notes or moving a note into a non-Notes list.
const notesVisible = canEdit ? (!!list.showNotes || noteCount > 0) : (!!list.showNotes && noteCount > 0);
// Feedback column: checkers see it whenever it's enabled (even empty — they need the add box);
// editors also see it while entries exist so turning it off can never strand unread input.
const feedbackVisible = canEdit ? (!!list.showFeedback || feedbackCount > 0) : !!list.showFeedback;
// Notes-only list: hide To Do/Done — but NEVER while real tasks exist (for anyone). A checkable
// task must never be invisible, so tasks re-reveal the columns until they're moved/deleted.
const tasksVisible = !list.notesOnly || taskCount > 0;
if (notesCol) notesCol.classList.toggle('col-hidden', !notesVisible);
if (feedbackCol) feedbackCol.classList.toggle('col-hidden', !feedbackVisible);
if (section) {
section.querySelectorAll('.checklist-column[data-col="todo"], .checklist-column[data-col="done"]')
.forEach((c) => c.classList.toggle('col-hidden', !tasksVisible));
// has-notes drives the 3-column stacking breakpoint — any third column
// counts, including extra task sections (Monday/Tuesday/…).
section.classList.toggle('has-notes', notesVisible || feedbackVisible || taskGroupsOf(list).length > 1);
}
// A notes-only list adds notes, not tasks — hide "+ Add Task" for it (the list-settings
// checkbox converts it back; a task added any other way re-reveals the columns above).
{ const atb = document.getElementById('addTaskBtn'); if (atb) atb.style.display = list.notesOnly ? 'none' : ''; }
if (section) {
const order = columnOrderOf(list);
const cols = {};
section.querySelectorAll('.checklist-column').forEach((c) => { cols[c.getAttribute('data-col')] = c; });
const desired = order.filter((k) => cols[k]);
const current = [...section.querySelectorAll('.checklist-column')].map((c) => c.getAttribute('data-col'));
if (current.join(',') !== desired.join(',')) desired.forEach((k) => section.appendChild(cols[k]));
const visibleKeys = order.filter((k) => (k === 'notes' ? notesVisible : tasksVisible));
renderColHeaderControls(section, order, visibleKeys, list, canEdit);
}
return notesVisible;
}
const COL_LABEL = { todo: 'To Do', done: 'Done', notes: 'Notes', feedback: 'Feedback' };
// Inject editor-only reorder chevrons (and, on Notes, a "+ note" button) into each column header.
// Chevrons move a column one step among the VISIBLE columns; disabled at the ends. Glyphs rotate to
// ↑/↓ when the columns are stacked (CSS), and the aria-label matches the current orientation.
function renderColHeaderControls(section, order, visibleKeys, list, canEdit) {
// Reorder chevrons only earn their header clutter when a third column (Notes or Feedback) is
// up AND there are at least two visible columns to arrange (a notes-only board has nothing to
// reorder — but it still needs the "+ add note" button, so only the chevrons are dropped below).
const notesVisible = visibleKeys.indexOf('notes') >= 0;
const extraVisible = notesVisible || visibleKeys.indexOf('feedback') >= 0;
if (!canEdit || !extraVisible) { section.querySelectorAll('.col-reorder').forEach((b) => b.remove()); return; }
const showChevrons = visibleKeys.length >= 2;
const visible = visibleKeys;
const stacked = window.matchMedia('(max-width: 960px)').matches;
const chev = (pts) => `<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true" focusable="false"><polyline points="${pts}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
section.querySelectorAll('.checklist-column').forEach((col) => {
const key = col.getAttribute('data-col');
const h2 = col.querySelector('h2');
if (!h2) return;
let box = h2.querySelector('.col-reorder');
// Extra task sections are glued after To Do — they never get reorder chevrons.
if (key && key.indexOf('grp-') === 0) { if (box) box.remove(); return; }
if (!canEdit) { if (box) box.remove(); return; }
if (!box) { box = document.createElement('span'); box.className = 'col-reorder'; h2.appendChild(box); }
const pos = visible.indexOf(key);
const atStart = pos <= 0, atEnd = pos < 0 || pos >= visible.length - 1;
const name = COL_LABEL[key] || key;
const addNote = key === 'notes'
? `<button type="button" class="col-move col-add-note" data-add-note-list="${escapeAttr(list.id)}" title="Add a note" aria-label="Add a note to this list"><svg viewBox="0 0 20 20" width="15" height="15" aria-hidden="true"><path d="M10 4v12M4 10h12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>`
: '';
// Mass-delete lives with the other owner-only header controls, so checkers
// never see it. Only rendered while there's something to clear.
const clearFb = key === 'feedback' && Array.isArray(list.feedback) && list.feedback.length
? `<button type="button" class="col-move col-clear-feedback" data-clear-feedback-list="${escapeAttr(list.id)}" title="Clear all feedback" aria-label="Delete all feedback on this list"><svg viewBox="0 0 20 20" width="15" height="15" aria-hidden="true"><path d="M4 6h12M8.5 6V4.5h3V6M6.5 6l.8 9.5h5.4l.8-9.5M8.7 8.8v4.4M11.3 8.8v4.4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button>`
: '';
box.innerHTML = addNote + clearFb + (showChevrons
? `<button type="button" class="col-move" data-col-move="${escapeAttr(key)}" data-dir="-1" ${atStart ? 'disabled' : ''} aria-label="Move ${escapeAttr(name)} ${stacked ? 'up' : 'left'}">${chev('12 5 7 10 12 15')}</button>`
+ `<button type="button" class="col-move" data-col-move="${escapeAttr(key)}" data-dir="1" ${atEnd ? 'disabled' : ''} aria-label="Move ${escapeAttr(name)} ${stacked ? 'down' : 'right'}">${chev('8 5 13 10 8 15')}</button>`
: '');
});
}
// Stamp each column's fold state + header controls on EVERY render (renderLists calls this
// before Flip.capture so measurements are final). Unlike the reorder chevrons this is for
// every user type — fold state is per-user/per-device, so anyone may fold their own view.
// The header also gains a count pill, shown (via CSS) only while folded, so a folded list
// still says how much it's hiding.
function applyColumnFold(list, counts) {
const section = todoList.closest('.checklists-section');
if (!section) return;
section.querySelectorAll('.checklist-column').forEach((col) => {
const key = col.getAttribute('data-col');
const h2 = col.querySelector('h2');
if (!h2 || !key) return;
const folded = isColFolded(list.id, key);
col.classList.toggle('col-folded', folded);
h2.classList.add('col-foldable');
let count = h2.querySelector('.col-count');
if (!count) {
count = document.createElement('span');
count.className = 'col-count';
const title = h2.querySelector('.col-title');
if (title) title.insertAdjacentElement('afterend', count); else h2.appendChild(count);
}
count.textContent = String(counts[key] || 0);
let btn = h2.querySelector('.col-fold');
if (!btn) {
btn = document.createElement('button');
btn.type = 'button';
btn.className = 'col-fold';
btn.setAttribute('data-col-fold', key);
btn.innerHTML = '<svg class="chevron" viewBox="0 0 20 20" width="18" height="18" aria-hidden="true" focusable="false"><path d="M5 8l5 5 5-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
btn.setAttribute('aria-expanded', folded ? 'false' : 'true');
const listEl = col.querySelector('.checklist-list');
if (listEl && listEl.id) btn.setAttribute('aria-controls', listEl.id);
const colName = COL_LABEL[key] || (h2.querySelector('.col-title') || {}).textContent || key; // custom sections use their own name
btn.setAttribute('aria-label', (folded ? 'Unfold the ' : 'Fold the ') + colName + ' list');
// Keep the chevron the LAST header control even after renderColHeaderControls
// creates its .col-reorder box (which appends itself to the h2).
if (h2.lastElementChild !== btn) h2.appendChild(btn);
});
}
// Move a column one step among the currently-visible columns; persist the shared order (master via
// full-state undo snapshot, scoped via PUT with a pre-mutation snapshot so undo records it and the
// 6s poll — guarded by isSaving in saveMutation — can't clobber the optimistic order).
function reorderColumn(listId, key, dir) {
const list = (checklistData.lists || []).find((l) => l.id === listId);
if (!list || !ownsListClient(list)) return;
const hasNotes = (list.items || []).some((i) => i.kind === 'note');
const hasTasks = (list.items || []).some((i) => i.kind !== 'note');
const notesVisible = !!list.showNotes || hasNotes; // editor view
const feedbackVisible = !!list.showFeedback || (Array.isArray(list.feedback) && list.feedback.length > 0);
const tasksVisible = !list.notesOnly || hasTasks; // mirror applyColumnLayout
const full = columnOrderOf(list);
const visible = full.filter((k) => (k === 'notes' ? notesVisible : (k === 'feedback' ? feedbackVisible : tasksVisible)));
const i = visible.indexOf(key); const j = i + dir;
if (i < 0 || j < 0 || j >= visible.length) return;
const nv = visible.slice(); const t = nv[i]; nv[i] = nv[j]; nv[j] = t;
const next = nv.concat(full.filter((k) => !nv.includes(k))); // keep any hidden key (notes) at the end
if (next.join(',') === full.join(',')) return;
if (isMasterAdmin()) {
const st = cloneState(checklistData);
const l = st.lists.find((x) => x.id === listId); if (l) l.columnOrder = next;
justMovedItemId = null; // this isn't an item move — don't trigger the card-flight FLIP
pushAdminState(st);
} else {
const before = ownedListMap();
list.columnOrder = next; // optimistic
renderLists();
saveMutation(apiUpdateList(listId, { columnOrder: next }), before);
}
}
function openAddNote(listId) {
renderListSelect();
addTargetListId = listId || addTargetListId;
if (addListSelect && addTargetListId) addListSelect.value = addTargetListId;
setAddMode('note');
populateItemGroupSelect(addTargetListId); // notes have no section — hides the row
resetMarkdownEditor('itemDescription');
addTaskModal.style.display = 'flex';
requestAnimationFrame(() => { itemTitleInput.focus({ preventScroll: true }); });
}
document.addEventListener('click', (e) => {
const mv = e.target.closest && e.target.closest('.col-move[data-col-move]');
if (mv) { const list = getCurrentList(); if (list) reorderColumn(list.id, mv.getAttribute('data-col-move'), parseInt(mv.getAttribute('data-dir'), 10)); return; }
const an = e.target.closest && e.target.closest('.col-add-note');
if (an) { openAddNote(an.getAttribute('data-add-note-list')); return; }
const cf = e.target.closest && e.target.closest('.col-clear-feedback');
if (cf) { clearAllFeedback(cf.getAttribute('data-clear-feedback-list')); }
});
function renderLists() {
// This render supersedes any pending deferred reconcile and resets the deferral cap.
if (deferredRenderTimer) { clearTimeout(deferredRenderTimer); deferredRenderTimer = null; }
firstDeferAt = 0;
renderAdminFilters();
const visible = visibleLists();
if (!visible || visible.length === 0) {
if (isAdmin()) {
listTabs.innerHTML = '<div class="empty-state" style="padding:8px;">No lists yet. Open the menu to add one.</div>';
} else if (!userName) {
listTabs.innerHTML = '';
} else {
listTabs.innerHTML = `<div class="empty-state" style="padding:8px;">No lists assigned to "${escapeHtml(userName)}" yet.</div>`;
}
todoList.innerHTML = '';
doneList.innerHTML = '';
notesList.innerHTML = '';
if (feedbackList) feedbackList.innerHTML = '';
document.querySelectorAll('.checklist-column[data-col^="grp-"]').forEach((c) => c.remove()); // stale sections
if (listTree) listTree.innerHTML = '';
justMovedItemId = null; // consume it even on this exit path so a later poll can't replay a flight
renderListSelect();
updateRequestsBadge();
return;
}
renderListTabs();
renderListTree();
updateListViewToggle();
const list = getCurrentList();
if (!list) { todoList.innerHTML = ''; doneList.innerHTML = ''; notesList.innerHTML = ''; if (feedbackList) feedbackList.innerHTML = ''; document.querySelectorAll('.checklist-column[data-col^="grp-"]').forEach((c) => c.remove()); justMovedItemId = null; return; }
const canEdit = ownsListClient(list);
// Notes are reference-only: never in To Do/Done, never counted toward completion.
const taskItems = list.items.filter((i) => i.kind !== 'note');
const noteItems = list.items.filter((i) => i.kind === 'note');
const todoItems = taskItems.filter((i) => !isCheckedForMe(list.id, i.id));
const doneItems = taskItems.filter((i) => isCheckedForMe(list.id, i.id));
const fbEntries = Array.isArray(list.feedback) ? list.feedback : [];
// Split the unchecked tasks into the list's sections (default 'todo' + any
// custom ones like Monday/Tuesday). Checked tasks all share the one Done.
const groups = taskGroupsOf(list);
const byGroup = {};
groups.forEach((g) => { byGroup[g.id] = []; });
todoItems.forEach((i) => byGroup[itemGroupId(i, groups)].push(i));
// Order/show the columns BEFORE any Flip.capture or rect snapshot so measurements are final.
const notesVisible = applyColumnLayout(list, canEdit, noteItems.length, taskItems.length, fbEntries.length);
syncTaskGroupColumns(list, groups);
const foldCounts = { todo: byGroup.todo.length, done: doneItems.length, notes: noteItems.length, feedback: fbEntries.length };
groups.slice(1).forEach((g) => { foldCounts['grp-' + g.id] = byGroup[g.id].length; });
applyColumnFold(list, foldCounts);
// The extra sections' item containers (created/kept by syncTaskGroupColumns).
const groupEls = {};
groups.slice(1).forEach((g) => {
const el = document.querySelector('.checklist-column[data-col="grp-' + cssEscape(g.id) + '"] .checklist-list');
if (el) groupEls[g.id] = el;
});
const userAction = justMovedItemId !== null; // a tap moved an item (vs a 6s poll)
const movedId = justMovedItemId; // read before it's consumed below
const listChanged = list.id !== lastRenderedListId; // navigated to a different list (or first load)
const animOn = motionReady() && !prefersReduced(); // only then is there an animation to protect
// "Suck into header" flourish: only on MY completing tap (userAction) of a card that is
// moving To Do → Done, is currently EXPANDED (there's a body to vacuum up), and will land
// collapsed. Snapshot the old body BEFORE the rebuild; the clone animates on a fixed
// overlay afterward. A teammate's poll-completion (userAction=false) just renders collapsed.
let suckSnap = null;
if (userAction && animOn && movedId && doneItems.some((i) => i.id === movedId) && isCollapsed(movedId, true)) {
// The old card may live in ANY task section, not just the built-in To Do.
const oldCard = document.querySelector('.checklist-column[data-col="todo"] .checklist-item[data-item-id="' + cssEscape(movedId) + '"], .checklist-column[data-col^="grp-"] .checklist-item[data-item-id="' + cssEscape(movedId) + '"]');
const oldBody = (oldCard && !oldCard.classList.contains('collapsed')) ? oldCard.querySelector('.checklist-item-body') : null;
if (oldBody && oldBody.offsetHeight > 0) {
suckSnap = { clone: oldBody.cloneNode(true), rect: oldBody.getBoundingClientRect(), bg: getComputedStyle(oldCard).backgroundColor };
}
}
// FLIP only on a real mutation; a gentle cascade when you switch lists; nothing on polls.
// Include notesList only when the Notes column is visible (a hidden/zero-rect node would make
// every note read as "fresh" and enter-animate spuriously on each check-off). Folded columns
// are excluded for the same reason — their cards are display:none / zero-rect.
const flipTargets = [
!isColFolded(list.id, 'todo') ? todoList : null,
].concat(groups.slice(1).map((g) => (groupEls[g.id] && !isColFolded(list.id, 'grp-' + g.id)) ? groupEls[g.id] : null)).concat([
!isColFolded(list.id, 'done') ? doneList : null,
(notesVisible && !isColFolded(list.id, 'notes')) ? notesList : null,
]).filter(Boolean);
const firstRects = userAction ? Flip.capture(flipTargets) : null;
renderList(todoList, byGroup.todo, false, list.id, canEdit);
groups.slice(1).forEach((g) => { if (groupEls[g.id]) renderList(groupEls[g.id], byGroup[g.id], false, list.id, canEdit); });
renderList(doneList, doneItems, true, list.id, canEdit);
renderList(notesList, noteItems, false, list.id, canEdit, 'No notes yet');
renderFeedback(list, canEdit); // bespoke render — never FLIP'd, keeps in-progress typing
if (userAction) { Flip.play(firstRects, flipTargets); if (animOn) animatingUntil = Date.now() + 850; } // fly the (already-collapsed) card across; glide the rest
else if (listChanged) { Flip.enterAll(flipTargets); if (animOn) animatingUntil = Date.now() + 550; } // cascade items in on a list switch
if (suckSnap) { playSuckIntoHeader(suckSnap); animatingUntil = Math.max(animatingUntil, Date.now() + 1150); }
lastRenderedListId = list.id;
justMovedItemId = null; // consumed — don't replay on the next poll render
// Celebrate the moment a list reaches 100% — but only on the tap that completes it
// (not on every poll while it stays complete, and not on mere navigation to a done list).
const complete = taskItems.length > 0 && todoItems.length === 0; // notes don't count
if (complete) {
// A folded Done list is display:none (zero rect, which celebrateComplete rejects) —
// burst from its still-visible column header instead so 100% always celebrates.
if (userAction && celebratedListId !== list.id) { celebrateComplete(doneList.offsetParent ? doneList : doneList.closest('.checklist-column')); haptic(HAPTIC_DONE); }
celebratedListId = list.id;
} else if (celebratedListId === list.id) {
celebratedListId = null; // dropped back below 100% → arm the burst again
}
renderListSelect();
applyKbFocus(); // re-apply the keyboard-focus ring (survives poll/action re-renders)
updateRequestsBadge(); // header ✋ count tracks every render/poll
}
// CANONICAL task-list regex — MUST stay byte-identical to md.js TASK_RE and server.js
// TASK_LINE_RE, or checkbox indices/counts disagree across the stack.
const TASK_LINE_RE = /^\s*[-*]\s+\[([ xX])\]\s*(.*)$/;
// Checkbox lines authored inline in a description, in document order. index = position.
function parseTaskList(description) {
const out = [];
String(description == null ? '' : description).replace(/\r\n?/g, '\n').split('\n').forEach((line) => {
const m = TASK_LINE_RE.exec(line);
if (m) out.push({ index: out.length, label: m[2] });
});
return out;
}
function itemTaskCount(listId, itemId) {
const list = (checklistData.lists || []).find((l) => l.id === listId);
const item = list && list.items.find((i) => i.id === itemId);
return item ? parseTaskList(item.description).length : 0;
}
// How many of a task's checkboxes are checked for me (only in-range indices count).
function subDoneCount(listId, itemId, check) {
const c = check || getMyCheck(listId, itemId);
const sc = (c && c.subChecks) || {};
const total = itemTaskCount(listId, itemId);
return Object.keys(sc).filter((k) => { const n = Number(k); return Number.isInteger(n) && n >= 0 && n < total; }).length;
}
function isTaskCheckedForMe(listId, itemId, index) {
const c = getMyCheck(listId, itemId);
return !!(c && c.subChecks && c.subChecks[String(index)]);
}
// A task with checkboxes is "done" iff EVERY checkbox is checked (derived live from the
// per-location marks, so the column and the parent pill can never disagree, even before
// the optimistic completed flag lands). Tasks without checkboxes use the normal flag.
function isCheckedForMe(listId, itemId) {
const total = itemTaskCount(listId, itemId);
if (total > 0) return subDoneCount(listId, itemId) === total;
const c = getMyCheck(listId, itemId);
return !!(c && c.completed);
}
function renderListTabs() {
const current = (getCurrentList() || {}).id;
const lists = visibleLists();
listTabs.innerHTML = lists.map((list) => {
const scopeText = [abbrevForLocation(list.location), (list.team || '').trim()].filter(Boolean).join(' · ');
// Master and executives both see lists across multiple locations/teams, so
// show the scope on each tab to tell same-named lists apart. Members only
// ever see their own scope, so the badge would be redundant noise for them.
const showScope = isMasterAdmin() || (currentUser && currentUser.role === 'executive');
const locBadge = showScope && scopeText
? `<span class="list-tab-loc">${escapeHtml(scopeText)}</span>`
: '';
// Editors still see hidden lists (members don't) — mark them so it's obvious.
const hiddenBadge = list.hidden ? '<span class="list-tab-hidden" title="Hidden from members">🚫 hidden</span>' : '';
const repeatBadge = list.resetSchedule ? `<span class="list-tab-repeat" title="${escapeAttr(resetScheduleText(list.resetSchedule))}">🔁</span>` : '';
// Requests inbox: show the OPEN count right on the tab so waiting requests are unmissable.
let requestsBadgeTab = '';
if (list.isRequests) {
const rec = listTeamChecks(list) || {};
const open = (list.items || []).filter((i) => i.kind !== 'note' && !((rec[i.id] || {}).completed)).length;
if (open) requestsBadgeTab = `<span class="list-tab-requests" title="${open} open request${open === 1 ? '' : 's'}">✋ ${open}</span>`;
}
const dragAttrs = ownsListClient(list) // any owner (master, exec, team admin) can drag-reorder their lists
? `draggable="true"
ondragstart="onListDragStart(event,'${escapeAttr(list.id)}')"
ondragend="onListDragEnd()"`
: '';
// Preset colors map to CSS via [data-color="name"]; a custom hex sets --lc inline.
let colorAttr = '';
if (list.color) {
colorAttr = list.color.charAt(0) === '#'
? `data-color="custom" style="--lc:${escapeAttr(list.color)}"`
: `data-color="${escapeAttr(list.color)}"`;
}
return `
<button class="list-tab ${list.id === current ? 'active' : ''} ${list.hidden ? 'list-hidden' : ''}"
data-list-id="${escapeAttr(list.id)}"
${colorAttr}
onclick="changeList('${escapeAttr(list.id)}')"
${dragAttrs}>
${escapeHtml(list.name)}${locBadge}${repeatBadge}${requestsBadgeTab}${hiddenBadge}
</button>
`;
}).join('');
updateListsToggle(lists);
}
// "Hide lists" toggle (master): update its label + show the active list name
// while the strip is collapsed so the owner keeps context.
function updateListsToggle(lists) {
if (!listTabsToggle) return;
const count = (lists || visibleLists()).length;
const hidden = document.body.classList.contains('lists-hidden');
// Icon + label spans so the label can collapse to just the icon on mobile.
listTabsToggle.innerHTML = hidden
? `<span class="lt-icon" aria-hidden="true">📋</span><span class="lt-label">Show lists</span><span class="lt-count">${count}</span>`
: `<span class="lt-icon" aria-hidden="true">📋</span><span class="lt-label">Hide lists</span>`;
listTabsToggle.setAttribute('aria-label', hidden ? `Show lists (${count})` : 'Hide lists');
listTabsToggle.setAttribute('aria-expanded', hidden ? 'false' : 'true');
if (activeListName) {
const cur = getCurrentList();
if (cur) {
const scope = [abbrevForLocation(cur.location), (cur.team || '').trim()].filter(Boolean).join(' · ');
const scopeHtml = scope ? ` <span class="active-list-scope">${escapeHtml(scope)}</span>` : '';
activeListName.innerHTML = `Editing <b>${escapeHtml(cur.name)}</b>${scopeHtml}`;
} else {
activeListName.innerHTML = '';
}
}
}
// ─── Family-tree navigation (master): Location → Team → Lists ──────
const COLOR_HEX = {
blue: '#39a8ff', green: '#2ecc71', amber: '#e0a800', red: '#ef4444', purple: '#9b59b6',
teal: '#1abc9c', pink: '#e84393', slate: '#7f8c8d', orange: '#e67e22', yellow: '#f1c40f',
lime: '#8bc34a', cyan: '#00bcd4', indigo: '#5c6bc0', brown: '#8d6e63'
};
function colorHex(c) { return !c ? '' : (c.charAt(0) === '#' ? c : (COLOR_HEX[c] || '')); }
function updateListViewToggle() {
if (!listViewToggle) return;
const isTree = document.body.classList.contains('tree-view');
listViewToggle.innerHTML = isTree
? '<span class="lt-icon" aria-hidden="true">▦</span><span class="lt-label">Tab view</span>'
: '<span class="lt-icon" aria-hidden="true">🌳</span><span class="lt-label">Tree view</span>';
// Keep an accessible name when the label is icon-only on mobile.
listViewToggle.setAttribute('aria-label', isTree ? 'Switch to tab view' : 'Switch to tree view');
}
// Switch between the flat tab strip and the Location→Team→List tree with a
// cascade animation: the current view shrinks/fades out, then the incoming
// rows fall into place one after another. Falls back to an instant swap when
// the user prefers reduced motion.
// Slide the list strip out/in when hiding/showing, rather than snapping. The
// body.lists-hidden swap (which drives display:none) is sequenced AFTER the
// collapse so the animation is visible.
let listsHideTimer = null;
function animateListsHidden(hidden) {
try { localStorage.setItem('checklist:listsHidden', hidden ? '1' : ''); } catch (_) {}
const apply = () => { document.body.classList.toggle('lists-hidden', hidden); updateListsToggle(); };
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const strip = document.body.classList.contains('tree-view') ? listTree : listTabs;
if (reduce || !strip) { apply(); return; }
if (listsHideTimer) { clearTimeout(listsHideTimer); listsHideTimer = null; }
if (hidden) {
strip.classList.add('strip-collapsing');
listsHideTimer = setTimeout(() => { strip.classList.remove('strip-collapsing'); apply(); listsHideTimer = null; }, 190);
} else {
apply(); // un-hide first so the strip is laid out, then animate it in
strip.classList.add('strip-expanding');
listsHideTimer = setTimeout(() => { strip.classList.remove('strip-expanding'); listsHideTimer = null; }, 320);
}
}
let viewSwitchTimer = null;
function switchListView(toTree) {
try { localStorage.setItem('checklist:treeView', toTree ? '1' : ''); } catch (_) {}
const apply = () => { document.body.classList.toggle('tree-view', toTree); updateListViewToggle(); renderLists(); };
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !listTabs || !listTree) { apply(); return; }
if (viewSwitchTimer) { clearTimeout(viewSwitchTimer); viewSwitchTimer = null; }
const outEl = toTree ? listTabs : listTree; // the view currently on screen
outEl.classList.add('view-leaving');
viewSwitchTimer = setTimeout(() => {
outEl.classList.remove('view-leaving');
apply(); // swaps which view is shown + rebuilds its contents
const inEl = toTree ? listTree : listTabs;
if (!inEl) return;
const items = Array.from(inEl.children);
inEl.classList.add('view-entering');
items.forEach((it, i) => { it.style.animationDelay = Math.min(i * 22, 400) + 'ms'; });
viewSwitchTimer = setTimeout(() => {
inEl.classList.remove('view-entering');
items.forEach((it) => { it.style.animationDelay = ''; });
viewSwitchTimer = null;
}, 800);
}, 140);
}
function renderListTree() {
if (!listTree) return;
if (!isEditor()) { listTree.innerHTML = ''; return; } // master + execs get the tree
const lists = visibleLists();
const current = (getCurrentList() || {}).id;
const byLoc = {};
lists.forEach((l) => {
const loc = (l.location || '').trim() || 'Global';
const team = (l.team || '').trim() || '__bldg__';
(byLoc[loc] = byLoc[loc] || {});
(byLoc[loc][team] = byLoc[loc][team] || []).push(l);
});
const locNames = Object.keys(byLoc).sort((a, b) => a.localeCompare(b));
let html = '';
locNames.forEach((loc) => {
const teams = byLoc[loc];
const locCount = Object.keys(teams).reduce((n, t) => n + teams[t].length, 0);
const locKey = 'loc::' + loc;
const locClosed = !!treeCollapsed[locKey];
html += `<div class="tree-node tree-node-loc" data-tree-toggle="${escapeAttr(locKey)}">
<span class="tree-caret">${locClosed ? '▸' : '▾'}</span>
<span class="tree-label">${escapeHtml(loc)}</span>
<span class="tree-count">${locCount}</span></div>`;
if (locClosed) return;
Object.keys(teams).sort((a, b) => a.localeCompare(b)).forEach((team) => {
const arr = teams[team]; // manual order (global-array order) so drag-reorder sticks
const teamKey = 'team::' + loc + '::' + team;
const teamClosed = !!treeCollapsed[teamKey];
const teamLabel = team === '__bldg__' ? 'Whole building' : team;
html += `<div class="tree-node tree-node-team" data-tree-toggle="${escapeAttr(teamKey)}">
<span class="tree-caret">${teamClosed ? '▸' : '▾'}</span>
<span class="tree-label">${escapeHtml(teamLabel)}</span>
<span class="tree-count">${arr.length}</span></div>`;
if (teamClosed) return;
arr.forEach((l) => {
const dot = `<span class="tree-dot" style="background:${escapeAttr(colorHex(l.color) || '#555')}"></span>`;
const hid = l.hidden ? '<span class="tree-hidden">hidden</span>' : '';
const rep = l.resetSchedule ? `<span class="tree-repeat" title="${escapeAttr(resetScheduleText(l.resetSchedule))}">🔁</span>` : '';
let reqPill = '';
if (l.isRequests) {
const rec = listTeamChecks(l) || {};
const open = (l.items || []).filter((i) => i.kind !== 'note' && !((rec[i.id] || {}).completed)).length;
if (open) reqPill = `<span class="list-tab-requests">✋ ${open}</span>`;
}
// Owned leaves are drag-reorderable within their team group (data-tree-group).
const dragAttrs = ownsListClient(l)
? `draggable="true" data-tree-group="${escapeAttr(teamKey)}"
ondragstart="onTreeLeafDragStart(event,'${escapeAttr(l.id)}')"
ondragend="onTreeLeafDragEnd()"`
: '';
html += `<div class="tree-leaf ${l.id === current ? 'active' : ''}" data-tree-list="${escapeAttr(l.id)}" ${dragAttrs}>
${dot}<span class="tree-leaf-name">${escapeHtml(l.name)}</span>${rep}${reqPill}${hid}</div>`;
});
});
});
listTree.innerHTML = html || '<div class="empty-state" style="padding:8px;">No lists.</div>';
}
// ── Cross-department requests ─────────────────────────────────────────
// Editors (master + execs) send a request to any department; it lands as a
// task in that team's auto-created "Requests" inbox list. The badge in the
// header shows the OPEN request count across every Requests list the signed-
// in user can see — always visible, so an exec can't miss waiting requests.
let requestPhotos = []; // [{url, caption}] attached before sending
function renderRequestPhotos() {
const box = document.getElementById('requestPhotos');
if (!box) return;
box.innerHTML = requestPhotos.map((p, i) => `
<div class="equip-photo">
<img class="equip-photo-thumb" src="${escapeAttr(normalizeImageUrl(p.url))}" alt="" loading="lazy" decoding="async" />
<span class="request-photo-name">Photo ${i + 1}</span>
<button class="equip-photo-remove" type="button" data-request-photo-remove="${i}" aria-label="Remove photo" title="Remove">✕</button>
</div>`).join('');
}
function openRequestModal() {
const modal = document.getElementById('requestModal');
const sel = document.getElementById('requestTarget');
if (!modal || !sel) return;
const targets = Array.isArray(checklistData.requestTargets) ? checklistData.requestTargets : [];
if (!targets.length) { showToast('No departments to request from yet', 'error'); return; }
sel.innerHTML = targets.map((t) =>
`<option value="${escapeAttr(t.location + '::' + t.team)}">${escapeHtml(t.team)} — ${escapeHtml(t.location)}</option>`).join('');
document.getElementById('requestTitle').value = '';
document.getElementById('requestDetails').value = '';
const err = document.getElementById('requestError'); if (err) err.style.display = 'none';
requestPhotos = [];
renderRequestPhotos();
modal.style.display = 'flex';
requestAnimationFrame(() => document.getElementById('requestTitle').focus({ preventScroll: true }));
}
function closeRequestModal() { const m = document.getElementById('requestModal'); if (m) closeOverlay(m); }
async function handleRequestPhotoFile() {
const inp = document.getElementById('requestPhotoFile');
const status = document.getElementById('requestUploadStatus');
const file = inp && inp.files && inp.files[0];
if (inp) inp.value = '';
if (!file) return;
if (requestPhotos.length >= 6) { showToast('Up to 6 photos per request', 'error'); return; }
if (file.size > 20 * 1024 * 1024) { showToast('Photo too large (max 20 MB)', 'error'); return; }
if (status) status.textContent = 'Uploading…';
try {
const dataUrl = await new Promise((resolve) => {
const r = new FileReader();
r.onload = () => resolve(r.result);
r.onerror = () => resolve(null);
r.readAsDataURL(file);
});
if (!dataUrl) throw new Error('Could not read that photo');
const res = await apiPost('/api/equipment/upload-image', { dataUrl }); // same store + sweep as gear photos
requestPhotos.push({ url: res.path, caption: '' });
renderRequestPhotos();
if (status) status.textContent = '';
} catch (err) {
if (status) status.textContent = '';
showToast('Upload failed: ' + (err && err.message ? err.message : 'try again'), 'error');
}
}
async function sendRequest() {
const sel = document.getElementById('requestTarget');
const title = (document.getElementById('requestTitle').value || '').trim();
const details = (document.getElementById('requestDetails').value || '').trim();
const err = document.getElementById('requestError');
const showErr = (msg) => { if (err) { err.textContent = msg; err.style.display = 'block'; } else showToast(msg, 'error'); };
if (!sel || !sel.value) { showErr('Pick a department.'); return; }
if (!title) { showErr('Say what you need.'); return; }
const sep = sel.value.indexOf('::');
const location = sel.value.slice(0, sep), team = sel.value.slice(sep + 2);
const btn = document.getElementById('requestSend');
if (btn) btn.disabled = true;
try {
await apiPost('/api/requests', { location, team, title, details, images: requestPhotos });
closeRequestModal();
showToast('Request sent to ' + team + ' — ' + location, 'success');
refreshState({ showToastOnError: false }); // badge/tab pills update if it's in our scope
} catch (e) {
showErr(e && e.message ? e.message : 'Could not send — try again');
} finally {
if (btn) btn.disabled = false;
}
}
{
const sendBtn = document.getElementById('requestSendBtn');
if (sendBtn) sendBtn.addEventListener('click', () => { if (isEditor()) openRequestModal(); });
const cancel = document.getElementById('requestCancel');
if (cancel) cancel.addEventListener('click', closeRequestModal);
const send = document.getElementById('requestSend');
if (send) send.addEventListener('click', sendRequest);
const file = document.getElementById('requestPhotoFile');
if (file) file.addEventListener('change', handleRequestPhotoFile);
const photos = document.getElementById('requestPhotos');
if (photos) photos.addEventListener('click', (e) => {
const rm = e.target.closest('[data-request-photo-remove]');
if (rm) { requestPhotos.splice(parseInt(rm.getAttribute('data-request-photo-remove'), 10), 1); renderRequestPhotos(); }
});
const modal = document.getElementById('requestModal');
if (modal) modal.addEventListener('click', (e) => { if (e.target === modal) closeRequestModal(); });
}
// Open-request count across every Requests list the user can see, judged by
// the LIST's own team scope (the record its checkers/execs write into).
function openRequestInfo() {
let count = 0, firstId = null;
(visibleLists() || []).forEach((l) => {
if (!l.isRequests) return;
const rec = listTeamChecks(l) || {};
(l.items || []).forEach((it) => {
if (it.kind === 'note') return;
const c = rec[it.id];
if (!(c && c.completed)) { count++; if (!firstId) firstId = l.id; }
});
});
return { count, firstId };
}
function updateRequestsBadge() {
const el = document.getElementById('requestsBadge');
if (!el) return;
const info = currentUser ? openRequestInfo() : { count: 0, firstId: null };
el.style.display = info.count ? '' : 'none';
const n = document.getElementById('requestsBadgeCount');
if (n) n.textContent = String(info.count);
el.dataset.target = info.firstId || '';
}
{
const badge = document.getElementById('requestsBadge');
if (badge) badge.addEventListener('click', () => { const id = badge.dataset.target; if (id) setSelectedList(id); });
}
// ── Feedback column (team input) ──────────────────────────────────────
// Entries live in list.feedback[] (NEVER in items[] — nothing here can be
// checked, counted, or reset). Unlike Notes this column is writable by
// EVERYONE the list applies to: the composer at the bottom is always there.
// Bespoke render, no FLIP: rebuilt on every poll, so any in-progress typing
// (composer draft, open inline edit) is snapshotted and restored.
let fbEditingId = null;
function renderFeedback(list, canEdit) {
if (!feedbackList) return;
const entries = Array.isArray(list.feedback) ? list.feedback : [];
if (fbEditingId && !entries.some((f) => f.id === fbEditingId)) fbEditingId = null; // entry got deleted elsewhere
const compEl = feedbackList.querySelector('.feedback-composer textarea');
const draft = compEl ? compEl.value : '';
const compFocused = !!compEl && document.activeElement === compEl;
const editEl = feedbackList.querySelector('.feedback-editbox textarea');
const editVal = editEl ? editEl.value : null;
const editFocused = !!editEl && document.activeElement === editEl;
const canTouch = (f) => canEdit || !!(currentUser && currentUser.role === 'user' && f.byUser === currentUser.username);
const fmtWhen = (iso) => {
const d = new Date(iso || '');
if (isNaN(d)) return '';
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ', ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
};
const rows = entries.map((f) => {
if (f.id === fbEditingId) {
return `<div class="feedback-entry feedback-editbox" data-fb-id="${escapeAttr(f.id)}">
<textarea class="feedback-input" maxlength="2000" aria-label="Edit feedback">${escapeHtml(editVal != null ? editVal : f.text)}</textarea>
<div class="feedback-actions">
<button type="button" class="btn btn-primary btn-small" data-fb-action="save" data-fb-id="${escapeAttr(f.id)}">Save</button>
<button type="button" class="btn btn-secondary btn-small" data-fb-action="cancel" data-fb-id="${escapeAttr(f.id)}">Cancel</button>
</div></div>`;
}
const meta = [f.by ? escapeHtml(f.by) : '', escapeHtml(fmtWhen(f.createdAt)), f.editedAt ? 'edited' : ''].filter(Boolean).join(' · ');
const actions = canTouch(f)
? `<div class="feedback-actions">
<button type="button" class="btn btn-secondary btn-small" data-fb-action="edit" data-fb-id="${escapeAttr(f.id)}">Edit</button>
<button type="button" class="btn btn-danger btn-small" data-fb-action="delete" data-fb-id="${escapeAttr(f.id)}">Delete</button></div>`
: '';
return `<div class="feedback-entry" data-fb-id="${escapeAttr(f.id)}">
<div class="feedback-text">${escapeHtml(f.text).replace(/\n/g, '<br>')}</div>
<div class="feedback-meta">${meta}</div>${actions}</div>`;
});
const empty = entries.length ? '' : '<div class="empty-state">Nothing yet — share input, ideas, or things to work on</div>';
const composer = `<div class="feedback-composer">
<textarea class="feedback-input" maxlength="2000" placeholder="Input, ideas, things to work on…" aria-label="Add feedback"></textarea>
<button type="button" class="btn btn-primary btn-small" data-fb-action="add" data-fb-list="${escapeAttr(list.id)}">Add feedback</button></div>`;
feedbackList.innerHTML = empty + rows.join('') + composer;
const nc = feedbackList.querySelector('.feedback-composer textarea');
if (nc && draft) nc.value = draft;
if (nc && compFocused) { nc.focus({ preventScroll: true }); try { nc.setSelectionRange(nc.value.length, nc.value.length); } catch (_) {} }
if (editFocused) {
const ne = feedbackList.querySelector('.feedback-editbox textarea');
if (ne) { ne.focus({ preventScroll: true }); try { ne.setSelectionRange(ne.value.length, ne.value.length); } catch (_) {} }
}
}
// Mass-delete every feedback entry on a list. Editors only — the button renders
// among the owner-only header controls, and the server re-checks ownsList. Goes
// through saveMutation so a scoped editor (executive) can UNDO it — the scoped
// snapshot diff carries feedback[]. Master (like Reset) relies on the confirm.
async function clearAllFeedback(listId) {
const list = (checklistData.lists || []).find((l) => l.id === listId);
if (!list || !ownsListClient(list)) return;
const n = (list.feedback || []).length;
if (!n) return;
if (!confirm('Delete all ' + n + ' feedback entr' + (n === 1 ? 'y' : 'ies') + ' on "' + list.name + '"? This clears the whole column.')) return;
fbEditingId = null; // nothing left to edit
const ok = await saveMutation(apiDelete('/api/lists/' + encodeURIComponent(listId) + '/feedback'));
if (ok) showToast('Feedback cleared', 'success');
}
// One delegated handler for all feedback actions (buttons are re-created on every poll render).
document.addEventListener('click', async (e) => {
const btn = e.target && e.target.closest && e.target.closest('[data-fb-action]');
if (!btn) return;
const action = btn.getAttribute('data-fb-action');
const list = getCurrentList();
if (!list) return;
const fid = btn.getAttribute('data-fb-id');
try {
if (action === 'add') {
const ta = feedbackList.querySelector('.feedback-composer textarea');
const text = ((ta && ta.value) || '').trim();
if (!text) { showToast('Write something first', 'error'); return; }
btn.disabled = true;
const by = (currentUser && currentUser.role === 'user' && activePerson) ? activePerson : '';
const res = await apiPost('/api/lists/' + encodeURIComponent(list.id) + '/feedback', { text, by });
if (ta) ta.value = ''; // clear BEFORE the re-render snapshots the draft
if (res && res.entry) { if (!Array.isArray(list.feedback)) list.feedback = []; list.feedback.push(res.entry); }
renderLists();
} else if (action === 'edit') {
fbEditingId = fid;
renderLists();
const ne = feedbackList.querySelector('.feedback-editbox textarea');
if (ne) { ne.focus({ preventScroll: true }); try { ne.setSelectionRange(ne.value.length, ne.value.length); } catch (_) {} }
} else if (action === 'cancel') {
fbEditingId = null;
renderLists();
} else if (action === 'save') {
const ta = feedbackList.querySelector('.feedback-editbox textarea');
const text = ((ta && ta.value) || '').trim();
if (!text) { showToast('Write something first', 'error'); return; }
btn.disabled = true;
const res = await apiPut('/api/lists/' + encodeURIComponent(list.id) + '/feedback/' + encodeURIComponent(fid), { text });
const cur = (list.feedback || []).find((f) => f.id === fid);
if (cur && res && res.entry) { cur.text = res.entry.text; cur.editedAt = res.entry.editedAt; }
fbEditingId = null;
renderLists();
} else if (action === 'delete') {
if (!confirm('Delete this feedback?')) return;
await apiDelete('/api/lists/' + encodeURIComponent(list.id) + '/feedback/' + encodeURIComponent(fid));
list.feedback = (list.feedback || []).filter((f) => f.id !== fid);
if (fbEditingId === fid) fbEditingId = null;
renderLists();
}
} catch (err) {
btn.disabled = false;
showToast(err && err.message ? err.message : 'That didn’t save — try again', 'error');
}
});
function renderList(container, items, isDone, listId, canEdit, emptyLabel) {
if (items.length === 0) {
container.innerHTML = '<div class="empty-state">' + escapeHtml(emptyLabel || 'No tasks yet') + '</div>';
return;
}
container.innerHTML = items.map((item) => {
const check = getMyCheck(listId, item.id);
const isNote = item.kind === 'note'; // reference card: no checkbox, not checkable, not draggable
// Sub-steps are now inline checkbox lines authored in the description itself.
const taskTotal = isNote ? 0 : parseTaskList(item.description).length; // a note's "- [ ]" lines are inert
const hasSubs = taskTotal > 0;
const subDone = hasSubs ? subDoneCount(listId, item.id, check) : 0;
const checked = hasSubs ? (subDone === taskTotal) : !!(check && check.completed);
const recurrenceText = (!isNote && item.recurrence) ? getRecurrenceText(item.recurrence, checked) : '';
const meta = (!isNote && checked && check && check.completedBy)
? `<div class="checklist-item-meta">Checked by ${escapeHtml(check.completedBy)} · ${escapeHtml(formatStamp(check.completedDate))}</div>`
: '';
// Cross-department request: who asked and when, plus any photos they attached.
const reqMeta = item.requestedBy
? `<div class="checklist-item-meta request-meta">✋ Requested by ${escapeHtml(item.requestedBy)}${item.requestedAt ? ' · ' + escapeHtml(formatStamp(item.requestedAt)) : ''}</div>`
: '';
const photosHtml = (Array.isArray(item.images) && item.images.length)
? `<div class="request-photo-strip">` + item.images.map((p, i) =>
`<button type="button" class="request-photo-open" data-req-photos="${escapeAttr(item.id)}" aria-label="View photo ${i + 1} of ${item.images.length}"><img src="${escapeAttr(normalizeImageUrl(p.url))}" alt="" loading="lazy" decoding="async" /></button>`).join('') + `</div>`
: '';
const dragAttrs = (canEdit && !isNote) // notes aren't drag-reorderable (the done-based drop guard has no 3rd column)
? `draggable="true"
ondragstart="onItemDragStart(event,'${escapeAttr(item.id)}',${isDone})"
ondragend="onItemDragEnd()"`
: '';
const moved = item.id === justMovedItemId ? ' just-moved' : '';
const recurrenceTag = recurrenceText
? `<span class="checklist-item-recurrence" title="${escapeAttr(recurrenceText)}"><span class="recurrence-icon" aria-hidden="true">🔄</span><span class="recurrence-text">${escapeHtml(recurrenceText)}</span></span>`
: '';
// The description now contains the checkbox lines (rendered by md.js as .md-task
// rows); they are hydrated to live per-location state + made interactive below.
const descHtml = item.description ? `<div class="checklist-item-description">${renderDescription(item.description)}</div>` : '';
// The parent's own control is DISPLAY-ONLY when a task has checkboxes: it shows
// progress (n/m) and a check when every box is done — you complete the task by
// ticking the boxes, so nothing can be marked done without checking each item.
const headerControl = isNote
? `<span class="note-ico" aria-hidden="true"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3h9l5 5v13H6z"/><path d="M15 3v5h5"/></svg></span>`
: hasSubs
? `<span class="subtask-parent${checked ? ' complete' : ''}" title="Auto-completes when all checkboxes are checked" aria-label="${subDone} of ${taskTotal} steps done">${checked ? '✓' : subDone + '/' + taskTotal}</span>`
: `<input type="checkbox" ${checked ? 'checked' : ''} data-action="toggle" data-item-id="${escapeAttr(item.id)}" />`;
const actionsHtml = canEdit ? `
<div class="checklist-item-actions admin-only">
<button class="btn btn-secondary" data-action="edit" data-item-id="${escapeAttr(item.id)}">Edit</button>
<button class="btn btn-secondary" data-action="duplicate" data-item-id="${escapeAttr(item.id)}" title="Make a copy in this list (then Move it elsewhere)">⧉ Duplicate</button>
<button class="btn btn-secondary" data-action="move" data-item-id="${escapeAttr(item.id)}">Move</button>
<button class="btn btn-danger" data-action="delete" data-item-id="${escapeAttr(item.id)}">Delete</button>
</div>` : '';
const bodyParts = reqMeta + descHtml + photosHtml + meta + actionsHtml;
// Collapsible only if there's a body to hide. Steady-state .collapsed is re-derived
// every render (survives the innerHTML rebuild); the chevron is a real <button> (SVG
// glyph, iPad-safe) so it gets Space/Enter + a focus ring free and keeps the header's
// completion checkbox a separate, independently-operable control (no role=button nesting).
const hasBody = !!bodyParts;
const collapsedNow = hasBody && isCollapsed(item.id, isDone);
const bodyId = 'itembody-' + item.id;
const chevron = hasBody
? `<button type="button" class="collapse-toggle" data-item-id="${escapeAttr(item.id)}" aria-expanded="${collapsedNow ? 'false' : 'true'}" aria-controls="${escapeAttr(bodyId)}" aria-label="${collapsedNow ? 'Expand task details' : 'Collapse task details'}"><svg class="chevron" viewBox="0 0 20 20" width="18" height="18" aria-hidden="true" focusable="false"><path d="M5 8l5 5 5-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>`
: '';
return `
<div class="checklist-item ${isDone ? 'done' : ''}${moved}${hasSubs ? ' has-subtasks' : ''}${isNote ? ' is-note' : ''}${collapsedNow ? ' collapsed' : ''}" data-item-id="${escapeAttr(item.id)}" data-kind="${escapeAttr(item.kind || 'task')}" ${dragAttrs}>
<div class="checklist-item-header${hasBody ? ' is-collapsible' : ''}">
${headerControl}
<div class="checklist-item-title">${escapeHtml(item.title)}</div>
${recurrenceTag}
${chevron}
</div>
${hasBody ? `<div class="checklist-item-body" id="${escapeAttr(bodyId)}">${bodyParts}</div>` : ''}
</div>
`;
}).join('');
// After the HTML is in the DOM: (1) make equipment words tappable (safe text-node pass),
// and (2) hydrate inline checkbox rows from live per-location state + make them tappable.
const _list = (checklistData.lists || []).find((l) => l.id === listId);
const _ctx = { loc: _list ? _list.location : '', team: _list ? _list.team : '' };
container.querySelectorAll('.checklist-item').forEach((card) => {
const itemId = card.getAttribute('data-item-id');
card.querySelectorAll('.checklist-item-description').forEach((d) => linkEquipmentTerms(d, _ctx));
// A NOTE's description is reference-only: leave its "- [ ]" rows as static, non-interactive
// bullets (no is-done state, no tabindex) so nothing looks or acts tickable.
if (card.getAttribute('data-kind') === 'note') return;
// Drive .is-done/aria from live state (the authored [ ]/[x] is only a template),
// and mark the row focusable/tappable — ONLY in the live list, not the editor copy.
card.querySelectorAll('.checklist-item-description .md-task').forEach((row) => {
const idx = parseInt(row.getAttribute('data-task-index'), 10);
const done = isTaskCheckedForMe(listId, itemId, idx);
row.classList.toggle('is-done', done);
row.setAttribute('aria-checked', done ? 'true' : 'false');
row.setAttribute('tabindex', '0');
});
});
}
// Render a task description: markdown (via md.js) with a safe fallback to escaped text.
function renderDescription(text) {
try { return (window.MD && MD.render) ? MD.render(text) : escapeHtml(text); }
catch (_) { return escapeHtml(text); }
}
// ─── Equipment terms: make gear words tappable to show a photo ──────
function escapeRegex(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function equipNormScope(s) { return String(s || '').trim().toLowerCase().replace(/\s+/g, ' '); }
// How well does an equipment entry apply to a list at (ctx.loc, ctx.team)?
// -1 = not for this list 0 = global (no scope) higher = more specific.
// Lets the same word (e.g. "Ingest Mac Studio") resolve to the on-site photo
// for whichever campus's list is being viewed.
function equipApplyScore(e, ctx) {
const locs = (e.scopeLocations || []).map(equipNormScope).filter(Boolean);
const teams = (e.scopeTeams || []).map(equipNormScope).filter(Boolean);
if (!locs.length && !teams.length) return 0; // global
const cl = equipNormScope(ctx && ctx.loc);
const ct = equipNormScope(ctx && ctx.team);
const locOK = !locs.length || locs.includes(cl);
const teamOK = !teams.length || teams.includes(ct);
if (!locOK || !teamOK) return -1;
return (locs.length ? 2 : 0) + (teams.length ? 1 : 0);
}
// Find the equipment entry for a term, preferring the most location/team-
// specific one that applies to this list (ctx). Global entries are the
// fallback; out-of-scope entries are ignored.
function equipLookup(term, ctx) {
const items = (checklistData.equipment && checklistData.equipment.items) || {};
const k = String(term || '').trim().toLowerCase();
let best = null, bestScore = -1;
for (const id in items) {
const e = items[id];
const matches = (e.name || '').toLowerCase() === k
|| (e.aliases || []).some((a) => String(a).toLowerCase() === k);
if (!matches) continue;
const s = equipApplyScore(e, ctx);
if (s >= 0 && s > bestScore) { best = e; bestScore = s; }
}
return best;
}
// A styled, tappable equipment word. `display` is the exact text shown to the
// checker (the word they typed); `name` is the equipment's canonical name used
// only for the popup title. No URL is ever shown to the user.
function equipSpan(display, name, image, note, id) {
const span = document.createElement('span');
span.className = 'equip-term' + (image ? '' : ' equip-term-noimg');
span.textContent = display;
span.setAttribute('data-equip-name', name);
if (image) span.setAttribute('data-equip-image', image);
if (note) span.setAttribute('data-equip-note', note);
if (id) span.setAttribute('data-equip-id', id); // lets the lightbox pull the entry's full photo set
span.setAttribute('role', 'button');
span.setAttribute('tabindex', '0');
span.setAttribute('title', image ? 'Tap to see a photo' : (note || name));
return span;
}
// Auto-link known equipment names/aliases inside a plain-text segment → [nodes].
function autoLinkSegment(text, termRe, ctx) {
if (!termRe) return { changed: false, nodes: [document.createTextNode(text)] };
termRe.lastIndex = 0;
const nodes = [];
let last = 0, m, changed = false;
while ((m = termRe.exec(text))) {
if (m.index > last) nodes.push(document.createTextNode(text.slice(last, m.index)));
const entry = equipLookup(m[1], ctx);
// Keep the matched word exactly as written; only the popup shows the canonical name.
if (entry) { nodes.push(equipSpan(m[1], entry.name, entry.image, entry.note, entry.id)); changed = true; }
else nodes.push(document.createTextNode(m[0]));
last = m.index + m[0].length;
}
if (last < text.length) nodes.push(document.createTextNode(text.slice(last)));
if (!nodes.length) nodes.push(document.createTextNode(text));
return { changed, nodes };
}
// Turn one text node's string into a fragment with [[markers]] + auto-linked terms.
function buildEquipFragment(text, termRe, ctx) {
const frag = document.createDocumentFragment();
const markerRe = /\[\[([^\]]+)\]\]/g;
const pieces = [];
let last = 0, m, hadMarker = false;
while ((m = markerRe.exec(text))) {
if (m.index > last) pieces.push({ t: 'text', v: text.slice(last, m.index) });
pieces.push({ t: 'marker', v: m[1] });
last = m.index + m[0].length;
hadMarker = true;
}
if (last < text.length) pieces.push({ t: 'text', v: text.slice(last) });
let changed = hadMarker;
pieces.forEach((p) => {
if (p.t === 'marker') {
const bar = p.v.indexOf('|');
let label = p.v, image = '', note = '';
if (bar >= 0) { label = p.v.slice(0, bar).trim(); image = p.v.slice(bar + 1).trim(); }
else label = label.trim();
const entry = equipLookup(label, ctx);
// Show the label the user typed inside [[ ]]; popup uses the canonical name.
let name = label;
if (entry) { image = image || entry.image; note = entry.note; name = entry.name; }
frag.appendChild(equipSpan(label, name, image, note, entry ? entry.id : ''));
} else {
const sub = autoLinkSegment(p.v, termRe, ctx);
if (sub.changed) changed = true;
sub.nodes.forEach((nd) => frag.appendChild(nd));
}
});
return changed ? frag : null;
}
// Walk text nodes under root (skipping links, code, and existing equip spans).
function walkTextNodes(root, fn) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(n) {
let p = n.parentNode;
while (p && p !== root) {
if (p.nodeName === 'A' || p.nodeName === 'CODE' ||
(p.classList && p.classList.contains('equip-term'))) return NodeFilter.FILTER_REJECT;
p = p.parentNode;
}
return n.nodeValue && n.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
}
});
const nodes = [];
let n;
while ((n = walker.nextNode())) nodes.push(n);
nodes.forEach(fn);
}
function linkEquipmentTerms(container, ctx) {
const items = (checklistData.equipment && checklistData.equipment.items) || {};
const terms = [];
Object.keys(items).forEach((id) => {
const e = items[id];
if (equipApplyScore(e, ctx) < 0) return; // gear from another campus — skip here
[e.name].concat(e.aliases || []).forEach((t) => { if (String(t || '').trim()) terms.push(String(t).trim()); });
});
const uniq = Array.from(new Set(terms)).sort((a, b) => b.length - a.length); // longest match wins
const termRe = uniq.length ? new RegExp('\\b(' + uniq.map(escapeRegex).join('|') + ')\\b', 'gi') : null;
walkTextNodes(container, (node) => {
const frag = buildEquipFragment(node.nodeValue, termRe, ctx);
if (frag) node.parentNode.replaceChild(frag, node);
});
}
// Turn a Google Drive share/view/open link into a directly-embeddable image URL
// (the plain "…/file/d/ID/view" link points at Drive's viewer page, not the image).
// Any non-Drive URL passes through unchanged.
function normalizeImageUrl(url) {
const u = String(url || '').trim();
if (!u) return '';
let id = '';
let m = u.match(/drive\.google\.com\/file\/d\/([-\w]{20,})/);
if (m) id = m[1];
if (!id && /(?:drive|docs)\.google\.com/.test(u)) { m = u.match(/[?&]id=([-\w]{20,})/); if (m) id = m[1]; }
if (!id) { m = u.match(/lh3\.googleusercontent\.com\/d\/([-\w]{20,})/); if (m) id = m[1]; }
if (id) return 'https://drive.google.com/thumbnail?id=' + id + '&sz=w3840'; // 4K-wide (Drive returns the original if smaller)
// In the iOS/Android apps the page is served from the app bundle, so a server-absolute
// path like /equip-images/x.jpg would resolve against the bundle and 404. On the web
// resolveUrl is the identity function, so this line is inert there.
return window.CHECKLIST_NATIVE ? window.CHECKLIST_NATIVE.resolveUrl(u) : u;
}
// ── Equipment photo pan/zoom controller ──
// A proper lightbox zoom: wheel/trackpad to zoom toward the cursor (desktop),
// pinch to zoom + two-finger pan (touch), drag to pan when zoomed, double-tap/
// click to toggle, and floating +/−/fit buttons. touch-action:none on the stage
// means pinching the PHOTO zooms the photo — never the whole webpage. The image
// is transformed at its natural pixel size, so zooming shows true detail.
const equipZoom = (function () {
const stage = equipStage, img = equipModalImg;
if (!stage || !img) return { load() {}, showHint() {} };
let scale = 1, tx = 0, ty = 0, fit = 1, minS = 1, maxS = 1;
let natW = 0, natH = 0, ready = false, onErr = null;
const pointers = new Map();
let dragLast = null, pinchStart = null, didPan = false;
let downTime = 0, downXY = null, lastTapTime = 0, lastTapXY = null, hintTimer = 0;
const rect = () => stage.getBoundingClientRect();
function computeFit() {
const r = rect();
fit = (natW && natH && r.width && r.height) ? Math.min(r.width / natW, r.height / natH) : 1;
minS = fit;
// Zoom-in ceiling. fit shrinks as the source grows, so fit*5 alone would pin a
// high-res photo near native 1:1 with no room to inspect a tiny label. Floor it so
// the user can always reach at least ~2× native — and up to devicePixelRatio× on a
// hi-DPI screen, i.e. true source-pixel:device-pixel detail.
maxS = Math.max(fit * 5, window.devicePixelRatio || 1, 2);
}
function center() {
const r = rect();
scale = fit;
tx = (r.width - natW * scale) / 2;
ty = (r.height - natH * scale) / 2;
}
function clamp() {
const r = rect(), cw = natW * scale, ch = natH * scale;
tx = cw <= r.width ? (r.width - cw) / 2 : Math.min(0, Math.max(r.width - cw, tx));
ty = ch <= r.height ? (r.height - ch) / 2 : Math.min(0, Math.max(r.height - ch, ty));
}
function apply(animate) {
img.style.transition = animate ? 'transform 0.28s cubic-bezier(0.22,1,0.36,1)' : 'none';
img.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')';
const zoomed = scale > fit * 1.01;
stage.classList.toggle('zoomed', zoomed);
if (equipZoomOut) equipZoomOut.disabled = !ready || scale <= minS * 1.01;
if (equipZoomIn) equipZoomIn.disabled = !ready || scale >= maxS * 0.995;
if (equipZoomReset) equipZoomReset.disabled = !ready || !zoomed;
}
function zoomAt(clientX, clientY, factor, animate) {
if (!ready) return;
const r = rect(), sx = clientX - r.left, sy = clientY - r.top;
const ix = (sx - tx) / scale, iy = (sy - ty) / scale; // image-local point under the cursor
scale = Math.min(maxS, Math.max(minS, scale * factor));
tx = sx - ix * scale; ty = sy - iy * scale; // keep that point under the cursor
clamp(); apply(animate);
}
function zoomCenter(factor, animate) { const r = rect(); zoomAt(r.left + r.width / 2, r.top + r.height / 2, factor, animate); }
function reset(animate) { if (!ready) return; computeFit(); center(); clamp(); apply(animate === true); }
function prepare() {
natW = img.naturalWidth || 0; natH = img.naturalHeight || 0;
// A "successful" load with 0×0 intrinsic size (e.g. a dimensionless SVG) can't
// be zoomed — treat it as a load failure so the user sees the actionable message
// instead of a stuck black stage.
if (!natW || !natH) { stage.classList.remove('loading'); if (onErr) onErr(); return; }
img.style.width = natW + 'px'; img.style.height = natH + 'px';
ready = true; stage.classList.remove('loading');
computeFit(); center(); clamp(); apply(false);
}
function load(src, alt, onError) {
onErr = onError || null;
ready = false; natW = natH = 0;
pointers.clear(); dragLast = null; pinchStart = null; didPan = false;
img.alt = alt || '';
stage.classList.add('loading'); stage.classList.remove('zoomed');
img.style.transition = 'none';
img.onload = () => { img.onload = null; prepare(); };
img.onerror = () => { img.onerror = null; stage.classList.remove('loading'); if (onErr) onErr(); };
img.src = src;
if (img.complete && img.naturalWidth) { img.onload = null; prepare(); } // already cached
}
function dismissHint() { if (equipZoomHint) equipZoomHint.classList.remove('show'); if (hintTimer) { clearTimeout(hintTimer); hintTimer = 0; } }
function showHint() {
if (!equipZoomHint) return;
const touch = window.matchMedia && window.matchMedia('(hover: none)').matches;
equipZoomHint.textContent = touch ? 'Pinch to zoom · double-tap to reset' : 'Scroll to zoom · drag to pan · double-click to reset';
equipZoomHint.classList.add('show');
if (hintTimer) clearTimeout(hintTimer);
hintTimer = setTimeout(() => equipZoomHint.classList.remove('show'), 2600);
}
stage.addEventListener('wheel', (e) => {
if (!ready) return;
e.preventDefault(); dismissHint();
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.0016), false);
}, { passive: false });
stage.addEventListener('pointerdown', (e) => {
if (!ready) return;
// The zoom controls live inside the stage — don't hijack their pointer
// (capturing it here swallows the button's click on desktop, so it took
// a "double-click" for the double-tap fallback to fire instead).
if (e.target.closest && e.target.closest('.equip-zoom-controls, .equip-nav')) return;
try { stage.setPointerCapture(e.pointerId); } catch (_) {}
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
dismissHint(); didPan = false;
downTime = performance.now(); downXY = { x: e.clientX, y: e.clientY };
if (pointers.size === 2) {
const p = [...pointers.values()];
pinchStart = { dist: Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y) || 1, cx: (p[0].x + p[1].x) / 2, cy: (p[0].y + p[1].y) / 2, scale: scale, tx: tx, ty: ty };
dragLast = null;
} else if (pointers.size === 1) {
dragLast = { x: e.clientX, y: e.clientY };
stage.classList.add('grabbing');
}
});
stage.addEventListener('pointermove', (e) => {
if (!pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size >= 2 && pinchStart) {
const p = [...pointers.values()];
const dist = Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y) || 1;
const cx = (p[0].x + p[1].x) / 2, cy = (p[0].y + p[1].y) / 2, r = rect();
const ix = (pinchStart.cx - r.left - pinchStart.tx) / pinchStart.scale;
const iy = (pinchStart.cy - r.top - pinchStart.ty) / pinchStart.scale;
scale = Math.min(maxS, Math.max(minS, pinchStart.scale * (dist / pinchStart.dist)));
tx = (cx - r.left) - ix * scale; ty = (cy - r.top) - iy * scale;
clamp(); apply(false); didPan = true;
} else if (pointers.size === 1 && dragLast && scale > fit * 1.01) {
const dx = e.clientX - dragLast.x, dy = e.clientY - dragLast.y;
dragLast = { x: e.clientX, y: e.clientY };
if (Math.abs(dx) + Math.abs(dy) > 2) didPan = true;
tx += dx; ty += dy; clamp(); apply(false);
}
});
function up(e) {
if (!pointers.has(e.pointerId)) return; // ignore pointers we never tracked (e.g. zoom-control clicks)
const single = pointers.size === 1;
pointers.delete(e.pointerId);
try { stage.releasePointerCapture(e.pointerId); } catch (_) {}
stage.classList.remove('grabbing');
if (pointers.size < 2) pinchStart = null;
if (pointers.size === 1) { const p = [...pointers.values()][0]; dragLast = { x: p.x, y: p.y }; }
else if (pointers.size === 0) dragLast = null;
// At fit (not zoomed in), a horizontal swipe flips to the prev/next photo —
// so it never fights panning, which only happens when zoomed.
if (single && downXY && ready && scale <= fit * 1.01) {
const dx = e.clientX - downXY.x, dy = e.clientY - downXY.y;
if (Math.abs(dx) > 45 && Math.abs(dx) > Math.abs(dy) * 1.4) {
equipHandleSwipe(dx < 0 ? 1 : -1); // swipe left → next
return;
}
}
// Unified double-tap / double-click (only when it was a tap, not a pan/pinch).
if (single && !didPan && downXY && ready) {
const dt = performance.now() - downTime, moved = Math.hypot(e.clientX - downXY.x, e.clientY - downXY.y);
if (dt < 400 && moved < 12) {
const now = performance.now();
if (now - lastTapTime < 320 && lastTapXY && Math.hypot(e.clientX - lastTapXY.x, e.clientY - lastTapXY.y) < 40) {
if (scale > fit * 1.05) reset(true);
else zoomAt(e.clientX, e.clientY, Math.max(fit * 2.5, 1) / scale, true);
lastTapTime = 0; lastTapXY = null;
} else { lastTapTime = now; lastTapXY = { x: e.clientX, y: e.clientY }; }
}
}
if (pointers.size === 0 && didPan) apply(true); // settle in-bounds
}
stage.addEventListener('pointerup', up);
stage.addEventListener('pointercancel', (e) => { pointers.delete(e.pointerId); if (pointers.size < 2) pinchStart = null; if (pointers.size === 0) dragLast = null; stage.classList.remove('grabbing'); });
if (equipZoomIn) equipZoomIn.addEventListener('click', () => zoomCenter(1.6, true));
if (equipZoomOut) equipZoomOut.addEventListener('click', () => zoomCenter(1 / 1.6, true));
if (equipZoomReset) equipZoomReset.addEventListener('click', () => reset(true));
window.addEventListener('resize', () => {
if (!ready || !equipModal || equipModal.style.display === 'none' || equipModal.classList.contains('closing')) return;
const wasFit = scale <= fit * 1.01;
computeFit(); if (wasFit) center(); clamp(); apply(false);
});
return { load, showHint };
})();
// Media items can be a video (added as a URL like a photo). Detect by extension so no data-model
// change is needed — a video just lives in images[] as another {url,caption} and renders as a player.
function isVideoUrl(u) { return /\.(mp4|webm|ogg|ogv|mov|m4v)(\?|#|$)/i.test(String(u || '')); }
let equipVideoEl = null;
function equipVideo() {
if (equipVideoEl) return equipVideoEl;
equipVideoEl = document.createElement('video');
equipVideoEl.className = 'equip-video';
equipVideoEl.setAttribute('controls', ''); equipVideoEl.setAttribute('playsinline', ''); equipVideoEl.setAttribute('preload', 'metadata');
if (equipStage && equipStage.parentNode) equipStage.parentNode.insertBefore(equipVideoEl, equipStage.nextSibling);
return equipVideoEl;
}
function equipHideVideo() { if (equipVideoEl) { try { equipVideoEl.pause(); } catch (_) {} equipVideoEl.style.display = 'none'; equipVideoEl.removeAttribute('src'); try { equipVideoEl.load(); } catch (_) {} } }
// An equipment entry's photos as a normalized [{url,caption}] list (legacy-safe).
function equipPhotosOf(entry) {
if (!entry) return [];
if (Array.isArray(entry.images) && entry.images.length) {
return entry.images.map((p) => (p && typeof p === 'object')
? { url: String(p.url || ''), caption: String(p.caption || '') }
: { url: String(p || ''), caption: '' }).filter((p) => p.url);
}
return entry.image ? [{ url: entry.image, caption: '' }] : [];
}
// ─── Equipment photo lightbox as a gallery (device photo, desktop config, …) ──
let equipGalleryPhotos = [];
let equipGalleryIndex = 0;
let equipGalleryName = '';
let equipGalleryEquipId = ''; // library id of the gear in the open lightbox (for issue history + reporting)
let equipGalleryNoteHtml = ''; // rendered note, re-applied on every photo so a load error never loses it
function renderEquipThumbs() {
if (!equipThumbs) return;
const multi = equipGalleryPhotos.length > 1;
equipThumbs.style.display = multi ? '' : 'none';
if (equipPrev) equipPrev.style.display = multi ? '' : 'none';
if (equipNext) equipNext.style.display = multi ? '' : 'none';
if (!multi) { equipThumbs.innerHTML = ''; return; }
equipThumbs.innerHTML = equipGalleryPhotos.map((p, i) => {
const active = i === equipGalleryIndex ? ' active' : '';
if (isVideoUrl(p.url)) {
return `<button class="equip-thumb-btn equip-thumb-vid${active}" type="button" data-i="${i}" title="${escapeAttr(p.caption || ('Video ' + (i + 1)))}" aria-label="Video ${i + 1}"><span aria-hidden="true">▶</span></button>`;
}
return `<button class="equip-thumb-btn${active}" type="button" data-i="${i}" title="${escapeAttr(p.caption || ('Photo ' + (i + 1)))}">
<img src="${escapeAttr(normalizeImageUrl(p.url))}" alt="" draggable="false" loading="lazy" decoding="async" />
</button>`;
}).join('');
}
function equipShowPhoto(i) {
if (!equipGalleryPhotos.length) return;
equipGalleryIndex = Math.max(0, Math.min(equipGalleryPhotos.length - 1, i));
const p = equipGalleryPhotos[equipGalleryIndex];
if (equipCaption) {
const cap = (p.caption || '').trim();
const counter = equipGalleryPhotos.length > 1 ? `<span class="equip-caption-count">${equipGalleryIndex + 1}/${equipGalleryPhotos.length}</span>` : '';
equipCaption.innerHTML = (cap ? `<span class="equip-caption-text">${escapeHtml(cap)}</span>` : '') + counter;
equipCaption.style.display = (cap || counter) ? '' : 'none';
}
if (equipThumbs) [...equipThumbs.children].forEach((t, idx) => t.classList.toggle('active', idx === equipGalleryIndex));
if (equipPrev) equipPrev.disabled = equipGalleryIndex <= 0;
if (equipNext) equipNext.disabled = equipGalleryIndex >= equipGalleryPhotos.length - 1;
// Re-apply the note on every photo — this restores it if a previous photo's
// load error had replaced it, so navigating back never loses troubleshooting steps.
if (equipModalNote) equipModalNote.innerHTML = equipGalleryNoteHtml;
if (isVideoUrl(p.url)) {
// Video item → inline player, skip the image zoom stage entirely.
if (equipStage) equipStage.style.display = 'none';
const v = equipVideo();
v.style.display = 'block'; // explicit — the CSS default is display:none
v.src = normalizeImageUrl(p.url);
} else {
equipHideVideo();
if (equipStage) equipStage.style.display = '';
equipZoom.load(normalizeImageUrl(p.url), equipGalleryName, () => {
if (equipStage) equipStage.style.display = 'none';
// Show the load error ABOVE the note (keep the note — nav to a good photo clears the error).
const err = "<p>⚠️ Couldn't load this photo. Use a public JPG or PNG link — HEIC files don't display in browsers, and a Google Drive file must be shared “Anyone with the link.”</p>";
if (equipModalNote) equipModalNote.innerHTML = err + (equipGalleryNoteHtml || '');
});
}
}
// Called by the zoom controller on a horizontal swipe when the photo is at fit.
function equipHandleSwipe(dir) {
if (equipGalleryPhotos.length < 2) return;
equipShowPhoto(equipGalleryIndex + dir);
}
function openEquipLightbox(name, photos, note, equipId, docs) {
if (!equipModal) return;
equipGalleryName = name || 'Equipment';
equipGalleryEquipId = equipId ? String(equipId) : '';
if (equipModalName) equipModalName.textContent = equipGalleryName;
// Normalize + drop empties; accept an array or a single {url,caption}/string.
equipGalleryPhotos = (Array.isArray(photos) ? photos : (photos ? [photos] : []))
.map((p) => (p && typeof p === 'object') ? { url: String(p.url || ''), caption: String(p.caption || '') } : { url: String(p || ''), caption: '' })
.filter((p) => p.url);
equipGalleryIndex = 0;
equipHideVideo(); // clear any prior video before (re)opening
const has = equipGalleryPhotos.length > 0;
// Attached documents render under the note (and survive photo-nav re-applies,
// because they're part of equipGalleryNoteHtml).
const docsHtml = (Array.isArray(docs) && docs.length)
? '<div class="equip-docs">' + docs.map((d) => {
const isUpload = String(d.url || '').indexOf('/doc-files/') === 0;
return `<a${isUpload ? ' class="doc-link"' : ''} href="${escapeAttr(window.CHECKLIST_NATIVE ? window.CHECKLIST_NATIVE.resolveUrl(d.url) : d.url)}" target="_blank" rel="noopener noreferrer">${isUpload ? '' : '🔗 '}${escapeHtml(d.name || 'Document')}</a>`;
}).join('') + '</div>'
: '';
equipGalleryNoteHtml = (note ? renderDescription(note) : '') + docsHtml;
if (equipModalNote) equipModalNote.innerHTML = equipGalleryNoteHtml || (has ? '' : '<p>No photo added yet.</p>');
if (equipStage) equipStage.style.display = has ? '' : 'none';
if (equipCaption) equipCaption.style.display = 'none';
renderEquipThumbs();
renderEquipIssues(equipGalleryEquipId); // known-issue history for this gear
equipModal.style.display = 'flex';
if (has) { equipShowPhoto(0); equipZoom.showHint(); } // hint once on open, not per photo
}
function closeEquipLightbox() { equipHideVideo(); if (equipModal) closeOverlay(equipModal); }
// ─── Equipment library manager (master) ─────────────────────────
async function openEquipMgmt() {
if (!equipMgmtModal) return;
equipMgmtError.style.display = 'none';
resetEquipForm();
if (equipSearch) equipSearch.value = '';
equipMgmtModal.style.display = 'flex';
equipMgmtList.innerHTML = '<div class="empty-state" style="padding:8px;">Loading…</div>';
try { equipItems = (await apiGet('/api/admin/equipment')).items || {}; }
catch (err) { equipMgmtList.innerHTML = `<div class="empty-state" style="padding:8px;">Failed to load: ${escapeHtml(err.message)}</div>`; return; }
drawEquipList();
}
// One equipment row's HTML (used inside the location groups).
function equipRowHtml(e) {
const aliases = (e.aliases || []).length ? `<span class="user-row-tag">aka ${escapeHtml(e.aliases.join(', '))}</span>` : '';
const note = e.note ? `<span class="user-row-tag empty">${escapeHtml(e.note)}</span>` : '';
// Locations are shown by the group header; on the row show team scope only.
const teamTags = (e.scopeTeams || []).map((s) => `<span class="user-row-tag">${escapeHtml(s)} team</span>`);
const noScope = !(e.scopeLocations || []).length && !(e.scopeTeams || []).length;
const nPhotos = equipPhotosOf(e).length;
const photoTag = nPhotos > 1 ? `<span class="user-row-tag">📷 ${nPhotos} photos</span>` : '';
const nDocs = (e.docs || []).length;
const docTag = nDocs ? `<span class="user-row-tag">📎 ${nDocs} doc${nDocs === 1 ? '' : 's'}</span>` : '';
const scope = (teamTags.length ? teamTags.join('') : (noScope ? '<span class="user-row-tag empty">everywhere</span>' : '')) + photoTag + docTag;
const thumb = e.image
? `<img class="equip-thumb" src="${escapeAttr(normalizeImageUrl(e.image))}" alt="" loading="lazy" decoding="async" />`
: '<span class="equip-thumb equip-thumb-empty">no&nbsp;photo</span>';
const eid = escapeAttr(e.id);
// Execs can SEE gear that overlaps their scope but only EDIT gear fully
// within it (server sets _canManage). View-only rows get no action buttons.
const canManage = e._canManage !== false;
const actions = canManage
? `<div class="user-row-actions">
<button class="btn btn-secondary btn-small" data-equip-action="edit" data-equip-id="${eid}">Edit</button>
<button class="btn btn-danger btn-small" data-equip-action="del" data-equip-id="${eid}">Delete</button>
</div>`
: '<div class="user-row-actions"><span class="user-row-tag empty">view only</span></div>';
return `
<div class="user-row" data-equip-id="${eid}">
<div class="user-row-main equip-row-main">
${thumb}
<div>
<div class="user-row-name">${escapeHtml(e.name)}</div>
<div class="user-row-meta">${scope}${aliases}${note}</div>
</div>
</div>
${actions}
</div>`;
}
// Equipment manager list, grouped into a collapsible tree by location (an item
// scoped to several campuses appears under each; un-scoped items go under
// "Everywhere"). Search filters first and auto-expands so matches are visible.
const EQUIP_EVERYWHERE = '🌐 Everywhere';
function drawEquipList() {
if (!equipMgmtList) return;
const q = ((equipSearch && equipSearch.value) || '').trim().toLowerCase();
let entries = Object.keys(equipItems).map((id) => equipItems[id]);
if (q) entries = entries.filter((e) => (e.name + ' ' + (e.aliases || []).join(' ')).toLowerCase().includes(q));
entries.sort((a, b) => (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase()));
if (!entries.length) {
equipMgmtList.innerHTML = `<div class="empty-state" style="padding:8px;">${Object.keys(equipItems).length ? 'No equipment matches.' : 'No equipment yet. Add some below.'}</div>`;
return;
}
const groups = {};
entries.forEach((e) => {
const locs = (e.scopeLocations || []).map((s) => String(s).trim()).filter(Boolean);
if (!locs.length) (groups[EQUIP_EVERYWHERE] = groups[EQUIP_EVERYWHERE] || []).push(e);
else locs.forEach((loc) => (groups[loc] = groups[loc] || []).push(e));
});
const names = Object.keys(groups).sort((a, b) => {
if (a === EQUIP_EVERYWHERE) return 1; // keep "Everywhere" last
if (b === EQUIP_EVERYWHERE) return -1;
return a.localeCompare(b);
});
const searching = !!q;
// Collapsed groups are RENDERED but hidden (.closed) — so expanding/collapsing
// is a class flip in handleEquipListClick, never an innerHTML rebuild. Rebuilding
// on every toggle re-created every photo thumbnail and made the tree feel glacial.
equipMgmtList.innerHTML = names.map((loc) => {
const arr = groups[loc];
const closed = !searching && !!equipTreeCollapsed[loc];
const head = `<div class="equip-tree-head" data-equip-tree-toggle="${escapeAttr(loc)}">
<span class="tree-caret">${closed ? '▸' : '▾'}</span>
<span class="tree-label">${escapeHtml(loc)}</span>
<span class="tree-count">${arr.length}</span></div>`;
const body = `<div class="equip-tree-group${closed ? ' closed' : ''}">${arr.map(equipRowHtml).join('')}</div>`;
return head + body;
}).join('');
}
// ── Manager photo gallery (add multiple, caption each, remove, reorder) ──
function renderEquipPhotos() {
if (!equipPhotos) return;
if (!equipFormPhotos.length) {
equipPhotos.innerHTML = '<div class="equip-photos-empty">No photos yet — add the device, its desktop/config, etc. below.</div>';
return;
}
equipPhotos.innerHTML = equipFormPhotos.map((p, i) => {
const thumb = isVideoUrl(p.url)
? `<span class="equip-photo-thumb equip-photo-vid" aria-hidden="true">▶</span>`
: `<img class="equip-photo-thumb" src="${escapeAttr(normalizeImageUrl(p.url))}" alt="" draggable="false" loading="lazy" decoding="async" />`;
return `
<div class="equip-photo" draggable="true" data-equip-photo="${i}">
<span class="equip-photo-handle" title="Drag to reorder">⋮⋮</span>
${thumb}
<input class="equip-photo-caption" type="text" maxlength="120" data-equip-photo-cap="${i}"
value="${escapeAttr(p.caption || '')}" placeholder="Label (e.g. Device, Desktop layout)" />
<button class="equip-photo-remove" type="button" data-equip-photo-remove="${i}" aria-label="Remove" title="Remove">✕</button>
</div>`;
}).join('');
}
function addEquipPhoto(url, caption) {
const u = String(url || '').trim();
if (!u) return;
if (equipFormPhotos.some((p) => p.url === u)) { renderEquipPhotos(); return; } // no dupes
if (equipFormPhotos.length >= 12) { equipMgmtError.textContent = 'Up to 12 photos per item.'; equipMgmtError.style.display = 'block'; return; }
equipFormPhotos.push({ url: u, caption: String(caption || '') });
renderEquipPhotos();
}
function addEquipUrlFromInput() {
if (!equipImage) return;
const url = (equipImage.value || '').trim();
if (!url) return;
addEquipPhoto(url, '');
equipImage.value = '';
equipImage.focus();
}
// ── Manager attached-documents editor (📎 uploads + pasted links) ──
function renderEquipDocs() {
if (!equipDocs) return;
if (!equipFormDocs.length) { equipDocs.innerHTML = ''; return; }
equipDocs.innerHTML = equipFormDocs.map((d, i) => `
<div class="equip-doc">
<span class="equip-doc-ico" aria-hidden="true">${d.url.indexOf('/doc-files/') === 0 ? '📄' : '🔗'}</span>
<input class="equip-doc-name" type="text" maxlength="80" data-equip-doc-name="${i}"
value="${escapeAttr(d.name || '')}" placeholder="Name (what people see)" />
<button class="equip-photo-remove" type="button" data-equip-doc-remove="${i}" aria-label="Remove document" title="Remove">✕</button>
</div>`).join('');
}
function addEquipDoc(url, name) {
const u = String(url || '').trim();
if (!u) return;
if (equipFormDocs.some((d) => d.url === u)) { renderEquipDocs(); return; } // no dupes
if (equipFormDocs.length >= 10) { equipMgmtError.textContent = 'Up to 10 documents per item.'; equipMgmtError.style.display = 'block'; return; }
equipFormDocs.push({ url: u, name: String(name || '').trim() });
renderEquipDocs();
}
function addEquipDocUrlFromInput() {
if (!equipDocUrl) return;
const url = (equipDocUrl.value || '').trim();
if (!url) return;
if (!/^https?:\/\//i.test(url)) { equipMgmtError.textContent = 'Paste a full link starting with http(s)://'; equipMgmtError.style.display = 'block'; return; }
equipMgmtError.style.display = 'none';
let name = 'Link';
try { const h = new URL(url).hostname.replace(/^www\./, ''); name = h === 'docs.google.com' ? 'Google Doc' : h; } catch (_) {}
addEquipDoc(url, name);
equipDocUrl.value = '';
const inp = equipDocs && equipDocs.querySelector(`input[data-equip-doc-name="${equipFormDocs.length - 1}"]`);
if (inp) { inp.focus(); inp.select(); } // name it right away
}
async function handleEquipDocFile() {
const file = equipDocFile && equipDocFile.files && equipDocFile.files[0];
if (equipDocFile) equipDocFile.value = ''; // same file can be re-picked later
if (!file) return;
if (file.size > 25 * 1024 * 1024) { equipMgmtError.textContent = 'File too large (max 25 MB).'; equipMgmtError.style.display = 'block'; return; }
equipMgmtError.style.display = 'none';
if (equipUploadStatus) equipUploadStatus.textContent = 'Uploading ' + file.name + '…';
try {
const dataUrl = await new Promise((resolve) => {
const r = new FileReader();
r.onload = () => resolve(r.result);
r.onerror = () => resolve(null);
r.readAsDataURL(file);
});
if (!dataUrl) throw new Error('Could not read that file');
const res = await apiPost('/api/documents/upload', { dataUrl, name: file.name });
addEquipDoc(res.path, res.name || file.name);
if (equipUploadStatus) equipUploadStatus.textContent = '';
} catch (err) {
if (equipUploadStatus) equipUploadStatus.textContent = '';
equipMgmtError.textContent = 'Upload failed: ' + err.message;
equipMgmtError.style.display = 'block';
}
}
// Drag-reorder the gallery thumbnails (desktop). Touch users add/remove instead.
let equipDragFrom = -1;
function setupEquipPhotoReorder() {
equipPhotos.addEventListener('dragstart', (e) => {
const el = e.target.closest('.equip-photo'); if (!el) return;
equipDragFrom = parseInt(el.getAttribute('data-equip-photo'), 10);
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', String(equipDragFrom)); } catch (_) {}
el.classList.add('dragging');
});
equipPhotos.addEventListener('dragover', (e) => { if (equipDragFrom >= 0) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } });
equipPhotos.addEventListener('drop', (e) => {
if (equipDragFrom < 0) return;
e.preventDefault();
const el = e.target.closest('.equip-photo');
// Drop on a thumbnail → insert before it; drop in the gap/empty area → append.
let to = el ? parseInt(el.getAttribute('data-equip-photo'), 10) : equipFormPhotos.length;
const [moved] = equipFormPhotos.splice(equipDragFrom, 1);
if (to > equipDragFrom) to--; // the removed item shifted everything after it down by one
equipFormPhotos.splice(Math.max(0, Math.min(equipFormPhotos.length, to)), 0, moved);
equipDragFrom = -1;
renderEquipPhotos();
});
equipPhotos.addEventListener('dragend', () => { equipDragFrom = -1; equipPhotos.querySelectorAll('.equip-photo.dragging').forEach((el) => el.classList.remove('dragging')); });
}
// Re-encode a picked image to a small JPEG data URL (max side ~1600px). This
// keeps uploads tiny and normalizes format — and harmlessly fails on HEIC,
// which browsers can't decode, so we can warn instead of storing a dud.
function downscaleImage(file, maxDim, quality) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
const W = img.naturalWidth, H = img.naturalHeight;
if (!W || !H) { reject(new Error('Could not read that image.')); return; }
const scale = Math.min(1, maxDim / Math.max(W, H));
const w = Math.max(1, Math.round(W * scale));
const h = Math.max(1, Math.round(H * scale));
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', quality));
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Browsers can’t open this image type (HEIC won’t work). Use a JPG or PNG — or set iPhone Camera → Formats → “Most Compatible.”')); };
img.src = url;
});
}
// Read a Blob/File as a data URL WITHOUT touching the pixels (no canvas round-trip) —
// preserves the original bytes EXACTLY, so full resolution survives and PNG/WebP keep
// their crisp text/line-art and alpha (a canvas re-encode would flatten and soften them).
function blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.onerror = () => reject(new Error('Could not read that file.'));
fr.readAsDataURL(blob);
});
}
// Largest original we upload untouched. Bigger sources are transcoded down so the request
// body stays within the server's budget. MUST stay <= the server's decoded cap (20 MB) —
// 20 MB base64-encodes to ~26.7 MB, comfortably under the upload route's 30 MB body limit.
const EQUIP_PRESERVE_MAX_BYTES = 20 * 1024 * 1024;
// Shared path for any image source (file pick OR camera capture). Keep the ORIGINAL
// full-resolution bytes when it's a web-displayable type within budget; only fall back to
// a bounded 4096px / JPEG-q0.92 transcode for oversized or non-displayable sources. That
// fallback (downscaleImage) also surfaces the friendly HEIC message via its img.onerror.
const MAX_VIDEO_UPLOAD_BYTES = 30 * 1024 * 1024; // must match the server's MAX_VIDEO_BYTES
async function uploadEquipImageBlob(blob) {
if (!blob) return false;
equipMgmtError.style.display = 'none';
const type = (blob.type || '').toLowerCase();
const isVid = type.startsWith('video/');
if (equipUploadStatus) equipUploadStatus.textContent = isVid ? 'Processing video…' : 'Processing photo…';
try {
let dataUrl;
if (isVid) {
// Videos can't be canvas-transcoded — send the raw bytes, but hold the
// size line client-side so we fail fast with a friendly message.
if (!/^video\/(mp4|webm|quicktime|ogg)$/.test(type)) throw new Error('Use an MP4, WEBM, or MOV video (that format won’t play in browsers).');
if (blob.size > MAX_VIDEO_UPLOAD_BYTES) throw new Error('Video is too large (max 30 MB). Trim it or lower the resolution.');
dataUrl = await blobToDataUrl(blob);
} else {
const displayable = /^image\/(jpeg|png|webp|gif)$/.test(type);
dataUrl = (displayable && blob.size <= EQUIP_PRESERVE_MAX_BYTES)
? await blobToDataUrl(blob) // original — full resolution, lossless
: await downscaleImage(blob, 4096, 0.92); // oversized/unknown → bounded transcode
}
if (equipUploadStatus) equipUploadStatus.textContent = 'Uploading…';
const res = await apiPost('/api/equipment/upload-image', { dataUrl });
addEquipPhoto(res.path, '');
if (equipUploadStatus) equipUploadStatus.textContent = (isVid ? 'Video' : 'Photo') + ' added ✓';
return true;
} catch (err) {
if (equipUploadStatus) equipUploadStatus.textContent = '';
equipMgmtError.textContent = err.message; equipMgmtError.style.display = 'block';
return false;
}
}
async function handleEquipFile(ev) {
const file = ev.target.files && ev.target.files[0];
if (!file) return;
try { await uploadEquipImageBlob(file); }
finally { if (equipFile) equipFile.value = ''; } // let them re-pick the same file
}
// ─── In-app camera capture (getUserMedia) ───────────────────────────
// Lets you snap a photo on the spot from any device — desktop included, where
// the OS file dialog can't reach the camera. On phones/tablets "Choose photo"
// already offers the camera, but this works there too.
let equipCamStream = null;
let equipCamFacing = 'environment'; // rear camera by default
function stopEquipCamStream() {
if (equipCamStream) { equipCamStream.getTracks().forEach((t) => t.stop()); equipCamStream = null; }
if (equipCamVideo) equipCamVideo.srcObject = null;
}
async function startEquipCamStream() {
stopEquipCamStream();
if (equipCamError) { equipCamError.style.display = 'none'; equipCamError.textContent = ''; }
try {
// Ask for the highest resolution the device offers (ideal, so it never errors —
// just falls back to the best available). Phone browsers cap getUserMedia around
// 1080p; for true full-sensor 4K on a phone, "Add photo" → the native camera is best.
equipCamStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: equipCamFacing, width: { ideal: 3840 }, height: { ideal: 2160 } }, audio: false });
if (equipCamVideo) { equipCamVideo.srcObject = equipCamStream; await equipCamVideo.play().catch(() => {}); }
} catch (err) {
if (equipCamError) {
equipCamError.textContent = 'Could not open the camera (' + ((err && err.name) || 'error') + '). Allow camera access in your browser, or use “Choose photo.”';
equipCamError.style.display = 'block';
}
}
}
async function openEquipCamera() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
equipMgmtError.textContent = 'This browser can’t open the camera. Use “Choose photo” instead.';
equipMgmtError.style.display = 'block';
return;
}
if (equipCamModal) equipCamModal.style.display = 'flex';
await startEquipCamStream();
}
function closeEquipCamera() {
stopEquipCamStream();
if (equipCamModal) closeOverlay(equipCamModal);
}
async function switchEquipCamera() {
equipCamFacing = (equipCamFacing === 'environment') ? 'user' : 'environment';
await startEquipCamStream();
}
async function captureEquipPhoto() {
if (!equipCamVideo || !equipCamVideo.videoWidth) return;
const canvas = document.createElement('canvas');
canvas.width = equipCamVideo.videoWidth;
canvas.height = equipCamVideo.videoHeight;
canvas.getContext('2d').drawImage(equipCamVideo, 0, 0, canvas.width, canvas.height);
closeEquipCamera(); // stop the stream + hide before the (async) upload
await new Promise((resolve) => {
// Single high-quality encode; uploadEquipImageBlob preserves these bytes as-is
// (no second downscale), so the capture is no longer double-compressed.
canvas.toBlob((blob) => { uploadEquipImageBlob(blob).finally(resolve); }, 'image/jpeg', 0.95);
});
}
// Which locations/teams may the current user scope equipment to?
// Options come from the union of the directory AND every location/team that
// actually has a list — so a campus with lists but no team login (e.g. a
// second site) is still selectable for its own on-site photos.
function equipAllowedScope() {
const dir = checklistData.directory || { locations: [], teams: [] };
const lc = (s) => String(s || '').toLowerCase();
const union = (fromDir, fromLists) => {
const seen = new Map();
fromDir.concat(fromLists).forEach((n) => { const k = lc(n); if (n && !seen.has(k)) seen.set(k, n); });
return [...seen.values()].sort((a, b) => a.localeCompare(b));
};
const allLocs = union((dir.locations || []).map((l) => l.name), (checklistData.lists || []).map((l) => l.location).filter(Boolean));
const allTeams = union((dir.teams || []).map((t) => t.name), (checklistData.lists || []).map((l) => l.team).filter(Boolean));
if (isMasterAdmin()) return { locs: allLocs, teams: allTeams, master: true };
// Execs: their own scope is the allowance (shown even if no list exists yet).
const sLoc = (currentUser && currentUser.scopeLocations) || [];
const sTeam = (currentUser && currentUser.scopeTeams) || [];
return {
locs: sLoc.length ? union(sLoc, []) : allLocs,
teams: sTeam.length ? union(sTeam, []) : allTeams,
master: false
};
}
function drawEquipScopeRows(selLocs, selTeams) {
const allow = equipAllowedScope();
const on = (arr, name) => (arr || []).some((s) => String(s).toLowerCase() === name.toLowerCase());
const cb = (kind, name, checked) => `<label class="exec-cb"><input type="checkbox" data-equip-cb="${kind}" value="${escapeAttr(name)}" ${checked ? 'checked' : ''}/> ${escapeHtml(name)}</label>`;
if (equipLocRow) equipLocRow.innerHTML = allow.locs.length ? allow.locs.map((n) => cb('location', n, on(selLocs, n))).join('') : '<span class="empty-state">none</span>';
if (equipTeamRow) equipTeamRow.innerHTML = allow.teams.length ? allow.teams.map((n) => cb('team', n, on(selTeams, n))).join('') : '<span class="empty-state">none</span>';
if (equipScopeHelp) equipScopeHelp.innerHTML = allow.master
? 'Where it appears — check location(s)/team(s), or leave both empty for everywhere.'
: 'Where it appears — check at least one of your location(s)/team(s).';
}
function getEquipScopeFromForm() {
const locs = equipLocRow ? [...equipLocRow.querySelectorAll('input[data-equip-cb="location"]:checked')].map((i) => i.value) : [];
const teams = equipTeamRow ? [...equipTeamRow.querySelectorAll('input[data-equip-cb="team"]:checked')].map((i) => i.value) : [];
return { scopeLocations: locs, scopeTeams: teams };
}
// Expand/collapse the add-equipment form (progressive disclosure). Opening scrolls
// it into view so the fields are visible even at the bottom of a long list.
function setEquipAddOpen(open) {
if (!equipAddSection) return;
equipAddSection.classList.toggle('open', !!open);
if (equipAddToggle) equipAddToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open) requestAnimationFrame(() => { try { equipAddSection.scrollIntoView({ block: 'nearest', behavior: prefersReduced() ? 'auto' : 'smooth' }); } catch (_) {} });
}
function resetEquipForm() {
equipEditId = null;
if (equipName) equipName.value = '';
if (equipAliases) equipAliases.value = '';
if (equipNote) equipNote.value = '';
if (equipFile) equipFile.value = '';
if (equipImage) equipImage.value = '';
if (equipUploadStatus) equipUploadStatus.textContent = '';
if (equipDocFile) equipDocFile.value = '';
if (equipDocUrl) equipDocUrl.value = '';
equipFormPhotos = [];
renderEquipPhotos();
equipFormDocs = [];
renderEquipDocs();
drawEquipScopeRows([], []);
if (equipSave) equipSave.textContent = 'Add equipment';
if (equipFormTitle) equipFormTitle.textContent = 'Add equipment';
if (equipCancelEdit) equipCancelEdit.style.display = 'none';
setEquipAddOpen(false); // collapse back to the list-first view
}
async function saveEquipment() {
equipMgmtError.style.display = 'none';
const scope = getEquipScopeFromForm();
// A URL left typed in the field but not yet "Add"ed still counts — fold it in.
const pending = equipImage ? (equipImage.value || '').trim() : '';
const images = equipFormPhotos.slice();
if (pending && images.length < 12 && !images.some((p) => p.url === pending)) images.push({ url: pending, caption: '' });
const body = {
name: (equipName.value || '').trim(),
aliases: (equipAliases.value || '').trim(),
images: images.map((p) => ({ url: p.url, caption: p.caption || '' })),
docs: equipFormDocs.map((d) => ({ url: d.url, name: (d.name || '').trim() })),
note: (equipNote.value || '').trim(),
scopeLocations: scope.scopeLocations,
scopeTeams: scope.scopeTeams
};
if (!body.name) { equipMgmtError.textContent = 'Name is required.'; equipMgmtError.style.display = 'block'; return; }
if (!isMasterAdmin()) {
// Match the server's intersection rule: on each axis the exec is
// restricted on, they must pick at least one value (leaving it blank
// would mean "everywhere" on that axis — outside their area).
const restrictedLoc = ((currentUser && currentUser.scopeLocations) || []).length > 0;
const restrictedTeam = ((currentUser && currentUser.scopeTeams) || []).length > 0;
if (restrictedLoc && !body.scopeLocations.length) {
equipMgmtError.textContent = 'Pick at least one of your locations for this equipment.'; equipMgmtError.style.display = 'block'; return;
}
if (restrictedTeam && !body.scopeTeams.length) {
equipMgmtError.textContent = 'Pick at least one of your teams for this equipment.'; equipMgmtError.style.display = 'block'; return;
}
if (!body.scopeLocations.length && !body.scopeTeams.length) {
equipMgmtError.textContent = 'Pick at least one location or team for this equipment.'; equipMgmtError.style.display = 'block'; return;
}
}
try {
if (equipEditId) await apiPut('/api/admin/equipment/' + encodeURIComponent(equipEditId), body);
else await apiPost('/api/admin/equipment', body);
showToast(equipEditId ? 'Equipment updated' : 'Equipment added', 'success');
equipItems = (await apiGet('/api/admin/equipment')).items || {};
resetEquipForm();
drawEquipList();
await refreshState({ showToastOnError: false }); // term-linking picks up the change everywhere
} catch (err) { equipMgmtError.textContent = err.message; equipMgmtError.style.display = 'block'; }
}
async function handleEquipListClick(ev) {
const toggle = ev.target.closest('[data-equip-tree-toggle]');
if (toggle) {
// Toggle IN PLACE — flip the caret and hide/show the already-rendered group.
// No drawEquipList(): a full rebuild re-created every row + thumbnail per tap.
const k = toggle.dataset.equipTreeToggle;
const closed = (equipTreeCollapsed[k] = !equipTreeCollapsed[k]);
const caret = toggle.querySelector('.tree-caret');
if (caret) caret.textContent = closed ? '▸' : '▾';
const body = toggle.nextElementSibling;
if (body && body.classList.contains('equip-tree-group')) body.classList.toggle('closed', closed);
return;
}
const btn = ev.target.closest('button[data-equip-action]');
if (!btn) return;
const id = btn.dataset.equipId;
const action = btn.dataset.equipAction;
if (action === 'edit') {
const e = equipItems[id];
if (!e) return;
equipEditId = id;
equipName.value = e.name || '';
equipAliases.value = (e.aliases || []).join(', ');
equipNote.value = e.note || '';
if (equipImage) equipImage.value = '';
equipFormPhotos = equipPhotosOf(e); // legacy single image → one-photo gallery
renderEquipPhotos();
equipFormDocs = (e.docs || []).map((d) => ({ url: d.url, name: d.name || '' }));
renderEquipDocs();
drawEquipScopeRows(e.scopeLocations || [], e.scopeTeams || []);
if (equipUploadStatus) equipUploadStatus.textContent = '';
equipSave.textContent = 'Save changes';
if (equipFormTitle) equipFormTitle.textContent = 'Edit equipment';
if (equipCancelEdit) equipCancelEdit.style.display = '';
setEquipAddOpen(true); // reveal the form (it's collapsed by default)
equipName.focus();
} else if (action === 'del') {
if (!confirm('Delete this equipment entry? Its word will stop being tappable.')) return;
try {
await apiDelete('/api/admin/equipment/' + encodeURIComponent(id));
showToast('Equipment deleted', 'success');
equipItems = (await apiGet('/api/admin/equipment')).items || {};
if (equipEditId === id) resetEquipForm();
drawEquipList();
await refreshState({ showToastOnError: false });
} catch (err) { equipMgmtError.textContent = err.message; equipMgmtError.style.display = 'block'; }
}
}
// Event delegation for checklist items
document.addEventListener('click', (e) => {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
const action = target.dataset.action;
if (!action) return;
const itemId = target.dataset.itemId;
if (action === 'edit') openEditModal(itemId);
else if (action === 'duplicate') duplicateItem(itemId);
else if (action === 'move') { e.stopPropagation(); openMovePopover(itemId, target); }
else if (action === 'delete') deleteItem(itemId);
});
document.addEventListener('change', (e) => {
const target = e.target;
if (!(target instanceof HTMLInputElement)) return;
if (target.dataset.action === 'toggle') toggleItem(target.dataset.itemId, target.checked);
});
function renderListSelect() {
// Only lists this account may add to: master -> all, team admin -> own team's.
const lists = editableLists();
if (!addTargetListId || !lists.find((l) => l.id === addTargetListId)) {
addTargetListId = lists[0] ? lists[0].id : null;
}
addListSelect.innerHTML = lists.map((list) => {
const scope = [abbrevForLocation(list.location), (list.team || '').trim()].filter(Boolean).join(' · ');
const label = scope ? `${list.name} — ${scope}` : list.name;
return `<option value="${escapeAttr(list.id)}" ${list.id === addTargetListId ? 'selected' : ''}>${escapeHtml(label)}</option>`;
}).join('');
}
// ─── Overview: today-at-a-glance progress + recent activity ──────────
// Read a list's checks from its TEAM's record (what oversight cares about),
// falling back to the viewer's own scope for global lists.
function listTeamChecks(list) {
const all = checklistData.checks || {};
const k = scopeKeyForList(list);
if (k && all[k] && all[k][list.id]) return all[k][list.id];
const mine = myScopeKey();
return (mine && all[mine] && all[mine][list.id]) || {};
}
function overviewListStats(list) {
const checks = listTeamChecks(list);
const tasks = (list.items || []).filter((it) => it.kind !== 'note'); // notes aren't completable
const total = tasks.length;
let done = 0;
tasks.forEach((it) => { if (checks[it.id] && checks[it.id].completed) done++; });
return { total, done };
}
function openOverview() {
if (!overviewModal || !overviewBody) return;
drawOverview();
overviewModal.style.display = 'flex';
}
function drawOverview() {
if (!overviewModal || !overviewBody) return;
const lists = visibleLists().filter((l) => !l.hidden); // skip hidden special-event lists
const byLoc = {};
lists.forEach((l) => {
const loc = (l.location || '').trim() || 'Global';
const team = (l.team || '').trim() || '—';
(byLoc[loc] = byLoc[loc] || {});
(byLoc[loc][team] = byLoc[loc][team] || []).push(l);
});
let html = '';
const locNames = Object.keys(byLoc).sort((a, b) => a.localeCompare(b));
if (!locNames.length) html = '<div class="empty-state" style="padding:10px;">No lists to show.</div>';
locNames.forEach((loc) => {
html += `<div class="ov-loc">${escapeHtml(loc)}</div>`;
const teams = byLoc[loc];
Object.keys(teams).sort((a, b) => a.localeCompare(b)).forEach((team) => {
html += `<div class="ov-team">${escapeHtml(team)}</div>`;
teams[team].slice().sort((a, b) => (a.name || '').localeCompare(b.name || '')).forEach((l) => {
const s = overviewListStats(l);
const pct = s.total ? Math.round((s.done / s.total) * 100) : 0;
const cls = (s.total && s.done === s.total) ? 'done' : (s.done > 0 ? 'partial' : 'none');
const dot = `<span class="ov-dot" style="background:${escapeAttr(colorHex(l.color) || '#555')}"></span>`;
html += `<div class="ov-row ${cls}" data-overview-list="${escapeAttr(l.id)}" role="button" tabindex="0">
${dot}<span class="ov-name">${escapeHtml(l.name)}</span>
<span class="ov-bar"><span class="ov-fill ${cls}" style="width:${pct}%"></span></span>
<span class="ov-count">${s.done}/${s.total}</span></div>`;
});
});
});
// Recent activity across the visible lists (newest first).
const events = [];
lists.forEach((l) => {
const checks = listTeamChecks(l);
Object.keys(checks).forEach((itemId) => {
const c = checks[itemId];
if (!c || !c.completed || !c.completedDate) return;
const item = (l.items || []).find((i) => i.id === itemId);
if (!item) return;
events.push({ at: c.completedDate, by: c.completedBy || '', title: item.title, listName: l.name });
});
});
events.sort((a, b) => (a.at < b.at ? 1 : (a.at > b.at ? -1 : 0)));
html += '<div class="ov-activity-head">Recent activity</div>';
const recent = events.slice(0, 15);
if (!recent.length) {
html += '<div class="empty-state" style="padding:8px;">Nothing checked yet.</div>';
} else {
html += recent.map((e) => `<div class="ov-act"><span class="ov-act-check">✓</span>
<span class="ov-act-text"><b>${escapeHtml(e.title)}</b> — ${escapeHtml(e.listName)}</span>
<span class="ov-act-meta">${e.by ? escapeHtml(e.by) + ' · ' : ''}${escapeHtml(formatStamp(e.at))}</span></div>`).join('');
}
overviewBody.innerHTML = html;
}
// ─── Activity Log (master-only audit view of who did what, app-wide) ───
let activityEntries = [];
let activityTimer = null;
async function fetchActivity() {
const data = await apiGet('/api/admin/activity');
return Array.isArray(data && data.activity) ? data.activity : [];
}
async function openActivity() {
if (!activityModal || !activityBody) return;
if (activitySearch) activitySearch.value = '';
activityBody.innerHTML = '<div class="empty-state" style="padding:12px;">Loading…</div>';
activityModal.style.display = 'flex';
try {
activityEntries = await fetchActivity();
drawActivity();
} catch (err) {
activityBody.innerHTML = '<div class="empty-state" style="padding:12px;">Couldn’t load activity: ' + escapeHtml(err.message || 'error') + '</div>';
}
startActivityAutoRefresh(); // live updates while it's open — no need to close/reopen
}
// Poll the log every 5s while the modal is open; re-render only on change, preserving scroll.
function startActivityAutoRefresh() {
stopActivityAutoRefresh();
activityTimer = setInterval(async () => {
if (!activityModal || getComputedStyle(activityModal).display === 'none') { stopActivityAutoRefresh(); return; }
try {
const next = await fetchActivity();
const changed = next.length !== activityEntries.length
|| (next[0] && next[0].ts) !== (activityEntries[0] && activityEntries[0].ts);
if (changed) {
activityEntries = next;
const keep = activityBody.scrollTop;
drawActivity();
activityBody.scrollTop = keep;
}
} catch (_) { /* transient — retry next tick */ }
}, 5000);
}
function stopActivityAutoRefresh() { if (activityTimer) { clearInterval(activityTimer); activityTimer = null; } }
function activityRoleBadge(role) {
const label = { master: 'Admin', executive: 'Exec', 'team-admin': 'Team Admin', checker: 'Checker' }[role] || role || '';
return '<span class="act-role act-role-' + escapeAttr(role || 'checker') + '">' + escapeHtml(label) + '</span>';
}
function activityIcon(kind) {
const k = String(kind || '');
if (k === 'check') return '✅';
if (k === 'uncheck') return '↩️';
if (k.endsWith('.delete')) return '🗑';
if (k.endsWith('.create') || k === 'import') return '➕';
if (k.endsWith('.edit') || k === 'lists.save') return '✏️';
if (k === 'item.move') return '➡️';
if (k === 'item.reorder') return '↕️';
if (k === 'list.reset') return '↺';
if (k === 'list.duplicate') return '⧉';
if (k === 'login') return '🔑';
if (k.indexOf('exec') === 0 || k.indexOf('user') === 0) return '👤';
if (k.indexOf('equip') === 0) return '🔧';
if (k.indexOf('directory') === 0) return '🏢';
return '•';
}
function activityDayLabel(d) {
const today = new Date().toDateString();
const yest = new Date(Date.now() - 86400000).toDateString();
const ds = d.toDateString();
if (ds === today) return 'Today';
if (ds === yest) return 'Yesterday';
return d.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' });
}
function drawActivity() {
if (!activityBody) return;
const q = ((activitySearch && activitySearch.value) || '').trim().toLowerCase();
const entries = q
? activityEntries.filter((e) => ((e.who || '') + ' ' + (e.msg || '') + ' ' + (e.role || '')).toLowerCase().indexOf(q) >= 0)
: activityEntries;
if (!entries.length) {
activityBody.innerHTML = '<div class="empty-state" style="padding:12px;">' + (q ? 'No matching activity.' : 'No activity recorded yet.') + '</div>';
return;
}
let html = '';
let curDay = null;
entries.forEach((e) => {
const d = new Date(e.ts);
const valid = !isNaN(d.getTime());
const day = valid ? activityDayLabel(d) : '—';
if (day !== curDay) { curDay = day; html += '<div class="act-day">' + escapeHtml(day) + '</div>'; }
const time = valid ? d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
html += '<div class="act-row">'
+ '<span class="act-icon" aria-hidden="true">' + activityIcon(e.kind) + '</span>'
+ '<div class="act-main">'
+ '<div class="act-line">' + activityRoleBadge(e.role)
+ '<span class="act-who">' + escapeHtml(e.who || '?') + '</span> '
+ '<span class="act-msg">' + escapeHtml(e.msg || '') + '</span></div>'
+ '<div class="act-time">' + escapeHtml(time) + '</div></div></div>';
});
activityBody.innerHTML = html;
}
// ─── List templates: start a new list from an existing one's tasks ───
function populateTemplateSelect() {
if (!listModalTemplate) return;
const lists = visibleLists();
listModalTemplate.innerHTML = '<option value="">Blank list (no tasks)</option>' +
lists.map((l) => {
const scope = [abbrevForLocation(l.location), (l.team || '').trim()].filter(Boolean).join(' · ');
const n = (l.items || []).length;
const label = `${l.name}${scope ? ' — ' + scope : ''} (${n} task${n === 1 ? '' : 's'})`;
return `<option value="${escapeAttr(l.id)}">${escapeHtml(label)}</option>`;
}).join('');
listModalTemplate.value = '';
}
function onTemplatePick() {
const src = (checklistData.lists || []).find((l) => l.id === (listModalTemplate && listModalTemplate.value));
if (!src) return;
setListModalColor(src.color || ''); // mirror the template's look…
setResetScheduleInModal(src.resetSchedule || null);
if (!listModalInput.value.trim()) listModalInput.value = src.name; // …and suggest its name
}
// Fresh copies of a template's tasks (no ids/checks carried over).
function templateItemsFromModal() {
if (modalMode !== 'add' || !listModalTemplate || !listModalTemplate.value) return [];
const src = (checklistData.lists || []).find((l) => l.id === listModalTemplate.value);
if (!src) return [];
return (src.items || []).map((it) => ({
title: it.title,
description: it.description || '',
kind: it.kind === 'note' ? 'note' : 'task',
recurrence: it.kind === 'note' ? null : (it.recurrence ? Object.assign({}, it.recurrence) : null)
}));
}
// ─── Add / Edit / Toggle / Delete ────────────────────────────
function addItem() {
const title = itemTitleInput.value.trim();
if (!title) { showToast('Enter a title first', 'error'); return; }
flushMarkdownEditor('itemDescription'); // pull formatted-view edits into the source
const description = itemDescriptionInput.value.trim();
const kind = addKind === 'note' ? 'note' : 'task'; // set by which button (+Add Task / +Add Note) opened the modal
const recurrence = (kind !== 'note' && isRecurringCheckbox.checked) ? addRec.read() : null; // notes never recur
const groupSel = document.getElementById('itemGroupSelect');
const groupRow = document.getElementById('itemGroupRow');
const group = (kind !== 'note' && groupRow && groupRow.style.display !== 'none' && groupSel && groupSel.value) ? groupSel.value : 'todo';
const targetId = addTargetListId;
if (!targetId || !checklistData.lists.find((l) => l.id === targetId)) {
showToast('Pick a list to add to', 'error');
return;
}
const onDone = (ok) => {
if (ok === false) return;
itemTitleInput.value = '';
itemDescriptionInput.value = '';
resetMarkdownEditor('itemDescription'); // clear the formatted view too
isRecurringCheckbox.checked = false;
recurrenceOptions.style.display = 'none';
addRec.write(null);
itemTitleInput.focus();
};
if (isMasterAdmin()) {
const next = cloneState(checklistData);
const target = next.lists.find((l) => l.id === targetId);
const newId = Date.now().toString() + '-' + Math.random().toString(16).slice(2, 6);
target.items.push(Object.assign({ id: newId, title, description, kind, recurrence, createdAt: new Date().toISOString() }, kind === 'note' ? {} : { group }));
if (kind === 'note') target.showNotes = true; // adding a note reveals the Notes column for everyone
justMovedItemId = newId; // the new card slides + flashes in
pushAdminState(next).then(onDone);
} else {
// The server auto-enables showNotes when a note is added (see items POST), so the scoped
// path needs no second call.
saveMutation(apiAddItem(targetId, { title, description, kind, recurrence, group })).then(onDone);
}
}
function openEditModal(itemId) {
const list = getCurrentList();
if (!list) return;
const item = list.items.find((i) => i.id === itemId);
if (!item) return;
editingTaskId = itemId;
editTitle.value = item.title;
editDescription.value = item.description || '';
// A note never recurs: hide the whole Recurring control when editing a note (conversion is
// forbidden, so kind stays fixed — there's no kind toggle here).
const isNote = item.kind === 'note';
editIsRecurring.checked = !isNote && !!item.recurrence;
editRecurrenceOptions.style.display = editIsRecurring.checked ? 'block' : 'none';
const editRecRow = editIsRecurring.closest('.checkbox-label, .form-group');
if (editRecRow) editRecRow.style.display = isNote ? 'none' : '';
editRec.write(isNote ? null : (item.recurrence || null));
// Section picker — only for tasks on lists that actually have extra sections.
{
const row = document.getElementById('editGroupRow');
const sel = document.getElementById('editGroupSelect');
if (row && sel) {
const groups = taskGroupsOf(list);
if (isNote || groups.length < 2) { row.style.display = 'none'; sel.innerHTML = ''; }
else {
sel.innerHTML = groups.map((g) => `<option value="${escapeAttr(g.id)}">${escapeHtml(g.name)}</option>`).join('');
sel.value = itemGroupId(item, groups);
row.style.display = '';
}
}
}
resetMarkdownEditor('editDescription');
editTaskModal.style.display = 'flex';
requestAnimationFrame(() => { editTitle.focus({ preventScroll: true }); editTitle.select(); });
}
function closeEditModal() {
editingTaskId = null;
closeOverlay(editTaskModal);
}
function saveEditTask() {
if (!editingTaskId) { closeEditModal(); return; }
const title = editTitle.value.trim();
if (!title) { showToast('Title is required', 'error'); return; }
flushMarkdownEditor('editDescription'); // pull formatted-view edits into the source
const description = editDescription.value.trim();
const recurrence = editIsRecurring.checked ? editRec.read() : null;
const egRow = document.getElementById('editGroupRow');
const egSel = document.getElementById('editGroupSelect');
const groupPicked = !!(egRow && egRow.style.display !== 'none' && egSel && egSel.value);
// Find the list that actually contains this task (the viewed list, which
// may differ from the server's selectedListId).
const ownerList = (checklistData.lists || []).find((l) => l.items.some((i) => i.id === editingTaskId));
if (!ownerList) { closeEditModal(); return; }
if (isMasterAdmin()) {
const next = cloneState(checklistData);
const list = next.lists.find((l) => l.id === ownerList.id);
const item = list.items.find((i) => i.id === editingTaskId);
if (!item) { closeEditModal(); return; }
item.title = title;
item.description = description;
item.recurrence = recurrence;
if (groupPicked && item.kind !== 'note') item.group = egSel.value;
pushAdminState(next).then(() => closeEditModal());
} else {
const body = { title, description, recurrence };
if (groupPicked) body.group = egSel.value;
saveMutation(apiUpdateItem(ownerList.id, editingTaskId, body))
.then(() => closeEditModal());
}
}
// Set when the user checks/unchecks an item, so the next render animates that
// one row sliding into its new column. Cleared after a single render so the
// 6s poll never replays it.
let justMovedItemId = null;
function toggleItem(itemId, checked) {
const list = getCurrentList();
if (!list) return;
if (!sessionToken) { openLogin(); return; }
justMovedItemId = itemId;
clearCollapseOverride(itemId); // fresh completion force-collapses; reopen expands
if (isEditor() && !applyingHistory) {
recordHistory({ type: 'check', listId: list.id, itemId: itemId, before: !checked, after: !!checked });
}
pushCheck(list.id, itemId, checked);
}
// Toggle one inline checkbox (by index). Completion is derived from the boxes, so a task
// with checkboxes is never completed via /api/check — only /api/check/sub (pushTaskCheck).
function taskToggle(itemId, index, checked) {
const list = getCurrentList();
if (!list || !itemId || !Number.isInteger(index)) return;
if (!sessionToken) { openLogin(); return; }
pushTaskCheck(list.id, itemId, index, checked);
}
// Copy a task into the SAME list (fresh id, no checkmark carried over) so the
// user can then Move the copy to another list. Routes master vs scoped like deleteItem.
async function duplicateItem(itemId) {
const ownerList = (checklistData.lists || []).find((l) => l.items.some((i) => i.id === itemId));
if (!ownerList) return;
const src = ownerList.items.find((i) => i.id === itemId);
if (!src) return;
const kind = src.kind === 'note' ? 'note' : 'task'; // duplicating a note keeps it a note (conversion is forbidden)
const body = {
title: src.title,
description: src.description || '', // checkbox lines live in the description → copied for free
kind,
recurrence: kind === 'note' ? null : (src.recurrence ? Object.assign({}, src.recurrence) : null)
};
if (kind !== 'note' && src.group) body.group = src.group; // stay in the same section
let ok;
if (isMasterAdmin()) {
const next = cloneState(checklistData);
const list = next.lists.find((l) => l.id === ownerList.id);
const copy = Object.assign({}, body, {
id: `${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
createdAt: new Date().toISOString()
});
const idx = list.items.findIndex((i) => i.id === itemId);
list.items.splice(idx >= 0 ? idx + 1 : list.items.length, 0, copy); // place right after the original
justMovedItemId = copy.id; // the copy slides + flashes in
ok = await pushAdminState(next);
} else {
ok = await saveMutation(apiAddItem(ownerList.id, body));
}
if (ok) showToast('Task duplicated — use Move to send the copy to another list', 'success');
}
function deleteItem(itemId) {
if (!confirm('Delete this task?')) return;
const ownerList = (checklistData.lists || []).find((l) => l.items.some((i) => i.id === itemId));
if (!ownerList) return;
if (isMasterAdmin()) {
const next = cloneState(checklistData);
const list = next.lists.find((l) => l.id === ownerList.id);
list.items = list.items.filter((i) => i.id !== itemId);
pushAdminState(next);
} else {
saveMutation(apiDeleteItem(ownerList.id, itemId));
}
}
// A self-contained recurrence editor, built into `mount`. Same option set as the
// list repeat editor (type, every-N interval, start weekday, day-of-week/month,
// and intraday clock times). Uses classes (not ids) so it can be instantiated more
// than once (add- and edit-task modals). Returns { read(), write(rec) }.
function makeRecurrenceEditor(mount) {
if (!mount) return { read: () => null, write: () => {} };
const dayOpts = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
.map((d, i) => `<option value="${i}">${d}</option>`).join('');
const monthOpts = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
.map((m, i) => `<option value="${i}">${m}</option>`).join('');
mount.innerHTML = `
<label class="rec-lbl">Repeat:</label>
<select class="rec-type">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
<div class="reset-row rec-every" style="display:none;">
<span>Every</span>
<input type="number" class="rec-interval" min="1" max="99" value="1" />
<span class="rec-unit">days</span>
<span class="reset-hint rec-hint"></span>
</div>
<div class="reset-row rec-start" style="display:none;">
<span>starting on</span>
<select class="rec-startdow">${dayOpts}</select>
</div>
<div class="reset-row rec-dow" style="display:none;">
<span>on</span>
<select class="rec-dowsel">${dayOpts}</select>
</div>
<div class="reset-row rec-month" style="display:none;">
<span>in</span>
<select class="rec-monthsel">${monthOpts}</select>
</div>
<div class="reset-row rec-dom" style="display:none;">
<span>on day</span>
<input type="number" class="rec-dominp" min="1" max="31" value="1" />
<span>of the month</span>
</div>
<div class="reset-row reset-times-row rec-times" style="display:none;">
<span>at</span>
<div class="reset-times-list rec-timeslist"></div>
<button type="button" class="btn btn-secondary btn-small reset-time-add rec-timesadd">+ Add time</button>
<span class="reset-hint reset-times-hint">leave empty to reset once a day</span>
</div>`;
const $ = (s) => mount.querySelector(s);
const typeSel = $('.rec-type'), interval = $('.rec-interval'), unit = $('.rec-unit'), hint = $('.rec-hint');
const startRow = $('.rec-start'), startDow = $('.rec-startdow'), everyRow = $('.rec-every');
const dowRow = $('.rec-dow'), dowSel = $('.rec-dowsel');
const monthRow = $('.rec-month'), monthSel = $('.rec-monthsel');
const domRow = $('.rec-dom'), domInp = $('.rec-dominp');
const timesRow = $('.rec-times'), timesList = $('.rec-timeslist'), timesAdd = $('.rec-timesadd');
const units = { daily: 'days', weekly: 'weeks', monthly: 'months', yearly: 'years' };
function addTime(v) {
const entry = document.createElement('span'); entry.className = 'reset-time-entry';
const inp = document.createElement('input'); inp.type = 'time'; inp.className = 'reset-time-input'; if (v) inp.value = v;
const rm = document.createElement('button'); rm.type = 'button'; rm.className = 'reset-time-remove'; rm.setAttribute('aria-label', 'Remove time'); rm.textContent = '×';
rm.addEventListener('click', () => entry.remove());
entry.appendChild(inp); entry.appendChild(rm); timesList.appendChild(entry); return inp;
}
function collectTimes() {
const seen = new Set(), out = [];
timesList.querySelectorAll('.reset-time-input').forEach((inp) => {
const m = /^(\d{1,2}):(\d{2})$/.exec((inp.value || '').trim()); if (!m) return;
const h = parseInt(m[1], 10), mn = parseInt(m[2], 10); if (h < 0 || h > 23 || mn < 0 || mn > 59) return;
const k = String(h).padStart(2, '0') + ':' + String(mn).padStart(2, '0'); if (seen.has(k)) return; seen.add(k); out.push(k);
});
out.sort(); return out;
}
function refresh() {
const t = typeSel.value; const iv = Math.max(1, parseInt(interval.value, 10) || 1);
unit.textContent = units[t] || '';
hint.textContent = t === 'weekly' ? '(2 = biweekly)' : '';
everyRow.style.display = 'flex';
startRow.style.display = (t === 'daily' && iv >= 2) ? 'flex' : 'none';
dowRow.style.display = t === 'weekly' ? 'flex' : 'none';
monthRow.style.display = t === 'yearly' ? 'flex' : 'none';
domRow.style.display = (t === 'monthly' || t === 'yearly') ? 'flex' : 'none';
timesRow.style.display = (t === 'daily' || t === 'weekly') ? 'flex' : 'none';
}
typeSel.addEventListener('change', refresh);
interval.addEventListener('input', refresh);
timesAdd.addEventListener('click', () => { const i = addTime(''); if (i) i.focus(); });
function write(rec) {
rec = rec || {};
typeSel.value = ['daily', 'weekly', 'monthly', 'yearly'].includes(rec.type) ? rec.type : 'daily';
interval.value = String(rec.interval > 1 ? rec.interval : 1);
startDow.value = String(rec.startDow != null ? rec.startDow : 1);
dowSel.value = String(rec.dayOfWeek != null ? rec.dayOfWeek : 0);
monthSel.value = String(rec.month != null ? rec.month : 0);
domInp.value = rec.dayOfMonth ? rec.dayOfMonth : 1;
timesList.innerHTML = '';
(Array.isArray(rec.times) ? rec.times : []).forEach(addTime);
refresh();
}
function read() {
const t = typeSel.value;
const iv = Math.min(99, Math.max(1, parseInt(interval.value, 10) || 1));
const dom = () => Math.min(31, Math.max(1, parseInt(domInp.value, 10) || 1));
const times = collectTimes();
const out = { type: t, interval: iv };
if (t === 'daily' && iv > 1) out.startDow = parseInt(startDow.value, 10) || 0;
if (t === 'weekly') out.dayOfWeek = parseInt(dowSel.value, 10) || 0;
if (t === 'monthly') out.dayOfMonth = dom();
if (t === 'yearly') { out.month = parseInt(monthSel.value, 10) || 0; out.dayOfMonth = dom(); }
if ((t === 'daily' || t === 'weekly') && times.length) out.times = times;
return out;
}
write(null);
return { read, write };
}
// Task recurrence now uses the same schedule shape as a list reset, so its
// human-readable text is produced by the same formatter (interval + times).
function getRecurrenceText(recurrence) {
return resetScheduleText(recurrence);
}
function formatStamp(iso) {
if (!iso) return '';
try {
const d = new Date(iso);
return d.toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
} catch (_) { return iso; }
}
// ─── List management (admin) ─────────────────────────────────
function setListModalColor(color) {
listModalColorValue = color || '';
if (!listModalColors) return;
const isHex = /^#[0-9a-fA-F]{6}$/.test(listModalColorValue);
listModalColors.querySelectorAll('.swatch').forEach((s) => {
if (s.classList.contains('swatch-custom')) {
s.classList.toggle('selected', isHex);
if (isHex) s.style.setProperty('--lc', listModalColorValue);
else s.style.removeProperty('--lc');
} else {
s.classList.toggle('selected', !isHex && (s.dataset.color || '') === listModalColorValue);
}
});
const inp = document.getElementById('listModalCustomColor');
if (inp && isHex) inp.value = listModalColorValue;
}
// ── Reset schedule controls in the Edit List modal ──
function updateResetScheduleControls() {
const t = listModalResetType ? listModalResetType.value : '';
const interval = Math.max(1, parseInt(listModalResetInterval && listModalResetInterval.value, 10) || 1);
const units = { daily: 'days', weekly: 'weeks', monthly: 'months', yearly: 'years' };
const show = (el, on) => { if (el) el.style.display = on ? 'flex' : 'none'; };
show(listModalResetEvery, !!t);
if (listModalResetUnit) listModalResetUnit.textContent = units[t] || '';
// Tiny example only where it isn't misleading.
if (listModalResetHint) listModalResetHint.textContent = t === 'weekly' ? '(2 = biweekly)' : '';
// Daily: pick the weekday the cycle starts on — only matters for every 2+ days.
show(listModalResetStartRow, t === 'daily' && interval >= 2);
show(listModalResetDowRow, t === 'weekly'); // "on [Wednesday]"
show(listModalResetMonthRow, t === 'yearly'); // "in [June]"
show(listModalResetDomRow, t === 'monthly' || t === 'yearly'); // "on day [N] of the month"
// Intraday repeat: reset at specific clock times — offered for daily/weekly only.
show(listModalResetTimesRow, t === 'daily' || t === 'weekly');
}
// ── Intraday reset times (the "repeat multiple times a day" editor) ──
// Each entry is a native <input type="time"> (gives "HH:MM" 24h, displayed in
// the user's locale) with a remove button. Empty list ⇒ plain once-a-day reset.
function addResetTimeEntry(value) {
if (!listModalResetTimesList) return;
const entry = document.createElement('span');
entry.className = 'reset-time-entry';
const input = document.createElement('input');
input.type = 'time';
input.className = 'reset-time-input';
if (value) input.value = value;
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'reset-time-remove';
remove.setAttribute('aria-label', 'Remove time');
remove.textContent = '×';
remove.addEventListener('click', () => { entry.remove(); });
entry.appendChild(input);
entry.appendChild(remove);
listModalResetTimesList.appendChild(entry);
return input;
}
function setResetTimes(times) {
if (!listModalResetTimesList) return;
listModalResetTimesList.innerHTML = '';
(Array.isArray(times) ? times : []).forEach((t) => addResetTimeEntry(t));
}
// Collected, validated, de-duped and sorted "HH:MM" values from the inputs.
function collectResetTimes() {
if (!listModalResetTimesList) return [];
const seen = new Set();
const out = [];
listModalResetTimesList.querySelectorAll('.reset-time-input').forEach((inp) => {
const m = /^(\d{1,2}):(\d{2})$/.exec((inp.value || '').trim());
if (!m) return;
const h = parseInt(m[1], 10), mn = parseInt(m[2], 10);
if (h < 0 || h > 23 || mn < 0 || mn > 59) return;
const key = String(h).padStart(2, '0') + ':' + String(mn).padStart(2, '0');
if (seen.has(key)) return;
seen.add(key);
out.push(key);
});
out.sort();
return out;
}
function setResetScheduleInModal(schedule) {
if (!listModalResetType) return;
const type = (schedule && schedule.type) || '';
listModalResetType.value = ['daily', 'weekly', 'monthly', 'yearly'].includes(type) ? type : '';
if (listModalResetInterval) listModalResetInterval.value = String((schedule && schedule.interval > 1) ? schedule.interval : 1);
if (listModalResetStartDow) listModalResetStartDow.value = String((schedule && schedule.startDow != null) ? schedule.startDow : 1);
if (listModalResetDow) listModalResetDow.value = String((schedule && schedule.dayOfWeek != null) ? schedule.dayOfWeek : 0);
if (listModalResetMonth) listModalResetMonth.value = String((schedule && schedule.month != null) ? schedule.month : 0);
if (listModalResetDom) listModalResetDom.value = String((schedule && schedule.dayOfMonth) ? schedule.dayOfMonth : 1);
setResetTimes((schedule && Array.isArray(schedule.times)) ? schedule.times : []);
updateResetScheduleControls();
}
function getResetScheduleFromModal() {
const t = listModalResetType ? listModalResetType.value : '';
if (!t) return null;
const dom = () => Math.min(31, Math.max(1, parseInt(listModalResetDom.value, 10) || 1));
// Interval ("every N"); anchor is set server-side. No anchor sent → the
// server (re)anchors to today whenever the repeat is set/changed here.
const interval = Math.min(99, Math.max(1, parseInt(listModalResetInterval && listModalResetInterval.value, 10) || 1));
// Intraday repeat times apply to daily/weekly; empty ⇒ once-a-day (unchanged).
const times = collectResetTimes();
if (t === 'daily') {
const sched = { type: 'daily', interval };
if (interval > 1) sched.startDow = parseInt(listModalResetStartDow && listModalResetStartDow.value, 10) || 0;
if (times.length) sched.times = times;
return sched;
}
if (t === 'weekly') {
const sched = { type: 'weekly', interval, dayOfWeek: parseInt(listModalResetDow.value, 10) || 0 };
if (times.length) sched.times = times;
return sched;
}
if (t === 'monthly') return { type: 'monthly', interval, dayOfMonth: dom() };
if (t === 'yearly') return { type: 'yearly', interval, month: parseInt(listModalResetMonth.value, 10) || 0, dayOfMonth: dom() };
return null;
}
async function resetCurrentList() {
const list = getCurrentList();
if (!list) { showToast('No list selected', 'error'); return; }
if (!confirm(`Reset "${list.name}"?\n\nClears all checkmarks and moves every item back to To Do.`)) return;
try {
await apiResetList(list.id);
showToast('List reset — everything back to To Do', 'success');
await refreshState({ showToastOnError: false });
} catch (err) { showToast('Reset failed: ' + err.message, 'error'); }
}
function openListModal(mode) {
modalMode = mode;
const current = getCurrentList();
modalTargetId = current ? current.id : null;
listModalTitle.textContent = mode === 'add' ? 'Add List' : 'Edit List';
listModalMessage.style.display = 'none';
listModalMessage.classList.remove('danger');
listModalInput.style.display = 'block';
// Master always picks scope; an executive picks it when ADDING a list
// (server validates it lands in their area); team admins never (forced).
const isExec = !!(currentUser && currentUser.role === 'executive');
const showScope = isMasterAdmin() || (isExec && mode === 'add');
listModalLocationGroup.style.display = showScope ? 'block' : 'none';
if (listModalTeamGroup) listModalTeamGroup.style.display = showScope ? 'block' : 'none';
listModalSave.textContent = 'Save';
listModalSave.classList.remove('btn-danger');
populateScopeSelects(); // ensure the location/team dropdowns are current
// "Start from" template picker: only when adding a list.
if (listModalTemplateGroup) listModalTemplateGroup.style.display = (mode === 'add') ? 'block' : 'none';
if (mode === 'add') populateTemplateSelect();
// Re-show the edit fields (a prior Delete hides them).
if (listModalColorGroup) listModalColorGroup.style.display = '';
if (listModalHiddenGroup) listModalHiddenGroup.style.display = '';
if (listModalRepeatGroup) listModalRepeatGroup.style.display = '';
if (mode === 'rename') {
listModalInput.value = current ? current.name : '';
listModalLocation.value = (current && current.location) ? current.location : '';
if (listModalTeam) listModalTeam.value = (current && current.team) ? current.team : '';
setListModalColor(current && current.color ? current.color : '');
if (listModalHidden) listModalHidden.checked = !!(current && current.hidden);
{ const sn = document.getElementById('listModalShowNotes'); if (sn) sn.checked = !!(current && current.showNotes); }
{ const no = document.getElementById('listModalNotesOnly'); if (no) no.checked = !!(current && current.notesOnly); }
{ const sf = document.getElementById('listModalShowFeedback'); if (sf) sf.checked = !!(current && current.showFeedback); }
setListModalSections(current ? taskGroupsOf(current) : [{ id: 'todo', name: 'To Do' }]);
syncListModalNotesOnly();
setResetScheduleInModal(current && current.resetSchedule);
// A Requests inbox is one-shot by design — no repeat schedule to edit.
if (current && current.isRequests && listModalRepeatGroup) listModalRepeatGroup.style.display = 'none';
} else {
listModalInput.value = '';
// Default to the first available option (master: "Everyone"/"Whole building";
// executive: their first scoped location/team).
if (listModalLocation.tagName === 'SELECT') listModalLocation.selectedIndex = 0; else listModalLocation.value = '';
if (listModalTeam && listModalTeam.tagName === 'SELECT') listModalTeam.selectedIndex = 0; else if (listModalTeam) listModalTeam.value = '';
setListModalColor('');
if (listModalHidden) listModalHidden.checked = false;
{ const sn = document.getElementById('listModalShowNotes'); if (sn) sn.checked = false; }
{ const no = document.getElementById('listModalNotesOnly'); if (no) no.checked = false; }
{ const sf = document.getElementById('listModalShowFeedback'); if (sf) sf.checked = false; }
setListModalSections([{ id: 'todo', name: 'To Do' }]);
syncListModalNotesOnly();
setResetScheduleInModal(null);
}
listModal.style.display = 'flex';
requestAnimationFrame(() => { listModalInput.focus({ preventScroll: true }); listModalInput.select(); });
}
function closeListModal() {
modalMode = null; modalTargetId = null;
closeOverlay(listModal);
listModalInput.value = '';
listModalLocation.value = '';
}
// ── Task sections editor (in the list modal) ─────────────────────────
// Working copy of {id,name} rows. Renames keep the row's stable id — that's
// what keeps every task in its section across a rename. Row 0 is the default
// ('todo'): renamable, never removable. New rows get a fresh random id.
let listModalGroups = [];
function renderListModalSections() {
const box = document.getElementById('listModalSections');
if (!box) return;
box.innerHTML = listModalGroups.map((g, i) => `
<div class="list-section-row" data-gid="${escapeAttr(g.id)}">
<input type="text" maxlength="40" data-section-name="${i}" value="${escapeAttr(g.name)}" placeholder="Section name (e.g. Monday)" />
${i === 0
? '<span class="list-section-default" title="New tasks land here unless another section is picked">default</span>'
: `<button type="button" class="equip-photo-remove" data-section-remove="${i}" aria-label="Remove section" title="Remove — its tasks go back to the default section">✕</button>`}
</div>`).join('');
}
function setListModalSections(groups) {
listModalGroups = groups.map((g) => ({ id: g.id, name: g.name }));
renderListModalSections();
}
{
const box = document.getElementById('listModalSections');
const add = document.getElementById('listModalSectionAdd');
if (box) {
box.addEventListener('input', (e) => {
const inp = e.target.closest('input[data-section-name]');
if (inp) { const i = parseInt(inp.getAttribute('data-section-name'), 10); if (listModalGroups[i]) listModalGroups[i].name = inp.value; }
});
box.addEventListener('click', (e) => {
const rm = e.target.closest('[data-section-remove]');
if (rm) { const i = parseInt(rm.getAttribute('data-section-remove'), 10); listModalGroups.splice(i, 1); renderListModalSections(); }
});
}
if (add) add.addEventListener('click', () => {
if (listModalGroups.length >= 10) { showToast('Up to 10 sections per list', 'error'); return; }
listModalGroups.push({ id: 'g' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), name: '' });
renderListModalSections();
const inp = document.querySelector(`#listModalSections input[data-section-name="${listModalGroups.length - 1}"]`);
if (inp) inp.focus();
});
}
// Read the editor back into a clean taskGroups array (blank-named extra rows
// are dropped; a blanked default falls back to "To Do").
function readListModalSections() {
return listModalGroups
.map((g, i) => ({ id: g.id, name: (g.name || '').trim() || (i === 0 ? 'To Do' : '') }))
.filter((g) => g.name);
}
// "Notes only" implies the Notes column (force + lock the showNotes box) and makes the repeat
// schedule meaningless (resets clear checks; a notes-only board has none) — hide that editor.
// The stored schedule is left untouched in case the list is converted back.
// It also hides the Task-sections editor (a notes-only board has no task columns).
function syncListModalNotesOnly() {
const no = document.getElementById('listModalNotesOnly');
const sn = document.getElementById('listModalShowNotes');
const on = !!(no && no.checked);
if (sn) { if (on) sn.checked = true; sn.disabled = on; }
if (listModalRepeatGroup) listModalRepeatGroup.style.display = on ? 'none' : '';
{ const sg = document.getElementById('listModalSectionsGroup'); if (sg) sg.style.display = on ? 'none' : ''; }
}
{ const no = document.getElementById('listModalNotesOnly'); if (no) no.addEventListener('change', syncListModalNotesOnly); }
function handleModalSave() {
const name = listModalInput.value.trim();
const location = (listModalLocation.value || '').trim();
const team = (listModalTeam && listModalTeam.value || '').trim();
const color = listModalColorValue;
const hidden = listModalHidden ? listModalHidden.checked : false;
const showNotesEl = document.getElementById('listModalShowNotes');
const notesOnlyEl = document.getElementById('listModalNotesOnly');
const notesOnly = notesOnlyEl ? notesOnlyEl.checked : false;
const showNotes = notesOnly || (showNotesEl ? showNotesEl.checked : false); // notes-only implies the Notes column
const showFeedbackEl = document.getElementById('listModalShowFeedback');
const showFeedback = showFeedbackEl ? showFeedbackEl.checked : false;
const taskGroups = readListModalSections();
const resetSchedule = getResetScheduleFromModal();
const starterItems = templateItemsFromModal(); // copied tasks if "Start from" chosen
// Team admin: scoped endpoints. The server forces the list into the
// caller's own (location, team), so only the name is sent.
if (!isMasterAdmin()) {
if (modalMode === 'add') {
if (!name) { closeListModal(); return; }
let body = { name, color, hidden, showNotes, notesOnly, showFeedback, taskGroups, resetSchedule };
if (currentUser && currentUser.role === 'executive') {
const loc = listModalLocation.value.trim();
const tm = (listModalTeam && listModalTeam.value || '').trim();
if (!execCanEditClient({ location: loc, team: tm })) {
showToast('Pick a location or team within your area', 'error');
return; // keep the modal open
}
body = { name, location: loc, team: tm, color, hidden, showNotes, notesOnly, showFeedback, taskGroups, resetSchedule };
}
if (starterItems.length) body.items = starterItems; // server rebuilds fresh ids
saveMutation(apiCreateList(body)).then((res) => {
if (res && res.id) setSelectedList(res.id);
closeListModal();
});
} else if (modalMode === 'rename') {
if (!name) { closeListModal(); return; }
saveMutation(apiUpdateList(modalTargetId, { name, color, hidden, showNotes, notesOnly, showFeedback, taskGroups, resetSchedule })).then(() => closeListModal());
} else if (modalMode === 'delete') {
saveMutation(apiDeleteList(modalTargetId)).then(() => closeListModal());
} else {
closeListModal();
}
return;
}
// Master admin: full-state push (unchanged), now also carrying team scope.
const next = cloneState(checklistData);
if (modalMode === 'add') {
if (!name) { closeListModal(); return; }
const id = (name + (location ? '-' + location : '') + (team ? '-' + team : '')).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9\-]/g, '') || Date.now().toString();
if (next.lists.find((l) => l.id === id)) {
showToast('A list with that name already exists', 'error');
return;
}
const items = starterItems.map((it, i) => ({
id: `${Date.now()}-${i}-${Math.random().toString(16).slice(2, 6)}`,
title: it.title, description: it.description, kind: it.kind === 'note' ? 'note' : 'task', recurrence: it.kind === 'note' ? null : it.recurrence,
createdAt: new Date().toISOString()
}));
next.lists.push({ id, name, location, team, color, hidden, showNotes, notesOnly, showFeedback, feedback: [], taskGroups, columnOrder: ['todo', 'done', 'notes', 'feedback'], resetSchedule, items, lastCheckDate: new Date().toDateString() });
next.selectedListId = id;
addTargetListId = id;
} else if (modalMode === 'rename') {
if (!name) { closeListModal(); return; }
const list = next.lists.find((l) => l.id === modalTargetId);
if (list) { list.name = name; list.location = location; list.team = team; list.color = color; list.hidden = hidden; list.showNotes = showNotes; list.notesOnly = notesOnly; list.showFeedback = showFeedback; list.taskGroups = taskGroups; list.resetSchedule = resetSchedule; }
} else if (modalMode === 'delete') {
next.lists = next.lists.filter((l) => l.id !== modalTargetId);
if (next.lists.length === 0) {
const newId = `${FALLBACK_LIST.id}-${Date.now()}`;
next.lists.push({ id: newId, name: FALLBACK_LIST.name, location: '', team: '', items: [], lastCheckDate: new Date().toDateString() });
next.selectedListId = newId;
addTargetListId = newId;
} else {
if (!next.lists.find((l) => l.id === addTargetListId)) addTargetListId = next.lists[0].id;
next.selectedListId = next.lists[0].id;
}
}
pushAdminState(next).then(() => closeListModal());
}
function deleteList() {
modalMode = 'delete';
const current = getCurrentList();
if (!current) return;
modalTargetId = current.id;
listModalTitle.textContent = 'Delete List';
listModalMessage.textContent = `Delete "${current.name}"? Tasks inside will be removed. If it was the last list, a blank "New List" will be created.`;
listModalMessage.style.display = 'block';
listModalMessage.classList.add('danger');
listModalInput.style.display = 'none';
listModalLocationGroup.style.display = 'none';
if (listModalTeamGroup) listModalTeamGroup.style.display = 'none';
// Delete is a plain confirm — hide every edit field (template/color/hidden/repeat).
if (listModalTemplateGroup) listModalTemplateGroup.style.display = 'none';
if (listModalColorGroup) listModalColorGroup.style.display = 'none';
if (listModalHiddenGroup) listModalHiddenGroup.style.display = 'none';
if (listModalRepeatGroup) listModalRepeatGroup.style.display = 'none';
listModalSave.textContent = 'Delete';
listModalSave.classList.add('btn-danger');
listModal.style.display = 'flex';
listModalInput.blur();
}
// ─── Move item ───────────────────────────────────────────────
function openMoveItemModal(itemId) {
moveModalItemId = itemId;
// A team admin can only move tasks among its own lists; master sees all.
const candidates = isMasterAdmin() ? (checklistData.lists || []) : editableLists();
const currentListId = (getCurrentList() || {}).id;
moveItemSelect.innerHTML = candidates.map((list) => {
const scope = [abbrevForLocation(list.location), (list.team || '').trim()].filter(Boolean).join(' · ');
const label = scope ? `${list.name} — ${scope}` : list.name;
return `<option value="${escapeAttr(list.id)}" ${list.id === currentListId ? 'selected' : ''}>${escapeHtml(label)}</option>`;
}).join('');
moveItemModal.style.display = 'flex';
requestAnimationFrame(() => { moveItemSelect.focus({ preventScroll: true }); });
}
function closeMoveItemModal() { moveModalItemId = null; closeOverlay(moveItemModal); }
function handleMoveItemSave() {
if (!moveModalItemId) { closeMoveItemModal(); return; }
const targetListId = moveItemSelect.value;
const sourceList = (checklistData.lists || []).find((l) => l.items.some((i) => i.id === moveModalItemId));
if (!sourceList || sourceList.id === targetListId) { closeMoveItemModal(); return; }
if (isMasterAdmin()) {
const next = cloneState(checklistData);
const src = next.lists.find((l) => l.id === sourceList.id);
const dst = next.lists.find((l) => l.id === targetListId);
if (!src || !dst) { closeMoveItemModal(); return; }
const idx = src.items.findIndex((i) => i.id === moveModalItemId);
const [item] = src.items.splice(idx, 1);
dst.items.push(item);
pushAdminState(next).then(() => closeMoveItemModal());
} else {
saveMutation(apiMoveItem(sourceList.id, moveModalItemId, targetListId)).then(() => closeMoveItemModal());
}
}
// ─── Move task: inline "Move to…" dropdown anchored to the Move button ──────
// Do the actual move (master vs scoped), then refresh + toast.
function moveItemTo(itemId, targetListId) {
const sourceList = (checklistData.lists || []).find((l) => l.items.some((i) => i.id === itemId));
if (!sourceList || sourceList.id === targetListId) return;
if (isMasterAdmin()) {
const next = cloneState(checklistData);
const src = next.lists.find((l) => l.id === sourceList.id);
const dst = next.lists.find((l) => l.id === targetListId);
if (!src || !dst) return;
const idx = src.items.findIndex((i) => i.id === itemId);
const [item] = src.items.splice(idx, 1);
dst.items.push(item);
pushAdminState(next).then((ok) => { if (ok) showToast(`Moved to "${dst.name}"`, 'success'); });
} else {
saveMutation(apiMoveItem(sourceList.id, itemId, targetListId)).then((ok) => { if (ok) showToast('Task moved', 'success'); });
}
}
// Move a task to another SECTION of its own list (Monday → Tuesday) — the
// in-list counterpart of moveItemTo. Master via the full-state push; scoped
// editors via the item PUT ({group} alone — the server touches nothing else
// and snaps unknown section ids to the default).
function moveItemToGroup(itemId, groupId) {
const sourceList = (checklistData.lists || []).find((l) => l.items.some((i) => i.id === itemId));
if (!sourceList) return;
const groups = taskGroupsOf(sourceList);
const target = groups.find((g) => g.id === groupId);
if (!target) return;
justMovedItemId = itemId; // FLIP: the card flies across the section columns
if (isMasterAdmin()) {
const next = cloneState(checklistData);
const item = next.lists.find((l) => l.id === sourceList.id).items.find((i) => i.id === itemId);
if (!item || item.kind === 'note') return;
item.group = groupId;
pushAdminState(next).then((ok) => { if (ok) showToast(`Moved to "${target.name}"`, 'success'); });
} else {
saveMutation(apiUpdateItem(sourceList.id, itemId, { group: groupId })).then((ok) => { if (ok) showToast(`Moved to "${target.name}"`, 'success'); });
}
}
// Keep a fixed popover fully on-screen, anchored under (or above) the button.
function positionPopoverNear(panel, anchorEl) {
panel.style.left = panel.style.top = panel.style.maxHeight = '';
panel.style.display = 'flex';
const margin = 8, vw = window.innerWidth, vh = window.innerHeight;
panel.style.maxHeight = (vh - margin * 2) + 'px';
const br = anchorEl.getBoundingClientRect();
const pw = panel.offsetWidth, ph = Math.min(panel.scrollHeight, vh - margin * 2);
const left = Math.max(margin, Math.min(br.left, vw - pw - margin));
let top;
if (ph <= vh - br.bottom - margin) top = br.bottom + 6; // below the button
else if (ph <= br.top - margin) top = br.top - ph - 6; // flip above
else top = margin; // clamp + scroll
panel.style.left = left + 'px';
panel.style.top = top + 'px';
}
let moveMenuOpen = false;
function openMovePopover(itemId, anchorBtn) {
if (!moveMenu) { openMoveItemModal(itemId); return; } // fallback
const sourceList = (checklistData.lists || []).find((l) => l.items.some((i) => i.id === itemId));
const candidates = (isMasterAdmin() ? (checklistData.lists || []) : editableLists())
.filter((l) => !sourceList || l.id !== sourceList.id);
// This list's OTHER sections (Monday → Tuesday…) come first — a task can
// move between sections right here, not just to another list entirely.
// Notes have no section; single-section lists skip the block.
let sectionOpts = [];
if (sourceList) {
const item = sourceList.items.find((i) => i.id === itemId);
const groups = taskGroupsOf(sourceList);
if (item && item.kind !== 'note' && groups.length > 1) {
const cur = itemGroupId(item, groups);
sectionOpts = groups.filter((g) => g.id !== cur);
}
}
if (!candidates.length && !sectionOpts.length) { showToast('No other list or section to move this to', 'error'); return; }
const sectionHtml = sectionOpts.length
? '<div class="move-menu-head">Move to section…</div>' + sectionOpts.map((g) =>
`<button type="button" class="move-opt" data-move-group="${escapeAttr(g.id)}"><span class="move-dot move-dot-section" aria-hidden="true"></span><span class="move-opt-name">${escapeHtml(g.name)}</span></button>`
).join('')
: '';
const listHtml = candidates.length
? `<div class="move-menu-head">${sectionOpts.length ? 'Or another list…' : 'Move to…'}</div>` + candidates.map((l) => {
const scope = [abbrevForLocation(l.location), (l.team || '').trim()].filter(Boolean).join(' · ');
const dot = `<span class="move-dot" style="background:${escapeAttr(colorHex(l.color) || '#8a93a6')}"></span>`;
const tag = scope ? `<span class="move-scope">${escapeHtml(scope)}</span>` : '';
return `<button type="button" class="move-opt" data-move-target="${escapeAttr(l.id)}">${dot}<span class="move-opt-name">${escapeHtml(l.name)}</span>${tag}</button>`;
}).join('')
: '';
moveMenu.innerHTML = sectionHtml + listHtml;
moveMenu.dataset.itemId = itemId;
moveMenu.classList.add('open');
if (moveMenuBackdrop) moveMenuBackdrop.style.display = 'block';
positionPopoverNear(moveMenu, anchorBtn);
moveMenuOpen = true;
}
function closeMovePopover() {
if (!moveMenu) return;
moveMenuOpen = false;
if (moveMenuBackdrop) moveMenuBackdrop.style.display = 'none';
clearTimeout(moveMenu._closeTimer);
const hide = () => {
moveMenu.classList.remove('open', 'closing');
moveMenu.style.display = 'none';
moveMenu.style.left = moveMenu.style.top = moveMenu.style.maxHeight = '';
};
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || moveMenu.style.display === 'none') { hide(); return; }
moveMenu.classList.add('closing'); // keeps .open (+ scroll lock) until the fade-out ends
moveMenu._closeTimer = setTimeout(hide, 160);
}
function setupMovePopover() {
if (!moveMenu) return;
moveMenu.addEventListener('click', (e) => {
const grp = e.target.closest('button[data-move-group]');
if (grp) {
const itemId = moveMenu.dataset.itemId;
closeMovePopover();
if (itemId) moveItemToGroup(itemId, grp.dataset.moveGroup);
return;
}
const opt = e.target.closest('button[data-move-target]');
if (!opt) return;
const itemId = moveMenu.dataset.itemId;
closeMovePopover();
if (itemId) moveItemTo(itemId, opt.dataset.moveTarget);
});
if (moveMenuBackdrop) moveMenuBackdrop.addEventListener('click', closeMovePopover);
document.addEventListener('click', (e) => {
if (moveMenuOpen && !moveMenu.contains(e.target) && !(e.target.closest && e.target.closest('[data-action="move"]'))) closeMovePopover();
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && moveMenuOpen) closeMovePopover(); });
window.addEventListener('resize', () => { if (moveMenuOpen) closeMovePopover(); });
}
// ─── Import / Export ─────────────────────────────────────────
function exportChecklists() {
let data;
if (isMasterAdmin()) {
data = checklistData; // full backup
} else {
// Editors back up ONLY their own lists (never other teams' data). Include the
// checks that belong to just those lists, keyed by their existing scope keys.
const lists = visibleLists();
const ids = new Set(lists.map((l) => l.id));
const checks = {};
const all = checklistData.checks || {};
Object.keys(all).forEach((scope) => {
const forScope = {};
Object.keys(all[scope] || {}).forEach((listId) => { if (ids.has(listId)) forScope[listId] = all[scope][listId]; });
if (Object.keys(forScope).length) checks[scope] = forScope;
});
data = {
exported: { by: (currentUser && (currentUser.displayName || currentUser.username)) || 'editor', at: new Date().toISOString(), scope: 'my-lists' },
lists,
checks
};
}
const count = Array.isArray(data.lists) ? data.lists.length : 0;
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `checklists-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showToast(isMasterAdmin() ? 'Downloaded full backup' : `Downloaded ${count} of your list${count === 1 ? '' : 's'}`, 'success');
}
async function importChecklists(e) {
const file = e.target.files && e.target.files[0];
e.target.value = '';
if (!file) return;
try {
const text = await file.text();
const parsed = JSON.parse(text);
const lists = (parsed && Array.isArray(parsed.lists)) ? parsed.lists : null;
if (!lists) { showToast('Not a valid checklist export', 'error'); return; }
if (!confirm(`Import ${lists.length} list(s)?\n\nExisting lists are refreshed, new ones added; lists outside your access are skipped.`)) return;
// Server enforces scope: master restores any list; an executive only their own.
const res = await apiPost('/api/import-lists', { lists });
await refreshState({ showToastOnError: false });
const msg = `Imported ${res.imported} list${res.imported === 1 ? '' : 's'}`
+ (res.skipped ? ` · skipped ${res.skipped} outside your access` : '');
showToast(msg, res.imported ? 'success' : 'error');
} catch (err) {
showToast('Import failed: ' + err.message, 'error');
}
}
async function sendDigestNow() {
if (!confirm('Email the report now to your area\'s recipients?')) return;
try {
const res = await apiPost('/api/send-report', {});
showToast(`Report sent — ${res.sentCount} email${res.sentCount === 1 ? '' : 's'}`, 'success');
} catch (err) {
showToast('Failed to send: ' + err.message, 'error');
}
}
async function duplicateCurrentList() {
const list = getCurrentList();
if (!list) { showToast('No list selected', 'error'); return; }
const before = (isMasterAdmin() && !applyingHistory) ? snapshotStructure(checklistData) : null;
try {
const res = await apiPost('/api/lists/' + encodeURIComponent(list.id) + '/duplicate', {});
await refreshState({ showToastOnError: false });
if (res && res.id) setSelectedList(res.id);
if (before) recordHistory({ type: 'structure', before: before, after: snapshotStructure(checklistData) });
showToast(`Duplicated "${list.name}"`, 'success');
} catch (err) {
showToast('Duplicate failed: ' + err.message, 'error');
}
}
// ─── Drag & drop (admin only) ────────────────────────────────
let dragListId = null;
let dragItemId = null;
let dragItemDone = false;
// List tabs render with draggable + ondragstart/ondragend only; the
// dragover/drop logic lives on the #listTabs strip (wired once in
// setupListTabDnd). Same win as the task cards: a vertical drop-line shows
// exactly where the list will land — including past the last tab, so a list
// can finally be dropped at the very end of the strip (the old
// drop-onto-a-tab behavior could only insert before an existing tab).
// Reposition the owned lists named in `orderedIds` into the array slots they
// already occupy, in the given order — leaving every other list untouched. Mirrors
// the server's /api/lists/reorder so master's optimistic client update matches.
function applyPermuteWithinSlots(lists, orderedIds) {
const idSet = new Set(orderedIds);
const slots = [];
for (let i = 0; i < lists.length; i++) if (idSet.has(lists[i].id)) slots.push(i);
const byId = new Map(lists.map((l) => [l.id, l]));
const ordered = orderedIds.map((id) => byId.get(id)).filter(Boolean);
for (let k = 0; k < slots.length && k < ordered.length; k++) lists[slots[k]] = ordered[k];
}
// Commit a list reorder from either view. `orderedIds` is the desired order of a
// set of list ids (the whole visible strip, or one tree team-group). Only ids the
// caller OWNS are moved. Master uses the undoable full-state push; other editors
// use the scoped reorder endpoint (order-only, so it records no undo diff).
function commitListReorder(orderedIds) {
const ownedIds = (orderedIds || []).filter((id) => {
const l = (checklistData.lists || []).find((x) => x.id === id);
return l && ownsListClient(l);
});
if (ownedIds.length < 2) return;
// No-op guard: bail if the owned lists are already in this relative order.
const cur = (checklistData.lists || []).map((l) => l.id).filter((id) => ownedIds.includes(id));
if (cur.join('') === ownedIds.join('')) return;
if (isMasterAdmin()) {
const next = cloneState(checklistData);
applyPermuteWithinSlots(next.lists, ownedIds);
pushAdminState(next);
} else {
applyPermuteWithinSlots(checklistData.lists, ownedIds); // optimistic
renderLists();
saveMutation(apiReorderLists(ownedIds), null); // order-only → no undo entry
}
}
function onListDragStart(e, id) {
const l = (checklistData.lists || []).find((x) => x.id === id);
if (!ownsListClient(l)) return; // only lists you can edit are draggable
dragListId = id;
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', id); } catch (_) {}
const el = listTabs.querySelector(`.list-tab[data-list-id="${cssEscape(id)}"]`);
if (el) setTimeout(() => { if (dragListId === id) el.classList.add('dragging'); }, 0);
}
function onListDragEnd() {
dragListId = null;
clearTabDropIndicator();
clearTabDragging();
}
function clearTabDragging() {
listTabs.querySelectorAll('.list-tab.dragging').forEach((el) => el.classList.remove('dragging'));
}
// The tab the dragged list should land *before* in the wrapping strip.
// Prefer tabs on the cursor's row (Y within their span); within that row pick
// the nearest tab and go before/after by the cursor's side of its centre.
// null ⇒ past the last tab (end of strip).
function getTabBeforeElement(container, x, y) {
const tabs = [...container.querySelectorAll('.list-tab:not(.dragging)')];
if (!tabs.length) return null;
let pool = tabs.filter((t) => { const b = t.getBoundingClientRect(); return y >= b.top && y <= b.bottom; });
if (!pool.length) {
// Cursor is in a gap between rows (or above/below) — fall back to the nearest row by Y.
let nb = null, nd = Infinity;
for (const t of tabs) { const b = t.getBoundingClientRect(); const d = Math.abs(y - (b.top + b.height / 2)); if (d < nd) { nd = d; nb = b; } }
if (nb) pool = tabs.filter((t) => { const b = t.getBoundingClientRect(); return b.top < nb.bottom && b.bottom > nb.top; });
}
if (!pool.length) return null;
let best = null, bd = Infinity, before = true;
for (const t of pool) { const b = t.getBoundingClientRect(); const cx = b.left + b.width / 2; const d = Math.abs(x - cx); if (d < bd) { bd = d; best = t; before = x < cx; } }
if (!best) return null;
if (before) return best;
let n = best.nextElementSibling; // after `best` ⇒ before the next tab in DOM order
while (n && !(n.classList && n.classList.contains('list-tab'))) n = n.nextElementSibling;
return n || null;
}
let tabDropIndicator = null;
function getTabDropIndicator() {
if (!tabDropIndicator) {
tabDropIndicator = document.createElement('div');
tabDropIndicator.className = 'tab-drop-indicator';
tabDropIndicator.setAttribute('aria-hidden', 'true');
}
return tabDropIndicator;
}
function clearTabDropIndicator() {
if (tabDropIndicator && tabDropIndicator.parentNode) tabDropIndicator.parentNode.removeChild(tabDropIndicator);
}
function onListStripDragOver(e) {
if (!dragListId) return; // only while reordering a list you own (dragListId is set only then)
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const before = getTabBeforeElement(listTabs, e.clientX, e.clientY);
const ind = getTabDropIndicator();
if (before) { if (before.previousElementSibling !== ind) listTabs.insertBefore(ind, before); }
else if (listTabs.lastElementChild !== ind) listTabs.appendChild(ind);
}
function onListStripDragLeave(e) {
if (!e.relatedTarget || !listTabs.contains(e.relatedTarget)) clearTabDropIndicator();
}
function onListStripDrop(e) {
e.preventDefault();
const ind = getTabDropIndicator();
let beforeId = null;
if (ind.parentNode === listTabs) {
let sib = ind.nextElementSibling;
while (sib && !(sib.classList && sib.classList.contains('list-tab'))) sib = sib.nextElementSibling;
if (sib) beforeId = sib.getAttribute('data-list-id');
}
const movedId = dragListId;
dragListId = null;
clearTabDropIndicator();
clearTabDragging();
if (!movedId) return;
// Build the new order of the VISIBLE strip with movedId dropped before
// beforeId (or appended at the end). commitListReorder then repositions only
// the lists the caller owns, within their own slots — so nothing else moves.
const visIds = visibleLists().map((l) => l.id).filter((id) => id !== movedId);
let at = beforeId ? visIds.indexOf(beforeId) : -1;
if (at < 0) at = visIds.length;
visIds.splice(at, 0, movedId);
commitListReorder(visIds);
}
function setupListTabDnd() {
if (!listTabs) return;
listTabs.addEventListener('dragover', onListStripDragOver);
listTabs.addEventListener('drop', onListStripDrop);
listTabs.addEventListener('dragleave', onListStripDragLeave);
}
setupListTabDnd();
// ── Tree-view list reorder (drag a leaf within its team group) ──
// Mirrors the tab-strip DnD but vertical and scoped to one team group, so a leaf
// can only be reordered among its siblings (dragging never re-homes a list to a
// different location/team). commitListReorder handles ownership + persistence.
let dragTreeId = null, dragTreeGroup = null, treeDropIndicator = null;
function onTreeLeafDragStart(e, id) {
const l = (checklistData.lists || []).find((x) => x.id === id);
if (!ownsListClient(l)) { try { e.preventDefault(); } catch (_) {} return; }
dragTreeId = id;
dragTreeGroup = e.currentTarget ? e.currentTarget.getAttribute('data-tree-group') : null;
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', id); } catch (_) {}
const el = e.currentTarget;
if (el) setTimeout(() => { if (dragTreeId === id) el.classList.add('dragging'); }, 0);
}
function onTreeLeafDragEnd() {
dragTreeId = null; dragTreeGroup = null;
clearTreeDropIndicator();
if (listTree) listTree.querySelectorAll('.tree-leaf.dragging').forEach((el) => el.classList.remove('dragging'));
}
function getTreeDropIndicator() {
if (!treeDropIndicator) {
treeDropIndicator = document.createElement('div');
treeDropIndicator.className = 'tree-drop-indicator';
treeDropIndicator.setAttribute('aria-hidden', 'true');
}
return treeDropIndicator;
}
function clearTreeDropIndicator() {
if (treeDropIndicator && treeDropIndicator.parentNode) treeDropIndicator.parentNode.removeChild(treeDropIndicator);
}
function treeGroupLeaves() {
if (!listTree || !dragTreeGroup) return [];
return [...listTree.querySelectorAll(`.tree-leaf[data-tree-group="${cssEscape(dragTreeGroup)}"]:not(.dragging)`)];
}
function getTreeBeforeElement(y) {
for (const el of treeGroupLeaves()) {
const b = el.getBoundingClientRect();
if (y < b.top + b.height / 2) return el; // land before the first leaf whose midpoint is below the cursor
}
return null; // past the last leaf of the group
}
function onTreeDragOver(e) {
if (!dragTreeId || !dragTreeGroup) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const before = getTreeBeforeElement(e.clientY);
const ind = getTreeDropIndicator();
if (before) { if (before.previousElementSibling !== ind) before.parentNode.insertBefore(ind, before); }
else {
const leaves = treeGroupLeaves();
const last = leaves[leaves.length - 1];
if (last && last.nextElementSibling !== ind) last.parentNode.insertBefore(ind, last.nextElementSibling);
}
}
function onTreeDragLeave(e) {
if (!e.relatedTarget || !listTree.contains(e.relatedTarget)) clearTreeDropIndicator();
}
function onTreeDrop(e) {
if (!dragTreeId || !dragTreeGroup) return;
e.preventDefault();
const movedId = dragTreeId, group = dragTreeGroup;
const ind = getTreeDropIndicator();
let beforeId = null;
if (ind.parentNode) {
let sib = ind.nextElementSibling;
while (sib && !(sib.classList && sib.classList.contains('tree-leaf'))) sib = sib.nextElementSibling;
if (sib) beforeId = sib.getAttribute('data-tree-list');
}
dragTreeId = null; dragTreeGroup = null;
clearTreeDropIndicator();
if (listTree) listTree.querySelectorAll('.tree-leaf.dragging').forEach((el) => el.classList.remove('dragging'));
// New order of this group's leaves, with movedId dropped before beforeId (or at the end).
const groupIds = [...listTree.querySelectorAll(`.tree-leaf[data-tree-group="${cssEscape(group)}"]`)]
.map((el) => el.getAttribute('data-tree-list'));
const order = groupIds.filter((id) => id !== movedId);
let at = beforeId ? order.indexOf(beforeId) : -1;
if (at < 0) at = order.length;
order.splice(at, 0, movedId);
commitListReorder(order);
}
function setupListTreeDnd() {
if (!listTree) return;
listTree.addEventListener('dragover', onTreeDragOver);
listTree.addEventListener('drop', onTreeDrop);
listTree.addEventListener('dragleave', onTreeDragLeave);
}
setupListTreeDnd();
// Task cards render with draggable + ondragstart/ondragend only; the
// dragover/drop logic lives on the To Do / Done *columns* (wired once in
// setupItemDnd) so you can drop into the empty space below the last card —
// i.e. land a task at the very bottom of the list, which the old
// drop-onto-a-card behavior could never do. A blue drop-line shows exactly
// where the card will land instead of highlighting (swapping) a card.
let dropIndicator = null;
function getDropIndicator() {
if (!dropIndicator) {
dropIndicator = document.createElement('div');
dropIndicator.className = 'drop-indicator';
dropIndicator.setAttribute('aria-hidden', 'true');
}
return dropIndicator;
}
function clearDropIndicator() {
if (dropIndicator && dropIndicator.parentNode) dropIndicator.parentNode.removeChild(dropIndicator);
}
function clearDragging() {
document.querySelectorAll('.checklist-item.dragging').forEach((el) => el.classList.remove('dragging'));
}
// Drag-reorder of TASKS is allowed for any editor who owns the current list (master, team
// admin, or in-scope executive). Master persists via pushAdminState; the rest via the scoped
// reorder endpoint. (Reordering the list TABS themselves stays master-only — separate concern.)
function canReorderCurrentList() { return isEditor() && ownsListClient(getCurrentList()); }
function onItemDragStart(e, id, isDone) {
if (!canReorderCurrentList()) return;
dragItemId = id;
dragItemDone = !!isDone;
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', id); } catch (_) {}
// Defer the dim so the drag ghost is snapshotted at full opacity.
const el = findItemEl(id);
if (el) setTimeout(() => { if (dragItemId === id) el.classList.add('dragging'); }, 0);
}
function onItemDragEnd() {
dragItemId = null;
clearDropIndicator();
clearDragging();
}
// The card the dragged item should land *before*: the first card whose
// vertical midpoint is below the cursor. null ⇒ past the last card (end).
function getDragBeforeElement(container, y) {
const cards = container.querySelectorAll('.checklist-item:not(.dragging)');
for (const card of cards) {
const box = card.getBoundingClientRect();
if (y < box.top + box.height / 2) return card;
}
return null;
}
function onColumnDragOver(e, columnIsDone) {
if (!canReorderCurrentList() || !dragItemId) return;
if (dragItemDone !== !!columnIsDone) return; // same column only (use the checkbox to move To Do ↔ Done)
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const container = columnIsDone ? doneList : todoList;
const before = getDragBeforeElement(container, e.clientY);
const ind = getDropIndicator();
if (before) { if (before.previousElementSibling !== ind) container.insertBefore(ind, before); }
else if (container.lastElementChild !== ind) container.appendChild(ind);
}
function onColumnDragLeave(e, columnIsDone) {
const container = columnIsDone ? doneList : todoList;
// Only clear when the pointer truly leaves the column — not when crossing
// between cards inside it (relatedTarget = where the pointer is heading).
if (!e.relatedTarget || !container.contains(e.relatedTarget)) clearDropIndicator();
}
function onColumnDrop(e, columnIsDone) {
e.preventDefault();
const container = columnIsDone ? doneList : todoList;
const ind = getDropIndicator();
// Resolve the drop slot from the indicator's position BEFORE clearing it.
let beforeId = null;
if (ind.parentNode === container) {
let sib = ind.nextElementSibling;
while (sib && !sib.classList.contains('checklist-item')) sib = sib.nextElementSibling;
if (sib) beforeId = sib.getAttribute('data-item-id');
}
const visible = [...container.querySelectorAll('.checklist-item:not(.dragging)')];
const lastVisibleId = visible.length ? visible[visible.length - 1].getAttribute('data-item-id') : null;
const movedId = dragItemId;
dragItemId = null;
clearDropIndicator();
clearDragging();
if (!canReorderCurrentList() || !movedId || dragItemDone !== !!columnIsDone) return;
const list = getCurrentList();
if (!list) return;
const next = cloneState(checklistData);
const nextList = next.lists.find((l) => l.id === list.id);
if (!nextList) return;
const items = nextList.items;
const fromIdx = items.findIndex((i) => i.id === movedId);
if (fromIdx < 0) return;
const [moved] = items.splice(fromIdx, 1);
let insertAt;
if (beforeId && beforeId !== movedId) {
insertAt = items.findIndex((i) => i.id === beforeId); // land just above that card
} else if (lastVisibleId && lastVisibleId !== movedId) {
insertAt = items.findIndex((i) => i.id === lastVisibleId) + 1; // past the last card → bottom of the list
} else {
insertAt = items.length;
}
if (insertAt < 0) insertAt = items.length;
items.splice(insertAt, 0, moved);
justMovedItemId = movedId; // flash it into its new spot
if (isMasterAdmin()) {
pushAdminState(next); // master: full-state save (keeps undo + works on global lists)
} else {
// Executive / team admin: apply the new order optimistically, then persist it via the
// scoped reorder endpoint. Snapshot owned lists BEFORE the optimistic mutation so
// saveMutation can diff old→new order and record the reorder for their undo history.
const reorderBefore = (!applyingHistory && isEditor()) ? ownedListMap() : null;
const liveList = (checklistData.lists || []).find((l) => l.id === list.id);
if (liveList) liveList.items = items;
renderLists();
saveMutation(apiReorderItems(list.id, items.map((i) => i.id)), reorderBefore);
}
}
function setupItemDnd() {
if (!todoList || !doneList) return;
todoList.addEventListener('dragover', (e) => onColumnDragOver(e, false));
todoList.addEventListener('drop', (e) => onColumnDrop(e, false));
todoList.addEventListener('dragleave', (e) => onColumnDragLeave(e, false));
doneList.addEventListener('dragover', (e) => onColumnDragOver(e, true));
doneList.addEventListener('drop', (e) => onColumnDrop(e, true));
doneList.addEventListener('dragleave', (e) => onColumnDragLeave(e, true));
}
setupItemDnd();
function findItemEl(id) {
return document.querySelector(`.checklist-item[data-item-id="${cssEscape(id)}"]`);
}
function cssEscape(s) {
if (window.CSS && CSS.escape) return CSS.escape(s);
return String(s).replace(/"/g, '\\"');
}
window.onListDragStart = onListDragStart;
window.onListDragEnd = onListDragEnd;
window.onItemDragStart = onItemDragStart;
window.onItemDragEnd = onItemDragEnd;
window.onTreeLeafDragStart = onTreeLeafDragStart;
window.onTreeLeafDragEnd = onTreeLeafDragEnd;
// ─── Helpers ─────────────────────────────────────────────────
function cloneState(s) { return JSON.parse(JSON.stringify(s)); }
function escapeHtml(text) {
return String(text == null ? '' : text)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeAttr(text) {
return String(text == null ? '' : text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function showToast(message, kind) {
toast.textContent = message;
toast.className = 'toast show' + (kind ? ' ' + kind : '');
clearTimeout(showToast._t);
showToast._t = setTimeout(() => { toast.className = 'toast'; }, 3000);
}
function hideLoading() {
const overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.style.display = 'none';
}
// ─── Service worker (PWA install support) ────────────────────
// Web only. The native shells serve this bundle from the app itself, so a worker would
// add a second invisible cache layer (and iOS's capacitor:// scheme can't host one).
if ('serviceWorker' in navigator && !(window.CHECKLIST_NATIVE && window.CHECKLIST_NATIVE.active)) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => { /* non-fatal */ });
});
}
// ─── First-launch setup (native apps only) ───────────────────
// The store build is installed by anyone, not just Waters, so it cannot assume which
// server it talks to. This runs BEFORE sign-in and blocks boot until a server is chosen.
// On the web it never appears — there the server is simply the origin the page came from.
function openServerSetup() {
const overlay = document.getElementById('setupOverlay');
if (!overlay) return;
const choices = document.getElementById('setupChoices');
const join = document.getElementById('setupJoin');
const hostInfo = document.getElementById('setupHostInfo');
const addr = document.getElementById('setupAddress');
const err = document.getElementById('setupError');
const connect = document.getElementById('setupConnect');
const showErr = (m) => { err.textContent = m; err.style.display = m ? 'block' : 'none'; };
const panel = (which) => {
choices.style.display = which === 'choices' ? '' : 'none';
join.style.display = which === 'join' ? '' : 'none';
hostInfo.style.display = which === 'host' ? '' : 'none';
showErr('');
};
overlay.querySelectorAll('[data-setup]').forEach((btn) => {
btn.addEventListener('click', () => {
if (btn.dataset.setup === 'local') {
// No address to validate and nothing to reach — the server is this device.
window.CHECKLIST_NATIVE.setMode('local');
location.reload();
return;
}
if (btn.dataset.setup === 'host') { panel('host'); return; }
// Pre-fill the bundled suggestion so Waters volunteers just tap Connect.
const suggested = (window.CHECKLIST_NATIVE && window.CHECKLIST_NATIVE.suggestedServer) || '';
if (suggested && !addr.value) addr.value = suggested.replace(/^https?:\/\//, '');
panel('join');
setTimeout(() => addr.focus(), 50);
});
});
document.getElementById('setupBack').addEventListener('click', () => panel('choices'));
document.getElementById('setupHostBack').addEventListener('click', () => panel('choices'));
document.getElementById('setupHostNext').addEventListener('click', () => { panel('join'); setTimeout(() => addr.focus(), 50); });
addr.addEventListener('keydown', (e) => { if (e.key === 'Enter') connect.click(); });
connect.addEventListener('click', async () => {
const url = normalizeServerUrl(addr.value);
if (!url) { showErr('Enter your server address, for example checklist.yourchurch.org'); return; }
connect.disabled = true; connect.textContent = 'Checking…'; showErr('');
try {
await probeServer(url);
window.CHECKLIST_NATIVE.setMode('server');
window.CHECKLIST_NATIVE.setServer(url);
// Full reload rather than patching state in place: native-bridge reads the
// server once at parse time to set up its fetch patch, so restarting is the
// only way to get a consistent world.
location.reload();
} catch (e) {
showErr(e.message);
connect.disabled = false; connect.textContent = 'Connect';
}
});
panel('choices');
overlay.style.display = 'flex';
}
// "Change server" on the sign-in sheet — native only; on the web the server is the origin
// and there is nothing to change. Clearing the token too, because a session from the old
// server is meaningless on the new one and would otherwise look like a mysterious 401.
(function wireChangeServer() {
const N = window.CHECKLIST_NATIVE;
if (!N || !N.active) return;
const wrap = document.getElementById('changeServerWrap');
const link = document.getElementById('changeServerLink');
if (!wrap || !link) return;
wrap.style.display = '';
if (N.mode === 'local') link.textContent = 'Switch mode';
link.addEventListener('click', (e) => { e.preventDefault(); switchAppMode(); });
}());
// Drop back to the setup screen. Nothing stored is deleted — local checklists stay in
// IndexedDB and are still there if the user picks on-device mode again.
function switchAppMode() {
const N = window.CHECKLIST_NATIVE;
if (!N || !N.active) return;
const where = (N.apiBase || '').replace(/^https?:\/\//, '');
const msg = N.mode === 'local'
? 'Leave on-device mode?\n\nYour checklists stay on this device and will still be here if you come back.'
: 'Disconnect from ' + (where || 'this server') + ' and choose again?\n\nYou will need to sign in again.';
if (!confirm(msg)) return;
N.clearServer();
try { localStorage.removeItem('checklist:token'); } catch (_) {}
location.reload();
}
// Accept what people actually type: "checklist.church.org", with or without a scheme,
// with or without a trailing slash or a stray path.
function normalizeServerUrl(raw) {
let v = String(raw || '').trim();
if (!v) return '';
if (!/^https?:\/\//i.test(v)) v = 'https://' + v;
try {
const u = new URL(v);
if (!u.hostname || u.hostname.indexOf('.') === -1) return '';
return u.origin; // drops any path, query or fragment
} catch (_) { return ''; }
}
// Confirm the address is really a checklist server before committing to it — otherwise
// a typo is stored and every screen afterwards fails for a reason nobody can see.
// Signed out, /api/me answers 401 with {"error": ...}; that pair is the fingerprint.
async function probeServer(base) {
let res;
try {
res = await fetch(base + '/api/me', { method: 'GET', headers: { 'Accept': 'application/json' } });
} catch (_) {
throw new Error('Could not reach ' + base.replace(/^https?:\/\//, '') + '. Check the address and that you are online.');
}
if (res.status === 200 || res.status === 401) {
let body = null;
try { body = await res.json(); } catch (_) {}
if (body && (body.error || body.user)) return true;
}
throw new Error('That address answered, but it is not a Checklist server.');
}
// ─── Boot ────────────────────────────────────────────────────
async function boot() {
// No server chosen yet (native first launch). Everything below would fail, so stop
// here: no API calls, no sign-in sheet, no polling until setup completes.
if (window.CHECKLIST_NATIVE && window.CHECKLIST_NATIVE.needsSetup) {
hideLoading();
openServerSetup();
return;
}
// Local mode has exactly one user and no network surface, so a sign-in sheet would be
// pure friction. Authenticate automatically with the per-install password the local
// runtime generated — the real login path still runs, nothing is bypassed.
if (window.CHECKLIST_NATIVE && window.CHECKLIST_NATIVE.mode === 'local') {
try {
// ALWAYS re-authenticate, never reuse a stored token. server.js keeps sessions
// in memory, and in local mode that memory is the app process — so every
// relaunch starts with an empty session table and yesterday's token is
// meaningless. Trusting it dropped the user on a sign-in sheet they have no
// password for.
const pw = await window.LocalServer.adminPassword();
const data = await apiPost('/api/login', { location: 'executive', password: pw });
sessionToken = data.token;
localStorage.setItem('checklist:token', sessionToken);
// Photos from earlier sessions need blob URLs before anything renders.
if (window.CHECKLIST_NATIVE.indexLocalMedia) await window.CHECKLIST_NATIVE.indexLocalMedia();
} catch (e) {
console.error('local sign-in failed', e);
}
}
if (sessionToken) {
try {
const me = await apiGet('/api/me');
applyUser(me.user);
await refreshState();
maybePromptChecker(); // returning checker with no name yet → pop the picker
} catch (_) {
// Token bad/expired; fall through to login prompt.
sessionToken = '';
localStorage.removeItem('checklist:token');
applyUser(null);
}
} else {
applyUser(null);
}
hideLoading();
if (!sessionToken) openLogin();
// Poll for state updates from other users
pollTimer = setInterval(() => {
if (!sessionToken || isSaving || isFlushing) return;
if (editTaskModal.style.display === 'flex') return;
if (listModal.style.display === 'flex') return;
if (loginModal.style.display === 'flex') return;
if (userMgmtModal.style.display === 'flex') return;
if (addTaskModal && addTaskModal.style.display === 'flex') return;
if (equipMgmtModal && equipMgmtModal.style.display === 'flex') return;
if (equipModal && equipModal.style.display === 'flex') return;
if (loadPending().length) { flushPendingChecks(); return; } // catch up before re-polling
refreshState({ showToastOnError: false }).catch(() => {});
}, 6000);
// The native iOS/Android shells fire this when the app returns to the foreground. A
// backgrounded WebView has its timers frozen, so the poll above is asleep while the
// phone is pocketed — without an immediate refresh, reopening the app shows up to 6s
// of stale check-offs, long enough for two volunteers to duplicate work. Nothing
// dispatches this on the web, where the tab's poll never stops.
window.addEventListener('checklist:resume', () => {
if (!sessionToken || isSaving || isFlushing) return;
if (loadPending().length) { flushPendingChecks(); return; }
refreshState({ showToastOnError: false }).catch(() => {});
});
// Sync queued check-offs the moment the network returns.
window.addEventListener('online', () => { updateOfflineUI(); flushPendingChecks(); });
window.addEventListener('offline', updateOfflineUI);
updateOfflineUI();
if (sessionToken) flushPendingChecks(); // resume anything left from a prior offline session
}
boot();
})();