Files
money/app_accounts.html
nmemmertandClaude Sonnet 5.5 0f4bc2bf6c
build-container / build (push) Successful in 40s
Accounts: mortgage payoff countdown with extra-payment what-if
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 19:10:27 -04:00

123 lines
11 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Savings &amp; Loans</title>
<style>
:root{--bg:#f6f7f9;--card:#fff;--ink:#1c2230;--mute:#667085;--line:#e4e7ec;--accent:#1f4fd8;--ok:#1a7f4b;--okbg:#e6f4ec;--bad:#b42318;--badbg:#fdeceb;--none:#eef0f3}
@media (prefers-color-scheme:dark){:root{--bg:#10131a;--card:#181c25;--ink:#e8ebf2;--mute:#9aa3b5;--line:#2a3040;--accent:#6c8cff;--ok:#5ad08f;--okbg:#14301f;--bad:#ff8a80;--badbg:#3a1a18;--none:#222838}}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif}
main{max-width:1000px;margin:0 auto;padding:24px 16px 64px}
h1{font-size:22px;margin:0}h2{font-size:16px;margin:0}.mute{color:var(--mute)}.row{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap}.spacer{flex:1}
a{color:var(--accent);font-weight:600;text-decoration:none}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;margin-bottom:16px}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px;margin:16px 0}
.kpi .l{font-size:12px;color:var(--mute)}.kpi .v{font-size:24px;font-weight:700;margin-top:2px}.kpi .s{font-size:12px;color:var(--mute)}
table{border-collapse:collapse;width:100%}th,td{padding:7px 8px;text-align:left;border-bottom:1px solid var(--line)}th{font-size:12px;color:var(--mute);font-weight:600}
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}
.big{font-size:26px;font-weight:700}
.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}
@media (max-width:560px){.tabs a{padding:8px 4px;font-size:13px}}
</style>
</head>
<body>
<main>
<nav class="tabs"><a href="/">Statements</a><a href="/budget">Budget</a><a href="/dashboard">Dashboard</a><a href="/accounts" class="on" aria-current="page">Accounts</a></nav>
<h1>Savings &amp; Loans</h1>
<div class="mute" style="margin-top:2px">Balances from your statements. Kept completely separate from spending, the dashboard and the budget.</div>
<div id="out"></div>
</main>
<script>
const $=s=>document.querySelector(s);
const money=(n,d=2)=>n==null?"–":(n<0?"-":"")+"$"+Math.abs(n).toLocaleString(undefined,{minimumFractionDigits:d,maximumFractionDigits:d});
const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const MON=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const dfmt=s=>{const d=new Date(s+"T12:00:00");return MON[d.getMonth()]+" "+d.getDate()+", "+d.getFullYear()};
const mshort=s=>{const d=new Date(s+"T12:00:00");return MON[d.getMonth()]+" "+String(d.getFullYear()).slice(2)};
function chart(pts){ // pts: [[date, balance]] oldest first
if(pts.length<2)return"";
const W=600,H=120,P=30,xs=pts.map((_,i)=>P+i*(W-2*P)/(pts.length-1));
const lo=Math.min(...pts.map(p=>p[1])),hi=Math.max(...pts.map(p=>p[1])),sp=(hi-lo)||1;
const ys=pts.map(p=>H-24-(p[1]-lo)/sp*(H-44));
return `<svg class="chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"><polyline points="${xs.map((x,i)=>x+","+ys[i]).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>`;
}
// 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(){
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.setup){$("#out").innerHTML=`<div class="card empty">Bills_Tracker.xlsx isn’t in the data folder yet. <a href="/">Set it up on the Statements tab.</a></div>`;return}
if(!j.rows.length){$("#out").innerHTML=`<div class="card empty"><b>No savings or loan balances recorded yet.</b><div class="mute" style="margin-top:6px">Add your Ally, CVFCU or mortgage statements on the <a href="/">Statements</a> tab. When it says savings / loan balances will be recorded, click <b>Add to workbook</b>.</div></div>`;return}
const by={};j.rows.forEach(r=>(by[r.account]=by[r.account]||[]).push(r));
const accts=Object.entries(by).map(([name,rs])=>{rs.sort((a,b)=>a.date<b.date?-1:1);return{name,rs,last:rs[rs.length-1],first:rs[0],group:rs[0].group}});
const sav=accts.filter(a=>a.group!=="Loan"),loan=accts.filter(a=>a.group==="Loan");
const sum=(l,f)=>l.reduce((t,a)=>t+(f(a)||0),0);
let h=`<div class="kpis">
<div class="card kpi" style="margin:0"><div class="l">Savings total</div><div class="v">${money(sum(sav,a=>a.last.balance))}</div><div class="s">${sav.length} account${sav.length===1?"":"s"}</div></div>
<div class="card kpi" style="margin:0"><div class="l">Interest earned this year</div><div class="v">${money(sum(sav,a=>a.last.ytd))}</div><div class="s">savings accounts</div></div>
<div class="card kpi" style="margin:0"><div class="l">Loan balance</div><div class="v">${money(sum(loan,a=>a.last.balance))}</div><div class="s">${loan.length?"owed":"no loans recorded"}</div></div>
<div class="card kpi" style="margin:0"><div class="l">Loan interest this year</div><div class="v">${money(sum(loan,a=>a.last.ytd))}</div><div class="s">paid so far</div></div></div>`;
for(const a of [...loan,...sav]){
const isLoan=a.group==="Loan",rs=a.rs,L=a.last,ch=L.balance-a.first.balance;
h+=`<div class="card"><div class="row"><h2>${esc(a.name)}</h2><span class="mute">${isLoan?"loan":"savings"} · as of ${dfmt(L.date)}</span><div class="spacer"></div><div class="big">${money(L.balance)}</div></div>`;
h+=`<div class="facts">`;
if(rs.length>1)h+=`<div>Since ${mshort(a.first.date)}<b style="color:${(isLoan?ch<0:ch>=0)?"var(--ok)":"var(--bad)"}">${ch>0?"+":""}${money(ch)}</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>`}
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>${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>`+
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;
for(const a of loan)wirePayoff(a.name.replace(/\W+/g,"_"),a.last);
}
load();
</script>
</body>
</html>