👁1

🌙PMSetup GuestUser ViewerRole
Filter:
100%
Find door / trailer / destination
0
Wing 2

Legend

Blue = Internal Trip
Purple = External
Dark Green = Preload
Light Red = Amazon
Dark Orange = Rejections
Light OY = LTL
Brown = Old Tyme Pallet
Removal zone hidden.
Wing 1

Legend

Blue = Internal Trip
Purple = External
Dark Green = Preload
Light Red = Amazon
Dark Orange = Rejections
Light OY = LTL
Brown = Old Tyme Pallet
Removal zone hidden.
Yard
`); win.document.close(); }catch(err){} } e.preventDefault(); }); }); document.addEventListener('keydown', (e)=>{ if (e.key === 'Escape' && host.classList.contains('open')) closeDrawer(); }); return host; } if (reportsBtn && !reportsBtn.__bound){ reportsBtn.__bound = true; reportsBtn.addEventListener('click', ()=>{ const drawer = ensureReportsDrawer(); if (drawer.classList.contains('open')) drawer.closeDrawer(); else drawer.openDrawer(); }); } const blackBookBtn = document.getElementById('openBlackBookBtn'); function ensureBlackBookDrawer(){ let host = document.getElementById('blackBookDrawerHost'); if (host) return host; if (!document.getElementById('blackBookDrawerStyles')){ const style = document.createElement('style'); style.id = 'blackBookDrawerStyles'; style.textContent = ` #blackBookDrawerHost{position:fixed;inset:0;z-index:2147483000;pointer-events:none} #blackBookDrawerHost .bb-backdrop{position:absolute;inset:0;background:rgba(0,0,0,0);opacity:0;transition:opacity .28s ease,background .28s ease} #blackBookDrawerHost .bb-panel{position:absolute;top:0;right:0;height:100%;width:min(480px,calc(100vw - 10px));background:#050505;border-left:1px solid rgba(255,255,255,.10);box-shadow:-28px 0 70px rgba(0,0,0,.46);transform:translateX(104%);transition:transform .34s cubic-bezier(.22,.84,.24,1),box-shadow .34s ease;will-change:transform;overflow:hidden} #blackBookDrawerHost .bb-panel-inner{height:100%;width:100%;background:#050505} #blackBookDrawerHost .bb-frame{display:block;width:100%;height:100%;border:0;background:#050505} #blackBookDrawerHost.open{pointer-events:auto} #blackBookDrawerHost.open .bb-backdrop{background:rgba(0,0,0,.42);opacity:1} #blackBookDrawerHost.open .bb-panel{transform:translateX(0)} body.blackbook-drawer-open{overflow:hidden} @media (max-width: 560px){ #blackBookDrawerHost .bb-panel{width:min(100vw,480px)} } `; document.head.appendChild(style); } host = document.createElement('div'); host.id = 'blackBookDrawerHost'; host.innerHTML = ` `; document.body.appendChild(host); const backdrop = host.querySelector('.bb-backdrop'); const frame = host.querySelector('#blackBookDrawerFrame'); let closeTimer = null; function openDrawer(){ if (!frame.getAttribute('src')) frame.setAttribute('src', './blackbook.php?panel=1'); host.classList.remove('closing'); window.clearTimeout(closeTimer); requestAnimationFrame(()=>{ host.classList.add('open'); document.body.classList.add('blackbook-drawer-open'); }); window.setTimeout(()=>{ try{ frame.contentWindow.postMessage({type:'nho-blackbook-focus-search'}, '*'); }catch(e){} }, 360); } function closeDrawer(){ host.classList.remove('open'); document.body.classList.remove('blackbook-drawer-open'); window.clearTimeout(closeTimer); closeTimer = window.setTimeout(()=> host.classList.remove('closing'), 360); } host.openDrawer = openDrawer; host.closeDrawer = closeDrawer; backdrop.addEventListener('click', (e)=>{ if (e.target === backdrop) closeDrawer(); }); window.addEventListener('message', (e)=>{ const type = e?.data?.type; if (type === 'nho-blackbook-close') closeDrawer(); if (type === 'nho-blackbook-open') openDrawer(); }); document.addEventListener('keydown', (e)=>{ if (e.key === 'Escape' && host.classList.contains('open')) closeDrawer(); }); return host; } if (blackBookBtn && !blackBookBtn.__bound){ blackBookBtn.__bound = true; blackBookBtn.addEventListener('click', ()=>{ const drawer = ensureBlackBookDrawer(); if (drawer.classList.contains('open')) { drawer.closeDrawer(); } else { drawer.openDrawer(); } }); } const departuresBtn = document.getElementById('openDeparturesBtn'); const controlBtn = document.getElementById('openControlBtn'); const wingBtn = document.getElementById('openWingBtn'); const openControlLinks = ()=>{ const departuresLink = departuresLinkCache || DEFAULT_DEPARTURES_LINK; [ 'https://m365.cloud.microsoft/search/?home=1&from=', 'https://rldynamics.com/CP/', 'https://rldynamics.com/montreal/', 'https://purolatorcontroltower.my.salesforce-sites.com/Main/ThinkLPFEB__tLP_FormEntryPortal_main?lang=1&filter=Network%20Transportation%20Control%20Centre%20and%20Planning&accessCode=60230451', 'https://purolator.ymshub.com/login', 'https://purolatorportal.cpg-gpc.ca/Fiori?sap-client=100&sap-language=EN&sap-sec_session_created=X#Shell-home', departuresLink, 'https://purolator-my.sharepoint.com/:x:/r/personal/dacosta_agyemang_purolator_com/_layouts/15/doc2.aspx?sourcedoc=%7B5590BA69-64D2-4137-84B9-F2AD448A1668%7D&file=AM%20SUMMARY%20REPORTING.xlsx&action=default&mobileredirect=true&DefaultItemOpen=1&ct=1740997024468&wdOrigin=OFFICECOM-WEB.MAIN.EDGEWORTH&cid=0e36fac7-8d18-47c9-a724-85026baf77df&wdPreviousSessionSrc=HarmonyWeb&wdPreviousSession=7bb3daf2-6ba9-40b4-bb00-6a663e3ae1bf' ].forEach((url)=>{ try{ window.open(url, '_blank', 'noopener,noreferrer'); }catch(e){} }); }; if (controlBtn && !controlBtn.__bound){ controlBtn.__bound = true; controlBtn.addEventListener('click', async (e)=>{ e.preventDefault(); e.stopPropagation(); await getDeparturesLink(); openControlLinks(); }); } if (wingBtn && !wingBtn.__bound){ wingBtn.__bound = true; wingBtn.addEventListener('click', async (e)=>{ e.preventDefault(); e.stopPropagation(); await getDeparturesLink(); openControlLinks(); }); } const oktaBtn = document.getElementById('openOktaBtn'); if (oktaBtn && !oktaBtn.__bound){ oktaBtn.__bound = true; oktaBtn.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); window.open('https://purolator.okta.com/oauth2/v1/authorize?client_id=okta.2b1959c8-bcc0-56eb-a589-cfcfb7422f26&code_challenge=xpJU1H_nJpYh42FT6GwihNztjD2S1YNrPpnX8SoCAMs&code_challenge_method=S256&nonce=BoKHSBkZ9GR5jXsbeCRKuXZPEbYZY0T10uuthEv8aI7xHp3CXAfS8NyOzDhu1W5v&redirect_uri=https%3A%2F%2Fpurolator.okta.com%2Fenduser%2Fcallback&response_type=code&state=YbcZOi8ZfB32NfMQMbjs2qxAWg1netkOv6p26RYsEGpoD8J49tIeufJqPR2d3AzZ&scope=openid%20profile%20email%20okta.users.read.self%20okta.users.manage.self%20okta.internal.enduser.read%20okta.internal.enduser.manage%20okta.enduser.dashboard.read%20okta.enduser.dashboard.manage%20okta.myAccount.sessions.manage%20okta.internal.navigation.enduser.read', '_blank', 'noopener,noreferrer'); }); } const cpanelBtn = document.getElementById('openCpanelBtn'); if (cpanelBtn && !cpanelBtn.__bound){ cpanelBtn.__bound = true; cpanelBtn.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); window.open('https://rldynamics.com/test3/admin/', '_blank', 'noopener,noreferrer'); }); } const promptDeparturesLinkChange = async ()=>{ if (READ_ONLY){ flashToast('Only admin can change the Departures link.'); return; } try{ document.querySelectorAll('.menu-panel.open').forEach(p=>p.classList.remove('open')); }catch(e){} const next = await showAppPrompt({ title:'Update Departures link', message:'Paste the full path or link for the new Departures file.', value:await getDeparturesLink(), placeholder:'https:// or file path', wide:true }); if (next && String(next).trim()){ await setDeparturesLink(String(next).trim()); flashToast('Departures link updated.'); } }; const bindDeparturesTrigger = (btn)=>{ if (!btn || btn.__departuresBound) return; btn.__departuresBound = true; btn.title = 'Click to open. Click and hold for 2.5 seconds to change link.'; let suppressDeparturesClick = false; let departuresHoldTimer = null; const clearHold = ()=>{ if (departuresHoldTimer){ clearTimeout(departuresHoldTimer); departuresHoldTimer = null; } }; btn.addEventListener('click', async (e)=>{ e.preventDefault(); e.stopPropagation(); if (btn.classList.contains('disabled') || btn.getAttribute('aria-disabled') === 'true'){ if (READ_ONLY) flashToast('Departures is available in admin only.'); return; } if (suppressDeparturesClick){ suppressDeparturesClick = false; return; } window.open(await getDeparturesLink(), '_blank', 'noopener,noreferrer'); }); const startHold = (e)=>{ if (e.type === 'mousedown' && e.button !== 0) return; if (btn.classList.contains('disabled') || btn.getAttribute('aria-disabled') === 'true') return; clearHold(); departuresHoldTimer = window.setTimeout(()=>{ departuresHoldTimer = null; suppressDeparturesClick = true; try{ document.querySelectorAll('.menu-panel.open').forEach(p=>p.classList.remove('open')); }catch(e){} promptDeparturesLinkChange(); }, 2500); }; btn.addEventListener('mousedown', startHold); btn.addEventListener('touchstart', startHold, { passive:true }); ['mouseup','mouseleave','mouseout','dragstart','touchend','touchcancel'].forEach(evt=>{ btn.addEventListener(evt, clearHold); }); }; [departuresBtn].concat(Array.from(document.querySelectorAll('[data-forward-id="openDeparturesBtn"]'))).forEach(bindDeparturesTrigger); const depotTerminalBtn = document.getElementById('openDepotTerminalBtn'); if (depotTerminalBtn && !depotTerminalBtn.__bound){ depotTerminalBtn.__bound = true; depotTerminalBtn.addEventListener('click', ()=>{ try{ const u = new URL(window.location.href); u.searchParams.set('terminalpopup', '1'); const w = window.open(u.toString(), 'depot_terminal_popup', 'width=1450,height=980,left=90,top=40,resizable=yes,scrollbars=no'); if (w) { try { w.focus(); } catch(e) {} } }catch(e){} }); } const hideAllToggle = document.getElementById('hideAllToggle'); const syncHideAllToggle = ()=>{ if(hideAllToggle) hideAllToggle.checked = document.body.classList.contains('all-hidden'); }; if (hideAllToggle && !hideAllToggle.__bound){ hideAllToggle.__bound=true; hideAllToggle.addEventListener('change', ()=>{ const next = !!hideAllToggle.checked; document.body.classList.toggle('all-hidden', next); setHeaderHidden(next); setYardHidden(next); }); } syncHideAllToggle(); const googleKeyBtn = document.getElementById('setGoogleMapsKeyBtn'); if (googleKeyBtn && !googleKeyBtn.__bound){ googleKeyBtn.__bound = true; googleKeyBtn.addEventListener('click', ()=>{ const key = promptForGoogleMapsApiKey(); if (key) window.alert('Google Maps routing key saved for this browser.'); }); } if (editBtn) editBtn.addEventListener('click', ()=> openTerminalModal(activeTerminalId)); if (picker) picker.addEventListener('change', (e)=> fillTerminalForm(terminalById(e.target.value))); if (termSearch){ setSearchWrapState(termSearch); termSearch.addEventListener('input', ()=>{ setSearchWrapState(termSearch); focusTerminalBySearchValue(termSearch.value, true); }); termSearch.addEventListener('keydown', (e)=>{ if (e.key === 'Enter'){ e.preventDefault(); focusTerminalBySearchValue(termSearch.value, false); }}); termSearch.addEventListener('change', ()=> focusTerminalBySearchValue(termSearch.value, false)); termSearch.addEventListener('dblclick', (e)=> e.stopPropagation(), true); } const clearTerminalSearchBtn = document.getElementById('clearTerminalSearchBtn'); if (clearTerminalSearchBtn && !clearTerminalSearchBtn.__bound){ clearTerminalSearchBtn.__bound=true; clearTerminalSearchBtn.addEventListener('click', ()=> clearSearchInput(termSearch)); } const headerLiveSearch = document.getElementById('headerLiveSearch'); const __resetMainView = ()=>{ try{ if (window.__NHO_VIEW && typeof window.__NHO_VIEW.setZoom === 'function') window.__NHO_VIEW.setZoom(1); if (window.__NHO_VIEW && typeof window.__NHO_VIEW.fit === 'function') window.__NHO_VIEW.fit(); }catch(err){} }; if (headerLiveSearch && !headerLiveSearch.__bound){ headerLiveSearch.__bound = true; setSearchWrapState(headerLiveSearch); updateHeaderSearchUi(); headerLiveSearch.addEventListener('input', ()=>{ hideSearchFlashOverlay(); setSearchWrapState(headerLiveSearch); performGlobalBoardSearch(headerLiveSearch.value); try{ window.__syncCommandHeaderClearance && window.__syncCommandHeaderClearance(); }catch(_e){} if (!String(headerLiveSearch.value || '').trim()) __resetMainView(); }); headerLiveSearch.addEventListener('keydown', (e)=>{ if (e.key === 'Escape'){ e.preventDefault(); clearSearchInput(headerLiveSearch); __resetMainView(); } else if (e.key === 'Enter') { e.preventDefault(); cycleHeaderSearchHit(e.shiftKey ? -1 : 1); } else if (e.key === 'ArrowDown') { e.preventDefault(); cycleHeaderSearchHit(1); } else if (e.key === 'ArrowUp') { e.preventDefault(); cycleHeaderSearchHit(-1); } }); headerLiveSearch.addEventListener('search', ()=>{ if (!String(headerLiveSearch.value || '').trim()) __resetMainView(); }); } const clearHeaderSearchBtn = document.getElementById('clearHeaderSearchBtn'); if (clearHeaderSearchBtn && !clearHeaderSearchBtn.__bound){ clearHeaderSearchBtn.__bound = true; clearHeaderSearchBtn.addEventListener('click', ()=>{ clearSearchInput(headerLiveSearch); __resetMainView(); }); } const headerSearchPrevBtn = document.getElementById('headerSearchPrevBtn'); const headerSearchNextBtn = document.getElementById('headerSearchNextBtn'); if (headerSearchPrevBtn && !headerSearchPrevBtn.__bound){ headerSearchPrevBtn.__bound = true; headerSearchPrevBtn.addEventListener('click', ()=> cycleHeaderSearchHit(-1)); } if (headerSearchNextBtn && !headerSearchNextBtn.__bound){ headerSearchNextBtn.__bound = true; headerSearchNextBtn.addEventListener('click', ()=> cycleHeaderSearchHit(1)); } setTimeout(()=>{ try{ terminalMap && terminalMap.invalidateSize(true); }catch(e){} }, 100); const zoomResetBtn = document.getElementById('zoomResetBtn'); if (zoomResetBtn && !zoomResetBtn.__bound){ zoomResetBtn.__bound = true; zoomResetBtn.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); __resetMainView(); }); } const handleTerminalMapResize = debounceNamed('__terminalMapResize', ()=>{ try{ terminalMap && terminalMap.invalidateSize(true); }catch(e){} }, 140); window.addEventListener('resize', handleTerminalMapResize); ['fiveSearch','finalSearch'].forEach(id=>{ const node = document.getElementById(id); if (node) node.addEventListener('dblclick', (e)=> e.stopPropagation(), true); }); const clearRailWithPin = async (which)=>{ if (READ_ONLY || !frontHasPermission('rails.clear')) return; const pin = await showAppPrompt({ title:'Clear rail', message:'Enter your PIN to clear this rail.', placeholder:'Enter PIN', type:'password', compact:true }); if (pin === null) return; if (String(pin).trim() !== '1234'){ try{ flashToast('Incorrect PIN.'); }catch(e){} return; } const isNext = which === 'next'; const targetMap = isNext ? fiveItems : finalItems; const targetList = document.getElementById(isNext ? 'fiveList' : 'finalList'); const handled = new Set(); const targets = []; const queueTarget = (card, fallbackKey, fallbackEntry)=>{ const key = String(card?.dataset?.key || fallbackKey || ''); if (!key || handled.has(key)) return; handled.add(key); let doorNum = Number(card?.dataset?.door ?? fallbackEntry?.data?.door); let timeMin = Number(card?.dataset?.tmin ?? fallbackEntry?.data?.timeMin); if ((!Number.isFinite(doorNum) || !Number.isFinite(timeMin)) && key.includes('|')){ const parts = key.split('|'); if (!Number.isFinite(doorNum)) doorNum = Number(parts[0]); if (!Number.isFinite(timeMin)) timeMin = Number(parts[1]); } targets.push({ key, card, doorNum, timeMin }); }; try{ Array.from(targetList?.querySelectorAll?.('.alert-card') || []).forEach(card => queueTarget(card)); }catch(e){} try{ Array.from(targetMap.entries()).forEach(([k, entry]) => queueTarget(entry?.el, k, entry)); }catch(e){} targets.forEach(({key, card, doorNum, timeMin})=>{ try{ if (Number.isFinite(doorNum) && Number.isFinite(timeMin)) clearDoorFromRailAction(doorNum, timeMin); }catch(e){} try{ if (Number.isFinite(doorNum)){ const doorEl = document.querySelector(`.yard-grid .door[data-number="${doorNum}"]`) || document.querySelector(`.door[data-number="${doorNum}"]`); if (doorEl){ if (Number.isFinite(timeMin)) { try{ removeTimeFromDoorByMinutes(doorNum, timeMin); }catch(e){} } const remaining = getDoorTimesWithFlags(doorEl); if (!remaining.length && doorEl.classList.contains('yard-door')){ try{ clearYardLocationByNumber(doorNum); }catch(e){} } } } }catch(e){} try{ card?.remove?.(); }catch(e){} try{ targetMap.delete(key); }catch(e){} try{ alertState.delete(key); }catch(e){} }); try{ if (targetList) targetList.replaceChildren(); }catch(e){} try{ refreshRailsFromBoard(); }catch(e){} try{ cleanupYardItemsMissingRailCards(); }catch(e){} try{ refreshAllRailUi(); }catch(e){} try{ saveStateToServerDebounced(); }catch(e){} }; const clearNext = document.getElementById('clearNextUpBtn'); if (clearNext){ clearNext.onclick = (e)=>{ e.preventDefault(); e.stopPropagation(); clearRailWithPin('next'); }; } const clearPre = document.getElementById('clearPreAlertBtn'); if (clearPre){ clearPre.onclick = (e)=>{ e.preventDefault(); e.stopPropagation(); clearRailWithPin('pre'); }; } if (saveTermBtn) saveTermBtn.addEventListener('click', saveActiveTerminal); if (addTermBtn) addTermBtn.addEventListener('click', addTerminalLocation); if (closeTermBtn) closeTermBtn.addEventListener('click', closeTerminalModal); if (termModal) termModal.addEventListener('click', (e)=>{ if (e.target === termModal) closeTerminalModal(); }); }catch(e){} // Add Trip / AM buttons try{ const addBtn = document.getElementById("addTripBtn"); if (addBtn){ addBtn.addEventListener("click", ()=>{ const n = firstEmptyYardSlotNumber(); if (!n){ alert("No empty yard slots available."); return; } requirePassword(()=> { try{ persistCurrentSnapshotNow(); }catch(e){} openCreateTripForYardSlot(n); }); }); } const dayBtn = document.getElementById("loadDayBtn"); if (dayBtn) dayBtn.addEventListener("click", ()=> requirePassword(async ()=>{ const ok = await showAppConfirm({ title:'DAY departures', message:'DAY button is not active yet. Open this action dialog anyway?', okText:'OK', cancelText:'Cancel', compact:true }); if (ok) flashToast('DAY preset button is ready for your day layout.'); })); const pmBtn = document.getElementById("loadPmBtn"); if (pmBtn) pmBtn.addEventListener("click", ()=> requirePassword(async ()=>{ if (window.__PAGE_MODE !== "PM"){ showWrongPageSetupDialog("PM"); return; } await loadAMDepartures(PM_DEPARTURES, {forceNextUp:true, buttonId:"loadPmBtn", label:"PM", resolveTargetDateIso:()=>getTodayIso()}); flashToast("PM departures loaded."); })); const amBtn = document.getElementById("loadAmBtn"); if (amBtn) amBtn.addEventListener("click", ()=> requirePassword(async ()=>{ if (window.__PAGE_MODE !== "AM"){ showWrongPageSetupDialog("AM"); return; } await loadAMDepartures(AM_DEPARTURES, {forceNextUp:true, buttonId:"loadAmBtn", label:"AM"}); flashToast("AM departures loaded."); })); const amWeekendBtn = document.getElementById("loadAmWeekendBtn"); const sunMonBtn = document.getElementById("loadSunMonBtn"); if (sunMonBtn) sunMonBtn.addEventListener("click", ()=> requirePassword(()=>{ if (window.__PAGE_MODE !== "SUNMON"){ window.location.href = "sunmon.php"; return; } loadAMDepartures(AM_DEPARTURES, {forceNextUp:true, buttonId:"loadSunMonBtn", label:"Sun./Mon."}); flashToast("Sun./Mon. departures loaded."); })); if (amWeekendBtn) amWeekendBtn.addEventListener("click", ()=> requirePassword(()=>{ if (window.__PAGE_MODE !== "WEEKEND"){ window.location.href = "weekend.php"; return; } loadAMDepartures(AM_WEEKEND_DEPARTURES, {forceNextUp:true, buttonId:"loadAmWeekendBtn", label:"Weekend"}); flashToast("Weekend departures loaded."); })); }catch(e){} // Reset button (restore original seeded layout) try{ const rBtn = document.getElementById('resetBtn'); if (rBtn){ rBtn.addEventListener('click', async (e)=>{ // Default: reset VIEW only (does not clear data) if (e && e.shiftKey){ if (!confirm('Clear all data back to the original layout? This will clear all times, yard trips, and notes.')) return; try{ const snap = (typeof buildDefaultSnapshot === 'function') ? buildDefaultSnapshot() : (window.__DEFAULT_SNAPSHOT || null); if (snap){ await saveStateToServer(snap); } else { if (typeof clearAllTimesAndYard === 'function') clearAllTimesAndYard(); if (typeof collectFullSnapshot === 'function') await saveStateToServer(collectFullSnapshot()); } }catch(e){} try{ localStorage.setItem('reset_hold_pm','1'); }catch(e){} location.reload(); return; } try{ if (window.__NHO_VIEW && typeof window.__NHO_VIEW.setZoom === 'function') { window.__NHO_VIEW.setZoom(1); } else if (window.__NHO_VIEW && typeof window.__NHO_VIEW.fit === 'function') { window.__NHO_VIEW.fit(); } }catch(err){} }); } }catch(e){} // Reset hold: after a SHIFT+Reset (full clear), keep rails empty until the next user change. try{ window.__RESET_HOLD = (localStorage.getItem('reset_hold_pm') === '1'); if (window.__RESET_HOLD) localStorage.removeItem('reset_hold_pm'); }catch(e){ window.__RESET_HOLD = false; } try{ const fiveSearch = document.getElementById('fiveSearch'); const finalSearch = document.getElementById('finalSearch'); if (fiveSearch) fiveSearch.addEventListener('input', ()=>{ setSearchWrapState(fiveSearch); updateNextUpSearchFilter(); }); if (finalSearch) finalSearch.addEventListener('input', ()=>{ setSearchWrapState(finalSearch); updatePreAlertSearchFilter(); }); const clearFive = document.getElementById('clearFiveSearchBtn'); const clearFinal = document.getElementById('clearFinalSearchBtn'); if (clearFive) clearFive.addEventListener('click', ()=> clearSearchInput(fiveSearch)); if (clearFinal) clearFinal.addEventListener('click', ()=> clearSearchInput(finalSearch)); setSearchWrapState(fiveSearch); setSearchWrapState(finalSearch); const collapseKey = (id)=>`rail_collapsed_pm_${id}`; const applyRailCollapsed = (panel, collapsed)=>{ if (!panel) return; panel.classList.toggle('is-collapsed', !!collapsed); try{ localStorage.setItem(collapseKey(panel.id || panel.getAttribute('aria-label') || 'rail'), collapsed ? '1' : '0'); }catch(e){} }; document.querySelectorAll('.rail-panel').forEach(panel=>{ try{ const stored = localStorage.getItem(collapseKey(panel.id || panel.getAttribute('aria-label') || 'rail')); if (stored === '1') panel.classList.add('is-collapsed'); }catch(e){} const head = panel.querySelector('.rail-head'); if (head) head.addEventListener('dblclick', ()=> applyRailCollapsed(panel, !panel.classList.contains('is-collapsed'))); }); document.querySelectorAll('.rail-body').forEach(body=>{ body.addEventListener('wheel', (e)=>{ const canScroll = body.scrollHeight > body.clientHeight; if (!canScroll) { e.preventDefault(); return; } const atTop = body.scrollTop <= 0; const atBottom = Math.ceil(body.scrollTop + body.clientHeight) >= body.scrollHeight; if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) e.preventDefault(); e.stopPropagation(); }, { passive:false }); }); }catch(e){} try{ window.addEventListener('pagehide', ()=>{ try{ persistCurrentSnapshotNow(); }catch(e){} }); window.addEventListener('beforeunload', ()=>{ try{ persistCurrentSnapshotNow(); }catch(e){} }); document.addEventListener('visibilitychange', ()=>{ if (document.visibilityState === 'hidden') { try{ persistCurrentSnapshotNow(); }catch(e){} } }); }catch(e){} startQuietSaveObserver(); if (READ_ONLY){ pollServer(); startNamedInterval('__pollServerInterval', pollServer, 1500); } // Keep countdowns live. Rail recalculation is handled by the main timer loop and // is temporarily frozen right after server restore so refreshed rails do not disappear. try { refreshAllRailUi(); } catch(e) {} try { startLiveTimers(); } catch(e) {} try{ initMobileRailDock(); }catch(e){} }); /* ========================= Timed comments (Notes by time) ========================= */ let __selectedTimeForNotes = null; function ensureNotesByTimeObj(doorEl){ if (!doorEl) return {}; try{ if (!doorEl.dataset.notesByTime) return {}; const obj = JSON.parse(doorEl.dataset.notesByTime); if (obj && typeof obj === "object") return obj; }catch(e){} return {}; } function setNotesByTimeObj(doorEl, obj){ try{ doorEl.dataset.notesByTime = JSON.stringify(obj || {}); }catch(e){ doorEl.dataset.notesByTime = "{}"; } } function wireTimedNotesUI(doorEl){ const selEl = document.getElementById("timedNoteSelected"); const hintEl = document.getElementById("timedNoteHint"); const txt = document.getElementById("timedNoteInput"); if (!selEl || !txt) return; const times = toTimesArray(doorEl?.dataset?.times || ""); const extra = toTimesArray(doorEl?.dataset?.extraTimes || ""); const localTimes = Array.isArray(selectedTimesLocal) ? selectedTimesLocal.filter(Boolean) : []; const localExtra = Array.isArray(extraTimesLocal) ? extraTimesLocal.filter(Boolean) : []; const all = [...new Set([...times, ...extra, ...localTimes, ...localExtra].filter(Boolean))]; const first = all.length ? all[0] : null; if (__selectedTimeForNotes == null) __selectedTimeForNotes = first; if (__selectedTimeForNotes != null && !all.includes(__selectedTimeForNotes)) __selectedTimeForNotes = first; if (__selectedTimeForNotes == null){ selEl.textContent = "None"; if (hintEl) hintEl.textContent = "Select a time chip above to write a timed comment."; txt.value = ""; txt.disabled = true; return; } const __dateIso = getTimeDateFromMap(selectedTimeDatesLocal, __selectedTimeForNotes, getTodayIso()); selEl.textContent = chipDisplayFromValue(__selectedTimeForNotes) + describeDateSuffix(__dateIso); if (hintEl) hintEl.textContent = "Trip details / note will save to this exact chip."; txt.disabled = false; const map = ensureNotesByTimeObj(doorEl); txt.value = (map[String(parseTimeToMinutes(__selectedTimeForNotes))] || ""); txt.oninput = ()=>{ const activeKey = String(__selectedTimeForNotes || '').trim(); if (!activeKey){ if (hintEl) hintEl.textContent = "Select a time chip above to write a timed comment."; return; } const map2 = ensureNotesByTimeObj(doorEl); const minuteKey = String(parseTimeToMinutes(activeKey)); const nextVal = String(txt.value || ''); if (nextVal.trim()) map2[minuteKey] = nextVal; else delete map2[minuteKey]; setNotesByTimeObj(doorEl, map2); if (selEl) selEl.textContent = chipDisplayFromValue(activeKey) + describeDateSuffix(getTimeDateFromMap(selectedTimeDatesLocal, activeKey, getTodayIso())); if (hintEl) hintEl.textContent = "Trip details / note will save to this exact chip."; try { syncAllRailCardsForDoor(doorEl.dataset.number); } catch(e){} try { refreshAllRailUi(); } catch(e){} try { saveStateToServerDebounced(); } catch(e){} try { sync(); } catch(e){} }; } document.addEventListener("click", (e)=>{ const chip = e.target?.closest?.(".chip"); if (!chip) return; const v = chip.dataset?.value || ''; if (!v) return; setSelectedTimeTarget(v); }, true); function closeMobileRails(){ try{ document.body.classList.remove('mobile-rails-open'); }catch(e){} try{ document.querySelectorAll('.rail-panel.mobile-open').forEach(el=>el.classList.remove('mobile-open')); }catch(e){} try{ document.querySelectorAll('#mobileRailDock button').forEach(btn=>btn.classList.remove('active')); }catch(e){} } function openMobileRail(which){ const mq = window.matchMedia && window.matchMedia('(max-width: 900px)'); if (mq && !mq.matches) return; const left = document.getElementById('nextUpRail'); const right = document.getElementById('preAlertRail'); closeMobileRails(); let target = null; if (which === 'next') target = left; if (which === 'pre') target = right; if (!target) return; try{ document.body.classList.add('mobile-rails-open'); }catch(e){} try{ target.classList.add('mobile-open'); }catch(e){} try{ const btn = document.getElementById(which === 'next' ? 'mobileNextUpBtn' : 'mobilePreAlertBtn'); if (btn) btn.classList.add('active'); }catch(e){} } function initMobileRailDock(){ const dock = document.getElementById('mobileRailDock'); if (!dock) return; const nextBtn = document.getElementById('mobileNextUpBtn'); const preBtn = document.getElementById('mobilePreAlertBtn'); const yardBtn = document.getElementById('mobileYardToggleBtn'); const hideBtn = document.getElementById('mobileHideRailsBtn'); if (nextBtn && !nextBtn.dataset.bound){ nextBtn.dataset.bound='1'; nextBtn.addEventListener('click', ()=>{ const open = document.getElementById('nextUpRail')?.classList.contains('mobile-open'); if (open) closeMobileRails(); else openMobileRail('next'); }); } if (preBtn && !preBtn.dataset.bound){ preBtn.dataset.bound='1'; preBtn.addEventListener('click', ()=>{ const open = document.getElementById('preAlertRail')?.classList.contains('mobile-open'); if (open) closeMobileRails(); else openMobileRail('pre'); }); } if (yardBtn && !yardBtn.dataset.bound){ yardBtn.dataset.bound='1'; yardBtn.addEventListener('click', ()=>{ document.body.classList.toggle('hide-yard'); yardBtn.classList.toggle('active', !document.body.classList.contains('hide-yard')); yardBtn.textContent = document.body.classList.contains('hide-yard') ? 'Show Yard' : 'Hide Yard'; }); yardBtn.classList.toggle('active', !document.body.classList.contains('hide-yard')); yardBtn.textContent = document.body.classList.contains('hide-yard') ? 'Show Yard' : 'Hide Yard'; } if (hideBtn && !hideBtn.dataset.bound){ hideBtn.dataset.bound='1'; hideBtn.addEventListener('click', ()=>closeMobileRails()); } document.addEventListener('click', function(ev){ const mq = window.matchMedia && window.matchMedia('(max-width: 900px)'); if (mq && !mq.matches) return; const inRail = ev.target && ev.target.closest && ev.target.closest('.rail-panel, #mobileRailDock'); if (!inRail) closeMobileRails(); }, true); } const handleMobileRailsResize = debounceNamed('__mobileRailsResize', ()=>{ const mq = window.matchMedia && window.matchMedia('(max-width: 900px)'); if (mq && !mq.matches) closeMobileRails(); }, 120); window.addEventListener('resize', handleMobileRailsResize); /* ========================= Terminal vista + admin lock + AM departures ========================= */ let terminalState = DEFAULT_TERMINALS.map(t => ({...t})); let activeTerminalId = terminalState[0]?.id || null; let terminalMap = null; let terminalMarkers = []; function getTerminalMarkerById(id){ const idx = terminalState.findIndex(t => String(t.id) === String(id)); return idx >= 0 ? terminalMarkers[idx] : null; } let terminalHoverCircle = null; let terminalBoundsSet = false; const TERMINAL_MAP_SIZE_KEY = 'nho_terminal_map_size_v1'; const CLOUDHAWK_FRAME_SIZE_KEY = 'nho_cp_frame_size_v2'; const PHONEBOOK_STORAGE_KEY = 'nho_phonebook_contacts_v1'; const PHONEBOOK_PIN = '1234'; const TERMINAL_PERSIST_KEY = 'nho_terminal_map_locations_v3'; const TERMINAL_DELETE_PIN = '1234'; function cloneTerminalDefaults(){ return DEFAULT_TERMINALS.map(t => ({...t})); } function getCurrentTerminalsSnapshot(){ return terminalState.map(t => ({...t})); } function readLocalTerminalPayload(){ try{ const keys = [TERMINAL_PERSIST_KEY, 'nho_terminal_map_locations', 'nho_terminal_map_locations_v2', 'nho_terminal_map_locations_v1']; for (const key of keys){ const raw = localStorage.getItem(key); if (!raw) continue; const data = JSON.parse(raw); if (Array.isArray(data)) return { items:data, savedAt:0 }; if (data && Array.isArray(data.items)) return { items:data.items, savedAt:Number(data.savedAt || 0) }; } }catch(e){} return null; } function writeLocalTerminalPayload(items){ try{ const payload = JSON.stringify({ savedAt: Date.now(), items: Array.isArray(items) ? items.map(t => ({...t})) : [] }); [TERMINAL_PERSIST_KEY, 'nho_terminal_map_locations'].forEach((key)=>{ try{ localStorage.setItem(key, payload); }catch(_e){} }); }catch(e){} } function applyTerminalsFromState(items){ const base = cloneTerminalDefaults(); const incoming = Array.isArray(items) ? items : []; const normalizedBaseIds = new Set(base.map(t => String(t.id))); const merged = base.map((def, idx)=>{ const src = incoming.find(it => String(it?.id || '') === def.id) || incoming[idx] || {}; return { id: String(src.id || def.id), name: String(src.name || def.name), region: String(src.region || def.region), manager: String(src.manager || def.manager), capacity: String(src.capacity || def.capacity), status: String(src.status || def.status), address: String(src.address || def.address), notes: String(src.notes || def.notes), lat: Number.isFinite(Number(src.lat)) ? Number(src.lat) : def.lat, lng: Number.isFinite(Number(src.lng)) ? Number(src.lng) : def.lng }; }); incoming.forEach((src, idx)=>{ const id = String(src?.id || `CUSTOM_${idx+1}`).trim() || `CUSTOM_${idx+1}`; if (normalizedBaseIds.has(id) || merged.some(t => String(t.id) === id)) return; merged.push({ id, name: String(src?.name || id), region: String(src?.region || ''), manager: String(src?.manager || ''), capacity: String(src?.capacity || ''), status: String(src?.status || 'Active'), address: String(src?.address || ''), notes: String(src?.notes || ''), lat: Number.isFinite(Number(src?.lat)) ? Number(src.lat) : 43.6532, lng: Number.isFinite(Number(src?.lng)) ? Number(src.lng) : -79.3832 }); }); terminalState = merged; if (!terminalState.find(t => t.id === activeTerminalId)) activeTerminalId = terminalState[0]?.id || null; renderTerminalVista(); populateTerminalSearchList(); } function escapeText(v){ return escapeHtml(String(v ?? '')); } function terminalById(id){ return terminalState.find(t => String(t.id) === String(id)) || null; } function getSavedTerminalMapSize(){ try{ const raw = localStorage.getItem(TERMINAL_MAP_SIZE_KEY); if (!raw) return null; const obj = JSON.parse(raw); const width = Number(obj?.width || 0); const height = Number(obj?.height || 0); return { width: Math.max(1120, Math.round(width || 0)), height: Math.max(860, Math.round(height || 0)) }; }catch(e){ return null; } } function applySavedTerminalMapSize(){ const wrap = document.getElementById('terminalGrid'); if (!wrap) return; const saved = getSavedTerminalMapSize(); if (!saved) return; wrap.style.width = `${saved.width}px`; wrap.style.height = `${saved.height}px`; } function persistTerminalMapSize(){ const wrap = document.getElementById('terminalGrid'); if (!wrap) return; try{ const rect = wrap.getBoundingClientRect(); localStorage.setItem(TERMINAL_MAP_SIZE_KEY, JSON.stringify({ width: Math.max(1120, Math.round(rect.width || 0)), height: Math.max(860, Math.round(rect.height || 0)) })); }catch(e){} } function bindTerminalMapResize(){ const wrap = document.getElementById('terminalGrid'); if (!wrap || wrap.__resizeBound) return; wrap.__resizeBound = true; applySavedTerminalMapSize(); try{ const ro = new ResizeObserver(()=>{ persistTerminalMapSize(); try{ terminalMap?.invalidateSize({animate:false}); }catch(e){} }); ro.observe(wrap); }catch(e){} window.addEventListener('resize', ()=>{ try{ terminalMap?.invalidateSize({animate:false}); }catch(e){ if (!optimisticSeenSelf) setViewerCount(Math.max(1, Number(badge.dataset.count || 0) || 0)); } }); } function getSavedCloudhawkFrameSize(){ try{ const raw = localStorage.getItem(CLOUDHAWK_FRAME_SIZE_KEY); if (!raw) return null; const obj = JSON.parse(raw); const width = Number(obj?.width || 0); const height = Number(obj?.height || 0); return { width: Math.max(800, Math.round(width || 0)), height: Math.max(1200, Math.round(height || 0)) }; }catch(e){ return null; } } function applySavedCloudhawkFrameSize(){ const wrap = document.getElementById('cloudhawkFrameWrap'); if (!wrap) return; const saved = getSavedCloudhawkFrameSize(); if (!saved) return; wrap.style.width = `${saved.width}px`; wrap.style.height = `${saved.height}px`; } function persistCloudhawkFrameSize(){ const wrap = document.getElementById('cloudhawkFrameWrap'); if (!wrap) return; try{ const rect = wrap.getBoundingClientRect(); localStorage.setItem(CLOUDHAWK_FRAME_SIZE_KEY, JSON.stringify({ width: Math.max(800, Math.round(rect.width || 0)), height: Math.max(1200, Math.round(rect.height || 0)) })); }catch(e){} } function bindCloudhawkFrameResize(){ const wrap = document.getElementById('cloudhawkFrameWrap'); if (!wrap || wrap.__resizeBound) return; wrap.__resizeBound = true; applySavedCloudhawkFrameSize(); try{ const ro = new ResizeObserver(()=>{ persistCloudhawkFrameSize(); }); ro.observe(wrap); }catch(e){} } function initCloudhawkFrame(){ applySavedCloudhawkFrameSize(); bindCloudhawkFrameResize(); const openBtn = document.getElementById('openCloudhawkBtn'); if (openBtn && !openBtn.__bound){ openBtn.__bound = true; openBtn.addEventListener('click', ()=>{ try{ window.open('https://rldynamics.com/CP/', '_blank', 'noopener,noreferrer'); }catch(e){} }); } } const DEFAULT_PHONEBOOK_CONTACTS = [{"name": "Dorian Yuen", "phone": "647 872 0391", "location": "Driver"}, {"name": "John Visic", "phone": "416 389 5920", "location": "Driver"}, {"name": "Miles", "phone": "647 996 8719", "location": "Driver"}, {"name": "Rajesh", "phone": "647 834 2001", "location": "Driver"}, {"name": "Roman", "phone": "416 731 8970", "location": "Driver"}, {"name": "Sonny", "phone": "416 710 3005", "location": "Driver"}, {"name": "Tuffour", "phone": "647 573 4542", "location": "Driver"}, {"name": "Manshair", "phone": "647 448 5200", "location": "Driver"}, {"name": "Ramik", "phone": "6475700039", "location": "Driver"}, {"name": "abby", "phone": "647 718 8676", "location": "Driver"}, {"name": "Gabriel Adu", "phone": "647 323 5828", "location": "Driver / float"}, {"name": "Bret", "phone": "705 828 3586", "location": "Driver - Barrie"}, {"name": "Mel", "phone": "416 930 9542", "location": "Driver - Barrie"}, {"name": "Trevor", "phone": "226 920 6282", "location": "Driver - Brantford"}, {"name": "Jeff Walters", "phone": "416 788 8470", "location": "Driver - Brantford"}, {"name": "Josh", "phone": "416 825 1462", "location": "Driver - Burlington"}, {"name": "Kyle", "phone": "365 336 2437", "location": "Driver - Burlington"}, {"name": "Mike", "phone": "416 899 0504", "location": "Driver - Chatham"}, {"name": "The Good Ryan", "phone": "519 550 2949", "location": "Driver - Guelph"}, {"name": "Alex", "phone": "905 921 0533", "location": "Driver - Hamilton"}, {"name": "Abdul", "phone": "226 750 7456", "location": "Driver - Kitchner"}, {"name": "BIZMARK", "phone": "519 701 0093", "location": "Driver - London"}, {"name": "Dylan", "phone": "519 494 8366", "location": "Driver - London"}, {"name": "Gary", "phone": "226 448 6653", "location": "Driver - London"}, {"name": "Narinder", "phone": "226 700 9120", "location": "Driver - London"}, {"name": "Tim", "phone": "519 384 0724", "location": "Driver - London"}, {"name": "Stu", "phone": "905 929 9750", "location": "Driver - Mount Hope"}, {"name": "Manuel", "phone": "905 572 0944", "location": "Driver - Niagara on the lake"}, {"name": "Nestor", "phone": "437 997 6318", "location": "Driver - Niagara on the lake"}, {"name": "Dejenco", "phone": "416 854 6407", "location": "Driver - North York"}, {"name": "Jamie", "phone": "289 668 0677", "location": "Driver - NOTL"}, {"name": "Glen", "phone": "705 931 5023", "location": "Driver - Peterborough"}, {"name": "Bob", "phone": "705 340 3976", "location": "Driver - Peterborough"}, {"name": "Max", "phone": "416 876 8028", "location": "Driver - Pickering"}, {"name": "trevor", "phone": "226 920 6282", "location": "Driver - Stratford"}, {"name": "Scott", "phone": "519 465 1156", "location": "Driver - Stratford"}, {"name": "Super Dave", "phone": "519 980 2948", "location": "Driver - Windsor"}, {"name": "JOHN", "phone": "519 378 5807", "location": "Driver - Owen Sound"}, {"name": "Amandeep", "phone": "647 330 8626", "location": "Shunt"}, {"name": "Amjad Goria", "phone": "416 388 9021", "location": "Shunt"}, {"name": "Asif Patel", "phone": "647 588 9864", "location": "Shunt"}, {"name": "Carry Smith", "phone": "416 806 0703", "location": "Shunt"}, {"name": "Cordell Richards", "phone": "905 409 8022", "location": "Shunt"}, {"name": "Darwin Balazo", "phone": "647 563 7854", "location": "Shunt"}, {"name": "Fitzjohn Lettman (Duces)", "phone": "416 407 1013", "location": "Shunt"}, {"name": "Jario Pinto", "phone": "647 303 5000", "location": "Shunt"}, {"name": "Kamalinder", "phone": "647 806 5100", "location": "Shunt"}, {"name": "Liang", "phone": "905 928 4288", "location": "Shunt"}, {"name": "Michael Brown", "phone": "647 391 4521", "location": "Shunt"}, {"name": "Mohammed Parekh", "phone": "416 704 4288", "location": "Shunt"}, {"name": "Mohsin Shaikh", "phone": "647 937 2746", "location": "Shunt"}, {"name": "Pawel Mieczkowski", "phone": "416 629 3541", "location": "Shunt"}, {"name": "ripdam", "phone": "437 995 6875", "location": "Shunt"}, {"name": "Simanpreet Singh", "phone": "437 996 0091", "location": "Shunt"}, {"name": "Zoltan Horvath", "phone": "416 889 3894", "location": "Shunt"}, {"name": "Gurpreet", "phone": "416 315 0466", "location": "Shunt"}, {"name": "Paul Hanson", "phone": "647 937 8837", "location": "Shunt"}, {"name": "Rahul Sharma", "phone": "6477873760", "location": "Shunt"}, {"name": "Amar", "phone": "4377662900", "location": "ONGO DRIVER"}, {"name": "MYSTERIOUS DRIVER", "phone": "4167888470", "location": "Driver - Brantford"}, {"name": "Sean", "phone": "289 700 2702", "location": "mount hope"}, {"name": "Rebecca", "phone": "437 333 6681", "location": "Driver/ Float"}, {"name": "Manohar Rehal", "phone": "226 789 2745", "location": "Driver- Guelph"}, {"name": "Rich", "phone": "9055203573", "location": "Driver- Burlington"}, {"name": "Sam", "phone": "416-580-0952", "location": "Driver- Stratford"}, {"name": "Steve Robertson", "phone": "289 442 4572", "location": "Driver- Mount Hope"}, {"name": "Greg Smith", "phone": "519 755 6880", "location": "Driver - Brantford"}, {"name": "John", "phone": "226 974 0280", "location": "Driver - Owen Sound"}, {"name": "Amrit", "phone": "647 832 4462", "location": "Driver- Guelph"}, {"name": "JIM (bubble Tea)", "phone": "416 618 3333", "location": "Driver- GTT"}, {"name": "Michael Ramsey", "phone": "416 432 3168", "location": "Barrie"}, {"name": "JORDAN COLES", "phone": "289 556 6698", "location": "BRANTFORD"}, {"name": "KIM", "phone": "226 583 0979", "location": "BRANTFORD"}, {"name": "Tariq", "phone": "647 400 7150", "location": "Burlington"}, {"name": "Nina", "phone": "519 - 401 - 1551", "location": "Chatham"}, {"name": "Candida", "phone": "587 990 8285", "location": "Edmonton North"}, {"name": "Martin (GTT Dispatch)", "phone": "416-207-3946", "location": "GTT DISPATCH"}, {"name": "Jennifer", "phone": "226 203 2697", "location": "Guelph"}, {"name": "Joelle hiuser", "phone": "226-581-1095", "location": "Kitchener"}, {"name": "John Nawrock ( Manager )", "phone": "416-333-6717", "location": "London"}, {"name": "Chris ( Section Manager )", "phone": "509-281-7740", "location": "London"}, {"name": "David Khan", "phone": "416 459 3268", "location": "Metro West"}, {"name": "Anthony Sukhai", "phone": "647-213-4127", "location": "Metro West P&D"}, {"name": "AYR DISPATCH", "phone": "905 793 0532", "location": "Mike"}, {"name": "Robert Green", "phone": "905-564-3355", "location": "Mississauga East"}, {"name": "Aiden", "phone": "905 638 3505", "location": "Mount Hope"}, {"name": "BobBy mankU", "phone": "416 206 4365", "location": "Mount Hope"}, {"name": "Najman mohammed", "phone": "416 779 5039", "location": "NHO ( control Room)"}, {"name": "Tony Nembhard", "phone": "416 453 5045", "location": "NHO (Day Shift Ops)"}, {"name": "Alex", "phone": "416 571 3347", "location": "NHO (Ops)"}, {"name": "Aminu", "phone": "647 453 1378", "location": "NHO (Ops)"}, {"name": "Elizabeth Robinson", "phone": "647 453 1427", "location": "NHO (Ops)"}, {"name": "JOHN NAWROCKI", "phone": "416 333 6717", "location": "Manager (London)"}, {"name": "BOBBY", "phone": "4162064365", "location": "HAMILTON"}, {"name": "Gary West", "phone": "647 524 6163", "location": "NHO (Ops)"}, {"name": "Hamad", "phone": "647 216 9554", "location": "NHO (Ops)"}, {"name": "Jerry", "phone": "647 523 6701", "location": "NHO (Ops)"}, {"name": "Jesus Rivas", "phone": "416 254 8317", "location": "NHO (Ops)"}, {"name": "Kelvin Amoah", "phone": "647 216 8178", "location": "NHO (Ops)"}, {"name": "Matengae", "phone": "647 216 8156", "location": "NHO (Ops)"}, {"name": "Randy", "phone": "416 206 5932", "location": "NHO (Ops)"}, {"name": "Shaneeza Mohamed", "phone": "437 987 4731", "location": "NHO(Control room)"}, {"name": "Deniz", "phone": "905 371 5065", "location": "Niagara On The Lake"}, {"name": "Kelly", "phone": "289 547 8398", "location": "Niagara On The Lake"}, {"name": "Amanda Wilcox", "phone": "437-324-6291", "location": "North York"}, {"name": "Scot", "phone": "647 212 8108", "location": "North York"}, {"name": "Watkins Thomas", "phone": "519 373 3892", "location": "Owen Sound"}, {"name": "Jim Black", "phone": "705-745-0150", "location": "Peterborough"}, {"name": "John", "phone": "4163336717", "location": "London"}, {"name": "GARAGE (VULCAN)", "phone": "416 241 2641", "location": "PRESS 8 , PRESS 5"}, {"name": "Patrick Castillo", "phone": "647-290-6944", "location": "Scarborough"}, {"name": "Eric Woo", "phone": "416-347-7306", "location": "Scarborough"}, {"name": "Ashley Pereira (male)", "phone": "647-809-6964", "location": "Stratford"}, {"name": "Aroon", "phone": "416 206 3792", "location": "Vaughan"}, {"name": "HUB Patrol #1", "phone": "416 459 2145", "location": ""}, {"name": "LP 24HR", "phone": "416 206 7735", "location": ""}, {"name": "JASSDEEP DAY SHIFT", "phone": "647 897 3756", "location": ""}, {"name": "Aleksey Larin", "phone": "647 702 6663", "location": "Added contacts"}, {"name": "Alex Kerschner", "phone": "416 894 5541", "location": "Added contacts"}, {"name": "Amrik Johal", "phone": "416 627 6595", "location": "Added contacts"}, {"name": "Andrew Weir", "phone": "647 446 0040", "location": "Added contacts"}, {"name": "Asif Patel", "phone": "647 588 9864", "location": "Added contacts"}, {"name": "Bhupinder Singh", "phone": "416 455 2305", "location": "Added contacts"}, {"name": "Bhupinderjit Singh", "phone": "647 403 2782", "location": "Added contacts"}, {"name": "Bikramjit Bajwa", "phone": "416 666 0976", "location": "Added contacts"}, {"name": "Blair Janes", "phone": "416 893 6603", "location": "Added contacts"}, {"name": "Bogdan Ignatov", "phone": "1 306 341 0797", "location": "Added contacts"}, {"name": "Calvin Seger", "phone": "416 908 6013", "location": "Added contacts"}, {"name": "Carlo Cifelli", "phone": "647 280 8969", "location": "Added contacts"}, {"name": "Cary Smith", "phone": "416 806 0703", "location": "Added contacts"}, {"name": "Choi Ling Chow", "phone": "416 817 7009", "location": "Added contacts"}, {"name": "Christopher Agnew", "phone": "905 442 4504", "location": "Added contacts"}, {"name": "Cordell Richards", "phone": "905 409 8022", "location": "Added contacts"}, {"name": "Daulton Henry", "phone": "416 625 2644", "location": "Added contacts"}, {"name": "De Qing Wang", "phone": "647 861 3119", "location": "Added contacts"}, {"name": "Dejanco Antoniev", "phone": "416 854 6407", "location": "Added contacts"}, {"name": "Devon Reid", "phone": "437 246 7928", "location": "Added contacts"}, {"name": "Dexter Robin Siy", "phone": "647 202 5250", "location": "Added contacts"}, {"name": "Dominic Ho", "phone": "905 409 2880", "location": "Added contacts"}, {"name": "Donna Marchewa", "phone": "416 970 9274", "location": "Added contacts"}, {"name": "Dorian Yuen", "phone": "647 872 0391", "location": "Added contacts"}, {"name": "Ener Palao", "phone": "416 832 5270", "location": "Added contacts"}, {"name": "Eric Carter", "phone": "1 905 914 1536", "location": "Added contacts"}, {"name": "Eric Santiago", "phone": "647 204 2257", "location": "Added contacts"}, {"name": "Farhad Falahian", "phone": "437 255 0743", "location": "Added contacts"}, {"name": "Fitzjohn Lettman", "phone": "416 407 1013", "location": "Added contacts"}, {"name": "Francis White", "phone": "416 839 6085", "location": "Added contacts"}, {"name": "Frank Gopaulsingh", "phone": "416 660 6918", "location": "Added contacts"}, {"name": "Fred Kizito", "phone": "647 570 0209", "location": "Added contacts"}, {"name": "Gerry Bruce", "phone": "416 893 4772", "location": "Added contacts"}, {"name": "Giuseppe Amenta", "phone": "647 239 2138", "location": "Added contacts"}, {"name": "Glenn Talsky", "phone": "416 266 3079", "location": "Added contacts"}, {"name": "Gregory Manchester", "phone": "416 275 1595", "location": "Added contacts"}, {"name": "Guido Maiolo", "phone": "416 258 5730", "location": "Added contacts"}, {"name": "Gurmit Singh", "phone": "647 967 6400", "location": "Added contacts"}, {"name": "Gurpreet Banwait", "phone": "416 509 4550", "location": "Added contacts"}, {"name": "Hanif Patel", "phone": "416 707 4720", "location": "Added contacts"}, {"name": "Haoxin Yang", "phone": "416 727 0896", "location": "Added contacts"}, {"name": "Harinder Gill", "phone": "905 497 7373", "location": "Added contacts"}, {"name": "Harkanwal Dayal", "phone": "416 827 0466", "location": "Added contacts"}, {"name": "Harry Kainth", "phone": "647 680 5346", "location": "Added contacts"}, {"name": "Hartley Nagy", "phone": "416 710 4278", "location": "Added contacts"}, {"name": "Helena Bogovichenko", "phone": "647 327 7277", "location": "Added contacts"}, {"name": "Hugh Morrow", "phone": "416 219 7788", "location": "Added contacts"}, {"name": "Isaka Ouattara", "phone": "647 831 8700", "location": "Added contacts"}, {"name": "Jagdesh Singh", "phone": "416 876 9135", "location": "Added contacts"}, {"name": "Jaime Carmona", "phone": "647 261 1250", "location": "Added contacts"}, {"name": "Jairo Duarte Pinto", "phone": "647 303 5000", "location": "Added contacts"}, {"name": "James Dixon", "phone": "416 274 5525", "location": "Added contacts"}, {"name": "James Parrish", "phone": "519 321 1085", "location": "Added contacts"}, {"name": "Jamie Manger", "phone": "416 503 8054", "location": "Added contacts"}, {"name": "Japinder Sandhu", "phone": "647 393 1150", "location": "Added contacts"}, {"name": "Jasdev Shahi", "phone": "647 606 8341", "location": "Added contacts"}, {"name": "Jaswinder Singh", "phone": "647 642 9863", "location": "Added contacts"}, {"name": "Jatinder Gill", "phone": "416 671 8352", "location": "Added contacts"}, {"name": "Jean-Francois Cloutier", "phone": "1 514 898 3868", "location": "Added contacts"}, {"name": "Jeyaseelan Krishnamoorthy", "phone": "416 388 4772", "location": "Added contacts"}, {"name": "Jichuan Wang", "phone": "647 778 6395", "location": "Added contacts"}, {"name": "Jimmy Bokuluta", "phone": "437 991 7078", "location": "Added contacts"}, {"name": "Joe Krpan", "phone": "416 508 6925", "location": "Added contacts"}, {"name": "John Laflamme", "phone": "647 649 9052", "location": "Added contacts"}, {"name": "John Locke", "phone": "647 627 6138", "location": "Added contacts"}, {"name": "John Moore", "phone": "416 992 2737", "location": "Added contacts"}, {"name": "John Orleans", "phone": "416 799 7898", "location": "Added contacts"}, {"name": "John Vicic", "phone": "416 389 5920", "location": "Added contacts"}, {"name": "Jon Barker", "phone": "905 244 2458", "location": "Added contacts"}, {"name": "Jongoh Park", "phone": "416 509 9157", "location": "Added contacts"}, {"name": "Jordan Kloosterman", "phone": "705 527 3623", "location": "Added contacts"}, {"name": "Juan Olivares-Romo", "phone": "647 868 2814", "location": "Added contacts"}, {"name": "Judith Kumor", "phone": "647 580 8090", "location": "Added contacts"}, {"name": "Kamalinder Girn", "phone": "647 806 5100", "location": "Added contacts"}, {"name": "Kamaljit Parmar", "phone": "416 550 9634", "location": "Added contacts"}, {"name": "Kamaljit Saini", "phone": "647 779 1306", "location": "Added contacts"}, {"name": "Kanwarjit Bajwa", "phone": "416 716 0911", "location": "Added contacts"}, {"name": "Kenneth Gaudon", "phone": "647 545 3347", "location": "Added contacts"}, {"name": "Kevin Booker", "phone": "416 919 3236", "location": "Added contacts"}, {"name": "Kevin Charles", "phone": "416 450 7433", "location": "Added contacts"}, {"name": "Kimmeshia Williams", "phone": "647 700 6635", "location": "Added contacts"}, {"name": "Krzysztof Sakowicz", "phone": "647 293 2230", "location": "Added contacts"}, {"name": "Kuldip Grewal", "phone": "416 605 5990", "location": "Added contacts"}, {"name": "Kulwinder Jassal", "phone": "416 858 5367", "location": "Added contacts"}, {"name": "Lalit Gupta", "phone": "647 289 9028", "location": "Added contacts"}, {"name": "Laurence Edward", "phone": "416 858 9833", "location": "Added contacts"}, {"name": "Lee Stone", "phone": "647 268 1014", "location": "Added contacts"}, {"name": "Leon Granville", "phone": "416 843 7002", "location": "Added contacts"}, {"name": "Leroy Clayton", "phone": "416 566 2461", "location": "Added contacts"}, {"name": "Liang Wang", "phone": "905 928 7393", "location": "Added contacts"}, {"name": "Lovepreet Singh", "phone": "647 627 7566", "location": "Added contacts"}, {"name": "Luther Tracey", "phone": "647 833 7383", "location": "Added contacts"}, {"name": "Mahadeo Ramsarran", "phone": "416 933 7446", "location": "Added contacts"}, {"name": "Manjit Virk", "phone": "416 988 5906", "location": "Added contacts"}, {"name": "Mansher Dhillon-Singh", "phone": "647 448 5200", "location": "Added contacts"}, {"name": "Mark Zubkavich", "phone": "647 456 8062", "location": "Added contacts"}, {"name": "Martin Cruickshank", "phone": "365 994 7894", "location": "Added contacts"}, {"name": "Martin Raymond", "phone": "519 802 9972", "location": "Added contacts"}, {"name": "Mathew Augustine", "phone": "416 399 7596", "location": "Added contacts"}, {"name": "Mehdi Rahimi", "phone": "647 787 3760", "location": "Added contacts"}, {"name": "Michael Brown", "phone": "647 391 4521", "location": "Added contacts"}, {"name": "Michael Green", "phone": "416 899 0504", "location": "Added contacts"}, {"name": "Michael McBride", "phone": "905 431 3832", "location": "Added contacts"}, {"name": "Michael Richards", "phone": "289 933 0622", "location": "Added contacts"}, {"name": "Michel Vachon", "phone": "416 550 4920", "location": "Added contacts"}, {"name": "Miguel Rodriguez Garcia", "phone": "647 606 6093", "location": "Added contacts"}, {"name": "Mohammed Abdulaziz", "phone": "416 833 7126", "location": "Added contacts"}, {"name": "Mohammed Parekh", "phone": "416 704 4288", "location": "Added contacts"}, {"name": "Mohinder Chohan", "phone": "647 338 6756", "location": "Added contacts"}, {"name": "Mohsin Shaikh", "phone": "647 937 2746", "location": "Added contacts"}, {"name": "Muhammad Hassan", "phone": "647 448 0751", "location": "Added contacts"}, {"name": "Muhammad Khokhar", "phone": "647 289 0234", "location": "Added contacts"}, {"name": "Myles Marsh", "phone": "647 996 8719", "location": "Added contacts"}, {"name": "Neil Clark", "phone": "416 407 5434", "location": "Added contacts"}, {"name": "Nuheid Sherman", "phone": "416 473 9108", "location": "Added contacts"}, {"name": "Oleg Korzun", "phone": "306 5810898", "location": "Added contacts"}, {"name": "Orlando Newell", "phone": "647 786 4389", "location": "Added contacts"}, {"name": "Osei Simons", "phone": "647 888 1199", "location": "Added contacts"}, {"name": "Owen Robinson", "phone": "416 294 1025", "location": "Added contacts"}, {"name": "Parmdeep Singh Malhi", "phone": "437 998 9246", "location": "Added contacts"}, {"name": "Paul Chalmers", "phone": "647 618 5312", "location": "Added contacts"}, {"name": "Paul Hanson", "phone": "647 937 8837", "location": "Added contacts"}, {"name": "Paul Homier", "phone": "647 989 2072", "location": "Added contacts"}, {"name": "Paul Ormesher", "phone": "416 433 4132", "location": "Added contacts"}, {"name": "Pavel Shapiro", "phone": "647 268 8408", "location": "Added contacts"}, {"name": "Pawel Mieczkowski", "phone": "416 629 3541", "location": "Added contacts"}, {"name": "Qiang Liu", "phone": "416 716 1855", "location": "Added contacts"}, {"name": "Qingbin Wang", "phone": "437 235 3113", "location": "Added contacts"}, {"name": "Quosay Karim", "phone": "416 670 6054", "location": "Added contacts"}, {"name": "Rahul Sharma", "phone": "416 824 8687", "location": "Added contacts"}, {"name": "Rajah Seepersaud", "phone": "647 299 3824", "location": "Added contacts"}, {"name": "Rajbir Bhalla", "phone": "416 822 7453", "location": "Added contacts"}, {"name": "Rajesh Jha", "phone": "647 839 2001", "location": "Added contacts"}, {"name": "Rakesh Mottan", "phone": "416 565 7031", "location": "Added contacts"}, {"name": "Randy Murphy", "phone": "1 905 875 8059", "location": "Added contacts"}, {"name": "Renante Caban", "phone": "416 888 9830", "location": "Added contacts"}, {"name": "Richard Brinklow", "phone": "647 831 0026", "location": "Added contacts"}, {"name": "Richard Daniow", "phone": "416 897 2227", "location": "Added contacts"}, {"name": "Robert Lee", "phone": "416 746 5237", "location": "Added contacts"}, {"name": "Robert Martin", "phone": "289 200 2493", "location": "Added contacts"}, {"name": "Rohan Sewnarain", "phone": "416 818 1065", "location": "Added contacts"}, {"name": "Roldan Agulay", "phone": "416 988 7351", "location": "Added contacts"}, {"name": "Roman Dualsky", "phone": "416 731 8970", "location": "Added contacts"}, {"name": "Ron Norris", "phone": "647 628 2582", "location": "Added contacts"}, {"name": "Rondi Rathan", "phone": "647 717 6283", "location": "Added contacts"}, {"name": "Roy Jeresano", "phone": "647 686 3232", "location": "Added contacts"}, {"name": "Salvatore Nanfo", "phone": "416 908 4960", "location": "Added contacts"}, {"name": "Samuel Osei-Asante", "phone": "416 402 2072", "location": "Added contacts"}, {"name": "Shawn Patterson", "phone": "416 399 4731", "location": "Added contacts"}, {"name": "Simranpreet Singh", "phone": "437 996 0091", "location": "Added contacts"}, {"name": "Stephen Aldridge", "phone": "905 409 0704", "location": "Added contacts"}, {"name": "Steve Connolly", "phone": "1 289 698 6423", "location": "Added contacts"}, {"name": "Steve Thompson", "phone": "647 917 0854", "location": "Added contacts"}, {"name": "Sukhwinder Brar", "phone": "416 710 3005", "location": "Added contacts"}, {"name": "Sultan Ibrahim", "phone": "647 786 4389", "location": "Added contacts"}, {"name": "Tay Gieng", "phone": "469 616 8228", "location": "Added contacts"}, {"name": "Tejinder Singh", "phone": "647 838 4077", "location": "Added contacts"}, {"name": "Teugin Bastiampillai", "phone": "416 881 0626", "location": "Added contacts"}, {"name": "Theo Couvavas", "phone": "416 518 2056", "location": "Added contacts"}, {"name": "Thomas Murray", "phone": "416 992 0649", "location": "Added contacts"}, {"name": "Thomas Yosef", "phone": "416 368 3866", "location": "Added contacts"}, {"name": "Tuffour Agyemang-Prempe", "phone": "647 573 4542", "location": "Added contacts"}, {"name": "Victor Moran Hernandez", "phone": "647 400 2318", "location": "Added contacts"}, {"name": "Victor Quiroz", "phone": "416 737 5827", "location": "Added contacts"}, {"name": "Wayne Heathfield", "phone": "905 965 2868", "location": "Added contacts"}, {"name": "Will Alke", "phone": "647 200 4870", "location": "Added contacts"}, {"name": "William Sanford", "phone": "705 716 2628", "location": "Added contacts"}, {"name": "William Warner", "phone": "416 721 9455", "location": "Added contacts"}, {"name": "Yang Di", "phone": "905 846 5739", "location": "Added contacts"}, {"name": "Zdzislaw Bazydlo", "phone": "416 564 7543", "location": "Added contacts"}, {"name": "Zoltan Horvath", "phone": "416 889 3894", "location": "Added contacts"}]; function normalizePhone(v){ return String(v||'').replace(/[^\d+]/g,'').trim(); } function loadPhonebookContacts(){ try{ const raw = localStorage.getItem(PHONEBOOK_STORAGE_KEY); if(raw){ const arr=JSON.parse(raw); if(Array.isArray(arr)&&arr.length) return arr; } }catch(e){} const seen = new Set(); return DEFAULT_PHONEBOOK_CONTACTS.filter(c=>{ const k=(String(c.name||'').trim().toLowerCase()+'|'+normalizePhone(c.phone)); if(!normalizePhone(c.phone)||seen.has(k)) return false; seen.add(k); return true; }); } function savePhonebookContacts(arr){ try{ localStorage.setItem(PHONEBOOK_STORAGE_KEY, JSON.stringify(arr)); }catch(e){} } let phonebookContacts = loadPhonebookContacts(); function renderPhonebook(){ const list = document.getElementById('phonebookList'); const stats = document.getElementById('phonebookStats'); const search = document.getElementById('phonebookSearch'); if(!list) return; const q = String(search?.value||'').trim().toLowerCase(); const filtered = phonebookContacts.filter(c=> !q || [c.name,c.phone,c.location,c.title].some(v=> String(v||'').toLowerCase().includes(q))); if(stats) stats.textContent = `${filtered.length} contacts shown • ${phonebookContacts.length} total`; list.innerHTML = filtered.map((c,idx)=> `
${escapeHtml(String(c.name||''))}
${escapeHtml(String(c.location||c.title||''))}
${escapeHtml(String(c.phone||''))}
`).join(''); } function requirePhonebookPin(){ return window.prompt('Enter PIN to manage contacts') === PHONEBOOK_PIN; } function promptPhonebookContact(existing){ const name = window.prompt('Contact name', existing?.name || ''); if(name===null) return null; const phone = window.prompt('Phone number', existing?.phone || ''); if(phone===null) return null; const location = window.prompt('Title / location', existing?.location || existing?.title || ''); if(location===null) return null; return { name:String(name).trim(), phone:String(phone).trim(), location:String(location).trim() }; } function editPhonebookContact(index){ if(!requirePhonebookPin()) return; const current = phonebookContacts[index]; if(!current) return; const next = promptPhonebookContact(current); if(!next || !next.name || !next.phone) return; phonebookContacts[index] = next; savePhonebookContacts(phonebookContacts); renderPhonebook(); } function deletePhonebookContact(index){ if(!requirePhonebookPin()) return; const current = phonebookContacts[index]; if(!current) return; if(!window.confirm(`Delete ${current.name}?`)) return; phonebookContacts.splice(index,1); savePhonebookContacts(phonebookContacts); renderPhonebook(); } window.editPhonebookContact = editPhonebookContact; window.deletePhonebookContact = deletePhonebookContact; function placePhonebookBesideMap(){ return; } function initPhonebook(){ return; } function buildTerminalMarkerHtml(term, active){ return `
${escapeText(term.name)}
`; } function initTerminalMap(){ if (!document.body.classList.contains('terminal-popup-mode')) return; const wrap = document.getElementById('terminalGrid'); if (!wrap || terminalMap || !(window.L && L.map)) return; applySavedTerminalMapSize(); terminalMap = L.map(wrap, { zoomControl:true, attributionControl:true, scrollWheelZoom:true, worldCopyJump:false, maxBoundsViscosity:0.9 }); setTimeout(()=>{ try{ terminalMap.invalidateSize(true); }catch(e){} }, 120); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom:18, attribution:'© OpenStreetMap' }).addTo(terminalMap); terminalMap.setView([43.78, -79.76], 7); try{ terminalMap.dragging.enable(); }catch(e){} terminalHoverCircle = L.circleMarker([45.2,-81.2], { radius:16, color:'#46d6ff', weight:2, fillColor:'#46d6ff', fillOpacity:0.14, opacity:0 }).addTo(terminalMap); terminalHoverCircle.bringToBack(); bindTerminalMapResize(); try{ terminalMap.invalidateSize({animate:false}); }catch(e){} setTimeout(()=>{ try{ terminalMap.invalidateSize({animate:false}); }catch(e){} }, 120); terminalMap.on('contextmenu', async (evt)=>{ if (READ_ONLY) return; const lat = Number(evt?.latlng?.lat); const lng = Number(evt?.latlng?.lng); if (!Number.isFinite(lat) || !Number.isFinite(lng)) return; const name = String(window.prompt('New depot / terminal name for this map spot', '') || '').trim(); if (!name) return; const idBase = name.toUpperCase().replace(/[^A-Z0-9]+/g,'_').replace(/^_+|_+$/g,'').slice(0,24) || 'CUSTOM'; let id = idBase, i = 2; while (terminalState.some(t => String(t.id) === id)) { id = `${idBase}_${i++}`; } const rec = {id, name, region:'', manager:'', capacity:'', status:'Active', address:'', notes:'Added from map', lat, lng}; terminalState.push(rec); activeTerminalId = id; renderTerminalVista(); fillTerminalForm(rec); try{ saveStateToServerDebounced(); }catch(e){} const saved = await savePersistentTerminals(); flashToast(saved ? 'New location added to the map and saved.' : 'New location added. Saved in this browser; server save needs write access.'); }); } function renderTerminalVista(){ initTerminalMap(); initCloudhawkFrame(); placePhonebookBesideMap(); initPhonebook(); setTimeout(()=>{ try{ window.terminalMap?.invalidateSize?.(); }catch(e){} }, 120); setTimeout(()=>{ try{ window.__NHO_VIEW?.fit?.(); }catch(e){} }, 180); setTimeout(()=>{ try{ window.__NHO_VIEW?.fit?.(); }catch(e){} }, 520); populateTerminalSearchList(); const picker = document.getElementById('terminalPicker'); if (picker){ picker.innerHTML = terminalState.map(t => ``).join(''); if (activeTerminalId) picker.value = activeTerminalId; } if (!(window.L && terminalMap)) return; terminalMarkers.forEach(m => { try{ terminalMap.removeLayer(m); }catch(e){} }); terminalMarkers = []; const bounds = []; terminalState.forEach((term)=>{ if (!Number.isFinite(Number(term.lat)) || !Number.isFinite(Number(term.lng))) return; const icon = L.divIcon({ className:'terminal-leaflet-icon', html: buildTerminalMarkerHtml(term, term.id === activeTerminalId), iconSize:[66,72], iconAnchor:[33,72], popupAnchor:[0,-58] }); const marker = L.marker([term.lat, term.lng], {icon, title: term.name, riseOnHover:true, keyboard:true}).addTo(terminalMap); marker.on('mouseover', (evt)=>{ activeTerminalId = term.id; showTerminalTooltip(term, evt.originalEvent || evt); if (terminalHoverCircle){ terminalHoverCircle.setLatLng([term.lat, term.lng]); terminalHoverCircle.setStyle({opacity:1, fillOpacity:0.15}); } }); marker.on('mousemove', (evt)=> moveTerminalTooltip(evt.originalEvent || evt)); marker.on('mouseout', ()=>{ hideTerminalTooltip(); if (terminalHoverCircle) terminalHoverCircle.setStyle({opacity:0, fillOpacity:0}); }); marker.on('click', ()=>{ activeTerminalId = term.id; hideTerminalTooltip(); if (terminalHoverCircle) terminalHoverCircle.setStyle({opacity:0, fillOpacity:0}); if (!READ_ONLY) openTerminalModal(term.id); }); marker.on('contextmenu', async (evt)=>{ if (READ_ONLY || !frontHasPermission('rails.clear')) return; try{ if (evt && evt.originalEvent){ evt.originalEvent.preventDefault(); evt.originalEvent.stopPropagation(); } }catch(_e){} const pin = await showAppPrompt({ title:'Delete map location', message:`Enter PIN 1234 to remove ${term.name} from the map.`, value:'', placeholder:'PIN', compact:true, type:'password' }); if (pin === null) return; if (String(pin).trim() !== TERMINAL_DELETE_PIN){ flashToast('Incorrect PIN. Map location was not deleted.'); return; } terminalState = terminalState.filter(t => String(t.id) !== String(term.id)); if (!terminalState.length) terminalState = cloneTerminalDefaults(); if (!terminalState.find(t => String(t.id) === String(activeTerminalId))) activeTerminalId = terminalState[0]?.id || null; renderTerminalVista(); const saved = await savePersistentTerminals(); flashToast(saved ? 'Location removed from the map.' : 'Location removed in this browser; server save needs write access.'); }); terminalMarkers.push(marker); bounds.push([term.lat, term.lng]); }); if (bounds.length && !terminalBoundsSet){ const llb = L.latLngBounds(bounds); terminalMap.fitBounds(llb.pad(0.28), {animate:false, padding:[32,32]}); if ((terminalMap.getZoom()||0) > 6) terminalMap.setZoom(6, {animate:false}); terminalBoundsSet = true; } } function showTerminalTooltip(term, evt){ const tip = document.getElementById('terminalTooltip'); if (!tip || !term) return; tip.innerHTML = `

