diff --git a/README.md b/README.md index 26a6aedb..71195617 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 +- 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. + +## 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. 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. + +## 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..459d07ca --- /dev/null +++ b/app.js @@ -0,0 +1,1274 @@ +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 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' }, +]; + +let bills = JSON.parse(JSON.stringify(initialBills)); +let manualTransactions = []; +let incomeProfiles = []; +let selectedIncomeId = null; +let lastComputedPaydays = []; + +const formatUSD = (amount) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + }).format(amount); + +const createIncomeId = () => `income_${Math.random().toString(16).slice(2)}_${Date.now()}`; + +function getDefaultIncomeProfiles(today) { + return [ + { + id: 'income_primary', + name: 'Justin', + type: 'semi-monthly', + amount: 2000, + semiMonthlyDays: [10, 25], + nextPayDate: null, + }, + { + id: 'income_secondary', + name: 'Christina', + type: 'bi-weekly', + amount: 1500, + semiMonthlyDays: [10, 25], + nextPayDate: new Date(today.getFullYear(), today.getMonth(), 5), + }, + ]; +} + +function getIncomeById(id) { + return incomeProfiles.find((income) => income.id === id) || null; +} + +function getIncomeColorIndex(incomeId) { + const index = incomeProfiles.findIndex((income) => income.id === incomeId); + return index >= 0 ? index % INCOME_COLOR_CLASSES.length : 0; +} + +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 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 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(); +} + +function removeIncomeProfile() { + if (incomeProfiles.length <= 1) { + return; + } + const removeIndex = incomeProfiles.findIndex((income) => income.id === selectedIncomeId); + if (removeIndex === -1) { + return; + } + 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' }); +} + +function calculatePaydays(currentDate, incomes) { + if (!currentDate) return []; + const targetYear = currentDate.getFullYear(); + 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); + } + } + }); + + 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: `${incomeId}_P${sequence}`, + label: `${income.name} P${sequence}`, + incomeId, + incomeName: income.name, + date: new Date(entry.date), + amount: entry.amount, + sequence, + }); + }); + }); + + paydays.sort((a, b) => { + if (a.date.getTime() !== b.date.getTime()) { + return a.date - b.date; + } + 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) { + 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, sourceType, incomeId, payoutAmount, combinedSources }) => { + assignments.push({ + paymentDate, + sourceId, + sourceLabel, + sourceType, + incomeId: incomeId || null, + amount: payoutAmount ?? amount, + billName: bill.name, + billId: bill.id, + combinedSources: combinedSources ? [...combinedSources] : [], + }); + }; + + if (bill.dueDay <= 5 && bill.assignmentType === 'auto') { + pushAssignment({ + paymentDate: new Date(billDate.getFullYear(), billDate.getMonth(), 1), + sourceId: 'buffer_payout', + sourceLabel: 'Buffer Payout', + sourceType: 'buffer', + }); + return assignments; + } + + 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.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: latestDate, + sourceId: bill.assignmentType, + sourceLabel: `${label} (Combined)`, + sourceType: 'combined', + combinedSources: contributing.map((payday) => payday.id), + }); + return assignments; + } + } + } + + if (bill.assignmentType.startsWith('payday:')) { + const [, paydayId] = bill.assignmentType.split(':'); + const payday = paydaysById[paydayId]; + if (payday) { + pushAssignment({ + paymentDate: new Date(payday.date), + sourceId: payday.id, + sourceLabel: payday.label, + sourceType: 'income', + incomeId: payday.incomeId, + }); + 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, + sourceType: 'income', + incomeId: closestPayday.incomeId, + }); + } else { + pushAssignment({ + paymentDate: plannedPaymentDate, + sourceId: 'buffer_needed', + sourceLabel: 'Buffer Needed', + sourceType: 'buffer', + }); + } + + return assignments; +} + +function addBill() { + const name = document.getElementById('newBillName').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.isFinite(base) && Number.isFinite(aggressive)) { + const newBill = { + id: Date.now().toString(), + name, + base, + dueDay, + aggressiveAmount: aggressive, + type: base !== aggressive ? 'debt' : 'fixed', + assignmentType, + }; + bills.push(newBill); + document.getElementById('newBillName').value = ''; + document.getElementById('newBillBaseAmount').value = 100; + document.getElementById('newBillAggressiveAmount').value = 100; + calculateBudget(); + } else { + alert('Please ensure the bill name, due day (1-31), and 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 = Number.parseFloat(document.getElementById('manualAmount').value) || 0; + + if (dateInput && description.trim() && amount !== 0) { + manualTransactions.push({ + id: Date.now().toString(), + date: new Date(dateInput), + description: description.trim(), + amount, + }); + 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.'); + } +} + +function removeManualTransaction(id) { + manualTransactions = manualTransactions.filter((t) => t.id !== id); + calculateBudget(); +} + +function updateBillProperty(input, property) { + const billId = input.getAttribute('data-bill-id'); + const bill = bills.find((b) => b.id === billId); + if (!bill) return; + + let newValue; + let isValid = true; + + if (property === 'name') { + newValue = input.value.trim(); + isValid = newValue.length > 0; + } else if (property === 'dueDay') { + newValue = Number.parseInt(input.value, 10); + isValid = Number.isFinite(newValue) && newValue >= 1 && newValue <= 31; + } else if (property === 'assignmentType') { + newValue = input.value; + } else { + newValue = Number.parseFloat(input.value); + isValid = Number.isFinite(newValue) && newValue >= 0; + } + + if (!isValid) { + input.value = bill[property]; + return; + } + + 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 (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'; + } +} + +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)` }); + }); + }); + + 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)`, + }); + } + } + + return options; +} + +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) { + const summary = {}; + const transfers = []; + + paydays.forEach((payday) => { + summary[payday.id] = { + id: payday.id, + label: payday.label, + incomeId: payday.incomeId, + incomeName: payday.incomeName, + sourceType: 'income', + totalIncome: payday.amount, + bills: [], + totalBills: 0, + variableAllocation: 0, + date: payday.date, + availableSurplus: payday.amount, + }; + }); + + paymentCalendar.forEach((payment) => { + if (payment.sourceType === 'buffer') { + 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; + } + }); + + Object.values(summary).forEach((entry) => { + if (entry.sourceType === 'combined' && entry.combinedFrom instanceof Set) { + entry.combinedFrom = Array.from(entry.combinedFrom); + } + }); + + 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; + + 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; + } + if (savingsSplit > 0) { + entry.bills.push({ + name: `Savings Goal (${index === 0 ? '1st' : '2nd'} Half)`, + amount: savingsSplit, + }); + entry.variableAllocation += savingsSplit; + } + }); + + sortedPaydays.forEach((payday) => { + const entry = summary[payday.id]; + if (!entry) return; + entry.availableSurplus = entry.totalIncome - (entry.totalBills + entry.variableAllocation); + }); + + 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; + }); + + Object.keys(summary).forEach((key) => { + const entry = summary[key]; + if (entry.sourceType === 'combined' && entry.totalBills === 0 && entry.totalIncome === 0) { + delete summary[key]; + } + }); + + return { summary, transfers }; +} + +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) => { + events.push({ + date: new Date(pay.date), + description: `DEPOSIT: ${pay.label}`, + income: pay.amount, + expense: 0, + type: 'deposit', + incomeId: pay.incomeId, + }); + }); + + paymentCalendar.forEach((payment) => { + const description = + payment.sourceType === 'buffer' + ? `PAYMENT: ${payment.name} (from Buffer)` + : `PAYMENT: ${payment.name}`; + events.push({ + date: new Date(payment.paymentDate), + description, + income: 0, + expense: payment.finalAmount, + type: payment.sourceType === 'combined' ? 'combined_bill' : 'bill', + incomeId: payment.incomeId || null, + }); + }); + + manualTransactions.forEach((tx) => { + events.push({ + date: new Date(tx.date), + description: `MANUAL: ${tx.description}`, + income: tx.amount > 0 ? tx.amount : 0, + expense: tx.amount < 0 ? Math.abs(tx.amount) : 0, + type: 'manual', + }); + }); + + 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; + + sortedPaydays.slice(0, allocationCount).forEach((payday, index) => { + const periodLabel = index === 0 ? '1st' : '2nd'; + if (variableSplit > 0) { + events.push({ + date: new Date(payday.date), + description: `ALLOCATION: Groceries/Gas (${periodLabel} Half)`, + income: 0, + expense: variableSplit, + type: 'variable', + }); + } + if (savingsSplit > 0) { + events.push({ + date: new Date(payday.date), + description: `TRANSFER: Monthly Savings Goal (${periodLabel} 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 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; + }; + + filtered.sort((a, b) => { + if (a.date.getTime() !== b.date.getTime()) { + return a.date - b.date; + } + return getOrderValue(a) - getOrderValue(b); + }); + + filtered.unshift({ + date: new Date(ledgerStartDate), + description: `STARTING BALANCE (${ledgerStartDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })})`, + income: 0, + expense: 0, + type: 'starting', + }); + + return filtered; +} + +function renderCurrentBills(paydays) { + const tbody = document.getElementById('current-bills-body'); + if (!tbody) return; + tbody.innerHTML = ''; + const aggression = document.getElementById('aggressionToggle').checked; + const options = getAssignmentOptions(paydays, incomeProfiles); + + 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 typeLabel = 'Fixed'; + let typeColor = 'text-gray-600'; + if (bill.type === 'debt' && aggression) { + typeLabel = 'Debt (Aggressive)'; + typeColor = 'text-red-600'; + } else if (bill.type === 'otp') { + typeLabel = 'One-Time'; + } + typeCell.textContent = typeLabel; + typeCell.className = `px-3 py-2 text-xs font-medium ${typeColor}`; + + const assignmentCell = row.insertCell(); + assignmentCell.innerHTML = ` + + `; + + const actionCell = row.insertCell(); + actionCell.className = 'text-center'; + actionCell.innerHTML = ` + + `; + }); +} + +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) { + 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 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((entry) => { + const row = tbody.insertRow(); + row.className = 'hover:bg-gray-50'; + 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(); + 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 px-2 py-1 ${badgeClass}`; + sourceCell.textContent = entry.sourceLabel; + + const actionCell = row.insertCell(); + actionCell.innerHTML = ` + + `; + }); +} + +function renderLedger(events, startingBuffer) { + const tbody = document.getElementById('cash-flow-ledger-body'); + if (!tbody) return; + tbody.innerHTML = ''; + + 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; + } + + runningBalance = runningBalance + event.income - event.expense; + + let rowClass = 'hover:bg-gray-50'; + 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 === '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-50 hover:bg-green-100' : 'bg-pink-50 hover:bg-pink-100'; + } + + 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'}`; +} + +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(); + incomeProfiles = getDefaultIncomeProfiles(today); + selectedIncomeId = incomeProfiles[0]?.id || null; + + document.getElementById('currentDate').valueAsDate = today; + document.getElementById('ledgerStartDate').valueAsDate = today; + document.getElementById('manualDate').valueAsDate = today; + 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', + '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()); + + calculateBudget(); +}); + diff --git a/index.html b/index.html new file mode 100644 index 00000000..a4069c5b --- /dev/null +++ b/index.html @@ -0,0 +1,308 @@ + + + + + + Dynamic Family Budget Planner + + + + + +
+
+

Dynamic Family Budget Planner

+

Cash Flow Management with Aggressive Debt Payoff

+
+ +
+

Configuration

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

Income Sources

+

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

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

Calculated Paydays this Cycle: Loading...

+
+
+ +
+
+

Monthly Summary

+
+

Income:

+

Fixed Bills:

+

Variables & Savings:

+
+

Net Surplus:

+
+
+ +
+

Variable Spending & Debt Aggression

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

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.

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

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; +}