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
+9 -2
View File
@@ -19,6 +19,12 @@ php -S 127.0.0.1:8080 router.php
Open `http://127.0.0.1:8080` and create the first administrator account. The SQLite database is created automatically at `storage/database/budget.sqlite`. Open `http://127.0.0.1:8080` and create the first administrator account. The SQLite database is created automatically at `storage/database/budget.sqlite`.
Run the accounting and export smoke test with:
```bash
php scripts/self-test.php
```
For Apache, use `public/` as the document root and enable `mod_rewrite`. For Apache, use `public/` as the document root and enable `mod_rewrite`.
## Core behavior ## Core behavior
@@ -26,9 +32,10 @@ For Apache, use `public/` as the document root and enable `mod_rewrite`.
- Each month has a manual starting income and an explicit previous-month remainder. - Each month has a manual starting income and an explicit previous-month remainder.
- Positive remaining funds can roll into the next month. Use **Recalculate** to refresh an existing month's rollover after changing the prior month. - Positive remaining funds can roll into the next month. Use **Recalculate** to refresh an existing month's rollover after changing the prior month.
- Income and expense rules can move a percentage or fixed amount into the dormant account automatically. - Income and expense rules can move a percentage or fixed amount into the dormant account automatically.
- Dormant deposits reduce usable funds; restorations increase the selected month's usable funds. - Dormant deposits reduce usable funds; restorations can return to monthly income or be tagged to a specific budget target.
- Payments linked to a budget item progress its paid and owing totals. - Payments linked to a budget item progress its paid and owing totals.
- Monthly recurring items copy from the prior month; yearly items copy from the same month in the prior year. - Monthly and yearly recurring items materialize from the latest matching series, even when a month was skipped.
- Annual fresh-income totals exclude rollover so carried money is not counted twice.
- JSON exports use the `neon-ledger/budget-planner` version 1 format and exclude credentials, 2FA secrets, and the Mailgun API key. - JSON exports use the `neon-ledger/budget-planner` version 1 format and exclude credentials, 2FA secrets, and the Mailgun API key.
## Bill reminders ## Bill reminders
+373 -28
View File
@@ -107,7 +107,9 @@ final class BudgetService
public function addIncome(string $month, string $label, float $amount, string $receivedOn, string $notes): int public function addIncome(string $month, string $label, float $amount, string $receivedOn, string $notes): int
{ {
$month = Support::month($month);
$this->ensureMonth($month); $this->ensureMonth($month);
$this->assertDateInMonth($receivedOn, $month, 'Income date');
$amount = max(0, $amount); $amount = max(0, $amount);
if ($amount <= 0 || trim($label) === '') { if ($amount <= 0 || trim($label) === '') {
throw new RuntimeException('Income needs a label and an amount greater than zero.'); throw new RuntimeException('Income needs a label and an amount greater than zero.');
@@ -150,6 +152,58 @@ final class BudgetService
} }
} }
public function updateIncome(int $id, string $month, string $label, float $amount, string $receivedOn, string $notes): void
{
$month = Support::month($month);
$this->assertDateInMonth($receivedOn, $month, 'Income date');
$amount = max(0, $amount);
if ($id <= 0 || $amount <= 0 || trim($label) === '') {
throw new RuntimeException('Income needs a label and an amount greater than zero.');
}
$withholding = $this->automaticAmount(
$amount,
(string) $this->settings->get('dead.income_mode', 'percent'),
(float) $this->settings->get('dead.income_value', 0)
);
$this->pdo->beginTransaction();
try {
$statement = $this->pdo->prepare(
'UPDATE income_entries
SET month = :month, label = :label, amount = :amount, received_on = :received_on,
withholding_amount = :withholding, notes = :notes
WHERE id = :id'
);
$statement->execute([
'month' => $month,
'label' => trim($label),
'amount' => $amount,
'received_on' => $receivedOn,
'withholding' => $withholding,
'notes' => trim($notes) ?: null,
'id' => $id,
]);
if ($statement->rowCount() === 0) {
$check = $this->pdo->prepare('SELECT COUNT(*) FROM income_entries WHERE id = :id');
$check->execute(['id' => $id]);
if ((int) $check->fetchColumn() === 0) {
throw new RuntimeException('Income entry was not found.');
}
}
$this->replaceDeadSource(
$month,
'income_withholding',
$withholding,
'income',
$id,
'Automatic withholding from ' . trim($label)
);
$this->pdo->commit();
} catch (\Throwable $exception) {
$this->pdo->rollBack();
throw $exception;
}
}
public function deleteIncome(int $id): void public function deleteIncome(int $id): void
{ {
$this->pdo->beginTransaction(); $this->pdo->beginTransaction();
@@ -196,6 +250,31 @@ final class BudgetService
return $this->pdo->query('SELECT * FROM categories WHERE active = 1 ORDER BY name')->fetchAll(); return $this->pdo->query('SELECT * FROM categories WHERE active = 1 ORDER BY name')->fetchAll();
} }
public function updateCategory(int $id, string $name, string $color, float $monthlyLimit): void
{
if ($id <= 0 || trim($name) === '') {
throw new RuntimeException('Category name is required.');
}
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
$color = '#7c5cff';
}
$statement = $this->pdo->prepare(
'UPDATE categories SET name = :name, color = :color, monthly_limit = :monthly_limit WHERE id = :id'
);
$statement->execute([
'name' => trim($name),
'color' => $color,
'monthly_limit' => max(0, $monthlyLimit),
'id' => $id,
]);
}
public function archiveCategory(int $id): void
{
$statement = $this->pdo->prepare('UPDATE categories SET active = 0 WHERE id = :id');
$statement->execute(['id' => $id]);
}
public function addBudgetItem( public function addBudgetItem(
string $month, string $month,
?int $categoryId, ?int $categoryId,
@@ -212,6 +291,9 @@ final class BudgetService
if (!in_array($recurrence, ['none', 'monthly', 'yearly'], true)) { if (!in_array($recurrence, ['none', 'monthly', 'yearly'], true)) {
$recurrence = 'none'; $recurrence = 'none';
} }
if ($dueDate !== null) {
$this->assertDateInMonth($dueDate, Support::month($month), 'Due date');
}
$series = $recurrence === 'none' ? null : bin2hex(random_bytes(12)); $series = $recurrence === 'none' ? null : bin2hex(random_bytes(12));
$statement = $this->pdo->prepare( $statement = $this->pdo->prepare(
'INSERT INTO budget_items (month, category_id, name, planned_amount, due_date, recurrence, series_key, notes) 'INSERT INTO budget_items (month, category_id, name, planned_amount, due_date, recurrence, series_key, notes)
@@ -236,6 +318,56 @@ final class BudgetService
$statement->execute(['id' => $id]); $statement->execute(['id' => $id]);
} }
public function updateBudgetItem(
int $id,
string $month,
?int $categoryId,
string $name,
float $plannedAmount,
?string $dueDate,
string $recurrence,
string $notes
): void {
if ($id <= 0 || trim($name) === '' || $plannedAmount < 0) {
throw new RuntimeException('A budget item needs a name and a non-negative target.');
}
if (!in_array($recurrence, ['none', 'monthly', 'yearly'], true)) {
$recurrence = 'none';
}
if ($dueDate !== null) {
$this->assertDateInMonth($dueDate, Support::month($month), 'Due date');
}
$existing = $this->pdo->prepare('SELECT series_key FROM budget_items WHERE id = :id AND month = :month');
$existing->execute(['id' => $id, 'month' => Support::month($month)]);
$series = $existing->fetchColumn();
if ($series === false) {
throw new RuntimeException('Budget item was not found.');
}
if ($recurrence !== 'none' && !$series) {
$series = bin2hex(random_bytes(12));
}
if ($recurrence === 'none') {
$series = null;
}
$statement = $this->pdo->prepare(
'UPDATE budget_items
SET category_id = :category_id, name = :name, planned_amount = :planned_amount,
due_date = :due_date, recurrence = :recurrence, series_key = :series_key, notes = :notes
WHERE id = :id AND month = :month'
);
$statement->execute([
'category_id' => $categoryId ?: null,
'name' => trim($name),
'planned_amount' => max(0, $plannedAmount),
'due_date' => $dueDate ?: null,
'recurrence' => $recurrence,
'series_key' => $series ?: null,
'notes' => trim($notes) ?: null,
'id' => $id,
'month' => Support::month($month),
]);
}
public function budgetItems(string $month): array public function budgetItems(string $month): array
{ {
$statement = $this->pdo->prepare( $statement = $this->pdo->prepare(
@@ -269,7 +401,9 @@ final class BudgetService
string $date, string $date,
string $notes string $notes
): int { ): int {
$month = Support::month($month);
$this->ensureMonth($month); $this->ensureMonth($month);
$this->assertDateInMonth($date, $month, 'Expense date');
if (trim($merchant) === '' || $amount <= 0) { if (trim($merchant) === '' || $amount <= 0) {
throw new RuntimeException('An expense needs a description and an amount greater than zero.'); throw new RuntimeException('An expense needs a description and an amount greater than zero.');
} }
@@ -277,7 +411,10 @@ final class BudgetService
$statement = $this->pdo->prepare('SELECT category_id FROM budget_items WHERE id = :id AND month = :month'); $statement = $this->pdo->prepare('SELECT category_id FROM budget_items WHERE id = :id AND month = :month');
$statement->execute(['id' => $budgetItemId, 'month' => $month]); $statement->execute(['id' => $budgetItemId, 'month' => $month]);
$itemCategory = $statement->fetchColumn(); $itemCategory = $statement->fetchColumn();
if ($itemCategory !== false && !$categoryId) { if ($itemCategory === false) {
throw new RuntimeException('The selected budget item is not part of this month.');
}
if (!$categoryId) {
$categoryId = $itemCategory ? (int) $itemCategory : null; $categoryId = $itemCategory ? (int) $itemCategory : null;
} }
} }
@@ -322,6 +459,77 @@ final class BudgetService
} }
} }
public function updateTransaction(
int $id,
string $month,
?int $budgetItemId,
?int $categoryId,
string $merchant,
float $amount,
string $date,
string $notes
): void {
$month = Support::month($month);
$this->assertDateInMonth($date, $month, 'Expense date');
if ($id <= 0 || trim($merchant) === '' || $amount <= 0) {
throw new RuntimeException('An expense needs a description and an amount greater than zero.');
}
$existing = $this->pdo->prepare('SELECT COUNT(*) FROM transactions WHERE id = :id');
$existing->execute(['id' => $id]);
if ((int) $existing->fetchColumn() === 0) {
throw new RuntimeException('Expense was not found.');
}
if ($budgetItemId) {
$statement = $this->pdo->prepare('SELECT category_id FROM budget_items WHERE id = :id AND month = :month');
$statement->execute(['id' => $budgetItemId, 'month' => $month]);
$itemCategory = $statement->fetchColumn();
if ($itemCategory === false) {
throw new RuntimeException('The selected budget item is not part of this month.');
}
if (!$categoryId) {
$categoryId = $itemCategory ? (int) $itemCategory : null;
}
}
$deadPull = $this->automaticAmount(
$amount,
(string) $this->settings->get('dead.transaction_mode', 'fixed'),
(float) $this->settings->get('dead.transaction_value', 0)
);
$this->pdo->beginTransaction();
try {
$statement = $this->pdo->prepare(
'UPDATE transactions
SET month = :month, budget_item_id = :budget_item_id, category_id = :category_id,
merchant = :merchant, amount = :amount, transacted_on = :transacted_on,
dead_pull_amount = :dead_pull, notes = :notes
WHERE id = :id'
);
$statement->execute([
'month' => $month,
'budget_item_id' => $budgetItemId ?: null,
'category_id' => $categoryId ?: null,
'merchant' => trim($merchant),
'amount' => $amount,
'transacted_on' => $date,
'dead_pull' => $deadPull,
'notes' => trim($notes) ?: null,
'id' => $id,
]);
$this->replaceDeadSource(
$month,
'transaction_pull',
$deadPull,
'transaction',
$id,
'Automatic dormant transfer for ' . trim($merchant)
);
$this->pdo->commit();
} catch (\Throwable $exception) {
$this->pdo->rollBack();
throw $exception;
}
}
public function deleteTransaction(int $id): void public function deleteTransaction(int $id): void
{ {
$this->pdo->beginTransaction(); $this->pdo->beginTransaction();
@@ -351,8 +559,16 @@ final class BudgetService
return $statement->fetchAll(); return $statement->fetchAll();
} }
public function moveDeadAccount(string $month, string $direction, float $amount, string $notes): int public function moveDeadAccount(
string $month,
string $direction,
float $amount,
string $notes,
string $destinationType = 'income',
?int $destinationId = null
): int
{ {
$month = Support::month($month);
$this->ensureMonth($month); $this->ensureMonth($month);
if ($amount <= 0) { if ($amount <= 0) {
throw new RuntimeException('Transfer amount must be greater than zero.'); throw new RuntimeException('Transfer amount must be greater than zero.');
@@ -361,25 +577,53 @@ final class BudgetService
if ($amount > $this->deadBalance() + 0.0001) { if ($amount > $this->deadBalance() + 0.0001) {
throw new RuntimeException('That restoration is larger than the dormant balance.'); throw new RuntimeException('That restoration is larger than the dormant balance.');
} }
$sourceType = 'restore_income';
$sourceId = null;
$destinationLabel = 'monthly income';
if ($destinationType === 'item' && $destinationId) {
$statement = $this->pdo->prepare('SELECT name FROM budget_items WHERE id = :id AND month = :month');
$statement->execute(['id' => $destinationId, 'month' => $month]);
$itemName = $statement->fetchColumn();
if ($itemName === false) {
throw new RuntimeException('The selected restoration target was not found.');
}
$sourceType = 'restore_item';
$sourceId = $destinationId;
$destinationLabel = (string) $itemName;
}
$amount *= -1; $amount *= -1;
$kind = 'restore'; $kind = 'restore';
$notes = trim($notes) ?: 'Restored to ' . $destinationLabel;
} else { } else {
$kind = 'manual_deposit'; $available = (float) $this->summary($month)['remaining'];
if ($amount > max(0, $available) + 0.0001) {
throw new RuntimeException('That deposit is larger than this months spendable balance.');
} }
return $this->insertDeadEntry($month, $kind, $amount, 'manual', null, trim($notes) ?: ucfirst(str_replace('_', ' ', $kind))); $kind = 'manual_deposit';
$sourceType = 'manual';
$sourceId = null;
$notes = trim($notes) ?: 'Manual dormant deposit';
}
return $this->insertDeadEntry($month, $kind, $amount, $sourceType, $sourceId, $notes);
} }
public function deadEntries(?string $month = null, int $limit = 100): array public function deadEntries(?string $month = null, int $limit = 100): array
{ {
if ($month) { if ($month) {
$statement = $this->pdo->prepare( $statement = $this->pdo->prepare(
'SELECT * FROM dead_account_entries WHERE month = :month ORDER BY created_at DESC, id DESC LIMIT ' . (int) $limit 'SELECT dae.*, bi.name AS destination_name
FROM dead_account_entries dae
LEFT JOIN budget_items bi ON dae.source_type = "restore_item" AND bi.id = dae.source_id
WHERE dae.month = :month ORDER BY dae.created_at DESC, dae.id DESC LIMIT ' . (int) $limit
); );
$statement->execute(['month' => $month]); $statement->execute(['month' => $month]);
return $statement->fetchAll(); return $statement->fetchAll();
} }
return $this->pdo->query( return $this->pdo->query(
'SELECT * FROM dead_account_entries ORDER BY created_at DESC, id DESC LIMIT ' . (int) $limit 'SELECT dae.*, bi.name AS destination_name
FROM dead_account_entries dae
LEFT JOIN budget_items bi ON dae.source_type = "restore_item" AND bi.id = dae.source_id
ORDER BY dae.created_at DESC, dae.id DESC LIMIT ' . (int) $limit
)->fetchAll(); )->fetchAll();
} }
@@ -390,6 +634,8 @@ final class BudgetService
public function addCalendarEvent(string $month, string $title, string $date, string $type, string $notes): int public function addCalendarEvent(string $month, string $title, string $date, string $type, string $notes): int
{ {
$month = Support::month($month);
$this->assertDateInMonth($date, $month, 'Event date');
if (trim($title) === '') { if (trim($title) === '') {
throw new RuntimeException('Calendar event title is required.'); throw new RuntimeException('Calendar event title is required.');
} }
@@ -437,30 +683,39 @@ final class BudgetService
if (!$plan) { if (!$plan) {
return [ return [
'month' => $month, 'starting_income' => 0.0, 'rollover_in' => 0.0, 'month' => $month, 'starting_income' => 0.0, 'rollover_in' => 0.0,
'additional_income' => 0.0, 'gross_resources' => 0.0, 'expenses' => 0.0, 'additional_income' => 0.0, 'fresh_income' => 0.0, 'gross_resources' => 0.0, 'expenses' => 0.0,
'dead_movement' => 0.0, 'remaining' => 0.0, 'planned' => 0.0, 'dead_movement' => 0.0, 'remaining' => 0.0, 'planned' => 0.0,
'paid_to_items' => 0.0, 'unallocated' => 0.0, 'savings_rate' => 0.0, 'dead_deposits' => 0.0, 'dead_restorations' => 0.0, 'paid_to_items' => 0.0,
'unallocated' => 0.0, 'savings_rate' => 0.0, 'budget_coverage' => 0.0,
]; ];
} }
$income = $this->sum('income_entries', 'amount', $month); $income = $this->sum('income_entries', 'amount', $month);
$expenses = $this->sum('transactions', 'amount', $month); $expenses = $this->sum('transactions', 'amount', $month);
$deadMovement = $this->sum('dead_account_entries', 'amount', $month); $deadMovement = $this->sum('dead_account_entries', 'amount', $month);
$deadDeposits = $this->sumWhere('dead_account_entries', 'amount', $month, 'amount > 0');
$deadRestorations = abs($this->sumWhere('dead_account_entries', 'amount', $month, 'amount < 0'));
$planned = $this->sum('budget_items', 'planned_amount', $month); $planned = $this->sum('budget_items', 'planned_amount', $month);
$gross = (float) $plan['starting_income'] + (float) $plan['rollover_in'] + $income; $freshIncome = (float) $plan['starting_income'] + $income;
$gross = $freshIncome + (float) $plan['rollover_in'];
$remaining = $gross - $expenses - $deadMovement; $remaining = $gross - $expenses - $deadMovement;
$usableBeforeExpenses = $gross - $deadMovement;
return [ return [
'month' => $month, 'month' => $month,
'starting_income' => (float) $plan['starting_income'], 'starting_income' => (float) $plan['starting_income'],
'rollover_in' => (float) $plan['rollover_in'], 'rollover_in' => (float) $plan['rollover_in'],
'additional_income' => $income, 'additional_income' => $income,
'fresh_income' => $freshIncome,
'gross_resources' => $gross, 'gross_resources' => $gross,
'expenses' => $expenses, 'expenses' => $expenses,
'dead_movement' => $deadMovement, 'dead_movement' => $deadMovement,
'dead_deposits' => $deadDeposits,
'dead_restorations' => $deadRestorations,
'remaining' => $remaining, 'remaining' => $remaining,
'planned' => $planned, 'planned' => $planned,
'paid_to_items' => $this->sumWhere('transactions', 'amount', $month, 'budget_item_id IS NOT NULL'), 'paid_to_items' => $this->sumWhere('transactions', 'amount', $month, 'budget_item_id IS NOT NULL'),
'unallocated' => $gross - $planned - max(0, $deadMovement), 'unallocated' => $usableBeforeExpenses - $planned,
'savings_rate' => $gross > 0 ? max(0, $deadMovement) / $gross * 100 : 0.0, 'savings_rate' => $freshIncome > 0 ? $deadDeposits / $freshIncome * 100 : 0.0,
'budget_coverage' => $planned > 0 ? min(100, $usableBeforeExpenses / $planned * 100) : 100.0,
]; ];
} }
@@ -478,9 +733,13 @@ final class BudgetService
public function yearlySummary(int $year): array public function yearlySummary(int $year): array
{ {
$totals = [ $totals = [
'fresh_income' => 0.0,
'rollover_in' => 0.0,
'gross_resources' => 0.0, 'gross_resources' => 0.0,
'expenses' => 0.0, 'expenses' => 0.0,
'dead_movement' => 0.0, 'dead_movement' => 0.0,
'dead_deposits' => 0.0,
'dead_restorations' => 0.0,
'remaining' => 0.0, 'remaining' => 0.0,
'planned' => 0.0, 'planned' => 0.0,
]; ];
@@ -499,18 +758,78 @@ final class BudgetService
public function categoryStats(string $month): array public function categoryStats(string $month): array
{ {
$statement = $this->pdo->prepare( $statement = $this->pdo->prepare(
'SELECT COALESCE(c.name, "Uncategorized") AS category_name, 'SELECT c.id, c.name AS category_name, c.color, c.monthly_limit,
COALESCE(c.color, "#718096") AS color, COALESCE(SUM(t.amount), 0) AS spent,
COALESCE(c.monthly_limit, 0) AS monthly_limit, COALESCE((SELECT SUM(bi.planned_amount) FROM budget_items bi WHERE bi.month = :item_month AND bi.category_id = c.id), 0) AS planned
COALESCE(SUM(t.amount), 0) AS spent FROM categories c
FROM transactions t LEFT JOIN transactions t ON t.category_id = c.id AND t.month = :transaction_month
LEFT JOIN categories c ON c.id = t.category_id WHERE c.active = 1
WHERE t.month = :month
GROUP BY c.id, c.name, c.color, c.monthly_limit GROUP BY c.id, c.name, c.color, c.monthly_limit
UNION ALL
SELECT NULL AS id, "Uncategorized" AS category_name, "#718096" AS color, 0 AS monthly_limit,
COALESCE(SUM(t.amount), 0) AS spent,
COALESCE((SELECT SUM(bi.planned_amount) FROM budget_items bi WHERE bi.month = :uncategorized_item_month AND bi.category_id IS NULL), 0) AS planned
FROM transactions t
WHERE t.month = :uncategorized_transaction_month AND t.category_id IS NULL
HAVING COALESCE(SUM(t.amount), 0) > 0
ORDER BY spent DESC' ORDER BY spent DESC'
); );
$statement->execute(['month' => $month]); $statement->execute([
return $statement->fetchAll(); 'item_month' => $month,
'transaction_month' => $month,
'uncategorized_item_month' => $month,
'uncategorized_transaction_month' => $month,
]);
return array_values(array_filter(
$statement->fetchAll(),
static fn (array $category): bool => (float) $category['spent'] > 0
|| (float) $category['planned'] > 0
|| (float) $category['monthly_limit'] > 0
));
}
public function upcomingBills(string $month, int $limit = 6): array
{
$items = array_values(array_filter(
$this->budgetItems($month),
static fn (array $item): bool => $item['due_date'] !== null && (float) $item['remaining_amount'] > 0
));
usort($items, static fn (array $left, array $right): int => strcmp((string) $left['due_date'], (string) $right['due_date']));
return array_slice($items, 0, max(1, $limit));
}
public function recentActivity(string $month, int $limit = 8): array
{
$activity = [];
foreach ($this->transactions($month) as $transaction) {
$activity[] = [
'type' => 'expense',
'title' => $transaction['merchant'],
'detail' => $transaction['category_name'] ?: 'Uncategorized',
'amount' => -(float) $transaction['amount'],
'date' => $transaction['transacted_on'],
];
}
foreach ($this->incomes($month) as $income) {
$activity[] = [
'type' => 'income',
'title' => $income['label'],
'detail' => 'Income received',
'amount' => (float) $income['amount'],
'date' => $income['received_on'],
];
}
foreach ($this->deadEntries($month, 100) as $entry) {
$activity[] = [
'type' => (float) $entry['amount'] >= 0 ? 'saved' : 'restored',
'title' => (float) $entry['amount'] >= 0 ? 'Moved to dormant' : 'Restored from dormant',
'detail' => $entry['notes'] ?: ucwords(str_replace('_', ' ', $entry['kind'])),
'amount' => -(float) $entry['amount'],
'date' => substr((string) $entry['created_at'], 0, 10),
];
}
usort($activity, static fn (array $left, array $right): int => strcmp($right['date'], $left['date']));
return array_slice($activity, 0, max(1, $limit));
} }
public function visitorStats(): array public function visitorStats(): array
@@ -585,19 +904,45 @@ final class BudgetService
return (float) $statement->fetchColumn(); return (float) $statement->fetchColumn();
} }
private function assertDateInMonth(string $date, string $month, string $label): void
{
$parts = preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $matches) ? $matches : null;
if ($parts === null
|| !checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1])
|| substr($date, 0, 7) !== $month
) {
throw new RuntimeException($label . ' must fall within ' . date('F Y', strtotime($month . '-01')) . '.');
}
}
private function materializeRecurringItems(string $month): void private function materializeRecurringItems(string $month): void
{ {
$targetDate = new DateTimeImmutable($month . '-01'); $targetDate = new DateTimeImmutable($month . '-01');
$sources = [ $targetMonthNumber = $targetDate->format('m');
['month' => $targetDate->modify('-1 month')->format('Y-m'), 'recurrence' => 'monthly'], $statements = [
['month' => $targetDate->modify('-1 year')->format('Y-m'), 'recurrence' => 'yearly'], [
'sql' => 'SELECT * FROM budget_items
WHERE month < :month AND recurrence = :recurrence AND series_key IS NOT NULL
ORDER BY month DESC, id DESC',
'parameters' => ['month' => $month, 'recurrence' => 'monthly'],
],
[
'sql' => 'SELECT * FROM budget_items
WHERE month < :month AND SUBSTR(month, 6, 2) = :month_number
AND recurrence = :recurrence AND series_key IS NOT NULL
ORDER BY month DESC, id DESC',
'parameters' => ['month' => $month, 'month_number' => $targetMonthNumber, 'recurrence' => 'yearly'],
],
]; ];
foreach ($sources as $source) { foreach ($statements as $source) {
$statement = $this->pdo->prepare( $statement = $this->pdo->prepare($source['sql']);
'SELECT * FROM budget_items WHERE month = :month AND recurrence = :recurrence AND series_key IS NOT NULL' $statement->execute($source['parameters']);
); $seen = [];
$statement->execute($source);
foreach ($statement->fetchAll() as $item) { foreach ($statement->fetchAll() as $item) {
if (isset($seen[$item['series_key']])) {
continue;
}
$seen[$item['series_key']] = true;
$exists = $this->pdo->prepare('SELECT COUNT(*) FROM budget_items WHERE month = :month AND series_key = :series_key'); $exists = $this->pdo->prepare('SELECT COUNT(*) FROM budget_items WHERE month = :month AND series_key = :series_key');
$exists->execute(['month' => $month, 'series_key' => $item['series_key']]); $exists->execute(['month' => $month, 'series_key' => $item['series_key']]);
if ((int) $exists->fetchColumn() > 0) { if ((int) $exists->fetchColumn() > 0) {
+5
View File
@@ -18,6 +18,11 @@ final class Database
{ {
$this->configPath = $configPath; $this->configPath = $configPath;
$this->config = $this->readConfig($configPath); $this->config = $this->readConfig($configPath);
$sqliteOverride = getenv('BUDGET_SQLITE_PATH');
if (is_string($sqliteOverride) && $sqliteOverride !== '') {
$this->config['driver'] = 'sqlite';
$this->config['sqlite_path'] = $sqliteOverride;
}
$driver = (string) ($this->config['driver'] ?? 'sqlite'); $driver = (string) ($this->config['driver'] ?? 'sqlite');
if ($driver === 'mysql') { if ($driver === 'mysql') {
+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.querySelectorAll('[data-trend-chart]').forEach(drawTrendChart);
}); });
document.querySelector('[data-menu-toggle]')?.addEventListener('click', () => { const sidebar = document.querySelector('#sidebar');
document.querySelector('#sidebar')?.classList.toggle('open'); 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) => { document.querySelector('[data-month-picker]')?.addEventListener('change', (event) => {
const picker = event.currentTarget; const picker = event.currentTarget;
@@ -34,12 +36,19 @@
if (event.target === dialog) dialog.close(); 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) => { document.querySelectorAll('[data-confirm]').forEach((form) => {
form.addEventListener('submit', (event) => { form.addEventListener('submit', (event) => {
if (!window.confirm(form.dataset.confirm)) event.preventDefault(); 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) => { document.querySelectorAll('.flash button').forEach((button) => {
button.addEventListener('click', () => button.closest('.flash')?.remove()); 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) { function drawTrendChart(canvas) {
let data; let data;
try { try {
@@ -91,7 +115,7 @@
const styles = getComputedStyle(document.documentElement); const styles = getComputedStyle(document.documentElement);
const text = styles.getPropertyValue('--muted').trim(); const text = styles.getPropertyValue('--muted').trim();
const border = styles.getPropertyValue('--border-strong').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 pink = styles.getPropertyValue('--pink').trim();
const padding = { top: 20, right: 12, bottom: 32, left: 48 }; const padding = { top: 20, right: 12, bottom: 32, left: 48 };
const plotWidth = width - padding.left - padding.right; 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'; require __DIR__ . '/../app/bootstrap.php';
if (!class_exists('Support', false)) {
class_alias(Support::class, 'Support');
}
function h(mixed $value): string function h(mixed $value): string
{ {
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); 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()) . '">'; 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 function redirect_to(string $route, array $parameters = []): never
{ {
header('Location: ' . url($route, $parameters)); header('Location: ' . url($route, $parameters));
@@ -135,6 +165,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
redirect_to('dashboard', ['month' => $month]); 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') { if ($action === 'income.delete') {
$budget->deleteIncome((int) ($_POST['id'] ?? 0)); $budget->deleteIncome((int) ($_POST['id'] ?? 0));
Support::audit($pdo, $auth->id(), 'income.deleted', 'income', (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::audit($pdo, $auth->id(), 'category.created', 'category', $id);
Support::flash('success', 'Category created.'); 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') { if ($action === 'item.add') {
@@ -175,6 +239,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
redirect_to('dashboard', ['month' => $month]); 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') { if ($action === 'transaction.add') {
$id = $budget->addTransaction( $id = $budget->addTransaction(
$month, $month,
@@ -197,12 +277,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
redirect_to('transactions', ['month' => $month]); 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') { if ($action === 'dead.move') {
$id = $budget->moveDeadAccount( $id = $budget->moveDeadAccount(
$month, $month,
(string) ($_POST['direction'] ?? 'deposit'), (string) ($_POST['direction'] ?? 'deposit'),
(float) ($_POST['amount'] ?? 0), (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::audit($pdo, $auth->id(), 'dead_account.moved', 'dead_entry', $id, ['month' => $month]);
Support::flash('success', 'Dormant account transfer recorded.'); Support::flash('success', 'Dormant account transfer recorded.');
@@ -230,18 +328,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
if ($action === 'settings.save') { 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 = [ $stringSettings = [
'app.name', 'app.currency_symbol', 'app.timezone', 'theme.default', 'app.name', 'app.currency_symbol', 'app.timezone', 'theme.default',
'dead.income_mode', 'dead.transaction_mode', 'alerts.email', 'dead.income_mode', 'dead.transaction_mode', 'alerts.email',
'mailgun.domain', 'mailgun.region', 'mailgun.from', 'mailgun.domain', 'mailgun.region', 'mailgun.from',
]; ];
foreach ($stringSettings as $key) { foreach ($stringSettings as $key) {
if (array_key_exists($key, $_POST)) { $settings->set($key, trim((string) $postSetting($key)));
$settings->set($key, trim((string) $_POST[$key]));
}
} }
foreach (['dead.income_value', 'dead.transaction_value'] as $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) { foreach (['income', 'bills', 'calendar', 'dead_account', 'comparisons', 'alerts', 'import_export'] as $module) {
$settings->set('modules.' . $module, isset($_POST['modules_' . $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'])); $settings->set('dead.hide_balance', isset($_POST['dead_hide_balance']));
$windows = array_values(array_unique(array_filter(array_map( $windows = array_values(array_unique(array_filter(array_map(
static fn (string $value): int => max(0, (int) trim($value)), 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))); ), 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.windows', $windows ?: [7, 3, 1]);
$settings->set('alerts.times', $times ?: ['09:00']); $settings->set('alerts.times', $times ?: ['09:00']);
if (trim((string) ($_POST['mailgun.api_key'] ?? '')) !== '') { if (trim((string) $postSetting('mailgun.api_key')) !== '') {
$settings->set('mailgun.api_key', trim((string) $_POST['mailgun.api_key'])); $settings->set('mailgun.api_key', trim((string) $postSetting('mailgun.api_key')));
} elseif (isset($_POST['clear_mailgun_key'])) { } elseif (isset($_POST['clear_mailgun_key'])) {
$settings->set('mailgun.api_key', ''); $settings->set('mailgun.api_key', '');
} }
@@ -416,6 +518,7 @@ switch ($route) {
'month' => $month, 'month' => $month,
'balance' => $budget->deadBalance(), 'balance' => $budget->deadBalance(),
'entries' => $budget->deadEntries(null, 150), 'entries' => $budget->deadEntries(null, 150),
'items' => $budget->budgetItems($month),
'hideBalance' => (bool) $settings->get('dead.hide_balance', true), 'hideBalance' => (bool) $settings->get('dead.hide_balance', true),
'symbol' => $symbol, 'symbol' => $symbol,
]); ]);
@@ -432,6 +535,7 @@ switch ($route) {
'newSecret' => $newSecret, 'newSecret' => $newSecret,
'totpUri' => $newSecret ? Totp::uri($newSecret, (string) $user['email'], (string) $settings->get('app.name', 'Neon Ledger')) : null, 'totpUri' => $newSecret ? Totp::uri($newSecret, (string) $user['email'], (string) $settings->get('app.name', 'Neon Ledger')) : null,
'mailgunConfigured' => (new Mailgun($settings))->configured(), 'mailgunConfigured' => (new Mailgun($settings))->configured(),
'categories' => $budget->categories(),
]); ]);
case 'data': case 'data':
render_view('data', [ render_view('data', [
@@ -454,6 +558,9 @@ switch ($route) {
'categories' => $budget->categories(), 'categories' => $budget->categories(),
'categoryStats' => $budget->categoryStats($month), 'categoryStats' => $budget->categoryStats($month),
'comparison' => $budget->comparison($month, 6), '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(), 'visitorStats' => $budget->visitorStats(),
'deadBalance' => $budget->deadBalance(), 'deadBalance' => $budget->deadBalance(),
'symbol' => $symbol, 'symbol' => $symbol,
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use Budget\ExportService;
$databasePath = sys_get_temp_dir() . '/neon-ledger-self-test-' . getmypid() . '.sqlite';
putenv('BUDGET_SQLITE_PATH=' . $databasePath);
register_shutdown_function(static function () use ($databasePath): void {
foreach ([$databasePath, $databasePath . '-shm', $databasePath . '-wal'] as $file) {
if (is_file($file)) {
unlink($file);
}
}
});
require __DIR__ . '/../app/bootstrap.php';
$assert = static function (bool $condition, string $message): void {
if (!$condition) {
throw new RuntimeException($message);
}
};
$settings->set('dead.income_mode', 'percent');
$settings->set('dead.income_value', 10);
$settings->set('dead.transaction_mode', 'fixed');
$settings->set('dead.transaction_value', 1);
$budget->savePlan('2026-01', 1000, 0, true, 'Self-test');
$categoryId = $budget->addCategory('Utilities', '#49a8ff', 200);
$itemId = $budget->addBudgetItem('2026-01', $categoryId, 'Internet', 55, '2026-01-20', 'monthly', '');
$transactionId = $budget->addTransaction('2026-01', $itemId, null, 'Internet payment', 10, '2026-01-10', '');
$january = $budget->summary('2026-01');
$assert(abs($january['remaining'] - 889) < 0.001, 'January remaining balance is incorrect.');
$assert(abs($budget->budgetItems('2026-01')[0]['remaining_amount'] - 45) < 0.001, 'Target progress is incorrect.');
$budget->updateTransaction($transactionId, '2026-01', $itemId, null, 'Internet payment', 20, '2026-01-10', '');
$budget->moveDeadAccount('2026-01', 'restore', 50, '', 'item', $itemId);
$january = $budget->summary('2026-01');
$assert(abs($january['remaining'] - 929) < 0.001, 'Dormant restoration accounting is incorrect.');
$assert(abs($budget->deadBalance() - 51) < 0.001, 'Dormant balance is incorrect.');
$february = $budget->ensureMonth('2026-02');
$februaryItems = $budget->budgetItems('2026-02');
$assert(abs((float) $february['rollover_in'] - 929) < 0.001, 'Rolling budget did not carry the remainder forward.');
$assert(count($februaryItems) === 1 && $februaryItems[0]['name'] === 'Internet', 'Recurring target was not materialized.');
$year = $budget->yearlySummary(2026);
$assert(abs($year['totals']['fresh_income'] - 1000) < 0.001, 'Yearly income double-counted rollover.');
$exporter = new ExportService($pdo, $settings, $budget);
$package = json_decode($exporter->package(), true);
$assert(($package['format'] ?? null) === 'neon-ledger/budget-planner', 'JSON package format is invalid.');
$assert(str_contains($exporter->html('2026-01'), 'Internet'), 'HTML export is missing target data.');
$assert(str_starts_with($exporter->pdf('2026-01'), '%PDF-1.4'), 'PDF export is invalid.');
echo "Neon Ledger self-test passed.\n";
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark"> <meta name="color-scheme" content="dark">
<title><?= h($title ?? 'Welcome') ?> · <?= h($appName) ?></title> <title><?= h($title ?? 'Welcome') ?> · <?= h($appName) ?></title>
<link rel="stylesheet" href="/assets/app.css"> <link rel="stylesheet" href="/assets/app.css?v=<?= (int) filemtime(APP_ROOT . '/public/assets/app.css') ?>">
</head> </head>
<body class="auth-body"> <body class="auth-body">
<main class="auth-shell"> <main class="auth-shell">
+18 -9
View File
@@ -2,16 +2,24 @@
$totals = $yearly['totals']; $totals = $yearly['totals'];
$bestMonth = null; $bestMonth = null;
foreach ($yearly['months'] as $row) { foreach ($yearly['months'] as $row) {
$hasActivity = (float) $row['fresh_income'] !== 0.0
|| (float) $row['rollover_in'] !== 0.0
|| (float) $row['expenses'] !== 0.0
|| (float) $row['planned'] !== 0.0
|| (float) $row['dead_movement'] !== 0.0;
if (!$hasActivity) {
continue;
}
if ($bestMonth === null || $row['remaining'] > $bestMonth['remaining']) { if ($bestMonth === null || $row['remaining'] > $bestMonth['remaining']) {
$bestMonth = $row; $bestMonth = $row;
} }
} }
?> ?>
<section class="hero-grid comparison-metrics"> <section class="hero-grid comparison-metrics">
<article class="metric-card card"><div class="metric-icon blue">Σ</div><p><?= (int) $yearly['year'] ?> resources</p><strong><?= h(Support::money($totals['gross_resources'], $symbol)) ?></strong><small>Across all twelve months</small></article> <article class="metric-card card"><div class="metric-icon blue"><?= icon('wallet') ?></div><p><?= (int) $yearly['year'] ?> fresh income</p><strong><?= h(Support::money($totals['fresh_income'], $symbol)) ?></strong><small>Rollover excluded to avoid double-counting</small></article>
<article class="metric-card card"><div class="metric-icon pink"></div><p><?= (int) $yearly['year'] ?> expenses</p><strong><?= h(Support::money($totals['expenses'], $symbol)) ?></strong><small><?= $totals['gross_resources'] > 0 ? number_format($totals['expenses'] / $totals['gross_resources'] * 100, 1) : '0.0' ?>% of resources</small></article> <article class="metric-card card"><div class="metric-icon pink"><?= icon('expense') ?></div><p><?= (int) $yearly['year'] ?> expenses</p><strong><?= h(Support::money($totals['expenses'], $symbol)) ?></strong><small><?= $totals['fresh_income'] > 0 ? number_format($totals['expenses'] / $totals['fresh_income'] * 100, 1) : '0.0' ?>% of fresh income</small></article>
<article class="metric-card card"><div class="metric-icon orange"></div><p>Moved dormant</p><strong><?= h(Support::money($totals['dead_movement'], $symbol)) ?></strong><small>Net dormant deposits</small></article> <article class="metric-card card"><div class="metric-icon orange"><?= icon('vault') ?></div><p>Moved dormant</p><strong><?= h(Support::money($totals['dead_deposits'], $symbol)) ?></strong><small><?= h(Support::money($totals['dead_restorations'], $symbol)) ?> restored</small></article>
<article class="metric-card card"><div class="metric-icon green"></div><p>Strongest remainder</p><strong><?= h(Support::money($bestMonth['remaining'] ?? 0, $symbol)) ?></strong><small><?= $bestMonth ? h(date('F', strtotime($bestMonth['month'] . '-01'))) : 'No data' ?></small></article> <article class="metric-card card"><div class="metric-icon green"><?= icon('chart') ?></div><p>Strongest remainder</p><strong><?= h(Support::money($bestMonth['remaining'] ?? 0, $symbol)) ?></strong><small><?= $bestMonth ? h(date('F', strtotime($bestMonth['month'] . '-01'))) : 'No data' ?></small></article>
</section> </section>
<section class="content-grid comparison-grid"> <section class="content-grid comparison-grid">
@@ -30,7 +38,7 @@ foreach ($yearly['months'] as $row) {
<div> <div>
<p><span><i style="--dot:<?= h($category['color']) ?>"></i><?= h($category['category_name']) ?></span><strong><?= h(Support::money($category['spent'], $symbol)) ?></strong></p> <p><span><i style="--dot:<?= h($category['color']) ?>"></i><?= h($category['category_name']) ?></span><strong><?= h(Support::money($category['spent'], $symbol)) ?></strong></p>
<div class="progress-track"><span style="width:<?= h((string) $width) ?>%;--bar:<?= h($category['color']) ?>"></span></div> <div class="progress-track"><span style="width:<?= h((string) $width) ?>%;--bar:<?= h($category['color']) ?>"></span></div>
<?php if ((float) $category['monthly_limit'] > 0): ?><small><?= number_format($category['spent'] / $category['monthly_limit'] * 100, 0) ?>% of <?= h(Support::money($category['monthly_limit'], $symbol)) ?> category limit</small><?php endif; ?> <small><?php if ((float) $category['planned'] > 0): ?><?= number_format($category['spent'] / $category['planned'] * 100, 0) ?>% of <?= h(Support::money($category['planned'], $symbol)) ?> planned<?php elseif ((float) $category['monthly_limit'] > 0): ?><?= number_format($category['spent'] / $category['monthly_limit'] * 100, 0) ?>% of <?= h(Support::money($category['monthly_limit'], $symbol)) ?> category limit<?php else: ?>No target defined<?php endif; ?></small>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
@@ -41,19 +49,20 @@ foreach ($yearly['months'] as $row) {
<article class="card"> <article class="card">
<div class="card-heading"> <div class="card-heading">
<div><p class="eyebrow">Statistical table</p><h2><?= (int) $yearly['year'] ?> monthly breakdown</h2></div> <div><p class="eyebrow">Statistical table</p><h2><?= (int) $yearly['year'] ?> monthly breakdown</h2></div>
<label class="search-box"><span></span><input type="search" placeholder="Find a month…" data-table-search="year-table"></label> <label class="search-box"><?= icon('search') ?><input type="search" placeholder="Find a month…" data-table-search="year-table"></label>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table id="year-table"> <table id="year-table">
<thead><tr><th>Month</th><th>Resources</th><th>Planned</th><th>Expenses</th><th>Dormant</th><th>Remainder</th></tr></thead> <thead><tr><th>Month</th><th>Fresh income</th><th>Rollover</th><th>Planned</th><th>Expenses</th><th>Saved</th><th>Remainder</th></tr></thead>
<tbody> <tbody>
<?php foreach ($yearly['months'] as $row): ?> <?php foreach ($yearly['months'] as $row): ?>
<tr> <tr>
<td><a class="text-link" href="<?= h(url('comparisons', ['month' => $row['month']])) ?>"><?= h(date('F Y', strtotime($row['month'] . '-01'))) ?></a></td> <td><a class="text-link" href="<?= h(url('comparisons', ['month' => $row['month']])) ?>"><?= h(date('F Y', strtotime($row['month'] . '-01'))) ?></a></td>
<td><?= h(Support::money($row['gross_resources'], $symbol)) ?></td> <td><?= h(Support::money($row['fresh_income'], $symbol)) ?></td>
<td><?= h(Support::money($row['rollover_in'], $symbol)) ?></td>
<td><?= h(Support::money($row['planned'], $symbol)) ?></td> <td><?= h(Support::money($row['planned'], $symbol)) ?></td>
<td><?= h(Support::money($row['expenses'], $symbol)) ?></td> <td><?= h(Support::money($row['expenses'], $symbol)) ?></td>
<td><?= h(Support::money($row['dead_movement'], $symbol)) ?></td> <td><?= h(Support::money($row['dead_deposits'], $symbol)) ?></td>
<td class="<?= $row['remaining'] >= 0 ? 'green' : 'red' ?>"><strong><?= h(Support::money($row['remaining'], $symbol)) ?></strong></td> <td class="<?= $row['remaining'] >= 0 ? 'green' : 'red' ?>"><strong><?= h(Support::money($row['remaining'], $symbol)) ?></strong></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
+210 -80
View File
@@ -1,144 +1,274 @@
<?php <?php
$remainingTone = $summary['remaining'] >= 0 ? 'green' : 'red'; $monthLabel = date('F Y', strtotime($month . '-01'));
$progress = $summary['gross_resources'] > 0 ? min(100, max(0, $summary['expenses'] / $summary['gross_resources'] * 100)) : 0; $daysInMonth = (int) date('t', strtotime($month . '-01'));
$currentMonth = date('Y-m');
$elapsedDays = $month < $currentMonth ? $daysInMonth : ($month > $currentMonth ? 0 : min($daysInMonth, (int) date('j')));
$forecast = $elapsedDays > 0 ? $summary['expenses'] / $elapsedDays * $daysInMonth : 0;
$spentPercent = $summary['gross_resources'] > 0 ? min(100, max(0, $summary['expenses'] / $summary['gross_resources'] * 100)) : 0;
$remainingPercent = $summary['gross_resources'] > 0 ? min(100, max(0, $summary['remaining'] / $summary['gross_resources'] * 100)) : 0;
$remainingDelta = $summary['remaining'] - $previousSummary['remaining'];
$incomeRule = (string) $settings->get('dead.income_mode', 'percent');
$incomeRuleValue = (float) $settings->get('dead.income_value', 0);
$transactionRule = (string) $settings->get('dead.transaction_mode', 'fixed');
$transactionRuleValue = (float) $settings->get('dead.transaction_value', 0);
?> ?>
<section class="hero-grid"> <section class="overview-hero card">
<article class="hero-balance card glow-purple"> <div class="hero-art" aria-hidden="true"></div>
<div> <div class="hero-copy">
<p class="eyebrow"><?= h(date('F Y', strtotime($month . '-01'))) ?> remaining</p> <div class="status-line">
<div class="hero-amount <?= h($remainingTone) ?>"><?= h(Support::money($summary['remaining'], $symbol)) ?></div> <span class="live-dot"></span>
<p class="muted"><?= number_format($progress, 1) ?>% of available resources spent</p> <span><?= h($monthLabel) ?> plan</span>
<span class="status-divider"></span>
<span><?= $plan['rolling_enabled'] ? 'Rolling budget on' : 'Manual rollover' ?></span>
</div> </div>
<div class="balance-ring" style="--progress:<?= h((string) $progress) ?>"> <p class="hero-label">Spendable balance</p>
<span><?= number_format(100 - $progress, 0) ?>%</span><small>left</small> <div class="hero-value <?= $summary['remaining'] >= 0 ? '' : 'negative' ?>"><?= h(Support::money($summary['remaining'], $symbol)) ?></div>
<p class="hero-summary">
<?= h(Support::money($summary['gross_resources'], $symbol)) ?> available
<span>·</span>
<?= number_format($remainingPercent, 0) ?>% remains
</p>
<div class="hero-actions">
<a class="button primary" href="<?= h(url('transactions', ['month' => $month, 'open' => 'expense-dialog'])) ?>"><?= icon('plus') ?> Record expense</a>
<button class="button glass" type="button" data-dialog-open="plan-dialog"><?= icon('wallet') ?> Edit monthly plan</button>
</div> </div>
</div>
<div class="hero-gauge">
<div class="gauge-ring" style="--progress:<?= h((string) $remainingPercent) ?>">
<span><?= number_format($remainingPercent, 0) ?>%</span>
<small>remaining</small>
</div>
<div class="gauge-note">
<span>Month forecast</span>
<strong><?= h(Support::money($forecast, $symbol)) ?></strong>
<small><?= $elapsedDays === 0 ? 'Begins when spending is recorded' : 'At the current daily pace' ?></small>
</div>
</div>
</section>
<section class="metric-grid">
<article class="metric-card card">
<div class="metric-top"><span class="metric-icon green"><?= icon('wallet') ?></span><span class="trend-tag">Fresh funds</span></div>
<p>Income this month</p>
<strong><?= h(Support::money($summary['fresh_income'], $symbol)) ?></strong>
<small><?= h(Support::money($summary['starting_income'], $symbol)) ?> starting + <?= h(Support::money($summary['additional_income'], $symbol)) ?> added</small>
</article> </article>
<article class="metric-card card"> <article class="metric-card card">
<div class="metric-icon blue"></div><p>Gross resources</p><strong><?= h(Support::money($summary['gross_resources'], $symbol)) ?></strong> <div class="metric-top"><span class="metric-icon pink"><?= icon('expense') ?></span><span class="trend-tag <?= $spentPercent > 80 ? 'warning' : '' ?>"><?= number_format($spentPercent, 0) ?>% used</span></div>
<small><?= h(Support::money($summary['starting_income'], $symbol)) ?> start + <?= h(Support::money($summary['rollover_in'], $symbol)) ?> rollover</small> <p>Total spent</p>
<strong><?= h(Support::money($summary['expenses'], $symbol)) ?></strong>
<small><?= count($items) ?> target<?= count($items) === 1 ? '' : 's' ?> · <?= h(Support::money($summary['planned'], $symbol)) ?> planned</small>
</article> </article>
<article class="metric-card card"> <article class="metric-card card">
<div class="metric-icon pink"></div><p>Expenses</p><strong><?= h(Support::money($summary['expenses'], $symbol)) ?></strong> <div class="metric-top"><span class="metric-icon purple"><?= icon('vault') ?></span><span class="trend-tag"><?= number_format($summary['savings_rate'], 1) ?>% rate</span></div>
<small><?= count($items) ?> defined budget item<?= count($items) === 1 ? '' : 's' ?></small> <p>Moved dormant</p>
<strong><?= h(Support::money($summary['dead_deposits'], $symbol)) ?></strong>
<small><?= h(Support::money($summary['dead_restorations'], $symbol)) ?> restored this month</small>
</article> </article>
<article class="metric-card card"> <article class="metric-card card">
<div class="metric-icon orange"></div><p>Dormant balance</p> <div class="metric-top"><span class="metric-icon blue"><?= icon('data') ?></span><span class="trend-tag <?= $remainingDelta < 0 ? 'negative' : '' ?>"><?= $remainingDelta >= 0 ? '+' : '' ?><?= h(Support::money($remainingDelta, $symbol)) ?></span></div>
<strong><?= h(Support::money($deadBalance, $symbol)) ?></strong> <p>Previous remainder</p>
<small><?= number_format($summary['savings_rate'], 1) ?>% of this month routed away</small> <strong><?= h(Support::money($summary['rollover_in'], $symbol)) ?></strong>
<small>Compared with <?= h(date('F', strtotime($month . '-01 -1 month'))) ?>s closing balance</small>
</article> </article>
</section> </section>
<section class="content-grid dashboard-grid"> <section class="dashboard-layout">
<div class="span-2 stack"> <div class="dashboard-main stack">
<article class="card"> <article class="card panel">
<div class="card-heading"> <div class="card-heading">
<div><p class="eyebrow">Progressional tracking</p><h2>Budget targets</h2></div> <div><p class="eyebrow">Progressional plan</p><h2>Budget targets</h2><p class="heading-note">Payments reduce the amount still owing in real time.</p></div>
<button class="button small" type="button" data-dialog-open="item-dialog">+ Add item</button> <button class="button small primary" type="button" data-dialog-open="item-dialog"><?= icon('plus') ?> New target</button>
</div> </div>
<?php if ($items === []): ?> <?php if ($items === []): ?>
<div class="empty-state"><strong>No targets yet</strong><p>Add a bill or category target to begin progress tracking.</p></div> <div class="empty-state"><span class="empty-icon"><?= icon('chart') ?></span><strong>Build your first target</strong><p>Add rent, internet, groceries, or any amount you want to track.</p><button class="button small" type="button" data-dialog-open="item-dialog">Add a budget target</button></div>
<?php else: ?> <?php else: ?>
<div class="budget-list"> <div class="budget-list">
<?php foreach ($items as $item): ?> <?php foreach ($items as $item): ?>
<div class="budget-row"> <div class="budget-row">
<span class="category-dot" style="--dot:<?= h($item['category_color'] ?: '#718096') ?>"></span> <div class="budget-identity">
<div class="budget-main"> <span class="category-mark" style="--dot:<?= h($item['category_color'] ?: '#718096') ?>"><?= h(strtoupper(substr($item['name'], 0, 1))) ?></span>
<div class="budget-title"> <div><strong><?= h($item['name']) ?></strong><span><?= h($item['category_name'] ?: 'Uncategorized') ?><?php if ($item['due_date']): ?> · due <?= h(date('M j', strtotime($item['due_date']))) ?><?php endif; ?></span></div>
<strong><?= h($item['name']) ?></strong>
<span><?= h($item['category_name'] ?: 'Uncategorized') ?><?= $item['due_date'] ? ' · due ' . h(date('M j', strtotime($item['due_date']))) : '' ?></span>
</div> </div>
<div class="budget-progress">
<div class="progress-labels"><span><?= number_format((float) $item['progress'], 0) ?>% paid</span><strong><?= h(Support::money($item['remaining_amount'], $symbol)) ?> left</strong></div>
<div class="progress-track"><span style="width:<?= h((string) $item['progress']) ?>%;--bar:<?= h($item['category_color'] ?: '#7c5cff') ?>"></span></div> <div class="progress-track"><span style="width:<?= h((string) $item['progress']) ?>%;--bar:<?= h($item['category_color'] ?: '#7c5cff') ?>"></span></div>
<div class="budget-meta"> <small><?= h(Support::money($item['paid_amount'], $symbol)) ?> of <?= h(Support::money($item['planned_amount'], $symbol)) ?></small>
<span><?= h(Support::money($item['paid_amount'], $symbol)) ?> paid</span>
<span><?= h(Support::money($item['remaining_amount'], $symbol)) ?> owing</span>
</div> </div>
</div> <div class="row-actions">
<strong class="target"><?= h(Support::money($item['planned_amount'], $symbol)) ?></strong> <button class="icon-button" type="button" data-dialog-open="item-edit-<?= (int) $item['id'] ?>" aria-label="Edit <?= h($item['name']) ?>"><?= icon('edit') ?></button>
<form method="post" data-confirm="Remove this budget item? Existing expenses will remain."> <form method="post" data-confirm="Remove this budget target? Existing expenses will remain.">
<?= csrf_field() ?><input type="hidden" name="action" value="item.delete"><input type="hidden" name="month" value="<?= h($month) ?>"><input type="hidden" name="id" value="<?= (int) $item['id'] ?>"> <?= csrf_field() ?><input type="hidden" name="action" value="item.delete"><input type="hidden" name="month" value="<?= h($month) ?>"><input type="hidden" name="id" value="<?= (int) $item['id'] ?>">
<button class="icon-button danger" title="Delete item" type="submit">×</button> <button class="icon-button danger" type="submit" aria-label="Delete <?= h($item['name']) ?>"><?= icon('trash') ?></button>
</form> </form>
</div> </div>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
</article> </article>
<article class="card"> <article class="card panel chart-panel">
<div class="card-heading"> <div class="card-heading">
<div><p class="eyebrow">Comparative view</p><h2>Six-month movement</h2></div> <div><p class="eyebrow">Comparative movement</p><h2>Six-month cash flow</h2><p class="heading-note">Resources include rollover for each individual month.</p></div>
<a class="text-link" href="<?= h(url('comparisons', ['month' => $month])) ?>">Full analysis </a> <a class="text-link" href="<?= h(url('comparisons', ['month' => $month])) ?>">Open analytics <span></span></a>
</div> </div>
<canvas class="trend-chart" data-trend-chart='<?= h(json_encode($comparison)) ?>' data-symbol="<?= h($symbol) ?>" height="220"></canvas> <canvas class="trend-chart" data-trend-chart='<?= h(json_encode($comparison)) ?>' data-symbol="<?= h($symbol) ?>" height="255"></canvas>
</article>
<article class="card panel">
<div class="card-heading">
<div><p class="eyebrow">Cash activity</p><h2>Recent movement</h2></div>
<a class="text-link" href="<?= h(url('transactions', ['month' => $month])) ?>">All transactions <span></span></a>
</div>
<?php if ($recentActivity === []): ?>
<div class="empty-state compact"><strong>No movement yet</strong><p>Income, expenses, and dormant transfers will collect here.</p></div>
<?php else: ?>
<div class="activity-list">
<?php foreach ($recentActivity as $activity): ?>
<div class="activity-row">
<span class="activity-icon <?= h($activity['type']) ?>"><?= icon($activity['type'] === 'expense' ? 'expense' : ($activity['type'] === 'saved' ? 'vault' : 'wallet')) ?></span>
<div><strong><?= h($activity['title']) ?></strong><small><?= h($activity['detail']) ?> · <?= h(date('M j', strtotime($activity['date']))) ?></small></div>
<b class="<?= $activity['amount'] >= 0 ? 'green' : '' ?>"><?= $activity['amount'] >= 0 ? '+' : '-' ?><?= h(Support::money(abs($activity['amount']), $symbol)) ?></b>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</article> </article>
</div> </div>
<div class="stack"> <aside class="dashboard-side stack">
<article class="card accent-card"> <article class="card panel plan-card">
<div class="card-heading"><div><p class="eyebrow">Monthly inputs</p><h2>Starting point</h2></div></div> <div class="card-heading">
<form method="post" class="stack-form compact"> <div><p class="eyebrow">Plan health</p><h2><?= h($monthLabel) ?></h2></div>
<?= csrf_field() ?><input type="hidden" name="action" value="plan.save"><input type="hidden" name="month" value="<?= h($month) ?>"> <button class="icon-button" type="button" data-dialog-open="plan-dialog" aria-label="Edit monthly plan"><?= icon('edit') ?></button>
<label>Manual starting income<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="starting_income" min="0" step="0.01" value="<?= h((string) $plan['starting_income']) ?>"></div></label> </div>
<label>Previous-month remainder<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="rollover_in" min="0" step="0.01" value="<?= h((string) $plan['rollover_in']) ?>"></div></label> <div class="health-score">
<label class="check-row slim"><input type="checkbox" name="rolling_enabled" value="1" <?= $plan['rolling_enabled'] ? 'checked' : '' ?>><span>Carry positive remainder forward</span></label> <span style="--score:<?= h((string) min(100, max(0, $summary['budget_coverage']))) ?>"><?= number_format($summary['budget_coverage'], 0) ?>%</span>
<label>Monthly note<textarea name="notes" rows="2" placeholder="What matters this month?"><?= h($plan['notes']) ?></textarea></label> <div><strong>Target coverage</strong><small>Spendable funds compared with planned targets</small></div>
<button class="button primary" type="submit">Save starting point</button> </div>
</form> <dl class="plan-facts">
<form method="post" class="inline-form top-gap"> <div><dt>Starting point</dt><dd><?= h(Support::money($summary['starting_income'], $symbol)) ?></dd></div>
<div><dt>Rollover field</dt><dd><?= h(Support::money($summary['rollover_in'], $symbol)) ?></dd></div>
<div><dt>Still unassigned</dt><dd class="<?= $summary['unallocated'] < 0 ? 'red' : '' ?>"><?= h(Support::money($summary['unallocated'], $symbol)) ?></dd></div>
</dl>
<form method="post" class="rollover-refresh">
<?= csrf_field() ?><input type="hidden" name="action" value="plan.refresh_rollover"><input type="hidden" name="month" value="<?= h($month) ?>"> <?= csrf_field() ?><input type="hidden" name="action" value="plan.refresh_rollover"><input type="hidden" name="month" value="<?= h($month) ?>">
<button class="text-link" type="submit"> Recalculate from <?= h(date('F', strtotime($month . '-01 -1 month'))) ?></button> <button class="text-link" type="submit">Refresh from <?= h(date('F', strtotime($month . '-01 -1 month'))) ?> <span>↻</span></button>
</form> </form>
<div class="rule-summary">
<span><?= icon('vault') ?></span>
<p><strong>Automatic set-aside</strong><small><?= h((string) $incomeRuleValue) ?><?= $incomeRule === 'percent' ? '% of income' : ' per income' ?> · <?= h((string) $transactionRuleValue) ?><?= $transactionRule === 'percent' ? '% per expense' : ' per expense' ?></small></p>
</div>
</article>
<article class="card panel">
<div class="card-heading">
<div><p class="eyebrow">Coming up</p><h2>Next bills</h2></div>
<a class="text-link" href="<?= h(url('calendar', ['month' => $month])) ?>">Calendar <span></span></a>
</div>
<?php if ($upcomingBills === []): ?>
<div class="empty-state compact"><strong>Nothing due</strong><p>Add due dates to targets to build the bill queue.</p></div>
<?php else: ?>
<div class="due-list">
<?php foreach ($upcomingBills as $bill): ?>
<?php $days = (int) floor((strtotime($bill['due_date']) - strtotime(date('Y-m-d'))) / 86400); ?>
<div>
<span class="date-tile"><b><?= h(date('d', strtotime($bill['due_date']))) ?></b><small><?= h(strtoupper(date('M', strtotime($bill['due_date'])))) ?></small></span>
<p><strong><?= h($bill['name']) ?></strong><small><?= h($bill['category_name'] ?: 'Uncategorized') ?> · <?= $days < 0 ? abs($days) . 'd overdue' : ($days === 0 ? 'due today' : 'in ' . $days . 'd') ?></small></p>
<b><?= h(Support::money($bill['remaining_amount'], $symbol)) ?></b>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</article> </article>
<?php if ($settings->module('income')): ?> <?php if ($settings->module('income')): ?>
<article class="card"> <article class="card panel">
<div class="card-heading"><div><p class="eyebrow">Income stream</p><h2>Additional income</h2></div><button class="button small secondary" type="button" data-dialog-open="income-dialog">+ Add</button></div> <div class="card-heading">
<div><p class="eyebrow">Income stream</p><h2>Additional income</h2></div>
<button class="button small" type="button" data-dialog-open="income-dialog"><?= icon('plus') ?> Add</button>
</div>
<?php if ($incomes === []): ?> <?php if ($incomes === []): ?>
<p class="muted">No additional income recorded.</p> <div class="empty-state compact"><strong>No additional income</strong><p>Your manual starting point still counts as the base income.</p></div>
<?php else: ?> <?php else: ?>
<div class="mini-list"> <div class="income-list">
<?php foreach (array_slice($incomes, 0, 5) as $income): ?> <?php foreach (array_slice($incomes, 0, 5) as $income): ?>
<div><span><strong><?= h($income['label']) ?></strong><small><?= h(date('M j', strtotime($income['received_on']))) ?> · <?= h(Support::money($income['withholding_amount'], $symbol)) ?> withheld</small></span><strong class="green">+<?= h(Support::money($income['amount'], $symbol)) ?></strong></div> <div>
<span class="activity-icon income"><?= icon('wallet') ?></span>
<p><strong><?= h($income['label']) ?></strong><small><?= h(date('M j', strtotime($income['received_on']))) ?> · <?= h(Support::money($income['withholding_amount'], $symbol)) ?> set aside</small></p>
<b>+<?= h(Support::money($income['amount'], $symbol)) ?></b>
<button class="micro-button" type="button" data-dialog-open="income-edit-<?= (int) $income['id'] ?>" aria-label="Edit <?= h($income['label']) ?>"><?= icon('edit') ?></button>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
</article> </article>
<?php endif; ?> <?php endif; ?>
<article class="card stats-card"> <article class="usage-card card">
<div class="card-heading"><div><p class="eyebrow">Usage stats</p><h2>Planner activity</h2></div></div> <p class="eyebrow">Private usage stats</p>
<div class="stat-trio"><div><strong><?= (int) $visitorStats['today'] ?></strong><span>today</span></div><div><strong><?= (int) $visitorStats['thirty_days'] ?></strong><span>30-day visitors</span></div><div><strong><?= (int) $visitorStats['views'] ?></strong><span>page views</span></div></div> <div><span><strong><?= (int) $visitorStats['today'] ?></strong><small>today</small></span><span><strong><?= (int) $visitorStats['thirty_days'] ?></strong><small>30-day visitors</small></span><span><strong><?= (int) $visitorStats['views'] ?></strong><small>page views</small></span></div>
</article> </article>
</div> </aside>
</section> </section>
<dialog id="plan-dialog">
<form method="post" class="dialog-card">
<?= csrf_field() ?><input type="hidden" name="action" value="plan.save"><input type="hidden" name="month" value="<?= h($month) ?>">
<div class="dialog-heading"><div><p class="eyebrow">Monthly foundation</p><h2>Edit <?= h($monthLabel) ?> plan</h2></div><button class="icon-button" type="button" data-dialog-close><?= icon('close') ?></button></div>
<div class="form-grid">
<label>Manual starting income<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="starting_income" min="0" step="0.01" value="<?= h((string) $plan['starting_income']) ?>"></div></label>
<label>Previous-month remainder<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="rollover_in" min="0" step="0.01" value="<?= h((string) $plan['rollover_in']) ?>"></div></label>
<label class="check-row span-2"><input type="checkbox" name="rolling_enabled" value="1" <?= $plan['rolling_enabled'] ? 'checked' : '' ?>><span><strong>Carry positive remainder forward</strong><small>The next month can use any money left at close.</small></span></label>
<label class="span-2">Monthly note<textarea name="notes" rows="3" placeholder="Intent, constraints, or focus for this month"><?= h($plan['notes']) ?></textarea></label>
</div>
<div class="dialog-actions"><button class="button" type="button" data-dialog-close>Cancel</button><button class="button primary" type="submit">Save monthly plan</button></div>
</form>
</dialog>
<dialog id="item-dialog"> <dialog id="item-dialog">
<form method="post" class="dialog-card"> <form method="post" class="dialog-card">
<?= csrf_field() ?><input type="hidden" name="action" value="item.add"><input type="hidden" name="month" value="<?= h($month) ?>"> <?= csrf_field() ?><input type="hidden" name="action" value="item.add"><input type="hidden" name="month" value="<?= h($month) ?>">
<div class="card-heading"><div><p class="eyebrow">Define the target</p><h2>Add budget item</h2></div><button class="icon-button" type="button" data-dialog-close>×</button></div> <div class="dialog-heading"><div><p class="eyebrow">Progressional target</p><h2>Add budget item</h2></div><button class="icon-button" type="button" data-dialog-close><?= icon('close') ?></button></div>
<div class="form-grid"> <?php require APP_ROOT . '/views/partials/item-fields.php'; ?>
<label class="span-2">Name<input type="text" name="name" required placeholder="Internet, rent, groceries…"></label> <div class="dialog-actions"><button class="button" type="button" data-dialog-close>Cancel</button><button class="button primary" type="submit">Add target</button></div>
<label>Target amount<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="planned_amount" min="0" step="0.01" required></div></label>
<label>Category<select name="category_id"><option value="">Uncategorized</option><?php foreach ($categories as $category): ?><option value="<?= (int) $category['id'] ?>"><?= h($category['name']) ?></option><?php endforeach; ?></select></label>
<label>Due date<input type="date" name="due_date" value="<?= h($month . '-01') ?>"></label>
<label>Repeat<select name="recurrence"><option value="none">One time</option><option value="monthly">Monthly</option><option value="yearly">Yearly</option></select></label>
<label class="span-2">Notes<textarea name="notes" rows="2"></textarea></label>
</div>
<button class="button primary full" type="submit">Add budget item</button>
</form> </form>
</dialog> </dialog>
<?php foreach ($items as $item): ?>
<dialog id="item-edit-<?= (int) $item['id'] ?>">
<form method="post" class="dialog-card">
<?= csrf_field() ?><input type="hidden" name="action" value="item.update"><input type="hidden" name="month" value="<?= h($month) ?>"><input type="hidden" name="id" value="<?= (int) $item['id'] ?>">
<div class="dialog-heading"><div><p class="eyebrow">Maintain target</p><h2>Edit <?= h($item['name']) ?></h2></div><button class="icon-button" type="button" data-dialog-close><?= icon('close') ?></button></div>
<?php $editingItem = $item; require APP_ROOT . '/views/partials/item-fields.php'; unset($editingItem); ?>
<p class="form-note">Changes apply to this month. Future recurring copies use the latest saved version.</p>
<div class="dialog-actions"><button class="button" type="button" data-dialog-close>Cancel</button><button class="button primary" type="submit">Save target</button></div>
</form>
</dialog>
<?php endforeach; ?>
<dialog id="income-dialog"> <dialog id="income-dialog">
<form method="post" class="dialog-card"> <form method="post" class="dialog-card">
<?= csrf_field() ?><input type="hidden" name="action" value="income.add"><input type="hidden" name="month" value="<?= h($month) ?>"> <?= csrf_field() ?><input type="hidden" name="action" value="income.add"><input type="hidden" name="month" value="<?= h($month) ?>">
<div class="card-heading"><div><p class="eyebrow">Beyond the starting point</p><h2>Add income</h2></div><button class="icon-button" type="button" data-dialog-close>×</button></div> <div class="dialog-heading"><div><p class="eyebrow">Beyond the starting point</p><h2>Add income</h2></div><button class="icon-button" type="button" data-dialog-close><?= icon('close') ?></button></div>
<div class="form-grid"> <?php require APP_ROOT . '/views/partials/income-fields.php'; ?>
<label class="span-2">Label<input type="text" name="label" required placeholder="Paycheck, freelance, refund…"></label> <p class="form-note">The configured withholding rule is applied automatically.</p>
<label>Amount<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="amount" min="0.01" step="0.01" required></div></label> <div class="dialog-actions"><button class="button" type="button" data-dialog-close>Cancel</button><button class="button primary" type="submit">Record income</button></div>
<label>Received<input type="date" name="received_on" value="<?= h(date('Y-m-d')) ?>" required></label>
<label class="span-2">Notes<textarea name="notes" rows="2"></textarea></label>
</div>
<p class="form-note">The configured income withholding will move automatically to the dormant account.</p>
<button class="button primary full" type="submit">Record income</button>
</form> </form>
</dialog> </dialog>
<?php foreach ($incomes as $income): ?>
<dialog id="income-edit-<?= (int) $income['id'] ?>">
<form method="post" class="dialog-card">
<?= csrf_field() ?><input type="hidden" name="action" value="income.update"><input type="hidden" name="month" value="<?= h($month) ?>"><input type="hidden" name="id" value="<?= (int) $income['id'] ?>">
<div class="dialog-heading"><div><p class="eyebrow">Maintain income</p><h2>Edit <?= h($income['label']) ?></h2></div><button class="icon-button" type="button" data-dialog-close><?= icon('close') ?></button></div>
<?php $editingIncome = $income; require APP_ROOT . '/views/partials/income-fields.php'; unset($editingIncome); ?>
<div class="dialog-actions">
<button class="text-link danger-link push-left" type="submit" name="action" value="income.delete" formnovalidate data-confirm-button="Delete this income entry and reverse its automatic withholding?"><?= icon('trash') ?> Delete</button>
<button class="button" type="button" data-dialog-close>Cancel</button>
<button class="button primary" type="submit">Save income</button>
</div>
</form>
</dialog>
<?php endforeach; ?>
+14 -9
View File
@@ -1,8 +1,9 @@
<section class="dead-hero card glow-blue"> <section class="dead-hero card">
<div> <div>
<span class="metric-icon purple"><?= icon('vault') ?></span>
<p class="eyebrow">Dormant and disregarded</p> <p class="eyebrow">Dormant and disregarded</p>
<h2>Dead account balance</h2> <h2>Your out-of-use reserve</h2>
<p>Money here is excluded from usable monthly funds until you explicitly restore it.</p> <p>Money here is subtracted from spendable funds, remains fully traceable, and can later be restored to monthly income or earmarked for a target.</p>
</div> </div>
<?php if ($hideBalance): ?> <?php if ($hideBalance): ?>
<button class="concealed-balance" type="button" data-concealed-balance data-value="<?= h(Support::money($balance, $symbol)) ?>"> <button class="concealed-balance" type="button" data-concealed-balance data-value="<?= h(Support::money($balance, $symbol)) ?>">
@@ -16,10 +17,14 @@
<section class="content-grid dead-grid"> <section class="content-grid dead-grid">
<article class="card"> <article class="card">
<div class="card-heading"><div><p class="eyebrow">Manual movement</p><h2>Deposit or restore</h2></div></div> <div class="card-heading"><div><p class="eyebrow">Manual movement</p><h2>Deposit or restore</h2></div></div>
<form method="post" class="stack-form"> <form method="post" class="stack-form" data-dead-transfer-form>
<?= csrf_field() ?><input type="hidden" name="action" value="dead.move"><input type="hidden" name="month" value="<?= h($month) ?>"> <?= csrf_field() ?><input type="hidden" name="action" value="dead.move"><input type="hidden" name="month" value="<?= h($month) ?>">
<label>Direction<select name="direction"><option value="deposit">Move into dormant account</option><option value="restore">Restore to this months usable budget</option></select></label> <label>Direction<select name="direction"><option value="deposit">Move into dormant account</option><option value="restore">Restore to this months usable budget</option></select></label>
<label>Amount<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="amount" min="0.01" step="0.01" required></div></label> <label>Amount<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="amount" min="0.01" step="0.01" required></div></label>
<div class="form-grid" data-restore-fields hidden>
<label>Restore to<select name="destination_type" data-restore-type><option value="income">Monthly income</option><option value="item">Specific budget target</option></select></label>
<label data-restore-item hidden>Budget target<select name="destination_id"><option value="">Choose a target</option><?php foreach ($items as $item): ?><option value="<?= (int) $item['id'] ?>"><?= h($item['name']) ?> · <?= h(Support::money($item['remaining_amount'], $symbol)) ?> left</option><?php endforeach; ?></select></label>
</div>
<label>Reason<textarea name="notes" rows="3" placeholder="Emergency reserve, return to groceries…"></textarea></label> <label>Reason<textarea name="notes" rows="3" placeholder="Emergency reserve, return to groceries…"></textarea></label>
<button class="button primary" type="submit">Record transfer</button> <button class="button primary" type="submit">Record transfer</button>
</form> </form>
@@ -27,9 +32,9 @@
<article class="card"> <article class="card">
<div class="card-heading"><div><p class="eyebrow">Automatic rules</p><h2>How funds arrive here</h2></div><a class="text-link" href="<?= h(url('settings')) ?>">Configure </a></div> <div class="card-heading"><div><p class="eyebrow">Automatic rules</p><h2>How funds arrive here</h2></div><a class="text-link" href="<?= h(url('settings')) ?>">Configure </a></div>
<div class="rule-list"> <div class="rule-list">
<div><span class="metric-icon blue"></span><p><strong>Income withholding</strong><small><?= h((string) $settings->get('dead.income_value', 0)) ?><?= $settings->get('dead.income_mode') === 'percent' ? '%' : ' fixed' ?> from each income source</small></p></div> <div><span class="metric-icon blue"><?= icon('wallet') ?></span><p><strong>Income withholding</strong><small><?= h((string) $settings->get('dead.income_value', 0)) ?><?= $settings->get('dead.income_mode') === 'percent' ? '%' : ' fixed' ?> from each income source</small></p></div>
<div><span class="metric-icon pink"></span><p><strong>Transaction pull</strong><small><?= h((string) $settings->get('dead.transaction_value', 0)) ?><?= $settings->get('dead.transaction_mode') === 'percent' ? '%' : ' fixed' ?> whenever an expense is recorded</small></p></div> <div><span class="metric-icon pink"><?= icon('expense') ?></span><p><strong>Transaction pull</strong><small><?= h((string) $settings->get('dead.transaction_value', 0)) ?><?= $settings->get('dead.transaction_mode') === 'percent' ? '%' : ' fixed' ?> whenever an expense is recorded</small></p></div>
<div><span class="metric-icon orange"></span><p><strong>Restorations</strong><small>Return to the selected month without changing historical expense records</small></p></div> <div><span class="metric-icon orange"><?= icon('data') ?></span><p><strong>Restorations</strong><small>Return to monthly income or identify a specific target without rewriting history</small></p></div>
</div> </div>
</article> </article>
</section> </section>
@@ -37,7 +42,7 @@
<article class="card"> <article class="card">
<div class="card-heading"> <div class="card-heading">
<div><p class="eyebrow">Permanent paper trail</p><h2>Dormant ledger</h2></div> <div><p class="eyebrow">Permanent paper trail</p><h2>Dormant ledger</h2></div>
<label class="search-box"><span></span><input type="search" placeholder="Search movements…" data-table-search="dead-table"></label> <label class="search-box"><?= icon('search') ?><input type="search" placeholder="Search movements…" data-table-search="dead-table"></label>
</div> </div>
<?php if ($entries === []): ?> <?php if ($entries === []): ?>
<div class="empty-state"><strong>The account is empty</strong><p>Automatic or manual transfers will appear here.</p></div> <div class="empty-state"><strong>The account is empty</strong><p>Automatic or manual transfers will appear here.</p></div>
@@ -51,7 +56,7 @@
<td><?= h(date('M j, Y g:i A', strtotime($entry['created_at']))) ?></td> <td><?= h(date('M j, Y g:i A', strtotime($entry['created_at']))) ?></td>
<td><a class="text-link" href="<?= h(url('dead-account', ['month' => $entry['month']])) ?>"><?= h($entry['month']) ?></a></td> <td><a class="text-link" href="<?= h(url('dead-account', ['month' => $entry['month']])) ?>"><?= h($entry['month']) ?></a></td>
<td><strong><?= h(ucwords(str_replace('_', ' ', $entry['kind']))) ?></strong><?php if ($entry['notes']): ?><small><?= h($entry['notes']) ?></small><?php endif; ?></td> <td><strong><?= h(ucwords(str_replace('_', ' ', $entry['kind']))) ?></strong><?php if ($entry['notes']): ?><small><?= h($entry['notes']) ?></small><?php endif; ?></td>
<td><?= h(ucfirst((string) ($entry['source_type'] ?: 'manual'))) ?></td> <td><?php if ($entry['source_type'] === 'restore_item'): ?>Target: <?= h($entry['destination_name'] ?: 'Archived target') ?><?php elseif ($entry['source_type'] === 'restore_income'): ?>Monthly income<?php else: ?><?= h(ucwords(str_replace('_', ' ', (string) ($entry['source_type'] ?: 'manual')))) ?><?php endif; ?></td>
<td class="<?= (float) $entry['amount'] >= 0 ? 'green' : 'orange' ?>"><strong><?= (float) $entry['amount'] >= 0 ? '+' : '' ?><?= h(Support::money($entry['amount'], $symbol)) ?></strong></td> <td class="<?= (float) $entry['amount'] >= 0 ? 'green' : 'orange' ?>"><strong><?= (float) $entry['amount'] >= 0 ? '+' : '' ?><?= h(Support::money($entry['amount'], $symbol)) ?></strong></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
+58 -30
View File
@@ -5,6 +5,14 @@ $previousMonth = date('Y-m', strtotime($currentMonth . '-01 -1 month'));
$nextMonth = date('Y-m', strtotime($currentMonth . '-01 +1 month')); $nextMonth = date('Y-m', strtotime($currentMonth . '-01 +1 month'));
$appName = (string) $settings->get('app.name', 'Neon Ledger'); $appName = (string) $settings->get('app.name', 'Neon Ledger');
$defaultTheme = (string) $settings->get('theme.default', 'dark'); $defaultTheme = (string) $settings->get('theme.default', 'dark');
$user = $auth->user();
$navItems = [
['dashboard', 'home', 'Overview', true],
['transactions', 'expense', 'Transactions', true],
['calendar', 'calendar', 'Bill calendar', $settings->module('calendar')],
['comparisons', 'chart', 'Analytics', $settings->module('comparisons')],
['dead-account', 'vault', 'Dormant account', $settings->module('dead_account')],
];
?> ?>
<!doctype html> <!doctype html>
<html lang="en" data-theme="<?= h($defaultTheme) ?>"> <html lang="en" data-theme="<?= h($defaultTheme) ?>">
@@ -12,77 +20,97 @@ $defaultTheme = (string) $settings->get('theme.default', 'dark');
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#090b12">
<title><?= h($title ?? 'Planner') ?> · <?= h($appName) ?></title> <title><?= h($title ?? 'Planner') ?> · <?= h($appName) ?></title>
<link rel="stylesheet" href="/assets/app.css"> <link rel="stylesheet" href="/assets/app.css?v=<?= (int) filemtime(APP_ROOT . '/public/assets/app.css') ?>">
<script>document.documentElement.dataset.theme=localStorage.getItem('budget-theme')||<?= json_encode($defaultTheme) ?>;</script> <script>document.documentElement.dataset.theme=localStorage.getItem('budget-theme')||<?= json_encode($defaultTheme) ?>;</script>
</head> </head>
<body> <body>
<div class="app-shell"> <div class="app-shell">
<aside class="sidebar" id="sidebar"> <aside class="sidebar" id="sidebar">
<a class="brand" href="<?= h(url('dashboard', ['month' => $currentMonth])) ?>"> <a class="brand" href="<?= h(url('dashboard', ['month' => $currentMonth])) ?>">
<span class="brand-mark">NL</span> <span class="brand-mark"><i></i><b>N</b></span>
<span><strong><?= h($appName) ?></strong><small>Financial command center</small></span> <span class="brand-copy"><strong><?= h($appName) ?></strong><small>Personal finance planner</small></span>
</a>
<div class="sidebar-section">
<p class="nav-label">Workspace</p>
<nav class="nav" aria-label="Planner">
<?php foreach ($navItems as [$navRoute, $navIcon, $navLabel, $enabled]): ?>
<?php if ($enabled): ?>
<a class="<?= $currentRoute === $navRoute ? 'active' : '' ?>" href="<?= h(url($navRoute, ['month' => $currentMonth])) ?>">
<?= icon($navIcon) ?><span><?= h($navLabel) ?></span>
</a> </a>
<nav class="nav">
<a class="<?= $currentRoute === 'dashboard' ? 'active' : '' ?>" href="<?= h(url('dashboard', ['month' => $currentMonth])) ?>"><span></span> Command center</a>
<a class="<?= $currentRoute === 'transactions' ? 'active' : '' ?>" href="<?= h(url('transactions', ['month' => $currentMonth])) ?>"><span></span> Expenses</a>
<?php if ($settings->module('calendar')): ?>
<a class="<?= $currentRoute === 'calendar' ? 'active' : '' ?>" href="<?= h(url('calendar', ['month' => $currentMonth])) ?>"><span></span> Bill calendar</a>
<?php endif; ?>
<?php if ($settings->module('comparisons')): ?>
<a class="<?= $currentRoute === 'comparisons' ? 'active' : '' ?>" href="<?= h(url('comparisons', ['month' => $currentMonth])) ?>"><span></span> Comparisons</a>
<?php endif; ?>
<?php if ($settings->module('dead_account')): ?>
<a class="<?= $currentRoute === 'dead-account' ? 'active' : '' ?>" href="<?= h(url('dead-account', ['month' => $currentMonth])) ?>"><span></span> Dormant account</a>
<?php endif; ?> <?php endif; ?>
<?php endforeach; ?>
</nav> </nav>
<div class="nav-label">Administration</div> </div>
<nav class="nav">
<a class="<?= $currentRoute === 'settings' ? 'active' : '' ?>" href="<?= h(url('settings')) ?>"><span></span> Settings</a> <div class="sidebar-section admin-section">
<p class="nav-label">Administration</p>
<nav class="nav" aria-label="Administration">
<a class="<?= $currentRoute === 'settings' ? 'active' : '' ?>" href="<?= h(url('settings')) ?>"><?= icon('settings') ?><span>Configuration</span></a>
<?php if ($settings->module('import_export')): ?> <?php if ($settings->module('import_export')): ?>
<a class="<?= $currentRoute === 'data' ? 'active' : '' ?>" href="<?= h(url('data', ['month' => $currentMonth])) ?>"><span></span> Import & export</a> <a class="<?= $currentRoute === 'data' ? 'active' : '' ?>" href="<?= h(url('data', ['month' => $currentMonth])) ?>"><?= icon('data') ?><span>Import & export</span></a>
<?php endif; ?> <?php endif; ?>
</nav> </nav>
</div>
<div class="sidebar-insight">
<span class="insight-orb"></span>
<p>Monthly planning</p>
<strong><?= h(date('F Y', strtotime($currentMonth . '-01'))) ?></strong>
<small>Progress is measured against income, targets, and money moved out of use.</small>
</div>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark mode"><span></span> <span data-theme-label>Light mode</span></button> <div class="admin-card">
<span class="avatar"><?= h(strtoupper(substr((string) ($user['email'] ?? 'A'), 0, 1))) ?></span>
<span><strong>Administrator</strong><small><?= h($user['email'] ?? '') ?></small></span>
</div>
<div class="sidebar-actions">
<button class="icon-control" type="button" data-theme-toggle aria-label="Toggle light and dark mode"><?= icon('sun') ?></button>
<form method="post"> <form method="post">
<?= csrf_field() ?> <?= csrf_field() ?><input type="hidden" name="action" value="logout">
<input type="hidden" name="action" value="logout"> <button class="icon-control" type="submit" aria-label="Sign out"><?= icon('logout') ?></button>
<button class="text-button" type="submit">Sign out</button>
</form> </form>
</div> </div>
</div>
</aside> </aside>
<button class="sidebar-scrim" type="button" data-menu-close aria-label="Close navigation"></button>
<main class="main"> <main class="main">
<header class="topbar"> <header class="topbar">
<button class="menu-button" type="button" data-menu-toggle aria-label="Open navigation"></button> <button class="menu-button" type="button" data-menu-toggle aria-label="Open navigation"><?= icon('menu') ?></button>
<div> <div class="page-title">
<p class="eyebrow"><?= h(date('l, F j')) ?></p> <p class="eyebrow"><?= h(date('l, F j, Y')) ?></p>
<h1><?= h($title ?? 'Planner') ?></h1> <h1><?= h($title ?? 'Planner') ?></h1>
</div> </div>
<?php if (!in_array($currentRoute, ['settings', 'data'], true)): ?> <?php if (!in_array($currentRoute, ['settings', 'data'], true)): ?>
<div class="month-switcher"> <div class="month-switcher">
<a aria-label="Previous month" href="<?= h(url($currentRoute, ['month' => $previousMonth])) ?>"></a> <a aria-label="Previous month" href="<?= h(url($currentRoute, ['month' => $previousMonth])) ?>"></a>
<label> <label>
<span class="sr-only">Budget month</span> <span class="sr-only">Budget month</span>
<input type="month" value="<?= h($currentMonth) ?>" data-month-picker data-route="<?= h($currentRoute) ?>"> <input type="month" value="<?= h($currentMonth) ?>" data-month-picker data-route="<?= h($currentRoute) ?>">
</label> </label>
<a aria-label="Next month" href="<?= h(url($currentRoute, ['month' => $nextMonth])) ?>"></a> <a aria-label="Next month" href="<?= h(url($currentRoute, ['month' => $nextMonth])) ?>"></a>
</div> </div>
<a class="button primary top-action" href="<?= h(url('transactions', ['month' => $currentMonth, 'open' => 'expense-dialog'])) ?>"><?= icon('plus') ?> Add expense</a>
<?php endif; ?> <?php endif; ?>
</header> </header>
<?php foreach ($flashes as $flash): ?> <?php foreach ($flashes as $flash): ?>
<div class="flash <?= h($flash['type']) ?>" role="status"> <div class="flash <?= h($flash['type']) ?>" role="status">
<span><?= $flash['type'] === 'success' ? '✓' : '!' ?></span> <span><?= $flash['type'] === 'success' ? '✓' : '!' ?></span>
<?= h($flash['message']) ?> <p><?= h($flash['message']) ?></p>
<button type="button" aria-label="Dismiss">×</button> <button type="button" aria-label="Dismiss"><?= icon('close') ?></button>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
<div class="page-content"><?= $content ?></div> <div class="page-content"><?= $content ?></div>
<footer class="app-footer"><span><?= h($appName) ?></span><span>Private by default · SQLite powered</span></footer>
</main> </main>
</div> </div>
<script src="/assets/app.js"></script> <script src="/assets/app.js?v=<?= (int) filemtime(APP_ROOT . '/public/assets/app.js') ?>"></script>
</body> </body>
</html> </html>
+7
View File
@@ -0,0 +1,7 @@
<?php $fieldIncome = $editingIncome ?? []; $defaultIncomeDate = $month === date('Y-m') ? date('Y-m-d') : $month . '-01'; ?>
<div class="form-grid">
<label class="span-2">Label<input type="text" name="label" required placeholder="Paycheck, freelance, refund…" value="<?= h($fieldIncome['label'] ?? '') ?>"></label>
<label>Amount<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="amount" min="0.01" step="0.01" required value="<?= h((string) ($fieldIncome['amount'] ?? '')) ?>"></div></label>
<label>Received<input type="date" name="received_on" value="<?= h((string) ($fieldIncome['received_on'] ?? $defaultIncomeDate)) ?>" min="<?= h($month . '-01') ?>" max="<?= h($month . '-' . date('t', strtotime($month . '-01'))) ?>" required></label>
<label class="span-2">Notes<textarea name="notes" rows="2" placeholder="Optional details"><?= h($fieldIncome['notes'] ?? '') ?></textarea></label>
</div>
+9
View File
@@ -0,0 +1,9 @@
<?php $fieldItem = $editingItem ?? []; ?>
<div class="form-grid">
<label class="span-2">Name<input type="text" name="name" required placeholder="Internet, rent, groceries…" value="<?= h($fieldItem['name'] ?? '') ?>"></label>
<label>Target amount<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="planned_amount" min="0" step="0.01" required value="<?= h((string) ($fieldItem['planned_amount'] ?? '')) ?>"></div></label>
<label>Category<select name="category_id"><option value="">Uncategorized</option><?php foreach ($categories as $category): ?><option value="<?= (int) $category['id'] ?>" <?= (int) ($fieldItem['category_id'] ?? 0) === (int) $category['id'] ? 'selected' : '' ?>><?= h($category['name']) ?></option><?php endforeach; ?></select></label>
<label>Due date<input type="date" name="due_date" value="<?= h((string) ($fieldItem['due_date'] ?? $month . '-01')) ?>"></label>
<label>Repeat<select name="recurrence"><option value="none" <?= ($fieldItem['recurrence'] ?? 'none') === 'none' ? 'selected' : '' ?>>One time</option><option value="monthly" <?= ($fieldItem['recurrence'] ?? '') === 'monthly' ? 'selected' : '' ?>>Monthly</option><option value="yearly" <?= ($fieldItem['recurrence'] ?? '') === 'yearly' ? 'selected' : '' ?>>Yearly</option></select></label>
<label class="span-2">Notes<textarea name="notes" rows="2" placeholder="Optional details"><?= h($fieldItem['notes'] ?? '') ?></textarea></label>
</div>
+9
View File
@@ -0,0 +1,9 @@
<?php $fieldTransaction = $editingTransaction ?? []; $defaultTransactionDate = $month === date('Y-m') ? date('Y-m-d') : $month . '-01'; ?>
<div class="form-grid">
<label class="span-2">Description<input type="text" name="merchant" required placeholder="Payment or merchant" value="<?= h($fieldTransaction['merchant'] ?? '') ?>"></label>
<label>Amount<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="amount" min="0.01" step="0.01" required value="<?= h((string) ($fieldTransaction['amount'] ?? '')) ?>"></div></label>
<label>Date<input type="date" name="transacted_on" value="<?= h((string) ($fieldTransaction['transacted_on'] ?? $defaultTransactionDate)) ?>" min="<?= h($month . '-01') ?>" max="<?= h($month . '-' . date('t', strtotime($month . '-01'))) ?>" required></label>
<label>Budget target<select name="budget_item_id"><option value="">General expense</option><?php foreach ($items as $item): ?><option value="<?= (int) $item['id'] ?>" <?= (int) ($fieldTransaction['budget_item_id'] ?? 0) === (int) $item['id'] ? 'selected' : '' ?>><?= h($item['name']) ?> · <?= h(Support::money($item['remaining_amount'], $symbol)) ?> left</option><?php endforeach; ?></select></label>
<label>Category<select name="category_id"><option value="">Use target / uncategorized</option><?php foreach ($categories as $category): ?><option value="<?= (int) $category['id'] ?>" <?= (int) ($fieldTransaction['category_id'] ?? 0) === (int) $category['id'] ? 'selected' : '' ?>><?= h($category['name']) ?></option><?php endforeach; ?></select></label>
<label class="span-2">Notes<textarea name="notes" rows="2" placeholder="Optional details"><?= h($fieldTransaction['notes'] ?? '') ?></textarea></label>
</div>
+17 -2
View File
@@ -96,12 +96,27 @@
</section> </section>
<article class="card"> <article class="card">
<div class="card-heading"><div><p class="eyebrow">Organize spending</p><h2>Add an expense category</h2></div></div> <div class="card-heading"><div><p class="eyebrow">Organize spending</p><h2>Expense categories</h2><p class="heading-note">Searchable records use these names, colors, and optional monthly guardrails.</p></div></div>
<form method="post" class="inline-fields"> <form method="post" class="inline-fields">
<?= csrf_field() ?><input type="hidden" name="action" value="category.add"> <?= csrf_field() ?><input type="hidden" name="action" value="category.add"><input type="hidden" name="return_route" value="settings">
<label>Name<input type="text" name="name" required placeholder="Utilities"></label> <label>Name<input type="text" name="name" required placeholder="Utilities"></label>
<label>Color<input type="color" name="color" value="#7c5cff"></label> <label>Color<input type="color" name="color" value="#7c5cff"></label>
<label>Optional monthly limit<input type="number" name="monthly_limit" min="0" step="0.01" value="0"></label> <label>Optional monthly limit<input type="number" name="monthly_limit" min="0" step="0.01" value="0"></label>
<button class="button primary" type="submit">Add category</button> <button class="button primary" type="submit">Add category</button>
</form> </form>
<?php if ($categories !== []): ?>
<div class="category-admin-list">
<?php foreach ($categories as $category): ?>
<form method="post">
<?= csrf_field() ?><input type="hidden" name="action" value="category.update"><input type="hidden" name="id" value="<?= (int) $category['id'] ?>">
<span class="category-swatch" style="--dot:<?= h($category['color']) ?>"></span>
<input aria-label="Category name" type="text" name="name" value="<?= h($category['name']) ?>" required>
<input aria-label="Category color" type="color" name="color" value="<?= h($category['color']) ?>">
<div class="money-input"><span><?= h((string) $s['app.currency_symbol']) ?></span><input aria-label="Monthly limit" type="number" name="monthly_limit" min="0" step="0.01" value="<?= h((string) $category['monthly_limit']) ?>"></div>
<button class="icon-button" type="submit" aria-label="Save <?= h($category['name']) ?>"><?= icon('edit') ?></button>
<button class="icon-button danger" type="submit" name="action" value="category.archive" formnovalidate data-confirm-button="Archive this category? Historical records will keep their category data." aria-label="Archive <?= h($category['name']) ?>"><?= icon('trash') ?></button>
</form>
<?php endforeach; ?>
</div>
<?php endif; ?>
</article> </article>
+37 -27
View File
@@ -1,33 +1,39 @@
<section class="summary-strip"> <section class="page-intro">
<div><span>Available this month</span><strong><?= h(Support::money($summary['gross_resources'], $symbol)) ?></strong></div> <div><p class="eyebrow">Searchable expense ledger</p><h2>Every payment, one trail</h2><p>Link a payment to a target and its owing balance moves immediately.</p></div>
<div><span>Total spent</span><strong class="pink"><?= h(Support::money($summary['expenses'], $symbol)) ?></strong></div> <button class="button primary" type="button" data-dialog-open="expense-dialog"><?= icon('plus') ?> Record expense</button>
<div><span>Remaining</span><strong class="<?= $summary['remaining'] >= 0 ? 'green' : 'red' ?>"><?= h(Support::money($summary['remaining'], $symbol)) ?></strong></div>
<button class="button primary" type="button" data-dialog-open="expense-dialog">+ Record expense</button>
</section> </section>
<article class="card"> <section class="summary-grid">
<article class="summary-card card"><span class="metric-icon blue"><?= icon('wallet') ?></span><div><small>Available</small><strong><?= h(Support::money($summary['gross_resources'], $symbol)) ?></strong></div></article>
<article class="summary-card card"><span class="metric-icon pink"><?= icon('expense') ?></span><div><small>Spent</small><strong><?= h(Support::money($summary['expenses'], $symbol)) ?></strong></div></article>
<article class="summary-card card"><span class="metric-icon purple"><?= icon('vault') ?></span><div><small>Set aside</small><strong><?= h(Support::money($summary['dead_deposits'], $symbol)) ?></strong></div></article>
<article class="summary-card card"><span class="metric-icon green"><?= icon('chart') ?></span><div><small>Remaining</small><strong class="<?= $summary['remaining'] < 0 ? 'red' : '' ?>"><?= h(Support::money($summary['remaining'], $symbol)) ?></strong></div></article>
</section>
<article class="card panel">
<div class="card-heading"> <div class="card-heading">
<div><p class="eyebrow">Searchable ledger</p><h2>Expense history</h2></div> <div><p class="eyebrow"><?= h(date('F Y', strtotime($month . '-01'))) ?></p><h2>Transaction history</h2><p class="heading-note"><?= count($transactions) ?> recorded expense<?= count($transactions) === 1 ? '' : 's' ?></p></div>
<label class="search-box"><span></span><input type="search" placeholder="Search expenses…" data-table-search="expense-table"></label> <label class="search-box"><?= icon('search') ?><input type="search" placeholder="Search description, item, category…" data-table-search="expense-table"></label>
</div> </div>
<?php if ($transactions === []): ?> <?php if ($transactions === []): ?>
<div class="empty-state"><strong>No expenses recorded</strong><p>Record a payment to move a bills progress forward.</p></div> <div class="empty-state"><span class="empty-icon"><?= icon('expense') ?></span><strong>No expenses recorded</strong><p>Record a payment to start progressing this months targets.</p><button class="button small" type="button" data-dialog-open="expense-dialog">Record the first expense</button></div>
<?php else: ?> <?php else: ?>
<div class="table-wrap"> <div class="table-wrap">
<table id="expense-table"> <table id="expense-table" class="data-table">
<thead><tr><th>Date</th><th>Description</th><th>Category / item</th><th>Amount</th><th>Dormant pull</th><th></th></tr></thead> <thead><tr><th>Date</th><th>Description</th><th>Category / target</th><th>Amount</th><th>Dormant pull</th><th><span class="sr-only">Actions</span></th></tr></thead>
<tbody> <tbody>
<?php foreach ($transactions as $transaction): ?> <?php foreach ($transactions as $transaction): ?>
<tr> <tr>
<td data-label="Date"><?= h(date('M j, Y', strtotime($transaction['transacted_on']))) ?></td> <td data-label="Date"><span class="table-date"><?= h(date('M j', strtotime($transaction['transacted_on']))) ?><small><?= h(date('Y', strtotime($transaction['transacted_on']))) ?></small></span></td>
<td data-label="Description"><strong><?= h($transaction['merchant']) ?></strong><?php if ($transaction['notes']): ?><small><?= h($transaction['notes']) ?></small><?php endif; ?></td> <td data-label="Description"><strong><?= h($transaction['merchant']) ?></strong><?php if ($transaction['notes']): ?><small><?= h($transaction['notes']) ?></small><?php endif; ?></td>
<td data-label="Category"><span class="pill"><i style="--dot:<?= h($transaction['category_color'] ?: '#718096') ?>"></i><?= h($transaction['category_name'] ?: 'Uncategorized') ?></span><?php if ($transaction['item_name']): ?><small><?= h($transaction['item_name']) ?></small><?php endif; ?></td> <td data-label="Category / target"><span class="pill"><i style="--dot:<?= h($transaction['category_color'] ?: '#718096') ?>"></i><?= h($transaction['category_name'] ?: 'Uncategorized') ?></span><?php if ($transaction['item_name']): ?><small><?= h($transaction['item_name']) ?></small><?php endif; ?></td>
<td data-label="Amount"><strong><?= h(Support::money($transaction['amount'], $symbol)) ?></strong></td> <td data-label="Amount"><strong class="amount-negative">-<?= h(Support::money($transaction['amount'], $symbol)) ?></strong></td>
<td data-label="Dormant pull"><?= h(Support::money($transaction['dead_pull_amount'], $symbol)) ?></td> <td data-label="Dormant pull"><?= h(Support::money($transaction['dead_pull_amount'], $symbol)) ?></td>
<td> <td class="table-actions">
<button class="icon-button" type="button" data-dialog-open="expense-edit-<?= (int) $transaction['id'] ?>" aria-label="Edit <?= h($transaction['merchant']) ?>"><?= icon('edit') ?></button>
<form method="post" data-confirm="Delete this expense and reverse its dormant transfer?"> <form method="post" data-confirm="Delete this expense and reverse its dormant transfer?">
<?= csrf_field() ?><input type="hidden" name="action" value="transaction.delete"><input type="hidden" name="month" value="<?= h($month) ?>"><input type="hidden" name="id" value="<?= (int) $transaction['id'] ?>"> <?= csrf_field() ?><input type="hidden" name="action" value="transaction.delete"><input type="hidden" name="month" value="<?= h($month) ?>"><input type="hidden" name="id" value="<?= (int) $transaction['id'] ?>">
<button class="icon-button danger" type="submit" title="Delete">×</button> <button class="icon-button danger" type="submit" aria-label="Delete <?= h($transaction['merchant']) ?>"><?= icon('trash') ?></button>
</form> </form>
</td> </td>
</tr> </tr>
@@ -41,16 +47,20 @@
<dialog id="expense-dialog"> <dialog id="expense-dialog">
<form method="post" class="dialog-card"> <form method="post" class="dialog-card">
<?= csrf_field() ?><input type="hidden" name="action" value="transaction.add"><input type="hidden" name="month" value="<?= h($month) ?>"> <?= csrf_field() ?><input type="hidden" name="action" value="transaction.add"><input type="hidden" name="month" value="<?= h($month) ?>">
<div class="card-heading"><div><p class="eyebrow">Progress a target</p><h2>Record expense</h2></div><button class="icon-button" type="button" data-dialog-close>×</button></div> <div class="dialog-heading"><div><p class="eyebrow">Progress a target</p><h2>Record expense</h2></div><button class="icon-button" type="button" data-dialog-close><?= icon('close') ?></button></div>
<div class="form-grid"> <?php require APP_ROOT . '/views/partials/transaction-fields.php'; ?>
<label class="span-2">Description<input type="text" name="merchant" required placeholder="Payment or merchant"></label> <p class="form-note">Your configured per-transaction dormant rule is applied automatically.</p>
<label>Amount<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="amount" min="0.01" step="0.01" required></div></label> <div class="dialog-actions"><button class="button" type="button" data-dialog-close>Cancel</button><button class="button primary" type="submit">Record expense</button></div>
<label>Date<input type="date" name="transacted_on" value="<?= h(date('Y-m-d')) ?>" required></label>
<label>Budget item<select name="budget_item_id"><option value="">General expense</option><?php foreach ($items as $item): ?><option value="<?= (int) $item['id'] ?>"><?= h($item['name']) ?> (<?= h(Support::money($item['remaining_amount'], $symbol)) ?> owing)</option><?php endforeach; ?></select></label>
<label>Category<select name="category_id"><option value="">Use item / uncategorized</option><?php foreach ($categories as $category): ?><option value="<?= (int) $category['id'] ?>"><?= h($category['name']) ?></option><?php endforeach; ?></select></label>
<label class="span-2">Notes<textarea name="notes" rows="2"></textarea></label>
</div>
<p class="form-note">A configured per-transaction amount or percentage will also be moved to the dormant account.</p>
<button class="button primary full" type="submit">Record expense</button>
</form> </form>
</dialog> </dialog>
<?php foreach ($transactions as $transaction): ?>
<dialog id="expense-edit-<?= (int) $transaction['id'] ?>">
<form method="post" class="dialog-card">
<?= csrf_field() ?><input type="hidden" name="action" value="transaction.update"><input type="hidden" name="month" value="<?= h($month) ?>"><input type="hidden" name="id" value="<?= (int) $transaction['id'] ?>">
<div class="dialog-heading"><div><p class="eyebrow">Maintain transaction</p><h2>Edit <?= h($transaction['merchant']) ?></h2></div><button class="icon-button" type="button" data-dialog-close><?= icon('close') ?></button></div>
<?php $editingTransaction = $transaction; require APP_ROOT . '/views/partials/transaction-fields.php'; unset($editingTransaction); ?>
<div class="dialog-actions"><button class="button" type="button" data-dialog-close>Cancel</button><button class="button primary" type="submit">Save expense</button></div>
</form>
</dialog>
<?php endforeach; ?>