@archerjessop/utilities 7.19.0 → 7.21.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,2 +1,2 @@
1
- import{calculateNOIByType as e,calculateAssignmentFee as r,calculateNetToBuyer as t,calculatePMT as a,calculateCOCRAtPercent as n,calculatePriceForCOCR as c}from"../../financial/calculations.js";import{formatCurrency as i,formatPriceValue as s,formatPercentage as l}from"../../financial/formatters.js";import{FINANCIAL_CONSTANTS as o}from"../../config/financial.js";async function calculateFinancials(u,d,m,R,E){const{state:F,updateState:T}=u;try{const u=d.match(/[\d,]+/);if(!u)return null;const E=parseFloat(u[0].replace(/,/g,""));if(!Number.isFinite(E)||E<=0)return null;const f=F.originalPrice?.match(/[\d,]+/),p=f?parseFloat(f[0].replace(/,/g,"")):E,P=m.match(/[\d.]+/);if(!P)return null;const S=parseFloat(P[0])/100;let w;if("str"===R&&F.cachedSTRData)w=F.cachedSTRData;else{if(!F.baseNOI){let r,t=null;if("assisted"===R&&(t=F.numberOfUnits),F.isUsingEstimatedCapRate){const a=F.currentEstimatedCapRate/100;r=e(p,a,R,{bedroomCount:t,strApiResult:F.cachedStrValue})}else r=e(p,S,R,{bedroomCount:t,strApiResult:F.cachedStrValue});T({baseNOI:r})}w=F.baseNOI}const C=w/12,I=E*(F.currentDownPaymentPercent/100),N=E*(F.currentDSCRPercent/100),_=E*(F.currentSellerFiPercent/100),h=r(E),A=t(E),O=o.INTEREST_RATE_TIERS[F.currentInterestRateType||"dscr_residential"],D=a(N,O.rate,O.amortization),L=a(_,o.SELLER_FI_INTEREST_RATE,o.SELLER_FI_AMORTIZATION),b=C-(D+L),g=n(E,w,30,{dscrLtvPercent:F.currentDSCRPercent,dscrRate:O.rate,dscrTerm:O.amortization,sellerFiPercent:F.currentSellerFiPercent}),y=c(w,.15,{downPercent:100*o.DEFAULT_DOWN_PAYMENT,dscrLtvPercent:100*o.DEFAULT_DSCR_PERCENTAGE,dscrRate:O.rate,dscrTerm:O.amortization});return window.lastFinancialCalculation={rawCashFlow:b,rawPrice:E,rawNOI:w},{noi:i(w),down:i(I),netToBuyer:i(A),sellerFi:i(_),cocr30:l(g),priceForCOCR15:s(y),dscr:i(D,!0),sfPayment:i(L,!0),cashFlow:i(b,!0),assignment:i(h),rawCashFlow:b,rawPrice:E,rawNOI:w}}catch(e){return console.error("Error calculating financials:",e),null}}export{calculateFinancials};
1
+ import{calculateNOIByType as e,calculateAssignmentFee as r,calculateNetToBuyer as t,calculatePMT as a,calculateCOCRAtPercent as n,calculatePriceForCOCR as c}from"../../financial/calculations.js";import{formatCurrency as i,formatPriceValue as s,formatPercentage as l}from"../../financial/formatters.js";import{FINANCIAL_CONSTANTS as o}from"../../config/financial.js";async function calculateFinancials(u,d,m,R,E){const{state:F,updateState:T}=u;try{const u=d.match(/[\d,]+/);if(!u)return null;const E=parseFloat(u[0].replace(/,/g,""));if(!Number.isFinite(E)||E<=0)return null;const f=F.originalPrice?.match(/[\d,]+/),p=f?parseFloat(f[0].replace(/,/g,"")):E,w=m.match(/[\d.]+/);if(!w)return null;const P=parseFloat(w[0])/100;let S;if("str"===R&&F.cachedSTRData)S=F.cachedSTRData;else{if(!F.baseNOI){let r,t=null;if("assisted"===R&&(t=F.numberOfUnits),F.isUsingEstimatedCapRate){const a=F.currentEstimatedCapRate/100;r=e(p,a,R,{bedroomCount:t,strApiResult:F.cachedStrValue})}else r=e(p,P,R,{bedroomCount:t,strApiResult:F.cachedStrValue});T({baseNOI:r})}S=F.baseNOI}const C=S/12,I=E*(F.currentDownPaymentPercent/100),N=E*(F.currentDSCRPercent/100),_=E*(F.currentSellerFiPercent/100),h=r(E),A=t(E),D=o.INTEREST_RATE_TIERS[F.currentInterestRateType||"dscr_residential"],O=a(N,D.rate,D.amortization),L=a(_,o.SELLER_FI_INTEREST_RATE,o.SELLER_FI_AMORTIZATION),b=C-(O+L),g=n(E,S,30,{dscrLtvPercent:F.currentDSCRPercent,dscrRate:D.rate,dscrTerm:D.amortization,sellerFiPercent:F.currentSellerFiPercent}),y=c(S,.15,{downPercent:100*o.DEFAULT_DOWN_PAYMENT,dscrLtvPercent:100*o.DEFAULT_DSCR_PERCENTAGE,dscrRate:D.rate,dscrTerm:D.amortization});return window.lastFinancialCalculation={rawCashFlow:b,rawPrice:E,rawNOI:S},{noi:i(S),down:i(I),netToBuyer:i(A),sellerFi:i(_),cocr30:l(g),priceForCOCR15:s(y),dscr:i(O,!0),sfPayment:i(L,!0),cashFlow:i(b,!0),assignment:i(h),rawCashFlow:b,rawDown:I,rawPrice:E,rawNOI:S}}catch(e){return console.error("Error calculating financials:",e),null}}export{calculateFinancials};
2
2
  //# sourceMappingURL=calculateFinancials.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"calculateFinancials.js","sources":["../../../src/browser/financial/calculateFinancials.js"],"sourcesContent":["// The one shared financial orchestrator for every property analyzer.\r\n//\r\n// Hoisted BYTE-IDENTICAL from zillow-analyzer's dom-utils.js calculateFinancials (the\r\n// green, proven copy). The ONLY change is state threading: the per-repo `state` /\r\n// `updateState` module singletons become an injected `ctx` ({ state, updateState }) so\r\n// this lives in @archerjessop/utilities and is shared by both analyzers. The math,\r\n// branches, side effects (window.lastFinancialCalculation), and return shape are unchanged\r\n// — equivalence is proven when Zillow imports this and its suite stays green (T6).\r\n\r\nimport {\r\n calculateAssignmentFee,\r\n calculateCOCRAtPercent,\r\n calculateNetToBuyer,\r\n calculateNOIByType,\r\n calculatePMT,\r\n calculatePriceForCOCR,\r\n} from \"../../financial/calculations.js\";\r\nimport { formatCurrency, formatPercentage, formatPriceValue } from \"../../financial/formatters.js\";\r\nimport { FINANCIAL_CONSTANTS } from \"../../config/financial.js\";\r\n\r\nexport async function calculateFinancials(ctx, priceText, capRateText, propertyType, _address) {\r\n const { state, updateState } = ctx;\r\n try {\r\n // Parse asking price - but use original price for NOI calculation\r\n const priceMatch = priceText.match(/[\\d,]+/);\r\n if (!priceMatch) return null;\r\n\r\n const askingPrice = parseFloat(priceMatch[0].replace(/,/g, \"\"));\r\n\r\n // Display guard (H2): a zero/non-finite price would push NOI->0 and COCR->NaN/Infinity.\r\n // Refuse to underwrite it; callers paint \"N/A\" instead.\r\n if (!Number.isFinite(askingPrice) || askingPrice <= 0) return null;\r\n\r\n // Get original price for NOI calculation\r\n const originalPriceMatch = state.originalPrice?.match(/[\\d,]+/);\r\n const originalPriceValue = originalPriceMatch ? parseFloat(originalPriceMatch[0].replace(/,/g, \"\")) : askingPrice;\r\n\r\n const capRateMatch = capRateText.match(/[\\d.]+/);\r\n if (!capRateMatch) return null;\r\n\r\n const capRate = parseFloat(capRateMatch[0]) / 100;\r\n\r\n let noi;\r\n\r\n // Calculate NOI based on ORIGINAL price and cap rate, not discounted price\r\n if (propertyType === \"str\" && state.cachedSTRData) {\r\n noi = state.cachedSTRData;\r\n } else {\r\n if (!state.baseNOI) {\r\n // Assisted-living NOI is bedrooms x income x 12. The bedroom count comes from the\r\n // footer beds field (numberOfUnits) — the units input is relabeled \"beds\" when\r\n // assisted is selected and pre-filled from any count detected on the page. Because it\r\n // is always visible and editable, the assumed count is never a hidden fabrication\r\n // (cf. the utilities extractBedrooms() silent default of 10 that H5 guarded against).\r\n let bedroomCount = null;\r\n if (propertyType === \"assisted\") {\r\n bedroomCount = state.numberOfUnits;\r\n }\r\n\r\n // Calculate baseNOI from original price\r\n let calculatedNOI;\r\n if (state.isUsingEstimatedCapRate) {\r\n const origCapRate = state.currentEstimatedCapRate / 100;\r\n calculatedNOI = calculateNOIByType(originalPriceValue, origCapRate, propertyType, {\r\n bedroomCount,\r\n strApiResult: state.cachedStrValue\r\n });\r\n } else {\r\n calculatedNOI = calculateNOIByType(originalPriceValue, capRate, propertyType, {\r\n bedroomCount,\r\n strApiResult: state.cachedStrValue\r\n });\r\n }\r\n updateState({ baseNOI: calculatedNOI });\r\n }\r\n\r\n noi = state.baseNOI; // Always use the original NOI calculation\r\n }\r\n\r\n const monthlyNOI = noi / 12;\r\n\r\n // Use discounted price for payment calculations\r\n const down = askingPrice * (state.currentDownPaymentPercent / 100);\r\n const dscrLoanAmount = askingPrice * (state.currentDSCRPercent / 100);\r\n const sellerFiAmount = askingPrice * (state.currentSellerFiPercent / 100);\r\n\r\n const assignment = calculateAssignmentFee(askingPrice);\r\n const netToBuyer = calculateNetToBuyer(askingPrice);\r\n\r\n const tier = FINANCIAL_CONSTANTS.INTEREST_RATE_TIERS[state.currentInterestRateType || \"dscr_residential\"];\r\n const dscr = calculatePMT(dscrLoanAmount, tier.rate, tier.amortization);\r\n const sfPayment = calculatePMT(sellerFiAmount, FINANCIAL_CONSTANTS.SELLER_FI_INTEREST_RATE, FINANCIAL_CONSTANTS.SELLER_FI_AMORTIZATION);\r\n\r\n const cashFlow = monthlyNOI - (dscr + sfPayment);\r\n\r\n const cocr30 = calculateCOCRAtPercent(askingPrice, noi, 30, {\r\n dscrLtvPercent: state.currentDSCRPercent,\r\n dscrRate: tier.rate,\r\n dscrTerm: tier.amortization,\r\n sellerFiPercent: state.currentSellerFiPercent\r\n });\r\n\r\n const priceForCOCR15 = calculatePriceForCOCR(noi, 0.15, {\r\n downPercent: FINANCIAL_CONSTANTS.DEFAULT_DOWN_PAYMENT * 100,\r\n dscrLtvPercent: FINANCIAL_CONSTANTS.DEFAULT_DSCR_PERCENTAGE * 100,\r\n dscrRate: tier.rate,\r\n dscrTerm: tier.amortization\r\n });\r\n\r\n // Store for export\r\n window.lastFinancialCalculation = {\r\n rawCashFlow: cashFlow,\r\n rawPrice: askingPrice,\r\n rawNOI: noi\r\n };\r\n\r\n return {\r\n noi: formatCurrency(noi),\r\n down: formatCurrency(down),\r\n netToBuyer: formatCurrency(netToBuyer),\r\n sellerFi: formatCurrency(sellerFiAmount),\r\n cocr30: formatPercentage(cocr30),\r\n priceForCOCR15: formatPriceValue(priceForCOCR15),\r\n dscr: formatCurrency(dscr, true),\r\n sfPayment: formatCurrency(sfPayment, true),\r\n cashFlow: formatCurrency(cashFlow, true),\r\n assignment: formatCurrency(assignment),\r\n rawCashFlow: cashFlow,\r\n rawPrice: askingPrice,\r\n rawNOI: noi,\r\n };\r\n } catch (error) {\r\n console.error(\"Error calculating financials:\", error);\r\n return null;\r\n }\r\n}\r\n"],"names":["async","calculateFinancials","ctx","priceText","capRateText","propertyType","_address","state","updateState","priceMatch","match","askingPrice","parseFloat","replace","Number","isFinite","originalPriceMatch","originalPrice","originalPriceValue","capRateMatch","capRate","noi","cachedSTRData","baseNOI","calculatedNOI","bedroomCount","numberOfUnits","isUsingEstimatedCapRate","origCapRate","currentEstimatedCapRate","calculateNOIByType","strApiResult","cachedStrValue","monthlyNOI","down","currentDownPaymentPercent","dscrLoanAmount","currentDSCRPercent","sellerFiAmount","currentSellerFiPercent","assignment","calculateAssignmentFee","netToBuyer","calculateNetToBuyer","tier","FINANCIAL_CONSTANTS","INTEREST_RATE_TIERS","currentInterestRateType","dscr","calculatePMT","rate","amortization","sfPayment","SELLER_FI_INTEREST_RATE","SELLER_FI_AMORTIZATION","cashFlow","cocr30","calculateCOCRAtPercent","dscrLtvPercent","dscrRate","dscrTerm","sellerFiPercent","priceForCOCR15","calculatePriceForCOCR","downPercent","DEFAULT_DOWN_PAYMENT","DEFAULT_DSCR_PERCENTAGE","window","lastFinancialCalculation","rawCashFlow","rawPrice","rawNOI","formatCurrency","sellerFi","formatPercentage","formatPriceValue","error","console"],"mappings":"8WAoBOA,eAAeC,oBAAoBC,EAAKC,EAAWC,EAAaC,EAAcC,GACnF,MAAMC,MAAEA,EAAKC,YAAEA,GAAgBN,EAC/B,IAEE,MAAMO,EAAaN,EAAUO,MAAM,UACnC,IAAKD,EAAY,OAAO,KAExB,MAAME,EAAcC,WAAWH,EAAW,GAAGI,QAAQ,KAAM,KAI3D,IAAKC,OAAOC,SAASJ,IAAgBA,GAAe,EAAG,OAAO,KAG9D,MAAMK,EAAqBT,EAAMU,eAAeP,MAAM,UAChDQ,EAAqBF,EAAqBJ,WAAWI,EAAmB,GAAGH,QAAQ,KAAM,KAAOF,EAEhGQ,EAAef,EAAYM,MAAM,UACvC,IAAKS,EAAc,OAAO,KAE1B,MAAMC,EAAUR,WAAWO,EAAa,IAAM,IAE9C,IAAIE,EAGJ,GAAqB,QAAjBhB,GAA0BE,EAAMe,cAClCD,EAAMd,EAAMe,kBACP,CACL,IAAKf,EAAMgB,QAAS,CAMlB,IAMIC,EANAC,EAAe,KAOnB,GANqB,aAAjBpB,IACFoB,EAAelB,EAAMmB,eAKnBnB,EAAMoB,wBAAyB,CACjC,MAAMC,EAAcrB,EAAMsB,wBAA0B,IACpDL,EAAgBM,EAAmBZ,EAAoBU,EAAavB,EAAc,CAChFoB,eACAM,aAAcxB,EAAMyB,gBAExB,MACER,EAAgBM,EAAmBZ,EAAoBE,EAASf,EAAc,CAC5EoB,eACAM,aAAcxB,EAAMyB,iBAGxBxB,EAAY,CAAEe,QAASC,GACzB,CAEAH,EAAMd,EAAMgB,OACd,CAEA,MAAMU,EAAaZ,EAAM,GAGnBa,EAAOvB,GAAeJ,EAAM4B,0BAA4B,KACxDC,EAAiBzB,GAAeJ,EAAM8B,mBAAqB,KAC3DC,EAAiB3B,GAAeJ,EAAMgC,uBAAyB,KAE/DC,EAAaC,EAAuB9B,GACpC+B,EAAaC,EAAoBhC,GAEjCiC,EAAOC,EAAoBC,oBAAoBvC,EAAMwC,yBAA2B,oBAChFC,EAAOC,EAAab,EAAgBQ,EAAKM,KAAMN,EAAKO,cACpDC,EAAYH,EAAaX,EAAgBO,EAAoBQ,wBAAyBR,EAAoBS,wBAE1GC,EAAWtB,GAAce,EAAOI,GAEhCI,EAASC,EAAuB9C,EAAaU,EAAK,GAAI,CAC1DqC,eAAgBnD,EAAM8B,mBACtBsB,SAAUf,EAAKM,KACfU,SAAUhB,EAAKO,aACfU,gBAAiBtD,EAAMgC,yBAGnBuB,EAAiBC,EAAsB1C,EAAK,IAAM,CACtD2C,YAAwD,IAA3CnB,EAAoBoB,qBACjCP,eAA8D,IAA9Cb,EAAoBqB,wBACpCP,SAAUf,EAAKM,KACfU,SAAUhB,EAAKO,eAUjB,OANAgB,OAAOC,yBAA2B,CAChCC,YAAad,EACbe,SAAU3D,EACV4D,OAAQlD,GAGH,CACLA,IAAKmD,EAAenD,GACpBa,KAAMsC,EAAetC,GACrBQ,WAAY8B,EAAe9B,GAC3B+B,SAAUD,EAAelC,GACzBkB,OAAQkB,EAAiBlB,GACzBM,eAAgBa,EAAiBb,GACjCd,KAAMwB,EAAexB,GAAM,GAC3BI,UAAWoB,EAAepB,GAAW,GACrCG,SAAUiB,EAAejB,GAAU,GACnCf,WAAYgC,EAAehC,GAC3B6B,YAAad,EACbe,SAAU3D,EACV4D,OAAQlD,EAEZ,CAAE,MAAOuD,GAEP,OADAC,QAAQD,MAAM,gCAAiCA,GACxC,IACT,CACF"}
