Files
money/app_dashboard.html
T
nmemmertandClaude Sonnet 5.5 4d076b2b73
build-container / build (push) Successful in 1m22s
Statement loader web app with Docker image and Gitea Actions build
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 12:41:35 -04:00

171 lines
15 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>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}
</style>
</head>
<body>
<main>
<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><a href="/">← Statement Loader</a></div>
<div class="kpis" id="kpis"></div>
<div class="grid">
<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. Card payments and transfers are not included (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;
$("#kpis").innerHTML=[
["Total paid",money(total),`${cur.length} payments`],
["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("");
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}
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>