Accounts: mortgage payoff countdown with extra-payment what-if
build-container / build (push) Successful in 40s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-21 19:10:27 -04:00
1 parent 5425d9913a
commit 0f4bc2bf6c
1 file changed
+40 -1
+40 -1
View File
@@ -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} .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)} .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} .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{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{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} .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)=>`<circle cx="${x}" cy="${ys[i]}" r="2.5"><title>${mshort(pts[i][0])}: ${money(pts[i][1])}</title></circle>`).join("")+ xs.map((x,i)=>`<circle cx="${x}" cy="${ys[i]}" r="2.5"><title>${mshort(pts[i][0])}: ${money(pts[i][1])}</title></circle>`).join("")+
`<text x="${P}" y="${H-6}">${mshort(pts[0][0])}</text><text x="${W-P}" y="${H-6}" text-anchor="end">${mshort(pts[pts.length-1][0])}</text></svg>`; `<text x="${P}" y="${H-6}">${mshort(pts[0][0])}</text><text x="${W-P}" y="${H-6}" text-anchor="end">${mshort(pts[pts.length-1][0])}</text></svg>`;
} }
// 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 `<div class="payoff" id="po${idx}"><div class="mute" style="font-size:12px">At today’s rate and payment, this loan is paid off on</div><div class="big" id="pod${idx}"></div><div id="pot${idx}" style="margin-top:2px"></div>
<div class="row" style="margin-top:10px"><label class="mute">What if I add $ <input type="number" min="0" step="25" value="0" id="poe${idx}"> extra each month?</label></div><div id="pow${idx}" style="margin-top:6px"></div></div>`;
}
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=`<b>${togo(base.date)}</b> 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?`<span class="win">Paid off ${dlong(r.date)}: ${Math.floor(k/12)} yr ${k%12} mo sooner, saving about ${money(base.interest-r.interest,0)} in interest.</span>`:"";
};
el.oninput=run;run();
}
async function load(){ async function load(){
const j=await (await fetch("/api/accounts")).json(); const j=await (await fetch("/api/accounts")).json();
if(j.error){$("#out").innerHTML=`<div class="card" style="color:var(--bad)">${esc(j.error)}</div>`;return} if(j.error){$("#out").innerHTML=`<div class="card" style="color:var(--bad)">${esc(j.error)}</div>`;return}
@@ -71,11 +109,12 @@ async function load(){
if(isLoan){if(L.rate!=null)h+=`<div>Interest rate<b>${L.rate}%</b></div>`;if(L.payment!=null)h+=`<div>Monthly payment<b>${money(L.payment)}</b></div>`;if(L.due)h+=`<div>Next due<b>${dfmt(L.due)}</b></div>`; if(isLoan){if(L.rate!=null)h+=`<div>Interest rate<b>${L.rate}%</b></div>`;if(L.payment!=null)h+=`<div>Monthly payment<b>${money(L.payment)}</b></div>`;if(L.due)h+=`<div>Next due<b>${dfmt(L.due)}</b></div>`;
const pr=rs.reduce((t,r)=>t+(r.principal||0),0),it=rs.reduce((t,r)=>t+(r.interest||0),0);h+=`<div>Principal paid (${rs.length} mo)<b>${money(pr)}</b></div><div>Interest paid (${rs.length} mo)<b>${money(it)}</b></div>`} const pr=rs.reduce((t,r)=>t+(r.principal||0),0),it=rs.reduce((t,r)=>t+(r.interest||0),0);h+=`<div>Principal paid (${rs.length} mo)<b>${money(pr)}</b></div><div>Interest paid (${rs.length} mo)<b>${money(it)}</b></div>`}
else{h+=`<div>Interest this year<b>${money(L.ytd)}</b></div>`;if(L.rate)h+=`<div>APY earned<b>${L.rate}%</b></div>`} else{h+=`<div>Interest this year<b>${money(L.ytd)}</b></div>`;if(L.rate)h+=`<div>APY earned<b>${L.rate}%</b></div>`}
h+=`</div>${chart(rs.map(r=>[r.date,r.balance]))}`; h+=`</div>${isLoan?payoffHTML(a.name.replace(/\W+/g,"_"),L):""}${chart(rs.map(r=>[r.date,r.balance]))}`;
h+=`<div style="overflow-x:auto"><table><tr><th>Statement</th><th class="n">Balance</th><th class="n">Interest</th>${isLoan?'<th class="n">Principal</th><th class="n">Payment</th>':'<th class="n">Deposits</th><th class="n">Withdrawals</th>'}</tr>`+ h+=`<div style="overflow-x:auto"><table><tr><th>Statement</th><th class="n">Balance</th><th class="n">Interest</th>${isLoan?'<th class="n">Principal</th><th class="n">Payment</th>':'<th class="n">Deposits</th><th class="n">Withdrawals</th>'}</tr>`+
rs.slice().reverse().slice(0,12).map(r=>`<tr><td>${dfmt(r.date)}</td><td class="n">${money(r.balance)}</td><td class="n">${money(r.interest)}</td>${isLoan?`<td class="n">${money(r.principal)}</td><td class="n">${money(r.payment)}</td>`:`<td class="n">${money(r.deposits)}</td><td class="n">${money(r.withdrawals)}</td>`}</tr>`).join("")+`</table></div></div>`; rs.slice().reverse().slice(0,12).map(r=>`<tr><td>${dfmt(r.date)}</td><td class="n">${money(r.balance)}</td><td class="n">${money(r.interest)}</td>${isLoan?`<td class="n">${money(r.principal)}</td><td class="n">${money(r.payment)}</td>`:`<td class="n">${money(r.deposits)}</td><td class="n">${money(r.withdrawals)}</td>`}</tr>`).join("")+`</table></div></div>`;
} }
$("#out").innerHTML=h; $("#out").innerHTML=h;
for(const a of loan)wirePayoff(a.name.replace(/\W+/g,"_"),a.last);
} }
load(); load();
</script> </script>