51ee4ecb5a
Finished the planner end to end. Corrected rollover and yearly accounting, recurring targets, savings rules, and targeted dormant restorations. Added editing/deletion for income, expenses, targets, and categories. Rebuilt dashboard, analytics, calendar, settings, exports, authentication, and responsive mobile UI. Added original artwork at [financial-flow.png](/Users/tyemeclifford/Documents/GH/budget/public/assets/images/financial-flow.png), generated with built-in Imagegen using an abstract financial-momentum landscape prompt. Added [self-test.php](/Users/tyemeclifford/Documents/GH/budget/scripts/self-test.php). Verification passed: automated accounting/export tests, full browser workflow, responsive 390px layout, light/dark themes, and no console errors. Tested with FrankenPHP v1.12.4. Live Mailgun delivery was not attempted without credentials.
206 lines
9.2 KiB
JavaScript
206 lines
9.2 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();
|
|
});
|
|
|
|
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);
|
|
});
|
|
})();
|