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