Files
recurring-checklist/server/public/app.js
T
Waters ChurchandClaude Opus 5 469db5bc60 Bring the checklist app under version control
The project has lived only as a folder on the Mac Studio and, briefly, as a
transfer tarball. This is the first commit: the Express server and its web
front end, the Capacitor iOS and Android wrappers, and the release and backup
scripts.

The repo is the source, not a backup of the machine. Left out on purpose, by
the .gitignore files that were already in the tree: node_modules, build output,
mobile/www (regenerated from server/public on every build), and server/data,
which holds the live checklists and belongs on the NAS rather than in git.

Every credential is excluded too — server/.env, the Play upload keystore and
its properties file, the App Store Connect key and ios-signing.config. The one
secret that was not in an ignored file was the NAS SSH password, hard-coded in
build-deploy.sh and grepped back out of it by backup-to-drive.sh. Both now read
it from the login keychain (service checklist-nas, account Christian), falling
back to NAS_PASS in the environment, so rotating it is one keychain entry
rather than a history rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:00:57 -04:00

7656 lines
452 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
})();