From df0b9355f9bf14ac6847b0063d3c461f2ea3e5f5 Mon Sep 17 00:00:00 2001 From: Justin-Seib <46479303+Justin-Seib@users.noreply.github.com> Date: Mon, 13 Oct 2025 23:02:31 -0500 Subject: [PATCH 1/2] Promote budget planner to repo root --- README.md | 25 +- app.js | 1147 ++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 277 +++++++++++++ styles.css | 59 +++ 4 files changed, 1505 insertions(+), 3 deletions(-) create mode 100644 app.js create mode 100644 index.html create mode 100644 styles.css diff --git a/README.md b/README.md index 26a6aedb..19673418 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,24 @@ -# developers +# Dynamic Family Budget Planner -This is the remote repository for the academy devs (students) +A single-page budgeting and cashflow planning prototype built with Tailwind CSS and vanilla JavaScript. Configure income sources, map bills to specific paychecks, plan aggressive debt payoff, and review a running cashflow ledger for the month. -# Jesse testing this out \ No newline at end of file +## Features +- Dynamic configuration for two customizable income sources with support for semi-monthly and bi-weekly schedules. +- Bill management workflow that lets you edit amounts, due days, assignment strategies, and aggressive payoff settings. +- Manual ledger transactions and savings allocations that flow into a combined payment calendar and paycheck summaries. +- Cashflow ledger that starts from your current buffer and tracks deposits, allocations, and expenses chronologically. + +## Getting started +1. Clone the repository. +2. From the repository root run a simple static server, for example: + - macOS/Linux: `python3 -m http.server 8000` + - Windows: `py -m http.server 8000` +3. Visit `http://localhost:8000/index.html` in your browser to interact with the planner. +4. Update the income labels (e.g., "Jim" and "Jessie") so every table and summary reflects your job names, then tweak bill assignments and transactions to fit your scenario. + +Because everything is static files, you can also host the repository with GitHub Pages. Point Pages to the `main` branch and browse to `https://.github.io/budget-planner/` once it publishes. + +## Project structure +- `index.html` – layout, Tailwind configuration, and root markup for the planner UI. +- `app.js` – budgeting logic, payday calculations, bill assignment rules, and rendering helpers. +- `styles.css` – shared utility styles layered on top of Tailwind. diff --git a/app.js b/app.js new file mode 100644 index 00000000..9b9eb154 --- /dev/null +++ b/app.js @@ -0,0 +1,1147 @@ +const USER_PAY_DATES = [10, 25]; +const SECONDARY_PAY_INTERVAL = 14; + +const DEFAULT_PRIMARY_NAME = 'Justin'; +const DEFAULT_SECONDARY_NAME = 'Christina'; + +function getIncomeNames() { + const primaryInput = document.getElementById('primaryIncomeName'); + const secondaryInput = document.getElementById('secondaryIncomeName'); + + const primaryName = primaryInput?.value.trim() || DEFAULT_PRIMARY_NAME; + const secondaryName = secondaryInput?.value.trim() || DEFAULT_SECONDARY_NAME; + + return { primaryName, secondaryName }; +} + +function getAssignmentOptions(primaryName, secondaryName) { + return [ + { value: 'auto', label: 'Auto (Closest Check)' }, + { value: 'split_primary_50', label: `${primaryName} P1/P2 (50/50 Split)` }, + { value: 'split_secondary_50', label: `${secondaryName} P1/P2 (50/50 Split)` }, + { + value: 'combined_primaryP1_secondaryP1', + label: `${primaryName} P1 / ${secondaryName} P1 (Combined)`, + }, + { + value: 'combined_primaryP2_secondaryP2', + label: `${primaryName} P2 / ${secondaryName} P2 (Combined)`, + }, + { value: 'primary_P1', label: `${primaryName} P1 (Full Amt)` }, + { value: 'primary_P2', label: `${primaryName} P2 (Full Amt)` }, + { value: 'secondary_P1', label: `${secondaryName} P1 (Full Amt)` }, + { value: 'secondary_P2', label: `${secondaryName} P2 (Full Amt)` }, + { value: 'secondary_P3', label: `${secondaryName} P3 (Full Amt)` }, + ]; +} + +const initialBills = [ + { id: '1', name: 'Rent (Apt w/ Garage)', base: 1900, dueDay: 1, aggressiveAmount: 1900, type: 'fixed', assignmentType: 'auto' }, + { id: '2', name: 'Crunchyroll', base: 10, dueDay: 1, aggressiveAmount: 10, type: 'sub', assignmentType: 'auto' }, + { id: '3', name: 'Missionlane', base: 42, dueDay: 5, aggressiveAmount: 84, type: 'debt', assignmentType: 'auto' }, + { id: '4', name: 'YT Premium', base: 15, dueDay: 5, aggressiveAmount: 15, type: 'sub', assignmentType: 'auto' }, + { id: '5', name: 'Christina OBGYN (OTP)', base: 196, dueDay: 5, aggressiveAmount: 196, type: 'otp', assignmentType: 'auto' }, + { id: '6', name: 'Car Payment', base: 600, dueDay: 10, aggressiveAmount: 900, type: 'debt', assignmentType: 'auto' }, + { id: '7', name: 'Netflix', base: 20, dueDay: 10, aggressiveAmount: 20, type: 'sub', assignmentType: 'auto' }, + { id: '8', name: 'Health Insurance', base: 196, dueDay: 12, aggressiveAmount: 196, type: 'fixed', assignmentType: 'auto' }, + { id: '9', name: 'Forthpay', base: 146, dueDay: 15, aggressiveAmount: 292, type: 'debt', assignmentType: 'split_primary_50' }, + { id: '10', name: 'Spotloan', base: 155, dueDay: 18, aggressiveAmount: 310, type: 'debt', assignmentType: 'auto' }, + { id: '11', name: 'Power (Apt Est.)', base: 250, dueDay: 20, aggressiveAmount: 250, type: 'variable', assignmentType: 'auto' }, + { id: '12', name: 'IRS', base: 60, dueDay: 20, aggressiveAmount: 120, type: 'debt', assignmentType: 'auto' }, + { id: '13', name: 'Phone Bill', base: 154, dueDay: 20, aggressiveAmount: 154, type: 'fixed', assignmentType: 'auto' }, + { id: '14', name: 'Car Insurance', base: 154, dueDay: 24, aggressiveAmount: 154, type: 'fixed', assignmentType: 'auto' }, + { id: '15', name: 'Internet', base: 75, dueDay: 28, aggressiveAmount: 75, type: 'fixed', assignmentType: 'auto' }, + { id: '16', name: 'Water (Apt Est.)', base: 90, dueDay: 30, aggressiveAmount: 90, type: 'variable', assignmentType: 'auto' }, +]; + +let bills = JSON.parse(JSON.stringify(initialBills)); +let manualTransactions = []; + +const formatUSD = (amount) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + }).format(amount); + +function populateAssignmentSelect(selectElement, selectedValue, primaryName, secondaryName) { + if (!selectElement) return; + const options = getAssignmentOptions(primaryName, secondaryName); + selectElement.innerHTML = options + .map((option) => ``) + .join(''); +} + +function updateIncomeLabels(primaryName, secondaryName) { + const primaryPayLabel = document.getElementById('primaryPayLabel'); + const secondaryPayLabel = document.getElementById('secondaryPayLabel'); + const secondaryDateLabel = document.getElementById('secondaryDateLabel'); + + if (primaryPayLabel) { + primaryPayLabel.textContent = primaryName; + } + if (secondaryPayLabel) { + secondaryPayLabel.textContent = secondaryName; + } + if (secondaryDateLabel) { + secondaryDateLabel.textContent = secondaryName; + } +} + + + +function scrollToBills() { + document.getElementById('bill-management-section').scrollIntoView({ behavior: 'smooth' }); +} + +function calculatePaydays(currentDate, secondaryPayDate, primaryPayAmount, secondaryPayAmount, names) { + const targetMonth = currentDate.getMonth(); + const targetYear = currentDate.getFullYear(); + let paydays = []; + + USER_PAY_DATES.forEach((day, index) => { + const sequence = index + 1; + const payDate = new Date(targetYear, targetMonth, day); + paydays.push({ + id: `primary_P${sequence}`, + label: `${names.primaryName} P${sequence}`, + sourceKey: 'primary', + sequence, + date: payDate, + amount: primaryPayAmount, + }); + }); + + let currentSecondaryPay = new Date(secondaryPayDate); + for (let i = 0; i < 3; i++) { + currentSecondaryPay.setDate(currentSecondaryPay.getDate() - SECONDARY_PAY_INTERVAL); + } + + const cycleEndLimit = new Date(targetYear, targetMonth + 1, 15); + + for (let i = 0; i < 6; i++) { + const checkDate = new Date(currentSecondaryPay); + if ( + (checkDate.getMonth() === targetMonth || + (checkDate.getFullYear() === targetYear && + checkDate.getMonth() === (targetMonth + 1) % 12 && + checkDate.getDate() < 10)) && + checkDate.getTime() < cycleEndLimit.getTime() + ) { + const exists = paydays.some((p) => p.date.getTime() === checkDate.getTime()); + if (!exists) { + paydays.push({ + id: 'secondary_candidate', + label: '', + sourceKey: 'secondary', + sequence: 0, + date: checkDate, + amount: secondaryPayAmount, + }); + } + } + currentSecondaryPay.setDate(currentSecondaryPay.getDate() + SECONDARY_PAY_INTERVAL); + } + + paydays.sort((a, b) => a.date - b.date); + + let secondaryCounter = 1; + paydays = paydays.map((payday) => { + if (payday.sourceKey === 'secondary') { + const id = `secondary_P${secondaryCounter}`; + const label = `${names.secondaryName} P${secondaryCounter}`; + const updated = { + ...payday, + id, + label, + sequence: secondaryCounter, + amount: secondaryPayAmount, + }; + secondaryCounter += 1; + return updated; + } + return { + ...payday, + id: `primary_P${payday.sequence}`, + label: `${names.primaryName} P${payday.sequence}`, + amount: primaryPayAmount, + }; + }); + + return paydays; +} + +function createPaymentAssignments(bill, paydays, names) { + const assignments = []; + const amount = bill.finalAmount; + const today = new Date(); + const billDate = new Date(today.getFullYear(), today.getMonth(), bill.dueDay); + const paydaysById = Object.fromEntries(paydays.map((payday) => [payday.id, payday])); + + const pushAssignment = ({ paymentDate, sourceId, sourceLabel, sourceKey, payoutAmount }) => { + assignments.push({ + paymentDate, + sourceId, + sourceLabel, + sourceKey, + amount: payoutAmount ?? amount, + billName: bill.name, + billId: bill.id, + }); + }; + + if (bill.dueDay <= 5 && bill.assignmentType === 'auto') { + pushAssignment({ + paymentDate: new Date(billDate.getFullYear(), billDate.getMonth(), 1), + sourceId: 'buffer_payout', + sourceLabel: 'Buffer Payout', + sourceKey: 'buffer', + }); + return assignments; + } + + if (bill.assignmentType === 'split_primary_50') { + const splitAmount = amount / 2; + ['primary_P1', 'primary_P2'].forEach((id) => { + const payday = paydaysById[id]; + if (payday) { + pushAssignment({ + paymentDate: new Date(payday.date), + sourceId: payday.id, + sourceLabel: payday.label, + sourceKey: payday.sourceKey, + payoutAmount: splitAmount, + }); + } + }); + return assignments; + } + + if (bill.assignmentType === 'split_secondary_50') { + const splitAmount = amount / 2; + ['secondary_P1', 'secondary_P2'].forEach((id) => { + const payday = paydaysById[id]; + if (payday) { + pushAssignment({ + paymentDate: new Date(payday.date), + sourceId: payday.id, + sourceLabel: payday.label, + sourceKey: payday.sourceKey, + payoutAmount: splitAmount, + }); + } + }); + return assignments; + } + + if (bill.assignmentType === 'combined_primaryP1_secondaryP1') { + const anchor = paydaysById.primary_P1 || paydaysById.secondary_P1; + if (anchor) { + pushAssignment({ + paymentDate: new Date(anchor.date), + sourceId: 'combined_primaryP1_secondaryP1', + sourceLabel: `${names.primaryName} P1 / ${names.secondaryName} P1 (Combined)`, + sourceKey: 'combined', + }); + } + return assignments; + } + + if (bill.assignmentType === 'combined_primaryP2_secondaryP2') { + const anchor = paydaysById.primary_P2 || paydaysById.secondary_P2; + if (anchor) { + pushAssignment({ + paymentDate: new Date(anchor.date), + sourceId: 'combined_primaryP2_secondaryP2', + sourceLabel: `${names.primaryName} P2 / ${names.secondaryName} P2 (Combined)`, + sourceKey: 'combined', + }); + } + return assignments; + } + + if (bill.assignmentType !== 'auto') { + const targetPayday = paydaysById[bill.assignmentType]; + if (targetPayday) { + pushAssignment({ + paymentDate: new Date(targetPayday.date), + sourceId: targetPayday.id, + sourceLabel: targetPayday.label, + sourceKey: targetPayday.sourceKey, + }); + return assignments; + } + } + + const plannedPaymentDate = new Date(billDate); + plannedPaymentDate.setDate(billDate.getDate() - 3); + + const coveringPaychecks = paydays.filter((pay) => pay.date.getTime() <= plannedPaymentDate.getTime()); + + if (coveringPaychecks.length > 0) { + const closestPayday = coveringPaychecks[coveringPaychecks.length - 1]; + pushAssignment({ + paymentDate: new Date(closestPayday.date), + sourceId: closestPayday.id, + sourceLabel: closestPayday.label, + sourceKey: closestPayday.sourceKey, + }); + } else { + pushAssignment({ + paymentDate: plannedPaymentDate, + sourceId: 'buffer_needed', + sourceLabel: 'Buffer Needed', + sourceKey: 'buffer', + }); + } + + return assignments; +} + + +function addBill() { + const name = document.getElementById('newBillName').value; + const dueDay = parseInt(document.getElementById('newBillDueDay').value, 10); + const base = parseFloat(document.getElementById('newBillBaseAmount').value); + const aggressive = parseFloat(document.getElementById('newBillAggressiveAmount').value); + const assignmentType = document.getElementById('newBillAssignment').value; + + if (name && dueDay >= 1 && dueDay <= 31 && !Number.isNaN(base) && !Number.isNaN(aggressive)) { + const newBill = { + id: Date.now().toString(), + name, + base, + dueDay, + aggressiveAmount: aggressive, + type: base !== aggressive ? 'debt' : 'fixed', + assignmentType, + }; + bills.push(newBill); + calculateBudget(); + + document.getElementById('newBillName').value = ''; + document.getElementById('newBillBaseAmount').value = 100; + document.getElementById('newBillAggressiveAmount').value = 100; + document.getElementById('newBillAssignment').value = 'auto'; + } else { + alert('Please ensure the bill name, due day (1-31), base, and aggressive amounts are valid.'); + } +} + +function removeBill(billId) { + bills = bills.filter((b) => b.id !== billId); + calculateBudget(); +} + +function addManualTransaction() { + const dateInput = document.getElementById('manualDate').valueAsDate; + const description = document.getElementById('manualDescription').value; + const amount = parseFloat(document.getElementById('manualAmount').value) || 0; + + if (dateInput && description.trim() && amount !== 0) { + const newTransaction = { + id: Date.now().toString(), + date: dateInput, + description: description.trim(), + amount, + }; + manualTransactions.push(newTransaction); + calculateBudget(); + + document.getElementById('manualDate').valueAsDate = new Date(); + document.getElementById('manualDescription').value = ''; + document.getElementById('manualAmount').value = 0; + } else { + alert('Please enter a valid date, description, and non-zero amount.'); + } +} + +function removeManualTransaction(id) { + manualTransactions = manualTransactions.filter((t) => t.id !== id); + calculateBudget(); +} + +function updateBillProperty(input, property) { + const billId = input.getAttribute('data-bill-id'); + const value = input.value; + + const bill = bills.find((b) => b.id === billId); + if (!bill) return; + + let newValue; + let isValid = true; + + if (property === 'name') { + newValue = value.trim(); + isValid = newValue.length > 0; + } else if (property === 'dueDay') { + newValue = parseInt(value, 10); + isValid = !Number.isNaN(newValue) && newValue >= 1 && newValue <= 31; + } else if (property === 'assignmentType') { + newValue = value; + } else { + newValue = parseFloat(value); + isValid = !Number.isNaN(newValue) && newValue >= 0; + } + + if (isValid) { + bill[property] = newValue; + + if (property === 'assignmentType' && newValue !== 'auto') { + const currentDate = document.getElementById('currentDate').valueAsDate; + const secondaryPayDate = document.getElementById('christinaPayDate').valueAsDate; + const primaryAmount = parseFloat(document.getElementById('userPayAmount').value) || 0; + const secondaryAmount = parseFloat(document.getElementById('wifePayAmount').value) || 0; + const names = getIncomeNames(); + const paydays = calculatePaydays(currentDate, secondaryPayDate, primaryAmount, secondaryAmount, names); + const paydaysById = Object.fromEntries(paydays.map((p) => [p.id, p])); + + let newDueDay = null; + if (newValue === 'split_primary_50') { + newDueDay = paydaysById.primary_P1?.date.getDate() ?? null; + } else if (newValue === 'split_secondary_50') { + newDueDay = paydaysById.secondary_P1?.date.getDate() ?? null; + } else if (newValue === 'combined_primaryP1_secondaryP1') { + const anchor = paydaysById.primary_P1 || paydaysById.secondary_P1; + newDueDay = anchor ? anchor.date.getDate() : null; + } else if (newValue === 'combined_primaryP2_secondaryP2') { + const anchor = paydaysById.primary_P2 || paydaysById.secondary_P2; + newDueDay = anchor ? anchor.date.getDate() : null; + } else { + newDueDay = paydaysById[newValue]?.date.getDate() ?? null; + } + + if (newDueDay !== null) { + bill.dueDay = newDueDay; + const dueInput = document.querySelector(`input[data-bill-id="${billId}"][data-field="dueDay"]`); + if (dueInput) { + dueInput.value = bill.dueDay; + } + } + } + + if (property === 'base' || property === 'aggressiveAmount') { + bill.type = bill.base !== bill.aggressiveAmount ? 'debt' : 'fixed'; + } + } else { + input.value = bill[property]; + } +} + +function calculateBudget() { + const aggression = document.getElementById('aggressionToggle').checked; + const groceries = parseFloat(document.getElementById('groceriesAmount').value) || 0; + const gas = parseFloat(document.getElementById('gasAmount').value) || 0; + const savingsGoal = parseFloat(document.getElementById('savingsGoal').value) || 0; + const primaryPayAmount = parseFloat(document.getElementById('userPayAmount').value) || 0; + const secondaryPayAmount = parseFloat(document.getElementById('wifePayAmount').value) || 0; + const startingBuffer = parseFloat(document.getElementById('startingBuffer').value) || 0; + const ledgerStartDate = document.getElementById('ledgerStartDate').valueAsDate; + + const currentDate = document.getElementById('currentDate').valueAsDate; + const secondaryPayDate = document.getElementById('christinaPayDate').valueAsDate; + const { primaryName, secondaryName } = getIncomeNames(); + + if (!currentDate || !secondaryPayDate || !ledgerStartDate) return; + + document.getElementById('aggressionLabel').textContent = aggression ? 'ON' : 'OFF'; + + updateIncomeLabels(primaryName, secondaryName); + + const newBillAssignmentSelect = document.getElementById('newBillAssignment'); + if (newBillAssignmentSelect) { + const previousValue = newBillAssignmentSelect.value || 'auto'; + populateAssignmentSelect(newBillAssignmentSelect, previousValue, primaryName, secondaryName); + } + + const paydays = calculatePaydays(currentDate, secondaryPayDate, primaryPayAmount, secondaryPayAmount, { + primaryName, + secondaryName, + }); + + document.getElementById('payday-display').innerHTML = paydays + .map( + (p) => + `${p.label} (${p.date.toLocaleDateString('en-US', { + day: 'numeric', + month: 'short', + })})` + ) + .join(''); + + let totalFixed = 0; + const totalVariable = groceries + gas + savingsGoal; + const paymentCalendar = []; + + bills.forEach((bill) => { + bill.finalAmount = aggression && bill.type === 'debt' ? bill.aggressiveAmount : bill.base; + + const assignments = createPaymentAssignments(bill, paydays, { primaryName, secondaryName }); + + assignments.forEach((assignment) => { + paymentCalendar.push({ + id: `${assignment.billId}-${assignment.sourceId}-${assignment.amount}`, + name: assignment.billName, + finalAmount: assignment.amount, + dueDay: bill.dueDay, + paymentDate: assignment.paymentDate, + sourceId: assignment.sourceId, + sourceLabel: assignment.sourceLabel, + sourceKey: assignment.sourceKey, + }); + }); + + totalFixed += bill.finalAmount; + }); + + paymentCalendar.sort((a, b) => a.paymentDate - b.paymentDate); + + const totalIncome = paydays.reduce((sum, pay) => sum + pay.amount, 0); + const netSurplus = totalIncome - (totalFixed + totalVariable); + + document.getElementById('total-income').textContent = formatUSD(totalIncome); + document.getElementById('total-fixed').textContent = formatUSD(totalFixed); + document.getElementById('total-variable').textContent = formatUSD(totalVariable); + document.getElementById('net-surplus').textContent = formatUSD(netSurplus); + document.getElementById('net-surplus').className = netSurplus >= 0 ? 'text-xl balance-positive' : 'text-xl balance-negative'; + + const checkSummaryData = generateCheckSummary(paymentCalendar, paydays, groceries, gas, savingsGoal, { + primaryName, + secondaryName, + }); + const ledgerEvents = generateLedger( + paydays, + paymentCalendar, + groceries, + gas, + savingsGoal, + startingBuffer, + ledgerStartDate + ); + + renderCurrentBills(primaryName, secondaryName); + renderManualTransactions(); + renderCheckSummary(checkSummaryData); + renderCalendar(paymentCalendar); + renderLedger(ledgerEvents, startingBuffer, ledgerStartDate); +} + +function generateCheckSummary(paymentCalendar, paydays, groceries, gas, savingsGoal, names) { + const summary = {}; + const paydaysById = Object.fromEntries(paydays.map((payday) => [payday.id, payday])); + + paydays.forEach((payday) => { + summary[payday.id] = { + id: payday.id, + label: payday.label, + sourceKey: payday.sourceKey, + totalIncome: payday.amount, + bills: [], + totalBills: 0, + variableAllocation: 0, + date: payday.date, + }; + }); + + const combinedConfigs = [ + { + id: 'combined_primaryP1_secondaryP1', + label: `${names.primaryName} P1 / ${names.secondaryName} P1 (Combined)`, + anchors: ['primary_P1', 'secondary_P1'], + }, + { + id: 'combined_primaryP2_secondaryP2', + label: `${names.primaryName} P2 / ${names.secondaryName} P2 (Combined)`, + anchors: ['primary_P2', 'secondary_P2'], + }, + ]; + + combinedConfigs.forEach(({ id, label, anchors }) => { + const anchorDate = paydaysById[anchors[0]]?.date || paydaysById[anchors[1]]?.date || null; + summary[id] = { + id, + label, + sourceKey: 'combined', + totalIncome: 0, + bills: [], + totalBills: 0, + variableAllocation: 0, + date: anchorDate, + }; + }); + + paymentCalendar.forEach((payment) => { + if (payment.sourceKey === 'buffer') { + return; + } + + const entry = summary[payment.sourceId]; + if (!entry) { + return; + } + + let billDisplayName = payment.name; + if (payment.sourceKey === 'combined') { + billDisplayName = `${payment.name} (Combined Payout)`; + } + + entry.bills.push({ name: billDisplayName, amount: payment.finalAmount }); + entry.totalBills += payment.finalAmount; + }); + + const variableSplit = (groceries + gas) / 2; + const savingsSplit = savingsGoal / 2; + const primaryChecks = paydays.filter((pay) => pay.sourceKey === 'primary'); + + if (primaryChecks.length >= 1) { + const anchorId = + summary.combined_primaryP1_secondaryP1 && summary.combined_primaryP1_secondaryP1.totalBills > 0 + ? 'combined_primaryP1_secondaryP1' + : primaryChecks[0]?.id; + const entry = summary[anchorId]; + if (entry) { + if (variableSplit > 0) { + entry.bills.push({ name: 'Groceries/Gas (1st Half)', amount: variableSplit }); + entry.variableAllocation += variableSplit; + } + if (savingsSplit > 0) { + entry.bills.push({ name: 'Savings Goal (1st Half)', amount: savingsSplit }); + entry.variableAllocation += savingsSplit; + } + } + } + + if (primaryChecks.length >= 2) { + const anchorId = + summary.combined_primaryP2_secondaryP2 && summary.combined_primaryP2_secondaryP2.totalBills > 0 + ? 'combined_primaryP2_secondaryP2' + : primaryChecks[1]?.id; + const entry = summary[anchorId]; + if (entry) { + if (variableSplit > 0) { + entry.bills.push({ name: 'Groceries/Gas (2nd Half)', amount: variableSplit }); + entry.variableAllocation += variableSplit; + } + if (savingsSplit > 0) { + entry.bills.push({ name: 'Savings Goal (2nd Half)', amount: savingsSplit }); + entry.variableAllocation += savingsSplit; + } + } + } + + const processTransfers = (individualIds, combinedId) => { + const combinedEntry = summary[combinedId]; + if (!combinedEntry || combinedEntry.totalBills === 0) { + return; + } + + let transferToCombined = 0; + individualIds.forEach((id) => { + const entry = summary[id]; + if (!entry || !entry.date) { + return; + } + + const outflow = entry.totalBills + entry.variableAllocation; + const transferAmount = entry.totalIncome - outflow; + + if (transferAmount !== 0) { + entry.bills.push({ name: `Transfer to ${combinedEntry.label} Pool`, amount: transferAmount }); + entry.totalBills += transferAmount; + entry.transferOut = transferAmount; + transferToCombined += transferAmount; + } + }); + + combinedEntry.totalIncome = transferToCombined; + }; + + processTransfers(['primary_P1', 'secondary_P1'], 'combined_primaryP1_secondaryP1'); + processTransfers(['primary_P2', 'secondary_P2'], 'combined_primaryP2_secondaryP2'); + + Object.keys(summary).forEach((key) => { + const entry = summary[key]; + if (entry.sourceKey === 'combined' && entry.totalBills === 0 && entry.totalIncome === 0) { + delete summary[key]; + } + }); + + return summary; +} + +function renderCheckSummary(summaryData) { + const container = document.getElementById('check-summary-container'); + container.innerHTML = ''; + + const entries = Object.values(summaryData); + const order = new Map([ + ['primary_P1', 1], + ['secondary_P1', 2], + ['combined_primaryP1_secondaryP1', 3], + ['primary_P2', 4], + ['secondary_P2', 5], + ['combined_primaryP2_secondaryP2', 6], + ['secondary_P3', 7], + ]); + + entries + .sort((a, b) => { + const orderA = order.get(a.id) ?? 99; + const orderB = order.get(b.id) ?? 99; + if (orderA !== orderB) { + return orderA - orderB; + } + if (a.date && b.date) { + return a.date - b.date; + } + return 0; + }) + .forEach((data) => { + const totalOutflow = data.totalBills + data.variableAllocation; + const finalCashFlow = data.transferOut ? 0 : data.totalIncome - totalOutflow; + const incomeDisplay = data.totalIncome > 0 ? formatUSD(data.totalIncome) : 'N/A'; + + let checkDate = 'N/A'; + if (data.date instanceof Date && !Number.isNaN(data.date.getTime())) { + checkDate = data.date.toLocaleDateString('en-US', { day: 'numeric', month: 'short' }); + } else if (data.sourceKey === 'combined') { + checkDate = data.id.includes('P1') ? 'Approx 10th' : 'Approx 25th'; + } + + let cardColor = 'text-gray-700'; + if (data.sourceKey === 'primary') { + cardColor = 'text-blue-700'; + } else if (data.sourceKey === 'secondary') { + cardColor = 'text-green-700'; + } + + const card = document.createElement('div'); + card.className = `summary-card-custom p-4 rounded-xl shadow-lg border ${ + finalCashFlow >= 0 ? 'bg-white border-secondary/50' : 'bg-red-50 border-red-300' + }`; + + card.innerHTML = ` +