${escapeText(term.name)}

${escapeText(term.id)}
${escapeText(term.status)}
Region
${escapeText(term.region)}
Manager
${escapeText(term.manager)}
Capacity
${escapeText(term.capacity)}
Address / Yard
${escapeText(term.address)}
${escapeText(term.notes)}
`; tip.classList.add('show'); tip.setAttribute('aria-hidden','false'); moveTerminalTooltip(evt); } function moveTerminalTooltip(evt){ const tip = document.getElementById('terminalTooltip'); if (!tip || !evt) return; const clientX = evt.clientX ?? (evt.originalEvent && evt.originalEvent.clientX) ?? (window.innerWidth/2); const clientY = evt.clientY ?? (evt.originalEvent && evt.originalEvent.clientY) ?? (window.innerHeight/2); const pad = 18; const width = tip.offsetWidth || 400; const height = tip.offsetHeight || 220; const left = Math.min(window.innerWidth - width - pad, clientX + 18); const top = Math.min(window.innerHeight - height - pad, clientY + 18); tip.style.left = `${Math.max(pad, left)}px`; tip.style.top = `${Math.max(pad, top)}px`; } function hideTerminalTooltip(){ const tip = document.getElementById('terminalTooltip'); if (!tip) return; tip.classList.remove('show'); tip.setAttribute('aria-hidden','true'); } const HQ_COORDS = { lat:43.7017, lng:-79.5949, label:'325 Humberline Dr, Etobicoke, ON, Canada' }; function haversineKm(lat1,lng1,lat2,lng2){ const R=6371; const toRad=(d)=>d*Math.PI/180; const dLat=toRad(lat2-lat1), dLng=toRad(lng2-lng1); const a=Math.sin(dLat/2)**2 + Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLng/2)**2; return 2*R*Math.asin(Math.sqrt(a)); } function estimateDriveMinutesFromHq(term){ const km = haversineKm(HQ_COORDS.lat,HQ_COORDS.lng,Number(term.lat),Number(term.lng)); const minutes = Math.max(12, Math.round((km / 72) * 60 * 1.16)); return { km: Math.round(km), minutes }; } function findNextBestTerminal(term){ if (!term) return null; let best=null; terminalState.forEach(t=>{ if (t.id===term.id) return; const km = haversineKm(Number(term.lat),Number(term.lng),Number(t.lat),Number(t.lng)); if (!best || km < best.km) best={term:t, km:Math.round(km)}; }); return best; } const GOOGLE_MAPS_KEY_STORAGE_KEY = 'nho_google_maps_js_api_key_v1'; let googleMapsApiPromise = null; function getGoogleMapsApiKey(){ try{ return String(window.GOOGLE_MAPS_API_KEY || localStorage.getItem(GOOGLE_MAPS_KEY_STORAGE_KEY) || '').trim(); }catch(e){ return String(window.GOOGLE_MAPS_API_KEY || '').trim(); } } function setGoogleMapsApiKey(key){ const clean = String(key || '').trim(); try{ if (clean) localStorage.setItem(GOOGLE_MAPS_KEY_STORAGE_KEY, clean); else localStorage.removeItem(GOOGLE_MAPS_KEY_STORAGE_KEY); }catch(e){} return clean; } function promptForGoogleMapsApiKey(){ const key = window.prompt('Paste your Google Maps JavaScript API key for accurate routing.', getGoogleMapsApiKey()); if (key === null) return ''; return setGoogleMapsApiKey(key); } function loadGoogleMapsRoutingApi(){ if (window.google?.maps?.DirectionsService) return Promise.resolve(window.google.maps); if (googleMapsApiPromise) return googleMapsApiPromise; const apiKey = getGoogleMapsApiKey() || promptForGoogleMapsApiKey(); if (!apiKey) return Promise.reject(new Error('NO_GOOGLE_KEY')); googleMapsApiPromise = new Promise((resolve, reject)=>{ const existing = document.getElementById('nhoGoogleMapsScript'); const done = ()=> window.google?.maps?.DirectionsService ? resolve(window.google.maps) : reject(new Error('GOOGLE_MAPS_NOT_READY')); if (existing){ existing.addEventListener('load', done, {once:true}); existing.addEventListener('error', ()=> reject(new Error('GOOGLE_MAPS_LOAD_FAILED')), {once:true}); if (window.google?.maps?.DirectionsService) done(); return; } const cb = '__nhoGoogleMapsLoaded'; window[cb] = ()=>{ try{ delete window[cb]; }catch(e){ window[cb]=undefined; } done(); }; const script = document.createElement('script'); script.id = 'nhoGoogleMapsScript'; script.async = true; script.defer = true; script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(apiKey)}&loading=async&callback=${cb}`; script.onerror = ()=>{ googleMapsApiPromise = null; try{ delete window[cb]; }catch(e){ window[cb]=undefined; } reject(new Error('GOOGLE_MAPS_LOAD_FAILED')); }; document.head.appendChild(script); }).catch((err)=>{ googleMapsApiPromise = null; throw err; }); return googleMapsApiPromise; } function getDirectionsDurationText(leg){ return leg?.duration_in_traffic?.text || leg?.duration?.text || ''; } async function getGoogleDriveEta(origin, destination){ const maps = await loadGoogleMapsRoutingApi(); return await new Promise((resolve, reject)=>{ const svc = new maps.DirectionsService(); svc.route({ origin, destination, travelMode: maps.TravelMode.DRIVING, drivingOptions: { departureTime: new Date(), trafficModel: maps.TrafficModel.BEST_GUESS }, unitSystem: maps.UnitSystem.METRIC, provideRouteAlternatives: false }, (result, status)=>{ if (status !== 'OK' || !result?.routes?.[0]?.legs?.[0]) return reject(new Error(String(status || 'ROUTE_FAILED'))); const leg = result.routes[0].legs[0]; resolve({ distanceText: leg?.distance?.text || '', durationText: getDirectionsDurationText(leg), durationValue: Number(leg?.duration_in_traffic?.value || leg?.duration?.value || 0) }); }); }); } async function terminalActionFromTooltip(type, id){ const term = terminalById(id); if (!term) return; if (type==='hq'){ try{ const eta = await getGoogleDriveEta(HQ_COORDS.label, {lat:Number(term.lat), lng:Number(term.lng)}); window.alert(`${term.name} is about ${eta.distanceText} from HQ (${HQ_COORDS.label}). Google drive time: ${eta.durationText}.`); return; }catch(err){ const eta = estimateDriveMinutesFromHq(term); window.alert(`${term.name} is about ${eta.km} km from HQ (${HQ_COORDS.label}). Accurate Google routing needs your Google Maps JavaScript API key with Directions API enabled, so this screen is showing the local estimate right now: ${eta.minutes} minutes.`); return; } } if (type==='best'){ const best = findNextBestTerminal(term); if (!best) return; try{ const eta = await getGoogleDriveEta(HQ_COORDS.label, {lat:Number(best.term.lat), lng:Number(best.term.lng)}); window.alert(`Next best service option near ${term.name}: ${best.term.name} (${best.km} km away from ${term.name}). Google drive time from HQ: ${eta.durationText} (${eta.distanceText}).`); return; }catch(err){ const eta = estimateDriveMinutesFromHq(best.term); window.alert(`Next best service option near ${term.name}: ${best.term.name} (${best.km} km away from ${term.name}). Accurate Google routing needs your Google Maps JavaScript API key with Directions API enabled, so this screen is showing the local estimate right now: ${eta.minutes} minutes.`); return; } } } window.terminalActionFromTooltip = terminalActionFromTooltip; async function loadPersistentTerminals(){ const localPayload = readLocalTerminalPayload(); let remotePayload = null; try{ const res = await fetch('./terminal_data.php', {cache:'no-store'}); const data = await res.json(); if (data && Array.isArray(data.items) && data.items.length){ remotePayload = { items:data.items, savedAt:Number(data.savedAt || 0) }; } }catch(e){} const chosen = (()=>{ if (remotePayload && localPayload){ return Number(localPayload.savedAt || 0) > Number(remotePayload.savedAt || 0) ? localPayload : remotePayload; } return remotePayload || localPayload; })(); if (chosen && Array.isArray(chosen.items) && chosen.items.length){ applyTerminalsFromState(chosen.items); writeLocalTerminalPayload(chosen.items); try{ saveStateToServerDebounced(); }catch(e){ if (!optimisticSeenSelf) setViewerCount(Math.max(1, Number(badge.dataset.count || 0) || 0)); } } } async function savePersistentTerminals(){ writeLocalTerminalPayload(terminalState); try{ const res = await fetch('./terminal_data.php', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({items: terminalState, savedAt: Date.now()}), cache:'no-store' }); const data = await res.json().catch(()=>({})); if (!(res && res.ok && data && data.ok)) throw new Error('terminal_save_failed'); return true; }catch(e){ return false; } } async function addTerminalLocation(){ if (READ_ONLY) return; const name = String(window.prompt('New depot / terminal name', '') || '').trim(); if (!name) return; const lat = Number(window.prompt('Latitude for the map pin', '43.6532')); const lng = Number(window.prompt('Longitude for the map pin', '-79.3832')); if (!Number.isFinite(lat) || !Number.isFinite(lng)) { alert('Please enter valid latitude and longitude.'); return; } const idBase = name.toUpperCase().replace(/[^A-Z0-9]+/g,'_').replace(/^_+|_+$/g,'').slice(0,24) || 'CUSTOM'; let id = idBase, i = 2; while (terminalState.some(t => String(t.id) === id)) { id = `${idBase}_${i++}`; } const rec = {id, name, region:'', manager:'', capacity:'', status:'Active', address:'', notes:'', lat, lng}; terminalState.push(rec); activeTerminalId = id; renderTerminalVista(); fillTerminalForm(rec); try{ saveStateToServerDebounced(); }catch(e){} try{ await savePersistentTerminals(); }catch(e){} flashToast('Terminal added to the map.'); } function populateTerminalSearchList(){ return; } function focusTerminalBySearchValue(raw, liveMode){ const q = String(raw || '').trim().toUpperCase(); if (!(window.L && terminalMap)) return false; if (!q){ try{ hideTerminalTooltip(); }catch(e){} return false; } const scored = terminalState.map(t=>{ const name = String(t.name || '').trim().toUpperCase(); let score = 9999; if (name === q) score = 0; else if (name.startsWith(q)) score = 1; else if (name.includes(q)) score = 2 + name.indexOf(q); else { let shared = 0; for (const part of q.split(/\s+/).filter(Boolean)) if (name.includes(part)) shared += 1; score = shared ? 20 - shared : 9999; } return {t,score}; }).filter(x=>x.score < 9999).sort((a,b)=>a.score-b.score || a.t.name.localeCompare(b.t.name)); const match = scored[0]?.t; if (!match) return false; activeTerminalId = match.id; try{ hideTerminalTooltip(); }catch(e){} try{ terminalMap.invalidateSize({animate:false}); }catch(e){} terminalMap.flyTo([match.lat, match.lng], 15, {animate:true, duration: liveMode ? 0.45 : 0.8}); setTimeout(()=>{ try{ const marker = getTerminalMarkerById(match.id); const mapRect = terminalMap?.getContainer?.()?.getBoundingClientRect?.() || {left:window.innerWidth*0.55, top:120, width:480, height:320}; const latLng = marker?.getLatLng ? marker.getLatLng() : L.latLng(match.lat, match.lng); const point = {x:(mapRect.width||480)/2, y:(mapRect.height||320)/2}; if (terminalHoverCircle){ terminalHoverCircle.setLatLng([match.lat, match.lng]); terminalHoverCircle.setStyle({opacity:1, fillOpacity:0.15}); } showTerminalTooltip(match, {clientX: mapRect.left + point.x, clientY: mapRect.top + point.y}); }catch(e){ if (!optimisticSeenSelf) setViewerCount(Math.max(1, Number(badge.dataset.count || 0) || 0)); } }, liveMode ? 260 : 420); return true; } function fillTerminalForm(term){ const cur = term || terminalState[0]; if (!cur) return; activeTerminalId = cur.id; const picker = document.getElementById('terminalPicker'); if (picker) picker.value = cur.id; const set = (id, val)=>{ const node = document.getElementById(id); if (node) node.value = val || ''; }; set('terminalNameInput', cur.name); set('terminalRegionInput', cur.region); set('terminalManagerInput', cur.manager); set('terminalCapacityInput', cur.capacity); set('terminalStatusInput', cur.status); set('terminalAddressInput', cur.address); set('terminalLatInput', cur.lat); set('terminalLngInput', cur.lng); set('terminalNotesInput', cur.notes); } function openTerminalModal(id){ if (READ_ONLY) return; const modal = document.getElementById('terminalModal'); if (!modal) return; fillTerminalForm(terminalById(id || activeTerminalId)); modal.classList.add('show'); modal.setAttribute('aria-hidden','false'); } function closeTerminalModal(){ const modal = document.getElementById('terminalModal'); if (!modal) return; modal.classList.remove('show'); modal.setAttribute('aria-hidden','true'); } function saveActiveTerminal(){ if (READ_ONLY) return; const id = document.getElementById('terminalPicker')?.value || activeTerminalId; const idx = terminalState.findIndex(t => String(t.id) === String(id)); if (idx < 0) return; activeTerminalId = id; const grab = (nid)=> String(document.getElementById(nid)?.value || '').trim(); const latVal = Number(document.getElementById('terminalLatInput')?.value); const lngVal = Number(document.getElementById('terminalLngInput')?.value); terminalState[idx] = { ...terminalState[idx], id, name: grab('terminalNameInput') || terminalState[idx].name, region: grab('terminalRegionInput'), manager: grab('terminalManagerInput'), capacity: grab('terminalCapacityInput'), status: grab('terminalStatusInput'), address: grab('terminalAddressInput'), notes: grab('terminalNotesInput'), lat: Number.isFinite(latVal) ? latVal : terminalState[idx].lat, lng: Number.isFinite(lngVal) ? lngVal : terminalState[idx].lng }; renderTerminalVista(); try{ saveStateToServerDebounced(); }catch(e){} try{ savePersistentTerminals(); }catch(e){} } function getNextOccurrenceIsoForMinutes(mins){ const today = getTodayIso(); if (!Number.isFinite(Number(mins))) return today; return Number(mins) < getNowMinutes() ? addDaysIso(today, 1) : today; } function getPresetTargetDateIso(label){ const upper = String(label || '').toUpperCase(); if (upper.includes('PM')) return getTodayIso(); if (upper.includes('DAY')) return getTodayIso(); if (upper.includes('SUN')) return getTodayIso(); return getAmLoadTargetDateIso(); } function getAmLoadTargetDateIso(){ const now = getNowDate(); const today = getDateIsoLocal(now); const currentMinutes = (now.getHours() * 60) + now.getMinutes(); // AM button date rules: // - From 12:00 AM through 8:00 AM, use the CURRENT date so AM departures stay in Next up. // - From 8:01 AM through 11:59 PM, use the NEXT date so those same AM times are not treated as late. if (currentMinutes >= 0 && currentMinutes <= (8 * 60)) return today; return addDaysIso(today, 1); } function getLockedAmDateIsoForDoorTime(doorNum, timeMin){ try{ const meta = alertState.get(keyOf(doorNum, timeMin)) || {}; const iso = String(meta.fixedDateIso || '').trim(); return isValidIsoDate(iso) ? iso : ''; }catch(e){ return ''; } } function hasActiveRailDuplicateForLoad(baseDest, mins, dateIso){ const wantDest = String(baseDest || '').trim().toUpperCase(); const wantMin = Number(mins); const wantDate = String(dateIso || getTodayIso()); const existsIn = (map)=>{ try{ return Array.from(map.values()).some(entry=>{ const card = entry?.el; return String(card?.dataset?.destBase || card?.dataset?.dest || '').trim().toUpperCase() === wantDest && Number(card?.dataset?.tmin || entry?.data?.timeMin) === wantMin && String(card?.dataset?.tdate || entry?.data?.dateIso || getTodayIso()) === wantDate; }); }catch(e){ return false; } }; return existsIn(fiveItems) || existsIn(finalItems); } function ensureNextUpRailEntry(doorNum, dest, timeMin, colorKey){ const k = keyOf(doorNum, timeMin); const doorEl = document.querySelector(`.yard-grid .door[data-number="${doorNum}"]`) || document.querySelector(`.door[data-number="${doorNum}"]`); const base = String(doorEl?.dataset?.baseLabel || dest || '').toUpperCase(); const td = doorEl ? getDoorTimeDates(doorEl) : {}; const dateIso = td[canonicalDoorTimeFromMinutes(timeMin)] || getNextOccurrenceIsoForMinutes(timeMin); const obj = { door: String(doorNum), dest: composeVisibleLabel(base, colorKey), trip: tripNameForKey(colorKey), timeStr: canonicalDoorTimeFromMinutes(timeMin), timeMin: Number(timeMin), dateIso, isExtra: false, colorKey, base }; if (!fiveItems.has(k) && !finalItems.has(k)) addFiveAlert(obj); else syncAllRailCardsForDoor(doorNum); } function clearAmLoadedDeparturesFromBoardAndRails(){ const isAmLoadedKey = (k)=> { try{ return !!(alertState.get(k)?.amLoaded); }catch(e){ return false; } }; try{ Array.from(fiveItems.entries()).forEach(([k,entry])=>{ if (!isAmLoadedKey(k)) return; try{ entry?.el?.remove(); }catch(e){} try{ fiveItems.delete(k); }catch(e){} try{ alertState.delete(k); }catch(e){} }); Array.from(finalItems.entries()).forEach(([k,entry])=>{ if (!isAmLoadedKey(k)) return; try{ entry?.el?.remove(); }catch(e){} try{ finalItems.delete(k); }catch(e){} try{ alertState.delete(k); }catch(e){} }); }catch(e){} AM_DEPARTURES.forEach(item => { const mins = parseTimeToMinutes(item.time); if (mins === null) return; const door = document.querySelector(`.door[data-number="${item.door}"]`); if (!door) return; const timeStr = canonicalDoorTimeFromMinutes(mins); const amTimeStr = formatMinutesToAmPm(mins); const normal = toTimesArray(door.dataset.times).filter(t => { const nt = normalizeTimeDateKey(t); return nt !== normalizeTimeDateKey(timeStr) && nt !== normalizeTimeDateKey(amTimeStr); }); const extra = toTimesArray(door.dataset.extraTimes).filter(t => { const nt = normalizeTimeDateKey(t); return nt !== normalizeTimeDateKey(timeStr) && nt !== normalizeTimeDateKey(amTimeStr); }); setDoorTimes(door, normal, extra); const td = getDoorTimeDates(door); delete td[timeStr]; delete td[amTimeStr]; setDoorTimeDates(door, td); const tc = getDoorTimedComments(door); delete tc[timeStr]; delete tc[amTimeStr]; delete tc[String(mins)]; setDoorTimedComments(door, tc); refreshDoorLabel(door); }); } function reconcileAmLoadedDepartureDatesAndRails(){ try{ const targetDateIso = (typeof options.resolveTargetDateIso === 'function') ? String(options.resolveTargetDateIso() || '') : getPresetTargetDateIso(buttonLabel); const safeTargetDateIso = isValidIsoDate(targetDateIso) ? targetDateIso : getTodayIso(); let touched = false; const items = (window.__PAGE_MODE === "WEEKEND") ? [...(Array.isArray(AM_WEEKEND_DEPARTURES) ? AM_WEEKEND_DEPARTURES : [])] : [...(Array.isArray(AM_DEPARTURES) ? AM_DEPARTURES : [])]; for (const [presetIndex, item] of Array.from(items).entries()){ const mins = parseTimeToMinutes(item.time); if (mins === null) continue; const door = document.querySelector(`.door[data-number="${item.door}"]`); if (!door) continue; const timeStr = canonicalDoorTimeFromMinutes(mins); const amTimeStr = formatMinutesToAmPm(mins); const allTimes = [...toTimesArray(door.dataset.times), ...toTimesArray(door.dataset.extraTimes)].map(normalizeTimeDateKey); const hasTime = allTimes.includes(normalizeTimeDateKey(timeStr)) || allTimes.includes(normalizeTimeDateKey(amTimeStr)); if (!hasTime) continue; const k = keyOf(item.door, mins); const meta = alertState.get(k) || {}; const shouldControlDate = !!meta.amLoaded; if (!shouldControlDate) continue; const td = getDoorTimeDates(door); const currentIso = getTimeDateFromMap(td, timeStr, '') || getTimeDateFromMap(td, amTimeStr, ''); const preservedIso = isValidIsoDate(meta.fixedDateIso) ? String(meta.fixedDateIso) : (isValidIsoDate(currentIso) ? currentIso : targetDateIso); if ((td[timeStr] || '') !== preservedIso || (td[amTimeStr] || '') !== preservedIso){ td[timeStr] = preservedIso; td[amTimeStr] = preservedIso; setDoorTimeDates(door, td); touched = true; } try{ const pre = fiveItems.get(k); if (pre?.el) pre.el.remove(); fiveItems.delete(k); }catch(e){} try{ const fin = finalItems.get(k); if (fin?.el) fin.el.remove(); finalItems.delete(k); }catch(e){} try{ alertState.set(k, { ...(alertState.get(k) || {}), fiveShown:false, finalShown:false, enteredPre:false, amLoaded:true, dismissed:false, fixedDateIso: preservedIso }); }catch(e){} } if (touched){ try{ refreshAllRailCardsFromDoors(); }catch(e){} } try{ refreshAllRailUi(); }catch(e){} try{ updatePreCountdowns(); }catch(e){} try{ applyRailShiftFilter(CURRENT_RAIL_SHIFT_FILTER); }catch(e){ if (!optimisticSeenSelf) setViewerCount(Math.max(1, Number(badge.dataset.count || 0) || 0)); } }catch(e){} } function rebuildRailsStrictFromDoors(){ try{ Array.from(fiveItems.values()).forEach(v=>{ try{ v?.el?.remove(); }catch(e){} }); Array.from(finalItems.values()).forEach(v=>{ try{ v?.el?.remove(); }catch(e){} }); fiveItems.clear(); finalItems.clear(); for (const [k,v] of alertState.entries()){ alertState.set(k, { ...(v || {}), fiveShown: false, finalShown: false, enteredPre: false }); } _tickAlertsImpl(); try{ refreshAllRailUi(); }catch(e){} try{ updatePreCountdowns(); }catch(e){} try{ applyRailShiftFilter(CURRENT_RAIL_SHIFT_FILTER); }catch(e){ if (!optimisticSeenSelf) setViewerCount(Math.max(1, Number(badge.dataset.count || 0) || 0)); } }catch(e){} } function clearPresetGeneratedForPeriod(periodTag){ try{ const wanted = normalizePeriodTag(periodTag || ''); if (!wanted) return; document.querySelectorAll('.door').forEach(door=>{ if (!door || !door.dataset) return; const doorNum = Number(door.dataset.number || 0); const times = toTimesArray(door.dataset.times); const extra = toTimesArray(door.dataset.extraTimes); const td = getDoorTimeDates(door); const periods = getDoorTimePeriods(door); const timedComments = getDoorTimedComments(door); const keepByMeta = (arr)=>arr.filter(t=>{ const mins = parseTimeToMinutes(String(t)); if (mins === null) return true; const meta = alertState.get(keyOf(doorNum, mins)) || {}; return !(meta.amLoaded && normalizePeriodTag(meta.periodTag) === wanted); }); const keptTimes = keepByMeta(times); const keptExtra = keepByMeta(extra); [...times, ...extra].forEach(t=>{ const mins = parseTimeToMinutes(String(t)); if (mins === null) return; const meta = alertState.get(keyOf(doorNum, mins)) || {}; if (!(meta.amLoaded && normalizePeriodTag(meta.periodTag) === wanted)) return; const canonical = canonicalDoorTimeFromMinutes(mins); const ampm = formatMinutesToAmPm(mins); [canonical, ampm, String(mins)].forEach(k=>{ try{ delete td[k]; }catch(e){} try{ delete periods[k]; }catch(e){} try{ delete timedComments[k]; }catch(e){} }); try{ alertState.delete(keyOf(doorNum, mins)); }catch(e){} }); if (keptTimes.length !== times.length || keptExtra.length !== extra.length){ setDoorTimes(door, sortTimeStrings(keptTimes), sortTimeStrings(keptExtra)); setDoorTimeDates(door, td); setDoorTimePeriods(door, periods); setDoorTimedComments(door, timedComments); try{ refreshDoorLabel(door); }catch(e){} } const dupLoads = getDoorDuplicateLoads(door); if (dupLoads.length){ const keptDup = dupLoads.filter(dl=>!(dl.amLoaded && normalizePeriodTag(dl.periodTag) === wanted)); if (keptDup.length !== dupLoads.length){ dupLoads.forEach(dl=>{ if (dl.amLoaded && normalizePeriodTag(dl.periodTag) === wanted){ try{ alertState.delete(String(dl.railKey || '')); }catch(e){} } }); setDoorDuplicateLoads(door, keptDup); } } }); Array.from(fiveItems.entries()).forEach(([k,v])=>{ const meta = alertState.get(k) || v?.data || {}; if (meta.amLoaded && normalizePeriodTag(meta.periodTag) === wanted){ try{ v?.el?.remove(); }catch(e){} try{ fiveItems.delete(k); }catch(e){} } }); Array.from(finalItems.entries()).forEach(([k,v])=>{ const meta = alertState.get(k) || v?.data || {}; if (meta.amLoaded && normalizePeriodTag(meta.periodTag) === wanted){ try{ v?.el?.remove(); }catch(e){} try{ finalItems.delete(k); }catch(e){} } }); try{ refreshAllRailUi(); }catch(e){ if (!optimisticSeenSelf) setViewerCount(Math.max(1, Number(badge.dataset.count || 0) || 0)); } }catch(e){} } function clearAllRailsAndDoorTimesForAmLoad(){ clearAmLoadedDeparturesFromBoardAndRails(); } function removeAmDepartureRailEntries(){ AM_DEPARTURES.forEach(item => { const mins = parseTimeToMinutes(item.time); if (mins === null) return; const k = keyOf(item.door, mins); try{ const pre = fiveItems.get(k); if (pre?.el) pre.el.remove(); fiveItems.delete(k); }catch(e){} try{ const fin = finalItems.get(k); if (fin?.el) fin.el.remove(); finalItems.delete(k); }catch(e){ if (!optimisticSeenSelf) setViewerCount(Math.max(1, Number(badge.dataset.count || 0) || 0)); } }); } function getWorkflowSnapshotStorageKey(kind){ return String(kind || '').toLowerCase() === 'pm' ? 'workflow_pm_snapshot_v1' : 'workflow_am_snapshot_v1'; } function collectActiveOperationalBoardState(){ const snap = (typeof collectDoorSnapshot === 'function') ? collectDoorSnapshot() : { doors:{}, yard:{}, notesMap:{} }; const hasOperationalData = (rec)=>{ if (!rec || rec.type !== 'door') return false; try{ return !!( String(rec.baseLabel || '').trim() || String(rec.colorKey || '').trim() || String(rec.times || '').trim() || String(rec.extraTimes || '').trim() || String(rec.notes || '').trim() || (rec.notesByTime && Object.keys(rec.notesByTime).length) || (Array.isArray(rec.duplicateLoads) && rec.duplicateLoads.length) ); }catch(e){ return false; } }; const keep = { version:1, doors:{}, yard:{}, notesMap: snap.notesMap || {} }; try{ Object.entries(snap.doors || {}).forEach(([k,v])=>{ if (hasOperationalData(v)) keep.doors[k] = v; }); }catch(e){} try{ Object.entries(snap.yard || {}).forEach(([k,v])=>{ if (hasOperationalData(v)) keep.yard[k] = v; }); }catch(e){} return keep; } function restoreActiveOperationalBoardState(state){ if (!state) return; const ensureDoorAt = (num, isYard, side, fallbackBase)=>{ let doorEl = document.querySelector(`${isYard ? '.yard-grid ' : ''}.door[data-number="${num}"]`); if (doorEl) return doorEl; const slot = document.querySelector(`${isYard ? '.yard-grid ' : ''}.empty-slot[data-number="${num}"]`); doorEl = createDoor(Number(num), isYard ? 'left' : (side || getSideForNumber(Number(num)))); if (isYard){ doorEl.dataset.side = 'yard'; doorEl.classList.add('yard-door'); } if (slot) slot.replaceWith(doorEl); else if (isYard){ document.getElementById('yardGrid')?.appendChild(doorEl); } else { stackForDoor(Number(num), side || getSideForNumber(Number(num)))?.appendChild(doorEl); } if (fallbackBase && !String(doorEl.dataset.baseLabel || '').trim()) doorEl.dataset.baseLabel = String(fallbackBase || ''); return doorEl; }; const applyOperational = (doorEl, rec)=>{ if (!doorEl || !rec || rec.type !== 'door') return; try{ if (!String(doorEl.dataset.baseLabel || '').trim() && rec.baseLabel) doorEl.dataset.baseLabel = String(rec.baseLabel || ''); }catch(e){} try{ setDoorTimes(doorEl, toTimesArray(rec.times || ''), toTimesArray(rec.extraTimes || '')); }catch(e){ doorEl.dataset.times = rec.times || ''; doorEl.dataset.extraTimes = rec.extraTimes || ''; } try{ doorEl.dataset.notes = sanitizeTripNoteText(rec.notes || ''); }catch(e){ doorEl.dataset.notes = rec.notes || ''; } try{ doorEl.dataset.notesByTime = JSON.stringify(sanitizeNotesByTimeMap(rec.notesByTime || {})); }catch(e){ doorEl.dataset.notesByTime = '{}'; } try{ setDoorDuplicateLoads(doorEl, rec.duplicateLoads || []); }catch(e){} try{ setDoorTimeDates(doorEl, rec.timeDates || rec.timeDatesByMin || {}); }catch(e){} try{ setDoorTimePeriods(doorEl, rec.timePeriods || {}); }catch(e){} try{ setDoorTimeColorMap(doorEl, rec.timeColorMap || {}); }catch(e){} try{ refreshDoorLabel(doorEl); }catch(e){ if (!optimisticSeenSelf) setViewerCount(Math.max(1, Number(badge.dataset.count || 0) || 0)); } }; try{ Object.entries(state.doors || {}).forEach(([k,v])=> applyOperational(ensureDoorAt(k, false, v.side, v.baseLabel), v)); }catch(e){} try{ Object.entries(state.yard || {}).forEach(([k,v])=> applyOperational(ensureDoorAt(k, true, 'yard', v.baseLabel), v)); }catch(e){} try{ if (state.notesMap){ notesMap = new Map(); Object.entries(state.notesMap).forEach(([k,v])=>{ if (v != null && String(v).trim()) notesMap.set(String(k), String(v)); }); } }catch(e){} } async function loadWorkflowSnapshot(kind){ if (READ_ONLY) return; try{ const upper = String(kind || '').toUpperCase(); const key = getWorkflowSnapshotStorageKey(kind); let snap = await fetchWorkflowSnapshotServer(kind); if (!snap){ const raw = localStorage.getItem(key); snap = raw ? JSON.parse(raw) : null; } if (!snap){ flashToast(`No saved ${upper} setup found.`); return; } if (!snap || !snap.doors){ flashToast(`Saved ${upper} setup is invalid.`); return; } const liveOps = collectActiveOperationalBoardState(); try{ localStorage.setItem(key, JSON.stringify(snap)); }catch(e){} try{ applyWorkflowLayoutSnapshot(snap); }catch(e){} try{ restoreActiveOperationalBoardState(liveOps); }catch(e){} try{ cleanupOrphanYardTrips(); }catch(e){} try{ tickRails(); }catch(e){} try{ refreshAllRailUi(); }catch(e){} try{ setCurrentSetupIndicator(upper); }catch(e){} try{ persistCurrentSnapshotNow(); }catch(_e){ try{ saveStateToServerDebounced(); }catch(__e){} } try{ flashToast(`${upper} setup loaded.`); }catch(_e){} }catch(err){ flashToast(`Unable to load ${String(kind || '').toUpperCase()} setup.`); } } async function loadAMDepartures(list = AM_DEPARTURES, options = {}){ if (READ_ONLY) return; if (window.__amLoadInProgress) return; const items = Array.isArray(list) ? list : AM_DEPARTURES; const forceNextUp = !!options.forceNextUp; const buttonId = options.buttonId || 'loadAmBtn'; const buttonLabel = options.label || 'AM'; const approved = await showAppConfirm({ title:`${buttonLabel} departures`, message:`Load ${items.length} ${buttonLabel} departures to the Next up rail? Existing rail items will remain.`, okText:'Load', cancelText:'Cancel', compact:true }); if (!approved) return; window.__amLoadInProgress = true; const amBtn = document.getElementById(buttonId); const prevText = amBtn ? amBtn.textContent : ''; try{ if (amBtn){ amBtn.disabled = true; amBtn.textContent = 'Loading…'; } const targetDateIso = (typeof options.resolveTargetDateIso === 'function') ? String(options.resolveTargetDateIso() || '') : getPresetTargetDateIso(buttonLabel); const safeTargetDateIso = isValidIsoDate(targetDateIso) ? targetDateIso : getTodayIso(); let assignedCount = 0; const setupTag = String(buttonLabel || '').toUpperCase(); const presetTripMap = (buttonLabel === 'AM' && window.__NHO_AM_PRESET_TRIPS && window.__NHO_AM_PRESET_TRIPS.readTrips) ? window.__NHO_AM_PRESET_TRIPS.readTrips() : {}; // v175 presetTripNumber const activePeriodTag = String(buttonLabel || '').toUpperCase().includes('SUN') ? 'SUNMON' : (String(buttonLabel || '').toUpperCase().includes('DAY') ? 'DAY' : (String(buttonLabel || '').toUpperCase().includes('PM') ? 'PM' : 'AM')); try{ clearPresetGeneratedForPeriod(activePeriodTag); }catch(e){} try{ if (activePeriodTag === 'AM' || activePeriodTag === 'PM') setCurrentSetupIndicator(activePeriodTag); }catch(e){} for (const [presetIndex, item] of Array.from(items).entries()){ const door = findOrCreateDoorByNumber(item.door); const mins = parseTimeToMinutes(item.time); if (mins === null) continue; const timeStr = canonicalDoorTimeFromMinutes(mins); const amTimeStr = formatMinutesToAmPm(mins); const baseDest = String(item.dest || '').trim().toUpperCase(); const existingBaseLabel = String(door.dataset.baseLabel || '').trim().toUpperCase(); const colorKey = (forceNextUp ? 'blue' : '') || String(item.colorKey || '').trim() || suggestTripTypeFromText(baseDest) || door.dataset.colorKey || ''; const periodTag = activePeriodTag; const existingDoorTimes = [...toTimesArray(door.dataset.times), ...toTimesArray(door.dataset.extraTimes)]; const existingDupLoads = getDoorDuplicateLoads(door); const doorHasExistingLoads = existingDoorTimes.length > 0 || existingDupLoads.length > 0; const conflictingDoorBase = !!doorHasExistingLoads && !!existingBaseLabel && existingBaseLabel !== baseDest; if (!conflictingDoorBase){ setDoorBaseLabel(door, baseDest); if (colorKey) setDoorColor(door, colorKey); } const td = getDoorTimeDates(door); const existingDateIso = getTimeDateFromMap(td, timeStr, '') || getTimeDateFromMap(td, amTimeStr, ''); const existingPeriods = getDoorTimePeriods(door); const existingPeriod = getTimePeriodTagFromMap(existingPeriods, timeStr, inferPeriodTagFromMinutes(mins)); const sameBaseExists = existingDoorTimes.some(t => parseTimeToMinutes(String(t)) === mins && String(getTimeDateFromMap(td, t, safeTargetDateIso)) === String(safeTargetDateIso)); const dupLoads = existingDupLoads; const sameDupSamePeriod = dupLoads.find(dl => Number(dl.timeMin) === Number(mins) && String(dl.dateIso) === String(safeTargetDateIso) && String(dl.destBase||'').trim().toUpperCase() === baseDest && normalizePeriodTag(dl.periodTag) === normalizePeriodTag(periodTag)); const baseMatchesWanted = !conflictingDoorBase && sameBaseExists && String(existingDateIso || safeTargetDateIso) === String(safeTargetDateIso) && String(door.dataset.baseLabel || '').trim().toUpperCase() === baseDest && normalizePeriodTag(existingPeriod) === normalizePeriodTag(periodTag); if (baseMatchesWanted || sameDupSamePeriod){ assignedCount += 1; continue; } const incomingNote = String(item.notes || '').trim(); const isBrantfordWeekendFourAm = baseDest === 'BRANTFORD' && mins === 240 && forceNextUp; if (isBrantfordWeekendFourAm){ try{ const existingParts = String(door.dataset.notes || '').split('•').map(v => sanitizeTripNoteText(v)).map(v => String(v || '').trim()).filter(Boolean); const cleanedParts = existingParts.filter(v => v.toLowerCase() !== 'internal trip'); door.dataset.notes = sanitizeTripNoteText(cleanedParts.join(' • ')); }catch(e){} } else if (incomingNote){ try{ const existingParts = String(door.dataset.notes || '').split('•').map(v => sanitizeTripNoteText(v)).map(v => String(v || '').trim()).filter(Boolean); const mergedParts = [...existingParts, incomingNote].filter(Boolean); const seen = new Set(); const deduped = mergedParts.filter(v => { const key = v.toLowerCase(); if (seen.has(key)) return false; seen.add(key); return true; }); door.dataset.notes = sanitizeTripNoteText(deduped.join(' • ')); }catch(e){} } if (!sameBaseExists && !conflictingDoorBase){ const existingTimes = sortTimeStrings(toTimesArray(door.dataset.times)); const existingExtras = sortTimeStrings(toTimesArray(door.dataset.extraTimes)); setDoorTimes(door, sortTimeStrings([...existingTimes, amTimeStr]), existingExtras); td[timeStr] = safeTargetDateIso; td[amTimeStr] = safeTargetDateIso; td[String(mins)] = safeTargetDateIso; setDoorTimeDates(door, td); const periods = getDoorTimePeriods(door); periods[timeStr] = periodTag; periods[amTimeStr] = periodTag; periods[String(mins)] = periodTag; setDoorTimePeriods(door, periods); const stdKey = keyOf(item.door, mins); alertState.set(stdKey, { ...(alertState.get(stdKey) || {}), amLoaded: true, dismissed: false, fiveShown: false, finalShown: false, enteredPre: false, periodTag, fixedDateIso: safeTargetDateIso, tripNumber: (item && item.tripNumber) ? String(item.tripNumber) : ((presetTripMap && presetTripMap[presetIndex]) ? String(presetTripMap[presetIndex]) : '') }); } else { const railKey = `dup|${item.door}|${mins}|${periodTag}|${nowMs()}|${Math.random().toString(36).slice(2,7)}`; const uid = getStableRailCardUid(railKey); appendDoorDuplicateLoad(door, { railKey, uid, timeMin: mins, timeStr: amTimeStr, dateIso: safeTargetDateIso, periodTag, destBase: baseDest, colorKey, isExtra: false, amLoaded: true, setupTag, tripNumber: (item && item.tripNumber) ? String(item.tripNumber) : ((presetTripMap && presetTripMap[presetIndex]) ? String(presetTripMap[presetIndex]) : '') }); alertState.set(railKey, { ...(alertState.get(railKey) || {}), amLoaded: true, dismissed: false, fiveShown: false, finalShown: false, enteredPre: false, periodTag, fixedDateIso: safeTargetDateIso, tripNumber: (item && item.tripNumber) ? String(item.tripNumber) : ((presetTripMap && presetTripMap[presetIndex]) ? String(presetTripMap[presetIndex]) : '') }); } refreshDoorLabel(door); assignedCount += 1; } try{ rebuildRailsStrictFromDoors(); }catch(e){} try{ refreshAllRailUi(); }catch(e){} try{ updatePreCountdowns(); }catch(e){} const totalRail = fiveItems.size + finalItems.size; console.log('AM/PM load complete', {assignedCount, expected: items.length, totalRail, mode: 'next-up-dedicated'}); try{ if (typeof collectFullSnapshot === 'function') await saveStateToServer(collectFullSnapshot()); else saveStateToServerDebounced(); }catch(e){ if (!optimisticSeenSelf) setViewerCount(Math.max(1, Number(badge.dataset.count || 0) || 0)); } }catch(err){ console.error('AM load failed', err); try{ alert(`AM load failed: ${err && err.message ? err.message : err}`); }catch(e){ if (!optimisticSeenSelf) setViewerCount(Math.max(1, Number(badge.dataset.count || 0) || 0)); } }finally{ if (amBtn){ amBtn.disabled = false; amBtn.textContent = prevText || buttonLabel; } window.__amLoadInProgress = false; } } function getAdminSessionToken(){ try{ let token = localStorage.getItem(ADMIN_TOKEN_KEY); if (!token){ token = `admin_${Math.random().toString(36).slice(2)}_${Date.now()}`; localStorage.setItem(ADMIN_TOKEN_KEY, token); } return token; }catch(e){ return `admin_${Date.now()}`; } } async function adminLockRequest(action, extra={}){ const token = getAdminSessionToken(); const body = { action, token, ua: navigator.userAgent || '', ...extra }; const res = await fetch(ADMIN_LOCK_URL, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body), cache:'no-store', keepalive: action === 'release' || action === 'heartbeat' }); return res.ok ? res.json() : { ok:false, message:'Unable to reach admin lock service.' }; } async function acquireAdminLock(options={}){ try{ const force = !!options.force; const password = String(options.password || ''); return await adminLockRequest(force ? 'force_acquire' : 'acquire', force ? { password } : {}); }catch(e){ return { ok:false, message:'Unable to acquire admin session.' }; } } async function releaseAdminLock(){ try{ return await adminLockRequest('release'); }catch(e){ return { ok:false }; } } function startAdminHeartbeat(){ if (READ_ONLY || !frontHasPermission('app.admin')) return; stopAdminHeartbeat(); try{ touchAdminWindowPresence(); }catch(e){} __adminHeartbeatTimer = setInterval(async ()=>{ try{ const res = await adminLockRequest('heartbeat'); if (!res || !res.ok){ try{ console.warn('Admin heartbeat missed; keeping admin session active on this screen.', res); }catch(e){} try{ touchAdminWindowPresence(); }catch(e){} return; } try{ touchAdminWindowPresence(); }catch(e){} }catch(e){ if (!optimisticSeenSelf) setViewerCount(Math.max(1, Number(badge.dataset.count || 0) || 0)); } }, 10000); } function stopAdminHeartbeat(){ if (__adminHeartbeatTimer){ clearInterval(__adminHeartbeatTimer); __adminHeartbeatTimer = null; } } /* ========================= applyDoorSnapshot patch: - Wing 1 + Wing 2 + Yard - avoids full clears (reduces flicker) ========================= */ function stackForDoor(n, side){ if (n >= 241 && n <= 276) return document.getElementById("leftStack2"); if (n >= 201 && n <= 238) return document.getElementById("rightStack2"); if (n >= 141 && n <= 177) return document.getElementById("leftStack"); if (n >= 101 && n <= 137) return document.getElementById("rightStack"); return (side === "right") ? document.getElementById("rightStack") : document.getElementById("leftStack"); } const __origApplyDoorSnapshot = applyDoorSnapshot; applyDoorSnapshot = function(state){ if (!state || !state.doors) return; __syncApplying = true; try{ applyCurrentSetupValue(state.currentSetup || '', { skipLocalStorage:false }); }catch(e){} for (const [numStr, rec] of Object.entries(state.doors)){ const n = Number(numStr); const currentDoor = document.querySelector(`.door[data-number="${n}"]`); const currentSlot = document.querySelector(`.empty-slot[data-number="${n}"]`); if (!rec || rec.type === "empty"){ if (currentDoor){ currentDoor.replaceWith(createEmptySlot(n)); } else if (!currentSlot) { stackForDoor(n, getSideForNumber(n))?.appendChild(createEmptySlot(n)); } continue; } let doorEl = currentDoor; if (!doorEl){ doorEl = createDoor(n, rec.side || getSideForNumber(n)); if (currentSlot) currentSlot.replaceWith(doorEl); else stackForDoor(n, rec.side || getSideForNumber(n))?.appendChild(doorEl); } doorEl.dataset.side = rec.side || doorEl.dataset.side || getSideForNumber(n); doorEl.dataset.baseLabel = rec.baseLabel || ''; doorEl.dataset.colorKey = rec.colorKey || ''; doorEl.dataset.oos = rec.oos ? 'true' : 'false'; doorEl.dataset.times = rec.times || ''; doorEl.dataset.extraTimes = rec.extraTimes || ''; doorEl.dataset.doubleDoor = rec.doubleDoor || ''; doorEl.dataset.notes = sanitizeTripNoteText(rec.notes || ''); doorEl.dataset.notesByTime = rec.notesByTime ? JSON.stringify(sanitizeNotesByTimeMap(rec.notesByTime)) : (doorEl.dataset.notesByTime || "{}"); setDoorDuplicateLoads(doorEl, rec.duplicateLoads || []); setDoorTimeDates(doorEl, rec.timeDates || rec.timeDatesByMin || {}); setDoorTimePeriods(doorEl, rec.timePeriods || {}); setDoorColor(doorEl, doorEl.dataset.colorKey || ''); setDoorOUStrip(doorEl, doorEl.dataset.oos === 'true'); refreshDoorLabel(doorEl); } // Yard patch try{ initYardGrid(); const grid = document.getElementById("yardGrid"); if (grid){ for (let i=1;i<=YARD_SLOTS;i++){ const num = yardNumFromIndex(i); const yd = state.yard ? state.yard[num] : null; let existing = grid.querySelector(`[data-number="${num}"]`); if (!existing){ existing = createEmptySlot(Number(num)); grid.appendChild(existing); } const isDoor = yd && yd.type === "door"; const needsDoor = isDoor && !existing.classList.contains("door"); const needsSlot = !isDoor && !existing.classList.contains("empty-slot"); if (needsDoor || needsSlot){ const repl = isDoor ? createDoor(Number(num), "left") : createEmptySlot(Number(num)); if (isDoor){ repl.dataset.side = "yard"; repl.classList.add("yard-door"); setDoorBaseLabel(repl, (yd.baseLabel||"")); repl.dataset.notes = sanitizeTripNoteText(yd.notes||""); repl.dataset.notesByTime = yd.notesByTime ? JSON.stringify(sanitizeNotesByTimeMap(yd.notesByTime)) : "{}"; setDoorDuplicateLoads(repl, yd.duplicateLoads || []); setDoorTimePeriods(repl, yd.timePeriods || {}); setDoorTimeColorMap(repl, yd.timeColorMap || {}); setDoorColor(repl, yd.colorKey||""); setDoorTimes(repl, (yd.times||"").split("|").filter(Boolean), (yd.extraTimes||"").split("|").filter(Boolean)); setDoorTimeDates(repl, yd.timeDates || yd.timeDatesByMin || {}); setDoorOOS(repl, !!yd.oos); repl.dataset.doubleDoor = yd.doubleDoor || ""; refreshDoorLabel(repl); } existing.replaceWith(repl); } else if (isDoor && existing.classList.contains("door")){ setDoorBaseLabel(existing, (yd.baseLabel||"")); existing.dataset.notes = sanitizeTripNoteText(yd.notes||""); existing.dataset.notesByTime = yd.notesByTime ? JSON.stringify(sanitizeNotesByTimeMap(yd.notesByTime)) : (existing.dataset.notesByTime || "{}"); setDoorDuplicateLoads(existing, yd.duplicateLoads || []); setDoorTimePeriods(existing, yd.timePeriods || {}); setDoorTimeColorMap(existing, yd.timeColorMap || {}); setDoorColor(existing, yd.colorKey||""); setDoorTimes(existing, (yd.times||"").split("|").filter(Boolean), (yd.extraTimes||"").split("|").filter(Boolean)); setDoorTimeDates(existing, yd.timeDates || yd.timeDatesByMin || {}); setDoorOOS(existing, !!yd.oos); existing.dataset.doubleDoor = yd.doubleDoor || ""; refreshDoorLabel(existing); } } } }catch(e){} try{ applyTerminalsFromState(state.terminals); }catch(e){} // Rails (existing function) try{ restoreRailsFromSnapshot(state); }catch(e){} try{ updatePreCountdowns(); }catch(e){} try{ startLiveTimers(); }catch(e){} __syncApplying = false; }; (function(){ function forceAllRailCardsVisible(){ try{ const bar = document.getElementById('shiftFilterBar'); if (bar) bar.remove(); }catch(e){} try{ window.CURRENT_RAIL_SHIFT_FILTER = 'ALL'; }catch(e){} try{ document.querySelectorAll('.shift-filter-btn').forEach(btn=>btn.classList.remove('active')); const allBtn = document.querySelector('.shift-filter-btn[data-shift-filter="ALL"]'); if (allBtn) allBtn.classList.add('active'); }catch(e){} try{ document.querySelectorAll('#fiveList .rail-card, #finalList .rail-card, .mobile-rail-panel .rail-card').forEach(card=>{ card.style.display = ''; card.hidden = false; card.classList.remove('rail-card-hidden-by-filter'); }); }catch(e){ if (!optimisticSeenSelf) setViewerCount(Math.max(1, Number(badge.dataset.count || 0) || 0)); } } window.applyRailShiftFilter = function(){ forceAllRailCardsVisible(); }; function getNormalizedSetupKind(kind){ const up = String(kind||'').trim().toUpperCase(); return (up === 'AM' || up === 'PM') ? up : ''; } function rebindTopPresetButtons(){ const rebind = (id, handler)=>{ const btn = document.getElementById(id); if (!btn || btn.__customPresetBound) return; const clone = btn.cloneNode(true); btn.parentNode.replaceChild(clone, btn); clone.__customPresetBound = true; clone.addEventListener('click', handler); }; rebind('loadAmBtn', ()=> requirePassword(async ()=>{ if (getNormalizedSetupKind(getCurrentSetupValue()) !== 'AM'){ try{ flashToast('Load AM setup first.'); }catch(e){} return; } try{ await loadAMDepartures(AM_DEPARTURES, {forceNextUp:true, buttonId:'loadAmBtn', label:'AM'}); }catch(e){ console.error(e); } })); rebind('loadPmBtn', ()=> requirePassword(async ()=>{ if (getNormalizedSetupKind(getCurrentSetupValue()) !== 'PM'){ try{ flashToast('Load PM setup first.'); }catch(e){} return; } try{ await loadAMDepartures(PM_DEPARTURES, {forceNextUp:true, buttonId:'loadPmBtn', label:'PM', resolveTargetDateIso:()=>getTodayIso()}); }catch(e){ console.error(e); } })); } if (document.readyState === 'loading'){ document.addEventListener('DOMContentLoaded', ()=>{ forceAllRailCardsVisible(); rebindTopPresetButtons(); }); } else { forceAllRailCardsVisible(); rebindTopPresetButtons(); } })();