Budget: expected income per month so Ready to assign works with mid-month and month-end paychecks
build-container / build (push) Successful in 41s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-21 15:10:17 -04:00
1 parent 3d956f3fd2
commit d7808a3925
2 files changed
+25 -11

No files matched your search

+9 -7
View File
@@ -42,7 +42,8 @@ input.amt{width:110px;text-align:right}
</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" 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>
@@ -55,7 +56,7 @@ input.amt{width:110px;text-align:right}
<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>
<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>
</main>
<script>
@@ -64,21 +65,21 @@ const $=s=>document.querySelector(s),MON=["Jan","Feb","Mar","Apr","May","Jun","J
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;
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;
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 ready=S.ready+(S.budgeted_month-sumB);
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=S.income_month>=1?`${Math.round(sumB/S.income_month*100)}% of this month’s income`:"";
$("#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");
@@ -103,6 +104,7 @@ function softRender(active){ // update totals without losing focus in the input
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"};
@@ -115,7 +117,7 @@ $("#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});
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);$("#msg").innerHTML=`<div class="alert">${esc(j.message)}</div>`}catch(e){$("#msg").innerHTML=`<div class="alert bad">${esc(e.message)}</div>`}
};
+16 -4
View File
@@ -14,6 +14,8 @@ def ym_add(ym, n):
def this_month(): return f"{dt.date.today():%Y-%m}"
EXPECTED = "Expected income" # stored on the Budget tab like an envelope row, but it is income you plan on, not an envelope
def compute(D, month):
cats = list(D.get("categories") or [])
spent = defaultdict(float) # (cat, ym)
@@ -23,10 +25,12 @@ def compute(D, month):
income = defaultdict(float)
for i in D.get("income", []): income[i[0][:7]] += i[3]
bud = defaultdict(float) # (cat, ym)
expected = {} # ym -> income you expect this month (paychecks still to come)
for m, env, amt in D.get("budget", []):
if env == EXPECTED: expected[m] = amt; continue
bud[(env, m)] += amt
if env not in cats: cats.append(env)
budget_months = sorted({m for (_, m) in bud})
budget_months = sorted({m for (_, m) in bud} | set(expected))
start = min(budget_months[0], month) if budget_months else month
first_tx = min((t[0][:7] for t in D["tx"]), default=month)
@@ -41,11 +45,19 @@ def compute(D, month):
rows.append(dict(envelope=c, budgeted=round(b, 2), spent=round(s, 2), prior=round(avail - b + s, 2),
available=round(avail, 2), suggest=suggest))
inc_month = income.get(month, 0.0)
inc_cum = sum(v for m, v in income.items() if start <= m <= month)
bud_cum = sum(v for (_, m), v in bud.items() if m <= month)
# income that counts for a month = whichever is bigger: what actually arrived, or what you said to expect
counted = lambda m: max(income.get(m, 0.0), expected.get(m, 0.0))
prev = ym_add(month, -1)
inc_prev = sum(counted(m) for m in {*income, *expected} if start <= m <= prev)
bud_prev = sum(v for (_, m), v in bud.items() if m <= prev)
prev_income = round(income.get(prev, 0.0), -1)
# a month from today on starts out expecting last month's income, until you set a figure
exp_default = expected.get(month, prev_income if month >= this_month() else 0.0)
inc_cum = inc_prev + counted(month); bud_cum = bud_prev + sum(v for (_, m), v in bud.items() if m == month)
months, m = [], min(first_tx, start)
while m <= ym_add(this_month(), 1):
months.append(m); m = ym_add(m, 1)
return dict(month=month, start=start, envelopes=rows, income_month=round(inc_month, 2),
ready=round(inc_cum - bud_cum, 2), budgeted_month=round(sum(r["budgeted"] for r in rows), 2),
ready=round(inc_cum - bud_cum, 2), ready_before=round(inc_prev - bud_prev, 2),
expected=exp_default, expected_saved=month in expected, budgeted_month=round(sum(r["budgeted"] for r in rows), 2),
has_budget=bool(budget_months), months=months, prev_has=any(v for (c, m), v in bud.items() if m == ym_add(month, -1)))