- Implemented the complete first-run experience:

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.
This commit is contained in:
Ty Clifford
2026-06-15 14:51:44 -04:00
parent 51ee4ecb5a
commit 6686388834
15 changed files with 1159 additions and 32 deletions
+172
View File
@@ -95,6 +95,178 @@
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 {