@archerjessop/utilities 7.22.0 → 7.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"click-handlers.js","sources":["../../../src/browser/ui/click-handlers.js"],"sourcesContent":["import { attachTooltip, removeTooltip, updateTooltipContent } from './tooltip-manager.js';\r\nimport { generatePriceTooltipHTML, generateCapRateTooltipHTML, generateDownPaymentTooltipHTML } from '../financial/tooltip-content-generators.js';\r\nimport { FINANCIAL_CONSTANTS } from '../../config/financial.js';\r\n\r\n// State is injected via the `callbacks` object (callbacks.state / callbacks.updateState)\r\n// so this shared module has no dependency on any per-platform global-state singleton.\r\n\r\nfunction updateDiscountButtonText(state) {\r\n const btn = document.getElementById(\"ln-discount-btn\");\r\n if (!btn) return;\r\n btn.textContent = state.currentPriceDiscount > 0 ? \"Reset to Asking\" : \"85% of Asking\";\r\n}\r\n\r\nexport function setupDiscountButtonHandler(buttonElement, callbacks) {\r\n if (!buttonElement) return;\r\n\r\n if (buttonElement.dataset.handlerAttached === 'true') return;\r\n buttonElement.dataset.handlerAttached = 'true';\r\n\r\n const { state, updateState } = callbacks;\r\n\r\n buttonElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n if (state.currentPriceDiscount > 0) {\r\n updateState({ currentPriceDiscount: 0 });\r\n } else {\r\n updateState({ currentPriceDiscount: 15 });\r\n }\r\n\r\n const priceElement = document.getElementById(\"prop-price\");\r\n if (priceElement) {\r\n priceElement.textContent = callbacks.getCurrentPrice();\r\n }\r\n callbacks.updatePriceLabel();\r\n callbacks.recalculateFinancials();\r\n updateDiscountButtonText(state);\r\n });\r\n}\r\n\r\nexport function setupPriceClickHandler(priceElement, priceLabelElement, callbacks) {\r\n if (!priceElement || !priceLabelElement) return;\r\n\r\n // Prevent duplicate attachment\r\n if (priceElement.dataset.handlerAttached === 'true') return;\r\n priceElement.dataset.handlerAttached = 'true';\r\n\r\n const { state, updateState } = callbacks;\r\n const metric = priceElement.closest('.metric');\r\n\r\n // Manual price entry — only when the page exposed no usable price (priceWasDefaulted or a\r\n // non-numeric display like \"No price\"). Committing a positive number sets it as the listing\r\n // price and re-flows everything, clearing the all-N/A state a missing price causes. When a\r\n // real price exists, the click keeps cycling the discount (below).\r\n function commitPrice(raw) {\r\n const match = String(raw).match(/[\\d,.]+/);\r\n const value = match ? parseFloat(match[0].replace(/,/g, \"\")) : NaN;\r\n if (Number.isFinite(value) && value > 0) {\r\n const formatted = `$${Math.round(value).toLocaleString()}`;\r\n updateState({ baseNOI: null, currentPriceDiscount: 0, originalPrice: formatted, priceWasDefaulted: false });\r\n priceElement.textContent = formatted;\r\n }\r\n callbacks.updatePriceLabel();\r\n callbacks.recalculateFinancials();\r\n updateDiscountButtonText(state);\r\n }\r\n\r\n function openPriceInput() {\r\n if (priceElement.querySelector(\"input\")) return;\r\n const input = document.createElement(\"input\");\r\n input.type = \"text\";\r\n input.value = \"\";\r\n input.placeholder = \"price $\";\r\n input.className = \"price-input\";\r\n input.style.width = \"110px\";\r\n priceElement.textContent = \"\";\r\n priceElement.appendChild(input);\r\n input.focus();\r\n\r\n let done = false;\r\n const finish = (save) => {\r\n if (done) return;\r\n done = true;\r\n const value = input.value;\r\n input.remove();\r\n if (save) commitPrice(value);\r\n else callbacks.recalculateFinancials();\r\n };\r\n input.addEventListener(\"keydown\", (ev) => {\r\n if (ev.key === \"Enter\") { ev.preventDefault(); finish(true); }\r\n else if (ev.key === \"Escape\") { ev.preventDefault(); finish(false); }\r\n });\r\n input.addEventListener(\"blur\", () => finish(true));\r\n }\r\n\r\n priceElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n if (priceElement.querySelector(\"input\")) return;\r\n\r\n const priceMissing = state.priceWasDefaulted || !/\\d/.test(priceElement.textContent || \"\");\r\n if (priceMissing) {\r\n openPriceInput();\r\n return;\r\n }\r\n\r\n let newDiscount = Math.floor(state.currentPriceDiscount / 10) * 10 + 10;\r\n if (newDiscount > 50) {\r\n newDiscount = 0;\r\n }\r\n\r\n updateState({ currentPriceDiscount: newDiscount });\r\n\r\n const newPrice = callbacks.getCurrentPrice();\r\n priceElement.textContent = newPrice;\r\n callbacks.updatePriceLabel();\r\n callbacks.recalculateFinancials();\r\n updateDiscountButtonText(state);\r\n\r\n if (metric) {\r\n const tooltipContent = generatePriceTooltipHTML(state.currentPriceDiscount);\r\n updateTooltipContent(metric, tooltipContent);\r\n }\r\n });\r\n\r\n priceLabelElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n updateState({ currentPriceDiscount: 0 });\r\n\r\n const resetPrice = state.originalPrice;\r\n priceElement.textContent = resetPrice;\r\n callbacks.updatePriceLabel();\r\n callbacks.recalculateFinancials();\r\n updateDiscountButtonText(state);\r\n\r\n if (metric) {\r\n const tooltipContent = generatePriceTooltipHTML(state.currentPriceDiscount);\r\n updateTooltipContent(metric, tooltipContent);\r\n }\r\n });\r\n\r\n if (metric) {\r\n const tooltipContent = generatePriceTooltipHTML(state.currentPriceDiscount);\r\n attachTooltip(metric, tooltipContent);\r\n priceLabelElement.classList.add('has-tooltip');\r\n }\r\n\r\n priceElement.style.cursor = \"pointer\";\r\n priceLabelElement.style.cursor = \"pointer\";\r\n}\r\n\r\n// Manual cap-rate entry on the cap cell — available for EVERY listing (reported, estimated, or\r\n// none). Clicking the cap value swaps in an inline input; committing a positive number routes\r\n// through the engine's capManuallySet override (NOI = original price x cap for every type), so\r\n// any change re-flows all calculations. baseNOI is cleared so the override recomputes, and\r\n// isUsingEstimatedCapRate is set so the calc reads the typed value from state rather than the\r\n// DOM. Clicking the label resets to the page's reported cap when there was one, else to the\r\n// 5% estimate.\r\nexport function setupCapRateClickHandler(capElement, capLabelElement, callbacks) {\r\n if (!capElement || !capLabelElement) return;\r\n\r\n // Prevent duplicate attachment\r\n if (capElement.dataset.handlerAttached === 'true') return;\r\n capElement.dataset.handlerAttached = 'true';\r\n\r\n const { state, updateState } = callbacks;\r\n const metric = capElement.closest('.metric');\r\n\r\n function commit(raw) {\r\n const match = String(raw).match(/[\\d.]+/);\r\n const value = match ? parseFloat(match[0]) : NaN;\r\n if (Number.isFinite(value) && value > 0) {\r\n updateState({\r\n baseNOI: null,\r\n capManuallySet: true,\r\n currentEstimatedCapRate: value,\r\n isUsingEstimatedCapRate: true,\r\n });\r\n }\r\n callbacks.recalculateFinancials();\r\n if (metric) {\r\n const tooltipContent = generateCapRateTooltipHTML(state.isUsingEstimatedCapRate);\r\n if (tooltipContent) updateTooltipContent(metric, tooltipContent);\r\n }\r\n }\r\n\r\n capElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n if (capElement.querySelector(\"input\")) return;\r\n\r\n const match = (capElement.textContent || \"\").match(/[\\d.]+/);\r\n const input = document.createElement(\"input\");\r\n input.type = \"text\";\r\n input.value = match ? match[0] : \"\";\r\n input.placeholder = \"cap %\";\r\n input.className = \"cap-input\";\r\n input.style.width = \"56px\";\r\n capElement.textContent = \"\";\r\n capElement.appendChild(input);\r\n input.focus();\r\n input.select();\r\n\r\n let done = false;\r\n const finish = (save) => {\r\n if (done) return;\r\n done = true;\r\n const value = input.value;\r\n // Remove the input before recalc so updateActiveCapDisplay can repaint the cap cell\r\n // (prop-cap is painted only there, never by applyFinancials).\r\n input.remove();\r\n if (save) commit(value);\r\n else callbacks.recalculateFinancials();\r\n };\r\n input.addEventListener(\"keydown\", (ev) => {\r\n if (ev.key === \"Enter\") { ev.preventDefault(); finish(true); }\r\n else if (ev.key === \"Escape\") { ev.preventDefault(); finish(false); }\r\n });\r\n input.addEventListener(\"blur\", () => finish(true));\r\n });\r\n\r\n capLabelElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n const reportedMatch = state.originalCapRate && !state.originalCapRate.includes(\"*\")\r\n ? state.originalCapRate.match(/[\\d.]+/)\r\n : null;\r\n\r\n if (reportedMatch) {\r\n // Restore the page's reported cap: write it to the cell so the non-estimated calc path\r\n // (which reads the cap cell) recomputes against it, not the prior override.\r\n capElement.textContent = `${parseFloat(reportedMatch[0])}%`;\r\n updateState({ baseNOI: null, capManuallySet: false, isUsingEstimatedCapRate: false });\r\n } else {\r\n const originalCapRate = state.originalEstimatedCapRate || FINANCIAL_CONSTANTS.DEFAULT_CAP_RATE * 100;\r\n updateState({\r\n baseNOI: null,\r\n capManuallySet: false,\r\n currentEstimatedCapRate: originalCapRate,\r\n isUsingEstimatedCapRate: true,\r\n });\r\n }\r\n callbacks.recalculateFinancials();\r\n\r\n if (metric) {\r\n const tooltipContent = generateCapRateTooltipHTML(state.isUsingEstimatedCapRate);\r\n if (tooltipContent) {\r\n updateTooltipContent(metric, tooltipContent);\r\n }\r\n }\r\n });\r\n\r\n if (metric) {\r\n const tooltipContent = generateCapRateTooltipHTML(state.isUsingEstimatedCapRate);\r\n if (tooltipContent) {\r\n attachTooltip(metric, tooltipContent);\r\n capLabelElement.classList.add('has-tooltip');\r\n }\r\n }\r\n\r\n capElement.style.cursor = \"pointer\";\r\n capLabelElement.style.cursor = \"pointer\";\r\n}\r\n\r\n// Manual STR-gross entry on the NOI cell (STR mode only). Clicking the NOI value swaps in an\r\n// inline input; committing a positive number stores it as the measured STR gross\r\n// (cachedStrValue {value, type:\"gross\"}) — the SAME seam the dormant str-revenue backend would\r\n// fill — so calculateFinancials applies NOI = gross x NOI_PERCENTAGE. baseNOI is cleared so the\r\n// type model recomputes, and capManuallySet is cleared so a prior cap-click override does not\r\n// clobber the gross. Clicking the NOI label resets to the 5.5%-of-price estimate.\r\nexport function setupNoiClickHandler(noiElement, noiLabelElement, callbacks) {\r\n if (!noiElement || !noiLabelElement) return;\r\n\r\n if (noiElement.dataset.handlerAttached === \"true\") return;\r\n noiElement.dataset.handlerAttached = \"true\";\r\n\r\n const { state, updateState } = callbacks;\r\n\r\n function commit(raw) {\r\n const match = String(raw).match(/[\\d,.]+/);\r\n const value = match ? parseFloat(match[0].replace(/,/g, \"\")) : NaN;\r\n if (Number.isFinite(value) && value > 0) {\r\n updateState({ cachedStrValue: { value, type: \"gross\" }, baseNOI: null, capManuallySet: false });\r\n }\r\n callbacks.recalculateFinancials();\r\n }\r\n\r\n noiElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n if (state.currentPropertyType !== \"str\") return;\r\n if (noiElement.querySelector(\"input\")) return;\r\n\r\n const current = state.cachedStrValue && Number.isFinite(state.cachedStrValue.value)\r\n ? String(state.cachedStrValue.value)\r\n : \"\";\r\n const input = document.createElement(\"input\");\r\n input.type = \"text\";\r\n input.value = current;\r\n input.placeholder = \"Awning gross $/yr\";\r\n input.className = \"noi-input\";\r\n input.style.width = \"92px\";\r\n noiElement.textContent = \"\";\r\n noiElement.appendChild(input);\r\n input.focus();\r\n input.select();\r\n\r\n let done = false;\r\n const finish = (save) => {\r\n if (done) return;\r\n done = true;\r\n if (save) commit(input.value);\r\n else callbacks.recalculateFinancials();\r\n };\r\n input.addEventListener(\"keydown\", (ev) => {\r\n if (ev.key === \"Enter\") { ev.preventDefault(); finish(true); }\r\n else if (ev.key === \"Escape\") { ev.preventDefault(); finish(false); }\r\n });\r\n input.addEventListener(\"blur\", () => finish(true));\r\n });\r\n\r\n noiLabelElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n if (state.currentPropertyType !== \"str\") return;\r\n updateState({ cachedStrValue: null, baseNOI: null });\r\n callbacks.recalculateFinancials();\r\n });\r\n\r\n noiElement.style.cursor = \"pointer\";\r\n noiLabelElement.style.cursor = \"pointer\";\r\n}\r\n\r\n// The \"↗ Awning\" affordance next to NOI: copy the current address to the clipboard and open\r\n// Awning's public calculator in a new tab, so the analyst pastes the address, reads the gross\r\n// revenue, and types it back into the NOI cell (setupNoiClickHandler). Read the address from\r\n// the live #prop-name so SPA navigation can't bind a stale value.\r\nexport function setupAwningLinkHandler(linkElement) {\r\n if (!linkElement) return;\r\n\r\n if (linkElement.dataset.handlerAttached === \"true\") return;\r\n linkElement.dataset.handlerAttached = \"true\";\r\n\r\n linkElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n const address = document.getElementById(\"prop-name\")?.textContent?.trim() || \"\";\r\n if (address && navigator.clipboard?.writeText) {\r\n navigator.clipboard.writeText(address).catch(() => {});\r\n }\r\n window.open(\"https://awning.com/airbnb-calculator\", \"_blank\", \"noopener\");\r\n });\r\n}\r\n\r\nexport function setupDownPaymentClickHandler(downElement, downLabelElement, callbacks) {\r\n if (!downElement || !downLabelElement) return;\r\n\r\n // Prevent duplicate attachment\r\n if (downElement.dataset.handlerAttached === 'true') return;\r\n downElement.dataset.handlerAttached = 'true';\r\n\r\n const { state, updateState } = callbacks;\r\n const metric = downElement.closest('.metric');\r\n\r\n downElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n let newDownPercent = state.currentDownPaymentPercent - 10;\r\n let newDSCRPercent = state.currentDSCRPercent - 10;\r\n let newSellerFiPercent = state.currentSellerFiPercent + 10;\r\n\r\n if (newDownPercent < 0) {\r\n newDownPercent = 60;\r\n newDSCRPercent = 70;\r\n newSellerFiPercent = 40;\r\n }\r\n\r\n updateState({\r\n currentDownPaymentPercent: newDownPercent,\r\n currentDSCRPercent: newDSCRPercent,\r\n currentSellerFiPercent: newSellerFiPercent\r\n });\r\n\r\n callbacks.updatePercentageLabels();\r\n callbacks.recalculateFinancials();\r\n\r\n setTimeout(() => {\r\n const priceElement = document.getElementById(\"prop-price\");\r\n const noiElement = document.getElementById(\"prop-noi\");\r\n\r\n if (priceElement && noiElement && metric) {\r\n const priceMatch = priceElement.textContent.match(/[\\d,]+/);\r\n const noiMatch = noiElement.textContent.match(/[\\d,.]+/);\r\n\r\n if (priceMatch && noiMatch) {\r\n const price = parseFloat(priceMatch[0].replace(/,/g, \"\"));\r\n let noi = parseFloat(noiMatch[0].replace(/,/g, \"\"));\r\n\r\n if (noiElement.textContent.includes(\"K\")) noi *= 1000;\r\n if (noiElement.textContent.includes(\"M\")) noi *= 1000000;\r\n\r\n removeTooltip(metric);\r\n setTimeout(() => {\r\n const tooltipContent = generateDownPaymentTooltipHTML(\r\n price,\r\n noi,\r\n state.currentDownPaymentPercent,\r\n state.currentDSCRPercent,\r\n state.currentSellerFiPercent,\r\n state.currentInterestRateType\r\n );\r\n updateTooltipContent(metric, tooltipContent);\r\n }, 50);\r\n }\r\n }\r\n }, 100);\r\n });\r\n\r\n downLabelElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n updateState({\r\n currentDownPaymentPercent: FINANCIAL_CONSTANTS.SELLER_FI_DOWN_PAYMENT * 100,\r\n currentDSCRPercent: FINANCIAL_CONSTANTS.DEFAULT_DSCR_PERCENTAGE * 100,\r\n currentSellerFiPercent: FINANCIAL_CONSTANTS.SELLER_FI_CARRY * 100\r\n });\r\n\r\n callbacks.updatePercentageLabels();\r\n callbacks.recalculateFinancials();\r\n\r\n setTimeout(() => {\r\n const priceElement = document.getElementById(\"prop-price\");\r\n const noiElement = document.getElementById(\"prop-noi\");\r\n\r\n if (priceElement && noiElement && metric) {\r\n const priceMatch = priceElement.textContent.match(/[\\d,]+/);\r\n const noiMatch = noiElement.textContent.match(/[\\d,.]+/);\r\n\r\n if (priceMatch && noiMatch) {\r\n const price = parseFloat(priceMatch[0].replace(/,/g, \"\"));\r\n let noi = parseFloat(noiMatch[0].replace(/,/g, \"\"));\r\n\r\n if (noiElement.textContent.includes(\"K\")) noi *= 1000;\r\n if (noiElement.textContent.includes(\"M\")) noi *= 1000000;\r\n\r\n removeTooltip(metric);\r\n setTimeout(() => {\r\n const tooltipContent = generateDownPaymentTooltipHTML(\r\n price,\r\n noi,\r\n state.currentDownPaymentPercent,\r\n state.currentDSCRPercent,\r\n state.currentSellerFiPercent,\r\n state.currentInterestRateType\r\n );\r\n updateTooltipContent(metric, tooltipContent);\r\n }, 50);\r\n }\r\n }\r\n }, 100);\r\n });\r\n\r\n if (metric && downLabelElement) {\r\n downLabelElement.classList.add('has-tooltip');\r\n }\r\n\r\n downElement.style.cursor = \"pointer\";\r\n downLabelElement.style.cursor = \"pointer\";\r\n}\r\n\r\n// Clicking the Equity cell resets the stack to the 60% down tier (DSCR 70 / seller carry 40) —\r\n// the highest down payment, the fastest way to cure an \"Equity\" red (down payment must cover the\r\n// seller's existing debt). Matches the 60/70/40 wrap in the down-payment handler above.\r\nexport function setupEquityResetHandler(equityElement, callbacks) {\r\n if (!equityElement) return;\r\n if (equityElement.dataset.handlerAttached === \"true\") return;\r\n equityElement.dataset.handlerAttached = \"true\";\r\n\r\n const { updateState } = callbacks;\r\n\r\n equityElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n updateState({\r\n currentDSCRPercent: 70,\r\n currentDownPaymentPercent: 60,\r\n currentSellerFiPercent: 40,\r\n });\r\n\r\n callbacks.updatePercentageLabels();\r\n callbacks.recalculateFinancials();\r\n });\r\n\r\n equityElement.style.cursor = \"pointer\";\r\n}\r\n"],"names":["updateDiscountButtonText","state","btn","document","getElementById","textContent","currentPriceDiscount","setupDiscountButtonHandler","buttonElement","callbacks","dataset","handlerAttached","updateState","addEventListener","e","preventDefault","stopPropagation","priceElement","getCurrentPrice","updatePriceLabel","recalculateFinancials","setupPriceClickHandler","priceLabelElement","metric","closest","openPriceInput","querySelector","input","createElement","type","value","placeholder","className","style","width","appendChild","focus","done","finish","save","remove","raw","match","String","parseFloat","replace","NaN","Number","isFinite","formatted","Math","round","toLocaleString","baseNOI","originalPrice","priceWasDefaulted","commitPrice","ev","key","test","newDiscount","floor","newPrice","tooltipContent","generatePriceTooltipHTML","updateTooltipContent","resetPrice","attachTooltip","classList","add","cursor","setupCapRateClickHandler","capElement","capLabelElement","select","capManuallySet","currentEstimatedCapRate","isUsingEstimatedCapRate","generateCapRateTooltipHTML","commit","reportedMatch","originalCapRate","includes","originalEstimatedCapRate","FINANCIAL_CONSTANTS","DEFAULT_CAP_RATE","setupNoiClickHandler","noiElement","noiLabelElement","currentPropertyType","current","cachedStrValue","setupAwningLinkHandler","linkElement","address","trim","navigator","clipboard","writeText","catch","window","open","setupDownPaymentClickHandler","downElement","downLabelElement","newDownPercent","currentDownPaymentPercent","newDSCRPercent","currentDSCRPercent","newSellerFiPercent","currentSellerFiPercent","updatePercentageLabels","setTimeout","priceMatch","noiMatch","price","noi","removeTooltip","generateDownPaymentTooltipHTML","currentInterestRateType","SELLER_FI_DOWN_PAYMENT","DEFAULT_DSCR_PERCENTAGE","SELLER_FI_CARRY","setupEquityResetHandler","equityElement"],"mappings":"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"}
1
+ {"version":3,"file":"click-handlers.js","sources":["../../../src/browser/ui/click-handlers.js"],"sourcesContent":["import { attachTooltip, removeTooltip, updateTooltipContent } from './tooltip-manager.js';\r\nimport { generatePriceTooltipHTML, generateCapRateTooltipHTML, generateDownPaymentTooltipHTML } from '../financial/tooltip-content-generators.js';\r\nimport { FINANCIAL_CONSTANTS } from '../../config/financial.js';\r\n\r\n// State is injected via the `callbacks` object (callbacks.state / callbacks.updateState)\r\n// so this shared module has no dependency on any per-platform global-state singleton.\r\n\r\nfunction updateDiscountButtonText(state) {\r\n const btn = document.getElementById(\"ln-discount-btn\");\r\n if (!btn) return;\r\n btn.textContent = state.currentPriceDiscount > 0 ? \"Reset to Asking\" : \"85% of Asking\";\r\n}\r\n\r\nexport function setupDiscountButtonHandler(buttonElement, callbacks) {\r\n if (!buttonElement) return;\r\n\r\n if (buttonElement.dataset.handlerAttached === 'true') return;\r\n buttonElement.dataset.handlerAttached = 'true';\r\n\r\n const { state, updateState } = callbacks;\r\n\r\n buttonElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n if (state.currentPriceDiscount > 0) {\r\n updateState({ currentPriceDiscount: 0 });\r\n } else {\r\n updateState({ currentPriceDiscount: 15 });\r\n }\r\n\r\n const priceElement = document.getElementById(\"prop-price\");\r\n if (priceElement) {\r\n priceElement.textContent = callbacks.getCurrentPrice();\r\n }\r\n callbacks.updatePriceLabel();\r\n callbacks.recalculateFinancials();\r\n updateDiscountButtonText(state);\r\n });\r\n}\r\n\r\nexport function setupPriceClickHandler(priceElement, priceLabelElement, callbacks) {\r\n if (!priceElement || !priceLabelElement) return;\r\n\r\n // Prevent duplicate attachment\r\n if (priceElement.dataset.handlerAttached === 'true') return;\r\n priceElement.dataset.handlerAttached = 'true';\r\n\r\n const { state, updateState } = callbacks;\r\n const metric = priceElement.closest('.metric');\r\n\r\n // Manual price entry — only when the page exposed no usable price (priceWasDefaulted or a\r\n // non-numeric display like \"No price\"). Committing a positive number sets it as the listing\r\n // price and re-flows everything, clearing the all-N/A state a missing price causes. When a\r\n // real price exists, the click keeps cycling the discount (below).\r\n function commitPrice(raw) {\r\n const match = String(raw).match(/[\\d,.]+/);\r\n const value = match ? parseFloat(match[0].replace(/,/g, \"\")) : NaN;\r\n if (Number.isFinite(value) && value > 0) {\r\n const formatted = `$${Math.round(value).toLocaleString()}`;\r\n updateState({ baseNOI: null, currentPriceDiscount: 0, originalPrice: formatted, priceWasDefaulted: false });\r\n priceElement.textContent = formatted;\r\n }\r\n callbacks.updatePriceLabel();\r\n callbacks.recalculateFinancials();\r\n updateDiscountButtonText(state);\r\n }\r\n\r\n function openPriceInput() {\r\n if (priceElement.querySelector(\"input\")) return;\r\n const input = document.createElement(\"input\");\r\n input.type = \"text\";\r\n input.value = \"\";\r\n input.placeholder = \"price $\";\r\n input.className = \"price-input\";\r\n input.style.width = \"110px\";\r\n priceElement.textContent = \"\";\r\n priceElement.appendChild(input);\r\n input.focus();\r\n\r\n let done = false;\r\n const finish = (save) => {\r\n if (done) return;\r\n done = true;\r\n const value = input.value;\r\n input.remove();\r\n if (save) commitPrice(value);\r\n else callbacks.recalculateFinancials();\r\n };\r\n input.addEventListener(\"keydown\", (ev) => {\r\n if (ev.key === \"Enter\") { ev.preventDefault(); finish(true); }\r\n else if (ev.key === \"Escape\") { ev.preventDefault(); finish(false); }\r\n });\r\n input.addEventListener(\"blur\", () => finish(true));\r\n }\r\n\r\n priceElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n if (priceElement.querySelector(\"input\")) return;\r\n\r\n const priceMissing = state.priceWasDefaulted || !/\\d/.test(priceElement.textContent || \"\");\r\n if (priceMissing) {\r\n openPriceInput();\r\n return;\r\n }\r\n\r\n let newDiscount = Math.floor(state.currentPriceDiscount / 10) * 10 + 10;\r\n if (newDiscount > 50) {\r\n newDiscount = 0;\r\n }\r\n\r\n updateState({ currentPriceDiscount: newDiscount });\r\n\r\n const newPrice = callbacks.getCurrentPrice();\r\n priceElement.textContent = newPrice;\r\n callbacks.updatePriceLabel();\r\n callbacks.recalculateFinancials();\r\n updateDiscountButtonText(state);\r\n\r\n if (metric) {\r\n const tooltipContent = generatePriceTooltipHTML(state.currentPriceDiscount);\r\n updateTooltipContent(metric, tooltipContent);\r\n }\r\n });\r\n\r\n priceLabelElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n updateState({ currentPriceDiscount: 0 });\r\n\r\n const resetPrice = state.originalPrice;\r\n priceElement.textContent = resetPrice;\r\n callbacks.updatePriceLabel();\r\n callbacks.recalculateFinancials();\r\n updateDiscountButtonText(state);\r\n\r\n if (metric) {\r\n const tooltipContent = generatePriceTooltipHTML(state.currentPriceDiscount);\r\n updateTooltipContent(metric, tooltipContent);\r\n }\r\n });\r\n\r\n if (metric) {\r\n const tooltipContent = generatePriceTooltipHTML(state.currentPriceDiscount);\r\n attachTooltip(metric, tooltipContent);\r\n priceLabelElement.classList.add('has-tooltip');\r\n }\r\n\r\n priceElement.style.cursor = \"pointer\";\r\n priceLabelElement.style.cursor = \"pointer\";\r\n}\r\n\r\n// Manual cap-rate entry on the cap cell — available for EVERY listing (reported, estimated, or\r\n// none). Clicking the cap value swaps in an inline input; committing a positive number routes\r\n// through the engine's capManuallySet override (NOI = original price x cap for every type), so\r\n// any change re-flows all calculations. baseNOI is cleared so the override recomputes, and\r\n// isUsingEstimatedCapRate is set so the calc reads the typed value from state rather than the\r\n// DOM. Clicking the label resets to the page's reported cap when there was one, else to the\r\n// 5% estimate.\r\nexport function setupCapRateClickHandler(capElement, capLabelElement, callbacks) {\r\n if (!capElement || !capLabelElement) return;\r\n\r\n // Prevent duplicate attachment\r\n if (capElement.dataset.handlerAttached === 'true') return;\r\n capElement.dataset.handlerAttached = 'true';\r\n\r\n const { state, updateState } = callbacks;\r\n const metric = capElement.closest('.metric');\r\n\r\n function commit(raw) {\r\n const match = String(raw).match(/[\\d.]+/);\r\n const value = match ? parseFloat(match[0]) : NaN;\r\n if (Number.isFinite(value) && value > 0) {\r\n updateState({\r\n baseNOI: null,\r\n capManuallySet: true,\r\n currentEstimatedCapRate: value,\r\n isUsingEstimatedCapRate: true,\r\n });\r\n }\r\n callbacks.recalculateFinancials();\r\n if (metric) {\r\n const tooltipContent = generateCapRateTooltipHTML(state.isUsingEstimatedCapRate);\r\n if (tooltipContent) updateTooltipContent(metric, tooltipContent);\r\n }\r\n }\r\n\r\n capElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n if (capElement.querySelector(\"input\")) return;\r\n\r\n const match = (capElement.textContent || \"\").match(/[\\d.]+/);\r\n const input = document.createElement(\"input\");\r\n input.type = \"text\";\r\n input.value = match ? match[0] : \"\";\r\n input.placeholder = \"cap %\";\r\n input.className = \"cap-input\";\r\n input.style.width = \"56px\";\r\n capElement.textContent = \"\";\r\n capElement.appendChild(input);\r\n input.focus();\r\n input.select();\r\n\r\n let done = false;\r\n const finish = (save) => {\r\n if (done) return;\r\n done = true;\r\n const value = input.value;\r\n // Remove the input before recalc so updateActiveCapDisplay can repaint the cap cell\r\n // (prop-cap is painted only there, never by applyFinancials).\r\n input.remove();\r\n if (save) commit(value);\r\n else callbacks.recalculateFinancials();\r\n };\r\n input.addEventListener(\"keydown\", (ev) => {\r\n if (ev.key === \"Enter\") { ev.preventDefault(); finish(true); }\r\n else if (ev.key === \"Escape\") { ev.preventDefault(); finish(false); }\r\n });\r\n input.addEventListener(\"blur\", () => finish(true));\r\n });\r\n\r\n capLabelElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n const reportedMatch = state.originalCapRate && !state.originalCapRate.includes(\"*\")\r\n ? state.originalCapRate.match(/[\\d.]+/)\r\n : null;\r\n\r\n if (reportedMatch) {\r\n // Restore the page's reported cap: write it to the cell so the non-estimated calc path\r\n // (which reads the cap cell) recomputes against it, not the prior override.\r\n capElement.textContent = `${parseFloat(reportedMatch[0])}%`;\r\n updateState({ baseNOI: null, capManuallySet: false, isUsingEstimatedCapRate: false });\r\n } else {\r\n const originalCapRate = state.originalEstimatedCapRate || FINANCIAL_CONSTANTS.DEFAULT_CAP_RATE * 100;\r\n updateState({\r\n baseNOI: null,\r\n capManuallySet: false,\r\n currentEstimatedCapRate: originalCapRate,\r\n isUsingEstimatedCapRate: true,\r\n });\r\n }\r\n callbacks.recalculateFinancials();\r\n\r\n if (metric) {\r\n const tooltipContent = generateCapRateTooltipHTML(state.isUsingEstimatedCapRate);\r\n if (tooltipContent) {\r\n updateTooltipContent(metric, tooltipContent);\r\n }\r\n }\r\n });\r\n\r\n if (metric) {\r\n const tooltipContent = generateCapRateTooltipHTML(state.isUsingEstimatedCapRate);\r\n if (tooltipContent) {\r\n attachTooltip(metric, tooltipContent);\r\n capLabelElement.classList.add('has-tooltip');\r\n }\r\n }\r\n\r\n capElement.style.cursor = \"pointer\";\r\n capLabelElement.style.cursor = \"pointer\";\r\n}\r\n\r\n// Manual STR-gross entry on the NOI cell (STR mode only). Clicking the NOI value swaps in an\r\n// inline input; committing a positive number stores it as the measured STR gross\r\n// (cachedStrValue {value, type:\"gross\"}) — the SAME seam the dormant str-revenue backend would\r\n// fill — so calculateFinancials applies NOI = gross x NOI_PERCENTAGE. baseNOI is cleared so the\r\n// type model recomputes, and capManuallySet is cleared so a prior cap-click override does not\r\n// clobber the gross. Clicking the NOI label resets to the 5.5%-of-price estimate.\r\nexport function setupNoiClickHandler(noiElement, noiLabelElement, callbacks) {\r\n if (!noiElement || !noiLabelElement) return;\r\n\r\n if (noiElement.dataset.handlerAttached === \"true\") return;\r\n noiElement.dataset.handlerAttached = \"true\";\r\n\r\n const { state, updateState } = callbacks;\r\n\r\n function commit(raw) {\r\n const match = String(raw).match(/[\\d,.]+/);\r\n const value = match ? parseFloat(match[0].replace(/,/g, \"\")) : NaN;\r\n if (Number.isFinite(value) && value > 0) {\r\n updateState({ cachedStrValue: { value, type: \"gross\" }, baseNOI: null, capManuallySet: false });\r\n }\r\n callbacks.recalculateFinancials();\r\n }\r\n\r\n noiElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n if (state.currentPropertyType !== \"str\") return;\r\n if (noiElement.querySelector(\"input\")) return;\r\n\r\n const current = state.cachedStrValue && Number.isFinite(state.cachedStrValue.value)\r\n ? String(state.cachedStrValue.value)\r\n : \"\";\r\n const input = document.createElement(\"input\");\r\n input.type = \"text\";\r\n input.value = current;\r\n input.placeholder = \"Awning gross $/yr\";\r\n input.className = \"noi-input\";\r\n input.style.width = \"92px\";\r\n noiElement.textContent = \"\";\r\n noiElement.appendChild(input);\r\n input.focus();\r\n input.select();\r\n\r\n let done = false;\r\n const finish = (save) => {\r\n if (done) return;\r\n done = true;\r\n if (save) commit(input.value);\r\n else callbacks.recalculateFinancials();\r\n };\r\n input.addEventListener(\"keydown\", (ev) => {\r\n if (ev.key === \"Enter\") { ev.preventDefault(); finish(true); }\r\n else if (ev.key === \"Escape\") { ev.preventDefault(); finish(false); }\r\n });\r\n input.addEventListener(\"blur\", () => finish(true));\r\n });\r\n\r\n noiLabelElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n if (state.currentPropertyType !== \"str\") return;\r\n updateState({ cachedStrValue: null, baseNOI: null });\r\n callbacks.recalculateFinancials();\r\n });\r\n\r\n noiElement.style.cursor = \"pointer\";\r\n noiLabelElement.style.cursor = \"pointer\";\r\n}\r\n\r\n// The \"↗ Awning\" affordance next to NOI: copy the current address to the clipboard and open\r\n// Awning's public calculator in a new tab, so the analyst pastes the address, reads the gross\r\n// revenue, and types it back into the NOI cell (setupNoiClickHandler). Read the address from\r\n// the live #prop-name so SPA navigation can't bind a stale value.\r\nexport function setupAwningLinkHandler(linkElement) {\r\n if (!linkElement) return;\r\n\r\n if (linkElement.dataset.handlerAttached === \"true\") return;\r\n linkElement.dataset.handlerAttached = \"true\";\r\n\r\n linkElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n const address = document.getElementById(\"prop-name\")?.textContent?.trim() || \"\";\r\n if (address && navigator.clipboard?.writeText) {\r\n navigator.clipboard.writeText(address).catch(() => {});\r\n }\r\n window.open(\"https://awning.com/airbnb-calculator\", \"_blank\", \"noopener\");\r\n });\r\n}\r\n\r\nexport function setupDownPaymentClickHandler(downElement, downLabelElement, callbacks) {\r\n if (!downElement || !downLabelElement) return;\r\n\r\n // Prevent duplicate attachment\r\n if (downElement.dataset.handlerAttached === 'true') return;\r\n downElement.dataset.handlerAttached = 'true';\r\n\r\n const { state, updateState } = callbacks;\r\n const metric = downElement.closest('.metric');\r\n\r\n downElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n let newDownPercent = state.currentDownPaymentPercent - 10;\r\n let newDSCRPercent = state.currentDSCRPercent - 10;\r\n let newSellerFiPercent = state.currentSellerFiPercent + 10;\r\n\r\n if (newDownPercent < 0) {\r\n newDownPercent = 60;\r\n newDSCRPercent = 70;\r\n newSellerFiPercent = 40;\r\n }\r\n\r\n updateState({\r\n currentDownPaymentPercent: newDownPercent,\r\n currentDSCRPercent: newDSCRPercent,\r\n currentSellerFiPercent: newSellerFiPercent\r\n });\r\n\r\n callbacks.updatePercentageLabels();\r\n callbacks.recalculateFinancials();\r\n\r\n setTimeout(() => {\r\n const priceElement = document.getElementById(\"prop-price\");\r\n const noiElement = document.getElementById(\"prop-noi\");\r\n\r\n if (priceElement && noiElement && metric) {\r\n const priceMatch = priceElement.textContent.match(/[\\d,]+/);\r\n const noiMatch = noiElement.textContent.match(/[\\d,.]+/);\r\n\r\n if (priceMatch && noiMatch) {\r\n const price = parseFloat(priceMatch[0].replace(/,/g, \"\"));\r\n let noi = parseFloat(noiMatch[0].replace(/,/g, \"\"));\r\n\r\n if (noiElement.textContent.includes(\"K\")) noi *= 1000;\r\n if (noiElement.textContent.includes(\"M\")) noi *= 1000000;\r\n\r\n removeTooltip(metric);\r\n setTimeout(() => {\r\n const tooltipContent = generateDownPaymentTooltipHTML(\r\n price,\r\n noi,\r\n state.currentDownPaymentPercent,\r\n state.currentDSCRPercent,\r\n state.currentSellerFiPercent,\r\n state.currentInterestRateType\r\n );\r\n updateTooltipContent(metric, tooltipContent);\r\n }, 50);\r\n }\r\n }\r\n }, 100);\r\n });\r\n\r\n downLabelElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n updateState({\r\n currentDownPaymentPercent: FINANCIAL_CONSTANTS.SELLER_FI_DOWN_PAYMENT * 100,\r\n currentDSCRPercent: FINANCIAL_CONSTANTS.DEFAULT_DSCR_PERCENTAGE * 100,\r\n currentSellerFiPercent: FINANCIAL_CONSTANTS.SELLER_FI_CARRY * 100\r\n });\r\n\r\n callbacks.updatePercentageLabels();\r\n callbacks.recalculateFinancials();\r\n\r\n setTimeout(() => {\r\n const priceElement = document.getElementById(\"prop-price\");\r\n const noiElement = document.getElementById(\"prop-noi\");\r\n\r\n if (priceElement && noiElement && metric) {\r\n const priceMatch = priceElement.textContent.match(/[\\d,]+/);\r\n const noiMatch = noiElement.textContent.match(/[\\d,.]+/);\r\n\r\n if (priceMatch && noiMatch) {\r\n const price = parseFloat(priceMatch[0].replace(/,/g, \"\"));\r\n let noi = parseFloat(noiMatch[0].replace(/,/g, \"\"));\r\n\r\n if (noiElement.textContent.includes(\"K\")) noi *= 1000;\r\n if (noiElement.textContent.includes(\"M\")) noi *= 1000000;\r\n\r\n removeTooltip(metric);\r\n setTimeout(() => {\r\n const tooltipContent = generateDownPaymentTooltipHTML(\r\n price,\r\n noi,\r\n state.currentDownPaymentPercent,\r\n state.currentDSCRPercent,\r\n state.currentSellerFiPercent,\r\n state.currentInterestRateType\r\n );\r\n updateTooltipContent(metric, tooltipContent);\r\n }, 50);\r\n }\r\n }\r\n }, 100);\r\n });\r\n\r\n if (metric && downLabelElement) {\r\n downLabelElement.classList.add('has-tooltip');\r\n }\r\n\r\n downElement.style.cursor = \"pointer\";\r\n downLabelElement.style.cursor = \"pointer\";\r\n}\r\n\r\n// Clicking the red-reasons pill resets the stack to the 60% down tier (DSCR 70 / seller carry 40)\r\n// — the highest down payment, the fastest way to cure an \"Equity\" red (down payment must cover the\r\n// seller's existing debt). Matches the 60/70/40 wrap in the down-payment handler above.\r\nexport function setupEquityResetHandler(pillElement, callbacks) {\r\n if (!pillElement) return;\r\n if (pillElement.dataset.handlerAttached === \"true\") return;\r\n pillElement.dataset.handlerAttached = \"true\";\r\n\r\n const { updateState } = callbacks;\r\n\r\n pillElement.addEventListener(\"click\", function(e) {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n\r\n updateState({\r\n currentDSCRPercent: 70,\r\n currentDownPaymentPercent: 60,\r\n currentSellerFiPercent: 40,\r\n });\r\n\r\n callbacks.updatePercentageLabels();\r\n callbacks.recalculateFinancials();\r\n });\r\n\r\n pillElement.style.cursor = \"pointer\";\r\n}\r\n"],"names":["updateDiscountButtonText","state","btn","document","getElementById","textContent","currentPriceDiscount","setupDiscountButtonHandler","buttonElement","callbacks","dataset","handlerAttached","updateState","addEventListener","e","preventDefault","stopPropagation","priceElement","getCurrentPrice","updatePriceLabel","recalculateFinancials","setupPriceClickHandler","priceLabelElement","metric","closest","openPriceInput","querySelector","input","createElement","type","value","placeholder","className","style","width","appendChild","focus","done","finish","save","remove","raw","match","String","parseFloat","replace","NaN","Number","isFinite","formatted","Math","round","toLocaleString","baseNOI","originalPrice","priceWasDefaulted","commitPrice","ev","key","test","newDiscount","floor","newPrice","tooltipContent","generatePriceTooltipHTML","updateTooltipContent","resetPrice","attachTooltip","classList","add","cursor","setupCapRateClickHandler","capElement","capLabelElement","select","capManuallySet","currentEstimatedCapRate","isUsingEstimatedCapRate","generateCapRateTooltipHTML","commit","reportedMatch","originalCapRate","includes","originalEstimatedCapRate","FINANCIAL_CONSTANTS","DEFAULT_CAP_RATE","setupNoiClickHandler","noiElement","noiLabelElement","currentPropertyType","current","cachedStrValue","setupAwningLinkHandler","linkElement","address","trim","navigator","clipboard","writeText","catch","window","open","setupDownPaymentClickHandler","downElement","downLabelElement","newDownPercent","currentDownPaymentPercent","newDSCRPercent","currentDSCRPercent","newSellerFiPercent","currentSellerFiPercent","updatePercentageLabels","setTimeout","priceMatch","noiMatch","price","noi","removeTooltip","generateDownPaymentTooltipHTML","currentInterestRateType","SELLER_FI_DOWN_PAYMENT","DEFAULT_DSCR_PERCENTAGE","SELLER_FI_CARRY","setupEquityResetHandler","pillElement"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- import{createNavigationGuard as e}from"./createNavigationGuard.js";import{createPanel as t}from"./createPanel.js";import{runReveals as a}from"./runReveals.js";import{syncInterestRateForUnits as n}from"./interestRateSync.js";import{setupPriceClickHandler as r,setupCapRateClickHandler as o,setupDownPaymentClickHandler as c,setupEquityResetHandler as i,setupNoiClickHandler as l,setupAwningLinkHandler as p,setupDiscountButtonHandler as s}from"../ui/click-handlers.js";import{calculateFinancials as u}from"../financial/calculateFinancials.js";import{calculateDOM as d}from"../../date/utilities.js";import{normalizeWhitespace as m}from"../../formatting/text.js";function createPipeline({adapter:y,config:f,ctx:g,exportOps:E,finance:b,render:w,resolveCssUrls:h,services:C}){const{state:F,updateState:P}=g,listingId=()=>y.getListingId(window.location.href);function watchLateFields(e){const isPresent=e=>"string"==typeof e&&""!==e.trim()&&"Not found"!==e,applyLateFields=()=>{const e=y.scrape();if(!e)return!1;const t=m(e.contact),a=m(e.phone),n=m(e.listingDate);return w.updateElement("prop-contact",t),w.updateElement("prop-phone",a),w.updateElement("prop-dom",d(n)),isPresent(t)&&isPresent(a)&&isPresent(n)};if(applyLateFields())return;let t=!1;let n=Math.ceil(1e4/300);const tick=()=>{e.isStale()||(!t&&f.reveals?.length&&(t=!0,a(f.reveals).finally(()=>{t=!1})),applyLateFields()||n--<=0||setTimeout(tick,300))};setTimeout(tick,300)}async function updateFooterData(){const t=e(listingId);if(t.capture(),f.reveals?.length&&(await a(f.reveals),t.isStale()))return;const m=b.scrapeAndApply();if(!m)return console.error("❌ Malformed listing data — missing a contract field, refusing to render"),void w.updateElement("prop-name","Data error — see console");const y=m.unitCount??4;P({numberOfUnits:y});const E=document.getElementById("ln-units-input");E&&(E.value=y),n(F,P,y),w.updateElement("prop-name",m.name),w.updateElement("prop-price",F.priceWasDefaulted?"No price":m.price),w.updateElement("prop-contact",m.contact),w.updateElement("prop-phone",m.phone),w.updateElement("prop-dom",d(m.listingDate)),w.updatePriceLabel(),w.updateCapRateLabel(),w.syncUnitsFieldForType(F.currentPropertyType,m.bedroomCount),function(e){const t=document.getElementById("prop-name");t&&e.name&&"Not found"!==e.name&&(t.style.cursor="pointer",t.style.textDecoration="underline",t.onclick=()=>{const t=`https://www.google.com/maps/search/${encodeURIComponent(e.name)}`;window.open(t,"_blank")});const a={getCurrentPrice:w.getCurrentPrice,recalculateFinancials:b.recalculateFinancials,state:F,updatePercentageLabels:w.updatePercentageLabels,updatePriceLabel:w.updatePriceLabel,updateState:P},n=document.getElementById("prop-price");r(n,n?.closest(".metric")?.querySelector(".metric-label"),a);const u=document.getElementById("prop-cap");o(u,u?.closest(".metric")?.querySelector(".metric-label"),a);const d=document.getElementById("prop-down");c(d,d?.closest(".metric")?.querySelector(".metric-label"),a),i(document.getElementById("prop-equity"),a);const m=document.getElementById("prop-noi");l(m,m?.closest(".metric")?.querySelector(".metric-label"),a),p(document.getElementById("prop-noi-awning")),s(document.getElementById("ln-discount-btn"),a)}(m),watchLateFields(t);const h=F.isUsingEstimatedCapRate?`${F.currentEstimatedCapRate}%`:m.capRate,S=await u(g,m.price,h,F.currentPropertyType,m.name);if(t.isStale())return;w.applyFinancials(S),w.updateActiveCapDisplay();const T=await C.loadLeadStatus(m.name);if(t.isStale())return;w.updateElement("prop-lead-status",T.leadStatus),w.updateLeadStatusTooltip(T);const I=await C.loadStrValue(m.name,t);t.isStale()||I&&"str"===F.currentPropertyType&&(P({baseNOI:null}),await b.recalculateFinancials(),t.isStale())||(await C.loadDebt(m.name,t),t.isStale()||await b.recalculateFinancials())}let S=!1,T=null;return{runPipeline:function(){S=!1,T&&(T.disconnect(),T=null),t({callbacks:{onExportClick:E.handleExportClick,onInterestRateTypeChange:()=>b.recalculateFinancials(),onPropertyTypeChange:()=>{b.handlePropertyTypeChange(),w.updateCapRateLabel();const e=y.scrape();w.syncUnitsFieldForType(F.currentPropertyType,e?.bedroomCount),b.recalculateFinancials()},state:F,updateState:P},cssUrls:h(f.cssFiles),defaultPropertyType:f.defaultPropertyType});const stopObserver=()=>{T&&(T.disconnect(),T=null)},tryImmediateUpdate=(e=!1)=>!!(()=>{const e=document.getElementById("prop-name"),t=document.getElementById("prop-price");return!!(e&&t&&e.textContent.trim()&&t.textContent.trim())})()&&(!(!e&&!(()=>{const e=y.scrape();return!!e&&"Not found"!==e.price&&!e.priceWasDefaulted})())&&((async()=>{S||(S=!0,await updateFooterData())})(),!0));if(tryImmediateUpdate())return;T=new MutationObserver(()=>{tryImmediateUpdate()&&stopObserver()}),T.observe(document.body,{childList:!0,subtree:!0});let e=0;const fallbackPoll=()=>{S||(tryImmediateUpdate(e>=8e3)?stopObserver():(e+=300,setTimeout(fallbackPoll,300)))};setTimeout(fallbackPoll,300)},updateFooterData:updateFooterData}}export{createPipeline};
1
+ import{createNavigationGuard as e}from"./createNavigationGuard.js";import{createPanel as t}from"./createPanel.js";import{runReveals as n}from"./runReveals.js";import{syncInterestRateForUnits as a}from"./interestRateSync.js";import{setupPriceClickHandler as r,setupCapRateClickHandler as o,setupDownPaymentClickHandler as c,setupEquityResetHandler as i,setupNoiClickHandler as l,setupAwningLinkHandler as s,setupDiscountButtonHandler as p}from"../ui/click-handlers.js";import{calculateFinancials as u}from"../financial/calculateFinancials.js";import{calculateDOM as d}from"../../date/utilities.js";import{normalizeWhitespace as m}from"../../formatting/text.js";function createPipeline({adapter:y,config:f,ctx:g,exportOps:E,finance:b,render:w,resolveCssUrls:h,services:C}){const{state:F,updateState:P}=g,listingId=()=>y.getListingId(window.location.href);function watchLateFields(e){const isPresent=e=>"string"==typeof e&&""!==e.trim()&&"Not found"!==e,applyLateFields=()=>{const e=y.scrape();if(!e)return!1;const t=m(e.contact),n=m(e.phone),a=m(e.listingDate);return w.updateElement("prop-contact",t),w.updateElement("prop-phone",n),w.updateElement("prop-dom",d(a)),isPresent(t)&&isPresent(n)&&isPresent(a)};if(applyLateFields())return;let t=!1;let a=Math.ceil(1e4/300);const tick=()=>{e.isStale()||(!t&&f.reveals?.length&&(t=!0,n(f.reveals).finally(()=>{t=!1})),applyLateFields()||a--<=0||setTimeout(tick,300))};setTimeout(tick,300)}async function updateFooterData(){const t=e(listingId);if(t.capture(),f.reveals?.length&&(await n(f.reveals),t.isStale()))return;const m=b.scrapeAndApply();if(!m)return console.error("❌ Malformed listing data — missing a contract field, refusing to render"),void w.updateElement("prop-name","Data error — see console");const y=m.unitCount??4;P({numberOfUnits:y});const E=document.getElementById("ln-units-input");E&&(E.value=y),a(F,P,y),w.updateElement("prop-name",m.name),w.updateElement("prop-price",F.priceWasDefaulted?"No price":m.price),w.updateElement("prop-contact",m.contact),w.updateElement("prop-phone",m.phone),w.updateElement("prop-dom",d(m.listingDate)),w.updatePriceLabel(),w.updateCapRateLabel(),w.syncUnitsFieldForType(F.currentPropertyType,m.bedroomCount),function(e){const t=document.getElementById("prop-name");t&&e.name&&"Not found"!==e.name&&(t.style.cursor="pointer",t.style.textDecoration="underline",t.onclick=()=>{const t=`https://www.google.com/maps/search/${encodeURIComponent(e.name)}`;window.open(t,"_blank")});const n={getCurrentPrice:w.getCurrentPrice,recalculateFinancials:b.recalculateFinancials,state:F,updatePercentageLabels:w.updatePercentageLabels,updatePriceLabel:w.updatePriceLabel,updateState:P},a=document.getElementById("prop-price");r(a,a?.closest(".metric")?.querySelector(".metric-label"),n);const u=document.getElementById("prop-cap");o(u,u?.closest(".metric")?.querySelector(".metric-label"),n);const d=document.getElementById("prop-down");c(d,d?.closest(".metric")?.querySelector(".metric-label"),n),i(document.getElementById("ln-red-reasons"),n);const m=document.getElementById("prop-noi");l(m,m?.closest(".metric")?.querySelector(".metric-label"),n),s(document.getElementById("prop-noi-awning")),p(document.getElementById("ln-discount-btn"),n)}(m),watchLateFields(t);const h=F.isUsingEstimatedCapRate?`${F.currentEstimatedCapRate}%`:m.capRate,S=await u(g,m.price,h,F.currentPropertyType,m.name);if(t.isStale())return;w.applyFinancials(S),w.updateActiveCapDisplay();const T=await C.loadLeadStatus(m.name);if(t.isStale())return;w.updateElement("prop-lead-status",T.leadStatus),w.updateLeadStatusTooltip(T);const I=await C.loadStrValue(m.name,t);t.isStale()||I&&"str"===F.currentPropertyType&&(P({baseNOI:null}),await b.recalculateFinancials(),t.isStale())||(await C.loadDebt(m.name,t),t.isStale()||await b.recalculateFinancials())}let S=!1,T=null;return{runPipeline:function(){S=!1,T&&(T.disconnect(),T=null),t({callbacks:{onExportClick:E.handleExportClick,onInterestRateTypeChange:()=>b.recalculateFinancials(),onPropertyTypeChange:()=>{b.handlePropertyTypeChange(),w.updateCapRateLabel();const e=y.scrape();w.syncUnitsFieldForType(F.currentPropertyType,e?.bedroomCount),b.recalculateFinancials()},state:F,updateState:P},cssUrls:h(f.cssFiles),defaultPropertyType:f.defaultPropertyType});const stopObserver=()=>{T&&(T.disconnect(),T=null)},tryImmediateUpdate=(e=!1)=>!!(()=>{const e=document.getElementById("prop-name"),t=document.getElementById("prop-price");return!!(e&&t&&e.textContent.trim()&&t.textContent.trim())})()&&(!(!e&&!(()=>{const e=y.scrape();return!!e&&"Not found"!==e.price&&!e.priceWasDefaulted})())&&((async()=>{S||(S=!0,await updateFooterData())})(),!0));if(tryImmediateUpdate())return;T=new MutationObserver(()=>{tryImmediateUpdate()&&stopObserver()}),T.observe(document.body,{childList:!0,subtree:!0});let e=0;const fallbackPoll=()=>{S||(tryImmediateUpdate(e>=8e3)?stopObserver():(e+=300,setTimeout(fallbackPoll,300)))};setTimeout(fallbackPoll,300)},updateFooterData:updateFooterData}}export{createPipeline};
2
2
  //# sourceMappingURL=pipeline.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"pipeline.js","sources":["../../../src/browser/widget/pipeline.js"],"sourcesContent":["// Pipeline unit: the re-runnable footer pipeline — builds the shared panel, wires the clickable\r\n// elements, runs the main async update (scrape -> financials -> lead status -> STR -> equity with\r\n// the navigation guard between awaits), and drives the immediate/observer/load entry points.\r\n// Extracted verbatim from createAnalyzer (T12).\r\n\r\nimport { createNavigationGuard } from \"./createNavigationGuard.js\";\r\nimport { createPanel } from \"./createPanel.js\";\r\nimport { runReveals } from \"./runReveals.js\";\r\nimport { syncInterestRateForUnits } from \"./interestRateSync.js\";\r\nimport {\r\n setupAwningLinkHandler,\r\n setupCapRateClickHandler,\r\n setupDiscountButtonHandler,\r\n setupDownPaymentClickHandler,\r\n setupEquityResetHandler,\r\n setupNoiClickHandler,\r\n setupPriceClickHandler,\r\n} from \"../ui/click-handlers.js\";\r\nimport { calculateFinancials } from \"../financial/calculateFinancials.js\";\r\nimport { calculateDOM } from \"../../date/utilities.js\";\r\nimport { normalizeWhitespace } from \"../../formatting/text.js\";\r\n\r\n// Some sites (e.g. Zillow) client-render parts of a listing — the listing-agent attribution and\r\n// the price-history table — a beat AFTER first paint, so the pipeline's single initial scrape\r\n// reads \"Not found\" for the fields they carry (contact, phone, listing date). After the first\r\n// render we poll the pure scrape() for just those display fields and fill them in as they arrive,\r\n// until all are present or this budget elapses. Poll-count based (like runReveals' waitForSelector)\r\n// so it stays bounded and predictable under heavy DOM churn.\r\nconst LATE_FIELD_TIMEOUT = 10000;\r\nconst LATE_FIELD_POLL_INTERVAL = 300;\r\n\r\n// The main render waits for the page to expose a scrapeable PRICE before it commits — price is the\r\n// field every financial metric derives from. On a full page load the server-rendered JSON-LD has it\r\n// immediately; on an SPA overlay (Zillow search -> listing) it is client-painted a beat after the\r\n// navigation fires, so an eager scrape would read no price and paint N/A everywhere with no recovery.\r\n// If the price never becomes scrapeable (a genuinely price-less/off-market listing) the timeout lets\r\n// the render proceed anyway, so the panel never hangs on \"Loading...\" — it shows the honest no-price state.\r\nconst DATA_READY_TIMEOUT = 8000;\r\nconst DATA_READY_POLL_INTERVAL = 300;\r\n\r\nexport function createPipeline({ adapter, config, ctx, exportOps, finance, render, resolveCssUrls, services }) {\r\n const { state, updateState } = ctx;\r\n const listingId = () => adapter.getListingId(window.location.href);\r\n\r\n function setupClickableElements(data) {\r\n const nameElement = document.getElementById(\"prop-name\");\r\n if (nameElement && data.name && data.name !== \"Not found\") {\r\n nameElement.style.cursor = \"pointer\";\r\n nameElement.style.textDecoration = \"underline\";\r\n nameElement.onclick = () => {\r\n const searchUrl = `https://www.google.com/maps/search/${encodeURIComponent(data.name)}`;\r\n window.open(searchUrl, \"_blank\");\r\n };\r\n }\r\n\r\n // The shared click-handlers read callbacks.state / callbacks.updateState — the engine's\r\n // ctx is injected here (no global-state coupling).\r\n const callbacks = {\r\n getCurrentPrice: render.getCurrentPrice,\r\n recalculateFinancials: finance.recalculateFinancials,\r\n state,\r\n updatePercentageLabels: render.updatePercentageLabels,\r\n updatePriceLabel: render.updatePriceLabel,\r\n updateState,\r\n };\r\n\r\n const priceElement = document.getElementById(\"prop-price\");\r\n setupPriceClickHandler(priceElement, priceElement?.closest(\".metric\")?.querySelector(\".metric-label\"), callbacks);\r\n\r\n const capElement = document.getElementById(\"prop-cap\");\r\n setupCapRateClickHandler(capElement, capElement?.closest(\".metric\")?.querySelector(\".metric-label\"), callbacks);\r\n\r\n const downElement = document.getElementById(\"prop-down\");\r\n setupDownPaymentClickHandler(downElement, downElement?.closest(\".metric\")?.querySelector(\".metric-label\"), callbacks);\r\n\r\n setupEquityResetHandler(document.getElementById(\"prop-equity\"), callbacks);\r\n\r\n const noiElement = document.getElementById(\"prop-noi\");\r\n setupNoiClickHandler(noiElement, noiElement?.closest(\".metric\")?.querySelector(\".metric-label\"), callbacks);\r\n setupAwningLinkHandler(document.getElementById(\"prop-noi-awning\"));\r\n\r\n setupDiscountButtonHandler(document.getElementById(\"ln-discount-btn\"), callbacks);\r\n }\r\n\r\n // Progressive fill for fields a site renders after first paint (see LATE_FIELD_* above).\r\n // Re-reads ONLY the scrape-derived display fields (contact, phone, listing date) via the pure\r\n // adapter.scrape() — never scrapeAndApply, so it touches no state and re-applies no cap rate —\r\n // and updates only those three elements; price/NOI/financials and all network calls are left\r\n // alone. Stops as soon as every field is present (so a server-rendered site like LoopNet, where\r\n // the first read already has them, never starts a poll), when the budget elapses, or when the\r\n // page navigated to another listing (guard). Whitespace is normalized here to match the\r\n // contract's single normalization point in finance.scrapeAndApply (e.g. a broker name that the\r\n // markup splits across lines).\r\n function watchLateFields(guard) {\r\n const isPresent = (value) => typeof value === \"string\" && value.trim() !== \"\" && value !== \"Not found\";\r\n\r\n const applyLateFields = () => {\r\n const data = adapter.scrape();\r\n if (!data) return false;\r\n const contact = normalizeWhitespace(data.contact);\r\n const phone = normalizeWhitespace(data.phone);\r\n const listingDate = normalizeWhitespace(data.listingDate);\r\n render.updateElement(\"prop-contact\", contact);\r\n render.updateElement(\"prop-phone\", phone);\r\n render.updateElement(\"prop-dom\", calculateDOM(listingDate));\r\n return isPresent(contact) && isPresent(phone) && isPresent(listingDate);\r\n };\r\n\r\n if (applyLateFields()) return;\r\n\r\n // A reveal's trigger (e.g. LoopNet's \"Call\" button) can render AFTER the one-shot runReveals\r\n // in updateFooterData fired — the broker CTA paints a beat after price/title — so the gated\r\n // field (phone) is never clicked into the DOM and the scrape poll above finds nothing to fill.\r\n // Re-run the idempotent reveals alongside the poll: runReveals no-ops once its waitFor target\r\n // is present, so this clicks each trigger at most once. The overlap guard prevents a second\r\n // click during the window between the first click and the revealed content appearing.\r\n let revealing = false;\r\n const retryReveals = () => {\r\n if (revealing || !config.reveals?.length) return;\r\n revealing = true;\r\n runReveals(config.reveals).finally(() => {\r\n revealing = false;\r\n });\r\n };\r\n\r\n let remaining = Math.ceil(LATE_FIELD_TIMEOUT / LATE_FIELD_POLL_INTERVAL);\r\n const tick = () => {\r\n if (guard.isStale()) return;\r\n retryReveals();\r\n if (applyLateFields() || remaining-- <= 0) return;\r\n setTimeout(tick, LATE_FIELD_POLL_INTERVAL);\r\n };\r\n setTimeout(tick, LATE_FIELD_POLL_INTERVAL);\r\n }\r\n\r\n async function updateFooterData() {\r\n // The listing this run is for. On an SPA the page can navigate mid-flight; after each\r\n // await we drop out if the identity changed, so a stale run never writes onto another\r\n // listing's panel. On a full-reload site getListingId is stable, so isStale() is always\r\n // false and this is a no-op.\r\n const guard = createNavigationGuard(listingId);\r\n guard.capture();\r\n\r\n // Click-to-reveal any data gated behind a button (broker phone/email, OM access) so the\r\n // pure scrape() below reads it. Platform-declared (config.reveals); a no-op when absent.\r\n if (config.reveals?.length) {\r\n await runReveals(config.reveals);\r\n if (guard.isStale()) return;\r\n }\r\n\r\n const data = finance.scrapeAndApply();\r\n if (!data) {\r\n console.error(\"❌ Malformed listing data — missing a contract field, refusing to render\");\r\n render.updateElement(\"prop-name\", \"Data error — see console\");\r\n return;\r\n }\r\n\r\n const unitCount = data.unitCount ?? 4;\r\n updateState({ numberOfUnits: unitCount });\r\n const unitsInput = document.getElementById(\"ln-units-input\");\r\n if (unitsInput) unitsInput.value = unitCount;\r\n\r\n syncInterestRateForUnits(state, updateState, unitCount);\r\n\r\n render.updateElement(\"prop-name\", data.name);\r\n // Display guard (H2): a defaulted price shows \"No price\"; the metrics fall through to N/A.\r\n render.updateElement(\"prop-price\", state.priceWasDefaulted ? \"No price\" : data.price);\r\n render.updateElement(\"prop-contact\", data.contact);\r\n render.updateElement(\"prop-phone\", data.phone);\r\n render.updateElement(\"prop-dom\", calculateDOM(data.listingDate));\r\n\r\n render.updatePriceLabel();\r\n render.updateCapRateLabel();\r\n render.syncUnitsFieldForType(state.currentPropertyType, data.bedroomCount);\r\n setupClickableElements(data);\r\n\r\n // Fields some sites render after first paint (agent contact/phone, listing date) start as\r\n // \"Not found\" above; fill them in progressively as they arrive without blocking what follows.\r\n watchLateFields(guard);\r\n\r\n const calculationCapRate = state.isUsingEstimatedCapRate ? `${state.currentEstimatedCapRate}%` : data.capRate;\r\n const financials = await calculateFinancials(ctx, data.price, calculationCapRate, state.currentPropertyType, data.name);\r\n if (guard.isStale()) return;\r\n render.applyFinancials(financials);\r\n render.updateActiveCapDisplay();\r\n\r\n const loiData = await services.loadLeadStatus(data.name);\r\n if (guard.isStale()) return;\r\n render.updateElement(\"prop-lead-status\", loiData.leadStatus);\r\n render.updateLeadStatusTooltip(loiData);\r\n\r\n // STR revenue seam: the footer already shows the 5.5%-of-price estimate. If the backend\r\n // returns real data, recompute the STR NOI with it. Dormant until that backend ships.\r\n const strResult = await services.loadStrValue(data.name, guard);\r\n if (guard.isStale()) return;\r\n if (strResult && state.currentPropertyType === \"str\") {\r\n updateState({ baseNOI: null });\r\n await finance.recalculateFinancials();\r\n if (guard.isStale()) return;\r\n }\r\n\r\n await services.loadDebt(data.name, guard);\r\n if (guard.isStale()) return;\r\n // Recompute (not just repaint equity) so the equity-aware red state picks up the newly\r\n // loaded debt; recalculateFinancials calls updateEquityDisplay internally.\r\n await finance.recalculateFinancials();\r\n }\r\n\r\n // One running pipeline at a time. Re-runnable so the SPA watcher can rebuild per listing;\r\n // the observer is tracked so a re-run detaches the previous one.\r\n let footerUpdated = false;\r\n let pipelineObserver = null;\r\n\r\n function runPipeline() {\r\n footerUpdated = false;\r\n if (pipelineObserver) {\r\n pipelineObserver.disconnect();\r\n pipelineObserver = null;\r\n }\r\n\r\n createPanel({\r\n callbacks: {\r\n onExportClick: exportOps.handleExportClick,\r\n onInterestRateTypeChange: () => finance.recalculateFinancials(),\r\n onPropertyTypeChange: () => {\r\n finance.handlePropertyTypeChange();\r\n render.updateCapRateLabel();\r\n const listing = adapter.scrape();\r\n render.syncUnitsFieldForType(state.currentPropertyType, listing?.bedroomCount);\r\n finance.recalculateFinancials();\r\n },\r\n state,\r\n updateState,\r\n },\r\n cssUrls: resolveCssUrls(config.cssFiles),\r\n defaultPropertyType: config.defaultPropertyType,\r\n });\r\n\r\n const runUpdateOnce = async () => {\r\n if (footerUpdated) return;\r\n footerUpdated = true;\r\n await updateFooterData();\r\n };\r\n\r\n const stopObserver = () => {\r\n if (pipelineObserver) {\r\n pipelineObserver.disconnect();\r\n pipelineObserver = null;\r\n }\r\n };\r\n\r\n // The panel's own elements are built (createPanel's async append finished).\r\n const panelReady = () => {\r\n const nameEl = document.getElementById(\"prop-name\");\r\n const priceEl = document.getElementById(\"prop-price\");\r\n return !!(nameEl && priceEl && nameEl.textContent.trim() && priceEl.textContent.trim());\r\n };\r\n\r\n // The page exposes a real, scrapeable price (see DATA_READY_* above). Pure read — no state writes.\r\n const priceReady = () => {\r\n const listing = adapter.scrape();\r\n return !!listing && listing.price !== \"Not found\" && !listing.priceWasDefaulted;\r\n };\r\n\r\n // Run the main update once the panel is built AND the price is scrapeable. `force` (the timeout\r\n // path) commits even without a price so a price-less listing renders its honest no-price state.\r\n const tryImmediateUpdate = (force = false) => {\r\n if (!panelReady()) return false;\r\n if (!force && !priceReady()) return false;\r\n runUpdateOnce();\r\n return true;\r\n };\r\n\r\n if (tryImmediateUpdate()) return;\r\n\r\n pipelineObserver = new MutationObserver(() => {\r\n if (tryImmediateUpdate()) stopObserver();\r\n });\r\n pipelineObserver.observe(document.body, { childList: true, subtree: true });\r\n\r\n // Bounded fallback for SPA overlays (already readyState \"complete\", so the load event never\r\n // fires) and for listings whose price never paints: poll until the price is scrapeable, then\r\n // force the render at the timeout so the panel never hangs on \"Loading...\".\r\n let waited = 0;\r\n const fallbackPoll = () => {\r\n if (footerUpdated) return;\r\n if (tryImmediateUpdate(waited >= DATA_READY_TIMEOUT)) {\r\n stopObserver();\r\n return;\r\n }\r\n waited += DATA_READY_POLL_INTERVAL;\r\n setTimeout(fallbackPoll, DATA_READY_POLL_INTERVAL);\r\n };\r\n setTimeout(fallbackPoll, DATA_READY_POLL_INTERVAL);\r\n }\r\n\r\n return { runPipeline, updateFooterData };\r\n}\r\n"],"names":["createPipeline","adapter","config","ctx","exportOps","finance","render","resolveCssUrls","services","state","updateState","listingId","getListingId","window","location","href","watchLateFields","guard","isPresent","value","trim","applyLateFields","data","scrape","contact","normalizeWhitespace","phone","listingDate","updateElement","calculateDOM","revealing","remaining","Math","ceil","tick","isStale","reveals","length","runReveals","finally","setTimeout","async","updateFooterData","createNavigationGuard","capture","scrapeAndApply","console","error","unitCount","numberOfUnits","unitsInput","document","getElementById","syncInterestRateForUnits","name","priceWasDefaulted","price","updatePriceLabel","updateCapRateLabel","syncUnitsFieldForType","currentPropertyType","bedroomCount","nameElement","style","cursor","textDecoration","onclick","searchUrl","encodeURIComponent","open","callbacks","getCurrentPrice","recalculateFinancials","updatePercentageLabels","priceElement","setupPriceClickHandler","closest","querySelector","capElement","setupCapRateClickHandler","downElement","setupDownPaymentClickHandler","setupEquityResetHandler","noiElement","setupNoiClickHandler","setupAwningLinkHandler","setupDiscountButtonHandler","setupClickableElements","calculationCapRate","isUsingEstimatedCapRate","currentEstimatedCapRate","capRate","financials","calculateFinancials","applyFinancials","updateActiveCapDisplay","loiData","loadLeadStatus","leadStatus","updateLeadStatusTooltip","strResult","loadStrValue","baseNOI","loadDebt","footerUpdated","pipelineObserver","runPipeline","disconnect","createPanel","onExportClick","handleExportClick","onInterestRateTypeChange","onPropertyTypeChange","handlePropertyTypeChange","listing","cssUrls","cssFiles","defaultPropertyType","stopObserver","tryImmediateUpdate","force","nameEl","priceEl","textContent","panelReady","priceReady","runUpdateOnce","MutationObserver","observe","body","childList","subtree","waited","fallbackPoll"],"mappings":"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"}
1
+ {"version":3,"file":"pipeline.js","sources":["../../../src/browser/widget/pipeline.js"],"sourcesContent":["// Pipeline unit: the re-runnable footer pipeline — builds the shared panel, wires the clickable\r\n// elements, runs the main async update (scrape -> financials -> lead status -> STR -> equity with\r\n// the navigation guard between awaits), and drives the immediate/observer/load entry points.\r\n// Extracted verbatim from createAnalyzer (T12).\r\n\r\nimport { createNavigationGuard } from \"./createNavigationGuard.js\";\r\nimport { createPanel } from \"./createPanel.js\";\r\nimport { runReveals } from \"./runReveals.js\";\r\nimport { syncInterestRateForUnits } from \"./interestRateSync.js\";\r\nimport {\r\n setupAwningLinkHandler,\r\n setupCapRateClickHandler,\r\n setupDiscountButtonHandler,\r\n setupDownPaymentClickHandler,\r\n setupEquityResetHandler,\r\n setupNoiClickHandler,\r\n setupPriceClickHandler,\r\n} from \"../ui/click-handlers.js\";\r\nimport { calculateFinancials } from \"../financial/calculateFinancials.js\";\r\nimport { calculateDOM } from \"../../date/utilities.js\";\r\nimport { normalizeWhitespace } from \"../../formatting/text.js\";\r\n\r\n// Some sites (e.g. Zillow) client-render parts of a listing — the listing-agent attribution and\r\n// the price-history table — a beat AFTER first paint, so the pipeline's single initial scrape\r\n// reads \"Not found\" for the fields they carry (contact, phone, listing date). After the first\r\n// render we poll the pure scrape() for just those display fields and fill them in as they arrive,\r\n// until all are present or this budget elapses. Poll-count based (like runReveals' waitForSelector)\r\n// so it stays bounded and predictable under heavy DOM churn.\r\nconst LATE_FIELD_TIMEOUT = 10000;\r\nconst LATE_FIELD_POLL_INTERVAL = 300;\r\n\r\n// The main render waits for the page to expose a scrapeable PRICE before it commits — price is the\r\n// field every financial metric derives from. On a full page load the server-rendered JSON-LD has it\r\n// immediately; on an SPA overlay (Zillow search -> listing) it is client-painted a beat after the\r\n// navigation fires, so an eager scrape would read no price and paint N/A everywhere with no recovery.\r\n// If the price never becomes scrapeable (a genuinely price-less/off-market listing) the timeout lets\r\n// the render proceed anyway, so the panel never hangs on \"Loading...\" — it shows the honest no-price state.\r\nconst DATA_READY_TIMEOUT = 8000;\r\nconst DATA_READY_POLL_INTERVAL = 300;\r\n\r\nexport function createPipeline({ adapter, config, ctx, exportOps, finance, render, resolveCssUrls, services }) {\r\n const { state, updateState } = ctx;\r\n const listingId = () => adapter.getListingId(window.location.href);\r\n\r\n function setupClickableElements(data) {\r\n const nameElement = document.getElementById(\"prop-name\");\r\n if (nameElement && data.name && data.name !== \"Not found\") {\r\n nameElement.style.cursor = \"pointer\";\r\n nameElement.style.textDecoration = \"underline\";\r\n nameElement.onclick = () => {\r\n const searchUrl = `https://www.google.com/maps/search/${encodeURIComponent(data.name)}`;\r\n window.open(searchUrl, \"_blank\");\r\n };\r\n }\r\n\r\n // The shared click-handlers read callbacks.state / callbacks.updateState — the engine's\r\n // ctx is injected here (no global-state coupling).\r\n const callbacks = {\r\n getCurrentPrice: render.getCurrentPrice,\r\n recalculateFinancials: finance.recalculateFinancials,\r\n state,\r\n updatePercentageLabels: render.updatePercentageLabels,\r\n updatePriceLabel: render.updatePriceLabel,\r\n updateState,\r\n };\r\n\r\n const priceElement = document.getElementById(\"prop-price\");\r\n setupPriceClickHandler(priceElement, priceElement?.closest(\".metric\")?.querySelector(\".metric-label\"), callbacks);\r\n\r\n const capElement = document.getElementById(\"prop-cap\");\r\n setupCapRateClickHandler(capElement, capElement?.closest(\".metric\")?.querySelector(\".metric-label\"), callbacks);\r\n\r\n const downElement = document.getElementById(\"prop-down\");\r\n setupDownPaymentClickHandler(downElement, downElement?.closest(\".metric\")?.querySelector(\".metric-label\"), callbacks);\r\n\r\n setupEquityResetHandler(document.getElementById(\"ln-red-reasons\"), callbacks);\r\n\r\n const noiElement = document.getElementById(\"prop-noi\");\r\n setupNoiClickHandler(noiElement, noiElement?.closest(\".metric\")?.querySelector(\".metric-label\"), callbacks);\r\n setupAwningLinkHandler(document.getElementById(\"prop-noi-awning\"));\r\n\r\n setupDiscountButtonHandler(document.getElementById(\"ln-discount-btn\"), callbacks);\r\n }\r\n\r\n // Progressive fill for fields a site renders after first paint (see LATE_FIELD_* above).\r\n // Re-reads ONLY the scrape-derived display fields (contact, phone, listing date) via the pure\r\n // adapter.scrape() — never scrapeAndApply, so it touches no state and re-applies no cap rate —\r\n // and updates only those three elements; price/NOI/financials and all network calls are left\r\n // alone. Stops as soon as every field is present (so a server-rendered site like LoopNet, where\r\n // the first read already has them, never starts a poll), when the budget elapses, or when the\r\n // page navigated to another listing (guard). Whitespace is normalized here to match the\r\n // contract's single normalization point in finance.scrapeAndApply (e.g. a broker name that the\r\n // markup splits across lines).\r\n function watchLateFields(guard) {\r\n const isPresent = (value) => typeof value === \"string\" && value.trim() !== \"\" && value !== \"Not found\";\r\n\r\n const applyLateFields = () => {\r\n const data = adapter.scrape();\r\n if (!data) return false;\r\n const contact = normalizeWhitespace(data.contact);\r\n const phone = normalizeWhitespace(data.phone);\r\n const listingDate = normalizeWhitespace(data.listingDate);\r\n render.updateElement(\"prop-contact\", contact);\r\n render.updateElement(\"prop-phone\", phone);\r\n render.updateElement(\"prop-dom\", calculateDOM(listingDate));\r\n return isPresent(contact) && isPresent(phone) && isPresent(listingDate);\r\n };\r\n\r\n if (applyLateFields()) return;\r\n\r\n // A reveal's trigger (e.g. LoopNet's \"Call\" button) can render AFTER the one-shot runReveals\r\n // in updateFooterData fired — the broker CTA paints a beat after price/title — so the gated\r\n // field (phone) is never clicked into the DOM and the scrape poll above finds nothing to fill.\r\n // Re-run the idempotent reveals alongside the poll: runReveals no-ops once its waitFor target\r\n // is present, so this clicks each trigger at most once. The overlap guard prevents a second\r\n // click during the window between the first click and the revealed content appearing.\r\n let revealing = false;\r\n const retryReveals = () => {\r\n if (revealing || !config.reveals?.length) return;\r\n revealing = true;\r\n runReveals(config.reveals).finally(() => {\r\n revealing = false;\r\n });\r\n };\r\n\r\n let remaining = Math.ceil(LATE_FIELD_TIMEOUT / LATE_FIELD_POLL_INTERVAL);\r\n const tick = () => {\r\n if (guard.isStale()) return;\r\n retryReveals();\r\n if (applyLateFields() || remaining-- <= 0) return;\r\n setTimeout(tick, LATE_FIELD_POLL_INTERVAL);\r\n };\r\n setTimeout(tick, LATE_FIELD_POLL_INTERVAL);\r\n }\r\n\r\n async function updateFooterData() {\r\n // The listing this run is for. On an SPA the page can navigate mid-flight; after each\r\n // await we drop out if the identity changed, so a stale run never writes onto another\r\n // listing's panel. On a full-reload site getListingId is stable, so isStale() is always\r\n // false and this is a no-op.\r\n const guard = createNavigationGuard(listingId);\r\n guard.capture();\r\n\r\n // Click-to-reveal any data gated behind a button (broker phone/email, OM access) so the\r\n // pure scrape() below reads it. Platform-declared (config.reveals); a no-op when absent.\r\n if (config.reveals?.length) {\r\n await runReveals(config.reveals);\r\n if (guard.isStale()) return;\r\n }\r\n\r\n const data = finance.scrapeAndApply();\r\n if (!data) {\r\n console.error(\"❌ Malformed listing data — missing a contract field, refusing to render\");\r\n render.updateElement(\"prop-name\", \"Data error — see console\");\r\n return;\r\n }\r\n\r\n const unitCount = data.unitCount ?? 4;\r\n updateState({ numberOfUnits: unitCount });\r\n const unitsInput = document.getElementById(\"ln-units-input\");\r\n if (unitsInput) unitsInput.value = unitCount;\r\n\r\n syncInterestRateForUnits(state, updateState, unitCount);\r\n\r\n render.updateElement(\"prop-name\", data.name);\r\n // Display guard (H2): a defaulted price shows \"No price\"; the metrics fall through to N/A.\r\n render.updateElement(\"prop-price\", state.priceWasDefaulted ? \"No price\" : data.price);\r\n render.updateElement(\"prop-contact\", data.contact);\r\n render.updateElement(\"prop-phone\", data.phone);\r\n render.updateElement(\"prop-dom\", calculateDOM(data.listingDate));\r\n\r\n render.updatePriceLabel();\r\n render.updateCapRateLabel();\r\n render.syncUnitsFieldForType(state.currentPropertyType, data.bedroomCount);\r\n setupClickableElements(data);\r\n\r\n // Fields some sites render after first paint (agent contact/phone, listing date) start as\r\n // \"Not found\" above; fill them in progressively as they arrive without blocking what follows.\r\n watchLateFields(guard);\r\n\r\n const calculationCapRate = state.isUsingEstimatedCapRate ? `${state.currentEstimatedCapRate}%` : data.capRate;\r\n const financials = await calculateFinancials(ctx, data.price, calculationCapRate, state.currentPropertyType, data.name);\r\n if (guard.isStale()) return;\r\n render.applyFinancials(financials);\r\n render.updateActiveCapDisplay();\r\n\r\n const loiData = await services.loadLeadStatus(data.name);\r\n if (guard.isStale()) return;\r\n render.updateElement(\"prop-lead-status\", loiData.leadStatus);\r\n render.updateLeadStatusTooltip(loiData);\r\n\r\n // STR revenue seam: the footer already shows the 5.5%-of-price estimate. If the backend\r\n // returns real data, recompute the STR NOI with it. Dormant until that backend ships.\r\n const strResult = await services.loadStrValue(data.name, guard);\r\n if (guard.isStale()) return;\r\n if (strResult && state.currentPropertyType === \"str\") {\r\n updateState({ baseNOI: null });\r\n await finance.recalculateFinancials();\r\n if (guard.isStale()) return;\r\n }\r\n\r\n await services.loadDebt(data.name, guard);\r\n if (guard.isStale()) return;\r\n // Recompute (not just repaint equity) so the equity-aware red state picks up the newly\r\n // loaded debt; recalculateFinancials calls updateEquityDisplay internally.\r\n await finance.recalculateFinancials();\r\n }\r\n\r\n // One running pipeline at a time. Re-runnable so the SPA watcher can rebuild per listing;\r\n // the observer is tracked so a re-run detaches the previous one.\r\n let footerUpdated = false;\r\n let pipelineObserver = null;\r\n\r\n function runPipeline() {\r\n footerUpdated = false;\r\n if (pipelineObserver) {\r\n pipelineObserver.disconnect();\r\n pipelineObserver = null;\r\n }\r\n\r\n createPanel({\r\n callbacks: {\r\n onExportClick: exportOps.handleExportClick,\r\n onInterestRateTypeChange: () => finance.recalculateFinancials(),\r\n onPropertyTypeChange: () => {\r\n finance.handlePropertyTypeChange();\r\n render.updateCapRateLabel();\r\n const listing = adapter.scrape();\r\n render.syncUnitsFieldForType(state.currentPropertyType, listing?.bedroomCount);\r\n finance.recalculateFinancials();\r\n },\r\n state,\r\n updateState,\r\n },\r\n cssUrls: resolveCssUrls(config.cssFiles),\r\n defaultPropertyType: config.defaultPropertyType,\r\n });\r\n\r\n const runUpdateOnce = async () => {\r\n if (footerUpdated) return;\r\n footerUpdated = true;\r\n await updateFooterData();\r\n };\r\n\r\n const stopObserver = () => {\r\n if (pipelineObserver) {\r\n pipelineObserver.disconnect();\r\n pipelineObserver = null;\r\n }\r\n };\r\n\r\n // The panel's own elements are built (createPanel's async append finished).\r\n const panelReady = () => {\r\n const nameEl = document.getElementById(\"prop-name\");\r\n const priceEl = document.getElementById(\"prop-price\");\r\n return !!(nameEl && priceEl && nameEl.textContent.trim() && priceEl.textContent.trim());\r\n };\r\n\r\n // The page exposes a real, scrapeable price (see DATA_READY_* above). Pure read — no state writes.\r\n const priceReady = () => {\r\n const listing = adapter.scrape();\r\n return !!listing && listing.price !== \"Not found\" && !listing.priceWasDefaulted;\r\n };\r\n\r\n // Run the main update once the panel is built AND the price is scrapeable. `force` (the timeout\r\n // path) commits even without a price so a price-less listing renders its honest no-price state.\r\n const tryImmediateUpdate = (force = false) => {\r\n if (!panelReady()) return false;\r\n if (!force && !priceReady()) return false;\r\n runUpdateOnce();\r\n return true;\r\n };\r\n\r\n if (tryImmediateUpdate()) return;\r\n\r\n pipelineObserver = new MutationObserver(() => {\r\n if (tryImmediateUpdate()) stopObserver();\r\n });\r\n pipelineObserver.observe(document.body, { childList: true, subtree: true });\r\n\r\n // Bounded fallback for SPA overlays (already readyState \"complete\", so the load event never\r\n // fires) and for listings whose price never paints: poll until the price is scrapeable, then\r\n // force the render at the timeout so the panel never hangs on \"Loading...\".\r\n let waited = 0;\r\n const fallbackPoll = () => {\r\n if (footerUpdated) return;\r\n if (tryImmediateUpdate(waited >= DATA_READY_TIMEOUT)) {\r\n stopObserver();\r\n return;\r\n }\r\n waited += DATA_READY_POLL_INTERVAL;\r\n setTimeout(fallbackPoll, DATA_READY_POLL_INTERVAL);\r\n };\r\n setTimeout(fallbackPoll, DATA_READY_POLL_INTERVAL);\r\n }\r\n\r\n return { runPipeline, updateFooterData };\r\n}\r\n"],"names":["createPipeline","adapter","config","ctx","exportOps","finance","render","resolveCssUrls","services","state","updateState","listingId","getListingId","window","location","href","watchLateFields","guard","isPresent","value","trim","applyLateFields","data","scrape","contact","normalizeWhitespace","phone","listingDate","updateElement","calculateDOM","revealing","remaining","Math","ceil","tick","isStale","reveals","length","runReveals","finally","setTimeout","async","updateFooterData","createNavigationGuard","capture","scrapeAndApply","console","error","unitCount","numberOfUnits","unitsInput","document","getElementById","syncInterestRateForUnits","name","priceWasDefaulted","price","updatePriceLabel","updateCapRateLabel","syncUnitsFieldForType","currentPropertyType","bedroomCount","nameElement","style","cursor","textDecoration","onclick","searchUrl","encodeURIComponent","open","callbacks","getCurrentPrice","recalculateFinancials","updatePercentageLabels","priceElement","setupPriceClickHandler","closest","querySelector","capElement","setupCapRateClickHandler","downElement","setupDownPaymentClickHandler","setupEquityResetHandler","noiElement","setupNoiClickHandler","setupAwningLinkHandler","setupDiscountButtonHandler","setupClickableElements","calculationCapRate","isUsingEstimatedCapRate","currentEstimatedCapRate","capRate","financials","calculateFinancials","applyFinancials","updateActiveCapDisplay","loiData","loadLeadStatus","leadStatus","updateLeadStatusTooltip","strResult","loadStrValue","baseNOI","loadDebt","footerUpdated","pipelineObserver","runPipeline","disconnect","createPanel","onExportClick","handleExportClick","onInterestRateTypeChange","onPropertyTypeChange","handlePropertyTypeChange","listing","cssUrls","cssFiles","defaultPropertyType","stopObserver","tryImmediateUpdate","force","nameEl","priceEl","textContent","panelReady","priceReady","runUpdateOnce","MutationObserver","observe","body","childList","subtree","waited","fallbackPoll"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@archerjessop/utilities",
3
- "version": "7.22.0",
3
+ "version": "7.24.0",
4
4
  "description": "Shared utilities for ArcherJessop property analysis tools",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",