build 714
Kennedy Wilson
Multifamily
Loading...
Kennedy Wilson
Multifamily
Type to search
LIVE NEWS
Kennedy Wilson
Multifamily
Loading...
+Math.round(ppsf)+'/sf');var _asf=+c.avg_sf||((c.nrsf&&c.units)?Math.round(+c.nrsf/+c.units):0);if(_asf>200&&_asf<5000)l3.push(_srNum(_asf)+' SF/u'); if(c.acq_cap){var cap=c.acq_cap>=1?c.acq_cap:c.acq_cap*100;if(cap>0&&cap<=200)l3.push(cap.toFixed(2)+'% cap');} var bs=(c.seller||c.buyer)?(_srEsc(c.seller||'?')+' → '+_srEsc(c.buyer||'?')):''; var coe=_srFmtCOE(c.coe); return '
' +'
' +''+_srEsc(c.property_name||'(unnamed)')+'' +'COE '+coe+'' +'
' +(l2.length?'
'+l2.join(' · ')+'
':'') +(l3.length?'
'+l3.join(' · ')+'
':'') +(bs?'
'+bs+'
':'') +'
'; } /* ── render dispatch ─────────────────────────────────────────────── */ function shellRecentRender(){ var body=document.getElementById('shellRecentBody'); if(!body)return; if(SR_TAB==='comps'){ if(!_srCompsLoaded){ body.innerHTML='
Loading sales comps…
'; loadShellComps().then(function(){if(SR_TAB==='comps')shellRecentRender();}); return; } return shellRenderComps(body); } if(!_srActLoaded){ body.innerHTML='
Loading recent activity…
'; loadShellActivity().then(function(){if(SR_TAB==='activity')shellRecentRender();}); return; } shellRenderActivity(body); } function shellRenderActivity(body){ var filtered=SR_ACTIONS; if(SR_SEARCH){ var q=SR_SEARCH; filtered=SR_ACTIONS.filter(function(r){ var blob=[r.property_name,r.deal_name,r.user_email,r.field,r.type,r.source,r.summary,r.before,r.after].map(function(v){return String(v==null?'':v);}).join(' ').toLowerCase(); if(r.changes)blob+=' '+Object.keys(r.changes).join(' ').toLowerCase(); return blob.indexOf(q)>=0; }); } if(!filtered.length){ body.innerHTML='
' +(SR_SEARCH?'No actions matching "'+_srEsc(SR_SEARCH)+'"':'No recent activity logged yet.
Edits, loan extractions, OM uploads and comp imports will appear here.') +'
'; return; } var h='
'; h+='' +['When','App','Action','Property','Detail','Who'].map(function(c,i){return '';}).join('') +''; filtered.forEach(function(r,i){ var bg=i%2?'#002b50':'#0d1530'; var prop=r.property_name||r.deal_name||'—'; var detail=''; if(r.field){detail=_srEsc(r.field)+': '+_srEsc(String(r.before||'—').slice(0,28))+''+_srEsc(String(r.after||'—').slice(0,28))+'';} else if(r.summary){detail=_srEsc(r.summary);} else if(r.changes&&Object.keys(r.changes).length){var ks=Object.keys(r.changes);detail=''+ks.length+' field'+(ks.length>1?'s':'')+': '+_srEsc(ks.slice(0,3).join(', '))+(ks.length>3?'…':'');} h+='' +'' +'' +'' +'' +'' +'' +''; }); h+='
'+c+'
'+_srFmtTs(r.ts)+''+_srAppTag(r.source)+''+_srTypeBadge(r.type)+''+_srEsc(prop)+''+detail+''+_srEsc(_srShortEmail(r.user_email))+'
'; h+='
Showing '+filtered.length+' of '+SR_ACTIONS.length+' recent actions across the whole portfolio
'; body.innerHTML=h; } function shellRenderComps(body){ var comps=(SR_COMPS||[]).filter(function(c){return c&&(c.coe||c.purchase_price||c.units);}) .sort(function(a,b){var ka=_srCOESort(a),kb=_srCOESort(b);if(ka&&kb)return kakb?-1:0;if(ka)return -1;if(kb)return 1;return 0;}); if(!comps.length){body.innerHTML='
No sales comps loaded yet.
';return;} var shown=comps.slice(0,80); var h='
Live comps · newest first · showing '+shown.length+' of '+comps.length+'
'; h+='
'; shown.forEach(function(c){h+=_srCompCard(c);}); h+='
'; body.innerHTML=h; } /* THEME TOGGLE - shell-level, applies to both the shell AND the iframe. Persists choice in localStorage so it survives reload. Iframe re-applies on load via its own DOMContentLoaded handler. */ /* Light mode removed — force dark everywhere and clear any saved preference so users previously on light mode aren't stuck (there's no toggle anymore). */ try{localStorage.removeItem('kwmf-theme');}catch(e){} document.documentElement.removeAttribute('data-theme'); /* Light mode retired — toggle is a no-op (no UI calls it anymore). */ function shellToggleTheme(){} async function shellRefresh() { /* AGGRESSIVE REFRESH PATH - the prior version just appended a query param, which iOS Safari's bfcache and aggressive CDN edge caching could still ignore. This version: 1) Unregisters any service workers (PWA shell can pin an old version) 2) Deletes all CacheStorage entries 3) Clears KWMF-related localStorage / sessionStorage keys 4) Navigates with `assign()` (NOT replace) to a fresh URL so bfcache is fully invalidated Wrapped in async so we await the cache clears before navigating. */ const brand = document.querySelector('.brand'); if (brand) brand.classList.add('refreshing'); const rb = document.getElementById('deskRefreshBtn'); if (rb) rb.classList.add('spinning'); try { if ('serviceWorker' in navigator) { const regs = await navigator.serviceWorker.getRegistrations(); await Promise.all(regs.map(r => r.unregister().catch(() => {}))); } } catch (e) {} try { if ('caches' in window) { const keys = await caches.keys(); await Promise.all(keys.map(k => caches.delete(k).catch(() => {}))); } } catch (e) {} try { /* Only clear keys we set - preserve Firebase auth so user stays logged in */ Object.keys(localStorage).forEach(k => { if (k.indexOf('kwmf') === 0 || k.indexOf('KWMF') === 0) localStorage.removeItem(k); }); sessionStorage.clear(); } catch (e) {} /* PRIME THE FRESH FETCH - explicitly hit the server for index.html with cache:'no-cache' so the browser's HTTP cache is invalidated for this resource before we navigate. Otherwise the browser may serve a cached copy of index.html even with a different query string (depending on CDN cache rules). */ try { await fetch(window.location.pathname + '?_prime=' + Date.now(), { cache: 'no-cache', credentials: 'same-origin' }).catch(function(){}); } catch (e) {} /* New URL = current origin + path + fresh `_r` (and strip any old `_r`) */ const u = new URL(window.location.href); u.searchParams.delete('_r'); u.searchParams.delete('_v'); u.searchParams.set('_r', Date.now().toString()); /* replace() discards the current page entirely - more aggressive than assign() at busting bfcache and in-memory page state. */ setTimeout(function(){ window.location.replace(u.toString()); }, 150); } /* Show the secondary-tab chip on the ACTIVE master tab's side (left for Pipeline, right for Portfolio); hide the other side. */ function _setTabLbl(text){ var t = (text || "").toUpperCase(); var isPipe = (CURRENT_APP === "pipeline"); var L = $("#attachL"), R = $("#attachR"), lblL = $("#tabLblL"), lblR = $("#tabLblR"); if (isPipe) { if (lblL) lblL.textContent = t; if (L) L.classList.add("show"); if (R) R.classList.remove("show"); } else { if (lblR) lblR.textContent = t; if (R) R.classList.add("show"); if (L) L.classList.remove("show"); } } function loadApp(app, extras) { CURRENT_APP = app; /* Persist the active app so returning to the dashboard (reload / resume) lands on the same tab within the window. Restored at startup before the first loadApp(). */ if (app === "pipeline" || app === "portfolio") { try { localStorage.setItem("kwmf_lastApp", JSON.stringify({ app: app, ts: Date.now() })); } catch(e) {} } /* Rent-roll coverage badge belongs to Portfolio's Rent Roll view only — hide it on Pipeline/Members so it doesn't linger over those tabs. Portfolio re-posts 'rrCov show' when its rent roll view is opened. */ if (app !== "portfolio") { var _cb = document.getElementById("rrCovBanner"); if (_cb) _cb.style.display = "none"; var _cp = document.getElementById("rrCovPopup"); if (_cp) _cp.style.display = "none"; } $$("#topNav button").forEach(b => b.classList.toggle("on", b.dataset.app === app)); $("#membersIcon").classList.toggle("on", app === "members"); try { if (typeof fitTickerChips === "function") setTimeout(fitTickerChips, 0); } catch(e){} const appLabel = app === "pipeline" ? "Pipeline" : app === "portfolio" ? "Portfolio" : "Loading"; _setTabLbl(appLabel.toUpperCase()); var _ts = $("#tabStamp"); if (_ts) _ts.style.display = (app === "pipeline") ? "" : "none"; /* Reset the shell to a clean state so a stuck overlay from the previous app (Performance picker header-hide, open menus, scrim) can't leave the next app looking frozen. */ try { var _sh = document.getElementById("shell"); if (_sh) _sh.classList.remove("perf-pick"); if (typeof closeAppMenu === "function") closeAppMenu(); if (typeof closeMobileMore === "function") closeMobileMore(); var _ov = document.getElementById("contentOverlay"); if (_ov) _ov.style.display = "none"; var _sc = document.getElementById("menuScrim"); if (_sc) _sc.style.display = "none"; } catch(e){} _showContentLoader(appLabel + "..."); let url = app === "pipeline" ? PIPELINE_URL : PORTFOLIO_URL; /* Per-load cache buster - every navigation to the iframe gets a fresh `_v=` so CDN/browser caches can't serve stale pipeline.html / portfolio.html. */ url += (url.indexOf("?") >= 0 ? "&" : "?") + "_v=" + Date.now(); if (extras) url += "&" + extras; const iframe = $("#content"); /* Robust loader: attach handlers BEFORE setting src, and always hide the loader on load, error, OR a timeout — so a missed onload (iOS sometimes skips it on cached/blank transitions) can't leave the page stuck loading. A module-level timer guards against rapid re-entrant loadApp() calls. */ clearTimeout(window._appLoadT); window._appLoadT = setTimeout(function(){ _hideContentLoader(); }, 8000); iframe.onload = function() { clearTimeout(window._appLoadT); _hideContentLoader(); // Tell the iframe the shell has already verified auth - it can load data now if (CURRENT_USER) { try { iframe.contentWindow.postMessage( { type: 'KWMF_SHELL_AUTH', email: CURRENT_USER.email, name: CURRENT_USER.displayName || '' }, window.location.origin ); } catch(e) {} } // Pre-warm the search cache after the iframe has had ~3s to load Firebase data setTimeout(function(){ try { iframe.contentWindow.postMessage({type:"KWMF_GET_SEARCH_DATA"}, window.location.origin); } catch(ex){} }, 3000); }; iframe.onerror = function(){ clearTimeout(window._appLoadT); _hideContentLoader(); }; iframe.src = url; if (app !== "members") { try { localStorage.setItem("kwmf_app", app); } catch (e) {} } _menuActiveTab = null; var _crumb = $("#menuCrumb"); if (_crumb) _crumb.textContent = ""; } /* === APP HAMBURGER MENU === */ var APP_TABS = { pipeline: [ {id:"dash",l:"Dashboard"}, {id:"all",l:"All"}, {id:"hot",l:"Hot"}, {id:"sec",l:"Secondary"}, {id:"hulic",l:"Hulic JV"}, {id:"tabl",l:"Tball"}, {id:"track",l:"Tracking"}, {id:"closed",l:"Closed"}, {id:"dispo",l:"Dispo"}, {id:"comps",l:"Sale Comps"} ], portfolio: [ {id:"map",l:"Map"}, {id:"overview",l:"Overview"}, {id:"properties",l:"Properties"}, {id:"debt",l:"Debt"}, {id:"equity",l:"Equity"}, {id:"performance",l:"Performance"}, {id:"analytics",l:"KW Analytics"}, {id:"sold",l:"Sold"} ] }; var _menuActiveTab = null; function _renderAppMenu() { var panel = $("#appMenuPanel"); var tabs = APP_TABS[CURRENT_APP] || []; var appLabel = CURRENT_APP === "pipeline" ? "Pipeline" : "Portfolio"; panel.innerHTML = '
' + appLabel + '
' + '
' + '🔍' + 'Search ' + appLabel + '…' + '
' + tabs.map(function(t){ return '
' + t.l + '
'; }).join(''); } function toggleAppMenu() { var panel = $("#appMenuPanel"); if (panel.classList.contains("show")) { closeAppMenu(); } else { openAppMenu(); } } function openAppMenu() { _renderAppMenu(); var hdr = document.querySelector(".hdr"); var ns = document.getElementById("newsStrip"); var panel = $("#appMenuPanel"); var scrim = document.getElementById("menuScrim"); // Start below the header; if the news strip is visible, push down past it too. var bottom = hdr ? hdr.getBoundingClientRect().bottom : 52; if (ns && ns.offsetHeight > 0 && getComputedStyle(ns).display !== "none") { bottom += ns.offsetHeight; } var topPx = bottom + "px"; panel.style.top = topPx; if (scrim) { scrim.style.top = topPx; // Offset left past the panel so the scrim never overlaps the panel — // on iPadOS overlapping fixed elements can steal touches from the higher-z panel. scrim.style.left = (panel.offsetWidth || 224) + "px"; } panel.classList.add("show"); if (scrim) scrim.classList.add("show"); // Block iframe touches (iOS) and show content overlay (desktop) so any click // outside the panel is caught and routed to closeAppMenu(). var cf = document.getElementById("content"); if (cf) cf.style.pointerEvents = "none"; var co = document.getElementById("contentOverlay"); if (co) co.style.display = "block"; $("#menuBtn").classList.add("on"); setTimeout(function(){ document.addEventListener("click", _closeMenuOutside, {capture:true}); }, 0); } function closeAppMenu() { $("#appMenuPanel").classList.remove("show"); $("#menuBtn").classList.remove("on"); var scrim = document.getElementById("menuScrim"); if (scrim) { scrim.classList.remove("show"); scrim.style.left = ""; } // Restore iframe interactivity and hide content overlay var cf = document.getElementById("content"); if (cf) cf.style.pointerEvents = ""; var co = document.getElementById("contentOverlay"); if (co) co.style.display = "none"; document.removeEventListener("click", _closeMenuOutside, {capture:true}); } function _closeMenuOutside(e) { var panel = $("#appMenuPanel"); var btn = $("#menuBtn"); if ((panel && panel.contains(e.target)) || (btn && btn.contains(e.target))) return; closeAppMenu(); } /* === GLOBAL SEARCH === _gsDataCache stores data from BOTH apps so search works regardless of which app is currently loaded — pipeline data is amber, portfolio is blue. */ var _gsDataCache = {pipeline:null, portfolio:null}; var _gsRetryTimer = null; function openGSearch() { var gs = document.getElementById("gsearch"); var inp = document.getElementById("gs-inp"); // Reveal overlay + focus SYNCHRONOUSLY inside the tap gesture. The overlay is // hidden via opacity (not display:none) so the input is always focusable — // this makes iOS slide the keyboard up the instant the row is tapped, the way // Robinhood / Instagram do it. Any setTimeout here would break that on iOS. gs.classList.add("show"); document.body.style.overflow = "hidden"; if (inp) { inp.value = ""; inp.placeholder = "Search…"; inp.focus(); } var clr = document.getElementById("gs-inp-clr"); if (clr) clr.classList.remove("show"); closeAppMenu(); _gsRenderInitial(); // Request fresh data from active iframe; retry once if no response within 1.5s. _gsSendDataReq(); if (_gsRetryTimer) clearTimeout(_gsRetryTimer); _gsRetryTimer = setTimeout(function(){ if (!_gsDataCache.pipeline && !_gsDataCache.portfolio) _gsSendDataReq(); }, 1500); } function _gsSendDataReq() { try { var fr = document.getElementById("content"); if (fr && fr.contentWindow) fr.contentWindow.postMessage({type:"KWMF_GET_SEARCH_DATA"}, window.location.origin); } catch(e){} } /* Visual Viewport listener: on iOS the keyboard shrinks the visual viewport but position:fixed elements don't adjust. dvh handles modern iOS; this is the fallback. Also sync --vvh so #gsearch can use it via CSS when not yet .show. */ function _syncShellVVH() { var h = window.visualViewport ? window.visualViewport.height : window.innerHeight; document.documentElement.style.setProperty('--vvh', h + 'px'); var gs = document.getElementById("gsearch"); if (gs) gs.style.height = h + "px"; } _syncShellVVH(); if (window.visualViewport) { window.visualViewport.addEventListener("resize", _syncShellVVH); } window.addEventListener("resize", _syncShellVVH); /* Tap top of screen (status bar area) → smooth scroll iframe content to top */ function _scrollFrameToTop(){ var fr=document.getElementById('content'); if(fr&&fr.contentWindow){ try{fr.contentWindow.scrollTo({top:0,behavior:'smooth'});}catch(e){} } } function closeGSearch() { if (_gsRetryTimer) { clearTimeout(_gsRetryTimer); _gsRetryTimer = null; } var gs = document.getElementById("gsearch"); gs.classList.remove("show"); gs.style.height = ""; document.body.style.overflow = ""; var inp = document.getElementById("gs-inp"); if (inp) inp.blur(); } function _gsEsc(s) { return String(s||"").replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); } function _gsMono(name) { return _gsEsc(String(name||"?").trim().charAt(0).toUpperCase() || "?"); } /* ---- Recent searches — stored per app, combined for display ---- */ function _gsGetRecent() { try { var pipe = (JSON.parse(localStorage.getItem("kwmf_recent_pipeline"))||[]) .map(function(it){ return Object.assign({},it,{_app:"pipeline"}); }); var port = (JSON.parse(localStorage.getItem("kwmf_recent_portfolio"))||[]) .map(function(it){ return Object.assign({},it,{_app:"portfolio"}); }); var out = [], pi = 0, qi = 0; while (out.length < 8 && (pi < pipe.length || qi < port.length)) { if (pi < pipe.length) out.push(pipe[pi++]); if (out.length < 8 && qi < port.length) out.push(port[qi++]); } return out; } catch(e){ return []; } } function _gsAddRecent(item) { var key = "kwmf_recent_" + (item._app || CURRENT_APP); try { var arr = JSON.parse(localStorage.getItem(key)) || []; arr = arr.filter(function(r){ return (r.name||"").toLowerCase() !== (item.name||"").toLowerCase(); }); arr.unshift(item); localStorage.setItem(key, JSON.stringify(arr.slice(0,8))); } catch(e){} } function _gsRemoveRecent(name, app) { var key = "kwmf_recent_" + (app || CURRENT_APP); try { var arr = JSON.parse(localStorage.getItem(key)) || []; arr = arr.filter(function(r){ return (r.name||"") !== name; }); localStorage.setItem(key, JSON.stringify(arr)); } catch(e){} _gsRenderInitial(); } function _gsClearRecent() { try { localStorage.removeItem("kwmf_recent_pipeline"); localStorage.removeItem("kwmf_recent_portfolio"); } catch(e){} _gsRenderInitial(); } /* Row HTML. Portfolio results include [Properties][Debt][Equity] tab chips. */ function _gsRow(it, isPipe, recent) { var sub = [it.city, it.state].filter(Boolean).join(", "); if (isPipe && it.stage) sub = (sub ? sub + " · " : "") + it.stage; var icoCls = isPipe ? "gs-ico gs-ico-pipe" : "gs-ico"; var payload = _gsEsc(JSON.stringify(it)); var app = it._app || (isPipe ? "pipeline" : "portfolio"); var chipDefs; if (isPipe) { var PIPE_CHIP_MAP = {"Hot":{id:"hot",l:"Hot"},"Secondary":{id:"sec",l:"Secondary"},"Hulic":{id:"hulic",l:"Hulic JV"},"TABL":{id:"tabl",l:"Tball"},"Tracking":{id:"track",l:"Tracking"},"Closed":{id:"closed",l:"Closed"}}; var pipeNames = it.pipes && it.pipes.length ? it.pipes : (it.pipeline_1 ? [it.pipeline_1] : []); chipDefs = [{id:"all",l:"All Deals"}]; pipeNames.forEach(function(p){ if (PIPE_CHIP_MAP[p]) chipDefs.push(PIPE_CHIP_MAP[p]); }); } else { chipDefs = [{id:"properties",l:"Properties"},{id:"debt",l:"Debt"},{id:"equity",l:"Equity"}]; } var tabChips = '
' + chipDefs.map(function(tc){ var ci = Object.assign({}, it, {tab:tc.id, _app:app}); return ''; }).join("") + '
'; var removeBtn = recent ? '' : ''; return '
' + '
' + _gsMono(it.name) + '
' + '
' + '
' + _gsEsc(it.name) + '
' + (sub ? '
' + _gsEsc(sub) + '
' : '') + tabChips + '
' + removeBtn + '
'; } /* Empty-query: recent searches + browse lists from both cached apps. */ function _gsRenderInitial() { var results = document.getElementById("gs-results"); var recent = _gsGetRecent(); var html = ""; if (recent.length) { html += '
Recent' + '' + '
' + recent.map(function(it){ return _gsRow(it, it._app === "pipeline", true); }).join(""); } var rn = recent.map(function(r){ return (r.name||"").toLowerCase(); }); var pd = _gsDataCache.pipeline; if (pd && pd.items && pd.items.length) { var b = pd.items.filter(function(it){ return rn.indexOf((it.name||"").toLowerCase()) === -1; }).slice(0,20); if (b.length) { html += '
Pipeline Deals
' + b.map(function(it){ return _gsRow(Object.assign({},it,{_app:"pipeline"}), true, false); }).join(""); } } var od = _gsDataCache.portfolio; if (od && od.items && od.items.length) { var bp = od.items.filter(function(it){ return rn.indexOf((it.name||"").toLowerCase()) === -1; }).slice(0,20); if (bp.length) { html += '
Portfolio Properties
' + bp.map(function(it){ return _gsRow(Object.assign({},it,{_app:"portfolio"}), false, false); }).join(""); } } results.innerHTML = html || '
Search by name, city, or state
'; } function _gsSearch(q) { var results = document.getElementById("gs-results"); var clr = document.getElementById("gs-inp-clr"); q = (q||"").trim(); if (clr) clr.classList.toggle("show", q.length > 0); if (!q) { _gsRenderInitial(); return; } var ql = q.toLowerCase(); var pi = (_gsDataCache.pipeline && _gsDataCache.pipeline.items || []) .map(function(it){ return Object.assign({},it,{_app:"pipeline"}); }); var oi = (_gsDataCache.portfolio && _gsDataCache.portfolio.items || []) .map(function(it){ return Object.assign({},it,{_app:"portfolio"}); }); var all = pi.concat(oi); if (!all.length) { results.innerHTML = '
Loading…
'; return; } var hits = all.filter(function(it){ return (it.name||"").toLowerCase().includes(ql) || (it.city||"").toLowerCase().includes(ql) || (it.state||"").toLowerCase().includes(ql) || (it.stage||"").toLowerCase().includes(ql); }).slice(0,40); if (!hits.length) { results.innerHTML = '
No results for “' + _gsEsc(q) + '”
'; return; } var pm = hits.filter(function(it){ return it._app === "pipeline"; }); var om = hits.filter(function(it){ return it._app === "portfolio"; }); var html = ""; if (pm.length) { if (om.length) html += '
Pipeline
'; html += pm.map(function(it){ return _gsRow(it, true, false); }).join(""); } if (om.length) { if (pm.length) html += '
Portfolio
'; html += om.map(function(it){ return _gsRow(it, false, false); }).join(""); } results.innerHTML = html; } function _gsSelect(item) { _gsAddRecent(item); closeGSearch(); var targetApp = item._app || CURRENT_APP; var tabId = item.tab || (targetApp === "pipeline" ? "all" : "properties"); var needSwitch = targetApp !== CURRENT_APP; if (needSwitch) loadApp(targetApp); selectSubTab(tabId); setTimeout(function(){ try { var msg = targetApp === "pipeline" ? {type:"KWMF_JUMP_TO_DEAL", name:item.name, id:item.id||"", tab:tabId} : {type:"KWMF_OPEN_PROPERTY", name:item.name, tab:tabId}; document.getElementById("content").contentWindow.postMessage(msg, window.location.origin); } catch(e){} }, needSwitch ? 1800 : 400); } window.addEventListener("message", function(e){ if (!e.data || e.data.type !== "KWMF_SEARCH_DATA") return; _gsDataCache[e.data.app] = e.data; var gs = document.getElementById("gsearch"); if (!gs || !gs.classList.contains("show")) return; var inp = document.getElementById("gs-inp"); if (inp && inp.value.trim()) _gsSearch(inp.value); else _gsRenderInitial(); }); function selectSubTab(tabId) { closeAppMenu(); _menuActiveTab = tabId; var tabs = APP_TABS[CURRENT_APP] || []; var tab = tabs.find(function(t){ return t.id === tabId; }) || null; var crumb = $("#menuCrumb"); if (crumb) crumb.textContent = ""; _setTabLbl(tab ? tab.l.toUpperCase() : (CURRENT_APP === "portfolio" ? "PORTFOLIO" : "PIPELINE")); try { $("#content").contentWindow.postMessage({type:"KWMF_SET_TAB", tab:tabId}, window.location.origin); } catch(e2) {} } window.addEventListener("message", function(e){ if (!e.data || e.data.type !== "KWMF_PERF_PICKER") return; var sh = document.getElementById("shell"); if (sh) sh.classList.toggle("perf-pick", !!e.data.open); }); window.addEventListener("message", function(e){ if (!e.data || e.data.type !== "KWMF_TAB_CHANGED") return; _menuActiveTab = e.data.tab || null; var crumb = $("#menuCrumb"); if (crumb) crumb.textContent = ""; _setTabLbl((e.data.label || "").toUpperCase() || (CURRENT_APP === "portfolio" ? "PORTFOLIO" : "PIPELINE")); var _ts = $("#tabStamp"); if (_ts && CURRENT_APP === "pipeline") { /* desktop-only feature — leave mobile/portfolio stamp untouched */ var _t = e.data.tab || ""; var _l = (e.data.label || _t).toUpperCase(); var _c = {hot:"#f87171",sec:"#fb923c",hulic:"#a78bfa",tabl:"#60a5fa",track:"#94a3b8",closed:"#6ee7b7",all:"#6cace4",dispo:"#fbbf24",comps:"#f9a8d4"}[_t] || "#6cace4"; _ts.textContent = _l; _ts.style.color = _c; _ts.style.borderColor = _c; _ts.style.display = ""; } }); /* DEEP-LINK - a pipeline deal card (KW-owned property) asks to jump to the KW Analytics tab focused on that asset. Switch to Portfolio and pass the property name through the query string; portfolio.html forwards it to the analytics iframe once it loads. */ window.addEventListener('message', function(e){ var d = e.data; if (!d || d.type !== 'KWMF_OPEN_ANALYTICS') return; var back = d.fromDeal ? ('&fromDeal=' + encodeURIComponent(d.fromDeal)) : ''; /* mode:'market' -> open straight into Market Analytics (used when the inline pipeline market modal has no match for the deal's market). */ var mq = (d.mode === 'market') ? '&anmode=market' : ''; if (d.prop) { /* Carry state too (alongside prop) so toggling to Market Analytics can filter to the deal's state cohort, not just focus the property in Property Analytics. */ var stq = d.state ? ('&state=' + encodeURIComponent(d.state)) : ''; loadApp('portfolio', 'view=analytics&prop=' + encodeURIComponent(d.prop) + stq + mq + back); } else if (d.state) { /* From a pipeline deal's "KW Portfolio Benchmark" panel - open Analytics filtered to that state's owned cohort. */ loadApp('portfolio', 'view=analytics&state=' + encodeURIComponent(d.state) + mq + back); } else { /* Bare request (deal-card "KW Analytics" button, no state) - just open the KW Analytics tab. */ loadApp('portfolio', 'view=analytics' + mq + back); } }); /* BACK - KW Analytics' "Back to deal" button asks to return to the pipeline deal card it was opened from. Reload Pipeline with the deal id so it reopens. */ window.addEventListener('message', function(e){ var d = e.data; if (!d || d.type !== 'KWMF_BACK_TO_DEAL' || !d.dealId) return; loadApp('pipeline', 'openDeal=' + encodeURIComponent(d.dealId)); }); /* Portfolio property modal "Open in Dispo" → switch to Pipeline's Dispo sheet focused on that asset. */ window.addEventListener('message', function(e){ var d = e.data; if (!d || d.type !== 'KWMF_OPEN_DISPO' || !d.name) return; loadApp('pipeline', 'dispo=' + encodeURIComponent(d.name)); }); /* Rent-roll coverage badge + popup — driven by postMessage from portfolio.html iframe */ (function(){ var banner = null, popup = null; function _banner(){ return banner || (banner = document.getElementById('rrCovBanner')); } function _popup(){ return popup || (popup = document.getElementById('rrCovPopup')); } window.addEventListener('message', function(e){ var d = e.data; if (!d || d.type !== 'rrCov') return; var b = _banner(), p = _popup(); if (d.action === 'show' && b) { b.style.display = 'flex'; } if (d.action === 'hide' && b) { b.style.display = 'none'; if(p) p.style.display = 'none'; } if (d.action === 'data' && b) { b.innerHTML = d.html || ''; } }); window.addEventListener('message', function(e){ var d = e.data; if (!d || d.type !== 'rrCovPopup') return; var p = _popup(); if (!p || d.action !== 'toggle') return; if (p.style.display !== 'none' && p.dataset.ptype === d.ptype) { p.style.display = 'none'; return; // same chip tapped again → close } p.dataset.ptype = d.ptype || ''; p.innerHTML = d.html || ''; p.style.display = 'block'; }); // Forward badge chip clicks to the portfolio iframe document.addEventListener('click', function(e){ var chip = e.target.closest ? e.target.closest('#rrCovBanner [data-rrcov]') : null; if (chip) { var ifr = document.getElementById('content'); if (ifr && ifr.contentWindow) ifr.contentWindow.postMessage({type:'rrCovClick',rrcov:chip.dataset.rrcov},'*'); return; } // Close popup on outside click var p = _popup(); if (p && p.style.display !== 'none' && !p.contains(e.target) && !e.target.closest('#rrCovBanner')) { p.style.display = 'none'; } }); document.addEventListener('touchstart', function(e){ var p = _popup(); if (p && p.style.display !== 'none' && !p.contains(e.target) && !e.target.closest('#rrCovBanner')) { p.style.display = 'none'; } }, {passive:true}); })(); /* KWMF_REQUEST_COLLAPSE - no-op now that collapse behavior is removed */ window.addEventListener('message', function(e){ var d = e.data; if (!d || d.type !== 'KWMF_REQUEST_COLLAPSE') return; /* iPad/desktop (>=768px) NEVER collapse the shell header — collapse is a phone-only behavior. Guard here so a stray child message can't hide it. */ }); /* === MOBILE KEYBOARD / IFRAME VIEWPORT FIX === The portfolio/pipeline pages (and their Ask-AI chat panels) run INSIDE the #content iframe. On iOS the soft keyboard does NOT shrink `100dvh`, and `window.visualViewport` is unreliable *inside an iframe* - it reports the iframe's box, not the device keyboard. So the inner page can't detect the keyboard and its fixed chat input ends up stranded behind it. The TOP-LEVEL page's visualViewport DOES reflect the keyboard (even when the focused field lives in the iframe). So we fix it here: when the keyboard is up, shrink #shell to the visible viewport height. That shrinks the iframe, so the inner page's `100dvh` chat panel now ends exactly at the top of the keyboard - the input sits just above it, no gap, no hacks inside the frame. */ (function(){ if(!window.visualViewport)return; var vv=window.visualViewport; function isMobile(){return window.matchMedia&&window.matchMedia("(max-width:767px)").matches;} function syncShellViewport(){ var shell=document.getElementById("shell"); if(!shell||!isMobile()){if(shell)shell.style.height="";return;} /* Keyboard height = layout height visible height. innerHeight stays at the full layout viewport on iOS while vv.height shrinks. */ var kb=window.innerHeight-vv.height; if(kb>90){ /* Force the page back to the top so the shrunk shell aligns with the visible region's top edge (iOS may have scrolled to reveal the input). */ if(window.pageYOffset||document.documentElement.scrollTop)window.scrollTo(0,0); shell.style.height=vv.height+"px"; }else{ /* Keyboard down → restore CSS-driven 100dvh. */ shell.style.height=""; } } vv.addEventListener("resize",syncShellViewport); vv.addEventListener("scroll",syncShellViewport); window.addEventListener("orientationchange",function(){setTimeout(syncShellViewport,300);}); })(); /* === LIVE RATES (reuses existing om-parser worker) === */ const RATES_URL = "https://om-parser.cdinapoli.workers.dev/rates"; function toggleFullscreen() { if (!document.fullscreenElement) { document.documentElement.requestFullscreen().catch(() => {}); } else { document.exitFullscreen().catch(() => {}); } } /* Last-known-good rates payload - lets the ticker paint instantly on a cold load and survive a flaky connection instead of showing bare "-". */ const RATE_LAST_KEY = "rate_last"; /* Render a /rates payload into the ticker chips. Pulled out of loadRates so the same logic serves both the live fetch and the cached instant-paint. */ function applyRates(d) { try { function gv(x){ return (x != null && typeof x === 'object') ? parseFloat(x.value) : parseFloat(x); } function gc(x){ return (x != null && typeof x === 'object' && x.change != null) ? parseFloat(x.change) : NaN; } /* PREV-DAY CACHE - survives refresh-button clears via the 'rate_' prefix (which my shellRefresh cache-clear ignores by skipping the rate prefix). */ var todayKey = new Date().toISOString().slice(0,10); var prev = {}; try { prev = JSON.parse(localStorage.getItem('rate_prev') || '{}'); } catch(e){} var today = {}; function resolveChange(name, val, workerChange) { /* Worker .change wins when present */ if (!isNaN(workerChange)) return workerChange; /* Else diff against yesterday's cached close */ var entry = prev[name]; if (!entry || entry.date === todayKey) return NaN; return val - entry.value; } /* Extract the FRED publish date from the rate response (added so users see which date the data is from - FRED has 1-day lag on Treasury rates). */ function gd(x){ return (x && typeof x === 'object' && x.date) ? x.date : null; } function setRate(id, name, val, workerChange, minVal, fredDate) { var el = document.getElementById(id); if (!el) return; if (isNaN(val) || val < (minVal||0.01)) return; var change = resolveChange(name, val, workerChange); today[name] = { value: val, date: todayKey }; /* Always render a chip - show "-bp" placeholder when change isn't known yet (first visit) so the visual slot is always reserved + user knows the chip is part of the layout, not missing. */ var bpsHtml; if (isNaN(change)) { bpsHtml = '-bp'; } else { var bps = Math.round(change * 100); if (bps > 0) bpsHtml = '+' + bps + 'bp'; else if (bps < 0) bpsHtml = '' + bps + 'bp'; else bpsHtml = '0bp'; } el.innerHTML = val.toFixed(2) + '%' + bpsHtml; el.className = 'v live'; /* Tooltip on the parent chip so user sees the FRED publish date. FRED has ~1 business day lag on Treasury rates (DGS2/5/10/30) - so a value showing "today" might actually be prior-day close. SOFR has similar lag. */ if (fredDate) { var chip = el.closest('.tk'); if (chip) chip.title = "FRED close as of " + fredDate + " (1-day lag is normal for Treasury rates)"; } } setRate('rate-sofr', 'sofr', gv(d.sofr), gc(d.sofr), 1.0, gd(d.sofr)); setRate('rate-2y', 't2', gv(d.t2), gc(d.t2), 0, gd(d.t2)); setRate('rate-5y', 't5', gv(d.t5), gc(d.t5), 0, gd(d.t5)); setRate('rate-10y', 't10', gv(d.t10), gc(d.t10), 0, gd(d.t10)); setRate('rate-30y', 't30', gv(d.t30), gc(d.t30), 0, gd(d.t30)); /* Seed prev cache - entries get locked once per day; subsequent same-day fetches don't overwrite the morning's value so the diff stays daily. */ Object.keys(today).forEach(function(k){ if (!prev[k] || prev[k].date !== todayKey) prev[k] = today[k]; }); try { localStorage.setItem('rate_prev', JSON.stringify(prev)); } catch(e){} } catch(e) { /* malformed payload - leave existing chips untouched */ } /* Re-check overlap now that chips carry their real (wider) values. */ try { if (typeof fitTickerChips === 'function') setTimeout(fitTickerChips, 0); } catch(e){} } /* Instant paint from last-known-good cache so the mobile ticker is never blank on a cold/flaky load. The live fetch overwrites within ~1s. */ function paintCachedRates() { try { var raw = localStorage.getItem(RATE_LAST_KEY); if (!raw) return; var d = JSON.parse(raw); if (d && typeof d === 'object') applyRates(d); } catch(e) {} } /* Live fetch with short retry/backoff. A single transient failure (worker cold start, cellular blip) no longer blanks the ticker for the full 10-min interval - it retries 3x (~1.2s, 2.4s, 3.6s), and if all fail it falls back to the cached last-known-good values instead of leaving "-". */ async function loadRates(attempt) { attempt = attempt || 0; try { var res = await fetch(RATES_URL, { cache: 'no-store' }); if (!res.ok) throw new Error('HTTP ' + res.status); var d = await res.json(); applyRates(d); try { localStorage.setItem(RATE_LAST_KEY, JSON.stringify(d)); } catch(e){} } catch(e) { if (attempt < 3) { setTimeout(function(){ loadRates(attempt + 1); }, 1200 * (attempt + 1)); } else { paintCachedRates(); /* last resort - show last-known-good */ } } } setInterval(loadRates, 600000); /* === Ticker centering + overlap guard === On desktop the rates ticker is absolutely positioned and CENTERED in the gap between the KW logo (left) and the Pipeline/Portfolio nav (right), vertically centered too — recomputed on every load / resize / rate-paint so it stays centered at all times and at all widths. We measure the ACTUAL rendered logo right-edge and nav left-edge, drop 2Y + 30Y (then 5Y, then 10Y) only if the chips can't fit the gap, and place the ticker's midpoint at the gap midpoint. */ function fitTickerChips(){ var ticker=document.querySelector('.hdr .ticker'); if(!ticker)return; var isDesktop=window.matchMedia&&window.matchMedia('(min-width:768px)').matches; if(!isDesktop){ ticker.style.left=''; ticker.style.transform=''; return; } /* mobile uses its own grid */ ticker.classList.remove('rates-min','rates-min2','rates-min3'); var logo=document.querySelector('.hdr #appMenu'); var nav=document.querySelector('.hdr #topNav'); var logoR=logo?logo.getBoundingClientRect().right:24; var navL=nav?nav.getBoundingClientRect().left:window.innerWidth; /* Center the ticker's midpoint at the midpoint of the logo→nav gap. */ function place(){ ticker.style.left=((logoR+navL)/2)+'px'; ticker.style.transform='translate(-50%,-50%)'; } /* Fits when the centered ticker clears BOTH the logo and the nav (8px margin). */ function fits(){ var r=ticker.getBoundingClientRect(); return r.left>=logoR+8 && r.right<=navL-8; } place(); if(fits())return; /* all 5 fit - done */ ticker.classList.add('rates-min'); place(); if(fits())return; /* drop 2Y + 30Y → SOFR/5Y/10Y */ ticker.classList.add('rates-min2'); place(); if(fits())return; /* drop 5Y → SOFR/10Y */ ticker.classList.add('rates-min3'); place(); /* drop 10Y → SOFR only */ } var _fitT; window.addEventListener('resize', function(){ clearTimeout(_fitT); _fitT=setTimeout(fitTickerChips, 80); }); setTimeout(fitTickerChips, 300); /* === LIVE NEWS TICKER (Cloudflare Worker /news) === */ const NEWS_WORKER_URL = "https://om-parser.cdinapoli.workers.dev/news"; const SOURCE_COLORS = { /* Geographic - blues/teals */ "Seattle":"#6cace4","Portland":"#38bdf8","Boise":"#67e8f9","NorCal":"#818cf8","SoCal":"#a78bfa", "San Diego":"#c084fc","Phoenix":"#f97316","Tucson":"#fb923c","Las Vegas":"#fbbf24","Reno":"#facc15", "Denver":"#4ade80","SLC":"#34d399","ABQ":"#2dd4bf","DFW":"#6cace4","Austin":"#38bdf8", "Houston":"#818cf8","San Antonio":"#a78bfa","NYC":"#f472b6","Northeast":"#e879f9", "DC/NoVA":"#c084fc","South FL":"#fb923c","Atlanta":"#fbbf24","Nashville":"#34d399", /* Sector - greens/emeralds */ "MF Trends":"#4ade80","Apt REIT":"#22d3ee","MF Dev":"#a3e635","MF Starts":"#86efac", "Affordable":"#fcd34d","BTR":"#fdba74","Student":"#67e8f9","Senior":"#d8b4fe", "CRE Deals":"#6cace4","CRE Debt":"#9bb8d3","RE PE":"#c4b5fd","Rents":"#86efac", "MF Acq":"#38bdf8","Cap Rates":"#5eead4","PropMgmt":"#a7f3d0", /* Publications - gold */ "GlobeSt":"#6cace4","MHN":"#6cace4","CoStar":"#6cace4","CBRE":"#6cace4", "JLL":"#6cace4","M&M":"#6cace4","Newmark":"#6cace4","Berkadia":"#6cace4", /* Macro - coral/red */ "Fed":"#f87171","Treasuries":"#fb923c","Inflation":"#fbbf24","Jobs":"#a3e635", "Housing":"#4ade80","GDP":"#f87171","CMBS":"#9bb8d3" }; function _nsAge(d) { const m = (Date.now() - new Date(d)) / 60000; if (m < 60) return Math.round(m) + "m"; if (m < 1440) return Math.round(m/60) + "h"; return Math.round(m/1440) + "d"; } function _nsEsc(s) { return (s||"").replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); } /* Consistent auto-color for live news sources not in SOURCE_COLORS map. Uses a simple string hash so the same source always gets the same color. */ function _nsAutoColor(s){ var p=['#6cace4','#f87171','#4ade80','#fbbf24','#c084fc','#38bdf8','#fb923c','#34d399','#a78bfa','#e879f9','#67e8f9','#fcd34d']; var h=0;for(var i=0;i<(s||'').length;i++)h=(h*31+s.charCodeAt(i))&0xffff; return p[h%p.length]; } /* NEWS_FALLBACK - shown when the worker /news endpoint is down or slow. Each fallback item links to a topic-relevant Google News search so the ticker is always clickable, even when the live feed isn't loaded yet. (Previous version used link:"#" which made every fallback item unclickable - compounded by the worker's /news endpoint currently throwing 500.) */ const _gn = q => "https://news.google.com/search?q=" + encodeURIComponent(q) + "&hl=en-US&gl=US&ceid=US:en"; const NEWS_FALLBACK = [ {source:"MF Trends", title:"Multifamily fundamentals remain resilient across gateway and sunbelt markets", link:_gn("multifamily housing market trends"), pubDate:null}, {source:"Fed", title:"Federal Reserve signals data-dependent stance on rate adjustments through year-end", link:_gn("Federal Reserve interest rate decision"), pubDate:null}, {source:"Cap Rates", title:"Class A multifamily cap rates holding in 4.5-5.5% range across primary markets", link:_gn("multifamily cap rates"), pubDate:null}, {source:"Rents", title:"New supply deliveries beginning to moderate effective rent growth in select sunbelt metros", link:_gn("apartment rent growth sunbelt"), pubDate:null}, {source:"CRE Debt", title:"Agency lending volume recovering as spreads tighten on multifamily product", link:_gn("commercial real estate lending multifamily"), pubDate:null}, {source:"RE PE", title:"Institutional allocations to value-add multifamily rising amid improving yield spreads", link:_gn("real estate private equity multifamily"), pubDate:null}, ]; /* Google News RSS deep-links are an opaque encoded redirect (news.google.com/rss/articles/CB...) that frequently resolves to the WRONG article or Google's interstitial, and can't be decoded client-side. So for any Google-News article link we open a Google search of the exact headline instead - the click then always lands on results matching the headline you clicked. Direct publisher links (non-Google) pass through untouched. */ function _nsResolveLink(i) { var link = (i && i.link ? String(i.link) : "").trim(); if (!link || link === "#") return ""; if (/news\.google\.[^/]+\/(rss\/)?articles?\//i.test(link) || /\/rss\/articles\//i.test(link)) { var q = (i.title || "").trim(); // full headline (incl. " - Publisher") = most precise search if (!q) return link; return "https://www.google.com/search?q=" + encodeURIComponent(q); } return link; } function _renderNewsStrip(items) { const strip = document.getElementById("newsStrip"); if (!strip) return; const mask = strip.querySelector('.ns-mask'); if (!mask) return; const markup = items.map(i => { /* Always use a vibrant color - unknown sources get a consistent auto-color so badges never fall back to flat grey. Background tint makes the box pop. */ const color = SOURCE_COLORS[i.source] || _nsAutoColor(i.source||'?'); const bg = color + '18'; /* ~10% opacity tint */ /* Use native anchor tags so middle-click, ctrl+click, right-click → copy link, and screen readers all work. Was previously which broke URL escaping for & and apostrophes, and lacked native a11y. */ const resolved = _nsResolveLink(i); const clickable = !!resolved; const tag = clickable ? 'a' : 'span'; const attrs = clickable ? ` href="${_nsEsc(resolved)}" target="_blank" rel="noopener noreferrer"` : ''; return `<${tag} class="ns-item"${attrs}>` + `${_nsEsc(i.source||'')}` + `${_nsEsc(i.title||'')}` + (i.pubDate ? `${_nsAge(i.pubDate)}` : '') + `·`; }).join(''); /* Remove old runner and create a fresh element - this guarantees the CSS animation starts from frame 0 with the correct duration every time, even when called multiple times (fallback → live refresh). */ const old = document.getElementById('nsRun'); if (old) old.remove(); const run = document.createElement('div'); run.className = 'ns-run'; run.id = 'nsRun'; run.innerHTML = markup + markup; // doubled for seamless loop mask.appendChild(run); strip.style.display = 'flex'; /* Double rAF: first frame triggers layout, second measures the real scrollWidth */ requestAnimationFrame(() => requestAnimationFrame(() => { const half = run.scrollWidth / 2; if (half > 10) { run.style.animationDuration = (half / 18).toFixed(1) + 's'; // ~18 px/s run.style.animationPlayState = 'running'; } })); } /* iOS black-screen-on-return fix. Safari on iOS PWA can fail to repaint the iframe's GPU compositor layer after returning from background — entire content area goes pure black. Technique: micro-opacity toggle (0.9999) on the iframe forces a re-rasterization of its GPU texture without any visible flash. Two nested rAF calls ensure the mutation fully commits before reverting. We also keep the translateZ(0) nudge as a secondary kick. Never use opacity < 1 on body — that creates a stacking context that can break iframe compositing. */ var _nudgeScheduled = false; var _bgSince = 0; function _iosRepaintNudge() { if (_nudgeScheduled) return; _nudgeScheduled = true; var cf = document.getElementById('content'); if (!cf) { _nudgeScheduled = false; return; } /* Phase 1: opacity micro-toggle forces GPU re-rasterization */ cf.style.opacity = '0.9999'; cf.style.willChange = 'transform'; void cf.offsetHeight; /* force synchronous layout */ requestAnimationFrame(function() { requestAnimationFrame(function() { cf.style.opacity = ''; /* Phase 2: translateZ sub-pixel nudge re-promotes the compositor layer */ cf.style.transform = 'translateZ(0.0001px)'; requestAnimationFrame(function() { cf.style.transform = ''; cf.style.willChange = ''; _nudgeScheduled = false; }); }); }); } function _onForeground() { if (document.hidden) return; /* Restart news ticker animation (iOS pauses CSS animations in background) */ var run = document.getElementById('nsRun'); if (run) { run.style.animationPlayState = 'paused'; void run.offsetWidth; run.style.animationPlayState = 'running'; } /* If backgrounded for >45s, reload the iframe entirely — GPU layer is gone. TOUCH DEVICES ONLY (iOS/iPad): desktop browsers keep the GPU layer + the app's in-memory state across tab switches, so reloading there needlessly throws away the user's exact screen. On desktop we skip the reload and just repaint, preserving the exact view/scroll/selection with no restore needed. */ var bgDur = _bgSince ? Date.now() - _bgSince : 0; var _touchDev = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); /* Auto-refresh on return: touch devices reload after 45s (GPU layer is gone anyway); desktop preserves state on quick alt-tabs but auto-refreshes data after a real absence (>10 min away → you want current data, and there's no in-progress interaction to lose). */ if ((bgDur > 45000 && _touchDev) || bgDur > 600000) { _bgSince = 0; _reloadContent(); return; } _iosRepaintNudge(); /* Safety net: GPU layer restore is async — re-nudge at 350ms and 900ms */ setTimeout(_iosRepaintNudge, 350); setTimeout(_iosRepaintNudge, 900); /* Also nudge on the very next user touch (re-registered each foreground restore) */ document.addEventListener('touchstart', _iosRepaintNudge, {once: true, passive: true, capture: true}); } document.addEventListener('visibilitychange', function() { if (document.hidden) { _bgSince = Date.now(); } else { _onForeground(); } }); /* window focus catches tab switches and cross-app returns on some iOS versions */ window.addEventListener('focus', _iosRepaintNudge); /* pageshow fires when iOS restores from bfcache */ window.addEventListener('pageshow', function(e) { if (e.persisted) _onForeground(); }); /* ── AUTO-REFRESH ────────────────────────────────────────────────────────────── Keep the dashboard's live data current WITHOUT a manual click. Reloads the content iframe (each view re-reads Firestore on load), but only when it's SAFE — you're idle or returning from away — so an in-progress drill-down / filter / scroll is never nuked mid-use. The header "↻ Refresh" button forces it on demand as the backup. */ var _lastActivity = Date.now(), _lastAutoReload = Date.now(); ['mousemove','mousedown','keydown','scroll','touchstart','click'].forEach(function(ev){ window.addEventListener(ev, function(){ _lastActivity = Date.now(); }, {passive:true, capture:true}); }); function _reloadContent(){ var cf = document.getElementById('content'); if(!cf || !cf.src || cf.src === 'about:blank') return; var cur = cf.src.replace(/([?&])_v=\d+/, '').replace(/[?&]$/, ''); cf.src = cur + (cur.indexOf('?') >= 0 ? '&' : '?') + '_v=' + Date.now(); _lastAutoReload = Date.now(); } /* Backup: force an immediate refresh (wired to the header button). */ function forceRefresh(){ _lastActivity = 0; _reloadContent(); } /* Idle auto-refresh: every 5 min, if the tab is visible AND untouched for 15 min AND it's been >15 min since the last reload, pull fresh data quietly. Idle-gated so it can't interrupt active work — active users get fresh data on their next navigation anyway. */ setInterval(function(){ if(document.hidden) return; var now = Date.now(); if(now - _lastActivity > 900000 && now - _lastAutoReload > 900000) _reloadContent(); }, 300000); /* Relevance filter - the geographic feeds query "apartment ", which on Google News drags in local incident stories (fires, explosions, crime) that merely contain the word "apartment" and outrank dry investment news. Drop anything that reads as a local-incident / crime story so the ticker stays on multifamily INVESTMENT + market news. (Tightening the worker feed queries at the source is a separate, optional worker change.) */ var NS_JUNK_RE = /\b(?:explosion|explode[sd]?|blast|gunman|gunfire|shooting|shootout|stabb(?:ed|ing)|homicide|murder(?:ed)?|killed|fatally|manslaughter|arson|standoff|hostage|overdose|carjack(?:ing)?|armed robbery|burglary|kidnap(?:ped|ping)?|manhunt|sexual assault|amber alert|shot dead|found dead|body found|sex offender|SWAT)\b/i; var NS_FIRE_RE = /\b(?:apartment|building|house|condo|complex|deadly|massive|\d-alarm|two-alarm|three-alarm)\s+fire\b|\bfire\s+(?:at|breaks|rips|guts|tears|destroys?|displaces?|kills?)/i; function _nsRelevant(i){ var t = (i && i.title ? String(i.title) : ""); if(!t) return false; return !NS_JUNK_RE.test(t) && !NS_FIRE_RE.test(t); } /* === Relevance ranking + de-dupe — keeps the ticker clean and on-focus === Floats the most relevant items to the FRONT (so they show first each cycle): Kennedy Wilson > competitors > Texas/Denver apartment > broader West > MF. Also drops near-duplicate headlines that arrive from multiple feeds. */ var NS_KW_RE = /kennedy\s*wilson|\bKWMF\b/i; /* Competitors to track — EDIT this list (used for ranking; the worker must also query these names for their news to actually arrive). */ var NS_COMPETITORS = ["Hamilton Zanze","MG Properties","Prometheus","Rockwood Capital","Abacus Capital","Prime Residential","Jackson Square Properties","Carmel Partners","FPA Multifamily","Tower 16","Western Wealth"]; // KW peer set — private Western MF investor/operators. EDIT freely. var NS_COMP_RE = NS_COMPETITORS.length ? new RegExp(NS_COMPETITORS.map(function(s){return s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');}).join('|'),'i') : null; var NS_FOCUS_RE = /\b(texas|tx|dallas|fort\s*worth|dfw|arlington|plano|frisco|irving|austin|houston|san\s*antonio|denver|colorado|aurora|boulder|fort\s*collins|colorado\s*springs|lakewood|centennial)\b/i; var NS_WEST_RE = /\b(phoenix|tucson|arizona|las\s*vegas|henderson|reno|nevada|salt\s*lake|utah|albuquerque|new\s*mexico|california|sacramento|seattle|portland|boise|idaho)\b/i; var NS_MF_RE = /\b(apartment|multifamily|multi-?family|rent(?:al|s)?|lease[- ]?up|garden|mid-?rise|BTR|build-to-rent|units?\b)\b/i; function _nsNormTitle(t){ return String(t||"").toLowerCase().replace(/\s+[-–|]\s+[^-–|]*$/,"").replace(/[^a-z0-9]+/g," ").trim(); } function _nsScore(i){ var t = (i&&i.title?String(i.title):"") + " " + (i&&i.source?String(i.source):""); var s = 0, mf = NS_MF_RE.test(t); if(NS_KW_RE.test(t)) s += 1000; if(NS_COMP_RE && NS_COMP_RE.test(t)) s += 300; if(NS_FOCUS_RE.test(t)) s += mf ? 200 : 80; // Texas + Denver = primary focus else if(NS_WEST_RE.test(t)) s += mf ? 120 : 40; // broader West else if(mf) s += 30; if(i&&i.pubDate){ var h=(Date.now()-new Date(i.pubDate))/3600000; if(h<24)s+=15; else if(h<72)s+=8; } return s; } function _nsDedupRank(items){ var seen={}, out=[]; items.forEach(function(i){ var k=_nsNormTitle(i&&i.title); if(!k||seen[k])return; seen[k]=1; out.push(i); }); out.sort(function(a,b){ return _nsScore(b)-_nsScore(a) || (new Date(b&&b.pubDate||0)-new Date(a&&a.pubDate||0)); }); return out.slice(0,45); } async function loadNews() { // Always show the strip - use fallback while fetching _renderNewsStrip(NEWS_FALLBACK); try { const r = await fetch(NEWS_WORKER_URL); if (!r.ok) return; const d = await r.json(); const items = _nsDedupRank((d.items || []).filter(_nsRelevant)); // clean junk, de-dupe, rank by focus if (items.length >= 4) _renderNewsStrip(items); // replace fallback with live news } catch (e) { /* worker unreachable - fallback stays */ } } setInterval(loadNews, 1200000); // refresh every 20 min /* =========================================================== INIT =========================================================== */ document.addEventListener("DOMContentLoaded", () => { // Restore last-used app try { const saved = localStorage.getItem("kwmf_app"); if (saved === "portfolio" || saved === "pipeline") CURRENT_APP = saved; } catch (e) {} $("#loginBtn").onclick = handleLogin; $("#loginPw").addEventListener("keypress", e => { if (e.key === "Enter") handleLogin(); }); $("#loginEmail").addEventListener("keypress", e => { if (e.key === "Enter") $("#loginPw").focus(); }); $("#toggleAuthMode").onclick = toggleAuthMode; $("#forgotPw").onclick = handleForgotPw; $("#signOutBtn").onclick = () => { try { localStorage.removeItem('kwmf_savedAuth'); } catch(e) {} AUTH.signOut(); }; /* Fullscreen toggle */ document.addEventListener("keydown", e => { if (e.key === "F" && !e.ctrlKey && !e.metaKey && !e.altKey && document.activeElement.tagName !== "INPUT") toggleFullscreen(); }); document.addEventListener("fullscreenchange", () => { const btn = $("#fsBtn"); if (btn) btn.innerHTML = document.fullscreenElement ? "✕" : "⛶"; }); $$("#topNav button").forEach(b => b.onclick = () => loadApp(b.dataset.app)); $("#membersIcon").onclick = () => loadApp("portfolio", "view=members"); });