Files
money/app_budget.html
T
nmemmertandClaude Sonnet 5.5 ae29738cee
build-container / build (push) Successful in 47s
Budget: hide percent-of-income when month income is under $1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 14:32:17 -04:00

121 lines
10 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>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)}
</style>
</head>
<body>
<main>
<div class="row"><h1>Envelope Budget</h1><div class="spacer"></div><a href="/dashboard">Dashboard</a><a href="/">Statement Loader</a></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="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">paychecks + interest</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. Aim for $0.</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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const mname=m=>MON[+m.slice(5)-1]+" "+m.slice(0,4);
let S=null,rows=[],dirty=false,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;
$("#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 ready=S.ready+(S.budgeted_month-sumB);
$("#inc").textContent=money(S.income_month);$("#il").textContent=`Income · ${mname(month)}`;
$("#bud").textContent=money(sumB);$("#buds").textContent=S.income_month>=1?`${Math.round(sumB/S.income_month*100)}% of 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){}}
}
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;
$("#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"};
$("#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});
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);$("#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);
</script>
</body>
</html>