${data.label}${checkDate !== 'N/A' ? ` (${checkDate})` : ''}

+

Income: ${incomeDisplay}

+ +
+

Total Payout: ${formatUSD(totalOutflow)}

+

Cash Flow: ${formatUSD(finalCashFlow)}

+
+ `; + + container.appendChild(card); + }); +} + +function generateLedger(paydays, paymentCalendar, groceries, gas, savingsGoal, STARTING_BUFFER, ledgerStartDate) { + const ledgerEvents = []; + + paydays.forEach((pay) => { + ledgerEvents.push({ + date: pay.date, + description: `DEPOSIT: ${pay.label}`, + income: pay.amount, + expense: 0, + type: 'deposit', + sourceKey: pay.sourceKey, + }); + }); + + paymentCalendar.forEach((payment) => { + const description = + payment.sourceKey === 'buffer' + ? `PAYMENT: ${payment.name} (from Buffer)` + : `PAYMENT: ${payment.name}`; + ledgerEvents.push({ + date: payment.paymentDate, + description, + income: 0, + expense: payment.finalAmount, + type: 'bill', + sourceKey: payment.sourceKey, + }); + }); + + manualTransactions.forEach((tx) => { + const amount = tx.amount; + ledgerEvents.push({ + date: tx.date, + description: `MANUAL: ${tx.description}`, + income: amount > 0 ? amount : 0, + expense: amount < 0 ? Math.abs(amount) : 0, + type: 'manual', + sourceKey: amount > 0 ? 'manual_income' : 'manual_expense', + }); + }); + + const primaryChecks = paydays.filter((p) => p.sourceKey === 'primary'); + const variableSplit = (groceries + gas) / 2; + const savingsSplit = savingsGoal / 2; + + if (primaryChecks.length >= 1 && (variableSplit > 0 || savingsSplit > 0)) { + const eventDate = new Date(primaryChecks[0].date); + if (variableSplit > 0) { + ledgerEvents.push({ + date: new Date(eventDate), + description: 'ALLOCATION: Groceries/Gas (1st Half)', + income: 0, + expense: variableSplit, + type: 'variable', + }); + } + if (savingsSplit > 0) { + ledgerEvents.push({ + date: new Date(eventDate), + description: 'TRANSFER: Monthly Savings Goal (1st Half)', + income: 0, + expense: savingsSplit, + type: 'savings', + }); + } + } + + if (primaryChecks.length >= 2 && (variableSplit > 0 || savingsSplit > 0)) { + const eventDate = new Date(primaryChecks[1].date); + if (variableSplit > 0) { + ledgerEvents.push({ + date: new Date(eventDate), + description: 'ALLOCATION: Groceries/Gas (2nd Half)', + income: 0, + expense: variableSplit, + type: 'variable', + }); + } + if (savingsSplit > 0) { + ledgerEvents.push({ + date: new Date(eventDate), + description: 'TRANSFER: Monthly Savings Goal (2nd Half)', + income: 0, + expense: savingsSplit, + type: 'savings', + }); + } + } + + const filteredEvents = ledgerEvents.filter((event) => event.date.getTime() >= ledgerStartDate.getTime()); + + filteredEvents.sort((a, b) => { + if (a.date.getTime() !== b.date.getTime()) { + return a.date - b.date; + } + const order = { + deposit: 1, + savings: 2, + variable: 3, + bill: 4, + manual: (event) => { + if (event.expense > 0) return 4.5; + if (event.income > 0) return 1.5; + return 5; + }, + unknown: 5, + }; + const getOrderValue = (event) => { + const val = order[event.type]; + return typeof val === 'function' ? val(event) : val; + }; + const aOrder = getOrderValue(a); + const bOrder = getOrderValue(b); + return (aOrder || order.unknown) - (bOrder || order.unknown); + }); + + filteredEvents.unshift({ + date: ledgerStartDate, + description: `STARTING BALANCE (${ledgerStartDate.toLocaleDateString('en-US', { day: 'numeric', month: 'short' })})`, + expense: 0, + income: 0, + isBuffer: true, + }); + + return filteredEvents; +} + +function renderCurrentBills(primaryName, secondaryName) { + const tbody = document.getElementById('current-bills-body'); + tbody.innerHTML = ''; + + const aggression = document.getElementById('aggressionToggle').checked; + const options = getAssignmentOptions(primaryName, secondaryName); + + bills.forEach((bill) => { + const row = tbody.insertRow(); + row.className = 'hover:bg-gray-50'; + + const nameCell = row.insertCell(); + nameCell.innerHTML = ` + + `; + + const dueCell = row.insertCell(); + dueCell.innerHTML = ` + + `; + + const baseCell = row.insertCell(); + baseCell.innerHTML = ` + + `; + + const aggressiveDisabled = aggression ? '' : 'disabled'; + const aggressiveClass = aggression ? '' : 'bg-gray-200 cursor-not-allowed'; + const aggressiveCell = row.insertCell(); + aggressiveCell.innerHTML = ` + + `; + + const typeCell = row.insertCell(); + let typeText = 'Fixed'; + let typeColor = 'text-gray-600'; + + if (bill.type === 'debt' && aggression) { + typeText = 'Debt (Aggressive)'; + typeColor = 'text-red-600'; + } else if (bill.type === 'otp') { + typeText = 'One-Time'; + } + + typeCell.textContent = typeText; + typeCell.className = `px-3 py-2 text-xs font-medium ${typeColor}`; + + const assignmentCell = row.insertCell(); + const optionsMarkup = options + .map( + (option) => ` + + ` + ) + .join(''); + assignmentCell.innerHTML = ` + + `; + + const actionCell = row.insertCell(); + actionCell.className = 'text-center'; + actionCell.innerHTML = ` + + `; + }); +} + +function renderManualTransactions() { + const tbody = document.getElementById('manual-transactions-body'); + tbody.innerHTML = ''; + + manualTransactions.sort((a, b) => a.date - b.date); + + if (manualTransactions.length === 0) { + const row = tbody.insertRow(); + row.insertCell().outerHTML = + 'No manual adjustments logged yet.'; + return; + } + + manualTransactions.forEach((tx) => { + const row = tbody.insertRow(); + const isIncome = tx.amount > 0; + row.className = isIncome ? 'bg-green-50 hover:bg-green-100' : 'bg-red-50 hover:bg-red-100'; + + row.insertCell().textContent = tx.date.toLocaleDateString('en-US', { day: 'numeric', month: 'short' }); + row.insertCell().textContent = tx.description; + + const amountCell = row.insertCell(); + amountCell.className = `px-3 py-2 text-right text-sm font-semibold ${isIncome ? 'text-secondary' : 'text-red-600'}`; + amountCell.textContent = formatUSD(tx.amount); + + const actionCell = row.insertCell(); + actionCell.className = 'text-center'; + actionCell.innerHTML = ` + + `; + }); +} + +function renderCalendar(calendar) { + const tbody = document.getElementById('payment-calendar-body'); + tbody.innerHTML = ''; + + calendar.forEach((bill) => { + const row = tbody.insertRow(); + row.className = 'hover:bg-gray-50'; + + row.insertCell().textContent = bill.name; + + const dueCell = row.insertCell(); + dueCell.textContent = bill.dueDay; + + row.insertCell().textContent = formatUSD(bill.finalAmount); + row.insertCell().textContent = bill.paymentDate.toLocaleDateString('en-US', { day: 'numeric', month: 'short' }); + + const sourceCell = row.insertCell(); + sourceCell.textContent = bill.sourceLabel; + let sourceColor = 'bg-yellow-100 text-yellow-800'; + if (bill.sourceKey === 'primary') { + sourceColor = 'bg-blue-100 text-blue-800'; + } else if (bill.sourceKey === 'secondary') { + sourceColor = 'bg-green-100 text-green-800'; + } else if (bill.sourceKey === 'combined') { + sourceColor = 'bg-purple-100 text-purple-800'; + } + sourceCell.className = `font-medium text-xs rounded-full p-1 ${sourceColor}`; + + const actionCell = row.insertCell(); + actionCell.innerHTML = ` + + `; + }); +} + +function renderLedger(ledgerEvents, STARTING_BUFFER, ledgerStartDate) { + const tbody = document.getElementById('cash-flow-ledger-body'); + tbody.innerHTML = ''; + + let runningBalance = STARTING_BUFFER; + + ledgerEvents.forEach((event) => { + if (event.isBuffer) { + const bufferRow = tbody.insertRow(); + bufferRow.className = 'bg-yellow-50 font-semibold'; + bufferRow.insertCell().textContent = event.date.toLocaleDateString('en-US', { day: 'numeric', month: 'short' }); + bufferRow.insertCell().textContent = event.description; + bufferRow.insertCell().textContent = ''; + bufferRow.insertCell().textContent = ''; + const balanceCell = bufferRow.insertCell(); + balanceCell.textContent = formatUSD(runningBalance); + balanceCell.className = 'px-3 py-2 text-right text-sm balance-positive'; + return; + } + + runningBalance = runningBalance + event.income - event.expense; + + let rowClass = 'hover:bg-gray-50'; + if (event.type === 'deposit' && event.sourceKey === 'primary') { + rowClass = 'bg-blue-50 hover:bg-blue-100'; + } else if (event.type === 'deposit' && event.sourceKey === 'secondary') { + rowClass = 'bg-green-50 hover:bg-green-100'; + } else if (event.type === 'bill' && event.sourceKey === 'buffer') { + rowClass = 'bg-red-50 hover:bg-red-100'; + } else if (event.type === 'bill' && event.sourceKey === 'combined') { + rowClass = 'bg-purple-50 hover:bg-purple-100'; + } else if (event.type === 'savings') { + rowClass = 'bg-indigo-50 hover:bg-indigo-100'; + } else if (event.type === 'variable') { + rowClass = 'bg-yellow-50 hover:bg-yellow-100'; + } else if (event.type === 'manual') { + rowClass = event.income > 0 ? 'bg-green-100 hover:bg-green-200' : 'bg-pink-100 hover:bg-pink-200'; + } + + const row = tbody.insertRow(); + row.className = rowClass; + + row.insertCell().textContent = event.date.toLocaleDateString('en-US', { day: 'numeric', month: 'short' }); + row.insertCell().textContent = event.description; + + const incomeCell = row.insertCell(); + incomeCell.className = 'px-3 py-2 text-right text-sm'; + incomeCell.textContent = event.income > 0 ? formatUSD(event.income) : ''; + + const expenseCell = row.insertCell(); + expenseCell.className = 'px-3 py-2 text-right text-sm text-red-600'; + expenseCell.textContent = event.expense > 0 ? formatUSD(event.expense) : ''; + + const balanceCell = row.insertCell(); + balanceCell.className = `px-3 py-2 text-right text-sm ${runningBalance >= 0 ? 'balance-positive' : 'balance-negative'}`; + balanceCell.textContent = formatUSD(runningBalance); + }); + + const finalRow = tbody.insertRow(); + finalRow.className = 'bg-primary/10 font-bold'; + finalRow.insertCell().textContent = ''; + finalRow.insertCell().textContent = 'END OF MONTH BALANCE'; + finalRow.insertCell().textContent = ''; + finalRow.insertCell().textContent = ''; + const finalBalanceCell = finalRow.insertCell(); + finalBalanceCell.textContent = formatUSD(runningBalance); + finalBalanceCell.className = `px-3 py-2 text-right text-base ${runningBalance >= 0 ? 'balance-positive' : 'balance-negative'}`; +} + +document.addEventListener('DOMContentLoaded', () => { + const today = new Date(); + const defaultChristinaPay = new Date(today.getFullYear(), today.getMonth(), 5); + + document.getElementById('currentDate').valueAsDate = today; + document.getElementById('ledgerStartDate').valueAsDate = today; + document.getElementById('christinaPayDate').valueAsDate = defaultChristinaPay; + document.getElementById('manualDate').valueAsDate = today; + document.getElementById('primaryIncomeName').value = DEFAULT_PRIMARY_NAME; + document.getElementById('secondaryIncomeName').value = DEFAULT_SECONDARY_NAME; + + const inputIds = [ + 'currentDate', + 'ledgerStartDate', + 'christinaPayDate', + 'primaryIncomeName', + 'secondaryIncomeName', + 'userPayAmount', + 'wifePayAmount', + 'startingBuffer', + 'savingsGoal', + 'groceriesAmount', + 'gasAmount', + ]; + + inputIds.forEach((id) => { + const element = document.getElementById(id); + if (!element) return; + const eventType = element.type === 'date' ? 'change' : 'input'; + element.addEventListener(eventType, () => calculateBudget()); + }); + + document.getElementById('aggressionToggle').addEventListener('change', () => calculateBudget()); + document.getElementById('addBillButton').addEventListener('click', (event) => { + event.preventDefault(); + addBill(); + }); + document.getElementById('addTransactionButton').addEventListener('click', (event) => { + event.preventDefault(); + addManualTransaction(); + }); + document.getElementById('recalculateButton').addEventListener('click', (event) => { + event.preventDefault(); + calculateBudget(); + }); + + document.getElementById('userPayAmount').value = 2000; + document.getElementById('wifePayAmount').value = 1500; + document.getElementById('savingsGoal').value = 500; + document.getElementById('startingBuffer').value = 200; + + calculateBudget(); +}); + +window.removeBill = removeBill; +window.scrollToBills = scrollToBills; +window.updateBillProperty = updateBillProperty; +window.removeManualTransaction = removeManualTransaction; diff --git a/index.html b/index.html new file mode 100644 index 00000000..af5547b6 --- /dev/null +++ b/index.html @@ -0,0 +1,277 @@ + + + + + + Dynamic Family Budget Planner + + + + + +
+
+

Dynamic Family Budget Planner

+

Cash Flow Management with Aggressive Debt Payoff

+
+ +
+

Configuration

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ $ + +
+
+
+ +
+ $ + +
+
+
+ +
+ $ + +
+
+
+
+

Calculated Paydays this Cycle: Loading...

+
+
+ +
+
+

Monthly Summary

+
+

Income:

+

Fixed Bills:

+

Variables & Savings:

+
+

Net Surplus:

+
+
+ +
+

Variable Spending & Debt Aggression

+
+
+ +
+ $ + +
+
+
+ +
+ $ + +
+
+
+ +
+ $ + +
+
+
+ Aggressive Debt Payoff + +
+
+
+
+ +
+

1. Bill Management (Add/Edit Bills & Manual Transactions)

+
+ +

Click this button after editing any bill amount or assignment below.

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+

Add Manual Transaction

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+

Current Bills List (Editable)

+
+ + + + + + + + + + + + + +
NameDue DayBase Amt ($)Aggressive Amt ($)TypeAssignmentAction
+
+
+ +
+

Manual Ledger Adjustments

+
+ + + + + + + + + + +
DateDescriptionAmountAction
+
+
+
+ +
+

2. Bill Coverage Per Paycheck

+
+
+ +
+

3. Master Payment Calendar (Due Dates & Planned Payouts)

+
+ + + + + + + + + + + + +
Bill/ExpenseDue Date (D)Payment AmountPlanned Payment Date (P)Source CheckActions
+
+
+ +
+

4. Paycheck Cash Flow Ledger (Running Balance)

+
+ + + + + + + + + + + +
DateDescriptionIncome (+)Expense (-)Running Balance
+
+
+
+ + + + diff --git a/styles.css b/styles.css new file mode 100644 index 00000000..c3029bed --- /dev/null +++ b/styles.css @@ -0,0 +1,59 @@ +body { + font-family: 'Inter', sans-serif; + background-color: #f3f4f6; +} + +.header-section { + background: linear-gradient(135deg, #4f46e5 0%, #3b82f6 100%); +} + +.scrollable-table { + max-height: 400px; + overflow-y: auto; +} + +.balance-positive { + color: #10b981; + font-weight: 600; +} + +.balance-negative { + color: #ef4444; + font-weight: 600; +} + +.toggle-switch-bg { + transition: background-color 0.2s; +} + +input:checked ~ .toggle-switch-bg { + background-color: #10b981; +} + +.summary-card-custom { + min-height: 12rem; + display: flex; + flex-direction: column; +} + +.editable-input { + width: 100%; + padding: 4px; + border: 1px solid #d1d5db; + border-radius: 6px; + text-align: center; + font-size: 0.875rem; +} + +.editable-name { + text-align: left; + padding-left: 0.75rem; +} + +.assignment-select { + width: 100%; + padding: 4px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 0.875rem; +} From 349ca041094c5215b0b5f52c1a14431fd90a8297 Mon Sep 17 00:00:00 2001 From: Justin-Seib <46479303+Justin-Seib@users.noreply.github.com> Date: Mon, 13 Oct 2025 23:24:29 -0500 Subject: [PATCH 2/2] Refactor income config and ledger flow --- README.md | 4 +- app.js | 1535 ++++++++++++++++++++++++++++------------------------ index.html | 129 +++-- 3 files changed, 913 insertions(+), 755 deletions(-) diff --git a/README.md b/README.md index 19673418..71195617 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A single-page budgeting and cashflow planning prototype built with Tailwind CSS and vanilla JavaScript. Configure income sources, map bills to specific paychecks, plan aggressive debt payoff, and review a running cashflow ledger for the month. ## Features -- Dynamic configuration for two customizable income sources with support for semi-monthly and bi-weekly schedules. +- Income source manager to add, rename, or remove semi-monthly and bi-weekly pay schedules, each with configurable amounts and pay dates. - Bill management workflow that lets you edit amounts, due days, assignment strategies, and aggressive payoff settings. - Manual ledger transactions and savings allocations that flow into a combined payment calendar and paycheck summaries. - Cashflow ledger that starts from your current buffer and tracks deposits, allocations, and expenses chronologically. @@ -14,7 +14,7 @@ A single-page budgeting and cashflow planning prototype built with Tailwind CSS - macOS/Linux: `python3 -m http.server 8000` - Windows: `py -m http.server 8000` 3. Visit `http://localhost:8000/index.html` in your browser to interact with the planner. -4. Update the income labels (e.g., "Jim" and "Jessie") so every table and summary reflects your job names, then tweak bill assignments and transactions to fit your scenario. +4. Use the **Income Sources** manager to rename existing jobs or add new ones with the correct pay frequency, then tweak bill assignments and transactions to fit your scenario. Because everything is static files, you can also host the repository with GitHub Pages. Point Pages to the `main` branch and browse to `https://.github.io/budget-planner/` once it publishes. diff --git a/app.js b/app.js index 9b9eb154..459d07ca 100644 --- a/app.js +++ b/app.js @@ -1,192 +1,358 @@ -const USER_PAY_DATES = [10, 25]; -const SECONDARY_PAY_INTERVAL = 14; +const MAX_INCOME_SOURCES = 4; +const BI_WEEKLY_INTERVAL = 14; + +const INCOME_COLOR_CLASSES = [ + { card: 'text-blue-700', ledgerRow: 'bg-blue-50 hover:bg-blue-100', badge: 'bg-blue-100 text-blue-800' }, + { card: 'text-green-700', ledgerRow: 'bg-green-50 hover:bg-green-100', badge: 'bg-green-100 text-green-800' }, + { card: 'text-amber-700', ledgerRow: 'bg-amber-50 hover:bg-amber-100', badge: 'bg-amber-100 text-amber-800' }, + { card: 'text-purple-700', ledgerRow: 'bg-purple-50 hover:bg-purple-100', badge: 'bg-purple-100 text-purple-800' }, +]; -const DEFAULT_PRIMARY_NAME = 'Justin'; -const DEFAULT_SECONDARY_NAME = 'Christina'; +const initialBills = [ + { id: '1', name: 'Rent (Apt w/ Garage)', base: 1900, aggressiveAmount: 1900, dueDay: 1, type: 'fixed', assignmentType: 'auto' }, + { id: '2', name: 'Crunchyroll', base: 10, aggressiveAmount: 10, dueDay: 1, type: 'sub', assignmentType: 'auto' }, + { id: '3', name: 'Missionlane', base: 42, aggressiveAmount: 84, dueDay: 5, type: 'debt', assignmentType: 'auto' }, + { id: '4', name: 'YT Premium', base: 15, aggressiveAmount: 15, dueDay: 5, type: 'sub', assignmentType: 'auto' }, + { id: '5', name: 'Christina OBGYN (OTP)', base: 196, aggressiveAmount: 196, dueDay: 5, type: 'otp', assignmentType: 'auto' }, + { id: '6', name: 'Car Payment', base: 600, aggressiveAmount: 900, dueDay: 10, type: 'debt', assignmentType: 'auto' }, + { id: '7', name: 'Netflix', base: 20, aggressiveAmount: 20, dueDay: 10, type: 'sub', assignmentType: 'auto' }, + { id: '8', name: 'Health Insurance', base: 196, aggressiveAmount: 196, dueDay: 12, type: 'fixed', assignmentType: 'auto' }, + { id: '9', name: 'Forthpay', base: 146, aggressiveAmount: 292, dueDay: 15, type: 'debt', assignmentType: 'split:income_primary' }, + { id: '10', name: 'Spotloan', base: 155, aggressiveAmount: 310, dueDay: 18, type: 'debt', assignmentType: 'auto' }, + { id: '11', name: 'Power (Apt Est.)', base: 250, aggressiveAmount: 250, dueDay: 20, type: 'variable', assignmentType: 'auto' }, + { id: '12', name: 'IRS', base: 60, aggressiveAmount: 120, dueDay: 20, type: 'debt', assignmentType: 'auto' }, + { id: '13', name: 'Phone Bill', base: 154, aggressiveAmount: 154, dueDay: 20, type: 'fixed', assignmentType: 'auto' }, + { id: '14', name: 'Car Insurance', base: 154, aggressiveAmount: 154, dueDay: 24, type: 'fixed', assignmentType: 'auto' }, + { id: '15', name: 'Internet', base: 75, aggressiveAmount: 75, dueDay: 28, type: 'fixed', assignmentType: 'auto' }, + { id: '16', name: 'Water (Apt Est.)', base: 90, aggressiveAmount: 90, dueDay: 30, type: 'variable', assignmentType: 'auto' }, +]; -function getIncomeNames() { - const primaryInput = document.getElementById('primaryIncomeName'); - const secondaryInput = document.getElementById('secondaryIncomeName'); +let bills = JSON.parse(JSON.stringify(initialBills)); +let manualTransactions = []; +let incomeProfiles = []; +let selectedIncomeId = null; +let lastComputedPaydays = []; - const primaryName = primaryInput?.value.trim() || DEFAULT_PRIMARY_NAME; - const secondaryName = secondaryInput?.value.trim() || DEFAULT_SECONDARY_NAME; +const formatUSD = (amount) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + }).format(amount); - return { primaryName, secondaryName }; -} +const createIncomeId = () => `income_${Math.random().toString(16).slice(2)}_${Date.now()}`; -function getAssignmentOptions(primaryName, secondaryName) { +function getDefaultIncomeProfiles(today) { return [ - { value: 'auto', label: 'Auto (Closest Check)' }, - { value: 'split_primary_50', label: `${primaryName} P1/P2 (50/50 Split)` }, - { value: 'split_secondary_50', label: `${secondaryName} P1/P2 (50/50 Split)` }, { - value: 'combined_primaryP1_secondaryP1', - label: `${primaryName} P1 / ${secondaryName} P1 (Combined)`, + id: 'income_primary', + name: 'Justin', + type: 'semi-monthly', + amount: 2000, + semiMonthlyDays: [10, 25], + nextPayDate: null, }, { - value: 'combined_primaryP2_secondaryP2', - label: `${primaryName} P2 / ${secondaryName} P2 (Combined)`, + id: 'income_secondary', + name: 'Christina', + type: 'bi-weekly', + amount: 1500, + semiMonthlyDays: [10, 25], + nextPayDate: new Date(today.getFullYear(), today.getMonth(), 5), }, - { value: 'primary_P1', label: `${primaryName} P1 (Full Amt)` }, - { value: 'primary_P2', label: `${primaryName} P2 (Full Amt)` }, - { value: 'secondary_P1', label: `${secondaryName} P1 (Full Amt)` }, - { value: 'secondary_P2', label: `${secondaryName} P2 (Full Amt)` }, - { value: 'secondary_P3', label: `${secondaryName} P3 (Full Amt)` }, ]; } -const initialBills = [ - { id: '1', name: 'Rent (Apt w/ Garage)', base: 1900, dueDay: 1, aggressiveAmount: 1900, type: 'fixed', assignmentType: 'auto' }, - { id: '2', name: 'Crunchyroll', base: 10, dueDay: 1, aggressiveAmount: 10, type: 'sub', assignmentType: 'auto' }, - { id: '3', name: 'Missionlane', base: 42, dueDay: 5, aggressiveAmount: 84, type: 'debt', assignmentType: 'auto' }, - { id: '4', name: 'YT Premium', base: 15, dueDay: 5, aggressiveAmount: 15, type: 'sub', assignmentType: 'auto' }, - { id: '5', name: 'Christina OBGYN (OTP)', base: 196, dueDay: 5, aggressiveAmount: 196, type: 'otp', assignmentType: 'auto' }, - { id: '6', name: 'Car Payment', base: 600, dueDay: 10, aggressiveAmount: 900, type: 'debt', assignmentType: 'auto' }, - { id: '7', name: 'Netflix', base: 20, dueDay: 10, aggressiveAmount: 20, type: 'sub', assignmentType: 'auto' }, - { id: '8', name: 'Health Insurance', base: 196, dueDay: 12, aggressiveAmount: 196, type: 'fixed', assignmentType: 'auto' }, - { id: '9', name: 'Forthpay', base: 146, dueDay: 15, aggressiveAmount: 292, type: 'debt', assignmentType: 'split_primary_50' }, - { id: '10', name: 'Spotloan', base: 155, dueDay: 18, aggressiveAmount: 310, type: 'debt', assignmentType: 'auto' }, - { id: '11', name: 'Power (Apt Est.)', base: 250, dueDay: 20, aggressiveAmount: 250, type: 'variable', assignmentType: 'auto' }, - { id: '12', name: 'IRS', base: 60, dueDay: 20, aggressiveAmount: 120, type: 'debt', assignmentType: 'auto' }, - { id: '13', name: 'Phone Bill', base: 154, dueDay: 20, aggressiveAmount: 154, type: 'fixed', assignmentType: 'auto' }, - { id: '14', name: 'Car Insurance', base: 154, dueDay: 24, aggressiveAmount: 154, type: 'fixed', assignmentType: 'auto' }, - { id: '15', name: 'Internet', base: 75, dueDay: 28, aggressiveAmount: 75, type: 'fixed', assignmentType: 'auto' }, - { id: '16', name: 'Water (Apt Est.)', base: 90, dueDay: 30, aggressiveAmount: 90, type: 'variable', assignmentType: 'auto' }, -]; +function getIncomeById(id) { + return incomeProfiles.find((income) => income.id === id) || null; +} -let bills = JSON.parse(JSON.stringify(initialBills)); -let manualTransactions = []; +function getIncomeColorIndex(incomeId) { + const index = incomeProfiles.findIndex((income) => income.id === incomeId); + return index >= 0 ? index % INCOME_COLOR_CLASSES.length : 0; +} -const formatUSD = (amount) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - }).format(amount); +function getIncomeColorClasses(incomeId) { + return INCOME_COLOR_CLASSES[getIncomeColorIndex(incomeId)]; +} + +function sanitizeSemiMonthlyDays(days) { + const cleaned = days + .map((value) => Number.parseInt(value, 10)) + .filter((value) => Number.isFinite(value) && value >= 1 && value <= 31); + if (!cleaned.length) { + return [10, 25]; + } + if (cleaned.length === 1) { + cleaned.push(cleaned[0] === 10 ? 25 : cleaned[0]); + } + return cleaned.slice(0, 2).sort((a, b) => a - b); +} -function populateAssignmentSelect(selectElement, selectedValue, primaryName, secondaryName) { - if (!selectElement) return; - const options = getAssignmentOptions(primaryName, secondaryName); - selectElement.innerHTML = options - .map((option) => ``) +function refreshIncomeSelect() { + const select = document.getElementById('incomeSelect'); + if (!select) return; + select.innerHTML = incomeProfiles + .map((income) => ``) .join(''); + if (!incomeProfiles.length) { + selectedIncomeId = null; + return; + } + if (!selectedIncomeId || !getIncomeById(selectedIncomeId)) { + selectedIncomeId = incomeProfiles[0].id; + } + select.value = selectedIncomeId; +} + +function updateIncomeFormVisibility(type) { + const semiFields = document.getElementById('semiMonthlyFields'); + const biWeeklyFields = document.getElementById('biWeeklyFields'); + if (semiFields) { + semiFields.classList.toggle('hidden', type !== 'semi-monthly'); + } + if (biWeeklyFields) { + biWeeklyFields.classList.toggle('hidden', type !== 'bi-weekly'); + } +} + +function renderIncomeForm() { + const income = getIncomeById(selectedIncomeId); + const nameInput = document.getElementById('incomeNameInput'); + const amountInput = document.getElementById('incomeAmountInput'); + const typeSelect = document.getElementById('incomeTypeSelect'); + const semiDay1 = document.getElementById('incomeSemiDay1'); + const semiDay2 = document.getElementById('incomeSemiDay2'); + const nextPayInput = document.getElementById('incomeNextPayDate'); + const removeBtn = document.getElementById('removeIncomeButton'); + + if (!income || !nameInput || !amountInput || !typeSelect) { + return; + } + + nameInput.value = income.name; + amountInput.value = income.amount; + typeSelect.value = income.type; + const [day1, day2] = income.semiMonthlyDays || [10, 25]; + if (semiDay1) { + semiDay1.value = day1; + } + if (semiDay2) { + semiDay2.value = day2; + } + if (nextPayInput) { + nextPayInput.valueAsDate = income.nextPayDate ? new Date(income.nextPayDate) : null; + } + if (removeBtn) { + removeBtn.disabled = incomeProfiles.length <= 1; + } + updateIncomeFormVisibility(income.type); } -function updateIncomeLabels(primaryName, secondaryName) { - const primaryPayLabel = document.getElementById('primaryPayLabel'); - const secondaryPayLabel = document.getElementById('secondaryPayLabel'); - const secondaryDateLabel = document.getElementById('secondaryDateLabel'); +function addIncomeProfile() { + if (incomeProfiles.length >= MAX_INCOME_SOURCES) { + alert(`You can track up to ${MAX_INCOME_SOURCES} income sources.`); + return; + } + const newIncome = { + id: createIncomeId(), + name: 'New Income', + type: 'semi-monthly', + amount: 0, + semiMonthlyDays: [10, 25], + nextPayDate: null, + }; + incomeProfiles.push(newIncome); + selectedIncomeId = newIncome.id; + refreshIncomeSelect(); + renderIncomeForm(); + calculateBudget(); +} - if (primaryPayLabel) { - primaryPayLabel.textContent = primaryName; +function removeIncomeProfile() { + if (incomeProfiles.length <= 1) { + return; } - if (secondaryPayLabel) { - secondaryPayLabel.textContent = secondaryName; + const removeIndex = incomeProfiles.findIndex((income) => income.id === selectedIncomeId); + if (removeIndex === -1) { + return; } - if (secondaryDateLabel) { - secondaryDateLabel.textContent = secondaryName; + const [removedIncome] = incomeProfiles.splice(removeIndex, 1); + bills = bills.map((bill) => { + if (!bill.assignmentType) return bill; + if (bill.assignmentType.startsWith('split:')) { + const [, incomeId] = bill.assignmentType.split(':'); + if (incomeId === removedIncome.id) { + return { ...bill, assignmentType: 'auto' }; + } + } else if (bill.assignmentType.startsWith('payday:')) { + const [, paydayId] = bill.assignmentType.split(':'); + if (paydayId.startsWith(`${removedIncome.id}_`)) { + return { ...bill, assignmentType: 'auto' }; + } + } else if (bill.assignmentType.startsWith('combined:')) { + const segments = bill.assignmentType.split(':').slice(1); + if (segments.some((value) => value.startsWith(`${removedIncome.id}_`))) { + return { ...bill, assignmentType: 'auto' }; + } + } + return bill; + }); + + if (!incomeProfiles.length) { + selectedIncomeId = null; + } else if (removeIndex >= incomeProfiles.length) { + selectedIncomeId = incomeProfiles[incomeProfiles.length - 1].id; + } else { + selectedIncomeId = incomeProfiles[removeIndex].id; } + + refreshIncomeSelect(); + renderIncomeForm(); + calculateBudget(); } +function handleIncomeFieldChange() { + const income = getIncomeById(selectedIncomeId); + if (!income) return; + const nameInput = document.getElementById('incomeNameInput'); + const amountInput = document.getElementById('incomeAmountInput'); + const typeSelect = document.getElementById('incomeTypeSelect'); + const semiDay1 = document.getElementById('incomeSemiDay1'); + const semiDay2 = document.getElementById('incomeSemiDay2'); + const nextPayInput = document.getElementById('incomeNextPayDate'); + + if (nameInput) { + income.name = nameInput.value.trim() || 'Income'; + } + if (amountInput) { + const value = Number.parseFloat(amountInput.value); + income.amount = Number.isFinite(value) ? value : 0; + } + if (typeSelect) { + income.type = typeSelect.value; + } + if (semiDay1 && semiDay2) { + income.semiMonthlyDays = sanitizeSemiMonthlyDays([semiDay1.value, semiDay2.value]); + } + if (nextPayInput) { + income.nextPayDate = nextPayInput.valueAsDate ? new Date(nextPayInput.valueAsDate) : null; + } + + if (income.type === 'bi-weekly' && !income.nextPayDate) { + income.nextPayDate = new Date(); + if (nextPayInput) { + nextPayInput.valueAsDate = income.nextPayDate; + } + } + + updateIncomeFormVisibility(income.type); + refreshIncomeSelect(); + calculateBudget(); +} + +function sanitizeDate(value) { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date) ? null : date; +} function scrollToBills() { - document.getElementById('bill-management-section').scrollIntoView({ behavior: 'smooth' }); + document.getElementById('bill-management-section')?.scrollIntoView({ behavior: 'smooth' }); } -function calculatePaydays(currentDate, secondaryPayDate, primaryPayAmount, secondaryPayAmount, names) { - const targetMonth = currentDate.getMonth(); +function calculatePaydays(currentDate, incomes) { + if (!currentDate) return []; const targetYear = currentDate.getFullYear(); - let paydays = []; - - USER_PAY_DATES.forEach((day, index) => { - const sequence = index + 1; - const payDate = new Date(targetYear, targetMonth, day); - paydays.push({ - id: `primary_P${sequence}`, - label: `${names.primaryName} P${sequence}`, - sourceKey: 'primary', - sequence, - date: payDate, - amount: primaryPayAmount, - }); + const targetMonth = currentDate.getMonth(); + const paydaysByIncome = new Map(); + + incomes.forEach((income) => { + paydaysByIncome.set(income.id, []); + if (income.type === 'semi-monthly') { + const days = sanitizeSemiMonthlyDays(income.semiMonthlyDays || [10, 25]); + days.forEach((day) => { + const payDate = new Date(targetYear, targetMonth, day); + paydaysByIncome.get(income.id).push({ date: payDate, amount: income.amount }); + }); + } else if (income.type === 'bi-weekly') { + const nextPayDate = sanitizeDate(income.nextPayDate); + if (!nextPayDate) { + return; + } + const current = new Date(nextPayDate); + for (let i = 0; i < 3; i += 1) { + current.setDate(current.getDate() - BI_WEEKLY_INTERVAL); + } + const cycleEndLimit = new Date(targetYear, targetMonth + 1, 15); + for (let i = 0; i < 6; i += 1) { + const checkDate = new Date(current); + const monthMatch = + checkDate.getMonth() === targetMonth || + (checkDate.getFullYear() === targetYear && + checkDate.getMonth() === (targetMonth + 1) % 12 && + checkDate.getDate() < 10); + if (monthMatch && checkDate.getTime() < cycleEndLimit.getTime()) { + paydaysByIncome.get(income.id).push({ date: checkDate, amount: income.amount }); + } + current.setDate(current.getDate() + BI_WEEKLY_INTERVAL); + } + } }); - let currentSecondaryPay = new Date(secondaryPayDate); - for (let i = 0; i < 3; i++) { - currentSecondaryPay.setDate(currentSecondaryPay.getDate() - SECONDARY_PAY_INTERVAL); - } - - const cycleEndLimit = new Date(targetYear, targetMonth + 1, 15); - - for (let i = 0; i < 6; i++) { - const checkDate = new Date(currentSecondaryPay); - if ( - (checkDate.getMonth() === targetMonth || - (checkDate.getFullYear() === targetYear && - checkDate.getMonth() === (targetMonth + 1) % 12 && - checkDate.getDate() < 10)) && - checkDate.getTime() < cycleEndLimit.getTime() - ) { - const exists = paydays.some((p) => p.date.getTime() === checkDate.getTime()); - if (!exists) { + const paydays = []; + paydaysByIncome.forEach((entries, incomeId) => { + const income = getIncomeById(incomeId); + if (!income) return; + entries + .sort((a, b) => a.date - b.date) + .forEach((entry, index) => { + const sequence = index + 1; paydays.push({ - id: 'secondary_candidate', - label: '', - sourceKey: 'secondary', - sequence: 0, - date: checkDate, - amount: secondaryPayAmount, + id: `${incomeId}_P${sequence}`, + label: `${income.name} P${sequence}`, + incomeId, + incomeName: income.name, + date: new Date(entry.date), + amount: entry.amount, + sequence, }); - } - } - currentSecondaryPay.setDate(currentSecondaryPay.getDate() + SECONDARY_PAY_INTERVAL); - } + }); + }); - paydays.sort((a, b) => a.date - b.date); - - let secondaryCounter = 1; - paydays = paydays.map((payday) => { - if (payday.sourceKey === 'secondary') { - const id = `secondary_P${secondaryCounter}`; - const label = `${names.secondaryName} P${secondaryCounter}`; - const updated = { - ...payday, - id, - label, - sequence: secondaryCounter, - amount: secondaryPayAmount, - }; - secondaryCounter += 1; - return updated; + paydays.sort((a, b) => { + if (a.date.getTime() !== b.date.getTime()) { + return a.date - b.date; } - return { - ...payday, - id: `primary_P${payday.sequence}`, - label: `${names.primaryName} P${payday.sequence}`, - amount: primaryPayAmount, - }; + if (a.incomeId === b.incomeId) { + return a.sequence - b.sequence; + } + return incomeProfiles.findIndex((income) => income.id === a.incomeId) - + incomeProfiles.findIndex((income) => income.id === b.incomeId); }); return paydays; } -function createPaymentAssignments(bill, paydays, names) { +function createPaymentAssignments(bill, paydays) { const assignments = []; const amount = bill.finalAmount; const today = new Date(); const billDate = new Date(today.getFullYear(), today.getMonth(), bill.dueDay); const paydaysById = Object.fromEntries(paydays.map((payday) => [payday.id, payday])); - const pushAssignment = ({ paymentDate, sourceId, sourceLabel, sourceKey, payoutAmount }) => { + const pushAssignment = ({ paymentDate, sourceId, sourceLabel, sourceType, incomeId, payoutAmount, combinedSources }) => { assignments.push({ paymentDate, sourceId, sourceLabel, - sourceKey, + sourceType, + incomeId: incomeId || null, amount: payoutAmount ?? amount, billName: bill.name, billId: bill.id, + combinedSources: combinedSources ? [...combinedSources] : [], }); }; @@ -195,79 +361,66 @@ function createPaymentAssignments(bill, paydays, names) { paymentDate: new Date(billDate.getFullYear(), billDate.getMonth(), 1), sourceId: 'buffer_payout', sourceLabel: 'Buffer Payout', - sourceKey: 'buffer', + sourceType: 'buffer', }); return assignments; } - if (bill.assignmentType === 'split_primary_50') { - const splitAmount = amount / 2; - ['primary_P1', 'primary_P2'].forEach((id) => { - const payday = paydaysById[id]; - if (payday) { - pushAssignment({ - paymentDate: new Date(payday.date), - sourceId: payday.id, - sourceLabel: payday.label, - sourceKey: payday.sourceKey, - payoutAmount: splitAmount, - }); - } + if (bill.assignmentType.startsWith('split:')) { + const [, incomeId] = bill.assignmentType.split(':'); + const incomePaydays = paydays + .filter((payday) => payday.incomeId === incomeId) + .sort((a, b) => a.sequence - b.sequence); + const slices = Math.min(incomePaydays.length, 2) || 1; + const splitAmount = amount / slices; + incomePaydays.slice(0, slices).forEach((payday) => { + pushAssignment({ + paymentDate: new Date(payday.date), + sourceId: payday.id, + sourceLabel: payday.label, + sourceType: 'income', + incomeId: payday.incomeId, + payoutAmount: splitAmount, + }); }); return assignments; } - if (bill.assignmentType === 'split_secondary_50') { - const splitAmount = amount / 2; - ['secondary_P1', 'secondary_P2'].forEach((id) => { - const payday = paydaysById[id]; - if (payday) { + if (bill.assignmentType.startsWith('combined:')) { + const identifiers = bill.assignmentType.split(':').slice(1); + if (identifiers.length >= 2) { + const [firstId, secondId] = identifiers; + const paydayA = paydaysById[firstId]; + const paydayB = paydaysById[secondId]; + const contributing = [paydayA, paydayB].filter(Boolean); + if (contributing.length) { + const latestDate = contributing.reduce( + (latest, payday) => (payday.date.getTime() > latest.getTime() ? new Date(payday.date) : latest), + new Date(contributing[0].date), + ); + const label = contributing.map((payday) => payday.label).join(' / '); pushAssignment({ - paymentDate: new Date(payday.date), - sourceId: payday.id, - sourceLabel: payday.label, - sourceKey: payday.sourceKey, - payoutAmount: splitAmount, + paymentDate: latestDate, + sourceId: bill.assignmentType, + sourceLabel: `${label} (Combined)`, + sourceType: 'combined', + combinedSources: contributing.map((payday) => payday.id), }); + return assignments; } - }); - return assignments; - } - - if (bill.assignmentType === 'combined_primaryP1_secondaryP1') { - const anchor = paydaysById.primary_P1 || paydaysById.secondary_P1; - if (anchor) { - pushAssignment({ - paymentDate: new Date(anchor.date), - sourceId: 'combined_primaryP1_secondaryP1', - sourceLabel: `${names.primaryName} P1 / ${names.secondaryName} P1 (Combined)`, - sourceKey: 'combined', - }); } - return assignments; - } - - if (bill.assignmentType === 'combined_primaryP2_secondaryP2') { - const anchor = paydaysById.primary_P2 || paydaysById.secondary_P2; - if (anchor) { - pushAssignment({ - paymentDate: new Date(anchor.date), - sourceId: 'combined_primaryP2_secondaryP2', - sourceLabel: `${names.primaryName} P2 / ${names.secondaryName} P2 (Combined)`, - sourceKey: 'combined', - }); - } - return assignments; } - if (bill.assignmentType !== 'auto') { - const targetPayday = paydaysById[bill.assignmentType]; - if (targetPayday) { + if (bill.assignmentType.startsWith('payday:')) { + const [, paydayId] = bill.assignmentType.split(':'); + const payday = paydaysById[paydayId]; + if (payday) { pushAssignment({ - paymentDate: new Date(targetPayday.date), - sourceId: targetPayday.id, - sourceLabel: targetPayday.label, - sourceKey: targetPayday.sourceKey, + paymentDate: new Date(payday.date), + sourceId: payday.id, + sourceLabel: payday.label, + sourceType: 'income', + incomeId: payday.incomeId, }); return assignments; } @@ -275,38 +428,36 @@ function createPaymentAssignments(bill, paydays, names) { const plannedPaymentDate = new Date(billDate); plannedPaymentDate.setDate(billDate.getDate() - 3); - const coveringPaychecks = paydays.filter((pay) => pay.date.getTime() <= plannedPaymentDate.getTime()); - if (coveringPaychecks.length > 0) { const closestPayday = coveringPaychecks[coveringPaychecks.length - 1]; pushAssignment({ paymentDate: new Date(closestPayday.date), sourceId: closestPayday.id, sourceLabel: closestPayday.label, - sourceKey: closestPayday.sourceKey, + sourceType: 'income', + incomeId: closestPayday.incomeId, }); } else { pushAssignment({ paymentDate: plannedPaymentDate, sourceId: 'buffer_needed', sourceLabel: 'Buffer Needed', - sourceKey: 'buffer', + sourceType: 'buffer', }); } return assignments; } - function addBill() { const name = document.getElementById('newBillName').value; - const dueDay = parseInt(document.getElementById('newBillDueDay').value, 10); - const base = parseFloat(document.getElementById('newBillBaseAmount').value); - const aggressive = parseFloat(document.getElementById('newBillAggressiveAmount').value); + const dueDay = Number.parseInt(document.getElementById('newBillDueDay').value, 10); + const base = Number.parseFloat(document.getElementById('newBillBaseAmount').value); + const aggressive = Number.parseFloat(document.getElementById('newBillAggressiveAmount').value); const assignmentType = document.getElementById('newBillAssignment').value; - if (name && dueDay >= 1 && dueDay <= 31 && !Number.isNaN(base) && !Number.isNaN(aggressive)) { + if (name && dueDay >= 1 && dueDay <= 31 && Number.isFinite(base) && Number.isFinite(aggressive)) { const newBill = { id: Date.now().toString(), name, @@ -317,14 +468,12 @@ function addBill() { assignmentType, }; bills.push(newBill); - calculateBudget(); - document.getElementById('newBillName').value = ''; document.getElementById('newBillBaseAmount').value = 100; document.getElementById('newBillAggressiveAmount').value = 100; - document.getElementById('newBillAssignment').value = 'auto'; + calculateBudget(); } else { - alert('Please ensure the bill name, due day (1-31), base, and aggressive amounts are valid.'); + alert('Please ensure the bill name, due day (1-31), and amounts are valid.'); } } @@ -336,21 +485,19 @@ function removeBill(billId) { function addManualTransaction() { const dateInput = document.getElementById('manualDate').valueAsDate; const description = document.getElementById('manualDescription').value; - const amount = parseFloat(document.getElementById('manualAmount').value) || 0; + const amount = Number.parseFloat(document.getElementById('manualAmount').value) || 0; if (dateInput && description.trim() && amount !== 0) { - const newTransaction = { + manualTransactions.push({ id: Date.now().toString(), - date: dateInput, + date: new Date(dateInput), description: description.trim(), amount, - }; - manualTransactions.push(newTransaction); - calculateBudget(); - + }); document.getElementById('manualDate').valueAsDate = new Date(); document.getElementById('manualDescription').value = ''; document.getElementById('manualAmount').value = 0; + calculateBudget(); } else { alert('Please enter a valid date, description, and non-zero amount.'); } @@ -363,8 +510,6 @@ function removeManualTransaction(id) { function updateBillProperty(input, property) { const billId = input.getAttribute('data-bill-id'); - const value = input.value; - const bill = bills.find((b) => b.id === billId); if (!bill) return; @@ -372,519 +517,347 @@ function updateBillProperty(input, property) { let isValid = true; if (property === 'name') { - newValue = value.trim(); + newValue = input.value.trim(); isValid = newValue.length > 0; } else if (property === 'dueDay') { - newValue = parseInt(value, 10); - isValid = !Number.isNaN(newValue) && newValue >= 1 && newValue <= 31; + newValue = Number.parseInt(input.value, 10); + isValid = Number.isFinite(newValue) && newValue >= 1 && newValue <= 31; } else if (property === 'assignmentType') { - newValue = value; + newValue = input.value; } else { - newValue = parseFloat(value); - isValid = !Number.isNaN(newValue) && newValue >= 0; + newValue = Number.parseFloat(input.value); + isValid = Number.isFinite(newValue) && newValue >= 0; } - if (isValid) { - bill[property] = newValue; - - if (property === 'assignmentType' && newValue !== 'auto') { - const currentDate = document.getElementById('currentDate').valueAsDate; - const secondaryPayDate = document.getElementById('christinaPayDate').valueAsDate; - const primaryAmount = parseFloat(document.getElementById('userPayAmount').value) || 0; - const secondaryAmount = parseFloat(document.getElementById('wifePayAmount').value) || 0; - const names = getIncomeNames(); - const paydays = calculatePaydays(currentDate, secondaryPayDate, primaryAmount, secondaryAmount, names); - const paydaysById = Object.fromEntries(paydays.map((p) => [p.id, p])); - - let newDueDay = null; - if (newValue === 'split_primary_50') { - newDueDay = paydaysById.primary_P1?.date.getDate() ?? null; - } else if (newValue === 'split_secondary_50') { - newDueDay = paydaysById.secondary_P1?.date.getDate() ?? null; - } else if (newValue === 'combined_primaryP1_secondaryP1') { - const anchor = paydaysById.primary_P1 || paydaysById.secondary_P1; - newDueDay = anchor ? anchor.date.getDate() : null; - } else if (newValue === 'combined_primaryP2_secondaryP2') { - const anchor = paydaysById.primary_P2 || paydaysById.secondary_P2; - newDueDay = anchor ? anchor.date.getDate() : null; - } else { - newDueDay = paydaysById[newValue]?.date.getDate() ?? null; - } + if (!isValid) { + input.value = bill[property]; + return; + } - if (newDueDay !== null) { - bill.dueDay = newDueDay; - const dueInput = document.querySelector(`input[data-bill-id="${billId}"][data-field="dueDay"]`); - if (dueInput) { - dueInput.value = bill.dueDay; - } + bill[property] = newValue; + + if (property === 'assignmentType' && newValue !== 'auto') { + const paydaysById = Object.fromEntries(lastComputedPaydays.map((payday) => [payday.id, payday])); + let newDueDay = null; + if (newValue.startsWith('split:')) { + const [, incomeId] = newValue.split(':'); + const candidate = lastComputedPaydays.find((payday) => payday.incomeId === incomeId); + newDueDay = candidate ? candidate.date.getDate() : null; + } else if (newValue.startsWith('payday:')) { + const [, paydayId] = newValue.split(':'); + newDueDay = paydaysById[paydayId]?.date.getDate() ?? null; + } else if (newValue.startsWith('combined:')) { + const ids = newValue.split(':').slice(1); + const dates = ids + .map((id) => paydaysById[id]?.date.getDate()) + .filter((value) => Number.isFinite(value)); + if (dates.length) { + newDueDay = Math.max(...dates); } } - - if (property === 'base' || property === 'aggressiveAmount') { - bill.type = bill.base !== bill.aggressiveAmount ? 'debt' : 'fixed'; + if (newDueDay !== null) { + bill.dueDay = newDueDay; + const dueInput = document.querySelector(`input[data-bill-id="${billId}"][data-field="dueDay"]`); + if (dueInput) { + dueInput.value = bill.dueDay; + } } - } else { - input.value = bill[property]; } -} - -function calculateBudget() { - const aggression = document.getElementById('aggressionToggle').checked; - const groceries = parseFloat(document.getElementById('groceriesAmount').value) || 0; - const gas = parseFloat(document.getElementById('gasAmount').value) || 0; - const savingsGoal = parseFloat(document.getElementById('savingsGoal').value) || 0; - const primaryPayAmount = parseFloat(document.getElementById('userPayAmount').value) || 0; - const secondaryPayAmount = parseFloat(document.getElementById('wifePayAmount').value) || 0; - const startingBuffer = parseFloat(document.getElementById('startingBuffer').value) || 0; - const ledgerStartDate = document.getElementById('ledgerStartDate').valueAsDate; - - const currentDate = document.getElementById('currentDate').valueAsDate; - const secondaryPayDate = document.getElementById('christinaPayDate').valueAsDate; - const { primaryName, secondaryName } = getIncomeNames(); - - if (!currentDate || !secondaryPayDate || !ledgerStartDate) return; - document.getElementById('aggressionLabel').textContent = aggression ? 'ON' : 'OFF'; - - updateIncomeLabels(primaryName, secondaryName); - - const newBillAssignmentSelect = document.getElementById('newBillAssignment'); - if (newBillAssignmentSelect) { - const previousValue = newBillAssignmentSelect.value || 'auto'; - populateAssignmentSelect(newBillAssignmentSelect, previousValue, primaryName, secondaryName); + if (property === 'base' || property === 'aggressiveAmount') { + bill.type = bill.base !== bill.aggressiveAmount ? 'debt' : 'fixed'; } +} - const paydays = calculatePaydays(currentDate, secondaryPayDate, primaryPayAmount, secondaryPayAmount, { - primaryName, - secondaryName, - }); - - document.getElementById('payday-display').innerHTML = paydays - .map( - (p) => - `${p.label} (${p.date.toLocaleDateString('en-US', { - day: 'numeric', - month: 'short', - })})` - ) - .join(''); - - let totalFixed = 0; - const totalVariable = groceries + gas + savingsGoal; - const paymentCalendar = []; - - bills.forEach((bill) => { - bill.finalAmount = aggression && bill.type === 'debt' ? bill.aggressiveAmount : bill.base; - - const assignments = createPaymentAssignments(bill, paydays, { primaryName, secondaryName }); - - assignments.forEach((assignment) => { - paymentCalendar.push({ - id: `${assignment.billId}-${assignment.sourceId}-${assignment.amount}`, - name: assignment.billName, - finalAmount: assignment.amount, - dueDay: bill.dueDay, - paymentDate: assignment.paymentDate, - sourceId: assignment.sourceId, - sourceLabel: assignment.sourceLabel, - sourceKey: assignment.sourceKey, - }); +function getAssignmentOptions(paydays, incomes) { + const options = [{ value: 'auto', label: 'Auto (Closest Paycheck)' }]; + incomes.forEach((income) => { + const incomePaydays = paydays.filter((payday) => payday.incomeId === income.id); + if (incomePaydays.length >= 2) { + options.push({ value: `split:${income.id}`, label: `${income.name} P1/P2 (50/50 Split)` }); + } + incomePaydays.forEach((payday) => { + options.push({ value: `payday:${payday.id}`, label: `${payday.label} (Full Amt)` }); }); - - totalFixed += bill.finalAmount; }); - paymentCalendar.sort((a, b) => a.paymentDate - b.paymentDate); - - const totalIncome = paydays.reduce((sum, pay) => sum + pay.amount, 0); - const netSurplus = totalIncome - (totalFixed + totalVariable); - - document.getElementById('total-income').textContent = formatUSD(totalIncome); - document.getElementById('total-fixed').textContent = formatUSD(totalFixed); - document.getElementById('total-variable').textContent = formatUSD(totalVariable); - document.getElementById('net-surplus').textContent = formatUSD(netSurplus); - document.getElementById('net-surplus').className = netSurplus >= 0 ? 'text-xl balance-positive' : 'text-xl balance-negative'; + if (incomes.length >= 2) { + const [firstIncome, secondIncome] = incomes; + const firstPaydays = paydays.filter((payday) => payday.incomeId === firstIncome.id); + const secondPaydays = paydays.filter((payday) => payday.incomeId === secondIncome.id); + const maxPairs = Math.min(firstPaydays.length, secondPaydays.length); + for (let i = 0; i < maxPairs; i += 1) { + options.push({ + value: `combined:${firstPaydays[i].id}:${secondPaydays[i].id}`, + label: `${firstPaydays[i].label} / ${secondPaydays[i].label} (Combined)`, + }); + } + } - const checkSummaryData = generateCheckSummary(paymentCalendar, paydays, groceries, gas, savingsGoal, { - primaryName, - secondaryName, - }); - const ledgerEvents = generateLedger( - paydays, - paymentCalendar, - groceries, - gas, - savingsGoal, - startingBuffer, - ledgerStartDate - ); + return options; +} - renderCurrentBills(primaryName, secondaryName); - renderManualTransactions(); - renderCheckSummary(checkSummaryData); - renderCalendar(paymentCalendar); - renderLedger(ledgerEvents, startingBuffer, ledgerStartDate); +function updateNewBillAssignmentOptions(paydays) { + const select = document.getElementById('newBillAssignment'); + if (!select) return; + const options = getAssignmentOptions(paydays, incomeProfiles); + const currentValue = select.value || 'auto'; + select.innerHTML = options + .map((option) => ``) + .join(''); } -function generateCheckSummary(paymentCalendar, paydays, groceries, gas, savingsGoal, names) { +function generateCheckSummary(paymentCalendar, paydays, groceries, gas, savingsGoal) { const summary = {}; - const paydaysById = Object.fromEntries(paydays.map((payday) => [payday.id, payday])); + const transfers = []; paydays.forEach((payday) => { summary[payday.id] = { id: payday.id, label: payday.label, - sourceKey: payday.sourceKey, + incomeId: payday.incomeId, + incomeName: payday.incomeName, + sourceType: 'income', totalIncome: payday.amount, bills: [], totalBills: 0, variableAllocation: 0, date: payday.date, - }; - }); - - const combinedConfigs = [ - { - id: 'combined_primaryP1_secondaryP1', - label: `${names.primaryName} P1 / ${names.secondaryName} P1 (Combined)`, - anchors: ['primary_P1', 'secondary_P1'], - }, - { - id: 'combined_primaryP2_secondaryP2', - label: `${names.primaryName} P2 / ${names.secondaryName} P2 (Combined)`, - anchors: ['primary_P2', 'secondary_P2'], - }, - ]; - - combinedConfigs.forEach(({ id, label, anchors }) => { - const anchorDate = paydaysById[anchors[0]]?.date || paydaysById[anchors[1]]?.date || null; - summary[id] = { - id, - label, - sourceKey: 'combined', - totalIncome: 0, - bills: [], - totalBills: 0, - variableAllocation: 0, - date: anchorDate, + availableSurplus: payday.amount, }; }); paymentCalendar.forEach((payment) => { - if (payment.sourceKey === 'buffer') { + if (payment.sourceType === 'buffer') { return; } - - const entry = summary[payment.sourceId]; - if (!entry) { - return; + if (payment.sourceType === 'combined') { + if (!summary[payment.sourceId]) { + summary[payment.sourceId] = { + id: payment.sourceId, + label: payment.sourceLabel, + sourceType: 'combined', + totalIncome: 0, + bills: [], + totalBills: 0, + variableAllocation: 0, + date: payment.paymentDate, + combinedFrom: new Set(payment.combinedSources || []), + }; + } else if (summary[payment.sourceId].combinedFrom instanceof Set) { + payment.combinedSources?.forEach((id) => summary[payment.sourceId].combinedFrom.add(id)); + } + const entry = summary[payment.sourceId]; + entry.bills.push({ name: `${payment.name} (Combined Payout)`, amount: payment.finalAmount }); + entry.totalBills += payment.finalAmount; + } else { + const entry = summary[payment.sourceId]; + if (!entry) return; + entry.bills.push({ name: payment.name, amount: payment.finalAmount }); + entry.totalBills += payment.finalAmount; } + }); - let billDisplayName = payment.name; - if (payment.sourceKey === 'combined') { - billDisplayName = `${payment.name} (Combined Payout)`; + Object.values(summary).forEach((entry) => { + if (entry.sourceType === 'combined' && entry.combinedFrom instanceof Set) { + entry.combinedFrom = Array.from(entry.combinedFrom); } - - entry.bills.push({ name: billDisplayName, amount: payment.finalAmount }); - entry.totalBills += payment.finalAmount; }); - const variableSplit = (groceries + gas) / 2; - const savingsSplit = savingsGoal / 2; - const primaryChecks = paydays.filter((pay) => pay.sourceKey === 'primary'); - - if (primaryChecks.length >= 1) { - const anchorId = - summary.combined_primaryP1_secondaryP1 && summary.combined_primaryP1_secondaryP1.totalBills > 0 - ? 'combined_primaryP1_secondaryP1' - : primaryChecks[0]?.id; - const entry = summary[anchorId]; - if (entry) { - if (variableSplit > 0) { - entry.bills.push({ name: 'Groceries/Gas (1st Half)', amount: variableSplit }); - entry.variableAllocation += variableSplit; - } - if (savingsSplit > 0) { - entry.bills.push({ name: 'Savings Goal (1st Half)', amount: savingsSplit }); - entry.variableAllocation += savingsSplit; - } - } - } + const sortedPaydays = [...paydays].sort((a, b) => a.date - b.date); + const allocationCount = Math.min(sortedPaydays.length, 2) || 1; + const variableSplit = allocationCount ? (groceries + gas) / allocationCount : 0; + const savingsSplit = allocationCount ? savingsGoal / allocationCount : 0; - if (primaryChecks.length >= 2) { - const anchorId = - summary.combined_primaryP2_secondaryP2 && summary.combined_primaryP2_secondaryP2.totalBills > 0 - ? 'combined_primaryP2_secondaryP2' - : primaryChecks[1]?.id; - const entry = summary[anchorId]; - if (entry) { - if (variableSplit > 0) { - entry.bills.push({ name: 'Groceries/Gas (2nd Half)', amount: variableSplit }); - entry.variableAllocation += variableSplit; - } - if (savingsSplit > 0) { - entry.bills.push({ name: 'Savings Goal (2nd Half)', amount: savingsSplit }); - entry.variableAllocation += savingsSplit; - } + sortedPaydays.slice(0, allocationCount).forEach((payday, index) => { + const entry = summary[payday.id]; + if (!entry) return; + if (variableSplit > 0) { + entry.bills.push({ + name: `Groceries/Gas (${index === 0 ? '1st' : '2nd'} Half)`, + amount: variableSplit, + }); + entry.variableAllocation += variableSplit; } - } - - const processTransfers = (individualIds, combinedId) => { - const combinedEntry = summary[combinedId]; - if (!combinedEntry || combinedEntry.totalBills === 0) { - return; + if (savingsSplit > 0) { + entry.bills.push({ + name: `Savings Goal (${index === 0 ? '1st' : '2nd'} Half)`, + amount: savingsSplit, + }); + entry.variableAllocation += savingsSplit; } + }); - let transferToCombined = 0; - individualIds.forEach((id) => { - const entry = summary[id]; - if (!entry || !entry.date) { - return; - } - - const outflow = entry.totalBills + entry.variableAllocation; - const transferAmount = entry.totalIncome - outflow; + sortedPaydays.forEach((payday) => { + const entry = summary[payday.id]; + if (!entry) return; + entry.availableSurplus = entry.totalIncome - (entry.totalBills + entry.variableAllocation); + }); - if (transferAmount !== 0) { - entry.bills.push({ name: `Transfer to ${combinedEntry.label} Pool`, amount: transferAmount }); - entry.totalBills += transferAmount; - entry.transferOut = transferAmount; - transferToCombined += transferAmount; - } + Object.values(summary) + .filter((entry) => entry.sourceType === 'combined' && entry.totalBills > 0) + .forEach((combinedEntry) => { + const sources = combinedEntry.combinedFrom || []; + let combinedIncome = 0; + sources.forEach((sourceId) => { + const sourceEntry = summary[sourceId]; + if (!sourceEntry) return; + const transferAmount = sourceEntry.availableSurplus ?? 0; + if (transferAmount !== 0) { + const description = `Transfer to ${combinedEntry.label}`; + sourceEntry.bills.push({ name: description, amount: transferAmount }); + sourceEntry.totalBills += transferAmount; + sourceEntry.transferOut = (sourceEntry.transferOut || 0) + transferAmount; + transfers.push({ + sourceId, + combinedId: combinedEntry.id, + amount: transferAmount, + date: sourceEntry.date, + description, + }); + combinedIncome += transferAmount; + sourceEntry.availableSurplus = 0; + } + }); + combinedEntry.totalIncome = combinedIncome; }); - combinedEntry.totalIncome = transferToCombined; - }; - - processTransfers(['primary_P1', 'secondary_P1'], 'combined_primaryP1_secondaryP1'); - processTransfers(['primary_P2', 'secondary_P2'], 'combined_primaryP2_secondaryP2'); - Object.keys(summary).forEach((key) => { const entry = summary[key]; - if (entry.sourceKey === 'combined' && entry.totalBills === 0 && entry.totalIncome === 0) { + if (entry.sourceType === 'combined' && entry.totalBills === 0 && entry.totalIncome === 0) { delete summary[key]; } }); - return summary; -} - -function renderCheckSummary(summaryData) { - const container = document.getElementById('check-summary-container'); - container.innerHTML = ''; - - const entries = Object.values(summaryData); - const order = new Map([ - ['primary_P1', 1], - ['secondary_P1', 2], - ['combined_primaryP1_secondaryP1', 3], - ['primary_P2', 4], - ['secondary_P2', 5], - ['combined_primaryP2_secondaryP2', 6], - ['secondary_P3', 7], - ]); - - entries - .sort((a, b) => { - const orderA = order.get(a.id) ?? 99; - const orderB = order.get(b.id) ?? 99; - if (orderA !== orderB) { - return orderA - orderB; - } - if (a.date && b.date) { - return a.date - b.date; - } - return 0; - }) - .forEach((data) => { - const totalOutflow = data.totalBills + data.variableAllocation; - const finalCashFlow = data.transferOut ? 0 : data.totalIncome - totalOutflow; - const incomeDisplay = data.totalIncome > 0 ? formatUSD(data.totalIncome) : 'N/A'; - - let checkDate = 'N/A'; - if (data.date instanceof Date && !Number.isNaN(data.date.getTime())) { - checkDate = data.date.toLocaleDateString('en-US', { day: 'numeric', month: 'short' }); - } else if (data.sourceKey === 'combined') { - checkDate = data.id.includes('P1') ? 'Approx 10th' : 'Approx 25th'; - } - - let cardColor = 'text-gray-700'; - if (data.sourceKey === 'primary') { - cardColor = 'text-blue-700'; - } else if (data.sourceKey === 'secondary') { - cardColor = 'text-green-700'; - } - - const card = document.createElement('div'); - card.className = `summary-card-custom p-4 rounded-xl shadow-lg border ${ - finalCashFlow >= 0 ? 'bg-white border-secondary/50' : 'bg-red-50 border-red-300' - }`; - - card.innerHTML = ` -

