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

187 lines
17 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>Bills Dashboard</title>
<style>
:root{--bg:#f6f7f9;--card:#fff;--ink:#1c2230;--mute:#667085;--line:#e4e7ec;--accent:#1f4fd8;--up:#b42318;--down:#1a7f4b;--none:#eef0f3}
@media (prefers-color-scheme:dark){:root{--bg:#10131a;--card:#181c25;--ink:#e8ebf2;--mute:#9aa3b5;--line:#2a3040;--accent:#6c8cff;--up:#ff8a80;--down:#5ad08f;--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:1180px;margin:0 auto;padding:24px 16px 64px}
h1{font-size:22px;margin:0}h2{font-size:15px;margin:0 0 10px}.mute{color:var(--mute)}.row{display:flex;gap:10px;align-items:center;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}
select,input{font:inherit;color:var(--ink);background:var(--card);border:1px solid var(--line);border-radius:8px;padding:6px 10px}
button{font:inherit;color:var(--ink);background:var(--card);border:1px solid var(--line);border-radius:8px;padding:6px 12px;cursor:pointer}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin:16px 0}
.kpi .l{font-size:12px;color:var(--mute)}.kpi .v{font-size:22px;font-weight:700;margin-top:2px}.kpi .s{font-size:12px;color:var(--mute)}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(480px,1fr));gap:16px;margin-bottom:16px}
@media (max-width:560px){.grid{grid-template-columns:1fr}}
svg{width:100%;height:auto;display:block;overflow:visible}svg text{fill:var(--mute);font-size:11px}
.legend{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:8px;font-size:12px;color:var(--mute)}.legend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:5px;vertical-align:-1px}
table{border-collapse:collapse;width:100%}th,td{padding:6px 8px;text-align:left;border-bottom:1px solid var(--line)}th{font-size:12px;color:var(--mute);font-weight:600;position:sticky;top:0;background:var(--card)}
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}.scroll{max-height:460px;overflow:auto}
.up{color:var(--up)}.down{color:var(--down)}
#tip{position:fixed;pointer-events:none;background:var(--ink);color:var(--bg);padding:6px 9px;border-radius:6px;font-size:12px;display:none;z-index:9;max-width:260px}
.bar{fill:var(--accent)}.bar:hover{opacity:.8}
.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}
</style>
</head>
<body>
<main>
<nav class="tabs"><a href="/">Statements</a><a href="/budget">Budget</a><a href="/dashboard" class="on" aria-current="page">Dashboard</a><a href="/accounts">Accounts</a></nav>
<div class="row"><h1>Bills Dashboard</h1><span class="mute" id="updated"></span><div class="spacer"></div>
<label class="mute">Year <select id="year"></select></label></div>
<div class="kpis" id="kpis"></div>
<div class="grid">
<div class="card"><h2 id="t0">Income vs spending</h2><div id="c0"></div><div class="legend" id="l0"></div></div>
<div class="card"><h2 id="tsrc">Income sources</h2><div class="scroll" id="inc"></div></div>
<div class="card"><h2 id="t1">Monthly total</h2><div id="c1"></div></div>
<div class="card"><h2>Monthly spending by category</h2><div id="c2"></div><div class="legend" id="l2"></div></div>
<div class="card"><h2>Year over year by month</h2><div id="c3"></div><div class="legend" id="l3"></div></div>
<div class="card"><h2>Year over year trend</h2><div id="c4"></div><div class="legend" id="l4"></div></div>
<div class="card"><h2>Category totals by year</h2><div id="c5"></div><div class="legend" id="l5"></div></div>
<div class="card"><h2 id="t6">Share by category</h2><div id="c6"></div><div class="legend" id="l6"></div></div>
</div>
<div class="grid">
<div class="card"><h2 id="t7">Top payees</h2><div class="scroll" id="payees"></div></div>
<div class="card"><h2>Category detail · year over year</h2><div class="scroll" id="catyoy"></div></div>
</div>
<div class="card">
<div class="row" style="margin-bottom:10px"><h2 style="margin:0">Transactions</h2><div class="spacer"></div>
<input id="q" placeholder="Search payee or description"><select id="fm"><option value="">All months</option></select>
<select id="fc"><option value="">All categories</option></select><select id="fa"><option value="">All accounts</option></select></div>
<div class="mute" id="count" style="margin-bottom:6px"></div><div class="scroll" id="tx"></div>
</div>
<p class="mute">Read-only view of Bills_Tracker.xlsx. Income = paychecks and interest. Card payments and transfers between your accounts are in neither income nor spending (see Excluded Transfers in the workbook). Refunds are negative amounts.</p>
</main>
<div id="tip"></div>
<script>
const $=s=>document.querySelector(s), MON=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const PAL=["#4E79A7","#F28E2B","#E15759","#76B7B2","#59A14F","#EDC948","#B07AA1","#9C9C9C"];
const money=(n,d=0)=>(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 tip=$("#tip");
function showTip(e,t){tip.innerHTML=t;tip.style.display="block";tip.style.left=Math.min(e.clientX+12,innerWidth-270)+"px";tip.style.top=(e.clientY+12)+"px"}
function hideTip(){tip.style.display="none"}
document.addEventListener("mouseover",e=>{const t=e.target.closest("[data-tip]");t?showTip(e,t.dataset.tip):hideTip()});
document.addEventListener("mousemove",e=>{if(tip.style.display=="block"){tip.style.left=Math.min(e.clientX+12,innerWidth-270)+"px";tip.style.top=(e.clientY+12)+"px"}});
let D=null;
function niceMax(v){if(v<=0)return 1;const p=Math.pow(10,Math.floor(Math.log10(v)));const m=v/p;return (m<=1?1:m<=2?2:m<=2.5?2.5:m<=5?5:10)*p}
// series: [{name,color,values[]}]; mode: "group"|"stack"|"line"
function chart(el,labels,series,mode,fmtv=v=>money(v),rot=false){
const W=560,B=rot?74:24,H=226+B,L=48,R=8,T=10,iw=W-L-R,ih=H-T-B;
let max=0;
labels.forEach((_,i)=>{max=Math.max(max,mode=="stack"?series.reduce((a,s)=>a+Math.max(0,s.values[i]||0),0):Math.max(...series.map(s=>s.values[i]||0)))});
max=niceMax(max);const y=v=>T+ih-(v/max)*ih;
let g="";for(let k=0;k<=4;k++){const v=max*k/4,yy=y(v);g+=`<line x1="${L}" x2="${W-R}" y1="${yy}" y2="${yy}" stroke="var(--line)"/><text x="${L-6}" y="${yy+4}" text-anchor="end">${v>=1000?"$"+(v/1000)+"k":"$"+v}</text>`}
const step=iw/labels.length;
labels.forEach((l,i)=>{const x=L+step*i+step/2;if(rot)g+=`<text x="${x}" y="${H-B+14}" text-anchor="end" transform="rotate(-35 ${x} ${H-B+14})">${esc(l)}</text>`;else if(labels.length<=12||i%2==0)g+=`<text x="${x}" y="${H-6}" text-anchor="middle">${esc(l)}</text>`});
if(mode=="line"){
series.forEach(s=>{const pts=s.values.map((v,i)=>v==null?null:[L+step*i+step/2,y(v)]);
let d="",pen=false;pts.forEach(p=>{if(!p){pen=false;return}d+=(pen?"L":"M")+p[0].toFixed(1)+" "+p[1].toFixed(1);pen=true});
g+=`<path d="${d}" fill="none" stroke="${s.color}" stroke-width="2"/>`;
pts.forEach((p,i)=>{if(p)g+=`<circle cx="${p[0]}" cy="${p[1]}" r="4" fill="${s.color}" data-tip="${esc(s.name)} · ${esc(labels[i])}<br><b>${fmtv(s.values[i])}</b>"/>`})});
}else if(mode=="stack"){
labels.forEach((l,i)=>{let acc=0;const x=L+step*i+step*.15,w=step*.7;
series.forEach(s=>{const v=Math.max(0,s.values[i]||0);if(!v)return;const y0=y(acc+v),h=y(acc)-y0;acc+=v;
g+=`<rect x="${x}" y="${y0}" width="${w}" height="${Math.max(h,0)}" fill="${s.color}" data-tip="${esc(s.name)} · ${esc(l)}<br><b>${fmtv(v)}</b>"/>`})});
}else{
const n=series.length,w=step*.8/n;
labels.forEach((l,i)=>series.forEach((s,k)=>{const v=s.values[i]||0;if(v<=0)return;
g+=`<rect x="${L+step*i+step*.1+w*k}" y="${y(v)}" width="${w*.92}" height="${y(0)-y(v)}" fill="${s.color}" rx="2" data-tip="${esc(s.name)} · ${esc(l)}<br><b>${fmtv(v)}</b>"/>`}));
}
$(el).innerHTML=`<svg viewBox="0 0 ${W} ${H}" role="img">${g}</svg>`;
}
function legend(el,items){$(el).innerHTML=items.map(i=>`<span><i style="background:${i.color}"></i>${esc(i.name)}</span>`).join("")}
function donut(el,items){
const tot=items.reduce((a,i)=>a+i.value,0);if(!tot){$(el).innerHTML='<div class="mute">No data</div>';return}
const cx=120,cy=110,r=90,ri=55;let a0=-Math.PI/2,g="";
items.forEach(i=>{const a1=a0+(i.value/tot)*2*Math.PI,lg=a1-a0>Math.PI?1:0;
const p=(a,rr)=>[cx+rr*Math.cos(a),cy+rr*Math.sin(a)];const [x0,y0]=p(a0,r),[x1,y1]=p(a1-.0001,r),[x2,y2]=p(a1-.0001,ri),[x3,y3]=p(a0,ri);
g+=`<path d="M${x0} ${y0}A${r} ${r} 0 ${lg} 1 ${x1} ${y1}L${x2} ${y2}A${ri} ${ri} 0 ${lg} 0 ${x3} ${y3}Z" fill="${i.color}" data-tip="${esc(i.name)}<br><b>${money(i.value)}</b> · ${(i.value/tot*100).toFixed(1)}%"/>`;a0=a1});
g+=`<text x="${cx}" y="${cy-2}" text-anchor="middle" style="font-size:18px;font-weight:700;fill:var(--ink)">${money(tot)}</text><text x="${cx}" y="${cy+14}" text-anchor="middle">total</text>`;
$(el).innerHTML=`<svg viewBox="0 0 240 220" style="max-width:300px;margin:0 auto">${g}</svg>`;
}
const sum=(a,f)=>a.reduce((s,x)=>s+f(x),0);
function render(){
const Y=+$("#year").value, all=D.tx.map(t=>({d:t[0],y:+t[0].slice(0,4),m:+t[0].slice(5,7),payee:t[1],cat:t[2],type:t[3],what:t[4],amt:t[5],acct:t[6],note:t[7]}));
const yrs=[...new Set(all.map(t=>t.y))].sort(), cur=all.filter(t=>t.y==Y);
$("#t1").textContent=`Monthly total · ${Y}`;$("#t6").textContent=`Share by category · ${Y}`;$("#t7").textContent=`Top payees · ${Y}`;
const byM=Array(12).fill(0);cur.forEach(t=>byM[t.m-1]+=t.amt);
const total=sum(byM,x=>x),active=byM.filter(v=>v!=0).length,hi=Math.max(...byM);
const cats={};cur.forEach(t=>cats[t.cat]=(cats[t.cat]||0)+t.amt);
const topc=Object.entries(cats).sort((a,b)=>b[1]-a[1]);
const lastM=cur.length?Math.max(...cur.map(t=>t.m)):0, prev=all.filter(t=>t.y==Y-1&&t.m<=lastM), curYtd=total;
const prevT=sum(prev,t=>t.amt), chg=prevT?(curYtd/prevT-1):null;
const inc=(D.income||[]).map(i=>({d:i[0],y:+i[0].slice(0,4),m:+i[0].slice(5,7),src:i[1],kind:i[2],amt:i[3]})).filter(i=>i.y==Y);
const incM=Array(12).fill(0);inc.forEach(i=>incM[i.m-1]+=i.amt);const incT=sum(incM,x=>x),net=incT-total,rate=incT>0?net/incT:null;
$("#t0").textContent=`Income vs spending · ${Y}`;$("#tsrc").textContent=`Income sources · ${Y}`;
$("#kpis").innerHTML=[
["Income",money(incT),`${inc.length} deposits`],
["Total paid (spending)",money(total),`${cur.length} payments`],
["Net (income − spending)",`<span class="${net<0?"up":"down"}">${net<0?"-":"+"}${money(Math.abs(net))}</span>`,rate==null?"":`${(rate*100).toFixed(0)}% of income ${net<0?"overspent":"saved"}`],
["Average per month",money(active?total/active:0),`${active} month(s) with data`],
["Highest month",hi>0?MON[byM.indexOf(hi)]:"-",hi>0?money(hi):""],
["Largest category",topc[0]?topc[0][0]:"-",topc[0]?money(topc[0][1]):""],
[`${Y} vs ${Y-1}, same months`,chg==null?"n/a":`<span class="${chg>0?"up":"down"}">${chg>0?"+":""}${(chg*100).toFixed(1)}%</span>`,prevT?`${money(curYtd)} vs ${money(prevT)} (Jan–${MON[lastM-1]})`:`no ${Y-1} data`]
].map(k=>`<div class="card kpi"><div class="l">${k[0]}</div><div class="v">${k[1]}</div><div class="s">${k[2]}</div></div>`).join("");
const iser=[{name:"Income",color:PAL[4],values:incM},{name:"Spending",color:PAL[2],values:byM.map(v=>Math.max(0,v))}];
chart("#c0",MON,iser,"group");legend("#l0",iser);
const sm={};inc.forEach(i=>{(sm[i.src]=sm[i.src]||{amt:0,n:0,kind:i.kind});sm[i.src].amt+=i.amt;sm[i.src].n++});
$("#inc").innerHTML=inc.length?"<table><tr><th>Source</th><th>Type</th><th class='n'>Deposits</th><th class='n'>Total</th></tr>"+Object.entries(sm).sort((a,b)=>b[1].amt-a[1].amt).map(([k,v])=>`<tr><td>${esc(k)}</td><td class="mute">${v.kind}</td><td class="n">${v.n}</td><td class="n">${money(v.amt,2)}</td></tr>`).join("")+`<tr><td><b>Total</b></td><td></td><td class="n">${inc.length}</td><td class="n"><b>${money(incT,2)}</b></td></tr></table>`:'<div class="mute">No income recorded for this year. Paychecks and interest are read from the Excluded Transfers tab.</div>';
chart("#c1",MON,[{name:"Total",color:PAL[0],values:byM}],"group");
// categories: top 7 + Other
const top=topc.slice(0,7).map(c=>c[0]);const col=c=>PAL[top.indexOf(c)>=0?top.indexOf(c):7];
const sers=[...top,"All other"].map((c,i)=>({name:c,color:PAL[i],values:Array(12).fill(0)}));
cur.forEach(t=>{const i=top.indexOf(t.cat);sers[i>=0?i:7].values[t.m-1]+=t.amt});
chart("#c2",MON,sers.filter(s=>s.values.some(v=>v)),"stack");legend("#l2",sers.filter(s=>s.values.some(v=>v)));
// YoY (latest 4 years ending at the selected year)
const ys=yrs.filter(y=>y<=Y).slice(-4);const ysers=ys.map((y,i)=>({name:String(y),color:PAL[i%7],values:Array(12).fill(0),has:Array(12).fill(false)}));
all.forEach(t=>{const i=ys.indexOf(t.y);if(i>=0){ysers[i].values[t.m-1]+=t.amt;ysers[i].has[t.m-1]=true}});
chart("#c3",MON,ysers,"group");legend("#l3",ysers);
chart("#c4",MON,ysers.map(s=>({...s,values:s.values.map((v,i)=>s.has[i]?v:null)})),"line");legend("#l4",ysers);
// category by year
const allCats=[...new Set(all.filter(t=>ys.includes(t.y)).map(t=>t.cat))];
const catTot=c=>sum(all.filter(t=>t.cat==c&&ys.includes(t.y)),t=>t.amt);const cl=allCats.sort((a,b)=>catTot(b)-catTot(a)).slice(0,10);
const cser=ys.map((y,i)=>({name:String(y),color:PAL[i%7],values:cl.map(c=>sum(all.filter(t=>t.y==y&&t.cat==c),t=>t.amt))}));
chart("#c5",cl,cser,"group",undefined,true);legend("#l5",cser);
const dn=topc.slice(0,7).map((c,i)=>({name:c[0],color:PAL[i],value:Math.max(0,c[1])}));const rest=topc.slice(7).reduce((a,c)=>a+Math.max(0,c[1]),0);if(rest)dn.push({name:"All other",color:PAL[7],value:rest});
donut("#c6",dn);legend("#l6",dn);
// top payees
const pm={};cur.forEach(t=>{(pm[t.payee]=pm[t.payee]||{amt:0,n:0,cat:t.cat});pm[t.payee].amt+=t.amt;pm[t.payee].n++});
$("#payees").innerHTML="<table><tr><th>Payee</th><th>Category</th><th class='n'>Payments</th><th class='n'>Total</th></tr>"+Object.entries(pm).sort((a,b)=>b[1].amt-a[1].amt).slice(0,25).map(([p,v])=>`<tr><td>${esc(p)}</td><td class="mute">${esc(v.cat)}</td><td class="n">${v.n}</td><td class="n">${money(v.amt)}</td></tr>`).join("")+"</table>";
// category yoy table
$("#catyoy").innerHTML="<table><tr><th>Category</th>"+ys.map(y=>`<th class='n'>${y}</th>`).join("")+"<th class='n'>Change</th></tr>"+cl.map(c=>{
const v=ys.map(y=>sum(all.filter(t=>t.y==y&&t.cat==c),t=>t.amt));const a=v[v.length-2],b=v[v.length-1];
const ch=v.length>1&&a>0&&b>0?(b/a-1):null;return `<tr><td>${esc(c)}</td>`+v.map(x=>`<td class="n">${money(x)}</td>`).join("")+`<td class="n ${ch>0?"up":"down"}">${ch==null?"":(ch>0?"+":"")+(ch*100).toFixed(0)+"%"}</td></tr>`}).join("")+"</table>";
renderTx(all);
}
let LIM=200;
function renderTx(all){
const q=$("#q").value.toLowerCase(),fm=$("#fm").value,fc=$("#fc").value,fa=$("#fa").value,Y=+$("#year").value;
let r=all.filter(t=>t.y==Y&&(!fm||t.m==+fm)&&(!fc||t.cat==fc)&&(!fa||t.acct==fa)&&(!q||(t.payee+" "+t.what+" "+t.note).toLowerCase().includes(q)));
r.sort((a,b)=>b.d.localeCompare(a.d));
$("#count").textContent=`${r.length} transactions · ${money(sum(r,t=>t.amt),2)}`;
$("#tx").innerHTML="<table><tr><th>Date</th><th>Payee</th><th>Category</th><th>What it was for</th><th>Account</th><th class='n'>Amount</th></tr>"+r.slice(0,LIM).map(t=>`<tr><td>${t.d}</td><td>${esc(t.payee)}</td><td class="mute">${esc(t.cat)}</td><td class="mute">${esc(t.what)}${t.note?` <i>(${esc(t.note)})</i>`:""}</td><td class="mute">${esc(t.acct)}</td><td class="n">${money(t.amt,2)}</td></tr>`).join("")+"</table>"+(r.length>LIM?`<div style="padding:8px"><button onclick="LIM+=300;render()">Show more</button></div>`:"");
}
async function init(){
const j=await (await fetch("/api/data")).json();if(j.error){document.body.innerHTML="<main>"+esc(j.error)+"</main>";return}
if(j.setup){document.body.innerHTML=`<main><h1>Bills Dashboard</h1><p>Bills_Tracker.xlsx isn’t in the data folder (<code>${esc(j.data_dir)}</code>) yet. <a href="/">Upload it on the Statement Loader page.</a></p></main>`;return}
D=j;$("#updated").textContent="Workbook saved "+j.updated;
const yrs=[...new Set(j.tx.map(t=>+t[0].slice(0,4)))].sort().reverse();
$("#year").innerHTML=yrs.map(y=>`<option>${y}</option>`).join("");
$("#fm").innerHTML+=MON.map((m,i)=>`<option value="${i+1}">${m}</option>`).join("");
$("#fc").innerHTML+=[...new Set(j.tx.map(t=>t[2]))].sort().map(c=>`<option>${esc(c)}</option>`).join("");
$("#fa").innerHTML+=[...new Set(j.tx.map(t=>t[6]))].sort().map(c=>`<option>${esc(c)}</option>`).join("");
["year","fm","fc","fa"].forEach(i=>$("#"+i).onchange=()=>{LIM=200;render()});$("#q").oninput=()=>{LIM=200;render()};
render();
}
init();
</script>
</body>
</html>