Files
money/app_accounts.html
T
2026-10-21 15:59:21 -04:00

84 lines
7.7 KiB
HTML
Raw 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}
.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>`;
}
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>${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;
}
load();
</script>
</body>
</html>