build-container / build (push) Successful in 40s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
187 lines
17 KiB
HTML
187 lines
17 KiB
HTML
<!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></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=>({"&":"&","<":"<",">":">",'"':"""}[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>
|