Budget: expected income per month so Ready to assign works with mid-month and month-end paychecks
build-container / build (push) Successful in 41s
build-container / build (push) Successful in 41s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3d956f3fd2
commit
d7808a3925
2 files changed
+25
-11
No files matched your search
+9
-7
@@ -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=>({"&":"&","<":"<",">":">",'"':"""}[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>`}
|
||||
};
|
||||
|
||||
@@ -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)))
|
||||
Reference in new issue
Block a user