${data.label}${checkDate !== 'N/A' ? ` (${checkDate})` : ''}

-

Income: ${incomeDisplay}

- -
-

Total Payout: ${formatUSD(totalOutflow)}

-

Cash Flow: ${formatUSD(finalCashFlow)}

-
- `; - - container.appendChild(card); - }); + return { summary, transfers }; } -function generateLedger(paydays, paymentCalendar, groceries, gas, savingsGoal, STARTING_BUFFER, ledgerStartDate) { - const ledgerEvents = []; +function generateLedger(paydays, paymentCalendar, groceries, gas, savingsGoal, startingBuffer, ledgerStartDate, transfers) { + const events = []; + const paydayMap = Object.fromEntries(paydays.map((payday) => [payday.id, payday])); + const startTime = ledgerStartDate?.getTime?.() ?? 0; paydays.forEach((pay) => { - ledgerEvents.push({ - date: pay.date, + events.push({ + date: new Date(pay.date), description: `DEPOSIT: ${pay.label}`, income: pay.amount, expense: 0, type: 'deposit', - sourceKey: pay.sourceKey, + incomeId: pay.incomeId, }); }); paymentCalendar.forEach((payment) => { const description = - payment.sourceKey === 'buffer' + payment.sourceType === 'buffer' ? `PAYMENT: ${payment.name} (from Buffer)` : `PAYMENT: ${payment.name}`; - ledgerEvents.push({ - date: payment.paymentDate, + events.push({ + date: new Date(payment.paymentDate), description, income: 0, expense: payment.finalAmount, - type: 'bill', - sourceKey: payment.sourceKey, + type: payment.sourceType === 'combined' ? 'combined_bill' : 'bill', + incomeId: payment.incomeId || null, }); }); manualTransactions.forEach((tx) => { - const amount = tx.amount; - ledgerEvents.push({ - date: tx.date, + events.push({ + date: new Date(tx.date), description: `MANUAL: ${tx.description}`, - income: amount > 0 ? amount : 0, - expense: amount < 0 ? Math.abs(amount) : 0, + income: tx.amount > 0 ? tx.amount : 0, + expense: tx.amount < 0 ? Math.abs(tx.amount) : 0, type: 'manual', - sourceKey: amount > 0 ? 'manual_income' : 'manual_expense', }); }); - const primaryChecks = paydays.filter((p) => p.sourceKey === 'primary'); - const variableSplit = (groceries + gas) / 2; - const savingsSplit = savingsGoal / 2; + const sortedPaydays = [...paydays].sort((a, b) => a.date - b.date); + const allocationCount = Math.min(sortedPaydays.length, 2) || 1; + const variableSplit = allocationCount ? (groceries + gas) / allocationCount : 0; + const savingsSplit = allocationCount ? savingsGoal / allocationCount : 0; - if (primaryChecks.length >= 1 && (variableSplit > 0 || savingsSplit > 0)) { - const eventDate = new Date(primaryChecks[0].date); + sortedPaydays.slice(0, allocationCount).forEach((payday, index) => { + const periodLabel = index === 0 ? '1st' : '2nd'; if (variableSplit > 0) { - ledgerEvents.push({ - date: new Date(eventDate), - description: 'ALLOCATION: Groceries/Gas (1st Half)', + events.push({ + date: new Date(payday.date), + description: `ALLOCATION: Groceries/Gas (${periodLabel} Half)`, income: 0, expense: variableSplit, type: 'variable', }); } if (savingsSplit > 0) { - ledgerEvents.push({ - date: new Date(eventDate), - description: 'TRANSFER: Monthly Savings Goal (1st Half)', + events.push({ + date: new Date(payday.date), + description: `TRANSFER: Monthly Savings Goal (${periodLabel} Half)`, income: 0, expense: savingsSplit, type: 'savings', }); } - } + }); - if (primaryChecks.length >= 2 && (variableSplit > 0 || savingsSplit > 0)) { - const eventDate = new Date(primaryChecks[1].date); - if (variableSplit > 0) { - ledgerEvents.push({ - date: new Date(eventDate), - description: 'ALLOCATION: Groceries/Gas (2nd Half)', - income: 0, - expense: variableSplit, - type: 'variable', - }); - } - if (savingsSplit > 0) { - ledgerEvents.push({ - date: new Date(eventDate), - description: 'TRANSFER: Monthly Savings Goal (2nd Half)', - income: 0, - expense: savingsSplit, - type: 'savings', - }); - } - } + transfers.forEach((transfer) => { + const payday = paydayMap[transfer.sourceId]; + if (!payday) return; + events.push({ + date: new Date(payday.date), + description: transfer.description, + income: transfer.amount < 0 ? Math.abs(transfer.amount) : 0, + expense: transfer.amount > 0 ? transfer.amount : 0, + type: 'combined_transfer', + incomeId: payday.incomeId, + }); + }); - const filteredEvents = ledgerEvents.filter((event) => event.date.getTime() >= ledgerStartDate.getTime()); + const filtered = events.filter((event) => event.date.getTime() >= startTime); + + const orderMap = { + deposit: 1, + savings: 2, + combined_transfer: 3, + variable: 4, + bill: 5, + combined_bill: 5, + manual: (event) => (event.income > 0 ? 1.5 : 4.5), + default: 6, + }; + + const getOrderValue = (event) => { + const value = orderMap[event.type]; + if (typeof value === 'function') { + return value(event); + } + return value ?? orderMap.default; + }; - filteredEvents.sort((a, b) => { + filtered.sort((a, b) => { if (a.date.getTime() !== b.date.getTime()) { return a.date - b.date; } - const order = { - deposit: 1, - savings: 2, - variable: 3, - bill: 4, - manual: (event) => { - if (event.expense > 0) return 4.5; - if (event.income > 0) return 1.5; - return 5; - }, - unknown: 5, - }; - const getOrderValue = (event) => { - const val = order[event.type]; - return typeof val === 'function' ? val(event) : val; - }; - const aOrder = getOrderValue(a); - const bOrder = getOrderValue(b); - return (aOrder || order.unknown) - (bOrder || order.unknown); + return getOrderValue(a) - getOrderValue(b); }); - filteredEvents.unshift({ - date: ledgerStartDate, - description: `STARTING BALANCE (${ledgerStartDate.toLocaleDateString('en-US', { day: 'numeric', month: 'short' })})`, - expense: 0, + filtered.unshift({ + date: new Date(ledgerStartDate), + description: `STARTING BALANCE (${ledgerStartDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })})`, income: 0, - isBuffer: true, + expense: 0, + type: 'starting', }); - return filteredEvents; + return filtered; } -function renderCurrentBills(primaryName, secondaryName) { +function renderCurrentBills(paydays) { const tbody = document.getElementById('current-bills-body'); + if (!tbody) return; tbody.innerHTML = ''; - const aggression = document.getElementById('aggressionToggle').checked; - const options = getAssignmentOptions(primaryName, secondaryName); + const options = getAssignmentOptions(paydays, incomeProfiles); bills.forEach((bill) => { const row = tbody.insertRow(); @@ -913,32 +886,29 @@ function renderCurrentBills(primaryName, secondaryName) { `; const typeCell = row.insertCell(); - let typeText = 'Fixed'; + let typeLabel = 'Fixed'; let typeColor = 'text-gray-600'; - if (bill.type === 'debt' && aggression) { - typeText = 'Debt (Aggressive)'; + typeLabel = 'Debt (Aggressive)'; typeColor = 'text-red-600'; } else if (bill.type === 'otp') { - typeText = 'One-Time'; + typeLabel = 'One-Time'; } - - typeCell.textContent = typeText; + typeCell.textContent = typeLabel; typeCell.className = `px-3 py-2 text-xs font-medium ${typeColor}`; const assignmentCell = row.insertCell(); - const optionsMarkup = options - .map( - (option) => ` - - ` - ) - .join(''); assignmentCell.innerHTML = ` `; @@ -952,14 +922,14 @@ function renderCurrentBills(primaryName, secondaryName) { function renderManualTransactions() { const tbody = document.getElementById('manual-transactions-body'); + if (!tbody) return; tbody.innerHTML = ''; manualTransactions.sort((a, b) => a.date - b.date); - if (manualTransactions.length === 0) { + if (!manualTransactions.length) { const row = tbody.insertRow(); - row.insertCell().outerHTML = - 'No manual adjustments logged yet.'; + row.insertCell().outerHTML = 'No manual adjustments logged yet.'; return; } @@ -983,33 +953,88 @@ function renderManualTransactions() { }); } +function renderCheckSummary(summaryData) { + const container = document.getElementById('check-summary-container'); + if (!container) return; + container.innerHTML = ''; + + const entries = Object.values(summaryData); + entries + .sort((a, b) => { + if (a.date && b.date && a.date.getTime() !== b.date.getTime()) { + return a.date - b.date; + } + if (a.sourceType === b.sourceType) { + return 0; + } + return a.sourceType === 'income' ? -1 : 1; + }) + .forEach((entry) => { + const totalOutflow = entry.totalBills + entry.variableAllocation; + const finalCashFlow = entry.totalIncome - totalOutflow; + const incomeDisplay = entry.totalIncome > 0 ? formatUSD(entry.totalIncome) : 'N/A'; + const dateLabel = entry.date + ? entry.date.toLocaleDateString('en-US', { day: 'numeric', month: 'short' }) + : 'N/A'; + + const colorClasses = entry.sourceType === 'income' ? getIncomeColorClasses(entry.incomeId) : { card: 'text-purple-700' }; + const card = document.createElement('div'); + card.className = `summary-card-custom p-4 rounded-xl shadow-lg border ${ + finalCashFlow >= 0 ? 'bg-white border-secondary/50' : 'bg-red-50 border-red-300' + }`; + + card.innerHTML = ` +

${entry.label}${dateLabel !== 'N/A' ? ` (${dateLabel})` : ''}

+

Income: ${incomeDisplay}

+ +
+

Total Payout: ${formatUSD(totalOutflow)}

+

Cash Flow: ${formatUSD(finalCashFlow)}

+
+ `; + + container.appendChild(card); + }); +} + function renderCalendar(calendar) { const tbody = document.getElementById('payment-calendar-body'); + if (!tbody) return; tbody.innerHTML = ''; - calendar.forEach((bill) => { + calendar.forEach((entry) => { const row = tbody.insertRow(); row.className = 'hover:bg-gray-50'; - - row.insertCell().textContent = bill.name; - - const dueCell = row.insertCell(); - dueCell.textContent = bill.dueDay; - - row.insertCell().textContent = formatUSD(bill.finalAmount); - row.insertCell().textContent = bill.paymentDate.toLocaleDateString('en-US', { day: 'numeric', month: 'short' }); + row.insertCell().textContent = entry.name; + row.insertCell().textContent = entry.dueDay; + row.insertCell().textContent = formatUSD(entry.finalAmount); + row.insertCell().textContent = entry.paymentDate.toLocaleDateString('en-US', { day: 'numeric', month: 'short' }); const sourceCell = row.insertCell(); - sourceCell.textContent = bill.sourceLabel; - let sourceColor = 'bg-yellow-100 text-yellow-800'; - if (bill.sourceKey === 'primary') { - sourceColor = 'bg-blue-100 text-blue-800'; - } else if (bill.sourceKey === 'secondary') { - sourceColor = 'bg-green-100 text-green-800'; - } else if (bill.sourceKey === 'combined') { - sourceColor = 'bg-purple-100 text-purple-800'; + let badgeClass = 'bg-yellow-100 text-yellow-800'; + if (entry.sourceType === 'income') { + badgeClass = getIncomeColorClasses(entry.incomeId).badge; + } else if (entry.sourceType === 'combined') { + badgeClass = 'bg-purple-100 text-purple-800'; } - sourceCell.className = `font-medium text-xs rounded-full p-1 ${sourceColor}`; + sourceCell.className = `font-medium text-xs rounded-full px-2 py-1 ${badgeClass}`; + sourceCell.textContent = entry.sourceLabel; const actionCell = row.insertCell(); actionCell.innerHTML = ` @@ -1018,21 +1043,22 @@ function renderCalendar(calendar) { }); } -function renderLedger(ledgerEvents, STARTING_BUFFER, ledgerStartDate) { +function renderLedger(events, startingBuffer) { const tbody = document.getElementById('cash-flow-ledger-body'); + if (!tbody) return; tbody.innerHTML = ''; - let runningBalance = STARTING_BUFFER; - - ledgerEvents.forEach((event) => { - if (event.isBuffer) { - const bufferRow = tbody.insertRow(); - bufferRow.className = 'bg-yellow-50 font-semibold'; - bufferRow.insertCell().textContent = event.date.toLocaleDateString('en-US', { day: 'numeric', month: 'short' }); - bufferRow.insertCell().textContent = event.description; - bufferRow.insertCell().textContent = ''; - bufferRow.insertCell().textContent = ''; - const balanceCell = bufferRow.insertCell(); + let runningBalance = startingBuffer; + + events.forEach((event) => { + if (event.type === 'starting') { + const row = tbody.insertRow(); + row.className = 'bg-yellow-50 font-semibold'; + row.insertCell().textContent = event.date.toLocaleDateString('en-US', { day: 'numeric', month: 'short' }); + row.insertCell().textContent = event.description; + row.insertCell().textContent = ''; + row.insertCell().textContent = ''; + const balanceCell = row.insertCell(); balanceCell.textContent = formatUSD(runningBalance); balanceCell.className = 'px-3 py-2 text-right text-sm balance-positive'; return; @@ -1041,20 +1067,18 @@ function renderLedger(ledgerEvents, STARTING_BUFFER, ledgerStartDate) { runningBalance = runningBalance + event.income - event.expense; let rowClass = 'hover:bg-gray-50'; - if (event.type === 'deposit' && event.sourceKey === 'primary') { - rowClass = 'bg-blue-50 hover:bg-blue-100'; - } else if (event.type === 'deposit' && event.sourceKey === 'secondary') { - rowClass = 'bg-green-50 hover:bg-green-100'; - } else if (event.type === 'bill' && event.sourceKey === 'buffer') { - rowClass = 'bg-red-50 hover:bg-red-100'; - } else if (event.type === 'bill' && event.sourceKey === 'combined') { + if (event.type === 'deposit') { + rowClass = getIncomeColorClasses(event.incomeId).ledgerRow; + } else if (event.type === 'combined_bill' || event.type === 'combined_transfer') { rowClass = 'bg-purple-50 hover:bg-purple-100'; - } else if (event.type === 'savings') { - rowClass = 'bg-indigo-50 hover:bg-indigo-100'; + } else if (event.type === 'bill') { + rowClass = 'bg-red-50 hover:bg-red-100'; } else if (event.type === 'variable') { rowClass = 'bg-yellow-50 hover:bg-yellow-100'; + } else if (event.type === 'savings') { + rowClass = 'bg-indigo-50 hover:bg-indigo-100'; } else if (event.type === 'manual') { - rowClass = event.income > 0 ? 'bg-green-100 hover:bg-green-200' : 'bg-pink-100 hover:bg-pink-200'; + rowClass = event.income > 0 ? 'bg-green-50 hover:bg-green-100' : 'bg-pink-50 hover:bg-pink-100'; } const row = tbody.insertRow(); @@ -1087,25 +1111,149 @@ function renderLedger(ledgerEvents, STARTING_BUFFER, ledgerStartDate) { finalBalanceCell.className = `px-3 py-2 text-right text-base ${runningBalance >= 0 ? 'balance-positive' : 'balance-negative'}`; } +function calculateBudget() { + const aggression = document.getElementById('aggressionToggle').checked; + const groceries = Number.parseFloat(document.getElementById('groceriesAmount').value) || 0; + const gas = Number.parseFloat(document.getElementById('gasAmount').value) || 0; + const savingsGoal = Number.parseFloat(document.getElementById('savingsGoal').value) || 0; + const startingBuffer = Number.parseFloat(document.getElementById('startingBuffer').value) || 0; + const ledgerStartDate = document.getElementById('ledgerStartDate').valueAsDate; + const currentDate = document.getElementById('currentDate').valueAsDate; + + if (!currentDate || !ledgerStartDate || !incomeProfiles.length) { + return; + } + + const aggressionLabel = document.getElementById('aggressionLabel'); + if (aggressionLabel) { + aggressionLabel.textContent = aggression ? 'ON' : 'OFF'; + } + + const paydays = calculatePaydays(currentDate, incomeProfiles); + lastComputedPaydays = paydays; + + document.getElementById('payday-display').innerHTML = paydays + .map( + (p) => + `${p.label} (${p.date.toLocaleDateString('en-US', { + day: 'numeric', + month: 'short', + })})`, + ) + .join(''); + + updateNewBillAssignmentOptions(paydays); + + let totalFixed = 0; + const totalVariable = groceries + gas + savingsGoal; + const paymentCalendar = []; + + bills.forEach((bill) => { + bill.finalAmount = aggression && bill.type === 'debt' ? bill.aggressiveAmount : bill.base; + const assignments = createPaymentAssignments(bill, paydays); + assignments.forEach((assignment) => { + paymentCalendar.push({ + id: `${assignment.billId}-${assignment.sourceId}-${assignment.amount}`, + name: assignment.billName, + finalAmount: assignment.amount, + dueDay: bill.dueDay, + paymentDate: assignment.paymentDate, + sourceId: assignment.sourceId, + sourceLabel: assignment.sourceLabel, + sourceType: assignment.sourceType, + incomeId: assignment.incomeId, + combinedSources: assignment.combinedSources, + }); + }); + totalFixed += bill.finalAmount; + }); + + paymentCalendar.sort((a, b) => a.paymentDate - b.paymentDate); + + const totalIncome = paydays.reduce((sum, pay) => sum + pay.amount, 0); + const netSurplus = totalIncome - (totalFixed + totalVariable); + + document.getElementById('total-income').textContent = formatUSD(totalIncome); + document.getElementById('total-fixed').textContent = formatUSD(totalFixed); + document.getElementById('total-variable').textContent = formatUSD(totalVariable); + document.getElementById('net-surplus').textContent = formatUSD(netSurplus); + document.getElementById('net-surplus').className = netSurplus >= 0 ? 'text-xl balance-positive' : 'text-xl balance-negative'; + + const checkSummaryResult = generateCheckSummary(paymentCalendar, paydays, groceries, gas, savingsGoal); + const ledgerEvents = generateLedger( + paydays, + paymentCalendar, + groceries, + gas, + savingsGoal, + startingBuffer, + ledgerStartDate, + checkSummaryResult.transfers, + ); + + renderCurrentBills(paydays); + renderManualTransactions(); + renderCheckSummary(checkSummaryResult.summary); + renderCalendar(paymentCalendar); + renderLedger(ledgerEvents, startingBuffer); +} + +function initializeIncomeSection() { + const select = document.getElementById('incomeSelect'); + const addBtn = document.getElementById('addIncomeButton'); + const removeBtn = document.getElementById('removeIncomeButton'); + const inputs = [ + 'incomeNameInput', + 'incomeAmountInput', + 'incomeTypeSelect', + 'incomeSemiDay1', + 'incomeSemiDay2', + 'incomeNextPayDate', + ]; + + if (select) { + select.addEventListener('change', (event) => { + selectedIncomeId = event.target.value; + renderIncomeForm(); + }); + } + + inputs.forEach((id) => { + const element = document.getElementById(id); + if (element) { + const eventType = element.type === 'date' ? 'change' : 'input'; + element.addEventListener(eventType, () => handleIncomeFieldChange()); + } + }); + + if (addBtn) { + addBtn.addEventListener('click', () => addIncomeProfile()); + } + if (removeBtn) { + removeBtn.addEventListener('click', () => removeIncomeProfile()); + } +} + document.addEventListener('DOMContentLoaded', () => { const today = new Date(); - const defaultChristinaPay = new Date(today.getFullYear(), today.getMonth(), 5); + incomeProfiles = getDefaultIncomeProfiles(today); + selectedIncomeId = incomeProfiles[0]?.id || null; document.getElementById('currentDate').valueAsDate = today; document.getElementById('ledgerStartDate').valueAsDate = today; - document.getElementById('christinaPayDate').valueAsDate = defaultChristinaPay; document.getElementById('manualDate').valueAsDate = today; - document.getElementById('primaryIncomeName').value = DEFAULT_PRIMARY_NAME; - document.getElementById('secondaryIncomeName').value = DEFAULT_SECONDARY_NAME; + document.getElementById('startingBuffer').value = 200; + document.getElementById('savingsGoal').value = 500; + document.getElementById('groceriesAmount').value = 400; + document.getElementById('gasAmount').value = 150; + + refreshIncomeSelect(); + renderIncomeForm(); + initializeIncomeSection(); const inputIds = [ 'currentDate', 'ledgerStartDate', - 'christinaPayDate', - 'primaryIncomeName', - 'secondaryIncomeName', - 'userPayAmount', - 'wifePayAmount', 'startingBuffer', 'savingsGoal', 'groceriesAmount', @@ -1120,28 +1268,7 @@ document.addEventListener('DOMContentLoaded', () => { }); document.getElementById('aggressionToggle').addEventListener('change', () => calculateBudget()); - document.getElementById('addBillButton').addEventListener('click', (event) => { - event.preventDefault(); - addBill(); - }); - document.getElementById('addTransactionButton').addEventListener('click', (event) => { - event.preventDefault(); - addManualTransaction(); - }); - document.getElementById('recalculateButton').addEventListener('click', (event) => { - event.preventDefault(); - calculateBudget(); - }); - - document.getElementById('userPayAmount').value = 2000; - document.getElementById('wifePayAmount').value = 1500; - document.getElementById('savingsGoal').value = 500; - document.getElementById('startingBuffer').value = 200; calculateBudget(); }); -window.removeBill = removeBill; -window.scrollToBills = scrollToBills; -window.updateBillProperty = updateBillProperty; -window.removeManualTransaction = removeManualTransaction; diff --git a/index.html b/index.html index af5547b6..a4069c5b 100644 --- a/index.html +++ b/index.html @@ -33,46 +33,71 @@

Dynamic Family Budget Planner

Configuration

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- $ - +
+
+
+ +
-
-
- -
- $ - +
+ + +
+
+ +
+ $ + +
-
- -
- $ - + +
+
+
+

Income Sources

+

Add, edit, or remove the pay schedules that fund this budget cycle.

+
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ $ + +
+
+
+ + +
+
+ +
+ + +
+
+
@@ -95,7 +120,7 @@

Variable Spending & Debt Aggression

-
+
@@ -117,14 +142,6 @@

Variable Spen

-
- Aggressive Debt Payoff - -
@@ -132,12 +149,26 @@

Variable Spen

1. Bill Management (Add/Edit Bills & Manual Transactions)

-

Click this button after editing any bill amount or assignment below.

+
+
+
+

Aggressive Debt Payoff

+

Toggle to allocate aggressive amounts across eligible debt items.

+
+ +
+
+
@@ -162,7 +193,7 @@

1. Bill Manag

-
@@ -185,7 +216,7 @@

Add Manual Transaction

-