6686388834
Account creation and guarded onboarding flow. Current-cash starting balance with automatic rollover enabled. Dynamic bill generation, shared/custom categories, recurring targets, and optional same-day onboarding transactions. Optional dormant reserve and additional categories. Mailgun email or authenticator-app 2FA configuration. Responsive dark/light UI with no overflow at 390px. Fixed administrator session-ID creation bug. Core implementation: [OnboardingService.php (line 59)](/Users/tyemeclifford/Documents/GH/budget/app/OnboardingService.php:59), [onboarding.php (line 19)](/Users/tyemeclifford/Documents/GH/budget/views/onboarding.php:19), and [index.php (line 143)](/Users/tyemeclifford/Documents/GH/budget/public/index.php:143). Verification passed: financial self-test, onboarding integration test, full browser walkthrough, mobile layout, routing guards, and zero browser console errors. Live email delivery requires actual Mailgun credentials and was not sent during testing.
378 lines
17 KiB
JavaScript
378 lines
17 KiB
JavaScript
(() => {
|
|
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);
|
|
});
|
|
})();
|