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>
7656 lines
452 KiB
JavaScript
7656 lines
452 KiB
JavaScript
// 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 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(' |