1
+ {"version":3,"file":"calculateFinancials.js","sources":["../../../src/browser/financial/calculateFinancials.js"],"sourcesContent":["// The one shared financial orchestrator for every property analyzer.\r\n//\r\n// Hoisted BYTE-IDENTICAL from zillow-analyzer's dom-utils.js calculateFinancials (the\r\n// green, proven copy). The ONLY change is state threading: the per-repo `state` /\r\n// `updateState` module singletons become an injected `ctx` ({ state, updateState }) so\r\n// this lives in @archerjessop/utilities and is shared by both analyzers. The math,\r\n// branches, side effects (window.lastFinancialCalculation), and return shape are unchanged\r\n// — equivalence is proven when Zillow imports this and its suite stays green (T6).\r\n\r\nimport {\r\n calculateAssignmentFee,\r\n calculateCOCRAtPercent,\r\n calculateNetToBuyer,\r\n calculateNOIByType,\r\n calculatePMT,\r\n calculatePriceForCOCR,\r\n} from \"../../financial/calculations.js\";\r\nimport { formatCurrency, formatPercentage, formatPriceValue } from \"../../financial/formatters.js\";\r\nimport { FINANCIAL_CONSTANTS } from \"../../config/financial.js\";\r\n\r\nexport async function calculateFinancials(ctx, priceText, capRateText, propertyType, _address) {\r\n const { state, updateState } = ctx;\r\n try {\r\n // Parse asking price - but use original price for NOI calculation\r\n const priceMatch = priceText.match(/[\\d,]+/);\r\n if (!priceMatch) return null;\r\n\r\n const askingPrice = parseFloat(priceMatch[0].replace(/,/g, \"\"));\r\n\r\n // Display guard (H2): a zero/non-finite price would push NOI->0 and COCR->NaN/Infinity.\r\n // Refuse to underwrite it; callers paint \"N/A\" instead.\r\n if (!Number.isFinite(askingPrice) || askingPrice <= 0) return null;\r\n\r\n // Get original price for NOI calculation\r\n const originalPriceMatch = state.originalPrice?.match(/[\\d,]+/);\r\n const originalPriceValue = originalPriceMatch ? parseFloat(originalPriceMatch[0].replace(/,/g, \"\")) : askingPrice;\r\n\r\n const capRateMatch = capRateText.match(/[\\d.]+/);\r\n if (!capRateMatch) return null;\r\n\r\n const capRate = parseFloat(capRateMatch[0]) / 100;\r\n\r\n let noi;\r\n\r\n // Calculate NOI based on ORIGINAL price and cap rate, not discounted price\r\n if (propertyType === \"str\" && state.cachedSTRData) {\r\n noi = state.cachedSTRData;\r\n } else {\r\n if (!state.baseNOI) {\r\n // Assisted-living NOI is bedrooms x income x 12. The bedroom count comes from the\r\n // footer beds field (numberOfUnits) — the units input is relabeled \"beds\" when\r\n // assisted is selected and pre-filled from any count detected on the page. Because it\r\n // is always visible and editable, the assumed count is never a hidden fabrication\r\n // (cf. the utilities extractBedrooms() silent default of 10 that H5 guarded against).\r\n let bedroomCount = null;\r\n if (propertyType === \"assisted\") {\r\n bedroomCount = state.numberOfUnits;\r\n }\r\n\r\n // Calculate baseNOI from original price\r\n let calculatedNOI;\r\n if (state.isUsingEstimatedCapRate) {\r\n const origCapRate = state.currentEstimatedCapRate / 100;\r\n calculatedNOI = calculateNOIByType(originalPriceValue, origCapRate, propertyType, {\r\n bedroomCount,\r\n strApiResult: state.cachedStrValue\r\n });\r\n } else {\r\n calculatedNOI = calculateNOIByType(originalPriceValue, capRate, propertyType, {\r\n bedroomCount,\r\n strApiResult: state.cachedStrValue\r\n });\r\n }\r\n updateState({ baseNOI: calculatedNOI });\r\n }\r\n\r\n noi = state.baseNOI; // Always use the original NOI calculation\r\n }\r\n\r\n const monthlyNOI = noi / 12;\r\n\r\n // Use discounted price for payment calculations\r\n const down = askingPrice * (state.currentDownPaymentPercent / 100);\r\n const dscrLoanAmount = askingPrice * (state.currentDSCRPercent / 100);\r\n const sellerFiAmount = askingPrice * (state.currentSellerFiPercent / 100);\r\n\r\n const assignment = calculateAssignmentFee(askingPrice);\r\n const netToBuyer = calculateNetToBuyer(askingPrice);\r\n\r\n const tier = FINANCIAL_CONSTANTS.INTEREST_RATE_TIERS[state.currentInterestRateType || \"dscr_residential\"];\r\n const dscr = calculatePMT(dscrLoanAmount, tier.rate, tier.amortization);\r\n const sfPayment = calculatePMT(sellerFiAmount, FINANCIAL_CONSTANTS.SELLER_FI_INTEREST_RATE, FINANCIAL_CONSTANTS.SELLER_FI_AMORTIZATION);\r\n\r\n const cashFlow = monthlyNOI - (dscr + sfPayment);\r\n\r\n const cocr30 = calculateCOCRAtPercent(askingPrice, noi, 30, {\r\n dscrLtvPercent: state.currentDSCRPercent,\r\n dscrRate: tier.rate,\r\n dscrTerm: tier.amortization,\r\n sellerFiPercent: state.currentSellerFiPercent\r\n });\r\n\r\n const priceForCOCR15 = calculatePriceForCOCR(noi, 0.15, {\r\n downPercent: FINANCIAL_CONSTANTS.DEFAULT_DOWN_PAYMENT * 100,\r\n dscrLtvPercent: FINANCIAL_CONSTANTS.DEFAULT_DSCR_PERCENTAGE * 100,\r\n dscrRate: tier.rate,\r\n dscrTerm: tier.amortization\r\n });\r\n\r\n // Store for export\r\n window.lastFinancialCalculation = {\r\n rawCashFlow: cashFlow,\r\n rawPrice: askingPrice,\r\n rawNOI: noi\r\n };\r\n\r\n return {\r\n noi: formatCurrency(noi),\r\n down: formatCurrency(down),\r\n netToBuyer: formatCurrency(netToBuyer),\r\n sellerFi: formatCurrency(sellerFiAmount),\r\n cocr30: formatPercentage(cocr30),\r\n priceForCOCR15: formatPriceValue(priceForCOCR15),\r\n dscr: formatCurrency(dscr, true),\r\n sfPayment: formatCurrency(sfPayment, true),\r\n cashFlow: formatCurrency(cashFlow, true),\r\n assignment: formatCurrency(assignment),\r\n rawCashFlow: cashFlow,\r\n rawDown: down,\r\n rawPrice: askingPrice,\r\n rawNOI: noi,\r\n };\r\n } catch (error) {\r\n console.error(\"Error calculating financials:\", error);\r\n return null;\r\n }\r\n}\r\n"],"names":["async","calculateFinancials","ctx","priceText","capRateText","propertyType","_address","state","updateState","priceMatch","match","askingPrice","parseFloat","replace","Number","isFinite","originalPriceMatch","originalPrice","originalPriceValue","capRateMatch","capRate","noi","cachedSTRData","baseNOI","calculatedNOI","bedroomCount","numberOfUnits","isUsingEstimatedCapRate","origCapRate","currentEstimatedCapRate","calculateNOIByType","strApiResult","cachedStrValue","monthlyNOI","down","currentDownPaymentPercent","dscrLoanAmount","currentDSCRPercent","sellerFiAmount","currentSellerFiPercent","assignment","calculateAssignmentFee","netToBuyer","calculateNetToBuyer","tier","FINANCIAL_CONSTANTS","INTEREST_RATE_TIERS","currentInterestRateType","dscr","calculatePMT","rate","amortization","sfPayment","SELLER_FI_INTEREST_RATE","SELLER_FI_AMORTIZATION","cashFlow","cocr30","calculateCOCRAtPercent","dscrLtvPercent","dscrRate","dscrTerm","sellerFiPercent","priceForCOCR15","calculatePriceForCOCR","downPercent","DEFAULT_DOWN_PAYMENT","DEFAULT_DSCR_PERCENTAGE","window","lastFinancialCalculation","rawCashFlow","rawPrice","rawNOI","formatCurrency","sellerFi","formatPercentage","formatPriceValue","rawDown","error","console"],"mappings":"8WAoBOA,eAAeC,oBAAoBC,EAAKC,EAAWC,EAAaC,EAAcC,GACnF,MAAMC,MAAEA,EAAKC,YAAEA,GAAgBN,EAC/B,IAEE,MAAMO,EAAaN,EAAUO,MAAM,UACnC,IAAKD,EAAY,OAAO,KAExB,MAAME,EAAcC,WAAWH,EAAW,GAAGI,QAAQ,KAAM,KAI3D,IAAKC,OAAOC,SAASJ,IAAgBA,GAAe,EAAG,OAAO,KAG9D,MAAMK,EAAqBT,EAAMU,eAAeP,MAAM,UAChDQ,EAAqBF,EAAqBJ,WAAWI,EAAmB,GAAGH,QAAQ,KAAM,KAAOF,EAEhGQ,EAAef,EAAYM,MAAM,UACvC,IAAKS,EAAc,OAAO,KAE1B,MAAMC,EAAUR,WAAWO,EAAa,IAAM,IAE9C,IAAIE,EAGJ,GAAqB,QAAjBhB,GAA0BE,EAAMe,cAClCD,EAAMd,EAAMe,kBACP,CACL,IAAKf,EAAMgB,QAAS,CAMlB,IAMIC,EANAC,EAAe,KAOnB,GANqB,aAAjBpB,IACFoB,EAAelB,EAAMmB,eAKnBnB,EAAMoB,wBAAyB,CACjC,MAAMC,EAAcrB,EAAMsB,wBAA0B,IACpDL,EAAgBM,EAAmBZ,EAAoBU,EAAavB,EAAc,CAChFoB,eACAM,aAAcxB,EAAMyB,gBAExB,MACER,EAAgBM,EAAmBZ,EAAoBE,EAASf,EAAc,CAC5EoB,eACAM,aAAcxB,EAAMyB,iBAGxBxB,EAAY,CAAEe,QAASC,GACzB,CAEAH,EAAMd,EAAMgB,OACd,CAEA,MAAMU,EAAaZ,EAAM,GAGnBa,EAAOvB,GAAeJ,EAAM4B,0BAA4B,KACxDC,EAAiBzB,GAAeJ,EAAM8B,mBAAqB,KAC3DC,EAAiB3B,GAAeJ,EAAMgC,uBAAyB,KAE/DC,EAAaC,EAAuB9B,GACpC+B,EAAaC,EAAoBhC,GAEjCiC,EAAOC,EAAoBC,oBAAoBvC,EAAMwC,yBAA2B,oBAChFC,EAAOC,EAAab,EAAgBQ,EAAKM,KAAMN,EAAKO,cACpDC,EAAYH,EAAaX,EAAgBO,EAAoBQ,wBAAyBR,EAAoBS,wBAE1GC,EAAWtB,GAAce,EAAOI,GAEhCI,EAASC,EAAuB9C,EAAaU,EAAK,GAAI,CAC1DqC,eAAgBnD,EAAM8B,mBACtBsB,SAAUf,EAAKM,KACfU,SAAUhB,EAAKO,aACfU,gBAAiBtD,EAAMgC,yBAGnBuB,EAAiBC,EAAsB1C,EAAK,IAAM,CACtD2C,YAAwD,IAA3CnB,EAAoBoB,qBACjCP,eAA8D,IAA9Cb,EAAoBqB,wBACpCP,SAAUf,EAAKM,KACfU,SAAUhB,EAAKO,eAUjB,OANAgB,OAAOC,yBAA2B,CAChCC,YAAad,EACbe,SAAU3D,EACV4D,OAAQlD,GAGH,CACLA,IAAKmD,EAAenD,GACpBa,KAAMsC,EAAetC,GACrBQ,WAAY8B,EAAe9B,GAC3B+B,SAAUD,EAAelC,GACzBkB,OAAQkB,EAAiBlB,GACzBM,eAAgBa,EAAiBb,GACjCd,KAAMwB,EAAexB,GAAM,GAC3BI,UAAWoB,EAAepB,GAAW,GACrCG,SAAUiB,EAAejB,GAAU,GACnCf,WAAYgC,EAAehC,GAC3B6B,YAAad,EACbqB,QAAS1C,EACToC,SAAU3D,EACV4D,OAAQlD,EAEZ,CAAE,MAAOwD,GAEP,OADAC,QAAQD,MAAM,gCAAiCA,GACxC,IACT,CACF"}
@@ -1,2 +1,2 @@
1
- import{syncInterestRateForUnits as n}from"./interestRateSync.js";const e=[{value:"multifamily",label:"Multifamily"},{value:"str",label:"STR"},{value:"assisted",label:"Assisted/Co Living"},{value:"business",label:"Business"},{value:"mixed_use",label:"Mixed Use"},{value:"rv_park",label:"RV Park"}];function createPanel(n){const{cssUrls:e=[],defaultPropertyType:a="multifamily",callbacks:s={}}=n||{};console.log("🎨 createPanel() called");const t=document.getElementById("ln-footer");t&&(console.log("🗑️ Removing existing footer"),t.remove()),console.log("📦 Loading CSS files...");const i=e.map(n=>{const e=document.createElement("link");return e.rel="stylesheet",e.href=n,e});let l=0;const c=i.length,onLoad=()=>{l++,console.log(`📄 CSS file loaded (${l}/${c})`),l===c&&(console.log("✨ All CSS loaded, creating footer elements"),createPanelElements(a,s))};i.forEach(n=>{n.onload=onLoad}),console.log("⏰ Setting 100ms fallback timeout"),setTimeout(()=>{console.log("⚠️ Fallback timeout reached, creating footer elements anyway"),createPanelElements(a,s)},100),i.forEach(n=>{document.head.appendChild(n)})}function createPanelElements(a,s){if(document.getElementById("ln-footer"))return;const{updateState:t}=s,i=document.createElement("div");i.id="ln-footer",i.className="ext-footer",i.innerHTML=`\n <div class="footer-container">\n <div class="footer-content">\n <div class="metrics-grid">\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">Address</span>\n <span id="prop-name" class="metric-value clickable prop-name">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">Lead Status</span>\n <span id="prop-lead-status" class="metric-value prop-lead-status">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">Price</span>\n <span id="prop-price" class="metric-value triangle prop-price">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">Cash Flow</span>\n <span id="prop-cashflow" class="metric-value weight-semibold prop-cashflow">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">Cap Rate</span>\n <span id="prop-cap" class="metric-value prop-cap">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">NOI</span>\n <span id="prop-noi" class="metric-value prop-noi">Loading...</span>\n <a id="prop-noi-awning" class="noi-awning-link" target="_blank" rel="noopener" title="Open Awning's calculator and copy this address to the clipboard" style="display:none;cursor:pointer;font-size:11px;font-weight:600;color:#200955;text-decoration:none;margin-top:2px;">↗ Awning</a>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">COCR (15%)</span>\n <span id="prop-cocr-15" class="metric-value prop-cocr-15">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">COCR (30%)</span>\n <span id="prop-cocr-30" class="metric-value prop-cocr-30">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric prop-dom-metric">\n <span class="metric-label">DOM</span>\n <span id="prop-dom" class="metric-value triangle prop-dom">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">Equity</span>\n <span id="prop-equity" class="metric-value prop-equity">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">Seller FI (40%)</span>\n <span id="prop-seller-fi" class="metric-value prop-seller-fi">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">Down (60%)</span>\n <span id="prop-down" class="metric-value triangle prop-down">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">DSCR (70%)</span>\n <span id="prop-dscr" class="metric-value prop-dscr">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">SF Payment</span>\n <span id="prop-sf" class="metric-value prop-sf">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">Contact</span>\n <span id="prop-contact" class="metric-value prop-contact">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">Phone</span>\n <span id="prop-phone" class="metric-value prop-contact">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">Net to Buyer</span>\n <span id="prop-net" class="metric-value prop-net">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">Assignment</span>\n <span id="prop-assignment" class="metric-value prop-assignment">Loading...</span>\n </div>\n </div>\n </div>\n </div>\n <div class="footer-controls">\n <div class="footer-controls-col">\n <div class="units-input-row">\n <input type="number" id="ln-units-input" class="units-input" min="1" max="999" value="4">\n <span class="units-inline-label">units</span>\n </div>\n <button class="btn-discount" id="ln-discount-btn">85% of Asking</button>\n </div>\n <div class="footer-controls-col">\n <select class="dropdown" id="ln-property-type">\n ${function(n){return e.map(({value:e,label:a})=>`<option value="${e}"${e===n?" selected":""}>${a}</option>`).join("\n ")}(a)}\n </select>\n <select class="dropdown" id="ln-interest-rate-type">\n <option value="dscr_residential" selected>DSCR Res (8%)</option>\n <option value="dscr_commercial">DSCR Com (10%)</option>\n <option value="commercial">Commercial (10%)</option>\n <option value="mixed_use">Mixed Use (10%)</option>\n <option value="rv_park">RV Park (11%)</option>\n </select>\n <button class="btn-primary" id="ln-export-btn" title="Open dashboard with property data">\n <svg class="icon" viewBox="0 0 24 24">\n <path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z" />\n </svg>\n Dashboard\n </button>\n </div>\n </div>\n </div>\n `;try{document.body?document.body.appendChild(i):document.documentElement&&document.documentElement.appendChild(i)}catch(n){}const l=document.getElementById("ln-export-btn");l&&l.addEventListener("click",()=>{s.onExportClick?.()});const c=document.getElementById("ln-property-type");c&&c.addEventListener("change",()=>{s.onPropertyTypeChange?.(c.value)});const o=document.getElementById("ln-interest-rate-type");o&&o.addEventListener("change",()=>{t({currentInterestRateType:o.value}),s.onInterestRateTypeChange?.(o.value)});const r=document.getElementById("ln-units-input");return r&&r.addEventListener("change",()=>{const e=parseInt(r.value)||4;t({numberOfUnits:e});const a=s.state&&n(s.state,t,e);a&&s.onInterestRateTypeChange?.(a)}),i}export{createPanel};
1
+ import{syncInterestRateForUnits as n}from"./interestRateSync.js";const e=[{value:"multifamily",label:"Multifamily"},{value:"str",label:"STR"},{value:"assisted",label:"Assisted/Co Living"},{value:"business",label:"Business"},{value:"mixed_use",label:"Mixed Use"},{value:"rv_park",label:"RV Park"}];function createPanel(n){const{cssUrls:e=[],defaultPropertyType:s="multifamily",callbacks:a={}}=n||{};console.log("🎨 createPanel() called");const t=document.getElementById("ln-footer");t&&(console.log("🗑️ Removing existing footer"),t.remove()),console.log("📦 Loading CSS files...");const l=e.map(n=>{const e=document.createElement("link");return e.rel="stylesheet",e.href=n,e});let i=0;const c=l.length,onLoad=()=>{i++,console.log(`📄 CSS file loaded (${i}/${c})`),i===c&&(console.log("✨ All CSS loaded, creating footer elements"),createPanelElements(s,a))};l.forEach(n=>{n.onload=onLoad}),console.log("⏰ Setting 100ms fallback timeout"),setTimeout(()=>{console.log("⚠️ Fallback timeout reached, creating footer elements anyway"),createPanelElements(s,a)},100),l.forEach(n=>{document.head.appendChild(n)})}function createPanelElements(s,a){if(document.getElementById("ln-footer"))return;const{updateState:t}=a,l=document.createElement("div");l.id="ln-footer",l.className="ext-footer",l.innerHTML=`\n <div class="footer-container">\n <div class="footer-content">\n <div class="metrics-grid">\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">Address</span>\n <span id="prop-name" class="metric-value clickable prop-name">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">Lead Status</span>\n <span id="prop-lead-status" class="metric-value prop-lead-status">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">Price</span>\n <span id="prop-price" class="metric-value triangle prop-price">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">Cash Flow</span>\n <span id="prop-cashflow" class="metric-value weight-semibold prop-cashflow">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">Cap Rate</span>\n <span id="prop-cap" class="metric-value prop-cap">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">NOI</span>\n <span id="prop-noi" class="metric-value prop-noi">Loading...</span>\n <a id="prop-noi-awning" class="noi-awning-link" target="_blank" rel="noopener" title="Open Awning's calculator and copy this address to the clipboard" style="display:none;cursor:pointer;font-size:11px;font-weight:600;color:#200955;text-decoration:none;margin-top:2px;">↗ Awning</a>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">COCR (15%)</span>\n <span id="prop-cocr-15" class="metric-value prop-cocr-15">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">COCR (30%)</span>\n <span id="prop-cocr-30" class="metric-value prop-cocr-30">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric prop-dom-metric">\n <span class="metric-label">DOM</span>\n <span id="prop-dom" class="metric-value triangle prop-dom">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">Equity</span>\n <span id="prop-equity" class="metric-value prop-equity">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">Seller FI (40%)</span>\n <span id="prop-seller-fi" class="metric-value prop-seller-fi">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">Down (60%)</span>\n <span id="prop-down" class="metric-value triangle prop-down">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">DSCR (70%)</span>\n <span id="prop-dscr" class="metric-value prop-dscr">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">SF Payment</span>\n <span id="prop-sf" class="metric-value prop-sf">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">Contact</span>\n <span id="prop-contact" class="metric-value prop-contact">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">Phone</span>\n <span id="prop-phone" class="metric-value prop-contact">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">Net to Buyer</span>\n <span id="prop-net" class="metric-value prop-net">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">Assignment</span>\n <span id="prop-assignment" class="metric-value prop-assignment">Loading...</span>\n </div>\n </div>\n </div>\n </div>\n <div class="footer-controls">\n <div class="footer-controls-col">\n <span id="ln-red-reasons" class="red-reasons-pill" style="display:none"></span>\n <div class="units-input-row">\n <input type="number" id="ln-units-input" class="units-input" min="1" max="999" value="4">\n <span class="units-inline-label">units</span>\n </div>\n <button class="btn-discount" id="ln-discount-btn">85% of Asking</button>\n </div>\n <div class="footer-controls-col">\n <select class="dropdown" id="ln-property-type">\n ${function(n){return e.map(({value:e,label:s})=>`<option value="${e}"${e===n?" selected":""}>${s}</option>`).join("\n ")}(s)}\n </select>\n <select class="dropdown" id="ln-interest-rate-type">\n <option value="dscr_residential" selected>DSCR Res (8%)</option>\n <option value="dscr_commercial">DSCR Com (10%)</option>\n <option value="commercial">Commercial (10%)</option>\n <option value="mixed_use">Mixed Use (10%)</option>\n <option value="rv_park">RV Park (11%)</option>\n </select>\n <button class="btn-primary" id="ln-export-btn" title="Open dashboard with property data">\n <svg class="icon" viewBox="0 0 24 24">\n <path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z" />\n </svg>\n Dashboard\n </button>\n </div>\n </div>\n </div>\n `;try{document.body?document.body.appendChild(l):document.documentElement&&document.documentElement.appendChild(l)}catch(n){}const i=document.getElementById("ln-export-btn");i&&i.addEventListener("click",()=>{a.onExportClick?.()});const c=document.getElementById("ln-property-type");c&&c.addEventListener("change",()=>{a.onPropertyTypeChange?.(c.value)});const o=document.getElementById("ln-interest-rate-type");o&&o.addEventListener("change",()=>{t({currentInterestRateType:o.value}),a.onInterestRateTypeChange?.(o.value)});const r=document.getElementById("ln-units-input");return r&&r.addEventListener("change",()=>{const e=parseInt(r.value)||4;t({numberOfUnits:e});const s=a.state&&n(a.state,t,e);s&&a.onInterestRateTypeChange?.(s)}),l}export{createPanel};
2
2
  //# sourceMappingURL=createPanel.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"createPanel.js","sources":["../../../src/browser/widget/createPanel.js"],"sourcesContent":["// Shared analysis-panel (footer) builder for the property analyzers.\r\n//\r\n// Extracted verbatim from each analyzer's dom-utils.js createFooter/createFooterElements.\r\n// Per-platform values are injected so this module has no Chrome or global-state dependency:\r\n// - cssUrls: stylesheet hrefs (callers resolve chrome.runtime.getURL themselves)\r\n// - defaultPropertyType: which property-type <option> is pre-selected (loopnet \"multifamily\", zillow \"str\")\r\n// - callbacks.state / callbacks.updateState: the per-platform state singleton\r\n// - callbacks.onExportClick / onPropertyTypeChange / onInterestRateTypeChange: optional notifications\r\n// (today these are CustomEvent dispatches; callers that need them pass a handler, others omit)\r\n\r\nimport { syncInterestRateForUnits } from \"./interestRateSync.js\";\r\n\r\nconst PROPERTY_TYPE_OPTIONS = [\r\n { value: \"multifamily\", label: \"Multifamily\" },\r\n { value: \"str\", label: \"STR\" },\r\n { value: \"assisted\", label: \"Assisted/Co Living\" },\r\n { value: \"business\", label: \"Business\" },\r\n { value: \"mixed_use\", label: \"Mixed Use\" },\r\n { value: \"rv_park\", label: \"RV Park\" }\r\n];\r\n\r\nfunction renderPropertyTypeOptions(defaultPropertyType) {\r\n return PROPERTY_TYPE_OPTIONS.map(({ value, label }) => {\r\n const selected = value === defaultPropertyType ? \" selected\" : \"\";\r\n return `<option value=\"${value}\"${selected}>${label}</option>`;\r\n }).join(\"\\n \");\r\n}\r\n\r\nexport function createPanel(config) {\r\n const { cssUrls = [], defaultPropertyType = \"multifamily\", callbacks = {} } = config || {};\r\n\r\n console.log(\"🎨 createPanel() called\");\r\n\r\n const existing = document.getElementById(\"ln-footer\");\r\n if (existing) {\r\n console.log(\"🗑️ Removing existing footer\");\r\n existing.remove();\r\n }\r\n\r\n console.log(\"📦 Loading CSS files...\");\r\n\r\n const links = cssUrls.map((href) => {\r\n const link = document.createElement(\"link\");\r\n link.rel = \"stylesheet\";\r\n link.href = href;\r\n return link;\r\n });\r\n\r\n // Wait for all stylesheets to load before creating footer\r\n let loadedCount = 0;\r\n const total = links.length;\r\n const onLoad = () => {\r\n loadedCount++;\r\n console.log(`📄 CSS file loaded (${loadedCount}/${total})`);\r\n if (loadedCount === total) {\r\n console.log(\"✨ All CSS loaded, creating footer elements\");\r\n createPanelElements(defaultPropertyType, callbacks);\r\n }\r\n };\r\n\r\n links.forEach((link) => { link.onload = onLoad; });\r\n\r\n console.log(\"⏰ Setting 100ms fallback timeout\");\r\n // Fallback - create footer after timeout even if shared CSS doesn't load\r\n setTimeout(() => {\r\n console.log(\"⚠️ Fallback timeout reached, creating footer elements anyway\");\r\n createPanelElements(defaultPropertyType, callbacks);\r\n }, 100);\r\n\r\n links.forEach((link) => { document.head.appendChild(link); });\r\n}\r\n\r\nfunction createPanelElements(defaultPropertyType, callbacks) {\r\n // Prevent duplicate creation\r\n if (document.getElementById(\"ln-footer\")) return;\r\n\r\n const { updateState } = callbacks;\r\n\r\n const footer = document.createElement(\"div\");\r\n footer.id = \"ln-footer\";\r\n footer.className = \"ext-footer\";\r\n\r\n footer.innerHTML = `\r\n <div class=\"footer-container\">\r\n <div class=\"footer-content\">\r\n <div class=\"metrics-grid\">\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Address</span>\r\n <span id=\"prop-name\" class=\"metric-value clickable prop-name\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Lead Status</span>\r\n <span id=\"prop-lead-status\" class=\"metric-value prop-lead-status\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Price</span>\r\n <span id=\"prop-price\" class=\"metric-value triangle prop-price\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Cash Flow</span>\r\n <span id=\"prop-cashflow\" class=\"metric-value weight-semibold prop-cashflow\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Cap Rate</span>\r\n <span id=\"prop-cap\" class=\"metric-value prop-cap\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">NOI</span>\r\n <span id=\"prop-noi\" class=\"metric-value prop-noi\">Loading...</span>\r\n <a id=\"prop-noi-awning\" class=\"noi-awning-link\" target=\"_blank\" rel=\"noopener\" title=\"Open Awning's calculator and copy this address to the clipboard\" style=\"display:none;cursor:pointer;font-size:11px;font-weight:600;color:#200955;text-decoration:none;margin-top:2px;\">↗ Awning</a>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">COCR (15%)</span>\r\n <span id=\"prop-cocr-15\" class=\"metric-value prop-cocr-15\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">COCR (30%)</span>\r\n <span id=\"prop-cocr-30\" class=\"metric-value prop-cocr-30\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric prop-dom-metric\">\r\n <span class=\"metric-label\">DOM</span>\r\n <span id=\"prop-dom\" class=\"metric-value triangle prop-dom\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Equity</span>\r\n <span id=\"prop-equity\" class=\"metric-value prop-equity\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Seller FI (40%)</span>\r\n <span id=\"prop-seller-fi\" class=\"metric-value prop-seller-fi\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Down (60%)</span>\r\n <span id=\"prop-down\" class=\"metric-value triangle prop-down\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">DSCR (70%)</span>\r\n <span id=\"prop-dscr\" class=\"metric-value prop-dscr\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">SF Payment</span>\r\n <span id=\"prop-sf\" class=\"metric-value prop-sf\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Contact</span>\r\n <span id=\"prop-contact\" class=\"metric-value prop-contact\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Phone</span>\r\n <span id=\"prop-phone\" class=\"metric-value prop-contact\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Net to Buyer</span>\r\n <span id=\"prop-net\" class=\"metric-value prop-net\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Assignment</span>\r\n <span id=\"prop-assignment\" class=\"metric-value prop-assignment\">Loading...</span>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n <div class=\"footer-controls\">\r\n <div class=\"footer-controls-col\">\r\n <div class=\"units-input-row\">\r\n <input type=\"number\" id=\"ln-units-input\" class=\"units-input\" min=\"1\" max=\"999\" value=\"4\">\r\n <span class=\"units-inline-label\">units</span>\r\n </div>\r\n <button class=\"btn-discount\" id=\"ln-discount-btn\">85% of Asking</button>\r\n </div>\r\n <div class=\"footer-controls-col\">\r\n <select class=\"dropdown\" id=\"ln-property-type\">\r\n ${renderPropertyTypeOptions(defaultPropertyType)}\r\n </select>\r\n <select class=\"dropdown\" id=\"ln-interest-rate-type\">\r\n <option value=\"dscr_residential\" selected>DSCR Res (8%)</option>\r\n <option value=\"dscr_commercial\">DSCR Com (10%)</option>\r\n <option value=\"commercial\">Commercial (10%)</option>\r\n <option value=\"mixed_use\">Mixed Use (10%)</option>\r\n <option value=\"rv_park\">RV Park (11%)</option>\r\n </select>\r\n <button class=\"btn-primary\" id=\"ln-export-btn\" title=\"Open dashboard with property data\">\r\n <svg class=\"icon\" viewBox=\"0 0 24 24\">\r\n <path d=\"M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z\" />\r\n </svg>\r\n Dashboard\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n `;\r\n\r\n try {\r\n if (document.body) {\r\n document.body.appendChild(footer);\r\n } else if (document.documentElement) {\r\n document.documentElement.appendChild(footer);\r\n }\r\n } catch (error) {\r\n // Silent fail\r\n }\r\n\r\n const exportBtn = document.getElementById(\"ln-export-btn\");\r\n if (exportBtn) {\r\n exportBtn.addEventListener(\"click\", () => {\r\n callbacks.onExportClick?.();\r\n });\r\n }\r\n\r\n const propertyTypeDropdown = document.getElementById(\"ln-property-type\");\r\n if (propertyTypeDropdown) {\r\n propertyTypeDropdown.addEventListener(\"change\", () => {\r\n callbacks.onPropertyTypeChange?.(propertyTypeDropdown.value);\r\n });\r\n }\r\n\r\n const interestRateTypeDropdown = document.getElementById(\"ln-interest-rate-type\");\r\n if (interestRateTypeDropdown) {\r\n interestRateTypeDropdown.addEventListener(\"change\", () => {\r\n updateState({ currentInterestRateType: interestRateTypeDropdown.value });\r\n callbacks.onInterestRateTypeChange?.(interestRateTypeDropdown.value);\r\n });\r\n }\r\n\r\n const unitsInput = document.getElementById(\"ln-units-input\");\r\n if (unitsInput) {\r\n unitsInput.addEventListener(\"change\", () => {\r\n const value = parseInt(unitsInput.value) || 4;\r\n updateState({ numberOfUnits: value });\r\n\r\n const newRateType = callbacks.state && syncInterestRateForUnits(callbacks.state, updateState, value);\r\n if (newRateType) callbacks.onInterestRateTypeChange?.(newRateType);\r\n });\r\n }\r\n\r\n return footer;\r\n}\r\n"],"names":["PROPERTY_TYPE_OPTIONS","value","label","createPanel","config","cssUrls","defaultPropertyType","callbacks","console","log","existing","document","getElementById","remove","links","map","href","link","createElement","rel","loadedCount","total","length","onLoad","createPanelElements","forEach","onload","setTimeout","head","appendChild","updateState","footer","id","className","innerHTML","join","renderPropertyTypeOptions","body","documentElement","error","exportBtn","addEventListener","onExportClick","propertyTypeDropdown","onPropertyTypeChange","interestRateTypeDropdown","currentInterestRateType","onInterestRateTypeChange","unitsInput","parseInt","numberOfUnits","newRateType","state","syncInterestRateForUnits"],"mappings":"iEAYA,MAAMA,EAAwB,CAC5B,CAAEC,MAAO,cAAeC,MAAO,eAC/B,CAAED,MAAO,MAAOC,MAAO,OACvB,CAAED,MAAO,WAAYC,MAAO,sBAC5B,CAAED,MAAO,WAAYC,MAAO,YAC5B,CAAED,MAAO,YAAaC,MAAO,aAC7B,CAAED,MAAO,UAAWC,MAAO,YAUtB,SAASC,YAAYC,GAC1B,MAAMC,QAAEA,EAAU,GAAEC,oBAAEA,EAAsB,cAAaC,UAAEA,EAAY,CAAA,GAAOH,GAAU,GAExFI,QAAQC,IAAI,2BAEZ,MAAMC,EAAWC,SAASC,eAAe,aACrCF,IACFF,QAAQC,IAAI,gCACZC,EAASG,UAGXL,QAAQC,IAAI,2BAEZ,MAAMK,EAAQT,EAAQU,IAAKC,IACzB,MAAMC,EAAON,SAASO,cAAc,QAGpC,OAFAD,EAAKE,IAAM,aACXF,EAAKD,KAAOA,EACLC,IAIT,IAAIG,EAAc,EAClB,MAAMC,EAAQP,EAAMQ,OACdC,OAAS,KACbH,IACAZ,QAAQC,IAAI,uBAAuBW,KAAeC,MAC9CD,IAAgBC,IAClBb,QAAQC,IAAI,8CACZe,oBAAoBlB,EAAqBC,KAI7CO,EAAMW,QAASR,IAAWA,EAAKS,OAASH,SAExCf,QAAQC,IAAI,oCAEZkB,WAAW,KACTnB,QAAQC,IAAI,gEACZe,oBAAoBlB,EAAqBC,IACxC,KAEHO,EAAMW,QAASR,IAAWN,SAASiB,KAAKC,YAAYZ,IACtD,CAEA,SAASO,oBAAoBlB,EAAqBC,GAEhD,GAAII,SAASC,eAAe,aAAc,OAE1C,MAAMkB,YAAEA,GAAgBvB,EAElBwB,EAASpB,SAASO,cAAc,OACtCa,EAAOC,GAAK,YACZD,EAAOE,UAAY,aAEnBF,EAAOG,UAAY,8iKA7DrB,SAAmC5B,GACjC,OAAON,EAAsBe,IAAI,EAAGd,QAAOC,WAElC,kBAAkBD,KADRA,IAAUK,EAAsB,YAAc,MACjBJ,cAC7CiC,KAAK,iBACV,CA2KcC,CAA0B9B,2zBAoBtC,IACMK,SAAS0B,KACX1B,SAAS0B,KAAKR,YAAYE,GACjBpB,SAAS2B,iBAClB3B,SAAS2B,gBAAgBT,YAAYE,EAEzC,CAAE,MAAOQ,GAET,CAEA,MAAMC,EAAY7B,SAASC,eAAe,iBACtC4B,GACFA,EAAUC,iBAAiB,QAAS,KAClClC,EAAUmC,oBAId,MAAMC,EAAuBhC,SAASC,eAAe,oBACjD+B,GACFA,EAAqBF,iBAAiB,SAAU,KAC9ClC,EAAUqC,uBAAuBD,EAAqB1C,SAI1D,MAAM4C,EAA2BlC,SAASC,eAAe,yBACrDiC,GACFA,EAAyBJ,iBAAiB,SAAU,KAClDX,EAAY,CAAEgB,wBAAyBD,EAAyB5C,QAChEM,EAAUwC,2BAA2BF,EAAyB5C,SAIlE,MAAM+C,EAAarC,SAASC,eAAe,kBAW3C,OAVIoC,GACFA,EAAWP,iBAAiB,SAAU,KACpC,MAAMxC,EAAQgD,SAASD,EAAW/C,QAAU,EAC5C6B,EAAY,CAAEoB,cAAejD,IAE7B,MAAMkD,EAAc5C,EAAU6C,OAASC,EAAyB9C,EAAU6C,MAAOtB,EAAa7B,GAC1FkD,GAAa5C,EAAUwC,2BAA2BI,KAInDpB,CACT"}
1
+ {"version":3,"file":"createPanel.js","sources":["../../../src/browser/widget/createPanel.js"],"sourcesContent":["// Shared analysis-panel (footer) builder for the property analyzers.\r\n//\r\n// Extracted verbatim from each analyzer's dom-utils.js createFooter/createFooterElements.\r\n// Per-platform values are injected so this module has no Chrome or global-state dependency:\r\n// - cssUrls: stylesheet hrefs (callers resolve chrome.runtime.getURL themselves)\r\n// - defaultPropertyType: which property-type <option> is pre-selected (loopnet \"multifamily\", zillow \"str\")\r\n// - callbacks.state / callbacks.updateState: the per-platform state singleton\r\n// - callbacks.onExportClick / onPropertyTypeChange / onInterestRateTypeChange: optional notifications\r\n// (today these are CustomEvent dispatches; callers that need them pass a handler, others omit)\r\n\r\nimport { syncInterestRateForUnits } from \"./interestRateSync.js\";\r\n\r\nconst PROPERTY_TYPE_OPTIONS = [\r\n { value: \"multifamily\", label: \"Multifamily\" },\r\n { value: \"str\", label: \"STR\" },\r\n { value: \"assisted\", label: \"Assisted/Co Living\" },\r\n { value: \"business\", label: \"Business\" },\r\n { value: \"mixed_use\", label: \"Mixed Use\" },\r\n { value: \"rv_park\", label: \"RV Park\" }\r\n];\r\n\r\nfunction renderPropertyTypeOptions(defaultPropertyType) {\r\n return PROPERTY_TYPE_OPTIONS.map(({ value, label }) => {\r\n const selected = value === defaultPropertyType ? \" selected\" : \"\";\r\n return `<option value=\"${value}\"${selected}>${label}</option>`;\r\n }).join(\"\\n \");\r\n}\r\n\r\nexport function createPanel(config) {\r\n const { cssUrls = [], defaultPropertyType = \"multifamily\", callbacks = {} } = config || {};\r\n\r\n console.log(\"🎨 createPanel() called\");\r\n\r\n const existing = document.getElementById(\"ln-footer\");\r\n if (existing) {\r\n console.log(\"🗑️ Removing existing footer\");\r\n existing.remove();\r\n }\r\n\r\n console.log(\"📦 Loading CSS files...\");\r\n\r\n const links = cssUrls.map((href) => {\r\n const link = document.createElement(\"link\");\r\n link.rel = \"stylesheet\";\r\n link.href = href;\r\n return link;\r\n });\r\n\r\n // Wait for all stylesheets to load before creating footer\r\n let loadedCount = 0;\r\n const total = links.length;\r\n const onLoad = () => {\r\n loadedCount++;\r\n console.log(`📄 CSS file loaded (${loadedCount}/${total})`);\r\n if (loadedCount === total) {\r\n console.log(\"✨ All CSS loaded, creating footer elements\");\r\n createPanelElements(defaultPropertyType, callbacks);\r\n }\r\n };\r\n\r\n links.forEach((link) => { link.onload = onLoad; });\r\n\r\n console.log(\"⏰ Setting 100ms fallback timeout\");\r\n // Fallback - create footer after timeout even if shared CSS doesn't load\r\n setTimeout(() => {\r\n console.log(\"⚠️ Fallback timeout reached, creating footer elements anyway\");\r\n createPanelElements(defaultPropertyType, callbacks);\r\n }, 100);\r\n\r\n links.forEach((link) => { document.head.appendChild(link); });\r\n}\r\n\r\nfunction createPanelElements(defaultPropertyType, callbacks) {\r\n // Prevent duplicate creation\r\n if (document.getElementById(\"ln-footer\")) return;\r\n\r\n const { updateState } = callbacks;\r\n\r\n const footer = document.createElement(\"div\");\r\n footer.id = \"ln-footer\";\r\n footer.className = \"ext-footer\";\r\n\r\n footer.innerHTML = `\r\n <div class=\"footer-container\">\r\n <div class=\"footer-content\">\r\n <div class=\"metrics-grid\">\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Address</span>\r\n <span id=\"prop-name\" class=\"metric-value clickable prop-name\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Lead Status</span>\r\n <span id=\"prop-lead-status\" class=\"metric-value prop-lead-status\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Price</span>\r\n <span id=\"prop-price\" class=\"metric-value triangle prop-price\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Cash Flow</span>\r\n <span id=\"prop-cashflow\" class=\"metric-value weight-semibold prop-cashflow\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Cap Rate</span>\r\n <span id=\"prop-cap\" class=\"metric-value prop-cap\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">NOI</span>\r\n <span id=\"prop-noi\" class=\"metric-value prop-noi\">Loading...</span>\r\n <a id=\"prop-noi-awning\" class=\"noi-awning-link\" target=\"_blank\" rel=\"noopener\" title=\"Open Awning's calculator and copy this address to the clipboard\" style=\"display:none;cursor:pointer;font-size:11px;font-weight:600;color:#200955;text-decoration:none;margin-top:2px;\">↗ Awning</a>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">COCR (15%)</span>\r\n <span id=\"prop-cocr-15\" class=\"metric-value prop-cocr-15\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">COCR (30%)</span>\r\n <span id=\"prop-cocr-30\" class=\"metric-value prop-cocr-30\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric prop-dom-metric\">\r\n <span class=\"metric-label\">DOM</span>\r\n <span id=\"prop-dom\" class=\"metric-value triangle prop-dom\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Equity</span>\r\n <span id=\"prop-equity\" class=\"metric-value prop-equity\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Seller FI (40%)</span>\r\n <span id=\"prop-seller-fi\" class=\"metric-value prop-seller-fi\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Down (60%)</span>\r\n <span id=\"prop-down\" class=\"metric-value triangle prop-down\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">DSCR (70%)</span>\r\n <span id=\"prop-dscr\" class=\"metric-value prop-dscr\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">SF Payment</span>\r\n <span id=\"prop-sf\" class=\"metric-value prop-sf\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Contact</span>\r\n <span id=\"prop-contact\" class=\"metric-value prop-contact\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Phone</span>\r\n <span id=\"prop-phone\" class=\"metric-value prop-contact\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Net to Buyer</span>\r\n <span id=\"prop-net\" class=\"metric-value prop-net\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Assignment</span>\r\n <span id=\"prop-assignment\" class=\"metric-value prop-assignment\">Loading...</span>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n <div class=\"footer-controls\">\r\n <div class=\"footer-controls-col\">\r\n <span id=\"ln-red-reasons\" class=\"red-reasons-pill\" style=\"display:none\"></span>\r\n <div class=\"units-input-row\">\r\n <input type=\"number\" id=\"ln-units-input\" class=\"units-input\" min=\"1\" max=\"999\" value=\"4\">\r\n <span class=\"units-inline-label\">units</span>\r\n </div>\r\n <button class=\"btn-discount\" id=\"ln-discount-btn\">85% of Asking</button>\r\n </div>\r\n <div class=\"footer-controls-col\">\r\n <select class=\"dropdown\" id=\"ln-property-type\">\r\n ${renderPropertyTypeOptions(defaultPropertyType)}\r\n </select>\r\n <select class=\"dropdown\" id=\"ln-interest-rate-type\">\r\n <option value=\"dscr_residential\" selected>DSCR Res (8%)</option>\r\n <option value=\"dscr_commercial\">DSCR Com (10%)</option>\r\n <option value=\"commercial\">Commercial (10%)</option>\r\n <option value=\"mixed_use\">Mixed Use (10%)</option>\r\n <option value=\"rv_park\">RV Park (11%)</option>\r\n </select>\r\n <button class=\"btn-primary\" id=\"ln-export-btn\" title=\"Open dashboard with property data\">\r\n <svg class=\"icon\" viewBox=\"0 0 24 24\">\r\n <path d=\"M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z\" />\r\n </svg>\r\n Dashboard\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n `;\r\n\r\n try {\r\n if (document.body) {\r\n document.body.appendChild(footer);\r\n } else if (document.documentElement) {\r\n document.documentElement.appendChild(footer);\r\n }\r\n } catch (error) {\r\n // Silent fail\r\n }\r\n\r\n const exportBtn = document.getElementById(\"ln-export-btn\");\r\n if (exportBtn) {\r\n exportBtn.addEventListener(\"click\", () => {\r\n callbacks.onExportClick?.();\r\n });\r\n }\r\n\r\n const propertyTypeDropdown = document.getElementById(\"ln-property-type\");\r\n if (propertyTypeDropdown) {\r\n propertyTypeDropdown.addEventListener(\"change\", () => {\r\n callbacks.onPropertyTypeChange?.(propertyTypeDropdown.value);\r\n });\r\n }\r\n\r\n const interestRateTypeDropdown = document.getElementById(\"ln-interest-rate-type\");\r\n if (interestRateTypeDropdown) {\r\n interestRateTypeDropdown.addEventListener(\"change\", () => {\r\n updateState({ currentInterestRateType: interestRateTypeDropdown.value });\r\n callbacks.onInterestRateTypeChange?.(interestRateTypeDropdown.value);\r\n });\r\n }\r\n\r\n const unitsInput = document.getElementById(\"ln-units-input\");\r\n if (unitsInput) {\r\n unitsInput.addEventListener(\"change\", () => {\r\n const value = parseInt(unitsInput.value) || 4;\r\n updateState({ numberOfUnits: value });\r\n\r\n const newRateType = callbacks.state && syncInterestRateForUnits(callbacks.state, updateState, value);\r\n if (newRateType) callbacks.onInterestRateTypeChange?.(newRateType);\r\n });\r\n }\r\n\r\n return footer;\r\n}\r\n"],"names":["PROPERTY_TYPE_OPTIONS","value","label","createPanel","config","cssUrls","defaultPropertyType","callbacks","console","log","existing","document","getElementById","remove","links","map","href","link","createElement","rel","loadedCount","total","length","onLoad","createPanelElements","forEach","onload","setTimeout","head","appendChild","updateState","footer","id","className","innerHTML","join","renderPropertyTypeOptions","body","documentElement","error","exportBtn","addEventListener","onExportClick","propertyTypeDropdown","onPropertyTypeChange","interestRateTypeDropdown","currentInterestRateType","onInterestRateTypeChange","unitsInput","parseInt","numberOfUnits","newRateType","state","syncInterestRateForUnits"],"mappings":"iEAYA,MAAMA,EAAwB,CAC5B,CAAEC,MAAO,cAAeC,MAAO,eAC/B,CAAED,MAAO,MAAOC,MAAO,OACvB,CAAED,MAAO,WAAYC,MAAO,sBAC5B,CAAED,MAAO,WAAYC,MAAO,YAC5B,CAAED,MAAO,YAAaC,MAAO,aAC7B,CAAED,MAAO,UAAWC,MAAO,YAUtB,SAASC,YAAYC,GAC1B,MAAMC,QAAEA,EAAU,GAAEC,oBAAEA,EAAsB,cAAaC,UAAEA,EAAY,CAAA,GAAOH,GAAU,GAExFI,QAAQC,IAAI,2BAEZ,MAAMC,EAAWC,SAASC,eAAe,aACrCF,IACFF,QAAQC,IAAI,gCACZC,EAASG,UAGXL,QAAQC,IAAI,2BAEZ,MAAMK,EAAQT,EAAQU,IAAKC,IACzB,MAAMC,EAAON,SAASO,cAAc,QAGpC,OAFAD,EAAKE,IAAM,aACXF,EAAKD,KAAOA,EACLC,IAIT,IAAIG,EAAc,EAClB,MAAMC,EAAQP,EAAMQ,OACdC,OAAS,KACbH,IACAZ,QAAQC,IAAI,uBAAuBW,KAAeC,MAC9CD,IAAgBC,IAClBb,QAAQC,IAAI,8CACZe,oBAAoBlB,EAAqBC,KAI7CO,EAAMW,QAASR,IAAWA,EAAKS,OAASH,SAExCf,QAAQC,IAAI,oCAEZkB,WAAW,KACTnB,QAAQC,IAAI,gEACZe,oBAAoBlB,EAAqBC,IACxC,KAEHO,EAAMW,QAASR,IAAWN,SAASiB,KAAKC,YAAYZ,IACtD,CAEA,SAASO,oBAAoBlB,EAAqBC,GAEhD,GAAII,SAASC,eAAe,aAAc,OAE1C,MAAMkB,YAAEA,GAAgBvB,EAElBwB,EAASpB,SAASO,cAAc,OACtCa,EAAOC,GAAK,YACZD,EAAOE,UAAY,aAEnBF,EAAOG,UAAY,yoKA7DrB,SAAmC5B,GACjC,OAAON,EAAsBe,IAAI,EAAGd,QAAOC,WAElC,kBAAkBD,KADRA,IAAUK,EAAsB,YAAc,MACjBJ,cAC7CiC,KAAK,iBACV,CA4KcC,CAA0B9B,2zBAoBtC,IACMK,SAAS0B,KACX1B,SAAS0B,KAAKR,YAAYE,GACjBpB,SAAS2B,iBAClB3B,SAAS2B,gBAAgBT,YAAYE,EAEzC,CAAE,MAAOQ,GAET,CAEA,MAAMC,EAAY7B,SAASC,eAAe,iBACtC4B,GACFA,EAAUC,iBAAiB,QAAS,KAClClC,EAAUmC,oBAId,MAAMC,EAAuBhC,SAASC,eAAe,oBACjD+B,GACFA,EAAqBF,iBAAiB,SAAU,KAC9ClC,EAAUqC,uBAAuBD,EAAqB1C,SAI1D,MAAM4C,EAA2BlC,SAASC,eAAe,yBACrDiC,GACFA,EAAyBJ,iBAAiB,SAAU,KAClDX,EAAY,CAAEgB,wBAAyBD,EAAyB5C,QAChEM,EAAUwC,2BAA2BF,EAAyB5C,SAIlE,MAAM+C,EAAarC,SAASC,eAAe,kBAW3C,OAVIoC,GACFA,EAAWP,iBAAiB,SAAU,KACpC,MAAMxC,EAAQgD,SAASD,EAAW/C,QAAU,EAC5C6B,EAAY,CAAEoB,cAAejD,IAE7B,MAAMkD,EAAc5C,EAAU6C,OAASC,EAAyB9C,EAAU6C,MAAOtB,EAAa7B,GAC1FkD,GAAa5C,EAAUwC,2BAA2BI,KAInDpB,CACT"}
@@ -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,setupNoiClickHandler as i,setupAwningLinkHandler as l,setupDiscountButtonHandler as p}from"../ui/click-handlers.js";import{calculateFinancials as s}from"../financial/calculateFinancials.js";import{calculateDOM as u}from"../../date/utilities.js";import{normalizeWhitespace as d}from"../../formatting/text.js";function createPipeline({adapter:m,config:y,ctx:f,exportOps:g,finance:E,render:b,resolveCssUrls:h,services:w}){const{state:C,updateState:P}=f,listingId=()=>m.getListingId(window.location.href);function watchLateFields(e){const isPresent=e=>"string"==typeof e&&""!==e.trim()&&"Not found"!==e,applyLateFields=()=>{const e=m.scrape();if(!e)return!1;const t=d(e.contact),a=d(e.phone),n=d(e.listingDate);return b.updateElement("prop-contact",t),b.updateElement("prop-phone",a),b.updateElement("prop-dom",u(n)),isPresent(t)&&isPresent(a)&&isPresent(n)};if(applyLateFields())return;let t=!1;let n=Math.ceil(1e4/300);const tick=()=>{e.isStale()||(!t&&y.reveals?.length&&(t=!0,a(y.reveals).finally(()=>{t=!1})),applyLateFields()||n--<=0||setTimeout(tick,300))};setTimeout(tick,300)}async function updateFooterData(){const t=e(listingId);if(t.capture(),y.reveals?.length&&(await a(y.reveals),t.isStale()))return;const d=E.scrapeAndApply();if(!d)return console.error("❌ Malformed listing data — missing a contract field, refusing to render"),void b.updateElement("prop-name","Data error — see console");const m=d.unitCount??4;P({numberOfUnits:m});const g=document.getElementById("ln-units-input");g&&(g.value=m),n(C,P,m),b.updateElement("prop-name",d.name),b.updateElement("prop-price",C.priceWasDefaulted?"No price":d.price),b.updateElement("prop-contact",d.contact),b.updateElement("prop-phone",d.phone),b.updateElement("prop-dom",u(d.listingDate)),b.updatePriceLabel(),b.updateCapRateLabel(),b.syncUnitsFieldForType(C.currentPropertyType,d.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:b.getCurrentPrice,recalculateFinancials:E.recalculateFinancials,state:C,updatePercentageLabels:b.updatePercentageLabels,updatePriceLabel:b.updatePriceLabel,updateState:P},n=document.getElementById("prop-price");r(n,n?.closest(".metric")?.querySelector(".metric-label"),a);const s=document.getElementById("prop-cap");o(s,s?.closest(".metric")?.querySelector(".metric-label"),a);const u=document.getElementById("prop-down");c(u,u?.closest(".metric")?.querySelector(".metric-label"),a);const d=document.getElementById("prop-noi");i(d,d?.closest(".metric")?.querySelector(".metric-label"),a),l(document.getElementById("prop-noi-awning")),p(document.getElementById("ln-discount-btn"),a)}(d),watchLateFields(t);const h=C.isUsingEstimatedCapRate?`${C.currentEstimatedCapRate}%`:d.capRate,S=await s(f,d.price,h,C.currentPropertyType,d.name);if(t.isStale())return;b.applyFinancials(S),b.updateActiveCapDisplay();const F=await w.loadLeadStatus(d.name);if(t.isStale())return;b.updateElement("prop-lead-status",F.leadStatus),b.updateLeadStatusTooltip(F);const T=await w.loadStrValue(d.name,t);t.isStale()||T&&"str"===C.currentPropertyType&&(P({baseNOI:null}),await E.recalculateFinancials(),t.isStale())||(await w.loadDebt(d.name,t),t.isStale()||b.updateEquityDisplay())}let S=!1,F=null;return{runPipeline:function(){S=!1,F&&(F.disconnect(),F=null),t({callbacks:{onExportClick:g.handleExportClick,onInterestRateTypeChange:()=>E.recalculateFinancials(),onPropertyTypeChange:()=>{E.handlePropertyTypeChange(),b.updateCapRateLabel();const e=m.scrape();b.syncUnitsFieldForType(C.currentPropertyType,e?.bedroomCount),E.recalculateFinancials()},state:C,updateState:P},cssUrls:h(y.cssFiles),defaultPropertyType:y.defaultPropertyType});const stopObserver=()=>{F&&(F.disconnect(),F=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=m.scrape();return!!e&&"Not found"!==e.price&&!e.priceWasDefaulted})())&&((async()=>{S||(S=!0,await updateFooterData())})(),!0));if(tryImmediateUpdate())return;F=new MutationObserver(()=>{tryImmediateUpdate()&&stopObserver()}),F.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 a}from"./runReveals.js";import{syncInterestRateForUnits as n}from"./interestRateSync.js";import{setupPriceClickHandler as r,setupCapRateClickHandler as o,setupDownPaymentClickHandler as c,setupNoiClickHandler as i,setupAwningLinkHandler as l,setupDiscountButtonHandler as s}from"../ui/click-handlers.js";import{calculateFinancials as p}from"../financial/calculateFinancials.js";import{calculateDOM as u}from"../../date/utilities.js";import{normalizeWhitespace as d}from"../../formatting/text.js";function createPipeline({adapter:m,config:y,ctx:f,exportOps:g,finance:E,render:b,resolveCssUrls:w,services:h}){const{state:C,updateState:F}=f,listingId=()=>m.getListingId(window.location.href);function watchLateFields(e){const isPresent=e=>"string"==typeof e&&""!==e.trim()&&"Not found"!==e,applyLateFields=()=>{const e=m.scrape();if(!e)return!1;const t=d(e.contact),a=d(e.phone),n=d(e.listingDate);return b.updateElement("prop-contact",t),b.updateElement("prop-phone",a),b.updateElement("prop-dom",u(n)),isPresent(t)&&isPresent(a)&&isPresent(n)};if(applyLateFields())return;let t=!1;let n=Math.ceil(1e4/300);const tick=()=>{e.isStale()||(!t&&y.reveals?.length&&(t=!0,a(y.reveals).finally(()=>{t=!1})),applyLateFields()||n--<=0||setTimeout(tick,300))};setTimeout(tick,300)}async function updateFooterData(){const t=e(listingId);if(t.capture(),y.reveals?.length&&(await a(y.reveals),t.isStale()))return;const d=E.scrapeAndApply();if(!d)return console.error("❌ Malformed listing data — missing a contract field, refusing to render"),void b.updateElement("prop-name","Data error — see console");const m=d.unitCount??4;F({numberOfUnits:m});const g=document.getElementById("ln-units-input");g&&(g.value=m),n(C,F,m),b.updateElement("prop-name",d.name),b.updateElement("prop-price",C.priceWasDefaulted?"No price":d.price),b.updateElement("prop-contact",d.contact),b.updateElement("prop-phone",d.phone),b.updateElement("prop-dom",u(d.listingDate)),b.updatePriceLabel(),b.updateCapRateLabel(),b.syncUnitsFieldForType(C.currentPropertyType,d.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:b.getCurrentPrice,recalculateFinancials:E.recalculateFinancials,state:C,updatePercentageLabels:b.updatePercentageLabels,updatePriceLabel:b.updatePriceLabel,updateState:F},n=document.getElementById("prop-price");r(n,n?.closest(".metric")?.querySelector(".metric-label"),a);const p=document.getElementById("prop-cap");o(p,p?.closest(".metric")?.querySelector(".metric-label"),a);const u=document.getElementById("prop-down");c(u,u?.closest(".metric")?.querySelector(".metric-label"),a);const d=document.getElementById("prop-noi");i(d,d?.closest(".metric")?.querySelector(".metric-label"),a),l(document.getElementById("prop-noi-awning")),s(document.getElementById("ln-discount-btn"),a)}(d),watchLateFields(t);const w=C.isUsingEstimatedCapRate?`${C.currentEstimatedCapRate}%`:d.capRate,P=await p(f,d.price,w,C.currentPropertyType,d.name);if(t.isStale())return;b.applyFinancials(P),b.updateActiveCapDisplay();const S=await h.loadLeadStatus(d.name);if(t.isStale())return;b.updateElement("prop-lead-status",S.leadStatus),b.updateLeadStatusTooltip(S);const T=await h.loadStrValue(d.name,t);t.isStale()||T&&"str"===C.currentPropertyType&&(F({baseNOI:null}),await E.recalculateFinancials(),t.isStale())||(await h.loadDebt(d.name,t),t.isStale()||await E.recalculateFinancials())}let P=!1,S=null;return{runPipeline:function(){P=!1,S&&(S.disconnect(),S=null),t({callbacks:{onExportClick:g.handleExportClick,onInterestRateTypeChange:()=>E.recalculateFinancials(),onPropertyTypeChange:()=>{E.handlePropertyTypeChange(),b.updateCapRateLabel();const e=m.scrape();b.syncUnitsFieldForType(C.currentPropertyType,e?.bedroomCount),E.recalculateFinancials()},state:C,updateState:F},cssUrls:w(y.cssFiles),defaultPropertyType:y.defaultPropertyType});const stopObserver=()=>{S&&(S.disconnect(),S=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=m.scrape();return!!e&&"Not found"!==e.price&&!e.priceWasDefaulted})())&&((async()=>{P||(P=!0,await updateFooterData())})(),!0));if(tryImmediateUpdate())return;S=new MutationObserver(()=>{tryImmediateUpdate()&&stopObserver()}),S.observe(document.body,{childList:!0,subtree:!0});let e=0;const fallbackPoll=()=>{P||(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 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 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 render.updateEquityDisplay();\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","noiElement","setupNoiClickHandler","setupAwningLinkHandler","setupDiscountButtonHandler","setupClickableElements","calculationCapRate","isUsingEstimatedCapRate","currentEstimatedCapRate","capRate","financials","calculateFinancials","applyFinancials","updateActiveCapDisplay","loiData","loadLeadStatus","leadStatus","updateLeadStatusTooltip","strResult","loadStrValue","baseNOI","loadDebt","updateEquityDisplay","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 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 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","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,2 +1,2 @@
1
- import{hasTooltip as e,attachTooltip as t,updateTooltipContent as n}from"../ui/tooltip-manager.js";import{generateDownPaymentTooltipHTML as r,generateCashFlowTooltipHTML as o}from"../financial/tooltip-content-generators.js";import{parseFinancialData as c,parseCashFlowData as s}from"../financial/tooltip-calculations.js";import{parsePriceNumber as i,computeActiveCapDisplay as a,parseReportedCap as l}from"../financial/capRate.js";import{equityPercentFromDebt as p}from"../../financial/calculations.js";import{formatCurrency as u}from"../../financial/formatters.js";import{PROPERTY_TYPE_CONSTANTS as d}from"../../config/property-types.js";const m=["prop-noi","prop-down","prop-net","prop-seller-fi","prop-cocr-30","prop-cocr-15","prop-assignment","prop-dscr","prop-sf","prop-cashflow"];function createRender({ctx:g}){const{state:f,updateState:y}=g;function getCurrentPrice(){if(f.originalPrice&&f.currentPriceDiscount>0){const e=parseFloat(f.originalPrice.replace(/[$,]/g,""))*(1-f.currentPriceDiscount/100);return`$${Math.round(e).toLocaleString()}`}return f.originalPrice}function updatePercentageLabels(){const e=document.querySelector("#prop-down")?.closest(".metric")?.querySelector(".metric-label");e&&(e.textContent=`Down (${f.currentDownPaymentPercent}%)`);const t=document.querySelector("#prop-seller-fi")?.closest(".metric")?.querySelector(".metric-label");t&&(t.textContent=`Seller FI (${f.currentSellerFiPercent}%)`);const n=document.querySelector("#prop-dscr")?.closest(".metric")?.querySelector(".metric-label");n&&(n.textContent=`DSCR (${f.currentDSCRPercent}%)`)}function updateElement(e,t){const n=document.getElementById(e);n&&(n.textContent=t)}return{applyFinancials:function(i){let a=!1;if(i){const l={"prop-noi":i.noi,"prop-down":i.down,"prop-net":i.netToBuyer,"prop-seller-fi":i.sellerFi,"prop-cocr-30":i.cocr30,"prop-cocr-15":i.priceForCOCR15,"prop-assignment":i.assignment,"prop-dscr":i.dscr,"prop-sf":i.sfPayment,"prop-cashflow":i.cashFlow};a=i.rawCashFlow<0;for(const[e,t]of Object.entries(l))updateElement(e,t);setTimeout(()=>{!function(){const o=document.getElementById("prop-down"),s=document.getElementById("prop-price"),i=document.getElementById("prop-noi");if(!o||!s||!i)return;const a=c(s.textContent,i.textContent);if(a){const c=r(a.price,a.noi,f.currentDownPaymentPercent,f.currentDSCRPercent,f.currentSellerFiPercent),s=o.closest(".metric");s&&(e(s)?n(s,c):t(s,c))}}(),function(){const r=document.getElementById("prop-cashflow"),c=document.getElementById("prop-price");if(!r||!c)return;const i=s(c.textContent,r.textContent);if(i){const c=o(i.price,i.monthlyCashFlow),s=r.closest(".metric");if(s){const r=s.querySelector(".metric-label");r&&r.classList.add("has-tooltip"),e(s)?n(s,c):t(s,c)}}}()},100)}else m.forEach(e=>updateElement(e,"N/A"));updatePercentageLabels(),function(){const r="str"===f.currentPropertyType,o=document.getElementById("prop-noi-awning");o&&(o.style.display=r?"inline-block":"none");const c=document.getElementById("prop-noi");if(!c)return;if(c.style.cursor=r?"pointer":"",c.querySelector("input"))return;const s=c.closest(".metric");if(r&&f.cachedStrValue&&Number.isFinite(f.cachedStrValue.value)){c.textContent=`${c.textContent}*`;const r=f.cachedStrValue.value,o=d.STR.NOI_PERCENTAGE,i=`<strong>Manual STR gross (Awning):</strong> ${u(r)}/yr<br>NOI = gross &times; ${Math.round(100*o)}% = ${u(r*o)}<hr><em>Click NOI to edit; click the label to reset to the estimate.</em>`;s&&(e(s)?n(s,i):(t(s,i),s.querySelector(".metric-label")?.classList.add("has-tooltip")))}else r&&s&&e(s)&&n(s,"<strong>STR NOI:</strong> 5.5%-of-price estimate.<hr><em>Click NOI to enter Awning's gross revenue.</em>")}();const l=document.getElementById("ln-footer");l&&l.classList.toggle("negative",a)},getCurrentPrice:getCurrentPrice,syncUnitsFieldForType:function(e,t){const n=document.querySelector(".units-inline-label");if(n&&(n.textContent="assisted"===e?"beds":"units"),"assisted"===e&&null!=t){const e=document.getElementById("ln-units-input");e&&(e.value=String(t)),y({numberOfUnits:t})}},updateActiveCapDisplay:function(){const r=document.getElementById("prop-cap");if(!r)return;if(r.querySelector("input"))return;const o=getCurrentPrice()||document.getElementById("prop-price")?.textContent||"",c=i(o);r.textContent=a(f.baseNOI,c);const s=l(f.originalCapRate,f.isUsingEstimatedCapRate),p=`${"<strong>Reported cap rate:</strong> "+(null!=s?`${s}%`:"N/A")}<hr><em>Click the cap rate to enter a value; click the label to reset</em>`,u=r.closest(".metric");if(u){const r=u.querySelector(".metric-label");e(u)?n(u,p):(t(u,p),r&&r.classList.add("has-tooltip"))}},updateCapRateLabel:function(){const e=document.querySelector("#prop-cap")?.closest(".metric")?.querySelector(".metric-label");if(e)switch(f.currentPropertyType){case"str":e.textContent="Cap Rate (STR)";break;case"assisted":e.textContent="Cap Rate (Assisted)";break;default:e.textContent="Cap Rate"}},updateElement:updateElement,updateEquityDisplay:function(){const r=document.getElementById("prop-equity");if(!r)return;const o=getCurrentPrice()||document.getElementById("prop-price")?.textContent||"",c=i(o),s=f.cachedDebtBalance,a=null==s,l=p(c,s);r.textContent=`${Math.round(100*l)}%${a?"*":""}`;const fmtUSD=e=>Number.isFinite(Number(e))?`$${Math.round(Number(e)).toLocaleString()}`:"N/A",u=a?"Estimated — no debt data, assuming 100% equity":"scraped"===f.equitySource?"Public records (API)":f.equitySource;let d=`<strong>Debt owing:</strong> ${a?"Unknown":fmtUSD(s)}`;d+=`<br><strong>Source:</strong> ${u}`,f.cachedDebtAddress&&(d+=`<br><strong>Matched:</strong> ${f.cachedDebtAddress}`);const m=Array.isArray(f.cachedMortgages)?f.cachedMortgages:[];m.length&&(d+="<hr>"+m.map(e=>{const t=null!=e.interestRate&&""!==e.interestRate?` @ ${e.interestRate}%`:"";return`<strong>${e.position||"Lien"}:</strong> ${fmtUSD(e.amount)} ${e.loanType||""}${t} (${e.lenderName||"?"})`}).join("<br>"));const g=r.closest(".metric");if(g){const r=g.querySelector(".metric-label");e(g)?n(g,d):(t(g,d),r&&r.classList.add("has-tooltip"))}},updateLeadStatusTooltip:function(r){const o=document.getElementById("prop-lead-status");if(!o)return;let c="No LOI data returned";r&&(c=`\n <strong>Contact:</strong> ${r.contactName} <br>\n ${r.opportunityAddress}\n `);const s=o.closest(".metric");if(s)if(e(s))n(s,c);else{t(s,c);const e=s.querySelector(".metric-label");e&&e.classList.add("has-tooltip")}},updatePercentageLabels:updatePercentageLabels,updatePriceLabel:function(){const e=document.querySelector("#prop-price")?.closest(".metric")?.querySelector(".metric-label");e&&(e.textContent=f.currentPriceDiscount>0?`Price (${f.currentPriceDiscount}%)`:"Price")}}}export{createRender};
1
+ import{hasTooltip as e,attachTooltip as t,updateTooltipContent as n}from"../ui/tooltip-manager.js";import{generateDownPaymentTooltipHTML as r,generateCashFlowTooltipHTML as o}from"../financial/tooltip-content-generators.js";import{parseFinancialData as c,parseCashFlowData as s}from"../financial/tooltip-calculations.js";import{parsePriceNumber as i,computeActiveCapDisplay as a,parseReportedCap as l}from"../financial/capRate.js";import{equityPercentFromDebt as p}from"../../financial/calculations.js";import{formatCurrency as u}from"../../financial/formatters.js";import{PROPERTY_TYPE_CONSTANTS as d}from"../../config/property-types.js";const m=["prop-noi","prop-down","prop-net","prop-seller-fi","prop-cocr-30","prop-cocr-15","prop-assignment","prop-dscr","prop-sf","prop-cashflow"];function createRender({ctx:g}){const{state:y,updateState:f}=g;function getCurrentPrice(){if(y.originalPrice&&y.currentPriceDiscount>0){const e=parseFloat(y.originalPrice.replace(/[$,]/g,""))*(1-y.currentPriceDiscount/100);return`$${Math.round(e).toLocaleString()}`}return y.originalPrice}function updatePercentageLabels(){const e=document.querySelector("#prop-down")?.closest(".metric")?.querySelector(".metric-label");e&&(e.textContent=`Down (${y.currentDownPaymentPercent}%)`);const t=document.querySelector("#prop-seller-fi")?.closest(".metric")?.querySelector(".metric-label");t&&(t.textContent=`Seller FI (${y.currentSellerFiPercent}%)`);const n=document.querySelector("#prop-dscr")?.closest(".metric")?.querySelector(".metric-label");n&&(n.textContent=`DSCR (${y.currentDSCRPercent}%)`)}function updateElement(e,t){const n=document.getElementById(e);n&&(n.textContent=t)}return{applyFinancials:function(i){let a=!1,l=!1;if(i){const p={"prop-noi":i.noi,"prop-down":i.down,"prop-net":i.netToBuyer,"prop-seller-fi":i.sellerFi,"prop-cocr-30":i.cocr30,"prop-cocr-15":i.priceForCOCR15,"prop-assignment":i.assignment,"prop-dscr":i.dscr,"prop-sf":i.sfPayment,"prop-cashflow":i.cashFlow};a=i.rawCashFlow<0;const u=y.cachedDebtBalance;l=Number.isFinite(u)&&u>i.rawDown;for(const[e,t]of Object.entries(p))updateElement(e,t);setTimeout(()=>{!function(){const o=document.getElementById("prop-down"),s=document.getElementById("prop-price"),i=document.getElementById("prop-noi");if(!o||!s||!i)return;const a=c(s.textContent,i.textContent);if(a){const c=r(a.price,a.noi,y.currentDownPaymentPercent,y.currentDSCRPercent,y.currentSellerFiPercent),s=o.closest(".metric");s&&(e(s)?n(s,c):t(s,c))}}(),function(){const r=document.getElementById("prop-cashflow"),c=document.getElementById("prop-price");if(!r||!c)return;const i=s(c.textContent,r.textContent);if(i){const c=o(i.price,i.monthlyCashFlow),s=r.closest(".metric");if(s){const r=s.querySelector(".metric-label");r&&r.classList.add("has-tooltip"),e(s)?n(s,c):t(s,c)}}}()},100)}else m.forEach(e=>updateElement(e,"N/A"));updatePercentageLabels(),function(){const r="str"===y.currentPropertyType,o=document.getElementById("prop-noi-awning");o&&(o.style.display=r?"inline-block":"none");const c=document.getElementById("prop-noi");if(!c)return;if(c.style.cursor=r?"pointer":"",c.querySelector("input"))return;const s=c.closest(".metric");if(r&&y.cachedStrValue&&Number.isFinite(y.cachedStrValue.value)){c.textContent=`${c.textContent}*`;const r=y.cachedStrValue.value,o=d.STR.NOI_PERCENTAGE,i=`<strong>Manual STR gross (Awning):</strong> ${u(r)}/yr<br>NOI = gross &times; ${Math.round(100*o)}% = ${u(r*o)}<hr><em>Click NOI to edit; click the label to reset to the estimate.</em>`;s&&(e(s)?n(s,i):(t(s,i),s.querySelector(".metric-label")?.classList.add("has-tooltip")))}else r&&s&&e(s)&&n(s,"<strong>STR NOI:</strong> 5.5%-of-price estimate.<hr><em>Click NOI to enter Awning's gross revenue.</em>")}();const p=a||l,g=document.getElementById("ln-footer");g&&g.classList.toggle("negative",p);const f=[];a&&f.push("CF"),l&&f.push("IE");const C=document.getElementById("ln-red-reasons");C&&(C.textContent=f.join(" · "),C.style.display=f.length?"":"none")},getCurrentPrice:getCurrentPrice,syncUnitsFieldForType:function(e,t){const n=document.querySelector(".units-inline-label");if(n&&(n.textContent="assisted"===e?"beds":"units"),"assisted"===e&&null!=t){const e=document.getElementById("ln-units-input");e&&(e.value=String(t)),f({numberOfUnits:t})}},updateActiveCapDisplay:function(){const r=document.getElementById("prop-cap");if(!r)return;if(r.querySelector("input"))return;const o=getCurrentPrice()||document.getElementById("prop-price")?.textContent||"",c=i(o);r.textContent=a(y.baseNOI,c);const s=l(y.originalCapRate,y.isUsingEstimatedCapRate),p=`${"<strong>Reported cap rate:</strong> "+(null!=s?`${s}%`:"N/A")}<hr><em>Click the cap rate to enter a value; click the label to reset</em>`,u=r.closest(".metric");if(u){const r=u.querySelector(".metric-label");e(u)?n(u,p):(t(u,p),r&&r.classList.add("has-tooltip"))}},updateCapRateLabel:function(){const e=document.querySelector("#prop-cap")?.closest(".metric")?.querySelector(".metric-label");if(e)switch(y.currentPropertyType){case"str":e.textContent="Cap Rate (STR)";break;case"assisted":e.textContent="Cap Rate (Assisted)";break;default:e.textContent="Cap Rate"}},updateElement:updateElement,updateEquityDisplay:function(){const r=document.getElementById("prop-equity");if(!r)return;const o=getCurrentPrice()||document.getElementById("prop-price")?.textContent||"",c=i(o),s=y.cachedDebtBalance,a=null==s,l=p(c,s);r.textContent=`${Math.round(100*l)}%${a?"*":""}`;const fmtUSD=e=>Number.isFinite(Number(e))?`$${Math.round(Number(e)).toLocaleString()}`:"N/A",u=a?"Estimated — no debt data, assuming 100% equity":"scraped"===y.equitySource?"Public records (API)":y.equitySource;let d=`<strong>Debt owing:</strong> ${a?"Unknown":fmtUSD(s)}`;d+=`<br><strong>Source:</strong> ${u}`,y.cachedDebtAddress&&(d+=`<br><strong>Matched:</strong> ${y.cachedDebtAddress}`);const m=Array.isArray(y.cachedMortgages)?y.cachedMortgages:[];m.length&&(d+="<hr>"+m.map(e=>{const t=null!=e.interestRate&&""!==e.interestRate?` @ ${e.interestRate}%`:"";return`<strong>${e.position||"Lien"}:</strong> ${fmtUSD(e.amount)} ${e.loanType||""}${t} (${e.lenderName||"?"})`}).join("<br>"));const g=r.closest(".metric");if(g){const r=g.querySelector(".metric-label");e(g)?n(g,d):(t(g,d),r&&r.classList.add("has-tooltip"))}},updateLeadStatusTooltip:function(r){const o=document.getElementById("prop-lead-status");if(!o)return;let c="No LOI data returned";r&&(c=`\n <strong>Contact:</strong> ${r.contactName} <br>\n ${r.opportunityAddress}\n `);const s=o.closest(".metric");if(s)if(e(s))n(s,c);else{t(s,c);const e=s.querySelector(".metric-label");e&&e.classList.add("has-tooltip")}},updatePercentageLabels:updatePercentageLabels,updatePriceLabel:function(){const e=document.querySelector("#prop-price")?.closest(".metric")?.querySelector(".metric-label");e&&(e.textContent=y.currentPriceDiscount>0?`Price (${y.currentPriceDiscount}%)`:"Price")}}}export{createRender};
2
2
  //# sourceMappingURL=render.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"render.js","sources":["../../../src/browser/widget/render.js"],"sourcesContent":["// Render unit: the panel's DOM-painting helpers — price/label updates, the financial-metric\r\n// paint, the active-cap display, and the hover tooltips. Everything here reads ctx.state and\r\n// writes the DOM; the finance math it needs comes from the pure capRate helpers. Extracted\r\n// verbatim from createAnalyzer's render closures (T12 decompose).\r\n\r\nimport { attachTooltip, hasTooltip, updateTooltipContent } from \"../ui/tooltip-manager.js\";\r\nimport {\r\n generateCashFlowTooltipHTML,\r\n generateDownPaymentTooltipHTML,\r\n} from \"../financial/tooltip-content-generators.js\";\r\nimport { parseCashFlowData, parseFinancialData } from \"../financial/tooltip-calculations.js\";\r\nimport { computeActiveCapDisplay, parsePriceNumber, parseReportedCap } from \"../financial/capRate.js\";\r\nimport { equityPercentFromDebt } from \"../../financial/calculations.js\";\r\nimport { formatCurrency } from \"../../financial/formatters.js\";\r\nimport { PROPERTY_TYPE_CONSTANTS } from \"../../config/property-types.js\";\r\n\r\nconst FINANCIAL_ELEMENT_IDS = [\r\n \"prop-noi\", \"prop-down\", \"prop-net\", \"prop-seller-fi\", \"prop-cocr-30\",\r\n \"prop-cocr-15\", \"prop-assignment\", \"prop-dscr\", \"prop-sf\", \"prop-cashflow\",\r\n];\r\n\r\nexport function createRender({ ctx }) {\r\n const { state, updateState } = ctx;\r\n\r\n function getCurrentPrice() {\r\n if (state.originalPrice && state.currentPriceDiscount > 0) {\r\n const numericPrice = parseFloat(state.originalPrice.replace(/[$,]/g, \"\"));\r\n const discountedPrice = numericPrice * (1 - state.currentPriceDiscount / 100);\r\n return `$${Math.round(discountedPrice).toLocaleString()}`;\r\n }\r\n return state.originalPrice;\r\n }\r\n\r\n function updatePriceLabel() {\r\n const priceLabelElement = document.querySelector(\"#prop-price\")?.closest(\".metric\")?.querySelector(\".metric-label\");\r\n if (priceLabelElement) {\r\n priceLabelElement.textContent = state.currentPriceDiscount > 0 ? `Price (${state.currentPriceDiscount}%)` : \"Price\";\r\n }\r\n }\r\n\r\n function updatePercentageLabels() {\r\n const downLabelElement = document.querySelector(\"#prop-down\")?.closest(\".metric\")?.querySelector(\".metric-label\");\r\n if (downLabelElement) downLabelElement.textContent = `Down (${state.currentDownPaymentPercent}%)`;\r\n\r\n const sellerFiLabelElement = document.querySelector(\"#prop-seller-fi\")?.closest(\".metric\")?.querySelector(\".metric-label\");\r\n if (sellerFiLabelElement) sellerFiLabelElement.textContent = `Seller FI (${state.currentSellerFiPercent}%)`;\r\n\r\n const dscrLabelElement = document.querySelector(\"#prop-dscr\")?.closest(\".metric\")?.querySelector(\".metric-label\");\r\n if (dscrLabelElement) dscrLabelElement.textContent = `DSCR (${state.currentDSCRPercent}%)`;\r\n }\r\n\r\n function updateElement(id, value) {\r\n const element = document.getElementById(id);\r\n if (element) element.textContent = value;\r\n }\r\n\r\n function updateCapRateLabel() {\r\n const capLabelElement = document.querySelector(\"#prop-cap\")?.closest(\".metric\")?.querySelector(\".metric-label\");\r\n if (!capLabelElement) return;\r\n switch (state.currentPropertyType) {\r\n case \"str\": capLabelElement.textContent = \"Cap Rate (STR)\"; break;\r\n case \"assisted\": capLabelElement.textContent = \"Cap Rate (Assisted)\"; break;\r\n default: capLabelElement.textContent = \"Cap Rate\"; break;\r\n }\r\n }\r\n\r\n // The panel shows the ACTIVE cap rate = NOI / current price (discount-aware via\r\n // getCurrentPrice), so the displayed cap is always internally consistent with the NOI metric\r\n // — including STR/assisted, where NOI is the type estimate/bedroom value and the listed cap\r\n // never drove it. The REPORTED cap (the scraped value, only when it was a real non-estimated\r\n // cap) is shown on hover; \"N/A\" when none was reported. When the cap is an estimate the\r\n // tooltip also keeps the click-to-cycle hint (clicking the cap is a manual NOI override).\r\n function updateActiveCapDisplay() {\r\n const capElement = document.getElementById(\"prop-cap\");\r\n if (!capElement) return;\r\n // Don't clobber an in-progress manual cap edit (the inline input lives in this cell).\r\n if (capElement.querySelector(\"input\")) return;\r\n\r\n const priceText = getCurrentPrice() || document.getElementById(\"prop-price\")?.textContent || \"\";\r\n const price = parsePriceNumber(priceText);\r\n capElement.textContent = computeActiveCapDisplay(state.baseNOI, price);\r\n\r\n const reported = parseReportedCap(state.originalCapRate, state.isUsingEstimatedCapRate);\r\n const reportedLine = `<strong>Reported cap rate:</strong> ${reported != null ? `${reported}%` : \"N/A\"}`;\r\n const editHint = \"<hr><em>Click the cap rate to enter a value; click the label to reset</em>\";\r\n const tooltipContent = `${reportedLine}${editHint}`;\r\n\r\n const metric = capElement.closest(\".metric\");\r\n if (metric) {\r\n const label = metric.querySelector(\".metric-label\");\r\n if (!hasTooltip(metric)) {\r\n attachTooltip(metric, tooltipContent);\r\n if (label) label.classList.add(\"has-tooltip\");\r\n } else {\r\n updateTooltipContent(metric, tooltipContent);\r\n }\r\n }\r\n }\r\n\r\n // Equity is DERIVED, not fetched: (price - debt) / price against the current (discount-aware)\r\n // price, so it recomputes on every price edit. No debt figure => 100% (\"estimated\", marked *).\r\n // The hover reveals the $ debt, the recorded liens, the matched address, and how it was acquired.\r\n function updateEquityDisplay() {\r\n const equityElement = document.getElementById(\"prop-equity\");\r\n if (!equityElement) return;\r\n\r\n const priceText = getCurrentPrice() || document.getElementById(\"prop-price\")?.textContent || \"\";\r\n const price = parsePriceNumber(priceText);\r\n const debt = state.cachedDebtBalance;\r\n const estimated = debt === null || debt === undefined;\r\n const equity = equityPercentFromDebt(price, debt);\r\n equityElement.textContent = `${Math.round(equity * 100)}%${estimated ? \"*\" : \"\"}`;\r\n\r\n const fmtUSD = (n) => (Number.isFinite(Number(n)) ? `$${Math.round(Number(n)).toLocaleString()}` : \"N/A\");\r\n const sourceLabel = estimated\r\n ? \"Estimated — no debt data, assuming 100% equity\"\r\n : (state.equitySource === \"scraped\" ? \"Public records (API)\" : state.equitySource);\r\n\r\n let tooltip = `<strong>Debt owing:</strong> ${estimated ? \"Unknown\" : fmtUSD(debt)}`;\r\n tooltip += `<br><strong>Source:</strong> ${sourceLabel}`;\r\n if (state.cachedDebtAddress) tooltip += `<br><strong>Matched:</strong> ${state.cachedDebtAddress}`;\r\n\r\n const mortgages = Array.isArray(state.cachedMortgages) ? state.cachedMortgages : [];\r\n if (mortgages.length) {\r\n tooltip += \"<hr>\" + mortgages.map((m) => {\r\n const rate = (m.interestRate != null && m.interestRate !== \"\") ? ` @ ${m.interestRate}%` : \"\";\r\n return `<strong>${m.position || \"Lien\"}:</strong> ${fmtUSD(m.amount)} ${m.loanType || \"\"}${rate} (${m.lenderName || \"?\"})`;\r\n }).join(\"<br>\");\r\n }\r\n\r\n const metric = equityElement.closest(\".metric\");\r\n if (metric) {\r\n const label = metric.querySelector(\".metric-label\");\r\n if (!hasTooltip(metric)) {\r\n attachTooltip(metric, tooltip);\r\n if (label) label.classList.add(\"has-tooltip\");\r\n } else {\r\n updateTooltipContent(metric, tooltip);\r\n }\r\n }\r\n }\r\n\r\n function syncUnitsFieldForType(propertyType, bedroomCount) {\r\n const label = document.querySelector(\".units-inline-label\");\r\n if (label) label.textContent = propertyType === \"assisted\" ? \"beds\" : \"units\";\r\n if (propertyType === \"assisted\" && bedroomCount != null) {\r\n const input = document.getElementById(\"ln-units-input\");\r\n if (input) input.value = String(bedroomCount);\r\n updateState({ numberOfUnits: bedroomCount });\r\n }\r\n }\r\n\r\n function updateLeadStatusTooltip(loiData) {\r\n const leadElement = document.getElementById(\"prop-lead-status\");\r\n if (!leadElement) return;\r\n let tooltipContent = \"No LOI data returned\";\r\n if (loiData) {\r\n tooltipContent = `\r\n <strong>Contact:</strong> ${loiData.contactName} <br>\r\n ${loiData.opportunityAddress}\r\n `;\r\n }\r\n const metric = leadElement.closest(\".metric\");\r\n if (metric) {\r\n if (!hasTooltip(metric)) {\r\n attachTooltip(metric, tooltipContent);\r\n const label = metric.querySelector(\".metric-label\");\r\n if (label) label.classList.add(\"has-tooltip\");\r\n } else {\r\n updateTooltipContent(metric, tooltipContent);\r\n }\r\n }\r\n }\r\n\r\n function updateDownHoverTooltip() {\r\n const downElement = document.getElementById(\"prop-down\");\r\n const priceElement = document.getElementById(\"prop-price\");\r\n const noiElement = document.getElementById(\"prop-noi\");\r\n if (!downElement || !priceElement || !noiElement) return;\r\n\r\n const financialData = parseFinancialData(priceElement.textContent, noiElement.textContent);\r\n if (financialData) {\r\n const tooltipContent = generateDownPaymentTooltipHTML(\r\n financialData.price,\r\n financialData.noi,\r\n state.currentDownPaymentPercent,\r\n state.currentDSCRPercent,\r\n state.currentSellerFiPercent\r\n );\r\n const metric = downElement.closest(\".metric\");\r\n if (metric) {\r\n if (!hasTooltip(metric)) attachTooltip(metric, tooltipContent);\r\n else updateTooltipContent(metric, tooltipContent);\r\n }\r\n }\r\n }\r\n\r\n function updateCashFlowHoverTooltip() {\r\n const cashFlowElement = document.getElementById(\"prop-cashflow\");\r\n const priceElement = document.getElementById(\"prop-price\");\r\n if (!cashFlowElement || !priceElement) return;\r\n\r\n const cashFlowData = parseCashFlowData(priceElement.textContent, cashFlowElement.textContent);\r\n if (cashFlowData) {\r\n const tooltipContent = generateCashFlowTooltipHTML(cashFlowData.price, cashFlowData.monthlyCashFlow);\r\n const metric = cashFlowElement.closest(\".metric\");\r\n if (metric) {\r\n const label = metric.querySelector(\".metric-label\");\r\n if (label) label.classList.add(\"has-tooltip\");\r\n if (!hasTooltip(metric)) attachTooltip(metric, tooltipContent);\r\n else updateTooltipContent(metric, tooltipContent);\r\n }\r\n }\r\n }\r\n\r\n // STR affordances: the Awning link and the click-to-edit NOI cell exist only in STR mode.\r\n // When a manual STR gross is active (cachedStrValue), mark the painted NOI with a trailing *\r\n // and a source tooltip — mirroring the cap-rate/equity \"*\" convention. Runs after the metric\r\n // paint so the * is appended to the freshly-set NOI text (and re-applied on every recalc).\r\n function updateStrAffordances() {\r\n const isStr = state.currentPropertyType === \"str\";\r\n\r\n const awningLink = document.getElementById(\"prop-noi-awning\");\r\n if (awningLink) awningLink.style.display = isStr ? \"inline-block\" : \"none\";\r\n\r\n const noiElement = document.getElementById(\"prop-noi\");\r\n if (!noiElement) return;\r\n noiElement.style.cursor = isStr ? \"pointer\" : \"\";\r\n if (noiElement.querySelector(\"input\")) return;\r\n\r\n const metric = noiElement.closest(\".metric\");\r\n const manual = isStr && state.cachedStrValue && Number.isFinite(state.cachedStrValue.value);\r\n\r\n if (manual) {\r\n noiElement.textContent = `${noiElement.textContent}*`;\r\n const gross = state.cachedStrValue.value;\r\n const pct = PROPERTY_TYPE_CONSTANTS.STR.NOI_PERCENTAGE;\r\n const tip = `<strong>Manual STR gross (Awning):</strong> ${formatCurrency(gross)}/yr`\r\n + `<br>NOI = gross &times; ${Math.round(pct * 100)}% = ${formatCurrency(gross * pct)}`\r\n + `<hr><em>Click NOI to edit; click the label to reset to the estimate.</em>`;\r\n if (metric) {\r\n if (!hasTooltip(metric)) {\r\n attachTooltip(metric, tip);\r\n metric.querySelector(\".metric-label\")?.classList.add(\"has-tooltip\");\r\n } else {\r\n updateTooltipContent(metric, tip);\r\n }\r\n }\r\n } else if (isStr && metric && hasTooltip(metric)) {\r\n updateTooltipContent(metric, \"<strong>STR NOI:</strong> 5.5%-of-price estimate.<hr><em>Click NOI to enter Awning's gross revenue.</em>\");\r\n }\r\n }\r\n\r\n function applyFinancials(financials) {\r\n let isCashFlowNegative = false;\r\n if (financials) {\r\n const elements = {\r\n \"prop-noi\": financials.noi,\r\n \"prop-down\": financials.down,\r\n \"prop-net\": financials.netToBuyer,\r\n \"prop-seller-fi\": financials.sellerFi,\r\n \"prop-cocr-30\": financials.cocr30,\r\n \"prop-cocr-15\": financials.priceForCOCR15,\r\n \"prop-assignment\": financials.assignment,\r\n \"prop-dscr\": financials.dscr,\r\n \"prop-sf\": financials.sfPayment,\r\n \"prop-cashflow\": financials.cashFlow,\r\n };\r\n isCashFlowNegative = financials.rawCashFlow < 0;\r\n for (const [id, value] of Object.entries(elements)) updateElement(id, value);\r\n setTimeout(() => {\r\n updateDownHoverTooltip();\r\n updateCashFlowHoverTooltip();\r\n }, 100);\r\n } else {\r\n FINANCIAL_ELEMENT_IDS.forEach((id) => updateElement(id, \"N/A\"));\r\n }\r\n\r\n updatePercentageLabels();\r\n updateStrAffordances();\r\n const footer = document.getElementById(\"ln-footer\");\r\n if (footer) footer.classList.toggle(\"negative\", isCashFlowNegative);\r\n }\r\n\r\n return {\r\n applyFinancials,\r\n getCurrentPrice,\r\n syncUnitsFieldForType,\r\n updateActiveCapDisplay,\r\n updateCapRateLabel,\r\n updateElement,\r\n updateEquityDisplay,\r\n updateLeadStatusTooltip,\r\n updatePercentageLabels,\r\n updatePriceLabel,\r\n };\r\n}\r\n"],"names":["FINANCIAL_ELEMENT_IDS","createRender","ctx","state","updateState","getCurrentPrice","originalPrice","currentPriceDiscount","discountedPrice","parseFloat","replace","Math","round","toLocaleString","updatePercentageLabels","downLabelElement","document","querySelector","closest","textContent","currentDownPaymentPercent","sellerFiLabelElement","currentSellerFiPercent","dscrLabelElement","currentDSCRPercent","updateElement","id","value","element","getElementById","applyFinancials","financials","isCashFlowNegative","elements","noi","down","netToBuyer","sellerFi","cocr30","priceForCOCR15","assignment","dscr","sfPayment","cashFlow","rawCashFlow","Object","entries","setTimeout","downElement","priceElement","noiElement","financialData","parseFinancialData","tooltipContent","generateDownPaymentTooltipHTML","price","metric","hasTooltip","updateTooltipContent","attachTooltip","updateDownHoverTooltip","cashFlowElement","cashFlowData","parseCashFlowData","generateCashFlowTooltipHTML","monthlyCashFlow","label","classList","add","updateCashFlowHoverTooltip","forEach","isStr","currentPropertyType","awningLink","style","display","cursor","cachedStrValue","Number","isFinite","gross","pct","PROPERTY_TYPE_CONSTANTS","STR","NOI_PERCENTAGE","tip","formatCurrency","updateStrAffordances","footer","toggle","syncUnitsFieldForType","propertyType","bedroomCount","input","String","numberOfUnits","updateActiveCapDisplay","capElement","priceText","parsePriceNumber","computeActiveCapDisplay","baseNOI","reported","parseReportedCap","originalCapRate","isUsingEstimatedCapRate","updateCapRateLabel","capLabelElement","updateEquityDisplay","equityElement","debt","cachedDebtBalance","estimated","equity","equityPercentFromDebt","fmtUSD","n","sourceLabel","equitySource","tooltip","cachedDebtAddress","mortgages","Array","isArray","cachedMortgages","length","map","m","rate","interestRate","position","amount","loanType","lenderName","join","updateLeadStatusTooltip","loiData","leadElement","contactName","opportunityAddress","updatePriceLabel","priceLabelElement"],"mappings":"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"}
1
+ {"version":3,"file":"render.js","sources":["../../../src/browser/widget/render.js"],"sourcesContent":["// Render unit: the panel's DOM-painting helpers — price/label updates, the financial-metric\r\n// paint, the active-cap display, and the hover tooltips. Everything here reads ctx.state and\r\n// writes the DOM; the finance math it needs comes from the pure capRate helpers. Extracted\r\n// verbatim from createAnalyzer's render closures (T12 decompose).\r\n\r\nimport { attachTooltip, hasTooltip, updateTooltipContent } from \"../ui/tooltip-manager.js\";\r\nimport {\r\n generateCashFlowTooltipHTML,\r\n generateDownPaymentTooltipHTML,\r\n} from \"../financial/tooltip-content-generators.js\";\r\nimport { parseCashFlowData, parseFinancialData } from \"../financial/tooltip-calculations.js\";\r\nimport { computeActiveCapDisplay, parsePriceNumber, parseReportedCap } from \"../financial/capRate.js\";\r\nimport { equityPercentFromDebt } from \"../../financial/calculations.js\";\r\nimport { formatCurrency } from \"../../financial/formatters.js\";\r\nimport { PROPERTY_TYPE_CONSTANTS } from \"../../config/property-types.js\";\r\n\r\nconst FINANCIAL_ELEMENT_IDS = [\r\n \"prop-noi\", \"prop-down\", \"prop-net\", \"prop-seller-fi\", \"prop-cocr-30\",\r\n \"prop-cocr-15\", \"prop-assignment\", \"prop-dscr\", \"prop-sf\", \"prop-cashflow\",\r\n];\r\n\r\nexport function createRender({ ctx }) {\r\n const { state, updateState } = ctx;\r\n\r\n function getCurrentPrice() {\r\n if (state.originalPrice && state.currentPriceDiscount > 0) {\r\n const numericPrice = parseFloat(state.originalPrice.replace(/[$,]/g, \"\"));\r\n const discountedPrice = numericPrice * (1 - state.currentPriceDiscount / 100);\r\n return `$${Math.round(discountedPrice).toLocaleString()}`;\r\n }\r\n return state.originalPrice;\r\n }\r\n\r\n function updatePriceLabel() {\r\n const priceLabelElement = document.querySelector(\"#prop-price\")?.closest(\".metric\")?.querySelector(\".metric-label\");\r\n if (priceLabelElement) {\r\n priceLabelElement.textContent = state.currentPriceDiscount > 0 ? `Price (${state.currentPriceDiscount}%)` : \"Price\";\r\n }\r\n }\r\n\r\n function updatePercentageLabels() {\r\n const downLabelElement = document.querySelector(\"#prop-down\")?.closest(\".metric\")?.querySelector(\".metric-label\");\r\n if (downLabelElement) downLabelElement.textContent = `Down (${state.currentDownPaymentPercent}%)`;\r\n\r\n const sellerFiLabelElement = document.querySelector(\"#prop-seller-fi\")?.closest(\".metric\")?.querySelector(\".metric-label\");\r\n if (sellerFiLabelElement) sellerFiLabelElement.textContent = `Seller FI (${state.currentSellerFiPercent}%)`;\r\n\r\n const dscrLabelElement = document.querySelector(\"#prop-dscr\")?.closest(\".metric\")?.querySelector(\".metric-label\");\r\n if (dscrLabelElement) dscrLabelElement.textContent = `DSCR (${state.currentDSCRPercent}%)`;\r\n }\r\n\r\n function updateElement(id, value) {\r\n const element = document.getElementById(id);\r\n if (element) element.textContent = value;\r\n }\r\n\r\n function updateCapRateLabel() {\r\n const capLabelElement = document.querySelector(\"#prop-cap\")?.closest(\".metric\")?.querySelector(\".metric-label\");\r\n if (!capLabelElement) return;\r\n switch (state.currentPropertyType) {\r\n case \"str\": capLabelElement.textContent = \"Cap Rate (STR)\"; break;\r\n case \"assisted\": capLabelElement.textContent = \"Cap Rate (Assisted)\"; break;\r\n default: capLabelElement.textContent = \"Cap Rate\"; break;\r\n }\r\n }\r\n\r\n // The panel shows the ACTIVE cap rate = NOI / current price (discount-aware via\r\n // getCurrentPrice), so the displayed cap is always internally consistent with the NOI metric\r\n // — including STR/assisted, where NOI is the type estimate/bedroom value and the listed cap\r\n // never drove it. The REPORTED cap (the scraped value, only when it was a real non-estimated\r\n // cap) is shown on hover; \"N/A\" when none was reported. When the cap is an estimate the\r\n // tooltip also keeps the click-to-cycle hint (clicking the cap is a manual NOI override).\r\n function updateActiveCapDisplay() {\r\n const capElement = document.getElementById(\"prop-cap\");\r\n if (!capElement) return;\r\n // Don't clobber an in-progress manual cap edit (the inline input lives in this cell).\r\n if (capElement.querySelector(\"input\")) return;\r\n\r\n const priceText = getCurrentPrice() || document.getElementById(\"prop-price\")?.textContent || \"\";\r\n const price = parsePriceNumber(priceText);\r\n capElement.textContent = computeActiveCapDisplay(state.baseNOI, price);\r\n\r\n const reported = parseReportedCap(state.originalCapRate, state.isUsingEstimatedCapRate);\r\n const reportedLine = `<strong>Reported cap rate:</strong> ${reported != null ? `${reported}%` : \"N/A\"}`;\r\n const editHint = \"<hr><em>Click the cap rate to enter a value; click the label to reset</em>\";\r\n const tooltipContent = `${reportedLine}${editHint}`;\r\n\r\n const metric = capElement.closest(\".metric\");\r\n if (metric) {\r\n const label = metric.querySelector(\".metric-label\");\r\n if (!hasTooltip(metric)) {\r\n attachTooltip(metric, tooltipContent);\r\n if (label) label.classList.add(\"has-tooltip\");\r\n } else {\r\n updateTooltipContent(metric, tooltipContent);\r\n }\r\n }\r\n }\r\n\r\n // Equity is DERIVED, not fetched: (price - debt) / price against the current (discount-aware)\r\n // price, so it recomputes on every price edit. No debt figure => 100% (\"estimated\", marked *).\r\n // The hover reveals the $ debt, the recorded liens, the matched address, and how it was acquired.\r\n function updateEquityDisplay() {\r\n const equityElement = document.getElementById(\"prop-equity\");\r\n if (!equityElement) return;\r\n\r\n const priceText = getCurrentPrice() || document.getElementById(\"prop-price\")?.textContent || \"\";\r\n const price = parsePriceNumber(priceText);\r\n const debt = state.cachedDebtBalance;\r\n const estimated = debt === null || debt === undefined;\r\n const equity = equityPercentFromDebt(price, debt);\r\n equityElement.textContent = `${Math.round(equity * 100)}%${estimated ? \"*\" : \"\"}`;\r\n\r\n const fmtUSD = (n) => (Number.isFinite(Number(n)) ? `$${Math.round(Number(n)).toLocaleString()}` : \"N/A\");\r\n const sourceLabel = estimated\r\n ? \"Estimated — no debt data, assuming 100% equity\"\r\n : (state.equitySource === \"scraped\" ? \"Public records (API)\" : state.equitySource);\r\n\r\n let tooltip = `<strong>Debt owing:</strong> ${estimated ? \"Unknown\" : fmtUSD(debt)}`;\r\n tooltip += `<br><strong>Source:</strong> ${sourceLabel}`;\r\n if (state.cachedDebtAddress) tooltip += `<br><strong>Matched:</strong> ${state.cachedDebtAddress}`;\r\n\r\n const mortgages = Array.isArray(state.cachedMortgages) ? state.cachedMortgages : [];\r\n if (mortgages.length) {\r\n tooltip += \"<hr>\" + mortgages.map((m) => {\r\n const rate = (m.interestRate != null && m.interestRate !== \"\") ? ` @ ${m.interestRate}%` : \"\";\r\n return `<strong>${m.position || \"Lien\"}:</strong> ${fmtUSD(m.amount)} ${m.loanType || \"\"}${rate} (${m.lenderName || \"?\"})`;\r\n }).join(\"<br>\");\r\n }\r\n\r\n const metric = equityElement.closest(\".metric\");\r\n if (metric) {\r\n const label = metric.querySelector(\".metric-label\");\r\n if (!hasTooltip(metric)) {\r\n attachTooltip(metric, tooltip);\r\n if (label) label.classList.add(\"has-tooltip\");\r\n } else {\r\n updateTooltipContent(metric, tooltip);\r\n }\r\n }\r\n }\r\n\r\n function syncUnitsFieldForType(propertyType, bedroomCount) {\r\n const label = document.querySelector(\".units-inline-label\");\r\n if (label) label.textContent = propertyType === \"assisted\" ? \"beds\" : \"units\";\r\n if (propertyType === \"assisted\" && bedroomCount != null) {\r\n const input = document.getElementById(\"ln-units-input\");\r\n if (input) input.value = String(bedroomCount);\r\n updateState({ numberOfUnits: bedroomCount });\r\n }\r\n }\r\n\r\n function updateLeadStatusTooltip(loiData) {\r\n const leadElement = document.getElementById(\"prop-lead-status\");\r\n if (!leadElement) return;\r\n let tooltipContent = \"No LOI data returned\";\r\n if (loiData) {\r\n tooltipContent = `\r\n <strong>Contact:</strong> ${loiData.contactName} <br>\r\n ${loiData.opportunityAddress}\r\n `;\r\n }\r\n const metric = leadElement.closest(\".metric\");\r\n if (metric) {\r\n if (!hasTooltip(metric)) {\r\n attachTooltip(metric, tooltipContent);\r\n const label = metric.querySelector(\".metric-label\");\r\n if (label) label.classList.add(\"has-tooltip\");\r\n } else {\r\n updateTooltipContent(metric, tooltipContent);\r\n }\r\n }\r\n }\r\n\r\n function updateDownHoverTooltip() {\r\n const downElement = document.getElementById(\"prop-down\");\r\n const priceElement = document.getElementById(\"prop-price\");\r\n const noiElement = document.getElementById(\"prop-noi\");\r\n if (!downElement || !priceElement || !noiElement) return;\r\n\r\n const financialData = parseFinancialData(priceElement.textContent, noiElement.textContent);\r\n if (financialData) {\r\n const tooltipContent = generateDownPaymentTooltipHTML(\r\n financialData.price,\r\n financialData.noi,\r\n state.currentDownPaymentPercent,\r\n state.currentDSCRPercent,\r\n state.currentSellerFiPercent\r\n );\r\n const metric = downElement.closest(\".metric\");\r\n if (metric) {\r\n if (!hasTooltip(metric)) attachTooltip(metric, tooltipContent);\r\n else updateTooltipContent(metric, tooltipContent);\r\n }\r\n }\r\n }\r\n\r\n function updateCashFlowHoverTooltip() {\r\n const cashFlowElement = document.getElementById(\"prop-cashflow\");\r\n const priceElement = document.getElementById(\"prop-price\");\r\n if (!cashFlowElement || !priceElement) return;\r\n\r\n const cashFlowData = parseCashFlowData(priceElement.textContent, cashFlowElement.textContent);\r\n if (cashFlowData) {\r\n const tooltipContent = generateCashFlowTooltipHTML(cashFlowData.price, cashFlowData.monthlyCashFlow);\r\n const metric = cashFlowElement.closest(\".metric\");\r\n if (metric) {\r\n const label = metric.querySelector(\".metric-label\");\r\n if (label) label.classList.add(\"has-tooltip\");\r\n if (!hasTooltip(metric)) attachTooltip(metric, tooltipContent);\r\n else updateTooltipContent(metric, tooltipContent);\r\n }\r\n }\r\n }\r\n\r\n // STR affordances: the Awning link and the click-to-edit NOI cell exist only in STR mode.\r\n // When a manual STR gross is active (cachedStrValue), mark the painted NOI with a trailing *\r\n // and a source tooltip — mirroring the cap-rate/equity \"*\" convention. Runs after the metric\r\n // paint so the * is appended to the freshly-set NOI text (and re-applied on every recalc).\r\n function updateStrAffordances() {\r\n const isStr = state.currentPropertyType === \"str\";\r\n\r\n const awningLink = document.getElementById(\"prop-noi-awning\");\r\n if (awningLink) awningLink.style.display = isStr ? \"inline-block\" : \"none\";\r\n\r\n const noiElement = document.getElementById(\"prop-noi\");\r\n if (!noiElement) return;\r\n noiElement.style.cursor = isStr ? \"pointer\" : \"\";\r\n if (noiElement.querySelector(\"input\")) return;\r\n\r\n const metric = noiElement.closest(\".metric\");\r\n const manual = isStr && state.cachedStrValue && Number.isFinite(state.cachedStrValue.value);\r\n\r\n if (manual) {\r\n noiElement.textContent = `${noiElement.textContent}*`;\r\n const gross = state.cachedStrValue.value;\r\n const pct = PROPERTY_TYPE_CONSTANTS.STR.NOI_PERCENTAGE;\r\n const tip = `<strong>Manual STR gross (Awning):</strong> ${formatCurrency(gross)}/yr`\r\n + `<br>NOI = gross &times; ${Math.round(pct * 100)}% = ${formatCurrency(gross * pct)}`\r\n + `<hr><em>Click NOI to edit; click the label to reset to the estimate.</em>`;\r\n if (metric) {\r\n if (!hasTooltip(metric)) {\r\n attachTooltip(metric, tip);\r\n metric.querySelector(\".metric-label\")?.classList.add(\"has-tooltip\");\r\n } else {\r\n updateTooltipContent(metric, tip);\r\n }\r\n }\r\n } else if (isStr && metric && hasTooltip(metric)) {\r\n updateTooltipContent(metric, \"<strong>STR NOI:</strong> 5.5%-of-price estimate.<hr><em>Click NOI to enter Awning's gross revenue.</em>\");\r\n }\r\n }\r\n\r\n function applyFinancials(financials) {\r\n let isCashFlowNegative = false;\r\n let insufficientEquity = false;\r\n if (financials) {\r\n const elements = {\r\n \"prop-noi\": financials.noi,\r\n \"prop-down\": financials.down,\r\n \"prop-net\": financials.netToBuyer,\r\n \"prop-seller-fi\": financials.sellerFi,\r\n \"prop-cocr-30\": financials.cocr30,\r\n \"prop-cocr-15\": financials.priceForCOCR15,\r\n \"prop-assignment\": financials.assignment,\r\n \"prop-dscr\": financials.dscr,\r\n \"prop-sf\": financials.sfPayment,\r\n \"prop-cashflow\": financials.cashFlow,\r\n };\r\n isCashFlowNegative = financials.rawCashFlow < 0;\r\n // Equity-aware: seller financing requires the seller to clear their existing debt at\r\n // close from the buyer's down payment, so a down payment that can't cover known debt is\r\n // an infeasible deal — same red treatment as negative cash flow (mirrors the dashboard).\r\n // Unknown debt (null) is assumed 100% equity and never flags.\r\n const debt = state.cachedDebtBalance;\r\n insufficientEquity = Number.isFinite(debt) && debt > financials.rawDown;\r\n for (const [id, value] of Object.entries(elements)) updateElement(id, value);\r\n setTimeout(() => {\r\n updateDownHoverTooltip();\r\n updateCashFlowHoverTooltip();\r\n }, 100);\r\n } else {\r\n FINANCIAL_ELEMENT_IDS.forEach((id) => updateElement(id, \"N/A\"));\r\n }\r\n\r\n updatePercentageLabels();\r\n updateStrAffordances();\r\n\r\n const isRed = isCashFlowNegative || insufficientEquity;\r\n const footer = document.getElementById(\"ln-footer\");\r\n if (footer) footer.classList.toggle(\"negative\", isRed);\r\n\r\n const reasons = [];\r\n if (isCashFlowNegative) reasons.push(\"CF\");\r\n if (insufficientEquity) reasons.push(\"IE\");\r\n const pill = document.getElementById(\"ln-red-reasons\");\r\n if (pill) {\r\n pill.textContent = reasons.join(\" · \");\r\n pill.style.display = reasons.length ? \"\" : \"none\";\r\n }\r\n }\r\n\r\n return {\r\n applyFinancials,\r\n getCurrentPrice,\r\n syncUnitsFieldForType,\r\n updateActiveCapDisplay,\r\n updateCapRateLabel,\r\n updateElement,\r\n updateEquityDisplay,\r\n updateLeadStatusTooltip,\r\n updatePercentageLabels,\r\n updatePriceLabel,\r\n };\r\n}\r\n"],"names":["FINANCIAL_ELEMENT_IDS","createRender","ctx","state","updateState","getCurrentPrice","originalPrice","currentPriceDiscount","discountedPrice","parseFloat","replace","Math","round","toLocaleString","updatePercentageLabels","downLabelElement","document","querySelector","closest","textContent","currentDownPaymentPercent","sellerFiLabelElement","currentSellerFiPercent","dscrLabelElement","currentDSCRPercent","updateElement","id","value","element","getElementById","applyFinancials","financials","isCashFlowNegative","insufficientEquity","elements","noi","down","netToBuyer","sellerFi","cocr30","priceForCOCR15","assignment","dscr","sfPayment","cashFlow","rawCashFlow","debt","cachedDebtBalance","Number","isFinite","rawDown","Object","entries","setTimeout","downElement","priceElement","noiElement","financialData","parseFinancialData","tooltipContent","generateDownPaymentTooltipHTML","price","metric","hasTooltip","updateTooltipContent","attachTooltip","updateDownHoverTooltip","cashFlowElement","cashFlowData","parseCashFlowData","generateCashFlowTooltipHTML","monthlyCashFlow","label","classList","add","updateCashFlowHoverTooltip","forEach","isStr","currentPropertyType","awningLink","style","display","cursor","cachedStrValue","gross","pct","PROPERTY_TYPE_CONSTANTS","STR","NOI_PERCENTAGE","tip","formatCurrency","updateStrAffordances","isRed","footer","toggle","reasons","push","pill","join","length","syncUnitsFieldForType","propertyType","bedroomCount","input","String","numberOfUnits","updateActiveCapDisplay","capElement","priceText","parsePriceNumber","computeActiveCapDisplay","baseNOI","reported","parseReportedCap","originalCapRate","isUsingEstimatedCapRate","updateCapRateLabel","capLabelElement","updateEquityDisplay","equityElement","estimated","equity","equityPercentFromDebt","fmtUSD","n","sourceLabel","equitySource","tooltip","cachedDebtAddress","mortgages","Array","isArray","cachedMortgages","map","m","rate","interestRate","position","amount","loanType","lenderName","updateLeadStatusTooltip","loiData","leadElement","contactName","opportunityAddress","updatePriceLabel","priceLabelElement"],"mappings":"+nBAgBA,MAAMA,EAAwB,CAC5B,WAAY,YAAa,WAAY,iBAAkB,eACvD,eAAgB,kBAAmB,YAAa,UAAW,iBAGtD,SAASC,cAAaC,IAAEA,IAC7B,MAAMC,MAAEA,EAAKC,YAAEA,GAAgBF,EAE/B,SAASG,kBACP,GAAIF,EAAMG,eAAiBH,EAAMI,qBAAuB,EAAG,CACzD,MACMC,EADeC,WAAWN,EAAMG,cAAcI,QAAQ,QAAS,MAC7B,EAAIP,EAAMI,qBAAuB,KACzE,MAAO,IAAII,KAAKC,MAAMJ,GAAiBK,kBACzC,CACA,OAAOV,EAAMG,aACf,CASA,SAASQ,yBACP,MAAMC,EAAmBC,SAASC,cAAc,eAAeC,QAAQ,YAAYD,cAAc,iBAC7FF,IAAkBA,EAAiBI,YAAc,SAAShB,EAAMiB,+BAEpE,MAAMC,EAAuBL,SAASC,cAAc,oBAAoBC,QAAQ,YAAYD,cAAc,iBACtGI,IAAsBA,EAAqBF,YAAc,cAAchB,EAAMmB,4BAEjF,MAAMC,EAAmBP,SAASC,cAAc,eAAeC,QAAQ,YAAYD,cAAc,iBAC7FM,IAAkBA,EAAiBJ,YAAc,SAAShB,EAAMqB,uBACtE,CAEA,SAASC,cAAcC,EAAIC,GACzB,MAAMC,EAAUZ,SAASa,eAAeH,GACpCE,IAASA,EAAQT,YAAcQ,EACrC,CAwPA,MAAO,CACLG,gBAlDF,SAAyBC,GACvB,IAAIC,GAAqB,EACrBC,GAAqB,EACzB,GAAIF,EAAY,CACd,MAAMG,EAAW,CACf,WAAYH,EAAWI,IACvB,YAAaJ,EAAWK,KACxB,WAAYL,EAAWM,WACvB,iBAAkBN,EAAWO,SAC7B,eAAgBP,EAAWQ,OAC3B,eAAgBR,EAAWS,eAC3B,kBAAmBT,EAAWU,WAC9B,YAAaV,EAAWW,KACxB,UAAWX,EAAWY,UACtB,gBAAiBZ,EAAWa,UAE9BZ,EAAqBD,EAAWc,YAAc,EAK9C,MAAMC,EAAO3C,EAAM4C,kBACnBd,EAAqBe,OAAOC,SAASH,IAASA,EAAOf,EAAWmB,QAChE,IAAK,MAAOxB,EAAIC,KAAUwB,OAAOC,QAAQlB,GAAWT,cAAcC,EAAIC,GACtE0B,WAAW,MAvGf,WACE,MAAMC,EAActC,SAASa,eAAe,aACtC0B,EAAevC,SAASa,eAAe,cACvC2B,EAAaxC,SAASa,eAAe,YAC3C,IAAKyB,IAAgBC,IAAiBC,EAAY,OAElD,MAAMC,EAAgBC,EAAmBH,EAAapC,YAAaqC,EAAWrC,aAC9E,GAAIsC,EAAe,CACjB,MAAME,EAAiBC,EACrBH,EAAcI,MACdJ,EAActB,IACdhC,EAAMiB,0BACNjB,EAAMqB,mBACNrB,EAAMmB,wBAEFwC,EAASR,EAAYpC,QAAQ,WAC/B4C,IACGC,EAAWD,GACXE,EAAqBF,EAAQH,GADTM,EAAcH,EAAQH,GAGnD,CACF,CAmFMO,GAjFN,WACE,MAAMC,EAAkBnD,SAASa,eAAe,iBAC1C0B,EAAevC,SAASa,eAAe,cAC7C,IAAKsC,IAAoBZ,EAAc,OAEvC,MAAMa,EAAeC,EAAkBd,EAAapC,YAAagD,EAAgBhD,aACjF,GAAIiD,EAAc,CAChB,MAAMT,EAAiBW,EAA4BF,EAAaP,MAAOO,EAAaG,iBAC9ET,EAASK,EAAgBjD,QAAQ,WACvC,GAAI4C,EAAQ,CACV,MAAMU,EAAQV,EAAO7C,cAAc,iBAC/BuD,GAAOA,EAAMC,UAAUC,IAAI,eAC1BX,EAAWD,GACXE,EAAqBF,EAAQH,GADTM,EAAcH,EAAQH,EAEjD,CACF,CACF,CAkEMgB,IACC,IACL,MACE3E,EAAsB4E,QAASlD,GAAOD,cAAcC,EAAI,QAG1DZ,yBAlEF,WACE,MAAM+D,EAAsC,QAA9B1E,EAAM2E,oBAEdC,EAAa/D,SAASa,eAAe,mBACvCkD,IAAYA,EAAWC,MAAMC,QAAUJ,EAAQ,eAAiB,QAEpE,MAAMrB,EAAaxC,SAASa,eAAe,YAC3C,IAAK2B,EAAY,OAEjB,GADAA,EAAWwB,MAAME,OAASL,EAAQ,UAAY,GAC1CrB,EAAWvC,cAAc,SAAU,OAEvC,MAAM6C,EAASN,EAAWtC,QAAQ,WAGlC,GAFe2D,GAAS1E,EAAMgF,gBAAkBnC,OAAOC,SAAS9C,EAAMgF,eAAexD,OAEzE,CACV6B,EAAWrC,YAAc,GAAGqC,EAAWrC,eACvC,MAAMiE,EAAQjF,EAAMgF,eAAexD,MAC7B0D,EAAMC,EAAwBC,IAAIC,eAClCC,EAAM,+CAA+CC,EAAeN,gCAC3CzE,KAAKC,MAAY,IAANyE,SAAiBK,EAAeN,EAAQC,8EAE9EvB,IACGC,EAAWD,GAIdE,EAAqBF,EAAQ2B,IAH7BxB,EAAcH,EAAQ2B,GACtB3B,EAAO7C,cAAc,kBAAkBwD,UAAUC,IAAI,gBAK3D,MAAWG,GAASf,GAAUC,EAAWD,IACvCE,EAAqBF,EAAQ,2GAEjC,CAmCE6B,GAEA,MAAMC,EAAQ5D,GAAsBC,EAC9B4D,EAAS7E,SAASa,eAAe,aACnCgE,GAAQA,EAAOpB,UAAUqB,OAAO,WAAYF,GAEhD,MAAMG,EAAU,GACZ/D,GAAoB+D,EAAQC,KAAK,MACjC/D,GAAoB8D,EAAQC,KAAK,MACrC,MAAMC,EAAOjF,SAASa,eAAe,kBACjCoE,IACFA,EAAK9E,YAAc4E,EAAQG,KAAK,OAChCD,EAAKjB,MAAMC,QAAUc,EAAQI,OAAS,GAAK,OAE/C,EAIE9F,gCACA+F,sBAnKF,SAA+BC,EAAcC,GAC3C,MAAM9B,EAAQxD,SAASC,cAAc,uBAErC,GADIuD,IAAOA,EAAMrD,YAA+B,aAAjBkF,EAA8B,OAAS,SACjD,aAAjBA,GAA+C,MAAhBC,EAAsB,CACvD,MAAMC,EAAQvF,SAASa,eAAe,kBAClC0E,IAAOA,EAAM5E,MAAQ6E,OAAOF,IAChClG,EAAY,CAAEqG,cAAeH,GAC/B,CACF,EA4JEI,uBA1OF,WACE,MAAMC,EAAa3F,SAASa,eAAe,YAC3C,IAAK8E,EAAY,OAEjB,GAAIA,EAAW1F,cAAc,SAAU,OAEvC,MAAM2F,EAAYvG,mBAAqBW,SAASa,eAAe,eAAeV,aAAe,GACvF0C,EAAQgD,EAAiBD,GAC/BD,EAAWxF,YAAc2F,EAAwB3G,EAAM4G,QAASlD,GAEhE,MAAMmD,EAAWC,EAAiB9G,EAAM+G,gBAAiB/G,EAAMgH,yBAGzDxD,EAAiB,GAFF,wCAAmD,MAAZqD,EAAmB,GAAGA,KAAc,mFAI1FlD,EAAS6C,EAAWzF,QAAQ,WAClC,GAAI4C,EAAQ,CACV,MAAMU,EAAQV,EAAO7C,cAAc,iBAC9B8C,EAAWD,GAIdE,EAAqBF,EAAQH,IAH7BM,EAAcH,EAAQH,GAClBa,GAAOA,EAAMC,UAAUC,IAAI,eAInC,CACF,EAkNE0C,mBA3PF,WACE,MAAMC,EAAkBrG,SAASC,cAAc,cAAcC,QAAQ,YAAYD,cAAc,iBAC/F,GAAKoG,EACL,OAAQlH,EAAM2E,qBACZ,IAAK,MAAOuC,EAAgBlG,YAAc,iBAAkB,MAC5D,IAAK,WAAYkG,EAAgBlG,YAAc,sBAAuB,MACtE,QAASkG,EAAgBlG,YAAc,WAE3C,EAoPEM,4BACA6F,oBA/MF,WACE,MAAMC,EAAgBvG,SAASa,eAAe,eAC9C,IAAK0F,EAAe,OAEpB,MAAMX,EAAYvG,mBAAqBW,SAASa,eAAe,eAAeV,aAAe,GACvF0C,EAAQgD,EAAiBD,GACzB9D,EAAO3C,EAAM4C,kBACbyE,EAAY1E,QACZ2E,EAASC,EAAsB7D,EAAOf,GAC5CyE,EAAcpG,YAAc,GAAGR,KAAKC,MAAe,IAAT6G,MAAiBD,EAAY,IAAM,KAE7E,MAAMG,OAAUC,GAAO5E,OAAOC,SAASD,OAAO4E,IAAM,IAAIjH,KAAKC,MAAMoC,OAAO4E,IAAI/G,mBAAqB,MAC7FgH,EAAcL,EAChB,iDACwB,YAAvBrH,EAAM2H,aAA6B,uBAAyB3H,EAAM2H,aAEvE,IAAIC,EAAU,gCAAgCP,EAAY,UAAYG,OAAO7E,KAC7EiF,GAAW,gCAAgCF,IACvC1H,EAAM6H,oBAAmBD,GAAW,iCAAiC5H,EAAM6H,qBAE/E,MAAMC,EAAYC,MAAMC,QAAQhI,EAAMiI,iBAAmBjI,EAAMiI,gBAAkB,GAC7EH,EAAU9B,SACZ4B,GAAW,OAASE,EAAUI,IAAKC,IACjC,MAAMC,EAA0B,MAAlBD,EAAEE,cAA2C,KAAnBF,EAAEE,aAAuB,MAAMF,EAAEE,gBAAkB,GAC3F,MAAO,WAAWF,EAAEG,UAAY,oBAAoBd,OAAOW,EAAEI,WAAWJ,EAAEK,UAAY,KAAKJ,MAASD,EAAEM,YAAc,SACnH1C,KAAK,SAGV,MAAMpC,EAASyD,EAAcrG,QAAQ,WACrC,GAAI4C,EAAQ,CACV,MAAMU,EAAQV,EAAO7C,cAAc,iBAC9B8C,EAAWD,GAIdE,EAAqBF,EAAQiE,IAH7B9D,EAAcH,EAAQiE,GAClBvD,GAAOA,EAAMC,UAAUC,IAAI,eAInC,CACF,EA0KEmE,wBA9JF,SAAiCC,GAC/B,MAAMC,EAAc/H,SAASa,eAAe,oBAC5C,IAAKkH,EAAa,OAClB,IAAIpF,EAAiB,uBACjBmF,IACFnF,EAAiB,qCACWmF,EAAQE,4BAClCF,EAAQG,4BAGZ,MAAMnF,EAASiF,EAAY7H,QAAQ,WACnC,GAAI4C,EACF,GAAKC,EAAWD,GAKdE,EAAqBF,EAAQH,OALN,CACvBM,EAAcH,EAAQH,GACtB,MAAMa,EAAQV,EAAO7C,cAAc,iBAC/BuD,GAAOA,EAAMC,UAAUC,IAAI,cACjC,CAIJ,EA2IE5D,8CACAoI,iBAvRF,WACE,MAAMC,EAAoBnI,SAASC,cAAc,gBAAgBC,QAAQ,YAAYD,cAAc,iBAC/FkI,IACFA,EAAkBhI,YAAchB,EAAMI,qBAAuB,EAAI,UAAUJ,EAAMI,yBAA2B,QAEhH,EAoRF"}
@@ -0,0 +1,2 @@
1
+ import{INTEREST_RATE_TIERS as e,DEFAULT_CAP_RATE as r,determineInterestRateType as n,SELLER_FI_AMORTIZATION as t,SELLER_FI_INTEREST_RATE as c}from"../config/financial.js";import{resolveListingFinancials as o,calculateAssignmentFee as i,calculatePMT as l}from"./calculations.js";const s=[{downPercent:60,dscrPercent:70,sellerPercent:40},{downPercent:50,dscrPercent:60,sellerPercent:50},{downPercent:40,dscrPercent:50,sellerPercent:60},{downPercent:30,dscrPercent:40,sellerPercent:70},{downPercent:20,dscrPercent:30,sellerPercent:80}],u=s[s.length-1];function tierAnnualDebtService(e,r,n,o){return 12*l(r*(e.dscrPercent/100),n,o)+12*l(r*(e.sellerPercent/100),c,t)}function sweepTiers(e,r,n,t,c){for(const o of s){const i=e-tierAnnualDebtService(o,r,n,t),l=o.downPercent/100*r;if(i>0&&l>=c)return{cashFlow:i,tier:o}}return null}function calculateEquityCarryScore({bedroomCount:t=null,capRate:c,price:l,propertyType:s="mfr",units:a=null}={}){const d=Number(l);if(!Number.isFinite(d)||d<=0)return null;const p=null==c||""===c?NaN:Number(c),m=Number.isFinite(p)?p/100:null,P=Number(a),f=n(s,Number.isFinite(P)?P:void 0),{amortization:w,rate:b}=e[f],{noi:y}=o({bedroomCount:t,estimatedCapRate:r,price:d,propertyType:s,reportedCapRate:m});let N,_,h=sweepTiers(y,d,b,w,0);if(h)N="prospect",_=d;else{const e=.85*d;h=sweepTiers(y,e,b,w,0),h?(N="discount",_=e):(N="dead",_=d)}const C=y-tierAnnualDebtService(u,_,b,w),F=_>0?C/_:0;let S;return S="dead"===N?"none":F>.08?"high":F>=.04?"medium":"low",{assignment:i(_),cap_source:null===m?"estimate":"reported",cash_flow:h?h.cashFlow:null,deal_pool:N,downpayment_percent:h?h.tier.downPercent:-1,equity_tier:h?{...h.tier}:null,offer_price:_,raw_yield:F,yield_band:S}}export{s as EQUITY_CARRY_TIERS,calculateEquityCarryScore};
2
+ //# sourceMappingURL=equity-carry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"equity-carry.js","sources":["../../src/financial/equity-carry.js"],"sourcesContent":["// src/financial/equity-carry.js\n\nimport { DEFAULT_CAP_RATE, INTEREST_RATE_TIERS, SELLER_FI_AMORTIZATION, SELLER_FI_INTEREST_RATE, determineInterestRateType } from \"../config/financial.js\";\nimport { calculateAssignmentFee, calculatePMT, resolveListingFinancials } from \"./calculations.js\";\n\n/**\n * Equity Carry financing tiers (constant 110% leverage: DSCR % + seller carry % = 110).\n * Ordered highest down-payment first so a sweep stops at the strongest qualifying tier:\n * cash flow falls as the down payment rises (more of the stack moves to the market-rate\n * DSCR leg and off the 0% seller note), so the first tier that clears in a 60->20 sweep is\n * the highest down payment that still cash-flows.\n */\nexport const EQUITY_CARRY_TIERS = [\n { downPercent: 60, dscrPercent: 70, sellerPercent: 40 },\n { downPercent: 50, dscrPercent: 60, sellerPercent: 50 },\n { downPercent: 40, dscrPercent: 50, sellerPercent: 60 },\n { downPercent: 30, dscrPercent: 40, sellerPercent: 70 },\n { downPercent: 20, dscrPercent: 30, sellerPercent: 80 },\n];\n\nconst DISCOUNT_BASIS = 0.85;\nconst FIXED_YIELD_TIER = EQUITY_CARRY_TIERS[EQUITY_CARRY_TIERS.length - 1]; // 20% down\nconst YIELD_BAND_HIGH = 0.08;\nconst YIELD_BAND_MEDIUM = 0.04;\n\n/**\n * Annual debt service for one tier at a given price basis, from the suite primitives.\n * DSCR leg amortizes at the property's market rate/term; the seller carry leg is 0% / 30yr.\n * @param {{dscrPercent:number, sellerPercent:number}} tier\n * @param {number} basis - Price the financing is sized against (asking or discounted)\n * @param {number} dscrRate - DSCR annual interest rate (decimal)\n * @param {number} dscrAmortization - DSCR amortization (years)\n * @returns {number} Combined annual debt service (DSCR + seller carry)\n */\nfunction tierAnnualDebtService(tier, basis, dscrRate, dscrAmortization) {\n const dscrAnnual = calculatePMT(basis * (tier.dscrPercent / 100), dscrRate, dscrAmortization) * 12;\n const sellerAnnual = calculatePMT(basis * (tier.sellerPercent / 100), SELLER_FI_INTEREST_RATE, SELLER_FI_AMORTIZATION) * 12;\n return dscrAnnual + sellerAnnual;\n}\n\n/**\n * Sweep the tiers (60 -> 20) at one price basis; return the first (highest down payment)\n * tier whose annual cash flow is positive AND whose down payment covers any known debt.\n * @returns {{tier:object, cashFlow:number}|null} Winning tier + its annual cash flow, or null\n */\nfunction sweepTiers(noi, basis, dscrRate, dscrAmortization, estimatedDebt) {\n for (const tier of EQUITY_CARRY_TIERS) {\n const cashFlow = noi - tierAnnualDebtService(tier, basis, dscrRate, dscrAmortization);\n const downPayment = (tier.downPercent / 100) * basis;\n if (cashFlow > 0 && downPayment >= estimatedDebt) {\n return { cashFlow, tier };\n }\n }\n return null;\n}\n\n/**\n * Score a scraped listing with the Equity Carry Method and route it to a deal pool.\n *\n * Pure, no IO. Accepts inputs in the raw shape Postgres returns: `price` and `capRate` may\n * be NUMERIC strings, and `capRate` is a PERCENT (e.g. \"6.5\", not 0.065) — both are coerced\n * here, so the trap where a clean-decimal fixture passes while production data fails silently\n * cannot occur. A null/non-finite reported cap falls back to DEFAULT_CAP_RATE for multifamily.\n *\n * At score time the property is assumed 100% equity (estimatedDebt = 0), so debt coverage\n * always passes and the routing is driven purely by cash flow; the real debt figure is tested\n * later by the debt drain, which demotes uncovered prospect/discount rows to `shadow`.\n *\n * Run A (routing): sweep 60->20 at the asking price; the first qualifying tier => prospect.\n * If none qualify, re-sweep at 85% of asking; the first qualifying tier => discount. If none\n * qualify in either pass => dead.\n *\n * Run B (comparable yield): independent of Run A, the cash flow at a fixed 20%-down stack on\n * the chosen offer price, divided by that offer price. Banded high/medium/low; dead rows are\n * forced to band `none` (the raw yield is still returned).\n *\n * @param {Object} input\n * @param {number|null} [input.bedroomCount] - Bedroom count (assisted living NOI only)\n * @param {number|string|null} input.capRate - Reported cap rate as a PERCENT; null => estimate\n * @param {number|string} input.price - Asking price (positive); null/0 returns null\n * @param {string} [input.propertyType] - DB property type (mfr/str/assisted/other)\n * @param {number|string|null} [input.units] - Unit count (selects residential vs commercial DSCR tier)\n * @returns {{assignment:number, cap_source:string, cash_flow:(number|null), deal_pool:string, downpayment_percent:number, equity_tier:(object|null), offer_price:number, raw_yield:number, yield_band:string}|null}\n * `cap_source` is \"reported\" when a usable cap was supplied, \"estimate\" when the row was\n * scored on DEFAULT_CAP_RATE because none was — the UI flags \"estimate\" rows as\n * needs-confirmation and offers a manual cap-rate input that re-scores the row. Most\n * meaningful for cap-driven types (mfr/other); STR/assisted derive NOI from other models.\n * Null when price is not a positive number (caller should skip + log; the SQL gate excludes these).\n */\nexport function calculateEquityCarryScore({\n bedroomCount = null,\n capRate,\n price,\n propertyType = \"mfr\",\n units = null,\n} = {}) {\n const priceNum = Number(price);\n if (!Number.isFinite(priceNum) || priceNum <= 0) return null;\n\n const capPercent = capRate === null || capRate === undefined || capRate === \"\" ? NaN : Number(capRate);\n const reportedCapRate = Number.isFinite(capPercent) ? capPercent / 100 : null;\n\n const unitsNum = Number(units);\n const rateType = determineInterestRateType(propertyType, Number.isFinite(unitsNum) ? unitsNum : undefined);\n const { amortization: dscrAmortization, rate: dscrRate } = INTEREST_RATE_TIERS[rateType];\n\n const { noi } = resolveListingFinancials({\n bedroomCount,\n estimatedCapRate: DEFAULT_CAP_RATE,\n price: priceNum,\n propertyType,\n reportedCapRate,\n });\n\n const estimatedDebt = 0;\n\n let dealPool;\n let offerPrice;\n let winner = sweepTiers(noi, priceNum, dscrRate, dscrAmortization, estimatedDebt);\n if (winner) {\n dealPool = \"prospect\";\n offerPrice = priceNum;\n } else {\n const discountBasis = priceNum * DISCOUNT_BASIS;\n winner = sweepTiers(noi, discountBasis, dscrRate, dscrAmortization, estimatedDebt);\n if (winner) {\n dealPool = \"discount\";\n offerPrice = discountBasis;\n } else {\n dealPool = \"dead\";\n offerPrice = priceNum;\n }\n }\n\n const cashFlow20 = noi - tierAnnualDebtService(FIXED_YIELD_TIER, offerPrice, dscrRate, dscrAmortization);\n const rawYield = offerPrice > 0 ? cashFlow20 / offerPrice : 0;\n\n let yieldBand;\n if (dealPool === \"dead\") {\n yieldBand = \"none\";\n } else if (rawYield > YIELD_BAND_HIGH) {\n yieldBand = \"high\";\n } else if (rawYield >= YIELD_BAND_MEDIUM) {\n yieldBand = \"medium\";\n } else {\n yieldBand = \"low\";\n }\n\n return {\n assignment: calculateAssignmentFee(offerPrice),\n cap_source: reportedCapRate === null ? \"estimate\" : \"reported\",\n cash_flow: winner ? winner.cashFlow : null,\n deal_pool: dealPool,\n downpayment_percent: winner ? winner.tier.downPercent : -1,\n equity_tier: winner ? { ...winner.tier } : null,\n offer_price: offerPrice,\n raw_yield: rawYield,\n yield_band: yieldBand,\n };\n}\n"],"names":["EQUITY_CARRY_TIERS","downPercent","dscrPercent","sellerPercent","FIXED_YIELD_TIER","length","tierAnnualDebtService","tier","basis","dscrRate","dscrAmortization","calculatePMT","SELLER_FI_INTEREST_RATE","SELLER_FI_AMORTIZATION","sweepTiers","noi","estimatedDebt","cashFlow","downPayment","calculateEquityCarryScore","bedroomCount","capRate","price","propertyType","units","priceNum","Number","isFinite","capPercent","NaN","reportedCapRate","unitsNum","rateType","determineInterestRateType","undefined","amortization","rate","INTEREST_RATE_TIERS","resolveListingFinancials","estimatedCapRate","DEFAULT_CAP_RATE","dealPool","offerPrice","winner","discountBasis","cashFlow20","rawYield","yieldBand","assignment","calculateAssignmentFee","cap_source","cash_flow","deal_pool","downpayment_percent","equity_tier","offer_price","raw_yield","yield_band"],"mappings":"sRAYY,MAACA,EAAqB,CAChC,CAAEC,YAAa,GAAIC,YAAa,GAAIC,cAAe,IACnD,CAAEF,YAAa,GAAIC,YAAa,GAAIC,cAAe,IACnD,CAAEF,YAAa,GAAIC,YAAa,GAAIC,cAAe,IACnD,CAAEF,YAAa,GAAIC,YAAa,GAAIC,cAAe,IACnD,CAAEF,YAAa,GAAIC,YAAa,GAAIC,cAAe,KAI/CC,EAAmBJ,EAAmBA,EAAmBK,OAAS,GAaxE,SAASC,sBAAsBC,EAAMC,EAAOC,EAAUC,GAGpD,OAFgG,GAA7EC,EAAaH,GAASD,EAAKL,YAAc,KAAMO,EAAUC,GAC6C,GAApGC,EAAaH,GAASD,EAAKJ,cAAgB,KAAMS,EAAyBC,EAEjG,CAOA,SAASC,WAAWC,EAAKP,EAAOC,EAAUC,EAAkBM,GAC1D,IAAK,MAAMT,KAAQP,EAAoB,CACrC,MAAMiB,EAAWF,EAAMT,sBAAsBC,EAAMC,EAAOC,EAAUC,GAC9DQ,EAAeX,EAAKN,YAAc,IAAOO,EAC/C,GAAIS,EAAW,GAAKC,GAAeF,EACjC,MAAO,CAAEC,WAAUV,OAEvB,CACA,OAAO,IACT,CAmCO,SAASY,2BAA0BC,aACxCA,EAAe,KAAIC,QACnBA,EAAOC,MACPA,EAAKC,aACLA,EAAe,MAAKC,MACpBA,EAAQ,MACN,IACF,MAAMC,EAAWC,OAAOJ,GACxB,IAAKI,OAAOC,SAASF,IAAaA,GAAY,EAAG,OAAO,KAExD,MAAMG,EAAaP,SAAyD,KAAZA,EAAiBQ,IAAMH,OAAOL,GACxFS,EAAkBJ,OAAOC,SAASC,GAAcA,EAAa,IAAM,KAEnEG,EAAWL,OAAOF,GAClBQ,EAAWC,EAA0BV,EAAcG,OAAOC,SAASI,GAAYA,OAAWG,IACxFC,aAAczB,EAAkB0B,KAAM3B,GAAa4B,EAAoBL,IAEzEjB,IAAEA,GAAQuB,EAAyB,CACvClB,eACAmB,iBAAkBC,EAClBlB,MAAOG,EACPF,eACAO,oBAKF,IAAIW,EACAC,EACAC,EAAS7B,WAAWC,EAAKU,EAAUhB,EAAUC,EAJ3B,GAKtB,GAAIiC,EACFF,EAAW,WACXC,EAAajB,MACR,CACL,MAAMmB,EAvGa,IAuGGnB,EACtBkB,EAAS7B,WAAWC,EAAK6B,EAAenC,EAAUC,EAV9B,GAWhBiC,GACFF,EAAW,WACXC,EAAaE,IAEbH,EAAW,OACXC,EAAajB,EAEjB,CAEA,MAAMoB,EAAa9B,EAAMT,sBAAsBF,EAAkBsC,EAAYjC,EAAUC,GACjFoC,EAAWJ,EAAa,EAAIG,EAAaH,EAAa,EAE5D,IAAIK,EAWJ,OATEA,EADe,SAAbN,EACU,OACHK,EAtHW,IAuHR,OACHA,GAvHa,IAwHV,SAEA,MAGP,CACLE,WAAYC,EAAuBP,GACnCQ,WAAgC,OAApBpB,EAA2B,WAAa,WACpDqB,UAAWR,EAASA,EAAO1B,SAAW,KACtCmC,UAAWX,EACXY,oBAAqBV,EAASA,EAAOpC,KAAKN,aAAc,EACxDqD,YAAaX,EAAS,IAAKA,EAAOpC,MAAS,KAC3CgD,YAAab,EACbc,UAAWV,EACXW,WAAYV,EAEhB"}
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export{calculateAppreciatedValue,calculateAssignmentFee,calculateBalloonBalance,calculateCOCR30,calculateCOCRAtPercent,calculateCashFlow,calculateCashFlowYield,calculateCashOutAfterRefi,calculateDiscountFromPrice,calculateNOIByType,calculateNetToBuyer,calculatePMT,calculatePriceForCOCR,calculatePriceFromDiscount,calculateSTRNOI,equityPercentFromDebt,resolveListingFinancials,safePercentage}from"./financial/calculations.js";export{fetchDebt}from"./services/debt.js";export{formatCurrency,formatPercentage,formatPriceValue}from"./financial/formatters.js";export{calculateOriginalPrice,convertCapRateToDecimal,createExportObjectCore,formatDownPaymentPercent,mapPropertyType}from"./export/export-logic.js";export{calculateDOM,formatDate}from"./date/utilities.js";export{calculateCursorPosition,extractNumericValue,filterNumericInput,formatInputDisplay,formatLiveInput,formatLiveNumber,parseNumericInput}from"./formatting/financial-formatting.js";export{normalizeWhitespace}from"./formatting/text.js";export{CALCULATION_TOLERANCE,DEFAULT_CAP_RATE,DEFAULT_DOWN_PAYMENT,DEFAULT_DSCR_PERCENTAGE,DEFAULT_EQUITY_ESTIMATE,DEFAULT_INTEREST_RATE_TYPE,FINANCIAL_CONSTANTS,INTEREST_RATE_TIERS,MAX_ITERATIONS,SELLER_FI_AMORTIZATION,SELLER_FI_CARRY,SELLER_FI_DOWN_PAYMENT,SELLER_FI_INTEREST_RATE,determineInterestRateType}from"./config/financial.js";export{ASSISTED_LIVING,MULTIFAMILY,PROPERTY_TYPES,PROPERTY_TYPE_CONSTANTS,STR}from"./config/property-types.js";export{ASSIGNMENT_FEE_PERCENTAGE,BUSINESS_CONSTANTS,BUYER_AGENT_COMMISSION,CLOSING_COSTS_PERCENTAGE,CONSERVATIVE_COCR15_PRICE_MULTIPLIER,HARD_MONEY_RATE,MAX_COCR15_PRICE_MULTIPLIER,MINIMUM_COCR15_PRICE,NET_TO_BUYER_PERCENTAGE,REHAB_RATE,SELLER_AGENT_COMMISSION}from"./config/business.js";export{lookupLOI}from"./services/loi-lookup.js";export{LOI_LOOKUP_CONFIG,LOI_SENT_STATUS,MATCH_TYPES}from"./config/loi-lookup.js";export{getEnvVar,isBrowserEnvironment,isNodeEnvironment}from"./environment/utilities.js";const e="./dist/styles/base.css";export{e as STYLES_PATH};
1
+ export{calculateAppreciatedValue,calculateAssignmentFee,calculateBalloonBalance,calculateCOCR30,calculateCOCRAtPercent,calculateCashFlow,calculateCashFlowYield,calculateCashOutAfterRefi,calculateDiscountFromPrice,calculateNOIByType,calculateNetToBuyer,calculatePMT,calculatePriceForCOCR,calculatePriceFromDiscount,calculateSTRNOI,equityPercentFromDebt,resolveListingFinancials,safePercentage}from"./financial/calculations.js";export{EQUITY_CARRY_TIERS,calculateEquityCarryScore}from"./financial/equity-carry.js";export{fetchDebt}from"./services/debt.js";export{formatCurrency,formatPercentage,formatPriceValue}from"./financial/formatters.js";export{calculateOriginalPrice,convertCapRateToDecimal,createExportObjectCore,formatDownPaymentPercent,mapPropertyType}from"./export/export-logic.js";export{calculateDOM,formatDate}from"./date/utilities.js";export{calculateCursorPosition,extractNumericValue,filterNumericInput,formatInputDisplay,formatLiveInput,formatLiveNumber,parseNumericInput}from"./formatting/financial-formatting.js";export{normalizeWhitespace}from"./formatting/text.js";export{CALCULATION_TOLERANCE,DEFAULT_CAP_RATE,DEFAULT_DOWN_PAYMENT,DEFAULT_DSCR_PERCENTAGE,DEFAULT_EQUITY_ESTIMATE,DEFAULT_INTEREST_RATE_TYPE,FINANCIAL_CONSTANTS,INTEREST_RATE_TIERS,MAX_ITERATIONS,SELLER_FI_AMORTIZATION,SELLER_FI_CARRY,SELLER_FI_DOWN_PAYMENT,SELLER_FI_INTEREST_RATE,determineInterestRateType}from"./config/financial.js";export{ASSISTED_LIVING,MULTIFAMILY,PROPERTY_TYPES,PROPERTY_TYPE_CONSTANTS,STR}from"./config/property-types.js";export{ASSIGNMENT_FEE_PERCENTAGE,BUSINESS_CONSTANTS,BUYER_AGENT_COMMISSION,CLOSING_COSTS_PERCENTAGE,CONSERVATIVE_COCR15_PRICE_MULTIPLIER,HARD_MONEY_RATE,MAX_COCR15_PRICE_MULTIPLIER,MINIMUM_COCR15_PRICE,NET_TO_BUYER_PERCENTAGE,REHAB_RATE,SELLER_AGENT_COMMISSION}from"./config/business.js";export{lookupLOI}from"./services/loi-lookup.js";export{LOI_LOOKUP_CONFIG,LOI_SENT_STATUS,MATCH_TYPES}from"./config/loi-lookup.js";export{getEnvVar,isBrowserEnvironment,isNodeEnvironment}from"./environment/utilities.js";const e="./dist/styles/base.css";export{e as STYLES_PATH};
2
2
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":["../src/index.js"],"sourcesContent":["/**\r\n * @archerjessop/utilities\r\n * Shared utilities for ArcherJessop property analysis tools\r\n */\r\n\r\n// Financial calculations\r\nexport { \r\n calculateAppreciatedValue,\r\n calculateAssignmentFee,\r\n calculateBalloonBalance,\r\n calculateCashFlow,\r\n calculateCashFlowYield,\r\n calculateCashOutAfterRefi,\r\n calculateCOCR30, \r\n calculateCOCRAtPercent,\r\n calculateDiscountFromPrice,\r\n calculateNetToBuyer,\r\n calculateNOIByType,\r\n calculatePMT,\r\n calculatePriceForCOCR,\r\n calculatePriceFromDiscount,\r\n calculateSTRNOI,\r\n equityPercentFromDebt,\r\n resolveListingFinancials,\r\n safePercentage,\r\n} from \"./financial/calculations.js\";\r\n\r\n// Agnostic debt service (pure IO; Node + browser)\r\nexport { fetchDebt } from \"./services/debt.js\";\r\n\r\n// Financial formatters\r\nexport { formatCurrency, formatPriceValue, formatPercentage } from \"./financial/formatters.js\";\r\n\r\n// Export logic (pure export-object creation)\r\nexport {\r\n calculateOriginalPrice,\r\n convertCapRateToDecimal,\r\n createExportObjectCore,\r\n formatDownPaymentPercent,\r\n mapPropertyType,\r\n} from \"./export/export-logic.js\";\r\n\r\n// Date utilities\r\nexport { calculateDOM, formatDate } from \"./date/utilities.js\";\r\n\r\n// Formatting utilities\r\nexport { \r\n calculateCursorPosition,\r\n extractNumericValue,\r\n filterNumericInput,\r\n formatInputDisplay,\r\n formatLiveInput,\r\n formatLiveNumber,\r\n parseNumericInput\r\n} from \"./formatting/financial-formatting.js\";\r\n\r\n// Text formatting utilities\r\nexport { normalizeWhitespace } from \"./formatting/text.js\";\r\n\r\n// Configuration constants\r\nexport * from \"./config/financial.js\";\r\nexport * from \"./config/property-types.js\";\r\nexport * from \"./config/business.js\";\r\n\r\nexport const STYLES_PATH = \"./dist/styles/base.css\";\r\n\r\n// LOI Lookup service and config\r\nexport { lookupLOI } from \"./services/loi-lookup.js\";\r\nexport { LOI_LOOKUP_CONFIG, MATCH_TYPES, LOI_SENT_STATUS } from \"./config/loi-lookup.js\";\r\n\r\n// Environment utilities\r\nexport { \r\n getEnvVar, \r\n isNodeEnvironment, \r\n isBrowserEnvironment \r\n} from \"./environment/utilities.js\";"],"names":["STYLES_PATH"],"mappings":"k6DAgEY,MAACA,EAAc"}
1
+ {"version":3,"file":"index.js","sources":["../src/index.js"],"sourcesContent":["/**\r\n * @archerjessop/utilities\r\n * Shared utilities for ArcherJessop property analysis tools\r\n */\r\n\r\n// Financial calculations\r\nexport { \r\n calculateAppreciatedValue,\r\n calculateAssignmentFee,\r\n calculateBalloonBalance,\r\n calculateCashFlow,\r\n calculateCashFlowYield,\r\n calculateCashOutAfterRefi,\r\n calculateCOCR30, \r\n calculateCOCRAtPercent,\r\n calculateDiscountFromPrice,\r\n calculateNetToBuyer,\r\n calculateNOIByType,\r\n calculatePMT,\r\n calculatePriceForCOCR,\r\n calculatePriceFromDiscount,\r\n calculateSTRNOI,\r\n equityPercentFromDebt,\r\n resolveListingFinancials,\r\n safePercentage,\r\n} from \"./financial/calculations.js\";\r\n\r\n// Equity Carry scoring engine (pure; scores scraped listings into deal pools)\r\nexport { EQUITY_CARRY_TIERS, calculateEquityCarryScore } from \"./financial/equity-carry.js\";\r\n\r\n// Agnostic debt service (pure IO; Node + browser)\r\nexport { fetchDebt } from \"./services/debt.js\";\r\n\r\n// Financial formatters\r\nexport { formatCurrency, formatPriceValue, formatPercentage } from \"./financial/formatters.js\";\r\n\r\n// Export logic (pure export-object creation)\r\nexport {\r\n calculateOriginalPrice,\r\n convertCapRateToDecimal,\r\n createExportObjectCore,\r\n formatDownPaymentPercent,\r\n mapPropertyType,\r\n} from \"./export/export-logic.js\";\r\n\r\n// Date utilities\r\nexport { calculateDOM, formatDate } from \"./date/utilities.js\";\r\n\r\n// Formatting utilities\r\nexport { \r\n calculateCursorPosition,\r\n extractNumericValue,\r\n filterNumericInput,\r\n formatInputDisplay,\r\n formatLiveInput,\r\n formatLiveNumber,\r\n parseNumericInput\r\n} from \"./formatting/financial-formatting.js\";\r\n\r\n// Text formatting utilities\r\nexport { normalizeWhitespace } from \"./formatting/text.js\";\r\n\r\n// Configuration constants\r\nexport * from \"./config/financial.js\";\r\nexport * from \"./config/property-types.js\";\r\nexport * from \"./config/business.js\";\r\n\r\nexport const STYLES_PATH = \"./dist/styles/base.css\";\r\n\r\n// LOI Lookup service and config\r\nexport { lookupLOI } from \"./services/loi-lookup.js\";\r\nexport { LOI_LOOKUP_CONFIG, MATCH_TYPES, LOI_SENT_STATUS } from \"./config/loi-lookup.js\";\r\n\r\n// Environment utilities\r\nexport { \r\n getEnvVar, \r\n isNodeEnvironment, \r\n isBrowserEnvironment \r\n} from \"./environment/utilities.js\";"],"names":["STYLES_PATH"],"mappings":"w/DAmEY,MAACA,EAAc"}
@@ -42,6 +42,20 @@
42
42
  flex-shrink: 0;
43
43
  }
44
44
 
45
+ .red-reasons-pill {
46
+ display: inline-flex;
47
+ align-items: center;
48
+ margin-bottom: var(--spacing-sm);
49
+ padding: 2px 10px;
50
+ border-radius: 999px;
51
+ background-color: #fde2e2;
52
+ color: var(--color-dark-red);
53
+ font-size: var(--font-size-sm);
54
+ font-weight: var(--font-weight-semibold);
55
+ letter-spacing: 0.5px;
56
+ white-space: nowrap;
57
+ }
58
+
45
59
  /* ===== METRICS GRID ===== */
46
60
 
47
61
  .metrics-grid {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@archerjessop/utilities",
3
- "version": "7.19.0",
3
+ "version": "7.21.0",
4
4
  "description": "Shared utilities for ArcherJessop property analysis tools",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",