build-container / build (push) Successful in 44s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
151 lines
14 KiB
HTML
151 lines
14 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Envelope Budget</title>
|
||
<style>
|
||
:root{--bg:#f6f7f9;--card:#fff;--ink:#1c2230;--mute:#667085;--line:#e4e7ec;--accent:#1f4fd8;--ok:#1a7f4b;--okbg:#e6f4ec;--bad:#b42318;--badbg:#fdeceb;--warn:#9a6700;--warnbg:#fff4d6;--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;--warn:#f0c14b;--warnbg:#3a2f10;--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: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}
|
||
button,select,input{font:inherit;color:var(--ink);background:var(--card);border:1px solid var(--line);border-radius:8px;padding:6px 12px}
|
||
button{cursor:pointer}button.primary{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}button:disabled{opacity:.45;cursor:default}
|
||
.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)}
|
||
.ready.zero{background:var(--okbg)}.ready.zero .v{color:var(--ok)}.ready.neg{background:var(--badbg)}.ready.neg .v{color:var(--bad)}.ready.pos{background:var(--warnbg)}.ready.pos .v{color:var(--warn)}
|
||
table{border-collapse:collapse;width:100%}th,td{padding: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}
|
||
input.amt{width:110px;text-align:right}
|
||
.bar{height:6px;border-radius:3px;background:var(--none);margin-top:5px;overflow:hidden;min-width:120px}.bar i{display:block;height:100%;background:var(--ok)}
|
||
.bar.warn i{background:#d9a400}.bar.bad i{background:var(--bad)}
|
||
.pill{display:inline-block;padding:1px 9px;border-radius:99px;font-size:12px;font-weight:600}.pill.ok{background:var(--okbg);color:var(--ok)}.pill.bad{background:var(--badbg);color:var(--bad)}.pill.zero{background:var(--none);color:var(--mute)}
|
||
.alert{padding:10px 12px;border-radius:8px;margin:12px 0;background:var(--okbg);color:var(--ok)}.alert.bad{background:var(--badbg);color:var(--bad)}
|
||
#move{display:none;margin-top:12px}tr.empty td{color:var(--mute)}
|
||
.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" class="on" aria-current="page">Budget</a><a href="/dashboard">Dashboard</a><a href="/accounts">Accounts</a></nav>
|
||
<div class="row"><h1>Envelope Budget</h1><div class="spacer"></div></div>
|
||
<div class="row" style="margin:14px 0 0">
|
||
<button id="prev">‹</button><b id="mlabel" style="min-width:120px;text-align:center;font-size:16px"></b><button id="next">›</button>
|
||
<select id="mpick"></select>
|
||
<div class="spacer"></div>
|
||
<button id="copy" title="Use last month's amounts">Copy last month</button>
|
||
<button id="suggest" title="Average of the last 3 months of spending">Suggest from spending</button>
|
||
<button id="addenv" title="Add a new category / envelope">Add envelope</button>
|
||
<button id="movebtn">Move money</button>
|
||
<button class="primary" id="save" disabled>Save budget</button>
|
||
</div>
|
||
<div id="msg"></div>
|
||
<div class="kpis">
|
||
<div class="card kpi"><div class="l" id="il">Income this month</div><div class="v" id="inc"></div><div class="s">received so far</div>
|
||
<div class="s" style="margin-top:6px">Expecting <input id="exp" class="amt" type="number" min="0" step="1" style="width:90px" title="Total income you expect this month, including paychecks that haven't arrived yet"> this month</div></div>
|
||
<div class="card kpi"><div class="l">Budgeted this month</div><div class="v" id="bud"></div><div class="s" id="buds"></div></div>
|
||
<div class="card kpi"><div class="l">Spent this month</div><div class="v" id="spent"></div><div class="s">from the Bill Log</div></div>
|
||
<div class="card kpi ready" id="readybox"><div class="l">Ready to assign</div><div class="v" id="ready"></div><div class="s" id="readys"></div></div>
|
||
</div>
|
||
<div class="card" id="movepanel" style="display:none;margin-bottom:16px">
|
||
<h2>Move money between envelopes</h2>
|
||
<div class="row"><select id="mfrom"></select> → <select id="mto"></select><input id="mamt" class="amt" type="number" min="0" step="1" placeholder="Amount"><button id="mgo" class="primary">Move</button><button id="mcancel">Cancel</button></div>
|
||
<div class="mute" style="margin-top:6px">Changes the Budgeted amounts for this month. Remember to Save.</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="row" style="margin-bottom:6px"><h2 style="margin:0">Envelopes</h2><div class="spacer"></div><label class="mute"><input type="checkbox" id="showall"> show empty envelopes</label></div>
|
||
<table id="tbl"></table>
|
||
<p class="mute" style="margin:10px 0 0">Available = everything you’ve budgeted since the first budget month minus everything spent, so leftovers roll over and overspending carries forward. Ready to assign = income since then minus everything budgeted. Income paid in pieces (like the 15th and the last day)? Type the month’s full total in “Expecting”, and it counts as soon as you budget it. Aim for $0.</p>
|
||
</div>
|
||
<div class="card" id="histcard" style="margin-top:16px;display:none">
|
||
<div class="row" style="margin-bottom:6px"><h2 style="margin:0">History by month</h2><div class="spacer"></div>
|
||
<select id="hmetric"><option value="2">Left over (rolls forward)</option><option value="0">Budgeted</option><option value="1">Spent</option></select></div>
|
||
<div style="overflow-x:auto"><table id="hist"></table></div>
|
||
<p class="mute" style="margin:10px 0 0">Left over is what the envelope held at the end of that month: everything budgeted so far minus everything spent. It is worked out from the Budget tab and Bill Log, so it updates if you edit a past month.</p>
|
||
</div>
|
||
</main>
|
||
<script>
|
||
const TOKEN="__TOKEN__";
|
||
const $=s=>document.querySelector(s),MON=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||
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 mname=m=>MON[+m.slice(5)-1]+" "+m.slice(0,4);
|
||
let S=null,rows=[],dirty=false,exp=0,month=new URLSearchParams(location.search).get("month")||null;
|
||
function add(m,n){const k=+m.slice(0,4)*12+(+m.slice(5)-1)+n;return Math.floor(k/12)+"-"+String(k%12+1).padStart(2,"0")}
|
||
async function load(m){
|
||
const r=await fetch("/api/budget"+(m?"?month="+m:""));S=await r.json();
|
||
if(S.setup){document.body.innerHTML='<main><h1>Envelope Budget</h1><p>Bills_Tracker.xlsx isn’t in the data folder yet. <a href="/">Set it up on the Statement Loader page.</a></p></main>';return}
|
||
if(S.error){$("#msg").innerHTML=`<div class="alert bad">${esc(S.error)}</div>`;return}
|
||
month=S.month;rows=S.envelopes.map(e=>({...e,edit:e.budgeted}));dirty=false;exp=S.expected||0;$("#exp").value=exp||"";
|
||
$("#mpick").innerHTML=S.months.map(x=>`<option value="${x}"${x==month?" selected":""}>${mname(x)}</option>`).join("");
|
||
$("#mlabel").textContent=mname(month);render();
|
||
}
|
||
function render(){
|
||
const sumB=rows.reduce((a,r)=>a+(+r.edit||0),0),spent=rows.reduce((a,r)=>a+r.spent,0);
|
||
const income=Math.max(S.income_month,exp),ready=S.ready_before+income-sumB; // later paychecks count once you say to expect them
|
||
$("#inc").textContent=money(S.income_month);$("#il").textContent=`Income · ${mname(month)}`;
|
||
$("#bud").textContent=money(sumB);$("#buds").textContent=income>=1?`${Math.round(sumB/income*100)}% of ${exp>S.income_month?"expected":"this month’s"} income`:"";
|
||
$("#spent").textContent=money(spent);
|
||
$("#ready").textContent=money(ready);const rb=$("#readybox");const z=Math.abs(ready)<0.005;
|
||
rb.className="card kpi ready "+(z?"zero":ready<0?"neg":"pos");
|
||
$("#readys").textContent=z?"Every dollar has a job":ready<0?"Over-assigned: take money from an envelope":"Left to assign to envelopes";
|
||
$("#save").disabled=!dirty;$("#copy").disabled=!S.prev_has;
|
||
const show=$("#showall").checked;
|
||
const vis=rows.filter(r=>show||r.edit||r.spent||r.prior||r.available);
|
||
$("#tbl").innerHTML="<tr><th>Envelope</th><th class='n'>Budgeted</th><th class='n'>Spent</th><th class='n'>Available</th></tr>"+vis.map(r=>{
|
||
const i=rows.indexOf(r),avail=r.prior+(+r.edit||0)-r.spent,pot=r.prior+(+r.edit||0),pct=pot>0?Math.min(100,Math.max(0,r.spent/pot*100)):(r.spent>0?100:0);
|
||
const cls=avail<-0.005?"bad":pct>=85?"warn":"";
|
||
return `<tr><td>${esc(r.envelope)}${r.prior?`<div class="mute" style="font-size:12px">${r.prior<0?"overspent":"rolled over"} ${money(Math.abs(r.prior))} from before</div>`:""}</td>
|
||
<td class="n"><input class="amt" type="number" step="1" min="0" value="${r.edit||""}" placeholder="0" data-i="${i}"></td>
|
||
<td class="n">${money(r.spent)}</td>
|
||
<td class="n"><span class="pill ${avail<-0.005?"bad":Math.abs(avail)<0.005?"zero":"ok"}">${money(avail)}</span><div class="bar ${cls}"><i style="width:${pct}%"></i></div></td></tr>`}).join("")
|
||
+`<tr><td><b>Total</b></td><td class="n"><b>${money(sumB)}</b></td><td class="n"><b>${money(spent)}</b></td><td class="n"><b>${money(rows.reduce((a,r)=>a+r.prior+(+r.edit||0)-r.spent,0))}</b></td></tr>`;
|
||
document.querySelectorAll("input.amt").forEach(inp=>inp.oninput=()=>{rows[+inp.dataset.i].edit=inp.value===""?0:+inp.value;dirty=true;softRender(inp)});
|
||
}
|
||
function softRender(active){ // update totals without losing focus in the input
|
||
const pos=active.selectionStart,i=active.dataset.i;render();
|
||
const n=document.querySelector(`input.amt[data-i="${i}"]`);if(n){n.focus();try{n.setSelectionRange(pos,pos)}catch(e){}}
|
||
}
|
||
let H=null;
|
||
async function loadHist(){try{H=await (await fetch("/api/budget_history")).json()}catch(e){H=null}renderHist()}
|
||
function renderHist(){
|
||
const c=$("#histcard");if(!H||!H.months.length){c.style.display="none";return}c.style.display="block";
|
||
const k=+$("#hmetric").value,ms=H.months.slice(-12),off=H.months.length-ms.length,cur=k===2;
|
||
const cell=v=>`<td class="n"><span style="${cur&&v<-0.005?"color:var(--bad)":cur&&v>0.005?"color:var(--ok)":""}">${Math.abs(v)<0.005?"–":money(v)}</span></td>`;
|
||
const tot=ms.map((_,i)=>H.envelopes.reduce((a,e)=>a+e.cells[off+i][k],0));
|
||
$("#hist").innerHTML="<tr><th>Envelope</th>"+ms.map(m=>`<th class="n">${MON[+m.slice(5)-1]} ${m.slice(2,4)}</th>`).join("")+"</tr>"+
|
||
H.envelopes.map(e=>`<tr><td>${esc(e.envelope)}</td>${ms.map((_,i)=>cell(e.cells[off+i][k])).join("")}</tr>`).join("")+
|
||
`<tr><td><b>Total</b></td>${tot.map(v=>`<td class="n"><b>${money(v)}</b></td>`).join("")}</tr>`;
|
||
}
|
||
$("#hmetric").onchange=renderHist;
|
||
function go(m){if(dirty&&!confirm("Discard unsaved changes?"))return;load(m)}
|
||
$("#prev").onclick=()=>go(add(month,-1));$("#next").onclick=()=>go(add(month,1));$("#mpick").onchange=e=>go(e.target.value);
|
||
$("#showall").onchange=render;
|
||
$("#exp").oninput=e=>{exp=+e.target.value||0;dirty=true;render()};
|
||
$("#copy").onclick=async()=>{const p=await (await fetch("/api/budget?month="+add(month,-1))).json();p.envelopes.forEach(e=>{const r=rows.find(x=>x.envelope==e.envelope);if(r)r.edit=e.budgeted});dirty=true;render()};
|
||
$("#suggest").onclick=()=>{rows.forEach(r=>{if(r.suggest)r.edit=r.suggest});dirty=true;render()};
|
||
$("#movebtn").onclick=()=>{const o=rows.map(r=>`<option>${esc(r.envelope)}</option>`).join("");$("#mfrom").innerHTML=o;$("#mto").innerHTML=o;$("#movepanel").style.display="block"};
|
||
$("#addenv").onclick=async()=>{
|
||
const name=(prompt("Name for the new envelope (category):")||"").trim();if(!name)return;
|
||
try{const r=await fetch("/api/add_category",{method:"POST",headers:{"X-Token":TOKEN},body:JSON.stringify({name})});const j=await r.json();if(!r.ok)throw new Error(j.error);
|
||
rows.push({envelope:j.name,budgeted:0,spent:0,prior:0,available:0,suggest:0,edit:0});$("#showall").checked=true;render();$("#msg").innerHTML=`<div class="alert">${esc(j.message)}</div>`}
|
||
catch(e){$("#msg").innerHTML=`<div class="alert bad">${esc(e.message)}</div>`}};
|
||
$("#mcancel").onclick=()=>$("#movepanel").style.display="none";
|
||
$("#mgo").onclick=()=>{const f=rows.find(r=>r.envelope==$("#mfrom").value),t=rows.find(r=>r.envelope==$("#mto").value),a=+$("#mamt").value;
|
||
if(!f||!t||f===t||!(a>0))return;f.edit=Math.max(0,(+f.edit||0)-a);t.edit=(+t.edit||0)+a;dirty=true;$("#movepanel").style.display="none";$("#mamt").value="";$("#showall").checked||(0);render()};
|
||
$("#save").onclick=async()=>{
|
||
const budgets={};rows.forEach(r=>{if(+r.edit)budgets[r.envelope]=+r.edit});if(exp>0)budgets["Expected income"]=exp;
|
||
try{const r=await fetch("/api/budget",{method:"POST",headers:{"X-Token":TOKEN},body:JSON.stringify({month,budgets})});const j=await r.json();if(!r.ok)throw new Error(j.error);
|
||
await load(month);loadHist();$("#msg").innerHTML=`<div class="alert">${esc(j.message)}</div>`}catch(e){$("#msg").innerHTML=`<div class="alert bad">${esc(e.message)}</div>`}
|
||
};
|
||
addEventListener("beforeunload",e=>{if(dirty){e.preventDefault();e.returnValue=""}});
|
||
load(month);loadHist();
|
||
</script>
|
||
</body>
|
||
</html>
|