From 0f4bc2bf6c2882713dc4e0e67bb17197cc73f3f5 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:10:27 -0400 Subject: [PATCH] Accounts: mortgage payoff countdown with extra-payment what-if Co-Authored-By: Claude Sonnet 5.5 --- app_accounts.html | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/app_accounts.html b/app_accounts.html index 43d7f7d..748b34c 100644 --- a/app_accounts.html +++ b/app_accounts.html @@ -20,6 +20,9 @@ td.n,th.n{text-align:right;font-variant-numeric:tabular-nums} .chart{width:100%;height:120px;margin:10px 0}.chart polyline{fill:none;stroke:var(--accent);stroke-width:2}.chart circle{fill:var(--accent)}.chart text{fill:var(--mute);font-size:11px} .facts{display:flex;gap:22px;flex-wrap:wrap;margin-top:4px}.facts div{font-size:12px;color:var(--mute)}.facts b{display:block;font-size:14px;color:var(--ink)} .empty{text-align:center;padding:36px 16px} +.payoff{margin:14px 0 4px;padding:14px 16px;border:1px solid var(--line);border-radius:10px;background:var(--none)} +.payoff .big{font-size:22px}.payoff .row{align-items:center}.payoff input{width:90px;text-align:right;font:inherit;color:var(--ink);background:var(--card);border:1px solid var(--line);border-radius:8px;padding:5px 8px} +.payoff .win{color:var(--ok);font-weight:600} .tabs{position:sticky;top:0;z-index:5;display:flex;gap:4px;margin:0 0 16px;padding:10px 0;background:var(--bg)} .tabs a{flex:1;max-width:170px;text-align:center;padding:8px 14px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--mute);font-weight:600;font-size:14px;text-decoration:none} .tabs a:hover{color:var(--ink)}.tabs a.on{background:var(--accent);border-color:var(--accent);color:#fff} @@ -49,6 +52,41 @@ function chart(pts){ // pts: [[date, balance]] oldest first xs.map((x,i)=>`${mshort(pts[i][0])}: ${money(pts[i][1])}`).join("")+ `${mshort(pts[0][0])}${mshort(pts[pts.length-1][0])}`; } +// Walk the loan forward month by month: interest = balance x rate/12, then the payment comes off (matches your statements to the cent). +function payoff(bal,rate,pay,extra,first){ + let n=0,interest=0,d=new Date(first+"T12:00:00");const day=d.getDate(); + while(bal>0.005&&n<1200){ + const i=bal*rate/1200,p=pay+extra;interest+=i;n++; + if(p>=bal+i){bal=0;break} + bal=bal+i-p;d=new Date(d.getFullYear(),d.getMonth()+1,Math.min(day,28)); + if(day>28){const last=new Date(d.getFullYear(),d.getMonth()+1,0).getDate();d.setDate(Math.min(day,last))} + } + return{n,interest,date:d,ok:bal<=0.005}; +} +function togo(date){ + const now=new Date();let y=date.getFullYear()-now.getFullYear(),m=date.getMonth()-now.getMonth(),dd=date.getDate()-now.getDate(); + if(dd<0){m--;dd+=new Date(date.getFullYear(),date.getMonth(),0).getDate()}if(m<0){y--;m+=12} + const part=(n,w)=>n?`${n} ${w}${n===1?"":"s"}`:""; + return [part(y,"year"),part(m,"month"),part(dd,"day")].filter(Boolean).join(", ")||"today"; +} +const dlong=d=>MON[d.getMonth()]+" "+d.getDate()+", "+d.getFullYear(); +function payoffHTML(idx,L){ + if(!(L.balance>0&&L.rate&&L.payment))return""; + return `
At today’s rate and payment, this loan is paid off on
+
`; +} +function wirePayoff(idx,L){ + const el=$("#poe"+idx);if(!el)return; + const first=L.due||L.date,base=payoff(L.balance,L.rate,L.payment,0,first); + const run=()=>{ + const x=Math.max(0,+el.value||0),r=payoff(L.balance,L.rate,L.payment,x,first); + $("#pod"+idx).textContent=dlong(base.date); + $("#pot"+idx).innerHTML=`${togo(base.date)} to go · ${base.n} payments left · about ${money(base.interest,0)} interest still to pay`; + const k=base.n-r.n; + $("#pow"+idx).innerHTML=x>0&&r.ok?`Paid off ${dlong(r.date)}: ${Math.floor(k/12)} yr ${k%12} mo sooner, saving about ${money(base.interest-r.interest,0)} in interest.`:""; + }; + el.oninput=run;run(); +} async function load(){ const j=await (await fetch("/api/accounts")).json(); if(j.error){$("#out").innerHTML=`
${esc(j.error)}
`;return} @@ -71,11 +109,12 @@ async function load(){ if(isLoan){if(L.rate!=null)h+=`
Interest rate${L.rate}%
`;if(L.payment!=null)h+=`
Monthly payment${money(L.payment)}
`;if(L.due)h+=`
Next due${dfmt(L.due)}
`; const pr=rs.reduce((t,r)=>t+(r.principal||0),0),it=rs.reduce((t,r)=>t+(r.interest||0),0);h+=`
Principal paid (${rs.length} mo)${money(pr)}
Interest paid (${rs.length} mo)${money(it)}
`} else{h+=`
Interest this year${money(L.ytd)}
`;if(L.rate)h+=`
APY earned${L.rate}%
`} - h+=`${chart(rs.map(r=>[r.date,r.balance]))}`; + h+=`${isLoan?payoffHTML(a.name.replace(/\W+/g,"_"),L):""}${chart(rs.map(r=>[r.date,r.balance]))}`; h+=`
${isLoan?'':''}`+ rs.slice().reverse().slice(0,12).map(r=>`${isLoan?``:``}`).join("")+`
StatementBalanceInterestPrincipalPaymentDepositsWithdrawals
${dfmt(r.date)}${money(r.balance)}${money(r.interest)}${money(r.principal)}${money(r.payment)}${money(r.deposits)}${money(r.withdrawals)}
`; } $("#out").innerHTML=h; + for(const a of loan)wirePayoff(a.name.replace(/\W+/g,"_"),a.last); } load();