Last active
May 24, 2026 14:37
-
-
Save StephenBrown2/6cf278ac3e109ec20489c385f6c8e562 to your computer and use it in GitHub Desktop.
Budget Calc
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"> | |
| <title>2026 Household Budget · Delaware, Ohio</title> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;600&display=swap'); | |
| :root { | |
| --bg:#f7f5f0;--surface:#fff;--s2:#f0ede6; | |
| --border:#e2ddd4;--border2:#c8c0b0; | |
| --text:#1a1814;--t2:#6b6257;--t3:#9e9589; | |
| --accent:#2d5a3d;--abg:#e8f2ec; | |
| --amber:#b85c00;--ambg:#fef3e6; | |
| --blue:#1a3a6b;--bbg:#e8eef8; | |
| --red:#8b2020; | |
| --r:6px;--r2:10px; | |
| --sh:0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04); | |
| } | |
| *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} | |
| html,body{overflow-x:hidden;width:100%} | |
| html{font-size:20px} | |
| body{font-family:'DM Sans',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.5} | |
| .hdr{background:var(--text);color:#f7f5f0;padding:18px 24px 14px;display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;position:fixed;top:0;left:0;right:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.25)} | |
| .hdr h1{font-family:'DM Serif Display',serif;font-size:1.45rem;font-weight:400;letter-spacing:-.02em} | |
| .hdr-sub{font-size:.7rem;color:#a09880;font-family:'DM Mono',monospace} | |
| .hdr-badge{margin-left:auto;font-size:.63rem;font-family:'DM Mono',monospace;color:#a09880;border:1px solid #3a3630;padding:3px 10px;border-radius:20px;white-space:nowrap} | |
| .ctrl-bar{background:var(--surface);border-bottom:1px solid var(--border);padding:14px 24px;display:flex;flex-direction:column;gap:12px;box-shadow:var(--sh)} | |
| .ctrl-row{display:flex;flex-wrap:wrap;gap:14px;align-items:flex-end} | |
| .ctrl-grp{display:flex;flex-direction:column;gap:5px;min-width:0} | |
| .ctrl-lbl{font-size:.63rem;font-family:'DM Mono',monospace;text-transform:uppercase;letter-spacing:.08em;color:var(--t2)} | |
| .sal-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap} | |
| input.sal-num{font-family:'DM Serif Display',serif;font-size:1.75rem;color:var(--text);border:none;border-bottom:2px solid transparent;background:transparent;outline:none;width:150px;padding:1px 0;cursor:text;-moz-appearance:textfield} | |
| input.sal-num:focus{border-bottom-color:var(--text)} | |
| input.sal-num::-webkit-outer-spin-button,input.sal-num::-webkit-inner-spin-button{-webkit-appearance:none;margin:0} | |
| .sal-hint{font-size:.68rem;color:var(--t3);font-family:'DM Mono',monospace;white-space:nowrap} | |
| input[type=range]{-webkit-appearance:none;appearance:none;width:200px;max-width:100%;height:4px;background:var(--border);border-radius:2px;outline:none;cursor:pointer} | |
| input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:18px;height:18px;border-radius:50%;background:var(--text);cursor:pointer;border:3px solid var(--surface);box-shadow:0 0 0 1px var(--text)} | |
| input[type=range].slim{width:90px;height:3px} | |
| input[type=range].slim::-webkit-slider-thumb{width:14px;height:14px;border:2px solid var(--surface);box-shadow:0 0 0 1px var(--t2);background:var(--t2)} | |
| .pill{display:flex;border:1px solid var(--border);border-radius:var(--r);overflow:hidden;font-size:.7rem;flex-wrap:wrap} | |
| .pill input[type=radio]{display:none} | |
| .pill label{padding:6px 10px;cursor:pointer;color:var(--t2);white-space:nowrap;border-right:1px solid var(--border);user-select:none;transition:background .12s,color .12s} | |
| .pill label:last-of-type{border-right:none} | |
| .pill input[type=radio]:checked+label{background:var(--text);color:#f7f5f0;font-weight:500} | |
| .num-inp{display:flex;align-items:center;gap:5px} | |
| .num-inp span{font-size:.8rem;color:var(--t2)} | |
| input.hlc-num{width:88px;font-family:'DM Mono',monospace;font-size:.8rem;padding:5px 7px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--text);outline:none;-moz-appearance:textfield} | |
| input.hlc-num:focus{border-color:var(--text)} | |
| input.hlc-num::-webkit-outer-spin-button,input.hlc-num::-webkit-inner-spin-button{-webkit-appearance:none} | |
| .metrics{padding:12px 24px;display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:8px} | |
| .metric{background:var(--surface);border:1px solid var(--border);border-radius:var(--r2);padding:10px 12px;box-shadow:var(--sh)} | |
| .m-l{font-size:.6rem;font-family:'DM Mono',monospace;text-transform:uppercase;letter-spacing:.07em;color:var(--t3);margin-bottom:3px} | |
| .m-v{font-family:'DM Serif Display',serif;font-size:1.15rem;color:var(--text);line-height:1.2} | |
| .m-s{font-size:.6rem;color:var(--t3);margin-top:2px;font-family:'DM Mono',monospace} | |
| .metric.grn .m-v{color:var(--accent)} | |
| .metric.amb .m-v{color:var(--amber)} | |
| .metric.red .m-v{color:var(--red)} | |
| .warns{padding:0 24px 8px;display:flex;flex-direction:column;gap:5px} | |
| .warn-box{background:var(--ambg);border:1px solid #e8c080;border-radius:var(--r2);padding:8px 12px;font-size:.73rem;color:var(--amber);line-height:1.5} | |
| .warn-box strong{font-weight:500} | |
| .info-box{background:var(--bbg);border:1px solid #b0c0de;border-radius:var(--r2);padding:8px 12px;font-size:.73rem;color:var(--blue);line-height:1.5} | |
| .info-box strong{font-weight:500} | |
| .main{padding:0 24px 32px;display:grid;grid-template-columns:1fr 360px;gap:12px;align-items:start} | |
| .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r2);overflow:hidden;box-shadow:var(--sh)} | |
| .card-hd{padding:9px 13px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;background:var(--s2)} | |
| .card-hd h2{font-family:'DM Mono',monospace;font-size:.65rem;font-weight:500;text-transform:uppercase;letter-spacing:.08em;color:var(--t2)} | |
| .hd-r{font-family:'DM Mono',monospace;font-size:.7rem;color:var(--t3)} | |
| .tbl-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;width:100%;max-width:100%} | |
| table{width:100%;border-collapse:collapse;min-width:400px} | |
| thead th{padding:6px 11px;text-align:right;font-size:.6rem;font-family:'DM Mono',monospace;text-transform:uppercase;letter-spacing:.06em;color:var(--t3);border-bottom:1px solid var(--border);background:var(--s2);white-space:nowrap} | |
| .th-short{display:none} | |
| thead th:first-child{text-align:left} | |
| tbody tr{border-bottom:1px solid var(--border)} | |
| tbody tr:last-child{border-bottom:none} | |
| tbody tr:hover{background:var(--bg)} | |
| td{padding:5px 11px;font-size:.77rem;text-align:right;color:var(--text)} | |
| td:first-child{text-align:left;color:var(--t2)} | |
| tr.g-row td{background:var(--s2);padding:5px 11px;font-size:.6rem;font-family:'DM Mono',monospace;text-transform:uppercase;letter-spacing:.07em;color:var(--t3)} | |
| tr.g-row td:not(:first-child){text-align:right;color:var(--text);font-size:.72rem;letter-spacing:0;text-transform:none;font-weight:500} | |
| tr.g-row td .dot{display:inline-block;width:7px;height:7px;border-radius:1px;margin-right:5px;vertical-align:middle} | |
| tr.sub-row td{font-weight:500;font-size:.77rem;background:var(--bg);font-family:'DM Mono',monospace} | |
| tr.sub-row td:first-child{font-weight:400;color:var(--t2)} | |
| tr.ttl-row td{font-weight:600;border-top:2px solid var(--text);padding:9px 11px;font-family:'DM Serif Display',serif;font-size:.88rem} | |
| tr.ttl-row td:first-child{font-family:'DM Mono',monospace;font-weight:500;font-size:.63rem;letter-spacing:.05em;text-transform:uppercase;color:var(--t2)} | |
| tr.ttl-row.ok td{color:var(--accent)} | |
| tr.ttl-row.over td{color:var(--red)} | |
| tr.fl-line td{font-style:italic} | |
| tr.fl-line td:first-child{font-style:normal} | |
| tr.life-auto td:first-child::after{content:' ↑↑';font-size:.58rem;color:var(--accent);vertical-align:super} | |
| tr.life-none td{color:var(--t3)} | |
| .note{font-size:.61rem;color:var(--t3);display:block} | |
| .mono{font-family:'DM Mono',monospace} | |
| .life-badge{display:inline-block;font-size:.57rem;padding:1px 5px;border-radius:3px;background:var(--abg);color:var(--accent);font-family:'DM Mono',monospace;margin-left:4px;vertical-align:middle} | |
| .life-none-badge{background:var(--s2);color:var(--t3)} | |
| .rcol{display:flex;flex-direction:column;gap:9px} | |
| .sc-hd{padding:9px 13px;border-bottom:1px solid var(--border);background:var(--s2)} | |
| .sc-hd h3{font-family:'DM Mono',monospace;font-size:.65rem;font-weight:500;text-transform:uppercase;letter-spacing:.07em;color:var(--t2)} | |
| .t-row{display:flex;justify-content:space-between;align-items:baseline;padding:6px 13px;font-size:.76rem;border-bottom:1px solid var(--border);gap:8px} | |
| .t-row:last-child{border-bottom:none} | |
| .t-row .tl{color:var(--t2);flex:1} | |
| .t-row .tv{font-family:'DM Mono',monospace;font-weight:500;color:var(--text);white-space:nowrap} | |
| .tn{font-size:.6rem;color:var(--t3);display:block} | |
| .t-total{display:flex;justify-content:space-between;align-items:center;padding:8px 13px;font-size:.8rem;font-weight:500;background:var(--bg);border-top:1px solid var(--border2);gap:8px} | |
| .t-total .tl{color:var(--t2)} | |
| .t-total .tv{font-family:'DM Mono',monospace;color:var(--text)} | |
| .a-bar{display:flex;height:16px;border-radius:3px;overflow:hidden;margin:10px 13px 7px} | |
| .a-seg{transition:width .3s} | |
| .a-leg{display:flex;flex-wrap:wrap;gap:7px;padding:0 13px 10px} | |
| .leg-i{display:flex;align-items:center;gap:3px;font-size:.6rem;color:var(--t2);font-family:'DM Mono',monospace} | |
| .leg-dot{width:7px;height:7px;border-radius:1px;flex-shrink:0} | |
| .fx-row{display:flex;align-items:center;padding:8px 13px;gap:8px;border-bottom:1px solid var(--border)} | |
| .fx-row:last-child{border-bottom:none} | |
| .fx-dot{width:9px;height:9px;border-radius:2px;flex-shrink:0} | |
| .fx-lbl{flex:1;font-size:.76rem;color:var(--t2);min-width:0} | |
| .fx-sub{font-size:.6rem;color:var(--t3);display:block;margin-top:1px} | |
| .fx-val{font-family:'DM Mono',monospace;font-size:.8rem;font-weight:500;color:var(--text);min-width:54px;text-align:right;white-space:nowrap} | |
| .fx-row.hl{background:var(--abg)} | |
| .fx-row.hl .fx-val{color:var(--accent)} | |
| .fx-row.bl{background:var(--bbg)} | |
| .fx-row.bl .fx-val{color:var(--blue)} | |
| .fx-row.wn{background:var(--ambg)} | |
| .fx-row.wn .fx-val{color:var(--amber)} | |
| .flow-wrap{background:var(--bg);border-top:1px solid var(--border);padding:10px 13px} | |
| .flow-title{font-size:.6rem;font-family:'DM Mono',monospace;text-transform:uppercase;letter-spacing:.07em;color:var(--t3);margin-bottom:7px} | |
| .flow-steps{display:flex;flex-direction:column} | |
| .flow-step{display:flex;align-items:stretch} | |
| .flow-spine{display:flex;flex-direction:column;align-items:center;width:20px;flex-shrink:0} | |
| .flow-circle{width:14px;height:14px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:.55rem;font-weight:600;color:#fff;flex-shrink:0;margin-top:2px} | |
| .flow-line{flex:1;width:2px;background:var(--border);margin:0 auto} | |
| .flow-content{flex:1;padding:2px 0 8px 8px;display:flex;justify-content:space-between;align-items:baseline;gap:4px;font-size:.73rem} | |
| .flow-name{color:var(--t2)} | |
| .flow-tag{font-size:.58rem;color:var(--t3);font-family:'DM Mono',monospace} | |
| .flow-step:last-child .flow-line{background:transparent} | |
| .pay-area{padding:10px 13px;background:var(--bg);border-top:1px solid var(--border)} | |
| .pay-date{font-family:'DM Serif Display',serif;font-size:1.05rem;color:var(--accent);text-align:center;padding:3px 0} | |
| .pay-int{font-size:.65rem;font-family:'DM Mono',monospace;color:var(--t3);text-align:center;margin-bottom:7px} | |
| .prog{height:6px;background:var(--border);border-radius:3px;overflow:hidden;margin-bottom:5px} | |
| .prog-fill{height:100%;border-radius:3px;background:var(--accent);transition:width .4s} | |
| .prog-lbls{display:flex;justify-content:space-between;font-size:.6rem;font-family:'DM Mono',monospace;color:var(--t3)} | |
| .pay-note{font-size:.7rem;color:var(--t2);padding-top:7px;line-height:1.5} | |
| .pay-note strong{color:var(--text);font-weight:500} | |
| .pay-stat{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:8px} | |
| .pay-stat-item{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:6px 8px} | |
| .pay-stat-l{font-size:.58rem;font-family:'DM Mono',monospace;text-transform:uppercase;letter-spacing:.06em;color:var(--t3);margin-bottom:2px} | |
| .pay-stat-v{font-family:'DM Mono',monospace;font-size:.78rem;font-weight:500;color:var(--text)} | |
| .ded-tag{display:inline-block;font-size:.57rem;padding:1px 5px;border-radius:3px;font-family:'DM Mono',monospace;margin-left:4px;vertical-align:middle} | |
| .ded-tag.wn{background:var(--ambg);color:var(--amber)} | |
| .ded-tag.ok{background:var(--abg);color:var(--accent)} | |
| /* Life insurance tier table in side panel */ | |
| .life-tiers{border:0.5px solid var(--border);border-radius:var(--r);overflow:hidden;margin:10px 13px} | |
| .life-tier-row{display:grid;grid-template-columns:1fr auto auto;padding:5px 9px;font-size:.7rem;border-bottom:0.5px solid var(--border);align-items:center;gap:8px} | |
| .life-tier-row:last-child{border-bottom:none} | |
| .life-tier-row.selected{background:var(--abg)} | |
| .life-tier-row .lt-lbl{color:var(--t2)} | |
| .life-tier-row .lt-mo{font-family:'DM Mono',monospace;font-weight:500;color:var(--text)} | |
| .life-tier-row .lt-tag{font-size:.58rem;padding:1px 5px;border-radius:3px;font-family:'DM Mono',monospace;white-space:nowrap} | |
| .lt-tag.auto{background:var(--abg);color:var(--accent)} | |
| .lt-tag.manual{background:var(--bbg);color:var(--blue);font-weight:500} | |
| .lt-tag.would-auto{background:var(--s2);color:var(--t3)} | |
| .lt-tag.avail{background:var(--s2);color:var(--t3)} | |
| .lt-tag.unafford{background:var(--redbg,#fdeaea);color:var(--red)} | |
| .life-tier-row{cursor:pointer;transition:background .1s} | |
| .life-tier-row:hover:not(.selected){background:var(--s2)} | |
| .life-reset{display:flex;align-items:center;justify-content:space-between;padding:7px 13px 5px;font-size:.7rem;color:var(--blue);border-bottom:1px solid var(--border)} | |
| .life-reset button{font-size:.68rem;font-family:'DM Mono',monospace;padding:3px 9px;border:1px solid var(--blue);border-radius:var(--r);background:var(--bbg);color:var(--blue);cursor:pointer} | |
| .life-reset button:hover{background:var(--blue);color:#fff} | |
| footer{padding:12px 24px;border-top:1px solid var(--border);font-size:.6rem;font-family:'DM Mono',monospace;color:var(--t3);line-height:1.7;background:var(--surface)} | |
| .foot-list{list-style:none;padding:0;margin:0} | |
| .foot-list li{display:inline} | |
| .foot-list li:not(:last-child)::after{content:' · '} | |
| /* ── W-4 grid layout (33/66) ── */ | |
| .w4-grid{display:grid;grid-template-columns:1fr 2fr} | |
| .w4-lbl,.w4-val{padding:7px 13px;border-bottom:1px solid var(--border);font-size:.76rem;line-height:1.5} | |
| .w4-lbl{color:var(--t2)} | |
| .w4-val{font-family:'DM Mono',monospace;font-weight:500;text-align:right;overflow-wrap:break-word} | |
| .w4-note{font-size:.6rem;color:var(--t3);display:block;text-align:right;margin-top:2px;overflow-wrap:break-word;white-space:normal} | |
| /* Remove border on the last row so it doesn't double up with the footer border-top */ | |
| .w4-grid>div:nth-last-child(-n+2){border-bottom:none} | |
| .w4-footer{padding:10px 13px;border-top:1px solid var(--border);background:var(--bg)} | |
| .w4-link{display:inline-flex;align-items:center;gap:5px;font-size:.75rem;font-family:'DM Mono',monospace;color:var(--blue);text-decoration:none;font-weight:500} | |
| .w4-footer-note{font-size:.65rem;color:var(--t3);margin-top:5px;line-height:1.6} | |
| /* ── Content width — centered on wide viewports (~1250px) ── */ | |
| /* Metrics/warns/main: constrain the element directly (no full-bleed background) */ | |
| .metrics,.warns,.alloc-section,.main{max-width:1250px;margin-left:auto;margin-right:auto} | |
| .alloc-section{padding:0 24px 8px} | |
| /* Ctrl-bar rows: inner content aligns with the 1250px grid below */ | |
| .ctrl-row{max-width:calc(1250px - 48px);margin-left:auto;margin-right:auto;width:100%} | |
| /* Header: background bleeds to edges; padding dynamically centers content at 1250px. | |
| max() ensures it never shrinks below the normal 24px on narrow viewports. */ | |
| .hdr{ | |
| padding-left:max(24px, calc((100% - 1250px) / 2)); | |
| padding-right:max(24px, calc((100% - 1250px) / 2)); | |
| } | |
| @media(max-width:800px){.metrics{grid-template-columns:repeat(3,1fr);padding:10px 16px}.main{grid-template-columns:1fr;padding:0 16px 24px}.ctrl-bar{padding:12px 16px}.warns{padding:0 16px 8px}footer{padding:10px 16px}.hdr{padding:12px 16px 10px}} | |
| @media(max-width:540px){.metrics{grid-template-columns:repeat(2,1fr);gap:6px;padding:8px 12px}.m-v{font-size:1rem}.ctrl-bar{padding:10px 12px}.hdr{padding:10px 12px}.hdr h1{font-size:1.2rem}.hdr-badge{margin-left:0}.main{padding:0 12px 20px}.warns{padding:0 12px 6px}footer{padding:8px 12px}input[type=range]{width:160px}input.sal-num{font-size:1.5rem;width:130px}} | |
| @media(max-width:480px){ | |
| /* 2-col metrics fits Pixel 9 (412px) and similar phones */ | |
| .metrics{grid-template-columns:repeat(2,1fr)} | |
| .m-v{font-size:.95rem} | |
| /* Hide Annual (col 3) only — keep % take-home (col 4) */ | |
| table thead th:nth-child(3),table tbody td:nth-child(3){display:none} | |
| table{min-width:0} | |
| /* Show short % header, hide long one */ | |
| .th-short{display:inline} | |
| .th-full{display:none} | |
| /* Side-by-side .t-row: label takes remaining space, value wraps within 50% */ | |
| .t-row{flex-wrap:nowrap;align-items:flex-start;gap:4px 6px} | |
| .t-row .tl{flex:1 1 0;min-width:0} | |
| .t-row .tv{flex:0 0 50%;max-width:50%;white-space:normal;overflow-wrap:break-word;text-align:right} | |
| .t-row .tn{white-space:normal;overflow-wrap:break-word} | |
| /* W-4 grid narrows to 40/60 on mobile */ | |
| .w4-grid{grid-template-columns:2fr 3fr} | |
| /* Footer items stack as bullets on narrow screens */ | |
| .foot-list li{display:list-item;list-style:disc;margin-left:18px;line-height:1.8} | |
| .foot-list li:not(:last-child)::after{content:none} | |
| } | |
| @media(max-width:400px){.pill label{font-size:.66rem;padding:5px 7px}input[type=range]{width:130px}} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="hdr"> | |
| <h1>2026 Household Budget</h1> | |
| <span class="hdr-sub">Delaware, Ohio · MFJ · 2 children</span> | |
| <span class="hdr-badge">Tax Year 2026</span> | |
| </div> | |
| <div class="ctrl-bar"> | |
| <div class="ctrl-row"> | |
| <div class="ctrl-grp" style="flex:1;min-width:200px"> | |
| <span class="ctrl-lbl">Gross annual salary</span> | |
| <div class="sal-row"> | |
| <input class="sal-num" type="text" inputmode="numeric" id="salNum" value="$150,000" | |
| onfocus="onSalFocus()" oninput="onSalInput(this.value)" onblur="onSalBlur(this.value)" | |
| autocomplete="off" title="Type a salary between $100,000 and $250,000"> | |
| <input type="range" id="salSlider" min="100000" max="250000" step="1000" value="150000" | |
| oninput="onSalSlider(this.value)"> | |
| <span class="sal-hint">$100k – $250k</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="ctrl-row"> | |
| <div class="ctrl-grp"> | |
| <span class="ctrl-lbl">401k strategy</span> | |
| <div class="pill"> | |
| <input type="radio" name="strat" id="s1" value="all-traditional" checked onchange="recalc()"><label for="s1">Max take-home</label> | |
| <input type="radio" name="strat" id="s2" value="hybrid" onchange="recalc()"><label for="s2">Max Roth (12%)</label> | |
| <input type="radio" name="strat" id="s3" value="all-roth" onchange="recalc()"><label for="s3">All Roth</label> | |
| </div> | |
| </div> | |
| <div class="ctrl-grp"> | |
| <span class="ctrl-lbl">Budget phase</span> | |
| <div class="pill"> | |
| <input type="radio" name="ph" id="p1" value="1" checked onchange="recalc()"><label for="p1">Phase 1 — Car savings</label> | |
| <input type="radio" name="ph" id="p2" value="2" onchange="recalc()"><label for="p2">Phase 2 — HELOC paydown</label> | |
| </div> | |
| </div> | |
| <div class="ctrl-grp"> | |
| <span class="ctrl-lbl">HELOC balance</span> | |
| <div class="num-inp"><span>$</span> | |
| <input class="hlc-num" type="number" id="helocBal" value="20000" step="500" min="0" max="100000" oninput="recalc()"> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="metrics" id="metrics"></div> | |
| <div class="warns" id="warns"></div> | |
| <div class="alloc-section"> | |
| <div class="card"> | |
| <div class="sc-hd"><h3>Take-home allocation</h3></div> | |
| <div class="a-bar" id="aBar"></div> | |
| <div class="a-leg" id="aLeg"></div> | |
| </div> | |
| </div> | |
| <div class="main"> | |
| <div> | |
| <div class="card"> | |
| <div class="card-hd"><h2>Complete budget</h2><span class="hd-r" id="balChk"></span></div> | |
| <div class="tbl-wrap"><table id="tbl"></table></div> | |
| </div> | |
| </div> | |
| <div class="rcol"> | |
| <!-- Flexible allocation (phase slider controls) --> | |
| <div class="card"> | |
| <div class="sc-hd"><h3 id="fcTitle">Flexible allocation</h3></div> | |
| <div id="fxCtrls"></div> | |
| <div id="fxFlow"></div> | |
| <div class="pay-area" id="payArea"></div> | |
| </div> | |
| <!-- 3. Term life --> | |
| <div class="card"><div class="sc-hd"><h3>Term life — auto-selected tier</h3></div><div id="lifeCard"></div></div> | |
| <!-- 4. Tax breakdown --> | |
| <div class="card"><div class="sc-hd"><h3>Tax breakdown</h3></div><div id="taxRows"></div></div> | |
| <!-- 5. Itemized deductions --> | |
| <div class="card"><div class="sc-hd"><h3>Itemized deductions</h3></div><div id="dedRows"></div></div> | |
| <!-- 6. W-4 guide --> | |
| <div class="card"><div class="sc-hd"><h3>W-4 withholding guide</h3></div><div id="w4Rows"></div></div> | |
| </div> | |
| </div> | |
| <footer id="foot"></footer> | |
| <script> | |
| // ── Constants ───────────────────────────────────────────────────────────────── | |
| const K={ | |
| maxK:24500,hsa:8750,hlth:6500, | |
| stdDed:32200,br10:24800,br12:100800, | |
| ssRate:.062,ssBase:184500,medRate:.0145, | |
| ohEx:4800,ohT1:26050,ohT2:100000,ohR1:.0275,ohR2:.035, | |
| cityR:.0185,ctc:4000, | |
| titheR:.10, charityR:.05, // 10% + 5% = 15% total (unchanged for tax) | |
| mortInt:16258.56,propTax:4923.12,saltCap:10000,hlcR:.0825/12, | |
| // Housing | |
| mort:2496.18,elec:149,gas:128,water:97,net:45, | |
| // Food | |
| groc:750,cow:83.33,pig:62.50, | |
| // Transportation (fuel moved from savings; autoMaint new) | |
| aIns:133.33,reg:25.58,fuel:100,autoMaint:50, | |
| // Communication | |
| cell:57.50, | |
| // Insurance (annual premiums converted to monthly) | |
| umbrella:800/12, jewelry:30/12, | |
| // Financial & subscriptions | |
| fp:250,ira:41.67,subs:90.22, | |
| // Giving add-on | |
| gifts:80, | |
| // Savings goals (fuel/trns removed from here — now under Transportation) | |
| vac:200,spF:40,seF:40,date:40,phones:30,fun:20, | |
| // Flex priority caps | |
| CAR_BASE:500,HG_MAX:500, | |
| // Term life insurance options — sorted most expensive → cheapest | |
| // Affordability criterion: flexNoLife - opt.mo >= hlcMin + CAR_BASE | |
| LIFE:[ | |
| {label:'2MM / 20-year',mo:518.64,cov:2000000,term:20}, | |
| {label:'2MM / 10-year',mo:355.04,cov:2000000,term:10}, | |
| {label:'1MM / 20-year',mo:204.02,cov:1000000,term:20}, | |
| {label:'1MM / 10-year',mo:135.62,cov:1000000,term:10}, | |
| {label:'500k / 20-year',mo:128.32,cov:500000,term:20}, | |
| {label:'500k / 10-year',mo:86.92,cov:500000,term:10}, | |
| ], | |
| }; | |
| let carTarget=500,hgP2=500; | |
| let lifeOverrideIdx=null; // null=auto-select; integer=manual override index into K.LIFE | |
| function setLifeTier(idx){ | |
| // Clicking the currently-active manual tier resets back to auto | |
| lifeOverrideIdx=(lifeOverrideIdx===idx)?null:idx; | |
| recalc(); | |
| } | |
| // ── Salary sync — formatted display via Intl.NumberFormat ─────────────────── | |
| const SAL_FMT = new Intl.NumberFormat('en-US', { | |
| style: 'currency', currency: 'USD', | |
| minimumFractionDigits: 0, maximumFractionDigits: 0, | |
| }); | |
| function parseSalary(s){return parseInt(String(s).replace(/[^0-9]/g,''),10)||0} | |
| function fmtSalary(v){return SAL_FMT.format(v)} | |
| function clampSal(v){return Math.round(Math.min(Math.max(100000,isNaN(v)?150000:v),250000)/1000)*1000} | |
| // On focus: strip formatting so user types a plain number | |
| function onSalFocus(){const inp=document.getElementById('salNum');inp.value=parseSalary(inp.value)||150000;inp.select()} | |
| // On input: update slider + recalc when the typed value is in range | |
| function onSalInput(v){const n=parseSalary(v);if(n>=100000&&n<=250000){document.getElementById('salSlider').value=Math.round(n/1000)*1000;recalc()}} | |
| // On blur: clamp, reformat with currency symbol + commas, sync slider | |
| function onSalBlur(v){const val=clampSal(parseSalary(v));document.getElementById('salNum').value=fmtSalary(val);document.getElementById('salSlider').value=val;recalc()} | |
| // Slider moved: reformat display | |
| function onSalSlider(v){const val=clampSal(+v);document.getElementById('salNum').value=fmtSalary(val);recalc()} | |
| // ── Format helpers ──────────────────────────────────────────────────────────── | |
| function fmt(n,dec){ | |
| n=Number(n);dec=Number.isFinite(Number(dec))?Number(dec):0; | |
| if(!Number.isFinite(n)||Number.isNaN(n))return'—'; | |
| const s=dec>0?Math.abs(n).toFixed(dec):Math.round(Math.abs(n)).toLocaleString(); | |
| return(n<0?'−$':'$')+s.replace(/\B(?=(\d{3})+(?!\d))/g,','); | |
| } | |
| function fmtM(n){const s=fmt(n);return s==='—'?'—':s+'/mo'} | |
| function fmtP(n){n=Number(n);if(!Number.isFinite(n)||Number.isNaN(n))return'—';return(Math.abs(n)*100).toFixed(1)+'%'} | |
| function setText(id,t){const e=document.getElementById(id);if(e)e.textContent=t} | |
| function setClass(id,c){const e=document.getElementById(id);if(e)e.className=c} | |
| // ── Tax math ────────────────────────────────────────────────────────────────── | |
| function ohTax(t){t=Number(t);if(!(t>0))return 0;if(t<=K.ohT1)return 0;if(t<=K.ohT2)return(t-K.ohT1)*K.ohR1;return(K.ohT2-K.ohT1)*K.ohR1+(t-K.ohT2)*K.ohR2} | |
| function fedTax(t){t=Number(t);if(!(t>0))return 0;return[[0,24800,.10],[24800,100800,.12],[100800,211400,.22],[211400,403550,.24],[403550,512450,.32],[512450,768600,.35],[768600,Infinity,.37]].reduce((a,[lo,hi,r])=>a+(t>lo?(Math.min(t,hi)-lo)*r:0),0)} | |
| function margRate(t){t=Number(t);if(t<=24800)return 10;if(t<=100800)return 12;if(t<=211400)return 22;if(t<=403550)return 24;if(t<=512450)return 32;if(t<=768600)return 35;return 37} | |
| // ── Core calculation ────────────────────────────────────────────────────────── | |
| function calc(gross,strat,hlcBal){ | |
| gross=Number(gross)||150000;hlcBal=Number(hlcBal)||0; | |
| const tithe=gross*K.titheR, charity=gross*K.charityR; | |
| const don=tithe+charity; // same 15% total — used for itemized deduction | |
| let trad=0; | |
| if(strat==='all-traditional'){trad=K.maxK} | |
| else if(strat==='all-roth'){trad=0} | |
| else{for(let i=0;i<15;i++){const agi=gross-trad-K.hsa-K.hlth;const oh=ohTax(Math.max(0,agi-K.ohEx));const cy=agi*K.cityR;const sl=Math.min(oh+cy+K.propTax,K.saltCap);trad=Math.min(Math.max(0,gross-K.hsa-K.hlth-Math.max(don+K.mortInt+sl,K.stdDed)-K.br12),K.maxK)}trad=Math.round(trad)} | |
| const roth=K.maxK-trad; | |
| const ficaBase=gross-K.hsa-K.hlth; | |
| const ss=Math.min(ficaBase,K.ssBase)*K.ssRate, med=ficaBase*K.medRate; | |
| const agi=gross-trad-K.hsa-K.hlth; | |
| const ohT=ohTax(Math.max(0,agi-K.ohEx)), cyT=agi*K.cityR; | |
| const rawSalt=ohT+cyT+K.propTax, salt=Math.min(rawSalt,K.saltCap), saltCapped=rawSalt>K.saltCap; | |
| const itm=don+K.mortInt+salt, useItm=itm>K.stdDed, ded=useItm?itm:K.stdDed; | |
| const fedT=Math.max(0,agi-ded), fedTRaw=fedTax(fedT), fedTAmt=Math.max(0,fedTRaw-K.ctc); | |
| const taxes=fedTAmt+ss+med+ohT+cyT; | |
| const takehome=(gross-taxes-K.maxK-K.hsa-K.hlth)/12; | |
| // Fixed base — everything EXCEPT term life (determined dynamically below) | |
| const fixedBase= | |
| tithe/12+charity/12+K.gifts+ | |
| K.mort+K.elec+K.gas+K.water+K.net+ | |
| K.groc+K.cow+K.pig+ | |
| K.aIns+K.reg+K.fuel+K.autoMaint+ | |
| K.cell+ | |
| K.umbrella+K.jewelry+ | |
| K.fp+K.ira+K.subs+ | |
| K.vac+K.spF+K.seF+K.date+K.phones+K.fun; | |
| // Term life: auto-select most expensive tier within threshold | |
| const flexNoLife=takehome-fixedBase; | |
| const hlcMin=hlcBal*K.hlcR; | |
| const lifeThreshold=hlcMin+K.CAR_BASE; // must leave room for HELOC floor + car base | |
| let autoLife=null; | |
| for(const opt of K.LIFE){ | |
| if(flexNoLife-opt.mo>=lifeThreshold){autoLife=opt;break} | |
| } | |
| // Apply manual override if set (any tier, even if below auto-threshold) | |
| const selectedLife=(lifeOverrideIdx!==null)?K.LIFE[lifeOverrideIdx]:autoLife; | |
| const lifeMo=selectedLife?selectedLife.mo:0; | |
| const fixed=fixedBase+lifeMo; | |
| const flex=takehome-fixed; // =flexNoLife-lifeMo | |
| return{gross,strat,trad,roth,tithe,charity,don,agi,fedT,fedTRaw,fedTAmt, | |
| ss,med,ohT,cyT,taxes,rawSalt,salt,saltCapped,itm,useItm,ded, | |
| effRate:taxes/gross,margR:margRate(fedT), | |
| takehome,fixedBase,lifeMo,autoLife,selectedLife,fixed,flex,flexNoLife, | |
| hlcBal,hlcInt:hlcMin}; | |
| } | |
| // ── Flex allocation: Car($500) → H/G($500) → Car(extra) → HELOC(remainder) ── | |
| function flexAllocs(b,phase){ | |
| const flex=b.flex, hlcMin=b.hlcBal>0?b.hlcInt:0; | |
| if(phase===1){ | |
| // Step 1: Car — up to min(carTarget, $500, flex) | |
| let carBase=Math.min(Math.min(carTarget,K.CAR_BASE),Math.max(0,flex)); | |
| let rem=Math.max(0,flex-carBase); | |
| // Step 2: H/G — next $500 | |
| let hg=Math.min(K.HG_MAX,rem);rem=Math.max(0,rem-hg); | |
| // Step 3: Car extra — slider beyond $500 | |
| let carExtra=Math.min(Math.max(0,carTarget-K.CAR_BASE),rem);rem=Math.max(0,rem-carExtra); | |
| // Step 4: HELOC — remainder | |
| let hlc=rem; | |
| // Enforce HELOC minimum floor: claw from H/G, then carExtra, then carBase | |
| if(hlc<hlcMin){ | |
| let d=hlcMin-hlc; | |
| const fH=Math.min(hg,d);hg-=fH;hlc+=fH;d-=fH; | |
| if(d>0.01){const fCX=Math.min(carExtra,d);carExtra-=fCX;hlc+=fCX;d-=fCX} | |
| if(d>0.01){const fCB=Math.min(carBase,d);carBase-=fCB;hlc+=fCB} | |
| } | |
| return{hg,carBase,carExtra,car:carBase+carExtra,hlc,hlcMin}; | |
| }else{ | |
| const maxHg=Math.min(K.HG_MAX,Math.max(0,flex)); | |
| let hg=Math.min(Math.max(0,hgP2),maxHg), hlc=flex-hg; | |
| if(hlc<hlcMin&&b.hlcBal>0){const d=Math.min(hlcMin-hlc,hg);hg-=d;hlc+=d} | |
| return{hg,carBase:0,carExtra:0,car:0,hlc,hlcMin}; | |
| } | |
| } | |
| // ── HELOC math ──────────────────────────────────────────────────────────────── | |
| function hlcMonths(bal,pmt){const r=K.hlcR,int=Number(bal)*r;if(!Number.isFinite(Number(pmt))||Number(pmt)<=int)return Infinity;if(Number(bal)<=0)return 0;const n=-Math.log(1-int/Number(pmt))/Math.log(1+r);return Number.isFinite(n)?Math.ceil(n):Infinity} | |
| function hlcTotalInt(bal,pmt,mo){if(!Number.isFinite(mo))return Infinity;return Math.max(0,Number(pmt)*mo-Number(bal))} | |
| function addMonths(n){if(!Number.isFinite(n))return'—';const d=new Date(2026,4,1);d.setMonth(d.getMonth()+Math.ceil(n));return d.toLocaleDateString('en-US',{month:'long',year:'numeric'})} | |
| function getInputs(){return{sal:clampSal(+document.getElementById('salSlider').value),strat:document.querySelector('input[name=strat]:checked').value,phase:+document.querySelector('input[name=ph]:checked').value,hlc:+document.getElementById('helocBal').value||0}} | |
| // ── Render: metrics ─────────────────────────────────────────────────────────── | |
| function renderMetrics(b,phase){ | |
| const life=b.selectedLife?b.selectedLife.label:'—'; | |
| const items=[ | |
| {l:'Monthly take-home',v:fmtM(b.takehome),s:fmt(b.takehome*12)+'/yr',c:'grn'}, | |
| {l:'Total taxes/yr',v:fmt(b.taxes),s:fmtP(b.effRate)+' eff. rate',c:''}, | |
| {l:'Federal marginal',v:b.margR+'%',s:fmt(b.fedT)+' taxable',c:b.margR>12?'amb':''}, | |
| {l:'Fixed expenses',v:fmtM(b.fixed),s:'incl. '+fmtM(b.lifeMo)+' life ins.',c:''}, | |
| {l:'Flex pool',v:fmtM(b.flex),s:phase===1?'car·H/G·car+·HELOC':'H/G·HELOC',c:b.flex<0?'red':b.flex<200?'amb':'grn'}, | |
| {l:'Life insurance',v:b.selectedLife?b.selectedLife.label:'Not affordable',s:b.selectedLife?fmtM(b.lifeMo):'increase salary',c:b.selectedLife?'':'amb'}, | |
| ]; | |
| document.getElementById('metrics').innerHTML=items.map(m=>`<div class="metric ${m.c}"><div class="m-l">${m.l}</div><div class="m-v">${m.v}</div><div class="m-s">${m.s}</div></div>`).join(''); | |
| } | |
| // ── Render: warnings ────────────────────────────────────────────────────────── | |
| function renderWarns(b,phase){ | |
| const fa=flexAllocs(b,phase), ws=[], info=[]; | |
| if(b.flex<0)ws.push(`<strong>Over budget by ${fmt(-b.flex)}/mo.</strong> Fixed expenses exceed take-home.`); | |
| if(fa.hlc<fa.hlcMin-0.5&&b.hlcBal>0)ws.push(`<strong>HELOC minimum not met</strong> even after clawback. Balance will grow.`); | |
| if(b.margR>12)ws.push(`<strong>Marginal rate ${b.margR}%.</strong> Switch to "Max Roth (12%)" to reduce taxable income.`); | |
| if(b.saltCapped)ws.push(`<strong>SALT capped.</strong> State/local taxes ${fmt(b.rawSalt)} exceeds the $10,000 deduction limit.`); | |
| if(!b.useItm)ws.push(`<strong>Standard deduction wins</strong> (${fmt(K.stdDed)} vs ${fmt(b.itm)} itemized) at this salary.`); | |
| if(!b.selectedLife)ws.push(`<strong>Term life insurance not yet affordable.</strong> Increase salary to unlock coverage. Threshold: flex pool ≥ ${fmt(b.hlcInt+K.CAR_BASE)}/mo after insurance.`); | |
| else{ | |
| const nextIdx=K.LIFE.indexOf(b.selectedLife)-1; | |
| if(nextIdx>=0){const next=K.LIFE[nextIdx];info.push(`<strong>Life insurance auto-selected: ${b.selectedLife.label}</strong> at ${fmtM(b.lifeMo)}. Next tier (${next.label} · ${fmtM(next.mo)}) unlocks when flex pool grows by ${fmt(next.mo-b.lifeMo)}/mo more.`)} | |
| } | |
| document.getElementById('warns').innerHTML=ws.map(w=>`<div class="warn-box">${w}</div>`).join('')+info.map(i=>`<div class="info-box">${i}</div>`).join(''); | |
| } | |
| // ── Render: budget table ────────────────────────────────────────────────────── | |
| function renderTable(b,phase){ | |
| const fa=flexAllocs(b,phase); | |
| const ti=b.tithe/12, ch=b.charity/12; | |
| const insTotal=K.umbrella+K.jewelry+b.lifeMo; | |
| const flexMo=fa.car+fa.hg+fa.hlc; | |
| // Pre-compute group subtotals (mo and yr) so they appear in header rows | |
| const G={ | |
| giv:{mo:ti+ch+K.gifts, yr:(ti+ch+K.gifts)*12}, | |
| hou:{mo:K.mort+K.elec+K.gas+K.water+K.net, yr:(K.mort+K.elec+K.gas+K.water+K.net)*12}, | |
| foo:{mo:K.groc+K.cow+K.pig, yr:K.groc*12+1000+750}, | |
| tra:{mo:K.aIns+K.reg+K.fuel+K.autoMaint, yr:(K.aIns+K.reg+K.fuel+K.autoMaint)*12}, | |
| com:{mo:K.cell, yr:K.cell*12}, | |
| ins:{mo:insTotal, yr:insTotal*12}, | |
| fin:{mo:K.fp+K.ira, yr:K.fp*12+500}, | |
| sub:{mo:K.subs, yr:K.subs*12}, | |
| sav:{mo:K.vac+K.spF+K.seF+K.date+K.phones+K.fun, yr:(K.vac+K.spF+K.seF+K.date+K.phones+K.fun)*12}, | |
| flx:{mo:flexMo, yr:flexMo*12}, | |
| }; | |
| const rows=[ | |
| // Subtotals now live in the group header {g,c,mo,yr} — no separate {sub} rows | |
| {g:'Giving & gifts',c:'#2d5a3d',...G.giv}, | |
| {n:'Tithe',nt:'10% of gross · scales with salary',mo:ti,yr:b.tithe,fx:true}, | |
| {n:'Charities',nt:'5% of gross · scales with salary',mo:ch,yr:b.charity,fx:true}, | |
| {n:'Gifts — birthdays & Christmas',mo:K.gifts,yr:K.gifts*12}, | |
| {g:'Housing',c:'#1a3a6b',...G.hou}, | |
| {n:'Mortgage',nt:'P&I + escrow (taxes + ins.) · 5.25%',mo:K.mort,yr:K.mort*12}, | |
| {n:'Electricity',nt:'12-mo avg',mo:K.elec,yr:K.elec*12}, | |
| {n:'Natural gas',mo:K.gas,yr:K.gas*12}, | |
| {n:'Water',mo:K.water,yr:K.water*12}, | |
| {n:'Internet',mo:K.net,yr:K.net*12}, | |
| {g:'Food',c:'#5a7a2d',...G.foo}, | |
| {n:'Groceries',mo:K.groc,yr:K.groc*12}, | |
| {n:'Half cow sinking fund',nt:'$1,000/yr',mo:K.cow,yr:1000}, | |
| {n:'Half pig sinking fund',nt:'$750/yr',mo:K.pig,yr:750}, | |
| {g:'Transportation',c:'#7a4a1a',...G.tra}, | |
| {n:'Auto insurance',nt:'$1,600/yr',mo:K.aIns,yr:1600}, | |
| {n:'Registration sinking fund',nt:'$1,355.55 due Oct 2030',mo:K.reg}, | |
| {n:'Fuel, parking & tolls sinking fund',mo:K.fuel,yr:K.fuel*12}, | |
| {n:'Auto maintenance sinking fund',nt:'Goal: $1,000',mo:K.autoMaint,yr:K.autoMaint*12}, | |
| {g:'Communication',c:'#2d5a6b',...G.com}, | |
| {n:'Cell phone',nt:'½ of $115 shared plan',mo:K.cell,yr:K.cell*12}, | |
| {g:'Insurance',c:'#4a1a6b',...G.ins}, | |
| {n:'Umbrella policy',nt:'$800/yr',mo:K.umbrella,yr:800}, | |
| {n:'Jewelry insurance',nt:'$30/yr',mo:K.jewelry,yr:30}, | |
| ...(b.selectedLife?[ | |
| {n:`Term life — ${b.selectedLife.label}`,nt:`$${(b.selectedLife.cov/1000000).toFixed(0)}MM · ${b.selectedLife.term}-year · auto-selected ↑↑`,mo:b.lifeMo,yr:b.lifeMo*12,lifeAuto:true}, | |
| ]:[ | |
| {n:'Term life insurance',nt:'Not affordable at this salary',mo:0,yr:0,lifeNone:true}, | |
| ]), | |
| {g:'Financial services',c:'#4a4a5a',...G.fin}, | |
| {n:'Financial planner',mo:K.fp,yr:K.fp*12}, | |
| {n:'IRA management fees',nt:'2 × $250/yr',mo:K.ira,yr:500}, | |
| {g:'Subscriptions',c:'#2d5a6b',...G.sub}, | |
| {n:'Amazon · Costco · Google One · Tesla · Kagi',nt:'Annual',mo:50.08,yr:601}, | |
| {n:'YouTube · YNAB · FastMail · Domains · Nabu · SimpleFIN · Bitwarden',nt:'Mix of billing periods',mo:40.14,yr:482}, | |
| {g:'Savings goals',c:'#7a4a1a',...G.sav}, | |
| {n:'Vacation',mo:K.vac,yr:K.vac*12}, | |
| {n:'Spouse fun money',mo:K.spF,yr:K.spF*12}, | |
| {n:'Self fun money',mo:K.seF,yr:K.seF*12}, | |
| {n:'Date money',mo:K.date,yr:K.date*12}, | |
| {n:'New phones sinking fund',mo:K.phones,yr:K.phones*12}, | |
| {n:'General fun money',mo:K.fun,yr:K.fun*12}, | |
| // Flex — ①③ for car (priorities 1+3), ④ for HELOC | |
| {g:phase===1?'Flexible — Phase 1':'Flexible — Phase 2',c:'#8b2020',...G.flx}, | |
| ...(phase===1?[ | |
| {n:'①③ Car fund',nt:'$500 base (①) + slider extra (③)',mo:fa.car,fl:true}, | |
| {n:'② Home improvement + garden',nt:'Priority 2 · capped at $500',mo:fa.hg,fl:true}, | |
| {n:'④ HELOC',nt:'Remainder · min interest enforced',mo:fa.hlc,fl:true}, | |
| ]:[ | |
| {n:'① Home improvement + garden',nt:'Priority 1 · slider up to $500',mo:fa.hg,fl:true}, | |
| {n:'② HELOC paydown',nt:'All remainder',mo:fa.hlc,fl:true}, | |
| ]), | |
| ]; | |
| const total=b.fixed+fa.car+fa.hg+fa.hlc; | |
| const diff=b.takehome-total, ok=Math.abs(diff)<0.5; | |
| let h=`<thead><tr><th>Line item</th><th>Monthly</th><th>Annual</th><th><span class="th-full">% take-home</span><span class="th-short">%</span></th></tr></thead><tbody>`; | |
| for(const r of rows){ | |
| if(r.g!==undefined){ | |
| // Group header row — show subtotals in all four columns | |
| const pct=(Number.isFinite(r.mo)&&b.takehome>0)?r.mo/b.takehome:NaN; | |
| h+=`<tr class="g-row"> | |
| <td><span class="dot" style="background:${r.c}"></span>${r.g}</td> | |
| <td>${fmt(r.mo)}</td> | |
| <td>${r.yr!=null?fmt(r.yr):''}</td> | |
| <td>${Number.isFinite(pct)&&pct>0?fmtP(pct):''}</td> | |
| </tr>`; | |
| }else{ | |
| const pct=(Number.isFinite(r.mo)&&b.takehome>0)?r.mo/b.takehome:NaN; | |
| const cls=r.fl?'fl-line':r.lifeAuto?'life-auto':r.lifeNone?'life-none':''; | |
| h+=`<tr class="${cls}"> | |
| <td>${r.n}${r.nt?`<span class="note">${r.nt}</span>`:''}</td> | |
| <td>${r.fl?`<em>${fmt(r.mo)}</em>`:fmt(r.mo)}</td> | |
| <td>${r.yr!=null?fmt(r.yr):'—'}</td> | |
| <td>${Number.isFinite(pct)&&pct>0?fmtP(pct):'—'}</td> | |
| </tr>`; | |
| } | |
| } | |
| const cls=ok?'ok':diff<0?'over':''; | |
| const chk=ok?'✓ balanced':diff>0?fmt(diff)+' unallocated':fmt(-diff)+' over budget'; | |
| h+=`<tr class="ttl-row ${cls}"><td>Monthly total</td><td class="mono">${fmt(total)}</td><td class="mono">${fmt(total*12)}</td><td>${chk}</td></tr></tbody>`; | |
| document.getElementById('tbl').innerHTML=h; | |
| document.getElementById('balChk').textContent=chk; | |
| } | |
| // ── Render: tax breakdown ───────────────────────────────────────────────────── | |
| function renderTax(b){ | |
| const rows=[['Federal taxable income',fmt(b.fedT),''],['Federal income tax',fmt(b.fedTRaw),''],['Child Tax Credit (2 children)','−$4,000',''],['Federal tax after CTC',fmt(b.fedTAmt),''],['Social Security (6.2%)',fmt(b.ss),'on '+fmt(Math.min(b.gross-K.hsa-K.hlth,K.ssBase))],['Medicare (1.45%)',fmt(b.med),''],['Ohio state tax',fmt(b.ohT),''],['Delaware city (1.85%)',fmt(b.cyT),'']]; | |
| let h=rows.map(([l,v,n])=>`<div class="t-row"><span class="tl">${l}${n?`<span class="tn">${n}</span>`:''}</span><span class="tv">${v}</span></div>`).join(''); | |
| h+=`<div class="t-total"><span class="tl">Total taxes · effective rate</span><span class="tv">${fmt(b.taxes)} · ${fmtP(b.effRate)}</span></div>`; | |
| document.getElementById('taxRows').innerHTML=h; | |
| } | |
| // ── Render: allocation bar ──────────────────────────────────────────────────── | |
| function renderAlloc(b,phase){ | |
| const fa=flexAllocs(b,phase),mo=b.takehome; | |
| const givingMo=b.tithe/12+b.charity/12+K.gifts; | |
| const segs=[ | |
| {p:givingMo/mo,c:'#2d5a3d',l:'Giving+Gifts'}, | |
| {p:(K.mort+K.elec+K.gas+K.water+K.net)/mo,c:'#1a3a6b',l:'Housing'}, | |
| {p:(K.groc+K.cow+K.pig)/mo,c:'#5a7a2d',l:'Food'}, | |
| {p:(K.aIns+K.reg+K.fuel+K.autoMaint)/mo,c:'#8b6020',l:'Transport'}, | |
| {p:(K.umbrella+K.jewelry+b.lifeMo)/mo,c:'#4a1a6b',l:'Insurance'}, | |
| {p:(K.fp+K.ira+K.subs+K.cell)/mo,c:'#4a4a5a',l:'Financial+Subs'}, | |
| {p:(K.vac+K.spF+K.seF+K.date+K.phones+K.fun)/mo,c:'#7a4a1a',l:'Savings'}, | |
| {p:Math.max(0,fa.car+fa.hg+fa.hlc)/mo,c:'#8b2020',l:'Flex'}, | |
| ]; | |
| document.getElementById('aBar').innerHTML=segs.map(s=>`<div class="a-seg" style="width:${Math.max(0,Math.min(100,s.p*100)).toFixed(1)}%;background:${s.c}" title="${s.l}"></div>`).join(''); | |
| document.getElementById('aLeg').innerHTML=segs.map(s=>`<div class="leg-i"><div class="leg-dot" style="background:${s.c}"></div>${s.l} ${Number.isFinite(s.p)&&s.p>0?fmtP(s.p):'—'}</div>`).join(''); | |
| } | |
| // ── Render: life insurance tier table ───────────────────────────────────────── | |
| function renderLife(b){ | |
| const threshold=b.hlcInt+K.CAR_BASE; | |
| const isManual=lifeOverrideIdx!==null; | |
| let h=''; | |
| // Header row: show reset button when manually overriding | |
| if(isManual){ | |
| h+=`<div class="life-reset"><span>Manual override active</span><button onclick="lifeOverrideIdx=null;recalc()">↺ Reset to auto</button></div>`; | |
| }else{ | |
| h+=`<div style="font-size:.65rem;color:var(--t2);padding:7px 13px 4px;border-bottom:1px solid var(--border)">Auto-selects highest affordable tier · click any row to override</div>`; | |
| } | |
| h+=`<div class="life-tiers">`; | |
| K.LIFE.forEach((opt,i)=>{ | |
| const remaining=b.flexNoLife-opt.mo; | |
| const isSelected=b.selectedLife&&b.selectedLife.label===opt.label; | |
| const isAutoTier=b.autoLife&&b.autoLife.label===opt.label; | |
| const atThreshold=remaining>=threshold; | |
| const canAfford=remaining>=0; | |
| // Build tag | |
| let tag; | |
| if(isSelected&&isManual){ | |
| tag=`<span class="lt-tag manual">selected</span>`; | |
| }else if(isSelected){ | |
| tag=`<span class="lt-tag auto">auto-selected</span>`; | |
| }else if(isManual&&isAutoTier){ | |
| tag=`<span class="lt-tag would-auto">would be auto</span>`; | |
| }else if(atThreshold){ | |
| tag=`<span class="lt-tag avail">available</span>`; | |
| }else if(canAfford){ | |
| tag=`<span class="lt-tag avail">affordable</span>`; | |
| }else{ | |
| tag=`<span class="lt-tag unafford">+${fmt(-remaining)}/mo over</span>`; | |
| } | |
| // Sub-hint shown under label | |
| let hint=''; | |
| if(isSelected&&isManual) hint='<span style="font-size:.58rem;color:var(--t3);display:block">click to reset to auto</span>'; | |
| else if(!isSelected&&canAfford) hint='<span style="font-size:.58rem;color:var(--t3);display:block">click to select</span>'; | |
| else if(!canAfford) hint='<span style="font-size:.58rem;color:var(--red);display:block">budget goes over — click to see impact</span>'; | |
| h+=`<div class="life-tier-row${isSelected?' selected':''}" onclick="setLifeTier(${i})"> | |
| <span class="lt-lbl">${opt.label}${hint}</span> | |
| <span class="lt-mo">${fmtM(opt.mo)}</span> | |
| ${tag} | |
| </div>`; | |
| }); | |
| h+=`</div>`; | |
| if(!b.selectedLife&&!isManual){ | |
| h+=`<div style="font-size:.7rem;color:var(--amber);padding:6px 13px 10px">No tier affordable at this salary.</div>`; | |
| } | |
| document.getElementById('lifeCard').innerHTML=h; | |
| } | |
| // ── Render: flex controls (slider-safe) ─────────────────────────────────────── | |
| function renderFlex(b,phase){ | |
| const fa=flexAllocs(b,phase); | |
| document.getElementById('fcTitle').textContent=phase===1?'Phase 1 — flexible (4-step priority)':'Phase 2 — flexible'; | |
| const ctrl=document.getElementById('fxCtrls'); | |
| if(ctrl.dataset.phase!=phase){ | |
| ctrl.dataset.phase=phase; | |
| if(phase===1){ | |
| ctrl.innerHTML=` | |
| <div class="fx-row bl" id="carRowP1"> | |
| <div class="fx-dot" style="background:#1a3a6b"></div> | |
| <div class="fx-lbl">Car fund total<span class="fx-sub" id="carSubP1"></span></div> | |
| <input type="range" class="slim" id="carSlider" min="0" max="5000" step="25" value="500" | |
| oninput="carTarget=+this.value;recalc()"> | |
| <div class="fx-val" id="carFxValP1"></div> | |
| </div> | |
| <div class="fx-row hl"> | |
| <div class="fx-dot" style="background:#2d5a3d"></div> | |
| <div class="fx-lbl">Home improvement + garden<span class="fx-sub" id="hgSubP1"></span></div> | |
| <div class="fx-val" id="hgFxValP1"></div> | |
| </div> | |
| <div class="fx-row" id="hlcRowP1"> | |
| <div class="fx-dot" style="background:#b85c00"></div> | |
| <div class="fx-lbl">HELOC<span class="fx-sub" id="hlcSubP1"></span></div> | |
| <div class="fx-val" id="hlcFxValP1"></div> | |
| </div>`; | |
| }else{ | |
| ctrl.innerHTML=` | |
| <div class="fx-row hl"> | |
| <div class="fx-dot" style="background:#2d5a3d"></div> | |
| <div class="fx-lbl">Home improvement + garden<span class="fx-sub">Priority 1 · slider up to $500</span></div> | |
| <input type="range" class="slim" id="hgSlider" min="0" max="500" step="25" value="500" | |
| oninput="hgP2=+this.value;recalc()"> | |
| <div class="fx-val" id="hgFxVal2"></div> | |
| </div> | |
| <div class="fx-row" id="hlcRowP2"> | |
| <div class="fx-dot" style="background:#8b2020"></div> | |
| <div class="fx-lbl">HELOC paydown<span class="fx-sub" id="hlcSubP2"></span></div> | |
| <div class="fx-val" id="hlcFxVal2"></div> | |
| </div>`; | |
| } | |
| } | |
| if(phase===1){ | |
| const maxCar=Math.max(0,Math.floor(b.flex)); | |
| const sl=document.getElementById('carSlider'); | |
| if(sl){sl.max=maxCar;const cv=Math.min(Math.max(0,carTarget),maxCar);if(carTarget!==cv){carTarget=cv;sl.value=cv}} | |
| const carExtraLbl=fa.carExtra>0.5?` · +${fmt(fa.carExtra)} extra`:''; | |
| const carBaseLbl=fa.carBase<K.CAR_BASE-0.5?`${fmt(fa.carBase)} (salary-limited)`:fa.carExtra>0.5?`$500 base + slider extra${carExtraLbl}`:`$500 base · slide right for extra`; | |
| setText('carSubP1',carBaseLbl); | |
| setText('carFxValP1',fmtM(fa.car)); | |
| setClass('carRowP1','fx-row bl'+(fa.car>0.5?' hl':'')); | |
| const hgReduced=fa.hg<K.HG_MAX-0.5&&b.flex>K.HG_MAX; | |
| setText('hgSubP1',`Priority 2 · capped at $500${hgReduced?' · reduced for HELOC min':''}`); | |
| setText('hgFxValP1',fmtM(fa.hg)); | |
| const hlcAbove=fa.hlc>fa.hlcMin+0.5, hlcLow=fa.hlc<fa.hlcMin-0.5; | |
| setText('hlcSubP1',hlcAbove?`Priority 4 · int ${fmtM(fa.hlcMin)} + principal ${fmtM(fa.hlc-fa.hlcMin)}`:hlcLow?`⚠ Floor not met · min ${fmtM(fa.hlcMin)}`:`Priority 4 · interest-only floor`); | |
| setText('hlcFxValP1',fmtM(fa.hlc)); | |
| setClass('hlcRowP1','fx-row '+(hlcAbove?'hl':hlcLow?'wn':'')); | |
| document.getElementById('fxFlow').innerHTML=`<div class="flow-wrap"> | |
| <div class="flow-title">Flex pool step-by-step</div> | |
| <div class="flow-steps"> | |
| <div class="flow-step"><div class="flow-spine"><div class="flow-circle" style="background:#1a3a6b">1</div><div class="flow-line"></div></div><div class="flow-content"><span class="flow-name">Car base <span class="flow-tag">first $500 (or slider if lower)</span></span><span style="font-family:'DM Mono',monospace;font-size:.73rem;font-weight:500;color:#1a3a6b">${fmtM(fa.carBase)}</span></div></div> | |
| <div class="flow-step"><div class="flow-spine"><div class="flow-circle" style="background:#2d5a3d">2</div><div class="flow-line"></div></div><div class="flow-content"><span class="flow-name">Home/garden <span class="flow-tag">next $500</span></span><span style="font-family:'DM Mono',monospace;font-size:.73rem;font-weight:500;color:#2d5a3d">${fmtM(fa.hg)}</span></div></div> | |
| <div class="flow-step"><div class="flow-spine"><div class="flow-circle" style="background:#1a3a6b">3</div><div class="flow-line"></div></div><div class="flow-content"><span class="flow-name">Car extra <span class="flow-tag">slider beyond $500</span></span><span style="font-family:'DM Mono',monospace;font-size:.73rem;font-weight:500;color:#1a3a6b">${fmtM(fa.carExtra)}</span></div></div> | |
| <div class="flow-step"><div class="flow-spine"><div class="flow-circle" style="background:#b85c00">4</div><div class="flow-line"></div></div><div class="flow-content"><span class="flow-name">HELOC <span class="flow-tag">remainder</span></span><span style="font-family:'DM Mono',monospace;font-size:.73rem;font-weight:500;color:${hlcAbove?'var(--accent)':'var(--amber)'}">${fmtM(fa.hlc)}</span></div></div> | |
| </div></div>`; | |
| const proj=23000+fa.car*14; | |
| const carCol=proj>=30000?'var(--accent)':'var(--amber)'; | |
| document.getElementById('payArea').innerHTML=`<div class="pay-stat"><div class="pay-stat-item"><div class="pay-stat-l">Car at mid-2027</div><div class="pay-stat-v" style="color:${carCol}">${fmt(proj)}</div></div><div class="pay-stat-item"><div class="pay-stat-l">HELOC P1 interest</div><div class="pay-stat-v">${fmt(fa.hlcMin*14)}</div></div>${fa.hlc>fa.hlcMin+0.5?`<div class="pay-stat-item"><div class="pay-stat-l">P1 principal paid</div><div class="pay-stat-v" style="color:var(--accent)">${fmt((fa.hlc-fa.hlcMin)*14)}</div></div>`:''}</div><div class="prog"><div class="prog-fill" style="width:${Math.min(100,proj/400).toFixed(1)}%;background:${carCol}"></div></div><div class="prog-lbls"><span>$0</span><span>$30k min</span><span>$40k max</span></div>`; | |
| }else{ | |
| const maxHg=Math.min(K.HG_MAX,Math.max(0,Math.floor(b.flex))); | |
| const sl=document.getElementById('hgSlider'); | |
| if(sl){sl.max=maxHg;const cvH=Math.min(Math.max(0,hgP2),maxHg);if(hgP2!==cvH){hgP2=cvH;sl.value=cvH}} | |
| const hgC=Math.min(Math.max(0,hgP2),maxHg), hlcPmt=b.flex-hgC; | |
| const paying=hlcPmt>b.hlcInt+0.5, atMin=Math.abs(hlcPmt-b.hlcInt)<=0.5; | |
| setText('hgFxVal2',fmtM(hgC)); | |
| setText('hlcFxVal2',fmtM(hlcPmt)); | |
| setText('hlcSubP2',paying?`Paying principal · int ${fmtM(b.hlcInt)} + principal ${fmtM(hlcPmt-b.hlcInt)}`:atMin?'Interest only':'⚠ Below minimum'); | |
| setClass('hlcRowP2','fx-row '+(paying?'hl':!atMin&&b.hlcBal>0?'wn':'')); | |
| document.getElementById('fxFlow').innerHTML=''; | |
| const mos=hlcMonths(b.hlcBal,hlcPmt), intPaid=hlcTotalInt(b.hlcBal,hlcPmt,mos), payDate=addMonths(mos); | |
| document.getElementById('payArea').innerHTML=paying?`<div class="pay-date">${payDate}</div><div class="pay-int">${Number.isFinite(mos)?mos+' months · '+fmt(intPaid)+' total interest':'—'}</div><div class="prog" style="margin:7px 0"><div class="prog-fill" style="width:4%"></div></div><div class="prog-lbls"><span>Phase 2 start</span><span>${payDate}</span></div><div class="pay-note">After payoff, <strong>${fmtM(hlcPmt)}</strong> frees up. HELOC line open until Feb 2036.</div>`:`<div style="padding:6px 0;font-size:.73rem;color:var(--t2);text-align:center">${hlcPmt<b.hlcInt&&b.hlcBal>0?'Slide H/G left to cover the '+fmtM(b.hlcInt)+' minimum.':'Slide H/G left to start paying principal.'}</div>`; | |
| } | |
| } | |
| // ── Render: itemized deductions ─────────────────────────────────────────────── | |
| function renderDed(b){ | |
| const rows=[ | |
| ['Tithe (10% of gross)',fmt(b.tithe),'Fully deductible as charitable contribution',''], | |
| ['Charities (5% of gross)',fmt(b.charity),'Fully deductible',''], | |
| ['Total charitable giving',fmt(b.don),'15% of gross — used for itemized deduction',''], | |
| ['Mortgage interest (2026)',fmt(K.mortInt),'Exact · Form 1098',''], | |
| ['Ohio income tax',fmt(b.ohT),'',''], | |
| ['Delaware city tax',fmt(b.cyT),'',''], | |
| ['Property tax',fmt(K.propTax),'Escrowed in mortgage',''], | |
| ['SALT subtotal',fmt(b.rawSalt),b.saltCapped?'⚠ capped at $10,000':'✓ under $10,000 cap',b.saltCapped?'wn':'ok'], | |
| ['Total itemized',fmt(b.itm),'',''], | |
| ['Standard deduction',fmt(K.stdDed),'2026 MFJ',''], | |
| ]; | |
| let h=rows.map(([l,v,n,tc])=>`<div class="t-row"><span class="tl">${l}${n?`<span class="ded-tag ${tc}">${n}</span>`:''}</span><span class="tv">${v}</span></div>`).join(''); | |
| const sav=b.useItm?(b.itm-K.stdDed)*b.margR/100:0; | |
| h+=`<div class="t-total"><span class="tl">Using <strong>${b.useItm?'itemized':'standard'}</strong></span><span class="tv" style="color:var(--accent)">${b.useItm?'saves ~'+fmt(sav)+'/yr':''}</span></div>`; | |
| document.getElementById('dedRows').innerHTML=h; | |
| } | |
| function renderW4(b){ | |
| const step4b=b.useItm?Math.max(0,Math.round(b.itm-K.stdDed)):0; | |
| const rows=[ | |
| {step:'Step 1',lbl:'Filing status',val:'Married Filing Jointly',note:''}, | |
| {step:'Step 2',lbl:'Multiple jobs / spouse works',val:'Leave blank',note:'Single income — checking this over-withholds'}, | |
| {step:'Step 3',lbl:'Dependents credit',val:fmt(4000),note:'2 children × $2,000 Child Tax Credit'}, | |
| {step:'Step 4b',lbl:'Deductions adjustment',val:step4b>0?fmt(step4b):'Leave blank', | |
| note:step4b>0?`Itemized ${fmt(Math.round(b.itm))} − standard ${fmt(K.stdDed)} · scales with salary`:'Taking standard deduction'}, | |
| {step:'Step 4c',lbl:'Extra withholding / period',val:'See estimator →',note:'Use for mid-year adjustments or salary changes'}, | |
| ]; | |
| // CSS Grid: 1fr 2fr (33/66). Each row = two consecutive grid cells. | |
| let h='<div class="w4-grid">'; | |
| h+=rows.map(r=>` | |
| <div class="w4-lbl">${r.step}<span class="tn">${r.lbl}</span></div> | |
| <div class="w4-val">${r.val}${r.note?`<span class="w4-note">${r.note}</span>`:''}</div>`).join(''); | |
| h+='</div>'; | |
| h+=`<div class="w4-footer"> | |
| <a href="https://apps.irs.gov/app/tax-withholding-estimator/" target="_blank" rel="noopener noreferrer" class="w4-link"> | |
| IRS Tax Withholding Estimator ↗ | |
| </a> | |
| <div class="w4-footer-note">Takes ~25 min. Have ready: most recent pay stub (YTD gross + federal withheld to date) and your best estimate of H2 salary. The estimator handles mid-year salary changes and outputs a pre-filled W-4.</div> | |
| </div>`; | |
| document.getElementById('w4Rows').innerHTML=h; | |
| } | |
| function recalc(){ | |
| const{sal,strat,phase,hlc}=getInputs(); | |
| const b=calc(sal,strat,hlc); | |
| renderMetrics(b,phase);renderWarns(b,phase);renderTable(b,phase); | |
| renderTax(b);renderAlloc(b,phase);renderLife(b);renderFlex(b,phase);renderDed(b);renderW4(b); | |
| const footItems=[ | |
| '2026 · MFJ · 2 children · Delaware, Ohio', | |
| 'Mortgage 5.25% on $315,904 · 2026 interest $16,259 · Property tax $4,923 escrowed', | |
| 'HELOC 8.25% variable · draw period ends Feb 2036', | |
| 'Child Tax Credit $4,000 · SALT cap $10,000', | |
| '401k $24,500 · HSA $8,750 · Health insurance $6,500 est.', | |
| 'Charitable giving: Tithe 10% + Charities 5% = 15% of gross', | |
| 'Gifts $80/mo · Auto maintenance $50/mo · Umbrella $67/mo · Jewelry $2.50/mo', | |
| 'Term life auto-selects highest affordable tier (threshold: flex pool ≥ HELOC min + $500 car base)', | |
| 'Phase 1 flex priority: Car $500 → H/G $500 → Car extra → HELOC remainder', | |
| 'All figures estimated — consult your financial planner before filing or making changes.', | |
| ]; | |
| document.getElementById('foot').innerHTML=`<ul class="foot-list">${footItems.map(i=>`<li>${i}</li>`).join('')}</ul>`; | |
| } | |
| recalc(); | |
| // Keep content clear of fixed header — measures actual rendered height and reapplies on resize | |
| (function(){ | |
| const hdr=document.querySelector('.hdr'); | |
| const ctrl=document.querySelector('.ctrl-bar'); | |
| if(!hdr||!ctrl)return; | |
| function adjust(){ctrl.style.marginTop=hdr.offsetHeight+'px'} | |
| adjust(); | |
| new ResizeObserver(adjust).observe(hdr); | |
| window.addEventListener('resize',adjust); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment