Accounts: mortgage payoff countdown with extra-payment what-if
build-container / build (push) Successful in 40s
build-container / build (push) Successful in 40s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5425d9913a
commit
0f4bc2bf6c
1 file changed
+40
-1
+40
-1
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user