(() => { const root = document.documentElement; const toggle = document.querySelector('[data-theme-toggle]'); const updateThemeLabel = () => { const label = document.querySelector('[data-theme-label]'); if (label) label.textContent = root.dataset.theme === 'dark' ? 'Light mode' : 'Dark mode'; }; updateThemeLabel(); toggle?.addEventListener('click', () => { root.dataset.theme = root.dataset.theme === 'dark' ? 'light' : 'dark'; localStorage.setItem('budget-theme', root.dataset.theme); updateThemeLabel(); document.querySelectorAll('[data-trend-chart]').forEach(drawTrendChart); }); const sidebar = document.querySelector('#sidebar'); const closeMenu = () => sidebar?.classList.remove('open'); document.querySelector('[data-menu-toggle]')?.addEventListener('click', () => sidebar?.classList.toggle('open')); document.querySelector('[data-menu-close]')?.addEventListener('click', closeMenu); sidebar?.querySelectorAll('a').forEach((link) => link.addEventListener('click', closeMenu)); document.querySelector('[data-month-picker]')?.addEventListener('change', (event) => { const picker = event.currentTarget; const parameters = new URLSearchParams({ route: picker.dataset.route, month: picker.value }); window.location.href = `?${parameters.toString()}`; }); document.querySelectorAll('[data-dialog-open]').forEach((button) => { button.addEventListener('click', () => document.getElementById(button.dataset.dialogOpen)?.showModal()); }); document.querySelectorAll('[data-dialog-close]').forEach((button) => { button.addEventListener('click', () => button.closest('dialog')?.close()); }); document.querySelectorAll('dialog').forEach((dialog) => { dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close(); }); }); const requestedDialog = new URLSearchParams(window.location.search).get('open'); if (requestedDialog) document.getElementById(requestedDialog)?.showModal(); document.querySelectorAll('[data-confirm]').forEach((form) => { form.addEventListener('submit', (event) => { if (!window.confirm(form.dataset.confirm)) event.preventDefault(); }); }); document.querySelectorAll('[data-confirm-button]').forEach((button) => { button.addEventListener('click', (event) => { if (!window.confirm(button.dataset.confirmButton)) event.preventDefault(); }); }); document.querySelectorAll('.flash button').forEach((button) => { button.addEventListener('click', () => button.closest('.flash')?.remove()); }); document.querySelectorAll('[data-table-search]').forEach((input) => { input.addEventListener('input', () => { const table = document.getElementById(input.dataset.tableSearch); const query = input.value.trim().toLowerCase(); table?.querySelectorAll('tbody tr').forEach((row) => { row.hidden = query !== '' && !row.textContent.toLowerCase().includes(query); }); }); }); const concealed = document.querySelector('[data-concealed-balance]'); if (concealed) { let approved = false; concealed.addEventListener('click', () => { if (!approved) approved = window.confirm('Reveal the dormant account balance while your pointer remains here?'); if (approved) { concealed.querySelector('.revealed').textContent = concealed.dataset.value; concealed.classList.add('reveal'); } }); concealed.addEventListener('mouseleave', () => { concealed.classList.remove('reveal'); approved = false; }); } document.querySelectorAll('[data-dead-transfer-form]').forEach((form) => { const direction = form.querySelector('[name="direction"]'); const restoreFields = form.querySelector('[data-restore-fields]'); const restoreType = form.querySelector('[data-restore-type]'); const restoreItem = form.querySelector('[data-restore-item]'); const syncRestoreFields = () => { const restoring = direction?.value === 'restore'; if (restoreFields) restoreFields.hidden = !restoring; if (restoreItem) restoreItem.hidden = !restoring || restoreType?.value !== 'item'; }; direction?.addEventListener('change', syncRestoreFields); restoreType?.addEventListener('change', syncRestoreFields); syncRestoreFields(); }); const onboardingForm = document.querySelector('[data-onboarding-form]'); if (onboardingForm) { const steps = [...onboardingForm.querySelectorAll('[data-onboarding-step]')]; const progress = [...document.querySelectorAll('[data-onboarding-progress]')]; const cashInput = onboardingForm.querySelector('[data-current-cash]'); const billCountInput = onboardingForm.querySelector('[data-bill-count]'); const billRows = onboardingForm.querySelector('[data-bill-rows]'); const billTemplate = document.querySelector('#onboarding-bill-template'); const markAllPaid = onboardingForm.querySelector('[data-mark-all-paid]'); const enableDead = onboardingForm.querySelector('[data-enable-onboarding-dead]'); const deadFields = onboardingForm.querySelector('[data-onboarding-dead-fields]'); const deadAmount = onboardingForm.querySelector('[data-dead-amount]'); const submitButton = onboardingForm.querySelector('[data-onboarding-submit]'); const warning = onboardingForm.querySelector('[data-onboarding-warning]'); const symbol = onboardingForm.dataset.symbol || '$'; let currentStep = 1; let generatedCount = -1; const money = (value) => `${value < 0 ? '-' : ''}${symbol}${Math.abs(value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, })}`; const showStep = (target) => { currentStep = target; steps.forEach((step) => { const active = Number(step.dataset.onboardingStep) === target; step.hidden = !active; step.classList.toggle('active', active); }); progress.forEach((item) => { const number = Number(item.dataset.onboardingProgress); item.classList.toggle('active', number === target); item.classList.toggle('complete', number < target); }); window.scrollTo({ top: 0, behavior: 'smooth' }); }; const validateCurrentStep = () => { const step = steps.find((item) => Number(item.dataset.onboardingStep) === currentStep); const fields = [...step.querySelectorAll('input, select, textarea')].filter((field) => !field.disabled); for (const field of fields) { if (!field.checkValidity()) { field.reportValidity(); return false; } } return true; }; const paidInputs = () => [...billRows.querySelectorAll('[data-bill-field="paid_now"]')]; const syncPaidMaster = () => { const inputs = paidInputs(); const checked = inputs.filter((input) => input.checked).length; markAllPaid.checked = inputs.length > 0 && checked === inputs.length; markAllPaid.indeterminate = checked > 0 && checked < inputs.length; }; const updateReview = () => { const cash = Number(cashInput.value) || 0; let paid = 0; billRows.querySelectorAll('.onboarding-bill-card').forEach((card) => { if (card.querySelector('[data-bill-field="paid_now"]').checked) { paid += Number(card.querySelector('[data-bill-field="amount"]').value) || 0; } }); const reserved = enableDead.checked ? Number(deadAmount.value) || 0 : 0; const remaining = cash - paid - reserved; onboardingForm.querySelector('[data-review-cash]').textContent = money(cash); onboardingForm.querySelector('[data-review-paid]').textContent = money(paid); onboardingForm.querySelector('[data-review-dead]').textContent = money(reserved); onboardingForm.querySelector('[data-review-remaining]').textContent = money(remaining); onboardingForm.querySelector('.review-remaining').classList.toggle('negative', remaining < 0); warning.hidden = remaining >= 0; submitButton.disabled = remaining < 0; }; const generateBillRows = () => { const count = Math.max(0, Math.min(50, Number.parseInt(billCountInput.value, 10) || 0)); billCountInput.value = String(count); if (generatedCount === count) return count; generatedCount = count; billRows.replaceChildren(); for (let index = 0; index < count; index++) { const fragment = billTemplate.content.cloneNode(true); const card = fragment.querySelector('.onboarding-bill-card'); card.querySelector('[data-bill-index]').textContent = String(index + 1).padStart(2, '0'); const title = card.querySelector('[data-bill-title]'); title.textContent = `Bill ${index + 1}`; card.querySelectorAll('[data-bill-field]').forEach((field) => { const key = field.dataset.billField; field.name = `bills[${index}][${key}]`; }); const nameInput = card.querySelector('[data-bill-field="name"]'); const amountInput = card.querySelector('[data-bill-field="amount"]'); const paidInput = card.querySelector('[data-bill-field="paid_now"]'); const categoryInput = card.querySelector('[data-bill-field="category"]'); const customWrap = card.querySelector('[data-custom-category]'); const customInput = card.querySelector('[data-bill-field="custom_category"]'); nameInput.addEventListener('input', () => { title.textContent = nameInput.value.trim() || `Bill ${index + 1}`; }); amountInput.addEventListener('input', updateReview); paidInput.addEventListener('change', () => { syncPaidMaster(); updateReview(); }); categoryInput.addEventListener('change', () => { const custom = categoryInput.value === '__custom__'; customWrap.hidden = !custom; customInput.required = custom; if (!custom) customInput.value = ''; }); paidInput.checked = markAllPaid.checked; billRows.append(fragment); } syncPaidMaster(); updateReview(); return count; }; onboardingForm.querySelectorAll('[data-onboarding-next]').forEach((button) => { button.addEventListener('click', () => { if (!validateCurrentStep()) return; if (currentStep === 2) { const count = generateBillRows(); showStep(count === 0 ? 4 : 3); updateReview(); return; } showStep(Math.min(4, currentStep + 1)); updateReview(); }); }); onboardingForm.querySelectorAll('[data-onboarding-back]').forEach((button) => { button.addEventListener('click', () => { if (currentStep === 4 && generatedCount === 0) { showStep(2); return; } showStep(Math.max(1, currentStep - 1)); }); }); markAllPaid.addEventListener('change', () => { paidInputs().forEach((input) => { input.checked = markAllPaid.checked; }); markAllPaid.indeterminate = false; updateReview(); }); enableDead.addEventListener('change', () => { deadFields.hidden = !enableDead.checked; deadAmount.required = enableDead.checked; if (!enableDead.checked) deadAmount.value = ''; updateReview(); }); cashInput.addEventListener('input', updateReview); deadAmount.addEventListener('input', updateReview); onboardingForm.addEventListener('submit', (event) => { generateBillRows(); updateReview(); if (submitButton.disabled) { event.preventDefault(); warning.hidden = false; } }); updateReview(); } function drawTrendChart(canvas) { let data; try { data = JSON.parse(canvas.dataset.trendChart || '[]'); } catch { return; } if (!data.length) return; const width = canvas.clientWidth || 600; const height = Number(canvas.getAttribute('height')) || 220; const ratio = window.devicePixelRatio || 1; canvas.width = width * ratio; canvas.height = height * ratio; canvas.style.height = `${height}px`; const context = canvas.getContext('2d'); context.scale(ratio, ratio); const styles = getComputedStyle(document.documentElement); const text = styles.getPropertyValue('--muted').trim(); const border = styles.getPropertyValue('--border-strong').trim(); const purple = styles.getPropertyValue('--purple-light').trim(); const pink = styles.getPropertyValue('--pink').trim(); const padding = { top: 20, right: 12, bottom: 32, left: 48 }; const plotWidth = width - padding.left - padding.right; const plotHeight = height - padding.top - padding.bottom; const maxValue = Math.max(...data.flatMap((row) => [Number(row.gross_resources) || 0, Number(row.expenses) || 0]), 1) * 1.12; context.clearRect(0, 0, width, height); context.font = '10px system-ui'; context.fillStyle = text; context.strokeStyle = border; context.lineWidth = 1; context.setLineDash([3, 5]); for (let line = 0; line <= 4; line++) { const y = padding.top + plotHeight * (line / 4); context.beginPath(); context.moveTo(padding.left, y); context.lineTo(width - padding.right, y); context.stroke(); const value = maxValue * (1 - line / 4); context.fillText(compactMoney(value, canvas.dataset.symbol), 0, y + 3); } context.setLineDash([]); const pointX = (index) => padding.left + (data.length === 1 ? plotWidth / 2 : plotWidth * index / (data.length - 1)); const pointY = (value) => padding.top + plotHeight - (Number(value) / maxValue * plotHeight); const drawSeries = (key, color, fill) => { context.beginPath(); data.forEach((row, index) => { const x = pointX(index); const y = pointY(row[key]); index === 0 ? context.moveTo(x, y) : context.lineTo(x, y); }); context.strokeStyle = color; context.lineWidth = 2.5; context.lineJoin = 'round'; context.stroke(); if (fill) { context.lineTo(pointX(data.length - 1), padding.top + plotHeight); context.lineTo(pointX(0), padding.top + plotHeight); context.closePath(); const gradient = context.createLinearGradient(0, padding.top, 0, padding.top + plotHeight); gradient.addColorStop(0, `${color}32`); gradient.addColorStop(1, `${color}00`); context.fillStyle = gradient; context.fill(); } data.forEach((row, index) => { context.beginPath(); context.arc(pointX(index), pointY(row[key]), 3.5, 0, Math.PI * 2); context.fillStyle = color; context.fill(); }); }; drawSeries('gross_resources', purple, true); drawSeries('expenses', pink, false); data.forEach((row, index) => { const label = new Date(`${row.month}-02T12:00:00`).toLocaleDateString(undefined, { month: 'short' }); context.fillStyle = text; context.textAlign = 'center'; context.fillText(label, pointX(index), height - 9); }); context.textAlign = 'left'; context.fillStyle = purple; context.fillRect(width - 145, 3, 8, 8); context.fillStyle = text; context.fillText('Resources', width - 132, 11); context.fillStyle = pink; context.fillRect(width - 70, 3, 8, 8); context.fillStyle = text; context.fillText('Spent', width - 57, 11); } function compactMoney(value, symbol = '$') { if (value >= 1000000) return `${symbol}${(value / 1000000).toFixed(1)}m`; if (value >= 1000) return `${symbol}${(value / 1000).toFixed(1)}k`; return `${symbol}${Math.round(value)}`; } document.querySelectorAll('[data-trend-chart]').forEach(drawTrendChart); let resizeTimer; window.addEventListener('resize', () => { window.clearTimeout(resizeTimer); resizeTimer = window.setTimeout(() => document.querySelectorAll('[data-trend-chart]').forEach(drawTrendChart), 120); }); })();