Financing Strategy
HELOC
Boat Loan
HELOC + Boat
Other
None
Remote Work While Sailing
Departure Age 45
Runway (years) —
Point of No Return —
Oh-Sh!t Year (stress) —
Boat Equity at End $0
Cash Balance vs Age
// ===== Timing
function getDepartureOffsetMonths(path){
const userAge = parseFloat(document.getElementById('userAge').value)||45;
if (path==='A') return 0;
if (path==='B') return (state.paths.B.yearsToSave||5)*12;
if (path==='C'){
const retAge = state.paths.C.retirementAge||60;
return Math.max(0, (retAge-userAge)*12);
}
return 0;
}
// ===== Small helpers used across UI
function getNumber(v){ const n = parseFloat(String(v).replace(/[^0-9.-]/g,'')); return isNaN(n)?0:n; }
function getActiveBoatPrice(path){
const lt = state.paths[path].loan;
if (lt === 'boat' || lt === 'both') return state.loanParams.boat.price || 0;
if (lt === 'heloc') return state.loanParams.heloc.boatPrice || state.loanParams.boat.price || 0;
return 0;
}
function setBoatEquityLabel(path, departureAge, runwayYears){
const labelEl = document.getElementById(`path${path}BoatEquity`)?.closest('.stat-item')?.querySelector('.stat-label');
if (!labelEl) return;
if (runwayYears >= 50) {
labelEl.textContent = 'Boat Equity at End (open-ended)';
} else {
const saleAge = Math.round((departureAge + runwayYears) * 10) / 10;
labelEl.textContent = `Boat Equity at End (Age ${saleAge})`;
}
}
function getHelocDraw(){ const p = state.loanParams.heloc; return Math.max(0, Number(p.drawAmount) || 0); }
// ===== Monthly simulation engine
function simulateRunwayMonthly(path, useStress=false){
const investReturn = (parseFloat(document.getElementById('investmentReturn')?.value)||6)/100;
const infl = (parseFloat(document.getElementById('inflationRate')?.value) || 3) / 100;
const rInvMonthly = investReturn/12;
const landMonthly = getMoney('monthlyLandCost') || 3000;
const baseCushion = landMonthly * 6; // today's dollars
const basePNRBuffer = landMonthly * 12; // today's dollars
const sailingBase = (Number(state.sailingBasePreset) + Number(state.sailingAdjustment)) || 0;
// start cash (growth pre-departure)
const liquid = getMoney('liquidSavings') || 0;
const portfolio = getMoney('investablePortfolio') || 0;
const annualIncome = getMoney('annualIncome') || 0;
const savingsRate = (parseFloat(document.getElementById('savingsRate').value)||0)/100;
const depOffset = getDepartureOffsetMonths(path);
let startCash = liquid + portfolio;
if (depOffset > 0){
const monthlySave = (annualIncome * savingsRate) / 12;
for (let m=1; m<=depOffset; m++){
startCash = startCash*(1+rInvMonthly) + monthlySave;
}
}
// Loan setup
const loanType = state.paths[path].loan;
const b = state.loanParams.boat;
const h = state.loanParams.heloc;
const o = state.loanParams.other;
let boatPrincipal = 0, boatTerm = 0, boatRate = 0, boatInsuranceM = 0;
let helocPrincipal = 0, helocRate = 0, helocIOM = 0, helocAmortM = 0;
let otherPrincipal = 0, otherRate = 0, otherTerm = 0;
const boatPrice = getActiveBoatPrice(path);
if (loanType === 'boat' || loanType === 'both'){
const downAmt = boatPrice * (b.downPercent||0)/100;
boatPrincipal = Math.max(0, boatPrice - downAmt);
boatTerm = b.termMonths||0;
boatRate = b.rate||0;
boatInsuranceM = (b.insuranceType==='percent')
? (boatPrice * (b.insurancePercent||0)/100)/12
: (b.insuranceBump||0);
}
if (loanType === 'heloc' || loanType === 'both'){
helocPrincipal = getHelocDraw();
helocRate = h.rate||0;
helocIOM = h.ioMonths||0;
helocAmortM = h.amortMonths||0;
}
if (loanType === 'other'){
otherPrincipal = o.principal||0;
otherRate = o.rate||0;
otherTerm = o.termMonths||0;
}
// Remote & passive income
const applyStress = useStress || !!document.getElementById('stressTestToggle')?.checked;
const passiveM = (getMoney('passiveIncome') || 0);
const remoteBaseM = state.paths[path].remoteWork ? (getMoney(`path${path}RemoteIncome`)||0) : 0;
const purchasePriceForValue = boatPrice>0 ? boatPrice : 0;
// Simulation loop (cap to 50 years => 600 months)
let cash = startCash;
let months = 0;
let runwayMonths = null;
let pnrMonths = null;
const yearlyRows = [];
let yearTracker = { startCash: cash, growth: 0, burn: 0 };
while (months < 600){
const yearIdx = Math.floor(months/12);
const {costMult, incomeMult} = getStressMultipliers(yearIdx, applyStress);
const inflFactor = Math.pow(1 + infl, Math.max(0, yearIdx));
const cushionNow = baseCushion * inflFactor;
const pnrBufferNow = basePNRBuffer * inflFactor;
// Costs
let sailCostM = sailingBase * inflFactor;
if (applyStress) sailCostM *= costMult;
if (applyStress && isStressShockYear(yearIdx)) {
sailCostM *= (1 + state.stress.shockCostBumpPct/100);
}
// Loan payments (month-specific)
let loanPayM = 0;
if (boatPrincipal > 0 && months < boatTerm){
const mBoat = fixedMonthlyPayment(boatPrincipal, boatRate, boatTerm);
const insM = (b.insuranceType==='percent')
? (boatPrice * (b.insurancePercent||0)/100)/12 * inflFactor
: boatInsuranceM * inflFactor;
loanPayM += mBoat + insM;
}
if (helocPrincipal > 0){
const due = helocMonthlyPayment(helocPrincipal, helocRate, months+1, helocIOM, helocAmortM);
loanPayM += due;
}
if (otherPrincipal > 0 && months < otherTerm){
loanPayM += fixedMonthlyPayment(otherPrincipal, otherRate, otherTerm);
}
// Income
let remoteM = remoteBaseM;
if (applyStress) remoteM *= incomeMult;
const inflow = passiveM + remoteM;
const outflow = sailCostM + loanPayM;
// Apply investment growth on cash *before* flows
const growth = cash * rInvMonthly;
cash += growth + inflow - outflow;
yearTracker.growth += growth;
yearTracker.burn += Math.max(0, outflow - inflow);
// PNR and runway checks (inflation-adjusted)
if (pnrMonths === null && cash <= cushionNow + pnrBufferNow){
pnrMonths = months + 1;
}
if (cash <= cushionNow){
runwayMonths = months + 1;
if (pnrMonths === null || pnrMonths > runwayMonths) pnrMonths = runwayMonths;
break;
}
months++;
// close out an annual row each 12 months
const endOfYear = months % 12 === 0;
if (endOfYear){
yearlyRows.push({
year: yearIdx,
startCash: yearTracker.startCash,
growth: yearTracker.growth,
burn: yearTracker.burn,
endCash: cash
});
yearTracker = { startCash: cash, growth: 0, burn: 0 };
}
}
if (runwayMonths === null){
runwayMonths = 600;
if (pnrMonths === null) pnrMonths = 600;
}
const boatValue = purchasePriceForValue>0
? boatValueAtMonths(purchasePriceForValue, runwayMonths)
: 0;
// Remaining balances at runway end
let totalLoanBalance = 0;
if (loanType === 'boat' || loanType === 'both'){
totalLoanBalance += fixedBalance(boatPrincipal, boatRate, boatTerm, Math.min(runwayMonths, boatTerm));
}
if (loanType === 'heloc' || loanType === 'both'){
totalLoanBalance += helocBalance(helocPrincipal, helocRate, Math.min(runwayMonths, (helocIOM+helocAmortM)), helocIOM, helocAmortM);
}
if (loanType === 'other'){
totalLoanBalance += fixedBalance(otherPrincipal, otherRate, otherTerm, Math.min(runwayMonths, otherTerm));
}
const boatEquity = Math.max(0, boatValue - totalLoanBalance);
return {
departureCash: startCash,
runwayYears: Math.min(runwayMonths/12, 50),
pnrYears: Math.min(pnrMonths/12, runwayMonths/12, 50),
finalCash: cash,
yearlyRows,
boatValue,
loanBalanceAtEnd: totalLoanBalance,
boatEquity
};
}
// ===== Rendering helpers
function simulateBoth(path){
const base = simulateRunwayMonthly(path,false);
const stress = simulateRunwayMonthly(path,true);
return { base, stress };
}
function updateChart(path, departureAge, sim){
const canvas = document.getElementById(`path${path}Chart`);
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (state.charts[path]) { state.charts[path].destroy(); state.charts[path] = null; }
const labels = [];
const cash = [];
const stress = [];
const base = sim.base;
const stressSim = sim.stress;
const yearsToShow = Math.min(
Math.max(base.yearlyRows.length, stressSim.yearlyRows.length) || 1,
20
);
for (let i=0;i<=yearsToShow;i++){
labels.push((Math.round((departureAge + i)*10)/10).toString());
const br = base.yearlyRows[i] || base.yearlyRows[base.yearlyRows.length-1];
const sr = stressSim.yearlyRows[i] || stressSim.yearlyRows[stressSim.yearlyRows.length-1];
const safeBase = Number.isFinite(br?.endCash) ? br.endCash : (Number.isFinite(base.finalCash) ? base.finalCash : 0);
const safeStress = Number.isFinite(sr?.endCash) ? sr.endCash : (Number.isFinite(stressSim.finalCash) ? stressSim.finalCash : 0);
cash.push(safeBase);
stress.push(safeStress);
}
state.charts[path] = new Chart(ctx,{
type:'line',
data:{
labels,
datasets:[
{label:'Cash Balance', data:cash, borderColor:'#3498db', backgroundColor:'rgba(52,152,219,0.1)', borderWidth:3, tension:.4},
{label:'Stress Scenario', data:stress, borderColor:'#e74c3c', backgroundColor:'rgba(231,76,60,0.1)', borderWidth:2, borderDash:[5,5], tension:.4}
]
},
options:{
responsive:true, maintainAspectRatio:false,
plugins:{ legend:{position:'top'}, tooltip:{callbacks:{label:(c)=>`${c.dataset.label}: ${formatCurrency(c.parsed.y)}`}} },
scales:{
x:{ title:{display:true,text:'Age'}, ticks:{autoSkip:false, maxTicksLimit:20} },
y:{ title:{display:true,text:'Value ($)'}, ticks:{callback:(v)=>formatCurrency(v)} }
}
}
});
}
function renderBalanceSheet(path, departureAge, sim){
const landMonthly = getMoney('monthlyLandCost') || 3000;
const baseCushion = landMonthly * 6;
const infl = (parseFloat(document.getElementById('inflationRate')?.value) || 3) / 100;
const rows = sim.base.yearlyRows;
if (!rows.length){
document.getElementById(`path${path}Sheet`).innerHTML =
'
No runway available.
';
return;
}
const maxYears = Math.min(rows.length, 20);
let html = `
Year
Age
Start Cash
Growth
Annual Burn
End Cash
`;
for (let i=0;i
${i}
${age}
${formatCurrency(Math.max(r.startCash, cushionNow))}
+${formatCurrency(r.growth)}
${formatCurrency(r.burn)}
${formatCurrency(Math.max(r.endCash, cushionNow))}
`;
}
html += `
`;
const headerEl = document.querySelector(`#path${path} .sheet-header`);
if (headerEl) headerEl.textContent = 'Balance Sheet (Annual)';
document.getElementById(`path${path}Sheet`).innerHTML = html;
}
// ===== Loan UI (snapshots + inputs)
function updateLoanInputs(path, loanType){
const container = document.getElementById(`path${path}LoanInputs`);
container.classList.toggle('active', loanType!=='none');
const priceFor = () => {
if (loanType === 'boat' || loanType === 'both') return state.loanParams.boat.price || 0;
if (loanType === 'heloc') return state.loanParams.heloc.boatPrice || state.loanParams.boat.price || 0;
return 0;
};
const fmt = formatCurrency;
const b = state.loanParams.boat;
const h = state.loanParams.heloc;
const o = state.loanParams.other;
// Boat loan snapshot
let boatSnap = null;
if (loanType === 'boat' || loanType === 'both'){
const boatPrice = priceFor();
const downPct = Number(b.downPercent)||0;
const downAmt = boatPrice * (downPct/100);
const principal = Math.max(0, boatPrice - downAmt);
const mBoat = fixedMonthlyPayment(principal, Number(b.rate)||0, Number(b.termMonths)||0);
const insMonthly = b.insuranceType==='percent'
? (boatPrice * (Number(b.insurancePercent)||0)/100)/12
: (Number(b.insuranceBump)||0);
const totalBoatM = mBoat + insMonthly;
boatSnap = { boatPrice, downPct, downAmt, principal, mBoat, insMonthly, totalBoatM };
}
// HELOC snapshot
let helocSnap = null;
if (loanType === 'heloc' || loanType === 'both'){
const principal = Math.max(0, Number(h.drawAmount)||0);
const r = (Number(h.rate)||0)/100/12;
const ioPay = principal * r;
const amortPay = helocMonthlyPayment(principal, Number(h.rate)||0, (Number(h.ioMonths)||0)+1, Number(h.ioMonths)||0, Number(h.amortMonths)||0);
helocSnap = { principal, ioPay, amortPay };
}
// Other loan snapshot
let otherSnap = null;
if (loanType === 'other'){
const principal = Math.max(0, Number(o.principal)||0);
const otherPay = fixedMonthlyPayment(principal, Number(o.rate)||0, Number(o.termMonths)||0);
otherSnap = { principal, otherPay };
}
let html = '';
// HELOC
if(loanType==='heloc' || loanType==='both'){
html += `
🏠 HELOC Configuration
${helocSnap ? `
Loan Snapshot
Draw Amount
${fmt(helocSnap.principal)}
Payment (Interest-Only)
${fmt(helocSnap.ioPay)}
Payment (Amortization)
${fmt(helocSnap.amortPay)}
Note: IO phase lasts ${nf0.format(Number(h.ioMonths)||0)} months, then switches to amortized payment for ${nf0.format(Number(h.amortMonths)||0)} months.
` : ''}
APR (%)
Interest-Only Months
Amortization Months
`;
}
// BOAT
if(loanType==='boat' || loanType==='both'){
html += `
⛵ Boat Loan Configuration
${boatSnap ? `
Loan Snapshot
Boat Price
${fmt(boatSnap.boatPrice)}
Down Payment (${nf0.format(boatSnap.downPct)}%)
${fmt(boatSnap.downAmt)}
Loan Principal
${fmt(boatSnap.principal)}
Monthly Payment
${fmt(boatSnap.mBoat)}
Insurance / mo
${fmt(boatSnap.insMonthly)}
Total Debt Service / mo
${fmt(boatSnap.totalBoatM)}
` : ''}
Down Payment (%)
APR (%)
Term (months)
Insurance Type
Fixed Monthly
% of Boat Value
`;
}
// OTHER
if(loanType==='other'){
html += `
📄 Other Loan Configuration
${otherSnap ? `
Loan Snapshot
Principal
${fmt(otherSnap.principal)}
Monthly Payment
${fmt(otherSnap.otherPay)}
` : ''}
APR (%)
Term (months)
`;
}
container.innerHTML = html;
wireMoneyInputs(container);
}
// ===== Orchestration per path
function updatePath(path){
const userAge = parseFloat(document.getElementById('userAge').value)||45;
const depOffset = getDepartureOffsetMonths(path);
const departureAge = userAge + (depOffset/12);
const sim = simulateBoth(path);
// Stats
document.getElementById(`path${path}DepartureAge`).textContent = Math.round(departureAge*10)/10;
const runwayYears = sim.base.runwayYears;
const pnrYears = sim.base.pnrYears;
const runwayText = runwayYears >= 50 ? '∞' : `${Math.round(runwayYears*10)/10} years`;
const pnrText = pnrYears >= 50 ? 'Never' : `Year ${Math.round(pnrYears*10)/10}`;
const ohshitText = sim.stress.pnrYears >= 50 ? 'Never' : `Year ${Math.round(sim.stress.pnrYears*10)/10}`;
document.getElementById(`path${path}Runway`).textContent = runwayText;
document.getElementById(`path${path}PNR`).textContent = pnrText;
document.getElementById(`path${path}OhShit`).textContent = ohshitText;
// Boat equity / balances at end of base runway
document.getElementById(`path${path}BoatEquity`).textContent = formatCurrency(sim.base.boatEquity);
document.getElementById(`path${path}LoanBalance`).textContent = formatCurrency(sim.base.loanBalanceAtEnd);
document.getElementById(`path${path}BoatValue`).textContent = formatCurrency(sim.base.boatValue);
if (path==='B'){
const netWorth = sim.base.departureCash - sim.base.loanBalanceAtEnd + sim.base.boatEquity;
document.getElementById('pathBNetWorth').textContent = formatCurrency(Math.max(0, netWorth));
}
if (path==='C'){
document.getElementById('pathCPortfolio').textContent = formatCurrency(sim.base.departureCash);
}
setBoatEquityLabel(path, departureAge, runwayYears);
updateChart(path, departureAge, sim);
renderBalanceSheet(path, departureAge, sim);
}
function updateAllPaths(){
// remote toggles -> show/hide inputs
['A','B','C'].forEach(p => {
const chk = document.getElementById(`path${p}RemoteWork`);
if (chk){
state.paths[p].remoteWork = !!chk.checked;
const box = document.getElementById(`path${p}RemoteInputs`);
if (box) box.style.display = chk.checked ? 'block' : 'none';
}
});
updatePath('A');
updatePath('B');
updatePath('C');
}
// ===== UI handlers
function togglePanel(){ document.getElementById('inputPanel').classList.toggle('collapsed'); }
function toggleAdvanced(){ document.getElementById('advancedContent').classList.toggle('show'); }
function selectSailingPreset(el, preset, amount){
state.sailingBasePreset = Number(amount);
state.sailingAdjustment = 0;
updateSailingCostDisplay();
el.closest('.radio-group').querySelectorAll('.radio-option').forEach(x=>x.classList.remove('selected'));
el.classList.add('selected');
updateAllPaths();
}
function adjustSailingCost(amount){
state.sailingAdjustment = Number(state.sailingAdjustment) + Number(amount);
updateSailingCostDisplay();
updateAllPaths();
}
function updateSailingCostDisplay(){
const total = Number(state.sailingBasePreset) + Number(state.sailingAdjustment);
document.getElementById('sailingCostDisplay').textContent = formatCurrency(total);
}
function selectLoan(el, path, loanType){
state.paths[path].loan = loanType;
el.closest('.loan-options').querySelectorAll('.loan-option').forEach(o=>o.classList.remove('selected'));
el.classList.add('selected');
const warn = document.getElementById(`path${path}Warning`);
if (warn) warn.style.display = (loanType==='heloc' || loanType==='both') ? 'block' : 'none';
updateLoanInputs(path, loanType);
updatePath(path);
}
// ===== FAQ toggle
function toggleFAQ(){
const wrap = document.getElementById('faqPanel');
const caret = document.getElementById('faqCaret');
wrap.classList.toggle('open');
const open = wrap.classList.contains('open');
caret.textContent = open ? '▼' : '▶';
}
// ===== Event wiring & initial render
document.addEventListener('DOMContentLoaded', () => {
wireMoneyInputs(document);
// Inputs that should trigger recompute
['userAge','partnerAge','savingsRate','inflationRate','investmentReturn'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', scheduleRecalc);
});
['stressTestToggle','stressRandomizeToggle','incomeVolatility','stressSeed','costUncertainty'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', scheduleRecalc);
});
// Remote toggles
['A','B','C'].forEach(p => {
const chk = document.getElementById(`path${p}RemoteWork`);
if (chk){
chk.addEventListener('change', updateAllPaths);
}
const inc = document.getElementById(`path${p}RemoteIncome`);
if (inc){
inc.addEventListener('input', () => { formatMoneyInput(inc); scheduleRecalc(); });
inc.addEventListener('blur', () => { formatMoneyInput(inc); updateAllPaths(); });
}
});
// Path B: years to save
const yrs = document.getElementById('pathBYearsToSave');
if (yrs){
yrs.addEventListener('input', () => {
state.paths.B.yearsToSave = parseInt(yrs.value)||1;
document.getElementById('pathBYearsDisplay').textContent = state.paths.B.yearsToSave;
updateAllPaths();
});
}
// Path C: retirement age
const rAge = document.getElementById('pathCRetirementAge');
if (rAge){
rAge.addEventListener('input', () => {
state.paths.C.retirementAge = parseInt(rAge.value)||60;
document.getElementById('pathCRetirementDisplay').textContent = state.paths.C.retirementAge;
updateAllPaths();
});
}
// Regret sliders (just update label)
[['A','pathARegret','pathARegretValue'],['B','pathBRegret','pathBRegretValue'],['C','pathCRegret','pathCRegretValue']].forEach(([p,sliderId,labelId])=>{
const s = document.getElementById(sliderId);
const l = document.getElementById(labelId);
if (s && l){
s.addEventListener('input', ()=>{ l.textContent = `${s.value}%`; });
}
});
// Build initial loan inputs for defaults
updateLoanInputs('A', state.paths.A.loan);
updateLoanInputs('B', state.paths.B.loan);
updateLoanInputs('C', state.paths.C.loan);
// First render
updateSailingCostDisplay();
updateAllPaths();
});