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.
This commit is contained in:
Ty Clifford
2026-06-15 10:33:55 -04:00
parent b7eaa81501
commit 51ee4ecb5a
18 changed files with 1568 additions and 559 deletions
+578 -339
View File
File diff suppressed because it is too large Load Diff
+28 -4
View File
@@ -13,9 +13,11 @@
document.querySelectorAll('[data-trend-chart]').forEach(drawTrendChart);
});
document.querySelector('[data-menu-toggle]')?.addEventListener('click', () => {
document.querySelector('#sidebar')?.classList.toggle('open');
});
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;
@@ -34,12 +36,19 @@
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());
@@ -71,6 +80,21 @@
});
}
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 {
@@ -91,7 +115,7 @@
const styles = getComputedStyle(document.documentElement);
const text = styles.getPropertyValue('--muted').trim();
const border = styles.getPropertyValue('--border-strong').trim();
const purple = styles.getPropertyValue('--purple-bright').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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+117 -10
View File
@@ -10,6 +10,10 @@ use Budget\Totp;
require __DIR__ . '/../app/bootstrap.php';
if (!class_exists('Support', false)) {
class_alias(Support::class, 'Support');
}
function h(mixed $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
@@ -25,6 +29,32 @@ function csrf_field(): string
return '<input type="hidden" name="csrf_token" value="' . h(Support::csrf()) . '">';
}
function icon(string $name, string $class = ''): string
{
$paths = [
'home' => '<path d="M3 10.8 12 3l9 7.8"/><path d="M5.5 9.5V21h13V9.5M9 21v-7h6v7"/>',
'expense' => '<path d="M4 7h16M4 12h16M4 17h10"/><path d="m17 15 3 3-3 3"/>',
'calendar' => '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M16 3v4M8 3v4M3 10h18"/>',
'chart' => '<path d="M4 19V9M10 19V5M16 19v-7M22 19H2"/>',
'vault' => '<rect x="3" y="4" width="18" height="16" rx="3"/><circle cx="12" cy="12" r="3"/><path d="M12 9V7M12 17v-2M9 12H7M17 12h-2"/>',
'settings' => '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3A1.7 1.7 0 0 0 10 3V2.8h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z"/>',
'data' => '<path d="M8 7h11l-3-3M16 17H5l3 3"/><path d="m19 7-3 3M5 17l3-3"/>',
'plus' => '<path d="M12 5v14M5 12h14"/>',
'wallet' => '<path d="M4 6.5h14a2 2 0 0 1 2 2V19H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h12"/><path d="M20 11h-5a2 2 0 0 0 0 4h5"/>',
'bell' => '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/>',
'edit' => '<path d="m4 20 4.5-1 10-10a2.1 2.1 0 0 0-3-3l-10 10L4 20Z"/><path d="m14 7 3 3"/>',
'trash' => '<path d="M4 7h16M9 7V4h6v3M7 7l1 14h8l1-14M10 11v6M14 11v6"/>',
'search' => '<circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/>',
'menu' => '<path d="M4 7h16M4 12h16M4 17h16"/>',
'close' => '<path d="m6 6 12 12M18 6 6 18"/>',
'sun' => '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
'logout' => '<path d="M10 5H5v14h5M14 8l4 4-4 4M8 12h10"/>',
'clock' => '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
];
$body = $paths[$name] ?? $paths['home'];
return '<svg class="icon ' . h($class) . '" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' . $body . '</svg>';
}
function redirect_to(string $route, array $parameters = []): never
{
header('Location: ' . url($route, $parameters));
@@ -135,6 +165,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
redirect_to('dashboard', ['month' => $month]);
}
if ($action === 'income.update') {
$budget->updateIncome(
(int) ($_POST['id'] ?? 0),
$month,
(string) ($_POST['label'] ?? ''),
(float) ($_POST['amount'] ?? 0),
(string) ($_POST['received_on'] ?? date('Y-m-d')),
(string) ($_POST['notes'] ?? '')
);
Support::audit($pdo, $auth->id(), 'income.updated', 'income', (int) ($_POST['id'] ?? 0), ['month' => $month]);
Support::flash('success', 'Income entry updated.');
redirect_to('dashboard', ['month' => $month]);
}
if ($action === 'income.delete') {
$budget->deleteIncome((int) ($_POST['id'] ?? 0));
Support::audit($pdo, $auth->id(), 'income.deleted', 'income', (int) ($_POST['id'] ?? 0));
@@ -150,7 +194,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
);
Support::audit($pdo, $auth->id(), 'category.created', 'category', $id);
Support::flash('success', 'Category created.');
redirect_to('dashboard', ['month' => $month]);
$returnRoute = (string) ($_POST['return_route'] ?? 'dashboard');
redirect_to(in_array($returnRoute, ['dashboard', 'settings'], true) ? $returnRoute : 'dashboard', $returnRoute === 'dashboard' ? ['month' => $month] : []);
}
if ($action === 'category.update') {
$budget->updateCategory(
(int) ($_POST['id'] ?? 0),
(string) ($_POST['name'] ?? ''),
(string) ($_POST['color'] ?? '#7c5cff'),
(float) ($_POST['monthly_limit'] ?? 0)
);
Support::audit($pdo, $auth->id(), 'category.updated', 'category', (int) ($_POST['id'] ?? 0));
Support::flash('success', 'Category updated.');
redirect_to('settings');
}
if ($action === 'category.archive') {
$budget->archiveCategory((int) ($_POST['id'] ?? 0));
Support::audit($pdo, $auth->id(), 'category.archived', 'category', (int) ($_POST['id'] ?? 0));
Support::flash('success', 'Category archived. Historical records were preserved.');
redirect_to('settings');
}
if ($action === 'item.add') {
@@ -175,6 +239,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
redirect_to('dashboard', ['month' => $month]);
}
if ($action === 'item.update') {
$budget->updateBudgetItem(
(int) ($_POST['id'] ?? 0),
$month,
!empty($_POST['category_id']) ? (int) $_POST['category_id'] : null,
(string) ($_POST['name'] ?? ''),
(float) ($_POST['planned_amount'] ?? 0),
!empty($_POST['due_date']) ? (string) $_POST['due_date'] : null,
(string) ($_POST['recurrence'] ?? 'none'),
(string) ($_POST['notes'] ?? '')
);
Support::audit($pdo, $auth->id(), 'budget_item.updated', 'budget_item', (int) ($_POST['id'] ?? 0), ['month' => $month]);
Support::flash('success', 'Budget item updated.');
redirect_to('dashboard', ['month' => $month]);
}
if ($action === 'transaction.add') {
$id = $budget->addTransaction(
$month,
@@ -197,12 +277,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
redirect_to('transactions', ['month' => $month]);
}
if ($action === 'transaction.update') {
$budget->updateTransaction(
(int) ($_POST['id'] ?? 0),
$month,
!empty($_POST['budget_item_id']) ? (int) $_POST['budget_item_id'] : null,
!empty($_POST['category_id']) ? (int) $_POST['category_id'] : null,
(string) ($_POST['merchant'] ?? ''),
(float) ($_POST['amount'] ?? 0),
(string) ($_POST['transacted_on'] ?? date('Y-m-d')),
(string) ($_POST['notes'] ?? '')
);
Support::audit($pdo, $auth->id(), 'transaction.updated', 'transaction', (int) ($_POST['id'] ?? 0), ['month' => $month]);
Support::flash('success', 'Expense updated and budget progress recalculated.');
redirect_to('transactions', ['month' => $month]);
}
if ($action === 'dead.move') {
$id = $budget->moveDeadAccount(
$month,
(string) ($_POST['direction'] ?? 'deposit'),
(float) ($_POST['amount'] ?? 0),
(string) ($_POST['notes'] ?? '')
(string) ($_POST['notes'] ?? ''),
(string) ($_POST['destination_type'] ?? 'income'),
!empty($_POST['destination_id']) ? (int) $_POST['destination_id'] : null
);
Support::audit($pdo, $auth->id(), 'dead_account.moved', 'dead_entry', $id, ['month' => $month]);
Support::flash('success', 'Dormant account transfer recorded.');
@@ -230,18 +328,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
if ($action === 'settings.save') {
$postSetting = static function (string $key, mixed $default = ''): mixed {
if (array_key_exists($key, $_POST)) {
return $_POST[$key];
}
return $_POST[str_replace('.', '_', $key)] ?? $default;
};
$stringSettings = [
'app.name', 'app.currency_symbol', 'app.timezone', 'theme.default',
'dead.income_mode', 'dead.transaction_mode', 'alerts.email',
'mailgun.domain', 'mailgun.region', 'mailgun.from',
];
foreach ($stringSettings as $key) {
if (array_key_exists($key, $_POST)) {
$settings->set($key, trim((string) $_POST[$key]));
}
$settings->set($key, trim((string) $postSetting($key)));
}
foreach (['dead.income_value', 'dead.transaction_value'] as $key) {
$settings->set($key, max(0, (float) ($_POST[$key] ?? 0)));
$settings->set($key, max(0, (float) $postSetting($key, 0)));
}
foreach (['income', 'bills', 'calendar', 'dead_account', 'comparisons', 'alerts', 'import_export'] as $module) {
$settings->set('modules.' . $module, isset($_POST['modules_' . $module]));
@@ -249,13 +351,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$settings->set('dead.hide_balance', isset($_POST['dead_hide_balance']));
$windows = array_values(array_unique(array_filter(array_map(
static fn (string $value): int => max(0, (int) trim($value)),
explode(',', (string) ($_POST['alerts.windows'] ?? '7,3,1'))
explode(',', (string) $postSetting('alerts.windows', '7,3,1'))
), static fn (int $value): bool => $value <= 365)));
$times = array_values(array_filter(array_map('trim', explode(',', (string) ($_POST['alerts.times'] ?? '09:00'))), static fn (string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time)));
$times = array_values(array_filter(array_map('trim', explode(',', (string) $postSetting('alerts.times', '09:00'))), static fn (string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time)));
$settings->set('alerts.windows', $windows ?: [7, 3, 1]);
$settings->set('alerts.times', $times ?: ['09:00']);
if (trim((string) ($_POST['mailgun.api_key'] ?? '')) !== '') {
$settings->set('mailgun.api_key', trim((string) $_POST['mailgun.api_key']));
if (trim((string) $postSetting('mailgun.api_key')) !== '') {
$settings->set('mailgun.api_key', trim((string) $postSetting('mailgun.api_key')));
} elseif (isset($_POST['clear_mailgun_key'])) {
$settings->set('mailgun.api_key', '');
}
@@ -416,6 +518,7 @@ switch ($route) {
'month' => $month,
'balance' => $budget->deadBalance(),
'entries' => $budget->deadEntries(null, 150),
'items' => $budget->budgetItems($month),
'hideBalance' => (bool) $settings->get('dead.hide_balance', true),
'symbol' => $symbol,
]);
@@ -432,6 +535,7 @@ switch ($route) {
'newSecret' => $newSecret,
'totpUri' => $newSecret ? Totp::uri($newSecret, (string) $user['email'], (string) $settings->get('app.name', 'Neon Ledger')) : null,
'mailgunConfigured' => (new Mailgun($settings))->configured(),
'categories' => $budget->categories(),
]);
case 'data':
render_view('data', [
@@ -454,6 +558,9 @@ switch ($route) {
'categories' => $budget->categories(),
'categoryStats' => $budget->categoryStats($month),
'comparison' => $budget->comparison($month, 6),
'previousSummary' => $budget->summary(date('Y-m', strtotime($month . '-01 -1 month')), false),
'upcomingBills' => $budget->upcomingBills($month),
'recentActivity' => $budget->recentActivity($month),
'visitorStats' => $budget->visitorStats(),
'deadBalance' => $budget->deadBalance(),
'symbol' => $symbol,